Merge branch 'main' into dependabot/npm_and_yarn/production-6dcdd079e8

This commit is contained in:
Ullrich Schäfer 2026-04-26 11:57:54 +02:00 committed by GitHub
commit fdb2baada0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 76 additions and 30 deletions

View file

@ -115,8 +115,16 @@ jobs:
docker image prune -af docker image prune -af
docker compose ps docker compose ps
# Annotate deploy in Grafana # Annotate deploy in Grafana. GRAFANA_SERVICE_TOKEN lives
GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN .env | cut -d= -f2- 2>/dev/null) # in secrets.infra.env (decrypted by cd-infra.yml into the
# merged /opt/trails-cool/.env on the server). cd-apps's
# own app.env intentionally does NOT carry it — apps don't
# need it at runtime. So we read from the merged `.env`
# that cd-infra populated. If cd-infra has never run on
# this host, .env may not exist; the `2>/dev/null` and
# the `if -n` guard make the annotation a silent no-op
# rather than a deploy failure in that case.
GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN .env 2>/dev/null | cut -d= -f2-)
if [ -n "$GRAFANA_TOKEN" ]; then if [ -n "$GRAFANA_TOKEN" ]; then
docker compose exec -T grafana curl -sf -X POST \ docker compose exec -T grafana curl -sf -X POST \
-H "Authorization: Bearer $GRAFANA_TOKEN" \ -H "Authorization: Bearer $GRAFANA_TOKEN" \

View file

@ -80,12 +80,14 @@ describe.skipIf(!runIntegration)("explore.server integration", () => {
expect(rows.find((r) => r.id === id)).toBeUndefined(); expect(rows.find((r) => r.id === id)).toBeUndefined();
}); });
it("demo persona is excluded from the directory", async () => { it("demo persona is INCLUDED in the directory", async () => {
const persona = loadPersona(); const persona = loadPersona();
// Insert a user with the persona's username — should still be filtered out. // Insert a user with the persona's username — should appear like any
// other public user. The /explore loader is responsible for the
// demo-badge tagging at render time, not the directory query.
const id = await makeUser({ username: persona.username }); const id = await makeUser({ username: persona.username });
const { rows } = await listDirectory({ page: 1, perPage: 50 }); const { rows } = await listDirectory({ page: 1, perPage: 50 });
expect(rows.find((r) => r.id === id)).toBeUndefined(); expect(rows.find((r) => r.id === id)).toBeDefined();
}); });
it("orders by most-recent public activity, NULLS LAST", async () => { it("orders by most-recent public activity, NULLS LAST", async () => {

View file

@ -1,7 +1,6 @@
import { and, count, desc, eq, gte, inArray, isNotNull, ne, sql } from "drizzle-orm"; import { and, count, desc, eq, gte, inArray, isNotNull, sql } from "drizzle-orm";
import { getDb } from "./db.ts"; import { getDb } from "./db.ts";
import { activities, follows, users } from "@trails-cool/db/schema/journal"; import { activities, follows, users } from "@trails-cool/db/schema/journal";
import { loadPersona } from "./demo-bot.server.ts";
import { localActorIri } from "./actor-iri.ts"; import { localActorIri } from "./actor-iri.ts";
const DEFAULT_PAGE_SIZE = 20; const DEFAULT_PAGE_SIZE = 20;
@ -42,13 +41,11 @@ function clampPage(raw: number | undefined): number {
} }
function exclusionFilters() { function exclusionFilters() {
// Public-only and not the demo persona. Banned/suspended users would // Public-only. The demo persona IS included on /explore — its whole
// purpose is to give new users a follow target, and the per-row demo
// badge in the UI signals what it is. Banned/suspended users would
// be filtered here too once such a status column exists — see design.md. // be filtered here too once such a status column exists — see design.md.
const persona = loadPersona(); return eq(users.profileVisibility, "public");
return and(
eq(users.profileVisibility, "public"),
ne(users.username, persona.username),
);
} }
/** /**

View file

@ -10,6 +10,7 @@ import {
listActiveRecently, listActiveRecently,
listDirectory, listDirectory,
} from "~/lib/explore.server"; } from "~/lib/explore.server";
import { loadPersona } from "~/lib/demo-bot.server";
import { FollowButton } from "~/components/FollowButton"; import { FollowButton } from "~/components/FollowButton";
const BIO_TRUNCATE = 120; const BIO_TRUNCATE = 120;
@ -44,6 +45,7 @@ export async function loader({ request }: Route.LoaderArgs) {
: new Map(); : new Map();
const isSelf = (rowId: string) => viewer?.id === rowId; const isSelf = (rowId: string) => viewer?.id === rowId;
const personaUsername = loadPersona().username;
const decorate = (row: typeof allRows[number]) => ({ const decorate = (row: typeof allRows[number]) => ({
id: row.id, id: row.id,
@ -53,6 +55,7 @@ export async function loader({ request }: Route.LoaderArgs) {
followerCount: followerCounts.get(row.id) ?? 0, followerCount: followerCounts.get(row.id) ?? 0,
followState: followStates.get(row.id) ?? null, followState: followStates.get(row.id) ?? null,
isSelf: isSelf(row.id), isSelf: isSelf(row.id),
isDemoUser: row.username === personaUsername,
}); });
// Resolved page size (after loader-side clamping inside listDirectory) // Resolved page size (after loader-side clamping inside listDirectory)
@ -85,6 +88,7 @@ interface DecoratedRow {
followerCount: number; followerCount: number;
followState: { following: boolean; pending: boolean } | null; followState: { following: boolean; pending: boolean } | null;
isSelf: boolean; isSelf: boolean;
isDemoUser: boolean;
} }
function DirectoryRow({ row, isSignedIn }: { row: DecoratedRow; isSignedIn: boolean }) { function DirectoryRow({ row, isSignedIn }: { row: DecoratedRow; isSignedIn: boolean }) {
@ -92,12 +96,22 @@ function DirectoryRow({ row, isSignedIn }: { row: DecoratedRow; isSignedIn: bool
return ( return (
<li className="flex items-start justify-between gap-4 border-b border-gray-100 px-4 py-4 last:border-b-0"> <li className="flex items-start justify-between gap-4 border-b border-gray-100 px-4 py-4 last:border-b-0">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<Link <div className="flex flex-wrap items-center gap-2">
to={`/users/${row.username}`} <Link
className="text-sm font-medium text-gray-900 hover:underline" to={`/users/${row.username}`}
> className="text-sm font-medium text-gray-900 hover:underline"
{row.displayName ?? row.username} >
</Link> {row.displayName ?? row.username}
</Link>
{row.isDemoUser && (
<span
className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-800"
title={t("demo.badge")}
>
{t("demo.badge")}
</span>
)}
</div>
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
@{row.username} · {t("social.followers.count", { count: row.followerCount })} @{row.username} · {t("social.followers.count", { count: row.followerCount })}
</p> </p>

View file

@ -28,7 +28,17 @@
output stdout output stdout
format json format json
} }
reverse_proxy journal:3000 reverse_proxy journal:3000 {
# During an `apps` deploy the journal container is briefly down
# (~1030s) while compose swaps containers. Without these,
# Caddy returns 502 immediately and the `caddy-502-rate` alert
# trips on every deploy. With them, Caddy holds and retries
# against the upstream for up to 30s — restart becomes
# invisible to clients. A real outage longer than 30s still
# 502s and correctly trips the alert.
lb_try_duration 30s
lb_try_interval 250ms
}
} }
www.{$DOMAIN:trails.cool} { www.{$DOMAIN:trails.cool} {
@ -53,5 +63,9 @@ planner.{$DOMAIN:trails.cool} {
output stdout output stdout
format json format json
} }
reverse_proxy planner:3001 reverse_proxy planner:3001 {
# Same rationale as the journal block — see the comment there.
lb_try_duration 30s
lb_try_interval 250ms
}
} }

View file

@ -206,6 +206,12 @@ groups:
annotations: annotations:
summary: "BRouter host metrics scrape has been failing for 2+ minutes — the dedicated host, vSwitch, or cAdvisor may be down" summary: "BRouter host metrics scrape has been failing for 2+ minutes — the dedicated host, vSwitch, or cAdvisor may be down"
# The threshold here is intentionally `> 0` for 2m — *any*
# sustained 502 stream is real. Deploy-time restarts no longer
# produce 502s thanks to `lb_try_duration` on Caddy's reverse
# proxy (see `infrastructure/Caddyfile`); if 502s appear here
# it means the upstream has been unreachable for longer than
# Caddy's retry window, which is a genuine outage.
- uid: caddy-502-rate - uid: caddy-502-rate
title: Caddy 502 errors detected title: Caddy 502 errors detected
condition: B condition: B

View file

@ -43,25 +43,26 @@ The directory SHALL be ordered by `MAX(activities.created_at) DESC` per user, co
The directory SHALL exclude: The directory SHALL exclude:
1. Users with `profile_visibility = 'private'` — they have explicitly opted out of public discovery (Mastodon-style locked accounts). 1. Users with `profile_visibility = 'private'` — they have explicitly opted out of public discovery (Mastodon-style locked accounts).
2. The instance's demo persona, identified by the username returned by `loadPersona()` — the demo bot is not a real user and should not appear in real discovery. 2. (Forward-compat) Users in any future banned/suspended state — when such a status column exists, it SHALL be added to the exclusion filter.
3. (Forward-compat) Users in any future banned/suspended state — when such a status column exists, it SHALL be added to the exclusion filter.
Excluded users SHALL NOT appear on `/explore` even if they have public activities and would otherwise sort to the top of the directory. Excluded users SHALL NOT appear on `/explore` even if they have public activities and would otherwise sort to the top of the directory.
The demo persona (identified by `loadPersona().username`) is **not** excluded — its purpose is to give new users a follow target, so it appears in the directory like any other public user. The directory row SHALL render a "demo account" badge next to the display name so viewers know what they're following.
#### Scenario: Private profile is excluded from the directory #### Scenario: Private profile is excluded from the directory
- **WHEN** user A has `profile_visibility = 'private'` and any activity history - **WHEN** user A has `profile_visibility = 'private'` and any activity history
- **THEN** A does not appear in the `/explore` directory regardless of which page is requested - **THEN** A does not appear in the `/explore` directory regardless of which page is requested
#### Scenario: Demo persona is excluded #### Scenario: Demo persona appears with a demo badge
- **WHEN** the demo persona username (per `loadPersona()`) matches a row that would otherwise appear - **WHEN** the demo persona username (per `loadPersona()`) matches a row in the directory
- **THEN** that row is filtered out of the directory - **THEN** the row is rendered like any other public user, with an additional small "demo account" badge next to the display name
#### Scenario: Public user with no activities is included #### Scenario: Public user with no activities is included
- **WHEN** user A has `profile_visibility = 'public'` but has never created an activity - **WHEN** user A has `profile_visibility = 'public'` but has never created an activity
- **THEN** A still appears in the directory (sorted toward the end by the recency rule) - **THEN** A still appears in the directory (sorted toward the end by the recency rule)
### Requirement: "Active recently" sub-section ### Requirement: "Active recently" sub-section
The `/explore` page SHALL render an "Active recently" sub-section at the top of the directory, listing up to N (default 5) public users who have created at least one public activity in the last 30 days, ordered by `MAX(activities.created_at) DESC`. The sub-section SHALL apply the same exclusion rules as the main directory (private profiles, demo persona, future banned/suspended users). When fewer than 1 user qualifies, the sub-section SHALL be omitted entirely (no empty header). The `/explore` page SHALL render an "Active recently" sub-section at the top of the directory, listing up to N (default 5) public users who have created at least one public activity in the last 30 days, ordered by `MAX(activities.created_at) DESC`. The sub-section SHALL apply the same exclusion rules as the main directory (private profiles, future banned/suspended users — the demo persona is included like any other public user, with the same demo-badge treatment). When fewer than 1 user qualifies, the sub-section SHALL be omitted entirely (no empty header).
#### Scenario: Active-recently strip rendered with qualifying users #### Scenario: Active-recently strip rendered with qualifying users
- **WHEN** at least one public local user (excluding private/demo) has a public activity within the last 30 days - **WHEN** at least one public local user (excluding private/demo) has a public activity within the last 30 days
@ -71,9 +72,13 @@ The `/explore` page SHALL render an "Active recently" sub-section at the top of
- **WHEN** no public local user (excluding private/demo) has any public activity within the last 30 days - **WHEN** no public local user (excluding private/demo) has any public activity within the last 30 days
- **THEN** the "Active recently" sub-section is not rendered at all; the main directory is the only listing on the page - **THEN** the "Active recently" sub-section is not rendered at all; the main directory is the only listing on the page
#### Scenario: Strip respects exclusion rules #### Scenario: Strip excludes private profiles
- **WHEN** a private profile or the demo persona has a recent public activity - **WHEN** a private profile has a recent public activity
- **THEN** they are not included in the "Active recently" strip — the same exclusion rules apply as for the main directory - **THEN** they are not included in the "Active recently" strip — the same private-profile exclusion as the main directory
#### Scenario: Strip includes the demo persona
- **WHEN** the demo persona has a recent public activity
- **THEN** it appears in the "Active recently" strip like any other public user, carrying the same demo-account badge as in the main directory
### Requirement: Pagination ### Requirement: Pagination
The directory SHALL paginate via `?page=N` (1-indexed) and `?perPage=K` query parameters. Page size SHALL default to 20 per page and SHALL be capped at 100; `perPage` values outside `[1, 100]` SHALL be clamped to that range without raising an error. The response SHALL surface a "Next page" link when more rows exist past the current page, and a "Previous page" link when `page > 1`. The directory SHALL paginate via `?page=N` (1-indexed) and `?perPage=K` query parameters. Page size SHALL default to 20 per page and SHALL be capped at 100; `perPage` values outside `[1, 100]` SHALL be clamped to that range without raising an error. The response SHALL surface a "Next page" link when more rows exist past the current page, and a "Previous page" link when `page > 1`.