diff --git a/core/http/react-ui/e2e/addons-install-file.spec.js b/core/http/react-ui/e2e/addons-install-file.spec.js new file mode 100644 index 0000000000..03d6d4109f --- /dev/null +++ b/core/http/react-ui/e2e/addons-install-file.spec.js @@ -0,0 +1,195 @@ +import { test, expect } from '@playwright/test' +import { ADMIN, app, installedAddon, sessionFor, mockAddons } from './addons-fixtures.js' + +// Install from a file: the button on the Add-ons page, the dialog with the +// unsigned notice and the host access step, the upgrade and the downgrade +// rules, and the link that `local-ai addons install` prints. The owner decides +// every answer, so these specs drive the page against a stand-in for the file +// routes and the host-grants routes and record what the page sent. + +const shots = process.env.UI_SHOTS_DIR + +async function shoot(page, name) { + if (!shots) return + await page.emulateMedia({ reducedMotion: 'reduce' }) + await page.waitForTimeout(400) + await page.screenshot({ path: `${shots}/${name}.png` }) +} + +const ID = 'c'.repeat(32) +const SHA = 'd'.repeat(64) + +const op = (name, title, text, risk, over = {}) => ({ name, title, text, risk, status: 'not_allowed', ...over }) + +function fileView(over = {}) { + return { + fileId: ID, name: 'memory-0.2.0-linux-amd64.zip', size: 5364318, sha256: SHA, addonId: 'memory', title: 'Short memory', + version: '0.2.0', platform: 'linux/amd64', api: { min: 2, max: 2 }, required: ['workspace.materialize'], optional: ['egress.check'], + installed: false, installedVersion: '', relation: 'new', source: 'local-file', origin: 'local file, unsigned', ...over, + } +} + +const PREVIEW = { + addonId: 'memory', title: 'Short memory', digest: 'sha256:' + SHA, version: '0.2.0', installed: false, official: false, revision: 4, nonce: 'nonce-f', + requested: { required: ['workspace.materialize'], optional: ['egress.check'] }, canInstall: true, blockedRequired: [], added: [], blanket: { applies: false }, + ops: [ + op('workspace.materialize', 'Copy files in', 'Copy files of a chat into the working area of the add-on.', 'changes data'), + op('egress.check', 'Ask about the network', 'Ask the host whether a network connection is allowed.', 'network'), + ], +} + +async function open(page, { file = fileView(), installed = [], uploadStatus = 200, uploadBody } = {}) { + await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(ADMIN, { addons: true }) })) + const state = await mockAddons(page, { apps: [], installed }) + const seen = { uploads: [], described: [], removed: [], preview: [], install: [], dispatch: state.log.dispatch } + await page.route(/\/api\/addons\/files(\/[0-9a-f]+)?$/, route => { + const req = route.request() + const json = (payload, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(payload) }) + if (req.method() === 'POST') { + seen.uploads.push({ contentType: req.headers()['content-type'], body: req.postDataBuffer()?.toString('latin1') || '' }) + return json(uploadBody || file, uploadStatus) + } + const id = req.url().split('/').pop() + if (req.method() === 'DELETE') { seen.removed.push(id); return route.fulfill({ status: 204 }) } + seen.described.push(id) + return id === ID ? json(file) : json({ error: { type: 'not_found', message: 'No such file' } }, 404) + }) + await page.route(/\/api\/addons\/host-grants(\/.*)?(\?.*)?$/, route => { + const req = route.request() + const path = new URL(req.url()).pathname.replace('/api/addons/host-grants', '') || '/' + const body = req.postData() ? JSON.parse(req.postData()) : null + const json = (payload, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(payload) }) + if (path === '/') return json({ revision: 4, locked: false, blanket: { mode: 'off', includeOptional: false, setBy: 'ui', revision: 0 }, floor: { source: 'file', addons: [] }, deny: { file: {}, ui: {} }, addons: [] }) + if (path === '/preview') { seen.preview.push(body); return json({ ...PREVIEW, installed: file.installed }) } + if (path === '/install') { + seen.install.push({ body, confirm: req.headers()['x-localai-confirm'] }) + if (!file.installed) { + state.installed.push(installedAddon({ id: 'memory', title: 'Short memory', version: file.version, sourceId: 'local-file', verification: 'sha256', enabled: false, state: 'stopped', generation: 1 })) + state.apps.push(app({ id: 'memory', name: 'memory', title: 'Short memory', version: file.version, generation: 1, enabled: false, state: 'stopped', mainPage: undefined, hasUI: false })) + } + return json({ payload: { OperationID: body.operationId, AddonID: body.addonId, Phase: 'completed', Generation: 1 } }) + } + return json({ error: { type: 'addon_error', message: 'unknown' } }, 404) + }) + return { state, seen } +} + +test.describe('Install from a file', () => { + test('uploads the zip, shows the unsigned notice, the digest and the host operations, and sends the approval', async ({ page }) => { + const { seen } = await open(page) + await page.goto('/app/addons') + await page.getByTestId('install-file-input').setInputFiles({ name: 'memory-0.2.0-linux-amd64.zip', mimeType: 'application/zip', buffer: Buffer.from('PK-fake-zip') }) + const dialog = page.getByTestId('install-dialog') + await expect(dialog).toBeVisible() + expect(seen.uploads).toHaveLength(1) + expect(seen.uploads[0].contentType).toContain('multipart/form-data') + expect(seen.uploads[0].body).toContain('name="file"; filename="memory-0.2.0-linux-amd64.zip"') + await expect(dialog.getByTestId('install-unsigned')).toContainText('Local file, unsigned') + await expect(dialog.getByTestId('install-unsigned')).toContainText('Nothing signs this file') + const facts = dialog.getByTestId('install-file-facts') + await expect(facts.getByTestId('install-file-sha')).toHaveText(SHA) + await expect(facts).toContainText('memory 0.2.0') + await expect(facts).toContainText('linux/amd64') + // The same step as for a library entry; a file is never official. + const step = dialog.getByTestId('install-host-access') + await expect(step).toBeVisible() + await expect(step.locator('[data-op="workspace.materialize"]').getByRole('switch')).toBeDisabled() + await expect(step.getByTestId('hostaccess-not-official')).toHaveCount(0) + expect(seen.preview).toEqual([{ sourceId: 'local-file', addonId: 'memory', version: ID }]) + await step.locator('[data-op="egress.check"]').getByRole('switch').click() + await shoot(page, 'install-from-file-dialog') + await dialog.getByTestId('install-confirm').click() + await expect(dialog.getByTestId('flow-steps')).toBeVisible() + await expect.poll(() => seen.install.length).toBe(1) + const { body, confirm } = seen.install[0] + expect(confirm).toBe('memory@sha256:' + SHA) + expect(body).toMatchObject({ action: 'install', sourceId: 'local-file', addonId: 'memory', version: ID }) + expect(body.downgrade).toBeUndefined() + expect(body.approve).toMatchObject({ digest: 'sha256:' + SHA, revision: 4, nonce: 'nonce-f' }) + expect(body.approve.ops.sort()).toEqual(['egress.check', 'workspace.materialize']) + // A fresh install is turned on, like a library install. + await expect.poll(() => seen.dispatch.some(d => d.action === 'enable')).toBe(true) + }) + + test('an upgrade says what it replaces, installs through the upgrade action and does not turn the add-on on', async ({ page }) => { + const { seen } = await open(page, { file: fileView({ installed: true, installedVersion: '0.1.0', relation: 'upgrade' }), installed: [installedAddon({ id: 'memory', title: 'Short memory', version: '0.1.0', sourceId: 'local-file', verification: 'sha256', enabled: false, state: 'stopped' })] }) + await page.goto('/app/addons') + await page.getByTestId('install-file-input').setInputFiles({ name: 'm.zip', mimeType: 'application/zip', buffer: Buffer.from('PK') }) + const dialog = page.getByTestId('install-dialog') + await expect(dialog.getByTestId('install-file-upgrade')).toContainText('replaces version 0.1.0 with version 0.2.0') + await dialog.getByTestId('install-confirm').click() + await expect.poll(() => seen.install.length).toBe(1) + expect(seen.install[0].body.action).toBe('upgrade') + expect(seen.install[0].body.downgrade).toBeUndefined() + await expect(dialog).toContainText('now runs version 0.2.0') + expect(seen.dispatch.filter(d => d.action === 'enable')).toHaveLength(0) + }) + + test('an older version needs a tick and sends the downgrade confirmation', async ({ page }) => { + const { seen } = await open(page, { file: fileView({ version: '0.0.9', installed: true, installedVersion: '0.1.0', relation: 'downgrade' }), installed: [installedAddon({ id: 'memory', title: 'Short memory', version: '0.1.0', sourceId: 'local-file', verification: 'sha256', enabled: false, state: 'stopped' })] }) + await page.goto('/app/addons') + await page.getByTestId('install-file-input').setInputFiles({ name: 'm.zip', mimeType: 'application/zip', buffer: Buffer.from('PK') }) + const dialog = page.getByTestId('install-dialog') + await expect(dialog.getByTestId('install-file-downgrade')).toContainText('older than the installed version 0.1.0') + await shoot(page, 'install-from-file-downgrade') + await expect(dialog.getByTestId('install-confirm')).toBeDisabled() + await dialog.getByTestId('install-file-downgrade-confirm').check() + await expect(dialog.getByTestId('install-confirm')).toBeEnabled() + await dialog.getByTestId('install-confirm').click() + await expect.poll(() => seen.install.length).toBe(1) + expect(seen.install[0].body.downgrade).toBe(true) + expect(seen.install[0].body.action).toBe('upgrade') + }) + + test('the same version cannot be installed again', async ({ page }) => { + await open(page, { file: fileView({ installed: true, installedVersion: '0.2.0', relation: 'same' }), installed: [installedAddon({ id: 'memory', title: 'Short memory', version: '0.2.0', sourceId: 'local-file', verification: 'sha256' })] }) + await page.goto('/app/addons') + await page.getByTestId('install-file-input').setInputFiles({ name: 'm.zip', mimeType: 'application/zip', buffer: Buffer.from('PK') }) + const dialog = page.getByTestId('install-dialog') + await expect(dialog.getByTestId('install-file-same')).toContainText('installed already') + await expect(dialog.getByTestId('install-confirm')).toBeDisabled() + }) + + test('shows the reason the owner gives when it refuses the file', async ({ page }) => { + await open(page, { uploadStatus: 400, uploadBody: { error: { type: 'wrong_platform', code: 400, message: 'This file is for linux/arm64; this host is linux/amd64' } } }) + await page.goto('/app/addons') + await page.getByTestId('install-file-input').setInputFiles({ name: 'm.zip', mimeType: 'application/zip', buffer: Buffer.from('PK') }) + const err = page.getByTestId('install-file-error') + await expect(err).toContainText('The file was not accepted.') + await expect(err).toContainText('This file is for linux/arm64; this host is linux/amd64') + await expect(page.getByTestId('install-dialog')).toHaveCount(0) + }) + + test('the link of the command line opens the dialog for the staged file, and cancel removes the file', async ({ page }) => { + const { seen } = await open(page) + await page.goto(`/app/addons?file=${ID}`) + const dialog = page.getByTestId('install-dialog') + await expect(dialog).toBeVisible() + expect(seen.described).toEqual([ID]) + expect(seen.uploads).toHaveLength(0) + await dialog.getByRole('button', { name: 'Cancel' }).click() + await expect(dialog).toHaveCount(0) + await expect.poll(() => seen.removed).toEqual([ID]) + await expect(page).not.toHaveURL(/file=/) + }) + + test('a link to a file that is gone says so', async ({ page }) => { + await open(page) + await page.goto(`/app/addons?file=${'e'.repeat(32)}`) + await expect(page.getByTestId('install-file-error')).toContainText('The file is gone') + }) + + test('the list and the host access page name an add-on from a file as unsigned', async ({ page }) => { + await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(ADMIN, { addons: true }) })) + const installed = installedAddon({ id: 'memory', title: 'Short memory', version: '0.2.0', sourceId: 'local-file', verification: 'sha256' }) + await mockAddons(page, { apps: [{ id: 'memory', name: 'memory', title: 'Short memory', description: 'Notes.', version: '0.2.0', generation: 1, enabled: true, state: 'running', hasUI: false, tools: [], toolCount: 0, launchMode: 'always' }], installed: [installed] }) + await page.route(/\/api\/addons\/host-grants$/, route => route.fulfill({ json: { + revision: 1, locked: false, blanket: { mode: 'off', includeOptional: false, setBy: 'ui', revision: 0 }, floor: { source: 'file', addons: [] }, deny: { file: {}, ui: {} }, + addons: [{ id: 'memory', title: 'Short memory', version: '0.2.0', state: 'running', sourceId: 'local-file', requested: { required: [], optional: [] }, ops: [], noLongerRequested: [], newOptional: [], identityMismatch: false, stopped: '' }], + } })) + await page.goto('/app/addons') + await expect(page.locator('[data-source="local-file"]').first()).toContainText('Local file, unsigned') + await page.goto('/app/addons?tab=access') + await expect(page.getByTestId('host-local-file')).toContainText('Local file, unsigned') + }) +}) diff --git a/core/http/react-ui/public/locales/de/addons.json b/core/http/react-ui/public/locales/de/addons.json index 9035d05391..e67d8ea382 100644 --- a/core/http/react-ui/public/locales/de/addons.json +++ b/core/http/react-ui/public/locales/de/addons.json @@ -897,5 +897,38 @@ "reviewNamed": "Den Host-Zugriff von {{name}} prüfen und freigeben", "title": "{{name}} {{version}} wartet auf deine Freigabe" } + }, + "file": { + "button": "Aus Datei installieren", + "checking": "Die Datei wird geprüft …", + "pick": "Eine ZIP-Datei eines Add-ons auswählen", + "rejected": "Die Datei wurde nicht akzeptiert.", + "gone": "Die Datei ist nicht mehr da. Eine Datei bleibt nach dem Hochladen eine Stunde lang erhalten. Lade sie erneut hoch.", + "installTitle": "{{name}} aus einer Datei installieren?", + "upgradeTitle": "{{name}} aus einer Datei aktualisieren?", + "confirmBody": "Version {{version}} aus der Datei {{name}} wird installiert und dann eingeschaltet.", + "confirmBodyUpgrade": "Version {{version}} aus der Datei {{name}} wird installiert. Das Add-on bleibt ein- oder ausgeschaltet, wie es jetzt ist.", + "upgradeAction": "Auf {{version}} aktualisieren", + "upgraded": "{{name}} läuft jetzt in Version {{version}}.", + "unsignedTitle": "Lokale Datei, nicht signiert", + "unsignedNotice": "Niemand signiert diese Datei, deshalb kann LocalAI nicht erkennen, wer sie erstellt hat. Installiere sie nur, wenn du ihrer Herkunft vertraust.", + "unsignedShort": "Lokale Datei, nicht signiert", + "badge": "Lokale Datei, nicht signiert", + "sourceValue": "Lokale Datei, nicht signiert", + "hostNotice": "Dieses Add-on stammt aus einer lokalen Datei, die niemand signiert. Gib ihm nur den Hostzugriff, den es nachweislich braucht.", + "facts": { + "file": "Datei", + "sha256": "SHA-256 der Datei", + "addon": "Add-on", + "platform": "Plattform", + "api": "Host-API" + }, + "relation": { + "upgrade": "Dies ersetzt Version {{installed}} durch Version {{version}}. Die Daten des Add-ons bleiben erhalten.", + "same": "Version {{version}} ist bereits installiert. Wähle eine Datei mit einer anderen Version.", + "downgradeTitle": "Ältere Version", + "downgrade": "Dies installiert Version {{version}}, die älter ist als die installierte Version {{installed}}. Eine ältere Version kann Daten, die eine neuere geschrieben hat, eventuell nicht lesen.", + "downgradeConfirm": "Ich möchte die ältere Version installieren" + } } } diff --git a/core/http/react-ui/public/locales/en/addons.json b/core/http/react-ui/public/locales/en/addons.json index e2500fd0cc..711ec53ca2 100644 --- a/core/http/react-ui/public/locales/en/addons.json +++ b/core/http/react-ui/public/locales/en/addons.json @@ -897,5 +897,38 @@ "reviewNamed": "Review and approve the host access of {{name}}", "title": "{{name}} {{version}} waits for your approval" } + }, + "file": { + "button": "Install from file", + "checking": "Checking the file…", + "pick": "Choose an add-on zip file", + "rejected": "The file was not accepted.", + "gone": "The file is gone. A file waits for one hour after the upload. Upload it again.", + "installTitle": "Install {{name}} from a file?", + "upgradeTitle": "Update {{name}} from a file?", + "confirmBody": "Version {{version}} from the file {{name}} is installed, then turned on.", + "confirmBodyUpgrade": "Version {{version}} from the file {{name}} is installed. The add-on stays on or off as it is now.", + "upgradeAction": "Update to {{version}}", + "upgraded": "{{name}} now runs version {{version}}.", + "unsignedTitle": "Local file, unsigned", + "unsignedNotice": "Nothing signs this file, so LocalAI cannot tell who made it. Install it only if you trust where it came from.", + "unsignedShort": "Local file, unsigned", + "badge": "Local file, unsigned", + "sourceValue": "Local file, unsigned", + "hostNotice": "This add-on came from a local file that nothing signs. Give it only the host access you are sure it needs.", + "facts": { + "file": "File", + "sha256": "File sha256", + "addon": "Add-on", + "platform": "Platform", + "api": "Host API" + }, + "relation": { + "upgrade": "This replaces version {{installed}} with version {{version}}. The data of the add-on is kept.", + "same": "Version {{version}} is installed already. Choose a file with another version.", + "downgradeTitle": "Older version", + "downgrade": "This installs version {{version}}, which is older than the installed version {{installed}}. An older version may not read data that a newer one wrote.", + "downgradeConfirm": "I want to install the older version" + } } } diff --git a/core/http/react-ui/public/locales/es/addons.json b/core/http/react-ui/public/locales/es/addons.json index 526b98bcc6..fab6041005 100644 --- a/core/http/react-ui/public/locales/es/addons.json +++ b/core/http/react-ui/public/locales/es/addons.json @@ -897,5 +897,38 @@ "reviewNamed": "Revisar y aprobar el acceso al host de {{name}}", "title": "{{name}} {{version}} espera tu aprobación" } + }, + "file": { + "button": "Instalar desde un archivo", + "checking": "Comprobando el archivo…", + "pick": "Elegir un archivo zip de un complemento", + "rejected": "No se aceptó el archivo.", + "gone": "El archivo ya no está. Un archivo se conserva una hora después de subirlo. Súbelo de nuevo.", + "installTitle": "¿Instalar {{name}} desde un archivo?", + "upgradeTitle": "¿Actualizar {{name}} desde un archivo?", + "confirmBody": "La versión {{version}} del archivo {{name}} se instala y después se activa.", + "confirmBodyUpgrade": "La versión {{version}} del archivo {{name}} se instala. El complemento sigue activado o desactivado como está ahora.", + "upgradeAction": "Actualizar a {{version}}", + "upgraded": "{{name}} ahora ejecuta la versión {{version}}.", + "unsignedTitle": "Archivo local, sin firma", + "unsignedNotice": "Nadie firma este archivo, así que LocalAI no puede saber quién lo creó. Instálalo solo si confías en su origen.", + "unsignedShort": "Archivo local, sin firma", + "badge": "Archivo local, sin firma", + "sourceValue": "Archivo local, sin firma", + "hostNotice": "Este complemento viene de un archivo local que nadie firma. Dale solo el acceso al host que estés seguro de que necesita.", + "facts": { + "file": "Archivo", + "sha256": "SHA-256 del archivo", + "addon": "Complemento", + "platform": "Plataforma", + "api": "API del host" + }, + "relation": { + "upgrade": "Esto reemplaza la versión {{installed}} por la versión {{version}}. Los datos del complemento se conservan.", + "same": "La versión {{version}} ya está instalada. Elige un archivo con otra versión.", + "downgradeTitle": "Versión anterior", + "downgrade": "Esto instala la versión {{version}}, que es anterior a la versión instalada {{installed}}. Una versión anterior puede no leer los datos que escribió una más nueva.", + "downgradeConfirm": "Quiero instalar la versión anterior" + } } } diff --git a/core/http/react-ui/public/locales/fr/addons.json b/core/http/react-ui/public/locales/fr/addons.json index 5cc8f6f46f..f893d9a82d 100644 --- a/core/http/react-ui/public/locales/fr/addons.json +++ b/core/http/react-ui/public/locales/fr/addons.json @@ -901,5 +901,38 @@ "reviewNamed": "Examiner et approuver l'accès à l'hôte de {{name}}", "title": "{{name}} {{version}} attend votre approbation" } + }, + "file": { + "button": "Installer depuis un fichier", + "checking": "Vérification du fichier…", + "pick": "Choisir un fichier zip de module", + "rejected": "Le fichier n’a pas été accepté.", + "gone": "Le fichier n’est plus là. Un fichier est conservé une heure après l’envoi. Envoyez-le à nouveau.", + "installTitle": "Installer {{name}} depuis un fichier ?", + "upgradeTitle": "Mettre à jour {{name}} depuis un fichier ?", + "confirmBody": "La version {{version}} du fichier {{name}} est installée, puis activée.", + "confirmBodyUpgrade": "La version {{version}} du fichier {{name}} est installée. Le module reste activé ou désactivé comme il l’est maintenant.", + "upgradeAction": "Passer à {{version}}", + "upgraded": "{{name}} fonctionne maintenant en version {{version}}.", + "unsignedTitle": "Fichier local, non signé", + "unsignedNotice": "Personne ne signe ce fichier, donc LocalAI ne peut pas savoir qui l’a créé. Ne l’installez que si vous faites confiance à son origine.", + "unsignedShort": "Fichier local, non signé", + "badge": "Fichier local, non signé", + "sourceValue": "Fichier local, non signé", + "hostNotice": "Ce module vient d’un fichier local que personne ne signe. Ne lui donnez que l’accès à l’hôte dont vous êtes sûr qu’il a besoin.", + "facts": { + "file": "Fichier", + "sha256": "SHA-256 du fichier", + "addon": "Module", + "platform": "Plateforme", + "api": "API de l’hôte" + }, + "relation": { + "upgrade": "Cela remplace la version {{installed}} par la version {{version}}. Les données du module sont conservées.", + "same": "La version {{version}} est déjà installée. Choisissez un fichier d’une autre version.", + "downgradeTitle": "Version plus ancienne", + "downgrade": "Cela installe la version {{version}}, plus ancienne que la version installée {{installed}}. Une version plus ancienne peut ne pas lire les données écrites par une version plus récente.", + "downgradeConfirm": "Je veux installer la version plus ancienne" + } } } diff --git a/core/http/react-ui/public/locales/id/addons.json b/core/http/react-ui/public/locales/id/addons.json index 8ec90046ea..969395f66a 100644 --- a/core/http/react-ui/public/locales/id/addons.json +++ b/core/http/react-ui/public/locales/id/addons.json @@ -897,5 +897,38 @@ "reviewNamed": "Tinjau dan setujui akses host {{name}}", "title": "{{name}} {{version}} menunggu persetujuan Anda" } + }, + "file": { + "button": "Pasang dari berkas", + "checking": "Memeriksa berkas…", + "pick": "Pilih berkas zip add-on", + "rejected": "Berkas tidak diterima.", + "gone": "Berkas sudah tidak ada. Berkas disimpan satu jam setelah diunggah. Unggah lagi.", + "installTitle": "Pasang {{name}} dari berkas?", + "upgradeTitle": "Perbarui {{name}} dari berkas?", + "confirmBody": "Versi {{version}} dari berkas {{name}} dipasang, lalu dinyalakan.", + "confirmBodyUpgrade": "Versi {{version}} dari berkas {{name}} dipasang. Add-on tetap menyala atau mati seperti sekarang.", + "upgradeAction": "Perbarui ke {{version}}", + "upgraded": "{{name}} sekarang berjalan di versi {{version}}.", + "unsignedTitle": "Berkas lokal, tanpa tanda tangan", + "unsignedNotice": "Tidak ada yang menandatangani berkas ini, jadi LocalAI tidak bisa tahu siapa yang membuatnya. Pasang hanya jika Anda memercayai asalnya.", + "unsignedShort": "Berkas lokal, tanpa tanda tangan", + "badge": "Berkas lokal, tanpa tanda tangan", + "sourceValue": "Berkas lokal, tanpa tanda tangan", + "hostNotice": "Add-on ini berasal dari berkas lokal yang tidak ditandatangani. Beri hanya akses host yang Anda yakini dibutuhkannya.", + "facts": { + "file": "Berkas", + "sha256": "SHA-256 berkas", + "addon": "Add-on", + "platform": "Platform", + "api": "API host" + }, + "relation": { + "upgrade": "Ini mengganti versi {{installed}} dengan versi {{version}}. Data add-on tetap disimpan.", + "same": "Versi {{version}} sudah terpasang. Pilih berkas dengan versi lain.", + "downgradeTitle": "Versi lebih lama", + "downgrade": "Ini memasang versi {{version}}, yang lebih lama daripada versi terpasang {{installed}}. Versi yang lebih lama mungkin tidak bisa membaca data yang ditulis versi yang lebih baru.", + "downgradeConfirm": "Saya ingin memasang versi yang lebih lama" + } } } diff --git a/core/http/react-ui/public/locales/it/addons.json b/core/http/react-ui/public/locales/it/addons.json index 2cefd05999..aed70c8090 100644 --- a/core/http/react-ui/public/locales/it/addons.json +++ b/core/http/react-ui/public/locales/it/addons.json @@ -897,5 +897,38 @@ "reviewNamed": "Controlla e approva l'accesso all'host di {{name}}", "title": "{{name}} {{version}} aspetta la tua approvazione" } + }, + "file": { + "button": "Installa da file", + "checking": "Controllo del file…", + "pick": "Scegli un file zip di un componente", + "rejected": "Il file non è stato accettato.", + "gone": "Il file non c’è più. Un file resta per un’ora dopo il caricamento. Caricalo di nuovo.", + "installTitle": "Installare {{name}} da un file?", + "upgradeTitle": "Aggiornare {{name}} da un file?", + "confirmBody": "La versione {{version}} del file {{name}} viene installata e poi attivata.", + "confirmBodyUpgrade": "La versione {{version}} del file {{name}} viene installata. Il componente resta attivo o disattivo come è ora.", + "upgradeAction": "Aggiorna a {{version}}", + "upgraded": "{{name}} ora esegue la versione {{version}}.", + "unsignedTitle": "File locale, non firmato", + "unsignedNotice": "Nessuno firma questo file, quindi LocalAI non può sapere chi l’ha creato. Installalo solo se ti fidi della sua origine.", + "unsignedShort": "File locale, non firmato", + "badge": "File locale, non firmato", + "sourceValue": "File locale, non firmato", + "hostNotice": "Questo componente arriva da un file locale che nessuno firma. Dagli solo l’accesso all’host che sei sicuro gli serva.", + "facts": { + "file": "File", + "sha256": "SHA-256 del file", + "addon": "Componente", + "platform": "Piattaforma", + "api": "API dell’host" + }, + "relation": { + "upgrade": "Questo sostituisce la versione {{installed}} con la versione {{version}}. I dati del componente vengono conservati.", + "same": "La versione {{version}} è già installata. Scegli un file con un’altra versione.", + "downgradeTitle": "Versione precedente", + "downgrade": "Questo installa la versione {{version}}, più vecchia della versione installata {{installed}}. Una versione più vecchia può non leggere i dati scritti da una più nuova.", + "downgradeConfirm": "Voglio installare la versione precedente" + } } } diff --git a/core/http/react-ui/public/locales/ko/addons.json b/core/http/react-ui/public/locales/ko/addons.json index 60b2797831..b74470e7e5 100644 --- a/core/http/react-ui/public/locales/ko/addons.json +++ b/core/http/react-ui/public/locales/ko/addons.json @@ -897,5 +897,38 @@ "reviewNamed": "{{name}}의 호스트 접근 검토 후 승인", "title": "{{name}} {{version}}이(가) 승인을 기다립니다" } + }, + "file": { + "button": "파일에서 설치", + "checking": "파일을 확인하는 중…", + "pick": "애드온 zip 파일 선택", + "rejected": "파일이 받아들여지지 않았습니다.", + "gone": "파일이 없습니다. 파일은 업로드 후 한 시간 동안 보관됩니다. 다시 업로드하세요.", + "installTitle": "파일에서 {{name}}을(를) 설치할까요?", + "upgradeTitle": "파일에서 {{name}}을(를) 업데이트할까요?", + "confirmBody": "파일 {{name}}의 {{version}} 버전을 설치한 다음 켭니다.", + "confirmBodyUpgrade": "파일 {{name}}의 {{version}} 버전을 설치합니다. 애드온은 지금처럼 켜짐 또는 꺼짐 상태를 유지합니다.", + "upgradeAction": "{{version}}(으)로 업데이트", + "upgraded": "{{name}}이(가) 이제 {{version}} 버전으로 실행됩니다.", + "unsignedTitle": "로컬 파일, 서명 없음", + "unsignedNotice": "이 파일에는 서명한 사람이 없어서 LocalAI는 누가 만들었는지 알 수 없습니다. 출처를 신뢰할 때만 설치하세요.", + "unsignedShort": "로컬 파일, 서명 없음", + "badge": "로컬 파일, 서명 없음", + "sourceValue": "로컬 파일, 서명 없음", + "hostNotice": "이 애드온은 아무도 서명하지 않은 로컬 파일에서 왔습니다. 꼭 필요하다고 확신하는 호스트 접근 권한만 주세요.", + "facts": { + "file": "파일", + "sha256": "파일 SHA-256", + "addon": "애드온", + "platform": "플랫폼", + "api": "호스트 API" + }, + "relation": { + "upgrade": "설치된 {{installed}} 버전을 {{version}} 버전으로 바꿉니다. 애드온의 데이터는 유지됩니다.", + "same": "{{version}} 버전은 이미 설치되어 있습니다. 다른 버전의 파일을 선택하세요.", + "downgradeTitle": "이전 버전", + "downgrade": "설치된 {{installed}} 버전보다 오래된 {{version}} 버전을 설치합니다. 이전 버전은 새 버전이 쓴 데이터를 읽지 못할 수 있습니다.", + "downgradeConfirm": "이전 버전을 설치하겠습니다" + } } } diff --git a/core/http/react-ui/public/locales/pt-BR/addons.json b/core/http/react-ui/public/locales/pt-BR/addons.json index eb62309fce..9bda43a4ca 100644 --- a/core/http/react-ui/public/locales/pt-BR/addons.json +++ b/core/http/react-ui/public/locales/pt-BR/addons.json @@ -897,5 +897,38 @@ "reviewNamed": "Revisar e aprovar o acesso ao host de {{name}}", "title": "{{name}} {{version}} aguarda sua aprovação" } + }, + "file": { + "button": "Instalar de um arquivo", + "checking": "Verificando o arquivo…", + "pick": "Escolher um arquivo zip de complemento", + "rejected": "O arquivo não foi aceito.", + "gone": "O arquivo não está mais lá. Um arquivo fica por uma hora depois do envio. Envie de novo.", + "installTitle": "Instalar {{name}} de um arquivo?", + "upgradeTitle": "Atualizar {{name}} de um arquivo?", + "confirmBody": "A versão {{version}} do arquivo {{name}} é instalada e depois ligada.", + "confirmBodyUpgrade": "A versão {{version}} do arquivo {{name}} é instalada. O complemento continua ligado ou desligado como está agora.", + "upgradeAction": "Atualizar para {{version}}", + "upgraded": "{{name}} agora roda na versão {{version}}.", + "unsignedTitle": "Arquivo local, sem assinatura", + "unsignedNotice": "Ninguém assina este arquivo, então o LocalAI não sabe quem o criou. Instale só se confiar na origem.", + "unsignedShort": "Arquivo local, sem assinatura", + "badge": "Arquivo local, sem assinatura", + "sourceValue": "Arquivo local, sem assinatura", + "hostNotice": "Este complemento veio de um arquivo local que ninguém assina. Dê a ele só o acesso ao host de que você tem certeza que ele precisa.", + "facts": { + "file": "Arquivo", + "sha256": "SHA-256 do arquivo", + "addon": "Complemento", + "platform": "Plataforma", + "api": "API do host" + }, + "relation": { + "upgrade": "Isto troca a versão {{installed}} pela versão {{version}}. Os dados do complemento são mantidos.", + "same": "A versão {{version}} já está instalada. Escolha um arquivo com outra versão.", + "downgradeTitle": "Versão mais antiga", + "downgrade": "Isto instala a versão {{version}}, que é mais antiga que a versão instalada {{installed}}. Uma versão mais antiga pode não ler dados escritos por uma mais nova.", + "downgradeConfirm": "Quero instalar a versão mais antiga" + } } } diff --git a/core/http/react-ui/public/locales/zh-CN/addons.json b/core/http/react-ui/public/locales/zh-CN/addons.json index ff163c4c09..9d611ffc22 100644 --- a/core/http/react-ui/public/locales/zh-CN/addons.json +++ b/core/http/react-ui/public/locales/zh-CN/addons.json @@ -897,5 +897,38 @@ "reviewNamed": "查看并批准 {{name}} 的主机访问", "title": "{{name}} {{version}} 等待你的批准" } + }, + "file": { + "button": "从文件安装", + "checking": "正在检查文件…", + "pick": "选择附加组件的 zip 文件", + "rejected": "文件未被接受。", + "gone": "文件已不存在。文件在上传后保留一小时。请重新上传。", + "installTitle": "从文件安装 {{name}}?", + "upgradeTitle": "从文件更新 {{name}}?", + "confirmBody": "将安装文件 {{name}} 中的 {{version}} 版本,然后启用。", + "confirmBodyUpgrade": "将安装文件 {{name}} 中的 {{version}} 版本。附加组件保持现在的开启或关闭状态。", + "upgradeAction": "更新到 {{version}}", + "upgraded": "{{name}} 现在运行 {{version}} 版本。", + "unsignedTitle": "本地文件,未签名", + "unsignedNotice": "没有人为此文件签名,因此 LocalAI 无法确定它由谁制作。只有在你信任其来源时才安装。", + "unsignedShort": "本地文件,未签名", + "badge": "本地文件,未签名", + "sourceValue": "本地文件,未签名", + "hostNotice": "此附加组件来自没有人签名的本地文件。只授予你确信它需要的主机访问权限。", + "facts": { + "file": "文件", + "sha256": "文件 SHA-256", + "addon": "附加组件", + "platform": "平台", + "api": "主机 API" + }, + "relation": { + "upgrade": "这会把 {{installed}} 版本替换为 {{version}} 版本。附加组件的数据会保留。", + "same": "{{version}} 版本已安装。请选择另一个版本的文件。", + "downgradeTitle": "较旧的版本", + "downgrade": "这会安装 {{version}} 版本,它比已安装的 {{installed}} 版本更旧。较旧的版本可能无法读取较新版本写入的数据。", + "downgradeConfirm": "我要安装较旧的版本" + } } } diff --git a/core/http/react-ui/src/components/addons/AddonRow.jsx b/core/http/react-ui/src/components/addons/AddonRow.jsx index 516dfd7895..3f4364543d 100644 --- a/core/http/react-ui/src/components/addons/AddonRow.jsx +++ b/core/http/react-ui/src/components/addons/AddonRow.jsx @@ -5,6 +5,7 @@ import Button from '../ui/Button' import { apiUrl } from '../../utils/basePath' import { AppIcon, ApprovalChip, BuiltinIcon, StateChip, UpdateChip, VersionText } from './AddonBits' import AddonMenu from './AddonMenu' +import { isLocalFile } from './fileInstall' import { addedByAddon, stateChip } from './addonState' // One installed add-on as a row: identity tile, name and state, one line of @@ -49,7 +50,7 @@ export default function AddonRow({ tile, pinned, selected = false, attention = ' )} {!app && {t('meta.builtinLong')}} {source && source.sourceId && ( - + )} {app && } diff --git a/core/http/react-ui/src/components/addons/HostAccessView.jsx b/core/http/react-ui/src/components/addons/HostAccessView.jsx index 1c1a9e82fb..a01df7111f 100644 --- a/core/http/react-ui/src/components/addons/HostAccessView.jsx +++ b/core/http/react-ui/src/components/addons/HostAccessView.jsx @@ -10,6 +10,7 @@ import ApproveUpgradeDialog, { WaitingBanner } from './ApproveUpgradeDialog' import BlanketPanel from './BlanketPanel' import DenySection from './DenySection' import { opNote } from './HostAccessList' +import { isLocalFile } from './fileInstall' import { stateChip } from './addonState' import { describeError } from './errors' import { blanketHolders, editedOps, groupOps, opEditable, pageOps, pendingApprovals } from './hostGrantsState' @@ -98,6 +99,7 @@ export default function HostAccessView({ grants, api = hostGrantsApi }) { )} + {isLocalFile(addon.sourceId) && {t('file.hostNotice')}} {stoppedText && {stoppedText}} {addon.identityMismatch && revokeAll(addon)}>{t('hostAccess.removeStale')}}>{t('hostAccess.identityMismatch')}} {addon.newOptional.length > 0 && {t('hostAccess.newOptional', { ops: addon.newOptional.join(', ') })}} diff --git a/core/http/react-ui/src/components/addons/InstallDialog.jsx b/core/http/react-ui/src/components/addons/InstallDialog.jsx index ce2a80c61b..c94f2652cd 100644 --- a/core/http/react-ui/src/components/addons/InstallDialog.jsx +++ b/core/http/react-ui/src/components/addons/InstallDialog.jsx @@ -1,5 +1,5 @@ /* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */ -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import { Link } from 'react-router-dom' import { useTranslation } from 'react-i18next' import Icon from '../Icon' @@ -13,6 +13,7 @@ import Callout from '../ui/Callout' import HostAccessList from './HostAccessList' import { useHostPreview } from './useHostPreview' import { approvalBody, hostErrorKey, installBlock, needsStep } from './hostGrantsState' +import { fileAction, fileGate } from './fileInstall' // The built-in chat tools that the entry replaces and that an installed add-on // already replaces: only one of them can serve the tool, so the dialog says so @@ -42,6 +43,10 @@ export default function InstallDialog({ entry, flow, actions, onClose, installed // really stages. const host = useHostPreview(entry, { enabled: Boolean(entry) && step === 'ask' }) const approval = useRef(null) + // An install from a file: a package nothing signs. The person sees the file's + // own facts and, for an older version, ticks a box first. + const local = entry ? entry.local : undefined + const [downgrade, setDowngrade] = useState(false) useEffect(() => { if (!entry) return undefined @@ -74,13 +79,20 @@ export default function InstallDialog({ entry, flow, actions, onClose, installed if (!entry) return null const showHost = host.status === 'ready' && needsStep(host.preview) const block = showHost ? installBlock(host.preview) : null + const gate = local ? fileGate(local, { downgradeTicked: downgrade }) : { allowed: true } const start = () => { + if (local) { + // A file installs only with the approval of this dialog, with or without + // host operations: the owner refuses a file install that has none. + approval.current = approvalBody(host.preview, host.ticked, { action: fileAction(local), sourceId: entry.sourceId, addonId: entry.id, version: entry.version, downgrade: local.relation === 'downgrade' && downgrade }) + return actions.installAndTurnOn(entry, { approval: approval.current, turnOn: !local.installed }) + } approval.current = showHost ? approvalBody(host.preview, host.ticked, { action: 'install', sourceId: entry.sourceId, addonId: entry.id, version: entry.version }) : null return actions.installAndTurnOn(entry, approval.current ? { approval: approval.current } : {}) } - const retry = () => actions.installAndTurnOn(entry, { skipInstall: flow.failedStep === 'turn-on', approval: approval.current }) + const retry = () => actions.installAndTurnOn(entry, { skipInstall: flow.failedStep === 'turn-on', approval: approval.current, turnOn: !(local && local.installed) }) const stepState = (id) => { if (step === 'ask') return 'pending' if (step === 'done') return 'done' @@ -92,13 +104,35 @@ export default function InstallDialog({ entry, flow, actions, onClose, installed
e.stopPropagation()}>
- {t('flow.confirmTitle', { name: entry.title })} + {local ? t(local.installed ? 'file.upgradeTitle' : 'file.installTitle', { name: entry.title }) : t('flow.confirmTitle', { name: entry.title })}
{step === 'ask' && ( <> -

{t('flow.confirmBody', { version: entry.version, library: entry.sourceId })}

- + {local + ?

{t(local.installed ? 'file.confirmBodyUpgrade' : 'file.confirmBody', { version: local.version, name: local.name })}

+ :

{t('flow.confirmBody', { version: entry.version, library: entry.sourceId })}

} + {local && ( + + {t('file.unsignedNotice')} + + )} + {local && local.relation === 'upgrade' && ( +

{t('file.relation.upgrade', { installed: local.installedVersion, version: local.version })}

+ )} + {local && local.relation === 'same' && ( + {t('file.relation.same', { version: local.version })} + )} + {local && local.relation === 'downgrade' && ( + +

{t('file.relation.downgrade', { installed: local.installedVersion, version: local.version })}

+ +
+ )} + {!local && } {shadowConflicts(entry, installed).map(c => ( {t('flow.shadowWarning', { tool: c.tool, name: c.name })} @@ -119,7 +153,7 @@ export default function InstallDialog({ entry, flow, actions, onClose, installed

{t('hostAccess.stepTitle')}

{t('hostAccess.stepIntro', { name: entry.title })}

- {host.preview.official === false && ( + {!local && host.preview.official === false && (

{t('hostAccess.notOfficial', { library: entry.sourceId })}

)} @@ -132,10 +166,21 @@ export default function InstallDialog({ entry, flow, actions, onClose, installed

{t('hostAccess.stepFooter')}

)} -
-
{t('trust.digest')}
{shortDigest(entry.digest)}
-
{t('trust.verification')}
{entry.policy.requireSignature ? t('trust.signatureRequired') : t('trust.digestOnly')}
-
+ {local ? ( +
+
{t('file.facts.file')}
{local.name}
+
{t('file.facts.sha256')}
{local.sha256}
+
{t('file.facts.addon')}
{local.addonId} {local.version}
+
{t('file.facts.platform')}
{local.platform}
+
{t('file.facts.api')}
{local.api.min === local.api.max ? local.api.min : `${local.api.min} - ${local.api.max}`}
+
{t('trust.verification')}
{t('file.unsignedShort')}
+
+ ) : ( +
+
{t('trust.digest')}
{shortDigest(entry.digest)}
+
{t('trust.verification')}
{entry.policy.requireSignature ? t('trust.signatureRequired') : t('trust.digestOnly')}
+
+ )} )} {step !== 'ask' && ( @@ -154,7 +199,7 @@ export default function InstallDialog({ entry, flow, actions, onClose, installed )} {step === 'done' && (

- {t('browse.installedOn', { name: entry.title })} + {local && local.installed ? t('file.upgraded', { name: entry.title, version: local.version }) : t('browse.installedOn', { name: entry.title })} {entry.ui && entry.ui.mainPage && <> {t('flow.seeInList')}}

)} @@ -168,7 +213,7 @@ export default function InstallDialog({ entry, flow, actions, onClose, installed
{step === 'ask' && } - {step === 'ask' && } + {step === 'ask' && } {running && } {step === 'failed' && } {step === 'failed' && !actions.unknown[entry.id] && } diff --git a/core/http/react-ui/src/components/addons/InstallFromFile.jsx b/core/http/react-ui/src/components/addons/InstallFromFile.jsx new file mode 100644 index 0000000000..4da19e6160 --- /dev/null +++ b/core/http/react-ui/src/components/addons/InstallFromFile.jsx @@ -0,0 +1,85 @@ +/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useSearchParams } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import { Button, Callout } from '../ui' +import InstallDialog from './InstallDialog' +import { useAddonActions } from './useAddonActions' +import { describeError } from './errors' +import { addonsApi, addonsCatalog, addonFilesApi } from '../../utils/addonsClient' +import { fileEntry, fileIdFromParams, normalizeFile } from './fileInstall' + +// Install from a file. The button takes the add-on zip that `make addons-zip` +// writes; the owner checks it and the person sees the same dialog as for a +// library entry, with the host access step and the notice that nothing signs a +// local file. A link from `local-ai addons install` carries ?file= of a +// file the command line staged, and opens the dialog straight away. +export default function InstallFromFile({ onInstalled, filesApi = addonFilesApi }) { + const { t } = useTranslation('addons') + const [params, setParams] = useSearchParams() + const input = useRef(null) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const [entry, setEntry] = useState(null) + const actions = useAddonActions(addonsApi, { onSettled: () => onInstalled && onInstalled(), adapter: addonsCatalog }) + const linked = fileIdFromParams(params) + const asked = useRef('') + + const show = useCallback((raw) => { + const file = normalizeFile(raw) + if (!file.fileId || !file.addonId) throw new Error('unexpected answer') + setEntry(fileEntry(file)) + }, []) + + const fail = useCallback((e) => { + setError(e && e.type === 'not_found' ? t('file.gone') : describeError(e, t)) + }, [t]) + + const pick = async (e) => { + const file = e.target.files && e.target.files[0] + e.target.value = '' + if (!file) return + setError(null) + setBusy(true) + try { show(await filesApi.upload(file)) } catch (err) { fail(err) } finally { setBusy(false) } + } + + // A staged file the link names. Read once per id. + useEffect(() => { + if (!linked || asked.current === linked) return undefined + asked.current = linked + const ctl = new AbortController() + setBusy(true) + filesApi.describe(linked, ctl.signal) + .then(show) + .catch(err => { if (!err || err.name !== 'AbortError') fail(err) }) + .finally(() => setBusy(false)) + return () => ctl.abort() + }, [linked, filesApi, show, fail]) + + const close = () => { + const done = entry + setEntry(null) + if (linked) setParams(prev => { const out = new URLSearchParams(prev); out.delete('file'); return out }, { replace: true }) + // A file that was not installed is not kept for the next visit. + const flow = done && actions.flows[`flow:${done.key}`] + if (done && !(flow && flow.step === 'done')) filesApi.remove(done.local.fileId).catch(() => {}) + } + + return ( + <> + + + {error && ( +
+ setError(null)}>{t('catalog.close')}}> + {t('file.rejected')} {error} + +
+ )} + + + ) +} diff --git a/core/http/react-ui/src/components/addons/OperatorFacts.jsx b/core/http/react-ui/src/components/addons/OperatorFacts.jsx index 8d07b6283c..28db4bf0cf 100644 --- a/core/http/react-ui/src/components/addons/OperatorFacts.jsx +++ b/core/http/react-ui/src/components/addons/OperatorFacts.jsx @@ -7,6 +7,7 @@ import { describeError } from './errors' import { updateView, formatWhen, upgradeAvailability } from './updateState' import { upgradeFailureWords } from './updateReview' import RecoveryPanel from './RecoveryPanel' +import { isLocalFile } from './fileInstall' import { addonUpgrades } from '../../utils/addonUpgrades' import { reasonText } from './UpgradeControls' import { UpgradeReason } from '../upgrade/UpgradeBits' @@ -25,7 +26,7 @@ export function OperatorFacts({ addon, api, language, actions, check, upgraded,

{t('manage.trust')}

{t('trust.verification')}
{t(`trust.mode.${addon.verification === 'sha256+sigstore' ? 'signed' : 'digest'}`)}
-
{t('trust.library')}
{addon.sourceId || '—'}
+
{t('trust.library')}
{isLocalFile(addon.sourceId) ? t('file.sourceValue') : (addon.sourceId || '—')}
{addon.channel && (<>
{t('trust.channel')}
{addon.channel}
)} {addon.selection && (<>
{t('trust.selection')}
{addon.selection}
)}
{t('trust.digest')}
{shortDigest(addon.digest)}
diff --git a/core/http/react-ui/src/components/addons/fileInstall.js b/core/http/react-ui/src/components/addons/fileInstall.js new file mode 100644 index 0000000000..f67309b909 --- /dev/null +++ b/core/http/react-ui/src/components/addons/fileInstall.js @@ -0,0 +1,73 @@ +// Pure helpers of the install from a file: what the owner found in an +// uploaded zip, shaped for the install dialog that library entries use. + +// The source of a package that came from a file. Nothing signs it. +export const LOCAL_FILE = 'local-file' + +export function isLocalFile(sourceId) { + return sourceId === LOCAL_FILE +} + +const RELATIONS = new Set(['new', 'upgrade', 'same', 'downgrade']) + +const list = (v) => (Array.isArray(v) ? v.filter(x => typeof x === 'string') : []) +const text = (v, max = 200) => (typeof v === 'string' ? v.slice(0, max) : '') + +// The answer of the upload, checked field by field. Anything the owner did +// not send in the expected shape is dropped, never guessed. +export function normalizeFile(raw) { + const v = raw && typeof raw === 'object' ? raw : {} + const api = v.api && typeof v.api === 'object' ? v.api : {} + return { + fileId: text(v.fileId, 64), + name: text(v.name), + size: Number.isFinite(v.size) ? v.size : 0, + sha256: text(v.sha256, 64).toLowerCase(), + addonId: text(v.addonId, 64), + title: text(v.title) || text(v.addonId, 64), + version: text(v.version, 64), + platform: text(v.platform, 32), + api: { min: Number.isFinite(api.min) ? api.min : 0, max: Number.isFinite(api.max) ? api.max : 0 }, + required: list(v.required), + optional: list(v.optional), + installed: v.installed === true, + installedVersion: text(v.installedVersion, 64), + relation: RELATIONS.has(v.relation) ? v.relation : 'new', + } +} + +// The entry the install dialog works on. The dialog asks the owner what the +// file needs from the host with the same preview as for a library entry: the +// source is local-file and the version is the id of the staged file. +export function fileEntry(file) { + return { + key: `${LOCAL_FILE}/${file.addonId}/${file.fileId}`, + id: file.addonId, + title: file.title, + sourceId: LOCAL_FILE, + version: file.fileId, + digest: `sha256:${file.sha256}`, + policy: { requireSignature: false }, + shadows: [], + local: file, + } +} + +// Whether the file may be installed at all, and what the person must confirm +// first. The same version is never installed again; an older one needs a tick. +export function fileGate(file, { downgradeTicked = false } = {}) { + if (!file || file.relation === 'same') return { allowed: false, reason: 'same' } + if (file.relation === 'downgrade' && !downgradeTicked) return { allowed: false, reason: 'downgrade' } + return { allowed: true, reason: '' } +} + +// The lifecycle action the file needs: an installed add-on is upgraded. +export function fileAction(file) { + return file && file.installed ? 'upgrade' : 'install' +} + +// The file id a link carries in ?file=, or '' when it has not the shape of one. +export function fileIdFromParams(params) { + const id = params && typeof params.get === 'function' ? params.get('file') : '' + return /^[0-9a-f]{32}$/.test(id || '') ? id : '' +} diff --git a/core/http/react-ui/src/components/addons/hostGrantsState.js b/core/http/react-ui/src/components/addons/hostGrantsState.js index 7b9fee530c..5f9022bdfb 100644 --- a/core/http/react-ui/src/components/addons/hostGrantsState.js +++ b/core/http/react-ui/src/components/addons/hostGrantsState.js @@ -125,10 +125,11 @@ export function approvedOps(preview, ticked) { } // The body of the install or upgrade that carries the approval. -export function approvalBody(preview, ticked, { operationId, action = 'install', sourceId, addonId, version, blanketAck } = {}) { +export function approvalBody(preview, ticked, { operationId, action = 'install', sourceId, addonId, version, blanketAck, downgrade } = {}) { const approve = { digest: preview.digest, ops: approvedOps(preview, ticked), revision: preview.revision, nonce: preview.nonce } if (blanketAck) approve.blanketAck = true - return { operationId, action, sourceId, addonId, version, approve } + // Only an install from a file can be a downgrade, and only after a tick. + return { operationId, action, sourceId, addonId, version, ...(downgrade ? { downgrade: true } : {}), approve } } // The operations grouped by family (the part before the first dot), in the diff --git a/core/http/react-ui/src/components/addons/installAndTurnOn.js b/core/http/react-ui/src/components/addons/installAndTurnOn.js index 9b77d8a280..d57a26e017 100644 --- a/core/http/react-ui/src/components/addons/installAndTurnOn.js +++ b/core/http/react-ui/src/components/addons/installAndTurnOn.js @@ -11,7 +11,7 @@ import { normalizeOperation } from '../../utils/addonsApi.js' // route, which grants only what the verified package declares; without it the // plain install runs, and the owner refuses it when the add-on needs a // host operation that is not held. -export async function runInstallAndTurnOn({ run, api, adapter, hostGrants, mark }, entry, { skipInstall = false, approval = null } = {}) { +export async function runInstallAndTurnOn({ run, api, adapter, hostGrants, mark }, entry, { skipInstall = false, approval = null, turnOn = true } = {}) { if (!skipInstall) { mark({ step: 'install' }) const installed = await run(`install:${entry.key}`, { @@ -27,6 +27,8 @@ export async function runInstallAndTurnOn({ run, api, adapter, hostGrants, mark if (!installed) return null if (installed.outcome !== 'completed') { mark({ step: 'failed', failedStep: 'install', outcome: installed.outcome, error: installed.error }); return installed } } + // An upgrade from a file keeps the add-on as it was: it does not turn it on. + if (!turnOn) { mark({ step: 'done' }); return { outcome: 'completed' } } mark({ step: 'turn-on' }) // Generation 0 sets no precondition: the add-on was just installed. const on = await run(`enable:${entry.id}:0`, { diff --git a/core/http/react-ui/src/pages/Addons.jsx b/core/http/react-ui/src/pages/Addons.jsx index 4f4e5384e0..257c1286e0 100644 --- a/core/http/react-ui/src/pages/Addons.jsx +++ b/core/http/react-ui/src/pages/Addons.jsx @@ -9,6 +9,7 @@ import AppsView from '../components/addons/AppsView' import BrowseView from '../components/addons/BrowseView' import LibrariesView from '../components/addons/LibrariesView' import HostAccessView from '../components/addons/HostAccessView' +import InstallFromFile from '../components/addons/InstallFromFile' import { useHostGrants } from '../components/addons/useHostGrants' import { pendingApprovals } from '../components/addons/hostGrantsState' import { resolveManage, resolveTab, visibleTabs } from '../components/addons/addonState' @@ -24,6 +25,8 @@ export default function Addons() { // The installed count for the tab and whether the list is a first run (the // empty state then carries the way to Find add-ons, so the header does not). const [summary, setSummary] = useState(null) + // A file install changes the list; the list is read again. + const [listKey, setListKey] = useState(0) const [params, setParams] = useSearchParams() const manage = resolveManage(params, isAdmin) const tab = manage ? 'apps' : resolveTab(params.get('tab'), isAdmin) @@ -68,7 +71,12 @@ export default function Addons() { {t('supporting')}{pagesNote && <> {pagesNote}}} - actions={showFind ? : null} + actions={isAdmin && tab === 'apps' ? ( + <> + {showFind && } + setListKey(n => n + 1)} /> + + ) : null} /> {tabs.length > 1 && ( @@ -95,7 +103,7 @@ export default function Addons() { )}
1 ? `ad-tab-${tab}` : undefined} aria-label={tabs.length > 1 ? undefined : t('tabs.apps')} className="ad-panel"> - {tab === 'apps' && } + {tab === 'apps' && } {tab === 'browse' && } {tab === 'libraries' && } {tab === 'access' && } diff --git a/core/http/react-ui/src/utils/addonFilesApi.js b/core/http/react-ui/src/utils/addonFilesApi.js new file mode 100644 index 0000000000..95ad970fbe --- /dev/null +++ b/core/http/react-ui/src/utils/addonFilesApi.js @@ -0,0 +1,37 @@ +// Client of the add-on file routes: an add-on zip is staged on the owner first, +// then installed through the host access install. Staging runs nothing; the +// owner keeps the file for an hour, for the person who uploaded it. +import { classifyHostGrants } from './hostGrantsApi.js' + +// The id of a staged file: 32 lower-case hex characters. +export const FILE_ID = /^[0-9a-f]{32}$/ + +// The rules a file can break, and the answers about a staged file. Each is a +// final answer with words of its own from the owner. +const TYPES = new Set([ + 'bad_zip', 'unsafe_path', 'unsupported_entry', 'too_large', 'too_many_files', 'compression_ratio', + 'bad_manifest', 'digest_mismatch', 'wrong_platform', 'bad_mode', 'not_found', 'busy', 'invalid_request', +]) + +export function classifyFiles(status, body) { + const err = body && typeof body === 'object' ? body.error : null + const type = err && typeof err === 'object' && typeof err.type === 'string' ? err.type : '' + if (TYPES.has(type)) { + return { kind: 'invalid', type, status, message: typeof err.message === 'string' ? err.message : '', details: err } + } + return classifyHostGrants(status, body) +} + +export function createAddonFilesApi(addonsApi) { + const call = (path, opts = {}) => addonsApi.call(`/api/addons/files${path}`, { classify: classifyFiles, ...opts }) + return { + // Sends the file and answers with what the owner found in it. + upload(file, signal) { + const form = new FormData() + form.append('file', file, file.name) + return call('', { method: 'POST', body: form, signal }) + }, + describe(id, signal) { return call(`/${encodeURIComponent(id)}`, { signal }) }, + async remove(id, signal) { await call(`/${encodeURIComponent(id)}`, { method: 'DELETE', signal }) }, + } +} diff --git a/core/http/react-ui/src/utils/addonsApi.js b/core/http/react-ui/src/utils/addonsApi.js index 1b51b4cec9..311eeb51d7 100644 --- a/core/http/react-ui/src/utils/addonsApi.js +++ b/core/http/react-ui/src/utils/addonsApi.js @@ -59,11 +59,13 @@ export function createAddonsApi({ fetch: fetchImpl, base = p => p } = {}) { async function call(path, { method = 'GET', body, signal, headers: extra, classify = classifyResponse } = {}) { let response try { + // A form goes as it is: the browser writes the multipart boundary. + const form = typeof FormData !== 'undefined' && body instanceof FormData response = await doFetch(base(path), { method, signal, - headers: { ...(body === undefined ? { Accept: 'application/json' } : { Accept: 'application/json', 'Content-Type': 'application/json' }), ...(extra || {}) }, - body: body === undefined ? undefined : JSON.stringify(body), + headers: { ...(body === undefined || form ? { Accept: 'application/json' } : { Accept: 'application/json', 'Content-Type': 'application/json' }), ...(extra || {}) }, + body: body === undefined ? undefined : form ? body : JSON.stringify(body), credentials: 'same-origin', }) } catch (e) { diff --git a/core/http/react-ui/src/utils/addonsClient.js b/core/http/react-ui/src/utils/addonsClient.js index a2acb43cbe..b471619cd2 100644 --- a/core/http/react-ui/src/utils/addonsClient.js +++ b/core/http/react-ui/src/utils/addonsClient.js @@ -2,8 +2,10 @@ import { apiUrl } from './basePath' import { createAddonsApi, createAddonsCatalogAdapter, normalizeOperation } from './addonsApi' import { createHostGrantsApi } from './hostGrantsApi' +import { createAddonFilesApi } from './addonFilesApi' export const addonsApi = createAddonsApi({ base: apiUrl }) export const addonsCatalog = createAddonsCatalogAdapter(addonsApi) +export const addonFilesApi = createAddonFilesApi(addonsApi) export const hostGrantsApi = createHostGrantsApi(addonsApi) export { normalizeOperation } diff --git a/core/http/react-ui/tests/unit/addonfile.test.mjs b/core/http/react-ui/tests/unit/addonfile.test.mjs new file mode 100644 index 0000000000..0ef33338d6 --- /dev/null +++ b/core/http/react-ui/tests/unit/addonfile.test.mjs @@ -0,0 +1,120 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { LOCAL_FILE, isLocalFile, normalizeFile, fileEntry, fileGate, fileAction, fileIdFromParams } from '../../src/components/addons/fileInstall.js' +import { approvalBody } from '../../src/components/addons/hostGrantsState.js' +import { createAddonFilesApi } from '../../src/utils/addonFilesApi.js' +import { createAddonsApi, AddonsError } from '../../src/utils/addonsApi.js' +import { runInstallAndTurnOn } from '../../src/components/addons/installAndTurnOn.js' + +const ID = 'a'.repeat(32) +const RAW = { + fileId: ID, name: 'memory-0.2.0-linux-amd64.zip', size: 1234, sha256: 'AB'.repeat(32), addonId: 'memory', title: 'Short memory', + version: '0.2.0', platform: 'linux/amd64', api: { min: 2, max: 2 }, required: ['workspace.materialize'], optional: ['egress.check'], + installed: true, installedVersion: '0.1.0', relation: 'upgrade', origin: 'local file, unsigned', +} + +test('addonfile: the answer of the upload is checked field by field', () => { + const f = normalizeFile(RAW) + assert.equal(f.sha256, 'ab'.repeat(32)) + assert.deepEqual(f.api, { min: 2, max: 2 }) + assert.equal(f.relation, 'upgrade') + assert.deepEqual(normalizeFile({ ...RAW, relation: 'weird', required: 'x', size: 'big' }).required, []) + assert.equal(normalizeFile({ ...RAW, relation: 'weird' }).relation, 'new') + assert.equal(normalizeFile(null).fileId, '') +}) + +test('addonfile: the dialog entry names the local file source and the staged file id as the version', () => { + const e = fileEntry(normalizeFile(RAW)) + assert.equal(e.sourceId, LOCAL_FILE) + assert.equal(e.version, ID) + assert.equal(e.id, 'memory') + assert.equal(e.digest, 'sha256:' + 'ab'.repeat(32)) + assert.equal(e.policy.requireSignature, false) + assert.equal(isLocalFile(e.sourceId), true) + assert.equal(isLocalFile('official'), false) +}) + +test('addonfile: the same version is never installed, an older one needs the tick', () => { + const f = (relation) => normalizeFile({ ...RAW, relation }) + assert.deepEqual(fileGate(f('same'), { downgradeTicked: true }), { allowed: false, reason: 'same' }) + assert.deepEqual(fileGate(f('downgrade')), { allowed: false, reason: 'downgrade' }) + assert.equal(fileGate(f('downgrade'), { downgradeTicked: true }).allowed, true) + assert.equal(fileGate(f('upgrade')).allowed, true) + assert.equal(fileGate(f('new')).allowed, true) + assert.equal(fileGate(null).allowed, false) +}) + +test('addonfile: an installed add-on is upgraded and a new one installed', () => { + assert.equal(fileAction(normalizeFile(RAW)), 'upgrade') + assert.equal(fileAction(normalizeFile({ ...RAW, installed: false, relation: 'new' })), 'install') +}) + +test('addonfile: only a well formed file id in the link opens the dialog', () => { + assert.equal(fileIdFromParams(new URLSearchParams(`file=${ID}`)), ID) + assert.equal(fileIdFromParams(new URLSearchParams('file=../x')), '') + assert.equal(fileIdFromParams(new URLSearchParams('')), '') + assert.equal(fileIdFromParams(null), '') +}) + +test('addonfile: the approval carries the downgrade confirmation only when it was given', () => { + const preview = { digest: 'sha256:aa', ops: [], revision: 3, nonce: 'n', blockedRequired: [] } + const base = { action: 'upgrade', sourceId: LOCAL_FILE, addonId: 'memory', version: ID } + assert.equal('downgrade' in approvalBody(preview, [], base), false) + assert.equal(approvalBody(preview, [], { ...base, downgrade: true }).downgrade, true) + assert.equal(approvalBody(preview, [], base).version, ID) +}) + +test('addonfile: the upload is a multipart form with the file part, and the file routes are the documented ones', async () => { + const seen = [] + const fetch = async (url, init) => { + seen.push({ url, init }) + return { ok: true, status: 200, json: async () => RAW } + } + const api = createAddonFilesApi(createAddonsApi({ fetch })) + const file = new File([new Uint8Array([80, 75])], 'x.zip') + const out = await api.upload(file) + assert.equal(out.fileId, ID) + assert.equal(seen[0].url, '/api/addons/files') + assert.equal(seen[0].init.method, 'POST') + assert.ok(seen[0].init.body instanceof FormData) + assert.equal(seen[0].init.body.get('file').name, 'x.zip') + assert.equal(seen[0].init.headers['Content-Type'], undefined, 'the browser writes the boundary') + await api.describe(ID) + assert.equal(seen[1].url, `/api/addons/files/${ID}`) + await api.remove(ID) + assert.equal(seen[2].init.method, 'DELETE') +}) + +test('addonfile: a refusal of the file keeps the reason the owner gave', async () => { + const fetch = async () => ({ ok: false, status: 400, json: async () => ({ error: { type: 'wrong_platform', message: 'This file is for linux/arm64; this host is linux/amd64' } }) }) + const api = createAddonFilesApi(createAddonsApi({ fetch })) + await assert.rejects(api.upload(new File([new Uint8Array([1])], 'x.zip')), (e) => { + assert.ok(e instanceof AddonsError) + assert.match(e.message, /linux\/arm64/) + return true + }) +}) + +test('addonfile: an upgrade from a file does not turn the add-on on, a new install does', async () => { + const marks = [] + const ran = [] + const run = async (key, op) => { ran.push(key); await op.submit('op1', undefined); return { outcome: 'completed' } } + const hostGrants = { install: async (body) => ({ payload: { OperationID: body.operationId, AddonID: body.addonId, Phase: 'completed', Generation: 2 } }) } + const adapter = { submitLifecycle: async () => { throw new Error('plain install must not run') } } + const api = { installed: async () => [{ id: 'memory', enabled: true }] } + const entry = { key: 'k', id: 'memory', sourceId: LOCAL_FILE, version: ID } + const approval = { action: 'upgrade', sourceId: LOCAL_FILE, addonId: 'memory', version: ID, approve: { digest: 'sha256:aa', ops: [], revision: 0, nonce: 'n' } } + await runInstallAndTurnOn({ run, api, adapter, hostGrants, mark: v => marks.push(v.step) }, entry, { approval, turnOn: false }) + assert.deepEqual(ran, ['install:k']) + assert.deepEqual(marks, ['install', 'done']) + ran.length = 0; marks.length = 0 + adapter.submitLifecycle = async () => ({ payload: {} }) + await runInstallAndTurnOn({ run, api, adapter, hostGrants, mark: v => marks.push(v.step) }, entry, { approval }) + assert.deepEqual(ran, ['install:k', 'enable:memory:0']) +}) + +test('addonfile: a gone file is a typed answer, not an outage', async () => { + const fetch = async () => ({ ok: false, status: 404, json: async () => ({ error: { type: 'not_found', message: 'No such file' } }) }) + const api = createAddonFilesApi(createAddonsApi({ fetch })) + await assert.rejects(api.describe(ID), (e) => e.kind === 'invalid' && e.type === 'not_found' && e.status === 404) +})