1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
|
.Dd September 3, 2026
.Dt OBSERVATIONS-INBOX 1
.Os
.Sh NAME
.Nm observations-inbox
.Nd accept audio replies to observations episodes
.Sh SYNOPSIS
.Nm
.Op Fl addr Ar host:port
.Op Fl db Ar path
.Op Fl base-url Ar url
.Fl passphrase-file Ar path
.Op Fl mail-file Ar path
.Op Fl gemini-key-file Ar path
.Op Fl ffprobe Ar path
.Op Fl ffmpeg Ar path
.Sh DESCRIPTION
.Nm
is the endpoint behind the drop zone on the
.Xr observations 7
website.
A listener drops a recording, it is stored in one SQLite file, and a
notification carrying an unguessable link arrives by mail.
.Pp
There is no account, no listing route, and no way to get from one submission
to another: the mail is the only record that a submission exists.
For why the format has an inbox at all, and what was given up by adding one,
see
.Xr observations 7 .
.Ss Options
.Bl -tag -width "-passphrase-file path"
.It Fl addr Ar host:port
Address to listen on.
Default
.Pa 127.0.0.1:8778 .
Intended to sit behind a reverse proxy; see
.Sx DEPLOYMENT .
.It Fl db Ar path
The SQLite database.
Default
.Pa /var/lib/observations/inbox.db .
Created, with its parent directory, if missing.
.It Fl base-url Ar url
Public base URL, used to build the links in notifications.
Default
.Pa https://observations.profpatsch.de .
.It Fl passphrase-file Ar path
File holding the submission passphrase.
.Sy Required :
without it anyone who finds the endpoint could submit.
.Pp
Matching is deliberately generous, because the passphrase is heard in an
episode and typed in from memory.
Case and every kind of whitespace are ignored, including none at all, so
.Ql OpenSesame
and
.Ql open sesame
are the same phrase; punctuation and accents are dropped; and a small number
of typos is tolerated, scaled to length \(en none below six characters, one up
to twelve, two beyond that, with a transposition of adjacent letters counting
as one.
.Pp
The phrase is held in memory rather than as a hash, since an edit distance
cannot be computed against a digest.
This gives up nothing: it is spoken aloud in every episode, so it was never
secret.
See
.Sx CAVEATS .
.It Fl mail-file Ar path
File holding the whole mail configuration: server, account, password and
recipient.
When empty, submissions are still accepted and stored, and their links are
written to the log instead.
.Pp
A notification is sent as
.Li multipart/alternative :
the same message as text and as HTML, the latter for the sake of a link that
can be clicked and a transcript that can be read.
The HTML part loads nothing from the network \(en no images, no stylesheets,
no fonts \(en so opening the mail cannot report that it was opened, or to
whom.
Both parts are quoted-printable, because a transcript is one long paragraph
and SMTP refuses a line over 998 octets.
.Pp
One
.Ar key = value
per line;
.Ql #
comments and blank lines are ignored.
Recognised keys are
.Ar host ,
.Ar port
(default 465, implicit TLS),
.Ar user ,
.Ar pass ,
.Ar from
(defaults to
.Ar user )
and
.Ar to .
An unknown key is an error rather than a warning, so a typo cannot silently
disable notifications.
.Pp
The server and the account live in this file rather than in flags on purpose.
Anything in the command line is visible in the process table to every user on
the machine, and when the unit is generated by Nix it is also readable in the
store.
The password is the sensitive part, but the account it belongs to is worth no
more publicity than it needs.
.It Fl gemini-key-file Ar path
File holding a Gemini API key.
When empty, transcription is unavailable and submissions are simply stored.
A key is only ever used for a submission whose sender ticked the consent box.
See
.Sx TRANSCRIBING .
.It Fl ffprobe Ar path , Fl ffmpeg Ar path
Paths to the ffmpeg tools, used to measure how long a submission is.
Default
.Ic ffprobe
and
.Ic ffmpeg
on PATH; the NixOS module puts both in the closure.
Neither is required \(en without them a submission is stored and served
unchanged, with an unknown length.
See
.Sx MEASURING .
.El
.Sh MEASURING
The length of a recording is read from its container after the upload has been
answered, so the submitter never waits for it, and written to
.Li duration_ms .
It appears on the review page and in the notification.
.Pp
Most containers declare their duration in the header and this is a cheap read.
A recording made in the browser does not: the page records with MediaRecorder,
which writes a WebM as a live stream, and the duration field would have to be
known before the recording it describes has finished.
Such a file is
.Sy remuxed \(en
rewritten into a new container with
.Ic -c copy ,
so the encoded audio is moved rather than decoded, and the result declares the
length that is now known.
This is the one case where the stored bytes are not the bytes that arrived.
It is done because a player cannot show a length, or offer a scrubber that can
be dragged, until it knows one; without it a submission has to be played from
start to end before it can be navigated.
.Pp
A file that already declares a duration is stored exactly as it was sent.
Every step is best effort: a recording that cannot be probed keeps its unknown
length, and one that cannot be remuxed keeps its original bytes.
The recording is the thing worth protecting, and a missing number is not a
reason to risk it.
.Pp
At startup, any submission still holding audio but no duration is measured the
same way, in the background and without blocking the listener.
This is what fixes recordings that arrived before there was anything to
measure them; it sends no mail, since those were announced when they arrived.
The work is bounded by the 50 MB ceiling and is not repeated once done.
.Sh TRANSCRIBING
A submission is transcribed only when its sender ticked the consent box and a
key is configured.
The recording of a submission that did not consent never leaves the machine.
Like measuring, it happens after the upload has been answered, so nobody waits
on a round trip to Google for a transcript they will not read.
.Pp
A submission with no transcript is in one of four states, and the review page
names which:
.Bl -bullet
.It
the sender did not consent, so nothing was attempted;
.It
it was transcribed, and the transcript is shown;
.It
it was attempted and failed, in which case the reason is recorded with the
submission in
.Li transcript_error
and shown verbatim on the page;
.It
consent was given and nothing is recorded either way \(en transcription is
still running, or the submission predates the column that holds the reason.
.El
.Pp
The reason is stored rather than only logged and mailed because that is the
form in which it is still there later: the notification is sent once, the log
rotates, and the review page is what is open when the question is asked.
Without it a transcription that died of an expired API key or a depleted
balance looked exactly like one nobody had permission to run.
.Pp
Nothing retries.
A transcription that failed has failed for good, and the page says so rather
than leaving a blank that might yet fill in.
The consequence is that an outage of the API \(en credit exhausted, key
expired \(en costs the transcripts of every submission that arrives during it,
permanently.
Those recordings are unaffected and can be listened to; only the convenience of
reading them first is gone.
.Sh ROUTES
.Bl -tag -width "POST /inbox/{token}/delete"
.It Cm POST /submit
Accept one submission.
Multipart, with a
.Li file
part and the fields
.Li passphrase ,
.Li consent
and
.Li note .
Answers JSON:
.Li {"ok":true}
or
.Li {"ok":false,"error":"…"} ,
the latter written to be shown to whoever sent it.
.It Cm GET /submit
Report whether the inbox is open, as
.Li {"open":true,"maxBytes":5242880} .
The drop zone asks this on load so that a full inbox is announced before
someone records a reply rather than after.
It deliberately reveals nothing about how much is stored or how many
submissions exist.
.It Cm GET /inbox/{token}
The review page for one submission: player, metadata, transcript \(en or why
there is none, see
.Sx TRANSCRIBING \(en
and a delete button.
.It Cm GET /inbox/{token}/audio
The recording.
Supports range requests, which is what lets the player seek.
.It Cm POST /inbox/{token}/delete
Delete the recording and free its space.
.El
.Pp
An unknown token, a malformed one and one whose submission was deleted all
answer 404 identically.
Distinguishing them would confirm that a token was once valid.
.Sh LIMITS
.Bl -tag -width "rate limit"
.It file size
5 MB, about five minutes from a phone.
Enforced with a
.Li MaxBytesReader ,
so a request that lies about its length cannot make the server allocate more.
.It total
50 MB.
When reached, submissions are refused with 503 until something is deleted, and
one mail is sent to say so.
.It rate
Twelve submissions per address per hour, counted only for attempts that got as
far as being valid \(en junk is refused on its own merits and does not use up
anyone's budget.
.Pp
Deliberately loose: it is a brake on a script, not a quota on a person.
The passphrase and the 50 MB ceiling are the real limits, and a rate low
enough to catch an ordinary retake produces a flat refusal that is
indistinguishable from the site being broken.
.It kind
Audio only, decided by inspecting the leading bytes.
The filename is a claim by whoever named the file and is never what decides.
.El
.Sh FILES
.Bl -tag -width "/var/lib/observations/inbox.db"
.It Pa /var/lib/observations/inbox.db
Every submission, audio included.
Nothing is written to the filesystem under a name derived from a submission,
so the database is the whole of the inbox: one file to copy, inspect or
delete.
.It Pa /var/lib/secrets/observations-inbox/passphrase
.It Pa /var/lib/secrets/observations-inbox/mail
.It Pa /var/lib/secrets/observations-inbox/gemini-key
Secrets, passed to the service as systemd credentials rather than read
directly, so the unit's own user never has filesystem access to them.
.El
.Sh DEPLOYMENT
.Nm
expects a reverse proxy in front of it.
Two pieces of that configuration are load-bearing rather than cosmetic:
.Bl -bullet
.It
.Sy Access logging must be off for Pa /inbox/ .
The token in the URL is the credential; a proxy that logs request paths writes
every capability into a file that is rotated, retained and backed up.
.It
.Li client_max_body_size
must be a little above 5 MB, so that an oversized upload is refused by the
proxy with a message rather than by a reset connection.
.El
.Pp
The NixOS module in
.Pa users/Profpatsch/website/module.nix
does both.
.Sh EXAMPLES
A mail configuration file:
.Bd -literal -offset indent
host = smtp.example.org
port = 465
user = someone@example.org
pass = the-password
to = someone@example.org
.Ed
.Pp
Run against a scratch database, without mail or transcription:
.Bd -literal -offset indent
printf 'some spoken phrase' > /tmp/pass
observations-inbox -db ./tmp/inbox.db -passphrase-file /tmp/pass \e
-base-url http://localhost:8778
.Ed
.Pp
Look at what is waiting, without the web interface:
.Bd -literal -offset indent
sqlite3 /var/lib/observations/inbox.db \e
"SELECT id, datetime(received_at,'unixepoch'), byte_size, token
FROM submission WHERE deleted_at IS NULL"
.Ed
.Sh SEE ALSO
.Xr observations 1 ,
.Xr observations 7
.Sh AUTHORS
.An Profpatsch
.Sh CAVEATS
Submissions are unreviewed audio from strangers.
Nothing has looked at them before you do.
.Pp
The database is
.Sy not backed up
by anything, and it lives on a filesystem with little headroom that the rest of
the machine also depends on.
That is what the 50 MB ceiling is for; it is not a storage system, and a
submission worth keeping should be moved out of it.
.Pp
A capability URL is a credential, and it cannot be revoked: anyone who obtains
the link can listen until the recording is deleted.
Deleting is therefore the only way to withdraw access, and it is also the only
way to free space.
.Pp
Losing the notification mail loses the link, and with it any way to reach that
submission through the web interface.
This is the intended trade for having no listing, but it does mean the mail is
load-bearing.
The database can still be queried directly, as above.
.Pp
The passphrase is a filter against scanners, not a secret.
Every listener has it, it travels in a form post, it is held in memory by the
running service, and it is only as private as the least discreet person who
heard the episode.
Changing it costs one file and a restart, which is the intended way to deal
with that.
.Pp
A failed transcription is never retried, by anything.
The reason is recorded and shown, so a missing transcript is at least
explained, but an interruption to the API costs the transcripts of everything
submitted while it lasts and nothing will go back for them.
Redoing one is manual work against the stored audio.
|