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.
|
||||
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
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;
|
||||
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">
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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>`,
|
||||
}),
|
||||
],
|
||||
});
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue