Merge branch 'main' into fix/credential-kind-public
This commit is contained in:
commit
1a96a07a8a
7 changed files with 192 additions and 21 deletions
11
.github/workflows/cd-staging.yml
vendored
11
.github/workflows/cd-staging.yml
vendored
|
|
@ -195,6 +195,13 @@ jobs:
|
||||||
# via cd-infra) are live. Idempotent.
|
# via cd-infra) are live. Idempotent.
|
||||||
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true
|
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
|
docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent ps
|
||||||
|
|
||||||
# ── PR preview deploy ────────────────────────────────────────────────
|
# ── PR preview deploy ────────────────────────────────────────────────
|
||||||
|
|
@ -356,6 +363,10 @@ jobs:
|
||||||
# Reload Caddy to pick up the per-PR snippet (writes/replaces it from the SCP step)
|
# 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
|
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
|
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
|
# Find any prior preview comment so we can update it in place rather
|
||||||
|
|
|
||||||
52
.github/workflows/disk-maintenance.yml
vendored
Normal file
52
.github/workflows/disk-maintenance.yml
vendored
Normal 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."
|
||||||
|
|
@ -4,6 +4,9 @@ interface Entry {
|
||||||
username: string;
|
username: string;
|
||||||
displayName: string | null;
|
displayName: string | null;
|
||||||
domain: string;
|
domain: string;
|
||||||
|
/** Local path (`/users/x`) or, for federated entries, the remote profile URL. */
|
||||||
|
profileUrl: string;
|
||||||
|
remote: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
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">
|
<ul className="mt-6 divide-y divide-gray-200 rounded-lg border border-gray-200 bg-white">
|
||||||
{entries.map((entry) => (
|
{entries.map((entry) => (
|
||||||
<li key={entry.username} className="px-4 py-3">
|
<li key={`${entry.username}@${entry.domain}`} className="px-4 py-3">
|
||||||
<a
|
<a
|
||||||
href={`/users/${entry.username}`}
|
href={entry.profileUrl}
|
||||||
|
{...(entry.remote ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
||||||
className="flex items-center justify-between hover:underline"
|
className="flex items-center justify-between hover:underline"
|
||||||
>
|
>
|
||||||
<span className="text-sm font-medium text-gray-900">
|
<span className="text-sm font-medium text-gray-900">
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,23 @@ describe.runIf(runIntegration)("federation inbox (integration)", () => {
|
||||||
expect(rows[0]!.acceptedAt).not.toBeNull();
|
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 () => {
|
it("inbound Follow is idempotent — replays don't double-insert", async () => {
|
||||||
const username = `fed-replay-${Date.now()}`;
|
const username = `fed-replay-${Date.now()}`;
|
||||||
const userId = await makeUser({ username });
|
const userId = await makeUser({ username });
|
||||||
|
|
|
||||||
|
|
@ -95,12 +95,18 @@ describe("actor object", () => {
|
||||||
expect(actor.preferredUsername).toBe("bruno");
|
expect(actor.preferredUsername).toBe("bruno");
|
||||||
expect(actor.name).toBe("Bruno");
|
expect(actor.name).toBe("Bruno");
|
||||||
expect(actor.summary).toBe("Riding bikes");
|
expect(actor.summary).toBe("Riding bikes");
|
||||||
// Profile-metadata field Mastodon renders ("this is a trails profile")
|
// Profile-metadata fields Mastodon renders ("this is a trails
|
||||||
const attachments = Array.isArray(actor.attachment) ? actor.attachment : [actor.attachment];
|
// profile"). MUST serialize as a JSON array — Mastodon's parser
|
||||||
const field = attachments.find((a: { type: string }) => a?.type === "PropertyValue");
|
// silently ignores a bare attachment object, which is what Fedify
|
||||||
expect(field?.name).toBe("🥾 trails.cool");
|
// compacts single-element arrays into (hence two fields).
|
||||||
expect(field?.value).toContain('href="http://localhost:3000/users/bruno"');
|
expect(Array.isArray(actor.attachment)).toBe(true);
|
||||||
expect(field?.value).toContain('rel="me"');
|
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 () => {
|
it("404s the actor for a private user", async () => {
|
||||||
|
|
|
||||||
|
|
@ -180,11 +180,19 @@ function buildFederation(): Federation<void> {
|
||||||
// metadata table — a human-visible "this is a trails profile"
|
// metadata table — a human-visible "this is a trails profile"
|
||||||
// marker. (The machine-readable marker is NodeInfo; this is
|
// marker. (The machine-readable marker is NodeInfo; this is
|
||||||
// flair.) Mastodon strips most HTML in values but keeps links.
|
// 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: [
|
attachments: [
|
||||||
new PropertyValue({
|
new PropertyValue({
|
||||||
name: "🥾 trails.cool",
|
name: "🥾 trails.cool",
|
||||||
value: `<a href="${localActorIri(identifier)}" rel="me">${getOrigin().replace(/^https?:\/\//, "")}/users/${identifier}</a>`,
|
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>`,
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { eq, and, count, desc, isNull, isNotNull } from "drizzle-orm";
|
import { eq, and, count, desc, isNull, isNotNull } from "drizzle-orm";
|
||||||
import { getDb } from "./db.ts";
|
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 { localActorIri } from "./actor-iri.ts";
|
||||||
import { createNotification } from "./notifications.server.ts";
|
import { createNotification } from "./notifications.server.ts";
|
||||||
import { logger } from "./logger.server.ts";
|
import { logger } from "./logger.server.ts";
|
||||||
|
|
@ -283,35 +283,89 @@ export interface CollectionEntry {
|
||||||
username: string;
|
username: string;
|
||||||
displayName: string | null;
|
displayName: string | null;
|
||||||
domain: string;
|
domain: string;
|
||||||
|
/** Where the entry's profile lives: local path or remote actor URL. */
|
||||||
|
profileUrl: string;
|
||||||
|
remote: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLLECTION_PAGE_SIZE = 50;
|
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.
|
* Paginated list of accepted followers of `userId`. Newest acceptance first.
|
||||||
* Pending requests are excluded — they live in the Requests tab on
|
* Includes remote (federated) followers — `follower_actor_iri` rows — so the
|
||||||
* /notifications.
|
* 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[]> {
|
export async function listFollowers(userId: string, page: number = 1): Promise<CollectionEntry[]> {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const offset = (Math.max(1, page) - 1) * COLLECTION_PAGE_SIZE;
|
const offset = (Math.max(1, page) - 1) * COLLECTION_PAGE_SIZE;
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
username: users.username,
|
localUsername: users.username,
|
||||||
displayName: users.displayName,
|
localDisplayName: users.displayName,
|
||||||
domain: users.domain,
|
localDomain: users.domain,
|
||||||
|
followerActorIri: follows.followerActorIri,
|
||||||
|
remoteUsername: remoteActors.username,
|
||||||
|
remoteDisplayName: remoteActors.displayName,
|
||||||
|
remoteDomain: remoteActors.domain,
|
||||||
})
|
})
|
||||||
.from(follows)
|
.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)))
|
.where(and(eq(follows.followedUserId, userId), isNotNull(follows.acceptedAt)))
|
||||||
.orderBy(desc(follows.acceptedAt))
|
.orderBy(desc(follows.acceptedAt))
|
||||||
.limit(COLLECTION_PAGE_SIZE)
|
.limit(COLLECTION_PAGE_SIZE)
|
||||||
.offset(offset);
|
.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.
|
* 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.
|
* Pending outgoing follows (against private/locked targets) are excluded.
|
||||||
*/
|
*/
|
||||||
export async function listFollowing(userId: string, page: number = 1): Promise<CollectionEntry[]> {
|
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 offset = (Math.max(1, page) - 1) * COLLECTION_PAGE_SIZE;
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
username: users.username,
|
localUsername: users.username,
|
||||||
displayName: users.displayName,
|
localDisplayName: users.displayName,
|
||||||
domain: users.domain,
|
localDomain: users.domain,
|
||||||
|
followedActorIri: follows.followedActorIri,
|
||||||
|
remoteUsername: remoteActors.username,
|
||||||
|
remoteDisplayName: remoteActors.displayName,
|
||||||
|
remoteDomain: remoteActors.domain,
|
||||||
})
|
})
|
||||||
.from(follows)
|
.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)))
|
.where(and(eq(follows.followerId, userId), isNotNull(follows.acceptedAt)))
|
||||||
.orderBy(desc(follows.acceptedAt))
|
.orderBy(desc(follows.acceptedAt))
|
||||||
.limit(COLLECTION_PAGE_SIZE)
|
.limit(COLLECTION_PAGE_SIZE)
|
||||||
.offset(offset);
|
.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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue