Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,9 @@ status endpoints and obvious bots do not. Published presentations use the same
static HTML transport as pages and are classified by Marp's `data-marpit-svg`
marker. Server events contain public route paths and
bounded categorical properties only, with no visitor headers or identifiers.
`content-published` adds `owner` (`operator` for admin-issued tokens without an
account, `user` for account tokens, `anonymous`) and, for files, `format`
(`image`, `video`, `audio`, `pdf`, `text`, `archive` or `other`).
The fixed User-Agent is `Schaffa/1.0 (aggregate publication metrics)`. Upstream
Umami's `/api/send` uses `isbot` and accepts this product/version User-Agent;
the service sees the application's egress IP, never the visitor's. The transport
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
"eslint-plugin-security": "^4.0.1",
"eslint-plugin-sonarjs": "^4.2.0",
"globals": "^17.12.0",
"puppeteer-core": "^25.6.0",
"puppeteer-core": "^25.16.0",
"tsx": "^4.23.12",
"typescript": "npm:@typescript/typescript6@^6.0.2",
"typescript-eslint": "^8.70.0"
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,6 @@
"dependencies": {
"@marp-team/marp-cli": "^4.5.0",
"parse5": "^8.0.1",
"puppeteer-core": "^25.6.0"
"puppeteer-core": "^25.16.0"
}
}
97 changes: 53 additions & 44 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 10 additions & 2 deletions src/scan-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ import {
removeStoredFile,
storeUpload,
} from "./storage.js";
import { publishedHtmlKind, serverTelemetry } from "./telemetry.js";
import {
publishedFileFormat,
publishedHtmlKind,
publisherOwner,
serverTelemetry,
} from "./telemetry.js";
import { scanStoredUpload, scanUpload, synchronousScanDemandCount } from "./virus-scanner.js";

interface PendingPage extends PageVersionRow {
Expand Down Expand Up @@ -159,6 +164,7 @@ async function processPage(page: PendingPage): Promise<ScanRunResult> {
kind: publishedHtmlKind(await readStoredFile(publicPath).catch(() => Buffer.alloc(0))),
anonymous: page.created_by_token_id === anonymousActorId,
interactive: page.kind === "interactive",
owner: publisherOwner(page.created_by_token_id),
});
}
return { processed: true, status: "clean", type: "page" };
Expand Down Expand Up @@ -206,11 +212,13 @@ async function processFile(file: FileRow): Promise<ScanRunResult> {
committed = updated.changes > 0;
await removeStoredFile(file.storage_path);
if (updated.changes === 0) await removeStoredFile(publicPath);
if (updated.changes > 0)
if (updated.changes > 0 && config.telemetry.umamiUrl)
serverTelemetry.send("content-published", "/api/files", {
kind: "file",
anonymous: false,
interactive: false,
format: publishedFileFormat(file.media_type),
owner: publisherOwner(file.created_by_token_id),
});
return { processed: true, status: "clean", type: "file" };
} catch (error) {
Expand Down
28 changes: 27 additions & 1 deletion src/telemetry.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { debuglog } from "node:util";
import type { FastifyReply, FastifyRequest } from "fastify";
import { anonymousActorId } from "./auth.js";
import { config } from "./config.js";
import { db } from "./db.js";
import { Sentry } from "./instrumentation.js";
import { shouldReportServerError } from "./telemetry-scrub.js";

Expand All @@ -13,6 +15,24 @@ export function publishedHtmlKind(html: string | Buffer): "page" | "presentation
return html.includes("data-marpit-svg") ? "presentation" : "page";
}

// A bounded category, never an identifier: admin-issued tokens have no account.
export function publisherOwner(tokenId: string): "anonymous" | "operator" | "user" {
if (tokenId === anonymousActorId) return "anonymous";
const token = db().prepare("SELECT user_id FROM tokens WHERE id = ?").get(tokenId) as
| { user_id: string | null }
| undefined;
return token?.user_id ? "user" : "operator";
}

export function publishedFileFormat(mediaType: string): string {
const [type = "", subtype = ""] = mediaType.toLowerCase().replace(/;.*/, "").trim().split("/");
if (["image", "video", "audio"].includes(type)) return type;
if (subtype === "pdf") return "pdf";
if (type === "text" || subtype === "json") return "text";
if (/zip|tar|7z|bzip|xz|rar/.test(subtype)) return "archive";
return "other";
}

// Honest application attribution, with a product/version prefix accepted by isbot.
export const collectorUserAgent = "Schaffa/1.0 (aggregate publication metrics)";
export const isObviousBot = (ua = "") =>
Expand Down Expand Up @@ -111,12 +131,18 @@ function recordApiAction(request: FastifyRequest) {
(request.method === "DELETE" && /^\/api\/(pages|files|guides)\/:\w+$/.test(route)) ||
/\/(pages|files|guides)\/:\w+(?:\/versions\/:version)?\/delete$/.test(route);
if (deleted) serverTelemetry.send("content-deleted", "/api", { kind: deletedContentKind(route) });
if (route === "/api/guides/:slug/finish")
if (route === "/api/guides/:slug/finish" && config.telemetry.umamiUrl) {
const { slug } = request.params as { slug: string };
const guide = db().prepare("SELECT owner_token_id FROM guides WHERE slug = ?").get(slug) as
| { owner_token_id: string }
| undefined;
serverTelemetry.send("content-published", "/api/guides", {
kind: "guide",
anonymous: false,
interactive: false,
owner: guide ? publisherOwner(guide.owner_token_id) : "operator",
});
}
}

export function recordResponseTelemetry(
Expand Down
Loading
Loading