feat: initial commit (#27)

* feat: initial commit (mise, nest, restic-api)

* ci: should use new nested tasks

* chore: use absolute imports

* chore: format

* fix: absolute imports for jest

* feat: REST API impl. with in-memory storage

* feat: s3 storage backing (wip)

* fix: use Range in S3 get object

* feat: validate path parameters

* chore: clean up checks

* chore: remove TODO

* docs: add descriptions to mise commands

* feat: JWT auth

* feat: load secrets from env (validate w. zod)

* chore: set default compose variables

* chore: add docker commands to mise

* fix: don't eat JWT errors

* fix: obfuscate s3 errors

* chore: validate auth schema

* feat: commit errors.ts

* fix: return partial status if Range provided

* chore: split jwt/payload validation errors

* fix: check name is a sha256 hash

* feat: streaming download

* feat: streaming upload

* feat: WORM / write once support

* fix: don't obscure other S3 errors for checkBucket

* refactor: more concise error types

* test: service unit tests for app/auth; e2e for storage

* chore: prettier ignore .dev folder

* feat: interceptor for Restic JSON routes

* test: app e2e tests (wip)

* test: partial content response

* chore: lint

* refactor: use class-validator for auth dto

* refactor: clean up constants through project

* refactor: also update binary content type

* fix: incorrect Basic parsing

* fix: actually handle validation errors in auth dto

* chore: delete .gitignore

* fix: should use 403 not 405 in worm

* chore: expose minio console for debug

* feat: delete config route

* fix: error consistency with restic reference

* feat: validate sha256 on blob upload

* fix: use bad request exception for mismatch

* feat: initial work on full e2e test

* fix: respond with correct headers for Range
refactor: unify respond logic

* test: backup & restore e2e test

* feat: setup empty yucca-api project

* feat: setup kysely for yucca-api & db repository

* refactor: `utils.ts` -> `utils/s3.ts`

* fix: shut down database after e2e test

* chore: remove stray log

* test: refactor getObject test

* chore: add built wrapper for now

* chore: add e2e/ to mise task

* chore: format

* fix: esm jest

* feat: setup database migrator

* feat: minimal template for frontend project

* chore: add prettier-plugin-tailwindcss to workspace

* chore: configure @immich/ui

* feat: sdk for web, hook up sample

* chore: add @oazapfts/runtime dependency

* chore: ignore `yucca-sdk/src/fetch-client.ts` from eslint

* chore: format

* chore: move all CI check stages into mise config

* test: mock $env/dynamic/publish for ui library

* chore: format code

* fix: correct restic API port in e2e test

* chore: move tasks around

* chore: use restic-wrapper from npm

* chore: string

* test: comprehensive e2e tests for restic API

* test: cover new commands in e2e tests

* test: worm-case for prune

* feat: lingui for svelte

* chore: ensure check runs lingui

* chore: ignore locales from checks

* chore: format/lint

* chore: configure nix-ld for mise

* chore: accept any no. of arguments for docker tasks/e2e

* chore: different strategy for e2e clean up

* chore: configure Git Town

Signed-off-by: izzy <me@insrt.uk>

* chore: add default.nix for mise

Signed-off-by: izzy <me@insrt.uk>

* chore: lost changes from parent

Signed-off-by: izzy <me@insrt.uk>

* fix: use valid language code

---------

Signed-off-by: izzy <me@insrt.uk>
This commit is contained in:
Paul Makles
2026-01-27 13:46:14 +00:00
committed by GitHub
parent 0f07813b18
commit 0b99068f23
140 changed files with 14733 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
data
+24
View File
@@ -0,0 +1,24 @@
name: ci
on:
pull_request:
branches: [main]
push:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0
- run: mise check
+37
View File
@@ -0,0 +1,37 @@
name: release-please
on:
workflow_dispatch: # For manual runs
release: # To update release PRs when one is merged
types: [published]
pull_request: # To handle the release label being removed
types: [unlabeled]
push: # Standard workflow
branches:
- main
concurrency:
group: release-please
cancel-in-progress: true
jobs:
release-please:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
outputs:
release_created: ${{ steps.rp.outputs.release_created }}
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- id: rp
uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4.4.0
with:
config-file: release-please-config.json
token: ${{ steps.generate-token.outputs.token }}
+6
View File
@@ -0,0 +1,6 @@
node_modules
.env.local
.env
mise.local.toml
+52
View File
@@ -5,3 +5,55 @@ restic = "0.18.0"
gh = "2.25.0"
"github:git-town/git-town" = "22.4.0"
[tasks.dev]
description = "Start all services in development mode"
depends = ["install:deps", "docker:start"]
run = [{ task = "*:dev" }]
[tasks.build]
description = "Build all packages"
depends = ["*:build"]
[tasks.test]
description = "Run all unit tests"
depends = ["*:test"]
[tasks."test:e2e"]
description = "Run all e2e tests"
run = [{ task = "*:test:e2e" }, { task = "e2e" }]
[tasks.check]
description = "Task group for CI checks"
run = [
{ task = "install:frozen" },
{ task = "build" },
{ tasks = [
"lint",
"*:check",
] },
{ task = "format" },
{ tasks = [
"docker:start",
"web:test:install-deps",
] },
{ tasks = [
"test",
"test:e2e",
] },
]
[env]
# default environment variables for development
YUCCA_API_PORT = 3000
RESTIC_API_PORT = 3010
S3_ACCESS_KEY_ID = "minio"
S3_SECRET_ACCESS_KEY = "miniominio"
S3_REGION = "minio"
S3_ENDPOINT = "http://localhost:9000"
S3_FORCE_PATH_STYLE = true
POSTGRES_HOST = "localhost"
POSTGRES_USERNAME = "postgres"
POSTGRES_PASSWORD = "postgres"
POSTGRES_DATABASE = "yucca"
JWT_SECRET = "cca13c34b450a77c1d4b9ecd25dff6aebc6d7417afdb31864f5943c59abd03a1"
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Start Docker containers"
set -e
docker compose up -d "$@"
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Stop Docker containers"
set -e
docker compose down "$@"
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
#MISE description="Run all end-to-end tests"
set -e
cleanup() {
lsof -ti:$RESTIC_API_PORT | xargs kill -9 2>/dev/null || true
}
trap cleanup EXIT INT TERM
# start services
mise restic-api:dev &
while ! nc -z localhost "$RESTIC_API_PORT" 2>/dev/null; do
sleep 0.1
done
pnpm --filter e2e test "$@"
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Check code formatting"
set -e
pnpm exec prettier --check .
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Fix code formatting"
set -e
pnpm exec prettier --write .
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Install all dependencies"
set -e
pnpm install
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Install all dependencies (don't update lockfile)"
set -e
pnpm install --frozen-lockfile
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Lint all code files"
set -e
pnpm exec eslint "**/src/**/*.ts" --max-warnings 0
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Fix all lint problems in code files"
set -e
pnpm exec eslint "**/src/**/*.ts" --max-warnings 0 --fix
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Build restic-api"
set -e
pnpm --filter restic-api build
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Run restic-api in development mode"
set -e
pnpm --filter restic-api start:dev
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Run unit tests for restic-api"
set -e
pnpm --filter restic-api test
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Run end-to-end tests for restic-api"
set -e
pnpm --filter restic-api test:e2e
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Run end-to-end tests in watch mode for restic-api"
set -e
pnpm --filter restic-api test:e2e --watch
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Run unit tests in watch mode for restic-api"
set -e
pnpm --filter restic-api test --watch
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
#MISE description="Build web"
#MISE depends=["yucca-sdk:build", "web:lingui"]
set -e
pnpm --filter web build
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Run Svelte checks for web"
set -e
pnpm --filter web check
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
#MISE description="Run web in development mode"
#MISE depends=["yucca-sdk:build", "web:lingui:compile"]
set -e
pnpm --filter web dev --host
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
#MISE description="Extract and compile i18n catalogs"
mise web:lingui:extract
mise web:lingui:compile
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
#MISE description="Ensure i18n catalogs are up to date"
#MISE depends=["web:lingui"]
set -e
git status --porcelain -- "web/src/locales" | grep -q . && exit 1 || exit 0
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Compile i18n catalogs"
set -e
pnpm --filter web lingui:compile
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Extract i18n catalog from source code"
set -e
pnpm --filter web lingui:extract
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
#MISE description="Run web in preview mode"
#MISE depends=["web:build"]
set -e
pnpm --filter web preview --host
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Run unit tests for web"
set -e
pnpm --filter web test:unit
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Run end-to-end tests for web"
set -e
pnpm --filter web test:e2e
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
#MISE description="Install playwright dependencies"
set -e
if [[ -z "${PLAYWRIGHT_BROWSERS_PATH}" ]]; then
pnpm --filter web exec playwright install
fi
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Build yucca-api"
set -e
pnpm --filter yucca-api build
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Run yucca-api in development mode"
set -e
pnpm --filter yucca-api start:dev
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
#MISE description="yucca-api: database migrations script"
#MISE dir="{{config_root}}/yucca-api"
#MISE depends=["yucca-api:build"]
set -e
node dist/bin/migrations.js
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
#MISE description="yucca-api: sync OpenAPI specification"
#MISE dir="{{config_root}}/yucca-api"
#MISE depends=["yucca-api:build"]
set -e
node dist/bin/sync-openapi.js
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Run unit tests for yucca-api"
set -e
pnpm --filter yucca-api test
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Run end-to-end tests for yucca-api"
set -e
pnpm --filter yucca-api test:e2e
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Run end-to-end tests in watch mode for yucca-api"
set -e
pnpm --filter yucca-api test:e2e --watch
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Run unit tests in watch mode for yucca-api"
set -e
pnpm --filter yucca-api test --watch
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
#MISE description="Generate and build yucca-sdk"
#MISE depends=["yucca-api:sync-openapi"]
set -e
pnpm --filter yucca-sdk generate
pnpm --filter yucca-sdk build
+25
View File
@@ -0,0 +1,25 @@
node_modules
coverage
.nyc_output
.DS_Store
*.log
.vscode
.idea
compiled
.awcache
.rpt2_cache
docs
dist
.dev
package-lock.json
pnpm-lock.yaml
yarn.lock
web/.svelte-kit
web/test-results
web/src/locales
yucca-sdk/src/fetch-client.ts
yucca-sdk/openapi-specs.json
+8
View File
@@ -0,0 +1,8 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 120,
"semi": true,
"organizeImportsSkipDestructiveCodeActions": true,
"plugins": ["prettier-plugin-organize-imports"]
}
+1
View File
@@ -0,0 +1 @@
{ "restic-api": "0.0.0" }
+15
View File
@@ -0,0 +1,15 @@
{
"editor.formatOnSave": true,
"files.associations": {
"*.css": "tailwindcss"
},
"files.exclude": {
"**/.git": true,
"**/.svn": true,
"**/.hg": true,
"**/.DS_Store": true,
"**/Thumbs.db": true,
"**/pack-*": true
},
"nixEnvSelector.nixFile": "${workspaceFolder}/default.nix"
}
+21
View File
@@ -0,0 +1,21 @@
init with valid JWT:
RESTIC_PASSWORD=password restic -r rest:http://:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiMDE5YmEzMjQtMjc0Ni03NmE2LThhNzQtMDEwMzg4MGYxZjc2IiwicmVwb3NpdG9yeSI6IjAxOWJhMzI0LTI3NDYtNzZhNi04YTc0LTAxMDM4ODBmMWY3NiIsIndyaXRlT25jZSI6ZmFsc2V9.t2PtMiB099B-WosU0hpw6udb0nobSiUN0BEpXY1WWrM@localhost:3000/019ba324-2746-76a6-8a74-0103880f1f76 init
The JWT:
{
"user": "019ba324-2746-76a6-8a74-0103880f1f76",
"repository": "019ba324-2746-76a6-8a74-0103880f1f76",
"writeOnce": false
}
And a WORM repo:
{
"user": "019ba324-2746-76a6-8a74-0103880f1f76",
"repository": "019ba324-2746-76a6-8a74-0103880f1f77",
"writeOnce": true
}
RESTIC_PASSWORD=password restic -r rest:http://:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiMDE5YmEzMjQtMjc0Ni03NmE2LThhNzQtMDEwMzg4MGYxZjc2IiwicmVwb3NpdG9yeSI6IjAxOWJhMzI0LTI3NDYtNzZhNi04YTc0LTAxMDM4ODBmMWY3NyIsIndyaXRlT25jZSI6dHJ1ZX0.f3mBt5KY3ZxLaOqzgOuvqaGJr2cPXKyV6alMjet4NCI@localhost:3000/019ba324-2746-76a6-8a74-0103880f1f77 init
+13
View File
@@ -0,0 +1,13 @@
## Development Guide
Ensure you have prerequisites installed:
- [Docker](https://docs.docker.com/engine/install/)
- [mise](https://mise.jdx.dev/getting-started.html)
Then use mise:
```bash
mise dev # install deps, prep environment, start servers
mise check # run all CI checks
```
+30
View File
@@ -0,0 +1,30 @@
name: yucca-devenv
services:
minio:
image: quay.io/minio/minio
volumes:
- .dev/data/minio:/data
command: server --console-address ":9001" /data
ports:
- 9000:9000
- 9001:9001
environment:
MINIO_ROOT_USER: ${S3_ACCESS_KEY_ID:-minio}
MINIO_ROOT_PASSWORD: ${S3_SECRET_ACCESS_KEY:-miniominio}
_MINIO_DRIVE_ACTIVE_MONITORING: off
database:
image: postgres:18
environment:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: yucca
ports:
- 5432:5432
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres -d yucca']
interval: 1s
timeout: 5s
retries: 30
start_period: 10s
+1
View File
@@ -0,0 +1 @@
pack-*
+40
View File
@@ -0,0 +1,40 @@
{
"name": "e2e",
"version": "1.0.0",
"description": "",
"type": "module",
"scripts": {
"test": "NODE_OPTIONS='--experimental-vm-modules' jest",
"test:watch": "NODE_OPTIONS='--experimental-vm-modules' jest --watch"
},
"keywords": [],
"author": "",
"license": "ISC",
"packageManager": "pnpm@10.27.0",
"devDependencies": {
"@types/jest": "^30.0.0",
"@types/node": "^22.19.3",
"jest": "^30.2.0",
"jsonwebtoken": "^9.0.3",
"ts-jest": "^29.4.6",
"typescript": "^5.9.3"
},
"jest": {
"preset": "ts-jest/presets/default-esm",
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/src/$1"
},
"transform": {
"^.+\\.tsx?$": [
"ts-jest",
{
"useESM": true
}
]
}
},
"dependencies": {
"@futo-org/restic-wrapper": "1.1.0",
"zod": "^4.3.5"
}
}
+12
View File
@@ -0,0 +1,12 @@
import { z } from 'zod';
const schema = z.object({
RESTIC_API_PORT: z.coerce.number().min(1000),
YUCCA_API_PORT: z.coerce.number().min(1000),
JWT_SECRET: z.string().min(32),
});
const env = schema.parse(process.env);
export default env;
+14
View File
@@ -0,0 +1,14 @@
import { version } from '@futo-org/restic-wrapper';
import env from 'src/env';
it('can connect to the restic API', async () => {
await expect(fetch(`http://localhost:${env.RESTIC_API_PORT}`).then((response) => response.status)).resolves.toBe(404);
});
it('can load restic', async () => {
await expect(version).resolves.toEqual(
expect.objectContaining({
version: '0.18.0',
}),
);
});
+562
View File
@@ -0,0 +1,562 @@
import {
backup,
cache,
cat,
check,
copy,
diff,
dump,
find,
forget,
init,
keyAdd,
keyList,
keyRemove,
list,
ls,
prune,
recover,
repair,
restore,
rewrite,
snapshots,
stats,
tag,
unlock,
} from '@futo-org/restic-wrapper';
import { jest } from '@jest/globals';
import jwt from 'jsonwebtoken';
import { randomUUID } from 'node:crypto';
import { mkdir, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import env from 'src/env';
const password = 'password';
function generateCase(writeOnce: boolean) {
const repository = randomUUID();
const token = jwt.sign(
{
user: randomUUID(),
repository,
writeOnce,
},
env.JWT_SECRET,
);
const repoUrl = `rest:http://restic:${token}@localhost:${env.RESTIC_API_PORT}/${repository}/`;
return {
repository,
repoUrl,
};
}
describe.each([
{ name: 'restic API', ...generateCase(false), writeOnce: false },
{ name: 'restic WORM API', ...generateCase(true), writeOnce: true },
])('$name (e2e)', ({ writeOnce, repoUrl }) => {
let workingDir;
beforeAll(async () => {
workingDir = tmpdir();
await mkdir(resolve(workingDir, 'folder'), {
recursive: true,
});
await writeFile(resolve(workingDir, 'folder', 'test-file'), 'test-file');
await writeFile(resolve(workingDir, 'folder', 'hello.json'), '{}');
await writeFile(join(workingDir, 'pwd'), 'password');
});
describe('init', () => {
jest.retryTimes(2);
it('creates a repository', async () => {
await init().repository(repoUrl).password(password).run();
}, 10_000);
});
describe('backup', () => {
it('creates a backup', async () => {
await backup().repository(repoUrl).password(password).addFile(resolve(workingDir, 'folder')).run();
});
});
describe('cache', () => {
it('returns cache directories', async () => {
await expect(cache().repository(repoUrl).password('password').run()).resolves.toEqual(
expect.objectContaining({
directories: expect.any(Number),
table: expect.arrayContaining([
expect.objectContaining({
'Last Used': expect.any(String),
Old: false,
'Repo ID': expect.any(String),
Size: expect.any(String),
}),
]),
}),
);
});
});
describe('cat', () => {
it('can read config', async () => {
expect(await cat().repository(repoUrl).password(password).target('config').run()).toEqual(
expect.objectContaining({
version: expect.any(Number),
}),
);
});
});
describe('check', () => {
it('can check repository', async () => {
expect(await check().repository(repoUrl).password(password).run()).toEqual(
expect.arrayContaining([
expect.objectContaining({
num_errors: 0,
}),
]),
);
});
});
describe('copy', () => {
let otherRepoUrl: string;
beforeEach(async () => {
otherRepoUrl = generateCase(writeOnce).repoUrl;
await init().repository(otherRepoUrl).password(password).run();
}, 10_000);
it('copies everything to new repository', async () => {
await copy()
.fromRepo(repoUrl)
.fromPasswordFile(join(workingDir, 'pwd'))
.repository(otherRepoUrl)
.password('password')
.run();
await expect(snapshots().repository(otherRepoUrl).password('password').run()).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
paths: expect.arrayContaining([expect.stringContaining('folder')]),
}),
]),
);
});
});
describe('diff', () => {
let snapshotA: string;
let snapshotB: string;
beforeEach(async () => {
const diffWorkingDir = tmpdir();
await mkdir(resolve(workingDir, 'diff'), {
recursive: true,
});
await writeFile(resolve(workingDir, 'diff', 'test-file'), 'data');
await writeFile(resolve(workingDir, 'diff', 'deleted-file'), 'data');
const { snapshot_id: snapshot_a } = await backup()
.repository(repoUrl)
.password(password)
.addFile(resolve(diffWorkingDir, 'diff', 'test-file'))
.addFile(resolve(diffWorkingDir, 'diff', 'deleted-file'))
.run();
await writeFile(resolve(workingDir, 'diff', 'test-file'), 'changed data');
await writeFile(resolve(workingDir, 'diff', 'new-file'), 'new data');
const { snapshot_id: snapshot_b } = await backup()
.repository(repoUrl)
.password(password)
.addFile(resolve(diffWorkingDir, 'diff', 'test-file'))
.addFile(resolve(diffWorkingDir, 'diff', 'new-file'))
.run();
snapshotA = snapshot_a;
snapshotB = snapshot_b;
});
it('correctly produces a diff', async () => {
await expect(diff().repository(repoUrl).password(password).compare(snapshotA, snapshotB).run()).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
path: expect.stringMatching(/deleted-file/),
modifier: '-',
}),
expect.objectContaining({
path: expect.stringMatching(/new-file/),
modifier: '+',
}),
expect.objectContaining({
path: expect.stringMatching(/test-file/),
modifier: 'M',
}),
expect.objectContaining({
source_snapshot: snapshotA,
target_snapshot: snapshotB,
added: expect.objectContaining({
files: 1,
}),
removed: expect.objectContaining({
files: 1,
}),
}),
]),
);
});
});
describe('dump', () => {
let snapshotId: string;
beforeEach(async () => {
const result = await backup().repository(repoUrl).password(password).addFile(resolve(workingDir, 'folder')).run();
snapshotId = result.snapshot_id;
});
it('dumps file to stdout', async () => {
const buffer = await dump()
.repository(repoUrl)
.password('password')
.snapshot(snapshotId)
.file(join(workingDir, 'folder', 'test-file'))
.run();
expect(buffer.toString()).toBe('test-file');
});
it('dumps folder as tar', async () => {
const buffer = await dump()
.repository(repoUrl)
.password('password')
.snapshot(snapshotId)
.file(join(workingDir, 'folder'))
.run();
expect(buffer.subarray(257, 262).toString()).toBe('ustar');
expect(buffer.length).toBeGreaterThanOrEqual(512);
});
});
describe('find', () => {
let snapshotId: string;
beforeEach(async () => {
const result = await backup().repository(repoUrl).password(password).addFile(resolve(workingDir, 'folder')).run();
snapshotId = result.snapshot_id;
});
it('finds objects', async () => {
await expect(find().repository(repoUrl).password(password).match('*.json').object().run()).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
hits: 1,
snapshot: snapshotId,
matches: expect.arrayContaining([
expect.objectContaining({
path: expect.stringContaining('hello.json'),
}),
]),
}),
]),
);
});
});
describe('forget', () => {
let snapshotId: string;
beforeEach(async () => {
const result = await backup().repository(repoUrl).password(password).addFile(resolve(workingDir, 'folder')).run();
snapshotId = result.snapshot_id;
});
it(writeOnce ? 'does not forget snapshot' : 'forgets snapshot', async () => {
await forget().repository(repoUrl).password(password).snapshot(snapshotId).run();
await expect(snapshots().repository(repoUrl).password(password).run()).resolves.toEqual(
writeOnce
? expect.arrayContaining([
expect.objectContaining({
id: snapshotId,
}),
])
: expect.not.arrayContaining([
expect.objectContaining({
id: snapshotId,
}),
]),
);
});
});
describe('keyList', () => {
it('lists keys', async () => {
const keys = await keyList().repository(repoUrl).password(password).run();
expect(keys.length).toBe(1);
expect(keys[0].current).toBeTruthy();
});
});
describe('keyAdd & keyRemove', () => {
let keyId: string;
it('adds a new key', async () => {
await keyAdd().repository(repoUrl).password(password).newInsecureNoPassword().run();
const keys = await keyList().repository(repoUrl).insecureNoPassword().run();
expect(keys.length).toBe(2);
keyId = keys.find((key) => key.current)!.id;
}, 10_000);
if (writeOnce) {
it('fails to remove the new key', async () => {
await expect(keyRemove().repository(repoUrl).password(password).keyId(keyId).run()).rejects.toThrow();
});
} else {
it('removes the new key', async () => {
await keyRemove().repository(repoUrl).password(password).keyId(keyId).run();
const keys = await keyList().repository(repoUrl).password(password).run();
expect(keys.length).toBe(1);
});
}
});
describe('list', () => {
it.each(['blobs', 'packs', 'index', 'snapshots', 'keys', 'locks'])('lists %s', async (type) => {
const items = await list()
.repository(repoUrl)
.password('password')
.type(type as any)
.run();
expect(Array.isArray(items)).toBe(true);
});
});
describe('ls', () => {
let snapshotId: string;
beforeEach(async () => {
const result = await backup().repository(repoUrl).password(password).addFile(resolve(workingDir, 'folder')).run();
snapshotId = result.snapshot_id;
});
it('provides a file listing', async () => {
await expect(ls().repository(repoUrl).password(password).snapshot(snapshotId).run()).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
message_type: 'snapshot',
id: snapshotId,
}),
expect.objectContaining({
path: expect.stringContaining('test-file'),
}),
expect.objectContaining({
path: expect.stringContaining('hello.json'),
}),
]),
);
});
});
describe('prune', () => {
beforeEach(async () => {
const { snapshot_id } = await backup()
.repository(repoUrl)
.password('password')
.addFile(join(workingDir, 'pwd'))
.run();
await forget().repository(repoUrl).password('password').snapshot(snapshot_id).run();
});
if (writeOnce) {
it("can't prune data", async () => {
await expect(prune().repository(repoUrl).password('password').run()).rejects.toThrow();
});
} else {
it('prunes old data', async () => {
const blobsBefore = await list().repository(repoUrl).password('password').type('blobs').run();
await prune().repository(repoUrl).password('password').run();
const blobsAfter = await list().repository(repoUrl).password('password').type('blobs').run();
expect(blobsBefore).not.toEqual(blobsAfter);
});
}
});
describe('recover', () => {
beforeEach(async () => {
const { snapshot_id } = await backup()
.repository(repoUrl)
.password('password')
.addFile(join(workingDir, 'pwd'))
.run();
await forget().repository(repoUrl).password('password').snapshot(snapshot_id).run();
});
if (writeOnce) {
it('fails to generate snapshot from raw data (mutable operation)', async () => {
await expect(recover().repository(repoUrl).password('password').run()).rejects.toThrow();
});
} else {
it('generates a new snapshot from raw data', async () => {
await recover().repository(repoUrl).password('password').run();
await expect(snapshots().repository(repoUrl).password('password').run()).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
tags: ['recovered'],
}),
]),
);
});
}
});
describe('repair', () => {
if (writeOnce) {
it('fails to generate a new index', async () => {
await expect(repair().repository(repoUrl).password('password').index().run()).rejects.toThrow();
});
it('fails to repair packs', async () => {
const packs = await list().repository(repoUrl).password('password').type('packs').run();
await expect(
repair()
.repository(repoUrl)
.password('password')
.pack(...packs)
.run(),
).rejects.toThrow();
});
} else {
it('generates a new index', async () => {
await repair().repository(repoUrl).password('password').index().run();
});
it('repairs packs', async () => {
const packs = await list().repository(repoUrl).password('password').type('packs').run();
await repair()
.repository(repoUrl)
.password('password')
.pack(...packs)
.run();
});
}
it('repairs snapshots', async () => {
await repair().repository(repoUrl).password('password').snapshots().run();
});
});
describe('restore', () => {
let snapshotId: string;
beforeEach(async () => {
const result = await backup().repository(repoUrl).password(password).addFile(resolve(workingDir, 'folder')).run();
snapshotId = result.snapshot_id;
});
it('restores a file', async () => {
await restore()
.repository(repoUrl)
.password(password)
.snapshot(snapshotId)
.target(resolve(workingDir, 'restored'))
.run();
await stat(resolve(workingDir, 'restored', workingDir, 'folder', 'test-file'));
});
});
describe('rewrite', () => {
let snapshotId: string;
beforeEach(async () => {
const result = await backup().repository(repoUrl).password(password).addFile(resolve(workingDir, 'folder')).run();
snapshotId = result.snapshot_id;
});
it('rewrite one snapshot', async () => {
const listBefore = await snapshots().repository(repoUrl).password('password').run();
await rewrite().repository(repoUrl).password('password').snapshot(snapshotId).exclude('hello.json').run();
const listAfter = await snapshots().repository(repoUrl).password('password').run();
expect(listAfter.length).toBe(listBefore.length + 1);
});
});
describe('snapshots', () => {
let snapshotId: string;
beforeEach(async () => {
const result = await backup().repository(repoUrl).password(password).addFile(resolve(workingDir, 'folder')).run();
snapshotId = result.snapshot_id;
});
it('lists snapshots', async () => {
await expect(snapshots().repository(repoUrl).password(password).run()).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
id: snapshotId,
}),
]),
);
});
});
describe('stats', () => {
it('generates stats', async () => {
await expect(stats().repository(repoUrl).password(password).run()).resolves.toEqual(
expect.objectContaining({
total_size: expect.any(Number),
total_file_count: expect.any(Number),
snapshots_count: expect.any(Number),
}),
);
});
});
describe('tag', () => {
let snapshotId: string;
beforeEach(async () => {
const result = await backup().repository(repoUrl).password(password).addFile(resolve(workingDir, 'folder')).run();
snapshotId = result.snapshot_id;
});
it('updates a specific snapshot', async () => {
await tag().repository(repoUrl).password(password).set('new-tag').snapshot(snapshotId).run();
await expect(snapshots().repository(repoUrl).password(password).run()).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
tags: ['new-tag'],
}),
]),
);
});
});
describe('unlock', () => {
it('works', async () => {
await unlock().repository(repoUrl).password('password').run();
});
});
});
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false,
"paths": {
"src/*": ["./src/*"]
}
},
"include": ["src", "test"]
}
+64
View File
@@ -0,0 +1,64 @@
import js from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import eslintPluginUnicorn from 'eslint-plugin-unicorn';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import typescriptEslint from 'typescript-eslint';
const __filename = fileURLToPath(import.meta.url);
export default typescriptEslint.config([
eslintPluginUnicorn.configs.recommended,
eslintPluginPrettierRecommended,
js.configs.recommended,
typescriptEslint.configs.recommended,
{
ignores: ['eslint.config.mjs', '.dev', 'web/.svelte-kit', 'web/src/locales', 'yucca-sdk/src/fetch-client.ts'],
},
{
languageOptions: {
parser: typescriptEslint.parser,
ecmaVersion: 5,
sourceType: 'module',
parserOptions: {
project: ['./*/tsconfig.json'],
tsconfigRootDir: path.dirname(__filename),
},
},
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'error',
'unicorn/prevent-abbreviations': 'off',
'unicorn/filename-case': 'off',
'unicorn/no-null': 'off',
'unicorn/prefer-top-level-await': 'off',
'unicorn/prefer-event-target': 'off',
'unicorn/no-thenable': 'off',
'unicorn/import-style': 'off',
'unicorn/prefer-structured-clone': 'off',
'unicorn/no-for-loop': 'off',
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/no-misused-promises': 'error',
'require-await': 'off',
'@typescript-eslint/require-await': 'error',
curly: 2,
'prettier/prettier': 0,
'object-shorthand': ['error', 'always'],
'no-unexpected-multiline': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
},
},
]);
+19
View File
@@ -0,0 +1,19 @@
{
"name": "yucca-monorepo",
"version": "0.0.1",
"description": "Monorepo for yucca",
"private": true,
"packageManager": "pnpm@10.27.0+sha512.72d699da16b1179c14ba9e64dc71c9a40988cbdc65c264cb0e489db7de917f20dcf4d64d8723625f2969ba52d4b7e2a1170682d9ac2a5dcaeaab732b7e16f04a",
"devDependencies": {
"@eslint/js": "^9.39.2",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-unicorn": "^62.0.0",
"prettier": "^3.7.4",
"prettier-plugin-organize-imports": "^4.3.0",
"prettier-plugin-svelte": "^3.4.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"typescript-eslint": "^8.52.0"
}
}
+10723
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
packages:
- e2e
- web
- restic-api
- yucca-api
- yucca-sdk
onlyBuiltDependencies:
- '@nestjs/core'
- '@scarf/scarf'
- esbuild
- unrs-resolver
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"release-type": "node",
"prerelease": true,
"packages": {
"restic-api": {
"package-name": "restic-api",
"prerelease-type": "alpha"
}
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["local>immich-app/.github:renovate-config"]
}
+56
View File
@@ -0,0 +1,56 @@
# compiled output
/dist
/node_modules
/build
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# temp directory
.temp
.tmp
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+73
View File
@@ -0,0 +1,73 @@
{
"name": "restic-api",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.965.0",
"@aws-sdk/lib-storage": "^3.965.0",
"@nestjs/common": "^11.0.1",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/platform-express": "^11.0.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"zod": "^4.3.5"
},
"devDependencies": {
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.10.7",
"@types/supertest": "^6.0.2",
"globals": "^16.0.0",
"jest": "^30.0.0",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0",
"vitest": "^4.0.16"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/$1"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
+30
View File
@@ -0,0 +1,30 @@
import { Module } from '@nestjs/common';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { JwtModule } from '@nestjs/jwt';
import { AppController } from './controllers/app.controller';
import env from './env';
import { AuthGuard } from './middleware/auth.guard';
import { ResticInterceptor } from './middleware/restic.interceptor';
import { LoggerRepository } from './repositories/logger.repository';
import { StorageRepository } from './repositories/storage.repository';
import { AppService } from './services/app.service';
import { AuthService } from './services/auth.service';
@Module({
imports: [
JwtModule.register({
global: true,
secret: env.JWT_SECRET,
}),
],
controllers: [AppController],
providers: [
LoggerRepository,
StorageRepository,
AuthService,
AppService,
{ provide: APP_GUARD, useClass: AuthGuard },
{ provide: APP_INTERCEPTOR, useClass: ResticInterceptor },
],
})
export class AppModule {}
@@ -0,0 +1,119 @@
import {
Controller,
Delete,
Get,
Head,
Headers,
HttpCode,
HttpStatus,
Param,
ParseBoolPipe,
Post,
Query,
Req,
Res,
} from '@nestjs/common';
import { type Request, type Response } from 'express';
import { BlobInfoResponseDto } from 'src/dto/app.dto';
import { type AuthDto } from 'src/dto/auth.dto';
import { Auth, AuthRoute } from 'src/middleware/auth.guard';
import { ResticRoute } from 'src/middleware/restic.interceptor';
import { AppService } from 'src/services/app.service';
import { respondWithObject } from 'src/utils/s3';
import { BlobParamsDto, BlobWithNameParamsDto } from 'src/validation';
@Controller()
export class AppController {
constructor(private readonly service: AppService) {}
@Post(':path')
@AuthRoute()
@HttpCode(HttpStatus.OK)
async createRepository(@Auth() auth: AuthDto, @Query('create', ParseBoolPipe) isCreate: boolean): Promise<void> {
await this.service.createRepository(auth.repository, isCreate);
}
@Delete(':path')
@AuthRoute()
@HttpCode(HttpStatus.NOT_IMPLEMENTED)
deleteRepository(@Auth() _auth: AuthDto): void {
this.service.deleteRepository();
}
@Head(':path/config')
@AuthRoute()
async checkConfig(@Auth() auth: AuthDto, @Res() res: Response): Promise<void> {
const size = await this.service.checkConfig(auth.repository);
res.set('Content-Length', String(size)).end();
}
@Get(':path/config')
@AuthRoute()
async getConfig(@Auth() auth: AuthDto, @Req() req: Request, @Res() res: Response): Promise<void> {
const config = await this.service.getConfig(auth.repository);
respondWithObject(config, req, res);
}
@Post(':path/config')
@AuthRoute()
@HttpCode(HttpStatus.OK)
async saveConfig(@Auth() auth: AuthDto, @Req() req: Request): Promise<void> {
await this.service.saveConfig(auth.repository, req, auth.writeOnce);
}
@Delete(':path/config')
@AuthRoute()
@HttpCode(HttpStatus.OK)
async deleteConfig(@Auth() auth: AuthDto): Promise<void> {
await this.service.deleteConfig(auth.repository, auth.writeOnce);
}
@Get(':path/:type')
@AuthRoute()
@ResticRoute()
async listBlobs(@Auth() auth: AuthDto, @Param() { type }: BlobParamsDto): Promise<BlobInfoResponseDto[]> {
return this.service.listBlobs(auth.repository, type);
}
@Head(':path/:type/:name')
@AuthRoute()
async checkBlob(
@Auth() auth: AuthDto,
@Param() { type, name }: BlobWithNameParamsDto,
@Res() res: Response,
): Promise<void> {
const size = await this.service.checkBlob(auth.repository, type, name);
res.set('Content-Length', String(size)).end();
}
@Get(':path/:type/:name')
@AuthRoute()
async getBlob(
@Auth() auth: AuthDto,
@Param() { type, name }: BlobWithNameParamsDto,
@Headers('range') range: string | undefined,
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
const blob = await this.service.getBlob(auth.repository, type, name, range);
respondWithObject(blob, req, res);
}
@Post(':path/:type/:name')
@AuthRoute()
@HttpCode(HttpStatus.OK)
async saveBlob(
@Auth() auth: AuthDto,
@Param() { type, name }: BlobWithNameParamsDto,
@Req() req: Request,
): Promise<void> {
await this.service.saveBlob(auth.repository, type, name, req, auth.writeOnce);
}
@Delete(':path/:type/:name')
@AuthRoute()
@HttpCode(HttpStatus.OK)
async deleteBlob(@Auth() auth: AuthDto, @Param() { type, name }: BlobWithNameParamsDto): Promise<void> {
await this.service.deleteBlob(auth.repository, type, name, auth.writeOnce);
}
}
+4
View File
@@ -0,0 +1,4 @@
export class BlobInfoResponseDto {
name!: string;
size!: number;
}
+12
View File
@@ -0,0 +1,12 @@
import { IsBoolean, IsUUID } from 'class-validator';
export class AuthDto {
@IsUUID()
user!: string;
@IsUUID()
repository!: string;
@IsBoolean()
writeOnce!: boolean;
}
+17
View File
@@ -0,0 +1,17 @@
export enum ContentType {
Binary = 'application/octet-stream',
ResticV2 = 'application/vnd.x.restic.rest.v2',
}
export enum MetadataKey {
Auth = 'AUTH',
Restic = 'RESTIC_V2',
}
export enum BlobType {
Data = 'data',
Index = 'index',
Keys = 'keys',
Locks = 'locks',
Snapshots = 'snapshots',
}
+17
View File
@@ -0,0 +1,17 @@
import { z } from 'zod';
const schema = z.object({
RESTIC_API_PORT: z.coerce.number().min(1000),
JWT_SECRET: z.string().min(32),
S3_ACCESS_KEY_ID: z.string(),
S3_SECRET_ACCESS_KEY: z.string(),
S3_REGION: z.string(),
S3_ENDPOINT: z.string(),
S3_FORCE_PATH_STYLE: z.coerce.boolean().default(false),
});
const env = schema.parse(process.env);
export default env;
+7
View File
@@ -0,0 +1,7 @@
import { InternalServerErrorException } from '@nestjs/common';
export class S3Error extends InternalServerErrorException {
constructor() {
super('An error occurred with the storage server');
}
}
+14
View File
@@ -0,0 +1,14 @@
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
// import { raw } from 'express';
import { AppModule } from './app.module';
import env from './env';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe());
// TODO? app.use(raw({ type: ContentType.Binary, limit: '100mb' }));
await app.listen(env.RESTIC_API_PORT);
}
void bootstrap();
+56
View File
@@ -0,0 +1,56 @@
import {
BadRequestException,
CanActivate,
ExecutionContext,
Injectable,
SetMetadata,
applyDecorators,
createParamDecorator,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';
import { type AuthDto } from 'src/dto/auth.dto';
import { MetadataKey } from 'src/enum';
import { AuthService } from 'src/services/auth.service';
export const AuthRoute = (options = {}): MethodDecorator => {
return applyDecorators(SetMetadata(MetadataKey.Auth, options));
};
export interface AuthRequest extends Request {
auth?: AuthDto;
}
export interface AuthenticatedRequest extends Request {
auth: AuthDto;
}
export const Auth = createParamDecorator((_, context: ExecutionContext): AuthDto => {
return context.switchToHttp().getRequest<AuthenticatedRequest>().auth;
});
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
private reflector: Reflector,
private service: AuthService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const targets = [context.getHandler()];
const options = this.reflector.getAllAndOverride<{ _emptyObject: never } | undefined>(MetadataKey.Auth, targets);
if (!options) {
return true;
}
const request = context.switchToHttp().getRequest<AuthRequest>();
request.auth = await this.service.authenticate(request.headers);
const path = request.params.path;
if (path && path !== request.auth.repository) {
throw new BadRequestException('Repository mismatch');
}
return true;
}
}
@@ -0,0 +1,44 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
NotImplementedException,
SetMetadata,
applyDecorators,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Request, Response } from 'express';
import { Observable, map } from 'rxjs';
import { ContentType, MetadataKey } from 'src/enum';
export const ResticRoute = (): MethodDecorator => {
return applyDecorators(SetMetadata(MetadataKey.Restic, true));
};
@Injectable()
export class ResticInterceptor implements NestInterceptor {
constructor(private reflector: Reflector) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const isResticV2 = this.reflector.get<boolean>(MetadataKey.Restic, context.getHandler());
if (!isResticV2) {
return next.handle();
}
const request = context.switchToHttp().getRequest<Request>();
const response = context.switchToHttp().getResponse<Response>();
if (request.headers.accept !== ContentType.ResticV2) {
throw new NotImplementedException();
}
return next.handle().pipe(
map((data) => {
response.setHeader('Content-Type', ContentType.ResticV2);
response.end(JSON.stringify(data));
return void 0;
}),
);
}
}
@@ -0,0 +1,14 @@
import { Injectable, Scope } from '@nestjs/common';
@Injectable({ scope: Scope.TRANSIENT })
export class LoggerRepository {
private context: string;
setContext(context: string) {
this.context = context;
}
debug(...args: any[]): void {
console.debug(`[${this.context}]`, ...args);
}
}
@@ -0,0 +1,108 @@
import {
CreateBucketCommand,
DeleteObjectCommand,
GetObjectCommand,
HeadBucketCommand,
HeadObjectCommand,
ListObjectsV2Command,
NotFound,
PutObjectCommandInput,
S3Client,
} from '@aws-sdk/client-s3';
import { Upload } from '@aws-sdk/lib-storage';
import { Injectable } from '@nestjs/common';
import { Readable } from 'node:stream';
import env from 'src/env';
@Injectable()
export class StorageRepository {
private client: S3Client;
constructor() {
this.client = new S3Client({
credentials: {
accessKeyId: env.S3_ACCESS_KEY_ID,
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
},
region: env.S3_REGION,
endpoint: env.S3_ENDPOINT,
forcePathStyle: env.S3_FORCE_PATH_STYLE,
});
}
async checkBucket(Bucket: string): Promise<boolean> {
try {
await this.client.send(new HeadBucketCommand({ Bucket }));
return true;
} catch (error) {
if (error instanceof NotFound) {
return false;
}
throw error;
}
}
createBucket(Bucket: string) {
return this.client.send(
new CreateBucketCommand({
Bucket,
}),
);
}
putObject(Bucket: string, Key: string, Body: Readable, writeOnce: boolean, sha256Hex?: string) {
const params: PutObjectCommandInput = { Bucket, Key, Body };
if (writeOnce) {
params.IfNoneMatch = '*';
}
if (sha256Hex) {
params.ChecksumSHA256 = Buffer.from(sha256Hex, 'hex').toString('base64');
}
const upload = new Upload({
client: this.client,
params,
});
return upload.done();
}
headObject(Bucket: string, Key: string) {
return this.client.send(
new HeadObjectCommand({
Bucket,
Key,
}),
);
}
async listObjects(Bucket: string, Prefix: string) {
return await this.client.send(
new ListObjectsV2Command({
Bucket,
Prefix,
}),
);
}
async getObject(Bucket: string, Key: string, Range?: string) {
return await this.client.send(
new GetObjectCommand({
Bucket,
Key,
Range,
}),
);
}
deleteObject(Bucket: string, Key: string) {
return this.client.send(
new DeleteObjectCommand({
Bucket,
Key,
}),
);
}
}
+286
View File
@@ -0,0 +1,286 @@
import { S3ServiceException } from '@aws-sdk/client-s3';
import { BlobType } from 'src/enum';
import { type Mocks, newMocks } from '../../test/mocks';
import { AppService } from './app.service';
describe(AppService.name, () => {
let mocks: Mocks;
let sut: AppService;
beforeEach(() => {
mocks = newMocks();
sut = new AppService(mocks.logger as never, mocks.storage as never);
});
it('should exist', () => {
expect(sut).toBeDefined();
});
describe('createRepository', () => {
it('should fail if isCreate is false', async () => {
await expect(sut.createRepository('repository', false)).rejects.toThrow();
expect(mocks.storage.checkBucket).toHaveBeenCalledTimes(0);
});
it('should fail if bucket exists', async () => {
mocks.storage.checkBucket.mockResolvedValue(true);
await expect(sut.createRepository('repository', true)).rejects.toThrow();
expect(mocks.storage.checkBucket).toHaveBeenCalled();
expect(mocks.storage.createBucket).toHaveBeenCalledTimes(0);
});
it('should succeed if bucket does not exist', async () => {
mocks.storage.checkBucket.mockResolvedValue(false);
await sut.createRepository('repository', true);
expect(mocks.storage.checkBucket).toHaveBeenCalled();
expect(mocks.storage.createBucket).toHaveBeenCalled();
});
it('should fail if S3 command throws', async () => {
mocks.storage.checkBucket.mockRejectedValueOnce(void 0);
await expect(sut.createRepository('repository', true)).rejects.toThrow();
expect(mocks.storage.checkBucket).toHaveBeenCalled();
expect(mocks.storage.createBucket).toHaveBeenCalledTimes(0);
mocks.storage.createBucket.mockRejectedValueOnce(void 0);
await expect(sut.createRepository('repository', true)).rejects.toThrow();
expect(mocks.storage.checkBucket).toHaveBeenCalled();
expect(mocks.storage.createBucket).toHaveBeenCalled();
});
});
describe('deleteRepository', () => {
it('should do nothing', () => {
sut.deleteRepository();
});
});
describe('checkConfig', () => {
it('should return content length', async () => {
mocks.storage.headObject.mockResolvedValue({ ContentLength: 123, $metadata: void 0 as never });
const result = await sut.checkConfig('repository');
expect(result).toBe(123);
expect(mocks.storage.headObject).toHaveBeenCalledWith('repository', 'config');
});
it('should return 0 if content length is undefined', async () => {
mocks.storage.headObject.mockResolvedValue({ $metadata: void 0 as never });
const result = await sut.checkConfig('repository');
expect(result).toBe(0);
});
it('should throw if headObject fails', async () => {
mocks.storage.headObject.mockRejectedValue(void 0);
await expect(sut.checkConfig('repository')).rejects.toThrow();
});
});
describe('getConfig', () => {
it('should return the stream', async () => {
const stream = Symbol('Stream');
mocks.storage.getObject.mockResolvedValue(stream as never);
const result = await sut.getConfig('repository');
expect(result).toBe(stream);
expect(mocks.storage.getObject).toHaveBeenCalledWith('repository', 'config');
});
it('should throw if getObject fails', async () => {
mocks.storage.getObject.mockImplementation(() => new Promise((_, reject) => reject()));
await expect(sut.getConfig('repository')).rejects.toThrow();
});
});
describe('saveConfig', () => {
it('should save config', async () => {
const body = Symbol('Body');
mocks.storage.putObject.mockResolvedValue(void 0 as never);
await sut.saveConfig('repository', body as never, false);
expect(mocks.storage.putObject).toHaveBeenCalledWith('repository', 'config', body, false);
});
it('should pass writeOnce flag', async () => {
const body = Symbol('Body');
mocks.storage.putObject.mockResolvedValue(void 0 as never);
await sut.saveConfig('repository', body as never, true);
expect(mocks.storage.putObject).toHaveBeenCalledWith('repository', 'config', body, true);
});
it('should throw on 412 error', async () => {
const error = new S3ServiceException({
name: 'PreconditionFailed',
$fault: 'client',
$metadata: { httpStatusCode: 412 },
});
mocks.storage.putObject.mockRejectedValue(error);
await expect(sut.saveConfig('repository', null as never, true)).rejects.toThrow('Config already exists');
});
it('should throw on other errors', async () => {
mocks.storage.putObject.mockRejectedValue(new Error('other'));
await expect(sut.saveConfig('repository', null as never, false)).rejects.toThrow();
});
});
describe('deleteConfig', () => {
it('should delete config', async () => {
mocks.storage.deleteObject.mockResolvedValue(void 0 as never);
await sut.deleteConfig('repository', false);
expect(mocks.storage.deleteObject).toHaveBeenCalledWith('repository', 'config');
});
it('should throw when writeOnce and not locks', async () => {
await expect(sut.deleteConfig('repository', true)).rejects.toThrow();
expect(mocks.storage.deleteObject).not.toHaveBeenCalled();
});
it('should throw if deleteObject fails', async () => {
mocks.storage.deleteObject.mockRejectedValue(void 0);
await expect(sut.deleteConfig('repository', false)).rejects.toThrow();
});
});
describe('listBlobs', () => {
it('should return mapped blobs', async () => {
mocks.storage.listObjects.mockResolvedValue({
Contents: [
{ Key: 'data/abc123', Size: 100 },
{ Key: 'data/def456', Size: 200 },
],
KeyCount: 2,
$metadata: void 0 as never,
});
const result = await sut.listBlobs('repository', BlobType.Data);
expect(result).toEqual([
{ name: 'abc123', size: 100 },
{ name: 'def456', size: 200 },
]);
expect(mocks.storage.listObjects).toHaveBeenCalledWith('repository', 'data/');
});
it('should return empty array when KeyCount is 0', async () => {
mocks.storage.listObjects.mockResolvedValue({ KeyCount: 0, $metadata: void 0 as never });
const result = await sut.listBlobs('repository', BlobType.Data);
expect(result).toEqual([]);
});
it('should throw if Contents is undefined', async () => {
mocks.storage.listObjects.mockResolvedValue({ KeyCount: 1, $metadata: void 0 as never });
await expect(sut.listBlobs('repository', BlobType.Data)).rejects.toThrow();
});
it('should throw if Key or Size is missing', async () => {
mocks.storage.listObjects.mockResolvedValue({
Contents: [{ Key: 'data/abc123' }],
KeyCount: 1,
$metadata: void 0 as never,
});
await expect(sut.listBlobs('repository', BlobType.Data)).rejects.toThrow();
});
it('should throw if listObjects fails', async () => {
mocks.storage.listObjects.mockRejectedValue(void 0);
await expect(sut.listBlobs('repository', BlobType.Data)).rejects.toThrow();
});
});
describe('checkBlob', () => {
it('should return content length', async () => {
mocks.storage.headObject.mockResolvedValue({ ContentLength: 456, $metadata: void 0 as never });
const result = await sut.checkBlob('repository', BlobType.Data, 'abc123');
expect(result).toBe(456);
expect(mocks.storage.headObject).toHaveBeenCalledWith('repository', 'data/abc123');
});
it('should return 0 if content length is undefined', async () => {
mocks.storage.headObject.mockResolvedValue({ $metadata: void 0 as never });
const result = await sut.checkBlob('repository', BlobType.Data, 'abc123');
expect(result).toBe(0);
});
it('should throw if headObject fails', async () => {
mocks.storage.headObject.mockRejectedValue(void 0);
await expect(sut.checkBlob('repository', BlobType.Data, 'abc123')).rejects.toThrow();
});
});
describe('getBlob', () => {
it('should return the stream', async () => {
const stream = Symbol('Stream');
mocks.storage.getObject.mockResolvedValue(stream as never);
const result = await sut.getBlob('repository', BlobType.Data, 'abc123');
expect(result).toBe(stream);
expect(mocks.storage.getObject).toHaveBeenCalledWith('repository', 'data/abc123', undefined);
});
it('should pass range to getObjectStream', async () => {
const stream = Symbol('Stream');
mocks.storage.getObject.mockResolvedValue(stream as never);
await sut.getBlob('repository', BlobType.Data, 'abc123', 'bytes=0-100');
expect(mocks.storage.getObject).toHaveBeenCalledWith('repository', 'data/abc123', 'bytes=0-100');
});
it('should throw if getObject fails', async () => {
mocks.storage.getObject.mockImplementation(() => new Promise((_, reject) => reject()));
await expect(sut.getBlob('repository', BlobType.Data, 'abc123')).rejects.toThrow();
});
});
describe('saveBlob', () => {
it('should save blob', async () => {
const body = Symbol('Body');
mocks.storage.putObject.mockResolvedValue(void 0 as never);
await sut.saveBlob('repository', BlobType.Data, 'abc123', body as never, false);
expect(mocks.storage.putObject).toHaveBeenCalledWith('repository', 'data/abc123', body, false, 'abc123');
});
it('should pass writeOnce flag', async () => {
const body = Symbol('Body');
mocks.storage.putObject.mockResolvedValue(void 0 as never);
await sut.saveBlob('repository', BlobType.Data, 'abc123', body as never, true);
expect(mocks.storage.putObject).toHaveBeenCalledWith('repository', 'data/abc123', body, true, 'abc123');
});
it('should throw ConflictException on 412 error', async () => {
const error = new S3ServiceException({
name: 'PreconditionFailed',
$fault: 'client',
$metadata: { httpStatusCode: 412 },
});
mocks.storage.putObject.mockRejectedValue(error);
await expect(sut.saveBlob('repository', BlobType.Data, 'abc123', null as never, true)).rejects.toThrow(
'Blob already exists',
);
});
it('should throw on other errors', async () => {
mocks.storage.putObject.mockRejectedValue(new Error('other'));
await expect(sut.saveBlob('repository', BlobType.Data, 'abc123', null as never, false)).rejects.toThrow();
});
});
describe('deleteBlob', () => {
it('should delete blob', async () => {
mocks.storage.deleteObject.mockResolvedValue(void 0 as never);
await sut.deleteBlob('repository', BlobType.Data, 'abc123', false);
expect(mocks.storage.deleteObject).toHaveBeenCalledWith('repository', 'data/abc123');
});
it('should allow delete of locks with writeOnce', async () => {
mocks.storage.deleteObject.mockResolvedValue(void 0 as never);
await sut.deleteBlob('repository', BlobType.Locks, 'abc123', true);
expect(mocks.storage.deleteObject).toHaveBeenCalledWith('repository', 'locks/abc123');
});
it('should throw when writeOnce and not locks', async () => {
await expect(sut.deleteBlob('repository', BlobType.Data, 'abc123', true)).rejects.toThrow();
expect(mocks.storage.deleteObject).not.toHaveBeenCalled();
});
it('should throw if deleteObject fails', async () => {
mocks.storage.deleteObject.mockRejectedValue(void 0);
await expect(sut.deleteBlob('repository', BlobType.Data, 'abc123', false)).rejects.toThrow();
});
});
});
+181
View File
@@ -0,0 +1,181 @@
import { GetObjectCommandOutput, S3ServiceException } from '@aws-sdk/client-s3';
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Readable } from 'node:stream';
import { BlobInfoResponseDto } from 'src/dto/app.dto';
import { BlobType } from 'src/enum';
import { S3Error } from 'src/errors';
import { LoggerRepository } from 'src/repositories/logger.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
@Injectable()
export class AppService {
constructor(
private readonly logger: LoggerRepository,
private readonly storage: StorageRepository,
) {
logger.setContext('AppService');
}
async createRepository(repository: string, isCreate: boolean): Promise<void> {
if (!isCreate) {
throw new BadRequestException();
}
this.logger.debug(`Creating a new repository at ${repository}`);
let exists: boolean;
try {
exists = await this.storage.checkBucket(repository);
} catch {
throw new S3Error();
}
if (exists) {
throw new ConflictException();
}
try {
await this.storage.createBucket(repository);
} catch {
throw new S3Error();
}
}
deleteRepository(): void {
this.logger.debug('Ignoring repository delete request');
}
async checkConfig(path: string): Promise<number> {
this.logger.debug(`Checking config at ${path}`);
try {
const { ContentLength } = await this.storage.headObject(path, 'config');
return ContentLength || 0;
} catch {
throw new NotFoundException();
}
}
async getConfig(path: string): Promise<GetObjectCommandOutput> {
this.logger.debug(`Reading repository config at ${path}`);
try {
return await this.storage.getObject(path, 'config');
} catch {
throw new S3Error();
}
}
async saveConfig(path: string, body: Readable, writeOnce: boolean): Promise<void> {
this.logger.debug(`Writing config to repository at ${path}`);
try {
await this.storage.putObject(path, 'config', body, writeOnce);
} catch (error) {
if (error instanceof S3ServiceException && error.$metadata.httpStatusCode === 412) {
throw new ForbiddenException('Config already exists');
}
throw new S3Error();
}
}
async deleteConfig(path: string, writeOnce: boolean): Promise<void> {
this.logger.debug(`Deleting repository config at ${path}`);
if (writeOnce) {
throw new ForbiddenException();
}
try {
await this.storage.deleteObject(path, 'config');
} catch {
throw new S3Error();
}
}
async listBlobs(path: string, type: BlobType): Promise<BlobInfoResponseDto[]> {
this.logger.debug(`Listing repository blobs at ${path} for ${type}`);
try {
const suffix = `${type}/`;
const { Contents, KeyCount } = await this.storage.listObjects(path, suffix);
if (KeyCount === 0) {
return [];
}
if (!Contents || Contents.some(({ Key, Size }) => !Key || !Size)) {
throw void 0;
}
return Contents!.map(({ Key, Size }) => ({
name: Key!.slice(suffix.length),
size: Size!,
}));
} catch {
throw new S3Error();
}
}
async checkBlob(path: string, type: BlobType, name: string): Promise<number> {
this.logger.debug(`Checking repository blob at ${path} for ${type}/${name}`);
try {
const { ContentLength } = await this.storage.headObject(path, `${type}/${name}`);
return ContentLength || 0;
} catch {
throw new NotFoundException();
}
}
async getBlob(path: string, type: BlobType, name: string, range?: string): Promise<GetObjectCommandOutput> {
this.logger.debug(`Downloading repository blob at ${path} for ${type}/${name} (range = ${range})`);
try {
return await this.storage.getObject(path, `${type}/${name}`, range);
} catch {
throw new S3Error();
}
}
async saveBlob(path: string, type: BlobType, name: string, body: Readable, writeOnce: boolean): Promise<void> {
this.logger.debug(`Uploading repository blob at ${path} for ${type}/${name}`);
try {
await this.storage.putObject(path, `${type}/${name}`, body, writeOnce, name);
} catch (error) {
if (error instanceof S3ServiceException) {
if (error.$metadata.httpStatusCode === 412) {
throw new ForbiddenException('Blob already exists');
}
if (error.name === 'XAmzContentChecksumMismatch') {
throw new BadRequestException('Content hash does not match blob name');
}
}
throw new S3Error();
}
}
async deleteBlob(path: string, type: BlobType, name: string, writeOnce: boolean): Promise<void> {
this.logger.debug(`Deleting repository blob at ${path} for ${type}/${name}`);
if (writeOnce && type !== BlobType.Locks) {
throw new ForbiddenException();
}
try {
await this.storage.deleteObject(path, `${type}/${name}`);
} catch {
throw new S3Error();
}
}
}
@@ -0,0 +1,53 @@
import { randomUUID } from 'node:crypto';
import { newJwtMock } from '../../test/mocks';
import { AuthService } from './auth.service';
describe(AuthService.name, () => {
let jwt: ReturnType<typeof newJwtMock>;
let sut: AuthService;
beforeEach(() => {
jwt = newJwtMock();
sut = new AuthService(jwt as never);
});
it('should exist', () => {
expect(sut).toBeDefined();
});
describe('authenticate', () => {
it('should throw if Authorization header is missing', async () => {
await expect(sut.authenticate({})).rejects.toThrow('Missing Authorization header');
});
it('should throw if not Basic auth', async () => {
await expect(sut.authenticate({ authorization: 'Bearer token' })).rejects.toThrow('Expected Basic auth');
});
it('should throw if token is missing', async () => {
const auth = Buffer.from('username').toString('base64');
await expect(sut.authenticate({ authorization: `Basic ${auth}` })).rejects.toThrow('Expected Basic auth token');
});
it('should throw if JWT verification fails', async () => {
jwt.verifyAsync.mockRejectedValue(new Error('invalid'));
const auth = Buffer.from('username:token').toString('base64');
await expect(sut.authenticate({ authorization: `Basic ${auth}` })).rejects.toThrow('Invalid JWT Token');
});
it('should throw if JWT payload is invalid', async () => {
jwt.verifyAsync.mockResolvedValue({ invalid: 'payload' });
const auth = Buffer.from('username:token').toString('base64');
await expect(sut.authenticate({ authorization: `Basic ${auth}` })).rejects.toThrow('Bad Request Exception');
});
it('should return auth dto on success', async () => {
const payload = { user: randomUUID(), repository: randomUUID(), writeOnce: true };
jwt.verifyAsync.mockResolvedValue(payload);
const auth = Buffer.from('username:token').toString('base64');
const result = await sut.authenticate({ authorization: `Basic ${auth}` });
expect(result).toEqual(payload);
expect(jwt.verifyAsync).toHaveBeenCalledWith('token');
});
});
});
+43
View File
@@ -0,0 +1,43 @@
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { type IncomingHttpHeaders } from 'node:http';
import { AuthDto } from 'src/dto/auth.dto';
@Injectable()
export class AuthService {
constructor(private readonly jwt: JwtService) {}
async authenticate(headers: IncomingHttpHeaders): Promise<AuthDto> {
if (!headers.authorization) {
throw new UnauthorizedException('Missing Authorization header');
}
if (!headers.authorization.startsWith('Basic ')) {
throw new UnauthorizedException('Expected Basic auth');
}
const auth = Buffer.from(headers.authorization.split(' ').pop() || '', 'base64').toString();
const [_, token] = auth.split(':');
if (!token) {
throw new UnauthorizedException('Expected Basic auth token');
}
let jwt;
try {
jwt = await this.jwt.verifyAsync(token);
} catch {
throw new UnauthorizedException('Invalid JWT Token');
}
const instance = plainToInstance(AuthDto, jwt);
const errors = await validate(instance);
if (errors.length > 0) {
throw new BadRequestException(errors.flatMap((err) => Object.values(err.constraints ?? {})));
}
return instance;
}
}
+47
View File
@@ -0,0 +1,47 @@
import { GetObjectCommandOutput } from '@aws-sdk/client-s3';
import { HttpStatus } from '@nestjs/common';
import { Request, Response } from 'express';
import { Readable } from 'node:stream';
import { ReadableStream } from 'node:stream/web';
import { ContentType } from '../enum';
/**
* Process S3 object as web response
*
* References:
* http#ServeContent
* https://pkg.go.dev/net/http#ServeContent
*/
export function respondWithObject(object: GetObjectCommandOutput, request: Request, response: Response) {
if (request.headers['if-none-match'] === object.ETag) {
return response.send(HttpStatus.NOT_MODIFIED);
}
const range = request.headers.range;
if (range && range !== 'bytes=0-') {
response.status(HttpStatus.PARTIAL_CONTENT);
} else {
response.status(HttpStatus.OK);
}
if (object.ETag) {
response.header('ETag', object.ETag);
}
response.set('Content-Type', object.ContentType ?? ContentType.Binary);
if (object.ContentRange) {
response.set('Content-Range', object.ContentRange);
}
if (object.ContentLength) {
response.set('Content-Length', object.ContentLength.toString());
}
const webStream = object.Body?.transformToWebStream();
if (webStream) {
Readable.fromWeb(webStream as ReadableStream).pipe(response);
} else {
return response.send(HttpStatus.INTERNAL_SERVER_ERROR);
}
}
+14
View File
@@ -0,0 +1,14 @@
import { IsEnum, IsNotEmpty, Matches } from 'class-validator';
import { BlobType } from './enum';
export class BlobParamsDto {
@IsNotEmpty()
@IsEnum(BlobType)
type!: BlobType;
}
export class BlobWithNameParamsDto extends BlobParamsDto {
@IsNotEmpty()
@Matches(/^[a-f0-9]{64}$/)
name!: string;
}
+391
View File
@@ -0,0 +1,391 @@
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test, TestingModule } from '@nestjs/testing';
import { createHash, randomUUID } from 'node:crypto';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
const makeAuthHeader = (token: string) => 'Basic ' + Buffer.from(`_:${token}`).toString('base64');
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
let repository: string;
let authHeader: string;
let wormAuthHeader: string;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(new ValidationPipe());
await app.init();
const jwtService = app.get(JwtService);
repository = randomUUID();
authHeader = makeAuthHeader(jwtService.sign({ user: randomUUID(), repository, writeOnce: false }));
wormAuthHeader = makeAuthHeader(jwtService.sign({ user: randomUUID(), repository, writeOnce: true }));
});
describe('POST /:path', () => {
it('fails if create is not true', async () => {
await request(app.getHttpServer())
.post(`/${repository}?create=false`)
.set('Authorization', authHeader)
.expect(400);
});
it('creates the repository', async () => {
await request(app.getHttpServer())
.post(`/${repository}?create=true`)
.set('Authorization', authHeader)
.expect(200);
});
it('fails if repository already exists', async () => {
await request(app.getHttpServer())
.post(`/${repository}?create=true`)
.set('Authorization', authHeader)
.expect(200);
await request(app.getHttpServer())
.post(`/${repository}?create=true`)
.set('Authorization', authHeader)
.expect(409);
});
});
describe('DELETE /:path', () => {
it('returns not implemented', async () => {
await request(app.getHttpServer()).delete(`/${repository}`).set('Authorization', authHeader).expect(501);
});
});
describe('HEAD /:path/config', () => {
it('returns 404 if config does not exist', async () => {
await request(app.getHttpServer()).head(`/${repository}/config`).set('Authorization', authHeader).expect(404);
});
it('returns content-length if config exists', async () => {
const config = 'test-config-data';
await request(app.getHttpServer())
.post(`/${repository}?create=true`)
.set('Authorization', authHeader)
.expect(200);
await request(app.getHttpServer())
.post(`/${repository}/config`)
.set('Authorization', authHeader)
.set('Content-Type', 'application/octet-stream')
.send(Buffer.from(config))
.expect(200);
await request(app.getHttpServer())
.head(`/${repository}/config`)
.set('Authorization', authHeader)
.expect(200)
.expect('Content-Length', String(config.length));
});
});
describe('GET, POST /:path/config', () => {
it('saves and returns config data', async () => {
const config = 'test-config-data';
await request(app.getHttpServer())
.post(`/${repository}?create=true`)
.set('Authorization', authHeader)
.expect(200);
await request(app.getHttpServer())
.post(`/${repository}/config`)
.set('Authorization', authHeader)
.set('Content-Type', 'application/octet-stream')
.send(Buffer.from(config))
.expect(200);
const response = await request(app.getHttpServer())
.get(`/${repository}/config`)
.set('Authorization', authHeader)
.expect(200)
.expect('Content-Type', 'application/octet-stream');
expect(response.body.toString()).toBe(config);
});
it('fails to overwrite config with worm', async () => {
const config = 'test-config-data';
await request(app.getHttpServer())
.post(`/${repository}?create=true`)
.set('Authorization', wormAuthHeader)
.expect(200);
await request(app.getHttpServer())
.post(`/${repository}/config`)
.set('Authorization', wormAuthHeader)
.set('Content-Type', 'application/octet-stream')
.send(Buffer.from(config))
.expect(200);
await request(app.getHttpServer())
.post(`/${repository}/config`)
.set('Authorization', wormAuthHeader)
.set('Content-Type', 'application/octet-stream')
.send(Buffer.from(config))
.expect(403);
});
});
describe('GET /:path/:type', () => {
it('returns 501 without restic v2 accept header', async () => {
await request(app.getHttpServer()).get(`/${repository}/data`).set('Authorization', authHeader).expect(501);
});
it('returns empty list when no blobs exist', async () => {
await request(app.getHttpServer())
.post(`/${repository}?create=true`)
.set('Authorization', authHeader)
.expect(200);
const response = await request(app.getHttpServer())
.get(`/${repository}/data`)
.set('Authorization', authHeader)
.set('Accept', 'application/vnd.x.restic.rest.v2')
.expect(200)
.expect('Content-Type', 'application/vnd.x.restic.rest.v2');
expect(JSON.parse(response.text)).toEqual([]);
});
it('returns list of blobs when they exist', async () => {
const blobData = 'test-blob-data';
const blobName = createHash('sha256').update(blobData).digest('hex');
await request(app.getHttpServer())
.post(`/${repository}?create=true`)
.set('Authorization', authHeader)
.expect(200);
await request(app.getHttpServer())
.post(`/${repository}/data/${blobName}`)
.set('Authorization', authHeader)
.set('Content-Type', 'application/octet-stream')
.send(Buffer.from(blobData));
await request(app.getHttpServer())
.post(`/${repository}/data/${blobName}`)
.set('Authorization', authHeader)
.set('Content-Type', 'application/octet-stream')
.send(Buffer.from(blobData))
.expect(200);
const response = await request(app.getHttpServer())
.get(`/${repository}/data`)
.set('Authorization', authHeader)
.set('Accept', 'application/vnd.x.restic.rest.v2')
.expect(200)
.expect('Content-Type', 'application/vnd.x.restic.rest.v2');
expect(JSON.parse(response.text)).toEqual([{ name: blobName, size: blobData.length }]);
});
});
describe('HEAD /:path/:type/:name', () => {
it('returns 404 if blob does not exist', async () => {
await request(app.getHttpServer())
.head(`/${repository}/data/${'a'.repeat(64)}`)
.set('Authorization', authHeader)
.expect(404);
});
it('returns content-length if blob exists', async () => {
const blobData = 'test-blob-data';
const blobName = createHash('sha256').update(blobData).digest('hex');
await request(app.getHttpServer())
.post(`/${repository}?create=true`)
.set('Authorization', authHeader)
.expect(200);
await request(app.getHttpServer())
.post(`/${repository}/data/${blobName}`)
.set('Authorization', authHeader)
.set('Content-Type', 'application/octet-stream')
.send(Buffer.from(blobData))
.expect(200);
await request(app.getHttpServer())
.head(`/${repository}/data/${blobName}`)
.set('Authorization', authHeader)
.expect(200)
.expect('Content-Length', String(blobData.length));
});
});
describe('GET, POST /:path/:type/:name', () => {
it('saves and returns blob data', async () => {
const blobData = 'test-blob-data';
const blobName = createHash('sha256').update(blobData).digest('hex');
await request(app.getHttpServer())
.post(`/${repository}?create=true`)
.set('Authorization', authHeader)
.expect(200);
await request(app.getHttpServer())
.post(`/${repository}/data/${blobName}`)
.set('Authorization', authHeader)
.set('Content-Type', 'application/octet-stream')
.send(Buffer.from(blobData))
.expect(200);
const response = await request(app.getHttpServer())
.get(`/${repository}/data/${blobName}`)
.set('Authorization', authHeader)
.expect(200)
.expect('Content-Type', 'application/octet-stream');
expect(response.body.toString()).toBe(blobData);
});
it('returns partial content with range header', async () => {
const blobData = 'test-blob-data';
const blobName = createHash('sha256').update(blobData).digest('hex');
await request(app.getHttpServer())
.post(`/${repository}?create=true`)
.set('Authorization', authHeader)
.expect(200);
await request(app.getHttpServer())
.post(`/${repository}/data/${blobName}`)
.set('Authorization', authHeader)
.set('Content-Type', 'application/octet-stream')
.send(Buffer.from(blobData))
.expect(200);
const response = await request(app.getHttpServer())
.get(`/${repository}/data/${blobName}`)
.set('Authorization', authHeader)
.set('Range', 'bytes=0-3')
.expect(206)
.expect('Content-Type', 'application/octet-stream');
expect(response.body.toString()).toBe('test');
});
it('fails to overwrite blob with worm', async () => {
const blobData = 'test-blob-data';
const blobName = createHash('sha256').update(blobData).digest('hex');
await request(app.getHttpServer())
.post(`/${repository}?create=true`)
.set('Authorization', wormAuthHeader)
.expect(200);
await request(app.getHttpServer())
.post(`/${repository}/data/${blobName}`)
.set('Authorization', wormAuthHeader)
.set('Content-Type', 'application/octet-stream')
.send(Buffer.from(blobData))
.expect(200);
await request(app.getHttpServer())
.post(`/${repository}/data/${blobName}`)
.set('Authorization', wormAuthHeader)
.set('Content-Type', 'application/octet-stream')
.send(Buffer.from(blobData))
.expect(403);
});
it('fails to write blob with non-hash for name', async () => {
const blobData = 'test-blob-data';
const blobName = 'invalid-hash';
await request(app.getHttpServer())
.post(`/${repository}?create=true`)
.set('Authorization', authHeader)
.expect(200);
await request(app.getHttpServer())
.post(`/${repository}/data/${blobName}`)
.set('Authorization', authHeader)
.set('Content-Type', 'application/octet-stream')
.send(Buffer.from(blobData))
.expect(400);
});
it('fails to write blob with non-matching hash for name', async () => {
const blobData = 'test-blob-data';
const blobName = 'a'.repeat(64);
await request(app.getHttpServer())
.post(`/${repository}?create=true`)
.set('Authorization', authHeader)
.expect(200);
await request(app.getHttpServer())
.post(`/${repository}/data/${blobName}`)
.set('Authorization', authHeader)
.set('Content-Type', 'application/octet-stream')
.send(Buffer.from(blobData))
.expect(400);
});
});
describe('DELETE /:path/:type/:name', () => {
it('deletes a blob', async () => {
const blobData = 'test-blob-data';
const blobName = createHash('sha256').update(blobData).digest('hex');
await request(app.getHttpServer())
.post(`/${repository}?create=true`)
.set('Authorization', authHeader)
.expect(200);
await request(app.getHttpServer())
.post(`/${repository}/data/${blobName}`)
.set('Authorization', authHeader)
.set('Content-Type', 'application/octet-stream')
.send(Buffer.from(blobData))
.expect(200);
await request(app.getHttpServer())
.delete(`/${repository}/data/${blobName}`)
.set('Authorization', authHeader)
.expect(200);
await request(app.getHttpServer())
.head(`/${repository}/data/${blobName}`)
.set('Authorization', authHeader)
.expect(404);
});
it('fails to delete blob with worm', async () => {
const blobData = 'test-blob-data';
const blobName = createHash('sha256').update(blobData).digest('hex');
await request(app.getHttpServer())
.post(`/${repository}?create=true`)
.set('Authorization', wormAuthHeader)
.expect(200);
await request(app.getHttpServer())
.post(`/${repository}/data/${blobName}`)
.set('Authorization', wormAuthHeader)
.set('Content-Type', 'application/octet-stream')
.send(Buffer.from(blobData))
.expect(200);
await request(app.getHttpServer())
.delete(`/${repository}/data/${blobName}`)
.set('Authorization', wormAuthHeader)
.expect(403);
});
});
});
+12
View File
@@ -0,0 +1,12 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/../src/$1"
}
}
+36
View File
@@ -0,0 +1,36 @@
import { LoggerRepository } from 'src/repositories/logger.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
export type RepositoryInterface<T extends object> = Pick<T, keyof T>;
export const newJwtMock = () => ({
verifyAsync: jest.fn(),
});
export const newLoggerRepositoryMock = (): jest.Mocked<RepositoryInterface<LoggerRepository>> => {
return {
setContext: jest.fn(),
debug: jest.fn(),
};
};
export const newStorageRepositoryMock = (): jest.Mocked<RepositoryInterface<StorageRepository>> => {
return {
checkBucket: jest.fn(),
createBucket: jest.fn(),
deleteObject: jest.fn(),
getObject: jest.fn(),
headObject: jest.fn(),
listObjects: jest.fn(),
putObject: jest.fn(),
};
};
export const newMocks = () => {
return {
logger: newLoggerRepositoryMock(),
storage: newStorageRepositoryMock(),
};
};
export type Mocks = ReturnType<typeof newMocks>;
@@ -0,0 +1,52 @@
import { randomUUID } from 'node:crypto';
import { Readable } from 'node:stream';
import { text } from 'node:stream/consumers';
import { ReadableStream } from 'node:stream/web';
import { StorageRepository } from 'src/repositories/storage.repository';
describe('StorageRepository (e2e)', () => {
let sut: StorageRepository;
beforeEach(() => {
sut = new StorageRepository();
});
it('works', async () => {
const Bucket = randomUUID();
const Key = randomUUID();
const contents = 'test object';
await expect(sut.checkBucket(Bucket)).resolves.toBe(false);
await sut.createBucket(Bucket);
await expect(sut.checkBucket(Bucket)).resolves.toBe(true);
await expect(sut.headObject(Bucket, Key)).rejects.toThrow();
await sut.putObject(Bucket, Key, Readable.from(contents), false);
await sut.headObject(Bucket, Key);
await expect(sut.listObjects(Bucket, Key)).resolves.toEqual(
expect.objectContaining({
Contents: expect.arrayContaining([expect.objectContaining({ Key, Size: contents.length })]),
}),
);
const object = await sut.getObject(Bucket, Key);
const stream = Readable.fromWeb(object.Body!.transformToWebStream() as ReadableStream);
await expect(text(stream!)).resolves.toBe(contents);
await sut.deleteObject(Bucket, Key);
await expect(sut.headObject(Bucket, Key)).rejects.toThrow();
});
it('respects worm', async () => {
const Bucket = randomUUID();
const Key = randomUUID();
const contents = 'test object';
await sut.createBucket(Bucket);
await sut.putObject(Bucket, Key, Readable.from(contents), false);
await expect(sut.putObject(Bucket, Key, Readable.from(contents), true)).rejects.toThrow();
});
});
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false,
"paths": {
"src/*": ["./src/*"]
}
},
"include": ["src", "test"]
}
+27
View File
@@ -0,0 +1,27 @@
test-results
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Lingui
src/locales/*.ts
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+5
View File
@@ -0,0 +1,5 @@
{
"plugins": ["prettier-plugin-tailwindcss", "prettier-plugin-svelte"],
"tailwindStylesheet": "./src/routes/layout.css",
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}
+6
View File
@@ -0,0 +1,6 @@
import { expect, test } from '@playwright/test';
test('home page has expected h1', async ({ page }) => {
await page.goto('/');
await expect(page.locator('h1')).toBeVisible();
});
+13
View File
@@ -0,0 +1,13 @@
import { jstsExtractor, svelteExtractor } from 'svelte-i18n-lingui/extractor';
export default {
locales: ['en', 'ja'],
sourceLocale: 'en',
catalogs: [
{
path: 'src/locales/{locale}',
include: ['src/lib', 'src/routes'],
},
],
extractors: [jstsExtractor, svelteExtractor],
};
+44
View File
@@ -0,0 +1,44 @@
{
"name": "web",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test:unit": "vitest run",
"test": "npm run test:unit -- --run && npm run test:e2e",
"test:e2e": "playwright test",
"lingui:extract": "lingui extract",
"lingui:compile": "lingui compile --typescript"
},
"devDependencies": {
"@lingui/cli": "^5.8.0",
"@playwright/test": "^1.57.0",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/kit": "^2.49.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.17",
"@vitest/browser": "4.0.17",
"@vitest/browser-playwright": "^4.0.15",
"playwright": "^1.57.0",
"prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.45.6",
"svelte-check": "^4.3.4",
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3",
"vite": "^7.2.6",
"vitest": "^4.0.17",
"vitest-browser-svelte": "^2.0.1"
},
"dependencies": {
"@immich/ui": "^0.59.0",
"@lingui/core": "^5.8.0",
"svelte-i18n-lingui": "^0.2.2",
"yucca-sdk": "workspace:^"
}
}
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
webServer: { command: 'npm run build && npm run preview', port: 4173 },
testDir: 'e2e',
});
+14
View File
@@ -0,0 +1,14 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
// eslint-disable-next-line
export {};
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
import { describe, it, expect } from 'vitest';
describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
});
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+2
View File
@@ -0,0 +1,2 @@
// place files you want to import through the `$lib` alias in this folder.
// eslint-disable-next-line
+30
View File
@@ -0,0 +1,30 @@
msgid ""
msgstr ""
"POT-Creation-Date: 2026-01-21 14:41+0000\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: en\n"
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: \n"
"Last-Translator: \n"
"Language-Team: \n"
"Plural-Forms: \n"
#: src/routes/+page.svelte:40
msgid "{num, plural, one {# item} other {# items}}"
msgstr "{num, plural, one {# item} other {# items}}"
#: src/routes/+page.svelte:37
msgid "From the {0}: {1}"
msgstr "From the {0}: {1}"
#: src/routes/+page.svelte:33
msgid "Purchase Immich"
msgstr "Purchase Immich"
#: src/routes/+page.svelte:37
msgid "yucca API"
msgstr "yucca API"
+24
View File
@@ -0,0 +1,24 @@
msgid ""
msgstr ""
"POT-Creation-Date: 2026-01-21 14:47+0000\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: test\n"
#: src/routes/+page.svelte:26
msgid "{num, plural, one {# item} other {# items}}"
msgstr "{num, plural, one {# hello} other {# hellos}}"
#: src/routes/+page.svelte:23
msgid "From the {0}: {1}"
msgstr "{0}! {1}?"
#: src/routes/+page.svelte:19
msgid "Purchase Immich"
msgstr "Hello Immich"
#: src/routes/+page.svelte:23
msgid "yucca API"
msgstr "the API"

Some files were not shown because too many files have changed in this diff Show More