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,301 +1,301 @@
|
||||||
// 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,
|
||||||
createInterceptors,
|
createInterceptors,
|
||||||
getParseAs,
|
getParseAs,
|
||||||
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>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createClient = (config: Config = {}): Client => {
|
export const createClient = (config: Config = {}): Client => {
|
||||||
let _config = mergeConfigs(createConfig(), config);
|
let _config = mergeConfigs(createConfig(), config);
|
||||||
|
|
||||||
const getConfig = (): Config => ({ ..._config });
|
const getConfig = (): Config => ({ ..._config });
|
||||||
|
|
||||||
const setConfig = (config: Config): Config => {
|
const setConfig = (config: Config): Config => {
|
||||||
_config = mergeConfigs(_config, config);
|
_config = mergeConfigs(_config, config);
|
||||||
return getConfig();
|
return getConfig();
|
||||||
};
|
};
|
||||||
|
|
||||||
const interceptors = createInterceptors<
|
const interceptors = createInterceptors<
|
||||||
Request,
|
Request,
|
||||||
Response,
|
Response,
|
||||||
unknown,
|
unknown,
|
||||||
ResolvedRequestOptions
|
ResolvedRequestOptions
|
||||||
>();
|
>();
|
||||||
|
|
||||||
const beforeRequest = async (options: RequestOptions) => {
|
const beforeRequest = async (options: RequestOptions) => {
|
||||||
const opts = {
|
const opts = {
|
||||||
..._config,
|
..._config,
|
||||||
...options,
|
...options,
|
||||||
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
|
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
|
||||||
headers: mergeHeaders(_config.headers, options.headers),
|
headers: mergeHeaders(_config.headers, options.headers),
|
||||||
serializedBody: undefined,
|
serializedBody: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (opts.security) {
|
if (opts.security) {
|
||||||
await setAuthParams({
|
await setAuthParams({
|
||||||
...opts,
|
...opts,
|
||||||
security: opts.security,
|
security: opts.security,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.requestValidator) {
|
if (opts.requestValidator) {
|
||||||
await opts.requestValidator(opts);
|
await opts.requestValidator(opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.body !== undefined && opts.bodySerializer) {
|
if (opts.body !== undefined && opts.bodySerializer) {
|
||||||
opts.serializedBody = opts.bodySerializer(opts.body);
|
opts.serializedBody = opts.bodySerializer(opts.body);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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);
|
||||||
|
|
||||||
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),
|
||||||
};
|
};
|
||||||
|
|
||||||
let request = new Request(url, requestInit);
|
let request = new Request(url, requestInit);
|
||||||
|
|
||||||
for (const fn of interceptors.request.fns) {
|
for (const fn of interceptors.request.fns) {
|
||||||
if (fn) {
|
if (fn) {
|
||||||
request = await fn(request, opts);
|
request = await fn(request, opts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetch must be assigned here, otherwise it would throw the error:
|
// fetch must be assigned here, otherwise it would throw the error:
|
||||||
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
|
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
|
||||||
const _fetch = opts.fetch!;
|
const _fetch = opts.fetch!;
|
||||||
let response: Response;
|
let response: Response;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
response = await _fetch(request);
|
response = await _fetch(request);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Handle fetch exceptions (AbortError, network errors, etc.)
|
// Handle fetch exceptions (AbortError, network errors, etc.)
|
||||||
let finalError = error;
|
let finalError = error;
|
||||||
|
|
||||||
for (const fn of interceptors.error.fns) {
|
for (const fn of interceptors.error.fns) {
|
||||||
if (fn) {
|
if (fn) {
|
||||||
finalError = (await fn(
|
finalError = (await fn(
|
||||||
error,
|
error,
|
||||||
undefined as any,
|
undefined as any,
|
||||||
request,
|
request,
|
||||||
opts,
|
opts,
|
||||||
)) as unknown;
|
)) as unknown;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
finalError = finalError || ({} as unknown);
|
finalError = finalError || ({} as unknown);
|
||||||
|
|
||||||
if (opts.throwOnError) {
|
if (opts.throwOnError) {
|
||||||
throw finalError;
|
throw finalError;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return error response
|
// Return error response
|
||||||
return opts.responseStyle === 'data'
|
return opts.responseStyle === "data"
|
||||||
? undefined
|
? undefined
|
||||||
: {
|
: {
|
||||||
error: finalError,
|
error: finalError,
|
||||||
request,
|
request,
|
||||||
response: undefined as any,
|
response: undefined as any,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const fn of interceptors.response.fns) {
|
for (const fn of interceptors.response.fns) {
|
||||||
if (fn) {
|
if (fn) {
|
||||||
response = await fn(response, request, opts);
|
response = await fn(response, request, opts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
request,
|
request,
|
||||||
response,
|
response,
|
||||||
};
|
};
|
||||||
|
|
||||||
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,
|
||||||
...result,
|
...result,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
...result,
|
...result,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parseAs === 'json') {
|
if (parseAs === "json") {
|
||||||
if (opts.responseValidator) {
|
if (opts.responseValidator) {
|
||||||
await opts.responseValidator(data);
|
await opts.responseValidator(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.responseTransformer) {
|
if (opts.responseTransformer) {
|
||||||
data = await opts.responseTransformer(data);
|
data = await opts.responseTransformer(data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return opts.responseStyle === 'data'
|
return opts.responseStyle === "data"
|
||||||
? data
|
? data
|
||||||
: {
|
: {
|
||||||
data,
|
data,
|
||||||
...result,
|
...result,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const textError = await response.text();
|
const textError = await response.text();
|
||||||
let jsonError: unknown;
|
let jsonError: unknown;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
jsonError = JSON.parse(textError);
|
jsonError = JSON.parse(textError);
|
||||||
} catch {
|
} catch {
|
||||||
// noop
|
// noop
|
||||||
}
|
}
|
||||||
|
|
||||||
const error = jsonError ?? textError;
|
const error = jsonError ?? textError;
|
||||||
let finalError = error;
|
let finalError = error;
|
||||||
|
|
||||||
for (const fn of interceptors.error.fns) {
|
for (const fn of interceptors.error.fns) {
|
||||||
if (fn) {
|
if (fn) {
|
||||||
finalError = (await fn(error, response, request, opts)) as string;
|
finalError = (await fn(error, response, request, opts)) as string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
finalError = finalError || ({} as string);
|
finalError = finalError || ({} as string);
|
||||||
|
|
||||||
if (opts.throwOnError) {
|
if (opts.throwOnError) {
|
||||||
throw finalError;
|
throw finalError;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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,
|
||||||
...result,
|
...result,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const makeMethodFn =
|
const makeMethodFn =
|
||||||
(method: Uppercase<HttpMethod>) => (options: RequestOptions) =>
|
(method: Uppercase<HttpMethod>) => (options: RequestOptions) =>
|
||||||
request({ ...options, method });
|
request({ ...options, method });
|
||||||
|
|
||||||
const makeSseFn =
|
const makeSseFn =
|
||||||
(method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
|
(method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
|
||||||
const { opts, url } = await beforeRequest(options);
|
const { opts, url } = await beforeRequest(options);
|
||||||
return createSseClient({
|
return createSseClient({
|
||||||
...opts,
|
...opts,
|
||||||
body: opts.body as BodyInit | null | undefined,
|
body: opts.body as BodyInit | null | undefined,
|
||||||
headers: opts.headers as unknown as Record<string, string>,
|
headers: opts.headers as unknown as Record<string, string>,
|
||||||
method,
|
method,
|
||||||
onRequest: async (url, init) => {
|
onRequest: async (url, init) => {
|
||||||
let request = new Request(url, init);
|
let request = new Request(url, init);
|
||||||
for (const fn of interceptors.request.fns) {
|
for (const fn of interceptors.request.fns) {
|
||||||
if (fn) {
|
if (fn) {
|
||||||
request = await fn(request, opts);
|
request = await fn(request, opts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return request;
|
return request;
|
||||||
},
|
},
|
||||||
url,
|
url,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
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,25 +1,25 @@
|
||||||
// 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,
|
||||||
Config,
|
Config,
|
||||||
CreateClientConfig,
|
CreateClientConfig,
|
||||||
Options,
|
Options,
|
||||||
RequestOptions,
|
RequestOptions,
|
||||||
RequestResult,
|
RequestResult,
|
||||||
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,210 +1,210 @@
|
||||||
// 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.
|
||||||
*
|
*
|
||||||
* @default globalThis.fetch
|
* @default globalThis.fetch
|
||||||
*/
|
*/
|
||||||
fetch?: typeof fetch;
|
fetch?: typeof fetch;
|
||||||
/**
|
/**
|
||||||
* Please don't use the Fetch client for Next.js applications. The `next`
|
* Please don't use the Fetch client for Next.js applications. The `next`
|
||||||
* options won't have any effect.
|
* options won't have any effect.
|
||||||
*
|
*
|
||||||
* Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead.
|
* Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead.
|
||||||
*/
|
*/
|
||||||
next?: never;
|
next?: never;
|
||||||
/**
|
/**
|
||||||
* Return the response data parsed in a specified format. By default, `auto`
|
* Return the response data parsed in a specified format. By default, `auto`
|
||||||
* will infer the appropriate method from the `Content-Type` response header.
|
* will infer the appropriate method from the `Content-Type` response header.
|
||||||
* You can override this behavior with any of the {@link Body} methods.
|
* You can override this behavior with any of the {@link Body} methods.
|
||||||
* Select `stream` if you don't want to parse response data at all.
|
* Select `stream` if you don't want to parse response data at all.
|
||||||
*
|
*
|
||||||
* @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.)?
|
||||||
*
|
*
|
||||||
* @default 'fields'
|
* @default 'fields'
|
||||||
*/
|
*/
|
||||||
responseStyle?: ResponseStyle;
|
responseStyle?: ResponseStyle;
|
||||||
/**
|
/**
|
||||||
* Throw an error instead of returning it in the response?
|
* Throw an error instead of returning it in the response?
|
||||||
*
|
*
|
||||||
* @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<{
|
||||||
responseStyle: TResponseStyle;
|
responseStyle: TResponseStyle;
|
||||||
throwOnError: ThrowOnError;
|
throwOnError: ThrowOnError;
|
||||||
}>,
|
}>,
|
||||||
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.
|
||||||
*
|
*
|
||||||
* {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
|
* {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
|
||||||
*/
|
*/
|
||||||
body?: unknown;
|
body?: unknown;
|
||||||
path?: Record<string, unknown>;
|
path?: Record<string, unknown>;
|
||||||
query?: Record<string, unknown>;
|
query?: Record<string, unknown>;
|
||||||
/**
|
/**
|
||||||
* Security mechanism(s) to use for the request.
|
* Security mechanism(s) to use for the request.
|
||||||
*/
|
*/
|
||||||
security?: ReadonlyArray<Auth>;
|
security?: ReadonlyArray<Auth>;
|
||||||
url: Url;
|
url: Url;
|
||||||
}
|
}
|
||||||
|
|
||||||
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> {
|
||||||
serializedBody?: string;
|
serializedBody?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RequestResult<
|
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
|
||||||
: {
|
: {
|
||||||
data: TData extends Record<string, unknown>
|
data: TData extends Record<string, unknown>
|
||||||
? TData[keyof TData]
|
? TData[keyof TData]
|
||||||
: TData;
|
: TData;
|
||||||
request: Request;
|
request: Request;
|
||||||
response: Response;
|
response: Response;
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
: 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)
|
||||||
| undefined
|
| undefined
|
||||||
: (
|
: (
|
||||||
| {
|
| {
|
||||||
data: TData extends Record<string, unknown>
|
data: TData extends Record<string, unknown>
|
||||||
? TData[keyof TData]
|
? TData[keyof TData]
|
||||||
: TData;
|
: TData;
|
||||||
error: undefined;
|
error: undefined;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
data: undefined;
|
data: undefined;
|
||||||
error: TError extends Record<string, unknown>
|
error: TError extends Record<string, unknown>
|
||||||
? TError[keyof TError]
|
? TError[keyof TError]
|
||||||
: TError;
|
: TError;
|
||||||
}
|
}
|
||||||
) & {
|
) & {
|
||||||
request: Request;
|
request: Request;
|
||||||
response: Response;
|
response: Response;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export interface ClientOptions {
|
export interface ClientOptions {
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
responseStyle?: ResponseStyle;
|
responseStyle?: ResponseStyle;
|
||||||
throwOnError?: boolean;
|
throwOnError?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type MethodFn = <
|
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>;
|
||||||
|
|
||||||
type BuildUrlFn = <
|
type BuildUrlFn = <
|
||||||
TData extends {
|
TData extends {
|
||||||
body?: unknown;
|
body?: unknown;
|
||||||
path?: Record<string, unknown>;
|
path?: Record<string, unknown>;
|
||||||
query?: Record<string, unknown>;
|
query?: Record<string, unknown>;
|
||||||
url: string;
|
url: string;
|
||||||
},
|
},
|
||||||
>(
|
>(
|
||||||
options: TData & Options<TData>,
|
options: TData & Options<TData>,
|
||||||
) => string;
|
) => string;
|
||||||
|
|
||||||
export type Client = CoreClient<
|
export type Client = CoreClient<
|
||||||
RequestFn,
|
RequestFn,
|
||||||
Config,
|
Config,
|
||||||
MethodFn,
|
MethodFn,
|
||||||
BuildUrlFn,
|
BuildUrlFn,
|
||||||
SseFn
|
SseFn
|
||||||
> & {
|
> & {
|
||||||
interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>;
|
interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -216,26 +216,26 @@ export type Client = CoreClient<
|
||||||
* to ensure your client always has the correct values.
|
* to ensure your client always has the correct values.
|
||||||
*/
|
*/
|
||||||
export type CreateClientConfig<T extends ClientOptions = ClientOptions> = (
|
export type CreateClientConfig<T extends ClientOptions = ClientOptions> = (
|
||||||
override?: Config<ClientOptions & T>,
|
override?: Config<ClientOptions & T>,
|
||||||
) => Config<Required<ClientOptions> & T>;
|
) => Config<Required<ClientOptions> & T>;
|
||||||
|
|
||||||
export interface TDataShape {
|
export interface TDataShape {
|
||||||
body?: unknown;
|
body?: unknown;
|
||||||
headers?: unknown;
|
headers?: unknown;
|
||||||
path?: unknown;
|
path?: unknown;
|
||||||
query?: unknown;
|
query?: unknown;
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>;
|
type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>;
|
||||||
|
|
||||||
export type Options<
|
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,332 +1,337 @@
|
||||||
// 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 = {},
|
||||||
...args
|
...args
|
||||||
}: 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];
|
||||||
|
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined || value === null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = parameters[name] || args;
|
const options = parameters[name] || args;
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
const serializedArray = serializeArrayParam({
|
const serializedArray = serializeArrayParam({
|
||||||
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,
|
||||||
});
|
});
|
||||||
if (serializedObject) search.push(serializedObject);
|
if (serializedObject) search.push(serializedObject);
|
||||||
} else {
|
} else {
|
||||||
const serializedPrimitive = serializePrimitiveParam({
|
const serializedPrimitive = serializePrimitiveParam({
|
||||||
allowReserved: options.allowReserved,
|
allowReserved: options.allowReserved,
|
||||||
name,
|
name,
|
||||||
value: value as string,
|
value: value as string,
|
||||||
});
|
});
|
||||||
if (serializedPrimitive) search.push(serializedPrimitive);
|
if (serializedPrimitive) search.push(serializedPrimitive);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return search.join('&');
|
return search.join("&");
|
||||||
};
|
};
|
||||||
return querySerializer;
|
return querySerializer;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Infers parseAs value from provided Content-Type header.
|
* Infers parseAs value from provided Content-Type header.
|
||||||
*/
|
*/
|
||||||
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,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
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;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
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) {
|
||||||
if (checkForExistence(options, auth.name)) {
|
if (checkForExistence(options, auth.name)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = await getAuthToken(auth, options.auth);
|
const token = await getAuthToken(auth, options.auth);
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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,
|
||||||
});
|
});
|
||||||
|
|
||||||
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);
|
||||||
return config;
|
return config;
|
||||||
};
|
};
|
||||||
|
|
||||||
const headersEntries = (headers: Headers): Array<[string, string]> => {
|
const headersEntries = (headers: Headers): Array<[string, string]> => {
|
||||||
const entries: Array<[string, string]> = [];
|
const entries: Array<[string, string]> = [];
|
||||||
headers.forEach((value, key) => {
|
headers.forEach((value, key) => {
|
||||||
entries.push([key, value]);
|
entries.push([key, value]);
|
||||||
});
|
});
|
||||||
return entries;
|
return entries;
|
||||||
};
|
};
|
||||||
|
|
||||||
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) {
|
||||||
if (!header) {
|
if (!header) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const iterator =
|
const iterator =
|
||||||
header instanceof Headers
|
header instanceof Headers
|
||||||
? headersEntries(header)
|
? headersEntries(header)
|
||||||
: Object.entries(header);
|
: Object.entries(header);
|
||||||
|
|
||||||
for (const [key, value] of iterator) {
|
for (const [key, value] of iterator) {
|
||||||
if (value === null) {
|
if (value === null) {
|
||||||
mergedHeaders.delete(key);
|
mergedHeaders.delete(key);
|
||||||
} else if (Array.isArray(value)) {
|
} else if (Array.isArray(value)) {
|
||||||
for (const v of value) {
|
for (const v of value) {
|
||||||
mergedHeaders.append(key, v as string);
|
mergedHeaders.append(key, v as string);
|
||||||
}
|
}
|
||||||
} else if (value !== undefined) {
|
} else if (value !== undefined) {
|
||||||
// assume object headers are meant to be JSON stringified, i.e. their
|
// assume object headers are meant to be JSON stringified, i.e. their
|
||||||
// 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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return mergedHeaders;
|
return mergedHeaders;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ErrInterceptor<Err, Res, Req, Options> = (
|
type ErrInterceptor<Err, Res, Req, Options> = (
|
||||||
error: Err,
|
error: Err,
|
||||||
response: Res,
|
response: Res,
|
||||||
request: Req,
|
request: Req,
|
||||||
options: Options,
|
options: Options,
|
||||||
) => Err | Promise<Err>;
|
) => Err | Promise<Err>;
|
||||||
|
|
||||||
type ReqInterceptor<Req, Options> = (
|
type ReqInterceptor<Req, Options> = (
|
||||||
request: Req,
|
request: Req,
|
||||||
options: Options,
|
options: Options,
|
||||||
) => Req | Promise<Req>;
|
) => Req | Promise<Req>;
|
||||||
|
|
||||||
type ResInterceptor<Res, Req, Options> = (
|
type ResInterceptor<Res, Req, Options> = (
|
||||||
response: Res,
|
response: Res,
|
||||||
request: Req,
|
request: Req,
|
||||||
options: Options,
|
options: Options,
|
||||||
) => Res | Promise<Res>;
|
) => Res | Promise<Res>;
|
||||||
|
|
||||||
class Interceptors<Interceptor> {
|
class Interceptors<Interceptor> {
|
||||||
fns: Array<Interceptor | null> = [];
|
fns: Array<Interceptor | null> = [];
|
||||||
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
this.fns = [];
|
this.fns = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
eject(id: number | Interceptor): void {
|
eject(id: number | Interceptor): void {
|
||||||
const index = this.getInterceptorIndex(id);
|
const index = this.getInterceptorIndex(id);
|
||||||
if (this.fns[index]) {
|
if (this.fns[index]) {
|
||||||
this.fns[index] = null;
|
this.fns[index] = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
exists(id: number | Interceptor): boolean {
|
exists(id: number | Interceptor): boolean {
|
||||||
const index = this.getInterceptorIndex(id);
|
const index = this.getInterceptorIndex(id);
|
||||||
return Boolean(this.fns[index]);
|
return Boolean(this.fns[index]);
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
update(
|
update(
|
||||||
id: number | Interceptor,
|
id: number | Interceptor,
|
||||||
fn: Interceptor,
|
fn: Interceptor,
|
||||||
): number | Interceptor | false {
|
): number | Interceptor | false {
|
||||||
const index = this.getInterceptorIndex(id);
|
const index = this.getInterceptorIndex(id);
|
||||||
if (this.fns[index]) {
|
if (this.fns[index]) {
|
||||||
this.fns[index] = fn;
|
this.fns[index] = fn;
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
use(fn: Interceptor): number {
|
use(fn: Interceptor): number {
|
||||||
this.fns.push(fn);
|
this.fns.push(fn);
|
||||||
return this.fns.length - 1;
|
return this.fns.length - 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Middleware<Req, Res, Err, Options> {
|
export interface Middleware<Req, Res, Err, Options> {
|
||||||
error: Interceptors<ErrInterceptor<Err, Res, Req, Options>>;
|
error: Interceptors<ErrInterceptor<Err, Res, Req, Options>>;
|
||||||
request: Interceptors<ReqInterceptor<Req, Options>>;
|
request: Interceptors<ReqInterceptor<Req, Options>>;
|
||||||
response: Interceptors<ResInterceptor<Res, Req, Options>>;
|
response: Interceptors<ResInterceptor<Res, Req, Options>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createInterceptors = <Req, Res, Err, Options>(): Middleware<
|
export const createInterceptors = <Req, Res, Err, Options>(): Middleware<
|
||||||
Req,
|
Req,
|
||||||
Res,
|
Res,
|
||||||
Err,
|
Err,
|
||||||
Options
|
Options
|
||||||
> => ({
|
> => ({
|
||||||
error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(),
|
error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(),
|
||||||
request: new Interceptors<ReqInterceptor<Req, Options>>(),
|
request: new Interceptors<ReqInterceptor<Req, Options>>(),
|
||||||
response: new Interceptors<ResInterceptor<Res, Req, Options>>(),
|
response: new Interceptors<ResInterceptor<Res, Req, Options>>(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const defaultQuerySerializer = createQuerySerializer({
|
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>(
|
||||||
override: Config<Omit<ClientOptions, keyof T> & T> = {},
|
override: Config<Omit<ClientOptions, keyof T> & T> = {},
|
||||||
): 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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,40 +3,40 @@
|
||||||
export type AuthToken = string | undefined;
|
export type AuthToken = string | undefined;
|
||||||
|
|
||||||
export interface Auth {
|
export interface Auth {
|
||||||
/**
|
/**
|
||||||
* Which part of the request do we use to send the auth?
|
* Which part of the request do we use to send the 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 (
|
||||||
auth: Auth,
|
auth: Auth,
|
||||||
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)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return token;
|
return token;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,100 +1,100 @@
|
||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
import type {
|
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;
|
||||||
|
|
||||||
export type BodySerializer = (body: any) => any;
|
export type BodySerializer = (body: any) => any;
|
||||||
|
|
||||||
type QuerySerializerOptionsObject = {
|
type QuerySerializerOptionsObject = {
|
||||||
allowReserved?: boolean;
|
allowReserved?: boolean;
|
||||||
array?: Partial<SerializerOptions<ArrayStyle>>;
|
array?: Partial<SerializerOptions<ArrayStyle>>;
|
||||||
object?: Partial<SerializerOptions<ObjectStyle>>;
|
object?: Partial<SerializerOptions<ObjectStyle>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type QuerySerializerOptions = QuerySerializerOptionsObject & {
|
export type QuerySerializerOptions = QuerySerializerOptionsObject & {
|
||||||
/**
|
/**
|
||||||
* Per-parameter serialization overrides. When provided, these settings
|
* Per-parameter serialization overrides. When provided, these settings
|
||||||
* override the global array/object settings for specific parameter names.
|
* override the global array/object settings for specific parameter names.
|
||||||
*/
|
*/
|
||||||
parameters?: Record<string, QuerySerializerOptionsObject>;
|
parameters?: Record<string, QuerySerializerOptionsObject>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const serializeFormDataPair = (
|
const serializeFormDataPair = (
|
||||||
data: FormData,
|
data: FormData,
|
||||||
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());
|
||||||
} else {
|
} else {
|
||||||
data.append(key, JSON.stringify(value));
|
data.append(key, JSON.stringify(value));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const serializeUrlSearchParamsPair = (
|
const serializeUrlSearchParamsPair = (
|
||||||
data: URLSearchParams,
|
data: URLSearchParams,
|
||||||
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));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formDataBodySerializer = {
|
export const formDataBodySerializer = {
|
||||||
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
|
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
|
||||||
body: T,
|
body: T,
|
||||||
): FormData => {
|
): FormData => {
|
||||||
const data = new FormData();
|
const data = new FormData();
|
||||||
|
|
||||||
Object.entries(body).forEach(([key, value]) => {
|
Object.entries(body).forEach(([key, value]) => {
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined || value === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
value.forEach((v) => serializeFormDataPair(data, key, v));
|
value.forEach((v) => serializeFormDataPair(data, key, v));
|
||||||
} else {
|
} else {
|
||||||
serializeFormDataPair(data, key, value);
|
serializeFormDataPair(data, key, value);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
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,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const urlSearchParamsBodySerializer = {
|
export const urlSearchParamsBodySerializer = {
|
||||||
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
|
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
|
||||||
body: T,
|
body: T,
|
||||||
): string => {
|
): string => {
|
||||||
const data = new URLSearchParams();
|
const data = new URLSearchParams();
|
||||||
|
|
||||||
Object.entries(body).forEach(([key, value]) => {
|
Object.entries(body).forEach(([key, value]) => {
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined || value === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
value.forEach((v) => serializeUrlSearchParamsPair(data, key, v));
|
value.forEach((v) => serializeUrlSearchParamsPair(data, key, v));
|
||||||
} else {
|
} else {
|
||||||
serializeUrlSearchParamsPair(data, key, value);
|
serializeUrlSearchParamsPair(data, key, value);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return data.toString();
|
return data.toString();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,176 +1,176 @@
|
||||||
// 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.
|
||||||
*/
|
*/
|
||||||
key: string;
|
key: string;
|
||||||
/**
|
/**
|
||||||
* Field mapped name. This is the name we want to use in the request.
|
* Field mapped name. This is the name we want to use in the request.
|
||||||
* If omitted, we use the same value as `key`.
|
* If omitted, we use the same value as `key`.
|
||||||
*/
|
*/
|
||||||
map?: string;
|
map?: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
in: Extract<Slot, 'body'>;
|
in: Extract<Slot, "body">;
|
||||||
/**
|
/**
|
||||||
* Key isn't required for bodies.
|
* Key isn't required for bodies.
|
||||||
*/
|
*/
|
||||||
key?: string;
|
key?: string;
|
||||||
map?: string;
|
map?: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
/**
|
/**
|
||||||
* 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.
|
||||||
*/
|
*/
|
||||||
key: string;
|
key: string;
|
||||||
/**
|
/**
|
||||||
* Field mapped name. This is the name we want to use in the request.
|
* Field mapped name. This is the name we want to use in the request.
|
||||||
* If `in` is omitted, `map` aliases `key` to the transport layer.
|
* If `in` is omitted, `map` aliases `key` to the transport layer.
|
||||||
*/
|
*/
|
||||||
map: Slot;
|
map: Slot;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface Fields {
|
export interface Fields {
|
||||||
allowExtra?: Partial<Record<Slot, boolean>>;
|
allowExtra?: Partial<Record<Slot, boolean>>;
|
||||||
args?: ReadonlyArray<Field>;
|
args?: ReadonlyArray<Field>;
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
type KeyMap = Map<
|
type KeyMap = Map<
|
||||||
string,
|
string,
|
||||||
| {
|
| {
|
||||||
in: Slot;
|
in: Slot;
|
||||||
map?: string;
|
map?: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
in?: never;
|
in?: never;
|
||||||
map: Slot;
|
map: Slot;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|
||||||
const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
|
const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
|
||||||
if (!map) {
|
if (!map) {
|
||||||
map = new Map();
|
map = new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
});
|
});
|
||||||
} else if (config.args) {
|
} else if (config.args) {
|
||||||
buildKeyMap(config.args, map);
|
buildKeyMap(config.args, map);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return map;
|
return map;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Params {
|
interface Params {
|
||||||
body: unknown;
|
body: unknown;
|
||||||
headers: Record<string, unknown>;
|
headers: Record<string, unknown>;
|
||||||
path: Record<string, unknown>;
|
path: Record<string, unknown>;
|
||||||
query: Record<string, unknown>;
|
query: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
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];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildClientParams = (
|
export const buildClientParams = (
|
||||||
args: ReadonlyArray<unknown>,
|
args: ReadonlyArray<unknown>,
|
||||||
fields: FieldsConfig,
|
fields: FieldsConfig,
|
||||||
) => {
|
) => {
|
||||||
const params: Params = {
|
const params: Params = {
|
||||||
body: {},
|
body: {},
|
||||||
headers: {},
|
headers: {},
|
||||||
path: {},
|
path: {},
|
||||||
query: {},
|
query: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const map = buildKeyMap(fields);
|
const map = buildKeyMap(fields);
|
||||||
|
|
||||||
let config: FieldsConfig[number] | undefined;
|
let config: FieldsConfig[number] | undefined;
|
||||||
|
|
||||||
for (const [index, arg] of args.entries()) {
|
for (const [index, arg] of args.entries()) {
|
||||||
if (fields[index]) {
|
if (fields[index]) {
|
||||||
config = fields[index];
|
config = fields[index];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
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;
|
||||||
if (field.in) {
|
if (field.in) {
|
||||||
(params[field.in] as Record<string, unknown>)[name] = arg;
|
(params[field.in] as Record<string, unknown>)[name] = arg;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
params.body = arg;
|
params.body = arg;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (const [key, value] of Object.entries(arg ?? {})) {
|
for (const [key, value] of Object.entries(arg ?? {})) {
|
||||||
const field = map.get(key);
|
const field = map.get(key);
|
||||||
|
|
||||||
if (field) {
|
if (field) {
|
||||||
if (field.in) {
|
if (field.in) {
|
||||||
const name = field.map || key;
|
const name = field.map || key;
|
||||||
(params[field.in] as Record<string, unknown>)[name] = value;
|
(params[field.in] as Record<string, unknown>)[name] = value;
|
||||||
} else {
|
} else {
|
||||||
params[field.map] = value;
|
params[field.map] = value;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const extra = extraPrefixes.find(([prefix]) =>
|
const extra = extraPrefixes.find(([prefix]) =>
|
||||||
key.startsWith(prefix),
|
key.startsWith(prefix),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (extra) {
|
if (extra) {
|
||||||
const [prefix, slot] = extra;
|
const [prefix, slot] = extra;
|
||||||
(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;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stripEmptySlots(params);
|
stripEmptySlots(params);
|
||||||
|
|
||||||
return params;
|
return params;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,181 +1,181 @@
|
||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
interface SerializeOptions<T>
|
interface SerializeOptions<T>
|
||||||
extends SerializePrimitiveOptions,
|
extends SerializePrimitiveOptions,
|
||||||
SerializerOptions<T> {}
|
SerializerOptions<T> {}
|
||||||
|
|
||||||
interface SerializePrimitiveOptions {
|
interface SerializePrimitiveOptions {
|
||||||
allowReserved?: boolean;
|
allowReserved?: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SerializerOptions<T> {
|
export interface SerializerOptions<T> {
|
||||||
/**
|
/**
|
||||||
* @default true
|
* @default true
|
||||||
*/
|
*/
|
||||||
explode: boolean;
|
explode: boolean;
|
||||||
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 {
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 "&";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const serializeArrayParam = ({
|
export const serializeArrayParam = ({
|
||||||
allowReserved,
|
allowReserved,
|
||||||
explode,
|
explode,
|
||||||
name,
|
name,
|
||||||
style,
|
style,
|
||||||
value,
|
value,
|
||||||
}: SerializeOptions<ArraySeparatorStyle> & {
|
}: SerializeOptions<ArraySeparatorStyle> & {
|
||||||
value: unknown[];
|
value: unknown[];
|
||||||
}) => {
|
}) => {
|
||||||
if (!explode) {
|
if (!explode) {
|
||||||
const joinedValues = (
|
const joinedValues = (
|
||||||
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}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
return serializePrimitiveParam({
|
return serializePrimitiveParam({
|
||||||
allowReserved,
|
allowReserved,
|
||||||
name,
|
name,
|
||||||
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const serializePrimitiveParam = ({
|
export const serializePrimitiveParam = ({
|
||||||
allowReserved,
|
allowReserved,
|
||||||
name,
|
name,
|
||||||
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.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${name}=${allowReserved ? value : encodeURIComponent(value)}`;
|
return `${name}=${allowReserved ? value : encodeURIComponent(value)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const serializeObjectParam = ({
|
export const serializeObjectParam = ({
|
||||||
allowReserved,
|
allowReserved,
|
||||||
explode,
|
explode,
|
||||||
name,
|
name,
|
||||||
style,
|
style,
|
||||||
value,
|
value,
|
||||||
valueOnly,
|
valueOnly,
|
||||||
}: SerializeOptions<ObjectSeparatorStyle> & {
|
}: SerializeOptions<ObjectSeparatorStyle> & {
|
||||||
value: Record<string, unknown> | Date;
|
value: Record<string, unknown> | Date;
|
||||||
valueOnly?: boolean;
|
valueOnly?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
if (value instanceof Date) {
|
if (value instanceof Date) {
|
||||||
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 = [
|
||||||
...values,
|
...values,
|
||||||
key,
|
key,
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const separator = separatorObjectExplode(style);
|
const separator = separatorObjectExplode(style);
|
||||||
const joinedValues = Object.entries(value)
|
const joinedValues = Object.entries(value)
|
||||||
.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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,133 +4,133 @@
|
||||||
* JSON-friendly union that mirrors what Pinia Colada can hash.
|
* JSON-friendly union that mirrors what Pinia Colada can hash.
|
||||||
*/
|
*/
|
||||||
export type JsonValue =
|
export type JsonValue =
|
||||||
| null
|
| null
|
||||||
| string
|
| string
|
||||||
| number
|
| number
|
||||||
| boolean
|
| boolean
|
||||||
| JsonValue[]
|
| JsonValue[]
|
||||||
| { [key: string]: JsonValue };
|
| { [key: string]: JsonValue };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes.
|
* Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes.
|
||||||
*/
|
*/
|
||||||
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) {
|
||||||
return value.toISOString();
|
return value.toISOString();
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Safely stringifies a value and parses it back into a JsonValue.
|
* Safely stringifies a value and parses it back into a JsonValue.
|
||||||
*/
|
*/
|
||||||
export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => {
|
export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => {
|
||||||
try {
|
try {
|
||||||
const json = JSON.stringify(input, queryKeyJsonReplacer);
|
const json = JSON.stringify(input, queryKeyJsonReplacer);
|
||||||
if (json === undefined) {
|
if (json === undefined) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return JSON.parse(json) as JsonValue;
|
return JSON.parse(json) as JsonValue;
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
return 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);
|
||||||
return prototype === Object.prototype || prototype === null;
|
return prototype === Object.prototype || prototype === null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns URLSearchParams into a sorted JSON object for deterministic keys.
|
* Turns URLSearchParams into a sorted JSON object for deterministic keys.
|
||||||
*/
|
*/
|
||||||
const serializeSearchParams = (params: URLSearchParams): JsonValue => {
|
const serializeSearchParams = (params: URLSearchParams): JsonValue => {
|
||||||
const entries = Array.from(params.entries()).sort(([a], [b]) =>
|
const entries = Array.from(params.entries()).sort(([a], [b]) =>
|
||||||
a.localeCompare(b),
|
a.localeCompare(b),
|
||||||
);
|
);
|
||||||
const result: Record<string, JsonValue> = {};
|
const result: Record<string, JsonValue> = {};
|
||||||
|
|
||||||
for (const [key, value] of entries) {
|
for (const [key, value] of entries) {
|
||||||
const existing = result[key];
|
const existing = result[key];
|
||||||
if (existing === undefined) {
|
if (existing === undefined) {
|
||||||
result[key] = value;
|
result[key] = value;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(existing)) {
|
if (Array.isArray(existing)) {
|
||||||
(existing as string[]).push(value);
|
(existing as string[]).push(value);
|
||||||
} else {
|
} else {
|
||||||
result[key] = [existing, value];
|
result[key] = [existing, value];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalizes any accepted value into a JSON-friendly shape for query keys.
|
* Normalizes any accepted value into a JSON-friendly shape for query keys.
|
||||||
*/
|
*/
|
||||||
export const serializeQueryKeyValue = (
|
export const serializeQueryKeyValue = (
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): JsonValue | undefined => {
|
): JsonValue | undefined => {
|
||||||
if (value === null) {
|
if (value === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value instanceof Date) {
|
if (value instanceof Date) {
|
||||||
return value.toISOString();
|
return value.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
return stringifyToJsonValue(value);
|
return stringifyToJsonValue(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof URLSearchParams !== 'undefined' &&
|
typeof URLSearchParams !== "undefined" &&
|
||||||
value instanceof URLSearchParams
|
value instanceof URLSearchParams
|
||||||
) {
|
) {
|
||||||
return serializeSearchParams(value);
|
return serializeSearchParams(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPlainObject(value)) {
|
if (isPlainObject(value)) {
|
||||||
return stringifyToJsonValue(value);
|
return stringifyToJsonValue(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,266 +1,266 @@
|
||||||
// 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.
|
||||||
*
|
*
|
||||||
* @default globalThis.fetch
|
* @default globalThis.fetch
|
||||||
*/
|
*/
|
||||||
fetch?: typeof fetch;
|
fetch?: typeof fetch;
|
||||||
/**
|
/**
|
||||||
* Implementing clients can call request interceptors inside this hook.
|
* Implementing clients can call request interceptors inside this hook.
|
||||||
*/
|
*/
|
||||||
onRequest?: (url: string, init: RequestInit) => Promise<Request>;
|
onRequest?: (url: string, init: RequestInit) => Promise<Request>;
|
||||||
/**
|
/**
|
||||||
* Callback invoked when a network or parsing error occurs during streaming.
|
* Callback invoked when a network or parsing error occurs during streaming.
|
||||||
*
|
*
|
||||||
* This option applies only if the endpoint returns a stream of events.
|
* This option applies only if the endpoint returns a stream of events.
|
||||||
*
|
*
|
||||||
* @param error The error that occurred.
|
* @param error The error that occurred.
|
||||||
*/
|
*/
|
||||||
onSseError?: (error: unknown) => void;
|
onSseError?: (error: unknown) => void;
|
||||||
/**
|
/**
|
||||||
* Callback invoked when an event is streamed from the server.
|
* Callback invoked when an event is streamed from the server.
|
||||||
*
|
*
|
||||||
* This option applies only if the endpoint returns a stream of events.
|
* This option applies only if the endpoint returns a stream of events.
|
||||||
*
|
*
|
||||||
* @param event Event streamed from the server.
|
* @param event Event streamed from the server.
|
||||||
* @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.
|
||||||
*
|
*
|
||||||
* This option applies only if the endpoint returns a stream of events.
|
* This option applies only if the endpoint returns a stream of events.
|
||||||
*
|
*
|
||||||
* @default 3000
|
* @default 3000
|
||||||
*/
|
*/
|
||||||
sseDefaultRetryDelay?: number;
|
sseDefaultRetryDelay?: number;
|
||||||
/**
|
/**
|
||||||
* Maximum number of retry attempts before giving up.
|
* Maximum number of retry attempts before giving up.
|
||||||
*/
|
*/
|
||||||
sseMaxRetryAttempts?: number;
|
sseMaxRetryAttempts?: number;
|
||||||
/**
|
/**
|
||||||
* Maximum retry delay in milliseconds.
|
* Maximum retry delay in milliseconds.
|
||||||
*
|
*
|
||||||
* Applies only when exponential backoff is used.
|
* Applies only when exponential backoff is used.
|
||||||
*
|
*
|
||||||
* This option applies only if the endpoint returns a stream of events.
|
* This option applies only if the endpoint returns a stream of events.
|
||||||
*
|
*
|
||||||
* @default 30000
|
* @default 30000
|
||||||
*/
|
*/
|
||||||
sseMaxRetryDelay?: number;
|
sseMaxRetryDelay?: number;
|
||||||
/**
|
/**
|
||||||
* Optional sleep function for retry backoff.
|
* Optional sleep function for retry backoff.
|
||||||
*
|
*
|
||||||
* Defaults to using `setTimeout`.
|
* Defaults to using `setTimeout`.
|
||||||
*/
|
*/
|
||||||
sseSleepFn?: (ms: number) => Promise<void>;
|
sseSleepFn?: (ms: number) => Promise<void>;
|
||||||
url: string;
|
url: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface StreamEvent<TData = unknown> {
|
export interface StreamEvent<TData = unknown> {
|
||||||
data: TData;
|
data: TData;
|
||||||
event?: string;
|
event?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
retry?: number;
|
retry?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ServerSentEventsResult<
|
export type ServerSentEventsResult<
|
||||||
TData = unknown,
|
TData = unknown,
|
||||||
TReturn = void,
|
TReturn = void,
|
||||||
TNext = unknown,
|
TNext = unknown,
|
||||||
> = {
|
> = {
|
||||||
stream: AsyncGenerator<
|
stream: AsyncGenerator<
|
||||||
TData extends Record<string, unknown> ? TData[keyof TData] : TData,
|
TData extends Record<string, unknown> ? TData[keyof TData] : TData,
|
||||||
TReturn,
|
TReturn,
|
||||||
TNext
|
TNext
|
||||||
>;
|
>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createSseClient = <TData = unknown>({
|
export const createSseClient = <TData = unknown>({
|
||||||
onRequest,
|
onRequest,
|
||||||
onSseError,
|
onSseError,
|
||||||
onSseEvent,
|
onSseEvent,
|
||||||
responseTransformer,
|
responseTransformer,
|
||||||
responseValidator,
|
responseValidator,
|
||||||
sseDefaultRetryDelay,
|
sseDefaultRetryDelay,
|
||||||
sseMaxRetryAttempts,
|
sseMaxRetryAttempts,
|
||||||
sseMaxRetryDelay,
|
sseMaxRetryDelay,
|
||||||
sseSleepFn,
|
sseSleepFn,
|
||||||
url,
|
url,
|
||||||
...options
|
...options
|
||||||
}: ServerSentEventsOptions): ServerSentEventsResult<TData> => {
|
}: ServerSentEventsOptions): ServerSentEventsResult<TData> => {
|
||||||
let lastEventId: string | undefined;
|
let lastEventId: string | undefined;
|
||||||
|
|
||||||
const sleep =
|
const sleep =
|
||||||
sseSleepFn ??
|
sseSleepFn ??
|
||||||
((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
|
((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
|
||||||
|
|
||||||
const createStream = async function* () {
|
const createStream = async function* () {
|
||||||
let retryDelay: number = sseDefaultRetryDelay ?? 3000;
|
let retryDelay: number = sseDefaultRetryDelay ?? 3000;
|
||||||
let attempt = 0;
|
let attempt = 0;
|
||||||
const signal = options.signal ?? new AbortController().signal;
|
const signal = options.signal ?? new AbortController().signal;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
if (signal.aborted) break;
|
if (signal.aborted) break;
|
||||||
|
|
||||||
attempt++;
|
attempt++;
|
||||||
|
|
||||||
const headers =
|
const headers =
|
||||||
options.headers instanceof Headers
|
options.headers instanceof Headers
|
||||||
? options.headers
|
? options.headers
|
||||||
: 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,
|
||||||
signal,
|
signal,
|
||||||
};
|
};
|
||||||
let request = new Request(url, requestInit);
|
let request = new Request(url, requestInit);
|
||||||
if (onRequest) {
|
if (onRequest) {
|
||||||
request = await onRequest(url, requestInit);
|
request = await onRequest(url, requestInit);
|
||||||
}
|
}
|
||||||
// fetch must be assigned here, otherwise it would throw the error:
|
// fetch must be assigned here, otherwise it would throw the error:
|
||||||
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
|
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
|
||||||
const _fetch = options.fetch ?? globalThis.fetch;
|
const _fetch = options.fetch ?? globalThis.fetch;
|
||||||
const response = await _fetch(request);
|
const response = await _fetch(request);
|
||||||
|
|
||||||
if (!response.ok)
|
if (!response.ok)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`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 {
|
||||||
reader.cancel();
|
reader.cancel();
|
||||||
} catch {
|
} catch {
|
||||||
// noop
|
// noop
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
signal.addEventListener('abort', abortHandler);
|
signal.addEventListener("abort", abortHandler);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
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)) {
|
||||||
retryDelay = parsed;
|
retryDelay = parsed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let data: unknown;
|
let data: 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;
|
||||||
} catch {
|
} catch {
|
||||||
data = rawData;
|
data = rawData;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsedJson) {
|
if (parsedJson) {
|
||||||
if (responseValidator) {
|
if (responseValidator) {
|
||||||
await responseValidator(data);
|
await responseValidator(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (responseTransformer) {
|
if (responseTransformer) {
|
||||||
data = await responseTransformer(data);
|
data = await responseTransformer(data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onSseEvent?.({
|
onSseEvent?.({
|
||||||
data,
|
data,
|
||||||
event: eventName,
|
event: eventName,
|
||||||
id: lastEventId,
|
id: lastEventId,
|
||||||
retry: retryDelay,
|
retry: retryDelay,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (dataLines.length) {
|
if (dataLines.length) {
|
||||||
yield data as any;
|
yield data as any;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
signal.removeEventListener('abort', abortHandler);
|
signal.removeEventListener("abort", abortHandler);
|
||||||
reader.releaseLock();
|
reader.releaseLock();
|
||||||
}
|
}
|
||||||
|
|
||||||
break; // exit loop on normal completion
|
break; // exit loop on normal completion
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// connection failed or aborted; retry after delay
|
// connection failed or aborted; retry after delay
|
||||||
onSseError?.(error);
|
onSseError?.(error);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
sseMaxRetryAttempts !== undefined &&
|
sseMaxRetryAttempts !== undefined &&
|
||||||
attempt >= sseMaxRetryAttempts
|
attempt >= sseMaxRetryAttempts
|
||||||
) {
|
) {
|
||||||
break; // stop after firing error
|
break; // stop after firing error
|
||||||
}
|
}
|
||||||
|
|
||||||
// exponential backoff: double retry each attempt, cap at 30s
|
// exponential backoff: double retry each attempt, cap at 30s
|
||||||
const backoff = Math.min(
|
const backoff = Math.min(
|
||||||
retryDelay * 2 ** (attempt - 1),
|
retryDelay * 2 ** (attempt - 1),
|
||||||
sseMaxRetryDelay ?? 30000,
|
sseMaxRetryDelay ?? 30000,
|
||||||
);
|
);
|
||||||
await sleep(backoff);
|
await sleep(backoff);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const stream = createStream();
|
const stream = createStream();
|
||||||
|
|
||||||
return { stream };
|
return { stream };
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,118 +1,118 @@
|
||||||
// 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,
|
||||||
Config = unknown,
|
Config = unknown,
|
||||||
MethodFn = never,
|
MethodFn = never,
|
||||||
BuildUrlFn = never,
|
BuildUrlFn = never,
|
||||||
SseFn = never,
|
SseFn = never,
|
||||||
> = {
|
> = {
|
||||||
/**
|
/**
|
||||||
* Returns the final request URL.
|
* Returns the final request URL.
|
||||||
*/
|
*/
|
||||||
buildUrl: BuildUrlFn;
|
buildUrl: BuildUrlFn;
|
||||||
getConfig: () => Config;
|
getConfig: () => Config;
|
||||||
request: RequestFn;
|
request: RequestFn;
|
||||||
setConfig: (config: Config) => Config;
|
setConfig: (config: Config) => Config;
|
||||||
} & {
|
} & {
|
||||||
[K in HttpMethod]: MethodFn;
|
[K in HttpMethod]: MethodFn;
|
||||||
} & ([SseFn] extends [never]
|
} & ([SseFn] extends [never]
|
||||||
? { sse?: never }
|
? { sse?: never }
|
||||||
: { sse: { [K in HttpMethod]: SseFn } });
|
: { sse: { [K in HttpMethod]: SseFn } });
|
||||||
|
|
||||||
export interface Config {
|
export interface Config {
|
||||||
/**
|
/**
|
||||||
* Auth token or a function returning auth token. The resolved value will be
|
* Auth token or a function returning auth token. The resolved value will be
|
||||||
* added to the request payload as defined by its `security` array.
|
* added to the request payload as defined by its `security` array.
|
||||||
*/
|
*/
|
||||||
auth?: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken;
|
auth?: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken;
|
||||||
/**
|
/**
|
||||||
* A function for serializing request body parameter. By default,
|
* A function for serializing request body parameter. By default,
|
||||||
* {@link JSON.stringify()} will be used.
|
* {@link JSON.stringify()} will be used.
|
||||||
*/
|
*/
|
||||||
bodySerializer?: BodySerializer | null;
|
bodySerializer?: BodySerializer | null;
|
||||||
/**
|
/**
|
||||||
* An object containing any HTTP headers that you want to pre-populate your
|
* An object containing any HTTP headers that you want to pre-populate your
|
||||||
* `Headers` object with.
|
* `Headers` object with.
|
||||||
*
|
*
|
||||||
* {@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
|
||||||
| number
|
| number
|
||||||
| boolean
|
| boolean
|
||||||
| (string | number | boolean)[]
|
| (string | number | boolean)[]
|
||||||
| null
|
| null
|
||||||
| undefined
|
| undefined
|
||||||
| unknown
|
| unknown
|
||||||
>;
|
>;
|
||||||
/**
|
/**
|
||||||
* The request method.
|
* The request method.
|
||||||
*
|
*
|
||||||
* {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
|
* {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
|
||||||
*/
|
*/
|
||||||
method?: Uppercase<HttpMethod>;
|
method?: Uppercase<HttpMethod>;
|
||||||
/**
|
/**
|
||||||
* A function for serializing request query parameters. By default, arrays
|
* A function for serializing request query parameters. By default, arrays
|
||||||
* will be exploded in form style, objects will be exploded in deepObject
|
* will be exploded in form style, objects will be exploded in deepObject
|
||||||
* style, and reserved characters are percent-encoded.
|
* style, and reserved characters are percent-encoded.
|
||||||
*
|
*
|
||||||
* This method will have no effect if the native `paramsSerializer()` Axios
|
* This method will have no effect if the native `paramsSerializer()` Axios
|
||||||
* API function is used.
|
* API function is used.
|
||||||
*
|
*
|
||||||
* {@link https://swagger.io/docs/specification/serialization/#query View examples}
|
* {@link https://swagger.io/docs/specification/serialization/#query View examples}
|
||||||
*/
|
*/
|
||||||
querySerializer?: QuerySerializer | QuerySerializerOptions;
|
querySerializer?: QuerySerializer | QuerySerializerOptions;
|
||||||
/**
|
/**
|
||||||
* A function validating request data. This is useful if you want to ensure
|
* A function validating request data. This is useful if you want to ensure
|
||||||
* the request conforms to the desired shape, so it can be safely sent to
|
* the request conforms to the desired shape, so it can be safely sent to
|
||||||
* the server.
|
* the server.
|
||||||
*/
|
*/
|
||||||
requestValidator?: (data: unknown) => Promise<unknown>;
|
requestValidator?: (data: unknown) => Promise<unknown>;
|
||||||
/**
|
/**
|
||||||
* A function transforming response data before it's returned. This is useful
|
* A function transforming response data before it's returned. This is useful
|
||||||
* for post-processing data, e.g. converting ISO strings into Date objects.
|
* for post-processing data, e.g. converting ISO strings into Date objects.
|
||||||
*/
|
*/
|
||||||
responseTransformer?: (data: unknown) => Promise<unknown>;
|
responseTransformer?: (data: unknown) => Promise<unknown>;
|
||||||
/**
|
/**
|
||||||
* A function validating response data. This is useful if you want to ensure
|
* A function validating response data. This is useful if you want to ensure
|
||||||
* the response conforms to the desired shape, so it can be safely passed to
|
* the response conforms to the desired shape, so it can be safely passed to
|
||||||
* the transformers and returned to the user.
|
* the transformers and returned to the user.
|
||||||
*/
|
*/
|
||||||
responseValidator?: (data: unknown) => Promise<unknown>;
|
responseValidator?: (data: unknown) => Promise<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type IsExactlyNeverOrNeverUndefined<T> = [T] extends [never]
|
type IsExactlyNeverOrNeverUndefined<T> = [T] extends [never]
|
||||||
? true
|
? true
|
||||||
: [T] extends [never | undefined]
|
: [T] extends [never | undefined]
|
||||||
? [undefined] extends [T]
|
? [undefined] extends [T]
|
||||||
? false
|
? false
|
||||||
: true
|
: true
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
export type OmitNever<T extends Record<string, unknown>> = {
|
export type OmitNever<T extends Record<string, unknown>> = {
|
||||||
[K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true
|
[K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true
|
||||||
? never
|
? never
|
||||||
: K]: T[K];
|
: K]: T[K];
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,143 +1,143 @@
|
||||||
// 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>;
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PATH_PARAM_RE = /\{[^{}]+\}/g;
|
export const PATH_PARAM_RE = /\{[^{}]+\}/g;
|
||||||
|
|
||||||
export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
|
export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
|
||||||
let url = _url;
|
let url = _url;
|
||||||
const matches = _url.match(PATH_PARAM_RE);
|
const matches = _url.match(PATH_PARAM_RE);
|
||||||
if (matches) {
|
if (matches) {
|
||||||
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];
|
||||||
|
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined || value === null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
url = url.replace(
|
url = url.replace(
|
||||||
match,
|
match,
|
||||||
serializeArrayParam({ explode, name, style, value }),
|
serializeArrayParam({ explode, name, style, value }),
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value === 'object') {
|
if (typeof value === "object") {
|
||||||
url = url.replace(
|
url = url.replace(
|
||||||
match,
|
match,
|
||||||
serializeObjectParam({
|
serializeObjectParam({
|
||||||
explode,
|
explode,
|
||||||
name,
|
name,
|
||||||
style,
|
style,
|
||||||
value: value as Record<string, unknown>,
|
value: value as Record<string, unknown>,
|
||||||
valueOnly: true,
|
valueOnly: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (style === 'matrix') {
|
if (style === "matrix") {
|
||||||
url = url.replace(
|
url = url.replace(
|
||||||
match,
|
match,
|
||||||
`;${serializePrimitiveParam({
|
`;${serializePrimitiveParam({
|
||||||
name,
|
name,
|
||||||
value: value as string,
|
value: value as string,
|
||||||
})}`,
|
})}`,
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return url;
|
return url;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getUrl = ({
|
export const getUrl = ({
|
||||||
baseUrl,
|
baseUrl,
|
||||||
path,
|
path,
|
||||||
query,
|
query,
|
||||||
querySerializer,
|
querySerializer,
|
||||||
url: _url,
|
url: _url,
|
||||||
}: {
|
}: {
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
path?: Record<string, unknown>;
|
path?: Record<string, unknown>;
|
||||||
query?: Record<string, unknown>;
|
query?: Record<string, unknown>;
|
||||||
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) {
|
||||||
url += `?${search}`;
|
url += `?${search}`;
|
||||||
}
|
}
|
||||||
return url;
|
return url;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getValidRequestBody(options: {
|
export function getValidRequestBody(options: {
|
||||||
body?: unknown;
|
body?: unknown;
|
||||||
bodySerializer?: BodySerializer | null;
|
bodySerializer?: BodySerializer | null;
|
||||||
serializedBody?: unknown;
|
serializedBody?: unknown;
|
||||||
}) {
|
}) {
|
||||||
const hasBody = options.body !== undefined;
|
const hasBody = options.body !== undefined;
|
||||||
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
|
||||||
if (hasBody) {
|
if (hasBody) {
|
||||||
return options.body;
|
return options.body;
|
||||||
}
|
}
|
||||||
|
|
||||||
// no body was provided
|
// no body was provided
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
},
|
||||||
console.error(error);
|
onError: ({ 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: () => {
|
||||||
onError: (error) => {
|
setIsLoggingIn(false);
|
||||||
console.error(error);
|
},
|
||||||
toast.error("Login failed", { description: error.message });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit = (values: LoginFormValues) => {
|
|
||||||
login.mutate({
|
|
||||||
body: {
|
|
||||||
username: values.username.trim(),
|
|
||||||
password: values.password.trim(),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error("Login failed", { description: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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([
|
||||||
path: { id: params.id, snapshotId: params.snapshotId },
|
getSnapshotDetails({
|
||||||
});
|
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({
|
||||||
onSuccess: () => {
|
fetchOptions: {
|
||||||
navigate("/login", { replace: true });
|
onSuccess: () => {
|
||||||
},
|
void navigate("/login", { replace: true });
|
||||||
});
|
},
|
||||||
|
onError: ({ error }) => {
|
||||||
const changePassword = useMutation({
|
console.error(error);
|
||||||
...changePasswordMutation(),
|
toast.error("Logout failed", { description: error.message });
|
||||||
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: {
|
newPassword,
|
||||||
currentPassword,
|
currentPassword: currentPassword,
|
||||||
newPassword,
|
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
|
|
@ -1,209 +1,223 @@
|
||||||
{
|
{
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"entries": [
|
"entries": [
|
||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1755765658194,
|
"when": 1755765658194,
|
||||||
"tag": "0000_known_madelyne_pryor",
|
"tag": "0000_known_madelyne_pryor",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 1,
|
"idx": 1,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1755775437391,
|
"when": 1755775437391,
|
||||||
"tag": "0001_far_frank_castle",
|
"tag": "0001_far_frank_castle",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 2,
|
"idx": 2,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1756930554198,
|
"when": 1756930554198,
|
||||||
"tag": "0002_cheerful_randall",
|
"tag": "0002_cheerful_randall",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 3,
|
"idx": 3,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1758653407064,
|
"when": 1758653407064,
|
||||||
"tag": "0003_mature_hellcat",
|
"tag": "0003_mature_hellcat",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 4,
|
"idx": 4,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1758961535488,
|
"when": 1758961535488,
|
||||||
"tag": "0004_wealthy_tomas",
|
"tag": "0004_wealthy_tomas",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 5,
|
"idx": 5,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1759416698274,
|
"when": 1759416698274,
|
||||||
"tag": "0005_simple_alice",
|
"tag": "0005_simple_alice",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 6,
|
"idx": 6,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1760734377440,
|
"when": 1760734377440,
|
||||||
"tag": "0006_secret_micromacro",
|
"tag": "0006_secret_micromacro",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 7,
|
"idx": 7,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1761224911352,
|
"when": 1761224911352,
|
||||||
"tag": "0007_watery_sersi",
|
"tag": "0007_watery_sersi",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 8,
|
"idx": 8,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1761414054481,
|
"when": 1761414054481,
|
||||||
"tag": "0008_silent_lady_bullseye",
|
"tag": "0008_silent_lady_bullseye",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 9,
|
"idx": 9,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1762095226041,
|
"when": 1762095226041,
|
||||||
"tag": "0009_little_adam_warlock",
|
"tag": "0009_little_adam_warlock",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 10,
|
"idx": 10,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1762610065889,
|
"when": 1762610065889,
|
||||||
"tag": "0010_perfect_proemial_gods",
|
"tag": "0010_perfect_proemial_gods",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 11,
|
"idx": 11,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1763644043601,
|
"when": 1763644043601,
|
||||||
"tag": "0011_familiar_stone_men",
|
"tag": "0011_familiar_stone_men",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 12,
|
"idx": 12,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1764100562084,
|
"when": 1764100562084,
|
||||||
"tag": "0012_add_short_ids",
|
"tag": "0012_add_short_ids",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 13,
|
"idx": 13,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1764182159797,
|
"when": 1764182159797,
|
||||||
"tag": "0013_elite_sprite",
|
"tag": "0013_elite_sprite",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 14,
|
"idx": 14,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1764182405089,
|
"when": 1764182405089,
|
||||||
"tag": "0014_wild_echo",
|
"tag": "0014_wild_echo",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 15,
|
"idx": 15,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1764182465287,
|
"when": 1764182465287,
|
||||||
"tag": "0015_jazzy_sersi",
|
"tag": "0015_jazzy_sersi",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 16,
|
"idx": 16,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1764194697035,
|
"when": 1764194697035,
|
||||||
"tag": "0016_fix-timestamps-to-ms",
|
"tag": "0016_fix-timestamps-to-ms",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 17,
|
"idx": 17,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1764357897219,
|
"when": 1764357897219,
|
||||||
"tag": "0017_fix-compression-modes",
|
"tag": "0017_fix-compression-modes",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 18,
|
"idx": 18,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1764794371040,
|
"when": 1764794371040,
|
||||||
"tag": "0018_breezy_invaders",
|
"tag": "0018_breezy_invaders",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 19,
|
"idx": 19,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1764839917446,
|
"when": 1764839917446,
|
||||||
"tag": "0019_secret_nomad",
|
"tag": "0019_secret_nomad",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 20,
|
"idx": 20,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1764847918249,
|
"when": 1764847918249,
|
||||||
"tag": "0020_even_dexter_bennett",
|
"tag": "0020_even_dexter_bennett",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 21,
|
"idx": 21,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1765307881092,
|
"when": 1765307881092,
|
||||||
"tag": "0021_steady_viper",
|
"tag": "0021_steady_viper",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 22,
|
"idx": 22,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1765794552191,
|
"when": 1765794552191,
|
||||||
"tag": "0022_woozy_shen",
|
"tag": "0022_woozy_shen",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 23,
|
"idx": 23,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1766320570509,
|
"when": 1766320570509,
|
||||||
"tag": "0023_special_thor",
|
"tag": "0023_special_thor",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 24,
|
"idx": 24,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1766325504548,
|
"when": 1766325504548,
|
||||||
"tag": "0024_schedules-one-fs",
|
"tag": "0024_schedules-one-fs",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 25,
|
"idx": 25,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1766431021321,
|
"when": 1766431021321,
|
||||||
"tag": "0025_remarkable_pete_wisdom",
|
"tag": "0025_remarkable_pete_wisdom",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 26,
|
"idx": 26,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1766765013108,
|
"when": 1766765013108,
|
||||||
"tag": "0026_migrate-local-repo-paths",
|
"tag": "0026_migrate-local-repo-paths",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 27,
|
"idx": 27,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1766778073418,
|
"when": 1766778073418,
|
||||||
"tag": "0027_careful_cammi",
|
"tag": "0027_careful_cammi",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 28,
|
"idx": 28,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"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 = {
|
const hasUsers = await authService.hasUsers();
|
||||||
httpOnly: true,
|
return c.json<GetStatusDto>({ hasUsers });
|
||||||
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();
|
|
||||||
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