diff --git a/.gitignore b/.gitignore index 143b3add..428ac9e1 100644 --- a/.gitignore +++ b/.gitignore @@ -36,11 +36,91 @@ dist-lib /*.vscode .codegraph -#env -/*.env +# Environment files (keep sanitized examples and the upstream template) *.env .env +.env.* +!.env.example +!.env.template +!.env.sample +!.env.*.example +!.env.*.template +!.env.*.sample + +# Private keys and local credentials +.ssh/ +.aws/ +.gnupg/ +id_rsa* +id_dsa* +id_ecdsa* +id_ed25519* +*.pem +*.key +*.p12 +*.pfx +*.ppk +.netrc +_netrc +.secrets/ +secrets/ +secrets.* +credentials +credentials.* +*.credentials.* +keys.json +rclone.conf +rclone.conf.* +restic-password +restic-password.* +restic-passwords/ +!secrets.example.* +!credentials.example.* +!rclone.conf.example +!restic-password.example + +# Local inventories and deployment overrides; keep examples in docs/examples/ +/inventory/ +/inventories/ +/inventory.ini +/inventory.yml +/inventory.yaml +/hosts.ini +inventory.local.* +inventory.prod.* +inventory.production.* +hosts.local.* +hosts.prod.* +hosts.production.* +docker-compose.override.yml +docker-compose.override.yaml +compose.override.yml +compose.override.yaml +*.local.yml +*.local.yaml + +# Generated application/backup data and temporary restore output +/data/ +/backups/ +/backup-data/ +/restic-repositories/ +/restic-cache/ +/restores/ +/restore-staging/ +/tmp/ +*.db +*.db-wal +*.db-shm +*.sqlite +*.sqlite-wal +*.sqlite-shm +*.sqlite3 +*.sqlite3-wal +*.sqlite3-shm +*.dump +*.sql.gz +*.sql.bz2 #todos TODO -*.TODO \ No newline at end of file +*.TODO diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..e1126677 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,73 @@ +# Instructions for coding agents + +These instructions apply to the entire public fork of `plutonhq/pluton`. + +## Public repository and attribution + +- Never commit secrets: passwords, API tokens, SSH/private keys, Restic passwords, + SMTP/database credentials, credential files, or decrypted application data. + Do not include them in fixtures, screenshots, logs, documentation, or PR text. +- Never introduce real production identifiers or inventories, including actual + IP addresses, internal hostnames, organization domains, or account usernames. + Use synthetic infrastructure fixtures such as `app-01`, `192.0.2.10`, + `example.internal`, and `/srv/example-app`. Sanitize command output before + sharing it publicly. +- Keep local configuration and generated backup/restore data out of Git. Review + both tracked and untracked changes before committing; `.gitignore` does not + protect files already tracked or replace a review for sensitive content. +- Preserve upstream Apache-2.0 licensing, attribution, copyright notices, and + acknowledgments. Do not rename the application or imply upstream endorsement. +- Never use, copy, reverse engineer, reproduce, or depend on proprietary Pluton + PRO/Business source code or binaries. Implement extensions independently using + this open-source codebase, public Restic/Rclone interfaces, and independently + written code. Public extension hooks do not authorize using proprietary code. + +## Workflow and validation + +- Work on a feature branch (normally `codex/`), not directly on `main`. +- Inspect the relevant code and summarize findings before modifying it. Follow + existing conventions and keep changes focused on the requested phase. +- Add tests for every new backend behavior, including error paths and permission + boundaries. Use generic fixtures and disposable test data, never production + repositories or infrastructure. Do not weaken existing tests to obtain a pass. +- Use the pnpm version pinned in `package.json` and the existing lockfile. Add + dependencies only when required for the task. +- Run the relevant checks documented in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). + Report commands, results, and environment limitations accurately. Do not start + the application against real data merely to validate documentation changes. +- Preserve the existing `.env.template` convention. Example files must contain + placeholders only; do not add settings for features that do not exist yet. + +## Restic operation boundaries + +- Treat imported/legacy Restic repositories as read-only by default. Initial + adoption must not initialize, migrate, re-key, tag, copy into, or back up to them. +- Clearly separate repository inspection operations from repository mutation + operations in APIs, services, command builders, jobs, tests, and documentation. + A future legacy adapter must enforce an operation allowlist on the backend; + hiding controls in the frontend is insufficient. +- Never automatically run `restic forget`, `restic prune`, or `restic unlock` + against a legacy repository. Initial adoption has no retention, prune, unlock, + repair, or other repository mutation path, even as error recovery. +- Account for lock files: an operation that reads snapshot data can still write + repository locks. Validate supported `--no-lock` behavior and read-only storage + access before allowing a legacy operation. Fail safely if the contract cannot + be met; never fall back to write credentials or automatic unlock. +- Avoid destructive restore behavior by default. Require an explicitly selected, + validated staging directory; reject in-place restore, path escape, overlap with + the repository/live source, and overwrite/delete behavior during initial + adoption. Restoring writes destination files even when the repository is read-only. +- Keep imported repositories separate from managed backup plans and their + initialization, scheduler, retention, replication, and recovery hooks. Never + route legacy access through a managed lifecycle just to reuse its UI. +- Changes to managed backup behavior require a separate, explicitly scoped task + and appropriate regression tests. Preserve existing retention behavior during + repository preparation and legacy design work. + +## Phase boundaries + +[docs/ROADMAP.md](docs/ROADMAP.md) records planned work, not delivered features. +Phase 0 is documentation and guardrails only: no runtime changes, Restic repository +writes, or implementation of later phases. The legacy design in +[docs/LEGACY_REPOSITORY_DESIGN.md](docs/LEGACY_REPOSITORY_DESIGN.md) is a proposal; +implement only the phase requested in a subsequent task. diff --git a/backend/__tests__/routes/legacyRepositories.test.ts b/backend/__tests__/routes/legacyRepositories.test.ts new file mode 100644 index 00000000..b4f238e7 --- /dev/null +++ b/backend/__tests__/routes/legacyRepositories.test.ts @@ -0,0 +1,120 @@ +import express, { type Express } from 'express'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import Cookies from 'cookies'; + +jest.mock('jsonwebtoken'); +jest.mock('cookies'); +jest.mock('../../src/services/ConfigService', () => ({ + configService: { + config: { + SECRET: 'legacy-routes-test-secret', + APIKEY: 'legacy-routes-test-api-key', + }, + }, +})); + +import { LegacyRepositoryController } from '../../src/controllers/LegacyRepositoryController'; +import { createLegacyRepositoryRouter } from '../../src/routes/legacyRepositories'; +import type { LegacyRepositoryService } from '../../src/services/LegacyRepositoryService'; + +describe('Legacy repository routes', () => { + let app: Express; + let service: { + getAll: jest.Mock; + getById: jest.Mock; + register: jest.Mock; + validate: jest.Mock; + listSnapshots: jest.Mock; + getSnapshot: jest.Mock; + getStats: jest.Mock; + deleteRegistration: jest.Mock; + }; + + const setAuthenticatedSession = (authenticated: boolean) => { + (Cookies as jest.MockedClass).mockImplementation( + () => ({ get: jest.fn().mockReturnValue(authenticated ? 'test-session' : undefined) }) as never + ); + (jwt.verify as jest.Mock).mockImplementation((_token, _secret, callback) => { + callback(authenticated ? null : new Error('invalid session')); + }); + }; + + beforeEach(() => { + jest.clearAllMocks(); + service = { + getAll: jest.fn(), + getById: jest.fn(), + register: jest.fn(), + validate: jest.fn(), + listSnapshots: jest.fn(), + getSnapshot: jest.fn(), + getStats: jest.fn(), + deleteRegistration: jest.fn(), + }; + app = express(); + app.use(express.json()); + app.use( + '/api/legacy-repositories', + createLegacyRepositoryRouter(new LegacyRepositoryController(service as unknown as LegacyRepositoryService)) + ); + setAuthenticatedSession(true); + }); + + it('requires an authenticated UI session', async () => { + setAuthenticatedSession(false); + + const response = await request(app).get('/api/legacy-repositories'); + + expect(response.status).toBe(401); + expect(service.getAll).not.toHaveBeenCalled(); + }); + + it('does not grant a legacy repository route to an API key', async () => { + setAuthenticatedSession(false); + + const response = await request(app) + .get('/api/legacy-repositories') + .set('Authorization', 'Bearer legacy-routes-test-api-key'); + + expect(response.status).toBe(401); + expect(service.getAll).not.toHaveBeenCalled(); + }); + + it('lists public registration data without credentials', async () => { + service.getAll.mockResolvedValue([ + { + id: 'legacy-fixture', + displayName: 'Fixture legacy repository', + repositoryPath: 'C:\\fixtures\\legacy-restic-repository', + backend: 'local', + isReadOnly: true, + validationStatus: 'available', + lastValidatedAt: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: null, + }, + ]); + + const response = await request(app).get('/api/legacy-repositories'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ success: true }); + expect(JSON.stringify(response.body)).not.toContain('password'); + }); + + it('passes exact snapshot filters to the read-only service', async () => { + service.listSnapshots.mockResolvedValue([]); + + const response = await request(app) + .get('/api/legacy-repositories/legacy-fixture/snapshots') + .query({ tag: 'application', path: 'C:\\fixtures\\source', host: 'fixture-host' }); + + expect(response.status).toBe(200); + expect(service.listSnapshots).toHaveBeenCalledWith('legacy-fixture', { + tag: 'application', + path: 'C:\\fixtures\\source', + host: 'fixture-host', + }); + }); +}); diff --git a/backend/__tests__/services/LegacyRepositoryService.test.ts b/backend/__tests__/services/LegacyRepositoryService.test.ts new file mode 100644 index 00000000..dc205940 --- /dev/null +++ b/backend/__tests__/services/LegacyRepositoryService.test.ts @@ -0,0 +1,186 @@ +import path from 'path'; +import Cryptr from 'cryptr'; + +jest.mock('../../src/services/ConfigService', () => ({ + configService: { + config: { + SECRET: 'legacy-adapter-test-secret-key', + }, + }, +})); + +import { LegacyRepositoryService } from '../../src/services/LegacyRepositoryService'; +import type { LegacyRepository } from '../../src/db/schema/legacyRepositories'; +import type { LegacyRepositoryStore } from '../../src/stores/LegacyRepositoryStore'; +import { + LegacyRepositoryInspectionError, + type LegacyResticInspectionClient, +} from '../../src/utils/restic/LegacyRepositoryInspector'; +import type { LegacyRepositorySnapshot } from '../../src/types/legacyRepositories'; + +const repositoryPath = path.resolve(process.cwd(), 'fixtures', 'legacy-restic-repository'); +const adapterPassword = 'adapter-test-password'; +const encryptionSecret = 'legacy-adapter-test-secret-key'; +const snapshots: LegacyRepositorySnapshot[] = [ + { + id: 'a'.repeat(64), + shortId: 'aaaaaaaa', + time: '2026-01-03T00:00:00.000Z', + hostname: 'host-a', + tags: ['daily', 'database'], + paths: ['C:\\fixtures\\source-a'], + }, + { + id: 'b'.repeat(64), + shortId: 'bbbbbbbb', + time: '2026-01-02T00:00:00.000Z', + hostname: 'host-b', + tags: ['weekly'], + paths: ['C:\\fixtures\\source-b'], + }, + { + id: 'c'.repeat(64), + shortId: 'cccccccc', + time: '2026-01-01T00:00:00.000Z', + hostname: 'host-a', + tags: ['daily'], + paths: ['C:\\fixtures\\source-c'], + }, +]; + +function createStoredRepository(overrides: Partial = {}): LegacyRepository { + return { + id: 'legacy-fixture', + displayName: 'Fixture legacy repository', + repositoryPath, + backend: 'local', + encryptedPassword: new Cryptr(encryptionSecret).encrypt(adapterPassword), + isReadOnly: true, + validationStatus: 'available', + lastValidatedAt: new Date('2026-01-01T00:00:00.000Z'), + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: null, + ...overrides, + }; +} + +describe('LegacyRepositoryService', () => { + let store: { + getAll: jest.Mock; + getById: jest.Mock; + create: jest.Mock; + updateValidationStatus: jest.Mock; + delete: jest.Mock; + }; + let inspector: jest.Mocked; + let service: LegacyRepositoryService; + + beforeEach(() => { + store = { + getAll: jest.fn(), + getById: jest.fn(), + create: jest.fn(), + updateValidationStatus: jest.fn(), + delete: jest.fn(), + }; + inspector = { + listSnapshots: jest.fn(), + getRepositoryStats: jest.fn(), + }; + service = new LegacyRepositoryService(store as unknown as LegacyRepositoryStore, inspector); + }); + + it('registers a validated local repository as read-only without returning its password', async () => { + inspector.listSnapshots.mockResolvedValue(snapshots); + store.create.mockImplementation(async data => + createStoredRepository({ + ...data, + createdAt: new Date('2026-01-03T00:00:00.000Z'), + updatedAt: null, + }) + ); + + const result = await service.register({ + displayName: 'Imported fixture', + repositoryPath, + password: adapterPassword, + }); + + expect(inspector.listSnapshots).toHaveBeenCalledWith(repositoryPath, adapterPassword); + expect(store.create).toHaveBeenCalledWith( + expect.objectContaining({ backend: 'local', isReadOnly: true, validationStatus: 'available' }) + ); + const saved = store.create.mock.calls[0][0]; + expect(saved.encryptedPassword).not.toContain(adapterPassword); + expect(result).toEqual(expect.objectContaining({ backend: 'local', isReadOnly: true })); + expect(JSON.stringify(result)).not.toContain(adapterPassword); + expect(result).not.toHaveProperty('encryptedPassword'); + }); + + it('rejects a non-absolute repository path before inspection', async () => { + await expect( + service.register({ + displayName: 'Invalid fixture', + repositoryPath: 'relative/legacy-repository', + password: adapterPassword, + }) + ).rejects.toMatchObject({ statusCode: 400 }); + expect(inspector.listSnapshots).not.toHaveBeenCalled(); + expect(store.create).not.toHaveBeenCalled(); + }); + + it('returns a safe validation error for incorrect credentials', async () => { + inspector.listSnapshots.mockRejectedValue(new LegacyRepositoryInspectionError('wrong-password')); + + await expect( + service.register({ displayName: 'Fixture legacy', repositoryPath, password: adapterPassword }) + ).rejects.toMatchObject({ + statusCode: 400, + message: 'Repository credentials could not be verified.', + }); + await service + .register({ displayName: 'Fixture legacy', repositoryPath, password: adapterPassword }) + .catch(error => expect((error as Error).message).not.toContain(adapterPassword)); + }); + + it('lists and filters multiple workloads locally by tag, path, and host', async () => { + store.getById.mockResolvedValue(createStoredRepository()); + store.updateValidationStatus.mockResolvedValue(createStoredRepository()); + inspector.listSnapshots.mockResolvedValue(snapshots); + + await expect(service.listSnapshots('legacy-fixture', { tag: 'daily' })).resolves.toHaveLength(2); + await expect(service.listSnapshots('legacy-fixture', { host: 'host-b' })).resolves.toEqual([ + expect.objectContaining({ id: 'b'.repeat(64) }), + ]); + await expect( + service.listSnapshots('legacy-fixture', { path: 'C:\\fixtures\\source-c' }) + ).resolves.toEqual([expect.objectContaining({ id: 'c'.repeat(64) })]); + expect(inspector.listSnapshots).toHaveBeenCalledWith(repositoryPath, expect.any(String)); + }); + + it('only deletes the local registration and never inspects or changes the repository', async () => { + store.getById.mockResolvedValue(createStoredRepository()); + store.delete.mockResolvedValue(true); + + await expect(service.deleteRegistration('legacy-fixture')).resolves.toBeUndefined(); + expect(store.delete).toHaveBeenCalledWith('legacy-fixture'); + expect(inspector.listSnapshots).not.toHaveBeenCalled(); + expect(inspector.getRepositoryStats).not.toHaveBeenCalled(); + }); + + it('rejects caller-supplied mode changes and refuses stored non-read-only records', async () => { + await expect( + service.register({ + displayName: 'Unsafe fixture', + repositoryPath, + password: adapterPassword, + isReadOnly: false, + }) + ).rejects.toMatchObject({ statusCode: 400 }); + expect(inspector.listSnapshots).not.toHaveBeenCalled(); + + store.getById.mockResolvedValue(createStoredRepository({ isReadOnly: false })); + await expect(service.listSnapshots('legacy-fixture')).rejects.toMatchObject({ statusCode: 409 }); + expect(inspector.listSnapshots).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/__tests__/utils/restic/LegacyRepositoryInspector.test.ts b/backend/__tests__/utils/restic/LegacyRepositoryInspector.test.ts new file mode 100644 index 00000000..71ec3e7c --- /dev/null +++ b/backend/__tests__/utils/restic/LegacyRepositoryInspector.test.ts @@ -0,0 +1,156 @@ +import { EventEmitter } from 'events'; + +jest.mock('child_process'); +jest.mock('../../../src/utils/binaryPathResolver'); + +import { spawn } from 'child_process'; +import { getBinaryPath } from '../../../src/utils/binaryPathResolver'; +import { + LegacyRepositoryInspectionError, + ResticLegacyRepositoryInspector, +} from '../../../src/utils/restic/LegacyRepositoryInspector'; + +const mockSpawn = spawn as jest.MockedFunction; +const mockGetBinaryPath = getBinaryPath as jest.MockedFunction; + +describe('ResticLegacyRepositoryInspector', () => { + let child: EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + kill: jest.Mock; + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetBinaryPath.mockReturnValue('C:\\test-tools\\restic.exe'); + child = Object.assign(new EventEmitter(), { + stdout: new EventEmitter(), + stderr: new EventEmitter(), + kill: jest.fn(), + }); + mockSpawn.mockReturnValue(child as never); + }); + + it('lists snapshots through a fixed read-only JSON command', async () => { + const inspector = new ResticLegacyRepositoryInspector(); + const repositoryPath = 'C:\\fixtures\\legacy-restic-repository'; + const password = 'adapter-test-password'; + const snapshotsPromise = inspector.listSnapshots(repositoryPath, password); + + setImmediate(() => { + child.stdout.emit( + 'data', + Buffer.from( + JSON.stringify([ + { + id: 'a'.repeat(64), + short_id: 'aaaaaaaa', + time: '2026-01-02T03:04:05.000Z', + hostname: 'fixture-host', + tags: ['daily'], + paths: ['C:\\fixtures\\source'], + }, + ]) + ) + ); + child.emit('close', 0); + }); + + await expect(snapshotsPromise).resolves.toEqual([ + expect.objectContaining({ + id: 'a'.repeat(64), + shortId: 'aaaaaaaa', + hostname: 'fixture-host', + tags: ['daily'], + }), + ]); + + expect(mockSpawn).toHaveBeenCalledWith( + 'C:\\test-tools\\restic.exe', + [ + '--no-lock', + '--no-cache', + '--json', + '--repo', + repositoryPath, + 'snapshots', + ], + expect.objectContaining({ shell: false, windowsHide: true }) + ); + const args = mockSpawn.mock.calls[0][1] as string[]; + const options = mockSpawn.mock.calls[0][2]; + const environment = options?.env as NodeJS.ProcessEnv; + expect(args.join(' ')).not.toContain(password); + expect(environment.RESTIC_PASSWORD).toBe(password); + expect(environment.RESTIC_REPOSITORY).toBeUndefined(); + expect(environment.RESTIC_REPOSITORY_FILE).toBeUndefined(); + expect(environment.RESTIC_PASSWORD_COMMAND).toBeUndefined(); + expect(environment.RESTIC_PASSWORD_FILE).toBeUndefined(); + }); + + it('returns normalized safe stats from Restic JSON', async () => { + const inspector = new ResticLegacyRepositoryInspector(); + const statsPromise = inspector.getRepositoryStats( + 'C:\\fixtures\\legacy-restic-repository', + 'adapter-test-password' + ); + + setImmediate(() => { + child.stdout.emit( + 'data', + Buffer.from( + JSON.stringify({ + total_size: 1024, + total_uncompressed_size: 2048, + compression_ratio: 2, + total_blob_count: 7, + snapshots_count: 2, + }) + ) + ); + child.emit('close', 0); + }); + + await expect(statsPromise).resolves.toEqual({ + totalSize: 1024, + totalUncompressedSize: 2048, + compressionRatio: 2, + totalBlobCount: 7, + snapshotCount: 2, + }); + expect(mockSpawn.mock.calls[0][1]).toContain('stats'); + expect(mockSpawn.mock.calls[0][1]).toContain('raw-data'); + }); + + it.each(['backup', 'forget', 'prune', 'unlock', 'migrate', 'repair', 'init', 'restore'])( + 'refuses the forbidden %s operation before spawning Restic', + async operation => { + const inspector = new ResticLegacyRepositoryInspector(); + + await expect( + inspector.execute(operation, 'C:\\fixtures\\legacy-restic-repository', 'adapter-test-password') + ).rejects.toMatchObject({ kind: 'forbidden-operation' }); + expect(mockSpawn).not.toHaveBeenCalled(); + } + ); + + it('maps a wrong-password exit without retaining or exposing stderr', async () => { + const inspector = new ResticLegacyRepositoryInspector(); + const password = 'adapter-test-password'; + const inspectionPromise = inspector.listSnapshots('C:\\fixtures\\legacy-restic-repository', password); + + setImmediate(() => { + child.stderr.emit('data', Buffer.from(`wrong password: ${password}`)); + child.emit('close', 12); + }); + + await expect(inspectionPromise).rejects.toMatchObject({ + kind: 'wrong-password', + message: 'Legacy repository inspection failed.', + }); + await inspectionPromise.catch(error => { + expect(error).toBeInstanceOf(LegacyRepositoryInspectionError); + expect((error as Error).message).not.toContain(password); + }); + }); +}); diff --git a/backend/drizzle/0003_luxuriant_fat_cobra.sql b/backend/drizzle/0003_luxuriant_fat_cobra.sql new file mode 100644 index 00000000..a32febf0 --- /dev/null +++ b/backend/drizzle/0003_luxuriant_fat_cobra.sql @@ -0,0 +1,12 @@ +CREATE TABLE `legacy_repositories` ( + `id` text PRIMARY KEY NOT NULL, + `display_name` text NOT NULL, + `repository_path` text NOT NULL, + `backend` text DEFAULT 'local' NOT NULL, + `encrypted_password` text NOT NULL, + `is_read_only` integer DEFAULT true NOT NULL, + `validation_status` text DEFAULT 'unknown' NOT NULL, + `last_validated_at` integer, + `created_at` integer DEFAULT (unixepoch()) NOT NULL, + `updated_at` integer +); diff --git a/backend/drizzle/meta/0003_snapshot.json b/backend/drizzle/meta/0003_snapshot.json new file mode 100644 index 00000000..181ece8e --- /dev/null +++ b/backend/drizzle/meta/0003_snapshot.json @@ -0,0 +1,940 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "38581f17-1db0-46fe-a67a-20273f367926", + "prevId": "8f594e05-6437-4a56-a87b-b1a9877e70de", + "tables": { + "backups": { + "name": "backups", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "in_progress": { + "name": "in_progress", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "success": { + "name": "success", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_msg": { + "name": "error_msg", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storage_id": { + "name": "storage_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'device'" + }, + "source_config": { + "name": "source_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encryption": { + "name": "encryption", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "compression": { + "name": "compression", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "download": { + "name": "download", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "task_stats": { + "name": "task_stats", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "progress_stats": { + "name": "progress_stats", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completion_stats": { + "name": "completion_stats", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mirrors": { + "name": "mirrors", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "backups_plan_id_plans_id_fk": { + "name": "backups_plan_id_plans_id_fk", + "tableFrom": "backups", + "tableTo": "plans", + "columnsFrom": [ + "plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "backups_storage_id_storages_id_fk": { + "name": "backups_storage_id_storages_id_fk", + "tableFrom": "backups", + "tableTo": "storages", + "columnsFrom": [ + "storage_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "devices": { + "name": "devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'device'" + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "versions": { + "name": "versions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "os": { + "name": "os", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metrics": { + "name": "metrics", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_seen": { + "name": "last_seen", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "devices_agent_id_unique": { + "name": "devices_agent_id_unique", + "columns": [ + "agent_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "legacy_repositories": { + "name": "legacy_repositories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_path": { + "name": "repository_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "backend": { + "name": "backend", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_read_only": { + "name": "is_read_only", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "validation_status": { + "name": "validation_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "last_validated_at": { + "name": "last_validated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plans": { + "name": "plans", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "in_progress": { + "name": "in_progress", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storage_id": { + "name": "storage_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'device'" + }, + "source_config": { + "name": "source_config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified": { + "name": "verified", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_backup_time": { + "name": "last_backup_time", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'backup'" + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stats": { + "name": "stats", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "plans_storage_id_storages_id_fk": { + "name": "plans_storage_id_storages_id_fk", + "tableFrom": "plans", + "tableTo": "storages", + "columnsFrom": [ + "storage_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "restores": { + "name": "restores", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "in_progress": { + "name": "in_progress", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_msg": { + "name": "error_msg", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "backup_id": { + "name": "backup_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storage_id": { + "name": "storage_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'device'" + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'backup'" + }, + "task_stats": { + "name": "task_stats", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "progress_stats": { + "name": "progress_stats", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completion_stats": { + "name": "completion_stats", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "restores_backup_id_backups_id_fk": { + "name": "restores_backup_id_backups_id_fk", + "tableFrom": "restores", + "tableTo": "backups", + "columnsFrom": [ + "backup_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "restores_plan_id_plans_id_fk": { + "name": "restores_plan_id_plans_id_fk", + "tableFrom": "restores", + "tableTo": "plans", + "columnsFrom": [ + "plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "restores_storage_id_storages_id_fk": { + "name": "restores_storage_id_storages_id_fk", + "tableFrom": "restores", + "tableTo": "storages", + "columnsFrom": [ + "storage_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "settings": { + "name": "settings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storages": { + "name": "storages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_path": { + "name": "default_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'/'" + }, + "settings": { + "name": "settings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "authType": { + "name": "authType", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json index 85261519..788c5ffb 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1772630106968, "tag": "0002_narrow_hairball", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1790061624138, + "tag": "0003_luxuriant_fat_cobra", + "breakpoints": true } ] } \ No newline at end of file diff --git a/backend/src/controllers/LegacyRepositoryController.ts b/backend/src/controllers/LegacyRepositoryController.ts new file mode 100644 index 00000000..89d999bb --- /dev/null +++ b/backend/src/controllers/LegacyRepositoryController.ts @@ -0,0 +1,105 @@ +import { Request, Response } from 'express'; +import { AppError } from '../utils/AppError'; +import { LegacyRepositoryService } from '../services/LegacyRepositoryService'; + +export class LegacyRepositoryController { + constructor(private readonly legacyRepositoryService: LegacyRepositoryService) {} + + async list(req: Request, res: Response): Promise { + try { + res.status(200).json({ success: true, result: await this.legacyRepositoryService.getAll() }); + } catch (error) { + this.respondWithError(res, error); + } + } + + async get(req: Request, res: Response): Promise { + try { + res.status(200).json({ success: true, result: await this.legacyRepositoryService.getById(req.params.id) }); + } catch (error) { + this.respondWithError(res, error); + } + } + + async create(req: Request, res: Response): Promise { + try { + const repository = await this.legacyRepositoryService.register(req.body); + res.status(201).json({ success: true, result: repository }); + } catch (error) { + this.respondWithError(res, error); + } + } + + async validate(req: Request, res: Response): Promise { + try { + const result = await this.legacyRepositoryService.validate(req.params.id); + res.status(200).json({ success: true, result }); + } catch (error) { + this.respondWithError(res, error); + } + } + + async listSnapshots(req: Request, res: Response): Promise { + try { + const filters = { + tag: this.readQueryString(req, 'tag'), + path: this.readQueryString(req, 'path'), + host: this.readQueryString(req, 'host'), + }; + const result = await this.legacyRepositoryService.listSnapshots(req.params.id, filters); + res.status(200).json({ success: true, result }); + } catch (error) { + this.respondWithError(res, error); + } + } + + async getSnapshot(req: Request, res: Response): Promise { + try { + const result = await this.legacyRepositoryService.getSnapshot( + req.params.id, + req.params.snapshotId + ); + res.status(200).json({ success: true, result }); + } catch (error) { + this.respondWithError(res, error); + } + } + + async getStats(req: Request, res: Response): Promise { + try { + const result = await this.legacyRepositoryService.getStats(req.params.id); + res.status(200).json({ success: true, result }); + } catch (error) { + this.respondWithError(res, error); + } + } + + async delete(req: Request, res: Response): Promise { + try { + await this.legacyRepositoryService.deleteRegistration(req.params.id); + res.status(200).json({ + success: true, + message: 'Legacy repository registration removed. The repository was not modified.', + }); + } catch (error) { + this.respondWithError(res, error); + } + } + + private readQueryString(req: Request, name: string): string | undefined { + const value = req.query[name]; + if (typeof value === 'undefined') return undefined; + if (typeof value !== 'string') { + throw new AppError(400, `${name} must be a single text value.`); + } + return value; + } + + private respondWithError(res: Response, error: unknown): void { + if (error instanceof AppError) { + res.status(error.statusCode).json({ success: false, error: error.message }); + return; + } + res.status(500).json({ success: false, error: 'Internal Server Error' }); + } +} diff --git a/backend/src/createApp.ts b/backend/src/createApp.ts index 9b3da90b..1e10ded6 100644 --- a/backend/src/createApp.ts +++ b/backend/src/createApp.ts @@ -25,6 +25,8 @@ import { SettingsController } from './controllers/SettingsController'; import { SetupController } from './controllers/SetupController'; import { createBackupRouter } from './routes/backups'; import { BackupController } from './controllers/BackupController'; +import { createLegacyRepositoryRouter } from './routes/legacyRepositories'; +import { LegacyRepositoryController } from './controllers/LegacyRepositoryController'; import versionMiddleware from './middlewares/versionMiddleware'; import { PlanStore } from './stores/PlanStore'; @@ -57,6 +59,8 @@ import { initializeLogger } from './utils/logger'; import { configService } from './services/ConfigService'; import { BaseRestoreManager } from './managers/BaseRestoreManager'; import { parseTrustProxy } from './utils/helpers'; +import { LegacyRepositoryStore } from './stores/LegacyRepositoryStore'; +import { LegacyRepositoryService } from './services/LegacyRepositoryService'; const MemoryStore = createMemoryStore(session); @@ -71,6 +75,7 @@ export async function createApp(): Promise<{ app: Express }> { const deviceStore = new DeviceStore(db); const restoreStore = new RestoreStore(db); const settingsStore = new SettingsStore(db); + const legacyRepositoryStore = new LegacyRepositoryStore(db); // Local Agents const localPlanAgent = new BaseBackupManager(); @@ -119,6 +124,7 @@ export async function createApp(): Promise<{ app: Express }> { ); const selfBackupService = new SelfBackupService(settingsStore, storageStore, sqlite, 'core'); const settingsService = new SettingsService(settingsStore, selfBackupService); + const legacyRepositoryService = new LegacyRepositoryService(legacyRepositoryStore); // API Route Controllers const planController = new PlanController(planService); @@ -129,6 +135,7 @@ export async function createApp(): Promise<{ app: Express }> { const settingsController = new SettingsController(settingsService); const userController = new UserController(settingsService); const setupController = new SetupController(); + const legacyRepositoryController = new LegacyRepositoryController(legacyRepositoryService); // Express App const app = express(); @@ -249,6 +256,7 @@ export async function createApp(): Promise<{ app: Express }> { app.use('/api/storages', createStorageRouter(storageController)); app.use('/api/restores', createRestoreRouter(restoreController)); app.use('/api/settings', createSettingsRouter(settingsController)); + app.use('/api/legacy-repositories', createLegacyRepositoryRouter(legacyRepositoryController)); app.use('/api/health', createHealthRouter()); // For any other request that doesn't match an API route or a static file, diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index 0c91771b..2db5324c 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -7,6 +7,7 @@ import { deviceRelations, devices } from './schema/devices'; import { restoreRelations, restores } from './schema/restores'; import { backupRelations, backups } from './schema/backups'; import { settings } from './schema/settings'; +import { legacyRepositories } from './schema/legacyRepositories'; import { appPaths } from '../utils/AppPaths'; const dbPath = path.join(appPaths.getDbDir(), 'pluton.db'); @@ -27,6 +28,7 @@ export const db = drizzle(sqlite, { backups, backupRelations, settings, + legacyRepositories, }, }); export type DatabaseType = typeof db; diff --git a/backend/src/db/schema/legacyRepositories.ts b/backend/src/db/schema/legacyRepositories.ts new file mode 100644 index 00000000..699c44ff --- /dev/null +++ b/backend/src/db/schema/legacyRepositories.ts @@ -0,0 +1,36 @@ +import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; +import { createInsertSchema, createSelectSchema } from 'drizzle-zod'; +import { sql } from 'drizzle-orm'; + +export type LegacyRepositoryBackend = 'local'; +export type LegacyRepositoryValidationStatus = 'unknown' | 'available' | 'unavailable'; + +/** + * A registration for a repository that was created outside Pluton. + * + * This is intentionally independent from `plans`, `backups`, and `storages`. + * The encrypted password is private implementation data and must never be sent + * to the frontend or used by a managed backup lifecycle. + */ +export const legacyRepositories = sqliteTable('legacy_repositories', { + id: text('id').notNull().primaryKey(), + displayName: text('display_name').notNull(), + repositoryPath: text('repository_path').notNull(), + backend: text('backend').$type().notNull().default('local'), + encryptedPassword: text('encrypted_password').notNull(), + isReadOnly: integer('is_read_only', { mode: 'boolean' }).notNull().default(true), + validationStatus: text('validation_status') + .$type() + .notNull() + .default('unknown'), + lastValidatedAt: integer('last_validated_at', { mode: 'timestamp' }), + createdAt: integer('created_at', { mode: 'timestamp' }) + .notNull() + .default(sql`(unixepoch())`), + updatedAt: integer('updated_at', { mode: 'timestamp' }), +}); + +export type LegacyRepository = typeof legacyRepositories.$inferSelect; +export type NewLegacyRepository = typeof legacyRepositories.$inferInsert; +export const legacyRepositoryInsertSchema = createInsertSchema(legacyRepositories); +export const legacyRepositorySelectSchema = createSelectSchema(legacyRepositories); diff --git a/backend/src/routes/legacyRepositories.ts b/backend/src/routes/legacyRepositories.ts new file mode 100644 index 00000000..7ee3d931 --- /dev/null +++ b/backend/src/routes/legacyRepositories.ts @@ -0,0 +1,23 @@ +import { Router } from 'express'; +import authMiddleware from '../middlewares/authMiddleware'; +import { LegacyRepositoryController } from '../controllers/LegacyRepositoryController'; + +/** + * All endpoints require the authenticated UI session. API-key access is not + * included in authMiddleware's restricted machine API allowlist. + */ +export function createLegacyRepositoryRouter( + controller: LegacyRepositoryController, + router: Router = Router() +): Router { + router.get('/', authMiddleware, controller.list.bind(controller)); + router.post('/', authMiddleware, controller.create.bind(controller)); + router.get('/:id', authMiddleware, controller.get.bind(controller)); + router.post('/:id/validate', authMiddleware, controller.validate.bind(controller)); + router.get('/:id/snapshots', authMiddleware, controller.listSnapshots.bind(controller)); + router.get('/:id/snapshots/:snapshotId', authMiddleware, controller.getSnapshot.bind(controller)); + router.get('/:id/stats', authMiddleware, controller.getStats.bind(controller)); + router.delete('/:id', authMiddleware, controller.delete.bind(controller)); + + return router; +} diff --git a/backend/src/services/LegacyRepositoryService.ts b/backend/src/services/LegacyRepositoryService.ts new file mode 100644 index 00000000..17bcaeaa --- /dev/null +++ b/backend/src/services/LegacyRepositoryService.ts @@ -0,0 +1,249 @@ +import path from 'path'; +import Cryptr from 'cryptr'; +import { z } from 'zod'; +import { configService } from './ConfigService'; +import { LegacyRepositoryStore } from '../stores/LegacyRepositoryStore'; +import type { LegacyRepository } from '../db/schema/legacyRepositories'; +import { AppError, NotFoundError } from '../utils/AppError'; +import { generateUID } from '../utils/helpers'; +import { + LegacyRepositoryInspectionError, + LegacyResticInspectionClient, + ResticLegacyRepositoryInspector, +} from '../utils/restic/LegacyRepositoryInspector'; +import type { + LegacyRepositoryConnectionStatus, + LegacyRepositoryPublic, + LegacyRepositoryRegistration, + LegacyRepositorySnapshot, + LegacyRepositorySnapshotFilters, + LegacyRepositoryStats, +} from '../types/legacyRepositories'; + +const registrationSchema = z + .object({ + displayName: z.string().trim().min(1).max(100), + repositoryPath: z.string().trim().min(1).max(1024), + password: z.string().min(1).max(4096), + }) + .strict(); + +const snapshotFiltersSchema = z + .object({ + tag: z.string().trim().min(1).max(256).optional(), + path: z.string().trim().min(1).max(1024).optional(), + host: z.string().trim().min(1).max(256).optional(), + }) + .strict(); + +const fullSnapshotId = /^[a-fA-F0-9]{64}$/; + +/** + * Keeps imported Restic repositories apart from Pluton's managed plans. + * The only repository interactions available here are through the dedicated + * inspection client; there is no backup, retention, restore, or lock cleanup path. + */ +export class LegacyRepositoryService { + constructor( + private readonly repositoryStore: LegacyRepositoryStore, + private readonly inspector: LegacyResticInspectionClient = new ResticLegacyRepositoryInspector() + ) {} + + async register(input: unknown): Promise { + const parsed = registrationSchema.safeParse(input); + if (!parsed.success) { + throw new AppError(400, 'A display name, absolute repository path, and repository password are required.'); + } + + const registration = this.validateRegistration(parsed.data); + await this.inspectRegistration(registration.repositoryPath, registration.password); + + const created = await this.repositoryStore.create({ + id: generateUID(), + displayName: registration.displayName, + repositoryPath: registration.repositoryPath, + backend: 'local', + encryptedPassword: this.encryptPassword(registration.password), + isReadOnly: true, + validationStatus: 'available', + lastValidatedAt: new Date(), + }); + + if (!created) { + throw new AppError(500, 'Could not save the legacy repository registration.'); + } + + return this.toPublic(created); + } + + async getAll(): Promise { + const repositories = await this.repositoryStore.getAll(); + return repositories.map(repository => this.toPublic(repository)); + } + + async getById(id: string): Promise { + return this.toPublic(await this.getStoredRepository(id)); + } + + async validate(id: string): Promise { + const repository = await this.getStoredRepository(id); + await this.inspectRepository(repository); + const updated = await this.repositoryStore.updateValidationStatus(id, 'available'); + return { + validationStatus: updated?.validationStatus || 'available', + lastValidatedAt: updated?.lastValidatedAt || new Date(), + }; + } + + async listSnapshots(id: string, filters: unknown = {}): Promise { + const parsedFilters = snapshotFiltersSchema.safeParse(filters); + if (!parsedFilters.success) { + throw new AppError(400, 'Snapshot filters must be non-empty text values.'); + } + + const snapshots = await this.inspectRepository(await this.getStoredRepository(id)); + await this.repositoryStore.updateValidationStatus(id, 'available'); + return this.filterSnapshots(snapshots, parsedFilters.data); + } + + async getSnapshot(id: string, snapshotId: string): Promise { + if (!fullSnapshotId.test(snapshotId)) { + throw new AppError(400, 'A full snapshot ID is required.'); + } + const snapshots = await this.listSnapshots(id); + const snapshot = snapshots.find(candidate => candidate.id.toLowerCase() === snapshotId.toLowerCase()); + if (!snapshot) { + throw new NotFoundError('Snapshot not found.'); + } + return snapshot; + } + + async getStats(id: string): Promise { + const repository = await this.getStoredRepository(id); + try { + const stats = await this.inspector.getRepositoryStats( + repository.repositoryPath, + this.decryptPassword(repository) + ); + await this.repositoryStore.updateValidationStatus(id, 'available'); + return stats; + } catch (error) { + await this.markUnavailable(id); + throw this.toSafeInspectionError(error); + } + } + + async deleteRegistration(id: string): Promise { + await this.getStoredRepository(id); + const deleted = await this.repositoryStore.delete(id); + if (!deleted) { + throw new NotFoundError('Legacy repository not found.'); + } + } + + private validateRegistration(input: LegacyRepositoryRegistration): LegacyRepositoryRegistration { + if (input.repositoryPath.includes('\0') || !path.isAbsolute(input.repositoryPath)) { + throw new AppError(400, 'Repository path must be an absolute local filesystem path.'); + } + return input; + } + + private async getStoredRepository(id: string): Promise { + if (!id || typeof id !== 'string') { + throw new AppError(400, 'Legacy repository ID is required.'); + } + const repository = await this.repositoryStore.getById(id); + if (!repository) { + throw new NotFoundError('Legacy repository not found.'); + } + if (repository.backend !== 'local' || repository.isReadOnly !== true) { + throw new AppError(409, 'This legacy repository is not available for read-only inspection.'); + } + return repository; + } + + private async inspectRegistration(repositoryPath: string, password: string): Promise { + try { + await this.inspector.listSnapshots(repositoryPath, password); + } catch (error) { + throw this.toSafeInspectionError(error); + } + } + + private async inspectRepository(repository: LegacyRepository): Promise { + try { + return await this.inspector.listSnapshots(repository.repositoryPath, this.decryptPassword(repository)); + } catch (error) { + await this.markUnavailable(repository.id); + throw this.toSafeInspectionError(error); + } + } + + private async markUnavailable(id: string): Promise { + try { + await this.repositoryStore.updateValidationStatus(id, 'unavailable'); + } catch { + // A local status update must not hide the original safe inspection error. + } + } + + private filterSnapshots( + snapshots: LegacyRepositorySnapshot[], + filters: LegacyRepositorySnapshotFilters + ): LegacyRepositorySnapshot[] { + return snapshots.filter(snapshot => { + if (filters.tag && !snapshot.tags.includes(filters.tag)) return false; + if (filters.path && !snapshot.paths.includes(filters.path)) return false; + if (filters.host && snapshot.hostname !== filters.host) return false; + return true; + }); + } + + private encryptPassword(password: string): string { + const secret = configService.config.SECRET; + if (!secret) { + throw new AppError(503, 'Legacy repository credentials are unavailable until Pluton setup is complete.'); + } + return new Cryptr(secret).encrypt(password); + } + + private decryptPassword(repository: LegacyRepository): string { + const secret = configService.config.SECRET; + if (!secret) { + throw new AppError(503, 'Legacy repository credentials are unavailable until Pluton setup is complete.'); + } + try { + return new Cryptr(secret).decrypt(repository.encryptedPassword); + } catch { + throw new AppError(500, 'Legacy repository credentials cannot be accessed.'); + } + } + + private toPublic(repository: LegacyRepository): LegacyRepositoryPublic { + const { encryptedPassword: _encryptedPassword, ...publicRepository } = repository; + return publicRepository; + } + + private toSafeInspectionError(error: unknown): AppError { + if (error instanceof AppError) return error; + if (error instanceof LegacyRepositoryInspectionError) { + switch (error.kind) { + case 'wrong-password': + return new AppError(400, 'Repository credentials could not be verified.'); + case 'repository-unavailable': + return new AppError(400, 'Repository could not be accessed.'); + case 'timeout': + return new AppError(504, 'Repository inspection timed out.'); + case 'invalid-output': + return new AppError(422, 'Repository returned unsupported inspection data.'); + case 'output-limit': + return new AppError(422, 'Repository inspection output is too large.'); + case 'forbidden-operation': + return new AppError(403, 'That repository operation is not allowed.'); + default: + return new AppError(502, 'Repository inspection failed.'); + } + } + return new AppError(502, 'Repository inspection failed.'); + } +} diff --git a/backend/src/stores/LegacyRepositoryStore.ts b/backend/src/stores/LegacyRepositoryStore.ts new file mode 100644 index 00000000..5ed40d3a --- /dev/null +++ b/backend/src/stores/LegacyRepositoryStore.ts @@ -0,0 +1,53 @@ +import { eq } from 'drizzle-orm'; +import { sql } from 'drizzle-orm/sql'; +import { DatabaseType } from '../db'; +import { + LegacyRepository, + LegacyRepositoryValidationStatus, + legacyRepositories, + NewLegacyRepository, +} from '../db/schema/legacyRepositories'; + +/** + * Persists only local registration metadata for externally created repositories. + * It has no relationship to plans, schedules, backups, or retention. + */ +export class LegacyRepositoryStore { + constructor(private db: DatabaseType) {} + + async getAll(): Promise { + return await this.db.query.legacyRepositories.findMany(); + } + + async getById(id: string): Promise { + return (await this.db.query.legacyRepositories.findFirst({ + where: eq(legacyRepositories.id, id), + })) || null; + } + + async create(data: NewLegacyRepository): Promise { + const result = await this.db.insert(legacyRepositories).values(data).returning(); + return result[0] || null; + } + + async updateValidationStatus( + id: string, + validationStatus: LegacyRepositoryValidationStatus + ): Promise { + const result = await this.db + .update(legacyRepositories) + .set({ + validationStatus, + lastValidatedAt: sql`(unixepoch())`, + updatedAt: sql`(unixepoch())`, + }) + .where(eq(legacyRepositories.id, id)) + .returning(); + return result[0] || null; + } + + async delete(id: string): Promise { + const result = await this.db.delete(legacyRepositories).where(eq(legacyRepositories.id, id)); + return result.changes > 0; + } +} diff --git a/backend/src/types/legacyRepositories.ts b/backend/src/types/legacyRepositories.ts new file mode 100644 index 00000000..f140783c --- /dev/null +++ b/backend/src/types/legacyRepositories.ts @@ -0,0 +1,44 @@ +import type { + LegacyRepository, + LegacyRepositoryBackend, + LegacyRepositoryValidationStatus, +} from '../db/schema/legacyRepositories'; + +export type LegacyRepositoryPublic = Omit; + +export type LegacyRepositoryRegistration = { + displayName: string; + repositoryPath: string; + password: string; +}; + +export type LegacyRepositorySnapshot = { + id: string; + shortId: string; + time: string; + hostname: string; + tags: string[]; + paths: string[]; + parent?: string; +}; + +export type LegacyRepositorySnapshotFilters = { + tag?: string; + path?: string; + host?: string; +}; + +export type LegacyRepositoryStats = { + totalSize: number; + totalUncompressedSize: number; + compressionRatio: number; + totalBlobCount: number; + snapshotCount: number; +}; + +export type LegacyRepositoryConnectionStatus = { + validationStatus: LegacyRepositoryValidationStatus; + lastValidatedAt: Date | null; +}; + +export type { LegacyRepositoryBackend, LegacyRepositoryValidationStatus }; diff --git a/backend/src/utils/restic/LegacyRepositoryInspector.ts b/backend/src/utils/restic/LegacyRepositoryInspector.ts new file mode 100644 index 00000000..42b667db --- /dev/null +++ b/backend/src/utils/restic/LegacyRepositoryInspector.ts @@ -0,0 +1,209 @@ +import { spawn } from 'child_process'; +import { z } from 'zod'; +import { getBinaryPath } from '../binaryPathResolver'; +import type { LegacyRepositorySnapshot, LegacyRepositoryStats } from '../../types/legacyRepositories'; + +const INSPECTION_TIMEOUT_MS = 30_000; +const MAX_INSPECTION_OUTPUT_BYTES = 8 * 1024 * 1024; +const ALLOWED_INSPECTION_OPERATIONS = new Set(['snapshots', 'stats']); + +export type LegacyRepositoryInspectionOperation = 'snapshots' | 'stats'; + +export type LegacyRepositoryInspectionFailure = + | 'forbidden-operation' + | 'wrong-password' + | 'repository-unavailable' + | 'timeout' + | 'invalid-output' + | 'output-limit' + | 'execution-failed'; + +/** + * Deliberately contains no subprocess output. Restic and transport errors can + * contain repository locations or backend details and are unsafe to return or log. + */ +export class LegacyRepositoryInspectionError extends Error { + constructor(public readonly kind: LegacyRepositoryInspectionFailure) { + super('Legacy repository inspection failed.'); + Object.setPrototypeOf(this, LegacyRepositoryInspectionError.prototype); + } +} + +export interface LegacyResticInspectionClient { + listSnapshots(repositoryPath: string, password: string): Promise; + getRepositoryStats(repositoryPath: string, password: string): Promise; +} + +const snapshotSchema = z.object({ + id: z.string().regex(/^[a-fA-F0-9]{64}$/), + time: z.string().min(1), + hostname: z.string().default(''), + tags: z.array(z.string()).default([]), + paths: z.array(z.string()).default([]), + parent: z.string().regex(/^[a-fA-F0-9]{64}$/).optional(), + short_id: z.string().optional(), +}); + +const snapshotsSchema = z.array(snapshotSchema); + +const statsSchema = z.object({ + total_size: z.number().nonnegative(), + total_uncompressed_size: z.number().nonnegative().default(0), + compression_ratio: z.number().nonnegative().default(0), + total_blob_count: z.number().int().nonnegative().default(0), + snapshots_count: z.number().int().nonnegative().default(0), +}); + +/** + * Purpose-built Restic command runner for imported repositories. + * + * It cannot receive arbitrary Restic arguments, always uses `--no-lock` and + * never forwards command stderr into application logs or API errors. + */ +export class ResticLegacyRepositoryInspector implements LegacyResticInspectionClient { + async listSnapshots(repositoryPath: string, password: string): Promise { + const output = await this.execute('snapshots', repositoryPath, password); + let parsed: unknown; + try { + parsed = JSON.parse(output); + } catch { + throw new LegacyRepositoryInspectionError('invalid-output'); + } + + const validation = snapshotsSchema.safeParse(parsed); + if (!validation.success) { + throw new LegacyRepositoryInspectionError('invalid-output'); + } + + return validation.data + .map(snapshot => ({ + id: snapshot.id, + shortId: snapshot.short_id || snapshot.id.slice(0, 8), + time: snapshot.time, + hostname: snapshot.hostname, + tags: snapshot.tags, + paths: snapshot.paths, + parent: snapshot.parent, + })) + .sort((left, right) => Date.parse(right.time) - Date.parse(left.time)); + } + + async getRepositoryStats(repositoryPath: string, password: string): Promise { + const output = await this.execute('stats', repositoryPath, password); + let parsed: unknown; + try { + parsed = JSON.parse(output); + } catch { + throw new LegacyRepositoryInspectionError('invalid-output'); + } + + const validation = statsSchema.safeParse(parsed); + if (!validation.success) { + throw new LegacyRepositoryInspectionError('invalid-output'); + } + + return { + totalSize: validation.data.total_size, + totalUncompressedSize: validation.data.total_uncompressed_size, + compressionRatio: validation.data.compression_ratio, + totalBlobCount: validation.data.total_blob_count, + snapshotCount: validation.data.snapshots_count, + }; + } + + async execute( + operation: string, + repositoryPath: string, + password: string, + timeoutMs: number = INSPECTION_TIMEOUT_MS + ): Promise { + if (!ALLOWED_INSPECTION_OPERATIONS.has(operation)) { + throw new LegacyRepositoryInspectionError('forbidden-operation'); + } + + const operationArgs = operation === 'stats' ? ['stats', '--mode', 'raw-data'] : ['snapshots']; + const args = [ + '--no-lock', + '--no-cache', + '--json', + '--repo', + repositoryPath, + ...operationArgs, + ]; + const environment = { ...process.env }; + delete environment.RESTIC_REPOSITORY; + delete environment.RESTIC_REPOSITORY_FILE; + delete environment.RESTIC_PASSWORD_COMMAND; + delete environment.RESTIC_PASSWORD_FILE; + environment.RESTIC_PASSWORD = password; + + return await new Promise((resolve, reject) => { + let settled = false; + let output = ''; + let outputBytes = 0; + const timeout = { value: undefined as NodeJS.Timeout | undefined }; + const settle = (callback: () => void) => { + if (settled) return; + settled = true; + if (timeout.value) clearTimeout(timeout.value); + callback(); + }; + + const child = spawn(getBinaryPath('restic'), args, { + env: environment, + shell: false, + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + timeout.value = setTimeout(() => { + try { + child.kill('SIGKILL'); + } catch { + // The child may already have exited. + } + settle(() => reject(new LegacyRepositoryInspectionError('timeout'))); + }, timeoutMs); + + child.on('error', () => { + settle(() => reject(new LegacyRepositoryInspectionError('execution-failed'))); + }); + + child.stdout?.on('data', (chunk: Buffer) => { + if (settled) return; + outputBytes += chunk.length; + if (outputBytes > MAX_INSPECTION_OUTPUT_BYTES) { + try { + child.kill('SIGKILL'); + } catch { + // The child may already have exited. + } + settle(() => reject(new LegacyRepositoryInspectionError('output-limit'))); + return; + } + output += chunk.toString(); + }); + + // Consume stderr so it cannot back up the process pipe, but never retain, + // log, or return it: it can include repository/backend details. + child.stderr?.on('data', () => undefined); + + child.on('close', code => { + if (settled) return; + if (code === 0) { + settle(() => resolve(output)); + return; + } + if (code === 12) { + settle(() => reject(new LegacyRepositoryInspectionError('wrong-password'))); + return; + } + if (code === 10) { + settle(() => reject(new LegacyRepositoryInspectionError('repository-unavailable'))); + return; + } + settle(() => reject(new LegacyRepositoryInspectionError('execution-failed'))); + }); + }); + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 00000000..e8851919 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,144 @@ +# Existing community architecture + +Baseline inspected for Phase 0: commit `5c74a48`, application version `0.20.0`. +This describes the checked-in public source, not proprietary editions. Phase 1 adds +a separate local, read-only legacy repository adapter; it does not modify the +managed backup lifecycle. + +## Application structure + +| Area | Current implementation and entry points | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Workspace | TypeScript monorepo with pnpm `10.20.0`, `backend/` and `frontend/` packages, and Turborepo build orchestration. Backend CI uses Node.js 24. See [package.json](../package.json), [pnpm-workspace.yaml](../pnpm-workspace.yaml), and [turbo.json](../turbo.json). | +| Backend | Node.js, Express 5, and TypeScript. [index.ts](../backend/src/index.ts) handles startup; [createApp.ts](../backend/src/createApp.ts) wires stores, managers, services, controllers, routes, middleware, and jobs. Routes/controllers call services, stores access the database, and managers/handlers run backup and restore operations. | +| Frontend | React 18, Vite 6, React Router 7, TanStack Query, and SCSS modules. [main.tsx](../frontend/src/main.tsx) installs providers; [router.tsx](../frontend/src/router.tsx) maps pages. `routes/`, `components/`, `services/`, and `hooks/` organize screens, UI, API access, and state. Snapshot browsing also uses Dexie/IndexedDB through [useSnapshotDatabase.ts](../frontend/src/components/common/SnapshotBrowser/hooks/useSnapshotDatabase.ts). | +| Database | SQLite through `better-sqlite3` and Drizzle ORM, with WAL enabled in [db/index.ts](../backend/src/db/index.ts). Schemas live in `backend/src/db/schema/`, migrations in `backend/drizzle/`, and persistence helpers in `backend/src/stores/`. Tables cover plans, backups, restores, devices, storages, settings, and the separate read-only legacy registrations. | +| Local state | [AppPaths.ts](../backend/src/utils/AppPaths.ts) locates the database, config, schedules, logs, temporary files, cache, downloads, and restores. Unpackaged development/test execution uses the current working directory's `data/`; production paths depend on installation mode and configuration. | +| Backup storage | Restic repositories are accessed via Rclone storage definitions. [generateResticRepoPath](../backend/src/utils/restic/helpers.ts) produces `rclone::`. The [storages schema](../backend/src/db/schema/storages.ts) includes settings and credential fields, and execution also uses local Rclone configuration. Database/runtime files and credentials must stay out of Git. | + +## Backup plan model + +[db/schema/plans.ts](../backend/src/db/schema/plans.ts) defines the `plans` table. +A plan has an ID, title/description, active/in-progress state, source ID/type, +storage ID/path, method, timestamps, tags, and JSON fields for source configuration, +settings, verification, and statistics. It relates to a device, storage, backups, +and restores; [PlanStore.ts](../backend/src/stores/PlanStore.ts) loads those records. + +[types/plans.ts](../backend/src/types/plans.ts) defines included/excluded source +paths and settings for intervals, retention, performance, integrity, encryption, +compression, retries, notifications, scripts, and replication. These are managed +workloads, not a neutral model of any pre-existing Restic repository. + +## Restic execution and managed lifecycle + +[runResticCommand](../backend/src/utils/restic/restic.ts) resolves the Restic/Rclone +binaries, constructs an argument array, and invokes `child_process.spawn` without +an explicit shell. It supplies per-call/global environment settings, Rclone +configuration, a local Restic cache, progress/error callbacks, and optional +timeouts. It is a general executor and does not enforce a legacy read-only policy. + +Managed backup/restore code derives the encrypted repository password from the +application `ENCRYPTION_KEY`; an unencrypted plan uses the explicit empty-password +path. Managed snapshots use `plan-` and `backup-` tags to associate Restic +data with application records. Existing repository passwords and untagged snapshots +therefore need their own abstraction. + +These existing paths remain outside the Phase 1 legacy adapter: + +- [BaseBackupManager.createBackup](../backend/src/managers/BaseBackupManager.ts) + invokes repository initialization, installs schedules, and can run a backup immediately. +- [BackupHandler](../backend/src/managers/handlers/BackupHandler.ts) can unlock stale + locks before a managed backup and adds application tags. +- [PruneHandler](../backend/src/managers/handlers/PruneHandler.ts) builds `forget` + with `--prune` using plan-specific retention settings. +- [BaseSnapshotManager](../backend/src/managers/BaseSnapshotManager.ts) and + [RestoreService](../backend/src/services/RestoreService.ts) work with managed + backup records; their interfaces are not an imported-repository safety boundary. + +They remain unchanged. The adapter has its own schema, store, service, controller, +routes, and [allowlisted inspector](../backend/src/utils/restic/LegacyRepositoryInspector.ts). +Its only Restic operations are JSON `snapshots` and `stats --mode raw-data`, each +with `--no-lock` and `--no-cache`; it does not reuse the managed executor. + +## Phase 1 legacy repository boundary + +The `legacy_repositories` table stores only local registration metadata, an +encrypted external repository password, mandatory `isReadOnly`, and local +validation status. [LegacyRepositoryService.ts](../backend/src/services/LegacyRepositoryService.ts) +rejects non-local or non-read-only records before any inspection. It validates +registration through snapshots, maps process errors to safe messages, applies exact +metadata filters locally, and deletes only the registration. + +Routes mounted at `/api/legacy-repositories` require the authenticated UI session. +The frontend route `/legacy-repositories` exposes registration, status, statistics, +and snapshot metadata only. Neither surface includes a browser, restore action, +retention action, mutation command, or lifecycle handoff. See +[LEGACY_REPOSITORY_DESIGN.md](LEGACY_REPOSITORY_DESIGN.md) for the exact Phase 1 +contract and known compatibility boundary. + +## Scheduling and job execution + +[CronManager.ts](../backend/src/managers/CronManager.ts) uses Croner and persists +dynamic schedules to JSON. [ScheduleReconciler.ts](../backend/src/services/ScheduleReconciler.ts) +reconciles stored plans with schedules. +[SystemTaskManager.ts](../backend/src/jobs/SystemTaskManager.ts) schedules maintenance +jobs using the same cron library. + +[JobQueue.ts](../backend/src/jobs/JobQueue.ts) is an in-memory queue with deduplication, +priority jobs, and retry state. +[JobProcessor.ts](../backend/src/jobs/JobProcessor.ts) polls it (every five seconds by +default), dispatches registered tasks, limits active backups, retries failures, and +emits failure events. Managers and event listeners update progress and database +records. [StartupRecovery.ts](../backend/src/services/StartupRecovery.ts) handles +interrupted managed work after restart; the job queue itself is not durable. + +## Existing features versus later work + +The public source already includes managed snapshot browsing, a restore wizard, +script hooks, and email/Slack/Discord/NTFY notification code. Remote strategy +interfaces also exist. Their presence does not establish that an imported legacy +repository can browse contents, restore, act as a standalone agent, or take over a +database lifecycle. Later roadmap phases must identify the additional behavior and +reuse only public code that meets the new boundaries. References to PRO/Business in +upstream package READMEs are attribution/context, not permission to inspect +proprietary implementations. + +## Validation commands + +Run from the repository root with Node.js 24 and the pinned pnpm version: + +```sh +pnpm install --frozen-lockfile +pnpm --filter @plutonhq/core-backend test --runInBand +pnpm --filter @plutonhq/core-backend lint +pnpm --filter @plutonhq/core-frontend lint +pnpm --filter @plutonhq/core-backend exec tsc --noEmit -p tsconfig.json +pnpm --filter @plutonhq/core-frontend exec tsc -b +pnpm build +git diff --check +``` + +The backend's Jest/ts-jest suites in `backend/__tests__/` cover routes, controllers, +services, stores, managers, jobs, utilities, and notifications. Restic/process unit +tests mock command execution; do not point validation at a real repository. +The frontend currently has no `test` script or checked-in test suite. + +Backend CI runs installation, backend lint, tests, and backend build; the pre-push +hook for `main` also builds the frontend. The root build orders the frontend build +(`tsc -b` and Vite) before the backend build (TypeScript, template copying/minification, +and copying frontend assets). Backend build cleans `backend/dist/` and +`backend/public/`; keep local data out of those generated directories. + +## Configuration and remaining questions + +The root [.env.template](../.env.template) already provides generic placeholders; +preserve it instead of adding a second configuration format. The tracked +`frontend/.env.dev` contains only app name/port settings. Ignoring future `.env.*` +files does not remove or sanitize tracked files. + +Phase 1 deliberately supports only local filesystem registrations and retains no +supported-Restic-version or repository-format matrix. Password storage uses local +encryption under `SECRET`; JSON parsing and output limits are implemented; and +`--no-lock` is required. No production inventory or repository was inspected. See +the [legacy adapter design](LEGACY_REPOSITORY_DESIGN.md) for the implemented +boundary and remaining compatibility work. diff --git a/docs/LEGACY_REPOSITORY_DESIGN.md b/docs/LEGACY_REPOSITORY_DESIGN.md new file mode 100644 index 00000000..7cd83028 --- /dev/null +++ b/docs/LEGACY_REPOSITORY_DESIGN.md @@ -0,0 +1,150 @@ +# Legacy Restic repository adapter — Phase 1 + +Status: implemented for local filesystem repositories only. This adapter uses only +the public Pluton source and documented Restic interfaces. It does not inspect a +real user repository during tests and does not certify a Restic-version or +repository-format compatibility matrix. + +## Adoption contract + +An operator can register an already-existing local Restic repository with a display +name, an absolute path on the Pluton server, and its own repository password. The +registration is a separate domain from managed plans, backups, schedules, storage +definitions, and retention. + +The adapter is mandatory read-only. The UI displays this state but provides no +control to change it. Removing a registration deletes only Pluton's local record; +it never deletes or changes repository contents. The original backup tooling keeps +ownership of scheduling, retention, maintenance, and credentials. + +Phase 1 provides connection validation, basic safe statistics, snapshot metadata, +and exact tag/path/host filters. It does not browse snapshot contents or restore +data. Those remain future work. + +## Stored local registration + +The [legacy repository schema](../backend/src/db/schema/legacyRepositories.ts) +creates a dedicated `legacy_repositories` table through migration +[`0003_luxuriant_fat_cobra.sql`](../backend/drizzle/0003_luxuriant_fat_cobra.sql). +It is not related to managed plans or backups. + +| Field | Meaning | +| --- | --- | +| `id`, `displayName` | Local registration identity and UI label. | +| `repositoryPath` | Existing, validated absolute local path. | +| `backend` | Fixed to `local` in Phase 1. Remote/Rclone backend locators are not accepted. | +| `encryptedPassword` | The external repository password encrypted for the local registration with Pluton's configured `SECRET`. It is never sent to the frontend. | +| `isReadOnly` | Always `true`; request payloads cannot opt out and non-read-only stored records are rejected. | +| `validationStatus`, `lastValidatedAt` | Local availability state from the most recent safe inspection. | +| `createdAt`, `updatedAt` | Local registration timestamps. | + +The repository password remains independent from Pluton's managed repository +password derivation and `ENCRYPTION_KEY`. `SECRET` protects the stored value; it +does not replace or re-key the external Restic password. The password is placed in +the child process environment only for the inspection and is not logged, returned, +or included in command arguments. + +## Read-only execution boundary + +[LegacyRepositoryInspector.ts](../backend/src/utils/restic/LegacyRepositoryInspector.ts) +is intentionally independent of the general managed `runResticCommand` helper. It +accepts only the following typed operations: + +| Operation | Fixed Restic arguments | Returned data | +| --- | --- | --- | +| Snapshot validation/listing | `--no-lock --no-cache --json --repo snapshots` | Normalized snapshot ID, time, host, tags, paths, and parent metadata. | +| Safe statistics | `--no-lock --no-cache --json --repo stats --mode raw-data` | Snapshot count, sizes, compression ratio, and blob count when the installed Restic supports it. | + +The executor builds an argument array, starts the binary with `shell: false`, uses +a bounded timeout and output limit, and discards stderr rather than logging it. +Before spawning it removes inherited `RESTIC_REPOSITORY`, repository-file, and +password-command/file variables. Structured JSON is validated before it becomes an +API response. User-provided filters are applied to returned snapshot metadata, so +they do not become raw CLI arguments. + +`--no-lock` is required because Restic reads can otherwise create repository lock +objects. It also means the repository owner must coordinate concurrent maintenance; +this adapter never attempts `unlock` as recovery. A read-only filesystem mount or +read-only backend credential is still recommended as defense in depth. See the +[Restic manual](https://restic.readthedocs.io/en/stable/manual_rest.html) for the +documented `--no-lock`, JSON, snapshot, and stats interfaces. + +The runtime allowlist rejects every other operation before process creation. In +particular, these commands are not available through the legacy adapter: + +- `backup` +- `forget` +- `prune` +- `unlock` +- `migrate` +- `repair` +- `init` +- `restore` + +There are no corresponding routes, jobs, retry handlers, or UI controls. The +adapter also does not use managed plan creation, retention, startup recovery, +replication, repository deletion, or repair/unlock paths. + +## API and UI + +All legacy routes require the authenticated UI session; the limited machine API-key +allowlist does not include them. + +| Endpoint | Local effect | +| --- | --- | +| `GET /api/legacy-repositories` | List safe local registration metadata. | +| `POST /api/legacy-repositories` | Validate with snapshot inspection, then store one read-only registration. | +| `GET /api/legacy-repositories/:id` | View one registration's safe metadata. | +| `POST /api/legacy-repositories/:id/validate` | Recheck access through snapshot inspection. | +| `GET /api/legacy-repositories/:id/snapshots` | List snapshot metadata, with optional exact `tag`, `path`, and `host` filters. | +| `GET /api/legacy-repositories/:id/snapshots/:snapshotId` | View one already-listed snapshot by full 64-character ID. | +| `GET /api/legacy-repositories/:id/stats` | Obtain safe statistics when supported by Restic. | +| `DELETE /api/legacy-repositories/:id` | Delete the local registration only. | + +The [Legacy Repositories UI](../frontend/src/routes/LegacyRepositories/) includes a +registration panel, repository list, availability status, basic statistics, +snapshot metadata, filters, and local-registration removal. It explicitly states +that the repository is read-only and that Pluton owns no retention policy. It has +no file browser, restoration path, retention action, or repository-mutation UI. + +## Error handling and validation + +The service validates a non-empty absolute path and credential before persistence. +It maps wrong-password, unavailable-repository, timeout, malformed JSON, oversized +output, and generic execution failures to safe API messages. Raw stderr, command +environment values, and password text do not cross the service boundary. + +Snapshot IDs must be full 64-character hexadecimal values. Tag, host, and path +filters use exact matching against the snapshot list. This deliberately avoids +inventing managed `plan-*` or `backup-*` tags and avoids content searches inside a +snapshot. + +## Tests and known boundary + +The Phase 1 tests use mocked child processes and generic fixtures rather than a +real repository: + +- [LegacyRepositoryInspector.test.ts](../backend/__tests__/utils/restic/LegacyRepositoryInspector.test.ts) + verifies fixed argument arrays, `shell: false`, inherited-environment clearing, + JSON parsing, safe statistics, password nondisclosure, and denial of all eight + forbidden operations before Restic is spawned. +- [LegacyRepositoryService.test.ts](../backend/__tests__/services/LegacyRepositoryService.test.ts) + verifies valid registration, invalid paths, incorrect credentials, multiple + workload filters, local-only deletion, encrypted credential storage, and the + mandatory read-only guard. +- [legacyRepositories.test.ts](../backend/__tests__/routes/legacyRepositories.test.ts) + verifies the session-only route boundary, safe public list data, and forwarding + of exact snapshot filters. + +These tests establish the application boundary. They do not prove a particular +external repository is mounted read-only or that every Restic version supports +`stats --mode raw-data`. Such support is reported safely at runtime and needs a +separately authorized disposable-fixture compatibility test before expanding the +support matrix. + +## Later work + +Phase 2 may add snapshot-content browsing and a separately authorized staged +restore design. It must validate an explicit isolated destination, prevent path or +symlink escape, handle cancellation and partial output, and keep imported +repository access read-only. Phase 1 does not add any restoration capability. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 00000000..1ad145ca --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,30 @@ +# Fork roadmap + +This public Apache-2.0 fork plans independently developed backup-management +capabilities for existing Restic environments. Use only the open-source Pluton +codebase, public Restic/Rclone interfaces, and independently written code. Preserve +upstream attribution and the Pluton name; proprietary PRO/Business implementations +and binaries are outside the development inputs. + +Phases 0 and 1 are complete within the stated local, read-only boundary. Phases +2-7 remain planned. The [architecture baseline](ARCHITECTURE.md) distinguishes +managed community functionality from the separate legacy adapter. + +| Phase | Planned scope | Completion evidence | +| --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 0 — Repository preparation | Complete: agent instructions, public-repository hygiene, architecture documentation, and an existing Restic compatibility analysis. No production access or live compatibility certification occurred. | Guardrails/design were reviewed, licenses and environment templates preserved, ignore rules checked, and validation commands recorded. | +| 1 — Legacy Restic Repository Adapter | Complete for local filesystem repositories: register an existing repository separately from managed plans, encrypt its independent password locally, enforce read-only access, list snapshots, inspect safe metadata/statistics, and filter by exact path/tag/host. | Dedicated allowlisted inspector uses `--no-lock`, argument arrays, structured JSON, bounded output, sanitized errors, and tests that deny all eight repository-mutating commands. Mocked generic fixtures were used; compatibility with a live repository is not certified. | +| 2 — Snapshot Browser and granular restore | Browse imported snapshot contents and restore selected files/directories into an explicitly selected staging directory. Evaluate reusable public UI components. | Tests cover path containment, symlinks, platform differences, snapshot identity, cancellation, and refusal to overwrite/delete live data. Repository access stays read-only. | +| 3 — Pilot migration of one backup workload | Separately authorize and document a controlled pilot for one synthetic/example workload, such as `app-01` and `/srv/example-app`, before applying the procedure privately to a real workload. Define validation, rollback, scheduling ownership, and cutover criteria. | Recovery from a staged restore is verified; the existing legacy repository remains read-only. Any new managed writes use a separately approved destination. No automatic repository conversion or retention takeover. | +| 4 — Remote Machine / independently developed Agent | Design and implement remote execution, authentication, capability boundaries, and lifecycle using independently written code and public interfaces. | Protocol/security review and tests for identity, authorization, disconnection, upgrades, cancellation, and scoped filesystem access. Existing public strategy hooks are not a completed agent. | +| 5 — Pre/Post Backup Scripts and database backup lifecycle | Assess existing community script hooks, then define the additional database dump/consistency, timeout, failure, artifact handling, and cleanup lifecycle. | Tests establish execution order, failed-dump behavior, credential redaction, and safe cleanup of generic database fixtures. No production credentials in examples. | +| 6 — Automated Recovery Testing | Restore into isolated disposable staging environments, validate recovery results, and report failures with bounded resource use and safe cleanup. | Repeatable recovery checks cannot overwrite live data or mutate imported repositories. Tests cover partial restores and failed cleanup. | +| 7 — Webhook and email notifications | Assess existing community notification code, then add the missing events/integrations for the new workflows. | Tested delivery/retry behavior and redacted payloads with generic destinations; existing notifications are not presented as newly implemented. | + +Each later phase needs a scoped implementation task and backend tests for new behavior. +Do not enable later phases as part of Phase 1. Registration never implies +permission to assume ownership of an existing repository's retention or maintenance. + +The next task, if separately authorized, may assess Phase 2 snapshot-content +browsing and staged restoration. Remote agents, database lifecycle, recovery +testing, and notification extensions remain later work. diff --git a/frontend/src/@types/index.ts b/frontend/src/@types/index.ts index 6ec50d02..4bad8c00 100644 --- a/frontend/src/@types/index.ts +++ b/frontend/src/@types/index.ts @@ -4,6 +4,7 @@ export * from './backups'; export * from './devices'; +export * from './legacyRepositories'; export * from './plans'; export * from './restores'; export * from './settings'; diff --git a/frontend/src/@types/legacyRepositories.ts b/frontend/src/@types/legacyRepositories.ts new file mode 100644 index 00000000..656bfb70 --- /dev/null +++ b/frontend/src/@types/legacyRepositories.ts @@ -0,0 +1,43 @@ +export type LegacyRepositoryValidationStatus = 'unknown' | 'available' | 'unavailable'; + +export interface LegacyRepository { + id: string; + displayName: string; + repositoryPath: string; + backend: 'local'; + isReadOnly: true; + validationStatus: LegacyRepositoryValidationStatus; + lastValidatedAt: string | null; + createdAt: string; + updatedAt: string | null; +} + +export interface LegacyRepositorySnapshot { + id: string; + shortId: string; + time: string; + hostname: string; + tags: string[]; + paths: string[]; + parent?: string; +} + +export interface LegacyRepositorySnapshotFilters { + tag?: string; + path?: string; + host?: string; +} + +export interface LegacyRepositoryStats { + totalSize: number; + totalUncompressedSize: number; + compressionRatio: number; + totalBlobCount: number; + snapshotCount: number; +} + +export interface LegacyRepositoryRegistration { + displayName: string; + repositoryPath: string; + password: string; +} diff --git a/frontend/src/components/App/SideNav/SideNav.tsx b/frontend/src/components/App/SideNav/SideNav.tsx index a4794d7f..4d5f0601 100644 --- a/frontend/src/components/App/SideNav/SideNav.tsx +++ b/frontend/src/components/App/SideNav/SideNav.tsx @@ -45,6 +45,20 @@ const SideNav = () => { )} + + {() => ( + + + + )} + {() => ( } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/routes/LegacyRepositories/LegacyRepositories.module.scss b/frontend/src/routes/LegacyRepositories/LegacyRepositories.module.scss new file mode 100644 index 00000000..ce3134dc --- /dev/null +++ b/frontend/src/routes/LegacyRepositories/LegacyRepositories.module.scss @@ -0,0 +1,424 @@ +.page { + width: 100%; +} + +.readOnlyNotice { + display: flex; + gap: 10px; + align-items: flex-start; + box-sizing: border-box; + margin: 20px 0; + padding: 14px 16px; + border: 1px solid var(--line-color); + border-left: 4px solid var(--primary-color); + border-radius: 6px; + background: var(--content-background-color); + + strong { + display: block; + color: var(--primary-color); + font-size: 0.76rem; + letter-spacing: 0.05em; + } + + p { + margin: 4px 0 0; + color: var(--content-text-color-light); + font-size: 0.82rem; + line-height: 1.45; + } +} + +.repositoryList { + display: grid; + gap: 12px; + margin-top: 20px; +} + +.repositoryCard { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 18px; + color: var(--content-text-color); + text-decoration: none; + background: var(--content-background-color); + border: 1px solid var(--line-color); + border-radius: 7px; + box-shadow: 0 0 12px var(--content-shadow-color); + transition: border-color 0.2s ease, transform 0.2s ease; + + &:hover { + border-color: var(--primary-color); + transform: translateY(-1px); + } +} + +.repositoryIdentity { + display: flex; + min-width: 0; + align-items: center; + gap: 12px; + + h3 { + margin: 0 0 5px; + font-size: 0.95rem; + } + + p { + overflow: hidden; + max-width: 600px; + margin: 0; + color: var(--content-text-color-light); + font-family: monospace; + font-size: 0.76rem; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.repositoryMeta { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 9px; + color: var(--content-text-color-light); + font-size: 0.75rem; +} + +.readOnlyPill, +.status { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 7px; + border-radius: 20px; + font-size: 0.68rem; + font-style: normal; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +.readOnlyPill { + color: var(--primary-color); + background: var(--primary-color-lighter); +} + +.available { + color: var(--success-text-color); + background: var(--success-bg-color); +} + +.unavailable { + color: var(--error-text-color); + background: var(--error-bg-color); +} + +.unknown { + color: var(--content-text-color-light); + background: var(--background-color); +} + +.field { + margin-bottom: 20px; + + label, + small { + display: block; + } + + label { + margin-bottom: 8px; + font-size: 0.78rem; + font-weight: 600; + } + + small { + margin-top: 7px; + color: var(--content-text-color-light); + font-size: 0.72rem; + line-height: 1.4; + } + + input { + width: 100%; + box-sizing: border-box; + padding: 12px; + color: var(--content-text-color); + border: 1px solid var(--field-line-color); + border-radius: 6px; + background: var(--content-background-color); + font-size: 0.82rem; + + &:focus { + outline: none; + border-color: var(--line-active-color); + } + } +} + +.primaryButton, +.secondaryButton, +.dangerButton, +.textButton { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + border-radius: 6px; + font-size: 0.75rem; + cursor: pointer; +} + +.primaryButton { + padding: 11px 16px; + color: var(--primary-text-color); + background: var(--primary-color); +} + +.secondaryButton { + padding: 9px 12px; + color: var(--content-text-color); + border: 1px solid var(--line-color); + background: var(--content-background-color); + + &:hover:not(:disabled) { + color: var(--primary-color); + border-color: var(--primary-color); + } +} + +.dangerButton { + padding: 9px 12px; + color: var(--error-text-color); + border: 1px solid var(--error-bg-active); + background: var(--error-bg-color); +} + +.textButton { + padding: 9px 4px; + color: var(--content-text-color-light); + background: transparent; +} + +.backLink { + display: inline-block; + margin-bottom: 18px; + color: var(--primary-color); + font-size: 0.8rem; + text-decoration: none; +} + +.summaryGrid, +.statsGrid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.summaryCard, +.section { + box-sizing: border-box; + padding: 18px; + border: 1px solid var(--line-color); + border-radius: 7px; + background: var(--content-background-color); + box-shadow: 0 0 12px var(--content-shadow-color); +} + +.summaryCard { + display: flex; + flex-direction: column; + gap: 8px; + + > span, + small { + color: var(--content-text-color-light); + font-size: 0.72rem; + } + + > strong { + font-size: 0.88rem; + } +} + +.pathValue { + overflow-wrap: anywhere; + font-family: monospace; +} + +.section { + margin-top: 20px; +} + +.sectionHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; + + h3 { + margin: 0 0 5px; + font-size: 0.95rem; + } + + p { + margin: 0; + color: var(--content-text-color-light); + font-size: 0.77rem; + line-height: 1.4; + } +} + +.statsGrid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + + div { + display: flex; + flex-direction: column; + gap: 5px; + padding: 12px; + border-radius: 6px; + background: var(--background-color); + } + + span { + color: var(--content-text-color-light); + font-size: 0.72rem; + } + + strong { + font-size: 0.92rem; + } +} + +.filters { + display: grid; + grid-template-columns: 1fr 1.5fr 1fr auto auto; + gap: 8px; + margin-bottom: 14px; + + input { + min-width: 0; + padding: 9px; + color: var(--content-text-color); + border: 1px solid var(--field-line-color); + border-radius: 6px; + background: var(--content-background-color); + font-size: 0.75rem; + } +} + +.snapshotList { + display: grid; + gap: 7px; +} + +.snapshotRow { + display: grid; + grid-template-columns: 1.1fr 1fr auto; + gap: 15px; + width: 100%; + padding: 12px; + color: var(--content-text-color); + border: 1px solid var(--line-color); + border-radius: 6px; + background: var(--background-color); + text-align: left; + cursor: pointer; + + &:hover { + border-color: var(--primary-color); + } + + div, + span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + div { + display: flex; + flex-direction: column; + gap: 3px; + } + + span { + color: var(--content-text-color-light); + font-size: 0.73rem; + } +} + +.snapshotMetadata { + display: grid; + gap: 13px; + + > div { + display: grid; + gap: 5px; + } + + span { + color: var(--content-text-color-light); + font-size: 0.72rem; + } + + strong, + code { + overflow-wrap: anywhere; + font-size: 0.8rem; + } +} + +.errorMessage, +.safeError { + padding: 11px 13px; + color: var(--error-text-color); + border-radius: 6px; + background: var(--error-bg-color); + font-size: 0.8rem; +} + +.emptyText { + color: var(--content-text-color-light); + font-size: 0.8rem; +} + +@media only screen and (max-width: 1024px) { + .summaryGrid, + .statsGrid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .filters { + grid-template-columns: 1fr 1fr; + } +} + +@media only screen and (max-width: 768px) { + .repositoryCard, + .repositoryMeta { + align-items: flex-start; + flex-direction: column; + } + + .repositoryMeta { + justify-content: flex-start; + } + + .summaryGrid, + .statsGrid, + .filters, + .snapshotRow { + grid-template-columns: 1fr; + } + + .repositoryIdentity p { + max-width: 250px; + } +} diff --git a/frontend/src/routes/LegacyRepositories/LegacyRepositories.tsx b/frontend/src/routes/LegacyRepositories/LegacyRepositories.tsx new file mode 100644 index 00000000..0b890a31 --- /dev/null +++ b/frontend/src/routes/LegacyRepositories/LegacyRepositories.tsx @@ -0,0 +1,67 @@ +import { useState } from 'react'; +import { Link } from 'react-router'; +import PageHeader from '../../components/common/PageHeader/PageHeader'; +import Icon from '../../components/common/Icon/Icon'; +import { useLegacyRepositories } from '../../services/legacyRepositories'; +import type { LegacyRepository } from '../../@types/legacyRepositories'; +import { formatDateTime } from '../../utils/helpers'; +import RegisterLegacyRepository from './RegisterLegacyRepository'; +import classes from './LegacyRepositories.module.scss'; + +const LegacyRepositories = () => { + const [showRegistration, setShowRegistration] = useState(false); + const { data, isLoading, error } = useLegacyRepositories(); + const repositories: LegacyRepository[] = data?.result || []; + + return ( +
+ setShowRegistration(true)} + /> +
+ +
+ READ ONLY +

Legacy repositories remain outside Pluton's backup and retention lifecycle. This area only checks access and displays existing snapshot metadata.

+
+
+ {error &&
{(error as Error).message}
} + {isLoading && ( +
+ +
+ )} + {!isLoading && repositories.length === 0 && ( +
+

+ No legacy repositories are registered. to inspect its snapshots. +

+
+ )} +
+ {repositories.map((repository) => ( + +
+ +
+

{repository.displayName}

+

{repository.repositoryPath}

+
+
+
+ {repository.validationStatus} + READ ONLY + Checked {repository.lastValidatedAt ? formatDateTime(repository.lastValidatedAt) : 'not yet'} +
+ + ))} +
+ {showRegistration && setShowRegistration(false)} />} +
+ ); +}; + +export default LegacyRepositories; diff --git a/frontend/src/routes/LegacyRepositories/LegacyRepositoryDetail.tsx b/frontend/src/routes/LegacyRepositories/LegacyRepositoryDetail.tsx new file mode 100644 index 00000000..ec40208f --- /dev/null +++ b/frontend/src/routes/LegacyRepositories/LegacyRepositoryDetail.tsx @@ -0,0 +1,193 @@ +import { useState, type FormEvent } from 'react'; +import { Link, useNavigate, useParams } from 'react-router'; +import { toast } from 'react-toastify'; +import ActionModal from '../../components/common/ActionModal/ActionModal'; +import Icon from '../../components/common/Icon/Icon'; +import Modal from '../../components/common/Modal/Modal'; +import NotFound from '../../components/common/NotFound/NotFound'; +import PageHeader from '../../components/common/PageHeader/PageHeader'; +import { + useDeleteLegacyRepository, + useLegacyRepository, + useLegacyRepositorySnapshots, + useLegacyRepositoryStats, + useValidateLegacyRepository, +} from '../../services/legacyRepositories'; +import type { LegacyRepositorySnapshotFilters, LegacyRepositorySnapshot } from '../../@types/legacyRepositories'; +import { formatBytes, formatDateTime } from '../../utils/helpers'; +import classes from './LegacyRepositories.module.scss'; + +const LegacyRepositoryDetail = () => { + const { id } = useParams(); + const navigate = useNavigate(); + const [filters, setFilters] = useState({}); + const [draftFilters, setDraftFilters] = useState({}); + const [selectedSnapshot, setSelectedSnapshot] = useState(); + const [showDelete, setShowDelete] = useState(false); + const { data: repositoryData, isLoading: repositoryLoading, error: repositoryError } = useLegacyRepository(id); + const { data: snapshotsData, isLoading: snapshotsLoading, error: snapshotsError } = useLegacyRepositorySnapshots(id, filters); + const { data: statsData, isLoading: statsLoading, error: statsError } = useLegacyRepositoryStats(id); + const validateMutation = useValidateLegacyRepository(); + const deleteMutation = useDeleteLegacyRepository(); + const repository = repositoryData?.result; + const snapshots = snapshotsData?.result || []; + const stats = statsData?.result; + + if (!id || (repositoryError && !repositoryLoading)) { + return ; + } + + const applyFilters = (event: FormEvent) => { + event.preventDefault(); + setFilters({ + tag: draftFilters.tag?.trim() || undefined, + path: draftFilters.path?.trim() || undefined, + host: draftFilters.host?.trim() || undefined, + }); + }; + + const clearFilters = () => { + setDraftFilters({}); + setFilters({}); + }; + + const validateAccess = () => { + validateMutation.mutate(id, { + onSuccess: () => toast.success('Repository access checked successfully.', { autoClose: 5000 }), + onError: (error: Error) => toast.error(error.message), + }); + }; + + const deleteRegistration = () => { + deleteMutation.mutate(id, { + onSuccess: () => { + toast.success('Legacy repository registration removed. The repository was not modified.', { autoClose: 5000 }); + navigate('/legacy-repositories'); + }, + onError: (error: Error) => toast.error(error.message), + }); + }; + + return ( +
+ ← Legacy Repositories + {repositoryLoading || !repository ? ( +
+ ) : ( + <> + + + + + } + /> +
+ +
+ READ ONLY +

No backup, restore, retention, prune, repair, migration, initialization, or lock cleanup is available for this repository.

+
+
+
+
+ Repository path + {repository.repositoryPath} +
+
+ Status + {repository.validationStatus} + {repository.lastValidatedAt ? `Last checked ${formatDateTime(repository.lastValidatedAt)}` : 'Not checked yet'} +
+
+ Backend + Local filesystem + Registered {formatDateTime(repository.createdAt)} +
+
+
+
+
+

Safe repository statistics

+

Read-only metadata reported by Restic when supported.

+
+
+ {statsLoading && } + {statsError &&

{(statsError as Error).message}

} + {stats && ( +
+
Snapshots{stats.snapshotCount}
+
Stored size{formatBytes(stats.totalSize)}
+
Uncompressed size{formatBytes(stats.totalUncompressedSize)}
+
Compression{stats.compressionRatio.toFixed(2)}×
+
+ )} +
+
+
+
+

Snapshots

+

Existing snapshot metadata only. Selecting a snapshot does not browse or restore its files.

+
+
+
+ setDraftFilters({ ...draftFilters, tag: event.target.value })} placeholder="Tag" /> + setDraftFilters({ ...draftFilters, path: event.target.value })} placeholder="Exact path" /> + setDraftFilters({ ...draftFilters, host: event.target.value })} placeholder="Host" /> + + +
+ {snapshotsError &&

{(snapshotsError as Error).message}

} + {snapshotsLoading && } + {!snapshotsLoading && !snapshotsError && snapshots.length === 0 &&

No snapshots match the current filters.

} +
+ {snapshots.map((snapshot) => ( + + ))} +
+
+ + )} + {selectedSnapshot && ( + setSelectedSnapshot(undefined)}> +
+
Snapshot ID{selectedSnapshot.id}
+
Time{formatDateTime(selectedSnapshot.time)}
+
Host{selectedSnapshot.hostname || 'Unknown host'}
+
Tags{selectedSnapshot.tags.length ? selectedSnapshot.tags.join(', ') : 'No tags'}
+
Paths{selectedSnapshot.paths.length ? selectedSnapshot.paths.map((path) => {path}) : No paths reported}
+ {selectedSnapshot.parent &&
Parent snapshot{selectedSnapshot.parent}
} +
+
+ )} + {showDelete && repository && ( + This removes only Pluton's local registration for {repository.displayName}. The repository and its snapshots will not be changed.} + closeModal={() => setShowDelete(false)} + primaryAction={{ + title: 'Remove registration', + type: 'danger', + icon: 'trash', + isPending: deleteMutation.isPending, + action: deleteRegistration, + }} + /> + )} +
+ ); +}; + +export default LegacyRepositoryDetail; diff --git a/frontend/src/routes/LegacyRepositories/RegisterLegacyRepository.tsx b/frontend/src/routes/LegacyRepositories/RegisterLegacyRepository.tsx new file mode 100644 index 00000000..3d17d649 --- /dev/null +++ b/frontend/src/routes/LegacyRepositories/RegisterLegacyRepository.tsx @@ -0,0 +1,91 @@ +import { useState, type FormEvent } from 'react'; +import { toast } from 'react-toastify'; +import Icon from '../../components/common/Icon/Icon'; +import SidePanel from '../../components/common/SidePanel/SidePanel'; +import { useRegisterLegacyRepository } from '../../services/legacyRepositories'; +import classes from './LegacyRepositories.module.scss'; + +type RegisterLegacyRepositoryProps = { + close: () => void; +}; + +const RegisterLegacyRepository = ({ close }: RegisterLegacyRepositoryProps) => { + const [displayName, setDisplayName] = useState(''); + const [repositoryPath, setRepositoryPath] = useState(''); + const [password, setPassword] = useState(''); + const registerMutation = useRegisterLegacyRepository(); + + const registerRepository = (event: FormEvent) => { + event.preventDefault(); + registerMutation.mutate( + { displayName, repositoryPath, password }, + { + onSuccess: () => { + toast.success('Legacy repository registered for read-only inspection.', { autoClose: 5000 }); + close(); + }, + onError: (error: Error) => toast.error(error.message), + } + ); + }; + + return ( + } + footer={ + + } + > +
+
+ +
+ READ ONLY +

Pluton will validate access and view snapshot metadata only. It will not create, restore, retain, delete, or modify this repository.

+
+
+
+ + setDisplayName(event.target.value)} + maxLength={100} + required + autoComplete="off" + /> +
+
+ + setRepositoryPath(event.target.value)} + required + autoComplete="off" + placeholder="C:\\backups\\legacy-repository" + /> + The path must be absolute on this Pluton server. +
+
+ + setPassword(event.target.value)} + required + autoComplete="new-password" + /> + The password is encrypted before the local registration is saved and is never shown again. +
+
+
+ ); +}; + +export default RegisterLegacyRepository; diff --git a/frontend/src/routes/index.ts b/frontend/src/routes/index.ts index c40302fe..d8b3a866 100644 --- a/frontend/src/routes/index.ts +++ b/frontend/src/routes/index.ts @@ -1,6 +1,8 @@ // Route Components export { default as DeviceSingle } from './DeviceSingle/DeviceSingle'; export { default as Login } from './Login/Login'; +export { default as LegacyRepositories } from './LegacyRepositories/LegacyRepositories'; +export { default as LegacyRepositoryDetail } from './LegacyRepositories/LegacyRepositoryDetail'; export { default as NotFoundRoute } from './NotFoundRoute/NotFoundRoute'; export { default as Plans } from './Plans/Plans'; export { default as PlanSingle } from './PlanSingle/PlanSingle'; diff --git a/frontend/src/services/index.ts b/frontend/src/services/index.ts index 45a08894..f6adcc70 100644 --- a/frontend/src/services/index.ts +++ b/frontend/src/services/index.ts @@ -4,6 +4,7 @@ export * from './backups'; export * from './devices'; +export * from './legacyRepositories'; export * from './plans'; export * from './restores'; export * from './settings'; diff --git a/frontend/src/services/legacyRepositories.ts b/frontend/src/services/legacyRepositories.ts new file mode 100644 index 00000000..f4a61cd3 --- /dev/null +++ b/frontend/src/services/legacyRepositories.ts @@ -0,0 +1,126 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { API_URL } from '../utils/constants'; +import type { + LegacyRepository, + LegacyRepositoryRegistration, + LegacyRepositorySnapshot, + LegacyRepositorySnapshotFilters, + LegacyRepositoryStats, +} from '../@types/legacyRepositories'; + +type ApiResponse = { + success: boolean; + result: T; + error?: string; + message?: string; +}; + +async function request(path: string, options: RequestInit = {}): Promise> { + const response = await fetch(`${API_URL}/legacy-repositories${path}`, { + credentials: 'include', + ...options, + }); + const data = (await response.json()) as ApiResponse; + if (!data.success) { + throw new Error(data.error || 'Legacy repository request failed.'); + } + return data; +} + +export function getLegacyRepositories() { + return request(''); +} + +export function useLegacyRepositories() { + return useQuery({ + queryKey: ['legacy-repositories'], + queryFn: getLegacyRepositories, + retry: false, + }); +} + +export function getLegacyRepository(id: string) { + return request(`/${encodeURIComponent(id)}`); +} + +export function useLegacyRepository(id?: string) { + return useQuery({ + queryKey: ['legacy-repositories', id], + queryFn: () => getLegacyRepository(id as string), + enabled: Boolean(id), + retry: false, + }); +} + +export function getLegacyRepositorySnapshots(id: string, filters: LegacyRepositorySnapshotFilters = {}) { + const query = new URLSearchParams(); + if (filters.tag) query.set('tag', filters.tag); + if (filters.path) query.set('path', filters.path); + if (filters.host) query.set('host', filters.host); + const suffix = query.toString(); + return request(`/${encodeURIComponent(id)}/snapshots${suffix ? `?${suffix}` : ''}`); +} + +export function useLegacyRepositorySnapshots(id?: string, filters: LegacyRepositorySnapshotFilters = {}) { + return useQuery({ + queryKey: ['legacy-repositories', id, 'snapshots', filters], + queryFn: () => getLegacyRepositorySnapshots(id as string, filters), + enabled: Boolean(id), + retry: false, + }); +} + +export function getLegacyRepositoryStats(id: string) { + return request(`/${encodeURIComponent(id)}/stats`); +} + +export function useLegacyRepositoryStats(id?: string) { + return useQuery({ + queryKey: ['legacy-repositories', id, 'stats'], + queryFn: () => getLegacyRepositoryStats(id as string), + enabled: Boolean(id), + retry: false, + }); +} + +export function useRegisterLegacyRepository() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (registration: LegacyRepositoryRegistration) => + request('', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(registration), + }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['legacy-repositories'] }), + }); +} + +export function useValidateLegacyRepository() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => + request<{ validationStatus: LegacyRepository['validationStatus']; lastValidatedAt: string }>( + `/${encodeURIComponent(id)}/validate`, + { method: 'POST', headers: { Accept: 'application/json' } } + ), + onSuccess: async (_result, id) => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['legacy-repositories'] }), + queryClient.invalidateQueries({ queryKey: ['legacy-repositories', id] }), + ]); + }, + }); +} + +export function useDeleteLegacyRepository() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => + request(`/${encodeURIComponent(id)}`, { + method: 'DELETE', + headers: { Accept: 'application/json' }, + }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['legacy-repositories'] }), + }); +}