Merge branch 'main' into feat/deep-secret-resolution
This commit is contained in:
commit
3d87bfa80e
104 changed files with 10547 additions and 6756 deletions
|
|
@ -21,3 +21,4 @@
|
||||||
!NOTICES.md
|
!NOTICES.md
|
||||||
!LICENSES/**
|
!LICENSES/**
|
||||||
|
|
||||||
|
node_modules/**
|
||||||
|
|
|
||||||
14
.editorconfig
Normal file
14
.editorconfig
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
end_of_line = lf
|
||||||
|
charset = utf-8
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.{ts,tsx,js,json}]
|
||||||
|
indent_style = tab
|
||||||
|
tab_width = 4
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
31
.gitattributes
vendored
Normal file
31
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# Set default behavior to automatically normalize line endings
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Explicitly declare text files
|
||||||
|
*.ts text eol=lf
|
||||||
|
*.tsx text eol=lf
|
||||||
|
*.js text eol=lf
|
||||||
|
*.json text eol=lf
|
||||||
|
*.md text eol=lf
|
||||||
|
*.css text eol=lf
|
||||||
|
*.html text eol=lf
|
||||||
|
*.yml text eol=lf
|
||||||
|
*.yaml text eol=lf
|
||||||
|
*.sql text eol=lf
|
||||||
|
*.sh text eol=lf
|
||||||
|
*.toml text eol=lf
|
||||||
|
Dockerfile* text eol=lf
|
||||||
|
.dockerignore text eol=lf
|
||||||
|
docker-compose*.yml text eol=lf
|
||||||
|
|
||||||
|
# Binary files
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.ico binary
|
||||||
|
*.webp binary
|
||||||
|
*.woff binary
|
||||||
|
*.woff2 binary
|
||||||
|
*.ttf binary
|
||||||
|
*.eot binary
|
||||||
7
.github/workflows/checks.yml
vendored
7
.github/workflows/checks.yml
vendored
|
|
@ -25,9 +25,10 @@ jobs:
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
uses: "./.github/actions/install-dependencies"
|
uses: "./.github/actions/install-dependencies"
|
||||||
|
|
||||||
- name: Run lint
|
- uses: oxc-project/oxlint-action@latest
|
||||||
shell: bash
|
with:
|
||||||
run: bun run lint:ci
|
config: .oxlintrc.json
|
||||||
|
deny-warnings: true
|
||||||
|
|
||||||
- name: Run type checks
|
- name: Run type checks
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
|
||||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
|
@ -78,6 +78,7 @@ jobs:
|
||||||
APP_VERSION=${{ needs.determine-release-type.outputs.tagname }}
|
APP_VERSION=${{ needs.determine-release-type.outputs.tagname }}
|
||||||
|
|
||||||
- name: Scan new image for vulnerabilities
|
- name: Scan new image for vulnerabilities
|
||||||
|
if: needs.determine-release-type.outputs.release_type == 'release'
|
||||||
uses: anchore/scan-action@v7
|
uses: anchore/scan-action@v7
|
||||||
id: scan
|
id: scan
|
||||||
with:
|
with:
|
||||||
|
|
@ -86,6 +87,7 @@ jobs:
|
||||||
severity-cutoff: critical
|
severity-cutoff: critical
|
||||||
|
|
||||||
- name: upload Anchore scan report
|
- name: upload Anchore scan report
|
||||||
|
if: needs.determine-release-type.outputs.release_type == 'release'
|
||||||
uses: github/codeql-action/upload-sarif@v4
|
uses: github/codeql-action/upload-sarif@v4
|
||||||
with:
|
with:
|
||||||
sarif_file: ${{ steps.scan.outputs.sarif }}
|
sarif_file: ${{ steps.scan.outputs.sarif }}
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -13,3 +13,4 @@ CLAUDE.md
|
||||||
mutagen.yml.lock
|
mutagen.yml.lock
|
||||||
notes.md
|
notes.md
|
||||||
smb-password.txt
|
smb-password.txt
|
||||||
|
cache.db
|
||||||
|
|
|
||||||
6
.oxfmtrc.json
Normal file
6
.oxfmtrc.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||||
|
"printWidth": 120,
|
||||||
|
"useTabs": true,
|
||||||
|
"endOfLine": "lf"
|
||||||
|
}
|
||||||
150
.oxlintrc.json
Normal file
150
.oxlintrc.json
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["unicorn", "typescript", "oxc"],
|
||||||
|
"categories": {},
|
||||||
|
"rules": {
|
||||||
|
"constructor-super": "warn",
|
||||||
|
"for-direction": "warn",
|
||||||
|
"no-async-promise-executor": "warn",
|
||||||
|
"no-caller": "warn",
|
||||||
|
"no-class-assign": "warn",
|
||||||
|
"no-compare-neg-zero": "warn",
|
||||||
|
"no-cond-assign": "warn",
|
||||||
|
"no-const-assign": "warn",
|
||||||
|
"no-constant-binary-expression": "warn",
|
||||||
|
"no-constant-condition": "warn",
|
||||||
|
"no-control-regex": "warn",
|
||||||
|
"no-debugger": "warn",
|
||||||
|
"no-delete-var": "warn",
|
||||||
|
"no-dupe-class-members": "warn",
|
||||||
|
"no-dupe-else-if": "warn",
|
||||||
|
"no-dupe-keys": "warn",
|
||||||
|
"no-duplicate-case": "warn",
|
||||||
|
"no-empty-character-class": "warn",
|
||||||
|
"no-empty-pattern": "warn",
|
||||||
|
"no-empty-static-block": "warn",
|
||||||
|
"no-eval": "warn",
|
||||||
|
"no-ex-assign": "warn",
|
||||||
|
"no-extra-boolean-cast": "warn",
|
||||||
|
"no-func-assign": "warn",
|
||||||
|
"no-global-assign": "warn",
|
||||||
|
"no-import-assign": "warn",
|
||||||
|
"no-invalid-regexp": "warn",
|
||||||
|
"no-irregular-whitespace": "warn",
|
||||||
|
"no-loss-of-precision": "warn",
|
||||||
|
"no-new-native-nonconstructor": "warn",
|
||||||
|
"no-nonoctal-decimal-escape": "warn",
|
||||||
|
"no-obj-calls": "warn",
|
||||||
|
"no-self-assign": "warn",
|
||||||
|
"no-setter-return": "warn",
|
||||||
|
"no-shadow-restricted-names": "warn",
|
||||||
|
"no-sparse-arrays": "warn",
|
||||||
|
"no-this-before-super": "warn",
|
||||||
|
"no-unassigned-vars": "warn",
|
||||||
|
"no-unsafe-finally": "warn",
|
||||||
|
"no-unsafe-negation": "warn",
|
||||||
|
"no-unsafe-optional-chaining": "warn",
|
||||||
|
"no-unused-expressions": "warn",
|
||||||
|
"no-unused-labels": "warn",
|
||||||
|
"no-unused-private-class-members": "warn",
|
||||||
|
"no-unused-vars": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"caughtErrorsIgnorePattern": "^_",
|
||||||
|
"argsIgnorePattern": "^_"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"no-useless-backreference": "warn",
|
||||||
|
"no-useless-catch": "warn",
|
||||||
|
"no-useless-escape": "warn",
|
||||||
|
"no-useless-rename": "warn",
|
||||||
|
"no-with": "warn",
|
||||||
|
"require-yield": "warn",
|
||||||
|
"use-isnan": "warn",
|
||||||
|
"valid-typeof": "warn",
|
||||||
|
"oxc/bad-array-method-on-arguments": "warn",
|
||||||
|
"oxc/bad-char-at-comparison": "warn",
|
||||||
|
"oxc/bad-comparison-sequence": "warn",
|
||||||
|
"oxc/bad-min-max-func": "warn",
|
||||||
|
"oxc/bad-object-literal-comparison": "warn",
|
||||||
|
"oxc/bad-replace-all-arg": "warn",
|
||||||
|
"oxc/const-comparisons": "warn",
|
||||||
|
"oxc/double-comparisons": "warn",
|
||||||
|
"oxc/erasing-op": "warn",
|
||||||
|
"oxc/missing-throw": "warn",
|
||||||
|
"oxc/number-arg-out-of-range": "warn",
|
||||||
|
"oxc/only-used-in-recursion": "warn",
|
||||||
|
"oxc/uninvoked-array-callback": "warn",
|
||||||
|
"typescript/await-thenable": "warn",
|
||||||
|
"typescript/no-array-delete": "warn",
|
||||||
|
"typescript/no-base-to-string": "warn",
|
||||||
|
"typescript/no-duplicate-enum-values": "warn",
|
||||||
|
"typescript/no-duplicate-type-constituents": "warn",
|
||||||
|
"typescript/no-extra-non-null-assertion": "warn",
|
||||||
|
"typescript/no-floating-promises": "warn",
|
||||||
|
"typescript/no-for-in-array": "warn",
|
||||||
|
"typescript/no-implied-eval": "warn",
|
||||||
|
"typescript/no-meaningless-void-operator": "warn",
|
||||||
|
"typescript/no-misused-new": "warn",
|
||||||
|
"typescript/no-misused-spread": "warn",
|
||||||
|
"typescript/no-non-null-asserted-optional-chain": "warn",
|
||||||
|
"typescript/no-redundant-type-constituents": "warn",
|
||||||
|
"typescript/no-this-alias": "warn",
|
||||||
|
"typescript/no-unnecessary-parameter-property-assignment": "warn",
|
||||||
|
"typescript/no-unsafe-declaration-merging": "warn",
|
||||||
|
"typescript/no-unsafe-unary-minus": "warn",
|
||||||
|
"typescript/no-useless-empty-export": "warn",
|
||||||
|
"typescript/no-wrapper-object-types": "warn",
|
||||||
|
"typescript/prefer-as-const": "warn",
|
||||||
|
"typescript/require-array-sort-compare": "warn",
|
||||||
|
"typescript/restrict-template-expressions": "warn",
|
||||||
|
"typescript/triple-slash-reference": "warn",
|
||||||
|
"typescript/unbound-method": "warn",
|
||||||
|
"unicorn/no-await-in-promise-methods": "warn",
|
||||||
|
"unicorn/no-empty-file": "warn",
|
||||||
|
"unicorn/no-invalid-fetch-options": "warn",
|
||||||
|
"unicorn/no-invalid-remove-event-listener": "warn",
|
||||||
|
"unicorn/no-new-array": "warn",
|
||||||
|
"unicorn/no-single-promise-in-promise-methods": "warn",
|
||||||
|
"unicorn/no-thenable": "warn",
|
||||||
|
"unicorn/no-unnecessary-await": "warn",
|
||||||
|
"unicorn/no-useless-fallback-in-spread": "warn",
|
||||||
|
"unicorn/no-useless-length-check": "warn",
|
||||||
|
"unicorn/no-useless-spread": "warn",
|
||||||
|
"unicorn/prefer-set-size": "warn",
|
||||||
|
"unicorn/prefer-string-starts-ends-with": "warn"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"jsx-a11y": {
|
||||||
|
"polymorphicPropName": null,
|
||||||
|
"components": {},
|
||||||
|
"attributes": {}
|
||||||
|
},
|
||||||
|
"next": {
|
||||||
|
"rootDir": []
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"formComponents": [],
|
||||||
|
"linkComponents": [],
|
||||||
|
"version": null
|
||||||
|
},
|
||||||
|
"jsdoc": {
|
||||||
|
"ignorePrivate": false,
|
||||||
|
"ignoreInternal": false,
|
||||||
|
"ignoreReplacesDocs": true,
|
||||||
|
"overrideReplacesDocs": true,
|
||||||
|
"augmentsExtendsReplacesDocs": false,
|
||||||
|
"implementsReplacesDocs": false,
|
||||||
|
"exemptDestructuredRootsFromChecks": false,
|
||||||
|
"tagNamePreference": {}
|
||||||
|
},
|
||||||
|
"vitest": {
|
||||||
|
"typecheck": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"builtin": true
|
||||||
|
},
|
||||||
|
"globals": {},
|
||||||
|
"ignorePatterns": ["**/api-client/**"]
|
||||||
|
}
|
||||||
|
|
@ -11,7 +11,7 @@ ENV VITE_RESTIC_VERSION=${RESTIC_VERSION} \
|
||||||
VITE_SHOUTRRR_VERSION=${SHOUTRRR_VERSION}
|
VITE_SHOUTRRR_VERSION=${SHOUTRRR_VERSION}
|
||||||
|
|
||||||
RUN apk upgrade --no-cache && \
|
RUN apk upgrade --no-cache && \
|
||||||
apk add --no-cache davfs2=1.6.1-r2 openssh-client fuse3 sshfs tini
|
apk add --no-cache davfs2=1.6.1-r2 openssh-client fuse3 sshfs tini nfs-utils cifs-utils
|
||||||
|
|
||||||
ENTRYPOINT ["/sbin/tini", "-s", "--"]
|
ENTRYPOINT ["/sbin/tini", "-s", "--"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -172,7 +172,7 @@ Now, when adding a new volume in the Zerobyte web interface, you can select "Dir
|
||||||
|
|
||||||
A repository is where your backups will be securely stored encrypted. Zerobyte supports multiple storage backends for your backup repositories:
|
A repository is where your backups will be securely stored encrypted. Zerobyte supports multiple storage backends for your backup repositories:
|
||||||
|
|
||||||
- **Local directories** - Store backups on local disk at `/var/lib/zerobyte/repositories/<repository-name>`
|
- **Local directories** - Store backups on local disk subfolder of `/var/lib/zerobyte/repositories/` or any other (mounted) path
|
||||||
- **S3-compatible storage** - Amazon S3, MinIO, Wasabi, DigitalOcean Spaces, etc.
|
- **S3-compatible storage** - Amazon S3, MinIO, Wasabi, DigitalOcean Spaces, etc.
|
||||||
- **Google Cloud Storage** - Google's cloud storage service
|
- **Google Cloud Storage** - Google's cloud storage service
|
||||||
- **Azure Blob Storage** - Microsoft Azure storage
|
- **Azure Blob Storage** - Microsoft Azure storage
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,12 @@
|
||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
import { type ClientOptions, type Config, createClient, createConfig } from './client';
|
import {
|
||||||
import type { ClientOptions as ClientOptions2 } from './types.gen';
|
type ClientOptions,
|
||||||
|
type Config,
|
||||||
|
createClient,
|
||||||
|
createConfig,
|
||||||
|
} from "./client";
|
||||||
|
import type { ClientOptions as ClientOptions2 } from "./types.gen";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `createClientConfig()` function will be called on client initialization
|
* The `createClientConfig()` function will be called on client initialization
|
||||||
|
|
@ -11,6 +16,10 @@ import type { ClientOptions as ClientOptions2 } from './types.gen';
|
||||||
* `setConfig()`. This is useful for example if you're using Next.js
|
* `setConfig()`. This is useful for example if you're using Next.js
|
||||||
* to ensure your client always has the correct values.
|
* to ensure your client always has the correct values.
|
||||||
*/
|
*/
|
||||||
export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (override?: Config<ClientOptions & T>) => Config<Required<ClientOptions> & T>;
|
export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (
|
||||||
|
override?: Config<ClientOptions & T>,
|
||||||
|
) => Config<Required<ClientOptions> & T>;
|
||||||
|
|
||||||
export const client = createClient(createConfig<ClientOptions2>({ baseUrl: 'http://localhost:4096' }));
|
export const client = createClient(
|
||||||
|
createConfig<ClientOptions2>({ baseUrl: "http://localhost:4096" }),
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
import { createSseClient } from '../core/serverSentEvents.gen';
|
import { createSseClient } from "../core/serverSentEvents.gen";
|
||||||
import type { HttpMethod } from '../core/types.gen';
|
import type { HttpMethod } from "../core/types.gen";
|
||||||
import { getValidRequestBody } from '../core/utils.gen';
|
import { getValidRequestBody } from "../core/utils.gen";
|
||||||
import type {
|
import type {
|
||||||
Client,
|
Client,
|
||||||
Config,
|
Config,
|
||||||
RequestOptions,
|
RequestOptions,
|
||||||
ResolvedRequestOptions,
|
ResolvedRequestOptions,
|
||||||
} from './types.gen';
|
} from "./types.gen";
|
||||||
import {
|
import {
|
||||||
buildUrl,
|
buildUrl,
|
||||||
createConfig,
|
createConfig,
|
||||||
|
|
@ -17,9 +17,9 @@ import {
|
||||||
mergeConfigs,
|
mergeConfigs,
|
||||||
mergeHeaders,
|
mergeHeaders,
|
||||||
setAuthParams,
|
setAuthParams,
|
||||||
} from './utils.gen';
|
} from "./utils.gen";
|
||||||
|
|
||||||
type ReqInit = Omit<RequestInit, 'body' | 'headers'> & {
|
type ReqInit = Omit<RequestInit, "body" | "headers"> & {
|
||||||
body?: any;
|
body?: any;
|
||||||
headers: ReturnType<typeof mergeHeaders>;
|
headers: ReturnType<typeof mergeHeaders>;
|
||||||
};
|
};
|
||||||
|
|
@ -66,8 +66,8 @@ export const createClient = (config: Config = {}): Client => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove Content-Type header if body is empty to avoid sending invalid requests
|
// remove Content-Type header if body is empty to avoid sending invalid requests
|
||||||
if (opts.body === undefined || opts.serializedBody === '') {
|
if (opts.body === undefined || opts.serializedBody === "") {
|
||||||
opts.headers.delete('Content-Type');
|
opts.headers.delete("Content-Type");
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = buildUrl(opts);
|
const url = buildUrl(opts);
|
||||||
|
|
@ -75,11 +75,11 @@ export const createClient = (config: Config = {}): Client => {
|
||||||
return { opts, url };
|
return { opts, url };
|
||||||
};
|
};
|
||||||
|
|
||||||
const request: Client['request'] = async (options) => {
|
const request: Client["request"] = async (options) => {
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
const { opts, url } = await beforeRequest(options);
|
const { opts, url } = await beforeRequest(options);
|
||||||
const requestInit: ReqInit = {
|
const requestInit: ReqInit = {
|
||||||
redirect: 'follow',
|
redirect: "follow",
|
||||||
...opts,
|
...opts,
|
||||||
body: getValidRequestBody(opts),
|
body: getValidRequestBody(opts),
|
||||||
};
|
};
|
||||||
|
|
@ -121,7 +121,7 @@ export const createClient = (config: Config = {}): Client => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return error response
|
// Return error response
|
||||||
return opts.responseStyle === 'data'
|
return opts.responseStyle === "data"
|
||||||
? undefined
|
? undefined
|
||||||
: {
|
: {
|
||||||
error: finalError,
|
error: finalError,
|
||||||
|
|
@ -143,33 +143,33 @@ export const createClient = (config: Config = {}): Client => {
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const parseAs =
|
const parseAs =
|
||||||
(opts.parseAs === 'auto'
|
(opts.parseAs === "auto"
|
||||||
? getParseAs(response.headers.get('Content-Type'))
|
? getParseAs(response.headers.get("Content-Type"))
|
||||||
: opts.parseAs) ?? 'json';
|
: opts.parseAs) ?? "json";
|
||||||
|
|
||||||
if (
|
if (
|
||||||
response.status === 204 ||
|
response.status === 204 ||
|
||||||
response.headers.get('Content-Length') === '0'
|
response.headers.get("Content-Length") === "0"
|
||||||
) {
|
) {
|
||||||
let emptyData: any;
|
let emptyData: any;
|
||||||
switch (parseAs) {
|
switch (parseAs) {
|
||||||
case 'arrayBuffer':
|
case "arrayBuffer":
|
||||||
case 'blob':
|
case "blob":
|
||||||
case 'text':
|
case "text":
|
||||||
emptyData = await response[parseAs]();
|
emptyData = await response[parseAs]();
|
||||||
break;
|
break;
|
||||||
case 'formData':
|
case "formData":
|
||||||
emptyData = new FormData();
|
emptyData = new FormData();
|
||||||
break;
|
break;
|
||||||
case 'stream':
|
case "stream":
|
||||||
emptyData = response.body;
|
emptyData = response.body;
|
||||||
break;
|
break;
|
||||||
case 'json':
|
case "json":
|
||||||
default:
|
default:
|
||||||
emptyData = {};
|
emptyData = {};
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
return opts.responseStyle === 'data'
|
return opts.responseStyle === "data"
|
||||||
? emptyData
|
? emptyData
|
||||||
: {
|
: {
|
||||||
data: emptyData,
|
data: emptyData,
|
||||||
|
|
@ -179,15 +179,15 @@ export const createClient = (config: Config = {}): Client => {
|
||||||
|
|
||||||
let data: any;
|
let data: any;
|
||||||
switch (parseAs) {
|
switch (parseAs) {
|
||||||
case 'arrayBuffer':
|
case "arrayBuffer":
|
||||||
case 'blob':
|
case "blob":
|
||||||
case 'formData':
|
case "formData":
|
||||||
case 'json':
|
case "json":
|
||||||
case 'text':
|
case "text":
|
||||||
data = await response[parseAs]();
|
data = await response[parseAs]();
|
||||||
break;
|
break;
|
||||||
case 'stream':
|
case "stream":
|
||||||
return opts.responseStyle === 'data'
|
return opts.responseStyle === "data"
|
||||||
? response.body
|
? response.body
|
||||||
: {
|
: {
|
||||||
data: response.body,
|
data: response.body,
|
||||||
|
|
@ -195,7 +195,7 @@ export const createClient = (config: Config = {}): Client => {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parseAs === 'json') {
|
if (parseAs === "json") {
|
||||||
if (opts.responseValidator) {
|
if (opts.responseValidator) {
|
||||||
await opts.responseValidator(data);
|
await opts.responseValidator(data);
|
||||||
}
|
}
|
||||||
|
|
@ -205,7 +205,7 @@ export const createClient = (config: Config = {}): Client => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return opts.responseStyle === 'data'
|
return opts.responseStyle === "data"
|
||||||
? data
|
? data
|
||||||
: {
|
: {
|
||||||
data,
|
data,
|
||||||
|
|
@ -238,7 +238,7 @@ export const createClient = (config: Config = {}): Client => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: we probably want to return error and improve types
|
// TODO: we probably want to return error and improve types
|
||||||
return opts.responseStyle === 'data'
|
return opts.responseStyle === "data"
|
||||||
? undefined
|
? undefined
|
||||||
: {
|
: {
|
||||||
error: finalError,
|
error: finalError,
|
||||||
|
|
@ -273,29 +273,29 @@ export const createClient = (config: Config = {}): Client => {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
buildUrl,
|
buildUrl,
|
||||||
connect: makeMethodFn('CONNECT'),
|
connect: makeMethodFn("CONNECT"),
|
||||||
delete: makeMethodFn('DELETE'),
|
delete: makeMethodFn("DELETE"),
|
||||||
get: makeMethodFn('GET'),
|
get: makeMethodFn("GET"),
|
||||||
getConfig,
|
getConfig,
|
||||||
head: makeMethodFn('HEAD'),
|
head: makeMethodFn("HEAD"),
|
||||||
interceptors,
|
interceptors,
|
||||||
options: makeMethodFn('OPTIONS'),
|
options: makeMethodFn("OPTIONS"),
|
||||||
patch: makeMethodFn('PATCH'),
|
patch: makeMethodFn("PATCH"),
|
||||||
post: makeMethodFn('POST'),
|
post: makeMethodFn("POST"),
|
||||||
put: makeMethodFn('PUT'),
|
put: makeMethodFn("PUT"),
|
||||||
request,
|
request,
|
||||||
setConfig,
|
setConfig,
|
||||||
sse: {
|
sse: {
|
||||||
connect: makeSseFn('CONNECT'),
|
connect: makeSseFn("CONNECT"),
|
||||||
delete: makeSseFn('DELETE'),
|
delete: makeSseFn("DELETE"),
|
||||||
get: makeSseFn('GET'),
|
get: makeSseFn("GET"),
|
||||||
head: makeSseFn('HEAD'),
|
head: makeSseFn("HEAD"),
|
||||||
options: makeSseFn('OPTIONS'),
|
options: makeSseFn("OPTIONS"),
|
||||||
patch: makeSseFn('PATCH'),
|
patch: makeSseFn("PATCH"),
|
||||||
post: makeSseFn('POST'),
|
post: makeSseFn("POST"),
|
||||||
put: makeSseFn('PUT'),
|
put: makeSseFn("PUT"),
|
||||||
trace: makeSseFn('TRACE'),
|
trace: makeSseFn("TRACE"),
|
||||||
},
|
},
|
||||||
trace: makeMethodFn('TRACE'),
|
trace: makeMethodFn("TRACE"),
|
||||||
} as Client;
|
} as Client;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
export type { Auth } from '../core/auth.gen';
|
export type { Auth } from "../core/auth.gen";
|
||||||
export type { QuerySerializerOptions } from '../core/bodySerializer.gen';
|
export type { QuerySerializerOptions } from "../core/bodySerializer.gen";
|
||||||
export {
|
export {
|
||||||
formDataBodySerializer,
|
formDataBodySerializer,
|
||||||
jsonBodySerializer,
|
jsonBodySerializer,
|
||||||
urlSearchParamsBodySerializer,
|
urlSearchParamsBodySerializer,
|
||||||
} from '../core/bodySerializer.gen';
|
} from "../core/bodySerializer.gen";
|
||||||
export { buildClientParams } from '../core/params.gen';
|
export { buildClientParams } from "../core/params.gen";
|
||||||
export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen';
|
export { serializeQueryKeyValue } from "../core/queryKeySerializer.gen";
|
||||||
export { createClient } from './client.gen';
|
export { createClient } from "./client.gen";
|
||||||
export type {
|
export type {
|
||||||
Client,
|
Client,
|
||||||
ClientOptions,
|
ClientOptions,
|
||||||
|
|
@ -21,5 +21,5 @@ export type {
|
||||||
ResolvedRequestOptions,
|
ResolvedRequestOptions,
|
||||||
ResponseStyle,
|
ResponseStyle,
|
||||||
TDataShape,
|
TDataShape,
|
||||||
} from './types.gen';
|
} from "./types.gen";
|
||||||
export { createConfig, mergeHeaders } from './utils.gen';
|
export { createConfig, mergeHeaders } from "./utils.gen";
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,25 @@
|
||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
import type { Auth } from '../core/auth.gen';
|
import type { Auth } from "../core/auth.gen";
|
||||||
import type {
|
import type {
|
||||||
ServerSentEventsOptions,
|
ServerSentEventsOptions,
|
||||||
ServerSentEventsResult,
|
ServerSentEventsResult,
|
||||||
} from '../core/serverSentEvents.gen';
|
} from "../core/serverSentEvents.gen";
|
||||||
import type {
|
import type {
|
||||||
Client as CoreClient,
|
Client as CoreClient,
|
||||||
Config as CoreConfig,
|
Config as CoreConfig,
|
||||||
} from '../core/types.gen';
|
} from "../core/types.gen";
|
||||||
import type { Middleware } from './utils.gen';
|
import type { Middleware } from "./utils.gen";
|
||||||
|
|
||||||
export type ResponseStyle = 'data' | 'fields';
|
export type ResponseStyle = "data" | "fields";
|
||||||
|
|
||||||
export interface Config<T extends ClientOptions = ClientOptions>
|
export interface Config<T extends ClientOptions = ClientOptions>
|
||||||
extends Omit<RequestInit, 'body' | 'headers' | 'method'>,
|
extends Omit<RequestInit, "body" | "headers" | "method">,
|
||||||
CoreConfig {
|
CoreConfig {
|
||||||
/**
|
/**
|
||||||
* Base URL for all requests made by this client.
|
* Base URL for all requests made by this client.
|
||||||
*/
|
*/
|
||||||
baseUrl?: T['baseUrl'];
|
baseUrl?: T["baseUrl"];
|
||||||
/**
|
/**
|
||||||
* Fetch API implementation. You can use this option to provide a custom
|
* Fetch API implementation. You can use this option to provide a custom
|
||||||
* fetch instance.
|
* fetch instance.
|
||||||
|
|
@ -43,13 +43,13 @@ export interface Config<T extends ClientOptions = ClientOptions>
|
||||||
* @default 'auto'
|
* @default 'auto'
|
||||||
*/
|
*/
|
||||||
parseAs?:
|
parseAs?:
|
||||||
| 'arrayBuffer'
|
| "arrayBuffer"
|
||||||
| 'auto'
|
| "auto"
|
||||||
| 'blob'
|
| "blob"
|
||||||
| 'formData'
|
| "formData"
|
||||||
| 'json'
|
| "json"
|
||||||
| 'stream'
|
| "stream"
|
||||||
| 'text';
|
| "text";
|
||||||
/**
|
/**
|
||||||
* Should we return only data or multiple fields (data, error, response, etc.)?
|
* Should we return only data or multiple fields (data, error, response, etc.)?
|
||||||
*
|
*
|
||||||
|
|
@ -61,12 +61,12 @@ export interface Config<T extends ClientOptions = ClientOptions>
|
||||||
*
|
*
|
||||||
* @default false
|
* @default false
|
||||||
*/
|
*/
|
||||||
throwOnError?: T['throwOnError'];
|
throwOnError?: T["throwOnError"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RequestOptions<
|
export interface RequestOptions<
|
||||||
TData = unknown,
|
TData = unknown,
|
||||||
TResponseStyle extends ResponseStyle = 'fields',
|
TResponseStyle extends ResponseStyle = "fields",
|
||||||
ThrowOnError extends boolean = boolean,
|
ThrowOnError extends boolean = boolean,
|
||||||
Url extends string = string,
|
Url extends string = string,
|
||||||
> extends Config<{
|
> extends Config<{
|
||||||
|
|
@ -75,11 +75,11 @@ export interface RequestOptions<
|
||||||
}>,
|
}>,
|
||||||
Pick<
|
Pick<
|
||||||
ServerSentEventsOptions<TData>,
|
ServerSentEventsOptions<TData>,
|
||||||
| 'onSseError'
|
| "onSseError"
|
||||||
| 'onSseEvent'
|
| "onSseEvent"
|
||||||
| 'sseDefaultRetryDelay'
|
| "sseDefaultRetryDelay"
|
||||||
| 'sseMaxRetryAttempts'
|
| "sseMaxRetryAttempts"
|
||||||
| 'sseMaxRetryDelay'
|
| "sseMaxRetryDelay"
|
||||||
> {
|
> {
|
||||||
/**
|
/**
|
||||||
* Any body that you want to add to your request.
|
* Any body that you want to add to your request.
|
||||||
|
|
@ -97,7 +97,7 @@ export interface RequestOptions<
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResolvedRequestOptions<
|
export interface ResolvedRequestOptions<
|
||||||
TResponseStyle extends ResponseStyle = 'fields',
|
TResponseStyle extends ResponseStyle = "fields",
|
||||||
ThrowOnError extends boolean = boolean,
|
ThrowOnError extends boolean = boolean,
|
||||||
Url extends string = string,
|
Url extends string = string,
|
||||||
> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> {
|
> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> {
|
||||||
|
|
@ -108,10 +108,10 @@ export type RequestResult<
|
||||||
TData = unknown,
|
TData = unknown,
|
||||||
TError = unknown,
|
TError = unknown,
|
||||||
ThrowOnError extends boolean = boolean,
|
ThrowOnError extends boolean = boolean,
|
||||||
TResponseStyle extends ResponseStyle = 'fields',
|
TResponseStyle extends ResponseStyle = "fields",
|
||||||
> = ThrowOnError extends true
|
> = ThrowOnError extends true
|
||||||
? Promise<
|
? Promise<
|
||||||
TResponseStyle extends 'data'
|
TResponseStyle extends "data"
|
||||||
? TData extends Record<string, unknown>
|
? TData extends Record<string, unknown>
|
||||||
? TData[keyof TData]
|
? TData[keyof TData]
|
||||||
: TData
|
: TData
|
||||||
|
|
@ -124,7 +124,7 @@ export type RequestResult<
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
: Promise<
|
: Promise<
|
||||||
TResponseStyle extends 'data'
|
TResponseStyle extends "data"
|
||||||
?
|
?
|
||||||
| (TData extends Record<string, unknown>
|
| (TData extends Record<string, unknown>
|
||||||
? TData[keyof TData]
|
? TData[keyof TData]
|
||||||
|
|
@ -159,30 +159,30 @@ type MethodFn = <
|
||||||
TData = unknown,
|
TData = unknown,
|
||||||
TError = unknown,
|
TError = unknown,
|
||||||
ThrowOnError extends boolean = false,
|
ThrowOnError extends boolean = false,
|
||||||
TResponseStyle extends ResponseStyle = 'fields',
|
TResponseStyle extends ResponseStyle = "fields",
|
||||||
>(
|
>(
|
||||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
|
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method">,
|
||||||
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
|
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
|
||||||
|
|
||||||
type SseFn = <
|
type SseFn = <
|
||||||
TData = unknown,
|
TData = unknown,
|
||||||
TError = unknown,
|
TError = unknown,
|
||||||
ThrowOnError extends boolean = false,
|
ThrowOnError extends boolean = false,
|
||||||
TResponseStyle extends ResponseStyle = 'fields',
|
TResponseStyle extends ResponseStyle = "fields",
|
||||||
>(
|
>(
|
||||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
|
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method">,
|
||||||
) => Promise<ServerSentEventsResult<TData, TError>>;
|
) => Promise<ServerSentEventsResult<TData, TError>>;
|
||||||
|
|
||||||
type RequestFn = <
|
type RequestFn = <
|
||||||
TData = unknown,
|
TData = unknown,
|
||||||
TError = unknown,
|
TError = unknown,
|
||||||
ThrowOnError extends boolean = false,
|
ThrowOnError extends boolean = false,
|
||||||
TResponseStyle extends ResponseStyle = 'fields',
|
TResponseStyle extends ResponseStyle = "fields",
|
||||||
>(
|
>(
|
||||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'> &
|
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method"> &
|
||||||
Pick<
|
Pick<
|
||||||
Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>,
|
Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>,
|
||||||
'method'
|
"method"
|
||||||
>,
|
>,
|
||||||
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
|
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
|
||||||
|
|
||||||
|
|
@ -233,9 +233,9 @@ export type Options<
|
||||||
TData extends TDataShape = TDataShape,
|
TData extends TDataShape = TDataShape,
|
||||||
ThrowOnError extends boolean = boolean,
|
ThrowOnError extends boolean = boolean,
|
||||||
TResponse = unknown,
|
TResponse = unknown,
|
||||||
TResponseStyle extends ResponseStyle = 'fields',
|
TResponseStyle extends ResponseStyle = "fields",
|
||||||
> = OmitKeys<
|
> = OmitKeys<
|
||||||
RequestOptions<TResponse, TResponseStyle, ThrowOnError>,
|
RequestOptions<TResponse, TResponseStyle, ThrowOnError>,
|
||||||
'body' | 'path' | 'query' | 'url'
|
"body" | "path" | "query" | "url"
|
||||||
> &
|
> &
|
||||||
([TData] extends [never] ? unknown : Omit<TData, 'url'>);
|
([TData] extends [never] ? unknown : Omit<TData, "url">);
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,20 @@
|
||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
import { getAuthToken } from '../core/auth.gen';
|
import { getAuthToken } from "../core/auth.gen";
|
||||||
import type { QuerySerializerOptions } from '../core/bodySerializer.gen';
|
import type { QuerySerializerOptions } from "../core/bodySerializer.gen";
|
||||||
import { jsonBodySerializer } from '../core/bodySerializer.gen';
|
import { jsonBodySerializer } from "../core/bodySerializer.gen";
|
||||||
import {
|
import {
|
||||||
serializeArrayParam,
|
serializeArrayParam,
|
||||||
serializeObjectParam,
|
serializeObjectParam,
|
||||||
serializePrimitiveParam,
|
serializePrimitiveParam,
|
||||||
} from '../core/pathSerializer.gen';
|
} from "../core/pathSerializer.gen";
|
||||||
import { getUrl } from '../core/utils.gen';
|
import { getUrl } from "../core/utils.gen";
|
||||||
import type { Client, ClientOptions, Config, RequestOptions } from './types.gen';
|
import type {
|
||||||
|
Client,
|
||||||
|
ClientOptions,
|
||||||
|
Config,
|
||||||
|
RequestOptions,
|
||||||
|
} from "./types.gen";
|
||||||
|
|
||||||
export const createQuerySerializer = <T = unknown>({
|
export const createQuerySerializer = <T = unknown>({
|
||||||
parameters = {},
|
parameters = {},
|
||||||
|
|
@ -17,7 +22,7 @@ export const createQuerySerializer = <T = unknown>({
|
||||||
}: QuerySerializerOptions = {}) => {
|
}: QuerySerializerOptions = {}) => {
|
||||||
const querySerializer = (queryParams: T) => {
|
const querySerializer = (queryParams: T) => {
|
||||||
const search: string[] = [];
|
const search: string[] = [];
|
||||||
if (queryParams && typeof queryParams === 'object') {
|
if (queryParams && typeof queryParams === "object") {
|
||||||
for (const name in queryParams) {
|
for (const name in queryParams) {
|
||||||
const value = queryParams[name];
|
const value = queryParams[name];
|
||||||
|
|
||||||
|
|
@ -32,17 +37,17 @@ export const createQuerySerializer = <T = unknown>({
|
||||||
allowReserved: options.allowReserved,
|
allowReserved: options.allowReserved,
|
||||||
explode: true,
|
explode: true,
|
||||||
name,
|
name,
|
||||||
style: 'form',
|
style: "form",
|
||||||
value,
|
value,
|
||||||
...options.array,
|
...options.array,
|
||||||
});
|
});
|
||||||
if (serializedArray) search.push(serializedArray);
|
if (serializedArray) search.push(serializedArray);
|
||||||
} else if (typeof value === 'object') {
|
} else if (typeof value === "object") {
|
||||||
const serializedObject = serializeObjectParam({
|
const serializedObject = serializeObjectParam({
|
||||||
allowReserved: options.allowReserved,
|
allowReserved: options.allowReserved,
|
||||||
explode: true,
|
explode: true,
|
||||||
name,
|
name,
|
||||||
style: 'deepObject',
|
style: "deepObject",
|
||||||
value: value as Record<string, unknown>,
|
value: value as Record<string, unknown>,
|
||||||
...options.object,
|
...options.object,
|
||||||
});
|
});
|
||||||
|
|
@ -57,7 +62,7 @@ export const createQuerySerializer = <T = unknown>({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return search.join('&');
|
return search.join("&");
|
||||||
};
|
};
|
||||||
return querySerializer;
|
return querySerializer;
|
||||||
};
|
};
|
||||||
|
|
@ -67,47 +72,47 @@ export const createQuerySerializer = <T = unknown>({
|
||||||
*/
|
*/
|
||||||
export const getParseAs = (
|
export const getParseAs = (
|
||||||
contentType: string | null,
|
contentType: string | null,
|
||||||
): Exclude<Config['parseAs'], 'auto'> => {
|
): Exclude<Config["parseAs"], "auto"> => {
|
||||||
if (!contentType) {
|
if (!contentType) {
|
||||||
// If no Content-Type header is provided, the best we can do is return the raw response body,
|
// If no Content-Type header is provided, the best we can do is return the raw response body,
|
||||||
// which is effectively the same as the 'stream' option.
|
// which is effectively the same as the 'stream' option.
|
||||||
return 'stream';
|
return "stream";
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanContent = contentType.split(';')[0]?.trim();
|
const cleanContent = contentType.split(";")[0]?.trim();
|
||||||
|
|
||||||
if (!cleanContent) {
|
if (!cleanContent) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
cleanContent.startsWith('application/json') ||
|
cleanContent.startsWith("application/json") ||
|
||||||
cleanContent.endsWith('+json')
|
cleanContent.endsWith("+json")
|
||||||
) {
|
) {
|
||||||
return 'json';
|
return "json";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cleanContent === 'multipart/form-data') {
|
if (cleanContent === "multipart/form-data") {
|
||||||
return 'formData';
|
return "formData";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
['application/', 'audio/', 'image/', 'video/'].some((type) =>
|
["application/", "audio/", "image/", "video/"].some((type) =>
|
||||||
cleanContent.startsWith(type),
|
cleanContent.startsWith(type),
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return 'blob';
|
return "blob";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cleanContent.startsWith('text/')) {
|
if (cleanContent.startsWith("text/")) {
|
||||||
return 'text';
|
return "text";
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
const checkForExistence = (
|
const checkForExistence = (
|
||||||
options: Pick<RequestOptions, 'auth' | 'query'> & {
|
options: Pick<RequestOptions, "auth" | "query"> & {
|
||||||
headers: Headers;
|
headers: Headers;
|
||||||
},
|
},
|
||||||
name?: string,
|
name?: string,
|
||||||
|
|
@ -118,7 +123,7 @@ const checkForExistence = (
|
||||||
if (
|
if (
|
||||||
options.headers.has(name) ||
|
options.headers.has(name) ||
|
||||||
options.query?.[name] ||
|
options.query?.[name] ||
|
||||||
options.headers.get('Cookie')?.includes(`${name}=`)
|
options.headers.get("Cookie")?.includes(`${name}=`)
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -128,8 +133,8 @@ const checkForExistence = (
|
||||||
export const setAuthParams = async ({
|
export const setAuthParams = async ({
|
||||||
security,
|
security,
|
||||||
...options
|
...options
|
||||||
}: Pick<Required<RequestOptions>, 'security'> &
|
}: Pick<Required<RequestOptions>, "security"> &
|
||||||
Pick<RequestOptions, 'auth' | 'query'> & {
|
Pick<RequestOptions, "auth" | "query"> & {
|
||||||
headers: Headers;
|
headers: Headers;
|
||||||
}) => {
|
}) => {
|
||||||
for (const auth of security) {
|
for (const auth of security) {
|
||||||
|
|
@ -143,19 +148,19 @@ export const setAuthParams = async ({
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = auth.name ?? 'Authorization';
|
const name = auth.name ?? "Authorization";
|
||||||
|
|
||||||
switch (auth.in) {
|
switch (auth.in) {
|
||||||
case 'query':
|
case "query":
|
||||||
if (!options.query) {
|
if (!options.query) {
|
||||||
options.query = {};
|
options.query = {};
|
||||||
}
|
}
|
||||||
options.query[name] = token;
|
options.query[name] = token;
|
||||||
break;
|
break;
|
||||||
case 'cookie':
|
case "cookie":
|
||||||
options.headers.append('Cookie', `${name}=${token}`);
|
options.headers.append("Cookie", `${name}=${token}`);
|
||||||
break;
|
break;
|
||||||
case 'header':
|
case "header":
|
||||||
default:
|
default:
|
||||||
options.headers.set(name, token);
|
options.headers.set(name, token);
|
||||||
break;
|
break;
|
||||||
|
|
@ -163,13 +168,13 @@ export const setAuthParams = async ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildUrl: Client['buildUrl'] = (options) =>
|
export const buildUrl: Client["buildUrl"] = (options) =>
|
||||||
getUrl({
|
getUrl({
|
||||||
baseUrl: options.baseUrl as string,
|
baseUrl: options.baseUrl as string,
|
||||||
path: options.path,
|
path: options.path,
|
||||||
query: options.query,
|
query: options.query,
|
||||||
querySerializer:
|
querySerializer:
|
||||||
typeof options.querySerializer === 'function'
|
typeof options.querySerializer === "function"
|
||||||
? options.querySerializer
|
? options.querySerializer
|
||||||
: createQuerySerializer(options.querySerializer),
|
: createQuerySerializer(options.querySerializer),
|
||||||
url: options.url,
|
url: options.url,
|
||||||
|
|
@ -177,7 +182,7 @@ export const buildUrl: Client['buildUrl'] = (options) =>
|
||||||
|
|
||||||
export const mergeConfigs = (a: Config, b: Config): Config => {
|
export const mergeConfigs = (a: Config, b: Config): Config => {
|
||||||
const config = { ...a, ...b };
|
const config = { ...a, ...b };
|
||||||
if (config.baseUrl?.endsWith('/')) {
|
if (config.baseUrl?.endsWith("/")) {
|
||||||
config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1);
|
config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1);
|
||||||
}
|
}
|
||||||
config.headers = mergeHeaders(a.headers, b.headers);
|
config.headers = mergeHeaders(a.headers, b.headers);
|
||||||
|
|
@ -193,7 +198,7 @@ const headersEntries = (headers: Headers): Array<[string, string]> => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mergeHeaders = (
|
export const mergeHeaders = (
|
||||||
...headers: Array<Required<Config>['headers'] | undefined>
|
...headers: Array<Required<Config>["headers"] | undefined>
|
||||||
): Headers => {
|
): Headers => {
|
||||||
const mergedHeaders = new Headers();
|
const mergedHeaders = new Headers();
|
||||||
for (const header of headers) {
|
for (const header of headers) {
|
||||||
|
|
@ -218,7 +223,7 @@ export const mergeHeaders = (
|
||||||
// content value in OpenAPI specification is 'application/json'
|
// content value in OpenAPI specification is 'application/json'
|
||||||
mergedHeaders.set(
|
mergedHeaders.set(
|
||||||
key,
|
key,
|
||||||
typeof value === 'object' ? JSON.stringify(value) : (value as string),
|
typeof value === "object" ? JSON.stringify(value) : (value as string),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -264,7 +269,7 @@ class Interceptors<Interceptor> {
|
||||||
}
|
}
|
||||||
|
|
||||||
getInterceptorIndex(id: number | Interceptor): number {
|
getInterceptorIndex(id: number | Interceptor): number {
|
||||||
if (typeof id === 'number') {
|
if (typeof id === "number") {
|
||||||
return this.fns[id] ? id : -1;
|
return this.fns[id] ? id : -1;
|
||||||
}
|
}
|
||||||
return this.fns.indexOf(id);
|
return this.fns.indexOf(id);
|
||||||
|
|
@ -309,16 +314,16 @@ const defaultQuerySerializer = createQuerySerializer({
|
||||||
allowReserved: false,
|
allowReserved: false,
|
||||||
array: {
|
array: {
|
||||||
explode: true,
|
explode: true,
|
||||||
style: 'form',
|
style: "form",
|
||||||
},
|
},
|
||||||
object: {
|
object: {
|
||||||
explode: true,
|
explode: true,
|
||||||
style: 'deepObject',
|
style: "deepObject",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const defaultHeaders = {
|
const defaultHeaders = {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createConfig = <T extends ClientOptions = ClientOptions>(
|
export const createConfig = <T extends ClientOptions = ClientOptions>(
|
||||||
|
|
@ -326,7 +331,7 @@ export const createConfig = <T extends ClientOptions = ClientOptions>(
|
||||||
): Config<Omit<ClientOptions, keyof T> & T> => ({
|
): Config<Omit<ClientOptions, keyof T> & T> => ({
|
||||||
...jsonBodySerializer,
|
...jsonBodySerializer,
|
||||||
headers: defaultHeaders,
|
headers: defaultHeaders,
|
||||||
parseAs: 'auto',
|
parseAs: "auto",
|
||||||
querySerializer: defaultQuerySerializer,
|
querySerializer: defaultQuerySerializer,
|
||||||
...override,
|
...override,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,15 @@ export interface Auth {
|
||||||
*
|
*
|
||||||
* @default 'header'
|
* @default 'header'
|
||||||
*/
|
*/
|
||||||
in?: 'header' | 'query' | 'cookie';
|
in?: "header" | "query" | "cookie";
|
||||||
/**
|
/**
|
||||||
* Header or query parameter name.
|
* Header or query parameter name.
|
||||||
*
|
*
|
||||||
* @default 'Authorization'
|
* @default 'Authorization'
|
||||||
*/
|
*/
|
||||||
name?: string;
|
name?: string;
|
||||||
scheme?: 'basic' | 'bearer';
|
scheme?: "basic" | "bearer";
|
||||||
type: 'apiKey' | 'http';
|
type: "apiKey" | "http";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getAuthToken = async (
|
export const getAuthToken = async (
|
||||||
|
|
@ -24,17 +24,17 @@ export const getAuthToken = async (
|
||||||
callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken,
|
callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken,
|
||||||
): Promise<string | undefined> => {
|
): Promise<string | undefined> => {
|
||||||
const token =
|
const token =
|
||||||
typeof callback === 'function' ? await callback(auth) : callback;
|
typeof callback === "function" ? await callback(auth) : callback;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (auth.scheme === 'bearer') {
|
if (auth.scheme === "bearer") {
|
||||||
return `Bearer ${token}`;
|
return `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (auth.scheme === 'basic') {
|
if (auth.scheme === "basic") {
|
||||||
return `Basic ${btoa(token)}`;
|
return `Basic ${btoa(token)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import type {
|
||||||
ArrayStyle,
|
ArrayStyle,
|
||||||
ObjectStyle,
|
ObjectStyle,
|
||||||
SerializerOptions,
|
SerializerOptions,
|
||||||
} from './pathSerializer.gen';
|
} from "./pathSerializer.gen";
|
||||||
|
|
||||||
export type QuerySerializer = (query: Record<string, unknown>) => string;
|
export type QuerySerializer = (query: Record<string, unknown>) => string;
|
||||||
|
|
||||||
|
|
@ -29,7 +29,7 @@ const serializeFormDataPair = (
|
||||||
key: string,
|
key: string,
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): void => {
|
): void => {
|
||||||
if (typeof value === 'string' || value instanceof Blob) {
|
if (typeof value === "string" || value instanceof Blob) {
|
||||||
data.append(key, value);
|
data.append(key, value);
|
||||||
} else if (value instanceof Date) {
|
} else if (value instanceof Date) {
|
||||||
data.append(key, value.toISOString());
|
data.append(key, value.toISOString());
|
||||||
|
|
@ -43,7 +43,7 @@ const serializeUrlSearchParamsPair = (
|
||||||
key: string,
|
key: string,
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): void => {
|
): void => {
|
||||||
if (typeof value === 'string') {
|
if (typeof value === "string") {
|
||||||
data.append(key, value);
|
data.append(key, value);
|
||||||
} else {
|
} else {
|
||||||
data.append(key, JSON.stringify(value));
|
data.append(key, JSON.stringify(value));
|
||||||
|
|
@ -74,7 +74,7 @@ export const formDataBodySerializer = {
|
||||||
export const jsonBodySerializer = {
|
export const jsonBodySerializer = {
|
||||||
bodySerializer: <T>(body: T): string =>
|
bodySerializer: <T>(body: T): string =>
|
||||||
JSON.stringify(body, (_key, value) =>
|
JSON.stringify(body, (_key, value) =>
|
||||||
typeof value === 'bigint' ? value.toString() : value,
|
typeof value === "bigint" ? value.toString() : value,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
type Slot = 'body' | 'headers' | 'path' | 'query';
|
type Slot = "body" | "headers" | "path" | "query";
|
||||||
|
|
||||||
export type Field =
|
export type Field =
|
||||||
| {
|
| {
|
||||||
in: Exclude<Slot, 'body'>;
|
in: Exclude<Slot, "body">;
|
||||||
/**
|
/**
|
||||||
* Field name. This is the name we want the user to see and use.
|
* Field name. This is the name we want the user to see and use.
|
||||||
*/
|
*/
|
||||||
|
|
@ -16,7 +16,7 @@ export type Field =
|
||||||
map?: string;
|
map?: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
in: Extract<Slot, 'body'>;
|
in: Extract<Slot, "body">;
|
||||||
/**
|
/**
|
||||||
* Key isn't required for bodies.
|
* Key isn't required for bodies.
|
||||||
*/
|
*/
|
||||||
|
|
@ -43,10 +43,10 @@ export interface Fields {
|
||||||
export type FieldsConfig = ReadonlyArray<Field | Fields>;
|
export type FieldsConfig = ReadonlyArray<Field | Fields>;
|
||||||
|
|
||||||
const extraPrefixesMap: Record<string, Slot> = {
|
const extraPrefixesMap: Record<string, Slot> = {
|
||||||
$body_: 'body',
|
$body_: "body",
|
||||||
$headers_: 'headers',
|
$headers_: "headers",
|
||||||
$path_: 'path',
|
$path_: "path",
|
||||||
$query_: 'query',
|
$query_: "query",
|
||||||
};
|
};
|
||||||
const extraPrefixes = Object.entries(extraPrefixesMap);
|
const extraPrefixes = Object.entries(extraPrefixesMap);
|
||||||
|
|
||||||
|
|
@ -68,14 +68,14 @@ const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const config of fields) {
|
for (const config of fields) {
|
||||||
if ('in' in config) {
|
if ("in" in config) {
|
||||||
if (config.key) {
|
if (config.key) {
|
||||||
map.set(config.key, {
|
map.set(config.key, {
|
||||||
in: config.in,
|
in: config.in,
|
||||||
map: config.map,
|
map: config.map,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if ('key' in config) {
|
} else if ("key" in config) {
|
||||||
map.set(config.key, {
|
map.set(config.key, {
|
||||||
map: config.map,
|
map: config.map,
|
||||||
});
|
});
|
||||||
|
|
@ -96,7 +96,7 @@ interface Params {
|
||||||
|
|
||||||
const stripEmptySlots = (params: Params) => {
|
const stripEmptySlots = (params: Params) => {
|
||||||
for (const [slot, value] of Object.entries(params)) {
|
for (const [slot, value] of Object.entries(params)) {
|
||||||
if (value && typeof value === 'object' && !Object.keys(value).length) {
|
if (value && typeof value === "object" && !Object.keys(value).length) {
|
||||||
delete params[slot as Slot];
|
delete params[slot as Slot];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -126,7 +126,7 @@ export const buildClientParams = (
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('in' in config) {
|
if ("in" in config) {
|
||||||
if (config.key) {
|
if (config.key) {
|
||||||
const field = map.get(config.key)!;
|
const field = map.get(config.key)!;
|
||||||
const name = field.map || config.key;
|
const name = field.map || config.key;
|
||||||
|
|
@ -157,7 +157,7 @@ export const buildClientParams = (
|
||||||
(params[slot] as Record<string, unknown>)[
|
(params[slot] as Record<string, unknown>)[
|
||||||
key.slice(prefix.length)
|
key.slice(prefix.length)
|
||||||
] = value;
|
] = value;
|
||||||
} else if ('allowExtra' in config && config.allowExtra) {
|
} else if ("allowExtra" in config && config.allowExtra) {
|
||||||
for (const [slot, allowed] of Object.entries(config.allowExtra)) {
|
for (const [slot, allowed] of Object.entries(config.allowExtra)) {
|
||||||
if (allowed) {
|
if (allowed) {
|
||||||
(params[slot as Slot] as Record<string, unknown>)[key] = value;
|
(params[slot as Slot] as Record<string, unknown>)[key] = value;
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,10 @@ export interface SerializerOptions<T> {
|
||||||
style: T;
|
style: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited';
|
export type ArrayStyle = "form" | "spaceDelimited" | "pipeDelimited";
|
||||||
export type ArraySeparatorStyle = ArrayStyle | MatrixStyle;
|
export type ArraySeparatorStyle = ArrayStyle | MatrixStyle;
|
||||||
type MatrixStyle = 'label' | 'matrix' | 'simple';
|
type MatrixStyle = "label" | "matrix" | "simple";
|
||||||
export type ObjectStyle = 'form' | 'deepObject';
|
export type ObjectStyle = "form" | "deepObject";
|
||||||
type ObjectSeparatorStyle = ObjectStyle | MatrixStyle;
|
type ObjectSeparatorStyle = ObjectStyle | MatrixStyle;
|
||||||
|
|
||||||
interface SerializePrimitiveParam extends SerializePrimitiveOptions {
|
interface SerializePrimitiveParam extends SerializePrimitiveOptions {
|
||||||
|
|
@ -29,40 +29,40 @@ interface SerializePrimitiveParam extends SerializePrimitiveOptions {
|
||||||
|
|
||||||
export const separatorArrayExplode = (style: ArraySeparatorStyle) => {
|
export const separatorArrayExplode = (style: ArraySeparatorStyle) => {
|
||||||
switch (style) {
|
switch (style) {
|
||||||
case 'label':
|
case "label":
|
||||||
return '.';
|
return ".";
|
||||||
case 'matrix':
|
case "matrix":
|
||||||
return ';';
|
return ";";
|
||||||
case 'simple':
|
case "simple":
|
||||||
return ',';
|
return ",";
|
||||||
default:
|
default:
|
||||||
return '&';
|
return "&";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => {
|
export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => {
|
||||||
switch (style) {
|
switch (style) {
|
||||||
case 'form':
|
case "form":
|
||||||
return ',';
|
return ",";
|
||||||
case 'pipeDelimited':
|
case "pipeDelimited":
|
||||||
return '|';
|
return "|";
|
||||||
case 'spaceDelimited':
|
case "spaceDelimited":
|
||||||
return '%20';
|
return "%20";
|
||||||
default:
|
default:
|
||||||
return ',';
|
return ",";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const separatorObjectExplode = (style: ObjectSeparatorStyle) => {
|
export const separatorObjectExplode = (style: ObjectSeparatorStyle) => {
|
||||||
switch (style) {
|
switch (style) {
|
||||||
case 'label':
|
case "label":
|
||||||
return '.';
|
return ".";
|
||||||
case 'matrix':
|
case "matrix":
|
||||||
return ';';
|
return ";";
|
||||||
case 'simple':
|
case "simple":
|
||||||
return ',';
|
return ",";
|
||||||
default:
|
default:
|
||||||
return '&';
|
return "&";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -80,11 +80,11 @@ export const serializeArrayParam = ({
|
||||||
allowReserved ? value : value.map((v) => encodeURIComponent(v as string))
|
allowReserved ? value : value.map((v) => encodeURIComponent(v as string))
|
||||||
).join(separatorArrayNoExplode(style));
|
).join(separatorArrayNoExplode(style));
|
||||||
switch (style) {
|
switch (style) {
|
||||||
case 'label':
|
case "label":
|
||||||
return `.${joinedValues}`;
|
return `.${joinedValues}`;
|
||||||
case 'matrix':
|
case "matrix":
|
||||||
return `;${name}=${joinedValues}`;
|
return `;${name}=${joinedValues}`;
|
||||||
case 'simple':
|
case "simple":
|
||||||
return joinedValues;
|
return joinedValues;
|
||||||
default:
|
default:
|
||||||
return `${name}=${joinedValues}`;
|
return `${name}=${joinedValues}`;
|
||||||
|
|
@ -94,7 +94,7 @@ export const serializeArrayParam = ({
|
||||||
const separator = separatorArrayExplode(style);
|
const separator = separatorArrayExplode(style);
|
||||||
const joinedValues = value
|
const joinedValues = value
|
||||||
.map((v) => {
|
.map((v) => {
|
||||||
if (style === 'label' || style === 'simple') {
|
if (style === "label" || style === "simple") {
|
||||||
return allowReserved ? v : encodeURIComponent(v as string);
|
return allowReserved ? v : encodeURIComponent(v as string);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -105,7 +105,7 @@ export const serializeArrayParam = ({
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.join(separator);
|
.join(separator);
|
||||||
return style === 'label' || style === 'matrix'
|
return style === "label" || style === "matrix"
|
||||||
? separator + joinedValues
|
? separator + joinedValues
|
||||||
: joinedValues;
|
: joinedValues;
|
||||||
};
|
};
|
||||||
|
|
@ -116,12 +116,12 @@ export const serializePrimitiveParam = ({
|
||||||
value,
|
value,
|
||||||
}: SerializePrimitiveParam) => {
|
}: SerializePrimitiveParam) => {
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined || value === null) {
|
||||||
return '';
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value === 'object') {
|
if (typeof value === "object") {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.',
|
"Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -143,7 +143,7 @@ export const serializeObjectParam = ({
|
||||||
return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
|
return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (style !== 'deepObject' && !explode) {
|
if (style !== "deepObject" && !explode) {
|
||||||
let values: string[] = [];
|
let values: string[] = [];
|
||||||
Object.entries(value).forEach(([key, v]) => {
|
Object.entries(value).forEach(([key, v]) => {
|
||||||
values = [
|
values = [
|
||||||
|
|
@ -152,13 +152,13 @@ export const serializeObjectParam = ({
|
||||||
allowReserved ? (v as string) : encodeURIComponent(v as string),
|
allowReserved ? (v as string) : encodeURIComponent(v as string),
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
const joinedValues = values.join(',');
|
const joinedValues = values.join(",");
|
||||||
switch (style) {
|
switch (style) {
|
||||||
case 'form':
|
case "form":
|
||||||
return `${name}=${joinedValues}`;
|
return `${name}=${joinedValues}`;
|
||||||
case 'label':
|
case "label":
|
||||||
return `.${joinedValues}`;
|
return `.${joinedValues}`;
|
||||||
case 'matrix':
|
case "matrix":
|
||||||
return `;${name}=${joinedValues}`;
|
return `;${name}=${joinedValues}`;
|
||||||
default:
|
default:
|
||||||
return joinedValues;
|
return joinedValues;
|
||||||
|
|
@ -170,12 +170,12 @@ export const serializeObjectParam = ({
|
||||||
.map(([key, v]) =>
|
.map(([key, v]) =>
|
||||||
serializePrimitiveParam({
|
serializePrimitiveParam({
|
||||||
allowReserved,
|
allowReserved,
|
||||||
name: style === 'deepObject' ? `${name}[${key}]` : key,
|
name: style === "deepObject" ? `${name}[${key}]` : key,
|
||||||
value: v as string,
|
value: v as string,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.join(separator);
|
.join(separator);
|
||||||
return style === 'label' || style === 'matrix'
|
return style === "label" || style === "matrix"
|
||||||
? separator + joinedValues
|
? separator + joinedValues
|
||||||
: joinedValues;
|
: joinedValues;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,12 @@ export type JsonValue =
|
||||||
export const queryKeyJsonReplacer = (_key: string, value: unknown) => {
|
export const queryKeyJsonReplacer = (_key: string, value: unknown) => {
|
||||||
if (
|
if (
|
||||||
value === undefined ||
|
value === undefined ||
|
||||||
typeof value === 'function' ||
|
typeof value === "function" ||
|
||||||
typeof value === 'symbol'
|
typeof value === "symbol"
|
||||||
) {
|
) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (typeof value === 'bigint') {
|
if (typeof value === "bigint") {
|
||||||
return value.toString();
|
return value.toString();
|
||||||
}
|
}
|
||||||
if (value instanceof Date) {
|
if (value instanceof Date) {
|
||||||
|
|
@ -50,7 +50,7 @@ export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => {
|
||||||
* Detects plain objects (including objects with a null prototype).
|
* Detects plain objects (including objects with a null prototype).
|
||||||
*/
|
*/
|
||||||
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
||||||
if (value === null || typeof value !== 'object') {
|
if (value === null || typeof value !== "object") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const prototype = Object.getPrototypeOf(value as object);
|
const prototype = Object.getPrototypeOf(value as object);
|
||||||
|
|
@ -94,22 +94,22 @@ export const serializeQueryKeyValue = (
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof value === 'string' ||
|
typeof value === "string" ||
|
||||||
typeof value === 'number' ||
|
typeof value === "number" ||
|
||||||
typeof value === 'boolean'
|
typeof value === "boolean"
|
||||||
) {
|
) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
value === undefined ||
|
value === undefined ||
|
||||||
typeof value === 'function' ||
|
typeof value === "function" ||
|
||||||
typeof value === 'symbol'
|
typeof value === "symbol"
|
||||||
) {
|
) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value === 'bigint') {
|
if (typeof value === "bigint") {
|
||||||
return value.toString();
|
return value.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,7 +122,7 @@ export const serializeQueryKeyValue = (
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof URLSearchParams !== 'undefined' &&
|
typeof URLSearchParams !== "undefined" &&
|
||||||
value instanceof URLSearchParams
|
value instanceof URLSearchParams
|
||||||
) {
|
) {
|
||||||
return serializeSearchParams(value);
|
return serializeSearchParams(value);
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
import type { Config } from './types.gen';
|
import type { Config } from "./types.gen";
|
||||||
|
|
||||||
export type ServerSentEventsOptions<TData = unknown> = Omit<
|
export type ServerSentEventsOptions<TData = unknown> = Omit<
|
||||||
RequestInit,
|
RequestInit,
|
||||||
'method'
|
"method"
|
||||||
> &
|
> &
|
||||||
Pick<Config, 'method' | 'responseTransformer' | 'responseValidator'> & {
|
Pick<Config, "method" | "responseTransformer" | "responseValidator"> & {
|
||||||
/**
|
/**
|
||||||
* Fetch API implementation. You can use this option to provide a custom
|
* Fetch API implementation. You can use this option to provide a custom
|
||||||
* fetch instance.
|
* fetch instance.
|
||||||
|
|
@ -35,7 +35,7 @@ export type ServerSentEventsOptions<TData = unknown> = Omit<
|
||||||
* @returns Nothing (void).
|
* @returns Nothing (void).
|
||||||
*/
|
*/
|
||||||
onSseEvent?: (event: StreamEvent<TData>) => void;
|
onSseEvent?: (event: StreamEvent<TData>) => void;
|
||||||
serializedBody?: RequestInit['body'];
|
serializedBody?: RequestInit["body"];
|
||||||
/**
|
/**
|
||||||
* Default retry delay in milliseconds.
|
* Default retry delay in milliseconds.
|
||||||
*
|
*
|
||||||
|
|
@ -121,12 +121,12 @@ export const createSseClient = <TData = unknown>({
|
||||||
: new Headers(options.headers as Record<string, string> | undefined);
|
: new Headers(options.headers as Record<string, string> | undefined);
|
||||||
|
|
||||||
if (lastEventId !== undefined) {
|
if (lastEventId !== undefined) {
|
||||||
headers.set('Last-Event-ID', lastEventId);
|
headers.set("Last-Event-ID", lastEventId);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const requestInit: RequestInit = {
|
const requestInit: RequestInit = {
|
||||||
redirect: 'follow',
|
redirect: "follow",
|
||||||
...options,
|
...options,
|
||||||
body: options.serializedBody,
|
body: options.serializedBody,
|
||||||
headers,
|
headers,
|
||||||
|
|
@ -146,13 +146,13 @@ export const createSseClient = <TData = unknown>({
|
||||||
`SSE failed: ${response.status} ${response.statusText}`,
|
`SSE failed: ${response.status} ${response.statusText}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.body) throw new Error('No body in SSE response');
|
if (!response.body) throw new Error("No body in SSE response");
|
||||||
|
|
||||||
const reader = response.body
|
const reader = response.body
|
||||||
.pipeThrough(new TextDecoderStream())
|
.pipeThrough(new TextDecoderStream())
|
||||||
.getReader();
|
.getReader();
|
||||||
|
|
||||||
let buffer = '';
|
let buffer = "";
|
||||||
|
|
||||||
const abortHandler = () => {
|
const abortHandler = () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -162,7 +162,7 @@ export const createSseClient = <TData = unknown>({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
signal.addEventListener('abort', abortHandler);
|
signal.addEventListener("abort", abortHandler);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
|
|
@ -170,26 +170,26 @@ export const createSseClient = <TData = unknown>({
|
||||||
if (done) break;
|
if (done) break;
|
||||||
buffer += value;
|
buffer += value;
|
||||||
// Normalize line endings: CRLF -> LF, then CR -> LF
|
// Normalize line endings: CRLF -> LF, then CR -> LF
|
||||||
buffer = buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
buffer = buffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||||
|
|
||||||
const chunks = buffer.split('\n\n');
|
const chunks = buffer.split("\n\n");
|
||||||
buffer = chunks.pop() ?? '';
|
buffer = chunks.pop() ?? "";
|
||||||
|
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
const lines = chunk.split('\n');
|
const lines = chunk.split("\n");
|
||||||
const dataLines: Array<string> = [];
|
const dataLines: Array<string> = [];
|
||||||
let eventName: string | undefined;
|
let eventName: string | undefined;
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.startsWith('data:')) {
|
if (line.startsWith("data:")) {
|
||||||
dataLines.push(line.replace(/^data:\s*/, ''));
|
dataLines.push(line.replace(/^data:\s*/, ""));
|
||||||
} else if (line.startsWith('event:')) {
|
} else if (line.startsWith("event:")) {
|
||||||
eventName = line.replace(/^event:\s*/, '');
|
eventName = line.replace(/^event:\s*/, "");
|
||||||
} else if (line.startsWith('id:')) {
|
} else if (line.startsWith("id:")) {
|
||||||
lastEventId = line.replace(/^id:\s*/, '');
|
lastEventId = line.replace(/^id:\s*/, "");
|
||||||
} else if (line.startsWith('retry:')) {
|
} else if (line.startsWith("retry:")) {
|
||||||
const parsed = Number.parseInt(
|
const parsed = Number.parseInt(
|
||||||
line.replace(/^retry:\s*/, ''),
|
line.replace(/^retry:\s*/, ""),
|
||||||
10,
|
10,
|
||||||
);
|
);
|
||||||
if (!Number.isNaN(parsed)) {
|
if (!Number.isNaN(parsed)) {
|
||||||
|
|
@ -202,7 +202,7 @@ export const createSseClient = <TData = unknown>({
|
||||||
let parsedJson = false;
|
let parsedJson = false;
|
||||||
|
|
||||||
if (dataLines.length) {
|
if (dataLines.length) {
|
||||||
const rawData = dataLines.join('\n');
|
const rawData = dataLines.join("\n");
|
||||||
try {
|
try {
|
||||||
data = JSON.parse(rawData);
|
data = JSON.parse(rawData);
|
||||||
parsedJson = true;
|
parsedJson = true;
|
||||||
|
|
@ -234,7 +234,7 @@ export const createSseClient = <TData = unknown>({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
signal.removeEventListener('abort', abortHandler);
|
signal.removeEventListener("abort", abortHandler);
|
||||||
reader.releaseLock();
|
reader.releaseLock();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,22 @@
|
||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
import type { Auth, AuthToken } from './auth.gen';
|
import type { Auth, AuthToken } from "./auth.gen";
|
||||||
import type {
|
import type {
|
||||||
BodySerializer,
|
BodySerializer,
|
||||||
QuerySerializer,
|
QuerySerializer,
|
||||||
QuerySerializerOptions,
|
QuerySerializerOptions,
|
||||||
} from './bodySerializer.gen';
|
} from "./bodySerializer.gen";
|
||||||
|
|
||||||
export type HttpMethod =
|
export type HttpMethod =
|
||||||
| 'connect'
|
| "connect"
|
||||||
| 'delete'
|
| "delete"
|
||||||
| 'get'
|
| "get"
|
||||||
| 'head'
|
| "head"
|
||||||
| 'options'
|
| "options"
|
||||||
| 'patch'
|
| "patch"
|
||||||
| 'post'
|
| "post"
|
||||||
| 'put'
|
| "put"
|
||||||
| 'trace';
|
| "trace";
|
||||||
|
|
||||||
export type Client<
|
export type Client<
|
||||||
RequestFn = never,
|
RequestFn = never,
|
||||||
|
|
@ -56,7 +56,7 @@ export interface Config {
|
||||||
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
|
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
|
||||||
*/
|
*/
|
||||||
headers?:
|
headers?:
|
||||||
| RequestInit['headers']
|
| RequestInit["headers"]
|
||||||
| Record<
|
| Record<
|
||||||
string,
|
string,
|
||||||
| string
|
| string
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
import type { BodySerializer, QuerySerializer } from './bodySerializer.gen';
|
import type { BodySerializer, QuerySerializer } from "./bodySerializer.gen";
|
||||||
import {
|
import {
|
||||||
type ArraySeparatorStyle,
|
type ArraySeparatorStyle,
|
||||||
serializeArrayParam,
|
serializeArrayParam,
|
||||||
serializeObjectParam,
|
serializeObjectParam,
|
||||||
serializePrimitiveParam,
|
serializePrimitiveParam,
|
||||||
} from './pathSerializer.gen';
|
} from "./pathSerializer.gen";
|
||||||
|
|
||||||
export interface PathSerializer {
|
export interface PathSerializer {
|
||||||
path: Record<string, unknown>;
|
path: Record<string, unknown>;
|
||||||
|
|
@ -22,19 +22,19 @@ export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
let explode = false;
|
let explode = false;
|
||||||
let name = match.substring(1, match.length - 1);
|
let name = match.substring(1, match.length - 1);
|
||||||
let style: ArraySeparatorStyle = 'simple';
|
let style: ArraySeparatorStyle = "simple";
|
||||||
|
|
||||||
if (name.endsWith('*')) {
|
if (name.endsWith("*")) {
|
||||||
explode = true;
|
explode = true;
|
||||||
name = name.substring(0, name.length - 1);
|
name = name.substring(0, name.length - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name.startsWith('.')) {
|
if (name.startsWith(".")) {
|
||||||
name = name.substring(1);
|
name = name.substring(1);
|
||||||
style = 'label';
|
style = "label";
|
||||||
} else if (name.startsWith(';')) {
|
} else if (name.startsWith(";")) {
|
||||||
name = name.substring(1);
|
name = name.substring(1);
|
||||||
style = 'matrix';
|
style = "matrix";
|
||||||
}
|
}
|
||||||
|
|
||||||
const value = path[name];
|
const value = path[name];
|
||||||
|
|
@ -51,7 +51,7 @@ export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value === 'object') {
|
if (typeof value === "object") {
|
||||||
url = url.replace(
|
url = url.replace(
|
||||||
match,
|
match,
|
||||||
serializeObjectParam({
|
serializeObjectParam({
|
||||||
|
|
@ -65,7 +65,7 @@ export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (style === 'matrix') {
|
if (style === "matrix") {
|
||||||
url = url.replace(
|
url = url.replace(
|
||||||
match,
|
match,
|
||||||
`;${serializePrimitiveParam({
|
`;${serializePrimitiveParam({
|
||||||
|
|
@ -77,7 +77,7 @@ export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const replaceValue = encodeURIComponent(
|
const replaceValue = encodeURIComponent(
|
||||||
style === 'label' ? `.${value as string}` : (value as string),
|
style === "label" ? `.${value as string}` : (value as string),
|
||||||
);
|
);
|
||||||
url = url.replace(match, replaceValue);
|
url = url.replace(match, replaceValue);
|
||||||
}
|
}
|
||||||
|
|
@ -98,13 +98,13 @@ export const getUrl = ({
|
||||||
querySerializer: QuerySerializer;
|
querySerializer: QuerySerializer;
|
||||||
url: string;
|
url: string;
|
||||||
}) => {
|
}) => {
|
||||||
const pathUrl = _url.startsWith('/') ? _url : `/${_url}`;
|
const pathUrl = _url.startsWith("/") ? _url : `/${_url}`;
|
||||||
let url = (baseUrl ?? '') + pathUrl;
|
let url = (baseUrl ?? "") + pathUrl;
|
||||||
if (path) {
|
if (path) {
|
||||||
url = defaultPathSerializer({ path, url });
|
url = defaultPathSerializer({ path, url });
|
||||||
}
|
}
|
||||||
let search = query ? querySerializer(query) : '';
|
let search = query ? querySerializer(query) : "";
|
||||||
if (search.startsWith('?')) {
|
if (search.startsWith("?")) {
|
||||||
search = search.substring(1);
|
search = search.substring(1);
|
||||||
}
|
}
|
||||||
if (search) {
|
if (search) {
|
||||||
|
|
@ -122,15 +122,15 @@ export function getValidRequestBody(options: {
|
||||||
const isSerializedBody = hasBody && options.bodySerializer;
|
const isSerializedBody = hasBody && options.bodySerializer;
|
||||||
|
|
||||||
if (isSerializedBody) {
|
if (isSerializedBody) {
|
||||||
if ('serializedBody' in options) {
|
if ("serializedBody" in options) {
|
||||||
const hasSerializedBody =
|
const hasSerializedBody =
|
||||||
options.serializedBody !== undefined && options.serializedBody !== '';
|
options.serializedBody !== undefined && options.serializedBody !== "";
|
||||||
|
|
||||||
return hasSerializedBody ? options.serializedBody : null;
|
return hasSerializedBody ? options.serializedBody : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// not all clients implement a serializedBody property (i.e. client-axios)
|
// not all clients implement a serializedBody property (i.e. client-axios)
|
||||||
return options.body !== '' ? options.body : null;
|
return options.body !== "" ? options.body : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// plain/text body
|
// plain/text body
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
export type * from './types.gen';
|
export type * from "./types.gen";
|
||||||
export * from './sdk.gen';
|
export * from "./sdk.gen";
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -23,7 +23,7 @@ export const DirectoryBrowser = ({ onSelectPath, selectedPath }: Props) => {
|
||||||
return await queryClient.ensureQueryData(browseFilesystemOptions({ query: { path } }));
|
return await queryClient.ensureQueryData(browseFilesystemOptions({ query: { path } }));
|
||||||
},
|
},
|
||||||
prefetchFolder: (path) => {
|
prefetchFolder: (path) => {
|
||||||
queryClient.prefetchQuery(browseFilesystemOptions({ query: { path } }));
|
void queryClient.prefetchQuery(browseFilesystemOptions({ query: { path } }));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { LifeBuoy } from "lucide-react";
|
import { LifeBuoy } from "lucide-react";
|
||||||
import { Outlet, redirect, useNavigate } from "react-router";
|
import { Outlet, redirect, useNavigate } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
@ -10,7 +9,7 @@ import { GridBackground } from "./grid-background";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { SidebarProvider, SidebarTrigger } from "./ui/sidebar";
|
import { SidebarProvider, SidebarTrigger } from "./ui/sidebar";
|
||||||
import { AppSidebar } from "./app-sidebar";
|
import { AppSidebar } from "./app-sidebar";
|
||||||
import { logoutMutation } from "../api-client/@tanstack/react-query.gen";
|
import { authClient } from "../lib/auth-client";
|
||||||
|
|
||||||
export const clientMiddleware = [authMiddleware];
|
export const clientMiddleware = [authMiddleware];
|
||||||
|
|
||||||
|
|
@ -27,16 +26,18 @@ export async function clientLoader({ context }: Route.LoaderArgs) {
|
||||||
export default function Layout({ loaderData }: Route.ComponentProps) {
|
export default function Layout({ loaderData }: Route.ComponentProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const logout = useMutation({
|
const handleLogout = async () => {
|
||||||
...logoutMutation(),
|
await authClient.signOut({
|
||||||
onSuccess: async () => {
|
fetchOptions: {
|
||||||
navigate("/login", { replace: true });
|
onSuccess: () => {
|
||||||
|
void navigate("/login", { replace: true });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: ({ error }) => {
|
||||||
console.error(error);
|
|
||||||
toast.error("Logout failed", { description: error.message });
|
toast.error("Logout failed", { description: error.message });
|
||||||
},
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider defaultOpen={true}>
|
<SidebarProvider defaultOpen={true}>
|
||||||
|
|
@ -54,7 +55,7 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
|
||||||
Welcome,
|
Welcome,
|
||||||
<span className="text-strong-accent">{loaderData.user?.username}</span>
|
<span className="text-strong-accent">{loaderData.user?.username}</span>
|
||||||
</span>
|
</span>
|
||||||
<Button variant="default" size="sm" onClick={() => logout.mutate({})} loading={logout.isPending}>
|
<Button variant="default" size="sm" onClick={handleLogout}>
|
||||||
Logout
|
Logout
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="default" size="sm" className="relative overflow-hidden hidden lg:inline-flex">
|
<Button variant="default" size="sm" className="relative overflow-hidden hidden lg:inline-flex">
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ export function RestoreForm({ snapshot, repository, snapshotId, returnPath }: Re
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
prefetchFolder: (path) => {
|
prefetchFolder: (path) => {
|
||||||
queryClient.prefetchQuery(
|
void queryClient.prefetchQuery(
|
||||||
listSnapshotFilesOptions({
|
listSnapshotFilesOptions({
|
||||||
path: { id: repository.id, snapshotId },
|
path: { id: repository.id, snapshotId },
|
||||||
query: { path },
|
query: { path },
|
||||||
|
|
@ -102,7 +102,7 @@ export function RestoreForm({ snapshot, repository, snapshotId, returnPath }: Re
|
||||||
toast.success("Restore completed", {
|
toast.success("Restore completed", {
|
||||||
description: `Successfully restored ${data.filesRestored} file(s). ${data.filesSkipped} file(s) skipped.`,
|
description: `Successfully restored ${data.filesRestored} file(s). ${data.filesSkipped} file(s) skipped.`,
|
||||||
});
|
});
|
||||||
navigate(returnPath);
|
void navigate(returnPath);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error("Restore failed", { description: error.message || "Failed to restore snapshot" });
|
toast.error("Restore failed", { description: error.message || "Failed to restore snapshot" });
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => {
|
||||||
const deleteSnapshots = useMutation({
|
const deleteSnapshots = useMutation({
|
||||||
...deleteSnapshotsMutation(),
|
...deleteSnapshotsMutation(),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["listSnapshots"] });
|
void queryClient.invalidateQueries({ queryKey: ["listSnapshots"] });
|
||||||
setShowBulkDeleteConfirm(false);
|
setShowBulkDeleteConfirm(false);
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
},
|
},
|
||||||
|
|
@ -62,7 +62,7 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => {
|
||||||
setShowReTagDialog(false);
|
setShowReTagDialog(false);
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["listSnapshots"] });
|
void queryClient.invalidateQueries({ queryKey: ["listSnapshots"] });
|
||||||
setShowReTagDialog(false);
|
setShowReTagDialog(false);
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
setTargetScheduleId("");
|
setTargetScheduleId("");
|
||||||
|
|
@ -70,7 +70,7 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleRowClick = (snapshotId: string) => {
|
const handleRowClick = (snapshotId: string) => {
|
||||||
navigate(`/repositories/${repositoryId}/${snapshotId}`);
|
void navigate(`/repositories/${repositoryId}/${snapshotId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleSelectAll = () => {
|
const toggleSelectAll = () => {
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ export const VolumeFileBrowser = ({
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
prefetchFolder: (path) => {
|
prefetchFolder: (path) => {
|
||||||
queryClient.prefetchQuery(
|
void queryClient.prefetchQuery(
|
||||||
listFilesOptions({
|
listFilesOptions({
|
||||||
path: { name: volumeName },
|
path: { name: volumeName },
|
||||||
query: { path },
|
query: { path },
|
||||||
|
|
|
||||||
|
|
@ -87,8 +87,8 @@ export function useServerEvents() {
|
||||||
const data = JSON.parse(e.data) as BackupEvent;
|
const data = JSON.parse(e.data) as BackupEvent;
|
||||||
console.log("[SSE] Backup completed:", data);
|
console.log("[SSE] Backup completed:", data);
|
||||||
|
|
||||||
queryClient.invalidateQueries();
|
void queryClient.invalidateQueries();
|
||||||
queryClient.refetchQueries();
|
void queryClient.refetchQueries();
|
||||||
|
|
||||||
handlersRef.current.get("backup:completed")?.forEach((handler) => {
|
handlersRef.current.get("backup:completed")?.forEach((handler) => {
|
||||||
handler(data);
|
handler(data);
|
||||||
|
|
@ -117,7 +117,7 @@ export function useServerEvents() {
|
||||||
const data = JSON.parse(e.data) as VolumeEvent;
|
const data = JSON.parse(e.data) as VolumeEvent;
|
||||||
console.log("[SSE] Volume updated:", data);
|
console.log("[SSE] Volume updated:", data);
|
||||||
|
|
||||||
queryClient.invalidateQueries();
|
void queryClient.invalidateQueries();
|
||||||
|
|
||||||
handlersRef.current.get("volume:updated")?.forEach((handler) => {
|
handlersRef.current.get("volume:updated")?.forEach((handler) => {
|
||||||
handler(data);
|
handler(data);
|
||||||
|
|
@ -128,7 +128,7 @@ export function useServerEvents() {
|
||||||
const data = JSON.parse(e.data) as VolumeEvent;
|
const data = JSON.parse(e.data) as VolumeEvent;
|
||||||
console.log("[SSE] Volume status updated:", data);
|
console.log("[SSE] Volume status updated:", data);
|
||||||
|
|
||||||
queryClient.invalidateQueries();
|
void queryClient.invalidateQueries();
|
||||||
|
|
||||||
handlersRef.current.get("volume:updated")?.forEach((handler) => {
|
handlersRef.current.get("volume:updated")?.forEach((handler) => {
|
||||||
handler(data);
|
handler(data);
|
||||||
|
|
@ -149,7 +149,7 @@ export function useServerEvents() {
|
||||||
console.log("[SSE] Mirror copy completed:", data);
|
console.log("[SSE] Mirror copy completed:", data);
|
||||||
|
|
||||||
// Invalidate queries to refresh mirror status in the UI
|
// Invalidate queries to refresh mirror status in the UI
|
||||||
queryClient.invalidateQueries();
|
void queryClient.invalidateQueries();
|
||||||
|
|
||||||
handlersRef.current.get("mirror:completed")?.forEach((handler) => {
|
handlersRef.current.get("mirror:completed")?.forEach((handler) => {
|
||||||
handler(data);
|
handler(data);
|
||||||
|
|
|
||||||
8
app/client/lib/auth-client.ts
Normal file
8
app/client/lib/auth-client.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { createAuthClient } from "better-auth/react";
|
||||||
|
import { usernameClient } from "better-auth/client/plugins";
|
||||||
|
import { inferAdditionalFields } from "better-auth/client/plugins";
|
||||||
|
import type { auth } from "~/lib/auth";
|
||||||
|
|
||||||
|
export const authClient = createAuthClient({
|
||||||
|
plugins: [inferAdditionalFields<typeof auth>(), usernameClient()],
|
||||||
|
});
|
||||||
1
app/client/lib/constants.ts
Normal file
1
app/client/lib/constants.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export const REPOSITORY_BASE = "/var/lib/zerobyte/repositories";
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import type {
|
import type {
|
||||||
GetBackupScheduleResponse,
|
GetBackupScheduleResponse,
|
||||||
GetMeResponse,
|
|
||||||
GetRepositoryResponse,
|
GetRepositoryResponse,
|
||||||
GetVolumeResponse,
|
GetVolumeResponse,
|
||||||
ListNotificationDestinationsResponse,
|
ListNotificationDestinationsResponse,
|
||||||
|
|
@ -11,8 +10,6 @@ export type Volume = GetVolumeResponse["volume"];
|
||||||
export type StatFs = GetVolumeResponse["statfs"];
|
export type StatFs = GetVolumeResponse["statfs"];
|
||||||
export type VolumeStatus = Volume["status"];
|
export type VolumeStatus = Volume["status"];
|
||||||
|
|
||||||
export type User = GetMeResponse["user"];
|
|
||||||
|
|
||||||
export type Repository = GetRepositoryResponse;
|
export type Repository = GetRepositoryResponse;
|
||||||
|
|
||||||
export type BackupSchedule = GetBackupScheduleResponse;
|
export type BackupSchedule = GetBackupScheduleResponse;
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ export default function DownloadRecoveryKeyPage() {
|
||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
toast.success("Recovery key downloaded successfully!");
|
toast.success("Recovery key downloaded successfully!");
|
||||||
navigate("/volumes", { replace: true });
|
void navigate("/volumes", { replace: true });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error("Failed to download recovery key", { description: error.message });
|
toast.error("Failed to download recovery key", { description: error.message });
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
@ -11,8 +10,8 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "
|
||||||
import { Input } from "~/client/components/ui/input";
|
import { Input } from "~/client/components/ui/input";
|
||||||
import { authMiddleware } from "~/middleware/auth";
|
import { authMiddleware } from "~/middleware/auth";
|
||||||
import type { Route } from "./+types/login";
|
import type { Route } from "./+types/login";
|
||||||
import { loginMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
|
||||||
import { ResetPasswordDialog } from "../components/reset-password-dialog";
|
import { ResetPasswordDialog } from "../components/reset-password-dialog";
|
||||||
|
import { authClient } from "~/client/lib/auth-client";
|
||||||
|
|
||||||
export const clientMiddleware = [authMiddleware];
|
export const clientMiddleware = [authMiddleware];
|
||||||
|
|
||||||
|
|
@ -36,6 +35,7 @@ type LoginFormValues = typeof loginSchema.inferIn;
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [showResetDialog, setShowResetDialog] = useState(false);
|
const [showResetDialog, setShowResetDialog] = useState(false);
|
||||||
|
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
||||||
|
|
||||||
const form = useForm<LoginFormValues>({
|
const form = useForm<LoginFormValues>({
|
||||||
resolver: arktypeResolver(loginSchema),
|
resolver: arktypeResolver(loginSchema),
|
||||||
|
|
@ -45,28 +45,32 @@ export default function LoginPage() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const login = useMutation({
|
const onSubmit = async (values: LoginFormValues) => {
|
||||||
...loginMutation(),
|
const { data, error } = await authClient.signIn.username({
|
||||||
onSuccess: async (data) => {
|
username: values.username.toLowerCase().trim(),
|
||||||
if (data.user && !data.user.hasDownloadedResticPassword) {
|
password: values.password,
|
||||||
navigate("/download-recovery-key");
|
fetchOptions: {
|
||||||
} else {
|
onRequest: () => {
|
||||||
navigate("/volumes");
|
setIsLoggingIn(true);
|
||||||
}
|
},
|
||||||
|
onResponse: () => {
|
||||||
|
setIsLoggingIn(false);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
|
||||||
console.error(error);
|
|
||||||
toast.error("Login failed", { description: error.message });
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = (values: LoginFormValues) => {
|
if (error) {
|
||||||
login.mutate({
|
console.error(error);
|
||||||
body: {
|
toast.error("Login failed", { description: error.message });
|
||||||
username: values.username.trim(),
|
return;
|
||||||
password: values.password.trim(),
|
}
|
||||||
},
|
|
||||||
});
|
const d = await authClient.getSession();
|
||||||
|
if (data.user && !d.data?.user.hasDownloadedResticPassword) {
|
||||||
|
void navigate("/download-recovery-key");
|
||||||
|
} else {
|
||||||
|
void navigate("/volumes");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -80,7 +84,7 @@ export default function LoginPage() {
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Username</FormLabel>
|
<FormLabel>Username</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} type="text" placeholder="admin" disabled={login.isPending} autoFocus />
|
<Input {...field} type="text" placeholder="admin" disabled={isLoggingIn} autoFocus />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|
@ -102,13 +106,13 @@ export default function LoginPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} type="password" disabled={login.isPending} />
|
<Input {...field} type="password" disabled={isLoggingIn} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Button type="submit" className="w-full" loading={login.isPending}>
|
<Button type="submit" className="w-full" loading={isLoggingIn}>
|
||||||
Login
|
Login
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
|
|
@ -18,7 +17,8 @@ import type { Route } from "./+types/onboarding";
|
||||||
import { AuthLayout } from "~/client/components/auth-layout";
|
import { AuthLayout } from "~/client/components/auth-layout";
|
||||||
import { Input } from "~/client/components/ui/input";
|
import { Input } from "~/client/components/ui/input";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
import { registerMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
import { authClient } from "~/client/lib/auth-client";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
export const clientMiddleware = [authMiddleware];
|
export const clientMiddleware = [authMiddleware];
|
||||||
|
|
||||||
|
|
@ -33,7 +33,8 @@ export function meta(_: Route.MetaArgs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const onboardingSchema = type({
|
const onboardingSchema = type({
|
||||||
username: "2<=string<=50",
|
username: type("2<=string<=30").pipe((str) => str.trim().toLowerCase()),
|
||||||
|
email: type("string.email").pipe((str) => str.trim().toLowerCase()),
|
||||||
password: "string>=8",
|
password: "string>=8",
|
||||||
confirmPassword: "string>=1",
|
confirmPassword: "string>=1",
|
||||||
});
|
});
|
||||||
|
|
@ -42,6 +43,7 @@ type OnboardingFormValues = typeof onboardingSchema.inferIn;
|
||||||
|
|
||||||
export default function OnboardingPage() {
|
export default function OnboardingPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
const form = useForm<OnboardingFormValues>({
|
const form = useForm<OnboardingFormValues>({
|
||||||
resolver: arktypeResolver(onboardingSchema),
|
resolver: arktypeResolver(onboardingSchema),
|
||||||
|
|
@ -49,22 +51,11 @@ export default function OnboardingPage() {
|
||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
confirmPassword: "",
|
confirmPassword: "",
|
||||||
|
email: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const registerUser = useMutation({
|
const onSubmit = async (values: OnboardingFormValues) => {
|
||||||
...registerMutation(),
|
|
||||||
onSuccess: async () => {
|
|
||||||
toast.success("Admin user created successfully!");
|
|
||||||
navigate("/download-recovery-key");
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error(error);
|
|
||||||
toast.error("Failed to create admin user", { description: error.message });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit = (values: OnboardingFormValues) => {
|
|
||||||
if (values.password !== values.confirmPassword) {
|
if (values.password !== values.confirmPassword) {
|
||||||
form.setError("confirmPassword", {
|
form.setError("confirmPassword", {
|
||||||
type: "manual",
|
type: "manual",
|
||||||
|
|
@ -73,18 +64,50 @@ export default function OnboardingPage() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
registerUser.mutate({
|
const { data, error } = await authClient.signUp.email({
|
||||||
body: {
|
username: values.username.toLowerCase().trim(),
|
||||||
username: values.username.trim(),
|
password: values.password,
|
||||||
password: values.password.trim(),
|
email: values.email.toLowerCase().trim(),
|
||||||
|
name: values.username,
|
||||||
|
displayUsername: values.username,
|
||||||
|
hasDownloadedResticPassword: false,
|
||||||
|
fetchOptions: {
|
||||||
|
onRequest: () => {
|
||||||
|
setSubmitting(true);
|
||||||
|
},
|
||||||
|
onResponse: () => {
|
||||||
|
setSubmitting(false);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (data?.token) {
|
||||||
|
toast.success("Admin user created successfully!");
|
||||||
|
void navigate("/download-recovery-key");
|
||||||
|
} else if (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error("Failed to create admin user", { description: error.message });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthLayout title="Welcome to Zerobyte" description="Create the admin user to get started">
|
<AuthLayout title="Welcome to Zerobyte" description="Create the admin user to get started">
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} type="email" placeholder="you@example.com" disabled={submitting} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Enter your email address</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="username"
|
name="username"
|
||||||
|
|
@ -92,7 +115,7 @@ export default function OnboardingPage() {
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Username</FormLabel>
|
<FormLabel>Username</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} type="text" placeholder="admin" disabled={registerUser.isPending} autoFocus />
|
<Input {...field} type="text" placeholder="admin" disabled={submitting} autoFocus />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>Choose a username for the admin account</FormDescription>
|
<FormDescription>Choose a username for the admin account</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
|
@ -106,12 +129,7 @@ export default function OnboardingPage() {
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Password</FormLabel>
|
<FormLabel>Password</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input {...field} type="password" placeholder="Enter a secure password" disabled={submitting} />
|
||||||
{...field}
|
|
||||||
type="password"
|
|
||||||
placeholder="Enter a secure password"
|
|
||||||
disabled={registerUser.isPending}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>Password must be at least 8 characters long.</FormDescription>
|
<FormDescription>Password must be at least 8 characters long.</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
|
@ -125,19 +143,14 @@ export default function OnboardingPage() {
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Confirm Password</FormLabel>
|
<FormLabel>Confirm Password</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input {...field} type="password" placeholder="Re-enter your password" disabled={submitting} />
|
||||||
{...field}
|
|
||||||
type="password"
|
|
||||||
placeholder="Re-enter your password"
|
|
||||||
disabled={registerUser.isPending}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Button type="submit" className="w-full" loading={registerUser.isPending}>
|
<Button type="submit" className="w-full" loading={submitting}>
|
||||||
Create Admin User
|
Create admin user
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
|
||||||
|
|
@ -728,7 +728,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
||||||
.filter(([key, value]) => key.startsWith("keep") && Boolean(value))
|
.filter(([key, value]) => key.startsWith("keep") && Boolean(value))
|
||||||
.map(([key, value]) => {
|
.map(([key, value]) => {
|
||||||
const label = key.replace("keep", "").toLowerCase();
|
const label = key.replace("keep", "").toLowerCase();
|
||||||
return `${value} ${label}`;
|
return `${value.toString()} ${label}`;
|
||||||
})
|
})
|
||||||
.join(", ") || "-"}
|
.join(", ") || "-"}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,13 @@ import { StatusDot } from "~/client/components/status-dot";
|
||||||
import { formatDistanceToNow } from "date-fns";
|
import { formatDistanceToNow } from "date-fns";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
|
import type { GetScheduleMirrorsResponse } from "~/client/api-client";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
scheduleId: number;
|
scheduleId: number;
|
||||||
primaryRepositoryId: string;
|
primaryRepositoryId: string;
|
||||||
repositories: Repository[];
|
repositories: Repository[];
|
||||||
|
initialData: GetScheduleMirrorsResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MirrorAssignment = {
|
type MirrorAssignment = {
|
||||||
|
|
@ -36,13 +38,14 @@ type MirrorAssignment = {
|
||||||
lastCopyError: string | null;
|
lastCopyError: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ScheduleMirrorsConfig = ({ scheduleId, primaryRepositoryId, repositories }: Props) => {
|
export const ScheduleMirrorsConfig = ({ scheduleId, primaryRepositoryId, repositories, initialData }: Props) => {
|
||||||
const [assignments, setAssignments] = useState<Map<string, MirrorAssignment>>(new Map());
|
const [assignments, setAssignments] = useState<Map<string, MirrorAssignment>>(new Map());
|
||||||
const [hasChanges, setHasChanges] = useState(false);
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
const [isAddingNew, setIsAddingNew] = useState(false);
|
const [isAddingNew, setIsAddingNew] = useState(false);
|
||||||
|
|
||||||
const { data: currentMirrors } = useQuery({
|
const { data: currentMirrors } = useQuery({
|
||||||
...getScheduleMirrorsOptions({ path: { scheduleId: scheduleId.toString() } }),
|
...getScheduleMirrorsOptions({ path: { scheduleId: scheduleId.toString() } }),
|
||||||
|
initialData,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: compatibility } = useQuery({
|
const { data: compatibility } = useQuery({
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,12 @@ import {
|
||||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import { parseError } from "~/client/lib/errors";
|
import { parseError } from "~/client/lib/errors";
|
||||||
import type { NotificationDestination } from "~/client/lib/types";
|
import type { NotificationDestination } from "~/client/lib/types";
|
||||||
|
import type { GetScheduleNotificationsResponse } from "~/client/api-client";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
scheduleId: number;
|
scheduleId: number;
|
||||||
destinations: NotificationDestination[];
|
destinations: NotificationDestination[];
|
||||||
|
initialData: GetScheduleNotificationsResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
type NotificationAssignment = {
|
type NotificationAssignment = {
|
||||||
|
|
@ -28,13 +30,14 @@ type NotificationAssignment = {
|
||||||
notifyOnFailure: boolean;
|
notifyOnFailure: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ScheduleNotificationsConfig = ({ scheduleId, destinations }: Props) => {
|
export const ScheduleNotificationsConfig = ({ scheduleId, destinations, initialData }: Props) => {
|
||||||
const [assignments, setAssignments] = useState<Map<number, NotificationAssignment>>(new Map());
|
const [assignments, setAssignments] = useState<Map<number, NotificationAssignment>>(new Map());
|
||||||
const [hasChanges, setHasChanges] = useState(false);
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
const [isAddingNew, setIsAddingNew] = useState(false);
|
const [isAddingNew, setIsAddingNew] = useState(false);
|
||||||
|
|
||||||
const { data: currentAssignments } = useQuery({
|
const { data: currentAssignments } = useQuery({
|
||||||
...getScheduleNotificationsOptions({ path: { scheduleId: scheduleId.toString() } }),
|
...getScheduleNotificationsOptions({ path: { scheduleId: scheduleId.toString() } }),
|
||||||
|
initialData,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateNotifications = useMutation({
|
const updateNotifications = useMutation({
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ export const SnapshotFileBrowser = (props: Props) => {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
prefetchFolder: (path) => {
|
prefetchFolder: (path) => {
|
||||||
queryClient.prefetchQuery(
|
void queryClient.prefetchQuery(
|
||||||
listSnapshotFilesOptions({
|
listSnapshotFilesOptions({
|
||||||
path: { id: repositoryId, snapshotId: snapshot.short_id },
|
path: { id: repositoryId, snapshotId: snapshot.short_id },
|
||||||
query: { path },
|
query: { path },
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,13 @@ import { ScheduleSummary } from "../components/schedule-summary";
|
||||||
import type { Route } from "./+types/backup-details";
|
import type { Route } from "./+types/backup-details";
|
||||||
import { SnapshotFileBrowser } from "../components/snapshot-file-browser";
|
import { SnapshotFileBrowser } from "../components/snapshot-file-browser";
|
||||||
import { SnapshotTimeline } from "../components/snapshot-timeline";
|
import { SnapshotTimeline } from "../components/snapshot-timeline";
|
||||||
import { getBackupSchedule, listNotificationDestinations, listRepositories } from "~/client/api-client";
|
import {
|
||||||
|
getBackupSchedule,
|
||||||
|
getScheduleMirrors,
|
||||||
|
getScheduleNotifications,
|
||||||
|
listNotificationDestinations,
|
||||||
|
listRepositories,
|
||||||
|
} from "~/client/api-client";
|
||||||
import { ScheduleNotificationsConfig } from "../components/schedule-notifications-config";
|
import { ScheduleNotificationsConfig } from "../components/schedule-notifications-config";
|
||||||
import { ScheduleMirrorsConfig } from "../components/schedule-mirrors-config";
|
import { ScheduleMirrorsConfig } from "../components/schedule-mirrors-config";
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
|
|
@ -53,13 +59,23 @@ export function meta(_: Route.MetaArgs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const clientLoader = async ({ params }: Route.LoaderArgs) => {
|
export const clientLoader = async ({ params }: Route.LoaderArgs) => {
|
||||||
const schedule = await getBackupSchedule({ path: { scheduleId: params.id } });
|
const [schedule, notifs, repos, scheduleNotifs, mirrors] = await Promise.all([
|
||||||
const notifs = await listNotificationDestinations();
|
getBackupSchedule({ path: { scheduleId: params.id } }),
|
||||||
const repos = await listRepositories();
|
listNotificationDestinations(),
|
||||||
|
listRepositories(),
|
||||||
|
getScheduleNotifications({ path: { scheduleId: params.id } }),
|
||||||
|
getScheduleMirrors({ path: { scheduleId: params.id } }),
|
||||||
|
]);
|
||||||
|
|
||||||
if (!schedule.data) return redirect("/backups");
|
if (!schedule.data) return redirect("/backups");
|
||||||
|
|
||||||
return { schedule: schedule.data, notifs: notifs.data, repos: repos.data };
|
return {
|
||||||
|
schedule: schedule.data,
|
||||||
|
notifs: notifs.data,
|
||||||
|
repos: repos.data,
|
||||||
|
scheduleNotifs: scheduleNotifs.data,
|
||||||
|
scheduleMirrors: mirrors.data,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ScheduleDetailsPage({ params, loaderData }: Route.ComponentProps) {
|
export default function ScheduleDetailsPage({ params, loaderData }: Route.ComponentProps) {
|
||||||
|
|
@ -120,7 +136,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
||||||
...deleteBackupScheduleMutation(),
|
...deleteBackupScheduleMutation(),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Backup schedule deleted successfully");
|
toast.success("Backup schedule deleted successfully");
|
||||||
navigate("/backups");
|
void navigate("/backups");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error("Failed to delete backup schedule", { description: parseError(error)?.message });
|
toast.error("Failed to delete backup schedule", { description: parseError(error)?.message });
|
||||||
|
|
@ -240,13 +256,18 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
||||||
schedule={schedule}
|
schedule={schedule}
|
||||||
/>
|
/>
|
||||||
<div className={cn({ hidden: !loaderData.notifs?.length })}>
|
<div className={cn({ hidden: !loaderData.notifs?.length })}>
|
||||||
<ScheduleNotificationsConfig scheduleId={schedule.id} destinations={loaderData.notifs ?? []} />
|
<ScheduleNotificationsConfig
|
||||||
|
scheduleId={schedule.id}
|
||||||
|
destinations={loaderData.notifs ?? []}
|
||||||
|
initialData={loaderData.scheduleNotifs ?? []}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={cn({ hidden: !loaderData.repos?.length || loaderData.repos.length < 2 })}>
|
<div className={cn({ hidden: !loaderData.repos?.length || loaderData.repos.length < 2 })}>
|
||||||
<ScheduleMirrorsConfig
|
<ScheduleMirrorsConfig
|
||||||
scheduleId={schedule.id}
|
scheduleId={schedule.id}
|
||||||
primaryRepositoryId={schedule.repositoryId}
|
primaryRepositoryId={schedule.repositoryId}
|
||||||
repositories={loaderData.repos ?? []}
|
repositories={loaderData.repos ?? []}
|
||||||
|
initialData={loaderData.scheduleMirrors ?? []}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<SnapshotTimeline
|
<SnapshotTimeline
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,7 @@ export function meta(_: Route.MetaArgs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const clientLoader = async () => {
|
export const clientLoader = async () => {
|
||||||
const volumes = await listVolumes();
|
const [volumes, repositories] = await Promise.all([listVolumes(), listRepositories()]);
|
||||||
const repositories = await listRepositories();
|
|
||||||
|
|
||||||
if (volumes.data && repositories.data) return { volumes: volumes.data, repositories: repositories.data };
|
if (volumes.data && repositories.data) return { volumes: volumes.data, repositories: repositories.data };
|
||||||
return { volumes: [], repositories: [] };
|
return { volumes: [], repositories: [] };
|
||||||
|
|
@ -59,7 +58,7 @@ export default function CreateBackup({ loaderData }: Route.ComponentProps) {
|
||||||
...createBackupScheduleMutation(),
|
...createBackupScheduleMutation(),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
toast.success("Backup job created successfully");
|
toast.success("Backup job created successfully");
|
||||||
navigate(`/backups/${data.id}`);
|
void navigate(`/backups/${data.id}`);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error("Failed to create backup job", {
|
toast.error("Failed to create backup job", {
|
||||||
|
|
|
||||||
|
|
@ -24,15 +24,20 @@ export function meta({ params }: Route.MetaArgs) {
|
||||||
|
|
||||||
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
|
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
|
||||||
const schedule = await getBackupSchedule({ path: { scheduleId: params.id } });
|
const schedule = await getBackupSchedule({ path: { scheduleId: params.id } });
|
||||||
|
|
||||||
if (!schedule.data) return redirect("/backups");
|
if (!schedule.data) return redirect("/backups");
|
||||||
|
|
||||||
const repositoryId = schedule.data.repository.id;
|
const [snapshot, repository] = await Promise.all([
|
||||||
const snapshot = await getSnapshotDetails({
|
getSnapshotDetails({
|
||||||
path: { id: repositoryId, snapshotId: params.snapshotId },
|
path: {
|
||||||
});
|
id: schedule.data.repositoryId,
|
||||||
if (!snapshot.data) return redirect(`/backups/${params.id}`);
|
snapshotId: params.snapshotId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getRepository({ path: { id: schedule.data.repositoryId } }),
|
||||||
|
]);
|
||||||
|
|
||||||
const repository = await getRepository({ path: { id: repositoryId } });
|
if (!snapshot.data) return redirect(`/backups/${params.id}`);
|
||||||
if (!repository.data) return redirect(`/backups/${params.id}`);
|
if (!repository.data) return redirect(`/backups/${params.id}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ export default function CreateNotification() {
|
||||||
...createNotificationDestinationMutation(),
|
...createNotificationDestinationMutation(),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Notification destination created successfully");
|
toast.success("Notification destination created successfully");
|
||||||
navigate(`/notifications`);
|
void navigate(`/notifications`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import { getNotificationDestination } from "~/client/api-client/sdk.gen";
|
||||||
import type { Route } from "./+types/notification-details";
|
import type { Route } from "./+types/notification-details";
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
import { Bell, Save, TestTube2, Trash2, X } from "lucide-react";
|
import { Bell, Save, TestTube2, Trash2 } from "lucide-react";
|
||||||
import { Alert, AlertDescription } from "~/client/components/ui/alert";
|
import { Alert, AlertDescription } from "~/client/components/ui/alert";
|
||||||
import { CreateNotificationForm, type NotificationFormValues } from "../components/create-notification-form";
|
import { CreateNotificationForm, type NotificationFormValues } from "../components/create-notification-form";
|
||||||
|
|
||||||
|
|
@ -66,7 +66,7 @@ export default function NotificationDetailsPage({ loaderData }: Route.ComponentP
|
||||||
...deleteNotificationDestinationMutation(),
|
...deleteNotificationDestinationMutation(),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Notification destination deleted successfully");
|
toast.success("Notification destination deleted successfully");
|
||||||
navigate("/notifications");
|
void navigate("/notifications");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error("Failed to delete notification destination", {
|
toast.error("Failed to delete notification destination", {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { UseFormReturn } from "react-hook-form";
|
import type { UseFormReturn } from "react-hook-form";
|
||||||
import { Check, Pencil, X, AlertTriangle } from "lucide-react";
|
import { Check, Pencil, X, AlertTriangle } from "lucide-react";
|
||||||
|
import { REPOSITORY_BASE } from "~/client/lib/constants";
|
||||||
import { Button } from "../../../../components/ui/button";
|
import { Button } from "../../../../components/ui/button";
|
||||||
import { FormItem, FormLabel, FormDescription } from "../../../../components/ui/form";
|
import { FormItem, FormLabel, FormDescription } from "../../../../components/ui/form";
|
||||||
import { DirectoryBrowser } from "../../../../components/directory-browser";
|
import { DirectoryBrowser } from "../../../../components/directory-browser";
|
||||||
|
|
@ -30,7 +31,7 @@ export const LocalRepositoryForm = ({ form }: Props) => {
|
||||||
<FormLabel>Repository Directory</FormLabel>
|
<FormLabel>Repository Directory</FormLabel>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex-1 text-sm font-mono bg-muted px-3 py-2 rounded-md border">
|
<div className="flex-1 text-sm font-mono bg-muted px-3 py-2 rounded-md border">
|
||||||
{form.watch("path") || "/var/lib/zerobyte/repositories"}
|
{form.watch("path") || REPOSITORY_BASE}
|
||||||
</div>
|
</div>
|
||||||
<Button type="button" variant="outline" onClick={() => setShowPathWarning(true)} size="sm">
|
<Button type="button" variant="outline" onClick={() => setShowPathWarning(true)} size="sm">
|
||||||
<Pencil className="h-4 w-4 mr-2" />
|
<Pencil className="h-4 w-4 mr-2" />
|
||||||
|
|
@ -53,8 +54,8 @@ export const LocalRepositoryForm = ({ form }: Props) => {
|
||||||
If the path is not a host mount, you will lose your repository data when the container restarts.
|
If the path is not a host mount, you will lose your repository data when the container restarts.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
The default path <code className="bg-muted px-1 rounded">/var/lib/zerobyte/repositories</code> is
|
The default path <code className="bg-muted px-1 rounded">{REPOSITORY_BASE}</code> is safe to use if you
|
||||||
already mounted from the host and is safe to use.
|
followed the recommended Docker Compose setup.
|
||||||
</p>
|
</p>
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
|
|
@ -83,7 +84,7 @@ export const LocalRepositoryForm = ({ form }: Props) => {
|
||||||
<div className="py-4">
|
<div className="py-4">
|
||||||
<DirectoryBrowser
|
<DirectoryBrowser
|
||||||
onSelectPath={(path) => form.setValue("path", path)}
|
onSelectPath={(path) => form.setValue("path", path)}
|
||||||
selectedPath={form.watch("path") || "/var/lib/zerobyte/repositories"}
|
selectedPath={form.watch("path") || REPOSITORY_BASE}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ export default function CreateRepository() {
|
||||||
...createRepositoryMutation(),
|
...createRepositoryMutation(),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
toast.success("Repository created successfully");
|
toast.success("Repository created successfully");
|
||||||
navigate(`/repositories/${data.repository.shortId}`);
|
void navigate(`/repositories/${data.repository.shortId}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -67,14 +67,14 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
queryClient.prefetchQuery(listSnapshotsOptions({ path: { id: data.id } }));
|
void queryClient.prefetchQuery(listSnapshotsOptions({ path: { id: data.id } }));
|
||||||
}, [queryClient, data.id]);
|
}, [queryClient, data.id]);
|
||||||
|
|
||||||
const deleteRepo = useMutation({
|
const deleteRepo = useMutation({
|
||||||
...deleteRepositoryMutation(),
|
...deleteRepositoryMutation(),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Repository deleted successfully");
|
toast.success("Repository deleted successfully");
|
||||||
navigate("/repositories");
|
void navigate("/repositories");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error("Failed to delete repository", {
|
toast.error("Failed to delete repository", {
|
||||||
|
|
|
||||||
|
|
@ -23,12 +23,12 @@ export function meta({ params }: Route.MetaArgs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
|
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
|
||||||
const snapshot = await getSnapshotDetails({
|
const [snapshot, repository] = await Promise.all([
|
||||||
path: { id: params.id, snapshotId: params.snapshotId },
|
getSnapshotDetails({ path: { id: params.id, snapshotId: params.snapshotId } }),
|
||||||
});
|
getRepository({ path: { id: params.id } }),
|
||||||
if (!snapshot.data) return redirect("/repositories");
|
]);
|
||||||
|
|
||||||
const repository = await getRepository({ path: { id: params.id } });
|
if (!snapshot.data) return redirect("/repositories");
|
||||||
if (!repository.data) return redirect(`/repositories`);
|
if (!repository.data) return redirect(`/repositories`);
|
||||||
|
|
||||||
return { snapshot: snapshot.data, id: params.id, repository: repository.data, snapshotId: params.snapshotId };
|
return { snapshot: snapshot.data, id: params.id, repository: repository.data, snapshotId: params.snapshotId };
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,14 @@ export function meta({ params }: Route.MetaArgs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
|
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
|
||||||
const snapshot = getSnapshotDetails({
|
const [snapshot, repository] = await Promise.all([
|
||||||
|
getSnapshotDetails({
|
||||||
path: { id: params.id, snapshotId: params.snapshotId },
|
path: { id: params.id, snapshotId: params.snapshotId },
|
||||||
});
|
}),
|
||||||
|
getRepository({ path: { id: params.id } }),
|
||||||
|
]);
|
||||||
|
|
||||||
const repository = await getRepository({ path: { id: params.id } });
|
if (!snapshot.data) return redirect(`/repositories/${params.id}`);
|
||||||
if (!repository.data) return redirect("/repositories");
|
if (!repository.data) return redirect("/repositories");
|
||||||
|
|
||||||
return { snapshot: snapshot, repository: repository.data };
|
return { snapshot: snapshot, repository: repository.data };
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "~/client/components/ui/alert-dialog";
|
} from "~/client/components/ui/alert-dialog";
|
||||||
import type { Repository } from "~/client/lib/types";
|
import type { Repository } from "~/client/lib/types";
|
||||||
|
import { REPOSITORY_BASE } from "~/client/lib/constants";
|
||||||
import { updateRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
import { updateRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import type { CompressionMode, RepositoryConfig } from "~/schemas/restic";
|
import type { CompressionMode, RepositoryConfig } from "~/schemas/restic";
|
||||||
|
|
||||||
|
|
@ -25,6 +26,18 @@ type Props = {
|
||||||
repository: Repository;
|
repository: Repository;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getEffectiveLocalPath = (repository: Repository): string | null => {
|
||||||
|
if (repository.type !== "local") return null;
|
||||||
|
const config = repository.config as { name: string; path?: string; isExistingRepository?: boolean };
|
||||||
|
|
||||||
|
if (config.isExistingRepository) {
|
||||||
|
return config.path ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const basePath = config.path || REPOSITORY_BASE;
|
||||||
|
return `${basePath}/${config.name}`;
|
||||||
|
};
|
||||||
|
|
||||||
export const RepositoryInfoTabContent = ({ repository }: Props) => {
|
export const RepositoryInfoTabContent = ({ repository }: Props) => {
|
||||||
const [name, setName] = useState(repository.name);
|
const [name, setName] = useState(repository.name);
|
||||||
const [compressionMode, setCompressionMode] = useState<CompressionMode>(
|
const [compressionMode, setCompressionMode] = useState<CompressionMode>(
|
||||||
|
|
@ -32,6 +45,8 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
|
||||||
);
|
);
|
||||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||||
|
|
||||||
|
const effectiveLocalPath = getEffectiveLocalPath(repository);
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
...updateRepositoryMutation(),
|
...updateRepositoryMutation(),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
|
@ -108,6 +123,12 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
|
||||||
<div className="text-sm font-medium text-muted-foreground">Status</div>
|
<div className="text-sm font-medium text-muted-foreground">Status</div>
|
||||||
<p className="mt-1 text-sm">{repository.status || "unknown"}</p>
|
<p className="mt-1 text-sm">{repository.status || "unknown"}</p>
|
||||||
</div>
|
</div>
|
||||||
|
{effectiveLocalPath && (
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">Effective Local Path</div>
|
||||||
|
<p className="mt-1 text-sm font-mono">{effectiveLocalPath}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-muted-foreground">Created at</div>
|
<div className="text-sm font-medium text-muted-foreground">Created at</div>
|
||||||
<p className="mt-1 text-sm">{new Date(repository.createdAt).toLocaleString()}</p>
|
<p className="mt-1 text-sm">{new Date(repository.createdAt).toLocaleString()}</p>
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,8 @@ import { Input } from "~/client/components/ui/input";
|
||||||
import { Label } from "~/client/components/ui/label";
|
import { Label } from "~/client/components/ui/label";
|
||||||
import { appContext } from "~/context";
|
import { appContext } from "~/context";
|
||||||
import type { Route } from "./+types/settings";
|
import type { Route } from "./+types/settings";
|
||||||
import {
|
import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
changePasswordMutation,
|
import { authClient } from "~/client/lib/auth-client";
|
||||||
downloadResticPasswordMutation,
|
|
||||||
logoutMutation,
|
|
||||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
|
||||||
|
|
||||||
export const handle = {
|
export const handle = {
|
||||||
breadcrumb: () => [{ label: "Settings" }],
|
breadcrumb: () => [{ label: "Settings" }],
|
||||||
|
|
@ -49,31 +46,22 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false);
|
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false);
|
||||||
const [downloadPassword, setDownloadPassword] = useState("");
|
const [downloadPassword, setDownloadPassword] = useState("");
|
||||||
|
const [isChangingPassword, setIsChangingPassword] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const logout = useMutation({
|
const handleLogout = async () => {
|
||||||
...logoutMutation(),
|
await authClient.signOut({
|
||||||
|
fetchOptions: {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
navigate("/login", { replace: true });
|
void navigate("/login", { replace: true });
|
||||||
},
|
},
|
||||||
});
|
onError: ({ error }) => {
|
||||||
|
console.error(error);
|
||||||
const changePassword = useMutation({
|
toast.error("Logout failed", { description: error.message });
|
||||||
...changePasswordMutation(),
|
},
|
||||||
onSuccess: (data) => {
|
|
||||||
if (data.success) {
|
|
||||||
toast.success("Password changed successfully. You will be logged out.");
|
|
||||||
setTimeout(() => {
|
|
||||||
logout.mutate({});
|
|
||||||
}, 1500);
|
|
||||||
} else {
|
|
||||||
toast.error("Failed to change password", { description: data.message });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error("Failed to change password", { description: error.message });
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const downloadResticPassword = useMutation({
|
const downloadResticPassword = useMutation({
|
||||||
...downloadResticPasswordMutation(),
|
...downloadResticPasswordMutation(),
|
||||||
|
|
@ -97,7 +85,7 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleChangePassword = (e: React.FormEvent) => {
|
const handleChangePassword = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (newPassword !== confirmPassword) {
|
if (newPassword !== confirmPassword) {
|
||||||
|
|
@ -110,10 +98,26 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
changePassword.mutate({
|
await authClient.changePassword({
|
||||||
body: {
|
|
||||||
currentPassword,
|
|
||||||
newPassword,
|
newPassword,
|
||||||
|
currentPassword: currentPassword,
|
||||||
|
revokeOtherSessions: true,
|
||||||
|
fetchOptions: {
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Password changed successfully. You will be logged out.");
|
||||||
|
setTimeout(() => {
|
||||||
|
void handleLogout();
|
||||||
|
}, 1500);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("Failed to change password", { description: error.error.message });
|
||||||
|
},
|
||||||
|
onRequest: () => {
|
||||||
|
setIsChangingPassword(true);
|
||||||
|
},
|
||||||
|
onResponse: () => {
|
||||||
|
setIsChangingPassword(false);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -194,7 +198,7 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
|
||||||
minLength={8}
|
minLength={8}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" loading={changePassword.isPending} className="mt-4">
|
<Button type="submit" loading={isChangingPassword} className="mt-4">
|
||||||
<KeyRound className="h-4 w-4 mr-2" />
|
<KeyRound className="h-4 w-4 mr-2" />
|
||||||
Change Password
|
Change Password
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ export default function CreateVolume() {
|
||||||
...createVolumeMutation(),
|
...createVolumeMutation(),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
toast.success("Volume created successfully");
|
toast.success("Volume created successfully");
|
||||||
navigate(`/volumes/${data.name}`);
|
void navigate(`/volumes/${data.name}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
|
||||||
...deleteVolumeMutation(),
|
...deleteVolumeMutation(),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Volume deleted successfully");
|
toast.success("Volume deleted successfully");
|
||||||
navigate("/volumes");
|
void navigate("/volumes");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error("Failed to delete volume", {
|
toast.error("Failed to delete volume", {
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
|
||||||
setPendingValues(null);
|
setPendingValues(null);
|
||||||
|
|
||||||
if (data.name !== volume.name) {
|
if (data.name !== volume.name) {
|
||||||
navigate(`/volumes/${data.name}`);
|
void navigate(`/volumes/${data.name}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
import { createContext } from "react-router";
|
import { createContext } from "react-router";
|
||||||
import type { User } from "./client/lib/types";
|
|
||||||
|
type User = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
hasDownloadedResticPassword: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
type AppContext = {
|
type AppContext = {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
|
|
|
||||||
65
app/drizzle/0029_boring_luke_cage.sql
Normal file
65
app/drizzle/0029_boring_luke_cage.sql
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
CREATE TABLE `account` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`account_id` text NOT NULL,
|
||||||
|
`provider_id` text NOT NULL,
|
||||||
|
`user_id` text NOT NULL,
|
||||||
|
`access_token` text,
|
||||||
|
`refresh_token` text,
|
||||||
|
`id_token` text,
|
||||||
|
`access_token_expires_at` integer,
|
||||||
|
`refresh_token_expires_at` integer,
|
||||||
|
`scope` text,
|
||||||
|
`password` text,
|
||||||
|
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users_table`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `account_userId_idx` ON `account` (`user_id`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `verification` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`identifier` text NOT NULL,
|
||||||
|
`value` text NOT NULL,
|
||||||
|
`expires_at` integer NOT NULL,
|
||||||
|
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `verification_identifier_idx` ON `verification` (`identifier`);--> statement-breakpoint
|
||||||
|
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||||
|
CREATE TABLE `__new_sessions_table` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`user_id` text NOT NULL,
|
||||||
|
`token` text NOT NULL,
|
||||||
|
`expires_at` integer NOT NULL,
|
||||||
|
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||||
|
`ip_address` text,
|
||||||
|
`user_agent` text,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users_table`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
DROP TABLE `sessions_table`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `__new_sessions_table` RENAME TO `sessions_table`;--> statement-breakpoint
|
||||||
|
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `sessions_table_token_unique` ON `sessions_table` (`token`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `__new_users_table` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`username` text NOT NULL,
|
||||||
|
`password_hash` text,
|
||||||
|
`has_downloaded_restic_password` integer DEFAULT false NOT NULL,
|
||||||
|
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`email` text NOT NULL,
|
||||||
|
`email_verified` integer DEFAULT false NOT NULL,
|
||||||
|
`image` text,
|
||||||
|
`display_username` text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `__new_users_table`("id", "username", "password_hash", "has_downloaded_restic_password", "created_at", "updated_at", "name", "email", "email_verified", "image", "display_username") SELECT "id", "username", "password_hash", "has_downloaded_restic_password", "created_at", "updated_at", "username", "username" || '@placeholder.local', false, "image", "username" FROM `users_table`;--> statement-breakpoint
|
||||||
|
|
||||||
|
DROP TABLE `users_table`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `__new_users_table` RENAME TO `users_table`;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `users_table_username_unique` ON `users_table` (`username`);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `users_table_email_unique` ON `users_table` (`email`);
|
||||||
2
app/drizzle/0030_lower-trim-username.sql
Normal file
2
app/drizzle/0030_lower-trim-username.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- Custom SQL migration file, put your code below! --
|
||||||
|
UPDATE users_table SET username = LOWER(TRIM(username));
|
||||||
1113
app/drizzle/meta/0029_snapshot.json
Normal file
1113
app/drizzle/meta/0029_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
1113
app/drizzle/meta/0030_snapshot.json
Normal file
1113
app/drizzle/meta/0030_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -204,6 +204,20 @@
|
||||||
"when": 1766778162985,
|
"when": 1766778162985,
|
||||||
"tag": "0028_third_amazoness",
|
"tag": "0028_third_amazoness",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 29,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1767819883495,
|
||||||
|
"tag": "0029_boring_luke_cage",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 30,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1767821088612,
|
||||||
|
"tag": "0030_lower-trim-username",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
50
app/lib/auth-middlewares/convert-legacy-user.ts
Normal file
50
app/lib/auth-middlewares/convert-legacy-user.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { hashPassword } from "better-auth/crypto";
|
||||||
|
import { and, eq, ne } from "drizzle-orm";
|
||||||
|
import { db } from "~/server/db/db";
|
||||||
|
import { account, usersTable } from "~/server/db/schema";
|
||||||
|
import type { AuthMiddlewareContext } from "../auth";
|
||||||
|
|
||||||
|
export const convertLegacyUserOnFirstLogin = async (ctx: AuthMiddlewareContext) => {
|
||||||
|
const { path, body } = ctx;
|
||||||
|
|
||||||
|
if (path !== "/sign-in/username") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyUser = await db.query.usersTable.findFirst({
|
||||||
|
where: and(eq(usersTable.username, body.username.trim().toLowerCase()), ne(usersTable.passwordHash, "")),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (legacyUser) {
|
||||||
|
const isValid = await Bun.password.verify(body.password, legacyUser.passwordHash ?? "");
|
||||||
|
|
||||||
|
if (isValid) {
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
const newUserId = crypto.randomUUID();
|
||||||
|
const accountId = crypto.randomUUID();
|
||||||
|
|
||||||
|
await tx.delete(usersTable).where(eq(usersTable.id, legacyUser.id));
|
||||||
|
|
||||||
|
await tx.insert(usersTable).values({
|
||||||
|
id: newUserId,
|
||||||
|
username: legacyUser.username,
|
||||||
|
email: legacyUser.email,
|
||||||
|
name: legacyUser.name,
|
||||||
|
hasDownloadedResticPassword: legacyUser.hasDownloadedResticPassword,
|
||||||
|
emailVerified: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.insert(account).values({
|
||||||
|
id: accountId,
|
||||||
|
providerId: "credential",
|
||||||
|
accountId: legacyUser.username,
|
||||||
|
userId: newUserId,
|
||||||
|
password: await hashPassword(body.password),
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error("Invalid credentials");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
17
app/lib/auth-middlewares/only-one-user.ts
Normal file
17
app/lib/auth-middlewares/only-one-user.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { db } from "~/server/db/db";
|
||||||
|
import type { AuthMiddlewareContext } from "../auth";
|
||||||
|
import { logger } from "~/server/utils/logger";
|
||||||
|
|
||||||
|
export const ensureOnlyOneUser = async (ctx: AuthMiddlewareContext) => {
|
||||||
|
const { path } = ctx;
|
||||||
|
|
||||||
|
if (path !== "/sign-up/email") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUser = await db.query.usersTable.findFirst();
|
||||||
|
if (existingUser) {
|
||||||
|
logger.error("Attempt to create a second administrator account blocked.");
|
||||||
|
throw new Error("An administrator account already exists");
|
||||||
|
}
|
||||||
|
};
|
||||||
49
app/lib/auth.ts
Normal file
49
app/lib/auth.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import {
|
||||||
|
betterAuth,
|
||||||
|
type AuthContext,
|
||||||
|
type BetterAuthOptions,
|
||||||
|
type MiddlewareContext,
|
||||||
|
type MiddlewareOptions,
|
||||||
|
} from "better-auth";
|
||||||
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
|
import { createAuthMiddleware, username } from "better-auth/plugins";
|
||||||
|
import { convertLegacyUserOnFirstLogin } from "./auth-middlewares/convert-legacy-user";
|
||||||
|
import { cryptoUtils } from "~/server/utils/crypto";
|
||||||
|
import { db } from "~/server/db/db";
|
||||||
|
import { ensureOnlyOneUser } from "./auth-middlewares/only-one-user";
|
||||||
|
|
||||||
|
export type AuthMiddlewareContext = MiddlewareContext<MiddlewareOptions, AuthContext<BetterAuthOptions>>;
|
||||||
|
|
||||||
|
export const auth = betterAuth({
|
||||||
|
secret: await cryptoUtils.deriveSecret("better-auth"),
|
||||||
|
hooks: {
|
||||||
|
before: createAuthMiddleware(async (ctx) => {
|
||||||
|
await ensureOnlyOneUser(ctx);
|
||||||
|
await convertLegacyUserOnFirstLogin(ctx);
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
database: drizzleAdapter(db, {
|
||||||
|
provider: "sqlite",
|
||||||
|
}),
|
||||||
|
emailAndPassword: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
modelName: "usersTable",
|
||||||
|
additionalFields: {
|
||||||
|
username: {
|
||||||
|
type: "string",
|
||||||
|
returned: true,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
hasDownloadedResticPassword: {
|
||||||
|
type: "boolean",
|
||||||
|
returned: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
modelName: "sessionsTable",
|
||||||
|
},
|
||||||
|
plugins: [username({})],
|
||||||
|
});
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
import { redirect, type MiddlewareFunction } from "react-router";
|
import { redirect, type MiddlewareFunction } from "react-router";
|
||||||
import { getMe, getStatus } from "~/client/api-client";
|
import { getStatus } from "~/client/api-client";
|
||||||
|
import { authClient } from "~/client/lib/auth-client";
|
||||||
import { appContext } from "~/context";
|
import { appContext } from "~/context";
|
||||||
|
|
||||||
export const authMiddleware: MiddlewareFunction = async ({ context, request }) => {
|
export const authMiddleware: MiddlewareFunction = async ({ context, request }) => {
|
||||||
const session = await getMe();
|
const { data: session } = await authClient.getSession();
|
||||||
|
|
||||||
const isAuthRoute = ["/login", "/onboarding"].includes(new URL(request.url).pathname);
|
const isAuthRoute = ["/login", "/onboarding"].includes(new URL(request.url).pathname);
|
||||||
|
|
||||||
if (!session.data?.user?.id && !isAuthRoute) {
|
if (!session?.user?.id && !isAuthRoute) {
|
||||||
const status = await getStatus();
|
const status = await getStatus();
|
||||||
if (!status.data?.hasUsers) {
|
if (!status.data?.hasUsers) {
|
||||||
throw redirect("/onboarding");
|
throw redirect("/onboarding");
|
||||||
|
|
@ -16,8 +17,8 @@ export const authMiddleware: MiddlewareFunction = async ({ context, request }) =
|
||||||
throw redirect("/login");
|
throw redirect("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.data?.user?.id) {
|
if (session?.user?.id) {
|
||||||
context.set(appContext, { user: session.data.user, hasUsers: true });
|
context.set(appContext, { user: session.user, hasUsers: true });
|
||||||
|
|
||||||
if (isAuthRoute) {
|
if (isAuthRoute) {
|
||||||
throw redirect("/");
|
throw redirect("/");
|
||||||
|
|
|
||||||
|
|
@ -27,11 +27,11 @@ export const links: Route.LinksFunction = () => [
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
mutationCache: new MutationCache({
|
mutationCache: new MutationCache({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries();
|
void queryClient.invalidateQueries();
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error("Mutation error:", error);
|
console.error("Mutation error:", error);
|
||||||
queryClient.invalidateQueries();
|
void queryClient.invalidateQueries();
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { notificationsController } from "./modules/notifications/notifications.c
|
||||||
import { handleServiceError } from "./utils/errors";
|
import { handleServiceError } from "./utils/errors";
|
||||||
import { logger } from "./utils/logger";
|
import { logger } from "./utils/logger";
|
||||||
import { config } from "./core/config";
|
import { config } from "./core/config";
|
||||||
|
import { auth } from "~/lib/auth";
|
||||||
|
|
||||||
export const generalDescriptor = (app: Hono) =>
|
export const generalDescriptor = (app: Hono) =>
|
||||||
openAPIRouteHandler(app, {
|
openAPIRouteHandler(app, {
|
||||||
|
|
@ -62,6 +63,7 @@ export const createApp = () => {
|
||||||
.route("/api/v1/system", systemController)
|
.route("/api/v1/system", systemController)
|
||||||
.route("/api/v1/events", eventsController);
|
.route("/api/v1/events", eventsController);
|
||||||
|
|
||||||
|
app.on(["POST", "GET"], "/api/auth/*", (c) => auth.handler(c.req.raw));
|
||||||
app.get("/api/v1/openapi.json", generalDescriptor(app));
|
app.get("/api/v1/openapi.json", generalDescriptor(app));
|
||||||
app.get("/api/v1/docs", requireAuth, scalarDescriptor);
|
app.get("/api/v1/docs", requireAuth, scalarDescriptor);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,45 @@
|
||||||
import { Command } from "commander";
|
|
||||||
import { password, select } from "@inquirer/prompts";
|
import { password, select } from "@inquirer/prompts";
|
||||||
import { eq } from "drizzle-orm";
|
import { hashPassword } from "better-auth/crypto";
|
||||||
|
import { Command } from "commander";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { toMessage } from "~/server/utils/errors";
|
||||||
import { db } from "../../db/db";
|
import { db } from "../../db/db";
|
||||||
import { sessionsTable, usersTable } from "../../db/schema";
|
import { account, sessionsTable, usersTable } from "../../db/schema";
|
||||||
|
|
||||||
const listUsers = () => {
|
const listUsers = () => {
|
||||||
return db.select({ id: usersTable.id, username: usersTable.username }).from(usersTable);
|
return db
|
||||||
|
.select({ id: usersTable.id, username: usersTable.username })
|
||||||
|
.from(usersTable);
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetPassword = async (username: string, newPassword: string) => {
|
const resetPassword = async (username: string, newPassword: string) => {
|
||||||
const [user] = await db.select().from(usersTable).where(eq(usersTable.username, username));
|
const [user] = await db
|
||||||
|
.select()
|
||||||
|
.from(usersTable)
|
||||||
|
.where(eq(usersTable.username, username));
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error(`User "${username}" not found`);
|
throw new Error(`User "${username}" not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newPasswordHash = await Bun.password.hash(newPassword, {
|
const newPasswordHash = await hashPassword(newPassword);
|
||||||
algorithm: "argon2id",
|
|
||||||
memoryCost: 19456,
|
|
||||||
timeCost: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
await tx.update(usersTable).set({ passwordHash: newPasswordHash }).where(eq(usersTable.id, user.id));
|
await tx
|
||||||
|
.update(account)
|
||||||
|
.set({ password: newPasswordHash })
|
||||||
|
.where(
|
||||||
|
and(eq(account.userId, user.id), eq(account.providerId, "credential")),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (user.passwordHash) {
|
||||||
|
const legacyHash = await Bun.password.hash(newPassword);
|
||||||
|
await tx
|
||||||
|
.update(usersTable)
|
||||||
|
.set({ passwordHash: legacyHash })
|
||||||
|
.where(eq(usersTable.id, user.id));
|
||||||
|
}
|
||||||
|
|
||||||
await tx.delete(sessionsTable).where(eq(sessionsTable.userId, user.id));
|
await tx.delete(sessionsTable).where(eq(sessionsTable.userId, user.id));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -42,7 +59,9 @@ export const resetPasswordCommand = new Command("reset-password")
|
||||||
|
|
||||||
if (users.length === 0) {
|
if (users.length === 0) {
|
||||||
console.error("❌ No users found in the database.");
|
console.error("❌ No users found in the database.");
|
||||||
console.log(" Please create a user first by starting the application.");
|
console.log(
|
||||||
|
" Please create a user first by starting the application.",
|
||||||
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,10 +99,12 @@ export const resetPasswordCommand = new Command("reset-password")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await resetPassword(username, newPassword);
|
await resetPassword(username, newPassword);
|
||||||
console.log(`\n✅ Password for user "${username}" has been reset successfully.`);
|
console.log(
|
||||||
|
`\n✅ Password for user "${username}" has been reset successfully.`,
|
||||||
|
);
|
||||||
console.log(" All existing sessions have been invalidated.");
|
console.log(" All existing sessions have been invalidated.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`\n❌ Failed to reset password: ${error instanceof Error ? error.message : "Unknown error"}`);
|
console.error(`\n❌ Failed to reset password: ${toMessage(error)}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ class SchedulerClass {
|
||||||
|
|
||||||
async stop() {
|
async stop() {
|
||||||
for (const task of this.tasks) {
|
for (const task of this.tasks) {
|
||||||
task.stop();
|
await task.stop();
|
||||||
}
|
}
|
||||||
this.tasks = [];
|
this.tasks = [];
|
||||||
logger.info("Scheduler stopped");
|
logger.info("Scheduler stopped");
|
||||||
|
|
@ -42,7 +42,7 @@ class SchedulerClass {
|
||||||
|
|
||||||
async clear() {
|
async clear() {
|
||||||
for (const task of this.tasks) {
|
for (const task of this.tasks) {
|
||||||
task.destroy();
|
await task.destroy();
|
||||||
}
|
}
|
||||||
this.tasks = [];
|
this.tasks = [];
|
||||||
logger.info("Scheduler cleared all tasks");
|
logger.info("Scheduler cleared all tasks");
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,51 @@
|
||||||
import "dotenv/config";
|
|
||||||
import { Database } from "bun:sqlite";
|
import { Database } from "bun:sqlite";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||||
import { DATABASE_URL } from "../core/constants";
|
import { DATABASE_URL } from "../core/constants";
|
||||||
import * as schema from "./schema";
|
import fs from "node:fs";
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import { config } from "../core/config";
|
import { config } from "../core/config";
|
||||||
|
import type * as schemaTypes from "./schema";
|
||||||
|
|
||||||
await fs.mkdir(path.dirname(DATABASE_URL), { recursive: true });
|
/**
|
||||||
|
* TODO: try to remove this if moving away from react-router.
|
||||||
|
* The rr vite plugin doesn't let us customize the chunk names
|
||||||
|
* to isolate the db initialization code from the rest of the server code.
|
||||||
|
*/
|
||||||
|
let _sqlite: Database | undefined;
|
||||||
|
let _db: ReturnType<typeof drizzle<typeof schemaTypes>> | undefined;
|
||||||
|
let _schema: typeof schemaTypes | undefined;
|
||||||
|
|
||||||
const sqlite = new Database(DATABASE_URL);
|
/**
|
||||||
export const db = drizzle({ client: sqlite, schema });
|
* Sets the database schema. This must be called before any database operations.
|
||||||
|
*/
|
||||||
|
export const setSchema = (schema: typeof schemaTypes) => {
|
||||||
|
_schema = schema;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initDb = () => {
|
||||||
|
if (!_schema) {
|
||||||
|
throw new Error("Database schema not set. Call setSchema() before accessing the database.");
|
||||||
|
}
|
||||||
|
fs.mkdirSync(path.dirname(DATABASE_URL), { recursive: true });
|
||||||
|
_sqlite = new Database(DATABASE_URL);
|
||||||
|
return drizzle({ client: _sqlite, schema: _schema });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database instance (Proxy for lazy initialization)
|
||||||
|
*/
|
||||||
|
export const db = new Proxy(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
get(_, prop, receiver) {
|
||||||
|
if (!_db) {
|
||||||
|
_db = initDb();
|
||||||
|
}
|
||||||
|
return Reflect.get(_db, prop, receiver);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
) as ReturnType<typeof drizzle<typeof schemaTypes>>;
|
||||||
|
|
||||||
export const runDbMigrations = () => {
|
export const runDbMigrations = () => {
|
||||||
let migrationsFolder: string;
|
let migrationsFolder: string;
|
||||||
|
|
@ -26,5 +60,9 @@ export const runDbMigrations = () => {
|
||||||
|
|
||||||
migrate(db, { migrationsFolder });
|
migrate(db, { migrationsFolder });
|
||||||
|
|
||||||
sqlite.run("PRAGMA foreign_keys = ON;");
|
if (!_sqlite) {
|
||||||
|
throw new Error("Database not initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
_sqlite.run("PRAGMA foreign_keys = ON;");
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { relations, sql } from "drizzle-orm";
|
import { relations, sql } from "drizzle-orm";
|
||||||
import { int, integer, sqliteTable, text, primaryKey, unique } from "drizzle-orm/sqlite-core";
|
import { index, int, integer, sqliteTable, text, primaryKey, unique } from "drizzle-orm/sqlite-core";
|
||||||
import type { CompressionMode, RepositoryBackend, repositoryConfigSchema, RepositoryStatus } from "~/schemas/restic";
|
import type { CompressionMode, RepositoryBackend, repositoryConfigSchema, RepositoryStatus } from "~/schemas/restic";
|
||||||
import type { BackendStatus, BackendType, volumeConfigSchema } from "~/schemas/volumes";
|
import type { BackendStatus, BackendType, volumeConfigSchema } from "~/schemas/volumes";
|
||||||
import type { NotificationType, notificationConfigSchema } from "~/schemas/notifications";
|
import type { NotificationType, notificationConfigSchema } from "~/schemas/notifications";
|
||||||
|
|
@ -14,9 +14,16 @@ export const volumesTable = sqliteTable("volumes_table", {
|
||||||
type: text().$type<BackendType>().notNull(),
|
type: text().$type<BackendType>().notNull(),
|
||||||
status: text().$type<BackendStatus>().notNull().default("unmounted"),
|
status: text().$type<BackendStatus>().notNull().default("unmounted"),
|
||||||
lastError: text("last_error"),
|
lastError: text("last_error"),
|
||||||
lastHealthCheck: integer("last_health_check", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
lastHealthCheck: integer("last_health_check", { mode: "number" })
|
||||||
createdAt: integer("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
.notNull()
|
||||||
updatedAt: integer("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
.default(sql`(unixepoch() * 1000)`),
|
||||||
|
createdAt: integer("created_at", { mode: "number" })
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(unixepoch() * 1000)`),
|
||||||
|
updatedAt: integer("updated_at", { mode: "number" })
|
||||||
|
.notNull()
|
||||||
|
.$onUpdate(() => Date.now())
|
||||||
|
.default(sql`(unixepoch() * 1000)`),
|
||||||
config: text("config", { mode: "json" }).$type<typeof volumeConfigSchema.inferOut>().notNull(),
|
config: text("config", { mode: "json" }).$type<typeof volumeConfigSchema.inferOut>().notNull(),
|
||||||
autoRemount: int("auto_remount", { mode: "boolean" }).notNull().default(true),
|
autoRemount: int("auto_remount", { mode: "boolean" }).notNull().default(true),
|
||||||
});
|
});
|
||||||
|
|
@ -27,24 +34,112 @@ export type VolumeInsert = typeof volumesTable.$inferInsert;
|
||||||
* Users Table
|
* Users Table
|
||||||
*/
|
*/
|
||||||
export const usersTable = sqliteTable("users_table", {
|
export const usersTable = sqliteTable("users_table", {
|
||||||
id: int().primaryKey({ autoIncrement: true }),
|
id: text("id").primaryKey(),
|
||||||
username: text().notNull().unique(),
|
username: text().notNull().unique(),
|
||||||
passwordHash: text("password_hash").notNull(),
|
passwordHash: text("password_hash"),
|
||||||
hasDownloadedResticPassword: int("has_downloaded_restic_password", { mode: "boolean" }).notNull().default(false),
|
hasDownloadedResticPassword: int("has_downloaded_restic_password", { mode: "boolean" }).notNull().default(false),
|
||||||
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
createdAt: int("created_at", { mode: "timestamp_ms" })
|
||||||
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
.notNull()
|
||||||
|
.default(sql`(unixepoch() * 1000)`),
|
||||||
|
updatedAt: int("updated_at", { mode: "timestamp_ms" })
|
||||||
|
.notNull()
|
||||||
|
.$onUpdate(() => new Date())
|
||||||
|
.default(sql`(unixepoch() * 1000)`),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
email: text("email").notNull().unique(),
|
||||||
|
emailVerified: integer("email_verified", { mode: "boolean" }).default(false).notNull(),
|
||||||
|
image: text("image"),
|
||||||
|
displayUsername: text("display_username"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type User = typeof usersTable.$inferSelect;
|
export type User = typeof usersTable.$inferSelect;
|
||||||
export const sessionsTable = sqliteTable("sessions_table", {
|
export const sessionsTable = sqliteTable("sessions_table", {
|
||||||
id: text().primaryKey(),
|
id: text().primaryKey(),
|
||||||
userId: int("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => usersTable.id, { onDelete: "cascade" }),
|
.references(() => usersTable.id, { onDelete: "cascade" }),
|
||||||
expiresAt: int("expires_at", { mode: "number" }).notNull(),
|
token: text("token").notNull().unique(),
|
||||||
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
expiresAt: int("expires_at", { mode: "timestamp_ms" }).notNull(),
|
||||||
|
createdAt: int("created_at", { mode: "timestamp_ms" })
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(unixepoch() * 1000)`),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||||
|
.notNull()
|
||||||
|
.$onUpdate(() => new Date())
|
||||||
|
.default(sql`(unixepoch() * 1000)`),
|
||||||
|
ipAddress: text("ip_address"),
|
||||||
|
userAgent: text("user_agent"),
|
||||||
});
|
});
|
||||||
export type Session = typeof sessionsTable.$inferSelect;
|
export type Session = typeof sessionsTable.$inferSelect;
|
||||||
|
|
||||||
|
export const account = sqliteTable(
|
||||||
|
"account",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
accountId: text("account_id").notNull(),
|
||||||
|
providerId: text("provider_id").notNull(),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => usersTable.id, { onDelete: "cascade" }),
|
||||||
|
accessToken: text("access_token"),
|
||||||
|
refreshToken: text("refresh_token"),
|
||||||
|
idToken: text("id_token"),
|
||||||
|
accessTokenExpiresAt: integer("access_token_expires_at", {
|
||||||
|
mode: "timestamp_ms",
|
||||||
|
}),
|
||||||
|
refreshTokenExpiresAt: integer("refresh_token_expires_at", {
|
||||||
|
mode: "timestamp_ms",
|
||||||
|
}),
|
||||||
|
scope: text("scope"),
|
||||||
|
password: text("password"),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(unixepoch() * 1000)`),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||||
|
.$onUpdate(() => new Date())
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(unixepoch() * 1000)`),
|
||||||
|
},
|
||||||
|
(table) => [index("account_userId_idx").on(table.userId)],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const verification = sqliteTable(
|
||||||
|
"verification",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
identifier: text("identifier").notNull(),
|
||||||
|
value: text("value").notNull(),
|
||||||
|
expiresAt: integer("expires_at", { mode: "number" }).notNull(),
|
||||||
|
createdAt: integer("created_at", { mode: "number" })
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(unixepoch() * 1000)`),
|
||||||
|
updatedAt: integer("updated_at", { mode: "number" })
|
||||||
|
.$onUpdate(() => Date.now())
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(unixepoch() * 1000)`),
|
||||||
|
},
|
||||||
|
(table) => [index("verification_identifier_idx").on(table.identifier)],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const userRelations = relations(usersTable, ({ many }) => ({
|
||||||
|
sessions: many(sessionsTable),
|
||||||
|
accounts: many(account),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const sessionRelations = relations(sessionsTable, ({ one }) => ({
|
||||||
|
user: one(usersTable, {
|
||||||
|
fields: [sessionsTable.userId],
|
||||||
|
references: [usersTable.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const accountRelations = relations(account, ({ one }) => ({
|
||||||
|
user: one(usersTable, {
|
||||||
|
fields: [account.userId],
|
||||||
|
references: [usersTable.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Repositories Table
|
* Repositories Table
|
||||||
*/
|
*/
|
||||||
|
|
@ -58,8 +153,12 @@ export const repositoriesTable = sqliteTable("repositories_table", {
|
||||||
status: text().$type<RepositoryStatus>().default("unknown"),
|
status: text().$type<RepositoryStatus>().default("unknown"),
|
||||||
lastChecked: int("last_checked", { mode: "number" }),
|
lastChecked: int("last_checked", { mode: "number" }),
|
||||||
lastError: text("last_error"),
|
lastError: text("last_error"),
|
||||||
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
createdAt: int("created_at", { mode: "number" })
|
||||||
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
.notNull()
|
||||||
|
.default(sql`(unixepoch() * 1000)`),
|
||||||
|
updatedAt: int("updated_at", { mode: "number" })
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(unixepoch() * 1000)`),
|
||||||
});
|
});
|
||||||
export type Repository = typeof repositoriesTable.$inferSelect;
|
export type Repository = typeof repositoriesTable.$inferSelect;
|
||||||
export type RepositoryInsert = typeof repositoriesTable.$inferInsert;
|
export type RepositoryInsert = typeof repositoriesTable.$inferInsert;
|
||||||
|
|
@ -97,8 +196,12 @@ export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
|
||||||
nextBackupAt: int("next_backup_at", { mode: "number" }),
|
nextBackupAt: int("next_backup_at", { mode: "number" }),
|
||||||
oneFileSystem: int("one_file_system", { mode: "boolean" }).notNull().default(false),
|
oneFileSystem: int("one_file_system", { mode: "boolean" }).notNull().default(false),
|
||||||
sortOrder: int("sort_order", { mode: "number" }).notNull().default(0),
|
sortOrder: int("sort_order", { mode: "number" }).notNull().default(0),
|
||||||
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
createdAt: int("created_at", { mode: "number" })
|
||||||
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
.notNull()
|
||||||
|
.default(sql`(unixepoch() * 1000)`),
|
||||||
|
updatedAt: int("updated_at", { mode: "number" })
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(unixepoch() * 1000)`),
|
||||||
});
|
});
|
||||||
export type BackupScheduleInsert = typeof backupSchedulesTable.$inferInsert;
|
export type BackupScheduleInsert = typeof backupSchedulesTable.$inferInsert;
|
||||||
|
|
||||||
|
|
@ -125,8 +228,12 @@ export const notificationDestinationsTable = sqliteTable("notification_destinati
|
||||||
enabled: int("enabled", { mode: "boolean" }).notNull().default(true),
|
enabled: int("enabled", { mode: "boolean" }).notNull().default(true),
|
||||||
type: text().$type<NotificationType>().notNull(),
|
type: text().$type<NotificationType>().notNull(),
|
||||||
config: text("config", { mode: "json" }).$type<typeof notificationConfigSchema.inferOut>().notNull(),
|
config: text("config", { mode: "json" }).$type<typeof notificationConfigSchema.inferOut>().notNull(),
|
||||||
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
createdAt: int("created_at", { mode: "number" })
|
||||||
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
.notNull()
|
||||||
|
.default(sql`(unixepoch() * 1000)`),
|
||||||
|
updatedAt: int("updated_at", { mode: "number" })
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(unixepoch() * 1000)`),
|
||||||
});
|
});
|
||||||
export const notificationDestinationRelations = relations(notificationDestinationsTable, ({ many }) => ({
|
export const notificationDestinationRelations = relations(notificationDestinationsTable, ({ many }) => ({
|
||||||
schedules: many(backupScheduleNotificationsTable),
|
schedules: many(backupScheduleNotificationsTable),
|
||||||
|
|
@ -149,7 +256,9 @@ export const backupScheduleNotificationsTable = sqliteTable(
|
||||||
notifyOnSuccess: int("notify_on_success", { mode: "boolean" }).notNull().default(false),
|
notifyOnSuccess: int("notify_on_success", { mode: "boolean" }).notNull().default(false),
|
||||||
notifyOnWarning: int("notify_on_warning", { mode: "boolean" }).notNull().default(true),
|
notifyOnWarning: int("notify_on_warning", { mode: "boolean" }).notNull().default(true),
|
||||||
notifyOnFailure: int("notify_on_failure", { mode: "boolean" }).notNull().default(true),
|
notifyOnFailure: int("notify_on_failure", { mode: "boolean" }).notNull().default(true),
|
||||||
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
createdAt: int("created_at", { mode: "number" })
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(unixepoch() * 1000)`),
|
||||||
},
|
},
|
||||||
(table) => [primaryKey({ columns: [table.scheduleId, table.destinationId] })],
|
(table) => [primaryKey({ columns: [table.scheduleId, table.destinationId] })],
|
||||||
);
|
);
|
||||||
|
|
@ -183,7 +292,9 @@ export const backupScheduleMirrorsTable = sqliteTable(
|
||||||
lastCopyAt: int("last_copy_at", { mode: "number" }),
|
lastCopyAt: int("last_copy_at", { mode: "number" }),
|
||||||
lastCopyStatus: text("last_copy_status").$type<"success" | "error">(),
|
lastCopyStatus: text("last_copy_status").$type<"success" | "error">(),
|
||||||
lastCopyError: text("last_copy_error"),
|
lastCopyError: text("last_copy_error"),
|
||||||
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
createdAt: int("created_at", { mode: "number" })
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(unixepoch() * 1000)`),
|
||||||
},
|
},
|
||||||
(table) => [unique().on(table.scheduleId, table.repositoryId)],
|
(table) => [unique().on(table.scheduleId, table.repositoryId)],
|
||||||
);
|
);
|
||||||
|
|
@ -207,7 +318,11 @@ export type BackupScheduleMirror = typeof backupScheduleMirrorsTable.$inferSelec
|
||||||
export const appMetadataTable = sqliteTable("app_metadata", {
|
export const appMetadataTable = sqliteTable("app_metadata", {
|
||||||
key: text().primaryKey(),
|
key: text().primaryKey(),
|
||||||
value: text().notNull(),
|
value: text().notNull(),
|
||||||
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
createdAt: int("created_at", { mode: "number" })
|
||||||
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
|
.notNull()
|
||||||
|
.default(sql`(unixepoch() * 1000)`),
|
||||||
|
updatedAt: int("updated_at", { mode: "number" })
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(unixepoch() * 1000)`),
|
||||||
});
|
});
|
||||||
export type AppMetadata = typeof appMetadataTable.$inferSelect;
|
export type AppMetadata = typeof appMetadataTable.$inferSelect;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { createHonoServer } from "react-router-hono-server/bun";
|
import { createHonoServer } from "react-router-hono-server/bun";
|
||||||
import { runDbMigrations } from "./db/db";
|
import * as schema from "./db/schema";
|
||||||
|
import { setSchema, runDbMigrations } from "./db/db";
|
||||||
import { startup } from "./modules/lifecycle/startup";
|
import { startup } from "./modules/lifecycle/startup";
|
||||||
import { retagSnapshots } from "./modules/lifecycle/migration";
|
import { retagSnapshots } from "./modules/lifecycle/migration";
|
||||||
import { logger } from "./utils/logger";
|
import { logger } from "./utils/logger";
|
||||||
|
|
@ -10,6 +11,8 @@ import { createApp } from "./app";
|
||||||
import { config } from "./core/config";
|
import { config } from "./core/config";
|
||||||
import { runCLI } from "./cli";
|
import { runCLI } from "./cli";
|
||||||
|
|
||||||
|
setSchema(schema);
|
||||||
|
|
||||||
const cliRun = await runCLI(Bun.argv);
|
const cliRun = await runCLI(Bun.argv);
|
||||||
if (cliRun) {
|
if (cliRun) {
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|
@ -22,9 +25,7 @@ runDbMigrations();
|
||||||
await retagSnapshots();
|
await retagSnapshots();
|
||||||
await validateRequiredMigrations(REQUIRED_MIGRATIONS);
|
await validateRequiredMigrations(REQUIRED_MIGRATIONS);
|
||||||
|
|
||||||
startup();
|
await startup();
|
||||||
|
|
||||||
logger.info(`Server is running at http://localhost:${config.port}`);
|
|
||||||
|
|
||||||
export type AppType = typeof app;
|
export type AppType = typeof app;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { Job } from "../core/scheduler";
|
|
||||||
import { authService } from "../modules/auth/auth.service";
|
|
||||||
|
|
||||||
export class CleanupSessionsJob extends Job {
|
|
||||||
async run() {
|
|
||||||
authService.cleanupExpiredSessions();
|
|
||||||
|
|
||||||
return { done: true, timestamp: new Date() };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,157 +1,8 @@
|
||||||
import { validator } from "hono-openapi";
|
|
||||||
import { rateLimiter } from "hono-rate-limiter";
|
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
import { getStatusDto, type GetStatusDto } from "./auth.dto";
|
||||||
import {
|
|
||||||
changePasswordBodySchema,
|
|
||||||
changePasswordDto,
|
|
||||||
getMeDto,
|
|
||||||
getStatusDto,
|
|
||||||
loginBodySchema,
|
|
||||||
loginDto,
|
|
||||||
logoutDto,
|
|
||||||
registerBodySchema,
|
|
||||||
registerDto,
|
|
||||||
type ChangePasswordDto,
|
|
||||||
type GetMeDto,
|
|
||||||
type GetStatusDto,
|
|
||||||
type LoginDto,
|
|
||||||
type LogoutDto,
|
|
||||||
type RegisterDto,
|
|
||||||
} from "./auth.dto";
|
|
||||||
import { authService } from "./auth.service";
|
import { authService } from "./auth.service";
|
||||||
import { toMessage } from "../../utils/errors";
|
|
||||||
import { config } from "~/server/core/config";
|
|
||||||
|
|
||||||
const COOKIE_NAME = "session_id";
|
export const authController = new Hono().get("/status", getStatusDto, async (c) => {
|
||||||
const COOKIE_OPTIONS = {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: false,
|
|
||||||
sameSite: "lax" as const,
|
|
||||||
path: "/",
|
|
||||||
};
|
|
||||||
|
|
||||||
const authRateLimiter = rateLimiter({
|
|
||||||
windowMs: 15 * 60 * 1000,
|
|
||||||
limit: 20,
|
|
||||||
keyGenerator: (c) => c.req.header("x-forwarded-for") ?? "",
|
|
||||||
skip: () => {
|
|
||||||
return config.__prod__ === false;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const authController = new Hono()
|
|
||||||
.post("/register", authRateLimiter, registerDto, validator("json", registerBodySchema), async (c) => {
|
|
||||||
const body = c.req.valid("json");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { user, sessionId } = await authService.register(body.username, body.password);
|
|
||||||
|
|
||||||
setCookie(c, COOKIE_NAME, sessionId, {
|
|
||||||
...COOKIE_OPTIONS,
|
|
||||||
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
|
|
||||||
});
|
|
||||||
|
|
||||||
return c.json<RegisterDto>(
|
|
||||||
{
|
|
||||||
success: true,
|
|
||||||
message: "User registered successfully",
|
|
||||||
user: {
|
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
hasDownloadedResticPassword: user.hasDownloadedResticPassword,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
201,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
return c.json<RegisterDto>({ success: false, message: toMessage(error) }, 400);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.post("/login", authRateLimiter, loginDto, validator("json", loginBodySchema), async (c) => {
|
|
||||||
const body = c.req.valid("json");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { sessionId, user, expiresAt } = await authService.login(body.username, body.password);
|
|
||||||
|
|
||||||
setCookie(c, COOKIE_NAME, sessionId, {
|
|
||||||
...COOKIE_OPTIONS,
|
|
||||||
expires: new Date(expiresAt),
|
|
||||||
});
|
|
||||||
|
|
||||||
return c.json<LoginDto>({
|
|
||||||
success: true,
|
|
||||||
message: "Login successful",
|
|
||||||
user: {
|
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
hasDownloadedResticPassword: user.hasDownloadedResticPassword,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
return c.json<LoginDto>({ success: false, message: toMessage(error) }, 401);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.post("/logout", authRateLimiter, logoutDto, async (c) => {
|
|
||||||
const sessionId = getCookie(c, COOKIE_NAME);
|
|
||||||
|
|
||||||
if (sessionId) {
|
|
||||||
await authService.logout(sessionId);
|
|
||||||
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json<LogoutDto>({ success: true });
|
|
||||||
})
|
|
||||||
.get("/me", getMeDto, async (c) => {
|
|
||||||
const sessionId = getCookie(c, COOKIE_NAME);
|
|
||||||
|
|
||||||
if (!sessionId) {
|
|
||||||
return c.json<GetMeDto>({ success: false, message: "Not authenticated" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = await authService.verifySession(sessionId);
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
|
|
||||||
return c.json({ message: "Not authenticated" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json<GetMeDto>({
|
|
||||||
success: true,
|
|
||||||
user: session.user,
|
|
||||||
message: "Authenticated",
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.get("/status", getStatusDto, async (c) => {
|
|
||||||
const hasUsers = await authService.hasUsers();
|
const hasUsers = await authService.hasUsers();
|
||||||
return c.json<GetStatusDto>({ hasUsers });
|
return c.json<GetStatusDto>({ hasUsers });
|
||||||
})
|
});
|
||||||
.post(
|
|
||||||
"/change-password",
|
|
||||||
authRateLimiter,
|
|
||||||
changePasswordDto,
|
|
||||||
validator("json", changePasswordBodySchema),
|
|
||||||
async (c) => {
|
|
||||||
const sessionId = getCookie(c, COOKIE_NAME);
|
|
||||||
|
|
||||||
if (!sessionId) {
|
|
||||||
return c.json<ChangePasswordDto>({ success: false, message: "Not authenticated" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = await authService.verifySession(sessionId);
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
|
|
||||||
return c.json<ChangePasswordDto>({ success: false, message: "Not authenticated" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = c.req.valid("json");
|
|
||||||
|
|
||||||
try {
|
|
||||||
await authService.changePassword(session.user.id, body.currentPassword, body.newPassword);
|
|
||||||
return c.json<ChangePasswordDto>({ success: true, message: "Password changed successfully" });
|
|
||||||
} catch (error) {
|
|
||||||
return c.json<ChangePasswordDto>({ success: false, message: toMessage(error) }, 400);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
|
||||||
|
|
@ -1,103 +1,6 @@
|
||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import { describeRoute, resolver } from "hono-openapi";
|
import { describeRoute, resolver } from "hono-openapi";
|
||||||
|
|
||||||
// Validation schemas
|
|
||||||
export const loginBodySchema = type({
|
|
||||||
username: "string>0",
|
|
||||||
password: "string>7",
|
|
||||||
});
|
|
||||||
|
|
||||||
export const registerBodySchema = type({
|
|
||||||
username: "string>2",
|
|
||||||
password: "string>7",
|
|
||||||
});
|
|
||||||
|
|
||||||
const loginResponseSchema = type({
|
|
||||||
message: "string",
|
|
||||||
success: "boolean",
|
|
||||||
user: type({
|
|
||||||
id: "number",
|
|
||||||
username: "string",
|
|
||||||
hasDownloadedResticPassword: "boolean",
|
|
||||||
}).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const loginDto = describeRoute({
|
|
||||||
description: "Login with username and password",
|
|
||||||
operationId: "login",
|
|
||||||
tags: ["Auth"],
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Login successful",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: resolver(loginResponseSchema),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export type LoginDto = typeof loginResponseSchema.infer;
|
|
||||||
|
|
||||||
export const registerDto = describeRoute({
|
|
||||||
description: "Register a new user",
|
|
||||||
operationId: "register",
|
|
||||||
tags: ["Auth"],
|
|
||||||
responses: {
|
|
||||||
201: {
|
|
||||||
description: "User created successfully",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: resolver(loginResponseSchema),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export type RegisterDto = typeof loginResponseSchema.infer;
|
|
||||||
|
|
||||||
const logoutResponseSchema = type({
|
|
||||||
success: "boolean",
|
|
||||||
});
|
|
||||||
|
|
||||||
export const logoutDto = describeRoute({
|
|
||||||
description: "Logout current user",
|
|
||||||
operationId: "logout",
|
|
||||||
tags: ["Auth"],
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Logout successful",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: resolver(logoutResponseSchema),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export type LogoutDto = typeof logoutResponseSchema.infer;
|
|
||||||
|
|
||||||
export const getMeDto = describeRoute({
|
|
||||||
description: "Get current authenticated user",
|
|
||||||
operationId: "getMe",
|
|
||||||
tags: ["Auth"],
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Current user information",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: resolver(loginResponseSchema),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export type GetMeDto = typeof loginResponseSchema.infer;
|
|
||||||
|
|
||||||
const statusResponseSchema = type({
|
const statusResponseSchema = type({
|
||||||
hasUsers: "boolean",
|
hasUsers: "boolean",
|
||||||
});
|
});
|
||||||
|
|
@ -119,35 +22,3 @@ export const getStatusDto = describeRoute({
|
||||||
});
|
});
|
||||||
|
|
||||||
export type GetStatusDto = typeof statusResponseSchema.infer;
|
export type GetStatusDto = typeof statusResponseSchema.infer;
|
||||||
|
|
||||||
export const changePasswordBodySchema = type({
|
|
||||||
currentPassword: "string>0",
|
|
||||||
newPassword: "string>7",
|
|
||||||
});
|
|
||||||
|
|
||||||
const changePasswordResponseSchema = type({
|
|
||||||
success: "boolean",
|
|
||||||
message: "string",
|
|
||||||
});
|
|
||||||
|
|
||||||
export const changePasswordDto = describeRoute({
|
|
||||||
description: "Change current user password",
|
|
||||||
operationId: "changePassword",
|
|
||||||
tags: ["Auth"],
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Password changed successfully",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: resolver(changePasswordResponseSchema),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ChangePasswordDto = typeof changePasswordResponseSchema.infer;
|
|
||||||
|
|
||||||
export type LoginBody = typeof loginBodySchema.infer;
|
|
||||||
export type RegisterBody = typeof registerBodySchema.infer;
|
|
||||||
export type ChangePasswordBody = typeof changePasswordBodySchema.infer;
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,10 @@
|
||||||
import { deleteCookie, getCookie } from "hono/cookie";
|
|
||||||
import { createMiddleware } from "hono/factory";
|
import { createMiddleware } from "hono/factory";
|
||||||
import { authService } from "./auth.service";
|
import { auth } from "~/lib/auth";
|
||||||
|
|
||||||
const COOKIE_NAME = "session_id";
|
|
||||||
const COOKIE_OPTIONS = {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: process.env.NODE_ENV === "production",
|
|
||||||
sameSite: "lax" as const,
|
|
||||||
path: "/",
|
|
||||||
};
|
|
||||||
|
|
||||||
declare module "hono" {
|
declare module "hono" {
|
||||||
interface ContextVariableMap {
|
interface ContextVariableMap {
|
||||||
user: {
|
user: {
|
||||||
id: number;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
hasDownloadedResticPassword: boolean;
|
hasDownloadedResticPassword: boolean;
|
||||||
};
|
};
|
||||||
|
|
@ -25,40 +16,17 @@ declare module "hono" {
|
||||||
* Verifies the session cookie and attaches user to context
|
* Verifies the session cookie and attaches user to context
|
||||||
*/
|
*/
|
||||||
export const requireAuth = createMiddleware(async (c, next) => {
|
export const requireAuth = createMiddleware(async (c, next) => {
|
||||||
const sessionId = getCookie(c, COOKIE_NAME);
|
const session = await auth.api.getSession({
|
||||||
|
headers: c.req.raw.headers,
|
||||||
|
});
|
||||||
|
|
||||||
if (!sessionId) {
|
const { user } = session ?? {};
|
||||||
return c.json({ message: "Authentication required" }, 401);
|
|
||||||
|
if (!user) {
|
||||||
|
return c.json<unknown>({ message: "Invalid or expired session" }, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await authService.verifySession(sessionId);
|
c.set("user", user);
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
|
|
||||||
return c.json({ message: "Invalid or expired session" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
c.set("user", session.user);
|
|
||||||
|
|
||||||
await next();
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Middleware to optionally attach user if authenticated
|
|
||||||
* Does not block the request if not authenticated
|
|
||||||
*/
|
|
||||||
export const optionalAuth = createMiddleware(async (c, next) => {
|
|
||||||
const sessionId = getCookie(c, COOKIE_NAME);
|
|
||||||
|
|
||||||
if (sessionId) {
|
|
||||||
const session = await authService.verifySession(sessionId);
|
|
||||||
|
|
||||||
if (session) {
|
|
||||||
c.set("user", session.user);
|
|
||||||
} else {
|
|
||||||
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await next();
|
await next();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,145 +1,7 @@
|
||||||
import { eq, lt } from "drizzle-orm";
|
|
||||||
import { db } from "../../db/db";
|
import { db } from "../../db/db";
|
||||||
import { sessionsTable, usersTable } from "../../db/schema";
|
import { usersTable } from "../../db/schema";
|
||||||
import { logger } from "../../utils/logger";
|
|
||||||
|
|
||||||
const SESSION_DURATION = 60 * 60 * 24 * 30 * 1000; // 30 days in milliseconds
|
|
||||||
|
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
/**
|
|
||||||
* Register a new user with username and password
|
|
||||||
*/
|
|
||||||
async register(username: string, password: string) {
|
|
||||||
const [existingUser] = await db.select().from(usersTable);
|
|
||||||
|
|
||||||
if (existingUser) {
|
|
||||||
throw new Error("Admin user already exists");
|
|
||||||
}
|
|
||||||
|
|
||||||
const passwordHash = await Bun.password.hash(password, {
|
|
||||||
algorithm: "argon2id",
|
|
||||||
memoryCost: 19456,
|
|
||||||
timeCost: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [user] = await db.insert(usersTable).values({ username, passwordHash }).returning();
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
throw new Error("User registration failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`User registered: ${username}`);
|
|
||||||
const sessionId = crypto.randomUUID();
|
|
||||||
const expiresAt = Date.now() + SESSION_DURATION;
|
|
||||||
|
|
||||||
await db.insert(sessionsTable).values({
|
|
||||||
id: sessionId,
|
|
||||||
userId: user.id,
|
|
||||||
expiresAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
user: {
|
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
createdAt: user.createdAt,
|
|
||||||
hasDownloadedResticPassword: user.hasDownloadedResticPassword,
|
|
||||||
},
|
|
||||||
sessionId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Login user with username and password
|
|
||||||
*/
|
|
||||||
async login(username: string, password: string) {
|
|
||||||
const [user] = await db.select().from(usersTable).where(eq(usersTable.username, username));
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
throw new Error("Invalid credentials");
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValid = await Bun.password.verify(password, user.passwordHash);
|
|
||||||
|
|
||||||
if (!isValid) {
|
|
||||||
throw new Error("Invalid credentials");
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionId = crypto.randomUUID();
|
|
||||||
const expiresAt = Date.now() + SESSION_DURATION;
|
|
||||||
|
|
||||||
await db.insert(sessionsTable).values({
|
|
||||||
id: sessionId,
|
|
||||||
userId: user.id,
|
|
||||||
expiresAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(`User logged in: ${username}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
sessionId,
|
|
||||||
user: {
|
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
hasDownloadedResticPassword: user.hasDownloadedResticPassword,
|
|
||||||
},
|
|
||||||
expiresAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Logout user by deleting their session
|
|
||||||
*/
|
|
||||||
async logout(sessionId: string) {
|
|
||||||
await db.delete(sessionsTable).where(eq(sessionsTable.id, sessionId));
|
|
||||||
logger.info(`User logged out: session ${sessionId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify a session and return the associated user
|
|
||||||
*/
|
|
||||||
async verifySession(sessionId: string) {
|
|
||||||
const [session] = await db
|
|
||||||
.select({
|
|
||||||
session: sessionsTable,
|
|
||||||
user: usersTable,
|
|
||||||
})
|
|
||||||
.from(sessionsTable)
|
|
||||||
.innerJoin(usersTable, eq(sessionsTable.userId, usersTable.id))
|
|
||||||
.where(eq(sessionsTable.id, sessionId));
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session.session.expiresAt < Date.now()) {
|
|
||||||
await db.delete(sessionsTable).where(eq(sessionsTable.id, sessionId));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
user: {
|
|
||||||
id: session.user.id,
|
|
||||||
username: session.user.username,
|
|
||||||
hasDownloadedResticPassword: session.user.hasDownloadedResticPassword,
|
|
||||||
},
|
|
||||||
session: {
|
|
||||||
id: session.session.id,
|
|
||||||
expiresAt: session.session.expiresAt,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up expired sessions
|
|
||||||
*/
|
|
||||||
async cleanupExpiredSessions() {
|
|
||||||
const result = await db.delete(sessionsTable).where(lt(sessionsTable.expiresAt, Date.now())).returning();
|
|
||||||
if (result.length > 0) {
|
|
||||||
logger.info(`Cleaned up ${result.length} expired sessions`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if any users exist in the system
|
* Check if any users exist in the system
|
||||||
*/
|
*/
|
||||||
|
|
@ -147,33 +9,6 @@ export class AuthService {
|
||||||
const [user] = await db.select({ id: usersTable.id }).from(usersTable).limit(1);
|
const [user] = await db.select({ id: usersTable.id }).from(usersTable).limit(1);
|
||||||
return !!user;
|
return !!user;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Change password for a user
|
|
||||||
*/
|
|
||||||
async changePassword(userId: number, currentPassword: string, newPassword: string) {
|
|
||||||
const [user] = await db.select().from(usersTable).where(eq(usersTable.id, userId));
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
throw new Error("User not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValid = await Bun.password.verify(currentPassword, user.passwordHash);
|
|
||||||
|
|
||||||
if (!isValid) {
|
|
||||||
throw new Error("Current password is incorrect");
|
|
||||||
}
|
|
||||||
|
|
||||||
const newPasswordHash = await Bun.password.hash(newPassword, {
|
|
||||||
algorithm: "argon2id",
|
|
||||||
memoryCost: 19456,
|
|
||||||
timeCost: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
await db.update(usersTable).set({ passwordHash: newPasswordHash }).where(eq(usersTable.id, userId));
|
|
||||||
|
|
||||||
logger.info(`Password changed for user: ${user.username}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const authService = new AuthService();
|
export const authService = new AuthService();
|
||||||
|
|
|
||||||
26
app/server/modules/auth/helpers.ts
Normal file
26
app/server/modules/auth/helpers.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { verifyPassword } from "better-auth/crypto";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "~/server/db/db";
|
||||||
|
import { account } from "~/server/db/schema";
|
||||||
|
|
||||||
|
type PasswordVerificationBody = {
|
||||||
|
userId: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verifyUserPassword = async ({ password, userId }: PasswordVerificationBody) => {
|
||||||
|
const userAccount = await db.query.account.findFirst({
|
||||||
|
where: eq(account.userId, userId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userAccount || !userAccount.password) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPasswordValid = await verifyPassword({ password: password, hash: userAccount.password });
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
@ -9,28 +9,26 @@ describe("backups security", () => {
|
||||||
const res = await app.request("/api/v1/backups");
|
const res = await app.request("/api/v1/backups");
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.message).toBe("Authentication required");
|
expect(body.message).toBe("Invalid or expired session");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return 401 if session is invalid", async () => {
|
test("should return 401 if session is invalid", async () => {
|
||||||
const res = await app.request("/api/v1/backups", {
|
const res = await app.request("/api/v1/backups", {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: "session_id=invalid-session",
|
Cookie: "better-auth.session_token=invalid-session",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.message).toBe("Invalid or expired session");
|
expect(body.message).toBe("Invalid or expired session");
|
||||||
|
|
||||||
expect(res.headers.get("Set-Cookie")).toContain("session_id=;");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return 200 if session is valid", async () => {
|
test("should return 200 if session is valid", async () => {
|
||||||
const { sessionId } = await createTestSession();
|
const { token } = await createTestSession();
|
||||||
|
|
||||||
const res = await app.request("/api/v1/backups", {
|
const res = await app.request("/api/v1/backups", {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `session_id=${sessionId}`,
|
Cookie: `better-auth.session_token=${token}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -61,7 +59,7 @@ describe("backups security", () => {
|
||||||
const res = await app.request(path, { method });
|
const res = await app.request(path, { method });
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.message).toBe("Authentication required");
|
expect(body.message).toBe("Invalid or expired session");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -71,23 +69,23 @@ describe("backups security", () => {
|
||||||
const res = await app.request("/api/v1/backups/999999");
|
const res = await app.request("/api/v1/backups/999999");
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.message).toBe("Authentication required");
|
expect(body.message).toBe("Invalid or expired session");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should not disclose if a volume exists when unauthenticated", async () => {
|
test("should not disclose if a volume exists when unauthenticated", async () => {
|
||||||
const res = await app.request("/api/v1/backups/volume/999999");
|
const res = await app.request("/api/v1/backups/volume/999999");
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.message).toBe("Authentication required");
|
expect(body.message).toBe("Invalid or expired session");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("input validation", () => {
|
describe("input validation", () => {
|
||||||
test("should return 404 for malformed schedule ID", async () => {
|
test("should return 404 for malformed schedule ID", async () => {
|
||||||
const { sessionId } = await createTestSession();
|
const { token } = await createTestSession();
|
||||||
const res = await app.request("/api/v1/backups/not-a-number", {
|
const res = await app.request("/api/v1/backups/not-a-number", {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `session_id=${sessionId}`,
|
Cookie: `better-auth.session_token=${token}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -95,10 +93,10 @@ describe("backups security", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return 404 for non-existent schedule ID", async () => {
|
test("should return 404 for non-existent schedule ID", async () => {
|
||||||
const { sessionId } = await createTestSession();
|
const { token } = await createTestSession();
|
||||||
const res = await app.request("/api/v1/backups/999999", {
|
const res = await app.request("/api/v1/backups/999999", {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `session_id=${sessionId}`,
|
Cookie: `better-auth.session_token=${token}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -108,11 +106,11 @@ describe("backups security", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return 400 for invalid payload on create", async () => {
|
test("should return 400 for invalid payload on create", async () => {
|
||||||
const { sessionId } = await createTestSession();
|
const { token } = await createTestSession();
|
||||||
const res = await app.request("/api/v1/backups", {
|
const res = await app.request("/api/v1/backups", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `session_id=${sessionId}`,
|
Cookie: `better-auth.session_token=${token}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ describe("execute backup", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// act
|
// act
|
||||||
backupsService.executeBackup(schedule.id);
|
void backupsService.executeBackup(schedule.id);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
await backupsService.executeBackup(schedule.id);
|
await backupsService.executeBackup(schedule.id);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ const calculateNextRun = (cronExpression: string): number => {
|
||||||
|
|
||||||
return interval.next().getTime();
|
return interval.next().getTime();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to parse cron expression "${cronExpression}": ${error}`);
|
logger.error(`Failed to parse cron expression "${cronExpression}": ${toMessage(error)}`);
|
||||||
const fallback = new Date();
|
const fallback = new Date();
|
||||||
fallback.setMinutes(fallback.getMinutes() + 1);
|
fallback.setMinutes(fallback.getMinutes() + 1);
|
||||||
return fallback.getTime();
|
return fallback.getTime();
|
||||||
|
|
|
||||||
|
|
@ -9,28 +9,26 @@ describe("events security", () => {
|
||||||
const res = await app.request("/api/v1/events");
|
const res = await app.request("/api/v1/events");
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.message).toBe("Authentication required");
|
expect(body.message).toBe("Invalid or expired session");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return 401 if session is invalid", async () => {
|
test("should return 401 if session is invalid", async () => {
|
||||||
const res = await app.request("/api/v1/events", {
|
const res = await app.request("/api/v1/events", {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: "session_id=invalid-session",
|
Cookie: "better-auth.session_token=invalid-session",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.message).toBe("Invalid or expired session");
|
expect(body.message).toBe("Invalid or expired session");
|
||||||
|
|
||||||
expect(res.headers.get("Set-Cookie")).toContain("session_id=;");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return 200 if session is valid", async () => {
|
test("should return 200 if session is valid", async () => {
|
||||||
const { sessionId } = await createTestSession();
|
const { token } = await createTestSession();
|
||||||
|
|
||||||
const res = await app.request("/api/v1/events", {
|
const res = await app.request("/api/v1/events", {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `session_id=${sessionId}`,
|
Cookie: `better-auth.session_token=${token}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -46,7 +44,7 @@ describe("events security", () => {
|
||||||
const res = await app.request(path, { method });
|
const res = await app.request(path, { method });
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.message).toBe("Authentication required");
|
expect(body.message).toBe("Invalid or expired session");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,14 +13,14 @@ export const eventsController = new Hono().use(requireAuth).get("/", (c) => {
|
||||||
event: "connected",
|
event: "connected",
|
||||||
});
|
});
|
||||||
|
|
||||||
const onBackupStarted = (data: { scheduleId: number; volumeName: string; repositoryName: string }) => {
|
const onBackupStarted = async (data: { scheduleId: number; volumeName: string; repositoryName: string }) => {
|
||||||
stream.writeSSE({
|
await stream.writeSSE({
|
||||||
data: JSON.stringify(data),
|
data: JSON.stringify(data),
|
||||||
event: "backup:started",
|
event: "backup:started",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onBackupProgress = (data: {
|
const onBackupProgress = async (data: {
|
||||||
scheduleId: number;
|
scheduleId: number;
|
||||||
volumeName: string;
|
volumeName: string;
|
||||||
repositoryName: string;
|
repositoryName: string;
|
||||||
|
|
@ -32,60 +32,60 @@ export const eventsController = new Hono().use(requireAuth).get("/", (c) => {
|
||||||
bytes_done: number;
|
bytes_done: number;
|
||||||
current_files: string[];
|
current_files: string[];
|
||||||
}) => {
|
}) => {
|
||||||
stream.writeSSE({
|
await stream.writeSSE({
|
||||||
data: JSON.stringify(data),
|
data: JSON.stringify(data),
|
||||||
event: "backup:progress",
|
event: "backup:progress",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onBackupCompleted = (data: {
|
const onBackupCompleted = async (data: {
|
||||||
scheduleId: number;
|
scheduleId: number;
|
||||||
volumeName: string;
|
volumeName: string;
|
||||||
repositoryName: string;
|
repositoryName: string;
|
||||||
status: "success" | "error" | "stopped" | "warning";
|
status: "success" | "error" | "stopped" | "warning";
|
||||||
}) => {
|
}) => {
|
||||||
stream.writeSSE({
|
await stream.writeSSE({
|
||||||
data: JSON.stringify(data),
|
data: JSON.stringify(data),
|
||||||
event: "backup:completed",
|
event: "backup:completed",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onVolumeMounted = (data: { volumeName: string }) => {
|
const onVolumeMounted = async (data: { volumeName: string }) => {
|
||||||
stream.writeSSE({
|
await stream.writeSSE({
|
||||||
data: JSON.stringify(data),
|
data: JSON.stringify(data),
|
||||||
event: "volume:mounted",
|
event: "volume:mounted",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onVolumeUnmounted = (data: { volumeName: string }) => {
|
const onVolumeUnmounted = async (data: { volumeName: string }) => {
|
||||||
stream.writeSSE({
|
await stream.writeSSE({
|
||||||
data: JSON.stringify(data),
|
data: JSON.stringify(data),
|
||||||
event: "volume:unmounted",
|
event: "volume:unmounted",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onVolumeUpdated = (data: { volumeName: string }) => {
|
const onVolumeUpdated = async (data: { volumeName: string }) => {
|
||||||
stream.writeSSE({
|
await stream.writeSSE({
|
||||||
data: JSON.stringify(data),
|
data: JSON.stringify(data),
|
||||||
event: "volume:updated",
|
event: "volume:updated",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onMirrorStarted = (data: { scheduleId: number; repositoryId: string; repositoryName: string }) => {
|
const onMirrorStarted = async (data: { scheduleId: number; repositoryId: string; repositoryName: string }) => {
|
||||||
stream.writeSSE({
|
await stream.writeSSE({
|
||||||
data: JSON.stringify(data),
|
data: JSON.stringify(data),
|
||||||
event: "mirror:started",
|
event: "mirror:started",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onMirrorCompleted = (data: {
|
const onMirrorCompleted = async (data: {
|
||||||
scheduleId: number;
|
scheduleId: number;
|
||||||
repositoryId: string;
|
repositoryId: string;
|
||||||
repositoryName: string;
|
repositoryName: string;
|
||||||
status: "success" | "error";
|
status: "success" | "error";
|
||||||
error?: string;
|
error?: string;
|
||||||
}) => {
|
}) => {
|
||||||
stream.writeSSE({
|
await stream.writeSSE({
|
||||||
data: JSON.stringify(data),
|
data: JSON.stringify(data),
|
||||||
event: "mirror:completed",
|
event: "mirror:completed",
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import { CleanupDanglingMountsJob } from "../../jobs/cleanup-dangling";
|
||||||
import { VolumeHealthCheckJob } from "../../jobs/healthchecks";
|
import { VolumeHealthCheckJob } from "../../jobs/healthchecks";
|
||||||
import { RepositoryHealthCheckJob } from "../../jobs/repository-healthchecks";
|
import { RepositoryHealthCheckJob } from "../../jobs/repository-healthchecks";
|
||||||
import { BackupExecutionJob } from "../../jobs/backup-execution";
|
import { BackupExecutionJob } from "../../jobs/backup-execution";
|
||||||
import { CleanupSessionsJob } from "../../jobs/cleanup-sessions";
|
|
||||||
import { repositoriesService } from "../repositories/repositories.service";
|
import { repositoriesService } from "../repositories/repositories.service";
|
||||||
import { notificationsService } from "../notifications/notifications.service";
|
import { notificationsService } from "../notifications/notifications.service";
|
||||||
import { VolumeAutoRemountJob } from "~/server/jobs/auto-remount";
|
import { VolumeAutoRemountJob } from "~/server/jobs/auto-remount";
|
||||||
|
|
@ -82,6 +81,5 @@ export const startup = async () => {
|
||||||
Scheduler.build(VolumeHealthCheckJob).schedule("*/30 * * * *");
|
Scheduler.build(VolumeHealthCheckJob).schedule("*/30 * * * *");
|
||||||
Scheduler.build(RepositoryHealthCheckJob).schedule("50 12 * * *");
|
Scheduler.build(RepositoryHealthCheckJob).schedule("50 12 * * *");
|
||||||
Scheduler.build(BackupExecutionJob).schedule("* * * * *");
|
Scheduler.build(BackupExecutionJob).schedule("* * * * *");
|
||||||
Scheduler.build(CleanupSessionsJob).schedule("0 0 * * *");
|
|
||||||
Scheduler.build(VolumeAutoRemountJob).schedule("*/5 * * * *");
|
Scheduler.build(VolumeAutoRemountJob).schedule("*/5 * * * *");
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -9,28 +9,26 @@ describe("notifications security", () => {
|
||||||
const res = await app.request("/api/v1/notifications/destinations");
|
const res = await app.request("/api/v1/notifications/destinations");
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.message).toBe("Authentication required");
|
expect(body.message).toBe("Invalid or expired session");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return 401 if session is invalid", async () => {
|
test("should return 401 if session is invalid", async () => {
|
||||||
const res = await app.request("/api/v1/notifications/destinations", {
|
const res = await app.request("/api/v1/notifications/destinations", {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: "session_id=invalid-session",
|
Cookie: "better-auth.session_token=invalid-session",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.message).toBe("Invalid or expired session");
|
expect(body.message).toBe("Invalid or expired session");
|
||||||
|
|
||||||
expect(res.headers.get("Set-Cookie")).toContain("session_id=;");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return 200 if session is valid", async () => {
|
test("should return 200 if session is valid", async () => {
|
||||||
const { sessionId } = await createTestSession();
|
const { token } = await createTestSession();
|
||||||
|
|
||||||
const res = await app.request("/api/v1/notifications/destinations", {
|
const res = await app.request("/api/v1/notifications/destinations", {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `session_id=${sessionId}`,
|
Cookie: `better-auth.session_token=${token}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -52,7 +50,7 @@ describe("notifications security", () => {
|
||||||
const res = await app.request(path, { method });
|
const res = await app.request(path, { method });
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.message).toBe("Authentication required");
|
expect(body.message).toBe("Invalid or expired session");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -62,16 +60,16 @@ describe("notifications security", () => {
|
||||||
const res = await app.request("/api/v1/notifications/destinations/999999");
|
const res = await app.request("/api/v1/notifications/destinations/999999");
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.message).toBe("Authentication required");
|
expect(body.message).toBe("Invalid or expired session");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("input validation", () => {
|
describe("input validation", () => {
|
||||||
test("should return 404 for malformed destination ID", async () => {
|
test("should return 404 for malformed destination ID", async () => {
|
||||||
const { sessionId } = await createTestSession();
|
const { token } = await createTestSession();
|
||||||
const res = await app.request("/api/v1/notifications/destinations/not-a-number", {
|
const res = await app.request("/api/v1/notifications/destinations/not-a-number", {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `session_id=${sessionId}`,
|
Cookie: `better-auth.session_token=${token}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -79,10 +77,10 @@ describe("notifications security", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return 404 for non-existent destination ID", async () => {
|
test("should return 404 for non-existent destination ID", async () => {
|
||||||
const { sessionId } = await createTestSession();
|
const { token } = await createTestSession();
|
||||||
const res = await app.request("/api/v1/notifications/destinations/999999", {
|
const res = await app.request("/api/v1/notifications/destinations/999999", {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `session_id=${sessionId}`,
|
Cookie: `better-auth.session_token=${token}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -92,11 +90,12 @@ describe("notifications security", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return 400 for invalid payload on create", async () => {
|
test("should return 400 for invalid payload on create", async () => {
|
||||||
const { sessionId } = await createTestSession();
|
const { token } = await createTestSession();
|
||||||
|
|
||||||
const res = await app.request("/api/v1/notifications/destinations", {
|
const res = await app.request("/api/v1/notifications/destinations", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `session_id=${sessionId}`,
|
Cookie: `better-auth.session_token=${token}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|
|
||||||
|
|
@ -320,18 +320,16 @@ function buildNotificationMessage(
|
||||||
snapshotId?: string;
|
snapshotId?: string;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const date = new Date().toLocaleDateString();
|
const backupName = context.scheduleName ?? "backup";
|
||||||
const time = new Date().toLocaleTimeString();
|
|
||||||
|
|
||||||
switch (event) {
|
switch (event) {
|
||||||
case "start":
|
case "start":
|
||||||
return {
|
return {
|
||||||
title: "🔵 Backup Started",
|
title: `Zerobyte ${backupName} started`,
|
||||||
body: [
|
body: [
|
||||||
`Volume: ${context.volumeName}`,
|
`Volume: ${context.volumeName}`,
|
||||||
`Repository: ${context.repositoryName}`,
|
`Repository: ${context.repositoryName}`,
|
||||||
context.scheduleName ? `Schedule: ${context.scheduleName}` : null,
|
context.scheduleName ? `Schedule: ${context.scheduleName}` : null,
|
||||||
`Time: ${date} - ${time}`,
|
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n"),
|
.join("\n"),
|
||||||
|
|
@ -339,7 +337,7 @@ function buildNotificationMessage(
|
||||||
|
|
||||||
case "success":
|
case "success":
|
||||||
return {
|
return {
|
||||||
title: "✅ Backup Completed successfully",
|
title: `Zerobyte ${backupName} completed successfully`,
|
||||||
body: [
|
body: [
|
||||||
`Volume: ${context.volumeName}`,
|
`Volume: ${context.volumeName}`,
|
||||||
`Repository: ${context.repositoryName}`,
|
`Repository: ${context.repositoryName}`,
|
||||||
|
|
@ -348,7 +346,6 @@ function buildNotificationMessage(
|
||||||
context.filesProcessed !== undefined ? `Files: ${context.filesProcessed}` : null,
|
context.filesProcessed !== undefined ? `Files: ${context.filesProcessed}` : null,
|
||||||
context.bytesProcessed ? `Size: ${context.bytesProcessed}` : null,
|
context.bytesProcessed ? `Size: ${context.bytesProcessed}` : null,
|
||||||
context.snapshotId ? `Snapshot: ${context.snapshotId}` : null,
|
context.snapshotId ? `Snapshot: ${context.snapshotId}` : null,
|
||||||
`Time: ${date} - ${time}`,
|
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n"),
|
.join("\n"),
|
||||||
|
|
@ -356,7 +353,7 @@ function buildNotificationMessage(
|
||||||
|
|
||||||
case "warning":
|
case "warning":
|
||||||
return {
|
return {
|
||||||
title: "! Backup completed with warnings",
|
title: `Zerobyte ${backupName} completed with warnings`,
|
||||||
body: [
|
body: [
|
||||||
`Volume: ${context.volumeName}`,
|
`Volume: ${context.volumeName}`,
|
||||||
`Repository: ${context.repositoryName}`,
|
`Repository: ${context.repositoryName}`,
|
||||||
|
|
@ -366,7 +363,6 @@ function buildNotificationMessage(
|
||||||
context.bytesProcessed ? `Size: ${context.bytesProcessed}` : null,
|
context.bytesProcessed ? `Size: ${context.bytesProcessed}` : null,
|
||||||
context.snapshotId ? `Snapshot: ${context.snapshotId}` : null,
|
context.snapshotId ? `Snapshot: ${context.snapshotId}` : null,
|
||||||
context.error ? `Warning: ${context.error}` : null,
|
context.error ? `Warning: ${context.error}` : null,
|
||||||
`Time: ${date} - ${time}`,
|
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n"),
|
.join("\n"),
|
||||||
|
|
@ -374,13 +370,12 @@ function buildNotificationMessage(
|
||||||
|
|
||||||
case "failure":
|
case "failure":
|
||||||
return {
|
return {
|
||||||
title: "❌ Backup failed",
|
title: `Zerobyte ${backupName} failed`,
|
||||||
body: [
|
body: [
|
||||||
`Volume: ${context.volumeName}`,
|
`Volume: ${context.volumeName}`,
|
||||||
`Repository: ${context.repositoryName}`,
|
`Repository: ${context.repositoryName}`,
|
||||||
context.scheduleName ? `Schedule: ${context.scheduleName}` : null,
|
context.scheduleName ? `Schedule: ${context.scheduleName}` : null,
|
||||||
context.error ? `Error: ${context.error}` : null,
|
context.error ? `Error: ${context.error}` : null,
|
||||||
`Time: ${date} - ${time}`,
|
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n"),
|
.join("\n"),
|
||||||
|
|
@ -388,12 +383,11 @@ function buildNotificationMessage(
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
title: "Backup Notification",
|
title: `Zerobyte ${backupName} notification`,
|
||||||
body: [
|
body: [
|
||||||
`Volume: ${context.volumeName}`,
|
`Volume: ${context.volumeName}`,
|
||||||
`Repository: ${context.repositoryName}`,
|
`Repository: ${context.repositoryName}`,
|
||||||
context.scheduleName ? `Schedule: ${context.scheduleName}` : null,
|
context.scheduleName ? `Schedule: ${context.scheduleName}` : null,
|
||||||
`Time: ${date} - ${time}`,
|
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n"),
|
.join("\n"),
|
||||||
|
|
|
||||||
|
|
@ -9,28 +9,26 @@ describe("repositories security", () => {
|
||||||
const res = await app.request("/api/v1/repositories");
|
const res = await app.request("/api/v1/repositories");
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.message).toBe("Authentication required");
|
expect(body.message).toBe("Invalid or expired session");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return 401 if session is invalid", async () => {
|
test("should return 401 if session is invalid", async () => {
|
||||||
const res = await app.request("/api/v1/repositories", {
|
const res = await app.request("/api/v1/repositories", {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: "session_id=invalid-session",
|
Cookie: "better-auth.session_token=invalid-session",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.message).toBe("Invalid or expired session");
|
expect(body.message).toBe("Invalid or expired session");
|
||||||
|
|
||||||
expect(res.headers.get("Set-Cookie")).toContain("session_id=;");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return 200 if session is valid", async () => {
|
test("should return 200 if session is valid", async () => {
|
||||||
const { sessionId } = await createTestSession();
|
const { token } = await createTestSession();
|
||||||
|
|
||||||
const res = await app.request("/api/v1/repositories", {
|
const res = await app.request("/api/v1/repositories", {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `session_id=${sessionId}`,
|
Cookie: `better-auth.session_token=${token}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -59,7 +57,7 @@ describe("repositories security", () => {
|
||||||
const res = await app.request(path, { method });
|
const res = await app.request(path, { method });
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.message).toBe("Authentication required");
|
expect(body.message).toBe("Invalid or expired session");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -69,16 +67,16 @@ describe("repositories security", () => {
|
||||||
const res = await app.request("/api/v1/repositories/non-existent-repo");
|
const res = await app.request("/api/v1/repositories/non-existent-repo");
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.message).toBe("Authentication required");
|
expect(body.message).toBe("Invalid or expired session");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("input validation", () => {
|
describe("input validation", () => {
|
||||||
test("should return 404 for non-existent repository", async () => {
|
test("should return 404 for non-existent repository", async () => {
|
||||||
const { sessionId } = await createTestSession();
|
const { token } = await createTestSession();
|
||||||
const res = await app.request("/api/v1/repositories/non-existent-repo", {
|
const res = await app.request("/api/v1/repositories/non-existent-repo", {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `session_id=${sessionId}`,
|
Cookie: `better-auth.session_token=${token}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -88,11 +86,11 @@ describe("repositories security", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return 400 for invalid payload on create", async () => {
|
test("should return 400 for invalid payload on create", async () => {
|
||||||
const { sessionId } = await createTestSession();
|
const { token } = await createTestSession();
|
||||||
const res = await app.request("/api/v1/repositories", {
|
const res = await app.request("/api/v1/repositories", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `session_id=${sessionId}`,
|
Cookie: `better-auth.session_token=${token}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|
|
||||||
|
|
@ -9,28 +9,26 @@ describe("system security", () => {
|
||||||
const res = await app.request("/api/v1/system/info");
|
const res = await app.request("/api/v1/system/info");
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.message).toBe("Authentication required");
|
expect(body.message).toBe("Invalid or expired session");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return 401 if session is invalid", async () => {
|
test("should return 401 if session is invalid", async () => {
|
||||||
const res = await app.request("/api/v1/system/info", {
|
const res = await app.request("/api/v1/system/info", {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: "session_id=invalid-session",
|
Cookie: "better-auth.session_token=invalid-session",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.message).toBe("Invalid or expired session");
|
expect(body.message).toBe("Invalid or expired session");
|
||||||
|
|
||||||
expect(res.headers.get("Set-Cookie")).toContain("session_id=;");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return 200 if session is valid", async () => {
|
test("should return 200 if session is valid", async () => {
|
||||||
const { sessionId } = await createTestSession();
|
const { token } = await createTestSession();
|
||||||
|
|
||||||
const res = await app.request("/api/v1/system/info", {
|
const res = await app.request("/api/v1/system/info", {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `session_id=${sessionId}`,
|
Cookie: `better-auth.session_token=${token}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -48,18 +46,18 @@ describe("system security", () => {
|
||||||
const res = await app.request(path, { method });
|
const res = await app.request(path, { method });
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.message).toBe("Authentication required");
|
expect(body.message).toBe("Invalid or expired session");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("input validation", () => {
|
describe("input validation", () => {
|
||||||
test("should return 400 for invalid payload on restic-password", async () => {
|
test("should return 400 for invalid payload on restic-password", async () => {
|
||||||
const { sessionId } = await createTestSession();
|
const { token } = await createTestSession();
|
||||||
const res = await app.request("/api/v1/system/restic-password", {
|
const res = await app.request("/api/v1/system/restic-password", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `session_id=${sessionId}`,
|
Cookie: `better-auth.session_token=${token}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
|
|
@ -69,11 +67,11 @@ describe("system security", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return 401 for incorrect password on restic-password", async () => {
|
test("should return 401 for incorrect password on restic-password", async () => {
|
||||||
const { sessionId } = await createTestSession();
|
const { token } = await createTestSession();
|
||||||
const res = await app.request("/api/v1/system/restic-password", {
|
const res = await app.request("/api/v1/system/restic-password", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `session_id=${sessionId}`,
|
Cookie: `better-auth.session_token=${token}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|
@ -83,7 +81,7 @@ describe("system security", () => {
|
||||||
|
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.message).toBe("Incorrect password");
|
expect(body.message).toBe("Invalid password");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { RESTIC_PASS_FILE } from "../../core/constants";
|
||||||
import { db } from "../../db/db";
|
import { db } from "../../db/db";
|
||||||
import { usersTable } from "../../db/schema";
|
import { usersTable } from "../../db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import { verifyUserPassword } from "../auth/helpers";
|
||||||
|
|
||||||
export const systemController = new Hono()
|
export const systemController = new Hono()
|
||||||
.use(requireAuth)
|
.use(requireAuth)
|
||||||
|
|
@ -35,16 +36,9 @@ export const systemController = new Hono()
|
||||||
const user = c.get("user");
|
const user = c.get("user");
|
||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
|
|
||||||
const [dbUser] = await db.select().from(usersTable).where(eq(usersTable.id, user.id));
|
const isPasswordValid = await verifyUserPassword({ password: body.password, userId: user.id });
|
||||||
|
if (!isPasswordValid) {
|
||||||
if (!dbUser) {
|
return c.json({ message: "Invalid password" }, 401);
|
||||||
return c.json({ message: "User not found" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValid = await Bun.password.verify(body.password, dbUser.passwordHash);
|
|
||||||
|
|
||||||
if (!isValid) {
|
|
||||||
return c.json({ message: "Incorrect password" }, 401);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -9,28 +9,26 @@ describe("volumes security", () => {
|
||||||
const res = await app.request("/api/v1/volumes");
|
const res = await app.request("/api/v1/volumes");
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.message).toBe("Authentication required");
|
expect(body.message).toBe("Invalid or expired session");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return 401 if session is invalid", async () => {
|
test("should return 401 if session is invalid", async () => {
|
||||||
const res = await app.request("/api/v1/volumes", {
|
const res = await app.request("/api/v1/volumes", {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: "session_id=invalid-session",
|
Cookie: "better-auth.session_token=invalid-session",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.message).toBe("Invalid or expired session");
|
expect(body.message).toBe("Invalid or expired session");
|
||||||
|
|
||||||
expect(res.headers.get("Set-Cookie")).toContain("session_id=;");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return 200 if session is valid", async () => {
|
test("should return 200 if session is valid", async () => {
|
||||||
const { sessionId } = await createTestSession();
|
const { token } = await createTestSession();
|
||||||
|
|
||||||
const res = await app.request("/api/v1/volumes", {
|
const res = await app.request("/api/v1/volumes", {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `session_id=${sessionId}`,
|
Cookie: `better-auth.session_token=${token}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -57,7 +55,7 @@ describe("volumes security", () => {
|
||||||
const res = await app.request(path, { method });
|
const res = await app.request(path, { method });
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.message).toBe("Authentication required");
|
expect(body.message).toBe("Invalid or expired session");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -67,16 +65,16 @@ describe("volumes security", () => {
|
||||||
const res = await app.request("/api/v1/volumes/non-existent-volume");
|
const res = await app.request("/api/v1/volumes/non-existent-volume");
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.message).toBe("Authentication required");
|
expect(body.message).toBe("Invalid or expired session");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("input validation", () => {
|
describe("input validation", () => {
|
||||||
test("should return 404 for non-existent volume", async () => {
|
test("should return 404 for non-existent volume", async () => {
|
||||||
const { sessionId } = await createTestSession();
|
const { token } = await createTestSession();
|
||||||
const res = await app.request("/api/v1/volumes/non-existent-volume", {
|
const res = await app.request("/api/v1/volumes/non-existent-volume", {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `session_id=${sessionId}`,
|
Cookie: `better-auth.session_token=${token}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -86,11 +84,11 @@ describe("volumes security", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return 400 for invalid payload on create", async () => {
|
test("should return 400 for invalid payload on create", async () => {
|
||||||
const { sessionId } = await createTestSession();
|
const { token } = await createTestSession();
|
||||||
const res = await app.request("/api/v1/volumes", {
|
const res = await app.request("/api/v1/volumes", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: `session_id=${sessionId}`,
|
Cookie: `better-auth.session_token=${token}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { RESTIC_PASS_FILE } from "../core/constants";
|
import { RESTIC_PASS_FILE } from "../core/constants";
|
||||||
import { isNodeJSErrnoException } from "./fs";
|
import { isNodeJSErrnoException } from "./fs";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
const hkdf = promisify(crypto.hkdf);
|
||||||
|
|
||||||
const algorithm = "aes-256-gcm" as const;
|
const algorithm = "aes-256-gcm" as const;
|
||||||
const keyLength = 32;
|
const keyLength = 32;
|
||||||
|
|
@ -227,9 +230,17 @@ const resolveSecretsDeep = async <T>(input: T): Promise<T> => {
|
||||||
|
|
||||||
return resolve(input) as Promise<T>;
|
return resolve(input) as Promise<T>;
|
||||||
};
|
};
|
||||||
|
async function deriveSecret(label: string) {
|
||||||
|
const masterSecret = await Bun.file(RESTIC_PASS_FILE).text();
|
||||||
|
|
||||||
|
const derivedKey = await hkdf("sha256", masterSecret, "", label, 32);
|
||||||
|
|
||||||
|
return Buffer.from(derivedKey).toString("hex");
|
||||||
|
}
|
||||||
|
|
||||||
export const cryptoUtils = {
|
export const cryptoUtils = {
|
||||||
resolveSecret,
|
resolveSecret,
|
||||||
sealSecret,
|
sealSecret,
|
||||||
resolveSecretsDeep,
|
resolveSecretsDeep,
|
||||||
|
deriveSecret,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { sanitizeSensitiveData } from "./sanitize";
|
||||||
|
|
||||||
const { printf, combine, colorize } = format;
|
const { printf, combine, colorize } = format;
|
||||||
|
|
||||||
const printConsole = printf((info) => `${info.level} > ${info.message}`);
|
const printConsole = printf((info) => `${info.level} > ${String(info.message)}`);
|
||||||
const consoleFormat = combine(colorize(), printConsole);
|
const consoleFormat = combine(colorize(), printConsole);
|
||||||
|
|
||||||
const getDefaultLevel = () => {
|
const getDefaultLevel = () => {
|
||||||
|
|
@ -27,7 +27,7 @@ const log = (level: "info" | "warn" | "error" | "debug", messages: unknown[]) =>
|
||||||
return sanitizeSensitiveData(JSON.stringify(m, null, 2));
|
return sanitizeSensitiveData(JSON.stringify(m, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
return sanitizeSensitiveData(String(m));
|
return sanitizeSensitiveData(String(JSON.stringify(m)));
|
||||||
});
|
});
|
||||||
|
|
||||||
winstonLogger.log(level, stringMessages.join(" "));
|
winstonLogger.log(level, stringMessages.join(" "));
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { $ } from "bun";
|
import { $ } from "bun";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
|
import { toMessage } from "./errors";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all configured rclone remotes
|
* List all configured rclone remotes
|
||||||
|
|
@ -9,7 +10,7 @@ export async function listRcloneRemotes(): Promise<string[]> {
|
||||||
const result = await $`rclone listremotes`.nothrow();
|
const result = await $`rclone listremotes`.nothrow();
|
||||||
|
|
||||||
if (result.exitCode !== 0) {
|
if (result.exitCode !== 0) {
|
||||||
logger.error(`Failed to list rclone remotes: ${result.stderr}`);
|
logger.error(`Failed to list rclone remotes: ${result.stderr.toString()}`);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,7 +37,7 @@ export async function getRcloneRemoteInfo(
|
||||||
const result = await $`rclone config show ${remote}`.quiet();
|
const result = await $`rclone config show ${remote}`.quiet();
|
||||||
|
|
||||||
if (result.exitCode !== 0) {
|
if (result.exitCode !== 0) {
|
||||||
logger.error(`Failed to get info for remote ${remote}: ${result.stderr}`);
|
logger.error(`Failed to get info for remote ${remote}: ${result.stderr.toString()}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,7 +71,7 @@ export async function getRcloneRemoteInfo(
|
||||||
|
|
||||||
return { type, config };
|
return { type, config };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error getting remote info for ${remote}: ${error}`);
|
logger.error(`Error getting remote info for ${remote}: ${toMessage(error)}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -362,8 +362,8 @@ const backup = async (
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
finally: async () => {
|
finally: async () => {
|
||||||
includeFile && (await fs.unlink(includeFile).catch(() => {}));
|
if (includeFile) await fs.unlink(includeFile).catch(() => {});
|
||||||
excludeFile && (await fs.unlink(excludeFile).catch(() => {}));
|
if (excludeFile) await fs.unlink(excludeFile).catch(() => {});
|
||||||
await cleanupTemporaryKeys(env);
|
await cleanupTemporaryKeys(env);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -397,7 +397,7 @@ const backup = async (
|
||||||
const result = backupOutputSchema(summaryLine);
|
const result = backupOutputSchema(summaryLine);
|
||||||
|
|
||||||
if (result instanceof type.errors) {
|
if (result instanceof type.errors) {
|
||||||
logger.error(`Restic backup output validation failed: ${result}`);
|
logger.error(`Restic backup output validation failed: ${result.summary}`);
|
||||||
return { result: null, exitCode: res.exitCode };
|
return { result: null, exitCode: res.exitCode };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -487,7 +487,7 @@ const restore = async (
|
||||||
const result = restoreOutputSchema(resSummary);
|
const result = restoreOutputSchema(resSummary);
|
||||||
|
|
||||||
if (result instanceof type.errors) {
|
if (result instanceof type.errors) {
|
||||||
logger.warn(`Restic restore output validation failed: ${result}`);
|
logger.warn(`Restic restore output validation failed: ${result.summary}`);
|
||||||
logger.info(`Restic restore completed for snapshot ${snapshotId} to target ${target}`);
|
logger.info(`Restic restore completed for snapshot ${snapshotId} to target ${target}`);
|
||||||
return {
|
return {
|
||||||
message_type: "summary" as const,
|
message_type: "summary" as const,
|
||||||
|
|
@ -531,8 +531,8 @@ const snapshots = async (config: RepositoryConfig, options: { tags?: string[] }
|
||||||
const result = snapshotInfoSchema.array()(JSON.parse(res.stdout));
|
const result = snapshotInfoSchema.array()(JSON.parse(res.stdout));
|
||||||
|
|
||||||
if (result instanceof type.errors) {
|
if (result instanceof type.errors) {
|
||||||
logger.error(`Restic snapshots output validation failed: ${result}`);
|
logger.error(`Restic snapshots output validation failed: ${result.summary}`);
|
||||||
throw new Error(`Restic snapshots output validation failed: ${result}`);
|
throw new Error(`Restic snapshots output validation failed: ${result.summary}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|
@ -710,8 +710,8 @@ const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) =
|
||||||
const snapshot = lsSnapshotInfoSchema(snapshotLine);
|
const snapshot = lsSnapshotInfoSchema(snapshotLine);
|
||||||
|
|
||||||
if (snapshot instanceof type.errors) {
|
if (snapshot instanceof type.errors) {
|
||||||
logger.error(`Restic ls snapshot info validation failed: ${snapshot}`);
|
logger.error(`Restic ls snapshot info validation failed: ${snapshot.summary}`);
|
||||||
throw new Error(`Restic ls snapshot info validation failed: ${snapshot}`);
|
throw new Error(`Restic ls snapshot info validation failed: ${snapshot.summary}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodes: Array<typeof lsNodeSchema.infer> = [];
|
const nodes: Array<typeof lsNodeSchema.infer> = [];
|
||||||
|
|
@ -720,7 +720,7 @@ const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) =
|
||||||
const nodeValidation = lsNodeSchema(nodeLine);
|
const nodeValidation = lsNodeSchema(nodeLine);
|
||||||
|
|
||||||
if (nodeValidation instanceof type.errors) {
|
if (nodeValidation instanceof type.errors) {
|
||||||
logger.warn(`Skipping invalid node: ${nodeValidation}`);
|
logger.warn(`Skipping invalid node: ${nodeValidation.summary}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,53 @@
|
||||||
import { authService } from "~/server/modules/auth/auth.service";
|
|
||||||
import { db } from "~/server/db/db";
|
import { db } from "~/server/db/db";
|
||||||
import { usersTable, sessionsTable } from "~/server/db/schema";
|
import { sessionsTable, usersTable, account } from "~/server/db/schema";
|
||||||
|
import { hashPassword } from "better-auth/crypto";
|
||||||
|
import { createHmac } from "node:crypto";
|
||||||
|
|
||||||
export async function createTestSession() {
|
export async function createTestSession() {
|
||||||
const [existingUser] = await db.select().from(usersTable);
|
const [existingUser] = await db.select().from(usersTable);
|
||||||
|
|
||||||
if (!existingUser) {
|
if (!existingUser) {
|
||||||
await authService.register("testadmin", "testpassword");
|
await db.insert(usersTable).values({
|
||||||
|
username: "testuser",
|
||||||
|
email: "test@test.com",
|
||||||
|
name: "Test User",
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const [user] = await db.select().from(usersTable);
|
const [user] = await db.select().from(usersTable);
|
||||||
|
|
||||||
const sessionId = crypto.randomUUID();
|
const token = crypto.randomUUID().replace(/-/g, "");
|
||||||
const expiresAt = Date.now() + 1000 * 60 * 60 * 24; // 24 hours
|
const sessionId = token;
|
||||||
|
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
await db.insert(sessionsTable).values({
|
await db.insert(sessionsTable).values({
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
|
token: token,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
return { sessionId, user };
|
// Better Auth signs the token using HMAC-SHA256 with the secret
|
||||||
|
// The secret is "test-secret" because we mocked cryptoUtils.deriveSecret
|
||||||
|
const signature = createHmac("sha256", "test-secret").update(token).digest("base64");
|
||||||
|
|
||||||
|
const signedToken = `${token}.${signature}`;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(account)
|
||||||
|
.values({
|
||||||
|
userId: user.id,
|
||||||
|
accountId: "testuser",
|
||||||
|
password: await hashPassword("password123"),
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
providerId: "credentials",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.onConflictDoNothing();
|
||||||
|
|
||||||
|
return { token: encodeURIComponent(signedToken), user };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,12 @@ import { beforeAll, mock } from "bun:test";
|
||||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { cwd } from "node:process";
|
import { cwd } from "node:process";
|
||||||
import { db } from "~/server/db/db";
|
import * as schema from "~/server/db/schema";
|
||||||
|
import { db, setSchema } from "~/server/db/db";
|
||||||
|
|
||||||
mock.module("~/server/utils/logger", () => ({
|
setSchema(schema);
|
||||||
|
|
||||||
|
void mock.module("~/server/utils/logger", () => ({
|
||||||
logger: {
|
logger: {
|
||||||
debug: () => {},
|
debug: () => {},
|
||||||
info: () => {},
|
info: () => {},
|
||||||
|
|
@ -13,6 +16,14 @@ mock.module("~/server/utils/logger", () => ({
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
void mock.module("~/server/utils/crypto", () => ({
|
||||||
|
cryptoUtils: {
|
||||||
|
deriveSecret: async () => "test-secret",
|
||||||
|
sealSecret: async (v: string) => v,
|
||||||
|
resolveSecret: async (v: string) => v,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const migrationsFolder = path.join(cwd(), "app", "drizzle");
|
const migrationsFolder = path.join(cwd(), "app", "drizzle");
|
||||||
migrate(db, { migrationsFolder });
|
migrate(db, { migrationsFolder });
|
||||||
|
|
|
||||||
42
biome.json
42
biome.json
|
|
@ -1,42 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
|
||||||
"vcs": {
|
|
||||||
"enabled": true,
|
|
||||||
"clientKind": "git",
|
|
||||||
"defaultBranch": "origin/main",
|
|
||||||
"useIgnoreFile": true
|
|
||||||
},
|
|
||||||
"files": {
|
|
||||||
"includes": ["**/*.{ts,tsx,json}", "!**/api-client", "!**/components/ui"],
|
|
||||||
"ignoreUnknown": false
|
|
||||||
},
|
|
||||||
"formatter": {
|
|
||||||
"enabled": true,
|
|
||||||
"indentStyle": "tab",
|
|
||||||
"lineWidth": 120
|
|
||||||
},
|
|
||||||
"linter": {
|
|
||||||
"enabled": true,
|
|
||||||
"rules": {
|
|
||||||
"recommended": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"javascript": {
|
|
||||||
"formatter": {
|
|
||||||
"quoteStyle": "double"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"assist": {
|
|
||||||
"enabled": true,
|
|
||||||
"actions": {
|
|
||||||
"source": {
|
|
||||||
"organizeImports": "off"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"css": {
|
|
||||||
"parser": {
|
|
||||||
"tailwindDirectives": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue