Merge branch 'main' into fix/credential-kind-public

This commit is contained in:
Ullrich Schäfer 2026-06-07 10:14:10 +02:00 committed by GitHub
commit 1a96a07a8a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 192 additions and 21 deletions

View file

@ -195,6 +195,13 @@ jobs:
# via cd-infra) are live. Idempotent.
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true
# Reclaim superseded image layers. cd-apps prunes after its own
# deploys, but a day of staging/preview deploys while cd-apps is
# red can fill the disk on its own (2026-06-07: 100% full,
# postgres down). The 1h filter avoids racing layers another
# in-flight deploy just pulled.
docker image prune -af --filter "until=1h" || true
docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent ps
# ── PR preview deploy ────────────────────────────────────────────────
@ -356,6 +363,10 @@ jobs:
# Reload Caddy to pick up the per-PR snippet (writes/replaces it from the SCP step)
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile
# Same disk-hygiene prune as the persistent staging deploy —
# preview pushes are the highest-volume image source.
docker image prune -af --filter "until=1h" || true
docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" ps
# Find any prior preview comment so we can update it in place rather

52
.github/workflows/disk-maintenance.yml vendored Normal file
View file

@ -0,0 +1,52 @@
name: Disk Maintenance
# Daily safety net for flagship disk usage. The deploy workflows prune
# superseded image layers after their own runs, but that protection
# disappears exactly when it's needed most: a deploy that fails early
# never reaches its prune step, while other workflows keep pulling
# fresh images (2026-06-07 incident: cd-apps red all day, ~10 staging
# deploys, disk 100% full, postgres down on a Saturday morning).
#
# Also doubles as a redundant alert channel: the run FAILS when the
# disk is still above the threshold after pruning, so a scheduled-run
# failure email lands even if the Grafana disk alert drowns in other
# noise (which is what happened during the incident).
on:
schedule:
# Daily at 04:30 UTC (offset from staging-cleanup's Monday 04:00)
- cron: "30 4 * * *"
workflow_dispatch: {}
concurrency:
group: disk-maintenance
cancel-in-progress: false
jobs:
prune-flagship:
name: Prune unused images (flagship)
runs-on: ubuntu-latest
environment: production
steps:
- name: Prune and check disk headroom
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
set -euo pipefail
echo "before: $(df -h / | tail -1)"
# 12h filter: never touch layers a same-day deploy may still
# be assembling; running containers' images are never pruned.
docker image prune -af --filter "until=12h"
echo "after: $(df -h / | tail -1)"
PCT=$(df --output=pcent / | tail -1 | tr -dc '0-9')
if [ "$PCT" -ge 85 ]; then
echo "Disk still at ${PCT}% after pruning — needs a human."
echo "Largest docker consumers:"
docker system df
exit 1
fi
echo "Disk at ${PCT}% — healthy."

View file

@ -4,6 +4,9 @@ interface Entry {
username: string;
displayName: string | null;
domain: string;
/** Local path (`/users/x`) or, for federated entries, the remote profile URL. */
profileUrl: string;
remote: boolean;
}
interface Props {
@ -42,9 +45,10 @@ export function CollectionPage({ kind, user, entries, page, total }: Props) {
) : (
<ul className="mt-6 divide-y divide-gray-200 rounded-lg border border-gray-200 bg-white">
{entries.map((entry) => (
<li key={entry.username} className="px-4 py-3">
<li key={`${entry.username}@${entry.domain}`} className="px-4 py-3">
<a
href={`/users/${entry.username}`}
href={entry.profileUrl}
{...(entry.remote ? { target: "_blank", rel: "noopener noreferrer" } : {})}
className="flex items-center justify-between hover:underline"
>
<span className="text-sm font-medium text-gray-900">

View file

@ -64,6 +64,23 @@ describe.runIf(runIntegration)("federation inbox (integration)", () => {
expect(rows[0]!.acceptedAt).not.toBeNull();
});
it("remote followers appear in the followers list (count/list consistency)", async () => {
const username = `fed-list-${Date.now()}`;
const userId = await makeUser({ username });
await recordRemoteFollow(REMOTE_ACTOR, username);
const { listFollowers, countFollowers } = await import("./follow.server.ts");
const [entries, total] = await Promise.all([listFollowers(userId), countFollowers(userId)]);
expect(total).toBe(1);
expect(entries).toHaveLength(1);
const entry = entries[0]!;
expect(entry.remote).toBe(true);
expect(entry.profileUrl).toBe(REMOTE_ACTOR);
// No remote_actors cache row in this test → identity parsed from the IRI.
expect(entry.username).toBe("alice");
expect(entry.domain).toBe("other-trails.example");
});
it("inbound Follow is idempotent — replays don't double-insert", async () => {
const username = `fed-replay-${Date.now()}`;
const userId = await makeUser({ username });

View file

@ -95,12 +95,18 @@ describe("actor object", () => {
expect(actor.preferredUsername).toBe("bruno");
expect(actor.name).toBe("Bruno");
expect(actor.summary).toBe("Riding bikes");
// Profile-metadata field Mastodon renders ("this is a trails profile")
const attachments = Array.isArray(actor.attachment) ? actor.attachment : [actor.attachment];
const field = attachments.find((a: { type: string }) => a?.type === "PropertyValue");
expect(field?.name).toBe("🥾 trails.cool");
expect(field?.value).toContain('href="http://localhost:3000/users/bruno"');
expect(field?.value).toContain('rel="me"');
// Profile-metadata fields Mastodon renders ("this is a trails
// profile"). MUST serialize as a JSON array — Mastodon's parser
// silently ignores a bare attachment object, which is what Fedify
// compacts single-element arrays into (hence two fields).
expect(Array.isArray(actor.attachment)).toBe(true);
const fields = actor.attachment.filter((a: { type: string }) => a?.type === "PropertyValue");
expect(fields.length).toBeGreaterThanOrEqual(2);
const trails = fields.find((f: { name: string }) => f.name === "🥾 trails.cool");
expect(trails?.value).toContain('href="http://localhost:3000/users/bruno"');
expect(trails?.value).toContain('rel="me"');
const instance = fields.find((f: { name: string }) => f.name === "Instance");
expect(instance?.value).toContain("localhost:3000");
});
it("404s the actor for a private user", async () => {

View file

@ -180,11 +180,19 @@ function buildFederation(): Federation<void> {
// metadata table — a human-visible "this is a trails profile"
// marker. (The machine-readable marker is NodeInfo; this is
// flair.) Mastodon strips most HTML in values but keeps links.
// NOTE: Mastodon's parser requires `attachment` to be a JSON
// *array* and silently ignores a bare object — and Fedify
// compacts single-element arrays to bare objects. Keep at least
// two fields here so the array survives serialization.
attachments: [
new PropertyValue({
name: "🥾 trails.cool",
value: `<a href="${localActorIri(identifier)}" rel="me">${getOrigin().replace(/^https?:\/\//, "")}/users/${identifier}</a>`,
}),
new PropertyValue({
name: "Instance",
value: `<a href="${getOrigin()}">${getOrigin().replace(/^https?:\/\//, "")}</a>`,
}),
],
});
})

View file

@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import { eq, and, count, desc, isNull, isNotNull } from "drizzle-orm";
import { getDb } from "./db.ts";
import { users, follows } from "@trails-cool/db/schema/journal";
import { users, follows, remoteActors } from "@trails-cool/db/schema/journal";
import { localActorIri } from "./actor-iri.ts";
import { createNotification } from "./notifications.server.ts";
import { logger } from "./logger.server.ts";
@ -283,35 +283,89 @@ export interface CollectionEntry {
username: string;
displayName: string | null;
domain: string;
/** Where the entry's profile lives: local path or remote actor URL. */
profileUrl: string;
remote: boolean;
}
const COLLECTION_PAGE_SIZE = 50;
/**
* Best-effort identity for a remote actor we may or may not have
* cached: prefer the remote_actors row, fall back to parsing the IRI
* (canonical fediverse shape `https://host/users/name`).
*/
function remoteEntry(
actorIri: string,
cached: { username: string | null; displayName: string | null; domain: string | null },
): CollectionEntry {
let host = "";
let lastSegment: string;
try {
const url = new URL(actorIri);
host = url.host;
lastSegment = url.pathname.split("/").filter(Boolean).pop() ?? "";
} catch {
// Malformed IRI in the DB — display it raw rather than hide the row.
lastSegment = actorIri;
}
return {
username: cached.username ?? lastSegment,
displayName: cached.displayName,
domain: cached.domain ?? host,
profileUrl: actorIri,
remote: true,
};
}
/**
* Paginated list of accepted followers of `userId`. Newest acceptance first.
* Pending requests are excluded they live in the Requests tab on
* /notifications.
* Includes remote (federated) followers `follower_actor_iri` rows so the
* list matches countFollowers; display data comes from the remote_actors
* cache when present, IRI parsing otherwise. Pending requests are excluded
* they live in the Requests tab on /notifications.
*/
export async function listFollowers(userId: string, page: number = 1): Promise<CollectionEntry[]> {
const db = getDb();
const offset = (Math.max(1, page) - 1) * COLLECTION_PAGE_SIZE;
const rows = await db
.select({
username: users.username,
displayName: users.displayName,
domain: users.domain,
localUsername: users.username,
localDisplayName: users.displayName,
localDomain: users.domain,
followerActorIri: follows.followerActorIri,
remoteUsername: remoteActors.username,
remoteDisplayName: remoteActors.displayName,
remoteDomain: remoteActors.domain,
})
.from(follows)
.innerJoin(users, eq(follows.followerId, users.id))
.leftJoin(users, eq(follows.followerId, users.id))
.leftJoin(remoteActors, eq(follows.followerActorIri, remoteActors.actorIri))
.where(and(eq(follows.followedUserId, userId), isNotNull(follows.acceptedAt)))
.orderBy(desc(follows.acceptedAt))
.limit(COLLECTION_PAGE_SIZE)
.offset(offset);
return rows;
return rows.map((r) =>
r.localUsername !== null
? {
username: r.localUsername,
displayName: r.localDisplayName,
domain: r.localDomain ?? "",
profileUrl: `/users/${r.localUsername}`,
remote: false,
}
: remoteEntry(r.followerActorIri ?? "", {
username: r.remoteUsername,
displayName: r.remoteDisplayName,
domain: r.remoteDomain,
}),
);
}
/**
* Paginated list of accepted follows from `userId`. Newest acceptance first.
* Includes remote (trails-to-trails) targets rows whose followed side is
* only an actor IRI for the same count/list consistency as listFollowers.
* Pending outgoing follows (against private/locked targets) are excluded.
*/
export async function listFollowing(userId: string, page: number = 1): Promise<CollectionEntry[]> {
@ -319,15 +373,34 @@ export async function listFollowing(userId: string, page: number = 1): Promise<C
const offset = (Math.max(1, page) - 1) * COLLECTION_PAGE_SIZE;
const rows = await db
.select({
username: users.username,
displayName: users.displayName,
domain: users.domain,
localUsername: users.username,
localDisplayName: users.displayName,
localDomain: users.domain,
followedActorIri: follows.followedActorIri,
remoteUsername: remoteActors.username,
remoteDisplayName: remoteActors.displayName,
remoteDomain: remoteActors.domain,
})
.from(follows)
.innerJoin(users, eq(follows.followedUserId, users.id))
.leftJoin(users, eq(follows.followedUserId, users.id))
.leftJoin(remoteActors, eq(follows.followedActorIri, remoteActors.actorIri))
.where(and(eq(follows.followerId, userId), isNotNull(follows.acceptedAt)))
.orderBy(desc(follows.acceptedAt))
.limit(COLLECTION_PAGE_SIZE)
.offset(offset);
return rows;
return rows.map((r) =>
r.localUsername !== null
? {
username: r.localUsername,
displayName: r.localDisplayName,
domain: r.localDomain ?? "",
profileUrl: `/users/${r.localUsername}`,
remote: false,
}
: remoteEntry(r.followedActorIri, {
username: r.remoteUsername,
displayName: r.remoteDisplayName,
domain: r.remoteDomain,
}),
);
}