diff --git a/Resources/Views/configuration.leaf b/Resources/Views/configuration.leaf index 78a7949..649d248 100644 --- a/Resources/Views/configuration.leaf +++ b/Resources/Views/configuration.leaf @@ -3,7 +3,7 @@ + source-sha256: e592ce3fe1f1b4fe -->

Configuring a relay

@@ -375,6 +375,41 @@ withholding one stops a wipe rather than causing one. When off, both routes refuse with wipe_not_enabled rather than a 404, so a client can tell a server that has this switched off from one too old to know what it is.

+

Reports about content

+

On. A person can report something to whoever runs the server, so +blocking is not the only thing they can do.

+ + + + + + + + + + + + + + + +
VariableDefaultWhat it does
FN_CONTENT_REPORTSonSet to false, 0, no or +off to stop taking reports. Anything else, including a +typo, leaves them on.
+

Off is a legitimate answer, not a broken relay. If nobody is going to +read them, collecting reports is a promise the server cannot keep, and +saying so plainly is better than a queue nobody opens.

+

When off, two things happen so that nothing pretends otherwise: +/.well-known/flamenet/relay reports +"reports": false, which lets a client leave the Report +option out of the interface entirely; and the route itself refuses with +reports_not_accepted rather than a 404, so a client can +tell "this server does not take reports" apart from "this server is too +old to know what a report is".

+

There is deliberately no route that reads reports. +They are read out of band by whoever operates the server — an endpoint +returning them would be a way to ask the relay what somebody reported, +which is a question nobody but the operator gets to ask.

Retention

The relay purges on its own timer, in-process. Nothing external is needed.

diff --git a/Sources/FlamenetRelay/Config.swift b/Sources/FlamenetRelay/Config.swift index e169a2f..dad78c4 100644 --- a/Sources/FlamenetRelay/Config.swift +++ b/Sources/FlamenetRelay/Config.swift @@ -147,6 +147,17 @@ struct Config: Sendable { /// carrying it is small. The reason to keep it off is honesty about what /// works, not fear of the route. let deviceWipeEnabled: Bool + /// Whether this relay accepts reports about content (§6.25). + /// + /// **On by default.** Without it, blocking is the only thing a person being + /// harassed can do, and that is the state this feature exists to leave. + /// + /// ⚠️ Off is a real answer, not a broken one. A self-hoster with nobody to + /// read reports should be able to say so — collecting reports that will + /// never be looked at is a promise the relay cannot keep. When off, the + /// discovery document says so, so clients can leave the affordance out + /// rather than offer a button that fails. + let contentReportsEnabled: Bool let attachmentBytesPerHour: Int /// The rolling 30-day per-account upload budget (C3). The hourly limits /// above stop bursts; this stops a steady drip from quietly owning the @@ -284,6 +295,11 @@ struct Config: Sendable { let raw = (env["FN_DEVICE_WIPE"] ?? "").lowercased() return raw == "true" || raw == "1" || raw == "yes" }(), + contentReportsEnabled: { + // On unless explicitly switched off. + let raw = (env["FN_CONTENT_REPORTS"] ?? "").lowercased() + return !(raw == "false" || raw == "0" || raw == "no" || raw == "off") + }(), attachmentBytesPerHour: int("FN_ATTACHMENT_BYTES_RATE", 200 * 1024 * 1024), attachmentBytesPerMonth: int("FN_ATTACHMENT_BYTES_PER_MONTH", 500 * 1024 * 1024), attachmentRetentionDays: int("FN_ATTACHMENT_RETENTION_DAYS", 30), diff --git a/Sources/FlamenetRelay/Entrypoint.swift b/Sources/FlamenetRelay/Entrypoint.swift index 06b7421..b571008 100644 --- a/Sources/FlamenetRelay/Entrypoint.swift +++ b/Sources/FlamenetRelay/Entrypoint.swift @@ -926,6 +926,16 @@ func register(routes r: Routes, on app: Application) throws { e2e.get("turn") { req in try await r.turnCredentials(req, now: unixNow()) } + + // §6.25 — file a report. + // + // ⚠️ There is deliberately no route that READS reports. They are read + // out of band by whoever operates the server; an endpoint returning + // them would be a way to ask the relay what somebody reported, which + // is a question nobody but the operator gets to ask. + e2e.post("moderation", "report") { req in + try await r.submitReport(req, now: unixNow()) + } } mountE2E(app.grouped("e2e")) diff --git a/Sources/FlamenetRelay/Migrations.swift b/Sources/FlamenetRelay/Migrations.swift index 528b492..c26c989 100644 --- a/Sources/FlamenetRelay/Migrations.swift +++ b/Sources/FlamenetRelay/Migrations.swift @@ -608,6 +608,10 @@ enum RelayMigrations { AddAccountIdentity(), AddLogSubject(), CreateHomeRecord(), AddEnvelopeID(), AddDeviceLastSeen(), AddVoipToken(), AcceptExistingContacts(), BackfillKeyLog(), CreateDeliveryToken(), DropGroupNames(), - SealGroupRosters(), CreateDeviceWipeOrder()] + SealGroupRosters(), CreateDeviceWipeOrder(), + // ⚠️ Appended, never inserted. Fluent runs these in registration + // order and records what it has run; putting a new one in the middle + // changes the order for a database that has already migrated. + CreateContentReport()] } } diff --git a/Sources/FlamenetRelay/RoutesDiscovery.swift b/Sources/FlamenetRelay/RoutesDiscovery.swift index bc0062a..9192380 100644 --- a/Sources/FlamenetRelay/RoutesDiscovery.swift +++ b/Sources/FlamenetRelay/RoutesDiscovery.swift @@ -70,6 +70,13 @@ extension Routes { let accounts: String /// Absolute URL of that account service, when there is one. let accountsURL: String? + /// Whether this relay takes reports about content (§6.25). + /// + /// Advertised so a client can leave the Report affordance out entirely + /// rather than offer a button that fails. An older relay omits the key, + /// and a client reading it as absent-means-yes is right: every relay + /// that predates the switch does accept reports. + let reports: Bool } /// `GET /.well-known/flamenet/relay` @@ -91,7 +98,8 @@ extension Routes { : (try? Curve25519.Signing.PrivateKey(rawRepresentation: config.federationKeyRaw ?? Data())) .map { $0.publicKey.rawRepresentation.base64EncodedString() }, accounts: shared ? "shared" : "local", - accountsURL: shared ? Self.accountsURL : nil + accountsURL: shared ? Self.accountsURL : nil, + reports: config.contentReportsEnabled )) } diff --git a/Sources/FlamenetRelay/RoutesModeration.swift b/Sources/FlamenetRelay/RoutesModeration.swift new file mode 100644 index 0000000..7d29b68 --- /dev/null +++ b/Sources/FlamenetRelay/RoutesModeration.swift @@ -0,0 +1,146 @@ +import Fluent +import Foundation +import SQLKit +import Vapor + +/// A report the operator can actually act on (spec §6.25). +/// +/// 🔴 **This row is the one place the operator can read user content, and it +/// is here because a reporter chose to put it here.** The relay stores +/// ciphertext it cannot open, so it cannot examine a reported story on its own +/// account. A report carrying only an accusation would give an operator nothing +/// but the assertion, which makes moderation either credulous or useless — so +/// the reporter's own decrypted copy travels with it, and the client is +/// required to say so before sending (§6.25). +/// +/// ⚠️ **Not exposed on any read route.** There is deliberately no endpoint that +/// returns these. Reports are read out of band by whoever operates the server; +/// a route would be a way to ask the relay what somebody reported, which is a +/// question nobody but the operator gets to ask. +final class ContentReport: Model, @unchecked Sendable { + static let schema = "content_reports" + + @ID(custom: "id", generatedBy: .database) var id: Int? + /// Who filed it. + @Field(key: "reporter_id") var reporterID: Int + /// Who it is about. + @Field(key: "about_id") var aboutID: Int + /// `story` or `message` — which surface it came from. + @Field(key: "kind") var kind: String + /// A short enumerated reason. + @Field(key: "reason") var reason: String + /// The reporter's decrypted copy. May be empty: a reporter may decline to + /// include it, and a report without it is weaker rather than invalid. + @Field(key: "content") var content: String + /// The reporter's own words, if they added any. + @Field(key: "note") var note: String + @Field(key: "created_at") var createdAt: Int + + init() {} + + /// The reasons a client may send. Enumerated rather than free text so the + /// column stays sortable and a client cannot smuggle a third field in here. + static let reasons: Set = [ + "abuse", "harassment", "spam", "impersonation", "csam", "other", + ] + static let kinds: Set = ["story", "message"] + + /// The largest copy a client may attach. + /// + /// ⚠️ A bound, because this is unencrypted user text on the operator's disk + /// and the route is authenticated but otherwise open. Large enough for a + /// caption and a note, far too small to use as storage. + static let maximumContent = 8_192 +} + +extension Routes { + + /// §6.25 — file a report. + /// + /// ⚠️ The reported account is never told, here or anywhere else. A report + /// that told its subject who filed it would be most dangerous to exactly + /// the people most likely to need it. + func submitReport(_ request: Request, now: Int) async throws -> Response { + // ⚠️ Refused with a code, not a 404. A relay that has switched reports + // off is a working relay that does not take them, and a client needs to + // tell that apart from an old relay that has never heard of the route — + // one is a setting, the other is a reason to stop asking. The + // discovery document carries the same fact, so a client that reads it + // never has to find out this way. + guard config.contentReportsEnabled else { + throw WireError("reports_not_accepted", + "Whoever runs this server does not take reports.", 501) + } + let body = request.body.data.map { Data(buffer: $0) } ?? Data() + let caller = try await authenticate( + request, body: body, path: "/e2e/moderation/report", now: now) + + struct Submission: Content { + let about: Int + let kind: String + let reason: String + let content: String? + let note: String? + } + let submission = try request.content.decode(Submission.self) + + guard ContentReport.kinds.contains(submission.kind) else { + throw WireError("bad_kind", "Unknown report kind.", 400) + } + guard ContentReport.reasons.contains(submission.reason) else { + throw WireError("bad_reason", "Unknown report reason.", 400) + } + // ⚠️ Reporting yourself is not a thing, and allowing it would let an + // account fill the table without another party being involved. + guard submission.about != caller.userID else { + throw WireError("bad_subject", "You cannot report yourself.", 400) + } + let content = String((submission.content ?? "").prefix(ContentReport.maximumContent)) + let note = String((submission.note ?? "").prefix(ContentReport.maximumContent)) + + let report = ContentReport() + report.reporterID = caller.userID + report.aboutID = submission.about + report.kind = submission.kind + report.reason = submission.reason + report.content = content + report.note = note + report.createdAt = now + try await report.save(on: request.db) + + // Nothing about the outcome is promised. A report is a request to + // somebody else, and telling the reporter "we have actioned this" when + // nobody has looked at it yet would be a lie with a nice tone. + return try .json(["ok": true]) + } +} + +/// ⚠️ `content` is `.sql(raw: "text")` rather than `.string`: a caption plus a +/// note can exceed whatever a driver picks for a default varchar, and a report +/// truncated by the schema is evidence quietly thrown away. +struct CreateContentReport: AsyncMigration { + func prepare(on database: any Database) async throws { + try await database.schema(ContentReport.schema) + .field("id", .int, .identifier(auto: true)) + .field("reporter_id", .int, .required) + .field("about_id", .int, .required) + .field("kind", .string, .required) + .field("reason", .string, .required) + .field("content", .sql(raw: "text"), .required) + .field("note", .sql(raw: "text"), .required) + .field("created_at", .int, .required) + .create() + // An operator's question is "what has been reported about this + // account", so that is what gets the index. + if let sql = database as? any SQLDatabase { + try? await sql.raw(""" + CREATE INDEX IF NOT EXISTS content_reports_about + ON content_reports (about_id) + """).run() + } + } + + func revert(on database: any Database) async throws { + try await database.schema(ContentReport.schema).delete() + } +} diff --git a/Tests/FlamenetRelayTests/ContentReportTests.swift b/Tests/FlamenetRelayTests/ContentReportTests.swift new file mode 100644 index 0000000..df542d7 --- /dev/null +++ b/Tests/FlamenetRelayTests/ContentReportTests.swift @@ -0,0 +1,226 @@ +import Crypto +import Fluent +import Foundation +import Testing +import Vapor + +@testable import FlamenetRelay + +/// Reporting content (spec §6.25). +/// +/// 🔴 **A report is the one place the operator can read user content, and it is +/// readable because the reporter chose to send it.** The relay holds ciphertext +/// it cannot open, so a report carrying only an accusation would give an +/// operator nothing but the assertion. Everything here is about that row being +/// exactly what the reporter meant to file, and nothing more. +@Suite("content reports") +struct ContentReportTests { + + private let now = 1_700_000_000 + + private func body(about: Int, kind: String = "story", reason: String = "abuse", + content: String = "what they posted", note: String = "") -> Data { + Data(""" + {"about":\(about),"kind":"\(kind)","reason":"\(reason)", + "content":"\(content)","note":"\(note)"} + """.utf8) + } + + private func file(_ h: RelayHarness, as d: RelayHarness.TestDevice, + _ payload: Data) async throws -> Response { + let req = h.request(.POST, "/e2e/moderation/report", as: d, body: payload, now: now) + return try await h.routes.submitReport(req, now: now) + } + + @Test("a report is stored with the reporter's copy") + func aReportIsStored() async throws { + try await withRelay { h in + let d = h.device(user: 1, id: "aaaa-0001") + _ = try await h.register(d, on: h.db) + let response = try await file(h, as: d, body(about: 2)) + #expect(response.status == .ok) + + let stored = try await ContentReport.query(on: h.db).all() + #expect(stored.count == 1) + #expect(stored[0].reporterID == 1) + #expect(stored[0].aboutID == 2) + #expect(stored[0].content == "what they posted") + } + } + + /// ⚠️ A reporter may decline to include the content. The report is weaker, + /// not invalid — refusing it would mean the only way to report something is + /// to hand over a copy, which is a worse bargain than the one §6.25 makes. + @Test("a report with no content is still accepted") + func anEmptyReportIsAccepted() async throws { + try await withRelay { h in + let d = h.device(user: 1, id: "aaaa-0001") + _ = try await h.register(d, on: h.db) + let response = try await file(h, as: d, body(about: 2, content: "")) + #expect(response.status == .ok) + #expect(try await ContentReport.query(on: h.db).count() == 1) + } + } + + /// ⚠️ Enumerated, so the column stays sortable and a client cannot smuggle + /// a third field through the reason. + @Test("an unknown reason is refused") + func anUnknownReasonIsRefused() async throws { + try await withRelay { h in + let d = h.device(user: 1, id: "aaaa-0001") + _ = try await h.register(d, on: h.db) + // ⚠️ Asserting the CODE, not merely that something threw. With the + // signing path wrong these tests passed on `unauthorized`, which + // is a WireError too — a green test proving nothing about reasons. + var code = "" + do { _ = try await file(h, as: d, body(about: 2, reason: "whatever")) } + catch let e as WireError { code = e.code } + #expect(code == "bad_reason") + #expect(try await ContentReport.query(on: h.db).count() == 0) + } + } + + @Test("an unknown kind is refused") + func anUnknownKindIsRefused() async throws { + try await withRelay { h in + let d = h.device(user: 1, id: "aaaa-0001") + _ = try await h.register(d, on: h.db) + var code = "" + do { _ = try await file(h, as: d, body(about: 2, kind: "diary")) } + catch let e as WireError { code = e.code } + #expect(code == "bad_kind") + } + } + + /// ⚠️ Reporting yourself would let one account fill the table without any + /// other party being involved. + @Test("reporting yourself is refused") + func reportingYourselfIsRefused() async throws { + try await withRelay { h in + let d = h.device(user: 1, id: "aaaa-0001") + _ = try await h.register(d, on: h.db) + var code = "" + do { _ = try await file(h, as: d, body(about: 1)) } + catch let e as WireError { code = e.code } + #expect(code == "bad_subject") + } + } + + /// 🔴 Unauthenticated reporting would let anybody fill an operator's table + /// with content attributed to accounts they do not hold. + @Test("an unauthenticated report is refused") + func anUnauthenticatedReportIsRefused() async throws { + try await withRelay { h in + let payload = body(about: 2) + let req = h.anonymousRequest(.POST, "/e2e/moderation/report", body: payload) + await #expect(throws: Error.self) { + _ = try await h.routes.submitReport(req, now: now) + } + } + } + + /// ⚠️ Bounded: this is unencrypted user text on the operator's disk, and + /// the route is authenticated but otherwise open. + @Test("an oversized copy is truncated rather than refused") + func anOversizedCopyIsTruncated() async throws { + try await withRelay { h in + let d = h.device(user: 1, id: "aaaa-0001") + _ = try await h.register(d, on: h.db) + let huge = String(repeating: "a", count: ContentReport.maximumContent + 500) + _ = try await file(h, as: d, body(about: 2, content: huge)) + let stored = try await ContentReport.query(on: h.db).all() + #expect(stored[0].content.count == ContentReport.maximumContent) + } + } + + /// 🔴 **No read route exists, and that is deliberate.** An endpoint + /// returning reports would be a way to ask the relay what somebody + /// reported — a question nobody but the operator gets to ask. This test + /// exists so that adding one is a conscious act that breaks it. + @Test("nothing on the wire can read reports back") + func reportsAreNotReadable() async throws { + try await withRelay { h in + let d = h.device(user: 1, id: "aaaa-0001") + _ = try await h.register(d, on: h.db) + _ = try await file(h, as: d, body(about: 2)) + // Asserted against the ROUTER rather than by making a request: the + // point is that no route returning reports exists at all, so adding + // one has to break this deliberately. + let readRoutes = h.app.routes.all.filter { + $0.path.map(\.description).joined(separator: "/").contains("moderation") + && $0.method != .POST + } + #expect(readRoutes.isEmpty) + } + } + + // MARK: - The switch + + /// On by default: an operator who has configured nothing still takes + /// reports, because the alternative leaves blocking as the only recourse. + @Test("reports are accepted with nothing configured") + func onByDefault() async throws { + try await withRelay { h in + let d = h.device(user: 1, id: "aaaa-0001") + _ = try await h.register(d, on: h.db) + #expect(try await file(h, as: d, body(about: 2)).status == .ok) + } + } + + /// 🔴 Off is refused with a code, not a 404. + /// + /// A relay that has switched reports off is a working relay that does not + /// take them. A client has to tell that apart from an old relay that never + /// had the route: one is a setting, the other is a reason to stop asking. + @Test("switched off, a report is refused in a way a client can read") + func offRefusesWithACode() async throws { + try await withRelay(env: ["FN_CONTENT_REPORTS": "false"]) { h in + let d = h.device(user: 1, id: "aaaa-0001") + _ = try await h.register(d, on: h.db) + do { + _ = try await self.file(h, as: d, self.body(about: 2)) + Issue.record("a report was accepted on a relay that does not take them") + } catch let error as WireError { + #expect(error.code == "reports_not_accepted") + #expect(error.data.status == 501) + } + // And nothing was written. + #expect(try await ContentReport.query(on: h.db).count() == 0) + } + } + + /// The discovery document carries the same fact, so a client can leave the + /// affordance out instead of finding out by failing. + @Test("the relay says in discovery whether it takes reports") + func discoveryAdvertisesIt() async throws { + try await withRelay { h in + let on = try await h.routes.relayDescriptor(h.anonymousRequest(.GET, "/.well-known/flamenet/relay")) + #expect(String(buffer: on.body.buffer!).contains("\"reports\":true")) + } + try await withRelay(env: ["FN_CONTENT_REPORTS": "false"]) { h in + let off = try await h.routes.relayDescriptor(h.anonymousRequest(.GET, "/.well-known/flamenet/relay")) + #expect(String(buffer: off.body.buffer!).contains("\"reports\":false")) + } + } + + /// Only an explicit no turns it off — a typo must not silently stop the + /// relay taking reports. + @Test("only an explicit no switches reports off") + func onlyExplicitNoDisables() throws { + func flag(_ v: String?) throws -> Bool { + var env = [ + "FN_ISSUER_PUBLIC_KEY": Curve25519.Signing.PrivateKey() + .publicKey.rawRepresentation.base64EncodedString(), + "FN_RELAY_AUDIENCE": "test.relay", + ] + if let v { env["FN_CONTENT_REPORTS"] = v } + return try Config.fromEnvironment(env).contentReportsEnabled + } + for on in [nil, "", "true", "yes", "1", "flase", "disabled"] { + #expect(try flag(on) == true, "\(on ?? "unset") should leave reports on") + } + for off in ["false", "FALSE", "0", "no", "off"] { + #expect(try flag(off) == false, "\(off) should switch reports off") + } + } +} diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index be14639..48281ba 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -141,6 +141,19 @@ Off. The relay does not carry instructions for a device to erase itself. Off because the feature is not finished end to end, not because the route is dangerous. No client in the field can honour a wipe — the Android build released on 2026-08-31 (v8) never asks for one — and the issuing side still needs the passphrase re-entered before an order can be signed. A half-built remote erase that is on by default looks like a guarantee and is not one yet. The relay is only ever a courier. The instruction is signed by the account key, which the relay does not hold, so it cannot forge one; and withholding one stops a wipe rather than causing one. When off, both routes refuse with `wipe_not_enabled` rather than a 404, so a client can tell a server that has this switched off from one too old to know what it is. +## Reports about content + +On. A person can report something to whoever runs the server, so blocking is not the only thing they can do. + +| Variable | Default | What it does | +|---|---|---| +| `FN_CONTENT_REPORTS` | on | Set to `false`, `0`, `no` or `off` to stop taking reports. Anything else, including a typo, leaves them on. | + +Off is a legitimate answer, not a broken relay. If nobody is going to read them, collecting reports is a promise the server cannot keep, and saying so plainly is better than a queue nobody opens. + +When off, two things happen so that nothing pretends otherwise: `/.well-known/flamenet/relay` reports `"reports": false`, which lets a client leave the Report option out of the interface entirely; and the route itself refuses with `reports_not_accepted` rather than a 404, so a client can tell "this server does not take reports" apart from "this server is too old to know what a report is". + +There is deliberately no route that **reads** reports. They are read out of band by whoever operates the server — an endpoint returning them would be a way to ask the relay what somebody reported, which is a question nobody but the operator gets to ask. ## Retention