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:
parent
74dca52ecc
commit
5905cdadea
17 changed files with 1079 additions and 61 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
64
apps/journal/app/lib/canView.test.ts
Normal file
64
apps/journal/app/lib/canView.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(); }}>
|
||||
|
|
|
|||
|
|
@ -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/<you></code>
|
||||
and on search engines that index those pages.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
377
docs/legal-archive/privacy-2026-04-20.md
Normal file
377
docs/legal-archive/privacy-2026-04-20.md
Normal 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
179
e2e/public-content.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue