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
package main

import (
	"bytes"
	"errors"
	"html/template"
	"net/http"
	"time"
)

// Reviewing a submission
// ============================================================================
//
// One submission, one URL, and no way to get from either to any other. There
// is deliberately no index: the mail notification is the only listing, so a
// forwarded link exposes a single recording rather than the inbox.
//
// The token in the URL *is* the credential, so everything here is arranged to
// keep it from being written down anywhere. nginx logging is turned off for
// this location in the NixOS module; the headers below stop the page being
// indexed, cached by anything shared, or leaked through a Referer.
//
// The page's own links are root-relative — /inbox/TOKEN/audio, not "audio" —
// and must stay that way. The review page is reachable both with and without a
// trailing slash, because the notification mail links to the slashless form and
// both patterns are registered, which is precisely what stops ServeMux from
// redirecting one to the other. A relative "audio" therefore resolves against
// /inbox/ on the URL people actually click, asks for /inbox/audio, and 404s:
// the player never loads and the delete button posts into nothing. Root-
// relative is correct under either form and costs no redirect. It is still not
// an absolute URL, so no Referer can carry the token off-site.

// noLeakHeaders sets the headers that keep a capability URL private.
func noLeakHeaders(w http.ResponseWriter) {
	// Not for a search engine, and not for a shared cache.
	w.Header().Set("X-Robots-Tag", "noindex, nofollow, noarchive")
	w.Header().Set("Cache-Control", "private, no-store, max-age=0")
	// The page links nowhere, but this makes the guarantee independent of
	// that staying true.
	w.Header().Set("Referrer-Policy", "no-referrer")
}

// The four states of a transcript
// ----------------------------------------------------------------------------
//
// A page that simply omits the transcript answers none of the questions worth
// asking about a submission that has none, and there are four different states
// behind that one blank space:
//
//   - not consented: nothing was attempted, and the audio never left the
//     machine. This is a property of the submission, not a failure.
//   - transcribed: the transcript is shown.
//   - attempted and failed: the reason was recorded with the submission
//     (setTranscriptError) and is shown verbatim, because "it failed" without
//     the reason is only marginally better than silence. Nothing retries it,
//     so the page says that too.
//   - consented, nothing recorded: transcription is still running, or the row
//     predates the column that records reasons. The page says only what it can
//     actually tell rather than guessing at one of the others.
//
// The reason is printed as it came back from the API. This page is reachable
// only by whoever holds the token, which is the operator — the submitter never
// receives the link — so an internal message is not disclosed to anyone it was
// not already going to reach through the notification mail.
//
// It is rendered as text and never as a link, which matters more than it looks:
// these messages carry URLs (Google's points at the billing console), and this
// page must not link off-site — a click would carry the token in a Referer, and
// the Referrer-Policy above is the belt to this braces. html/template escapes
// the reason, so it cannot introduce an anchor of its own; keeping it out of an
// href is the part that has to be done here.

// handleReview renders the page for one submission.
func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
	noLeakHeaders(w)
	token := r.PathValue("token")
	sub, err := submissionByToken(s.db, token)
	if errors.Is(err, ErrNotFound) {
		http.NotFound(w, r)
		return
	}
	if err != nil {
		s.fail(w, err)
		return
	}
	var buf bytes.Buffer
	if err := reviewTmpl.Execute(&buf, map[string]any{
		"S":          sub,
		"Token":      token,
		"Received":   sub.ReceivedAt.Format("2006-01-02 15:04"),
		"Size":       humanBytes(sub.ByteSize),
		"Duration":   durationText(sub),
		"Transcript": sub.Transcript.String,
		"TranscErr":  sub.TranscriptError.String,
	}); err != nil {
		s.fail(w, err)
		return
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.Write(buf.Bytes())
}

// handleReviewAudio serves the recording itself.
//
// http.ServeContent rather than a plain Write: it answers Range requests, which
// is what lets the player seek and what lets an interrupted download resume.
// Without it a five-minute recording can only ever be played from the start.
func (s *server) handleReviewAudio(w http.ResponseWriter, r *http.Request) {
	noLeakHeaders(w)
	token := r.PathValue("token")
	audio, mime, err := audioByToken(s.db, token)
	if errors.Is(err, ErrNotFound) {
		http.NotFound(w, r)
		return
	}
	if err != nil {
		s.fail(w, err)
		return
	}
	w.Header().Set("Content-Type", mime)
	// The name is not meaningful to a browser here, but it makes a saved file
	// land with a sensible name.
	w.Header().Set("Content-Disposition", `inline; filename="submission"`)
	http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(audio))
}

// handleReviewDelete removes a submission.
//
// A capability URL cannot be revoked once it has been sent, so deleting the
// recording is the only way to withdraw access — which is also what frees the
// quota.
func (s *server) handleReviewDelete(w http.ResponseWriter, r *http.Request) {
	noLeakHeaders(w)
	token := r.PathValue("token")
	err := deleteSubmission(s.db, token)
	if errors.Is(err, ErrNotFound) {
		http.NotFound(w, r)
		return
	}
	if err != nil {
		s.fail(w, err)
		return
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.Write([]byte(deletedPage))
}

func durationText(s *Submission) string {
	if !s.DurationMs.Valid || s.DurationMs.Int64 <= 0 {
		return ""
	}
	d := time.Duration(s.DurationMs.Int64) * time.Millisecond
	m := int(d / time.Minute)
	sec := int(d/time.Second) % 60
	return formatMinSec(m, sec)
}

func formatMinSec(m, s int) string {
	return itoa(m) + ":" + pad2(s)
}

func itoa(n int) string {
	if n == 0 {
		return "0"
	}
	var b []byte
	for n > 0 {
		b = append([]byte{byte('0' + n%10)}, b...)
		n /= 10
	}
	return string(b)
}

func pad2(n int) string {
	if n < 10 {
		return "0" + itoa(n)
	}
	return itoa(n)
}

const reviewStyle = `
  *, *::before, *::after { box-sizing: border-box; }
  :root { color-scheme: light dark; }
  body {
    font-family: system-ui, sans-serif;
    font-size: 18px;
    line-height: 1.5;
    max-width: 40em;
    margin: 0 auto;
    padding: 2em 1em 4em;
    background: light-dark(#fff, #1a1a1a);
    color: light-dark(#111, #ddd);
  }
  h1 { font-weight: 400; font-size: 1.4em; }
  audio { width: 100%; margin: 1em 0; }
  dl { font-size: 0.9em; color: light-dark(#555, #aaa); }
  dt { font-weight: 600; margin-top: 0.5em; }
  dd { margin: 0; }
  .transcript {
    background: light-dark(#f2f2f2, #252525);
    border-radius: 4px;
    padding: 1em;
    margin: 1.5em 0;
    white-space: pre-wrap;
    font-size: 0.95em;
  }
  .failed {
    background: light-dark(#fdf3f3, #2a2020);
    border-left: 3px solid light-dark(#c33, #f66);
    border-radius: 4px;
    padding: 1em;
    margin: 1.5em 0;
    font-size: 0.9em;
  }
  .failed .why { font-family: ui-monospace, monospace; word-break: break-word; }
  .danger { margin-top: 3em; }
  button {
    font: inherit;
    padding: 0.5em 1em;
    border-radius: 4px;
    border: 1px solid light-dark(#c33, #f66);
    background: transparent;
    color: light-dark(#c33, #f66);
    cursor: pointer;
  }
  .note { font-style: italic; }
`

var reviewTmpl = template.Must(template.New("review").Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<meta name="referrer" content="no-referrer">
<title>submission</title>
<style>` + reviewStyle + `</style>
</head>
<body>

<h1>A submitted observation</h1>

<audio controls preload="metadata" src="/inbox/{{ .Token }}/audio"></audio>

<dl>
  <dt>Received</dt><dd>{{ .Received }}</dd>
  <dt>Size</dt><dd>{{ .Size }}{{ with .Duration }} · {{ . }}{{ end }}</dd>
  {{ with .S.Filename }}<dt>Filename</dt><dd>{{ . }}</dd>{{ end }}
  <dt>Transcription</dt>
  <dd>
    {{- if not .S.Consented }}not permitted
    {{- else if .Transcript }}permitted by the sender
    {{- else if .TranscErr }}permitted by the sender, but it did not happen
    {{- else }}permitted by the sender, but there is no transcript
    {{- end }}
  </dd>
</dl>

{{ with .S.Note }}<p class="note">{{ . }}</p>{{ end }}

{{ if .Transcript }}<div class="transcript">{{ .Transcript }}</div>
{{ else if .TranscErr }}
<div class="failed">
  <p>Transcription failed, and is not tried again:</p>
  <p class="why">{{ .TranscErr }}</p>
  <p>The recording itself is unaffected.</p>
</div>
{{ end }}

<form class="danger" method="POST" action="/inbox/{{ .Token }}/delete"
      onsubmit="return confirm('Delete this submission? The link stops working and the audio is gone.')">
  <button type="submit">Delete this submission</button>
</form>

</body>
</html>
`))

const deletedPage = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title>deleted</title>
<style>` + reviewStyle + `</style>
</head>
<body>
<h1>Deleted</h1>
<p>The recording is gone and this link no longer works.</p>
</body>
</html>
`