Accept a report, so blocking is not the only thing a person can do #87
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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()]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user