Accept a report, so blocking is not the only thing a person can do #87

Merged
paulhitt merged 2 commits from feat/content-reports into main 2026-09-03 00:01:44 +00:00
8 changed files with 461 additions and 3 deletions
+36 -1
View File
@@ -3,7 +3,7 @@
<!-- GENERATED by Scripts/build-doc-pages.sh from docs/CONFIGURATION.md.
Edits made HERE are deleted the next time that script runs.
Change the markdown source and re-run it.
source-sha256: 41e0e20cc4e1f529 -->
source-sha256: e592ce3fe1f1b4fe -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="configuring-a-relay">Configuring a relay</h1>
@@ -375,6 +375,41 @@ withholding one stops a wipe rather than causing one. When off, both
routes refuse with <code>wipe_not_enabled</code> 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.</p>
<h2 id="reports-about-content">Reports about content</h2>
<p>On. A person can report something to whoever runs the server, so
blocking is not the only thing they can do.</p>
<table>
<thead>
<tr>
<th>Variable</th>
<th>Default</th>
<th>What it does</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>FN_CONTENT_REPORTS</code></td>
<td>on</td>
<td>Set to <code>false</code>, <code>0</code>, <code>no</code> or
<code>off</code> to stop taking reports. Anything else, including a
typo, leaves them on.</td>
</tr>
</tbody>
</table>
<p>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.</p>
<p>When off, two things happen so that nothing pretends otherwise:
<code>/.well-known/flamenet/relay</code> reports
<code>"reports": false</code>, which lets a client leave the Report
option out of the interface entirely; and the route itself refuses with
<code>reports_not_accepted</code> 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".</p>
<p>There is deliberately no route that <strong>reads</strong> 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.</p>
<h2 id="retention">Retention</h2>
<p>The relay purges on its own timer, in-process. Nothing external is
needed.</p>
+16
View File
@@ -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),
+10
View File
@@ -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"))
+5 -1
View File
@@ -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()]
}
}
+9 -1
View File
@@ -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
))
}
@@ -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<String> = [
"abuse", "harassment", "spam", "impersonation", "csam", "other",
]
static let kinds: Set<String> = ["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()
}
}
@@ -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")
}
}
}
+13
View File
@@ -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