funoteka 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +90 -0
- package/CHANGELOG.md +104 -0
- package/DEPLOY.md +375 -0
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/deploy/answers.schema.json +151 -0
- package/deploy/build.mjs +88 -0
- package/deploy/check-publishable.mjs +246 -0
- package/deploy/smoke.mjs +681 -0
- package/deploy/systemd/funoteka.env.example +41 -0
- package/deploy/systemd/funoteka.service +77 -0
- package/deploy/windows/funoteka-service.xml +58 -0
- package/deploy/windows/install-service.ps1 +158 -0
- package/dist/annotation/store.js +67 -0
- package/dist/api/admin-export.js +227 -0
- package/dist/api/admin-guard.js +181 -0
- package/dist/api/admin-library.js +113 -0
- package/dist/api/admin.js +1162 -0
- package/dist/api/annotation.js +193 -0
- package/dist/api/artistinfo.js +140 -0
- package/dist/api/audit.js +32 -0
- package/dist/api/auth.js +145 -0
- package/dist/api/bookmark.js +108 -0
- package/dist/api/browse.js +1241 -0
- package/dist/api/config-file.js +202 -0
- package/dist/api/config.js +285 -0
- package/dist/api/cover.js +304 -0
- package/dist/api/download.js +112 -0
- package/dist/api/envelope.js +162 -0
- package/dist/api/extensions.js +43 -0
- package/dist/api/genre.js +81 -0
- package/dist/api/health.js +35 -0
- package/dist/api/history.js +374 -0
- package/dist/api/idempotency.js +66 -0
- package/dist/api/keys.js +90 -0
- package/dist/api/log-file.js +65 -0
- package/dist/api/meta.js +1410 -0
- package/dist/api/playlist.js +311 -0
- package/dist/api/rescan.js +196 -0
- package/dist/api/roots.js +161 -0
- package/dist/api/router.js +133 -0
- package/dist/api/scanner.js +163 -0
- package/dist/api/search.js +82 -0
- package/dist/api/server.js +643 -0
- package/dist/api/settings.js +67 -0
- package/dist/api/stream.js +442 -0
- package/dist/api/stubs.js +183 -0
- package/dist/api/transcode.js +678 -0
- package/dist/api/user.js +73 -0
- package/dist/api/virtual.js +362 -0
- package/dist/api/visibility.js +21 -0
- package/dist/artist/apply.js +752 -0
- package/dist/artist/credit.js +95 -0
- package/dist/artist/folder.js +65 -0
- package/dist/artist/name.js +205 -0
- package/dist/artist/nfo.js +101 -0
- package/dist/artist/translit.js +79 -0
- package/dist/bookmark/store.js +54 -0
- package/dist/classify/classify.js +405 -0
- package/dist/classify/collision-name.js +109 -0
- package/dist/classify/folder-name.js +466 -0
- package/dist/classify/roles.js +277 -0
- package/dist/classify/shelf-name.js +178 -0
- package/dist/classify/tree.js +43 -0
- package/dist/cli/args.js +169 -0
- package/dist/cli/daemon.js +192 -0
- package/dist/cli/entry.js +25 -0
- package/dist/cli/keys.js +52 -0
- package/dist/cli.js +638 -0
- package/dist/cover/pick.js +58 -0
- package/dist/cover/picture.js +111 -0
- package/dist/cue/engine.js +759 -0
- package/dist/cue/match.js +142 -0
- package/dist/cue/parse.js +182 -0
- package/dist/cue/plan.js +286 -0
- package/dist/cue/track-name.js +120 -0
- package/dist/db/index.js +163 -0
- package/dist/db/issue.js +73 -0
- package/dist/db/ledger.js +84 -0
- package/dist/db/migrations/001_init.sql +196 -0
- package/dist/db/migrations/002_cue_track_flexible.sql +35 -0
- package/dist/db/migrations/003_cue_metadata.sql +14 -0
- package/dist/db/migrations/004_cue_performer.sql +12 -0
- package/dist/db/migrations/005_incremental.sql +23 -0
- package/dist/db/migrations/006_tags.sql +57 -0
- package/dist/db/migrations/007_artist_credit.sql +35 -0
- package/dist/db/migrations/008_issue_stage.sql +39 -0
- package/dist/db/migrations/009_cue_title.sql +20 -0
- package/dist/db/migrations/010_duration_source.sql +31 -0
- package/dist/db/migrations/011_track_title_source.sql +28 -0
- package/dist/db/migrations/012_file_container.sql +25 -0
- package/dist/db/migrations/013_cover_art.sql +28 -0
- package/dist/db/migrations/014_album_year.sql +17 -0
- package/dist/db/migrations/015_probe_method.sql +14 -0
- package/dist/db/migrations/016_tags_method.sql +20 -0
- package/dist/db/migrations/017_cover_indirect.sql +23 -0
- package/dist/db/migrations/018_release_year.sql +14 -0
- package/dist/db/migrations/019_file_tag_name_file_value.sql +20 -0
- package/dist/db/migrations/020_analyze_after_index.sql +16 -0
- package/dist/db/migrations/021_cue_index_files.sql +15 -0
- package/dist/db/migrations/022_sidecar_text.sql +19 -0
- package/dist/db/migrations/023_track_credit.sql +23 -0
- package/dist/db/migrations/024_playlists.sql +63 -0
- package/dist/db/migrations/025_playlist_sequence.sql +27 -0
- package/dist/db/migrations/026_playlist_source_file.sql +19 -0
- package/dist/db/migrations/027_lookup_indexes.sql +30 -0
- package/dist/db/migrations/028_annotations.sql +47 -0
- package/dist/db/migrations/029_scan_state_root_run.sql +28 -0
- package/dist/db/migrations/030_history.sql +108 -0
- package/dist/db/migrations/031_queue_position_and_plays.sql +46 -0
- package/dist/db/migrations/032_bookmarks.sql +27 -0
- package/dist/db/migrations/033_bookmark_order.sql +14 -0
- package/dist/db/migrations/034_junk.sql +33 -0
- package/dist/db/migrations/035_api_key.sql +38 -0
- package/dist/db/migrations/036_file_tag_first.sql +88 -0
- package/dist/db/migrations/037_admin_idempotency.sql +31 -0
- package/dist/db/migrations/038_cue_audio_index.sql +24 -0
- package/dist/db/sweep.js +109 -0
- package/dist/history/store.js +151 -0
- package/dist/inventory/inventory.js +294 -0
- package/dist/junk/marks.js +143 -0
- package/dist/junk/rule.js +93 -0
- package/dist/mcp/client.js +48 -0
- package/dist/mcp/server.js +175 -0
- package/dist/mcp/tools.js +348 -0
- package/dist/playlist/files.js +116 -0
- package/dist/playlist/import.js +260 -0
- package/dist/playlist/store.js +240 -0
- package/dist/probe/ffprobe.js +179 -0
- package/dist/run.js +120 -0
- package/dist/scan/kinds.js +49 -0
- package/dist/scan/scan.js +503 -0
- package/dist/scan/schedule.js +132 -0
- package/dist/scan/settle.js +172 -0
- package/dist/scan/walk.js +132 -0
- package/dist/search/index.js +41 -0
- package/dist/search/query.js +28 -0
- package/dist/stream/flac.js +541 -0
- package/dist/stream/mpeg.js +179 -0
- package/dist/stream/recode.js +393 -0
- package/dist/stream/rewrite.js +163 -0
- package/dist/stream/segment.js +624 -0
- package/dist/tags/apply.js +703 -0
- package/dist/tags/encode.js +164 -0
- package/dist/tags/first.js +130 -0
- package/dist/tags/flac.js +196 -0
- package/dist/tags/genres.js +123 -0
- package/dist/tags/id3v1.js +209 -0
- package/dist/tags/id3v2.js +754 -0
- package/dist/tags/mp4.js +534 -0
- package/dist/tags/mpeg.js +355 -0
- package/dist/tags/ogg.js +463 -0
- package/dist/tags/picture.js +71 -0
- package/dist/tags/read.js +269 -0
- package/dist/tags/types.js +51 -0
- package/dist/tags/vorbis-comment.js +155 -0
- package/dist/text/encoding.js +384 -0
- package/dist/text/site-name.js +37 -0
- package/dist/util/child.js +21 -0
- package/dist/util/names.js +97 -0
- package/funoteka.json.example +27 -0
- package/package.json +55 -0
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
import { createServer as createHttpServer, } from 'node:http';
|
|
2
|
+
import { authenticate, coverSignature } from "./auth.js";
|
|
3
|
+
import { keyHolds } from "./keys.js";
|
|
4
|
+
import { ApiError, ERROR, failed, ok, parseFormat, render, } from "./envelope.js";
|
|
5
|
+
import { health } from "./health.js";
|
|
6
|
+
import { binaryRoute, route } from "./router.js";
|
|
7
|
+
import { visibilityOf } from "./visibility.js";
|
|
8
|
+
/**
|
|
9
|
+
* The API as an HTTP server.
|
|
10
|
+
*
|
|
11
|
+
* Built rather than started: the caller decides when it listens, so a test can
|
|
12
|
+
* put one on a port the kernel picks and a deployment can put one on the port
|
|
13
|
+
* the operator chose, and both are the same server. Nothing here reads the
|
|
14
|
+
* environment or the command line — that is config's business, and keeping it
|
|
15
|
+
* out is what makes this callable from a test at all.
|
|
16
|
+
*
|
|
17
|
+
* A server with nothing to check credentials against is refused here, at the
|
|
18
|
+
* only point where one comes into being. The alternative — answering everyone
|
|
19
|
+
* because no password was configured — is a music library on the open network,
|
|
20
|
+
* and it is the kind of misconfiguration that looks like it worked.
|
|
21
|
+
*/
|
|
22
|
+
export function createServer(db, config) {
|
|
23
|
+
if (config.user === '' || (config.password === '' && config.apiKey === '')) {
|
|
24
|
+
throw new Error('no credentials: set FUNOTEKA_USER with FUNOTEKA_PASSWORD or FUNOTEKA_APIKEY');
|
|
25
|
+
}
|
|
26
|
+
return createHttpServer((request, response) => {
|
|
27
|
+
if (config.logRequests)
|
|
28
|
+
note(request, response);
|
|
29
|
+
// Every path out of `handle` is accounted for, and this catch is the one
|
|
30
|
+
// that covers the part before its own try: reading a request body can fail
|
|
31
|
+
// when a client hangs up mid-POST, and an unhandled rejection in Node does
|
|
32
|
+
// not end a request — it ends the process. A server that the whole household
|
|
33
|
+
// listens to must not be killable by one phone losing signal.
|
|
34
|
+
handle(db, config, request, response).catch((err) => {
|
|
35
|
+
// The format is not known here — the failure may have been before the
|
|
36
|
+
// request was read far enough to say — so the refusal is rendered in the
|
|
37
|
+
// protocol's own default, which is what a client that sent no `f` expects.
|
|
38
|
+
refuse(response, 'request', '', 'xml', err);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The credentials a request may carry, which never reach the log.
|
|
44
|
+
*
|
|
45
|
+
* `p` is the password plainly or `enc:`-hexed, `t` and `s` are the halves of a
|
|
46
|
+
* salted login, `apiKey` is the OpenSubsonic key. `u` is deliberately not here:
|
|
47
|
+
* it names the listener rather than proving them, and a line that does not say
|
|
48
|
+
* who asked answers half of what this line exists for.
|
|
49
|
+
*
|
|
50
|
+
* Masking four names is what makes the rest of the query writable, and that is
|
|
51
|
+
* the whole point — see `note` below.
|
|
52
|
+
*/
|
|
53
|
+
const SECRET_PARAMETERS = new Set(['p', 't', 's', 'apikey']);
|
|
54
|
+
/**
|
|
55
|
+
* The methods the protocol requires to be reachable without credentials.
|
|
56
|
+
*
|
|
57
|
+
* One, and the specification is emphatic about it: "Unlike all other APIs
|
|
58
|
+
* `getOpenSubsonicExtensions` **must** be publicly accessible". The reason is
|
|
59
|
+
* the order a client works in — it asks what a server supports *before* it
|
|
60
|
+
* hands that server a password, and a server that answered this with a refusal
|
|
61
|
+
* would leave it choosing between guessing and not asking.
|
|
62
|
+
*
|
|
63
|
+
* It costs the rule above nothing. That rule is that a stranger must not read
|
|
64
|
+
* this server's *surface* — must not learn which methods exist by asking for
|
|
65
|
+
* them — and this method is a fixed list of names about the build, which says
|
|
66
|
+
* nothing about the collection and cannot be made to say anything by the
|
|
67
|
+
* request. Everything else still refuses an unauthenticated caller, and
|
|
68
|
+
* everything else still refuses to admit it exists.
|
|
69
|
+
*/
|
|
70
|
+
const PUBLIC = new Set(['getopensubsonicextensions']);
|
|
71
|
+
/**
|
|
72
|
+
* A value a log line can carry without lying about where it ends.
|
|
73
|
+
*
|
|
74
|
+
* Two things a client can do to a decoded value, and both were live before this
|
|
75
|
+
* existed. A newline — `?x=a%0Afunoteka: GET /rest/ping 200 1ms` — writes a
|
|
76
|
+
* *second* line, so a client decides what the log says happened. And an encoded
|
|
77
|
+
* ampersand — `?id=tr:1%26p=hunter2` — forges the parameter that follows it,
|
|
78
|
+
* which is a reader being shown a password that was never sent. Neither leaks a
|
|
79
|
+
* secret; both make the log a thing a stranger has a hand in.
|
|
80
|
+
*
|
|
81
|
+
* So the value is written with control characters and ampersands escaped, and
|
|
82
|
+
* nothing else touched: `tr:109911` and `Кино` stay readable, which is the
|
|
83
|
+
* whole reason the query is written at all.
|
|
84
|
+
*/
|
|
85
|
+
function readable(value) {
|
|
86
|
+
return value.replace(/[\u0000-\u001f\u007f&]/gu, (character) => `%${character.charCodeAt(0).toString(16).padStart(2, '0').toUpperCase()}`);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* How much of a query is written.
|
|
90
|
+
*
|
|
91
|
+
* A log line is not the place for five hundred ids: `savePlayQueue` names every
|
|
92
|
+
* song of the queue and a scrobble of an album names all of it. The first few
|
|
93
|
+
* are what tell a reader what the call was, and the rest is a file that grows
|
|
94
|
+
* without ever answering a question the front of it did not.
|
|
95
|
+
*/
|
|
96
|
+
const LOGGED_QUERY = 240;
|
|
97
|
+
/**
|
|
98
|
+
* What a client asked, for the log line.
|
|
99
|
+
*
|
|
100
|
+
* **The parameters are here because their absence produced a wrong conclusion.**
|
|
101
|
+
* This line used to carry the method, the status and the time and nothing else,
|
|
102
|
+
* on the reasoning that every client spells its password in the query — true,
|
|
103
|
+
* and the reason `SECRET_PARAMETERS` masks four names rather than a reason to
|
|
104
|
+
* write none of them. What that cost: "what does this client actually send?"
|
|
105
|
+
* was unanswerable from the log, and unanswerable is not the same as empty. A
|
|
106
|
+
* slice of the log by method frequency showed no `savePlayQueue` and no
|
|
107
|
+
* `getPlayQueue`, and the conclusion drawn from it was that the operator's
|
|
108
|
+
* client does not use the play queue. It uses both, constantly (task:2863).
|
|
109
|
+
*
|
|
110
|
+
* Exported for its own test, which is about the four names above: a password in
|
|
111
|
+
* a log file is a password in every copy of that file.
|
|
112
|
+
*/
|
|
113
|
+
export function askedOf(url) {
|
|
114
|
+
const at = url.indexOf('?');
|
|
115
|
+
if (at === -1)
|
|
116
|
+
return '';
|
|
117
|
+
// Built up to the budget and no further. `savePlayQueue` names every song of
|
|
118
|
+
// the queue, and escaping five hundred of them to throw 99% of the result away
|
|
119
|
+
// is work a request pays for on its own thread — 353 µs against 3.5 µs for an
|
|
120
|
+
// ordinary call, and none of the difference reaches the file.
|
|
121
|
+
let query = '';
|
|
122
|
+
for (const [name, value] of new URLSearchParams(url.slice(at + 1))) {
|
|
123
|
+
// Lowered on both sides, and that is not tidiness: the protocol spells its
|
|
124
|
+
// parameters in camelCase and a client is free to spell them any way it
|
|
125
|
+
// likes, so `P=`, `ApiKey=` and `apikey=` all reach the same handler
|
|
126
|
+
// (`auth.ts`) and all have to reach the same mask. An exact comparison here
|
|
127
|
+
// wrote `P=hunter2` into the log in full.
|
|
128
|
+
const shown = `${name}=${SECRET_PARAMETERS.has(name.toLowerCase()) ? '<masked>' : readable(value)}`;
|
|
129
|
+
if (query.length + shown.length + 2 > LOGGED_QUERY)
|
|
130
|
+
return `${query}…`;
|
|
131
|
+
query += `${query === '' ? '?' : '&'}${shown}`;
|
|
132
|
+
}
|
|
133
|
+
return query;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The responses whose envelope said `failed`, so that the log can say so too.
|
|
137
|
+
*
|
|
138
|
+
* A refusal is not an HTTP status here — `send` answers 200 whatever happened,
|
|
139
|
+
* because a Subsonic client reads the error out of the body — so a line carrying
|
|
140
|
+
* only the status said nothing about whether the call worked. (The one
|
|
141
|
+
* exception is the method `STATUS_REFUSALS` names, and this mark is on its
|
|
142
|
+
* refusals too: a line can carry a 404 and still say `failed`.) That cost three
|
|
143
|
+
* investigations in one day: a client reporting a server version it could not
|
|
144
|
+
* determine when the truth was a refused key, a client reporting that it could
|
|
145
|
+
* not load an artist beside a log that looked healthy, and a question about
|
|
146
|
+
* whether refusals should be statuses at all (task:2896). One word makes all
|
|
147
|
+
* three readable.
|
|
148
|
+
*
|
|
149
|
+
* A `WeakSet` keyed by the response rather than a header: a header is the
|
|
150
|
+
* client's business and this is the server's, and nothing about the answer
|
|
151
|
+
* should change in order to make it legible.
|
|
152
|
+
*/
|
|
153
|
+
const refusals = new WeakSet();
|
|
154
|
+
/**
|
|
155
|
+
* The line itself, so that what a log says about a request can be read without a
|
|
156
|
+
* log — the same reason `askedOf` is exported for its own test.
|
|
157
|
+
*/
|
|
158
|
+
export function noteLine(line) {
|
|
159
|
+
return (`funoteka: ${line.method} ${line.path}${line.asked} ${line.status}` +
|
|
160
|
+
`${line.failed === true ? ' failed' : ''}${line.aborted === true ? ' aborted' : ''} ` +
|
|
161
|
+
`${line.ms}ms`);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* One line per request, when the operator asked for them.
|
|
165
|
+
*
|
|
166
|
+
* Which method a client called, what it asked with, and how the server answered
|
|
167
|
+
* — the three things "why will this phone not sync" is answered from, and the
|
|
168
|
+
* middle one was missing until it cost an answer (see `askedOf`), and the third
|
|
169
|
+
* was half-missing until it cost three (see `refusals`).
|
|
170
|
+
*
|
|
171
|
+
* **A request that never finished is the fourth silence.** A line is written
|
|
172
|
+
* when the response finishes, so a client that hung up mid-answer left nothing
|
|
173
|
+
* at all — and that is exactly the shape of "it says nought bytes in its offline
|
|
174
|
+
* cache" beside a log full of 200s: the download was cut somewhere, and the
|
|
175
|
+
* server's own record of it did not exist. The bytes may have arrived at the
|
|
176
|
+
* socket and gone nowhere, and this cannot tell those apart; what it can say is
|
|
177
|
+
* that the answer was not written out in full.
|
|
178
|
+
*/
|
|
179
|
+
function note(request, response) {
|
|
180
|
+
const started = Date.now();
|
|
181
|
+
const asked = askedOf(request.url ?? '');
|
|
182
|
+
const write = (ended) => {
|
|
183
|
+
process.stderr.write(`${noteLine({
|
|
184
|
+
method: request.method ?? '?',
|
|
185
|
+
path: request.url?.split('?')[0] ?? '?',
|
|
186
|
+
asked,
|
|
187
|
+
status: response.statusCode,
|
|
188
|
+
...ended,
|
|
189
|
+
ms: Date.now() - started,
|
|
190
|
+
})}\n`);
|
|
191
|
+
};
|
|
192
|
+
response.on('finish', () => write({ failed: refusals.has(response) }));
|
|
193
|
+
// `close` follows `finish` on every answer that was written out, so the guard
|
|
194
|
+
// is what tells the two apart rather than the event.
|
|
195
|
+
response.on('close', () => {
|
|
196
|
+
if (!response.writableFinished)
|
|
197
|
+
write({ aborted: true });
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* One request, from the path to the last byte.
|
|
202
|
+
*
|
|
203
|
+
* Kept async because `stream` is: it hands the response to a file, and the
|
|
204
|
+
* request is not over when the handler returns. Everything here runs before
|
|
205
|
+
* that hand-over, so a refusal is always still a refusal the client can read.
|
|
206
|
+
*/
|
|
207
|
+
async function handle(db, config, request, response) {
|
|
208
|
+
// The base is a placeholder: only the path and the query are ever read, and
|
|
209
|
+
// a request line carries those without a scheme or an authority.
|
|
210
|
+
// Before anything is decided, because the browser decides first: a page that
|
|
211
|
+
// is not allowed to read this answer never gets to send the request that
|
|
212
|
+
// would have asked for it.
|
|
213
|
+
if (config.cors)
|
|
214
|
+
allowCrossOrigin(response);
|
|
215
|
+
const url = new URL(request.url ?? '/', 'http://localhost');
|
|
216
|
+
// Health, and before anything else is decided about this request. It is not
|
|
217
|
+
// the API: it belongs to no method, it carries no credentials, it is answered
|
|
218
|
+
// in no envelope and it negotiates no format — the asker is a supervisor that
|
|
219
|
+
// wants one word about the process, and every rule below is about a client
|
|
220
|
+
// making a call. See `health.ts` for why it is public.
|
|
221
|
+
if (url.pathname === HEALTH_PATH) {
|
|
222
|
+
answerHealth(response, db, request.method);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const method = methodName(url.pathname);
|
|
226
|
+
if (method === null) {
|
|
227
|
+
notFound(response);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (request.method === 'OPTIONS') {
|
|
231
|
+
// A preflight asks what may be done, not for a method to be run. Answering
|
|
232
|
+
// it with an envelope would be answering a question nobody asked — and the
|
|
233
|
+
// browser reads the headers and the empty body, not the payload.
|
|
234
|
+
response.writeHead(204);
|
|
235
|
+
response.end();
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const { query, body } = await parameters(request, url.searchParams);
|
|
239
|
+
const format = parseFormat(query.get('f'));
|
|
240
|
+
// `.view` is the suffix the protocol's own URLs carry and the bare name is
|
|
241
|
+
// what its documentation calls the method. Both are in the wild — clients
|
|
242
|
+
// that predate the shorthand kept using it — and they are one method.
|
|
243
|
+
//
|
|
244
|
+
// `.m3u8` is the same fact with a different tail: the HLS method's URL is
|
|
245
|
+
// `/rest/hls.m3u8`, spelled with a file extension a player recognises rather
|
|
246
|
+
// than with `.view`, and the method it names is `hls`. Left unstripped it is a
|
|
247
|
+
// name nothing matches, and the client that asks for a stream would be told
|
|
248
|
+
// there is no such method — which is exactly what a stub exists to avoid.
|
|
249
|
+
const name = method.replace(/\.view$/, '').replace(/\.m3u8$/, '');
|
|
250
|
+
// Credentials first, and before the method is looked up: a stranger on the
|
|
251
|
+
// network should not be able to read the server's surface by asking for
|
|
252
|
+
// methods and hearing which ones exist. It is also the only place to check
|
|
253
|
+
// them, since a stream never passes through the envelope at all.
|
|
254
|
+
// The one route that hands picture links out also accepts them back without
|
|
255
|
+
// credentials — see `coverSignature`. The name is already lowered here, and
|
|
256
|
+
// this is the only place that decides it.
|
|
257
|
+
// The registered keys are read per request rather than cached at startup: the
|
|
258
|
+
// registry is what a person edits while the server runs, and a key revoked a
|
|
259
|
+
// minute ago must stop working now, not at the next restart. The query is one
|
|
260
|
+
// indexed scan of a table with a handful of rows, and it runs only for a
|
|
261
|
+
// request that presented a key at all.
|
|
262
|
+
const verdict = authenticate(query, config, name.toLowerCase() === 'getcoverart', (given) => keyHolds(db, given));
|
|
263
|
+
if (!verdict.ok && !PUBLIC.has(name.toLowerCase())) {
|
|
264
|
+
send(response, failed(verdict.code, verdict.message), format, name);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const context = {
|
|
268
|
+
db,
|
|
269
|
+
config,
|
|
270
|
+
query,
|
|
271
|
+
body,
|
|
272
|
+
origin: originOf(request, config),
|
|
273
|
+
visibility: visibilityOf(query, config),
|
|
274
|
+
};
|
|
275
|
+
const binary = binaryRoute(name);
|
|
276
|
+
try {
|
|
277
|
+
if (binary !== undefined) {
|
|
278
|
+
await binary(context, request, response);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
send(response, await envelope(name, context), format, name);
|
|
282
|
+
}
|
|
283
|
+
catch (err) {
|
|
284
|
+
refuse(response, method, name, format, err);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* What a page from another origin needs in order to be allowed to call this.
|
|
289
|
+
*
|
|
290
|
+
* The API is a set of endpoints a client app calls, and one of the kinds of
|
|
291
|
+
* client it is meant to be used from is a page: a web player is loaded from its
|
|
292
|
+
* own site and calls the server from there. The browser asks the server first
|
|
293
|
+
* whether that is allowed, and a server that does not answer never hears the
|
|
294
|
+
* request. So `*` — every page may ask, and every page that asks is still
|
|
295
|
+
* refused unless it knows the credentials, which is what the API's own auth is
|
|
296
|
+
* for and is not weakened by this.
|
|
297
|
+
*
|
|
298
|
+
* Two headers are here for reasons that are not obvious. `Range` is allowed
|
|
299
|
+
* because seeking is a range request, and a browser blocks a header it was not
|
|
300
|
+
* told about — a player whose seeking silently fails looks like a broken
|
|
301
|
+
* server. And `Content-Range` and `Accept-Ranges` are *exposed* because
|
|
302
|
+
* JavaScript cannot read a response header it was not granted, and a client
|
|
303
|
+
* that cannot read `Content-Range` cannot tell how long a track is.
|
|
304
|
+
*/
|
|
305
|
+
function allowCrossOrigin(response) {
|
|
306
|
+
response.setHeader('access-control-allow-origin', '*');
|
|
307
|
+
response.setHeader('access-control-allow-methods', 'GET, POST, HEAD, OPTIONS');
|
|
308
|
+
response.setHeader('access-control-allow-headers', 'Content-Type, Range');
|
|
309
|
+
response.setHeader('access-control-expose-headers', 'Content-Range, Accept-Ranges, Content-Length');
|
|
310
|
+
response.setHeader('access-control-max-age', '86400');
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* The parameters, whether the client spelled them in the URL or in the body.
|
|
314
|
+
*
|
|
315
|
+
* The protocol allows both and clients use both: a phone would rather not put a
|
|
316
|
+
* password in a URL, and some clients POST everything. So a POST whose body is
|
|
317
|
+
* form-encoded is read and merged, and the body wins where a name appears in
|
|
318
|
+
* both — it is the half of the request that was written for this call.
|
|
319
|
+
*
|
|
320
|
+
* **A name may appear more than once, and that is not a repeat.** The protocol
|
|
321
|
+
* spells a list as the parameter repeated — `createPlaylist` takes one `songId`
|
|
322
|
+
* per song — and the first version of this merged with `set`, which keeps the
|
|
323
|
+
* last value and drops the rest. Nothing called it with a repeated name until
|
|
324
|
+
* playlists did, so a client asking for a playlist of five songs would have got
|
|
325
|
+
* one song: the last one, with nothing on the wire to say four were lost.
|
|
326
|
+
*
|
|
327
|
+
* Every POST body is read even when it is not form-encoded, and that is not
|
|
328
|
+
* tidiness: a request body nobody reads stays in the socket, and the connection
|
|
329
|
+
* cannot be reused for the next call until it is gone. The cap is the other
|
|
330
|
+
* half of the same thought — a Subsonic request is a handful of short
|
|
331
|
+
* parameters, so a body of a megabyte is not a request this server has any
|
|
332
|
+
* question to ask about.
|
|
333
|
+
*
|
|
334
|
+
* The body is handed back as well as merged, and the reason is the one thing it
|
|
335
|
+
* is not: form encoding is not the only thing a body can be. The `transcoding`
|
|
336
|
+
* extension's `ClientInfo` is nested JSON that a query string has no room for,
|
|
337
|
+
* and a route that expects it parses it from here — see `RouteContext.body`.
|
|
338
|
+
*/
|
|
339
|
+
const MAX_BODY = 1024 * 1024;
|
|
340
|
+
async function parameters(request, fromUrl) {
|
|
341
|
+
if (request.method !== 'POST')
|
|
342
|
+
return { query: fromUrl, body: null };
|
|
343
|
+
const chunks = [];
|
|
344
|
+
let length = 0;
|
|
345
|
+
for await (const chunk of request) {
|
|
346
|
+
const buffer = chunk;
|
|
347
|
+
length += buffer.length;
|
|
348
|
+
if (length > MAX_BODY) {
|
|
349
|
+
request.destroy();
|
|
350
|
+
return { query: fromUrl, body: null };
|
|
351
|
+
}
|
|
352
|
+
chunks.push(buffer);
|
|
353
|
+
}
|
|
354
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
355
|
+
const type = request.headers['content-type'] ?? '';
|
|
356
|
+
if (!type.startsWith('application/x-www-form-urlencoded'))
|
|
357
|
+
return { query: fromUrl, body: text };
|
|
358
|
+
// The body's values for a name replace the URL's *all at once*, and then keep
|
|
359
|
+
// their own order: a parameter the client put in both places is one parameter
|
|
360
|
+
// written twice, while one it repeated in the body is a list.
|
|
361
|
+
const merged = new URLSearchParams(fromUrl);
|
|
362
|
+
const replacedNames = new Set();
|
|
363
|
+
for (const [name, value] of new URLSearchParams(text)) {
|
|
364
|
+
if (!replacedNames.has(name)) {
|
|
365
|
+
merged.delete(name);
|
|
366
|
+
replacedNames.add(name);
|
|
367
|
+
}
|
|
368
|
+
merged.append(name, value);
|
|
369
|
+
}
|
|
370
|
+
return { query: merged, body: text };
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Where the client reached this server, as the client itself spelled it.
|
|
374
|
+
*
|
|
375
|
+
* A deployed server binds `0.0.0.0`, which is not an address anyone can call
|
|
376
|
+
* back, so an answer that has to name the server must use the authority the
|
|
377
|
+
* request arrived with. `Host` is exactly that, and it is what the client
|
|
378
|
+
* already used to get here; a URL built from the config would name a host it
|
|
379
|
+
* never called.
|
|
380
|
+
*
|
|
381
|
+
* The scheme is `http` because it is the only one this server speaks — it
|
|
382
|
+
* terminates no TLS of its own — and the fallback is for an HTTP/1.0 request
|
|
383
|
+
* that carried no `Host` at all, which is a client talking to us directly and
|
|
384
|
+
* would have nowhere else to be answered from.
|
|
385
|
+
*/
|
|
386
|
+
function originOf(request, config) {
|
|
387
|
+
return `http://${request.headers.host ?? `localhost:${config.port}`}`;
|
|
388
|
+
}
|
|
389
|
+
async function envelope(name, context) {
|
|
390
|
+
const handler = route(name);
|
|
391
|
+
if (handler === undefined) {
|
|
392
|
+
// An unknown method is answered as a failed call and not as a missing page.
|
|
393
|
+
// Clients read the error out of the body and would take a 404 as a server
|
|
394
|
+
// too broken to talk to, rather than as a method that does not exist.
|
|
395
|
+
return failed(ERROR.generic, `unknown method: ${name}`);
|
|
396
|
+
}
|
|
397
|
+
// Awaited because a route may answer later — see `Route`. The try around this
|
|
398
|
+
// is the caller's, and it is what keeps a rejected route a refusal a client can
|
|
399
|
+
// read rather than a request that never ends.
|
|
400
|
+
return ok(signedImageLinks(await handler(context), context));
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Every picture link in an answer, signed on its way out.
|
|
404
|
+
*
|
|
405
|
+
* **Here, once, rather than where each link is built.** A URL to a picture is
|
|
406
|
+
* named by a field ending in `ImageUrl`, and there are two builders of one (an
|
|
407
|
+
* artist's, shared by five listings, and `getArtistInfo2`'s three sizes in
|
|
408
|
+
* another module) — a rule that catches every field named that way cannot forget
|
|
409
|
+
* the next one, and threading a secret through six functions to sign four
|
|
410
|
+
* strings is how one of them ends up unsigned.
|
|
411
|
+
*
|
|
412
|
+
* The signature is what lets a client's *image loader* fetch a picture at all:
|
|
413
|
+
* it does not authenticate. Measured on the operator's Symfonium, the artist
|
|
414
|
+
* picture is asked for with no `u`, `t` or `s`, and a server that guards that
|
|
415
|
+
* route like the rest answers a refusal the client can only draw a placeholder
|
|
416
|
+
* over (task:2916).
|
|
417
|
+
*
|
|
418
|
+
* Only our own links are touched: the value has to be on this server's own
|
|
419
|
+
* authority and name `getCoverArt`, so a URL a route built for somewhere else is
|
|
420
|
+
* left exactly as it was.
|
|
421
|
+
*/
|
|
422
|
+
function signedImageLinks(payload, context) {
|
|
423
|
+
const sign = (value, field) => {
|
|
424
|
+
if (typeof value === 'string') {
|
|
425
|
+
if (!field.endsWith('ImageUrl'))
|
|
426
|
+
return value;
|
|
427
|
+
if (!value.startsWith(`${context.origin}${API_PREFIX}getCoverArt`))
|
|
428
|
+
return value;
|
|
429
|
+
const link = new URL(value);
|
|
430
|
+
const id = link.searchParams.get('id');
|
|
431
|
+
if (id === null)
|
|
432
|
+
return value;
|
|
433
|
+
link.searchParams.set('sig', coverSignature(id, context.config));
|
|
434
|
+
return link.toString();
|
|
435
|
+
}
|
|
436
|
+
if (Array.isArray(value))
|
|
437
|
+
return value.map((item) => sign(item, field));
|
|
438
|
+
if (value !== null && typeof value === 'object') {
|
|
439
|
+
return Object.fromEntries(Object.entries(value).map(([name, inner]) => [name, sign(inner, name)]));
|
|
440
|
+
}
|
|
441
|
+
return value;
|
|
442
|
+
};
|
|
443
|
+
return sign(payload, '');
|
|
444
|
+
}
|
|
445
|
+
function refuse(response, method, route, format, err) {
|
|
446
|
+
// A stream that already wrote a header owes the client bytes, not an
|
|
447
|
+
// envelope, and there is no way to take a status back. The connection is the
|
|
448
|
+
// only thing left to end.
|
|
449
|
+
if (response.headersSent) {
|
|
450
|
+
response.destroy();
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
// A refusal is an envelope, and an envelope is not a file. `download` sets a
|
|
454
|
+
// name for what it is about to send *before* it knows it can send it — the
|
|
455
|
+
// header has to be in place before `serveBytes` writes the head — so a refusal
|
|
456
|
+
// that arrives afterwards would carry `attachment; filename="…m4a"` beside
|
|
457
|
+
// "this cannot be produced without re-encoding", and a client that trusts the
|
|
458
|
+
// 200 saves the error under the song's name (task:2864).
|
|
459
|
+
response.removeHeader('content-disposition');
|
|
460
|
+
if (err instanceof ApiError) {
|
|
461
|
+
send(response, failed(err.code, err.message), format, route);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
// A client that hung up is not a fault of this server and is not worth a line
|
|
465
|
+
// on stderr: an aborted request is what a phone losing signal looks like, and
|
|
466
|
+
// reporting it as an internal error would train whoever reads the log to
|
|
467
|
+
// ignore the one message that matters.
|
|
468
|
+
if (err !== null && typeof err === 'object' && 'code' in err && isDisconnect(err)) {
|
|
469
|
+
response.destroy();
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
// The other writer held the file for longer than this server is willing to
|
|
473
|
+
// wait — the stages of a scan, in practice, which hold it for seconds at a
|
|
474
|
+
// time (task:2880).
|
|
475
|
+
//
|
|
476
|
+
// It is not a fault of this server: the meta layer has two writers by design,
|
|
477
|
+
// and the answer can say what happened rather than reading "Internal error",
|
|
478
|
+
// which would send whoever reads it looking for a bug in the API.
|
|
479
|
+
//
|
|
480
|
+
// **The answer says the write did not happen, and it says so on purpose**
|
|
481
|
+
// (task:2884). The protocol has no code for "busy", so this is a generic
|
|
482
|
+
// failure; a generic failure a client is told only to retry is one it may take
|
|
483
|
+
// as having gone through — and a Subsonic client does not retry on its own.
|
|
484
|
+
// Measured on the live daemon: 4 saves in 100 were refused during a scan, and
|
|
485
|
+
// every one of them was simply lost. This message is the only place that loss
|
|
486
|
+
// can be stated, so it states it.
|
|
487
|
+
//
|
|
488
|
+
// **The alternative was considered and rejected.** The other way out is to let
|
|
489
|
+
// the daemon wait longer, and the wait here is synchronous — it blocks the one
|
|
490
|
+
// thread that answers everybody. Measured: `ping` came back after 5133 ms
|
|
491
|
+
// while a single request waited on the lock. Shortening what holds it is the
|
|
492
|
+
// real answer and is task:2880; until that lands, this is what the client is
|
|
493
|
+
// owed.
|
|
494
|
+
if (err instanceof Error && err.message.includes('database is locked')) {
|
|
495
|
+
process.stderr.write(`funoteka: ${method} found the database locked\n`);
|
|
496
|
+
send(response, failed(ERROR.generic, 'The library is being written by another process — nothing was saved; try again'), format, route);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
// Anything else is a fault in this server rather than a request it can
|
|
500
|
+
// refuse, and a throw that escaped a handler would take the whole process
|
|
501
|
+
// down with it. It is said on stderr for whoever runs the server, and
|
|
502
|
+
// answered vaguely, because a stranger is owed no account of our internals.
|
|
503
|
+
process.stderr.write(`funoteka: ${method} failed: ${String(err)}\n`);
|
|
504
|
+
send(response, failed(ERROR.generic, 'Internal error'), format, route);
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* The ways a client goes away mid-request, which are not this server's failures.
|
|
508
|
+
*
|
|
509
|
+
* `ECONNRESET` is the socket dying under a read, `ERR_STREAM_PREMATURE_CLOSE`
|
|
510
|
+
* is a body that stopped arriving before its `Content-Length` promised, and
|
|
511
|
+
* `ECANCELED` is Node's own name for the same thing on a destroyed request.
|
|
512
|
+
*/
|
|
513
|
+
function isDisconnect(err) {
|
|
514
|
+
const code = err.code ?? '';
|
|
515
|
+
return code === 'ECONNRESET' || code === 'ERR_STREAM_PREMATURE_CLOSE' || code === 'ECANCELED';
|
|
516
|
+
}
|
|
517
|
+
/** Where methods live. Everything outside it is not the API and is not answered as one. */
|
|
518
|
+
const API_PREFIX = '/rest/';
|
|
519
|
+
/** The one path outside the API this server answers, and it is not a method. */
|
|
520
|
+
const HEALTH_PATH = '/health';
|
|
521
|
+
/**
|
|
522
|
+
* Answer a liveness probe.
|
|
523
|
+
*
|
|
524
|
+
* `no-store`, because a cached "healthy" is the one answer a health check must
|
|
525
|
+
* never be given: a supervisor asking about a server that has been wedged for an
|
|
526
|
+
* hour must not be handed the answer a proxy wrote down when it was working.
|
|
527
|
+
*
|
|
528
|
+
* HEAD is answered as HEAD — the same status and headers with no body — because
|
|
529
|
+
* that is what `wget --spider` sends, and a probe that cannot ask is a probe that
|
|
530
|
+
* gets written off as a broken server.
|
|
531
|
+
*/
|
|
532
|
+
function answerHealth(response, db, method) {
|
|
533
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
534
|
+
const body = 'method not allowed\n';
|
|
535
|
+
response.writeHead(405, {
|
|
536
|
+
'content-type': 'text/plain; charset=utf-8',
|
|
537
|
+
'content-length': Buffer.byteLength(body),
|
|
538
|
+
allow: 'GET, HEAD',
|
|
539
|
+
});
|
|
540
|
+
response.end(body);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
const answer = health(db);
|
|
544
|
+
response.writeHead(answer.status, {
|
|
545
|
+
'content-type': 'application/json; charset=utf-8',
|
|
546
|
+
'content-length': Buffer.byteLength(answer.body),
|
|
547
|
+
'cache-control': 'no-store',
|
|
548
|
+
});
|
|
549
|
+
response.end(method === 'HEAD' ? undefined : answer.body);
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* The method name the client asked for, decoded — or null if it did not ask the
|
|
553
|
+
* API at all.
|
|
554
|
+
*
|
|
555
|
+
* Decoding is what makes the name comparable to the protocol's own spellings and
|
|
556
|
+
* makes the error faithful when it is not one of them. A path that is not valid
|
|
557
|
+
* encoding has no decoded form to prefer, so it is taken as it came: refusing
|
|
558
|
+
* the request would hide which name the client got wrong.
|
|
559
|
+
*/
|
|
560
|
+
function methodName(pathname) {
|
|
561
|
+
if (!pathname.startsWith(API_PREFIX))
|
|
562
|
+
return null;
|
|
563
|
+
const raw = pathname.slice(API_PREFIX.length);
|
|
564
|
+
try {
|
|
565
|
+
return decodeURIComponent(raw);
|
|
566
|
+
}
|
|
567
|
+
catch {
|
|
568
|
+
return raw;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* The methods whose refusals carry an HTTP status instead of the usual 200.
|
|
573
|
+
*
|
|
574
|
+
* **One method, and deliberately one.** `send` answers 200 for every answer
|
|
575
|
+
* including a refusal, because a Subsonic client reads the error out of the
|
|
576
|
+
* body, and a client that had to handle transport failures as well would have
|
|
577
|
+
* two ways to be told the same thing. Every route keeps that convention, the
|
|
578
|
+
* stubbed byte routes included, and the operator has seen it and kept it.
|
|
579
|
+
*
|
|
580
|
+
* `getTranscodeStream` is the exception its own page asks for: "In case of an
|
|
581
|
+
* error, a standard HTTP error code is returned with a descriptive message",
|
|
582
|
+
* with 400, 401, 404 and 500 declared beside it in the OpenAPI document.
|
|
583
|
+
* Measured before this: all three ways that route can refuse — an id that names
|
|
584
|
+
* no song, a token issued for another song, and a token this server never issued
|
|
585
|
+
* — came back a 200. A client written against that document branches on the
|
|
586
|
+
* status line, read every refusal as success, and would go on to play the
|
|
587
|
+
* envelope as audio (task:2913).
|
|
588
|
+
*
|
|
589
|
+
* The body is the envelope either way. The status is *added*, not substituted:
|
|
590
|
+
* the code and the message a Subsonic client reads are still there.
|
|
591
|
+
*/
|
|
592
|
+
const STATUS_REFUSALS = new Set(['gettranscodestream']);
|
|
593
|
+
/** What the status line says for each kind of refusal, from the OpenAPI document. */
|
|
594
|
+
const STATUS_OF_ERROR = new Map([
|
|
595
|
+
[ERROR.missingParameter, 400],
|
|
596
|
+
[ERROR.wrongCredentials, 401],
|
|
597
|
+
[ERROR.tokenAuthRefused, 401],
|
|
598
|
+
[ERROR.unsupportedAuthMechanism, 401],
|
|
599
|
+
[ERROR.conflictingAuthMechanisms, 401],
|
|
600
|
+
[ERROR.invalidApiKey, 401],
|
|
601
|
+
[ERROR.notAuthorized, 401],
|
|
602
|
+
[ERROR.notFound, 404],
|
|
603
|
+
]);
|
|
604
|
+
/**
|
|
605
|
+
* The status line for one answer, given the method it answers.
|
|
606
|
+
*
|
|
607
|
+
* Exported for its own test, the same reason `noteLine` and `askedOf` are: what
|
|
608
|
+
* a status *is* should be readable without a server. Everything but the one
|
|
609
|
+
* method above answers 200, whatever happened — see `send`.
|
|
610
|
+
*/
|
|
611
|
+
export function statusFor(method, envelope) {
|
|
612
|
+
if (envelope.status !== 'failed')
|
|
613
|
+
return 200;
|
|
614
|
+
if (!STATUS_REFUSALS.has(method.toLowerCase()))
|
|
615
|
+
return 200;
|
|
616
|
+
const { code } = (envelope.error ?? {});
|
|
617
|
+
// Unmapped is a fault rather than something a client can act on, and 500 is
|
|
618
|
+
// both what the document declares for it and what it is.
|
|
619
|
+
return code === undefined ? 500 : (STATUS_OF_ERROR.get(code) ?? 500);
|
|
620
|
+
}
|
|
621
|
+
function send(response, envelope, format, method = '') {
|
|
622
|
+
const { contentType, body } = render(envelope, format);
|
|
623
|
+
// Kept for the log line, which is written when the response finishes. Every
|
|
624
|
+
// refusal goes through here — a route's, an unknown method's, a credential's —
|
|
625
|
+
// so one mark covers all of them.
|
|
626
|
+
if (envelope.status === 'failed')
|
|
627
|
+
refusals.add(response);
|
|
628
|
+
// 200 for every answer, including a refusal — except the methods
|
|
629
|
+
// `STATUS_REFUSALS` names, whose own specification asks for a status.
|
|
630
|
+
response.writeHead(statusFor(method, envelope), {
|
|
631
|
+
'content-type': contentType,
|
|
632
|
+
'content-length': Buffer.byteLength(body),
|
|
633
|
+
});
|
|
634
|
+
response.end(body);
|
|
635
|
+
}
|
|
636
|
+
function notFound(response) {
|
|
637
|
+
const body = 'not found\n';
|
|
638
|
+
response.writeHead(404, {
|
|
639
|
+
'content-type': 'text/plain; charset=utf-8',
|
|
640
|
+
'content-length': Buffer.byteLength(body),
|
|
641
|
+
});
|
|
642
|
+
response.end(body);
|
|
643
|
+
}
|