diff --git a/backend/api/locales.ts b/backend/api/locales.ts index 222203c9a..46437b90a 100644 --- a/backend/api/locales.ts +++ b/backend/api/locales.ts @@ -123,6 +123,67 @@ async function routes(app: FastifyInstance) { } ) + /** + * INSTALL A LOCALE FROM AN UPLOADED FILE + * + * The way in for a wiki that cannot reach github at all: the same published package, carried in by + * hand instead of downloaded. The body is the strings document itself rather than a multipart form + * — one file, no fields — and the file name arrives in the query string because it is what says + * which locale this is. + */ + app.post<{ Querystring: { fileName: string } }>( + '/upload', + { + config: { + permissions: ['manage:system'] + }, + schema: { + summary: 'Install a locale from an uploaded strings file', + description: + 'The body is the locale package itself — one of the `.json` files published at `requarks/wiki-locales` — sent as `application/json` rather than as a multipart form. For an installation that cannot reach the internet, where `/locales/fetch` has nothing to read and a locale has no row to be installed from; this creates the row as well as filling it.\n\nThe file name is the identity, exactly as it is upstream: `fr-FR.json` installs `fr-FR`, so a renamed file installs the wrong locale and a name that is not a language tag is refused. So is a body that is not one flat object of strings, and so is `en`, which ships with the wiki.\n\nNo hash is recorded, since nothing was downloaded — a later run of `/locales/fetch` on an instance that does reach upstream will therefore re-download the locale.', + tags: ['Locales'], + consumes: ['application/json'], + querystring: { + type: 'object', + properties: { + fileName: { + type: 'string', + minLength: 1, + maxLength: 255, + description: 'The name of the uploaded file, e.g. `fr-FR.json`.' + } + }, + required: ['fileName'] + }, + response: { + 200: { + description: 'Locale installed successfully', + type: 'object', + properties: { + ok: { type: 'boolean' }, + code: { + type: 'string', + description: 'The locale the file was installed as, read off its name.' + }, + message: { type: 'string' } + } + } + } + } + }, + async (req, reply) => { + let code: string + try { + code = await WIKI.models.locales.installFromFile(req.query.fileName, req.body) + } catch (err: any) { + return reply.badRequest(err.message) + } + await audit(req, 'admin', 'uploadLocale', { code, fileName: req.query.fileName }) + + return { ok: true, code, message: 'Locale installed successfully.' } + } + ) + /** * SET A LOCALE'S ALIASES */ diff --git a/backend/locales/en.json b/backend/locales/en.json index 04818470f..8c76c20c9 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -232,6 +232,7 @@ "admin.audit.actions.updateUser": "Updated a user", "admin.audit.actions.updateUserDefaults": "Changed the user defaults", "admin.audit.actions.uploadAsset": "Uploaded a file", + "admin.audit.actions.uploadLocale": "Installed a locale from a file", "admin.audit.actions.verifyEmail": "Confirmed an email address", "admin.audit.actions.watchPage": "Started watching a page", "admin.audit.allActions": "Any action", @@ -693,12 +694,16 @@ "admin.locale.downloadNew": "Install New Locale", "admin.locale.downloadTitle": "Download Locale", "admin.locale.editAliases": "Edit Locale Aliases", - "admin.locale.fetch": "Fetch Locales", + "admin.locale.fetch": "Fetch Updates", "admin.locale.fetchHint": "Check for new and updated locales.", "admin.locale.forcePrefix": "Force Locale Prefix", "admin.locale.forcePrefixHint": "Paths without a locale code will always be redirected to the primary locale.", "admin.locale.install": "Install", "admin.locale.installFailed": "Failed to install the locale.", + "admin.locale.installFile": "Install from file...", + "admin.locale.installFileFailed": "Failed to install the locale from this file.", + "admin.locale.installFileHint": "Install a locale package (.json) downloaded from the Wiki.js locales repository, for a wiki that cannot reach the internet.", + "admin.locale.installFileSuccess": "Locale {code} installed successfully.", "admin.locale.installSuccess": "Locale installed successfully.", "admin.locale.loadFailed": "Failed to fetch locale settings.", "admin.locale.name": "Name", @@ -2557,7 +2562,7 @@ "localeFetchDialog.resultNone": "Everything is already up to date.", "localeFetchDialog.resultUnchanged": "{count} already up to date", "localeFetchDialog.resultUpdated": "No locale updated | {count} locale updated | {count} locales updated", - "localeFetchDialog.title": "Fetch Locales", + "localeFetchDialog.title": "Fetch Updates", "navEdit.clearItems": "Clear All Items", "navEdit.editMenuItems": "Edit Menu Items", "navEdit.editingInherited": "Inherited menu — shared with every page using it", diff --git a/backend/models/auditLog.ts b/backend/models/auditLog.ts index 0f32977f9..16b1f961d 100644 --- a/backend/models/auditLog.ts +++ b/backend/models/auditLog.ts @@ -96,6 +96,7 @@ export const AUDIT_ACTIONS = { 'flushIconCache', 'fetchLocales', 'installLocale', + 'uploadLocale', 'updateLocale', 'updateMailConfig', 'sendTestEmail', diff --git a/backend/models/locales.ts b/backend/models/locales.ts index 813390603..442c35216 100644 --- a/backend/models/locales.ts +++ b/backend/models/locales.ts @@ -68,6 +68,22 @@ function localeInfoFor(code: string) { } } +/** + * Whether a parsed document is a locale string set. + * + * Every locale package is one flat object of key to string — `locales/en.json` is the shape, and the + * published packages are translations of it. Anything else is a JSON file that is not a locale, and + * the two that would otherwise get this far are worth naming: the repository's own `metadata.json` + * is an array, and a nested object is a namespaced format this wiki does not read. + */ +function isStringsDocument(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false + } + const entries = Object.entries(value) + return entries.length > 0 && entries.every(([, str]) => typeof str === 'string') +} + /** A locale row, as far as naming it for a list is concerned. */ interface NameableLocale { code: string @@ -359,6 +375,91 @@ class Locales { WIKI.logger.info(`Locale ${code} installed successfully. [ OK ]`) } + /** + * Install a locale from a strings file an administrator uploaded, rather than from upstream. + * + * The whole point is the wiki that cannot reach github: an air-gapped instance has no metadata to + * read and therefore not even a row to install from, so this creates the row as well as filling + * it — it is `install` and the `added` half of `updateFromRemote` at once. The file is one of the + * packages published at `requarks/wiki-locales`, carried in by hand. + * + * **The file name is the identity**, exactly as it is for the remote packages and the files in + * `locales/`: `fr-FR.json` is the locale `fr-FR`, and nothing else in the upload says which locale + * it is. So a file somebody renamed installs the wrong locale, which is why the name is held to + * being a structurally valid language tag rather than just non-empty. + * + * It is read verbatim, case and all — `refreshFromDisk` reads the files in `locales/` the same way, + * and `localeInfoFor` says why neither canonicalizes. The extension is therefore matched exactly + * too: taking `FR-FR.JSON` would file the strings under a code that names no published package and + * sits beside the `fr-FR` a later fetch would create, so it is refused as the renamed file it is. + * + * **The hash is left empty**, as it is for a locale that came off disk: no upstream file was + * downloaded, so there is nothing a later update run could compare against. That makes the first + * run that does reach upstream re-download it, which is the right answer for strings of unknown + * provenance — and costs an air-gapped wiki nothing, since it never has such a run. + * + * @returns The code the file was installed as. + */ + async installFromFile(fileName: string, strings: unknown): Promise { + const name = (fileName ?? '').trim() + if (!name.endsWith('.json')) { + throw new Error( + `"${name}" is not a locale package: it must be a .json file named for its locale, e.g. "fr-FR.json".` + ) + } + const code = name.slice(0, -'.json'.length) + + // -> Same reasoning as `install`: it ships with the wiki and this build's strings are the + // authority on what the interface says + if (code === SOURCE_LOCALE) { + throw new Error(`Locale ${code} ships with the wiki and cannot be uploaded.`) + } + + let localeInfo: ReturnType | null = null + try { + localeInfo = localeInfoFor(code) + } catch { + // -> Not a structurally valid tag. Reported with the rest of what the name can be wrong about + } + /* + Parsing is not enough on its own. BCP 47 allows a primary language subtag of five to eight + letters, for subtags nobody ever registered, so `Intl.Locale` happily accepts `french` and + `passwd` -- and an upload named either would install a locale called that, sitting in the + admin list for ever with nothing to say what it is. Every language strings are published for is + ISO 639, which is two or three letters, and that is what makes a name a language tag here. + + A path rather than a bare name fails the same check, which is why nothing is stripped off the + front of it: `../../etc/passwd` does not parse as a tag, and a name that is not just a name is + not a locale package whatever it ends in. + */ + if (!localeInfo || localeInfo.language.length > 3) { + throw new Error(`"${name}" is not named for a valid language tag.`) + } + + if (!isStringsDocument(strings)) { + throw new Error(`"${name}" does not hold a locale string set.`) + } + + WIKI.logger.info(`Installing locale ${code} from an uploaded file...`) + await WIKI.db + .insert(localesTable) + .values({ + code, + ...localeInfo, + isInstalled: true, + hash: '', + strings + }) + .onConflictDoUpdate({ + target: localesTable.code, + set: { strings, isInstalled: true, hash: '', updatedAt: sql`now()` } + }) + await this.reloadCache() + WIKI.events.outbound.emit('reloadLocales') + WIKI.logger.info(`Locale ${code} installed successfully. [ OK ]`) + return code + } + /** * Set — or, with empty values, clear — what a locale is called and what it is addressed as. * diff --git a/frontend/src/assets/icons.generated.js b/frontend/src/assets/icons.generated.js index afdb572f8..23614573f 100644 --- a/frontend/src/assets/icons.generated.js +++ b/frontend/src/assets/icons.generated.js @@ -5,7 +5,7 @@ never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or removing an icon; `check-icons.mjs` fails the build if this drifts. - 272 icons. + 273 icons. */ export const BUNDLED_ICONS = { "la:angle-right": {"body":"","width":32,"height":32}, @@ -65,6 +65,7 @@ export const BUNDLED_ICONS = { "la:file-image": {"body":"","width":32,"height":32}, "la:file-import": {"body":"","width":32,"height":32}, "la:file-invoice": {"body":"","width":32,"height":32}, + "la:file-upload": {"body":"","width":32,"height":32}, "la:fill": {"body":"","width":32,"height":32}, "la:fingerprint": {"body":"","width":32,"height":32}, "la:folder-open": {"body":"","width":32,"height":32}, diff --git a/frontend/src/pages/AdminLocale.vue b/frontend/src/pages/AdminLocale.vue index d2a4a8df1..19b87e476 100644 --- a/frontend/src/pages/AdminLocale.vue +++ b/frontend/src/pages/AdminLocale.vue @@ -22,6 +22,17 @@ @click="fetchLocales"> {{ t(`admin.locale.fetchHint`) }} + + {{ t(`admin.locale.installFileHint`) }} + +