Apply public-content-visibility: visibility flag + public profile

Implements the public-content-visibility OpenSpec change. Adds the
smallest social surface that lets us demo the product to logged-out
visitors without user signup.

Schema:
- `visibility text NOT NULL DEFAULT 'private'` on routes + activities.
- Shared Visibility type exported from the schema module.

Access:
- New canView(content, viewer, { asDirectLink }) helper in auth.server.ts
  centralises the rule: public → anyone; unlisted → anyone on direct
  link; private → owner only.
- routes.$id and activities.$id loaders return 404 (not 403) when
  canView rejects, so existence of private content isn't leaked.
- Detail pages emit Open Graph + Twitter Card meta on public/unlisted
  content only.

Editing:
- Visibility <select> on routes/:id/edit with owner-only access.
- Activity detail page gets a small visibility form + set-visibility
  action intent (no separate activity-edit page needed).
- EN + DE i18n under routes.visibility.* and activities.visibility.*.

Listings:
- Listing helpers listPublicRoutesForOwner / listPublicActivitiesForOwner
  for cross-user queries. Existing owner-scoped listRoutes/listActivities
  stay — owners see their own content regardless of visibility.

Public profile:
- /users/:username is now truly public. Renders the user's public
  routes + activities, 404s when no public content exists AND viewer
  isn't the owner (prevents account enumeration).
- Owner sees a short "this is your profile" note linking to settings.
- Open Graph meta (og:type=profile) for shareable preview.

Privacy manifest:
- Added a bullet noting public content is world-visible on profile
  and indexable by search engines.
- Bumped PRIVACY_LAST_UPDATED to 2026-04-20 + rendered legal-archive
  snapshot.

Tests:
- 13 unit tests for canView covering the full matrix.
- 6 E2E tests in e2e/public-content.test.ts covering:
  - Private route → 404 for logged-out visitor
  - Public route → reachable + OG tags present (og:title, og:type,
    og:site_name)
  - Owner still sees own private content
  - Profile 404 when no public content
  - Profile renders when at least one public route exists
  - Unlisted route reachable via direct URL but hidden from profile
- Test file runs serially (describe.configure mode=serial) to avoid
  WebAuthn virtual-authenticator races under Playwright's default
  parallel workers.
- New public-content Playwright project added to config.

Rollout safety: every existing row in prod keeps visibility='private'
by default — nothing becomes visible to outsiders until an owner
explicitly opts in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-19 09:11:39 +02:00
parent 74dca52ecc
commit 5905cdadea
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
17 changed files with 1079 additions and 61 deletions

View file

@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
import { eq, desc, and, sql } from "drizzle-orm";
import { getDb } from "./db.ts";
import { activities, routes, syncImports } from "@trails-cool/db/schema/journal";
import type { Visibility } from "@trails-cool/db/schema/journal";
import { parseGpxAsync } from "@trails-cool/gpx";
import { setGeomFromGpx } from "./routes.server.ts";
@ -13,6 +14,21 @@ export interface ActivityInput {
distance?: number | null;
duration?: number | null;
startedAt?: Date | null;
visibility?: Visibility;
}
export async function updateActivityVisibility(
id: string,
ownerId: string,
visibility: Visibility,
): Promise<boolean> {
const db = getDb();
const result = await db
.update(activities)
.set({ visibility })
.where(and(eq(activities.id, id), eq(activities.ownerId, ownerId)))
.returning({ id: activities.id });
return result.length > 0;
}
export async function createActivity(ownerId: string, input: ActivityInput) {
@ -100,6 +116,24 @@ export async function listActivities(ownerId: string) {
return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null }));
}
/**
* List the *public* activities of a given owner. Used for cross-user
* listings (the public profile page); never includes `unlisted` or
* `private` content.
*/
export async function listPublicActivitiesForOwner(ownerId: string) {
const db = getDb();
const rows = await db
.select()
.from(activities)
.where(and(eq(activities.ownerId, ownerId), eq(activities.visibility, "public")))
.orderBy(desc(activities.createdAt));
const ids = rows.map((r) => r.id);
const geojsonMap = ids.length > 0 ? await getSimplifiedActivityGeojsonBatch(ids) : new Map();
return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null }));
}
export async function linkActivityToRoute(activityId: string, routeId: string, _ownerId: string) {
const db = getDb();
await db

View file

@ -13,6 +13,7 @@ import type {
} from "@simplewebauthn/types";
import { getDb } from "./db.ts";
import { users, credentials, magicTokens } from "@trails-cool/db/schema/journal";
import type { Visibility } from "@trails-cool/db/schema/journal";
const RP_NAME = "trails.cool";
const RP_ID = process.env.DOMAIN ?? "localhost";
@ -410,6 +411,45 @@ export const sessionStorage = createCookieSessionStorage({
},
});
/**
* A row that carries the minimum a visibility check needs.
*/
export interface Viewable {
ownerId: string;
visibility: Visibility;
}
/**
* The caller's identity. `null` represents a logged-out visitor.
*/
export interface Viewer {
id: string;
}
/**
* Decide whether a viewer may see a piece of content.
*
* - `public` content is viewable by anyone.
* - `unlisted` content is viewable only on direct-link access listings
* should omit it. Callers rendering a detail page pass `asDirectLink:
* true`; listings default to `false`.
* - `private` content is viewable only by the owner.
*
* Centralised here so detail loaders and listing queries use the same
* rule. Intentionally does not throw; callers handle the `false` case
* (usually by returning HTTP 404 rather than 403 to avoid leaking
* existence).
*/
export function canView(
content: Viewable,
viewer: Viewer | null,
{ asDirectLink = false }: { asDirectLink?: boolean } = {},
): boolean {
if (content.visibility === "public") return true;
if (content.visibility === "unlisted" && asDirectLink) return true;
return viewer?.id === content.ownerId;
}
/**
* Record the user's acceptance of the current Terms version. Updates both
* `terms_accepted_at` (NOW) and `terms_version`. Used when an existing user

View file

@ -0,0 +1,64 @@
import { describe, it, expect } from "vitest";
import { canView, type Viewable, type Viewer } from "./auth.server.ts";
const owner: Viewer = { id: "owner-id" };
const other: Viewer = { id: "other-id" };
function row(visibility: "private" | "unlisted" | "public"): Viewable {
return { ownerId: owner.id, visibility };
}
describe("canView", () => {
describe("public content", () => {
it("is viewable by the owner", () => {
expect(canView(row("public"), owner)).toBe(true);
});
it("is viewable by another logged-in user", () => {
expect(canView(row("public"), other)).toBe(true);
});
it("is viewable by a logged-out visitor", () => {
expect(canView(row("public"), null)).toBe(true);
});
it("ignores asDirectLink (always viewable)", () => {
expect(canView(row("public"), null, { asDirectLink: false })).toBe(true);
expect(canView(row("public"), null, { asDirectLink: true })).toBe(true);
});
});
describe("unlisted content", () => {
it("is viewable by the owner", () => {
expect(canView(row("unlisted"), owner)).toBe(true);
});
it("is viewable by another user on a direct link", () => {
expect(canView(row("unlisted"), other, { asDirectLink: true })).toBe(true);
});
it("is viewable by a logged-out visitor on a direct link", () => {
expect(canView(row("unlisted"), null, { asDirectLink: true })).toBe(true);
});
it("is NOT visible in a listing to another user", () => {
expect(canView(row("unlisted"), other, { asDirectLink: false })).toBe(false);
});
it("is NOT visible in a listing to a logged-out visitor", () => {
expect(canView(row("unlisted"), null, { asDirectLink: false })).toBe(false);
});
it("defaults to the listing rule when asDirectLink is omitted", () => {
expect(canView(row("unlisted"), other)).toBe(false);
expect(canView(row("unlisted"), null)).toBe(false);
});
});
describe("private content", () => {
it("is viewable by the owner", () => {
expect(canView(row("private"), owner)).toBe(true);
expect(canView(row("private"), owner, { asDirectLink: true })).toBe(true);
});
it("is NOT viewable by another user", () => {
expect(canView(row("private"), other)).toBe(false);
expect(canView(row("private"), other, { asDirectLink: true })).toBe(false);
});
it("is NOT viewable by a logged-out visitor", () => {
expect(canView(row("private"), null)).toBe(false);
expect(canView(row("private"), null, { asDirectLink: true })).toBe(false);
});
});
});

View file

@ -16,4 +16,4 @@ export const TERMS_VERSION = "2026-04-19";
* require re-acceptance (the policy is informational, not contract), so this
* is display-only not persisted.
*/
export const PRIVACY_LAST_UPDATED = "2026-04-19";
export const PRIVACY_LAST_UPDATED = "2026-04-20";

View file

@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
import { eq, desc, and } from "drizzle-orm";
import { getDb } from "./db.ts";
import { routes, routeVersions } from "@trails-cool/db/schema/journal";
import type { Visibility } from "@trails-cool/db/schema/journal";
import { parseGpxAsync } from "@trails-cool/gpx";
import { sql } from "drizzle-orm";
@ -10,6 +11,7 @@ export interface RouteInput {
description?: string;
gpx?: string;
routingProfile?: string;
visibility?: Visibility;
}
export async function createRoute(ownerId: string, input: RouteInput) {
@ -96,6 +98,23 @@ export async function listRoutes(ownerId: string) {
return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null }));
}
/**
* List the *public* routes of a given owner. Used for cross-user listings
* (the public profile page); never includes `unlisted` or `private` content.
*/
export async function listPublicRoutesForOwner(ownerId: string) {
const db = getDb();
const rows = await db
.select()
.from(routes)
.where(and(eq(routes.ownerId, ownerId), eq(routes.visibility, "public")))
.orderBy(desc(routes.updatedAt));
const ids = rows.map((r) => r.id);
const geojsonMap = ids.length > 0 ? await getSimplifiedGeojsonBatch(ids) : new Map();
return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null }));
}
export async function updateRoute(
id: string,
ownerId: string,
@ -106,6 +125,7 @@ export async function updateRoute(
const updateData: Record<string, unknown> = { updatedAt: new Date() };
if (input.name !== undefined) updateData.name = input.name;
if (input.description !== undefined) updateData.description = input.description;
if (input.visibility !== undefined) updateData.visibility = input.visibility;
if (input.gpx) {
updateData.gpx = input.gpx;

View file

@ -1,12 +1,15 @@
import { data, redirect } from "react-router";
import { useTranslation } from "react-i18next";
import type { Route } from "./+types/activities.$id";
import { getSessionUser } from "~/lib/auth.server";
import { getActivity, deleteActivity, linkActivityToRoute, createRouteFromActivity } from "~/lib/activities.server";
import { canView, getSessionUser } from "~/lib/auth.server";
import { getActivity, deleteActivity, linkActivityToRoute, createRouteFromActivity, updateActivityVisibility } from "~/lib/activities.server";
import { deleteImportByActivity } from "~/lib/sync/imports.server";
import { listRoutes } from "~/lib/routes.server";
import { ClientDate } from "~/components/ClientDate";
import { ClientMap } from "~/components/ClientMap";
import type { Visibility } from "@trails-cool/db/schema/journal";
const VISIBILITY_VALUES = new Set<Visibility>(["private", "unlisted", "public"]);
export async function loader({ params, request }: Route.LoaderArgs) {
const activity = await getActivity(params.id);
@ -15,6 +18,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
const user = await getSessionUser(request);
const isOwner = user?.id === activity.ownerId;
// Visibility gate — public always, unlisted on direct link, private owner-only.
// 404 (not 403) to avoid leaking existence.
if (!canView(activity, user, { asDirectLink: true })) {
throw data({ error: "Activity not found" }, { status: 404 });
}
const userRoutes = isOwner && user ? await listRoutes(user.id) : [];
return data({
@ -30,6 +39,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
hasGpx: !!activity.gpx,
geojson: activity.geojson ?? null,
startedAt: activity.startedAt?.toISOString() ?? null,
visibility: activity.visibility,
createdAt: activity.createdAt.toISOString(),
importSource: activity.importSource,
},
@ -66,12 +76,41 @@ export async function action({ params, request }: Route.ActionArgs) {
return data({ error: "Activity not found" }, { status: 404 });
}
if (intent === "set-visibility") {
const raw = formData.get("visibility") as string | null;
if (!raw || !VISIBILITY_VALUES.has(raw as Visibility)) {
return data({ error: "Invalid visibility" }, { status: 400 });
}
const ok = await updateActivityVisibility(params.id, user.id, raw as Visibility);
if (!ok) return data({ error: "Activity not found" }, { status: 404 });
return redirect(`/activities/${params.id}`);
}
return data({ error: "Unknown action" }, { status: 400 });
}
export function meta({ data: loaderData }: Route.MetaArgs) {
const name = (loaderData as { activity: { name: string } })?.activity?.name ?? "Activity";
return [{ title: `${name} — trails.cool` }];
const activity = (loaderData as { activity?: { name: string; description: string | null; visibility: string } })?.activity;
const name = activity?.name ?? "Activity";
const title = `${name} — trails.cool`;
const tags: Array<Record<string, string>> = [{ title }];
if (activity && (activity.visibility === "public" || activity.visibility === "unlisted")) {
const description = (activity.description && activity.description.length > 0)
? activity.description.slice(0, 280)
: `An activity on trails.cool`;
tags.push(
{ property: "og:title", content: title },
{ property: "og:description", content: description },
{ property: "og:type", content: "article" },
{ property: "og:site_name", content: "trails.cool" },
{ name: "twitter:card", content: "summary" },
{ name: "twitter:title", content: title },
{ name: "twitter:description", content: description },
);
}
return tags;
}
export default function ActivityDetailPage({ loaderData }: Route.ComponentProps) {
@ -177,6 +216,35 @@ export default function ActivityDetailPage({ loaderData }: Route.ComponentProps)
</div>
)}
{isOwner && (
<div className="mt-8 border-t border-gray-200 pt-6">
<form method="post" className="flex items-end gap-3">
<input type="hidden" name="intent" value="set-visibility" />
<div>
<label htmlFor="visibility" className="block text-sm font-medium text-gray-700">
{t("activities.visibility.label")}
</label>
<select
id="visibility"
name="visibility"
defaultValue={activity.visibility}
className="mt-1 block rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="private">{t("activities.visibility.private")}</option>
<option value="unlisted">{t("activities.visibility.unlisted")}</option>
<option value="public">{t("activities.visibility.public")}</option>
</select>
</div>
<button
type="submit"
className="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
{t("routes.saveChanges")}
</button>
</form>
</div>
)}
{isOwner && (
<div className="mt-8 border-t border-gray-200 pt-6">
<form method="post" onSubmit={(e) => { if (!confirm(t("activities.deleteConfirm"))) e.preventDefault(); }}>

View file

@ -375,6 +375,14 @@ export default function PrivacyPage() {
<li>Routes: GPX, geometry, title, description</li>
<li>Activities: title, description, date, linked route</li>
<li>GPX / JSON export available per object and overall</li>
<li>
Routes and activities each carry a visibility setting
(<code>private</code> / <code>unlisted</code> / <code>public</code>)
that defaults to <code>private</code>. Content you explicitly mark
<code> public</code> is visible to anyone on the internet,
including on your public profile at <code>/users/&lt;you&gt;</code>
and on search engines that index those pages.
</li>
</ul>
</section>

View file

@ -1,7 +1,11 @@
import { data, redirect } from "react-router";
import { useTranslation } from "react-i18next";
import type { Route } from "./+types/routes.$id.edit";
import { getSessionUser } from "~/lib/auth.server";
import { getRoute, updateRoute } from "~/lib/routes.server";
import type { Visibility } from "@trails-cool/db/schema/journal";
const VISIBILITY_VALUES = new Set<Visibility>(["private", "unlisted", "public"]);
export async function loader({ params, request }: Route.LoaderArgs) {
const user = await getSessionUser(request);
@ -12,7 +16,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
if (route.ownerId !== user.id) throw data({ error: "Not authorized" }, { status: 403 });
return data({
route: { id: route.id, name: route.name, description: route.description },
route: {
id: route.id,
name: route.name,
description: route.description,
visibility: route.visibility,
},
});
}
@ -24,13 +33,17 @@ export async function action({ params, request }: Route.ActionArgs) {
const name = formData.get("name") as string;
const description = formData.get("description") as string;
const gpxFile = formData.get("gpx") as File | null;
const visibilityRaw = formData.get("visibility") as string | null;
const input: { name?: string; description?: string; gpx?: string } = {};
const input: { name?: string; description?: string; gpx?: string; visibility?: Visibility } = {};
if (name) input.name = name;
if (description !== null) input.description = description;
if (gpxFile && gpxFile.size > 0) {
input.gpx = await gpxFile.text();
}
if (visibilityRaw && VISIBILITY_VALUES.has(visibilityRaw as Visibility)) {
input.visibility = visibilityRaw as Visibility;
}
await updateRoute(params.id, user.id, input);
return redirect(`/routes/${params.id}`);
@ -42,6 +55,7 @@ export function meta(_args: Route.MetaArgs) {
export default function EditRoutePage({ loaderData }: Route.ComponentProps) {
const { route } = loaderData;
const { t } = useTranslation("journal");
return (
<div className="mx-auto max-w-2xl px-4 py-8">
@ -75,6 +89,27 @@ export default function EditRoutePage({ loaderData }: Route.ComponentProps) {
/>
</div>
<div>
<label htmlFor="visibility" className="block text-sm font-medium text-gray-700">
{t("routes.visibility.label")}
</label>
<select
id="visibility"
name="visibility"
defaultValue={route.visibility}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="private">{t("routes.visibility.private")}</option>
<option value="unlisted">{t("routes.visibility.unlisted")}</option>
<option value="public">{t("routes.visibility.public")}</option>
</select>
<p className="mt-1 text-xs text-gray-500">
{route.visibility === "private" && t("routes.visibility.privateHelp")}
{route.visibility === "unlisted" && t("routes.visibility.unlistedHelp")}
{route.visibility === "public" && t("routes.visibility.publicHelp")}
</p>
</div>
<div>
<label htmlFor="gpx" className="block text-sm font-medium text-gray-700">
Update GPX (optional creates new version)

View file

@ -2,7 +2,7 @@ import { useState, useCallback } from "react";
import { data, redirect } from "react-router";
import { useTranslation } from "react-i18next";
import type { Route } from "./+types/routes.$id";
import { getSessionUser } from "~/lib/auth.server";
import { canView, getSessionUser } from "~/lib/auth.server";
import { getRoute, getRouteWithVersions, deleteRoute, updateRoute } from "~/lib/routes.server";
import { ClientDate } from "~/components/ClientDate";
import { ClientMap } from "~/components/ClientMap";
@ -19,6 +19,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
const user = await getSessionUser(request);
const isOwner = user?.id === route.ownerId;
// Visibility gate: public always renders, unlisted renders on direct link,
// private requires ownership. Return 404 (not 403) to avoid leaking existence.
if (!canView(route, user, { asDirectLink: true })) {
throw data({ error: "Route not found" }, { status: 404 });
}
// Compute per-day stats if route has day breaks and GPX
let dayStats: Array<{ dayNumber: number; startName?: string; endName?: string; distance: number; ascent: number; descent: number }> = [];
if (route.dayBreaks && route.dayBreaks.length > 0 && route.gpx) {
@ -44,6 +50,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
hasGpx: !!route.gpx,
dayBreaks: route.dayBreaks ?? [],
geojson: routeWithGeojson?.geojson ?? null,
visibility: route.visibility,
createdAt: route.createdAt.toISOString(),
updatedAt: route.updatedAt.toISOString(),
},
@ -89,8 +96,29 @@ export async function action({ params, request }: Route.ActionArgs) {
}
export function meta({ data: loaderData }: Route.MetaArgs) {
const name = (loaderData as { route: { name: string } })?.route?.name ?? "Route";
return [{ title: `${name} — trails.cool` }];
const route = (loaderData as { route?: { name: string; description: string | null; visibility: string } })?.route;
const name = route?.name ?? "Route";
const title = `${name} — trails.cool`;
const tags: Array<Record<string, string>> = [{ title }];
// Only emit Open Graph / Twitter Card tags for publicly-reachable content.
// Private routes are gated before meta is even called, but belt-and-braces.
if (route && (route.visibility === "public" || route.visibility === "unlisted")) {
const description = (route.description && route.description.length > 0)
? route.description.slice(0, 280)
: `A route on trails.cool`;
tags.push(
{ property: "og:title", content: title },
{ property: "og:description", content: description },
{ property: "og:type", content: "article" },
{ property: "og:site_name", content: "trails.cool" },
{ name: "twitter:card", content: "summary" },
{ name: "twitter:title", content: title },
{ name: "twitter:description", content: description },
);
}
return tags;
}
export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {

View file

@ -1,9 +1,13 @@
import { data } from "react-router";
import type { Route } from "./+types/users.$username";
import { useTranslation } from "react-i18next";
import { getDb } from "~/lib/db";
import { users } from "@trails-cool/db/schema/journal";
import { eq } from "drizzle-orm";
import { getSessionUser } from "~/lib/auth.server";
import { listPublicRoutesForOwner } from "~/lib/routes.server";
import { listPublicActivitiesForOwner } from "~/lib/activities.server";
import { ClientDate } from "~/components/ClientDate";
export async function loader({ params, request }: Route.LoaderArgs) {
const db = getDb();
@ -13,9 +17,20 @@ export async function loader({ params, request }: Route.LoaderArgs) {
throw data({ error: "User not found" }, { status: 404 });
}
const currentUser = await getSessionUser(request);
const [publicRoutes, publicActivities, currentUser] = await Promise.all([
listPublicRoutesForOwner(user.id),
listPublicActivitiesForOwner(user.id),
getSessionUser(request),
]);
const isOwn = currentUser?.id === user.id;
// 404 for users with no public content at all, to prevent account
// enumeration. Owners still see their own profile even when empty.
if (!isOwn && publicRoutes.length === 0 && publicActivities.length === 0) {
throw data({ error: "User not found" }, { status: 404 });
}
return data({
user: {
username: user.username,
@ -24,20 +39,54 @@ export async function loader({ params, request }: Route.LoaderArgs) {
domain: user.domain,
createdAt: user.createdAt.toISOString(),
},
routes: publicRoutes.map((r) => ({
id: r.id,
name: r.name,
description: r.description,
distance: r.distance,
elevationGain: r.elevationGain,
updatedAt: r.updatedAt.toISOString(),
})),
activities: publicActivities.map((a) => ({
id: a.id,
name: a.name,
description: a.description,
distance: a.distance,
duration: a.duration,
startedAt: a.startedAt?.toISOString() ?? null,
createdAt: a.createdAt.toISOString(),
})),
isOwn,
});
}
export function meta({ data: loaderData }: Route.MetaArgs) {
const username = (loaderData as { user: { username: string } })?.user?.username ?? "User";
return [{ title: `@${username} — trails.cool` }];
const user = (loaderData as { user?: { username: string; displayName: string | null; domain: string; bio: string | null } })?.user;
const displayName = user?.displayName ?? user?.username ?? "Profile";
const title = `${displayName} (@${user?.username}) — trails.cool`;
const description = user?.bio && user.bio.length > 0
? user.bio.slice(0, 280)
: `${displayName} on trails.cool`;
if (!user) return [{ title }];
return [
{ title },
{ property: "og:title", content: title },
{ property: "og:description", content: description },
{ property: "og:type", content: "profile" },
{ property: "og:site_name", content: "trails.cool" },
{ name: "twitter:card", content: "summary" },
{ name: "twitter:title", content: title },
{ name: "twitter:description", content: description },
];
}
export default function UserProfilePage({ loaderData }: Route.ComponentProps) {
const { user, isOwn } = loaderData;
const { user, routes, activities, isOwn } = loaderData;
const { t } = useTranslation("journal");
return (
<div className="mx-auto max-w-2xl px-4 py-8">
<div className="mx-auto max-w-3xl px-4 py-8">
<div className="flex items-start gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-blue-100 text-2xl font-bold text-blue-600">
{user.username[0]?.toUpperCase()}
@ -54,22 +103,77 @@ export default function UserProfilePage({ loaderData }: Route.ComponentProps) {
</div>
{isOwn && (
<div className="mt-6 flex gap-2">
<form action="/auth/logout" method="post">
<button
type="submit"
className="rounded bg-gray-100 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-200"
>
Log out
</button>
</form>
<div className="mt-6 rounded-md border border-blue-100 bg-blue-50 p-3 text-sm text-blue-800">
{t("profile.ownNote")}{" "}
<a href="/settings" className="underline hover:text-blue-900">
{t("profile.goToSettings")}
</a>
</div>
)}
<div className="mt-8">
<h2 className="text-lg font-semibold text-gray-900">Routes</h2>
<p className="mt-2 text-sm text-gray-500">No routes yet.</p>
<section className="mt-8">
<h2 className="text-lg font-semibold text-gray-900">
{t("routes.title")} ({routes.length})
</h2>
{routes.length === 0 ? (
<p className="mt-2 text-sm text-gray-500">{t("profile.noPublicRoutes")}</p>
) : (
<ul className="mt-3 divide-y divide-gray-200 rounded-md border border-gray-200">
{routes.map((r) => (
<li key={r.id} className="px-4 py-3">
<a href={`/routes/${r.id}`} className="block hover:bg-gray-50">
<div className="flex items-baseline justify-between gap-4">
<span className="font-medium text-gray-900">{r.name}</span>
{r.distance != null && (
<span className="shrink-0 text-sm tabular-nums text-gray-600">
{(r.distance / 1000).toFixed(1)} km
</span>
)}
</div>
{r.description && (
<p className="mt-1 line-clamp-1 text-sm text-gray-500">{r.description}</p>
)}
<p className="mt-1 text-xs text-gray-400">
<ClientDate iso={r.updatedAt} />
</p>
</a>
</li>
))}
</ul>
)}
</section>
<section className="mt-8">
<h2 className="text-lg font-semibold text-gray-900">
{t("activities.title")} ({activities.length})
</h2>
{activities.length === 0 ? (
<p className="mt-2 text-sm text-gray-500">{t("profile.noPublicActivities")}</p>
) : (
<ul className="mt-3 divide-y divide-gray-200 rounded-md border border-gray-200">
{activities.map((a) => (
<li key={a.id} className="px-4 py-3">
<a href={`/activities/${a.id}`} className="block hover:bg-gray-50">
<div className="flex items-baseline justify-between gap-4">
<span className="font-medium text-gray-900">{a.name}</span>
{a.distance != null && (
<span className="shrink-0 text-sm tabular-nums text-gray-600">
{(a.distance / 1000).toFixed(1)} km
</span>
)}
</div>
{a.description && (
<p className="mt-1 line-clamp-1 text-sm text-gray-500">{a.description}</p>
)}
<p className="mt-1 text-xs text-gray-400">
<ClientDate iso={a.startedAt ?? a.createdAt} />
</p>
</a>
</li>
))}
</ul>
)}
</section>
</div>
);
}

View file

@ -0,0 +1,377 @@
# Privacy Policy — trails.cool
Datenschutzerklärung / Privacy Policy
Stand / Last updated: 2026-04-20. Die deutsche Fassung ist maßgeblich.
The German version is authoritative; English summaries follow each
section.
1. Verantwortlicher
Verantwortlich für die Datenverarbeitung im Sinne der DSGVO ist:
Ullrich Schäfer
Mehringdamm 87
10965 Berlin
Germany
E-Mail:
legal@trails.cool
English.
Data controller under GDPR is the party named
above. Contact for any data-protection matter: legal@trails.cool.
2. Erhobene Daten und Zwecke
Wir verarbeiten nur die Daten, die für den Betrieb der jeweiligen
Funktion erforderlich sind. trails.cool besteht aus zwei Teilen:
dem
Journal
(trails.cool, mit Konto) und dem
Planner
(planner.trails.cool, anonym).
Kontodaten (Journal):
E-Mail-Adresse,
Benutzername, Anzeigename, Passkey-Public-Key. Zweck:
Kontoverwaltung und Anmeldung.
Nutzerinhalte (Journal):
Routen (GPX-Daten,
Geometrie, Titel, Beschreibung) und Aktivitäten (Titel,
Beschreibung, Datum, Verknüpfung zu Routen). Zweck: Speicherung
und Anzeige innerhalb des Dienstes.
Anmeldedaten (Journal):
kurzlebige Magic-Link-Token
(zur E-Mail-basierten Anmeldung). Zweck: Authentifizierung.
Sitzungscookie (Journal):
eine zufällige
Sitzungs-ID nach dem Einloggen. Zweck: Authentifizierung während
der Sitzung.
Planner-Sitzungsdaten:
anonyme Sitzungs-ID,
kollaborativer Zustand (Wegpunkte, Notizen). Keine Zuordnung zu
einer Person. Zweck: gemeinsames Planen von Routen.
Server-Logfiles:
IP-Adresse, Zeitstempel,
HTTP-Methode, Pfad, Statuscode, User-Agent. Zweck: Sicherheit,
Betrieb, Fehlersuche. Details siehe Abschnitt 4.
Fehlerdaten (Sentry):
Stacktraces,
Fehlermeldungen, Browser-/OS-Information aus dem User-Agent,
Performance-Metriken. Bei eingeloggten Journal-Nutzer:innen
zusätzlich die Nutzer-ID (keine E-Mail, kein Benutzername).
IP-Adressen werden nicht aktiv gespeichert, ebenso keine Cookies
und keine Formulareingaben.
English.
The Journal stores only what you provide (account
details and your own routes/activities) plus short-lived auth
artefacts. The Planner is anonymous and holds only ephemeral
session state. Server logs and Sentry error data are covered
separately below.
3. Rechtsgrundlagen
Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung):
Kontoführung, Anmeldung per Passkey oder Magic Link, Speicherung
und Anzeige der von Ihnen erstellten Routen und Aktivitäten,
Versand notwendiger Transaktions-E-Mails, Bereitstellung anonymer
Planner-Sitzungen. Ein Konto sowie die Bestätigung der
Nutzungsbedingungen sind Bestandteil dieses Nutzungsvertrags
sie sind
keine
Einwilligung im Sinne von Art. 6 Abs. 1
lit. a DSGVO.
Art. 6 Abs. 1 lit. f DSGVO (berechtigte Interessen):
kurzzeitige Server-Logfiles zur Sicherung des Betriebs und zur
Missbrauchsabwehr, Rate-Limiting, Fehlermonitoring über Sentry
zur Sicherstellung der Funktionsfähigkeit des Dienstes.
English.
Contract (Art. 6(1)(b)) covers everything
account- and content-related; legitimate interests (Art. 6(1)(f))
cover short-lived server logs, rate-limiting, and error monitoring.
We do
not
rely on consent for any of this.
4. Server-Logfiles
Beim Aufruf der Dienste werden automatisch technische Informationen
protokolliert:
IP-Adresse
Zeitstempel
HTTP-Methode, Pfad, Statuscode
User-Agent (Browser, Betriebssystem)
Zweck:
Sicherheit, Betrieb und Fehlersuche.
Rechtsgrundlage:
Art. 6 Abs. 1 lit. f DSGVO.
Speicherdauer:
maximal 14 Tage, danach automatische
Löschung. Eine Zusammenführung dieser Daten mit anderen Datenquellen
findet nicht statt.
English.
HTTP requests to our servers are logged (IP,
timestamp, method, path, status, user-agent) for up to 14 days for
operational and security purposes under Art. 6(1)(f), then deleted.
5. Speicherdauer
Konto und zugehörige Inhalte: bis zur Löschung durch Sie
Planner-Sitzungen: automatische Löschung nach 7 Tagen Inaktivität
Magic-Link-Token: 15 Minuten
Server-Logfiles: maximal 14 Tage
Fehlerdaten (Sentry): 90 Tage
Hinweis Alpha: Während der Alpha-Phase behält sich der Betreiber
ausdrücklich vor, die Datenbank zurückzusetzen oder einzelne
Datensätze zu löschen. Dies kann zu Datenverlust führen, bevor Sie
eine Löschung veranlassen. Details dazu in den Nutzungsbedingungen.
English.
Account and content kept until you delete them.
Ephemeral data (sessions, magic-link tokens, logs, Sentry events)
deleted automatically on the schedules above. Alpha caveat: the
operator may reset the database or delete individual records
during alpha, which can cause data loss before you request
deletion. See the Terms of Service.
6. Empfänger und Drittanbieter
Wir geben personenbezogene Daten nur an die unten genannten
Auftragsverarbeiter und Dritten weiter, und auch dort nur im
jeweils notwendigen Umfang.
Sentry
(Functional Software Inc.) Fehler- und
Performance-Monitoring. Was übermittelt wird: Stacktraces,
Fehlertext, Browser-/OS-Informationen aus dem User-Agent,
Performance-Daten; bei eingeloggten Journal-Nutzer:innen
zusätzlich die Nutzer-ID. IP-Adressen werden nicht aktiv
gespeichert (
sendDefaultPii
ist deaktiviert), ebenso
keine Cookies und keine vollständigen HTTP-Header. Keine
Session-Replays. Sentry agiert als externer Dienstleister
(Auftragsverarbeiter) für die Fehlerbehandlung. Da Sentry ein
Anbieter mit Sitz in den USA ist, kann im Einzelfall eine
Übermittlung personenbezogener Daten in ein Drittland im Sinne
der Art. 44 ff. DSGVO stattfinden; wir stützen uns hierbei auf
die von Sentry bereitgestellten
Standardvertragsklauseln.
OpenStreetMap
Kartenkacheln werden beim Anzeigen
der Karten direkt vom Browser von OSM-Tile-Servern geladen. Dabei
werden
IP-Adresse und User-Agent
an OSM übertragen.
Dies ist notwendig, um überhaupt eine Karte darzustellen; es gilt
die
OSM Foundation Privacy Policy
.
Overpass API
(POI-Daten) POI-Abfragen laufen
serverseitig über unsere eigene Route
/api/overpass
.
Der Upstream-Dienst sieht nur unsere Server-IP, nicht die Ihrer
Nutzer:innen. Aktueller Upstream:
overpass.private.coffee
, der ohne Query-Logs arbeitet. Eine selbst gehostete Instanz ist
geplant.
BRouter
Routenberechnung läuft auf einer von uns
selbst gehosteten Instanz. Keine Weitergabe an Dritte.
E-Mail-Versand
Transaktions-E-Mails (Magic
Link, Willkommensnachricht) werden über einen konfigurierten
externen SMTP-Dienst versendet. Dabei wird die E-Mail-Adresse
der Empfänger:in an diesen Dienst übergeben. Selbst-betriebene
Instanzen konfigurieren ihren eigenen Mailserver.
Hosting
Die Dienste werden in Rechenzentren
innerhalb der EU betrieben. Ein Auftragsverarbeitungsvertrag mit
dem Hoster besteht.
English.
Third parties and what they receive: Sentry (error
details, no IPs/cookies); OpenStreetMap tile servers
(your IP and user-agent, directly from your browser, to load map
tiles); Overpass (via our server-side proxy, so upstream only sees
our server); BRouter (self-hosted, no third party involved); SMTP
provider (your email address for magic link / welcome mail);
hosting provider in the EU under a DPA.
7. Ihre Rechte
Als betroffene Person stehen Ihnen die folgenden Rechte zu:
Auskunft (Art. 15 DSGVO)
Berichtigung (Art. 16 DSGVO)
Löschung (Art. 17 DSGVO)
Einschränkung der Verarbeitung (Art. 18 DSGVO)
Datenübertragbarkeit (Art. 20 DSGVO)
Widerspruch gegen Verarbeitung auf Basis berechtigter Interessen (Art. 21 DSGVO)
Zur Ausübung dieser Rechte genügt eine formlose E-Mail an
legal@trails.cool
. Einen vollständigen Export Ihrer Routen und Aktivitäten können Sie
jederzeit direkt in den Kontoeinstellungen herunterladen (GPX bzw.
JSON). Ihr Konto samt Inhalten können Sie dort ebenfalls selbst
löschen.
English.
You have the standard GDPR rights (access,
rectification, erasure, restriction, portability, objection). Email
us to exercise them. Data exports and account deletion are also
available directly in your account settings.
8. Beschwerderecht
Sie haben das Recht, sich bei einer Datenschutzaufsichtsbehörde zu
beschweren. Für uns zuständig ist:
Berliner Beauftragte für Datenschutz und Informationsfreiheit
Friedrichstr. 219
10969 Berlin
www.datenschutz-berlin.de
English.
You have the right to lodge a complaint with a
supervisory authority; ours is the Berlin data-protection
commissioner (address above).
Privacy Manifest
A plain-language summary of what we do, for readers who prefer
behaviour over paragraphs. Binding text is above.
Planner (planner.trails.cool)
Anonymous by design. No account, no identifier, nothing persisted
past the session.
No cookies, no localStorage, no sessionStorage
No browser-side error tracking (Sentry is not loaded)
Session data is deleted automatically after 7 days of inactivity
Journal (trails.cool)
Holds only what you put in. Exportable any time, deletable any time.
Account: email, username, display name, passkey public key
Routes: GPX, geometry, title, description
Activities: title, description, date, linked route
GPX / JSON export available per object and overall
Routes and activities each carry a visibility setting
(
private
/
unlisted
/
public
)
that defaults to
private
. Content you explicitly mark
public
is visible to anyone on the internet,
including on your public profile at
/users/<you>
and on search engines that index those pages.
Sentry
Journal server: always on
Journal browser: only after login, torn down on logout
Planner: server only; the browser never loads Sentry
IPs not actively stored, no cookies, no full headers (
sendDefaultPii: false
)
No replays (replay integration not installed, sample rates 0)
User ID only (no email/username) on Journal-logged-in events
What we don't do
Sell data
Show ads
Build user profiles
Run tracking pixels, analytics, or A/B tests
Security
Auth via passkey (WebAuthn) or magic link — no passwords stored
HTTPS + HSTS preload on all origins; session cookies httpOnly + Secure
CSP, X-Frame-Options, X-Content-Type-Options on every response
Containers run non-root; host firewall restricts to HTTP/HTTPS/SSH
Gitleaks + dependency audit on every PR
Vulnerability reports:
SECURITY.md

179
e2e/public-content.test.ts Normal file
View file

@ -0,0 +1,179 @@
import { test, expect, type CDPSession, type Page } from "@playwright/test";
// Reuses the virtual-authenticator + register helpers from auth.test.ts.
// Inlined rather than factored out to keep this file independently runnable.
async function setupVirtualAuthenticator(cdp: CDPSession) {
await cdp.send("WebAuthn.enable");
const { authenticatorId } = await cdp.send("WebAuthn.addVirtualAuthenticator", {
options: {
protocol: "ctap2",
transport: "internal",
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
},
});
return authenticatorId;
}
async function registerUser(page: Page, email: string, username: string) {
await page.goto("/auth/register");
await expect(page.getByRole("heading", { name: "Register" })).toBeVisible();
await page.getByLabel("Email").fill(email);
await page.getByLabel("Username").fill(username);
await page.getByRole("checkbox").check();
await page.getByRole("button", { name: /Register with Passkey/ }).click();
await expect(page).toHaveURL("/", { timeout: 10000 });
}
async function createRoute(page: Page, name: string): Promise<string> {
await page.goto("/routes/new");
await page.getByLabel("Name").fill(name);
await page.getByRole("button", { name: "Create Route" }).click();
await page.waitForURL(/\/routes\/[0-9a-f-]+$/, { timeout: 10000 });
const url = page.url();
const id = url.split("/").pop()!;
return id;
}
async function setRouteVisibility(
page: Page,
id: string,
visibility: "private" | "unlisted" | "public",
) {
await page.goto(`/routes/${id}/edit`);
await page.getByLabel("Visibility").selectOption(visibility);
await page.getByRole("button", { name: "Save Changes" }).click();
await page.waitForURL(new RegExp(`/routes/${id}$`), { timeout: 10000 });
}
// Registration + WebAuthn virtual authenticator can race under parallel
// Playwright workers on a shared local Postgres; run this file serially.
test.describe.configure({ mode: "serial" });
test.describe("Public content visibility", () => {
test("private route is 404 for a logged-out visitor", async ({ page, browser }) => {
const cdp = await page.context().newCDPSession(page);
await setupVirtualAuthenticator(cdp);
const email = `pcv-priv-${Date.now()}@example.com`;
const username = `pcvpriv${Date.now()}`;
await registerUser(page, email, username);
const id = await createRoute(page, "My private draft");
// Open a second browser context (no cookies) and try the same URL.
const anonCtx = await browser.newContext();
const anon = await anonCtx.newPage();
const resp = await anon.goto(`/routes/${id}`);
// Loader throws 404; the response status reflects that.
expect(resp?.status()).toBe(404);
await anonCtx.close();
});
test("public route is reachable by a logged-out visitor and emits OG tags", async ({ page, browser }) => {
const cdp = await page.context().newCDPSession(page);
await setupVirtualAuthenticator(cdp);
const email = `pcv-pub-${Date.now()}@example.com`;
const username = `pcvpub${Date.now()}`;
await registerUser(page, email, username);
const id = await createRoute(page, "Public ride");
await setRouteVisibility(page, id, "public");
const anonCtx = await browser.newContext();
const anon = await anonCtx.newPage();
const resp = await anon.goto(`/routes/${id}`);
expect(resp?.status()).toBe(200);
await expect(anon.getByRole("heading", { name: "Public ride" })).toBeVisible();
// OG tags are present in the document head on public pages.
const ogTitle = await anon.locator('meta[property="og:title"]').getAttribute("content");
expect(ogTitle).toContain("Public ride");
const ogType = await anon.locator('meta[property="og:type"]').getAttribute("content");
expect(ogType).toBe("article");
const ogSite = await anon.locator('meta[property="og:site_name"]').getAttribute("content");
expect(ogSite).toBe("trails.cool");
await anonCtx.close();
});
test("owner can still view their own private content", async ({ page }) => {
const cdp = await page.context().newCDPSession(page);
await setupVirtualAuthenticator(cdp);
const email = `pcv-owner-${Date.now()}@example.com`;
const username = `pcvowner${Date.now()}`;
await registerUser(page, email, username);
const id = await createRoute(page, "My only route");
// Same session reload: owner still sees it even though default is private.
await page.goto(`/routes/${id}`);
await expect(page.getByRole("heading", { name: "My only route" })).toBeVisible();
});
test("profile 404 when user has no public content", async ({ page, browser }) => {
const cdp = await page.context().newCDPSession(page);
await setupVirtualAuthenticator(cdp);
const email = `pcv-empty-${Date.now()}@example.com`;
const username = `pcvempty${Date.now()}`;
await registerUser(page, email, username);
await createRoute(page, "Private thoughts"); // left private
const anonCtx = await browser.newContext();
const anon = await anonCtx.newPage();
const resp = await anon.goto(`/users/${username}`);
expect(resp?.status()).toBe(404);
await anonCtx.close();
});
test("profile renders when user has at least one public route", async ({ page, browser }) => {
const cdp = await page.context().newCDPSession(page);
await setupVirtualAuthenticator(cdp);
const email = `pcv-prof-${Date.now()}@example.com`;
const username = `pcvprof${Date.now()}`;
await registerUser(page, email, username);
const id = await createRoute(page, "Bikepacking loop");
await setRouteVisibility(page, id, "public");
const anonCtx = await browser.newContext();
const anon = await anonCtx.newPage();
const resp = await anon.goto(`/users/${username}`);
expect(resp?.status()).toBe(200);
await expect(anon.getByRole("heading", { name: username })).toBeVisible();
await expect(anon.getByRole("link", { name: /Bikepacking loop/ })).toBeVisible();
await anonCtx.close();
});
test("unlisted route is reachable by direct URL but does not appear on profile", async ({ page, browser }) => {
const cdp = await page.context().newCDPSession(page);
await setupVirtualAuthenticator(cdp);
const email = `pcv-unl-${Date.now()}@example.com`;
const username = `pcvunl${Date.now()}`;
await registerUser(page, email, username);
// Two routes: one unlisted, one public — profile shows only the public one.
const publicId = await createRoute(page, "Public one");
await setRouteVisibility(page, publicId, "public");
const unlistedId = await createRoute(page, "Unlisted secret");
await setRouteVisibility(page, unlistedId, "unlisted");
const anonCtx = await browser.newContext();
const anon = await anonCtx.newPage();
// Direct URL to unlisted → 200
const direct = await anon.goto(`/routes/${unlistedId}`);
expect(direct?.status()).toBe(200);
await expect(anon.getByRole("heading", { name: "Unlisted secret" })).toBeVisible();
// Profile lists only the public route
await anon.goto(`/users/${username}`);
await expect(anon.getByRole("link", { name: /Public one/ })).toBeVisible();
await expect(anon.getByRole("link", { name: /Unlisted secret/ })).not.toBeVisible();
await anonCtx.close();
});
});

View file

@ -1,62 +1,62 @@
## 1. Schema
- [ ] 1.1 Add `visibility text NOT NULL DEFAULT 'private'` to `journal.routes` in `packages/db/src/schema/journal.ts`
- [ ] 1.2 Add the same column to `journal.activities` in the same file
- [ ] 1.3 Export a shared type `type Visibility = 'private' | 'unlisted' | 'public'` from `packages/db/src/schema/journal.ts` (or a small adjacent module) and reuse at the app layer
- [x] 1.1 Add `visibility text NOT NULL DEFAULT 'private'` to `journal.routes` in `packages/db/src/schema/journal.ts`
- [x] 1.2 Add the same column to `journal.activities` in the same file
- [x] 1.3 Export a shared type `type Visibility = 'private' | 'unlisted' | 'public'` from `packages/db/src/schema/journal.ts` (or a small adjacent module) and reuse at the app layer
## 2. Server-side access helper
- [ ] 2.1 Add `canView(content, user)` in `apps/journal/app/lib/auth.server.ts` that returns `true` for `public`, `true` for `unlisted` (the direct-URL case — callers pass `true` when routed to a detail page, `false` when generating listings), and ownership-checked otherwise
- [ ] 2.2 Unit test the three matrix cells
- [x] 2.1 Add `canView(content, user)` in `apps/journal/app/lib/auth.server.ts` that returns `true` for `public`, `true` for `unlisted` (the direct-URL case — callers pass `true` when routed to a detail page, `false` when generating listings), and ownership-checked otherwise
- [x] 2.2 Unit test the three matrix cells
## 3. Route detail access
- [ ] 3.1 Update `apps/journal/app/routes/routes.$id.tsx` loader: fetch route, then apply `canView`; return 404 when not allowed
- [ ] 3.2 Expose `visibility` on the loader's returned shape so the component can render the current-visibility badge to the owner
- [ ] 3.3 Emit Open Graph / Twitter Card `meta` for `public` and `unlisted` routes (title, description, `og:type="article"`, `og:site_name="trails.cool"`, `twitter:card="summary"`)
- [x] 3.1 Update `apps/journal/app/routes/routes.$id.tsx` loader: fetch route, then apply `canView`; return 404 when not allowed
- [x] 3.2 Expose `visibility` on the loader's returned shape so the component can render the current-visibility badge to the owner
- [x] 3.3 Emit Open Graph / Twitter Card `meta` for `public` and `unlisted` routes (title, description, `og:type="article"`, `og:site_name="trails.cool"`, `twitter:card="summary"`)
## 4. Activity detail access
- [ ] 4.1 Mirror 3.1 in `apps/journal/app/routes/activities.$id.tsx`
- [ ] 4.2 Mirror 3.3 for activities
- [x] 4.1 Mirror 3.1 in `apps/journal/app/routes/activities.$id.tsx`
- [x] 4.2 Mirror 3.3 for activities
## 5. Visibility selector in the edit flow
- [ ] 5.1 Add a visibility `<select>` to `apps/journal/app/routes/routes.$id.edit.tsx`
- [ ] 5.2 Wire the form action in `routes.$id.tsx` / the edit route to persist the new value via `updateRoute`
- [ ] 5.3 Add i18n keys (EN + DE) for `routes.visibility.{label,private,unlisted,public,privateHelp,unlistedHelp,publicHelp}`
- [ ] 5.4 Do the same for activities (no separate edit page exists yet — either add a minimal one or surface the selector on the detail page for the owner; pick during implementation)
- [x] 5.1 Add a visibility `<select>` to `apps/journal/app/routes/routes.$id.edit.tsx`
- [x] 5.2 Wire the form action in `routes.$id.tsx` / the edit route to persist the new value via `updateRoute`
- [x] 5.3 Add i18n keys (EN + DE) for `routes.visibility.{label,private,unlisted,public,privateHelp,unlistedHelp,publicHelp}`
- [x] 5.4 Surface the selector on the activity detail page for the owner (simpler than a new edit page); new `updateActivityVisibility` helper + `set-visibility` action intent
## 6. Listing filters
- [ ] 6.1 Update `listRoutes(userId)` in `apps/journal/app/lib/routes.server.ts` to accept a `viewerUserId: string | null` and filter to rows where `visibility='public'` OR `owner_id = viewerUserId`
- [ ] 6.2 Same for `listActivities` in the activities equivalent
- [ ] 6.3 Audit any other query that returns routes/activities across users and apply the same filter (grep `users.id` / `owner_id` joins)
- [x] 6.1 Added `listPublicRoutesForOwner(ownerId)` — returns only public rows for profile listings. `listRoutes(ownerId)` stays as-is (owners see their own content regardless of visibility, per spec)
- [x] 6.2 Added `listPublicActivitiesForOwner(ownerId)` with the same shape
- [x] 6.3 Audited all callsites of `listRoutes` / `listActivities` / raw selects on `from(routes|activities)` — every path already passes the session user's own id, so no cross-user leaks. Cross-user surface is new (profile page) and uses the public-only helpers.
## 7. Public profile page
- [ ] 7.1 Broaden `apps/journal/app/routes/users.$username.tsx` loader to not require auth
- [ ] 7.2 Fetch the user row by username; return 404 if no such user
- [ ] 7.3 Fetch that user's `public` routes and `public` activities via the updated listing filters
- [ ] 7.4 Return 404 if the user exists but has no public content at all (prevents account enumeration)
- [ ] 7.5 Render the profile header (display name, `@username@domain` handle), reverse-chronological lists, and an owner-only "This is your profile" control strip linking to settings
- [ ] 7.6 Emit Open Graph meta (`og:title`, `og:site_name`, `og:type="profile"`)
- [x] 7.1 The loader never required auth (it was already public-accessible); kept that way
- [x] 7.2 Fetch user row by username; 404 if missing
- [x] 7.3 Fetch public routes + public activities via the new `listPublicRoutesForOwner` / `listPublicActivitiesForOwner`
- [x] 7.4 404 when user has no public content AND the viewer isn't the owner (owners still see their own empty profile)
- [x] 7.5 Rendered profile header, reverse-chron lists, owner-only note linking to settings
- [x] 7.6 Open Graph + Twitter Card meta (`og:title`, `og:description`, `og:type="profile"`, `og:site_name`)
## 8. Copy + docs
- [ ] 8.1 Add a short sentence to the Privacy Manifest (`legal.privacy.tsx`) noting public content is world-visible and exportable the same way
- [ ] 8.2 Bump `PRIVACY_LAST_UPDATED` in `apps/journal/app/lib/legal.ts` and add a new snapshot under `docs/legal-archive/`
- [x] 8.1 Privacy Manifest: added a bullet in the Journal section explaining the visibility flag and that `public` content is world-visible
- [x] 8.2 Bumped `PRIVACY_LAST_UPDATED` to 2026-04-20 and rendered `docs/legal-archive/privacy-2026-04-20.md`
## 9. Testing
- [ ] 9.1 Unit: `canView` matrix (private/unlisted/public × owner/other/anon)
- [ ] 9.2 E2E: logged-out visitor can view a public route page; gets 404 on a private one
- [ ] 9.3 E2E: owner marks a route public, logs out, can still view it at the same URL
- [ ] 9.4 E2E: `/users/:username` 404s for a user with no public content, renders for one with at least one public item
- [ ] 9.5 E2E: OG tags are present on public detail pages (assert via `page.locator('meta[property="og:title"]')`)
- [x] 9.1 Unit: `canView` matrix (13 cases across private/unlisted/public × owner/other/anon × asDirectLink)
- [x] 9.2 E2E: private route returns 404 to logged-out visitor; public route renders
- [x] 9.3 E2E: owner can view their own private route; unlisted reachable on direct URL but omitted from profile
- [x] 9.4 E2E: `/users/:username` 404s when no public content; renders when there's at least one public route
- [x] 9.5 E2E: OG tags (`og:title`, `og:type=article`, `og:site_name`) present on public route pages
## 10. Rollout
- [ ] 10.1 Merge schema + code; `drizzle-kit push --force` adds the column with `'private'` default — no row changes
- [ ] 10.2 Confirm on prod that the three existing users' routes/activities are still auth-only (they should be, since all rows default to `private`)
- [ ] 10.3 Hand off to `demo-activity-bot` which inserts with `visibility: 'public'` directly
- [x] 10.1 Schema ships with `drizzle-kit push --force` — column default `'private'`, no row changes on prod
- [ ] 10.2 (Post-deploy check) Confirm on prod that existing users' routes/activities remain auth-only — handled at merge time, not in this PR
- [ ] 10.3 (Next change) `demo-activity-bot` will insert with `visibility='public'` directly — not in this PR

View file

@ -58,6 +58,15 @@ export const magicTokens = journalSchema.table("magic_tokens", {
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
/**
* Visibility for routes and activities. Stored as a plain text column so we
* can extend without a migration. See spec `public-content-visibility`.
* - private: only the owner can view
* - unlisted: anyone with the direct URL can view; excluded from listings
* - public: anyone can view; appears in listings and profiles
*/
export type Visibility = "private" | "unlisted" | "public";
export const routes = journalSchema.table("routes", {
id: text("id").primaryKey(),
ownerId: text("owner_id")
@ -74,6 +83,7 @@ export const routes = journalSchema.table("routes", {
dayBreaks: jsonb("day_breaks").$type<number[]>(),
tags: jsonb("tags").$type<string[]>(),
plannerState: bytea("planner_state"),
visibility: text("visibility").$type<Visibility>().notNull().default("private"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});
@ -107,6 +117,7 @@ export const activities = journalSchema.table("activities", {
elevationLoss: real("elevation_loss"),
photos: jsonb("photos").$type<string[]>(),
participants: jsonb("participants").$type<string[]>(),
visibility: text("visibility").$type<Visibility>().notNull().default("private"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});

View file

@ -186,6 +186,21 @@ export default {
openInPlanner: "Im Planer öffnen",
uploadGpx: "oder eine GPX-Datei hochladen",
},
visibility: {
label: "Sichtbarkeit",
private: "Privat",
unlisted: "Nicht gelistet (nur mit Link sichtbar)",
public: "Öffentlich (für alle sichtbar)",
privateHelp: "Nur du kannst diese Route sehen.",
unlistedHelp: "Jede:r mit der URL kann sie ansehen; sie erscheint nicht auf deinem öffentlichen Profil.",
publicHelp: "Sichtbar für alle, auch nicht eingeloggte Besucher:innen; erscheint auf deinem öffentlichen Profil.",
},
},
profile: {
ownNote: "Das ist dein Profil — Besucher:innen sehen nur Inhalte, die du als öffentlich markiert hast.",
goToSettings: "Zu den Einstellungen",
noPublicRoutes: "Noch keine öffentlichen Routen.",
noPublicActivities: "Noch keine öffentlichen Aktivitäten.",
},
activities: {
title: "Aktivitäten",
@ -196,6 +211,12 @@ export default {
delete: "Aktivität löschen",
deleteConfirm: "Möchtest du diese Aktivität wirklich löschen?",
importedFrom: "Importiert von {{provider}}",
visibility: {
label: "Sichtbarkeit",
private: "Privat",
unlisted: "Nicht gelistet (nur mit Link sichtbar)",
public: "Öffentlich (für alle sichtbar)",
},
},
settings: {
title: "Einstellungen",

View file

@ -186,6 +186,21 @@ export default {
openInPlanner: "Open in Planner",
uploadGpx: "or upload a GPX file",
},
visibility: {
label: "Visibility",
private: "Private",
unlisted: "Unlisted (only people with the link)",
public: "Public (anyone on the internet)",
privateHelp: "Only you can see this route.",
unlistedHelp: "Anyone with the URL can view it; it won't appear on your public profile.",
publicHelp: "Visible to anyone, including logged-out visitors; listed on your public profile.",
},
},
profile: {
ownNote: "This is your profile — visitors see only what you've marked public.",
goToSettings: "Go to settings",
noPublicRoutes: "No public routes yet.",
noPublicActivities: "No public activities yet.",
},
activities: {
title: "Activities",
@ -196,6 +211,12 @@ export default {
delete: "Delete Activity",
deleteConfirm: "Are you sure you want to delete this activity?",
importedFrom: "Imported from {{provider}}",
visibility: {
label: "Visibility",
private: "Private",
unlisted: "Unlisted (only people with the link)",
public: "Public (anyone on the internet)",
},
},
settings: {
title: "Settings",

View file

@ -39,6 +39,14 @@ export default defineConfig({
baseURL: "http://localhost:3000",
},
},
{
name: "public-content",
testMatch: "public-content.test.ts",
use: {
...devices["Desktop Chrome"],
baseURL: "http://localhost:3000",
},
},
{
name: "integration",
testMatch: "integration.test.ts",