Translator API key management.
This commit is contained in:
@@ -305,14 +305,15 @@ An external machine-translation service connects over WebSocket at
|
|||||||
`/_translate/{key}` — deliberately **not** under `/_api`: the SSO
|
`/_translate/{key}` — deliberately **not** under `/_api`: the SSO
|
||||||
forward-auth does not cover that route, and the key in the path is the
|
forward-auth does not cover that route, and the key in the path is the
|
||||||
access control. Keys live in `Data.translate_keys` (key -> display name) —
|
access control. Keys live in `Data.translate_keys` (key -> display name) —
|
||||||
12 lowercase alphanumeric characters each, the first one generated at
|
12 lowercase alphanumeric characters each; the first is generated at
|
||||||
database bootstrap and multiple keys reserved for future management (e.g.
|
database bootstrap, further ones are managed in the editor's lang tab
|
||||||
a web UI). The full WS URL(s) are printed in the startup log
|
(add/rename/delete ride the `PUT /_api/settings` round-trip; the name is
|
||||||
(`ws://localhost:{port}/_translate/{key}` locally,
|
an inline display label only). The full WS URL(s) are printed in the
|
||||||
`wss://{hostname}/_translate/{key}` on a public hostname) and the keys are
|
startup log (`ws://localhost:{port}/_translate/{key}` locally,
|
||||||
surfaced to the admin in `GET /_api/settings` as `translate_keys`. An
|
`wss://{hostname}/_translate/{key}` on a public hostname) and shown in the
|
||||||
unknown or empty key rejects the handshake (close-before-accept → HTTP
|
lang tab as click-to-copy links; the keys are also surfaced in
|
||||||
403). Transactions storing results record the connecting key as the kanta
|
`GET /_api/settings` as `translate_keys`. An unknown or empty key rejects
|
||||||
|
the handshake (close-before-accept → HTTP 403). Transactions storing results record the connecting key as the kanta
|
||||||
transaction `user`.
|
transaction `user`.
|
||||||
|
|
||||||
Frames are JSON-encoded tagged msgspec structs (`pagerite/translate.py`;
|
Frames are JSON-encoded tagged msgspec structs (`pagerite/translate.py`;
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
// Lang tab: the site-wide translation target languages (translate_langs)
|
// Lang tab: the site-wide translation target languages (translate_langs)
|
||||||
// and the translator service WebSocket URL(s) (translate_keys). ALL
|
// and the translator service keys (translate_keys) with their WebSocket
|
||||||
// languages are listed, English included — a page whose primary language
|
// URLs. ALL languages are listed, English included — a page whose primary
|
||||||
// (Node.language, configured per row in the structure tab, inherited down
|
// language (Node.language, configured per row in the structure tab,
|
||||||
// the hierarchy) differs can be translated INTO any other. Flag clicks
|
// inherited down the hierarchy) differs can be translated INTO any other.
|
||||||
// toggle and save immediately; the settings round-trip re-reads the
|
// Flag clicks toggle and save immediately; the settings round-trip
|
||||||
// payload, so this tab only ever changes translate_langs. The settings
|
// re-reads the payload, so this tab only ever changes translate_langs. The
|
||||||
// write's invalidation hook kicks the translation dispatcher. The refresh
|
// settings write's invalidation hook kicks the translation dispatcher. The
|
||||||
// button drops all machine translations (user patches are kept), making
|
// refresh button drops all machine translations (user patches are kept),
|
||||||
// the dispatcher re-translate everything.
|
// making the dispatcher re-translate everything. Translator keys are
|
||||||
|
// managed inline (➕ add, name edit, ✕ delete); new keys are generated
|
||||||
|
// here in the server's format and everything rides the settings
|
||||||
|
// round-trip. Clicking a key copies its full URL (following ws:// would
|
||||||
|
// fail).
|
||||||
import { computed, onActivated, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onActivated, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { LANG_GROUPS, TRANSLATABLE, flagFor, langName } from './langs'
|
import { LANG_GROUPS, TRANSLATABLE, flagFor, langName } from './langs'
|
||||||
|
import { copyList } from './analytics/format.js'
|
||||||
import { dropPageCache } from './swapdoc'
|
import { dropPageCache } from './swapdoc'
|
||||||
|
|
||||||
defineProps({ pagePath: { type: String, default: '' } })
|
defineProps({ pagePath: { type: String, default: '' } })
|
||||||
@@ -21,6 +26,16 @@ const saveError = ref('')
|
|||||||
const selected = ref(new Set())
|
const selected = ref(new Set())
|
||||||
const keyUrls = ref([])
|
const keyUrls = ref([])
|
||||||
|
|
||||||
|
// Full WebSocket URL for a key. New keys are generated right here: 12
|
||||||
|
// lowercase alphanumerics, the server-side format (state._KEY_ALPHABET).
|
||||||
|
const wsUrl = (key) =>
|
||||||
|
`${location.origin.replace(/^http/, 'ws')}/_translate/${key}`
|
||||||
|
const KEY_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'
|
||||||
|
const newKey = () =>
|
||||||
|
[...crypto.getRandomValues(new Uint8Array(12))]
|
||||||
|
.map((b) => KEY_ALPHABET[b % KEY_ALPHABET.length])
|
||||||
|
.join('')
|
||||||
|
|
||||||
// The toggleable targets: every translatable language, laid out in
|
// The toggleable targets: every translatable language, laid out in
|
||||||
// geographic/cultural groups (one row each) rather than alphabetized —
|
// geographic/cultural groups (one row each) rather than alphabetized —
|
||||||
// related languages sit together (a node's own primary is excluded per
|
// related languages sit together (a node's own primary is excluded per
|
||||||
@@ -52,9 +67,8 @@ onMounted(async () => {
|
|||||||
try {
|
try {
|
||||||
const s = await (await fetch('/_api/settings')).json()
|
const s = await (await fetch('/_api/settings')).json()
|
||||||
selected.value = new Set(s.translate_langs || [])
|
selected.value = new Set(s.translate_langs || [])
|
||||||
const wsBase = location.origin.replace(/^http/, 'ws')
|
|
||||||
keyUrls.value = Object.entries(s.translate_keys || {})
|
keyUrls.value = Object.entries(s.translate_keys || {})
|
||||||
.map(([key, name]) => ({ name, url: `${wsBase}/_translate/${key}` }))
|
.map(([key, name]) => ({ key, name, url: wsUrl(key) }))
|
||||||
} catch { /* keep defaults */ }
|
} catch { /* keep defaults */ }
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -100,6 +114,39 @@ async function refresh() {
|
|||||||
refreshing.value = false
|
refreshing.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Key management rides the settings round-trip, like toggle() above:
|
||||||
|
// mutate keyUrls, then PUT the whole settings payload with the new
|
||||||
|
// translate_keys. ➕ adds a fresh unnamed key, names save on every
|
||||||
|
// keystroke (@input — spamming the server is fine), ✕ deletes without
|
||||||
|
// confirmation.
|
||||||
|
async function saveKeys() {
|
||||||
|
try {
|
||||||
|
const s = await (await fetch('/_api/settings')).json()
|
||||||
|
const res = await fetch('/_api/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
...s,
|
||||||
|
translate_keys: Object.fromEntries(keyUrls.value.map((k) => [k.key, k.name])),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
saveError.value = res.ok ? '' : '⚠️ changes could not be saved'
|
||||||
|
} catch {
|
||||||
|
saveError.value = '⚠️ changes could not be saved'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addKey() {
|
||||||
|
const key = newKey()
|
||||||
|
keyUrls.value.push({ key, name: '', url: wsUrl(key) })
|
||||||
|
saveKeys()
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeKey(k) {
|
||||||
|
keyUrls.value = keyUrls.value.filter((x) => x.key !== k.key)
|
||||||
|
saveKeys()
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -129,28 +176,37 @@ async function refresh() {
|
|||||||
|
|
||||||
<section class="block">
|
<section class="block">
|
||||||
<div class="block-head">
|
<div class="block-head">
|
||||||
<span class="field-label">translations</span>
|
<span class="field-label">Translator API</span>
|
||||||
<small class="muted">deleting re-translates everything; user edits are kept</small>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div v-for="k in keyUrls" :key="k.key" class="key-row">
|
||||||
type="button"
|
<a
|
||||||
class="refresh-btn"
|
:href="k.url"
|
||||||
:disabled="refreshing"
|
class="key-link"
|
||||||
title="delete all machine translations and let the translator re-fill them"
|
title="click to copy the URL"
|
||||||
@click="refresh"
|
@click.prevent="copyList(k.url, $event)"
|
||||||
>
|
>{{ k.key }}</a>
|
||||||
{{ refreshing ? 'refreshing…' : 'refresh all translations' }}
|
<input
|
||||||
</button>
|
v-model="k.name"
|
||||||
</section>
|
type="text"
|
||||||
|
class="edit key-name"
|
||||||
<section v-if="keyUrls.length" class="block">
|
title="display name"
|
||||||
<div class="block-head">
|
@input="saveKeys()"
|
||||||
<span class="field-label">translator service</span>
|
>
|
||||||
<small class="muted">connect scripts/translator.py to</small>
|
<button type="button" class="act del" title="delete key" @click="removeKey(k)">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-for="k in keyUrls" :key="k.url" class="key-row">
|
<div class="add-row">
|
||||||
<code>{{ k.url }}</code>
|
<button type="button" class="add" title="new translator key" @click="addKey()">➕ API key</button>
|
||||||
<small class="muted">{{ k.name }}</small>
|
</div>
|
||||||
|
<p><small class="muted">AI translator agents can connect with the API keys to do machine translations to your selected languages. Click the button below to delete all translations and start over. User edits are kept.</small></p>
|
||||||
|
<div class="refresh-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="refresh-btn"
|
||||||
|
:disabled="refreshing"
|
||||||
|
@click="refresh"
|
||||||
|
>
|
||||||
|
{{ refreshing ? 'Reseting…' : 'Reset' }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -252,10 +308,83 @@ async function refresh() {
|
|||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.key-row code {
|
/* Real links (handy for right-click/drag) showing just the key, but the
|
||||||
|
click copies the full URL instead of following — ws:// would fail to
|
||||||
|
navigate. Normal text color, not link-styled; position: relative
|
||||||
|
anchors the "Copied!" popup (analytics/format.js). */
|
||||||
|
.key-link {
|
||||||
|
position: relative;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-code);
|
||||||
user-select: all;
|
user-select: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.refresh-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Name input / ✕ / ➕ follow the structure tab's conventions: inputs stay
|
||||||
|
borderless until interacted with, glyph buttons redden / solidify on
|
||||||
|
hover. */
|
||||||
|
.key-name {
|
||||||
|
flex: 0 0 9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit:hover {
|
||||||
|
border-color: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit:focus {
|
||||||
|
background: var(--bg);
|
||||||
|
border-color: var(--accent);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.act {
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.del:hover {
|
||||||
|
color: #e06c75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add {
|
||||||
|
padding: 0 0.3rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.refresh-btn {
|
.refresh-btn {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
margin-bottom: 0.2rem;
|
margin-bottom: 0.2rem;
|
||||||
|
|||||||
@@ -298,6 +298,7 @@ class SettingsIn(BaseModel):
|
|||||||
brand_html: str = ""
|
brand_html: str = ""
|
||||||
transition: str = "cube"
|
transition: str = "cube"
|
||||||
translate_langs: list[str] | None = None # None keeps the current set
|
translate_langs: list[str] | None = None # None keeps the current set
|
||||||
|
translate_keys: dict[str, str] | None = None # None keeps the current keys
|
||||||
|
|
||||||
|
|
||||||
@router.put("/_api/settings", status_code=204)
|
@router.put("/_api/settings", status_code=204)
|
||||||
@@ -318,6 +319,8 @@ async def put_settings(settings: SettingsIn, request: Request) -> None:
|
|||||||
for lang in settings.translate_langs
|
for lang in settings.translate_langs
|
||||||
if (tag := i18n.base_tag(lang))
|
if (tag := i18n.base_tag(lang))
|
||||||
}
|
}
|
||||||
|
if settings.translate_keys is not None:
|
||||||
|
data.translate_keys = settings.translate_keys
|
||||||
_invalidate_pages()
|
_invalidate_pages()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -104,8 +104,8 @@ class Data(msgspec.Struct):
|
|||||||
#: API keys gating the translator service WebSocket (/_translate/{key};
|
#: API keys gating the translator service WebSocket (/_translate/{key};
|
||||||
#: the external forward-auth does not cover that route): key -> display
|
#: the external forward-auth does not cover that route): key -> display
|
||||||
#: name. Keys are 12 lowercase alphanumeric characters; the first is
|
#: name. Keys are 12 lowercase alphanumeric characters; the first is
|
||||||
#: generated at database bootstrap (see app.py), multiple keys are a
|
#: generated at database bootstrap, more are managed in the editor
|
||||||
#: future reservation (e.g. managed via a web interface).
|
#: shell's lang tab (via /_api/settings).
|
||||||
translate_keys: dict[str, str] = {}
|
translate_keys: dict[str, str] = {}
|
||||||
#: Wanted target languages for the translator service (presence-keys,
|
#: Wanted target languages for the translator service (presence-keys,
|
||||||
#: value always True). The dispatcher offers jobs only in the
|
#: value always True). The dispatcher offers jobs only in the
|
||||||
|
|||||||
+3
-4
@@ -342,6 +342,7 @@ def _seed(data: Data) -> None:
|
|||||||
|
|
||||||
#: Translator key format: 12 lowercase alphanumeric characters — not
|
#: Translator key format: 12 lowercase alphanumeric characters — not
|
||||||
#: brute-forceable over a WebSocket handshake, still human-manageable.
|
#: brute-forceable over a WebSocket handshake, still human-manageable.
|
||||||
|
#: The editor's lang tab generates further keys in the same format.
|
||||||
_KEY_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"
|
_KEY_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
|
|
||||||
|
|
||||||
@@ -349,10 +350,8 @@ _KEY_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"
|
|||||||
def _translator_defaults(data: Data) -> None:
|
def _translator_defaults(data: Data) -> None:
|
||||||
"""Translator defaults on database creation: the first service key and
|
"""Translator defaults on database creation: the first service key and
|
||||||
the wanted target languages (Spanish and Chinese — English is the
|
the wanted target languages (Spanish and Chinese — English is the
|
||||||
original language, never a translation target).
|
original language, never a translation target). Further keys are
|
||||||
|
managed in the editor shell's lang tab."""
|
||||||
Keys are a dict (key -> display name) with the future reservation that
|
|
||||||
multiple keys could be managed (e.g. via a web interface)."""
|
|
||||||
key = "".join(secrets.choice(_KEY_ALPHABET) for _ in range(12))
|
key = "".join(secrets.choice(_KEY_ALPHABET) for _ in range(12))
|
||||||
data.translate_keys[key] = "default"
|
data.translate_keys[key] = "default"
|
||||||
data.translate_langs = {"es": True, "zh": True}
|
data.translate_langs = {"es": True, "zh": True}
|
||||||
|
|||||||
Reference in New Issue
Block a user