@hoardodile/host 0.0.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/LICENSE +18 -0
- package/README.md +73 -0
- package/dist/chunks/worker-entry.mjs +422 -0
- package/dist/container-fKxefO7p.d.ts +58 -0
- package/dist/contract/index.d.ts +49 -0
- package/dist/contract/index.js +187 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/hoard/index.d.ts +754 -0
- package/dist/hoard/index.js +1939 -0
- package/dist/hoard/index.js.map +1 -0
- package/dist/image-Dx2Be3Bj.d.ts +168 -0
- package/dist/index.d.ts +788 -0
- package/dist/index.js +3311 -0
- package/dist/index.js.map +1 -0
- package/dist/media/index.d.ts +96 -0
- package/dist/media/index.js +152 -0
- package/dist/media/index.js.map +1 -0
- package/dist/probe/index.d.ts +113 -0
- package/dist/probe/index.js +454 -0
- package/dist/probe/index.js.map +1 -0
- package/dist/probe-cache-BkRI7HEK.d.ts +25 -0
- package/dist/render/index.d.ts +125 -0
- package/dist/render/index.js +430 -0
- package/dist/render/index.js.map +1 -0
- package/package.json +103 -0
- package/src/__testutils__/zip-fixtures.ts +139 -0
- package/src/activation.ts +116 -0
- package/src/api-types.ts +60 -0
- package/src/api.test.ts +564 -0
- package/src/api.ts +501 -0
- package/src/archive/7z.test.ts +222 -0
- package/src/archive/7z.ts +261 -0
- package/src/archive/archive-ops.test.ts +654 -0
- package/src/archive/extract-archive.test.ts +237 -0
- package/src/archive/extract-archive.ts +534 -0
- package/src/archive/extract.ts +693 -0
- package/src/archive/format.ts +49 -0
- package/src/archive/index.ts +116 -0
- package/src/archive/listing.ts +121 -0
- package/src/archive/materialize.ts +88 -0
- package/src/archive/name-decode.test.ts +34 -0
- package/src/archive/name-decode.ts +287 -0
- package/src/archive/nested-cd-cache.ts +55 -0
- package/src/archive/nested-entry.test.ts +194 -0
- package/src/archive/nested-entry.ts +254 -0
- package/src/archive/pack.ts +40 -0
- package/src/archive/zip-entries.test.ts +56 -0
- package/src/archive/zip-entries.ts +336 -0
- package/src/capability-guard.ts +73 -0
- package/src/container.ts +62 -0
- package/src/contract/container-fixture.ts +74 -0
- package/src/contract/index.ts +10 -0
- package/src/contract/suite.test.ts +101 -0
- package/src/contract/suite.ts +205 -0
- package/src/define-plugin.test.ts +53 -0
- package/src/define-plugin.ts +12 -0
- package/src/dev-runner.test.ts +98 -0
- package/src/dev-runner.ts +73 -0
- package/src/directory-api.test.ts +229 -0
- package/src/directory-api.ts +42 -0
- package/src/directory-container.ts +141 -0
- package/src/discovery.ts +295 -0
- package/src/errors.ts +74 -0
- package/src/fixtures.ts +8 -0
- package/src/hash.test.ts +301 -0
- package/src/hash.ts +167 -0
- package/src/hoard/dir-size.ts +58 -0
- package/src/hoard/index.ts +84 -0
- package/src/hoard/migrate-script.test.ts +539 -0
- package/src/hoard/order-manifest.test.ts +115 -0
- package/src/hoard/order-manifest.ts +131 -0
- package/src/hoard/paths.test.ts +226 -0
- package/src/hoard/paths.ts +569 -0
- package/src/hoard/plugin-vault.test.ts +160 -0
- package/src/hoard/plugin-vault.ts +233 -0
- package/src/hoard/sanitize.test.ts +149 -0
- package/src/hoard/sanitize.ts +230 -0
- package/src/hoard/staging-dir.ts +133 -0
- package/src/hoard/version.test.ts +285 -0
- package/src/hoard/version.ts +216 -0
- package/src/hoard/versioned-folder-ops.ts +152 -0
- package/src/hoard/write-versioned.test.ts +67 -0
- package/src/hoard/write-versioned.ts +39 -0
- package/src/hooks.test.ts +528 -0
- package/src/hooks.ts +428 -0
- package/src/index.ts +135 -0
- package/src/loader.test.ts +442 -0
- package/src/loader.ts +213 -0
- package/src/media/index.ts +16 -0
- package/src/media/render-cache.ts +27 -0
- package/src/media/seekable.ts +35 -0
- package/src/media/thumb-input.test.ts +109 -0
- package/src/media/thumb-input.ts +194 -0
- package/src/nested-view.test.ts +223 -0
- package/src/nested-view.ts +239 -0
- package/src/probe/audio.test.ts +135 -0
- package/src/probe/audio.ts +140 -0
- package/src/probe/av.ts +84 -0
- package/src/probe/ffprobe.ts +197 -0
- package/src/probe/image.test.ts +251 -0
- package/src/probe/image.ts +336 -0
- package/src/probe/index.ts +54 -0
- package/src/probe/sniff.test.ts +72 -0
- package/src/probe/sniff.ts +67 -0
- package/src/probe/video.test.ts +41 -0
- package/src/probe/video.ts +116 -0
- package/src/probe-cache.test.ts +89 -0
- package/src/probe-cache.ts +65 -0
- package/src/render/ffmpeg.ts +78 -0
- package/src/render/index.ts +34 -0
- package/src/render/pipeline.test.ts +187 -0
- package/src/render/pipeline.ts +479 -0
- package/src/sandbox/call-timers.ts +68 -0
- package/src/sandbox/fixtures/api-error-plugin.mjs +11 -0
- package/src/sandbox/fixtures/asset-plugin.mjs +13 -0
- package/src/sandbox/fixtures/chatty-plugin.mjs +14 -0
- package/src/sandbox/fixtures/container-plugin.mjs +8 -0
- package/src/sandbox/fixtures/context-plugin.mjs +19 -0
- package/src/sandbox/fixtures/crash-plugin.mjs +6 -0
- package/src/sandbox/fixtures/echo-plugin.mjs +17 -0
- package/src/sandbox/fixtures/exit-plugin.mjs +6 -0
- package/src/sandbox/fixtures/fetch-plugin.mjs +8 -0
- package/src/sandbox/fixtures/flood-api-plugin.mjs +8 -0
- package/src/sandbox/fixtures/flood-log-plugin.mjs +8 -0
- package/src/sandbox/fixtures/hostile-dynamic-plugin.mjs +9 -0
- package/src/sandbox/fixtures/hostile-plugin.mjs +13 -0
- package/src/sandbox/fixtures/huge-result-plugin.mjs +8 -0
- package/src/sandbox/fixtures/logging-plugin.mjs +9 -0
- package/src/sandbox/fixtures/range-plugin.mjs +9 -0
- package/src/sandbox/fixtures/slow-api-plugin.mjs +11 -0
- package/src/sandbox/fixtures/spin-plugin.mjs +9 -0
- package/src/sandbox/fixtures/stuck-plugin.mjs +12 -0
- package/src/sandbox/fixtures/thrower-plugin.mjs +7 -0
- package/src/sandbox/host.test.ts +619 -0
- package/src/sandbox/host.ts +933 -0
- package/src/sandbox/host.unit.test.ts +330 -0
- package/src/sandbox/protocol.test.ts +85 -0
- package/src/sandbox/protocol.ts +147 -0
- package/src/sandbox/sandboxed-plugin.ts +30 -0
- package/src/sandbox/worker-entry.mjs +422 -0
- package/src/seed.test.ts +102 -0
- package/src/seed.ts +160 -0
- package/src/settings-store.ts +26 -0
- package/src/types.ts +21 -0
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import { isAbsolute, resolve, sep } from "node:path"
|
|
3
|
+
import {
|
|
4
|
+
currentVersion as diskCurrentVersion,
|
|
5
|
+
readActiveVersion,
|
|
6
|
+
} from "./version.ts"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Windows reserved base names (case-insensitive). These must not appear as
|
|
10
|
+
* the base of any filename we create, regardless of extension, or CreateFile
|
|
11
|
+
* will fail with bizarre errors. The set is strict on Windows and advisory
|
|
12
|
+
* on other platforms.
|
|
13
|
+
*
|
|
14
|
+
* @see https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file
|
|
15
|
+
*/
|
|
16
|
+
const WINDOWS_RESERVED = new Set([
|
|
17
|
+
"CON",
|
|
18
|
+
"PRN",
|
|
19
|
+
"AUX",
|
|
20
|
+
"NUL",
|
|
21
|
+
"COM1",
|
|
22
|
+
"COM2",
|
|
23
|
+
"COM3",
|
|
24
|
+
"COM4",
|
|
25
|
+
"COM5",
|
|
26
|
+
"COM6",
|
|
27
|
+
"COM7",
|
|
28
|
+
"COM8",
|
|
29
|
+
"COM9",
|
|
30
|
+
"LPT1",
|
|
31
|
+
"LPT2",
|
|
32
|
+
"LPT3",
|
|
33
|
+
"LPT4",
|
|
34
|
+
"LPT5",
|
|
35
|
+
"LPT6",
|
|
36
|
+
"LPT7",
|
|
37
|
+
"LPT8",
|
|
38
|
+
"LPT9",
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Name of the content subfolder inside a resource folder. User entries
|
|
43
|
+
* live under `resources/<id>/data/`; metadata dotfiles (`.cover.*`,
|
|
44
|
+
* `.deleted`, `.order`) stay at the resource root. Content and metadata
|
|
45
|
+
* are replaced/kept independently: commits swap the whole `data/`
|
|
46
|
+
* subtree, while root dotfiles survive re-uploads.
|
|
47
|
+
*/
|
|
48
|
+
export const RESOURCE_DATA_DIR_NAME = "data"
|
|
49
|
+
|
|
50
|
+
const FORBIDDEN_VISIBLE_CHARS = '<>:"|?*'
|
|
51
|
+
|
|
52
|
+
function hasControlChar(segment: string): boolean {
|
|
53
|
+
for (let i = 0; i < segment.length; i++) {
|
|
54
|
+
if (segment.charCodeAt(i) < 32) return true
|
|
55
|
+
}
|
|
56
|
+
return false
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function hasForbiddenVisibleChar(segment: string): boolean {
|
|
60
|
+
for (let i = 0; i < segment.length; i++) {
|
|
61
|
+
const ch = segment[i]
|
|
62
|
+
if (ch !== undefined && FORBIDDEN_VISIBLE_CHARS.includes(ch)) return true
|
|
63
|
+
}
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Root-level subdirectory semantics:
|
|
69
|
+
* - `versions/<version>/` is the user's manual-sync scope, partitioned by
|
|
70
|
+
* archive version. Each version folder holds: `app.sqlite` (the
|
|
71
|
+
* per-version database snapshot), `db-backups/` (manual backups,
|
|
72
|
+
* only kept for the current version), `snapshots/` (automatic daily
|
|
73
|
+
* snapshots, only kept for the current version), `resources/<id>/`,
|
|
74
|
+
* `characters/<id>/`, `plugins/<id>/` (installed content plugins
|
|
75
|
+
* frozen with that version; the builtin `file` plugin is not stored
|
|
76
|
+
* here). Old versions are FROZEN: no writes ever land in
|
|
77
|
+
* `versions/<v>` once a `versions/<v+1>` exists.
|
|
78
|
+
* - `local` holds host-only state. Derived caches (thumbs, previews,
|
|
79
|
+
* extraction caches, tmp, read-only DB clones for past-version
|
|
80
|
+
* viewing) live under `local/cache/` and are wiped wholesale by
|
|
81
|
+
* clear cache; the rest (`logs`, `trash`, session key, upload
|
|
82
|
+
* staging) is persistent. It never leaves the host.
|
|
83
|
+
*
|
|
84
|
+
* `paths.active` resolves to the **active** version (which may be the
|
|
85
|
+
* latest version, or a past version when the user is viewing
|
|
86
|
+
* read-only). `paths.latest` always points at the latest version --
|
|
87
|
+
* the only version writers may target. `paths.atVersion(v)` exposes
|
|
88
|
+
* arbitrary cross-version reads (used for character avatar/fullbody
|
|
89
|
+
* fallback once `avatarVersion` / `fullbodyVersion` columns point at a
|
|
90
|
+
* historical archive).
|
|
91
|
+
*
|
|
92
|
+
* Every resolved path MUST come out of this module so the frozen-version
|
|
93
|
+
* boundary stays enforceable.
|
|
94
|
+
*/
|
|
95
|
+
export type StoragePaths = {
|
|
96
|
+
readonly root: string
|
|
97
|
+
readonly activeVersion: number
|
|
98
|
+
readonly latestVersion: number
|
|
99
|
+
readonly active: VersionPaths
|
|
100
|
+
readonly latest: VersionPaths
|
|
101
|
+
readonly local: LocalPaths
|
|
102
|
+
/** Per-version archive paths. Use for cross-version fallback reads. */
|
|
103
|
+
atVersion(v: number): VersionPaths
|
|
104
|
+
/**
|
|
105
|
+
* Path to the live runtime SQLite DB: `<root>/app.sqlite`.
|
|
106
|
+
* This file is the only writable database during normal operation.
|
|
107
|
+
* It lives outside `versions/` so that syncing `versions/` to other
|
|
108
|
+
* devices cannot corrupt the in-use database. Only archived snapshots
|
|
109
|
+
* (written by {@link createNextVersion}) and backup files belong in
|
|
110
|
+
* `versions/`.
|
|
111
|
+
*/
|
|
112
|
+
runtimeDb(): string
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type VersionPaths = {
|
|
116
|
+
readonly root: string
|
|
117
|
+
readonly version: number
|
|
118
|
+
/** Path to the per-version SQLite DB: `<root>/versions/<v>/app.sqlite`. */
|
|
119
|
+
versionSnapshotDb(): string
|
|
120
|
+
/** Root folder of a resource: `<root>/versions/<v>/resources/<id>`. */
|
|
121
|
+
resource(id: string): string
|
|
122
|
+
/**
|
|
123
|
+
* Content root of a resource — where user entries live:
|
|
124
|
+
* `<root>/versions/<v>/resources/<id>/data`. The container reads
|
|
125
|
+
* entries from here; the resource root only holds metadata dotfiles.
|
|
126
|
+
*/
|
|
127
|
+
resourceData(id: string): string
|
|
128
|
+
/** Root folder of all resources in this version: `<root>/versions/<v>/resources`. */
|
|
129
|
+
resources(): string
|
|
130
|
+
/** Root folder of all characters in this version: `<root>/versions/<v>/characters`. */
|
|
131
|
+
characters(): string
|
|
132
|
+
/** Root folder of all documents in this version: `<root>/versions/<v>/documents`. */
|
|
133
|
+
documents(): string
|
|
134
|
+
/** Root folder of a character: `<root>/versions/<v>/characters/<id>`. */
|
|
135
|
+
character(id: string): string
|
|
136
|
+
/** Root of manual backups: `<root>/versions/<v>/db-backups`. */
|
|
137
|
+
dbBackups(): string
|
|
138
|
+
/** Path to one manual backup: `<root>/versions/<v>/db-backups/<name>`. */
|
|
139
|
+
dbBackup(name: string): string
|
|
140
|
+
/** Root of automatic snapshots: `<root>/versions/<v>/snapshots`. */
|
|
141
|
+
snapshots(): string
|
|
142
|
+
/** Path to one automatic snapshot: `<root>/versions/<v>/snapshots/<name>`. */
|
|
143
|
+
snapshot(name: string): string
|
|
144
|
+
/**
|
|
145
|
+
* Path to a deleted-entity placeholder
|
|
146
|
+
* (`<root>/versions/<v>/<kind>/<id>/.deleted`) written when hard
|
|
147
|
+
* delete cannot remove a folder whose files live under frozen past
|
|
148
|
+
* archives.
|
|
149
|
+
*/
|
|
150
|
+
deletedMarker(kind: "resources" | "characters", id: string): string
|
|
151
|
+
/** Root folder of a document: `<root>/versions/<v>/documents/<id>`. */
|
|
152
|
+
document(id: string): string
|
|
153
|
+
/**
|
|
154
|
+
* Installed content plugins for this version:
|
|
155
|
+
* `<root>/versions/<v>/plugins`. Each subdirectory is named by
|
|
156
|
+
* `manifest.id`. The builtin `file` plugin is not stored here.
|
|
157
|
+
*/
|
|
158
|
+
plugins(): string
|
|
159
|
+
/**
|
|
160
|
+
* Host-managed plugin asset vault of an installed plugin:
|
|
161
|
+
* `<root>/versions/<v>/plugins/<id>/vault`. Downloads land here
|
|
162
|
+
* (user-consented, vault-confined); the directory is host-owned,
|
|
163
|
+
* reserved against plugin zips (upload rejects a shipped `vault/`),
|
|
164
|
+
* and its lifecycle follows the plugin's own directory.
|
|
165
|
+
*/
|
|
166
|
+
pluginVaultDir(id: string): string
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export type LocalPaths = {
|
|
170
|
+
/** The local (non-synced) root: `<root>/local`. */
|
|
171
|
+
readonly root: string
|
|
172
|
+
/** Derived cache root: `<localRoot>/cache`. */
|
|
173
|
+
cache(): string
|
|
174
|
+
/** Server logs: `<localRoot>/logs`. */
|
|
175
|
+
logs(): string
|
|
176
|
+
/**
|
|
177
|
+
* Path to a local derived cover/thumb variant:
|
|
178
|
+
* `<localRoot>/cache/<resources|characters>/<id>/<variant>.<format>`.
|
|
179
|
+
* Holds synthesized covers (resource covers, character avatars and
|
|
180
|
+
* fullbody images); re-rendered when cleared.
|
|
181
|
+
*/
|
|
182
|
+
localCover(
|
|
183
|
+
subjectKind: "resource" | "character",
|
|
184
|
+
id: string,
|
|
185
|
+
variant: string,
|
|
186
|
+
format?: string,
|
|
187
|
+
): string
|
|
188
|
+
/**
|
|
189
|
+
* Per-file derived image variant:
|
|
190
|
+
* `<localRoot>/cache/resources/<id>/file-preview/<sourceCacheId>__<variantKey>.<format>`.
|
|
191
|
+
* The source identity (see {@link sourceCacheId}) is collision-free
|
|
192
|
+
* across nested paths; the variant key (see {@link imageVariantKey})
|
|
193
|
+
* encodes the render spec, so distinct specs never collide on one
|
|
194
|
+
* cache file.
|
|
195
|
+
*/
|
|
196
|
+
resFileVariant(
|
|
197
|
+
id: string,
|
|
198
|
+
filename: string,
|
|
199
|
+
variantKey: string,
|
|
200
|
+
format?: string,
|
|
201
|
+
): string
|
|
202
|
+
/** Directory holding a resource's per-file variants. */
|
|
203
|
+
resFilePreviewDir(id: string): string
|
|
204
|
+
/** Resource file-list sidecar: `<localRoot>/cache/resources/<id>/files-cache.json`. */
|
|
205
|
+
resFilesCache(id: string): string
|
|
206
|
+
/**
|
|
207
|
+
* Root of the local per-resource directory:
|
|
208
|
+
* `<localRoot>/cache/resources/<id>`.
|
|
209
|
+
*/
|
|
210
|
+
resource(id: string): string
|
|
211
|
+
/**
|
|
212
|
+
* Root of the local per-character directory:
|
|
213
|
+
* `<localRoot>/cache/characters/<id>`.
|
|
214
|
+
* Holds (a) versioned copies of replaced avatar / fullbody images and
|
|
215
|
+
* (b) thumbnail variants (`avatar.webp`, `fullbody.webp`).
|
|
216
|
+
*/
|
|
217
|
+
character(id: string): string
|
|
218
|
+
/** Root of the trash: `<localRoot>/trash`. */
|
|
219
|
+
trash(): string
|
|
220
|
+
/** Path to a single trashed item: `<localRoot>/trash/<id>`. */
|
|
221
|
+
trashItem(id: string): string
|
|
222
|
+
/**
|
|
223
|
+
* Root of the local temp directory: `<localRoot>/cache/tmp`. Holds
|
|
224
|
+
* short-lived working files (upload buffers, backup markers, read-only
|
|
225
|
+
* `view-<v>.sqlite` clones). Lives under the cache root so clear cache
|
|
226
|
+
* wipes it together with the other derived data; it is also cleaned on
|
|
227
|
+
* server startup.
|
|
228
|
+
*/
|
|
229
|
+
tmp(): string
|
|
230
|
+
/** Path to a single temp file: `<localRoot>/cache/tmp/<name>`. */
|
|
231
|
+
tmpFile(name: string): string
|
|
232
|
+
/**
|
|
233
|
+
* Path to the iron-session seal key file: `<root>/local/.session-key`.
|
|
234
|
+
* 32-byte base64-encoded secret, auto-generated on first boot. Lives in
|
|
235
|
+
* `local/` (never synced) so each host has its own seal key.
|
|
236
|
+
*/
|
|
237
|
+
sessionKey(): string
|
|
238
|
+
/**
|
|
239
|
+
* Root of the host-only temporary directory tree:
|
|
240
|
+
* `<localRoot>/.tmp`. Holds the global staging pool
|
|
241
|
+
* ({@link stagingPoolRoot}) plus short-lived extraction directories
|
|
242
|
+
* (`extract-*`). The leading dot keeps host-only state out of any
|
|
243
|
+
* user-facing listing. Cleared on server startup together with
|
|
244
|
+
* {@link LocalPaths.tmp}.
|
|
245
|
+
*/
|
|
246
|
+
uploadStagingRoot(): string
|
|
247
|
+
/**
|
|
248
|
+
* Root of the global staging pool: `<localRoot>/.tmp/staging`.
|
|
249
|
+
* Every file uploaded through the per-file upload endpoint lands
|
|
250
|
+
* here as `<fileId><ext>` and is addressed by its `fileId` alone —
|
|
251
|
+
* there is no per-batch grouping. Files are removed individually
|
|
252
|
+
* on client delete or consumed (and deleted) by commit at resource
|
|
253
|
+
* creation. Cleared on startup together with
|
|
254
|
+
* {@link LocalPaths.uploadStagingRoot}.
|
|
255
|
+
*/
|
|
256
|
+
stagingPoolRoot(): string
|
|
257
|
+
/**
|
|
258
|
+
* Path of a single staged file in the global pool:
|
|
259
|
+
* `<stagingPoolRoot>/<fileId><ext>`. `ext` is the lower-cased
|
|
260
|
+
* extension of the original filename (empty for extensionless
|
|
261
|
+
* uploads).
|
|
262
|
+
*/
|
|
263
|
+
stagingPoolFile(fileId: string, ext: string): string
|
|
264
|
+
/**
|
|
265
|
+
* Per-video-frame thumbnail cache:
|
|
266
|
+
* `<localRoot>/cache/resources/<id>/frames/<sourceCacheId>/<timeMs>.avif`.
|
|
267
|
+
* Synthesised on-demand by the video hover preview endpoint.
|
|
268
|
+
*/
|
|
269
|
+
resVideoFrame(id: string, filename: string, timeMs: number): string
|
|
270
|
+
/**
|
|
271
|
+
* Root of persisted zip-entry extractions for a resource version:
|
|
272
|
+
* `<localRoot>/cache/resources/<id>/extracted/v<fileVersion>/`.
|
|
273
|
+
*/
|
|
274
|
+
resExtractedDir(id: string, fileVersion: number): string
|
|
275
|
+
/**
|
|
276
|
+
* On-disk path for a materialized zip entry used by probe/ffmpeg
|
|
277
|
+
* paths: `<localRoot>/cache/resources/<id>/extracted/v<v>/<sourceCacheId>`.
|
|
278
|
+
*/
|
|
279
|
+
resExtractedEntry(id: string, fileVersion: number, entryName: string): string
|
|
280
|
+
/**
|
|
281
|
+
* Plugin container extraction root for a resource version:
|
|
282
|
+
* `<localRoot>/cache/resources/<id>/extracted/v<fileVersion>/archives`.
|
|
283
|
+
* `extractArchive` materializes each archive into its own
|
|
284
|
+
* subdirectory here (`archives/<archiveName>/<innerPath>`), served to
|
|
285
|
+
* the browser via the `/extracted/` route.
|
|
286
|
+
*/
|
|
287
|
+
resExtractedArchivesDir(id: string, fileVersion: number): string
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export type CreateStoragePathsOptions = {
|
|
291
|
+
readonly root: string
|
|
292
|
+
/**
|
|
293
|
+
* Active (viewing) version. When omitted together with latestVersion,
|
|
294
|
+
* resolves from `local/version-state.json` and the version dirs under
|
|
295
|
+
* `versions/`. When only latestVersion is pinned explicitly, defaults to
|
|
296
|
+
* that same value (call sites that override max version only).
|
|
297
|
+
*/
|
|
298
|
+
readonly activeVersion?: number
|
|
299
|
+
/**
|
|
300
|
+
* Latest (current, writable) version. When omitted, the maximum
|
|
301
|
+
* version directory under `versions/` is used, or `1` when none exist.
|
|
302
|
+
*/
|
|
303
|
+
readonly latestVersion?: number
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Build a {@link StoragePaths} rooted at `opts.root`. The root must be an
|
|
308
|
+
* absolute path (the sync boundary would not be well-defined otherwise).
|
|
309
|
+
*
|
|
310
|
+
* @throws `Error` when `root` is not absolute.
|
|
311
|
+
*/
|
|
312
|
+
export function createStoragePaths(
|
|
313
|
+
opts: CreateStoragePathsOptions,
|
|
314
|
+
): StoragePaths {
|
|
315
|
+
if (!isAbsolute(opts.root)) {
|
|
316
|
+
throw new Error(`storage root must be an absolute path: ${opts.root}`)
|
|
317
|
+
}
|
|
318
|
+
const root = resolve(opts.root)
|
|
319
|
+
const versionsRootPath = resolve(root, "versions")
|
|
320
|
+
const localRoot = resolve(root, "local")
|
|
321
|
+
|
|
322
|
+
const diskMax = diskCurrentVersion(root)
|
|
323
|
+
let latestVersion: number
|
|
324
|
+
if (opts.latestVersion !== undefined) {
|
|
325
|
+
latestVersion = opts.latestVersion
|
|
326
|
+
} else {
|
|
327
|
+
latestVersion = diskMax > 0 ? diskMax : 1
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
let activeVersion: number
|
|
331
|
+
if (opts.activeVersion !== undefined) {
|
|
332
|
+
activeVersion = opts.activeVersion
|
|
333
|
+
} else if (opts.latestVersion !== undefined) {
|
|
334
|
+
activeVersion = latestVersion
|
|
335
|
+
} else {
|
|
336
|
+
activeVersion = diskMax > 0 ? readActiveVersion(root) : latestVersion
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function versionAt(version: number): VersionPaths {
|
|
340
|
+
const vSeg = assertSafeSegment(String(version))
|
|
341
|
+
const vRoot = join(versionsRootPath, vSeg)
|
|
342
|
+
return {
|
|
343
|
+
root: vRoot,
|
|
344
|
+
version,
|
|
345
|
+
versionSnapshotDb: () => join(vRoot, "app.sqlite"),
|
|
346
|
+
resource: (id) => join(vRoot, "resources", assertSafeSegment(id)),
|
|
347
|
+
resourceData: (id) =>
|
|
348
|
+
join(vRoot, "resources", assertSafeSegment(id), RESOURCE_DATA_DIR_NAME),
|
|
349
|
+
resources: () => join(vRoot, "resources"),
|
|
350
|
+
characters: () => join(vRoot, "characters"),
|
|
351
|
+
documents: () => join(vRoot, "documents"),
|
|
352
|
+
character: (id) => join(vRoot, "characters", assertSafeSegment(id)),
|
|
353
|
+
dbBackups: () => join(vRoot, "db-backups"),
|
|
354
|
+
dbBackup: (name) => join(vRoot, "db-backups", assertSafeSegment(name)),
|
|
355
|
+
snapshots: () => join(vRoot, "snapshots"),
|
|
356
|
+
snapshot: (name) => join(vRoot, "snapshots", assertSafeSegment(name)),
|
|
357
|
+
deletedMarker: (kind, id) =>
|
|
358
|
+
join(vRoot, kind, assertSafeSegment(id), ".deleted"),
|
|
359
|
+
document: (id) => join(vRoot, "documents", assertSafeSegment(id)),
|
|
360
|
+
plugins: () => join(vRoot, "plugins"),
|
|
361
|
+
pluginVaultDir: (id) =>
|
|
362
|
+
join(vRoot, "plugins", assertSafeSegment(id), "vault"),
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const active = versionAt(activeVersion)
|
|
367
|
+
const latest = versionAt(latestVersion)
|
|
368
|
+
const uploadStagingRootPath = join(localRoot, ".tmp")
|
|
369
|
+
const cacheRoot = join(localRoot, "cache")
|
|
370
|
+
|
|
371
|
+
const local: LocalPaths = {
|
|
372
|
+
root: localRoot,
|
|
373
|
+
cache: () => cacheRoot,
|
|
374
|
+
logs: () => join(localRoot, "logs"),
|
|
375
|
+
localCover: (subjectKind, id, variant, format) =>
|
|
376
|
+
join(
|
|
377
|
+
cacheRoot,
|
|
378
|
+
localCoverSubjectDir(subjectKind),
|
|
379
|
+
assertSafeSegment(id),
|
|
380
|
+
`${assertSafeSegment(variant)}.${format ?? "avif"}`,
|
|
381
|
+
),
|
|
382
|
+
resFileVariant: (id, filename, variantKey, format) =>
|
|
383
|
+
join(
|
|
384
|
+
cacheRoot,
|
|
385
|
+
"resources",
|
|
386
|
+
assertSafeSegment(id),
|
|
387
|
+
"file-preview",
|
|
388
|
+
`${assertSafeSegment(sourceCacheId(filename))}__${assertSafeSegment(variantKey)}.${format ?? "avif"}`,
|
|
389
|
+
),
|
|
390
|
+
resFilePreviewDir: (id) =>
|
|
391
|
+
join(cacheRoot, "resources", assertSafeSegment(id), "file-preview"),
|
|
392
|
+
resFilesCache: (id) =>
|
|
393
|
+
join(cacheRoot, "resources", assertSafeSegment(id), "files-cache.json"),
|
|
394
|
+
resource: (id) => join(cacheRoot, "resources", assertSafeSegment(id)),
|
|
395
|
+
character: (id) => join(cacheRoot, "characters", assertSafeSegment(id)),
|
|
396
|
+
trash: () => join(localRoot, "trash"),
|
|
397
|
+
trashItem: (id) => join(localRoot, "trash", assertSafeSegment(id)),
|
|
398
|
+
tmp: () => join(cacheRoot, "tmp"),
|
|
399
|
+
tmpFile: (name) => join(cacheRoot, "tmp", assertSafeSegment(name)),
|
|
400
|
+
sessionKey: () => join(localRoot, ".session-key"),
|
|
401
|
+
uploadStagingRoot: () => uploadStagingRootPath,
|
|
402
|
+
stagingPoolRoot: () => join(uploadStagingRootPath, "staging"),
|
|
403
|
+
stagingPoolFile: (fileId, ext) =>
|
|
404
|
+
join(
|
|
405
|
+
uploadStagingRootPath,
|
|
406
|
+
"staging",
|
|
407
|
+
`${assertSafeSegment(fileId)}${ext}`,
|
|
408
|
+
),
|
|
409
|
+
resVideoFrame: (id, filename, timeMs) =>
|
|
410
|
+
join(
|
|
411
|
+
cacheRoot,
|
|
412
|
+
"resources",
|
|
413
|
+
assertSafeSegment(id),
|
|
414
|
+
"frames",
|
|
415
|
+
assertSafeSegment(sourceCacheId(filename)),
|
|
416
|
+
`${timeMs}.avif`,
|
|
417
|
+
),
|
|
418
|
+
resExtractedDir: (id, fileVersion) =>
|
|
419
|
+
join(
|
|
420
|
+
cacheRoot,
|
|
421
|
+
"resources",
|
|
422
|
+
assertSafeSegment(id),
|
|
423
|
+
"extracted",
|
|
424
|
+
`v${fileVersion}`,
|
|
425
|
+
),
|
|
426
|
+
resExtractedEntry: (id, fileVersion, entryName) =>
|
|
427
|
+
join(
|
|
428
|
+
cacheRoot,
|
|
429
|
+
"resources",
|
|
430
|
+
assertSafeSegment(id),
|
|
431
|
+
"extracted",
|
|
432
|
+
`v${fileVersion}`,
|
|
433
|
+
assertSafeSegment(sourceCacheId(entryName)),
|
|
434
|
+
),
|
|
435
|
+
resExtractedArchivesDir: (id, fileVersion) =>
|
|
436
|
+
join(
|
|
437
|
+
cacheRoot,
|
|
438
|
+
"resources",
|
|
439
|
+
assertSafeSegment(id),
|
|
440
|
+
"extracted",
|
|
441
|
+
`v${fileVersion}`,
|
|
442
|
+
"archives",
|
|
443
|
+
),
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return {
|
|
447
|
+
root,
|
|
448
|
+
activeVersion,
|
|
449
|
+
latestVersion,
|
|
450
|
+
active,
|
|
451
|
+
latest,
|
|
452
|
+
local,
|
|
453
|
+
atVersion: (v) => versionAt(v),
|
|
454
|
+
runtimeDb: () => join(root, "app.sqlite"),
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Map a {@link LocalPaths.thumb} subjectKind onto its on-disk subdirectory.
|
|
460
|
+
* Variants now live flat inside the per-id local directory (no enclosing
|
|
461
|
+
* `thumbs/` parent), so `resource` -> `resources` and `character` ->
|
|
462
|
+
* `characters` (both plural to match the storage layout convention).
|
|
463
|
+
*/
|
|
464
|
+
function localCoverSubjectDir(subjectKind: "resource" | "character"): string {
|
|
465
|
+
return subjectKind === "resource" ? "resources" : "characters"
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Map a source filename to the readable prefix of its derived-cache
|
|
470
|
+
* identity (see {@link sourceCacheId}). Strips the source extension
|
|
471
|
+
* before {@link LocalPaths.thumb} appends `.webp`, so `1.jpeg` becomes
|
|
472
|
+
* `1__jpeg` (not `1.jpeg.webp`). The source extension is folded into
|
|
473
|
+
* the basename so two sources sharing a stem (e.g. `01.png` /
|
|
474
|
+
* `01.jpg`) cannot collide on the same cache file. Separators are
|
|
475
|
+
* flattened so subdirectory entry names never trip the single-segment
|
|
476
|
+
* path authority check.
|
|
477
|
+
*
|
|
478
|
+
* The encoding (`__<ext>`) avoids any character that
|
|
479
|
+
* {@link assertSafeSegment} rejects (no dot, no separator, no control
|
|
480
|
+
* char) so the result passes the boundary check unchanged.
|
|
481
|
+
*/
|
|
482
|
+
function toCacheBasename(filename: string): string {
|
|
483
|
+
const dot = filename.lastIndexOf(".")
|
|
484
|
+
if (dot <= 0) return filename.replace(/[/\\]/g, "__")
|
|
485
|
+
const stem = filename.slice(0, dot).replace(/[/\\]/g, "__")
|
|
486
|
+
const ext = filename.slice(dot + 1)
|
|
487
|
+
return `${stem}__${ext}`
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Stable, collision-free cache identity for one source file:
|
|
492
|
+
* the flattened basename (human-readable) plus a short hash of the
|
|
493
|
+
* full relative path. The hash disambiguates flattening ambiguities —
|
|
494
|
+
* `a__b/c.png` and `a/b__c.png` both flatten to `a__b__c__png`, but
|
|
495
|
+
* their paths hash differently, so distinct files never share a cache
|
|
496
|
+
* entry. Pure hex suffix, so the result passes
|
|
497
|
+
* {@link assertSafeSegment} unchanged.
|
|
498
|
+
*/
|
|
499
|
+
function sourceCacheId(filename: string): string {
|
|
500
|
+
return `${toCacheBasename(filename)}__${shortHash(filename)}`
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Short, stable hash fragment: the first 8 hex chars of the sha256 of
|
|
505
|
+
* `input`. Pure hex, so the result passes {@link assertSafeSegment}
|
|
506
|
+
* unchanged.
|
|
507
|
+
*/
|
|
508
|
+
function shortHash(input: string): string {
|
|
509
|
+
return createHash("sha256").update(input).digest("hex").slice(0, 8)
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Short, stable cache identity for one derived image variant: the
|
|
514
|
+
* sha256 of the spec's canonical string (see
|
|
515
|
+
* `imageVariantCanonical` in `@hoardodile/sdk-types/image-variant`),
|
|
516
|
+
* truncated to 8 hex chars.
|
|
517
|
+
*/
|
|
518
|
+
export function imageVariantKey(canonical: string): string {
|
|
519
|
+
return shortHash(canonical)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Validate a single path segment. We reject anything that embeds a path
|
|
524
|
+
* separator, a drive letter, a NUL or other control code, a Windows
|
|
525
|
+
* reserved basename, or a trailing dot/space (Windows normalises those away
|
|
526
|
+
* and you get the wrong file).
|
|
527
|
+
*
|
|
528
|
+
* @throws `Error` when `segment` is empty or rejected by any of the rules.
|
|
529
|
+
*/
|
|
530
|
+
export function assertSafeSegment(segment: string): string {
|
|
531
|
+
if (segment.length === 0) throw new Error("path segment must not be empty")
|
|
532
|
+
if (segment === "." || segment === "..") {
|
|
533
|
+
throw new Error(`path segment must not be '${segment}'`)
|
|
534
|
+
}
|
|
535
|
+
if (segment.includes("/") || segment.includes("\\")) {
|
|
536
|
+
throw new Error(`path segment must not contain separators: ${segment}`)
|
|
537
|
+
}
|
|
538
|
+
if (hasForbiddenVisibleChar(segment) || hasControlChar(segment)) {
|
|
539
|
+
throw new Error(`path segment contains disallowed characters: ${segment}`)
|
|
540
|
+
}
|
|
541
|
+
if (segment.endsWith(".") || segment.endsWith(" ")) {
|
|
542
|
+
throw new Error(
|
|
543
|
+
`path segment must not end with dot or space: ${JSON.stringify(segment)}`,
|
|
544
|
+
)
|
|
545
|
+
}
|
|
546
|
+
const base = segment.split(".")[0]?.toUpperCase()
|
|
547
|
+
if (base !== undefined && WINDOWS_RESERVED.has(base)) {
|
|
548
|
+
throw new Error(`path segment is a reserved name: ${segment}`)
|
|
549
|
+
}
|
|
550
|
+
return segment
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function join(...segments: readonly string[]): string {
|
|
554
|
+
return resolve(...segments)
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Ensure `candidate` is contained within `ancestor` (after `resolve`). Used
|
|
559
|
+
* as a belt-and-braces check before any disk operation that mixes user
|
|
560
|
+
* input with a base directory.
|
|
561
|
+
*/
|
|
562
|
+
export function assertInside(ancestor: string, candidate: string): string {
|
|
563
|
+
const resolved = resolve(candidate)
|
|
564
|
+
const base = resolve(ancestor)
|
|
565
|
+
if (resolved !== base && !resolved.startsWith(base + sep)) {
|
|
566
|
+
throw new Error(`path ${resolved} escapes ${base}`)
|
|
567
|
+
}
|
|
568
|
+
return resolved
|
|
569
|
+
}
|