diff --git a/apps/journal/app/lib/activities.server.ts b/apps/journal/app/lib/activities.server.ts index a4ec344..f2ea007 100644 --- a/apps/journal/app/lib/activities.server.ts +++ b/apps/journal/app/lib/activities.server.ts @@ -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 { + 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 diff --git a/apps/journal/app/lib/auth.server.ts b/apps/journal/app/lib/auth.server.ts index 0aa8861..cc006bc 100644 --- a/apps/journal/app/lib/auth.server.ts +++ b/apps/journal/app/lib/auth.server.ts @@ -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 diff --git a/apps/journal/app/lib/canView.test.ts b/apps/journal/app/lib/canView.test.ts new file mode 100644 index 0000000..ec7e259 --- /dev/null +++ b/apps/journal/app/lib/canView.test.ts @@ -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); + }); + }); +}); diff --git a/apps/journal/app/lib/legal.ts b/apps/journal/app/lib/legal.ts index a312bc7..9105096 100644 --- a/apps/journal/app/lib/legal.ts +++ b/apps/journal/app/lib/legal.ts @@ -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"; diff --git a/apps/journal/app/lib/routes.server.ts b/apps/journal/app/lib/routes.server.ts index 2a77a14..1d6f13c 100644 --- a/apps/journal/app/lib/routes.server.ts +++ b/apps/journal/app/lib/routes.server.ts @@ -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 = { 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; diff --git a/apps/journal/app/routes/activities.$id.tsx b/apps/journal/app/routes/activities.$id.tsx index 1b80106..3c840ae 100644 --- a/apps/journal/app/routes/activities.$id.tsx +++ b/apps/journal/app/routes/activities.$id.tsx @@ -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(["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> = [{ 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) )} + {isOwner && ( +
+
+ +
+ + +
+ +
+
+ )} + {isOwner && (
{ if (!confirm(t("activities.deleteConfirm"))) e.preventDefault(); }}> diff --git a/apps/journal/app/routes/legal.privacy.tsx b/apps/journal/app/routes/legal.privacy.tsx index 90a601c..b653d13 100644 --- a/apps/journal/app/routes/legal.privacy.tsx +++ b/apps/journal/app/routes/legal.privacy.tsx @@ -73,7 +73,14 @@ export default function PrivacyPage() { 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. + und Anzeige innerhalb des Dienstes. Jede Route und jede + Aktivität hat eine Sichtbarkeits-Einstellung + (privat / nicht gelistet / öffentlich), + die standardmäßig auf privat gesetzt ist. Inhalte, + die Sie ausdrücklich auf öffentlich setzen, sind + weltweit abrufbar – einschließlich auf Ihrem öffentlichen + Profil unter /users/<benutzername> – und + können von Suchmaschinen indexiert werden.
  • Anmeldedaten (Journal): kurzlebige Magic-Link-Token @@ -106,9 +113,13 @@ export default function PrivacyPage() {

    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. + artefacts. Each route and activity has a visibility setting + (private / unlisted / public) + that defaults to private; content you mark{" "} + public is world-visible (on your public profile and + indexable by search engines). The Planner is anonymous and holds + only ephemeral session state. Server logs and Sentry error data + are covered separately below.

    @@ -375,6 +386,14 @@ export default function PrivacyPage() {
  • 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. +
  • diff --git a/apps/journal/app/routes/routes.$id.edit.tsx b/apps/journal/app/routes/routes.$id.edit.tsx index 9dce0ca..e1fe2e9 100644 --- a/apps/journal/app/routes/routes.$id.edit.tsx +++ b/apps/journal/app/routes/routes.$id.edit.tsx @@ -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(["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 (
    @@ -75,6 +89,27 @@ export default function EditRoutePage({ loaderData }: Route.ComponentProps) { />
    +
    + + +

    + {route.visibility === "private" && t("routes.visibility.privateHelp")} + {route.visibility === "unlisted" && t("routes.visibility.unlistedHelp")} + {route.visibility === "public" && t("routes.visibility.publicHelp")} +

    +
    +