mirror of
https://github.com/immich-app/yucca.git
synced 2026-09-30 13:33:00 +08:00
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:
@@ -0,0 +1 @@
|
||||
data
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
|
||||
.env.local
|
||||
.env
|
||||
|
||||
mise.local.toml
|
||||
@@ -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"
|
||||
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
#MISE description="Start Docker containers"
|
||||
set -e
|
||||
|
||||
docker compose up -d "$@"
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
#MISE description="Stop Docker containers"
|
||||
set -e
|
||||
|
||||
docker compose down "$@"
|
||||
Executable
+18
@@ -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 "$@"
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
#MISE description="Check code formatting"
|
||||
set -e
|
||||
|
||||
pnpm exec prettier --check .
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
#MISE description="Fix code formatting"
|
||||
set -e
|
||||
|
||||
pnpm exec prettier --write .
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
#MISE description="Install all dependencies"
|
||||
set -e
|
||||
|
||||
pnpm install
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
#MISE description="Install all dependencies (don't update lockfile)"
|
||||
set -e
|
||||
|
||||
pnpm install --frozen-lockfile
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
#MISE description="Lint all code files"
|
||||
set -e
|
||||
|
||||
pnpm exec eslint "**/src/**/*.ts" --max-warnings 0
|
||||
Executable
+5
@@ -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
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
#MISE description="Build restic-api"
|
||||
set -e
|
||||
|
||||
pnpm --filter restic-api build
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
#MISE description="Run restic-api in development mode"
|
||||
set -e
|
||||
|
||||
pnpm --filter restic-api start:dev
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
#MISE description="Run unit tests for restic-api"
|
||||
set -e
|
||||
|
||||
pnpm --filter restic-api test
|
||||
Executable
+5
@@ -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
|
||||
Executable
+5
@@ -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
|
||||
Executable
+5
@@ -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
|
||||
Executable
+6
@@ -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
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
#MISE description="Run Svelte checks for web"
|
||||
set -e
|
||||
|
||||
pnpm --filter web check
|
||||
Executable
+6
@@ -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
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
#MISE description="Extract and compile i18n catalogs"
|
||||
mise web:lingui:extract
|
||||
mise web:lingui:compile
|
||||
Executable
+6
@@ -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
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
#MISE description="Compile i18n catalogs"
|
||||
set -e
|
||||
|
||||
pnpm --filter web lingui:compile
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
#MISE description="Extract i18n catalog from source code"
|
||||
set -e
|
||||
|
||||
pnpm --filter web lingui:extract
|
||||
Executable
+6
@@ -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
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
#MISE description="Run unit tests for web"
|
||||
set -e
|
||||
|
||||
pnpm --filter web test:unit
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
#MISE description="Run end-to-end tests for web"
|
||||
set -e
|
||||
|
||||
pnpm --filter web test:e2e
|
||||
Executable
+7
@@ -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
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
#MISE description="Build yucca-api"
|
||||
set -e
|
||||
|
||||
pnpm --filter yucca-api build
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
#MISE description="Run yucca-api in development mode"
|
||||
set -e
|
||||
|
||||
pnpm --filter yucca-api start:dev
|
||||
Executable
+7
@@ -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
|
||||
Executable
+7
@@ -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
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
#MISE description="Run unit tests for yucca-api"
|
||||
set -e
|
||||
|
||||
pnpm --filter yucca-api test
|
||||
Executable
+5
@@ -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
|
||||
Executable
+5
@@ -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
|
||||
Executable
+5
@@ -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
|
||||
Executable
+7
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 120,
|
||||
"semi": true,
|
||||
"organizeImportsSkipDestructiveCodeActions": true,
|
||||
"plugins": ["prettier-plugin-organize-imports"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "restic-api": "0.0.0" }
|
||||
Vendored
+15
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
pack-*
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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: '^_',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+10723
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
packages:
|
||||
- e2e
|
||||
- web
|
||||
- restic-api
|
||||
- yucca-api
|
||||
- yucca-sdk
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- '@nestjs/core'
|
||||
- '@scarf/scarf'
|
||||
- esbuild
|
||||
- unrs-resolver
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["local>immich-app/.github:renovate-config"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export class BlobInfoResponseDto {
|
||||
name!: string;
|
||||
size!: number;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { IsBoolean, IsUUID } from 'class-validator';
|
||||
|
||||
export class AuthDto {
|
||||
@IsUUID()
|
||||
user!: string;
|
||||
|
||||
@IsUUID()
|
||||
repository!: string;
|
||||
|
||||
@IsBoolean()
|
||||
writeOnce!: boolean;
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { InternalServerErrorException } from '@nestjs/common';
|
||||
|
||||
export class S3Error extends InternalServerErrorException {
|
||||
constructor() {
|
||||
super('An error occurred with the storage server');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"plugins": ["prettier-plugin-tailwindcss", "prettier-plugin-svelte"],
|
||||
"tailwindStylesheet": "./src/routes/layout.css",
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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],
|
||||
};
|
||||
@@ -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:^"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
webServer: { command: 'npm run build && npm run preview', port: 4173 },
|
||||
testDir: 'e2e',
|
||||
});
|
||||
Vendored
+14
@@ -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 {};
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 |
@@ -0,0 +1,2 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
// eslint-disable-next-line
|
||||
@@ -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"
|
||||
@@ -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
Reference in New Issue
Block a user