mirror of
https://github.com/diced/zipline.git
synced 2025-12-16 09:30:54 -08:00
Compare commits
101 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71dbbb584a | ||
|
|
f03bd74865 | ||
|
|
f059dcca35 | ||
|
|
531ba13daf | ||
|
|
cd8b892a90 | ||
|
|
3575981984 | ||
|
|
81c880b1ca | ||
|
|
9b8e57bda0 | ||
|
|
4a8f90a901 | ||
|
|
6acdc72776 | ||
|
|
f78c873aae | ||
|
|
0f82bf8d90 | ||
|
|
82a7f1d0bf | ||
|
|
2fd1007e4b | ||
|
|
c360235fa8 | ||
|
|
a4404f1ae8 | ||
|
|
56d1492377 | ||
|
|
fa9bf185d5 | ||
|
|
eca6a0c5fd | ||
|
|
f58ed2f368 | ||
|
|
64c39dab76 | ||
|
|
ac08f4f797 | ||
|
|
91a2c05d3b | ||
|
|
3ccc108d43 | ||
|
|
aaaf0cf5aa | ||
|
|
db7cf70bca | ||
|
|
8b59e1dc53 | ||
|
|
da066db07e | ||
|
|
b566d13c8d | ||
|
|
6a76c5243f | ||
|
|
38a90787d0 | ||
|
|
4652ada85e | ||
|
|
5f96c762e0 | ||
|
|
651f32e7ba | ||
|
|
dcbd9e40f0 | ||
|
|
3486e9880e | ||
|
|
b058c15f26 | ||
|
|
96f60edaee | ||
|
|
d7f3e1503f | ||
|
|
dfc8fca3e0 | ||
|
|
28f7d3f618 | ||
|
|
5c0830c6da | ||
|
|
ef33fcbe1d | ||
|
|
4b1ca07510 | ||
|
|
438b9b5a67 | ||
|
|
ed1273efba | ||
|
|
e8518f92c7 | ||
|
|
fbf9e10e56 | ||
|
|
a1ee1178ae | ||
|
|
e5eaaca5a0 | ||
|
|
6e9dea989e | ||
|
|
5bc9b6ef0a | ||
|
|
6362d06253 | ||
|
|
81866b4b50 | ||
|
|
4b3878d553 | ||
|
|
d0a613ab8e | ||
|
|
1bff0564e7 | ||
|
|
df449b1bcb | ||
|
|
bd057944ce | ||
|
|
856fa00d1d | ||
|
|
1703cee75a | ||
|
|
0a970da241 | ||
|
|
04b0a18b85 | ||
|
|
e7de1c9762 | ||
|
|
2df9098586 | ||
|
|
e8380cc261 | ||
|
|
71a1ed9072 | ||
|
|
6b0bbad8d4 | ||
|
|
8f12621315 | ||
|
|
e5ee076e08 | ||
|
|
8382a1b55d | ||
|
|
a35d8b87ee | ||
|
|
f70eea97b0 | ||
|
|
7ab5c4e180 | ||
|
|
486165625d | ||
|
|
08eb2df26c | ||
|
|
4a5d01c663 | ||
|
|
485f106a65 | ||
|
|
3d3f519403 | ||
|
|
617f42d3bf | ||
|
|
25a2a54d8a | ||
|
|
35c37c235f | ||
|
|
594dfa6ef9 | ||
|
|
5ab36a08b2 | ||
|
|
90aef3dce1 | ||
|
|
8b9303ed80 | ||
|
|
ee9639ac65 | ||
|
|
055bee6286 | ||
|
|
c3bc598016 | ||
|
|
c0261285af | ||
|
|
0538b792ac | ||
|
|
567a855ba1 | ||
|
|
2e59f5bd7f | ||
|
|
ef0580655d | ||
|
|
8ece705eb5 | ||
|
|
485fa62ed9 | ||
|
|
b4819cd038 | ||
|
|
cb2f2daf60 | ||
|
|
c2848f19c1 | ||
|
|
55684528b8 | ||
|
|
9611e6d5a5 |
@@ -4,4 +4,5 @@ build
|
|||||||
node_modules
|
node_modules
|
||||||
uploads*
|
uploads*
|
||||||
.env
|
.env
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
generated
|
||||||
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node: [20.x, 22.x, 23.x]
|
node: [20.x, 22.x, 24.x]
|
||||||
arch: [amd64, arm64]
|
arch: [amd64, arm64]
|
||||||
runs-on: ubuntu-24.04${{ matrix.arch == 'arm64' && '-arm' || '' }}
|
runs-on: ubuntu-24.04${{ matrix.arch == 'arm64' && '-arm' || '' }}
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/docker-release.yml
vendored
2
.github/workflows/docker-release.yml
vendored
@@ -46,6 +46,8 @@ jobs:
|
|||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
provenance: false
|
provenance: false
|
||||||
|
build-args: |
|
||||||
|
ZIPLINE_GIT_SHA=${{ steps.sha.outputs.short_sha }}
|
||||||
tags: |
|
tags: |
|
||||||
ghcr.io/diced/zipline:${{ steps.version.outputs.zipline_version }}-${{ matrix.arch }}
|
ghcr.io/diced/zipline:${{ steps.version.outputs.zipline_version }}-${{ matrix.arch }}
|
||||||
ghcr.io/diced/zipline:${{ steps.version.outputs.zipline_version }}-${{ steps.sha.outputs.short_sha }}-${{ matrix.arch }}
|
ghcr.io/diced/zipline:${{ steps.version.outputs.zipline_version }}-${{ steps.sha.outputs.short_sha }}-${{ matrix.arch }}
|
||||||
|
|||||||
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
@@ -40,6 +40,8 @@ jobs:
|
|||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
provenance: false
|
provenance: false
|
||||||
|
build-args: |
|
||||||
|
ZIPLINE_GIT_SHA=${{ steps.sha.outputs.short_sha }}
|
||||||
tags: |
|
tags: |
|
||||||
ghcr.io/diced/zipline:trunk-${{ matrix.arch }}
|
ghcr.io/diced/zipline:trunk-${{ matrix.arch }}
|
||||||
ghcr.io/diced/zipline:trunk-${{ steps.sha.outputs.short_sha }}-${{ matrix.arch }}
|
ghcr.io/diced/zipline:trunk-${{ steps.sha.outputs.short_sha }}-${{ matrix.arch }}
|
||||||
|
|||||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -23,6 +23,7 @@
|
|||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
|
.idea
|
||||||
|
|
||||||
# debug
|
# debug
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
@@ -42,7 +43,13 @@ next-env.d.ts
|
|||||||
# eslint
|
# eslint
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
|
||||||
|
# nix dev env
|
||||||
|
!.envrc
|
||||||
|
.direnv
|
||||||
|
.devenv
|
||||||
|
|
||||||
# zipline
|
# zipline
|
||||||
uploads*/
|
uploads*/
|
||||||
*.crt
|
*.crt
|
||||||
*.key
|
*.key
|
||||||
|
generated
|
||||||
1
.prettierignore
Executable file
1
.prettierignore
Executable file
@@ -0,0 +1 @@
|
|||||||
|
pnpm-lock.yaml
|
||||||
@@ -3,9 +3,7 @@ FROM node:22-alpine3.21 AS base
|
|||||||
ENV PNPM_HOME="/pnpm"
|
ENV PNPM_HOME="/pnpm"
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
|
|
||||||
RUN npm install -g corepack
|
RUN corepack enable
|
||||||
RUN corepack enable pnpm
|
|
||||||
RUN corepack prepare pnpm@latest --activate
|
|
||||||
|
|
||||||
RUN apk add --no-cache ffmpeg tzdata
|
RUN apk add --no-cache ffmpeg tzdata
|
||||||
|
|
||||||
@@ -42,6 +40,8 @@ COPY --from=builder /zipline/.next ./.next
|
|||||||
|
|
||||||
COPY --from=builder /zipline/mimes.json ./mimes.json
|
COPY --from=builder /zipline/mimes.json ./mimes.json
|
||||||
COPY --from=builder /zipline/code.json ./code.json
|
COPY --from=builder /zipline/code.json ./code.json
|
||||||
|
COPY --from=builder /zipline/generated ./generated
|
||||||
|
|
||||||
|
|
||||||
RUN pnpm build:prisma
|
RUN pnpm build:prisma
|
||||||
|
|
||||||
@@ -50,4 +50,7 @@ RUN rm -rf /tmp/* /root/*
|
|||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
ARG ZIPLINE_GIT_SHA
|
||||||
|
ENV ZIPLINE_GIT_SHA=${ZIPLINE_GIT_SHA:-"unknown"}
|
||||||
|
|
||||||
CMD ["node", "--enable-source-maps", "build/server"]
|
CMD ["node", "--enable-source-maps", "build/server"]
|
||||||
|
|||||||
34
README.md
34
README.md
@@ -77,11 +77,17 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgres://${POSTGRESQL_USER:-zipline}:${POSTGRESQL_PASSWORD}@postgresql:5432/${POSTGRESQL_DB:-zipline}
|
- DATABASE_URL=postgres://${POSTGRESQL_USER:-zipline}:${POSTGRESQL_PASSWORD}@postgresql:5432/${POSTGRESQL_DB:-zipline}
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgresql
|
postgresql:
|
||||||
|
condition: service_healthy
|
||||||
volumes:
|
volumes:
|
||||||
- './uploads:/zipline/uploads'
|
- './uploads:/zipline/uploads'
|
||||||
- './public:/zipline/public'
|
- './public:/zipline/public'
|
||||||
- './themes:/zipline/themes'
|
- './themes:/zipline/themes'
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'wget', '-q', '--spider', 'http://localhost:3000/api/healthcheck']
|
||||||
|
interval: 15s
|
||||||
|
timeout: 2s
|
||||||
|
retries: 2
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
@@ -192,6 +198,32 @@ Create a pull request on GitHub. If your PR does not pass the action checks, the
|
|||||||
|
|
||||||
Here's how to setup Zipline for development
|
Here's how to setup Zipline for development
|
||||||
|
|
||||||
|
#### Nix
|
||||||
|
|
||||||
|
If you have [direnv](https://direnv.net/) installed, you can simply cd into the cloned directory and run the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
direnv allow
|
||||||
|
```
|
||||||
|
|
||||||
|
Granted that you have direnv setup properly, you will now be in a new nix shell with all the dependencies and PostgreSQL installed.
|
||||||
|
|
||||||
|
If you aren't using direnv, you can run the following command to enter the nix shell:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix develop --no-pure-eval
|
||||||
|
```
|
||||||
|
|
||||||
|
Useful commands regarding the postgres server:
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
| --------------- | --------------------------------------------------- |
|
||||||
|
| `pgup` | Starts the postgres server in the background. |
|
||||||
|
| `pgdown` | Stops the postgres server running in the background |
|
||||||
|
| `pg_ctl status` | See if the postgres server is running |
|
||||||
|
|
||||||
|
After familiarizing yourself with the environment, you can continue below (skipping the prerequisites since they are already installed).
|
||||||
|
|
||||||
#### Prerequisites
|
#### Prerequisites
|
||||||
|
|
||||||
- nodejs (lts -> 20.x, 22.x)
|
- nodejs (lts -> 20.x, 22.x)
|
||||||
|
|||||||
10
SECURITY.md
10
SECURITY.md
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
## Supported Versions
|
## Supported Versions
|
||||||
|
|
||||||
| Version | Supported |
|
| Version | Supported |
|
||||||
| ------- | ------------------------------------- |
|
| ------- | ------------------ |
|
||||||
| 4.x.x | :white_check_mark: |
|
| 4.2.x | :white_check_mark: |
|
||||||
| < 3 | :white_check_mark: (EOL at June 2025) |
|
| < 3 | :x: |
|
||||||
| < 2 | :x: |
|
| < 2 | :x: |
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
|||||||
@@ -26,11 +26,17 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgres://${POSTGRESQL_USER:-zipline}:${POSTGRESQL_PASSWORD}@postgresql:5432/${POSTGRESQL_DB:-zipline}
|
- DATABASE_URL=postgres://${POSTGRESQL_USER:-zipline}:${POSTGRESQL_PASSWORD}@postgresql:5432/${POSTGRESQL_DB:-zipline}
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgresql
|
postgresql:
|
||||||
|
condition: service_healthy
|
||||||
volumes:
|
volumes:
|
||||||
- './uploads:/zipline/uploads'
|
- './uploads:/zipline/uploads'
|
||||||
- './public:/zipline/public'
|
- './public:/zipline/public'
|
||||||
- './themes:/zipline/themes'
|
- './themes:/zipline/themes'
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'wget', '-q', '--spider', 'http://0.0.0.0:3000/api/healthcheck']
|
||||||
|
interval: 15s
|
||||||
|
timeout: 2s
|
||||||
|
retries: 2
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
|||||||
@@ -1,44 +1,70 @@
|
|||||||
// TODO: migrate everything to use eslint 9 features instead of compatibility layers
|
|
||||||
|
|
||||||
import unusedImports from 'eslint-plugin-unused-imports';
|
import unusedImports from 'eslint-plugin-unused-imports';
|
||||||
import typescriptEslint from '@typescript-eslint/eslint-plugin';
|
import tseslint from 'typescript-eslint';
|
||||||
import tsParser from '@typescript-eslint/parser';
|
import prettier from 'eslint-plugin-prettier';
|
||||||
|
import prettierConfig from 'eslint-config-prettier';
|
||||||
|
import nextConfig from '@next/eslint-plugin-next';
|
||||||
|
import reactHooksPlugin from 'eslint-plugin-react-hooks';
|
||||||
|
import reactPlugin from 'eslint-plugin-react';
|
||||||
|
import jsxA11yPlugin from 'eslint-plugin-jsx-a11y';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import js from '@eslint/js';
|
import fs from 'node:fs';
|
||||||
import { FlatCompat } from '@eslint/eslintrc';
|
|
||||||
import { includeIgnoreFile } from '@eslint/compat';
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
const compat = new FlatCompat({
|
|
||||||
baseDirectory: __dirname,
|
|
||||||
recommendedConfig: js.configs.recommended,
|
|
||||||
allConfig: js.configs.all,
|
|
||||||
});
|
|
||||||
|
|
||||||
const gitignorePath = path.resolve(__dirname, '.gitignore');
|
const gitignorePath = path.resolve(__dirname, '.gitignore');
|
||||||
|
const gitignoreContent = fs.readFileSync(gitignorePath, 'utf8');
|
||||||
|
const gitignorePatterns = gitignoreContent
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => line.trim() && !line.startsWith('#'))
|
||||||
|
.map((pattern) => pattern.trim());
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: gitignorePatterns },
|
||||||
|
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
|
||||||
export default [
|
|
||||||
includeIgnoreFile(gitignorePath),
|
|
||||||
...compat.extends(
|
|
||||||
'next/core-web-vitals',
|
|
||||||
'plugin:prettier/recommended',
|
|
||||||
'plugin:@typescript-eslint/recommended',
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
|
files: ['**/*.{js,mjs,cjs,ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'module',
|
||||||
|
parserOptions: {
|
||||||
|
ecmaFeatures: {
|
||||||
|
jsx: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
'unused-imports': unusedImports,
|
'unused-imports': unusedImports,
|
||||||
'@typescript-eslint': typescriptEslint,
|
prettier: prettier,
|
||||||
|
'@next/next': nextConfig,
|
||||||
|
'react-hooks': reactHooksPlugin,
|
||||||
|
react: reactPlugin,
|
||||||
|
'jsx-a11y': jsxA11yPlugin,
|
||||||
},
|
},
|
||||||
|
|
||||||
languageOptions: {
|
|
||||||
parser: tsParser,
|
|
||||||
},
|
|
||||||
|
|
||||||
rules: {
|
rules: {
|
||||||
'linebreak-style': ['error', 'unix'],
|
...reactPlugin.configs.recommended.rules,
|
||||||
|
|
||||||
|
...reactHooksPlugin.configs.recommended.rules,
|
||||||
|
|
||||||
|
...nextConfig.configs.recommended.rules,
|
||||||
|
...nextConfig.configs['core-web-vitals'].rules,
|
||||||
|
|
||||||
|
...prettierConfig.rules,
|
||||||
|
'prettier/prettier': [
|
||||||
|
'error',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
fileInfoOptions: {
|
||||||
|
withNodeModules: false,
|
||||||
|
},
|
||||||
|
ignoreFileExtensions: ['pnpm-lock.yaml'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'linebreak-style': ['error', 'unix'],
|
||||||
quotes: [
|
quotes: [
|
||||||
'error',
|
'error',
|
||||||
'single',
|
'single',
|
||||||
@@ -46,7 +72,6 @@ export default [
|
|||||||
avoidEscape: true,
|
avoidEscape: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
semi: ['error', 'always'],
|
semi: ['error', 'always'],
|
||||||
'jsx-quotes': ['error', 'prefer-single'],
|
'jsx-quotes': ['error', 'prefer-single'],
|
||||||
indent: 'off',
|
indent: 'off',
|
||||||
@@ -77,10 +102,17 @@ export default [
|
|||||||
argsIgnorePattern: '^_',
|
argsIgnorePattern: '^_',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
'@typescript-eslint/ban-ts-comment': 'off',
|
'@typescript-eslint/ban-ts-comment': 'off',
|
||||||
'@typescript-eslint/no-explicit-any': 'off',
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
'@typescript-eslint/no-unused-expressions': 'off',
|
'@typescript-eslint/no-unused-expressions': 'off',
|
||||||
},
|
},
|
||||||
|
settings: {
|
||||||
|
react: {
|
||||||
|
version: 'detect',
|
||||||
|
},
|
||||||
|
next: {
|
||||||
|
rootDir: __dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
);
|
||||||
|
|||||||
254
flake.lock
generated
Normal file
254
flake.lock
generated
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"cachix": {
|
||||||
|
"inputs": {
|
||||||
|
"devenv": [
|
||||||
|
"devenv"
|
||||||
|
],
|
||||||
|
"flake-compat": [
|
||||||
|
"devenv"
|
||||||
|
],
|
||||||
|
"git-hooks": [
|
||||||
|
"devenv",
|
||||||
|
"git-hooks"
|
||||||
|
],
|
||||||
|
"nixpkgs": [
|
||||||
|
"devenv",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1748883665,
|
||||||
|
"narHash": "sha256-R0W7uAg+BLoHjMRMQ8+oiSbTq8nkGz5RDpQ+ZfxxP3A=",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "cachix",
|
||||||
|
"rev": "f707778d902af4d62d8dd92c269f8e70de09acbe",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "cachix",
|
||||||
|
"ref": "latest",
|
||||||
|
"repo": "cachix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devenv": {
|
||||||
|
"inputs": {
|
||||||
|
"cachix": "cachix",
|
||||||
|
"flake-compat": "flake-compat",
|
||||||
|
"git-hooks": "git-hooks",
|
||||||
|
"nix": "nix",
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1753888869,
|
||||||
|
"narHash": "sha256-VRYrrUmvXnBzfzuJVoI3os1H/0l8cJQ2KnrrxWkTB3E=",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "devenv",
|
||||||
|
"rev": "bdf26a4453eff6bae835f33d519a36f77e0ca257",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "devenv",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devenv-root": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"narHash": "sha256-d6xi4mKdjkX2JFicDIv5niSzpyI0m/Hnm8GGAIU04kY=",
|
||||||
|
"type": "file",
|
||||||
|
"url": "file:///dev/null"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"type": "file",
|
||||||
|
"url": "file:///dev/null"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-compat": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1747046372,
|
||||||
|
"narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=",
|
||||||
|
"owner": "edolstra",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "edolstra",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-parts": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs-lib": [
|
||||||
|
"devenv",
|
||||||
|
"nix",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1733312601,
|
||||||
|
"narHash": "sha256-4pDvzqnegAfRkPwO3wmwBhVi/Sye1mzps0zHWYnP88c=",
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "flake-parts",
|
||||||
|
"rev": "205b12d8b7cd4802fbcb8e8ef6a0f1408781a4f9",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "flake-parts",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-parts_2": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs-lib": "nixpkgs-lib"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1753121425,
|
||||||
|
"narHash": "sha256-TVcTNvOeWWk1DXljFxVRp+E0tzG1LhrVjOGGoMHuXio=",
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "flake-parts",
|
||||||
|
"rev": "644e0fc48951a860279da645ba77fe4a6e814c5e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "flake-parts",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"git-hooks": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-compat": [
|
||||||
|
"devenv",
|
||||||
|
"flake-compat"
|
||||||
|
],
|
||||||
|
"gitignore": "gitignore",
|
||||||
|
"nixpkgs": [
|
||||||
|
"devenv",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1750779888,
|
||||||
|
"narHash": "sha256-wibppH3g/E2lxU43ZQHC5yA/7kIKLGxVEnsnVK1BtRg=",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "git-hooks.nix",
|
||||||
|
"rev": "16ec914f6fb6f599ce988427d9d94efddf25fe6d",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "git-hooks.nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gitignore": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"devenv",
|
||||||
|
"git-hooks",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1709087332,
|
||||||
|
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "gitignore.nix",
|
||||||
|
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "gitignore.nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nix": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-compat": [
|
||||||
|
"devenv",
|
||||||
|
"flake-compat"
|
||||||
|
],
|
||||||
|
"flake-parts": "flake-parts",
|
||||||
|
"git-hooks-nix": [
|
||||||
|
"devenv",
|
||||||
|
"git-hooks"
|
||||||
|
],
|
||||||
|
"nixpkgs": [
|
||||||
|
"devenv",
|
||||||
|
"nixpkgs"
|
||||||
|
],
|
||||||
|
"nixpkgs-23-11": [
|
||||||
|
"devenv"
|
||||||
|
],
|
||||||
|
"nixpkgs-regression": [
|
||||||
|
"devenv"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1752773918,
|
||||||
|
"narHash": "sha256-dOi/M6yNeuJlj88exI+7k154z+hAhFcuB8tZktiW7rg=",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "nix",
|
||||||
|
"rev": "031c3cf42d2e9391eee373507d8c12e0f9606779",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "cachix",
|
||||||
|
"ref": "devenv-2.30",
|
||||||
|
"repo": "nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1752827260,
|
||||||
|
"narHash": "sha256-noFjJbm/uWRcd2Lotr7ovedfhKVZT+LeJs9rU416lKQ=",
|
||||||
|
"owner": "nixos",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "b527e89270879aaaf584c41f26b2796be634bc9d",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nixos",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "b527e89270879aaaf584c41f26b2796be634bc9d",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs-lib": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1751159883,
|
||||||
|
"narHash": "sha256-urW/Ylk9FIfvXfliA1ywh75yszAbiTEVgpPeinFyVZo=",
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "nixpkgs.lib",
|
||||||
|
"rev": "14a40a1d7fb9afa4739275ac642ed7301a9ba1ab",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "nixpkgs.lib",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"devenv": "devenv",
|
||||||
|
"devenv-root": "devenv-root",
|
||||||
|
"flake-parts": "flake-parts_2",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
128
flake.nix
Normal file
128
flake.nix
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
{
|
||||||
|
inputs = {
|
||||||
|
# required for some reason when entering the shell for devenv
|
||||||
|
devenv-root = {
|
||||||
|
url = "file+file:///dev/null";
|
||||||
|
flake = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
# node 24.4.1, postgres 17
|
||||||
|
nixpkgs.url = "github:nixos/nixpkgs/b527e89270879aaaf584c41f26b2796be634bc9d";
|
||||||
|
flake-parts.url = "github:hercules-ci/flake-parts";
|
||||||
|
|
||||||
|
devenv.url = "github:cachix/devenv";
|
||||||
|
devenv.inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
|
||||||
|
nixConfig = {
|
||||||
|
extra-trusted-public-keys = "devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw=";
|
||||||
|
extra-substituters = "https://devenv.cachix.org";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs =
|
||||||
|
inputs@{ flake-parts, devenv-root, ... }:
|
||||||
|
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||||
|
imports = [
|
||||||
|
inputs.devenv.flakeModule
|
||||||
|
];
|
||||||
|
|
||||||
|
systems = [
|
||||||
|
"x86_64-linux"
|
||||||
|
"x86_64-darwin"
|
||||||
|
"aarch64-linux"
|
||||||
|
"aarch64-darwin"
|
||||||
|
];
|
||||||
|
|
||||||
|
perSystem =
|
||||||
|
{
|
||||||
|
config,
|
||||||
|
self',
|
||||||
|
inputs',
|
||||||
|
pkgs,
|
||||||
|
system,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
psqlConfig = {
|
||||||
|
username = "postgres";
|
||||||
|
password = "postgres";
|
||||||
|
database = "zipline";
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
devenv.shells.default = {
|
||||||
|
packages = with pkgs; [
|
||||||
|
git
|
||||||
|
|
||||||
|
# to generate thumbnails
|
||||||
|
ffmpeg
|
||||||
|
|
||||||
|
# for testing docker
|
||||||
|
colima
|
||||||
|
docker
|
||||||
|
docker-compose
|
||||||
|
];
|
||||||
|
|
||||||
|
scripts = {
|
||||||
|
pgup.exec = ''
|
||||||
|
process-compose up postgres -D
|
||||||
|
'';
|
||||||
|
|
||||||
|
minioup.exec = ''
|
||||||
|
process-compose up minio -D
|
||||||
|
'';
|
||||||
|
|
||||||
|
downall.exec = ''
|
||||||
|
process-compose down
|
||||||
|
'';
|
||||||
|
|
||||||
|
# ensure that volumes are mounted with write access for docker containers
|
||||||
|
start_colima.exec = ''
|
||||||
|
colima start --mount $PWD/themes:w --mount $PWD/uploads:w --mount $PWD/public:w
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
enterShell = ''
|
||||||
|
export name="zipline-env";
|
||||||
|
echo -e "\n[$name]: run 'pgup' to start services, 'pgdown' to stop services";
|
||||||
|
'';
|
||||||
|
|
||||||
|
languages.javascript = {
|
||||||
|
enable = true;
|
||||||
|
package = pkgs.nodejs_24;
|
||||||
|
|
||||||
|
corepack.enable = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
services = {
|
||||||
|
postgres = {
|
||||||
|
enable = true;
|
||||||
|
package = pkgs.postgresql_17;
|
||||||
|
|
||||||
|
initialScript = ''
|
||||||
|
CREATE ROLE "${psqlConfig.username}" WITH LOGIN PASSWORD '${psqlConfig.password}' SUPERUSER;
|
||||||
|
'';
|
||||||
|
|
||||||
|
initialDatabases = [
|
||||||
|
{
|
||||||
|
name = psqlConfig.database;
|
||||||
|
user = psqlConfig.username;
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
listen_addresses = "0.0.0.0";
|
||||||
|
port = 5432;
|
||||||
|
};
|
||||||
|
|
||||||
|
minio = {
|
||||||
|
enable = true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
process.managers.process-compose = {
|
||||||
|
tui.enable = false;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
132
package.json
132
package.json
@@ -2,13 +2,14 @@
|
|||||||
"name": "zipline",
|
"name": "zipline",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"version": "4.0.2",
|
"version": "4.2.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "cross-env pnpm run --stream \"/^build:.*/\"",
|
"build": "cross-env pnpm run --stream \"/^build:.*/\"",
|
||||||
"build:prisma": "prisma generate --no-hints",
|
"build:prisma": "prisma generate --no-hints",
|
||||||
"build:next": "ZIPLINE_BUILD=true next build",
|
"build:next": "ZIPLINE_BUILD=true next build",
|
||||||
"build:server": "tsup",
|
"build:server": "tsup",
|
||||||
"dev": "cross-env TURBOPACK=1 NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --enable-source-maps ./src/server",
|
"dev": "cross-env TURBOPACK=1 NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --enable-source-maps ./src/server",
|
||||||
|
"dev:nd": "cross-env TURBOPACK=1 NODE_ENV=development tsx --require dotenv/config --enable-source-maps ./src/server",
|
||||||
"dev:inspector": "cross-env TURBOPACK=1 NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --inspect=0.0.0.0:9229 --enable-source-maps ./src/server",
|
"dev:inspector": "cross-env TURBOPACK=1 NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --inspect=0.0.0.0:9229 --enable-source-maps ./src/server",
|
||||||
"start": "cross-env NODE_ENV=production node --trace-warnings --require dotenv/config --enable-source-maps ./build/server",
|
"start": "cross-env NODE_ENV=production node --trace-warnings --require dotenv/config --enable-source-maps ./build/server",
|
||||||
"start:inspector": "cross-env NODE_ENV=production node --require dotenv/config --inspect=0.0.0.0:9229 --enable-source-maps ./build/server",
|
"start:inspector": "cross-env NODE_ENV=production node --require dotenv/config --inspect=0.0.0.0:9229 --enable-source-maps ./build/server",
|
||||||
@@ -17,99 +18,104 @@
|
|||||||
"validate:lint": "eslint --cache --fix .",
|
"validate:lint": "eslint --cache --fix .",
|
||||||
"validate:format": "prettier --write --ignore-path .gitignore .",
|
"validate:format": "prettier --write --ignore-path .gitignore .",
|
||||||
"db:prototype": "prisma db push --skip-generate && prisma generate --no-hints",
|
"db:prototype": "prisma db push --skip-generate && prisma generate --no-hints",
|
||||||
"db:migrate": "prisma migrate dev --create-only"
|
"db:migrate": "prisma migrate dev --create-only",
|
||||||
|
"docker:engine": "colima start --mount $PWD/themes:w --mount $PWD/uploads:w --mount $PWD/public:w",
|
||||||
|
"docker:compose:dev:build": "docker-compose --file docker-compose.dev.yml build --build-arg ZIPLINE_GIT_SHA=$(git rev-parse HEAD)",
|
||||||
|
"docker:compose:dev:up": "docker-compose --file docker-compose.dev.yml up -d",
|
||||||
|
"docker:compose:dev:down": "docker-compose --file docker-compose.dev.yml down",
|
||||||
|
"docker:compose:dev:logs": "docker-compose --file docker-compose.dev.yml logs -f"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "3.726.1",
|
"@aws-sdk/client-s3": "3.859.0",
|
||||||
|
"@aws-sdk/lib-storage": "3.859.0",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/cors": "^10.0.2",
|
"@fastify/cors": "^11.1.0",
|
||||||
"@fastify/multipart": "^9.0.3",
|
"@fastify/multipart": "^9.0.3",
|
||||||
"@fastify/rate-limit": "^10.2.2",
|
"@fastify/rate-limit": "^10.3.0",
|
||||||
"@fastify/sensible": "^6.0.3",
|
"@fastify/sensible": "^6.0.3",
|
||||||
"@fastify/static": "^8.1.1",
|
"@fastify/static": "^8.2.0",
|
||||||
"@github/webauthn-json": "^2.1.1",
|
"@github/webauthn-json": "^2.1.1",
|
||||||
"@mantine/charts": "^7.17.0",
|
"@mantine/charts": "^8.2.2",
|
||||||
"@mantine/code-highlight": "^7.17.0",
|
"@mantine/code-highlight": "^8.2.2",
|
||||||
"@mantine/core": "^7.17.0",
|
"@mantine/core": "^8.2.2",
|
||||||
"@mantine/dates": "^7.17.0",
|
"@mantine/dates": "^8.2.2",
|
||||||
"@mantine/dropzone": "^7.17.0",
|
"@mantine/dropzone": "^8.2.2",
|
||||||
"@mantine/form": "^7.17.0",
|
"@mantine/form": "^8.2.2",
|
||||||
"@mantine/hooks": "^7.17.0",
|
"@mantine/hooks": "^8.2.2",
|
||||||
"@mantine/modals": "^7.17.0",
|
"@mantine/modals": "^8.2.2",
|
||||||
"@mantine/notifications": "^7.17.0",
|
"@mantine/notifications": "^8.2.2",
|
||||||
"@prisma/client": "^6.4.1",
|
"@prisma/adapter-pg": "^6.13.0",
|
||||||
"@prisma/internals": "^6.4.1",
|
"@prisma/client": "^6.13.0",
|
||||||
"@prisma/migrate": "^6.4.1",
|
"@prisma/internals": "^6.13.0",
|
||||||
"@smithy/node-http-handler": "^4.0.4",
|
"@prisma/migrate": "^6.13.0",
|
||||||
"@tabler/icons-react": "^3.30.0",
|
"@smithy/node-http-handler": "^4.1.0",
|
||||||
"@xoi/gps-metadata-remover": "^1.1.2",
|
"@tabler/icons-react": "^3.34.1",
|
||||||
"argon2": "^0.41.1",
|
"argon2": "^0.43.1",
|
||||||
"bytes": "^3.1.2",
|
"bytes": "^3.1.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"colorette": "^2.0.20",
|
"colorette": "^2.0.20",
|
||||||
"commander": "^13.1.0",
|
"commander": "^14.0.0",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^10.0.0",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^17.2.1",
|
||||||
"express": "^4.21.2",
|
|
||||||
"fast-glob": "^3.3.3",
|
"fast-glob": "^3.3.3",
|
||||||
"fastify": "^5.2.1",
|
"fastify": "^5.4.0",
|
||||||
"fastify-plugin": "^5.0.1",
|
"fastify-plugin": "^5.0.1",
|
||||||
"fflate": "^0.8.2",
|
"fflate": "^0.8.2",
|
||||||
"fluent-ffmpeg": "^2.1.3",
|
"fluent-ffmpeg": "^2.1.3",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"iron-session": "^8.0.4",
|
"iron-session": "^8.0.4",
|
||||||
"isomorphic-dompurify": "^2.22.0",
|
"isomorphic-dompurify": "^2.26.0",
|
||||||
"katex": "^0.16.21",
|
"katex": "^0.16.22",
|
||||||
"mantine-datatable": "^7.15.1",
|
"mantine-datatable": "^8.2.0",
|
||||||
"ms": "^2.1.3",
|
"ms": "^2.1.3",
|
||||||
"multer": "1.4.5-lts.1",
|
"multer": "2.0.2",
|
||||||
"next": "^15.2.4",
|
"next": "^15.4.5",
|
||||||
|
"nuqs": "^2.4.3",
|
||||||
"otplib": "^12.0.1",
|
"otplib": "^12.0.1",
|
||||||
"prisma": "^6.4.1",
|
"prisma": "^6.13.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.0.0-rc.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.0.0-rc.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-markdown": "^10.0.0",
|
"react-markdown": "^10.1.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sharp": "^0.33.5",
|
"sharp": "^0.34.3",
|
||||||
"swr": "^2.3.2",
|
"swr": "^2.3.4",
|
||||||
"zod": "^3.24.2",
|
"typescript-eslint": "^8.38.0",
|
||||||
"zustand": "^5.0.3"
|
"zod": "^3.25.67",
|
||||||
|
"zustand": "^5.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^1.2.7",
|
"@next/eslint-plugin-next": "^15.4.5",
|
||||||
"@eslint/eslintrc": "^3.3.0",
|
|
||||||
"@eslint/js": "^9.21.0",
|
|
||||||
"@types/bytes": "^3.1.5",
|
"@types/bytes": "^3.1.5",
|
||||||
"@types/express": "^5.0.0",
|
|
||||||
"@types/fluent-ffmpeg": "^2.1.27",
|
"@types/fluent-ffmpeg": "^2.1.27",
|
||||||
"@types/katex": "^0.16.7",
|
"@types/katex": "^0.16.7",
|
||||||
"@types/ms": "^2.1.0",
|
"@types/ms": "^2.1.0",
|
||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/node": "^22.13.5",
|
"@types/node": "^24.1.0",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/react": "^19.0.10",
|
"@types/react": "^19.1.9",
|
||||||
"@types/react-dom": "^19.0.4",
|
"@types/react-dom": "^19.1.7",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.24.1",
|
"eslint": "^9.32.0",
|
||||||
"@typescript-eslint/parser": "^8.24.1",
|
"eslint-config-next": "^15.4.5",
|
||||||
"eslint": "^9.21.0",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-config-next": "^15.1.7",
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-plugin-prettier": "^5.5.3",
|
||||||
"eslint-plugin-prettier": "^5.2.3",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-unused-imports": "^4.1.4",
|
"eslint-plugin-unused-imports": "^4.1.4",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.6",
|
||||||
"postcss-preset-mantine": "^1.17.0",
|
"postcss-preset-mantine": "^1.18.0",
|
||||||
"postcss-simple-vars": "^7.0.1",
|
"postcss-simple-vars": "^7.0.1",
|
||||||
"prettier": "^3.5.2",
|
"prettier": "^3.6.2",
|
||||||
"sass": "^1.86.1",
|
"sass": "^1.89.2",
|
||||||
"tsc-alias": "^1.8.10",
|
"tsc-alias": "^1.8.16",
|
||||||
"tsup": "^8.3.6",
|
"tsup": "^8.5.0",
|
||||||
"tsx": "^4.19.3",
|
"tsx": "^4.20.3",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.9.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=22"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.2.0"
|
"packageManager": "pnpm@10.12.1"
|
||||||
}
|
}
|
||||||
|
|||||||
6690
pnpm-lock.yaml
generated
6690
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
10
pnpm-workspace.yaml
Normal file
10
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
ignoredBuiltDependencies:
|
||||||
|
- unrs-resolver
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- '@parcel/watcher'
|
||||||
|
- '@prisma/client'
|
||||||
|
- '@prisma/engines'
|
||||||
|
- argon2
|
||||||
|
- esbuild
|
||||||
|
- prisma
|
||||||
|
- sharp
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Zipline" ADD COLUMN "featuresVersionAPI" TEXT NOT NULL DEFAULT 'https://zipline-version.diced.sh',
|
||||||
|
ADD COLUMN "featuresVersionChecking" BOOLEAN NOT NULL DEFAULT true;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Zipline" ADD COLUMN "oauthDiscordWhitelistIds" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `oauthDiscordWhitelistIds` on the `Zipline` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Zipline" DROP COLUMN "oauthDiscordWhitelistIds",
|
||||||
|
ADD COLUMN "oauthDiscordAllowedIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||||
|
ADD COLUMN "oauthDiscordDeniedIds" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Zipline" ADD COLUMN "domains" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
# Please do not edit this file manually
|
# Please do not edit this file manually
|
||||||
# It should be added in your version-control system (e.g., Git)
|
# It should be added in your version-control system (e.g., Git)
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
|
output = "../generated/client"
|
||||||
|
previewFeatures = ["queryCompiler", "driverAdapters"]
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
@@ -57,6 +59,9 @@ model Zipline {
|
|||||||
featuresMetricsAdminOnly Boolean @default(false)
|
featuresMetricsAdminOnly Boolean @default(false)
|
||||||
featuresMetricsShowUserSpecific Boolean @default(true)
|
featuresMetricsShowUserSpecific Boolean @default(true)
|
||||||
|
|
||||||
|
featuresVersionChecking Boolean @default(true)
|
||||||
|
featuresVersionAPI String @default("https://zipline-version.diced.sh")
|
||||||
|
|
||||||
invitesEnabled Boolean @default(true)
|
invitesEnabled Boolean @default(true)
|
||||||
invitesLength Int @default(6)
|
invitesLength Int @default(6)
|
||||||
|
|
||||||
@@ -78,6 +83,8 @@ model Zipline {
|
|||||||
oauthDiscordClientId String?
|
oauthDiscordClientId String?
|
||||||
oauthDiscordClientSecret String?
|
oauthDiscordClientSecret String?
|
||||||
oauthDiscordRedirectUri String?
|
oauthDiscordRedirectUri String?
|
||||||
|
oauthDiscordAllowedIds String[] @default([])
|
||||||
|
oauthDiscordDeniedIds String[] @default([])
|
||||||
|
|
||||||
oauthGoogleClientId String?
|
oauthGoogleClientId String?
|
||||||
oauthGoogleClientSecret String?
|
oauthGoogleClientSecret String?
|
||||||
@@ -129,6 +136,8 @@ model Zipline {
|
|||||||
pwaDescription String @default("Zipline")
|
pwaDescription String @default("Zipline")
|
||||||
pwaThemeColor String @default("#000000")
|
pwaThemeColor String @default("#000000")
|
||||||
pwaBackgroundColor String @default("#000000")
|
pwaBackgroundColor String @default("#000000")
|
||||||
|
|
||||||
|
domains String[] @default([])
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import Link from 'next/link';
|
|||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import ConfigProvider from './ConfigProvider';
|
import ConfigProvider from './ConfigProvider';
|
||||||
|
import VersionBadge from './VersionBadge';
|
||||||
|
|
||||||
type NavLinks = {
|
type NavLinks = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -357,23 +358,27 @@ export default function Layout({ children, config }: { children: React.ReactNode
|
|||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<Divider mt='auto' />
|
<div style={{ marginTop: 'auto' }}>
|
||||||
|
<VersionBadge />
|
||||||
|
|
||||||
<ScrollArea mah='auto'>
|
<Divider />
|
||||||
<Box>
|
|
||||||
{config.website.externalLinks.map(({ name, url }, i) => (
|
<ScrollArea mah='auto'>
|
||||||
<NavLink
|
<Box>
|
||||||
key={i}
|
{config.website.externalLinks.map(({ name, url }, i) => (
|
||||||
label={name}
|
<NavLink
|
||||||
leftSection={<IconExternalLink size='1rem' />}
|
key={i}
|
||||||
variant='light'
|
label={name}
|
||||||
component={Link}
|
leftSection={<IconExternalLink size='1rem' />}
|
||||||
href={url}
|
variant='light'
|
||||||
target='_blank'
|
component={Link}
|
||||||
/>
|
href={url}
|
||||||
))}
|
target='_blank'
|
||||||
</Box>
|
/>
|
||||||
</ScrollArea>
|
))}
|
||||||
|
</Box>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
</AppShell.Navbar>
|
</AppShell.Navbar>
|
||||||
|
|
||||||
<AppShell.Main>
|
<AppShell.Main>
|
||||||
|
|||||||
173
src/components/VersionBadge.tsx
Normal file
173
src/components/VersionBadge.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import useVersion from '@/lib/hooks/useVersion';
|
||||||
|
import {
|
||||||
|
Anchor,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Flex,
|
||||||
|
Indicator,
|
||||||
|
Modal,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
|
|
||||||
|
function DataDisplay({ items }: { items: { label: string; value: string; href?: string }[] }) {
|
||||||
|
return (
|
||||||
|
<Paper withBorder p='sm'>
|
||||||
|
<Stack gap='xs'>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<Flex justify='space-between' align='center' style={{ width: '100%' }} key={index}>
|
||||||
|
<Text c='dimmed' fw='bolder' style={{ flex: 1 }}>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{item.href ? (
|
||||||
|
<Anchor href={item.href} target='_blank'>
|
||||||
|
{item.value}
|
||||||
|
</Anchor>
|
||||||
|
) : (
|
||||||
|
<Text>{item.value}</Text>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VersionButton({ text, children, href }: { href: string; text: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
component='a'
|
||||||
|
href={href}
|
||||||
|
target='_blank'
|
||||||
|
variant='filled'
|
||||||
|
fullWidth
|
||||||
|
color='blue'
|
||||||
|
size='sm'
|
||||||
|
mt='xs'
|
||||||
|
leftSection={
|
||||||
|
<Text size='sm' fw='bolder'>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VersionBadge() {
|
||||||
|
const { version, isLoading } = useVersion();
|
||||||
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
|
|
||||||
|
if (isLoading) return null;
|
||||||
|
if (!version) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal title='Zipline Version' opened={opened} onClose={close} size='lg'>
|
||||||
|
{version.isLatest && <Text>Running the latest version of Zipline.</Text>}
|
||||||
|
{version.isUpstream && (
|
||||||
|
<Text>
|
||||||
|
You are running an <b>unstable</b> version of Zipline. Upstream versions are not fully tested and
|
||||||
|
may contain bugs.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{!version.isLatest && !version.isUpstream && version.isRelease && (
|
||||||
|
<Text>
|
||||||
|
You are running an <b>outdated</b> version of Zipline. It is recommended to update to the{' '}
|
||||||
|
<Anchor href={version.latest.url}>latest version</Anchor>.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Indicator
|
||||||
|
processing
|
||||||
|
position='middle-end'
|
||||||
|
inline
|
||||||
|
offset={-15}
|
||||||
|
color='red'
|
||||||
|
disabled={version.isLatest}
|
||||||
|
>
|
||||||
|
<Title order={3} my='sm'>
|
||||||
|
Current Version
|
||||||
|
</Title>
|
||||||
|
</Indicator>
|
||||||
|
<DataDisplay
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: 'Version',
|
||||||
|
value: version.version.tag!,
|
||||||
|
href: `https://github.com/diced/zipline/releases/${version.version.tag}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Commit',
|
||||||
|
value: version.version.sha!,
|
||||||
|
href: `https://github.com/diced/zipline/commit/${version.version.sha}`,
|
||||||
|
},
|
||||||
|
{ label: 'Upstream?', value: version.isUpstream ? 'Yes' : 'No' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!version.isLatest && version.isUpstream && version.latest.commit && (
|
||||||
|
<>
|
||||||
|
<Title order={3} mt='sm'>
|
||||||
|
Latest Commit Available
|
||||||
|
</Title>
|
||||||
|
<Text c='dimmed' size='sm' mb='sm'>
|
||||||
|
This is only visible when running an upstream version.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<DataDisplay
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: 'Commit',
|
||||||
|
value: version.latest.commit.sha!.slice(0, 7)!,
|
||||||
|
href: `https://github.com/diced/zipline/commit/${version.latest.commit.sha}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Available to update',
|
||||||
|
value: version.latest.commit.pull ? 'Yes' : 'No',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!version.isLatest && version.isRelease && (
|
||||||
|
<>
|
||||||
|
<Title order={3} mt='sm'>
|
||||||
|
{version.latest.tag} is available
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
<VersionButton text='Changelogs' href={version.latest.url}>
|
||||||
|
{version.latest.tag}
|
||||||
|
</VersionButton>
|
||||||
|
|
||||||
|
<VersionButton text='Update' href='https://zipline.diced.sh/docs/get-started/docker#updating'>
|
||||||
|
{version.latest.tag}
|
||||||
|
</VersionButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Tooltip label='Click to view more version information'>
|
||||||
|
<Badge
|
||||||
|
onClick={open}
|
||||||
|
style={{ cursor: 'pointer', textTransform: 'unset' }}
|
||||||
|
mx='sm'
|
||||||
|
my='xs'
|
||||||
|
color={version.isLatest ? 'green' : 'red'}
|
||||||
|
variant='dot'
|
||||||
|
size='lg'
|
||||||
|
radius='md'
|
||||||
|
>
|
||||||
|
{version.version?.tag}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { fetchApi } from '@/lib/fetchApi';
|
|||||||
import { Button, Divider, Modal, NumberInput, PasswordInput, Stack, TextInput } from '@mantine/core';
|
import { Button, Divider, Modal, NumberInput, PasswordInput, Stack, TextInput } from '@mantine/core';
|
||||||
import { showNotification } from '@mantine/notifications';
|
import { showNotification } from '@mantine/notifications';
|
||||||
import { IconEye, IconKey, IconPencil, IconPencilOff, IconTrashFilled } from '@tabler/icons-react';
|
import { IconEye, IconKey, IconPencil, IconPencilOff, IconTrashFilled } from '@tabler/icons-react';
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { mutateFiles } from '../actions';
|
import { mutateFiles } from '../actions';
|
||||||
|
|
||||||
export default function EditFileDetailsModal({
|
export default function EditFileDetailsModal({
|
||||||
@@ -17,6 +17,7 @@ export default function EditFileDetailsModal({
|
|||||||
}) {
|
}) {
|
||||||
if (!file) return null;
|
if (!file) return null;
|
||||||
|
|
||||||
|
const [name, setName] = useState<string>(file.name ?? '');
|
||||||
const [maxViews, setMaxViews] = useState<number | null>(file?.maxViews ?? null);
|
const [maxViews, setMaxViews] = useState<number | null>(file?.maxViews ?? null);
|
||||||
const [password, setPassword] = useState<string | null>('');
|
const [password, setPassword] = useState<string | null>('');
|
||||||
const [originalName, setOriginalName] = useState<string | null>(file?.originalName ?? null);
|
const [originalName, setOriginalName] = useState<string | null>(file?.originalName ?? null);
|
||||||
@@ -54,12 +55,16 @@ export default function EditFileDetailsModal({
|
|||||||
password?: string;
|
password?: string;
|
||||||
originalName?: string;
|
originalName?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
|
name?: string;
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
if (maxViews !== null) data['maxViews'] = maxViews;
|
if (maxViews !== null) data['maxViews'] = maxViews;
|
||||||
if (password !== null) data['password'] = password?.trim();
|
|
||||||
if (originalName !== null) data['originalName'] = originalName?.trim();
|
if (originalName !== null) data['originalName'] = originalName?.trim();
|
||||||
if (type !== null) data['type'] = type?.trim();
|
if (type !== null) data['type'] = type?.trim();
|
||||||
|
if (name !== file.name) data['name'] = name.trim();
|
||||||
|
|
||||||
|
const passwordTrimmed = password?.trim();
|
||||||
|
if (passwordTrimmed !== '') data['password'] = passwordTrimmed;
|
||||||
|
|
||||||
const { error } = await fetchApi(`/api/user/files/${file.id}`, 'PATCH', data);
|
const { error } = await fetchApi(`/api/user/files/${file.id}`, 'PATCH', data);
|
||||||
|
|
||||||
@@ -85,9 +90,26 @@ export default function EditFileDetailsModal({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setName(file.name ?? '');
|
||||||
|
setMaxViews(file.maxViews ?? null);
|
||||||
|
setPassword(file.password ? '' : null);
|
||||||
|
setOriginalName(file.originalName ?? null);
|
||||||
|
setType(file.type ?? null);
|
||||||
|
}
|
||||||
|
}, [open, file]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal zIndex={300} title={`Editing "${file.name}"`} onClose={onClose} opened={open}>
|
<Modal zIndex={300} title={`Editing "${file.name}"`} onClose={onClose} opened={open}>
|
||||||
<Stack gap='xs' my='sm'>
|
<Stack gap='xs' my='sm'>
|
||||||
|
<TextInput
|
||||||
|
label='Name'
|
||||||
|
description='Rename the file.'
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.currentTarget.value.trim())}
|
||||||
|
/>
|
||||||
|
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label='Max Views'
|
label='Max Views'
|
||||||
placeholder='Unlimited'
|
placeholder='Unlimited'
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
Pill,
|
Pill,
|
||||||
PillsInput,
|
PillsInput,
|
||||||
ScrollArea,
|
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Text,
|
Text,
|
||||||
Title,
|
Title,
|
||||||
@@ -61,8 +60,8 @@ import {
|
|||||||
removeFromFolder,
|
removeFromFolder,
|
||||||
viewFile,
|
viewFile,
|
||||||
} from '../actions';
|
} from '../actions';
|
||||||
import FileStat from './FileStat';
|
|
||||||
import EditFileDetailsModal from './EditFileDetailsModal';
|
import EditFileDetailsModal from './EditFileDetailsModal';
|
||||||
|
import FileStat from './FileStat';
|
||||||
|
|
||||||
function ActionButton({
|
function ActionButton({
|
||||||
Icon,
|
Icon,
|
||||||
@@ -189,9 +188,9 @@ export default function FileModal({
|
|||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
size='auto'
|
size='auto'
|
||||||
|
maw='90vw'
|
||||||
centered
|
centered
|
||||||
zIndex={200}
|
zIndex={200}
|
||||||
scrollAreaComponent={ScrollArea.Autosize}
|
|
||||||
>
|
>
|
||||||
{file ? (
|
{file ? (
|
||||||
<>
|
<>
|
||||||
@@ -305,7 +304,8 @@ export default function FileModal({
|
|||||||
onClick={() => removeFromFolder(file)}
|
onClick={() => removeFromFolder(file)}
|
||||||
fullWidth
|
fullWidth
|
||||||
>
|
>
|
||||||
Remove from folder "{folders?.find((f) => f.id === file.folderId)?.name ?? ''}
|
Remove from folder "
|
||||||
|
{folders?.find((f: { id: string }) => f.id === file.folderId)?.name ?? ''}
|
||||||
"
|
"
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
@@ -337,18 +337,21 @@ export default function FileModal({
|
|||||||
<Combobox.Dropdown>
|
<Combobox.Dropdown>
|
||||||
<Combobox.Options>
|
<Combobox.Options>
|
||||||
{folders
|
{folders
|
||||||
?.filter((f) => f.name.toLowerCase().includes(search.toLowerCase().trim()))
|
?.filter((f: { name: string }) =>
|
||||||
.map((f) => (
|
f.name.toLowerCase().includes(search.toLowerCase().trim()),
|
||||||
|
)
|
||||||
|
.map((f: { name: string; id: string }) => (
|
||||||
<Combobox.Option value={f.id} key={f.id}>
|
<Combobox.Option value={f.id} key={f.id}>
|
||||||
{f.name}
|
{f.name}
|
||||||
</Combobox.Option>
|
</Combobox.Option>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{!folders?.some((f) => f.name === search) && search.trim().length > 0 && (
|
{!folders?.some((f: { name: string }) => f.name === search) &&
|
||||||
<Combobox.Option value='$create'>
|
search.trim().length > 0 && (
|
||||||
+ Create folder "{search}"
|
<Combobox.Option value='$create'>
|
||||||
</Combobox.Option>
|
+ Create folder "{search}"
|
||||||
)}
|
</Combobox.Option>
|
||||||
|
)}
|
||||||
</Combobox.Options>
|
</Combobox.Options>
|
||||||
</Combobox.Dropdown>
|
</Combobox.Dropdown>
|
||||||
</Combobox>
|
</Combobox>
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { Icon, IconFileUnknown, IconPlayerPlay, IconShieldLockFilled } from '@tabler/icons-react';
|
import { Icon, IconFileUnknown, IconPlayerPlay, IconShieldLockFilled } from '@tabler/icons-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
import { renderMode } from '../pages/upload/renderMode';
|
import { renderMode } from '../pages/upload/renderMode';
|
||||||
import Render from '../render/Render';
|
import Render from '../render/Render';
|
||||||
import fileIcon from './fileIcon';
|
import fileIcon from './fileIcon';
|
||||||
|
import { parseAsStringLiteral, useQueryState } from 'nuqs';
|
||||||
|
|
||||||
function PlaceholderContent({ text, Icon }: { text: string; Icon: Icon }) {
|
function PlaceholderContent({ text, Icon }: { text: string; Icon: Icon }) {
|
||||||
return (
|
return (
|
||||||
@@ -29,7 +30,7 @@ function PlaceholderContent({ text, Icon }: { text: string; Icon: Icon }) {
|
|||||||
|
|
||||||
function Placeholder({ text, Icon, ...props }: { text: string; Icon: Icon; onClick?: () => void }) {
|
function Placeholder({ text, Icon, ...props }: { text: string; Icon: Icon; onClick?: () => void }) {
|
||||||
return (
|
return (
|
||||||
<Center py='xs' style={{ height: '100%', width: '100%', cursor: 'pointed' }} {...props}>
|
<Center py='xs' style={{ height: '100%', width: '100%', cursor: 'pointer' }} {...props}>
|
||||||
<PlaceholderContent text={text} Icon={Icon} />
|
<PlaceholderContent text={text} Icon={Icon} />
|
||||||
</Center>
|
</Center>
|
||||||
);
|
);
|
||||||
@@ -73,69 +74,82 @@ export default function DashboardFileType({
|
|||||||
}: {
|
}: {
|
||||||
file: DbFile | File;
|
file: DbFile | File;
|
||||||
show?: boolean;
|
show?: boolean;
|
||||||
password?: string;
|
password?: string | null;
|
||||||
code?: boolean;
|
code?: boolean;
|
||||||
allowZoom?: boolean;
|
allowZoom?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const [overrideType] = useQueryState('otype', parseAsStringLiteral(['video', 'audio', 'image', 'text']));
|
||||||
|
|
||||||
const disableMediaPreview = useSettingsStore((state) => state.settings.disableMediaPreview);
|
const disableMediaPreview = useSettingsStore((state) => state.settings.disableMediaPreview);
|
||||||
|
|
||||||
const dbFile = 'id' in file;
|
const dbFile = 'id' in file;
|
||||||
const renderIn = renderMode(file.name.split('.').pop() || '');
|
const renderIn = useMemo(() => renderMode(file.name.split('.').pop() || ''), [file.name]);
|
||||||
|
|
||||||
const [fileContent, setFileContent] = useState('');
|
const [fileContent, setFileContent] = useState('');
|
||||||
const [type, setType] = useState<string>(file.type.split('/')[0]);
|
const [type, setType] = useState<string>(file.type.split('/')[0]);
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const gettext = async () => {
|
const getText = useCallback(async () => {
|
||||||
if (!dbFile) {
|
try {
|
||||||
const reader = new FileReader();
|
if (!dbFile) {
|
||||||
reader.onload = () => {
|
const reader = new FileReader();
|
||||||
if ((reader.result! as string).length > 1 * 1024 * 1024) {
|
reader.onload = () => {
|
||||||
setFileContent(
|
if ((reader.result! as string).length > 1 * 1024 * 1024) {
|
||||||
reader.result!.slice(0, 1 * 1024 * 1024) +
|
setFileContent(
|
||||||
'\n...\nThe file is too big to display click the download icon to view/download it.',
|
reader.result!.slice(0, 1 * 1024 * 1024) +
|
||||||
);
|
'\n...\nThe file is too big to display click the download icon to view/download it.',
|
||||||
} else {
|
);
|
||||||
setFileContent(reader.result as string);
|
} else {
|
||||||
}
|
setFileContent(reader.result as string);
|
||||||
};
|
}
|
||||||
reader.readAsText(file);
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
if (file.size > 1 * 1024 * 1024) {
|
||||||
}
|
const res = await fetch(`/raw/${file.name}${password ? `?pw=${password}` : ''}`, {
|
||||||
|
headers: {
|
||||||
if (file.size > 1 * 1024 * 1024) {
|
Range: 'bytes=0-' + 1 * 1024 * 1024, // 0 mb to 1 mb
|
||||||
const res = await fetch(`/raw/${file.name}${password ? `?pw=${password}` : ''}`, {
|
},
|
||||||
headers: {
|
});
|
||||||
Range: 'bytes=0-' + 1 * 1024 * 1024, // 0 mb to 1 mb
|
if (!res.ok) throw new Error('Failed to fetch file');
|
||||||
},
|
const text = await res.text();
|
||||||
});
|
setFileContent(
|
||||||
|
text + '\n...\nThe file is too big to display click the download icon to view/download it.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`/raw/${file.name}${password ? `?pw=${password}` : ''}`);
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch file');
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
setFileContent(
|
setFileContent(text);
|
||||||
text + '\n...\nThe file is too big to display click the download icon to view/download it.',
|
} catch {
|
||||||
);
|
setFileContent('Error loading file.');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
}, [dbFile, file, password]);
|
||||||
const res = await fetch(`/raw/${file.name}${password ? `?pw=${password}` : ''}`);
|
|
||||||
const text = await res.text();
|
|
||||||
|
|
||||||
setFileContent(text);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (code) {
|
if (code) {
|
||||||
setType('text');
|
setType('text');
|
||||||
gettext();
|
getText();
|
||||||
} else if (type === 'text') {
|
} else if (overrideType === 'text' || type === 'text') {
|
||||||
gettext();
|
getText();
|
||||||
} else {
|
} else {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = 'auto';
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
if (disableMediaPreview && !show)
|
if (disableMediaPreview && !show)
|
||||||
return <Placeholder text={`Click to view file ${file.name}`} Icon={fileIcon(file.type)} />;
|
return <Placeholder text={`Click to view file ${file.name}`} Icon={fileIcon(file.type)} />;
|
||||||
|
|
||||||
@@ -153,7 +167,7 @@ export default function DashboardFileType({
|
|||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
|
|
||||||
switch (type) {
|
switch (overrideType || type) {
|
||||||
case 'video':
|
case 'video':
|
||||||
return show ? (
|
return show ? (
|
||||||
<video
|
<video
|
||||||
@@ -162,11 +176,14 @@ export default function DashboardFileType({
|
|||||||
muted
|
muted
|
||||||
controls
|
controls
|
||||||
src={dbFile ? `/raw/${file.name}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
|
src={dbFile ? `/raw/${file.name}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer', maxWidth: '85vw', maxHeight: '85vh' }}
|
||||||
/>
|
/>
|
||||||
) : (file as DbFile).thumbnail && dbFile ? (
|
) : (file as DbFile).thumbnail && dbFile ? (
|
||||||
<Box pos='relative'>
|
<Box pos='relative'>
|
||||||
<MantineImage src={`/raw/${(file as DbFile).thumbnail!.path}`} alt={file.name} />
|
<MantineImage
|
||||||
|
src={`/raw/${(file as DbFile).thumbnail!.path}`}
|
||||||
|
alt={file.name || 'Video thumbnail'}
|
||||||
|
/>
|
||||||
|
|
||||||
<Center
|
<Center
|
||||||
pos='absolute'
|
pos='absolute'
|
||||||
@@ -191,14 +208,12 @@ export default function DashboardFileType({
|
|||||||
return show ? (
|
return show ? (
|
||||||
<Center>
|
<Center>
|
||||||
<MantineImage
|
<MantineImage
|
||||||
mah={400}
|
|
||||||
src={dbFile ? `/raw/${file.name}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
|
src={dbFile ? `/raw/${file.name}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
|
||||||
alt={file.name}
|
alt={file.name || 'Image'}
|
||||||
style={{
|
style={{
|
||||||
maxWidth: '90vw',
|
|
||||||
maxHeight: '90vh',
|
|
||||||
cursor: allowZoom ? 'zoom-in' : 'default',
|
cursor: allowZoom ? 'zoom-in' : 'default',
|
||||||
width: 'auto',
|
maxWidth: '70vw',
|
||||||
|
maxHeight: '70vw',
|
||||||
}}
|
}}
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
/>
|
/>
|
||||||
@@ -208,10 +223,10 @@ export default function DashboardFileType({
|
|||||||
src={
|
src={
|
||||||
dbFile ? `/raw/${file.name}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)
|
dbFile ? `/raw/${file.name}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)
|
||||||
}
|
}
|
||||||
alt={file.name}
|
alt={file.name || 'Image'}
|
||||||
style={{
|
style={{
|
||||||
maxWidth: '90vw',
|
maxWidth: '95vw',
|
||||||
maxHeight: '90vh',
|
maxHeight: '95vh',
|
||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
cursor: 'zoom-out',
|
cursor: 'zoom-out',
|
||||||
width: 'auto',
|
width: 'auto',
|
||||||
@@ -225,7 +240,7 @@ export default function DashboardFileType({
|
|||||||
fit='contain'
|
fit='contain'
|
||||||
mah={400}
|
mah={400}
|
||||||
src={dbFile ? `/raw/${file.name}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
|
src={dbFile ? `/raw/${file.name}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
|
||||||
alt={file.name}
|
alt={file.name || 'Image'}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 'audio':
|
case 'audio':
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { IncompleteFile } from '@/lib/db/models/incompleteFile';
|
|||||||
import { fetchApi } from '@/lib/fetchApi';
|
import { fetchApi } from '@/lib/fetchApi';
|
||||||
import { ActionIcon, Badge, Button, Card, Group, Modal, Paper, Stack, Text, Tooltip } from '@mantine/core';
|
import { ActionIcon, Badge, Button, Card, Group, Modal, Paper, Stack, Text, Tooltip } from '@mantine/core';
|
||||||
import { showNotification } from '@mantine/notifications';
|
import { showNotification } from '@mantine/notifications';
|
||||||
import { IncompleteFileStatus } from '@prisma/client';
|
import { IncompleteFileStatus } from '../../../../generated/client';
|
||||||
import { IconFileDots, IconTrashFilled } from '@tabler/icons-react';
|
import { IconFileDots, IconTrashFilled } from '@tabler/icons-react';
|
||||||
import { useRouter } from 'next/router';
|
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||||
import { ReactNode, useEffect, useState } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
const badgeMap: Record<IncompleteFileStatus, ReactNode> = {
|
const badgeMap: Record<IncompleteFileStatus, ReactNode> = {
|
||||||
@@ -33,9 +33,7 @@ const badgeMap: Record<IncompleteFileStatus, ReactNode> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function PendingFilesButton() {
|
export default function PendingFilesButton() {
|
||||||
const router = useRouter();
|
const [open, setOpen] = useQueryState('popen', parseAsBoolean.withDefault(false));
|
||||||
|
|
||||||
const [open, setOpen] = useState(router.query.pending !== undefined);
|
|
||||||
|
|
||||||
const { data: incompleteFiles, mutate } = useSWR<
|
const { data: incompleteFiles, mutate } = useSWR<
|
||||||
Extract<IncompleteFile[], Response['/api/user/files/incomplete']>
|
Extract<IncompleteFile[], Response['/api/user/files/incomplete']>
|
||||||
@@ -68,15 +66,6 @@ export default function PendingFilesButton() {
|
|||||||
mutate();
|
mutate();
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
router.push({ query: { ...router.query, pending: 'true' } }, undefined, { shallow: true });
|
|
||||||
} else {
|
|
||||||
delete router.query.pending;
|
|
||||||
router.push({ query: router.query }, undefined, { shallow: true });
|
|
||||||
}
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal opened={open} onClose={() => setOpen(false)} title='Pending Files'>
|
<Modal opened={open} onClose={() => setOpen(false)} title='Pending Files'>
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export async function bulkDelete(ids: string[], setSelectedFiles: (files: File[]
|
|||||||
icon: <IconFilesOff size='1rem' />,
|
icon: <IconFilesOff size='1rem' />,
|
||||||
id: 'bulk-delete',
|
id: 'bulk-delete',
|
||||||
autoClose: true,
|
autoClose: true,
|
||||||
|
loading: false,
|
||||||
});
|
});
|
||||||
} else if (data) {
|
} else if (data) {
|
||||||
notifications.update({
|
notifications.update({
|
||||||
@@ -107,6 +108,7 @@ export async function bulkFavorite(ids: string[]) {
|
|||||||
icon: <IconStarsOff size='1rem' />,
|
icon: <IconStarsOff size='1rem' />,
|
||||||
id: 'bulk-favorite',
|
id: 'bulk-favorite',
|
||||||
autoClose: true,
|
autoClose: true,
|
||||||
|
loading: false,
|
||||||
});
|
});
|
||||||
} else if (data) {
|
} else if (data) {
|
||||||
notifications.update({
|
notifications.update({
|
||||||
@@ -116,6 +118,7 @@ export async function bulkFavorite(ids: string[]) {
|
|||||||
icon: <IconStarsFilled size='1rem' />,
|
icon: <IconStarsFilled size='1rem' />,
|
||||||
id: 'bulk-favorite',
|
id: 'bulk-favorite',
|
||||||
autoClose: true,
|
autoClose: true,
|
||||||
|
loading: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default function CreateTagModal({ open, onClose }: { open: boolean; onClo
|
|||||||
const color = values.color.trim() === '' ? colorHash(values.name) : values.color.trim();
|
const color = values.color.trim() === '' ? colorHash(values.name) : values.color.trim();
|
||||||
|
|
||||||
if (!color.startsWith('#')) {
|
if (!color.startsWith('#')) {
|
||||||
form.setFieldError('color', 'Color must start with #');
|
return form.setFieldError('color', 'Color must start with #');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data, error } = await fetchApi<Extract<Response['/api/user/tags'], Tag>>(
|
const { data, error } = await fetchApi<Extract<Response['/api/user/tags'], Tag>>(
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { Tag } from '@/lib/db/models/tag';
|
|
||||||
import { Pill, isLightColor } from '@mantine/core';
|
import { Pill, isLightColor } from '@mantine/core';
|
||||||
|
|
||||||
export default function TagPill({
|
export default function TagPill({
|
||||||
tag,
|
tag,
|
||||||
...other
|
...other
|
||||||
}: {
|
}: {
|
||||||
tag: Tag | null;
|
tag: { color: string; name: string } | null;
|
||||||
withRemoveButton?: boolean;
|
withRemoveButton?: boolean;
|
||||||
onRemove?: () => void;
|
onRemove?: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
|
import { mutateFiles } from '@/components/file/actions';
|
||||||
import { Response } from '@/lib/api/response';
|
import { Response } from '@/lib/api/response';
|
||||||
import { Tag } from '@/lib/db/models/tag';
|
import { Tag } from '@/lib/db/models/tag';
|
||||||
import { ActionIcon, Group, Modal, Paper, Stack, Text, Title, Tooltip } from '@mantine/core';
|
|
||||||
import { IconPencil, IconPlus, IconTagOff, IconTags, IconTrashFilled } from '@tabler/icons-react';
|
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import useSWR from 'swr';
|
|
||||||
import TagPill from './TagPill';
|
|
||||||
import { fetchApi } from '@/lib/fetchApi';
|
import { fetchApi } from '@/lib/fetchApi';
|
||||||
|
import { ActionIcon, Group, Modal, Paper, Stack, Text, Title, Tooltip } from '@mantine/core';
|
||||||
import { showNotification } from '@mantine/notifications';
|
import { showNotification } from '@mantine/notifications';
|
||||||
|
import { IconPencil, IconPlus, IconTagOff, IconTags, IconTrashFilled } from '@tabler/icons-react';
|
||||||
|
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
import CreateTagModal from './CreateTagModal';
|
import CreateTagModal from './CreateTagModal';
|
||||||
import EditTagModal from './EditTagModal';
|
import EditTagModal from './EditTagModal';
|
||||||
import { mutateFiles } from '@/components/file/actions';
|
import TagPill from './TagPill';
|
||||||
|
|
||||||
export default function TagsButton() {
|
export default function TagsButton() {
|
||||||
const router = useRouter();
|
const [open, setOpen] = useQueryState('topen', parseAsBoolean.withDefault(false));
|
||||||
|
const [createModalOpen, setCreateModalOpen] = useQueryState('ctopen', parseAsBoolean.withDefault(false));
|
||||||
const [open, setOpen] = useState(router.query.tags !== undefined);
|
|
||||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
|
||||||
const [selectedTag, setSelectedTag] = useState<Tag | null>(null);
|
const [selectedTag, setSelectedTag] = useState<Tag | null>(null);
|
||||||
|
|
||||||
const { data: tags, mutate } = useSWR<Extract<Tag[], Response['/api/user/tags']>>('/api/user/tags');
|
const { data: tags, mutate } = useSWR<Extract<Tag[], Response['/api/user/tags']>>('/api/user/tags');
|
||||||
@@ -44,15 +42,6 @@ export default function TagsButton() {
|
|||||||
mutateFiles();
|
mutateFiles();
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
router.push({ query: { ...router.query, tags: 'true' } }, undefined, { shallow: true });
|
|
||||||
} else {
|
|
||||||
delete router.query.tags;
|
|
||||||
router.push({ query: router.query }, undefined, { shallow: true });
|
|
||||||
}
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CreateTagModal open={createModalOpen} onClose={() => setCreateModalOpen(false)} />
|
<CreateTagModal open={createModalOpen} onClose={() => setCreateModalOpen(false)} />
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Response } from '@/lib/api/response';
|
import { Response } from '@/lib/api/response';
|
||||||
import type { Prisma } from '@prisma/client';
|
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
type ApiPaginationOptions = {
|
type ApiPaginationOptions = {
|
||||||
@@ -7,7 +6,17 @@ type ApiPaginationOptions = {
|
|||||||
filter?: string;
|
filter?: string;
|
||||||
perpage?: number;
|
perpage?: number;
|
||||||
favorite?: boolean;
|
favorite?: boolean;
|
||||||
sort?: keyof Prisma.FileOrderByWithAggregationInput;
|
sort?:
|
||||||
|
| 'name'
|
||||||
|
| 'id'
|
||||||
|
| 'createdAt'
|
||||||
|
| 'updatedAt'
|
||||||
|
| 'deletesAt'
|
||||||
|
| 'originalName'
|
||||||
|
| 'size'
|
||||||
|
| 'type'
|
||||||
|
| 'views'
|
||||||
|
| 'favorite';
|
||||||
order?: 'asc' | 'desc';
|
order?: 'asc' | 'desc';
|
||||||
id?: string;
|
id?: string;
|
||||||
search?: {
|
search?: {
|
||||||
|
|||||||
@@ -12,47 +12,24 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { IconFileUpload, IconFilesOff } from '@tabler/icons-react';
|
import { IconFileUpload, IconFilesOff } from '@tabler/icons-react';
|
||||||
import Link from 'next/link';
|
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useApiPagination } from '../useApiPagination';
|
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||||
|
import { useApiPagination } from '../useApiPagination';
|
||||||
|
|
||||||
const DashboardFile = dynamic(() => import('@/components/file/DashboardFile'), {
|
const DashboardFile = dynamic(() => import('@/components/file/DashboardFile'), {
|
||||||
loading: () => <Skeleton height={350} animate />,
|
loading: () => <Skeleton height={350} animate />,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function FavoriteFiles() {
|
export default function FavoriteFiles() {
|
||||||
const router = useRouter();
|
const [page, setPage] = useQueryState('fpage', parseAsInteger.withDefault(1));
|
||||||
|
|
||||||
const [page, setPage] = useState<number>(
|
|
||||||
router.query.favoritePage ? parseInt(router.query.favoritePage as string) : 1,
|
|
||||||
);
|
|
||||||
const { data, isLoading } = useApiPagination({
|
const { data, isLoading } = useApiPagination({
|
||||||
page,
|
page,
|
||||||
favorite: true,
|
favorite: true,
|
||||||
filter: 'dashboard',
|
filter: 'dashboard',
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
router.replace(
|
|
||||||
{
|
|
||||||
query: {
|
|
||||||
...router.query,
|
|
||||||
favoritePage: page,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
{ shallow: true },
|
|
||||||
);
|
|
||||||
}, [page]);
|
|
||||||
|
|
||||||
if (!isLoading && !data?.page.length && router.query.favoritePage) {
|
|
||||||
delete router.query.favoritePage;
|
|
||||||
router.replace({ query: router.query }, undefined, { shallow: true });
|
|
||||||
setPage(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isLoading && !data?.page.length) {
|
if (!isLoading && !data?.page.length) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import RelativeDate from '@/components/RelativeDate';
|
import RelativeDate from '@/components/RelativeDate';
|
||||||
import FileModal from '@/components/file/DashboardFile/FileModal';
|
import FileModal from '@/components/file/DashboardFile/FileModal';
|
||||||
import { addMultipleToFolder, copyFile, deleteFile } from '@/components/file/actions';
|
import { addMultipleToFolder, copyFile, deleteFile, downloadFile } from '@/components/file/actions';
|
||||||
import { Response } from '@/lib/api/response';
|
import { Response } from '@/lib/api/response';
|
||||||
import { bytes } from '@/lib/bytes';
|
import { bytes } from '@/lib/bytes';
|
||||||
import { type File } from '@/lib/db/models/file';
|
import { type File } from '@/lib/db/models/file';
|
||||||
@@ -28,9 +28,9 @@ import {
|
|||||||
useCombobox,
|
useCombobox,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useClipboard } from '@mantine/hooks';
|
import { useClipboard } from '@mantine/hooks';
|
||||||
import type { Prisma } from '@prisma/client';
|
|
||||||
import {
|
import {
|
||||||
IconCopy,
|
IconCopy,
|
||||||
|
IconDownload,
|
||||||
IconExternalLink,
|
IconExternalLink,
|
||||||
IconFile,
|
IconFile,
|
||||||
IconGridPatternFilled,
|
IconGridPatternFilled,
|
||||||
@@ -39,7 +39,7 @@ import {
|
|||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { DataTable } from 'mantine-datatable';
|
import { DataTable } from 'mantine-datatable';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/router';
|
import { parseAsBoolean, parseAsInteger, parseAsStringLiteral, useQueryState } from 'nuqs';
|
||||||
import { useEffect, useReducer, useState } from 'react';
|
import { useEffect, useReducer, useState } from 'react';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import { bulkDelete, bulkFavorite } from '../bulk';
|
import { bulkDelete, bulkFavorite } from '../bulk';
|
||||||
@@ -179,7 +179,6 @@ function TagsFilter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function FileTable({ id }: { id?: string }) {
|
export default function FileTable({ id }: { id?: string }) {
|
||||||
const router = useRouter();
|
|
||||||
const clipboard = useClipboard();
|
const clipboard = useClipboard();
|
||||||
const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion);
|
const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion);
|
||||||
|
|
||||||
@@ -187,13 +186,30 @@ export default function FileTable({ id }: { id?: string }) {
|
|||||||
'/api/user/folders?noincl=true',
|
'/api/user/folders?noincl=true',
|
||||||
);
|
);
|
||||||
|
|
||||||
const [page, setPage] = useState<number>(router.query.page ? parseInt(router.query.page as string) : 1);
|
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
|
||||||
const [perpage, setPerpage] = useState<number>(20);
|
const [perpage, setPerpage] = useState<number>(20);
|
||||||
const [sort, setSort] = useState<keyof Prisma.FileOrderByWithAggregationInput>('createdAt');
|
const [sort, setSort] = useQueryState(
|
||||||
const [order, setOrder] = useState<'asc' | 'desc'>('desc');
|
'sort',
|
||||||
|
parseAsStringLiteral([
|
||||||
|
'id',
|
||||||
|
'createdAt',
|
||||||
|
'updatedAt',
|
||||||
|
'deletesAt',
|
||||||
|
'name',
|
||||||
|
'originalName',
|
||||||
|
'size',
|
||||||
|
'type',
|
||||||
|
'views',
|
||||||
|
'favorite',
|
||||||
|
]).withDefault('createdAt'),
|
||||||
|
);
|
||||||
|
const [order, setOrder] = useQueryState<'asc' | 'desc'>(
|
||||||
|
'order',
|
||||||
|
parseAsStringLiteral(['asc', 'desc']).withDefault('desc'),
|
||||||
|
);
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
|
||||||
const [idSearchOpen, setIdSearchOpen] = useState(false);
|
const [idSearchOpen, setIdSearchOpen] = useQueryState('idsearch', parseAsBoolean.withDefault(false));
|
||||||
const [searchField, setSearchField] = useState<'name' | 'originalName' | 'type' | 'tags' | 'id'>('name');
|
const [searchField, setSearchField] = useState<'name' | 'originalName' | 'type' | 'tags' | 'id'>('name');
|
||||||
const [searchQuery, setSearchQuery] = useReducer(
|
const [searchQuery, setSearchQuery] = useReducer(
|
||||||
(state: ReducerQuery['state'], action: ReducerQuery['action']) => {
|
(state: ReducerQuery['state'], action: ReducerQuery['action']) => {
|
||||||
@@ -253,19 +269,6 @@ export default function FileTable({ id }: { id?: string }) {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
router.replace(
|
|
||||||
{
|
|
||||||
query: {
|
|
||||||
...router.query,
|
|
||||||
page: page,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
{ shallow: true },
|
|
||||||
);
|
|
||||||
}, [page]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data && selectedFile) {
|
if (data && selectedFile) {
|
||||||
const file = data.page.find((x) => x.id === selectedFile.id);
|
const file = data.page.find((x) => x.id === selectedFile.id);
|
||||||
@@ -304,9 +307,8 @@ export default function FileTable({ id }: { id?: string }) {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIdSearchOpen((open) => !open);
|
setIdSearchOpen((open) => !open);
|
||||||
}}
|
}}
|
||||||
color='blue'
|
|
||||||
// lol if it works it works :shrug:
|
// lol if it works it works :shrug:
|
||||||
style={{ position: 'relative', top: '-36.4px', left: '219px', margin: 0 }}
|
style={{ position: 'relative', top: '-36.4px', left: '221px', margin: 0 }}
|
||||||
>
|
>
|
||||||
<IconGridPatternFilled size='1rem' />
|
<IconGridPatternFilled size='1rem' />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -519,6 +521,18 @@ export default function FileTable({ id }: { id?: string }) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip label='Download file'>
|
||||||
|
<ActionIcon
|
||||||
|
color='gray'
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
downloadFile(file);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconDownload size='1rem' />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip label='Delete file'>
|
<Tooltip label='Delete file'>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
color='red'
|
color='red'
|
||||||
@@ -546,7 +560,7 @@ export default function FileTable({ id }: { id?: string }) {
|
|||||||
direction: order,
|
direction: order,
|
||||||
}}
|
}}
|
||||||
onSortStatusChange={(data) => {
|
onSortStatusChange={(data) => {
|
||||||
setSort(data.columnAccessor as keyof Prisma.FileOrderByWithAggregationInput);
|
setSort(data.columnAccessor as any);
|
||||||
setOrder(data.direction);
|
setOrder(data.direction);
|
||||||
}}
|
}}
|
||||||
onCellClick={({ record }) => setSelectedFile(record)}
|
onCellClick={({ record }) => setSelectedFile(record)}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
import { IconFileUpload, IconFilesOff } from '@tabler/icons-react';
|
import { IconFileUpload, IconFilesOff } from '@tabler/icons-react';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/router';
|
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useApiPagination } from '../useApiPagination';
|
import { useApiPagination } from '../useApiPagination';
|
||||||
|
|
||||||
@@ -25,9 +25,7 @@ const DashboardFile = dynamic(() => import('@/components/file/DashboardFile'), {
|
|||||||
const PER_PAGE_OPTIONS = [9, 12, 15, 30, 45];
|
const PER_PAGE_OPTIONS = [9, 12, 15, 30, 45];
|
||||||
|
|
||||||
export default function Files({ id }: { id?: string }) {
|
export default function Files({ id }: { id?: string }) {
|
||||||
const router = useRouter();
|
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
|
||||||
|
|
||||||
const [page, setPage] = useState<number>(router.query.page ? parseInt(router.query.page as string) : 1);
|
|
||||||
const [perpage, setPerpage] = useState<number>(15);
|
const [perpage, setPerpage] = useState<number>(15);
|
||||||
const [cachedPages, setCachedPages] = useState<number>(1);
|
const [cachedPages, setCachedPages] = useState<number>(1);
|
||||||
|
|
||||||
@@ -43,19 +41,6 @@ export default function Files({ id }: { id?: string }) {
|
|||||||
}
|
}
|
||||||
}, [data?.pages]);
|
}, [data?.pages]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
router.replace(
|
|
||||||
{
|
|
||||||
query: {
|
|
||||||
...router.query,
|
|
||||||
page: page,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
{ shallow: true },
|
|
||||||
);
|
|
||||||
}, [page]);
|
|
||||||
|
|
||||||
const from = (page - 1) * perpage + 1;
|
const from = (page - 1) * perpage + 1;
|
||||||
const to = Math.min(page * perpage, data?.total ?? 0);
|
const to = Math.min(page * perpage, data?.total ?? 0);
|
||||||
const totalRecords = data?.total ?? 0;
|
const totalRecords = data?.total ?? 0;
|
||||||
|
|||||||
@@ -13,35 +13,17 @@ import {
|
|||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { IconFileUpload, IconFilesOff } from '@tabler/icons-react';
|
import { IconFileUpload, IconFilesOff } from '@tabler/icons-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/router';
|
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useApiPagination } from '../files/useApiPagination';
|
import { useApiPagination } from '../files/useApiPagination';
|
||||||
|
|
||||||
export default function FavoriteFiles() {
|
export default function FavoriteFiles() {
|
||||||
const router = useRouter();
|
const [page, setPage] = useQueryState('fpage', parseAsInteger.withDefault(1));
|
||||||
|
|
||||||
const [page, setPage] = useState<number>(
|
|
||||||
router.query.favoritePage ? parseInt(router.query.favoritePage as string) : 1,
|
|
||||||
);
|
|
||||||
const { data, isLoading } = useApiPagination({
|
const { data, isLoading } = useApiPagination({
|
||||||
page,
|
page,
|
||||||
favorite: true,
|
favorite: true,
|
||||||
filter: 'dashboard',
|
filter: 'dashboard',
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
router.replace(
|
|
||||||
{
|
|
||||||
query: {
|
|
||||||
...router.query,
|
|
||||||
favoritePage: page,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
{ shallow: true },
|
|
||||||
);
|
|
||||||
}, [page]);
|
|
||||||
|
|
||||||
if (!isLoading && data?.page.length === 0) return null;
|
if (!isLoading && data?.page.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -55,7 +55,9 @@ export default function ViewFilesModal({
|
|||||||
}}
|
}}
|
||||||
pos='relative'
|
pos='relative'
|
||||||
>
|
>
|
||||||
{folder?.files?.map((file) => <DashboardFile file={file} key={file.id} />)}
|
{folder?.files?.map((file) => (
|
||||||
|
<DashboardFile file={file} key={file.id} />
|
||||||
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { ActionIcon, Button, Group, Modal, Stack, Switch, TextInput, Title, Tool
|
|||||||
import { useForm } from '@mantine/form';
|
import { useForm } from '@mantine/form';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import { IconFolderPlus, IconPlus } from '@tabler/icons-react';
|
import { IconFolderPlus, IconPlus } from '@tabler/icons-react';
|
||||||
import { useState } from 'react';
|
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||||
import { mutate } from 'swr';
|
import { mutate } from 'swr';
|
||||||
import FolderGridView from './views/FolderGridView';
|
import FolderGridView from './views/FolderGridView';
|
||||||
import FolderTableView from './views/FolderTableView';
|
import FolderTableView from './views/FolderTableView';
|
||||||
@@ -15,7 +15,7 @@ import FolderTableView from './views/FolderTableView';
|
|||||||
export default function DashboardFolders() {
|
export default function DashboardFolders() {
|
||||||
const view = useViewStore((state) => state.folders);
|
const view = useViewStore((state) => state.folders);
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useQueryState('cfopen', parseAsBoolean.withDefault(false));
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ export default function FolderGridView() {
|
|||||||
}}
|
}}
|
||||||
pos='relative'
|
pos='relative'
|
||||||
>
|
>
|
||||||
{folders?.map((folder) => <FolderCard key={folder.id} folder={folder} />)}
|
{folders?.map((folder) => (
|
||||||
|
<FolderCard key={folder.id} folder={folder} />
|
||||||
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
) : (
|
) : (
|
||||||
<Paper withBorder p='sm' my='sm'>
|
<Paper withBorder p='sm' my='sm'>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import GridTableSwitcher from '@/components/GridTableSwitcher';
|
|||||||
import { useViewStore } from '@/lib/store/view';
|
import { useViewStore } from '@/lib/store/view';
|
||||||
import { ActionIcon, Button, Group, Modal, NumberInput, Select, Stack, Title, Tooltip } from '@mantine/core';
|
import { ActionIcon, Button, Group, Modal, NumberInput, Select, Stack, Title, Tooltip } from '@mantine/core';
|
||||||
import { IconPlus, IconTagOff } from '@tabler/icons-react';
|
import { IconPlus, IconTagOff } from '@tabler/icons-react';
|
||||||
import { useState } from 'react';
|
|
||||||
import InviteGridView from './views/InviteGridView';
|
import InviteGridView from './views/InviteGridView';
|
||||||
import InviteTableView from './views/InviteTableView';
|
import InviteTableView from './views/InviteTableView';
|
||||||
import { useForm } from '@mantine/form';
|
import { useForm } from '@mantine/form';
|
||||||
@@ -11,10 +10,11 @@ import { Response } from '@/lib/api/response';
|
|||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import { Invite } from '@/lib/db/models/invite';
|
import { Invite } from '@/lib/db/models/invite';
|
||||||
import { mutate } from 'swr';
|
import { mutate } from 'swr';
|
||||||
|
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||||
|
|
||||||
export default function DashboardInvites() {
|
export default function DashboardInvites() {
|
||||||
const view = useViewStore((state) => state.invites);
|
const view = useViewStore((state) => state.invites);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useQueryState('ciopen', parseAsBoolean.withDefault(false));
|
||||||
|
|
||||||
const form = useForm<{
|
const form = useForm<{
|
||||||
maxUses: number | '';
|
maxUses: number | '';
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ export default function InviteGridView() {
|
|||||||
}}
|
}}
|
||||||
pos='relative'
|
pos='relative'
|
||||||
>
|
>
|
||||||
{folders?.map((invite) => <InviteCard key={invite.id} invite={invite} />)}
|
{folders?.map((invite) => (
|
||||||
|
<InviteCard key={invite.id} invite={invite} />
|
||||||
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
) : (
|
) : (
|
||||||
<Paper withBorder p='sm' my='sm'>
|
<Paper withBorder p='sm' my='sm'>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import FilesUrlsCountGraph from './parts/FilesUrlsCountGraph';
|
|||||||
import { useApiStats } from './useStats';
|
import { useApiStats } from './useStats';
|
||||||
import { StatsCardsSkeleton } from './parts/StatsCards';
|
import { StatsCardsSkeleton } from './parts/StatsCards';
|
||||||
import { StatsTablesSkeleton } from './parts/StatsTables';
|
import { StatsTablesSkeleton } from './parts/StatsTables';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
const StatsCards = dynamic(() => import('./parts/StatsCards'));
|
const StatsCards = dynamic(() => import('./parts/StatsCards'));
|
||||||
const StatsTables = dynamic(() => import('./parts/StatsTables'));
|
const StatsTables = dynamic(() => import('./parts/StatsTables'));
|
||||||
@@ -14,21 +15,23 @@ const StorageGraph = dynamic(() => import('./parts/StorageGraph'));
|
|||||||
const ViewsGraph = dynamic(() => import('./parts/ViewsGraph'));
|
const ViewsGraph = dynamic(() => import('./parts/ViewsGraph'));
|
||||||
|
|
||||||
export default function DashboardMetrics() {
|
export default function DashboardMetrics() {
|
||||||
const [dateRange, setDateRange] = useState<[Date | null, Date | null]>([
|
const today = dayjs();
|
||||||
new Date(Date.now() - 86400000 * 7),
|
|
||||||
new Date(),
|
const [dateRange, setDateRange] = useState<[string | null, string | null]>([
|
||||||
]); // default: [7 days ago, now]
|
today.subtract(7, 'day').toISOString(),
|
||||||
|
today.toISOString(),
|
||||||
|
]);
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [allTime, setAllTime] = useState(false);
|
const [allTime, setAllTime] = useState(false);
|
||||||
|
|
||||||
const { data, isLoading } = useApiStats({
|
const { data, isLoading } = useApiStats({
|
||||||
from: dateRange[0]?.toISOString() ?? undefined,
|
from: allTime || !dateRange[0] ? undefined : new Date(dateRange[0]).toISOString(),
|
||||||
to: dateRange[1]?.toISOString() ?? undefined,
|
to: allTime || !dateRange[1] ? undefined : new Date(dateRange[1]).toISOString(),
|
||||||
all: allTime,
|
all: allTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleDateChange = (value: [Date | null, Date | null]) => {
|
const handleDateChange = (value: [string | null, string | null]) => {
|
||||||
setAllTime(false);
|
setAllTime(false);
|
||||||
setDateRange(value);
|
setDateRange(value);
|
||||||
};
|
};
|
||||||
@@ -40,17 +43,49 @@ export default function DashboardMetrics() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal title='Change range' opened={open} onClose={() => setOpen(false)} size='auto'>
|
<Modal title='Change range' opened={open} onClose={() => setOpen(false)} size='auto'>
|
||||||
<Paper withBorder>
|
<Paper withBorder style={{ minHeight: 300 }}>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
type='range'
|
type='range'
|
||||||
value={dateRange}
|
value={dateRange}
|
||||||
onChange={handleDateChange}
|
onChange={handleDateChange}
|
||||||
allowSingleDateInRange={false}
|
allowSingleDateInRange={false}
|
||||||
maxDate={new Date(Date.now() + 0)}
|
maxDate={new Date()}
|
||||||
|
presets={[
|
||||||
|
{
|
||||||
|
value: [today.subtract(2, 'day').format('YYYY-MM-DD'), today.format('YYYY-MM-DD')],
|
||||||
|
label: 'Last two days',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: [today.subtract(7, 'day').format('YYYY-MM-DD'), today.format('YYYY-MM-DD')],
|
||||||
|
label: 'Last 7 days',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: [today.startOf('month').format('YYYY-MM-DD'), today.format('YYYY-MM-DD')],
|
||||||
|
label: 'This month',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: [
|
||||||
|
today.subtract(1, 'month').startOf('month').format('YYYY-MM-DD'),
|
||||||
|
today.subtract(1, 'month').endOf('month').format('YYYY-MM-DD'),
|
||||||
|
],
|
||||||
|
label: 'Last month',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: [today.startOf('year').format('YYYY-MM-DD'), today.format('YYYY-MM-DD')],
|
||||||
|
label: 'This year',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: [
|
||||||
|
today.subtract(1, 'year').startOf('year').format('YYYY-MM-DD'),
|
||||||
|
today.subtract(1, 'year').endOf('year').format('YYYY-MM-DD'),
|
||||||
|
],
|
||||||
|
label: 'Last year',
|
||||||
|
},
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
<Group mt='md'>
|
<Group mt='lg'>
|
||||||
<Button fullWidth onClick={() => setOpen(false)}>
|
<Button fullWidth onClick={() => setOpen(false)}>
|
||||||
Close
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
@@ -69,25 +104,14 @@ export default function DashboardMetrics() {
|
|||||||
</Button>
|
</Button>
|
||||||
{!allTime ? (
|
{!allTime ? (
|
||||||
<Text size='sm' c='dimmed'>
|
<Text size='sm' c='dimmed'>
|
||||||
{data?.length ? (
|
{dateRange[0] ? new Date(dateRange[0]).toLocaleDateString() : '—'}
|
||||||
<>
|
{dateRange[1] ? ` to ${new Date(dateRange[1]).toLocaleDateString()}` : ''}
|
||||||
{new Date(data?.[0]?.createdAt).toLocaleDateString()}
|
|
||||||
{' to '}
|
|
||||||
{new Date(data?.[data.length - 1]?.createdAt).toLocaleDateString()}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{dateRange[0]?.toLocaleDateString()}{' '}
|
|
||||||
{dateRange[1] ? `to ${dateRange[1]?.toLocaleDateString()}` : ''}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Text size='sm' c='dimmed'>
|
<Text size='sm' c='dimmed'>
|
||||||
All Time
|
All Time
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{/* <Tooltip label='This may take longer than usual to load.'> */}
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
label={!allTime ? 'This may take longer than usual to load.' : 'You are viewing all time stats.'}
|
label={!allTime ? 'This may take longer than usual to load.' : 'You are viewing all time stats.'}
|
||||||
>
|
>
|
||||||
@@ -107,22 +131,18 @@ export default function DashboardMetrics() {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div>
|
<div>
|
||||||
<StatsCardsSkeleton />
|
<StatsCardsSkeleton />
|
||||||
|
|
||||||
<StatsTablesSkeleton />
|
<StatsTablesSkeleton />
|
||||||
</div>
|
</div>
|
||||||
) : data?.length ? (
|
) : data?.length ? (
|
||||||
<div>
|
<div>
|
||||||
<StatsCards data={data!} />
|
<StatsCards data={data} />
|
||||||
|
<StatsTables data={data} />
|
||||||
<StatsTables data={data!} />
|
|
||||||
|
|
||||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }}>
|
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }}>
|
||||||
<FilesUrlsCountGraph metrics={data!} />
|
<FilesUrlsCountGraph metrics={data} />
|
||||||
<ViewsGraph metrics={data!} />
|
<ViewsGraph metrics={data} />
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<StorageGraph metrics={data!} />
|
<StorageGraph metrics={data} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,57 +1,60 @@
|
|||||||
import { Response } from '@/lib/api/response';
|
import { Response } from '@/lib/api/response';
|
||||||
import { Group, SimpleGrid, Skeleton, Stack, Title } from '@mantine/core';
|
import { Alert, Anchor, Collapse, Group, SimpleGrid, Skeleton, Stack, Title } from '@mantine/core';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
|
import Domains from './parts/Domains';
|
||||||
|
|
||||||
function SettingsSkeleton() {
|
function SettingsSkeleton() {
|
||||||
return <Skeleton height={280} animate />;
|
return <Skeleton height={280} animate />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ServerSettingsCore = dynamic(() => import('./parts/ServerSettingsCore'), {
|
const Core = dynamic(() => import('./parts/Core'), {
|
||||||
loading: () => <SettingsSkeleton />,
|
loading: () => <SettingsSkeleton />,
|
||||||
});
|
});
|
||||||
const ServerSettingsChunks = dynamic(() => import('./parts/ServerSettingsChunks'), {
|
const Chunks = dynamic(() => import('./parts/Chunks'), {
|
||||||
loading: () => <SettingsSkeleton />,
|
loading: () => <SettingsSkeleton />,
|
||||||
});
|
});
|
||||||
const ServerSettingsDiscord = dynamic(() => import('./parts/ServerSettingsDiscord'), {
|
const Discord = dynamic(() => import('./parts/Discord'), {
|
||||||
loading: () => <SettingsSkeleton />,
|
loading: () => <SettingsSkeleton />,
|
||||||
});
|
});
|
||||||
const ServerSettingsFeatures = dynamic(() => import('./parts/ServerSettingsFeatures'), {
|
const Features = dynamic(() => import('./parts/Features'), {
|
||||||
loading: () => <SettingsSkeleton />,
|
loading: () => <SettingsSkeleton />,
|
||||||
});
|
});
|
||||||
const ServerSettingsFiles = dynamic(() => import('./parts/ServerSettingsFiles'), {
|
const Files = dynamic(() => import('./parts/Files'), {
|
||||||
loading: () => <SettingsSkeleton />,
|
loading: () => <SettingsSkeleton />,
|
||||||
});
|
});
|
||||||
const ServerSettingsHttpWebhook = dynamic(() => import('./parts/ServerSettingsHttpWebhook'), {
|
const HttpWebhook = dynamic(() => import('./parts/HttpWebhook'), {
|
||||||
loading: () => <SettingsSkeleton />,
|
loading: () => <SettingsSkeleton />,
|
||||||
});
|
});
|
||||||
const ServerSettingsInvites = dynamic(() => import('./parts/ServerSettingsInvites'), {
|
const Invites = dynamic(() => import('./parts/Invites'), {
|
||||||
loading: () => <SettingsSkeleton />,
|
loading: () => <SettingsSkeleton />,
|
||||||
});
|
});
|
||||||
const ServerSettingsMfa = dynamic(() => import('./parts/ServerSettingsMfa'), {
|
const Mfa = dynamic(() => import('./parts/Mfa'), {
|
||||||
loading: () => <SettingsSkeleton />,
|
loading: () => <SettingsSkeleton />,
|
||||||
});
|
});
|
||||||
const ServerSettingsOauth = dynamic(() => import('./parts/ServerSettingsOauth'), {
|
const Oauth = dynamic(() => import('./parts/Oauth'), {
|
||||||
loading: () => <SettingsSkeleton />,
|
loading: () => <SettingsSkeleton />,
|
||||||
});
|
});
|
||||||
const ServerSettingsRatelimit = dynamic(() => import('./parts/ServerSettingsRatelimit'), {
|
const Ratelimit = dynamic(() => import('./parts/Ratelimit'), {
|
||||||
loading: () => <SettingsSkeleton />,
|
loading: () => <SettingsSkeleton />,
|
||||||
});
|
});
|
||||||
const ServerSettingsTasks = dynamic(() => import('./parts/ServerSettingsTasks'), {
|
const Tasks = dynamic(() => import('./parts/Tasks'), {
|
||||||
loading: () => <SettingsSkeleton />,
|
loading: () => <SettingsSkeleton />,
|
||||||
});
|
});
|
||||||
const ServerSettingsUrls = dynamic(() => import('./parts/ServerSettingsUrls'), {
|
const Urls = dynamic(() => import('./parts/Urls'), {
|
||||||
loading: () => <SettingsSkeleton />,
|
loading: () => <SettingsSkeleton />,
|
||||||
});
|
});
|
||||||
const ServerSettingsWebsite = dynamic(() => import('./parts/ServerSettingsWebsite'), {
|
const Website = dynamic(() => import('./parts/Website'), {
|
||||||
loading: () => <SettingsSkeleton />,
|
loading: () => <SettingsSkeleton />,
|
||||||
});
|
});
|
||||||
const ServerSettingsPWA = dynamic(() => import('./parts/ServerSettingsPWA'), {
|
const PWA = dynamic(() => import('./parts/PWA'), {
|
||||||
loading: () => <SettingsSkeleton />,
|
loading: () => <SettingsSkeleton />,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function DashboardSettings() {
|
export default function DashboardSettings() {
|
||||||
const { data, isLoading, error } = useSWR<Response['/api/server/settings']>('/api/server/settings');
|
const { data, isLoading, error } = useSWR<Response['/api/server/settings']>('/api/server/settings');
|
||||||
|
const [opened, { toggle }] = useDisclosure(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -59,36 +62,58 @@ export default function DashboardSettings() {
|
|||||||
<Title order={1}>Server Settings</Title>
|
<Title order={1}>Server Settings</Title>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
{(data?.tampered?.length ?? 0) > 0 && (
|
||||||
|
<Alert color='red' title='Environment Variable Settings' mt='md'>
|
||||||
|
<strong>{data!.tampered.length}</strong> setting{data!.tampered.length > 1 ? 's' : ''} have been set
|
||||||
|
via environment variables, therefore any changes made to them on this page will not take effect
|
||||||
|
unless the environment variable corresponding to the setting is removed. If you prefer using
|
||||||
|
environment variables, you can ignore this message. Click{' '}
|
||||||
|
<Anchor onClick={toggle} size='sm'>
|
||||||
|
here
|
||||||
|
</Anchor>{' '}
|
||||||
|
to {opened ? 'close' : 'view'} the list of overridden settings.
|
||||||
|
<Collapse in={opened} transitionDuration={200}>
|
||||||
|
<ul>
|
||||||
|
{data!.tampered.map((setting) => (
|
||||||
|
<li key={setting}>{setting}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Collapse>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
|
||||||
{error ? (
|
{error ? (
|
||||||
<div>Error loading server settings</div>
|
<div>Error loading server settings</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ServerSettingsCore swr={{ data, isLoading }} />
|
<Core swr={{ data, isLoading }} />
|
||||||
<ServerSettingsChunks swr={{ data, isLoading }} />
|
<Chunks swr={{ data, isLoading }} />
|
||||||
<ServerSettingsTasks swr={{ data, isLoading }} />
|
<Tasks swr={{ data, isLoading }} />
|
||||||
<ServerSettingsMfa swr={{ data, isLoading }} />
|
<Mfa swr={{ data, isLoading }} />
|
||||||
|
|
||||||
<ServerSettingsFeatures swr={{ data, isLoading }} />
|
<Features swr={{ data, isLoading }} />
|
||||||
<ServerSettingsFiles swr={{ data, isLoading }} />
|
<Files swr={{ data, isLoading }} />
|
||||||
<Stack>
|
<Stack>
|
||||||
<ServerSettingsUrls swr={{ data, isLoading }} />
|
<Urls swr={{ data, isLoading }} />
|
||||||
<ServerSettingsInvites swr={{ data, isLoading }} />
|
<Invites swr={{ data, isLoading }} />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<ServerSettingsRatelimit swr={{ data, isLoading }} />
|
<Ratelimit swr={{ data, isLoading }} />
|
||||||
<ServerSettingsWebsite swr={{ data, isLoading }} />
|
<Website swr={{ data, isLoading }} />
|
||||||
<ServerSettingsOauth swr={{ data, isLoading }} />
|
<Oauth swr={{ data, isLoading }} />
|
||||||
|
|
||||||
<ServerSettingsPWA swr={{ data, isLoading }} />
|
<PWA swr={{ data, isLoading }} />
|
||||||
|
|
||||||
<ServerSettingsHttpWebhook swr={{ data, isLoading }} />
|
<HttpWebhook swr={{ data, isLoading }} />
|
||||||
|
|
||||||
|
<Domains swr={{ data, isLoading }} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
<Stack mt='md' gap='md'>
|
<Stack mt='md' gap='md'>
|
||||||
{error ? null : <ServerSettingsDiscord swr={{ data, isLoading }} />}
|
{error ? null : <Discord swr={{ data, isLoading }} />}
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useRouter } from 'next/router';
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { settingsOnSubmit } from '../settingsOnSubmit';
|
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||||
|
|
||||||
export default function ServerSettingsChunks({
|
export default function Chunks({
|
||||||
swr: { data, isLoading },
|
swr: { data, isLoading },
|
||||||
}: {
|
}: {
|
||||||
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||||
@@ -18,6 +18,12 @@ export default function ServerSettingsChunks({
|
|||||||
chunksMax: '95mb',
|
chunksMax: '95mb',
|
||||||
chunksSize: '25mb',
|
chunksSize: '25mb',
|
||||||
},
|
},
|
||||||
|
enhanceGetInputProps: (payload: any): object => ({
|
||||||
|
disabled:
|
||||||
|
data?.tampered?.includes(payload.field) ||
|
||||||
|
(payload.field !== 'chunksEnabled' && !form.values.chunksEnabled) ||
|
||||||
|
false,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = settingsOnSubmit(router, form);
|
const onSubmit = settingsOnSubmit(router, form);
|
||||||
@@ -26,9 +32,9 @@ export default function ServerSettingsChunks({
|
|||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|
||||||
form.setValues({
|
form.setValues({
|
||||||
chunksEnabled: data?.chunksEnabled ?? true,
|
chunksEnabled: data.settings.chunksEnabled ?? true,
|
||||||
chunksMax: data!.chunksMax ?? '',
|
chunksMax: data.settings.chunksMax ?? '',
|
||||||
chunksSize: data!.chunksSize ?? '',
|
chunksSize: data.settings.chunksSize ?? '',
|
||||||
});
|
});
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@ import { useRouter } from 'next/router';
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { settingsOnSubmit } from '../settingsOnSubmit';
|
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||||
|
|
||||||
export default function ServerSettingsCore({
|
export default function Core({
|
||||||
swr: { data, isLoading },
|
swr: { data, isLoading },
|
||||||
}: {
|
}: {
|
||||||
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||||
@@ -22,6 +22,9 @@ export default function ServerSettingsCore({
|
|||||||
coreDefaultDomain: '',
|
coreDefaultDomain: '',
|
||||||
coreTempDirectory: '/tmp/zipline',
|
coreTempDirectory: '/tmp/zipline',
|
||||||
},
|
},
|
||||||
|
enhanceGetInputProps: (payload) => ({
|
||||||
|
disabled: data?.tampered?.includes(payload.field) || false,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = async (values: typeof form.values) => {
|
const onSubmit = async (values: typeof form.values) => {
|
||||||
@@ -35,10 +38,12 @@ export default function ServerSettingsCore({
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
form.setValues({
|
form.setValues({
|
||||||
coreReturnHttpsUrls: data?.coreReturnHttpsUrls ?? false,
|
coreReturnHttpsUrls: data.settings.coreReturnHttpsUrls ?? false,
|
||||||
coreDefaultDomain: data?.coreDefaultDomain ?? '',
|
coreDefaultDomain: data.settings.coreDefaultDomain ?? '',
|
||||||
coreTempDirectory: data?.coreTempDirectory ?? '/tmp/zipline',
|
coreTempDirectory: data.settings.coreTempDirectory ?? '/tmp/zipline',
|
||||||
});
|
});
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ import { settingsOnSubmit } from '../settingsOnSubmit';
|
|||||||
|
|
||||||
type DiscordEmbed = Record<string, any>;
|
type DiscordEmbed = Record<string, any>;
|
||||||
|
|
||||||
export default function ServerSettingsDiscord({
|
export default function Discord({
|
||||||
swr: { data, isLoading },
|
swr: { data, isLoading },
|
||||||
}: {
|
}: {
|
||||||
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||||
@@ -65,6 +65,9 @@ export default function ServerSettingsDiscord({
|
|||||||
discordOnUploadEmbedTimestamp: false,
|
discordOnUploadEmbedTimestamp: false,
|
||||||
discordOnUploadEmbedUrl: false,
|
discordOnUploadEmbedUrl: false,
|
||||||
},
|
},
|
||||||
|
enhanceGetInputProps: (payload) => ({
|
||||||
|
disabled: data?.tampered?.includes(payload.field) || false,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const formOnShorten = useForm({
|
const formOnShorten = useForm({
|
||||||
@@ -124,41 +127,45 @@ export default function ServerSettingsDiscord({
|
|||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|
||||||
formMain.setValues({
|
formMain.setValues({
|
||||||
discordWebhookUrl: data?.discordWebhookUrl ?? '',
|
discordWebhookUrl: data.settings.discordWebhookUrl ?? '',
|
||||||
discordUsername: data?.discordUsername ?? '',
|
discordUsername: data.settings.discordUsername ?? '',
|
||||||
discordAvatarUrl: data?.discordAvatarUrl ?? '',
|
discordAvatarUrl: data.settings.discordAvatarUrl ?? '',
|
||||||
});
|
});
|
||||||
|
|
||||||
formOnUpload.setValues({
|
formOnUpload.setValues({
|
||||||
discordOnUploadWebhookUrl: data?.discordOnUploadWebhookUrl ?? '',
|
discordOnUploadWebhookUrl: data.settings.discordOnUploadWebhookUrl ?? '',
|
||||||
discordOnUploadUsername: data?.discordOnUploadUsername ?? '',
|
discordOnUploadUsername: data.settings.discordOnUploadUsername ?? '',
|
||||||
discordOnUploadAvatarUrl: data?.discordOnUploadAvatarUrl ?? '',
|
discordOnUploadAvatarUrl: data.settings.discordOnUploadAvatarUrl ?? '',
|
||||||
|
|
||||||
discordOnUploadContent: data?.discordOnUploadContent ?? '',
|
discordOnUploadContent: data.settings.discordOnUploadContent ?? '',
|
||||||
discordOnUploadEmbed: data?.discordOnUploadEmbed ? true : false,
|
discordOnUploadEmbed: data.settings.discordOnUploadEmbed ? true : false,
|
||||||
discordOnUploadEmbedTitle: (data?.discordOnUploadEmbed as DiscordEmbed)?.title ?? '',
|
discordOnUploadEmbedTitle: (data.settings.discordOnUploadEmbed as DiscordEmbed)?.title ?? '',
|
||||||
discordOnUploadEmbedDescription: (data?.discordOnUploadEmbed as DiscordEmbed)?.description ?? '',
|
discordOnUploadEmbedDescription:
|
||||||
discordOnUploadEmbedFooter: (data?.discordOnUploadEmbed as DiscordEmbed)?.footer ?? '',
|
(data.settings.discordOnUploadEmbed as DiscordEmbed)?.description ?? '',
|
||||||
discordOnUploadEmbedColor: (data?.discordOnUploadEmbed as DiscordEmbed)?.color ?? '',
|
discordOnUploadEmbedFooter: (data.settings.discordOnUploadEmbed as DiscordEmbed)?.footer ?? '',
|
||||||
discordOnUploadEmbedThumbnail: (data?.discordOnUploadEmbed as DiscordEmbed)?.thumbnail ?? false,
|
discordOnUploadEmbedColor: (data.settings.discordOnUploadEmbed as DiscordEmbed)?.color ?? '',
|
||||||
discordOnUploadEmbedImageOrVideo: (data?.discordOnUploadEmbed as DiscordEmbed)?.imageOrVideo ?? false,
|
discordOnUploadEmbedThumbnail: (data.settings.discordOnUploadEmbed as DiscordEmbed)?.thumbnail ?? false,
|
||||||
discordOnUploadEmbedTimestamp: (data?.discordOnUploadEmbed as DiscordEmbed)?.timestamp ?? false,
|
discordOnUploadEmbedImageOrVideo:
|
||||||
discordOnUploadEmbedUrl: (data?.discordOnUploadEmbed as DiscordEmbed)?.url ?? false,
|
(data.settings.discordOnUploadEmbed as DiscordEmbed)?.imageOrVideo ?? false,
|
||||||
|
discordOnUploadEmbedTimestamp: (data.settings.discordOnUploadEmbed as DiscordEmbed)?.timestamp ?? false,
|
||||||
|
discordOnUploadEmbedUrl: (data.settings.discordOnUploadEmbed as DiscordEmbed)?.url ?? false,
|
||||||
});
|
});
|
||||||
|
|
||||||
formOnShorten.setValues({
|
formOnShorten.setValues({
|
||||||
discordOnShortenWebhookUrl: data?.discordOnShortenWebhookUrl ?? '',
|
discordOnShortenWebhookUrl: data.settings.discordOnShortenWebhookUrl ?? '',
|
||||||
discordOnShortenUsername: data?.discordOnShortenUsername ?? '',
|
discordOnShortenUsername: data.settings.discordOnShortenUsername ?? '',
|
||||||
discordOnShortenAvatarUrl: data?.discordOnShortenAvatarUrl ?? '',
|
discordOnShortenAvatarUrl: data.settings.discordOnShortenAvatarUrl ?? '',
|
||||||
|
|
||||||
discordOnShortenContent: data?.discordOnShortenContent ?? '',
|
discordOnShortenContent: data.settings.discordOnShortenContent ?? '',
|
||||||
discordOnShortenEmbed: data?.discordOnShortenEmbed ? true : false,
|
discordOnShortenEmbed: data.settings.discordOnShortenEmbed ? true : false,
|
||||||
discordOnShortenEmbedTitle: (data?.discordOnShortenEmbed as DiscordEmbed)?.title ?? '',
|
discordOnShortenEmbedTitle: (data.settings.discordOnShortenEmbed as DiscordEmbed)?.title ?? '',
|
||||||
discordOnShortenEmbedDescription: (data?.discordOnShortenEmbed as DiscordEmbed)?.description ?? '',
|
discordOnShortenEmbedDescription:
|
||||||
discordOnShortenEmbedFooter: (data?.discordOnShortenEmbed as DiscordEmbed)?.footer ?? '',
|
(data.settings.discordOnShortenEmbed as DiscordEmbed)?.description ?? '',
|
||||||
discordOnShortenEmbedColor: (data?.discordOnShortenEmbed as DiscordEmbed)?.color ?? '',
|
discordOnShortenEmbedFooter: (data.settings.discordOnShortenEmbed as DiscordEmbed)?.footer ?? '',
|
||||||
discordOnShortenEmbedTimestamp: (data?.discordOnShortenEmbed as DiscordEmbed)?.timestamp ?? false,
|
discordOnShortenEmbedColor: (data.settings.discordOnShortenEmbed as DiscordEmbed)?.color ?? '',
|
||||||
discordOnShortenEmbedUrl: (data?.discordOnShortenEmbed as DiscordEmbed)?.url ?? false,
|
discordOnShortenEmbedTimestamp:
|
||||||
|
(data.settings.discordOnShortenEmbed as DiscordEmbed)?.timestamp ?? false,
|
||||||
|
discordOnShortenEmbedUrl: (data.settings.discordOnShortenEmbed as DiscordEmbed)?.url ?? false,
|
||||||
});
|
});
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
117
src/components/pages/serverSettings/parts/Domains.tsx
Normal file
117
src/components/pages/serverSettings/parts/Domains.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { Response } from '@/lib/api/response';
|
||||||
|
import { Button, Group, LoadingOverlay, Paper, SimpleGrid, TextInput, Title } from '@mantine/core';
|
||||||
|
import { useForm } from '@mantine/form';
|
||||||
|
import { IconPlus, IconTrash } from '@tabler/icons-react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||||
|
|
||||||
|
export default function Domains({
|
||||||
|
swr: { data, isLoading },
|
||||||
|
}: {
|
||||||
|
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [domains, setDomains] = useState<string[]>([]);
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
newDomain: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = settingsOnSubmit(router, form);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data) return;
|
||||||
|
const domainsData = Array.isArray(data.settings.domains)
|
||||||
|
? data.settings.domains.map((d) => String(d))
|
||||||
|
: [];
|
||||||
|
setDomains(domainsData);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const addDomain = () => {
|
||||||
|
const { newDomain } = form.values;
|
||||||
|
if (!newDomain) return;
|
||||||
|
|
||||||
|
const updatedDomains = [...domains, newDomain.trim()];
|
||||||
|
setDomains(updatedDomains);
|
||||||
|
form.setValues({ newDomain: '' });
|
||||||
|
onSubmit({ domains: updatedDomains });
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeDomain = (index: number) => {
|
||||||
|
const updatedDomains = domains.filter((_, i) => i !== index);
|
||||||
|
setDomains(updatedDomains);
|
||||||
|
onSubmit({ domains: updatedDomains });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder p='sm' pos='relative'>
|
||||||
|
<LoadingOverlay visible={isLoading} />
|
||||||
|
|
||||||
|
<Title order={2}>Domains</Title>
|
||||||
|
|
||||||
|
<Group mt='md' align='flex-end'>
|
||||||
|
<TextInput
|
||||||
|
label='Domain'
|
||||||
|
description='Enter a domain name (e.g. example.com)'
|
||||||
|
placeholder='example.com'
|
||||||
|
{...form.getInputProps('newDomain')}
|
||||||
|
/>
|
||||||
|
<Button onClick={addDomain} leftSection={<IconPlus size='1rem' />}>
|
||||||
|
Add Domain
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<SimpleGrid mt='md' cols={{ base: 1, sm: 2, md: 3 }} spacing='md' verticalSpacing='md'>
|
||||||
|
{domains.map((domain, index) => (
|
||||||
|
<Paper
|
||||||
|
key={index}
|
||||||
|
withBorder
|
||||||
|
p='md'
|
||||||
|
radius='md'
|
||||||
|
shadow='xs'
|
||||||
|
style={{
|
||||||
|
background: 'rgba(0,0,0,0.03)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
minHeight: 64,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group justify='space-between' align='center' wrap='nowrap'>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minWidth: 0,
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{domain}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant='subtle'
|
||||||
|
color='red'
|
||||||
|
size='xs'
|
||||||
|
onClick={() => removeDomain(index)}
|
||||||
|
px={8}
|
||||||
|
style={{
|
||||||
|
aspectRatio: '1/1',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconTrash size='1rem' />
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,22 @@
|
|||||||
import { Response } from '@/lib/api/response';
|
import { Response } from '@/lib/api/response';
|
||||||
import { Button, LoadingOverlay, NumberInput, Paper, SimpleGrid, Switch, Title } from '@mantine/core';
|
import {
|
||||||
|
Anchor,
|
||||||
|
Button,
|
||||||
|
LoadingOverlay,
|
||||||
|
NumberInput,
|
||||||
|
Paper,
|
||||||
|
SimpleGrid,
|
||||||
|
Switch,
|
||||||
|
TextInput,
|
||||||
|
Title,
|
||||||
|
} from '@mantine/core';
|
||||||
import { useForm } from '@mantine/form';
|
import { useForm } from '@mantine/form';
|
||||||
import { IconDeviceFloppy } from '@tabler/icons-react';
|
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { settingsOnSubmit } from '../settingsOnSubmit';
|
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||||
|
|
||||||
export default function ServerSettingsFeatures({
|
export default function Features({
|
||||||
swr: { data, isLoading },
|
swr: { data, isLoading },
|
||||||
}: {
|
}: {
|
||||||
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||||
@@ -25,24 +35,33 @@ export default function ServerSettingsFeatures({
|
|||||||
featuresMetricsEnabled: true,
|
featuresMetricsEnabled: true,
|
||||||
featuresMetricsAdminOnly: false,
|
featuresMetricsAdminOnly: false,
|
||||||
featuresMetricsShowUserSpecific: true,
|
featuresMetricsShowUserSpecific: true,
|
||||||
|
featuresVersionChecking: true,
|
||||||
|
featuresVersionAPI: 'https://zipline-version.diced.sh/',
|
||||||
},
|
},
|
||||||
|
enhanceGetInputProps: (payload) => ({
|
||||||
|
disabled: data?.tampered?.includes(payload.field) || false,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = settingsOnSubmit(router, form);
|
const onSubmit = settingsOnSubmit(router, form);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
form.setValues({
|
form.setValues({
|
||||||
featuresImageCompression: data?.featuresImageCompression ?? true,
|
featuresImageCompression: data.settings.featuresImageCompression ?? true,
|
||||||
featuresRobotsTxt: data?.featuresRobotsTxt ?? true,
|
featuresRobotsTxt: data.settings.featuresRobotsTxt ?? true,
|
||||||
featuresHealthcheck: data?.featuresHealthcheck ?? true,
|
featuresHealthcheck: data.settings.featuresHealthcheck ?? true,
|
||||||
featuresUserRegistration: data?.featuresUserRegistration ?? false,
|
featuresUserRegistration: data.settings.featuresUserRegistration ?? false,
|
||||||
featuresOauthRegistration: data?.featuresOauthRegistration ?? true,
|
featuresOauthRegistration: data.settings.featuresOauthRegistration ?? true,
|
||||||
featuresDeleteOnMaxViews: data?.featuresDeleteOnMaxViews ?? true,
|
featuresDeleteOnMaxViews: data.settings.featuresDeleteOnMaxViews ?? true,
|
||||||
featuresThumbnailsEnabled: data?.featuresThumbnailsEnabled ?? true,
|
featuresThumbnailsEnabled: data.settings.featuresThumbnailsEnabled ?? true,
|
||||||
featuresThumbnailsNumberThreads: data?.featuresThumbnailsNumberThreads ?? 4,
|
featuresThumbnailsNumberThreads: data.settings.featuresThumbnailsNumberThreads ?? 4,
|
||||||
featuresMetricsEnabled: data?.featuresMetricsEnabled ?? true,
|
featuresMetricsEnabled: data.settings.featuresMetricsEnabled ?? true,
|
||||||
featuresMetricsAdminOnly: data?.featuresMetricsAdminOnly ?? false,
|
featuresMetricsAdminOnly: data.settings.featuresMetricsAdminOnly ?? false,
|
||||||
featuresMetricsShowUserSpecific: data?.featuresMetricsShowUserSpecific ?? true,
|
featuresMetricsShowUserSpecific: data.settings.featuresMetricsShowUserSpecific ?? true,
|
||||||
|
featuresVersionChecking: data.settings.featuresVersionChecking ?? true,
|
||||||
|
featuresVersionAPI: data.settings.featuresVersionAPI ?? 'https://zipline-version.diced.sh/',
|
||||||
});
|
});
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
@@ -107,7 +126,7 @@ export default function ServerSettingsFeatures({
|
|||||||
description='Shows metrics specific to each user, for all users.'
|
description='Shows metrics specific to each user, for all users.'
|
||||||
{...form.getInputProps('featuresMetricsShowUserSpecific', { type: 'checkbox' })}
|
{...form.getInputProps('featuresMetricsShowUserSpecific', { type: 'checkbox' })}
|
||||||
/>
|
/>
|
||||||
|
<div />
|
||||||
<Switch
|
<Switch
|
||||||
label='Enable Thumbnails'
|
label='Enable Thumbnails'
|
||||||
description='Enables thumbnail generation for images. Requires a server restart.'
|
description='Enables thumbnail generation for images. Requires a server restart.'
|
||||||
@@ -122,6 +141,30 @@ export default function ServerSettingsFeatures({
|
|||||||
max={16}
|
max={16}
|
||||||
{...form.getInputProps('featuresThumbnailsNumberThreads')}
|
{...form.getInputProps('featuresThumbnailsNumberThreads')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label='Version Checking'
|
||||||
|
description='Enable version checking for the server. This will check for updates and display the status on the sidebar to all users.'
|
||||||
|
{...form.getInputProps('featuresVersionChecking', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label='Version API URL'
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
The URL of the version checking server. The default is{' '}
|
||||||
|
<Anchor size='xs' href='zipline-version.diced.sh' target='_blank'>
|
||||||
|
https://zipline-version.diced.sh
|
||||||
|
</Anchor>
|
||||||
|
. Visit the{' '}
|
||||||
|
<Anchor size='xs' href='https://github.com/diced/zipline-version-worker' target='_blank'>
|
||||||
|
GitHub
|
||||||
|
</Anchor>{' '}
|
||||||
|
to host your own version checking server.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
placeholder='https://zipline-version.diced.sh/'
|
||||||
|
{...form.getInputProps('featuresVersionAPI')}
|
||||||
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
|
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
|
||||||
@@ -16,7 +16,7 @@ import { useRouter } from 'next/router';
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { settingsOnSubmit } from '../settingsOnSubmit';
|
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||||
|
|
||||||
export default function ServerSettingsFiles({
|
export default function Files({
|
||||||
swr: { data, isLoading },
|
swr: { data, isLoading },
|
||||||
}: {
|
}: {
|
||||||
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||||
@@ -48,6 +48,9 @@ export default function ServerSettingsFiles({
|
|||||||
filesRandomWordsNumAdjectives: 3,
|
filesRandomWordsNumAdjectives: 3,
|
||||||
filesRandomWordsSeparator: '-',
|
filesRandomWordsSeparator: '-',
|
||||||
},
|
},
|
||||||
|
enhanceGetInputProps: (payload) => ({
|
||||||
|
disabled: data?.tampered?.includes(payload.field) || false,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = async (values: typeof form.values) => {
|
const onSubmit = async (values: typeof form.values) => {
|
||||||
@@ -80,18 +83,20 @@ export default function ServerSettingsFiles({
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
form.setValues({
|
form.setValues({
|
||||||
filesRoute: data?.filesRoute ?? '/u',
|
filesRoute: data.settings.filesRoute ?? '/u',
|
||||||
filesLength: data?.filesLength ?? 6,
|
filesLength: data.settings.filesLength ?? 6,
|
||||||
filesDefaultFormat: data?.filesDefaultFormat ?? 'random',
|
filesDefaultFormat: data.settings.filesDefaultFormat ?? 'random',
|
||||||
filesDisabledExtensions: data?.filesDisabledExtensions.join(', ') ?? '',
|
filesDisabledExtensions: data.settings.filesDisabledExtensions.join(', ') ?? '',
|
||||||
filesMaxFileSize: data?.filesMaxFileSize ?? '100mb',
|
filesMaxFileSize: data.settings.filesMaxFileSize ?? '100mb',
|
||||||
filesDefaultExpiration: data?.filesDefaultExpiration ?? '',
|
filesDefaultExpiration: data.settings.filesDefaultExpiration ?? '',
|
||||||
filesAssumeMimetypes: data?.filesAssumeMimetypes ?? false,
|
filesAssumeMimetypes: data.settings.filesAssumeMimetypes ?? false,
|
||||||
filesDefaultDateFormat: data?.filesDefaultDateFormat ?? 'YYYY-MM-DD_HH:mm:ss',
|
filesDefaultDateFormat: data.settings.filesDefaultDateFormat ?? 'YYYY-MM-DD_HH:mm:ss',
|
||||||
filesRemoveGpsMetadata: data?.filesRemoveGpsMetadata ?? false,
|
filesRemoveGpsMetadata: data.settings.filesRemoveGpsMetadata ?? false,
|
||||||
filesRandomWordsNumAdjectives: data?.filesRandomWordsNumAdjectives ?? 3,
|
filesRandomWordsNumAdjectives: data.settings.filesRandomWordsNumAdjectives ?? 3,
|
||||||
filesRandomWordsSeparator: data?.filesRandomWordsSeparator ?? '-',
|
filesRandomWordsSeparator: data.settings.filesRandomWordsSeparator ?? '-',
|
||||||
});
|
});
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@ import { useRouter } from 'next/router';
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { settingsOnSubmit } from '../settingsOnSubmit';
|
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||||
|
|
||||||
export default function ServerSettingsHttpWebhook({
|
export default function HttpWebhook({
|
||||||
swr: { data, isLoading },
|
swr: { data, isLoading },
|
||||||
}: {
|
}: {
|
||||||
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||||
@@ -17,6 +17,9 @@ export default function ServerSettingsHttpWebhook({
|
|||||||
httpWebhookOnUpload: '',
|
httpWebhookOnUpload: '',
|
||||||
httpWebhookOnShorten: '',
|
httpWebhookOnShorten: '',
|
||||||
},
|
},
|
||||||
|
enhanceGetInputProps: (payload) => ({
|
||||||
|
disabled: data?.tampered?.includes(payload.field) || false,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = async (values: typeof form.values) => {
|
const onSubmit = async (values: typeof form.values) => {
|
||||||
@@ -37,8 +40,8 @@ export default function ServerSettingsHttpWebhook({
|
|||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|
||||||
form.setValues({
|
form.setValues({
|
||||||
httpWebhookOnUpload: data?.httpWebhookOnUpload ?? '',
|
httpWebhookOnUpload: data.settings.httpWebhookOnUpload ?? '',
|
||||||
httpWebhookOnShorten: data?.httpWebhookOnShorten ?? '',
|
httpWebhookOnShorten: data.settings.httpWebhookOnShorten ?? '',
|
||||||
});
|
});
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@ import { useRouter } from 'next/router';
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { settingsOnSubmit } from '../settingsOnSubmit';
|
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||||
|
|
||||||
export default function ServerSettingsInvites({
|
export default function Invites({
|
||||||
swr: { data, isLoading },
|
swr: { data, isLoading },
|
||||||
}: {
|
}: {
|
||||||
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||||
@@ -17,6 +17,12 @@ export default function ServerSettingsInvites({
|
|||||||
invitesEnabled: true,
|
invitesEnabled: true,
|
||||||
invitesLength: 6,
|
invitesLength: 6,
|
||||||
},
|
},
|
||||||
|
enhanceGetInputProps: (payload: any): object => ({
|
||||||
|
disabled:
|
||||||
|
data?.tampered?.includes(payload.field) ||
|
||||||
|
(payload.field !== 'invitesEnabled' && !form.values.invitesEnabled) ||
|
||||||
|
false,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = settingsOnSubmit(router, form);
|
const onSubmit = settingsOnSubmit(router, form);
|
||||||
@@ -25,8 +31,8 @@ export default function ServerSettingsInvites({
|
|||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|
||||||
form.setValues({
|
form.setValues({
|
||||||
invitesEnabled: data?.invitesEnabled ?? true,
|
invitesEnabled: data.settings.invitesEnabled ?? true,
|
||||||
invitesLength: data?.invitesLength ?? 6,
|
invitesLength: data.settings.invitesLength ?? 6,
|
||||||
});
|
});
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
@@ -50,7 +56,6 @@ export default function ServerSettingsInvites({
|
|||||||
placeholder='6'
|
placeholder='6'
|
||||||
min={1}
|
min={1}
|
||||||
max={64}
|
max={64}
|
||||||
disabled={!form.values.invitesEnabled}
|
|
||||||
{...form.getInputProps('invitesLength')}
|
{...form.getInputProps('invitesLength')}
|
||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
@@ -6,7 +6,7 @@ import { useRouter } from 'next/router';
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { settingsOnSubmit } from '../settingsOnSubmit';
|
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||||
|
|
||||||
export default function ServerSettingsMfa({
|
export default function Mfa({
|
||||||
swr: { data, isLoading },
|
swr: { data, isLoading },
|
||||||
}: {
|
}: {
|
||||||
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||||
@@ -18,6 +18,9 @@ export default function ServerSettingsMfa({
|
|||||||
mfaTotpIssuer: 'Zipline',
|
mfaTotpIssuer: 'Zipline',
|
||||||
mfaPasskeys: false,
|
mfaPasskeys: false,
|
||||||
},
|
},
|
||||||
|
enhanceGetInputProps: (payload) => ({
|
||||||
|
disabled: data?.tampered?.includes(payload.field) || false,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = settingsOnSubmit(router, form);
|
const onSubmit = settingsOnSubmit(router, form);
|
||||||
@@ -26,9 +29,9 @@ export default function ServerSettingsMfa({
|
|||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|
||||||
form.setValues({
|
form.setValues({
|
||||||
mfaTotpEnabled: data?.mfaTotpEnabled ?? false,
|
mfaTotpEnabled: data.settings.mfaTotpEnabled ?? false,
|
||||||
mfaTotpIssuer: data?.mfaTotpIssuer ?? 'Zipline',
|
mfaTotpIssuer: data.settings.mfaTotpIssuer ?? 'Zipline',
|
||||||
mfaPasskeys: data?.mfaPasskeys,
|
mfaPasskeys: data.settings.mfaPasskeys,
|
||||||
});
|
});
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ import { useRouter } from 'next/router';
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { settingsOnSubmit } from '../settingsOnSubmit';
|
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||||
|
|
||||||
export default function ServerSettingsOauth({
|
export default function Oauth({
|
||||||
swr: { data, isLoading },
|
swr: { data, isLoading },
|
||||||
}: {
|
}: {
|
||||||
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||||
@@ -30,6 +30,8 @@ export default function ServerSettingsOauth({
|
|||||||
oauthDiscordClientId: '',
|
oauthDiscordClientId: '',
|
||||||
oauthDiscordClientSecret: '',
|
oauthDiscordClientSecret: '',
|
||||||
oauthDiscordRedirectUri: '',
|
oauthDiscordRedirectUri: '',
|
||||||
|
oauthDiscordAllowedIds: '',
|
||||||
|
oauthDiscordDeniedIds: '',
|
||||||
|
|
||||||
oauthGoogleClientId: '',
|
oauthGoogleClientId: '',
|
||||||
oauthGoogleClientSecret: '',
|
oauthGoogleClientSecret: '',
|
||||||
@@ -46,11 +48,21 @@ export default function ServerSettingsOauth({
|
|||||||
oauthOidcUserinfoUrl: '',
|
oauthOidcUserinfoUrl: '',
|
||||||
oauthOidcRedirectUri: '',
|
oauthOidcRedirectUri: '',
|
||||||
},
|
},
|
||||||
|
enhanceGetInputProps: (payload) => ({
|
||||||
|
disabled: data?.tampered?.includes(payload.field) || false,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = async (values: typeof form.values) => {
|
const onSubmit = async (values: typeof form.values) => {
|
||||||
for (const key in values) {
|
for (const key in values) {
|
||||||
if (!['oauthBypassLocalLogin', 'oauthLoginOnly'].includes(key)) {
|
if (
|
||||||
|
![
|
||||||
|
'oauthBypassLocalLogin',
|
||||||
|
'oauthLoginOnly',
|
||||||
|
'oauthDiscordAllowedIds',
|
||||||
|
'oauthDiscordDeniedIds',
|
||||||
|
].includes(key)
|
||||||
|
) {
|
||||||
if ((values[key as keyof typeof form.values] as string)?.trim() === '') {
|
if ((values[key as keyof typeof form.values] as string)?.trim() === '') {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
values[key as keyof typeof form.values] = null;
|
values[key as keyof typeof form.values] = null;
|
||||||
@@ -61,6 +73,16 @@ export default function ServerSettingsOauth({
|
|||||||
)?.trim();
|
)?.trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key === 'oauthDiscordAllowedIds' || key === 'oauthDiscordDeniedIds') {
|
||||||
|
if (Array.isArray(values[key])) continue;
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
values[key] = (values[key] as string)
|
||||||
|
.split(',')
|
||||||
|
.map((id) => id.trim())
|
||||||
|
.filter((id) => id !== '');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return settingsOnSubmit(router, form)(values);
|
return settingsOnSubmit(router, form)(values);
|
||||||
@@ -70,27 +92,33 @@ export default function ServerSettingsOauth({
|
|||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|
||||||
form.setValues({
|
form.setValues({
|
||||||
oauthBypassLocalLogin: data?.oauthBypassLocalLogin ?? false,
|
oauthBypassLocalLogin: data.settings.oauthBypassLocalLogin ?? false,
|
||||||
oauthLoginOnly: data?.oauthLoginOnly ?? false,
|
oauthLoginOnly: data.settings.oauthLoginOnly ?? false,
|
||||||
|
|
||||||
oauthDiscordClientId: data?.oauthDiscordClientId ?? '',
|
oauthDiscordClientId: data.settings.oauthDiscordClientId ?? '',
|
||||||
oauthDiscordClientSecret: data?.oauthDiscordClientSecret ?? '',
|
oauthDiscordClientSecret: data.settings.oauthDiscordClientSecret ?? '',
|
||||||
oauthDiscordRedirectUri: data?.oauthDiscordRedirectUri ?? '',
|
oauthDiscordRedirectUri: data.settings.oauthDiscordRedirectUri ?? '',
|
||||||
|
oauthDiscordAllowedIds: data.settings.oauthDiscordAllowedIds
|
||||||
|
? data.settings.oauthDiscordAllowedIds.join(', ')
|
||||||
|
: '',
|
||||||
|
oauthDiscordDeniedIds: data.settings.oauthDiscordDeniedIds
|
||||||
|
? data.settings.oauthDiscordDeniedIds.join(', ')
|
||||||
|
: '',
|
||||||
|
|
||||||
oauthGoogleClientId: data?.oauthGoogleClientId ?? '',
|
oauthGoogleClientId: data.settings.oauthGoogleClientId ?? '',
|
||||||
oauthGoogleClientSecret: data?.oauthGoogleClientSecret ?? '',
|
oauthGoogleClientSecret: data.settings.oauthGoogleClientSecret ?? '',
|
||||||
oauthGoogleRedirectUri: data?.oauthGoogleRedirectUri ?? '',
|
oauthGoogleRedirectUri: data.settings.oauthGoogleRedirectUri ?? '',
|
||||||
|
|
||||||
oauthGithubClientId: data?.oauthGithubClientId ?? '',
|
oauthGithubClientId: data.settings.oauthGithubClientId ?? '',
|
||||||
oauthGithubClientSecret: data?.oauthGithubClientSecret ?? '',
|
oauthGithubClientSecret: data.settings.oauthGithubClientSecret ?? '',
|
||||||
oauthGithubRedirectUri: data?.oauthGithubRedirectUri ?? '',
|
oauthGithubRedirectUri: data.settings.oauthGithubRedirectUri ?? '',
|
||||||
|
|
||||||
oauthOidcClientId: data?.oauthOidcClientId ?? '',
|
oauthOidcClientId: data.settings.oauthOidcClientId ?? '',
|
||||||
oauthOidcClientSecret: data?.oauthOidcClientSecret ?? '',
|
oauthOidcClientSecret: data.settings.oauthOidcClientSecret ?? '',
|
||||||
oauthOidcAuthorizeUrl: data?.oauthOidcAuthorizeUrl ?? '',
|
oauthOidcAuthorizeUrl: data.settings.oauthOidcAuthorizeUrl ?? '',
|
||||||
oauthOidcTokenUrl: data?.oauthOidcTokenUrl ?? '',
|
oauthOidcTokenUrl: data.settings.oauthOidcTokenUrl ?? '',
|
||||||
oauthOidcUserinfoUrl: data?.oauthOidcUserinfoUrl ?? '',
|
oauthOidcUserinfoUrl: data.settings.oauthOidcUserinfoUrl ?? '',
|
||||||
oauthOidcRedirectUri: data?.oauthOidcRedirectUri ?? '',
|
oauthOidcRedirectUri: data.settings.oauthOidcRedirectUri ?? '',
|
||||||
});
|
});
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
@@ -129,6 +157,16 @@ export default function ServerSettingsOauth({
|
|||||||
|
|
||||||
<TextInput label='Discord Client ID' {...form.getInputProps('oauthDiscordClientId')} />
|
<TextInput label='Discord Client ID' {...form.getInputProps('oauthDiscordClientId')} />
|
||||||
<TextInput label='Discord Client Secret' {...form.getInputProps('oauthDiscordClientSecret')} />
|
<TextInput label='Discord Client Secret' {...form.getInputProps('oauthDiscordClientSecret')} />
|
||||||
|
<TextInput
|
||||||
|
label='Discord Allowed IDs'
|
||||||
|
description='A comma-separated list of Discord user IDs that are allowed to log in. Leave empty to disable allow list.'
|
||||||
|
{...form.getInputProps('oauthDiscordAllowedIds')}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label='Discord Denied IDs'
|
||||||
|
description='A comma-separated list of Discord user IDs that are denied from logging in. Leave empty to disable deny list.'
|
||||||
|
{...form.getInputProps('oauthDiscordDeniedIds')}
|
||||||
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label='Discord Redirect URL'
|
label='Discord Redirect URL'
|
||||||
description='The redirect URL to use instead of the host when logging in. This is not required if the URL generated by Zipline works as intended.'
|
description='The redirect URL to use instead of the host when logging in. This is not required if the URL generated by Zipline works as intended.'
|
||||||
@@ -17,7 +17,7 @@ import { useRouter } from 'next/router';
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { settingsOnSubmit } from '../settingsOnSubmit';
|
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||||
|
|
||||||
export default function ServerSettingsPWA({
|
export default function PWA({
|
||||||
swr: { data, isLoading },
|
swr: { data, isLoading },
|
||||||
}: {
|
}: {
|
||||||
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||||
@@ -32,6 +32,12 @@ export default function ServerSettingsPWA({
|
|||||||
pwaThemeColor: '',
|
pwaThemeColor: '',
|
||||||
pwaBackgroundColor: '',
|
pwaBackgroundColor: '',
|
||||||
},
|
},
|
||||||
|
enhanceGetInputProps: (payload: any): object => ({
|
||||||
|
disabled:
|
||||||
|
data?.tampered?.includes(payload.field) ||
|
||||||
|
(payload.field !== 'pwaEnabled' && !form.values.pwaEnabled) ||
|
||||||
|
false,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = async (values: typeof form.values) => {
|
const onSubmit = async (values: typeof form.values) => {
|
||||||
@@ -53,13 +59,15 @@ export default function ServerSettingsPWA({
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
form.setValues({
|
form.setValues({
|
||||||
pwaEnabled: data?.pwaEnabled ?? false,
|
pwaEnabled: data.settings.pwaEnabled ?? false,
|
||||||
pwaTitle: data?.pwaTitle ?? '',
|
pwaTitle: data.settings.pwaTitle ?? '',
|
||||||
pwaShortName: data?.pwaShortName ?? '',
|
pwaShortName: data.settings.pwaShortName ?? '',
|
||||||
pwaDescription: data?.pwaDescription ?? '',
|
pwaDescription: data.settings.pwaDescription ?? '',
|
||||||
pwaThemeColor: data?.pwaThemeColor ?? '',
|
pwaThemeColor: data.settings.pwaThemeColor ?? '',
|
||||||
pwaBackgroundColor: data?.pwaBackgroundColor ?? '',
|
pwaBackgroundColor: data.settings.pwaBackgroundColor ?? '',
|
||||||
});
|
});
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
@@ -86,7 +94,6 @@ export default function ServerSettingsPWA({
|
|||||||
label='Title'
|
label='Title'
|
||||||
description='The title for the PWA'
|
description='The title for the PWA'
|
||||||
placeholder='Zipline'
|
placeholder='Zipline'
|
||||||
disabled={!form.values.pwaEnabled}
|
|
||||||
{...form.getInputProps('pwaTitle')}
|
{...form.getInputProps('pwaTitle')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -94,7 +101,6 @@ export default function ServerSettingsPWA({
|
|||||||
label='Short Name'
|
label='Short Name'
|
||||||
description='The short name for the PWA'
|
description='The short name for the PWA'
|
||||||
placeholder='Zipline'
|
placeholder='Zipline'
|
||||||
disabled={!form.values.pwaEnabled}
|
|
||||||
{...form.getInputProps('pwaShortName')}
|
{...form.getInputProps('pwaShortName')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -102,7 +108,6 @@ export default function ServerSettingsPWA({
|
|||||||
label='Description'
|
label='Description'
|
||||||
description='The description for the PWA'
|
description='The description for the PWA'
|
||||||
placeholder='Zipline'
|
placeholder='Zipline'
|
||||||
disabled={!form.values.pwaEnabled}
|
|
||||||
{...form.getInputProps('pwaDescription')}
|
{...form.getInputProps('pwaDescription')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -110,7 +115,6 @@ export default function ServerSettingsPWA({
|
|||||||
label='Theme Color'
|
label='Theme Color'
|
||||||
description='The theme color for the PWA'
|
description='The theme color for the PWA'
|
||||||
placeholder='#000000'
|
placeholder='#000000'
|
||||||
disabled={!form.values.pwaEnabled}
|
|
||||||
{...form.getInputProps('pwaThemeColor')}
|
{...form.getInputProps('pwaThemeColor')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -118,7 +122,6 @@ export default function ServerSettingsPWA({
|
|||||||
label='Background Color'
|
label='Background Color'
|
||||||
description='The background color for the PWA'
|
description='The background color for the PWA'
|
||||||
placeholder='#ffffff'
|
placeholder='#ffffff'
|
||||||
disabled={!form.values.pwaEnabled}
|
|
||||||
{...form.getInputProps('pwaBackgroundColor')}
|
{...form.getInputProps('pwaBackgroundColor')}
|
||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
@@ -16,7 +16,7 @@ import { useRouter } from 'next/router';
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { settingsOnSubmit } from '../settingsOnSubmit';
|
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||||
|
|
||||||
export default function ServerSettingsRatelimit({
|
export default function Ratelimit({
|
||||||
swr: { data, isLoading },
|
swr: { data, isLoading },
|
||||||
}: {
|
}: {
|
||||||
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||||
@@ -36,6 +36,12 @@ export default function ServerSettingsRatelimit({
|
|||||||
ratelimitAdminBypass: false,
|
ratelimitAdminBypass: false,
|
||||||
ratelimitAllowList: '',
|
ratelimitAllowList: '',
|
||||||
},
|
},
|
||||||
|
enhanceGetInputProps: (payload: any): object => ({
|
||||||
|
disabled:
|
||||||
|
data?.tampered?.includes(payload.field) ||
|
||||||
|
(payload.field !== 'ratelimitEnabled' && !form.values.ratelimitEnabled) ||
|
||||||
|
false,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = async (values: typeof form.values) => {
|
const onSubmit = async (values: typeof form.values) => {
|
||||||
@@ -62,11 +68,11 @@ export default function ServerSettingsRatelimit({
|
|||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|
||||||
form.setValues({
|
form.setValues({
|
||||||
ratelimitEnabled: data?.ratelimitEnabled ?? true,
|
ratelimitEnabled: data.settings.ratelimitEnabled ?? true,
|
||||||
ratelimitMax: data?.ratelimitMax ?? 10,
|
ratelimitMax: data.settings.ratelimitMax ?? 10,
|
||||||
ratelimitWindow: data?.ratelimitWindow ?? '',
|
ratelimitWindow: data.settings.ratelimitWindow ?? '',
|
||||||
ratelimitAdminBypass: data?.ratelimitAdminBypass ?? false,
|
ratelimitAdminBypass: data.settings.ratelimitAdminBypass ?? false,
|
||||||
ratelimitAllowList: data?.ratelimitAllowList.join(', ') ?? '',
|
ratelimitAllowList: data.settings.ratelimitAllowList.join(', ') ?? '',
|
||||||
});
|
});
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
@@ -91,7 +97,6 @@ export default function ServerSettingsRatelimit({
|
|||||||
<Switch
|
<Switch
|
||||||
label='Admin Bypass'
|
label='Admin Bypass'
|
||||||
description='Allow admins to bypass the ratelimit.'
|
description='Allow admins to bypass the ratelimit.'
|
||||||
disabled={!form.values.ratelimitEnabled}
|
|
||||||
{...form.getInputProps('ratelimitAdminBypass', { type: 'checkbox' })}
|
{...form.getInputProps('ratelimitAdminBypass', { type: 'checkbox' })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -100,7 +105,6 @@ export default function ServerSettingsRatelimit({
|
|||||||
description='The maximum number of requests allowed within the window. If no window is set, this is the maximum number of requests until it reaches the limit.'
|
description='The maximum number of requests allowed within the window. If no window is set, this is the maximum number of requests until it reaches the limit.'
|
||||||
placeholder='10'
|
placeholder='10'
|
||||||
min={1}
|
min={1}
|
||||||
disabled={!form.values.ratelimitEnabled}
|
|
||||||
{...form.getInputProps('ratelimitMax')}
|
{...form.getInputProps('ratelimitMax')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -109,7 +113,6 @@ export default function ServerSettingsRatelimit({
|
|||||||
description='The window in seconds to allow the max requests.'
|
description='The window in seconds to allow the max requests.'
|
||||||
placeholder='60'
|
placeholder='60'
|
||||||
min={1}
|
min={1}
|
||||||
disabled={!form.values.ratelimitEnabled}
|
|
||||||
{...form.getInputProps('ratelimitWindow')}
|
{...form.getInputProps('ratelimitWindow')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -117,7 +120,6 @@ export default function ServerSettingsRatelimit({
|
|||||||
label='Allow List'
|
label='Allow List'
|
||||||
description='A comma-separated list of IP addresses to bypass the ratelimit.'
|
description='A comma-separated list of IP addresses to bypass the ratelimit.'
|
||||||
placeholder='1.1.1.1, 8.8.8.8'
|
placeholder='1.1.1.1, 8.8.8.8'
|
||||||
disabled={!form.values.ratelimitEnabled}
|
|
||||||
{...form.getInputProps('ratelimitAllowList')}
|
{...form.getInputProps('ratelimitAllowList')}
|
||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
@@ -6,7 +6,7 @@ import { useRouter } from 'next/router';
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { settingsOnSubmit } from '../settingsOnSubmit';
|
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||||
|
|
||||||
export default function ServerSettingsTasks({
|
export default function Tasks({
|
||||||
swr: { data, isLoading },
|
swr: { data, isLoading },
|
||||||
}: {
|
}: {
|
||||||
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||||
@@ -20,6 +20,9 @@ export default function ServerSettingsTasks({
|
|||||||
tasksThumbnailsInterval: '30m',
|
tasksThumbnailsInterval: '30m',
|
||||||
tasksMetricsInterval: '30m',
|
tasksMetricsInterval: '30m',
|
||||||
},
|
},
|
||||||
|
enhanceGetInputProps: (payload) => ({
|
||||||
|
disabled: data?.tampered?.includes(payload.field) || false,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = settingsOnSubmit(router, form);
|
const onSubmit = settingsOnSubmit(router, form);
|
||||||
@@ -28,11 +31,11 @@ export default function ServerSettingsTasks({
|
|||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|
||||||
form.setValues({
|
form.setValues({
|
||||||
tasksDeleteInterval: data?.tasksDeleteInterval ?? '30m',
|
tasksDeleteInterval: data.settings.tasksDeleteInterval ?? '30m',
|
||||||
tasksClearInvitesInterval: data?.tasksClearInvitesInterval ?? '30m',
|
tasksClearInvitesInterval: data.settings.tasksClearInvitesInterval ?? '30m',
|
||||||
tasksMaxViewsInterval: data?.tasksMaxViewsInterval ?? '30m',
|
tasksMaxViewsInterval: data.settings.tasksMaxViewsInterval ?? '30m',
|
||||||
tasksThumbnailsInterval: data?.tasksThumbnailsInterval ?? '30m',
|
tasksThumbnailsInterval: data.settings.tasksThumbnailsInterval ?? '30m',
|
||||||
tasksMetricsInterval: data?.tasksMetricsInterval ?? '30m',
|
tasksMetricsInterval: data.settings.tasksMetricsInterval ?? '30m',
|
||||||
});
|
});
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@ import { useRouter } from 'next/router';
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { settingsOnSubmit } from '../settingsOnSubmit';
|
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||||
|
|
||||||
export default function ServerSettingsUrls({
|
export default function Urls({
|
||||||
swr: { data, isLoading },
|
swr: { data, isLoading },
|
||||||
}: {
|
}: {
|
||||||
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||||
@@ -17,6 +17,9 @@ export default function ServerSettingsUrls({
|
|||||||
urlsRoute: '/go',
|
urlsRoute: '/go',
|
||||||
urlsLength: 6,
|
urlsLength: 6,
|
||||||
},
|
},
|
||||||
|
enhanceGetInputProps: (payload) => ({
|
||||||
|
disabled: data?.tampered?.includes(payload.field) || false,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = settingsOnSubmit(router, form);
|
const onSubmit = settingsOnSubmit(router, form);
|
||||||
@@ -25,8 +28,8 @@ export default function ServerSettingsUrls({
|
|||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|
||||||
form.setValues({
|
form.setValues({
|
||||||
urlsRoute: data?.urlsRoute ?? '/go',
|
urlsRoute: data.settings.urlsRoute ?? '/go',
|
||||||
urlsLength: data?.urlsLength ?? 6,
|
urlsLength: data.settings.urlsLength ?? 6,
|
||||||
});
|
});
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ const defaultExternalLinks = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function ServerSettingsWebsite({
|
export default function Website({
|
||||||
swr: { data, isLoading },
|
swr: { data, isLoading },
|
||||||
}: {
|
}: {
|
||||||
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||||
@@ -37,6 +37,9 @@ export default function ServerSettingsWebsite({
|
|||||||
websiteThemeDark: 'builtin:dark_gray',
|
websiteThemeDark: 'builtin:dark_gray',
|
||||||
websiteThemeLight: 'builtin:light_gray',
|
websiteThemeLight: 'builtin:light_gray',
|
||||||
},
|
},
|
||||||
|
enhanceGetInputProps: (payload) => ({
|
||||||
|
disabled: data?.tampered?.includes(payload.field) || false,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = async (values: typeof form.values) => {
|
const onSubmit = async (values: typeof form.values) => {
|
||||||
@@ -76,16 +79,20 @@ export default function ServerSettingsWebsite({
|
|||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|
||||||
form.setValues({
|
form.setValues({
|
||||||
websiteTitle: data?.websiteTitle ?? 'Zipline',
|
websiteTitle: data.settings.websiteTitle ?? 'Zipline',
|
||||||
websiteTitleLogo: data?.websiteTitleLogo ?? '',
|
websiteTitleLogo: data.settings.websiteTitleLogo ?? '',
|
||||||
websiteExternalLinks: JSON.stringify(data?.websiteExternalLinks ?? defaultExternalLinks, null, 2),
|
websiteExternalLinks: JSON.stringify(
|
||||||
websiteLoginBackground: data?.websiteLoginBackground ?? '',
|
data.settings.websiteExternalLinks ?? defaultExternalLinks,
|
||||||
websiteLoginBackgroundBlur: data?.websiteLoginBackgroundBlur ?? true,
|
null,
|
||||||
websiteDefaultAvatar: data?.websiteDefaultAvatar ?? '',
|
2,
|
||||||
websiteTos: data?.websiteTos ?? '',
|
),
|
||||||
websiteThemeDefault: data?.websiteThemeDefault ?? 'system',
|
websiteLoginBackground: data.settings.websiteLoginBackground ?? '',
|
||||||
websiteThemeDark: data?.websiteThemeDark ?? 'builtin:dark_gray',
|
websiteLoginBackgroundBlur: data.settings.websiteLoginBackgroundBlur ?? true,
|
||||||
websiteThemeLight: data?.websiteThemeLight ?? 'builtin:light_gray',
|
websiteDefaultAvatar: data.settings.websiteDefaultAvatar ?? '',
|
||||||
|
websiteTos: data.settings.websiteTos ?? '',
|
||||||
|
websiteThemeDefault: data.settings.websiteThemeDefault ?? 'system',
|
||||||
|
websiteThemeDark: data.settings.websiteThemeDark ?? 'builtin:dark_gray',
|
||||||
|
websiteThemeLight: data.settings.websiteThemeLight ?? 'builtin:light_gray',
|
||||||
});
|
});
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
@@ -176,7 +183,6 @@ export default function ServerSettingsWebsite({
|
|||||||
label='Dark Theme'
|
label='Dark Theme'
|
||||||
description='The dark theme to use for the website when the default theme is "system".'
|
description='The dark theme to use for the website when the default theme is "system".'
|
||||||
placeholder='builtin:dark_gray'
|
placeholder='builtin:dark_gray'
|
||||||
disabled={form.values.websiteThemeDefault !== 'system'}
|
|
||||||
{...form.getInputProps('websiteThemeDark')}
|
{...form.getInputProps('websiteThemeDark')}
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
@@ -186,7 +192,6 @@ export default function ServerSettingsWebsite({
|
|||||||
label='Light Theme'
|
label='Light Theme'
|
||||||
description='The light theme to use for the website when the default theme is "system".'
|
description='The light theme to use for the website when the default theme is "system".'
|
||||||
placeholder='builtin:light_gray'
|
placeholder='builtin:light_gray'
|
||||||
disabled={form.values.websiteThemeDefault !== 'system'}
|
|
||||||
{...form.getInputProps('websiteThemeLight')}
|
{...form.getInputProps('websiteThemeLight')}
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
@@ -49,6 +49,8 @@ export default function SettingsFileView() {
|
|||||||
embedColor: user?.view.embedColor ?? '',
|
embedColor: user?.view.embedColor ?? '',
|
||||||
align: user?.view.align ?? 'left',
|
align: user?.view.align ?? 'left',
|
||||||
showMimetype: user?.view.showMimetype ?? false,
|
showMimetype: user?.view.showMimetype ?? false,
|
||||||
|
showTags: user?.view.showTags ?? false,
|
||||||
|
showFolder: user?.view.showFolder ?? false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -63,6 +65,8 @@ export default function SettingsFileView() {
|
|||||||
embedColor: values.embedColor.trim() || null,
|
embedColor: values.embedColor.trim() || null,
|
||||||
align: values.align,
|
align: values.align,
|
||||||
showMimetype: values.showMimetype,
|
showMimetype: values.showMimetype,
|
||||||
|
showTags: values.showTags,
|
||||||
|
showFolder: values.showFolder,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data, error } = await fetchApi<Response['/api/user']>('/api/user', 'PATCH', {
|
const { data, error } = await fetchApi<Response['/api/user']>('/api/user', 'PATCH', {
|
||||||
@@ -110,6 +114,20 @@ export default function SettingsFileView() {
|
|||||||
disabled={!form.values.enabled}
|
disabled={!form.values.enabled}
|
||||||
{...form.getInputProps('showMimetype', { type: 'checkbox' })}
|
{...form.getInputProps('showMimetype', { type: 'checkbox' })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label='Show tags'
|
||||||
|
description="Show the file's tags in the view-route"
|
||||||
|
disabled={!form.values.enabled}
|
||||||
|
{...form.getInputProps('showTags', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label='Show folder'
|
||||||
|
description='Show the name/link of the folder if possible in the view-route'
|
||||||
|
disabled={!form.values.enabled}
|
||||||
|
{...form.getInputProps('showFolder', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
<Textarea
|
<Textarea
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
Switch,
|
Switch,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { IconDownload, IconEyeFilled, IconGlobe, IconPercentage, IconWriting } from '@tabler/icons-react';
|
import { IconDownload, IconEyeFilled, IconGlobe, IconPercentage, IconWriting } from '@tabler/icons-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
@@ -45,7 +44,6 @@ export type GeneratorOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const copier = (options: GeneratorOptions) => {
|
export const copier = (options: GeneratorOptions) => {
|
||||||
if (options.unix_useEcho) return 'echo';
|
|
||||||
if (options.mac_enableCompatibility) return 'pbcopy';
|
if (options.mac_enableCompatibility) return 'pbcopy';
|
||||||
if (options.wl_enableCompatibility) return 'wl-copy';
|
if (options.wl_enableCompatibility) return 'wl-copy';
|
||||||
return 'xclip -selection clipboard';
|
return 'xclip -selection clipboard';
|
||||||
@@ -106,10 +104,22 @@ export default function GeneratorButton({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { data: tokenData, isLoading, error } = useSWR<Response['/api/user/token']>('/api/user/token');
|
const { data: tokenData, isLoading, error } = useSWR<Response['/api/user/token']>('/api/user/token');
|
||||||
|
const { data: settingsData } = useSWR<Response['/api/server/settings']>('/api/server/settings');
|
||||||
|
|
||||||
const isUnixLike = name === 'Flameshot' || name === 'Shell Script';
|
const isUnixLike = name === 'Flameshot' || name === 'Shell Script';
|
||||||
const onlyFile = generatorType === 'file';
|
const onlyFile = generatorType === 'file';
|
||||||
|
|
||||||
|
const domains = Array.isArray(settingsData?.settings.domains)
|
||||||
|
? settingsData?.settings.domains.map((d) => String(d))
|
||||||
|
: [];
|
||||||
|
const domainOptions = [
|
||||||
|
{ value: '', label: 'Default Domain' },
|
||||||
|
...domains.map((domain) => ({
|
||||||
|
value: domain,
|
||||||
|
label: domain,
|
||||||
|
})),
|
||||||
|
] as { value: string; label: string; disabled?: boolean }[];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal opened={opened} onClose={() => setOpen(false)} title={`Generate ${name} Uploader`}>
|
<Modal opened={opened} onClose={() => setOpen(false)} title={`Generate ${name} Uploader`}>
|
||||||
@@ -188,14 +198,21 @@ export default function GeneratorButton({
|
|||||||
onChange={(value) => setOption({ maxViews: value === '' ? null : Number(value) })}
|
onChange={(value) => setOption({ maxViews: value === '' ? null : Number(value) })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextInput
|
<Select
|
||||||
|
data={domainOptions}
|
||||||
label='Override Domain'
|
label='Override Domain'
|
||||||
description='Override the domain with this value. This will change the domain returned in your uploads. Leave blank to use the default domain.'
|
description='Override the domain with this value. This will change the domain returned in your uploads. Leave blank to use the default domain.'
|
||||||
leftSection={<IconGlobe size='1rem' />}
|
leftSection={<IconGlobe size='1rem' />}
|
||||||
value={options.overrides_returnDomain ?? ''}
|
value={options.overrides_returnDomain ?? ''}
|
||||||
onChange={(event) =>
|
onChange={(value) => setOption({ overrides_returnDomain: value || null })}
|
||||||
setOption({ overrides_returnDomain: event.currentTarget.value.trim() || null })
|
comboboxProps={{
|
||||||
}
|
withinPortal: true,
|
||||||
|
portalProps: {
|
||||||
|
style: {
|
||||||
|
zIndex: 100000000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Text c='dimmed' size='sm'>
|
<Text c='dimmed' size='sm'>
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export function flameshot(token: string, type: 'file' | 'url', options: Generato
|
|||||||
|
|
||||||
if (type === 'file') {
|
if (type === 'file') {
|
||||||
script = `#!/bin/bash${options.wl_compositorUnsupported ? '\nexport XDG_CURRENT_DESKTOP=sway' : ''}
|
script = `#!/bin/bash${options.wl_compositorUnsupported ? '\nexport XDG_CURRENT_DESKTOP=sway' : ''}
|
||||||
flameshot gui -r > /tmp/screenshot.png
|
${options.mac_enableCompatibility ? '/Applications/flameshot.app/Contents/MacOS/flameshot' : 'flameshot'} gui -r > /tmp/screenshot.png
|
||||||
${curl.join(' ')}${options.noJson ? '' : ' | jq -r .files[0].url'} | tr -d '\\n' | ${copier(options)}
|
${curl.join(' ')}${options.noJson ? '' : ' | jq -r .files[0].url'} | tr -d '\\n' | ${copier(options)}
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function shell(token: string, type: 'file' | 'url', options: GeneratorOpt
|
|||||||
];
|
];
|
||||||
|
|
||||||
if (type === 'file') {
|
if (type === 'file') {
|
||||||
curl.push('-F', 'file=@$1');
|
curl.push('-F', '"file=@$1;type=$(file --mime-type -b "$1")"');
|
||||||
curl.push('-H', "'content-type: multipart/form-data'");
|
curl.push('-H', "'content-type: multipart/form-data'");
|
||||||
} else {
|
} else {
|
||||||
curl.push('-H', "'content-type: application/json'");
|
curl.push('-H', "'content-type: application/json'");
|
||||||
@@ -61,20 +61,22 @@ export function shell(token: string, type: 'file' | 'url', options: GeneratorOpt
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(toAddHeaders)) {
|
for (const [key, value] of Object.entries(toAddHeaders)) {
|
||||||
curl.push('-H', `${key}: ${value}`);
|
curl.push('-H', `"${key}: ${value}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
let script;
|
let script;
|
||||||
|
|
||||||
if (type === 'file') {
|
if (type === 'file') {
|
||||||
script = `#!/bin/bash
|
script = `#!/bin/bash
|
||||||
${curl.join(' ')}${options.noJson ? '' : ' | jq -r .files[0].url'} | tr -d '\\n' | ${copier(options)}
|
${curl.join(' ')}${options.noJson ? '' : ' | jq -r .files[0].url'}${
|
||||||
|
options.unix_useEcho ? '' : ` | ${copier(options)}`
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
script = `#!/bin/bash
|
script = `#!/bin/bash
|
||||||
${curl.join(' ')} -d "{\\"url\\": \\"$1\\"}"${
|
${curl.join(' ')} -d "{\\"destination\\": \\"$1\\"}"${
|
||||||
options.noJson ? '' : ' | jq -r .files[0].url'
|
options.noJson ? '' : ' | jq -r .url'
|
||||||
} | tr -d '\\n' | ${copier(options)}
|
}${options.unix_useEcho ? '' : ` | ${copier(options)}`}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ export default function SettingsGenerators() {
|
|||||||
<Code>curl</Code>
|
<Code>curl</Code>
|
||||||
</Anchor>
|
</Anchor>
|
||||||
,{' '}
|
,{' '}
|
||||||
|
<Anchor component={Link} href='https://darwinsys.com/file/'>
|
||||||
|
<Code>file</Code>
|
||||||
|
</Anchor>
|
||||||
|
,{' '}
|
||||||
<Anchor component={Link} href='https://github.com/stedolan/jq'>
|
<Anchor component={Link} href='https://github.com/stedolan/jq'>
|
||||||
<Code>jq</Code>
|
<Code>jq</Code>
|
||||||
</Anchor>
|
</Anchor>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { RegistrationResponseJSON } from '@github/webauthn-json/dist/types/brows
|
|||||||
import { ActionIcon, Button, Group, Modal, Paper, Stack, Text, TextInput } from '@mantine/core';
|
import { ActionIcon, Button, Group, Modal, Paper, Stack, Text, TextInput } from '@mantine/core';
|
||||||
import { modals } from '@mantine/modals';
|
import { modals } from '@mantine/modals';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import { UserPasskey } from '@prisma/client';
|
import { UserPasskey } from '../../../../../../generated/client';
|
||||||
import { IconKey, IconKeyOff, IconTrashFilled } from '@tabler/icons-react';
|
import { IconKey, IconKeyOff, IconTrashFilled } from '@tabler/icons-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { mutate } from 'swr';
|
import { mutate } from 'swr';
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useUserStore } from '@/lib/store/user';
|
|||||||
import { darken } from '@/lib/theme/color';
|
import { darken } from '@/lib/theme/color';
|
||||||
import { Button, ButtonProps, Paper, SimpleGrid, Text, Title, useMantineTheme } from '@mantine/core';
|
import { Button, ButtonProps, Paper, SimpleGrid, Text, Title, useMantineTheme } from '@mantine/core';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import type { OAuthProviderType } from '@prisma/client';
|
import type { OAuthProviderType } from '../../../../../../generated/client';
|
||||||
import {
|
import {
|
||||||
IconBrandDiscordFilled,
|
IconBrandDiscordFilled,
|
||||||
IconBrandGithubFilled,
|
IconBrandGithubFilled,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { useClipboard, useColorScheme } from '@mantine/hooks';
|
|||||||
import { notifications, showNotification } from '@mantine/notifications';
|
import { notifications, showNotification } from '@mantine/notifications';
|
||||||
import { IconDeviceSdCard, IconFiles, IconUpload, IconX } from '@tabler/icons-react';
|
import { IconDeviceSdCard, IconFiles, IconUpload, IconX } from '@tabler/icons-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import UploadOptionsButton from '../UploadOptionsButton';
|
import UploadOptionsButton from '../UploadOptionsButton';
|
||||||
import { uploadFiles } from '../uploadFiles';
|
import { uploadFiles } from '../uploadFiles';
|
||||||
import ToUploadFile from './ToUploadFile';
|
import ToUploadFile from './ToUploadFile';
|
||||||
@@ -35,6 +35,8 @@ export default function UploadFile({ title, folder }: { title?: string; folder?:
|
|||||||
const clipboard = useClipboard();
|
const clipboard = useClipboard();
|
||||||
const config = useConfig();
|
const config = useConfig();
|
||||||
|
|
||||||
|
const isMac = typeof navigator !== 'undefined' && navigator.userAgent.includes('Macintosh');
|
||||||
|
|
||||||
const [options, ephemeral, clearEphemeral] = useUploadOptionsStore(
|
const [options, ephemeral, clearEphemeral] = useUploadOptionsStore(
|
||||||
useShallow((state) => [state.options, state.ephemeral, state.clearEphemeral]),
|
useShallow((state) => [state.options, state.ephemeral, state.clearEphemeral]),
|
||||||
);
|
);
|
||||||
@@ -47,34 +49,23 @@ export default function UploadFile({ title, folder }: { title?: string; folder?:
|
|||||||
});
|
});
|
||||||
const [dropLoading, setLoading] = useState(false);
|
const [dropLoading, setLoading] = useState(false);
|
||||||
|
|
||||||
const handlePaste = (e: ClipboardEvent) => {
|
const aggSize = useCallback(() => files.reduce((acc, file) => acc + file.size, 0), [files]);
|
||||||
if (!e.clipboardData) return;
|
|
||||||
|
|
||||||
|
const handlePaste = useCallback((e: ClipboardEvent) => {
|
||||||
|
if (!e.clipboardData) return;
|
||||||
for (let i = 0; i !== e.clipboardData.items.length; ++i) {
|
for (let i = 0; i !== e.clipboardData.items.length; ++i) {
|
||||||
if (!e.clipboardData.items[i].type.startsWith('image')) return;
|
if (!e.clipboardData.items[i].type.startsWith('image')) return;
|
||||||
|
|
||||||
const blob = e.clipboardData.items[i].getAsFile();
|
const blob = e.clipboardData.items[i].getAsFile();
|
||||||
if (!blob) return;
|
if (!blob) return;
|
||||||
|
setFiles((prev) => [...prev, blob]);
|
||||||
setFiles([...files, blob]);
|
showNotification({ message: `Image ${blob.name} pasted from clipboard`, color: 'blue' });
|
||||||
showNotification({
|
|
||||||
message: `Image ${blob.name} pasted from clipboard`,
|
|
||||||
color: 'blue',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const aggSize = () => files.reduce((acc, file) => acc + file.size, 0);
|
|
||||||
|
|
||||||
const upload = () => {
|
const upload = () => {
|
||||||
const toPartialFiles: File[] = [];
|
const toPartialFiles: File[] = files.filter(
|
||||||
for (let i = 0; i !== files.length; ++i) {
|
(file) => config.chunks.enabled && file.size >= bytes(config.chunks.max),
|
||||||
const file = files[i];
|
);
|
||||||
if (config.chunks.enabled && file.size >= bytes(config.chunks.max)) {
|
|
||||||
toPartialFiles.push(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toPartialFiles.length > 0) {
|
if (toPartialFiles.length > 0) {
|
||||||
uploadPartialFiles(toPartialFiles, {
|
uploadPartialFiles(toPartialFiles, {
|
||||||
setFiles,
|
setFiles,
|
||||||
@@ -89,7 +80,7 @@ export default function UploadFile({ title, folder }: { title?: string; folder?:
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const size = aggSize();
|
const size = aggSize();
|
||||||
if (size > bytes(config.files.maxFileSize) && !toPartialFiles.length) {
|
if (size > bytes(config.files.maxFileSize)) {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
title: 'Upload may fail',
|
title: 'Upload may fail',
|
||||||
color: 'yellow',
|
color: 'yellow',
|
||||||
@@ -103,7 +94,6 @@ export default function UploadFile({ title, folder }: { title?: string; folder?:
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
uploadFiles(files, {
|
uploadFiles(files, {
|
||||||
setFiles,
|
setFiles,
|
||||||
setLoading,
|
setLoading,
|
||||||
@@ -119,11 +109,22 @@ export default function UploadFile({ title, folder }: { title?: string; folder?:
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.addEventListener('paste', handlePaste);
|
document.addEventListener('paste', handlePaste);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('paste', handlePaste);
|
document.removeEventListener('paste', handlePaste);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [handlePaste]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||||
|
if (files.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
};
|
||||||
|
}, [files.length]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -140,7 +141,7 @@ export default function UploadFile({ title, folder }: { title?: string; folder?:
|
|||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Dropzone
|
<Dropzone
|
||||||
onDrop={(f) => setFiles([...f, ...files])}
|
onDrop={(f) => setFiles((prev) => [...f, ...prev])}
|
||||||
my='sm'
|
my='sm'
|
||||||
loading={dropLoading}
|
loading={dropLoading}
|
||||||
disabled={dropLoading}
|
disabled={dropLoading}
|
||||||
@@ -165,7 +166,8 @@ export default function UploadFile({ title, folder }: { title?: string; folder?:
|
|||||||
Drag images here or click to select files
|
Drag images here or click to select files
|
||||||
</Text>
|
</Text>
|
||||||
<Text size='sm' inline mt='xs'>
|
<Text size='sm' inline mt='xs'>
|
||||||
Or <Kbd size='xs'>Ctrl</Kbd> + <Kbd size='xs'>V</Kbd> to paste images from clipboard
|
Or <Kbd size='xs'>{isMac ? '⌘' : 'Ctrl'}</Kbd> + <Kbd size='xs'>V</Kbd> to paste images from
|
||||||
|
clipboard
|
||||||
</Text>
|
</Text>
|
||||||
<Text size='sm' c='dimmed' inline mt={7}>
|
<Text size='sm' c='dimmed' inline mt={7}>
|
||||||
Attach as many files as you like, they will show up below to review before uploading.
|
Attach as many files as you like, they will show up below to review before uploading.
|
||||||
@@ -217,7 +219,6 @@ export default function UploadFile({ title, folder }: { title?: string; folder?:
|
|||||||
|
|
||||||
<Group justify='right' gap='sm' my='md'>
|
<Group justify='right' gap='sm' my='md'>
|
||||||
<UploadOptionsButton folder={folder} numFiles={files.length} />
|
<UploadOptionsButton folder={folder} numFiles={files.length} />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant='outline'
|
||||||
leftSection={<IconUpload size={18} />}
|
leftSection={<IconUpload size={18} />}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
import { useClipboard } from '@mantine/hooks';
|
import { useClipboard } from '@mantine/hooks';
|
||||||
import { IconCursorText, IconEyeFilled, IconFiles, IconUpload } from '@tabler/icons-react';
|
import { IconCursorText, IconEyeFilled, IconFiles, IconUpload } from '@tabler/icons-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import UploadOptionsButton from '../UploadOptionsButton';
|
import UploadOptionsButton from '../UploadOptionsButton';
|
||||||
import { renderMode } from '../renderMode';
|
import { renderMode } from '../renderMode';
|
||||||
import { uploadFiles } from '../uploadFiles';
|
import { uploadFiles } from '../uploadFiles';
|
||||||
@@ -30,15 +30,26 @@ export default function UploadText({
|
|||||||
codeMeta: Parameters<typeof DashboardUploadText>[0]['codeMeta'];
|
codeMeta: Parameters<typeof DashboardUploadText>[0]['codeMeta'];
|
||||||
}) {
|
}) {
|
||||||
const clipboard = useClipboard();
|
const clipboard = useClipboard();
|
||||||
|
|
||||||
const [options, ephemeral, clearEphemeral] = useUploadOptionsStore(
|
const [options, ephemeral, clearEphemeral] = useUploadOptionsStore(
|
||||||
useShallow((state) => [state.options, state.ephemeral, state.clearEphemeral]),
|
useShallow((state) => [state.options, state.ephemeral, state.clearEphemeral]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const [selectedLanguage, setSelectedLanguage] = useState('txt');
|
const [selectedLanguage, setSelectedLanguage] = useState('txt');
|
||||||
const [text, setText] = useState('');
|
const [text, setText] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||||
|
if (text.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
};
|
||||||
|
}, [text]);
|
||||||
|
|
||||||
const renderIn = renderMode(selectedLanguage);
|
const renderIn = renderMode(selectedLanguage);
|
||||||
|
|
||||||
const handleTab = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
const handleTab = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
@@ -52,12 +63,10 @@ export default function UploadText({
|
|||||||
|
|
||||||
const upload = () => {
|
const upload = () => {
|
||||||
const blob = new Blob([text]);
|
const blob = new Blob([text]);
|
||||||
|
|
||||||
const file = new File([blob], `text.${selectedLanguage}`, {
|
const file = new File([blob], `text.${selectedLanguage}`, {
|
||||||
type: codeMeta.find((meta) => meta.ext === selectedLanguage)?.mime,
|
type: codeMeta.find((meta) => meta.ext === selectedLanguage)?.mime,
|
||||||
lastModified: Date.now(),
|
lastModified: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
uploadFiles([file], {
|
uploadFiles([file], {
|
||||||
clipboard,
|
clipboard,
|
||||||
setFiles: () => {},
|
setFiles: () => {},
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ import {
|
|||||||
IconTrashFilled,
|
IconTrashFilled,
|
||||||
IconWriting,
|
IconWriting,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import ms, { StringValue } from 'ms';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import { useShallow } from 'zustand/shallow';
|
import { useShallow } from 'zustand/shallow';
|
||||||
@@ -39,7 +39,7 @@ import { useShallow } from 'zustand/shallow';
|
|||||||
export default function UploadOptionsButton({ folder, numFiles }: { folder?: string; numFiles: number }) {
|
export default function UploadOptionsButton({ folder, numFiles }: { folder?: string; numFiles: number }) {
|
||||||
const config = useConfig();
|
const config = useConfig();
|
||||||
|
|
||||||
const [opened, setOpen] = useState(false);
|
const [opened, setOpen] = useQueryState('upopen', parseAsBoolean.withDefault(false));
|
||||||
const [options, ephemeral, setOption, setEphemeral, changes, clearEphemeral, clearOptions] =
|
const [options, ephemeral, setOption, setEphemeral, changes, clearEphemeral, clearOptions] =
|
||||||
useUploadOptionsStore(
|
useUploadOptionsStore(
|
||||||
useShallow((state) => [
|
useShallow((state) => [
|
||||||
@@ -62,9 +62,20 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str
|
|||||||
const { data: folders } = useSWR<Extract<Response['/api/user/folders'], Folder[]>>(
|
const { data: folders } = useSWR<Extract<Response['/api/user/folders'], Folder[]>>(
|
||||||
'/api/user/folders?noincl=true',
|
'/api/user/folders?noincl=true',
|
||||||
);
|
);
|
||||||
|
const { data: settingsData } = useSWR<Response['/api/server/settings']>('/api/server/settings');
|
||||||
|
|
||||||
const combobox = useCombobox();
|
const combobox = useCombobox();
|
||||||
const [folderSearch, setFolderSearch] = useState('');
|
const [folderSearch, setFolderSearch] = useState('');
|
||||||
|
|
||||||
|
const domains = Array.isArray(settingsData?.settings.domains) ? settingsData.settings.domains : [];
|
||||||
|
const domainOptions = [
|
||||||
|
{ value: '', label: 'Default Domain' },
|
||||||
|
...domains.map((domain) => ({
|
||||||
|
value: domain,
|
||||||
|
label: domain,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (folder) return;
|
if (folder) return;
|
||||||
|
|
||||||
@@ -84,6 +95,7 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str
|
|||||||
<Stack gap='xs' my='sm'>
|
<Stack gap='xs' my='sm'>
|
||||||
<Select
|
<Select
|
||||||
data={[
|
data={[
|
||||||
|
{ value: 'default', label: `Default (${config.files.defaultExpiration ?? 'never'})` },
|
||||||
{ value: 'never', label: 'Never' },
|
{ value: 'never', label: 'Never' },
|
||||||
{ value: '5min', label: '5 minutes' },
|
{ value: '5min', label: '5 minutes' },
|
||||||
{ value: '10min', label: '10 minutes' },
|
{ value: '10min', label: '10 minutes' },
|
||||||
@@ -121,7 +133,7 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str
|
|||||||
label={
|
label={
|
||||||
<>
|
<>
|
||||||
Deletes at{' '}
|
Deletes at{' '}
|
||||||
{options.deletesAt !== 'never' ? (
|
{options.deletesAt !== 'default' ? (
|
||||||
<Badge variant='outline' size='xs'>
|
<Badge variant='outline' size='xs'>
|
||||||
saved
|
saved
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -133,8 +145,8 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str
|
|||||||
The file will automatically delete itself after this time.{' '}
|
The file will automatically delete itself after this time.{' '}
|
||||||
{config.files.defaultExpiration ? (
|
{config.files.defaultExpiration ? (
|
||||||
<>
|
<>
|
||||||
The default expiration time is <b>{ms(config.files.defaultExpiration as StringValue)}</b>{' '}
|
The default expiration time is <b>{config.files.defaultExpiration}</b> (you can override
|
||||||
(you can override this with the below option).
|
this with the below option).
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -147,7 +159,7 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str
|
|||||||
}
|
}
|
||||||
leftSection={<IconAlarmFilled size='1rem' />}
|
leftSection={<IconAlarmFilled size='1rem' />}
|
||||||
value={options.deletesAt}
|
value={options.deletesAt}
|
||||||
onChange={(value) => setOption('deletesAt', value || 'never')}
|
onChange={(value) => setOption('deletesAt', value || 'default')}
|
||||||
comboboxProps={{
|
comboboxProps={{
|
||||||
withinPortal: true,
|
withinPortal: true,
|
||||||
portalProps: {
|
portalProps: {
|
||||||
@@ -263,9 +275,7 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str
|
|||||||
|
|
||||||
<Combobox.Dropdown>
|
<Combobox.Dropdown>
|
||||||
<Combobox.Options>
|
<Combobox.Options>
|
||||||
<Combobox.Option defaultChecked={true} value='no folder'>
|
<Combobox.Option value='no folder'>No Folder</Combobox.Option>
|
||||||
No Folder
|
|
||||||
</Combobox.Option>
|
|
||||||
|
|
||||||
{folders
|
{folders
|
||||||
?.filter((f) => f.name.toLowerCase().includes(folderSearch.toLowerCase().trim()))
|
?.filter((f) => f.name.toLowerCase().includes(folderSearch.toLowerCase().trim()))
|
||||||
@@ -278,7 +288,8 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str
|
|||||||
</Combobox.Dropdown>
|
</Combobox.Dropdown>
|
||||||
</Combobox>
|
</Combobox>
|
||||||
|
|
||||||
<TextInput
|
<Select
|
||||||
|
data={domainOptions}
|
||||||
label={
|
label={
|
||||||
<>
|
<>
|
||||||
Override Domain{' '}
|
Override Domain{' '}
|
||||||
@@ -292,12 +303,15 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str
|
|||||||
description='Override the domain with this value. This will change the domain returned in your uploads. Leave blank to use the default domain.'
|
description='Override the domain with this value. This will change the domain returned in your uploads. Leave blank to use the default domain.'
|
||||||
leftSection={<IconGlobe size='1rem' />}
|
leftSection={<IconGlobe size='1rem' />}
|
||||||
value={options.overrides_returnDomain ?? ''}
|
value={options.overrides_returnDomain ?? ''}
|
||||||
onChange={(event) =>
|
onChange={(value) => setOption('overrides_returnDomain', value || null)}
|
||||||
setOption(
|
comboboxProps={{
|
||||||
'overrides_returnDomain',
|
withinPortal: true,
|
||||||
event.currentTarget.value.trim() === '' ? null : event.currentTarget.value.trim(),
|
portalProps: {
|
||||||
)
|
style: {
|
||||||
}
|
zIndex: 100000000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
export enum RenderMode {
|
export enum RenderMode {
|
||||||
Katex,
|
Katex = 'katex',
|
||||||
Markdown,
|
Markdown = 'md',
|
||||||
Highlight,
|
Highlight = 'hl',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderMode(extension: string) {
|
export function renderMode(extension: string) {
|
||||||
switch (extension) {
|
switch (extension) {
|
||||||
case 'tex':
|
case 'tex':
|
||||||
|
case 'katex':
|
||||||
return RenderMode.Katex;
|
return RenderMode.Katex;
|
||||||
case 'md':
|
case 'md':
|
||||||
return RenderMode.Markdown;
|
return RenderMode.Markdown;
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ export function uploadFiles(
|
|||||||
|
|
||||||
req.open('POST', '/api/upload');
|
req.open('POST', '/api/upload');
|
||||||
|
|
||||||
options.deletesAt !== 'never' && req.setRequestHeader('x-zipline-deletes-at', options.deletesAt);
|
options.deletesAt !== 'default' && req.setRequestHeader('x-zipline-deletes-at', options.deletesAt);
|
||||||
options.format !== 'default' && req.setRequestHeader('x-zipline-format', options.format);
|
options.format !== 'default' && req.setRequestHeader('x-zipline-format', options.format);
|
||||||
options.imageCompressionPercent &&
|
options.imageCompressionPercent &&
|
||||||
req.setRequestHeader('x-zipline-image-compression-percent', options.imageCompressionPercent.toString());
|
req.setRequestHeader('x-zipline-image-compression-percent', options.imageCompressionPercent.toString());
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useConfig } from '@/components/ConfigProvider';
|
import { useConfig } from '@/components/ConfigProvider';
|
||||||
import { Response } from '@/lib/api/response';
|
import { Response } from '@/lib/api/response';
|
||||||
import { bytes } from '@/lib/bytes';
|
import { bytes } from '@/lib/bytes';
|
||||||
import { randomCharacters } from '@/lib/random';
|
|
||||||
import { ErrorBody } from '@/lib/response';
|
import { ErrorBody } from '@/lib/response';
|
||||||
import { UploadOptionsStore } from '@/lib/store/uploadOptions';
|
import { UploadOptionsStore } from '@/lib/store/uploadOptions';
|
||||||
import { ActionIcon, Anchor, Group, Stack, Table, Text, Tooltip } from '@mantine/core';
|
import { ActionIcon, Anchor, Group, Stack, Table, Text, Tooltip } from '@mantine/core';
|
||||||
@@ -101,7 +100,6 @@ export async function uploadPartialFiles(
|
|||||||
|
|
||||||
for (let i = 0; i !== files.length; ++i) {
|
for (let i = 0; i !== files.length; ++i) {
|
||||||
const file = files[i];
|
const file = files[i];
|
||||||
const identifier = randomCharacters(8);
|
|
||||||
const nChunks = Math.ceil(file.size / chunkSize);
|
const nChunks = Math.ceil(file.size / chunkSize);
|
||||||
const chunks: {
|
const chunks: {
|
||||||
blob: Blob;
|
blob: Blob;
|
||||||
@@ -129,6 +127,7 @@ export async function uploadPartialFiles(
|
|||||||
|
|
||||||
let ready = true;
|
let ready = true;
|
||||||
let totalLoaded = 0;
|
let totalLoaded = 0;
|
||||||
|
let identifier: string | undefined;
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
|
|
||||||
for (let j = 0; j !== nChunks; ++j) {
|
for (let j = 0; j !== nChunks; ++j) {
|
||||||
@@ -163,7 +162,7 @@ export async function uploadPartialFiles(
|
|||||||
req.addEventListener(
|
req.addEventListener(
|
||||||
'load',
|
'load',
|
||||||
() => {
|
() => {
|
||||||
const res: Response['/api/upload'] = JSON.parse(req.responseText);
|
const res: Response['/api/upload/partial'] = JSON.parse(req.responseText);
|
||||||
|
|
||||||
if ((res as ErrorBody).error) {
|
if ((res as ErrorBody).error) {
|
||||||
notifications.update({
|
notifications.update({
|
||||||
@@ -172,7 +171,7 @@ export async function uploadPartialFiles(
|
|||||||
message: (res as ErrorBody).error,
|
message: (res as ErrorBody).error,
|
||||||
color: 'red',
|
color: 'red',
|
||||||
icon: <IconFileXFilled size='1rem' />,
|
icon: <IconFileXFilled size='1rem' />,
|
||||||
autoClose: true,
|
autoClose: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
});
|
});
|
||||||
ready = false;
|
ready = false;
|
||||||
@@ -190,6 +189,10 @@ export async function uploadPartialFiles(
|
|||||||
autoClose: false,
|
autoClose: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (j === 0) {
|
||||||
|
identifier = res.partialIdentifier;
|
||||||
|
}
|
||||||
|
|
||||||
if (j === chunks.length - 1) {
|
if (j === chunks.length - 1) {
|
||||||
notifications.update({
|
notifications.update({
|
||||||
id: 'upload-partial',
|
id: 'upload-partial',
|
||||||
@@ -215,6 +218,10 @@ export async function uploadPartialFiles(
|
|||||||
>
|
>
|
||||||
Click here to copy the URL to clipboard while it's being processed.
|
Click here to copy the URL to clipboard while it's being processed.
|
||||||
</Anchor>
|
</Anchor>
|
||||||
|
<br />
|
||||||
|
<Anchor component={Link} href='/dashboard/files?popen=true'>
|
||||||
|
View processing files
|
||||||
|
</Anchor>
|
||||||
</Text>
|
</Text>
|
||||||
),
|
),
|
||||||
color: 'green',
|
color: 'green',
|
||||||
@@ -235,8 +242,8 @@ export async function uploadPartialFiles(
|
|||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
req.open('POST', '/api/upload');
|
req.open('POST', '/api/upload/partial');
|
||||||
options.deletesAt !== 'never' && req.setRequestHeader('x-zipline-deletes-at', options.deletesAt);
|
options.deletesAt !== 'default' && req.setRequestHeader('x-zipline-deletes-at', options.deletesAt);
|
||||||
options.format !== 'default' && req.setRequestHeader('x-zipline-format', options.format);
|
options.format !== 'default' && req.setRequestHeader('x-zipline-format', options.format);
|
||||||
options.imageCompressionPercent &&
|
options.imageCompressionPercent &&
|
||||||
req.setRequestHeader(
|
req.setRequestHeader(
|
||||||
@@ -258,7 +265,7 @@ export async function uploadPartialFiles(
|
|||||||
req.setRequestHeader('x-zipline-folder', ephemeral.folderId);
|
req.setRequestHeader('x-zipline-folder', ephemeral.folderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
req.setRequestHeader('x-zipline-p-identifier', identifier);
|
identifier && req.setRequestHeader('x-zipline-p-identifier', identifier);
|
||||||
req.setRequestHeader('x-zipline-p-filename', encodeURIComponent(file.name));
|
req.setRequestHeader('x-zipline-p-filename', encodeURIComponent(file.name));
|
||||||
req.setRequestHeader('x-zipline-p-lastchunk', j === chunks.length - 1 ? 'true' : 'false');
|
req.setRequestHeader('x-zipline-p-lastchunk', j === chunks.length - 1 ? 'true' : 'false');
|
||||||
req.setRequestHeader('x-zipline-p-content-type', file.type);
|
req.setRequestHeader('x-zipline-p-content-type', file.type);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import GridTableSwitcher from '@/components/GridTableSwitcher';
|
import GridTableSwitcher from '@/components/GridTableSwitcher';
|
||||||
import { Response } from '@/lib/api/response';
|
import { Response } from '@/lib/api/response';
|
||||||
|
import { Url } from '@/lib/db/models/url';
|
||||||
import { fetchApi } from '@/lib/fetchApi';
|
import { fetchApi } from '@/lib/fetchApi';
|
||||||
import { useViewStore } from '@/lib/store/view';
|
import { useViewStore } from '@/lib/store/view';
|
||||||
import {
|
import {
|
||||||
@@ -23,17 +24,16 @@ import { modals } from '@mantine/modals';
|
|||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import { IconClipboardCopy, IconExternalLink, IconLink, IconLinkOff } from '@tabler/icons-react';
|
import { IconClipboardCopy, IconExternalLink, IconLink, IconLinkOff } from '@tabler/icons-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useState } from 'react';
|
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||||
import { mutate } from 'swr';
|
import { mutate } from 'swr';
|
||||||
import UrlGridView from './views/UrlGridView';
|
import UrlGridView from './views/UrlGridView';
|
||||||
import UrlTableView from './views/UrlTableView';
|
import UrlTableView from './views/UrlTableView';
|
||||||
import { Url } from '@/lib/db/models/url';
|
|
||||||
|
|
||||||
export default function DashboardURLs() {
|
export default function DashboardURLs() {
|
||||||
const clipboard = useClipboard();
|
const clipboard = useClipboard();
|
||||||
const view = useViewStore((state) => state.urls);
|
const view = useViewStore((state) => state.urls);
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useQueryState('cuopen', parseAsBoolean.withDefault(false));
|
||||||
|
|
||||||
const form = useForm<{
|
const form = useForm<{
|
||||||
url: string;
|
url: string;
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ export default function UrlGridView() {
|
|||||||
}}
|
}}
|
||||||
pos='relative'
|
pos='relative'
|
||||||
>
|
>
|
||||||
{urls?.map((url) => <UrlCard setSelectedUrl={setSelectedUrl} key={url.id} url={url} />)}
|
{urls?.map((url) => (
|
||||||
|
<UrlCard setSelectedUrl={setSelectedUrl} key={url.id} url={url} />
|
||||||
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
) : (
|
) : (
|
||||||
<Paper withBorder p='sm' my='sm'>
|
<Paper withBorder p='sm' my='sm'>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
import { useForm } from '@mantine/form';
|
import { useForm } from '@mantine/form';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import { IconPhotoMinus, IconUserCancel, IconUserPlus } from '@tabler/icons-react';
|
import { IconPhotoMinus, IconUserCancel, IconUserPlus } from '@tabler/icons-react';
|
||||||
import { useState } from 'react';
|
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||||
import { mutate } from 'swr';
|
import { mutate } from 'swr';
|
||||||
import UserGridView from './views/UserGridView';
|
import UserGridView from './views/UserGridView';
|
||||||
import UserTableView from './views/UserTableView';
|
import UserTableView from './views/UserTableView';
|
||||||
@@ -30,7 +30,7 @@ import UserTableView from './views/UserTableView';
|
|||||||
export default function DashboardUsers() {
|
export default function DashboardUsers() {
|
||||||
const currentUser = useUserStore((state) => state.user);
|
const currentUser = useUserStore((state) => state.user);
|
||||||
const view = useViewStore((state) => state.users);
|
const view = useViewStore((state) => state.users);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useQueryState('cuseropen', parseAsBoolean.withDefault(false));
|
||||||
|
|
||||||
const form = useForm<{
|
const form = useForm<{
|
||||||
username: string;
|
username: string;
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ export default function UserGridView() {
|
|||||||
}}
|
}}
|
||||||
pos='relative'
|
pos='relative'
|
||||||
>
|
>
|
||||||
{users?.map((user) => <UserCard key={user.id} user={user} />)}
|
{users?.map((user) => (
|
||||||
|
<UserCard key={user.id} user={user} />
|
||||||
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
) : (
|
) : (
|
||||||
<Paper withBorder p='sm' my='sm'>
|
<Paper withBorder p='sm' my='sm'>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useState } from 'react';
|
|||||||
import KaTeX from './KaTeX';
|
import KaTeX from './KaTeX';
|
||||||
import Markdown from './Markdown';
|
import Markdown from './Markdown';
|
||||||
import HighlightCode from './code/HighlightCode';
|
import HighlightCode from './code/HighlightCode';
|
||||||
|
import { parseAsStringEnum, useQueryState } from 'nuqs';
|
||||||
|
|
||||||
export function RenderAlert({
|
export function RenderAlert({
|
||||||
renderer,
|
renderer,
|
||||||
@@ -46,9 +47,11 @@ export default function Render({
|
|||||||
language: string;
|
language: string;
|
||||||
code: string;
|
code: string;
|
||||||
}) {
|
}) {
|
||||||
|
const [overrideRender] = useQueryState('orender', parseAsStringEnum<RenderMode>(Object.values(RenderMode)));
|
||||||
|
|
||||||
const [highlight, setHighlight] = useState(false);
|
const [highlight, setHighlight] = useState(false);
|
||||||
|
|
||||||
switch (mode) {
|
switch (overrideRender || mode) {
|
||||||
case RenderMode.Katex:
|
case RenderMode.Katex:
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { ActionIcon, CopyButton, Paper, ScrollArea, Text, useMantineTheme } from '@mantine/core';
|
import { ActionIcon, Button, CopyButton, Paper, ScrollArea, Text, useMantineTheme } from '@mantine/core';
|
||||||
import { IconCheck, IconClipboardCopy } from '@tabler/icons-react';
|
import { IconCheck, IconClipboardCopy, IconChevronDown, IconChevronUp } from '@tabler/icons-react';
|
||||||
import hljs from 'highlight.js';
|
import hljs from 'highlight.js';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
export default function HighlightCode({ language, code }: { language: string; code: string }) {
|
export default function HighlightCode({ language, code }: { language: string; code: string }) {
|
||||||
const theme = useMantineTheme();
|
const theme = useMantineTheme();
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
const lines = code.split('\n');
|
const lines = code.split('\n');
|
||||||
const lineNumbers = lines.map((_, i) => i + 1);
|
const lineNumbers = lines.map((_, i) => i + 1);
|
||||||
|
const displayLines = expanded ? lines : lines.slice(0, 50);
|
||||||
|
const displayLineNumbers = expanded ? lineNumbers : lineNumbers.slice(0, 50);
|
||||||
|
|
||||||
if (!hljs.getLanguage(language)) {
|
if (!hljs.getLanguage(language)) {
|
||||||
language = 'text';
|
language = 'text';
|
||||||
@@ -33,9 +37,9 @@ export default function HighlightCode({ language, code }: { language: string; co
|
|||||||
</CopyButton>
|
</CopyButton>
|
||||||
|
|
||||||
<ScrollArea type='auto' dir='ltr' offsetScrollbars={false}>
|
<ScrollArea type='auto' dir='ltr' offsetScrollbars={false}>
|
||||||
<pre style={{ margin: 0 }} className='theme'>
|
<pre style={{ margin: 0, whiteSpace: 'pre', overflowX: 'auto' }} className='theme'>
|
||||||
<code className='theme'>
|
<code className='theme'>
|
||||||
{lines.map((line, i) => (
|
{displayLines.map((line, i) => (
|
||||||
<div key={i}>
|
<div key={i}>
|
||||||
<Text
|
<Text
|
||||||
component='span'
|
component='span'
|
||||||
@@ -44,7 +48,7 @@ export default function HighlightCode({ language, code }: { language: string; co
|
|||||||
mr='md'
|
mr='md'
|
||||||
style={{ userSelect: 'none', fontFamily: 'monospace' }}
|
style={{ userSelect: 'none', fontFamily: 'monospace' }}
|
||||||
>
|
>
|
||||||
{lineNumbers[i]}
|
{displayLineNumbers[i]}
|
||||||
</Text>
|
</Text>
|
||||||
<span
|
<span
|
||||||
className='line'
|
className='line'
|
||||||
@@ -57,6 +61,18 @@ export default function HighlightCode({ language, code }: { language: string; co
|
|||||||
</code>
|
</code>
|
||||||
</pre>
|
</pre>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
|
{lines.length > 50 && (
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='compact-sm'
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
leftSection={expanded ? <IconChevronUp size='1rem' /> : <IconChevronDown size='1rem' />}
|
||||||
|
style={{ position: 'absolute', bottom: '0.5rem', right: '0.5rem' }}
|
||||||
|
>
|
||||||
|
{expanded ? 'Show Less' : `Show More (${lines.length - 50} more lines)`}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
89
src/ctl/commands/export-config.ts
Normal file
89
src/ctl/commands/export-config.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { reloadSettings } from '@/lib/config';
|
||||||
|
import { rawConfig } from '@/lib/config/read';
|
||||||
|
import { DATABASE_TO_PROP } from '@/lib/config/read/db';
|
||||||
|
import { ENVS } from '@/lib/config/read/env';
|
||||||
|
import { getProperty } from '@/lib/config/read/transform';
|
||||||
|
import { validateConfigObject } from '@/lib/config/validate';
|
||||||
|
import { randomCharacters } from '@/lib/random';
|
||||||
|
|
||||||
|
function convertValueToEnv(
|
||||||
|
value: any,
|
||||||
|
identified: NonNullable<ReturnType<typeof getEnvFromProperty>>,
|
||||||
|
): string {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
console.warn(`Value for property ${identified.property} is null or undefined.`);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value) && value.length === 0) return '';
|
||||||
|
|
||||||
|
switch (identified.type) {
|
||||||
|
case 'boolean':
|
||||||
|
return value ? 'true' : 'false';
|
||||||
|
case 'number':
|
||||||
|
return value.toString();
|
||||||
|
case 'string':
|
||||||
|
case 'ms':
|
||||||
|
case 'byte':
|
||||||
|
return `"${value.replace(/"/g, '\\"')}"`;
|
||||||
|
case 'string[]':
|
||||||
|
return `"${value.map((v: string) => v.replace(/"/g, '\\"')).join(',')}"`;
|
||||||
|
case 'json':
|
||||||
|
return `"${JSON.stringify(value).replace(/"/g, '\\"')}"`;
|
||||||
|
default:
|
||||||
|
console.warn(`Unknown type for property ${identified.property}: ${identified.type}`);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEnvFromProperty(property: string): NonNullable<typeof env> | null {
|
||||||
|
const env = ENVS.find(
|
||||||
|
(env) => env.property === DATABASE_TO_PROP[property as keyof typeof DATABASE_TO_PROP],
|
||||||
|
);
|
||||||
|
if (!env) {
|
||||||
|
console.warn(`No environment variable found for property: ${property}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportConfig({ yml, showDefaults }: { yml?: boolean; showDefaults?: boolean }) {
|
||||||
|
const clonedDefault = structuredClone(rawConfig);
|
||||||
|
clonedDefault.core.secret = randomCharacters(32);
|
||||||
|
clonedDefault.core.databaseUrl = 'postgres://pg:pg@pg/pg';
|
||||||
|
|
||||||
|
const defaultConfig = validateConfigObject(clonedDefault);
|
||||||
|
|
||||||
|
await reloadSettings();
|
||||||
|
|
||||||
|
const { prisma } = await import('@/lib/db/index.js');
|
||||||
|
|
||||||
|
const ziplineTable = await prisma.zipline.findFirst({
|
||||||
|
omit: {
|
||||||
|
id: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
firstSetup: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!ziplineTable) {
|
||||||
|
console.error('No Zipline configuration found in the database, run the setup again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(ziplineTable)) {
|
||||||
|
if (value === null || value === undefined) continue;
|
||||||
|
|
||||||
|
const envVar = getEnvFromProperty(key);
|
||||||
|
if (!envVar) continue;
|
||||||
|
|
||||||
|
const defaultValue = getProperty(defaultConfig, envVar.property);
|
||||||
|
if (value === defaultValue && !showDefaults) continue;
|
||||||
|
|
||||||
|
const envValue = convertValueToEnv(value, envVar);
|
||||||
|
if (envValue.trim() === '') continue;
|
||||||
|
|
||||||
|
console.log(`${yml ? '- ' : ''}${envVar.variable}=${envValue}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,18 @@ import { guess } from '@/lib/mimes';
|
|||||||
import { statSync } from 'fs';
|
import { statSync } from 'fs';
|
||||||
import { readFile, readdir } from 'fs/promises';
|
import { readFile, readdir } from 'fs/promises';
|
||||||
import { join, parse, resolve } from 'path';
|
import { join, parse, resolve } from 'path';
|
||||||
|
import { reloadSettings } from '@/lib/config';
|
||||||
|
import { bytes } from '@/lib/bytes';
|
||||||
|
|
||||||
export async function importDir(directory: string, { id, folder }: { id?: string; folder?: string }) {
|
export async function importDir(
|
||||||
|
directory: string,
|
||||||
|
{ id, folder, skipDb }: { id?: string; folder?: string; skipDb?: boolean },
|
||||||
|
) {
|
||||||
const fullPath = resolve(directory);
|
const fullPath = resolve(directory);
|
||||||
if (!statSync(fullPath).isDirectory()) return console.error('Not a directory:', directory);
|
if (!statSync(fullPath).isDirectory()) return console.error('Not a directory:', directory);
|
||||||
|
|
||||||
|
await reloadSettings();
|
||||||
|
|
||||||
const { prisma } = await import('@/lib/db/index.js');
|
const { prisma } = await import('@/lib/db/index.js');
|
||||||
let userId: string;
|
let userId: string;
|
||||||
|
|
||||||
@@ -62,18 +69,41 @@ export async function importDir(directory: string, { id, folder }: { id?: string
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await prisma.file.createMany({
|
if (!skipDb) {
|
||||||
data,
|
const { count } = await prisma.file.createMany({
|
||||||
});
|
data,
|
||||||
|
});
|
||||||
|
console.log(`Inserted ${count} files into the database.`);
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Imported', res.count, 'files');
|
const totalSize = data.reduce((acc, file) => acc + file.size, 0);
|
||||||
|
let completed = 0;
|
||||||
|
|
||||||
const { datasource } = await import('@/lib/datasource/index.js');
|
const { datasource } = await import('@/lib/datasource/index.js');
|
||||||
for (let i = 0; i !== files.length; ++i) {
|
for (let i = 0; i !== files.length; ++i) {
|
||||||
const buff = await readFile(join(fullPath, files[i]));
|
console.log(`Uploading ${data[i].name} (${bytes(data[i].size)})...`);
|
||||||
|
|
||||||
await datasource.put(data[i].name, buff);
|
const start = process.hrtime();
|
||||||
console.log('Uploaded', data[i].name);
|
|
||||||
|
const buff = await readFile(join(fullPath, files[i]));
|
||||||
|
await datasource.put(data[i].name, buff, {
|
||||||
|
mimetype: data[i].type ?? 'application/octet-stream',
|
||||||
|
});
|
||||||
|
|
||||||
|
const diff = process.hrtime(start);
|
||||||
|
|
||||||
|
const time = diff[0] * 1e9 + diff[1];
|
||||||
|
const timeStr = time > 1e9 ? `${(time / 1e9).toFixed(2)}s` : `${(time / 1e6).toFixed(2)}ms`;
|
||||||
|
|
||||||
|
const uploadSpeed = (data[i].size / time) * 1e9;
|
||||||
|
const uploadSpeedStr =
|
||||||
|
uploadSpeed > 1e9 ? `${(uploadSpeed / 1e9).toFixed(2)} GB/s` : `${(uploadSpeed / 1e6).toFixed(2)} MB/s`;
|
||||||
|
|
||||||
|
completed += data[i].size;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Uploaded ${data[i].name} in ${timeStr} (${bytes(data[i].size)}) ${i + 1}/${files.length} ${bytes(completed)}/${bytes(totalSize)} ${uploadSpeedStr}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Done importing files.');
|
console.log('Done importing files.');
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { listUsers } from './commands/list-users';
|
|||||||
import { readConfig } from './commands/read-config';
|
import { readConfig } from './commands/read-config';
|
||||||
import { setUser } from './commands/set-user';
|
import { setUser } from './commands/set-user';
|
||||||
import { importDir } from './commands/import-dir';
|
import { importDir } from './commands/import-dir';
|
||||||
|
import { exportConfig } from './commands/export-config';
|
||||||
|
|
||||||
const cli = new Command();
|
const cli = new Command();
|
||||||
|
|
||||||
@@ -39,8 +40,17 @@ cli
|
|||||||
'-i, --id [user_id]',
|
'-i, --id [user_id]',
|
||||||
'the id that imported files should belong to. if unspecificed the user with the "administrator" username as well as the "SUPERADMIN" role will be used',
|
'the id that imported files should belong to. if unspecificed the user with the "administrator" username as well as the "SUPERADMIN" role will be used',
|
||||||
)
|
)
|
||||||
|
.option('--skip-db', 'do not add the files to the database')
|
||||||
|
.option('--skip-ds', 'do not add the files to the datasource')
|
||||||
.option('-f, --folder [folder_id]', 'an optional folder to add the files to')
|
.option('-f, --folder [folder_id]', 'an optional folder to add the files to')
|
||||||
.argument('<directory>', 'the directory to import into Zipline')
|
.argument('<directory>', 'the directory to import into Zipline')
|
||||||
.action(importDir);
|
.action(importDir);
|
||||||
|
|
||||||
|
cli
|
||||||
|
.command('export-config')
|
||||||
|
.summary('export the current configuration as environment variables')
|
||||||
|
.option('-y, --yml', 'export the configuration in a yml format', false)
|
||||||
|
.option('-d, --show-defaults', 'ignore default values and only export changed values', false)
|
||||||
|
.action(exportConfig);
|
||||||
|
|
||||||
cli.parse();
|
cli.parse();
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { ApiServerThumbnailsResponse } from '@/server/routes/api/server/thumbnai
|
|||||||
import { ApiSetupResponse } from '@/server/routes/api/setup';
|
import { ApiSetupResponse } from '@/server/routes/api/setup';
|
||||||
import { ApiStatsResponse } from '@/server/routes/api/stats';
|
import { ApiStatsResponse } from '@/server/routes/api/stats';
|
||||||
import { ApiUploadResponse } from '@/server/routes/api/upload';
|
import { ApiUploadResponse } from '@/server/routes/api/upload';
|
||||||
|
import { ApiUploadPartialResponse } from '@/server/routes/api/upload/partial';
|
||||||
import { ApiUserResponse } from '@/server/routes/api/user';
|
import { ApiUserResponse } from '@/server/routes/api/user';
|
||||||
import { ApiUserExportResponse } from '@/server/routes/api/user/export';
|
import { ApiUserExportResponse } from '@/server/routes/api/user/export';
|
||||||
import { ApiUserFilesResponse } from '@/server/routes/api/user/files';
|
import { ApiUserFilesResponse } from '@/server/routes/api/user/files';
|
||||||
@@ -76,6 +77,7 @@ export type Response = {
|
|||||||
'/api/healthcheck': ApiHealthcheckResponse;
|
'/api/healthcheck': ApiHealthcheckResponse;
|
||||||
'/api/setup': ApiSetupResponse;
|
'/api/setup': ApiSetupResponse;
|
||||||
'/api/upload': ApiUploadResponse;
|
'/api/upload': ApiUploadResponse;
|
||||||
|
'/api/upload/partial': ApiUploadPartialResponse;
|
||||||
'/api/version': ApiVersionResponse;
|
'/api/version': ApiVersionResponse;
|
||||||
'/api/stats': ApiStatsResponse;
|
'/api/stats': ApiStatsResponse;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
import { config } from '@/lib/config';
|
|
||||||
import { hashPassword } from '@/lib/crypto';
|
|
||||||
import { prisma } from '@/lib/db';
|
|
||||||
import { log } from '@/lib/logger';
|
|
||||||
import { guess } from '@/lib/mimes';
|
|
||||||
import { formatFileName } from '@/lib/uploader/formatFileName';
|
|
||||||
import { UploadHeaders, UploadOptions } from '@/lib/uploader/parseHeaders';
|
|
||||||
import { ApiUploadResponse, MultipartFileBuffer } from '@/server/routes/api/upload';
|
|
||||||
import { FastifyRequest } from 'fastify';
|
|
||||||
import { writeFile } from 'fs/promises';
|
|
||||||
import { join } from 'path';
|
|
||||||
import { Worker } from 'worker_threads';
|
|
||||||
import { getExtension } from './upload';
|
|
||||||
|
|
||||||
const logger = log('api').c('upload');
|
|
||||||
export async function handlePartialUpload({
|
|
||||||
file,
|
|
||||||
options,
|
|
||||||
domain,
|
|
||||||
response,
|
|
||||||
req,
|
|
||||||
}: {
|
|
||||||
file: MultipartFileBuffer;
|
|
||||||
options: UploadOptions;
|
|
||||||
domain: string;
|
|
||||||
response: ApiUploadResponse;
|
|
||||||
req: FastifyRequest<{ Headers: UploadHeaders }>;
|
|
||||||
}) {
|
|
||||||
if (!options.partial) throw 'No partial upload options provided';
|
|
||||||
logger.debug('partial upload detected', { partial: options.partial });
|
|
||||||
|
|
||||||
if (!options.partial.identifier || !options.partial.range || options.partial.range.length !== 3)
|
|
||||||
throw 'Invalid partial upload';
|
|
||||||
|
|
||||||
const extension = getExtension(options.partial.filename, options.overrides?.extension);
|
|
||||||
|
|
||||||
if (config.files.disabledExtensions.includes(extension)) throw `File extension ${extension} is not allowed`;
|
|
||||||
|
|
||||||
const format = options.format || config.files.defaultFormat;
|
|
||||||
let fileName = formatFileName(format, decodeURIComponent(options.partial.filename));
|
|
||||||
|
|
||||||
if (options.overrides?.filename || format === 'name') {
|
|
||||||
if (options.overrides?.filename) fileName = decodeURIComponent(options.overrides!.filename!);
|
|
||||||
const existing = await prisma.file.findFirst({
|
|
||||||
where: {
|
|
||||||
name: {
|
|
||||||
startsWith: fileName,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (existing) throw `A file with the name "${fileName}*" already exists`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mimetype = options.partial.contentType;
|
|
||||||
if (mimetype === 'application/octet-stream' && config.files.assumeMimetypes) {
|
|
||||||
const mime = await guess(extension.substring(1));
|
|
||||||
if (mime) mimetype = mime;
|
|
||||||
}
|
|
||||||
|
|
||||||
let folder = null;
|
|
||||||
if (options.folder) {
|
|
||||||
folder = await prisma.folder.findFirst({
|
|
||||||
where: {
|
|
||||||
id: options.folder,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!folder) throw 'Folder does not exist';
|
|
||||||
|
|
||||||
if (!folder.allowUploads && folder.userId !== req.user?.id) throw 'Folder is not open';
|
|
||||||
}
|
|
||||||
|
|
||||||
const tempFile = join(
|
|
||||||
config.core.tempDirectory,
|
|
||||||
`zipline_partial_${options.partial.identifier}_${options.partial.range[0]}_${options.partial.range[1]}`,
|
|
||||||
);
|
|
||||||
await writeFile(tempFile, file.buffer);
|
|
||||||
|
|
||||||
if (options.partial.lastchunk) {
|
|
||||||
const fileUpload = await prisma.file.create({
|
|
||||||
data: {
|
|
||||||
name: `${fileName}${extension}`,
|
|
||||||
size: 0,
|
|
||||||
type: mimetype,
|
|
||||||
User: {
|
|
||||||
connect: {
|
|
||||||
id: req.user ? req.user.id : options.folder ? folder?.userId : undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...(options.password && { password: await hashPassword(options.password) }),
|
|
||||||
...(options.folder && { Folder: { connect: { id: options.folder } } }),
|
|
||||||
...(options.addOriginalName && {
|
|
||||||
originalName: options.partial.filename
|
|
||||||
? decodeURIComponent(options.partial.filename)
|
|
||||||
: file.filename,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
new Worker('./build/offload/partial.js', {
|
|
||||||
workerData: {
|
|
||||||
user: {
|
|
||||||
id: req.user ? req.user.id : options.folder ? folder?.userId : undefined,
|
|
||||||
},
|
|
||||||
file: {
|
|
||||||
id: fileUpload.id,
|
|
||||||
filename: fileUpload.name,
|
|
||||||
type: fileUpload.type,
|
|
||||||
},
|
|
||||||
options,
|
|
||||||
domain,
|
|
||||||
responseUrl: `${domain}/${encodeURIComponent(fileUpload.name)}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
response.files.push({
|
|
||||||
id: fileUpload.id,
|
|
||||||
type: fileUpload.type,
|
|
||||||
url: `${domain}/${encodeURIComponent(fileUpload.name)}`,
|
|
||||||
pending: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
response.partialSuccess = true;
|
|
||||||
}
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
import { ApiUploadResponse, MultipartFileBuffer } from '@/server/routes/api/upload';
|
|
||||||
import { FastifyRequest } from 'fastify';
|
|
||||||
import { extname } from 'path';
|
|
||||||
import { bytes } from '@/lib/bytes';
|
|
||||||
import { compress } from '@/lib/compress';
|
|
||||||
import { config } from '@/lib/config';
|
|
||||||
import { hashPassword } from '@/lib/crypto';
|
|
||||||
import { datasource } from '@/lib/datasource';
|
|
||||||
import { prisma } from '@/lib/db';
|
|
||||||
import { fileSelect } from '@/lib/db/models/file';
|
|
||||||
import { onUpload } from '@/lib/webhooks';
|
|
||||||
import { removeGps } from '@/lib/gps';
|
|
||||||
import { log } from '@/lib/logger';
|
|
||||||
import { guess } from '@/lib/mimes';
|
|
||||||
import { formatFileName } from '@/lib/uploader/formatFileName';
|
|
||||||
import { UploadHeaders, UploadOptions } from '@/lib/uploader/parseHeaders';
|
|
||||||
|
|
||||||
const logger = log('api').c('upload');
|
|
||||||
|
|
||||||
const commonDoubleExts = [
|
|
||||||
'.tar.gz',
|
|
||||||
'.tar.xz',
|
|
||||||
'.tar.bz2',
|
|
||||||
'.tar.lz',
|
|
||||||
'.tar.lzma',
|
|
||||||
'.tar.Z',
|
|
||||||
'.tar.7z',
|
|
||||||
'.zip.gz',
|
|
||||||
'.zip.xz',
|
|
||||||
'.rar.gz',
|
|
||||||
'.log.gz',
|
|
||||||
'.csv.gz',
|
|
||||||
'.pdf.gz',
|
|
||||||
// feel free to PR more
|
|
||||||
];
|
|
||||||
|
|
||||||
export const getExtension = (filename: string, override?: string): string => {
|
|
||||||
return override ?? commonDoubleExts.find((ext) => filename.endsWith(ext)) ?? extname(filename);
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function handleFile({
|
|
||||||
file,
|
|
||||||
i,
|
|
||||||
options,
|
|
||||||
domain,
|
|
||||||
response,
|
|
||||||
req,
|
|
||||||
}: {
|
|
||||||
file: MultipartFileBuffer;
|
|
||||||
i: number;
|
|
||||||
options: UploadOptions;
|
|
||||||
domain: string;
|
|
||||||
response: ApiUploadResponse;
|
|
||||||
req: FastifyRequest<{ Headers: UploadHeaders }>;
|
|
||||||
}) {
|
|
||||||
const extension = getExtension(file.filename, options.overrides?.extension);
|
|
||||||
|
|
||||||
if (config.files.disabledExtensions.includes(extension)) throw `File extension ${extension} is not allowed`;
|
|
||||||
|
|
||||||
if (file.file.bytesRead > bytes(config.files.maxFileSize))
|
|
||||||
throw `File size is too large. Maximum file size is ${bytes(config.files.maxFileSize)} bytes`;
|
|
||||||
|
|
||||||
const format = options.format || config.files.defaultFormat;
|
|
||||||
let fileName = formatFileName(format, file.filename);
|
|
||||||
|
|
||||||
if (options.overrides?.filename || format === 'name') {
|
|
||||||
if (options.overrides?.filename) fileName = decodeURIComponent(options.overrides!.filename!);
|
|
||||||
const existing = await prisma.file.findFirst({
|
|
||||||
where: {
|
|
||||||
name: {
|
|
||||||
startsWith: fileName,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (existing) throw `A file with the name "${fileName}*" already exists`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mimetype = file.mimetype;
|
|
||||||
if (mimetype === 'application/octet-stream' && config.files.assumeMimetypes) {
|
|
||||||
const mime = await guess(extension.substring(1));
|
|
||||||
|
|
||||||
if (!mime) response.assumedMimetypes![i] = false;
|
|
||||||
else {
|
|
||||||
response.assumedMimetypes![i] = true;
|
|
||||||
mimetype = mime;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let folder = null;
|
|
||||||
if (options.folder) {
|
|
||||||
folder = await prisma.folder.findFirst({
|
|
||||||
where: {
|
|
||||||
id: options.folder,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!folder) throw 'Folder does not exist';
|
|
||||||
|
|
||||||
if (!folder.allowUploads && folder.userId !== req.user?.id) throw 'Folder is not open';
|
|
||||||
}
|
|
||||||
|
|
||||||
let compressed = false;
|
|
||||||
if (mimetype.startsWith('image/') && options.imageCompressionPercent) {
|
|
||||||
file.buffer = await compress(file.buffer, options.imageCompressionPercent);
|
|
||||||
logger.c('jpg').debug(`compressed file ${file.filename}`, {
|
|
||||||
nsize: bytes(file.buffer.length),
|
|
||||||
});
|
|
||||||
|
|
||||||
compressed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
let removedGps = false;
|
|
||||||
if (mimetype.startsWith('image/') && config.files.removeGpsMetadata) {
|
|
||||||
removedGps = await removeGps(file.buffer);
|
|
||||||
|
|
||||||
if (removedGps) {
|
|
||||||
logger.c('gps').debug(`removed gps metadata from ${file.filename}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileUpload = await prisma.file.create({
|
|
||||||
data: {
|
|
||||||
name: `${fileName}${compressed ? '.jpg' : extension}`,
|
|
||||||
size: file.buffer ? file.buffer.length : file.file.bytesRead,
|
|
||||||
type: compressed ? 'image/jpeg' : mimetype,
|
|
||||||
User: {
|
|
||||||
connect: {
|
|
||||||
id: req.user ? req.user.id : options.folder ? folder?.userId : undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...(options.maxViews && { maxViews: options.maxViews }),
|
|
||||||
...(options.password && { password: await hashPassword(options.password) }),
|
|
||||||
...(options.deletesAt && { deletesAt: options.deletesAt }),
|
|
||||||
...(options.folder && { Folder: { connect: { id: options.folder } } }),
|
|
||||||
...(options.addOriginalName && { originalName: file.filename }),
|
|
||||||
},
|
|
||||||
select: fileSelect,
|
|
||||||
});
|
|
||||||
|
|
||||||
await datasource.put(fileUpload.name, file.buffer);
|
|
||||||
|
|
||||||
const responseUrl = `${domain}${
|
|
||||||
config.files.route === '/' || config.files.route === '' ? '' : `${config.files.route}`
|
|
||||||
}/${fileUpload.name}`;
|
|
||||||
|
|
||||||
response.files.push({
|
|
||||||
id: fileUpload.id,
|
|
||||||
type: fileUpload.type,
|
|
||||||
url: encodeURI(responseUrl),
|
|
||||||
|
|
||||||
...(removedGps && { removedGps: true }),
|
|
||||||
...(compressed && { compressed: true }),
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(`${req.user ? req.user.username : '[anonymous folder upload]'} uploaded ${fileUpload.name}`, {
|
|
||||||
size: bytes(fileUpload.size),
|
|
||||||
ip: req.ip,
|
|
||||||
});
|
|
||||||
|
|
||||||
await onUpload({
|
|
||||||
user: req.user ?? {
|
|
||||||
id: 'anonymous',
|
|
||||||
username: 'anonymous',
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
role: 'USER',
|
|
||||||
},
|
|
||||||
file: fileUpload,
|
|
||||||
link: {
|
|
||||||
raw: `${domain}/raw/${encodeURIComponent(fileUpload.name)}`,
|
|
||||||
returned: encodeURI(responseUrl),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
|
|
||||||
export function compress(buffer: Buffer, qualty: number) {
|
export function compressFile(filePath: string, quality: number) {
|
||||||
return sharp(buffer).withMetadata().jpeg({ quality: qualty }).toBuffer();
|
const buffer = sharp(filePath).withMetadata().jpeg({ quality: quality }).toBuffer();
|
||||||
|
|
||||||
|
return buffer.then((data) => {
|
||||||
|
return sharp(data).toFile(filePath);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function replaceFileNameJpg(original: string, when?: boolean) {
|
export function replaceFileNameJpg(original: string, when?: boolean) {
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { validateConfigObject, Config } from './validate';
|
|||||||
let config: Config;
|
let config: Config;
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
// eslint-disable-next-line no-var
|
|
||||||
var __config__: Config;
|
var __config__: Config;
|
||||||
|
var __tamperedConfig__: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const reloadSettings = async () => {
|
const reloadSettings = async () => {
|
||||||
|
|||||||
@@ -1,474 +0,0 @@
|
|||||||
import msFn, { StringValue } from 'ms';
|
|
||||||
import { log } from '../logger';
|
|
||||||
import { bytes } from '../bytes';
|
|
||||||
import { prisma } from '../db';
|
|
||||||
import { join } from 'path';
|
|
||||||
import { tmpdir } from 'os';
|
|
||||||
|
|
||||||
type EnvType = 'string' | 'string[]' | 'number' | 'boolean' | 'byte' | 'ms' | 'json[]';
|
|
||||||
|
|
||||||
export type ParsedConfig = ReturnType<typeof read>;
|
|
||||||
|
|
||||||
export const rawConfig: any = {
|
|
||||||
core: {
|
|
||||||
port: undefined,
|
|
||||||
hostname: undefined,
|
|
||||||
secret: undefined,
|
|
||||||
databaseUrl: undefined,
|
|
||||||
returnHttpsUrls: undefined,
|
|
||||||
tempDirectory: undefined,
|
|
||||||
},
|
|
||||||
chunks: {
|
|
||||||
max: undefined,
|
|
||||||
size: undefined,
|
|
||||||
enabled: undefined,
|
|
||||||
},
|
|
||||||
tasks: {
|
|
||||||
deleteInterval: undefined,
|
|
||||||
clearInvitesInterval: undefined,
|
|
||||||
maxViewsInterval: undefined,
|
|
||||||
thumbnailsInterval: undefined,
|
|
||||||
metricsInterval: undefined,
|
|
||||||
},
|
|
||||||
files: {
|
|
||||||
route: undefined,
|
|
||||||
length: undefined,
|
|
||||||
defaultFormat: undefined,
|
|
||||||
disabledExtensions: undefined,
|
|
||||||
maxFileSize: undefined,
|
|
||||||
defaultExpiration: undefined,
|
|
||||||
assumeMimetypes: undefined,
|
|
||||||
defaultDateFormat: undefined,
|
|
||||||
removeGpsMetadata: undefined,
|
|
||||||
randomWordsNumAdjectives: undefined,
|
|
||||||
randomWordsSeperator: undefined,
|
|
||||||
},
|
|
||||||
urls: {
|
|
||||||
route: undefined,
|
|
||||||
length: undefined,
|
|
||||||
},
|
|
||||||
datasource: {
|
|
||||||
type: undefined,
|
|
||||||
},
|
|
||||||
features: {
|
|
||||||
imageCompression: undefined,
|
|
||||||
robotsTxt: undefined,
|
|
||||||
healthcheck: undefined,
|
|
||||||
invites: undefined,
|
|
||||||
userRegistration: undefined,
|
|
||||||
oauthRegistration: undefined,
|
|
||||||
deleteOnMaxViews: undefined,
|
|
||||||
thumbnails: {
|
|
||||||
enabled: undefined,
|
|
||||||
num_threads: undefined,
|
|
||||||
},
|
|
||||||
metrics: {
|
|
||||||
enabled: undefined,
|
|
||||||
adminOnly: undefined,
|
|
||||||
showUserSpecific: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
invites: {
|
|
||||||
enabled: undefined,
|
|
||||||
length: undefined,
|
|
||||||
},
|
|
||||||
website: {
|
|
||||||
title: undefined,
|
|
||||||
titleLogo: undefined,
|
|
||||||
externalLinks: undefined,
|
|
||||||
loginBackground: undefined,
|
|
||||||
defaultAvatar: undefined,
|
|
||||||
tos: undefined,
|
|
||||||
theme: {
|
|
||||||
default: undefined,
|
|
||||||
dark: undefined,
|
|
||||||
light: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mfa: {
|
|
||||||
totp: {
|
|
||||||
enabled: undefined,
|
|
||||||
issuer: undefined,
|
|
||||||
},
|
|
||||||
passkeys: undefined,
|
|
||||||
},
|
|
||||||
oauth: {
|
|
||||||
bypassLocalLogin: undefined,
|
|
||||||
loginOnly: undefined,
|
|
||||||
discord: {
|
|
||||||
clientId: undefined,
|
|
||||||
clientSecret: undefined,
|
|
||||||
},
|
|
||||||
github: {
|
|
||||||
clientId: undefined,
|
|
||||||
clientSecret: undefined,
|
|
||||||
},
|
|
||||||
google: {
|
|
||||||
clientId: undefined,
|
|
||||||
clientSecret: undefined,
|
|
||||||
},
|
|
||||||
oidc: {
|
|
||||||
clientId: undefined,
|
|
||||||
clientSecret: undefined,
|
|
||||||
authorizeUrl: undefined,
|
|
||||||
userinfoUrl: undefined,
|
|
||||||
tokenUrl: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
discord: null,
|
|
||||||
ratelimit: {
|
|
||||||
enabled: undefined,
|
|
||||||
max: undefined,
|
|
||||||
window: undefined,
|
|
||||||
adminBypass: undefined,
|
|
||||||
allowList: undefined,
|
|
||||||
},
|
|
||||||
httpWebhook: {
|
|
||||||
onUpload: undefined,
|
|
||||||
onShorten: undefined,
|
|
||||||
},
|
|
||||||
ssl: {
|
|
||||||
key: undefined,
|
|
||||||
cert: undefined,
|
|
||||||
},
|
|
||||||
pwa: {
|
|
||||||
enabled: undefined,
|
|
||||||
title: undefined,
|
|
||||||
shortName: undefined,
|
|
||||||
description: undefined,
|
|
||||||
backgroundColor: undefined,
|
|
||||||
themeColor: undefined,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PROP_TO_ENV = {
|
|
||||||
'core.port': 'CORE_PORT',
|
|
||||||
'core.hostname': 'CORE_HOSTNAME',
|
|
||||||
'core.secret': 'CORE_SECRET',
|
|
||||||
'core.databaseUrl': ['CORE_DATABASE_URL', 'DATABASE_URL'],
|
|
||||||
|
|
||||||
'datasource.type': 'DATASOURCE_TYPE',
|
|
||||||
|
|
||||||
// only for errors, not used in readenv
|
|
||||||
'datasource.s3': 'DATASOURCE_S3_*',
|
|
||||||
'datasource.local': 'DATASOURCE_LOCAL_*',
|
|
||||||
|
|
||||||
'datasource.s3.accessKeyId': 'DATASOURCE_S3_ACCESS_KEY_ID',
|
|
||||||
'datasource.s3.secretAccessKey': 'DATASOURCE_S3_SECRET_ACCESS_KEY',
|
|
||||||
'datasource.s3.region': 'DATASOURCE_S3_REGION',
|
|
||||||
'datasource.s3.bucket': 'DATASOURCE_S3_BUCKET',
|
|
||||||
'datasource.s3.endpoint': 'DATASOURCE_S3_ENDPOINT',
|
|
||||||
'datasource.s3.forcePathStyle': 'DATASOURCE_S3_FORCE_PATH_STYLE',
|
|
||||||
|
|
||||||
'datasource.local.directory': 'DATASOURCE_LOCAL_DIRECTORY',
|
|
||||||
|
|
||||||
'ssl.key': 'SSL_KEY',
|
|
||||||
'ssl.cert': 'SSL_CERT',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DATABASE_TO_PROP = {
|
|
||||||
coreReturnHttpsUrls: 'core.returnHttpsUrls',
|
|
||||||
coreDefaultDomain: 'core.defaultDomain',
|
|
||||||
coreTempDirectory: 'core.tempDirectory',
|
|
||||||
|
|
||||||
chunksMax: 'chunks.max',
|
|
||||||
chunksSize: 'chunks.size',
|
|
||||||
chunksEnabled: 'chunks.enabled',
|
|
||||||
|
|
||||||
tasksDeleteInterval: 'tasks.deleteInterval',
|
|
||||||
tasksClearInvitesInterval: 'tasks.clearInvitesInterval',
|
|
||||||
tasksMaxViewsInterval: 'tasks.maxViewsInterval',
|
|
||||||
tasksThumbnailsInterval: 'tasks.thumbnailsInterval',
|
|
||||||
tasksMetricsInterval: 'tasks.metricsInterval',
|
|
||||||
|
|
||||||
filesRoute: 'files.route',
|
|
||||||
filesLength: 'files.length',
|
|
||||||
filesDefaultFormat: 'files.defaultFormat',
|
|
||||||
filesDisabledExtensions: 'files.disabledExtensions',
|
|
||||||
filesMaxFileSize: 'files.maxFileSize',
|
|
||||||
filesDefaultExpiration: 'files.defaultExpiration',
|
|
||||||
filesAssumeMimetypes: 'files.assumeMimetypes',
|
|
||||||
filesDefaultDateFormat: 'files.defaultDateFormat',
|
|
||||||
filesRemoveGpsMetadata: 'files.removeGpsMetadata',
|
|
||||||
filesRandomWordsNumAdjectives: 'files.randomWordsNumAdjectives',
|
|
||||||
filesRandomWordsSeperator: 'files.randomWordsSeperator',
|
|
||||||
|
|
||||||
urlsRoute: 'urls.route',
|
|
||||||
urlsLength: 'urls.length',
|
|
||||||
|
|
||||||
featuresImageCompression: 'features.imageCompression',
|
|
||||||
featuresRobotsTxt: 'features.robotsTxt',
|
|
||||||
featuresHealthcheck: 'features.healthcheck',
|
|
||||||
featuresUserRegistration: 'features.userRegistration',
|
|
||||||
featuresOauthRegistration: 'features.oauthRegistration',
|
|
||||||
featuresDeleteOnMaxViews: 'features.deleteOnMaxViews',
|
|
||||||
|
|
||||||
featuresThumbnailsEnabled: 'features.thumbnails.enabled',
|
|
||||||
featuresThumbnailsNumberThreads: 'features.thumbnails.num_threads',
|
|
||||||
|
|
||||||
featuresMetricsEnabled: 'features.metrics.enabled',
|
|
||||||
featuresMetricsAdminOnly: 'features.metrics.adminOnly',
|
|
||||||
featuresMetricsShowUserSpecific: 'features.metrics.showUserSpecific',
|
|
||||||
|
|
||||||
invitesEnabled: 'invites.enabled',
|
|
||||||
invitesLength: 'invites.length',
|
|
||||||
|
|
||||||
websiteTitle: 'website.title',
|
|
||||||
websiteTitleLogo: 'website.titleLogo',
|
|
||||||
websiteExternalLinks: 'website.externalLinks',
|
|
||||||
websiteLoginBackground: 'website.loginBackground',
|
|
||||||
websiteLoginBackgroundBlur: 'website.loginBackgroundBlur',
|
|
||||||
websiteDefaultAvatar: 'website.defaultAvatar',
|
|
||||||
websiteTos: 'website.tos',
|
|
||||||
|
|
||||||
websiteThemeDefault: 'website.theme.default',
|
|
||||||
websiteThemeDark: 'website.theme.dark',
|
|
||||||
websiteThemeLight: 'website.theme.light',
|
|
||||||
|
|
||||||
oauthBypassLocalLogin: 'oauth.bypassLocalLogin',
|
|
||||||
oauthLoginOnly: 'oauth.loginOnly',
|
|
||||||
|
|
||||||
oauthDiscordClientId: 'oauth.discord.clientId',
|
|
||||||
oauthDiscordClientSecret: 'oauth.discord.clientSecret',
|
|
||||||
oauthDiscordRedirectUri: 'oauth.discord.redirectUri',
|
|
||||||
|
|
||||||
oauthGoogleClientId: 'oauth.google.clientId',
|
|
||||||
oauthGoogleClientSecret: 'oauth.google.clientSecret',
|
|
||||||
oauthGoogleRedirectUri: 'oauth.google.redirectUri',
|
|
||||||
|
|
||||||
oauthGithubClientId: 'oauth.github.clientId',
|
|
||||||
oauthGithubClientSecret: 'oauth.github.clientSecret',
|
|
||||||
oauthGithubRedirectUri: 'oauth.github.redirectUri',
|
|
||||||
|
|
||||||
oauthOidcClientId: 'oauth.oidc.clientId',
|
|
||||||
oauthOidcClientSecret: 'oauth.oidc.clientSecret',
|
|
||||||
oauthOidcAuthorizeUrl: 'oauth.oidc.authorizeUrl',
|
|
||||||
oauthOidcUserinfoUrl: 'oauth.oidc.userinfoUrl',
|
|
||||||
oauthOidcTokenUrl: 'oauth.oidc.tokenUrl',
|
|
||||||
oauthOidcRedirectUri: 'oauth.oidc.redirectUri',
|
|
||||||
|
|
||||||
mfaTotpEnabled: 'mfa.totp.enabled',
|
|
||||||
mfaTotpIssuer: 'mfa.totp.issuer',
|
|
||||||
mfaPasskeys: 'mfa.passkeys',
|
|
||||||
|
|
||||||
ratelimitEnabled: 'ratelimit.enabled',
|
|
||||||
ratelimitMax: 'ratelimit.max',
|
|
||||||
ratelimitWindow: 'ratelimit.window',
|
|
||||||
ratelimitAdminBypass: 'ratelimit.adminBypass',
|
|
||||||
ratelimitAllowList: 'ratelimit.allowList',
|
|
||||||
|
|
||||||
httpWebhookOnUpload: 'httpWebhook.onUpload',
|
|
||||||
httpWebhookOnShorten: 'httpWebhook.onShorten',
|
|
||||||
|
|
||||||
discordWebhookUrl: 'discord.webhookUrl',
|
|
||||||
discordUsername: 'discord.username',
|
|
||||||
discordAvatarUrl: 'discord.avatarUrl',
|
|
||||||
|
|
||||||
discordOnUploadWebhookUrl: 'discord.onUpload.webhookUrl',
|
|
||||||
discordOnUploadUsername: 'discord.onUpload.username',
|
|
||||||
discordOnUploadAvatarUrl: 'discord.onUpload.avatarUrl',
|
|
||||||
discordOnUploadContent: 'discord.onUpload.content',
|
|
||||||
discordOnUploadEmbed: 'discord.onUpload.embed',
|
|
||||||
|
|
||||||
discordOnShortenWebhookUrl: 'discord.onShorten.webhookUrl',
|
|
||||||
discordOnShortenUsername: 'discord.onShorten.username',
|
|
||||||
discordOnShortenAvatarUrl: 'discord.onShorten.avatarUrl',
|
|
||||||
discordOnShortenContent: 'discord.onShorten.content',
|
|
||||||
discordOnShortenEmbed: 'discord.onShorten.embed',
|
|
||||||
|
|
||||||
pwaEnabled: 'pwa.enabled',
|
|
||||||
pwaTitle: 'pwa.title',
|
|
||||||
pwaShortName: 'pwa.shortName',
|
|
||||||
pwaDescription: 'pwa.description',
|
|
||||||
pwaThemeColor: 'pwa.themeColor',
|
|
||||||
pwaBackgroundColor: 'pwa.backgroundColor',
|
|
||||||
};
|
|
||||||
|
|
||||||
const logger = log('config').c('read');
|
|
||||||
|
|
||||||
export async function readDatabaseSettings() {
|
|
||||||
let ziplineTable = await prisma.zipline.findFirst({
|
|
||||||
omit: {
|
|
||||||
createdAt: true,
|
|
||||||
updatedAt: true,
|
|
||||||
id: true,
|
|
||||||
firstSetup: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!ziplineTable) {
|
|
||||||
ziplineTable = await prisma.zipline.create({
|
|
||||||
data: {
|
|
||||||
coreTempDirectory: join(tmpdir(), 'zipline'),
|
|
||||||
},
|
|
||||||
omit: {
|
|
||||||
createdAt: true,
|
|
||||||
updatedAt: true,
|
|
||||||
id: true,
|
|
||||||
firstSetup: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return ziplineTable;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readEnv() {
|
|
||||||
const envs = [
|
|
||||||
env('core.port', 'number'),
|
|
||||||
env('core.hostname', 'string'),
|
|
||||||
env('core.secret', 'string'),
|
|
||||||
env('core.databaseUrl', 'string'),
|
|
||||||
|
|
||||||
env('datasource.type', 'string'),
|
|
||||||
|
|
||||||
env('datasource.s3.accessKeyId', 'string'),
|
|
||||||
env('datasource.s3.secretAccessKey', 'string'),
|
|
||||||
env('datasource.s3.region', 'string'),
|
|
||||||
env('datasource.s3.bucket', 'string'),
|
|
||||||
env('datasource.s3.endpoint', 'string'),
|
|
||||||
env('datasource.s3.forcePathStyle', 'boolean'),
|
|
||||||
|
|
||||||
env('datasource.local.directory', 'string'),
|
|
||||||
|
|
||||||
env('ssl.key', 'string'),
|
|
||||||
env('ssl.cert', 'string'),
|
|
||||||
];
|
|
||||||
|
|
||||||
const raw: Record<keyof typeof rawConfig, any> = {};
|
|
||||||
|
|
||||||
for (let i = 0; i !== envs.length; ++i) {
|
|
||||||
const env = envs[i];
|
|
||||||
if (Array.isArray(env.variable)) {
|
|
||||||
env.variable = env.variable.find((v) => process.env[v] !== undefined) || 'DATABASE_URL';
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = process.env[env.variable];
|
|
||||||
|
|
||||||
if (value === undefined) continue;
|
|
||||||
|
|
||||||
if (env.variable === 'DATASOURCE_TYPE') {
|
|
||||||
if (value === 's3') {
|
|
||||||
raw['datasource.s3.accessKeyId'] = undefined;
|
|
||||||
raw['datasource.s3.secretAccessKey'] = undefined;
|
|
||||||
raw['datasource.s3.region'] = undefined;
|
|
||||||
raw['datasource.s3.bucket'] = undefined;
|
|
||||||
} else if (value === 'local') {
|
|
||||||
raw['datasource.local.directory'] = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = parse(value, env.type);
|
|
||||||
if (parsed === undefined) continue;
|
|
||||||
|
|
||||||
raw[env.property] = parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function read() {
|
|
||||||
const database = await readDatabaseSettings();
|
|
||||||
const env = readEnv();
|
|
||||||
|
|
||||||
const raw = structuredClone(rawConfig);
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(database as Record<string, any>)) {
|
|
||||||
if (value === undefined) {
|
|
||||||
logger.warn('Missing database value', { key });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!DATABASE_TO_PROP[key as keyof typeof DATABASE_TO_PROP]) continue;
|
|
||||||
if (value == undefined) continue;
|
|
||||||
|
|
||||||
setProperty(raw, DATABASE_TO_PROP[key as keyof typeof DATABASE_TO_PROP], value);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(env)) {
|
|
||||||
if (value === undefined) {
|
|
||||||
logger.warn('Missing env value', { key });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
setProperty(raw, key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isObject(value: any) {
|
|
||||||
return typeof value === 'object' && value !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setProperty(obj: any, path: string, value: any) {
|
|
||||||
if (!isObject(obj)) return obj;
|
|
||||||
|
|
||||||
const root = obj;
|
|
||||||
const dot = path.split('.');
|
|
||||||
|
|
||||||
for (let i = 0; i !== dot.length; ++i) {
|
|
||||||
const key = dot[i];
|
|
||||||
|
|
||||||
if (i === dot.length - 1) {
|
|
||||||
obj[key] = value;
|
|
||||||
} else if (!isObject(obj[key])) {
|
|
||||||
obj[key] = typeof dot[i + 1] === 'number' ? [] : {};
|
|
||||||
}
|
|
||||||
|
|
||||||
obj = obj[key];
|
|
||||||
}
|
|
||||||
|
|
||||||
return root;
|
|
||||||
}
|
|
||||||
|
|
||||||
function env(property: keyof typeof PROP_TO_ENV, type: EnvType) {
|
|
||||||
return {
|
|
||||||
variable: PROP_TO_ENV[property],
|
|
||||||
property,
|
|
||||||
type,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parse(value: string, type: EnvType) {
|
|
||||||
switch (type) {
|
|
||||||
case 'string':
|
|
||||||
return value;
|
|
||||||
case 'string[]':
|
|
||||||
return value
|
|
||||||
.split(',')
|
|
||||||
.filter((s) => s.length !== 0)
|
|
||||||
.map((s) => s.trim());
|
|
||||||
case 'number':
|
|
||||||
return number(value);
|
|
||||||
case 'boolean':
|
|
||||||
return boolean(value);
|
|
||||||
case 'byte':
|
|
||||||
return bytes(value);
|
|
||||||
case 'ms':
|
|
||||||
return msFn(value as StringValue);
|
|
||||||
case 'json[]':
|
|
||||||
try {
|
|
||||||
return JSON.parse(value);
|
|
||||||
} catch {
|
|
||||||
logger.error('Failed to parse JSON array', { value });
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function number(value: string) {
|
|
||||||
const num = Number(value);
|
|
||||||
if (isNaN(num)) return undefined;
|
|
||||||
|
|
||||||
return num;
|
|
||||||
}
|
|
||||||
|
|
||||||
function boolean(value: string) {
|
|
||||||
if (value === 'true') return true;
|
|
||||||
if (value === 'false') return false;
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
156
src/lib/config/read/db.ts
Normal file
156
src/lib/config/read/db.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
export const DATABASE_TO_PROP = {
|
||||||
|
coreReturnHttpsUrls: 'core.returnHttpsUrls',
|
||||||
|
coreDefaultDomain: 'core.defaultDomain',
|
||||||
|
coreTempDirectory: 'core.tempDirectory',
|
||||||
|
|
||||||
|
chunksMax: 'chunks.max',
|
||||||
|
chunksSize: 'chunks.size',
|
||||||
|
chunksEnabled: 'chunks.enabled',
|
||||||
|
|
||||||
|
tasksDeleteInterval: 'tasks.deleteInterval',
|
||||||
|
tasksClearInvitesInterval: 'tasks.clearInvitesInterval',
|
||||||
|
tasksMaxViewsInterval: 'tasks.maxViewsInterval',
|
||||||
|
tasksThumbnailsInterval: 'tasks.thumbnailsInterval',
|
||||||
|
tasksMetricsInterval: 'tasks.metricsInterval',
|
||||||
|
|
||||||
|
filesRoute: 'files.route',
|
||||||
|
filesLength: 'files.length',
|
||||||
|
filesDefaultFormat: 'files.defaultFormat',
|
||||||
|
filesDisabledExtensions: 'files.disabledExtensions',
|
||||||
|
filesMaxFileSize: 'files.maxFileSize',
|
||||||
|
filesDefaultExpiration: 'files.defaultExpiration',
|
||||||
|
filesAssumeMimetypes: 'files.assumeMimetypes',
|
||||||
|
filesDefaultDateFormat: 'files.defaultDateFormat',
|
||||||
|
filesRemoveGpsMetadata: 'files.removeGpsMetadata',
|
||||||
|
filesRandomWordsNumAdjectives: 'files.randomWordsNumAdjectives',
|
||||||
|
filesRandomWordsSeparator: 'files.randomWordsSeparator',
|
||||||
|
|
||||||
|
urlsRoute: 'urls.route',
|
||||||
|
urlsLength: 'urls.length',
|
||||||
|
|
||||||
|
featuresImageCompression: 'features.imageCompression',
|
||||||
|
featuresRobotsTxt: 'features.robotsTxt',
|
||||||
|
featuresHealthcheck: 'features.healthcheck',
|
||||||
|
featuresUserRegistration: 'features.userRegistration',
|
||||||
|
featuresOauthRegistration: 'features.oauthRegistration',
|
||||||
|
featuresDeleteOnMaxViews: 'features.deleteOnMaxViews',
|
||||||
|
|
||||||
|
featuresThumbnailsEnabled: 'features.thumbnails.enabled',
|
||||||
|
featuresThumbnailsNumberThreads: 'features.thumbnails.num_threads',
|
||||||
|
|
||||||
|
featuresMetricsEnabled: 'features.metrics.enabled',
|
||||||
|
featuresMetricsAdminOnly: 'features.metrics.adminOnly',
|
||||||
|
featuresMetricsShowUserSpecific: 'features.metrics.showUserSpecific',
|
||||||
|
|
||||||
|
featuresVersionChecking: 'features.versionChecking',
|
||||||
|
featuresVersionAPI: 'features.versionAPI',
|
||||||
|
|
||||||
|
invitesEnabled: 'invites.enabled',
|
||||||
|
invitesLength: 'invites.length',
|
||||||
|
domains: 'domains',
|
||||||
|
|
||||||
|
websiteTitle: 'website.title',
|
||||||
|
websiteTitleLogo: 'website.titleLogo',
|
||||||
|
websiteExternalLinks: 'website.externalLinks',
|
||||||
|
websiteLoginBackground: 'website.loginBackground',
|
||||||
|
websiteLoginBackgroundBlur: 'website.loginBackgroundBlur',
|
||||||
|
websiteDefaultAvatar: 'website.defaultAvatar',
|
||||||
|
websiteTos: 'website.tos',
|
||||||
|
|
||||||
|
websiteThemeDefault: 'website.theme.default',
|
||||||
|
websiteThemeDark: 'website.theme.dark',
|
||||||
|
websiteThemeLight: 'website.theme.light',
|
||||||
|
|
||||||
|
oauthBypassLocalLogin: 'oauth.bypassLocalLogin',
|
||||||
|
oauthLoginOnly: 'oauth.loginOnly',
|
||||||
|
|
||||||
|
oauthDiscordClientId: 'oauth.discord.clientId',
|
||||||
|
oauthDiscordClientSecret: 'oauth.discord.clientSecret',
|
||||||
|
oauthDiscordRedirectUri: 'oauth.discord.redirectUri',
|
||||||
|
oauthDiscordAllowedIds: 'oauth.discord.allowedIds',
|
||||||
|
oauthDiscordDeniedIds: 'oauth.discord.deniedIds',
|
||||||
|
|
||||||
|
oauthGoogleClientId: 'oauth.google.clientId',
|
||||||
|
oauthGoogleClientSecret: 'oauth.google.clientSecret',
|
||||||
|
oauthGoogleRedirectUri: 'oauth.google.redirectUri',
|
||||||
|
|
||||||
|
oauthGithubClientId: 'oauth.github.clientId',
|
||||||
|
oauthGithubClientSecret: 'oauth.github.clientSecret',
|
||||||
|
oauthGithubRedirectUri: 'oauth.github.redirectUri',
|
||||||
|
|
||||||
|
oauthOidcClientId: 'oauth.oidc.clientId',
|
||||||
|
oauthOidcClientSecret: 'oauth.oidc.clientSecret',
|
||||||
|
oauthOidcAuthorizeUrl: 'oauth.oidc.authorizeUrl',
|
||||||
|
oauthOidcUserinfoUrl: 'oauth.oidc.userinfoUrl',
|
||||||
|
oauthOidcTokenUrl: 'oauth.oidc.tokenUrl',
|
||||||
|
oauthOidcRedirectUri: 'oauth.oidc.redirectUri',
|
||||||
|
|
||||||
|
mfaTotpEnabled: 'mfa.totp.enabled',
|
||||||
|
mfaTotpIssuer: 'mfa.totp.issuer',
|
||||||
|
mfaPasskeys: 'mfa.passkeys',
|
||||||
|
|
||||||
|
ratelimitEnabled: 'ratelimit.enabled',
|
||||||
|
ratelimitMax: 'ratelimit.max',
|
||||||
|
ratelimitWindow: 'ratelimit.window',
|
||||||
|
ratelimitAdminBypass: 'ratelimit.adminBypass',
|
||||||
|
ratelimitAllowList: 'ratelimit.allowList',
|
||||||
|
|
||||||
|
httpWebhookOnUpload: 'httpWebhook.onUpload',
|
||||||
|
httpWebhookOnShorten: 'httpWebhook.onShorten',
|
||||||
|
|
||||||
|
discordWebhookUrl: 'discord.webhookUrl',
|
||||||
|
discordUsername: 'discord.username',
|
||||||
|
discordAvatarUrl: 'discord.avatarUrl',
|
||||||
|
|
||||||
|
discordOnUploadWebhookUrl: 'discord.onUpload.webhookUrl',
|
||||||
|
discordOnUploadUsername: 'discord.onUpload.username',
|
||||||
|
discordOnUploadAvatarUrl: 'discord.onUpload.avatarUrl',
|
||||||
|
discordOnUploadContent: 'discord.onUpload.content',
|
||||||
|
discordOnUploadEmbed: 'discord.onUpload.embed',
|
||||||
|
|
||||||
|
discordOnShortenWebhookUrl: 'discord.onShorten.webhookUrl',
|
||||||
|
discordOnShortenUsername: 'discord.onShorten.username',
|
||||||
|
discordOnShortenAvatarUrl: 'discord.onShorten.avatarUrl',
|
||||||
|
discordOnShortenContent: 'discord.onShorten.content',
|
||||||
|
discordOnShortenEmbed: 'discord.onShorten.embed',
|
||||||
|
|
||||||
|
pwaEnabled: 'pwa.enabled',
|
||||||
|
pwaTitle: 'pwa.title',
|
||||||
|
pwaShortName: 'pwa.shortName',
|
||||||
|
pwaDescription: 'pwa.description',
|
||||||
|
pwaThemeColor: 'pwa.themeColor',
|
||||||
|
pwaBackgroundColor: 'pwa.backgroundColor',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DatabaseToPropKey = keyof typeof DATABASE_TO_PROP;
|
||||||
|
|
||||||
|
export async function readDatabaseSettings() {
|
||||||
|
let ziplineTable = await prisma.zipline.findFirst({
|
||||||
|
omit: {
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
id: true,
|
||||||
|
firstSetup: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ziplineTable) {
|
||||||
|
ziplineTable = await prisma.zipline.create({
|
||||||
|
data: {
|
||||||
|
coreTempDirectory: join(tmpdir(), 'zipline'),
|
||||||
|
},
|
||||||
|
omit: {
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
id: true,
|
||||||
|
firstSetup: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return ziplineTable;
|
||||||
|
}
|
||||||
199
src/lib/config/read/env.ts
Normal file
199
src/lib/config/read/env.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { log } from '@/lib/logger';
|
||||||
|
import { parse } from './transform';
|
||||||
|
|
||||||
|
export type EnvType = 'string' | 'string[]' | 'number' | 'boolean' | 'byte' | 'ms' | 'json';
|
||||||
|
export function env(property: string, env: string | string[], type: EnvType, isDb: boolean = false) {
|
||||||
|
return {
|
||||||
|
variable: env,
|
||||||
|
property,
|
||||||
|
type,
|
||||||
|
isDb,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ENVS = [
|
||||||
|
env('core.port', 'CORE_PORT', 'number'),
|
||||||
|
env('core.hostname', 'CORE_HOSTNAME', 'string'),
|
||||||
|
env('core.secret', 'CORE_SECRET', 'string'),
|
||||||
|
env('core.databaseUrl', ['DATABASE_URL', 'CORE_DATABASE_URL'], 'string'),
|
||||||
|
|
||||||
|
env('datasource.type', 'DATASOURCE_TYPE', 'string'),
|
||||||
|
env('datasource.s3.accessKeyId', 'DATASOURCE_S3_ACCESS_KEY_ID', 'string'),
|
||||||
|
env('datasource.s3.secretAccessKey', 'DATASOURCE_S3_SECRET_ACCESS_KEY', 'string'),
|
||||||
|
env('datasource.s3.region', 'DATASOURCE_S3_REGION', 'string'),
|
||||||
|
env('datasource.s3.bucket', 'DATASOURCE_S3_BUCKET', 'string'),
|
||||||
|
env('datasource.s3.endpoint', 'DATASOURCE_S3_ENDPOINT', 'string'),
|
||||||
|
env('datasource.s3.forcePathStyle', 'DATASOURCE_S3_FORCE_PATH_STYLE', 'boolean'),
|
||||||
|
env('datasource.s3.subdirectory', 'DATASOURCE_S3_SUBDIRECTORY', 'string'),
|
||||||
|
|
||||||
|
env('datasource.local.directory', 'DATASOURCE_LOCAL_DIRECTORY', 'string'),
|
||||||
|
|
||||||
|
env('ssl.key', 'SSL_KEY', 'string'),
|
||||||
|
env('ssl.cert', 'SSL_CERT', 'string'),
|
||||||
|
|
||||||
|
// database stuff
|
||||||
|
env('core.returnHttpsUrls', 'CORE_RETURN_HTTPS_URLS', 'boolean', true),
|
||||||
|
env('core.defaultDomain', 'CORE_DEFAULT_DOMAIN', 'string', true),
|
||||||
|
env('core.tempDirectory', 'CORE_TEMP_DIRECTORY', 'string', true),
|
||||||
|
|
||||||
|
env('chunks.max', 'CHUNKS_MAX', 'string', true),
|
||||||
|
env('chunks.size', 'CHUNKS_SIZE', 'string', true),
|
||||||
|
env('chunks.enabled', 'CHUNKS_ENABLED', 'boolean', true),
|
||||||
|
|
||||||
|
env('tasks.deleteInterval', 'TASKS_DELETE_INTERVAL', 'string', true),
|
||||||
|
env('tasks.clearInvitesInterval', 'TASKS_CLEAR_INVITES_INTERVAL', 'string', true),
|
||||||
|
env('tasks.maxViewsInterval', 'TASKS_MAX_VIEWS_INTERVAL', 'string', true),
|
||||||
|
env('tasks.thumbnailsInterval', 'TASKS_THUMBNAILS_INTERVAL', 'string', true),
|
||||||
|
env('tasks.metricsInterval', 'TASKS_METRICS_INTERVAL', 'string', true),
|
||||||
|
|
||||||
|
env('files.route', 'FILES_ROUTE', 'string', true),
|
||||||
|
env('files.length', 'FILES_LENGTH', 'number', true),
|
||||||
|
env('files.defaultFormat', 'FILES_DEFAULT_FORMAT', 'string', true),
|
||||||
|
env('files.disabledExtensions', 'FILES_DISABLED_EXTENSIONS', 'string[]', true),
|
||||||
|
env('files.maxFileSize', 'FILES_MAX_FILE_SIZE', 'string', true),
|
||||||
|
env('files.defaultExpiration', 'FILES_DEFAULT_EXPIRATION', 'string', true),
|
||||||
|
env('files.assumeMimetypes', 'FILES_ASSUME_MIMETYPES', 'boolean', true),
|
||||||
|
env('files.defaultDateFormat', 'FILES_DEFAULT_DATE_FORMAT', 'string', true),
|
||||||
|
env('files.removeGpsMetadata', 'FILES_REMOVE_GPS_METADATA', 'boolean', true),
|
||||||
|
env('files.randomWordsNumAdjectives', 'FILES_RANDOM_WORDS_NUM_ADJECTIVES', 'number', true),
|
||||||
|
env('files.randomWordsSeparator', 'FILES_RANDOM_WORDS_SEPARATOR', 'string', true),
|
||||||
|
|
||||||
|
env('urls.route', 'URLS_ROUTE', 'string', true),
|
||||||
|
env('urls.length', 'URLS_LENGTH', 'number', true),
|
||||||
|
|
||||||
|
env('features.imageCompression', 'FEATURES_IMAGE_COMPRESSION', 'boolean', true),
|
||||||
|
env('features.robotsTxt', 'FEATURES_ROBOTS_TXT', 'boolean', true),
|
||||||
|
env('features.healthcheck', 'FEATURES_HEALTHCHECK', 'boolean', true),
|
||||||
|
env('features.userRegistration', 'FEATURES_USER_REGISTRATION', 'boolean', true),
|
||||||
|
env('features.oauthRegistration', 'FEATURES_OAUTH_REGISTRATION', 'boolean', true),
|
||||||
|
env('features.deleteOnMaxViews', 'FEATURES_DELETE_ON_MAX_VIEWS', 'boolean', true),
|
||||||
|
env('features.thumbnails.enabled', 'FEATURES_THUMBNAILS_ENABLED', 'boolean', true),
|
||||||
|
env('features.thumbnails.num_threads', 'FEATURES_THUMBNAILS_NUM_THREADS', 'number', true),
|
||||||
|
env('features.metrics.enabled', 'FEATURES_METRICS_ENABLED', 'boolean', true),
|
||||||
|
env('features.metrics.adminOnly', 'FEATURES_METRICS_ADMIN_ONLY', 'boolean', true),
|
||||||
|
env('features.metrics.showUserSpecific', 'FEATURES_METRICS_SHOW_USER_SPECIFIC', 'boolean', true),
|
||||||
|
env('features.versionChecking', 'FEATURES_VERSION_CHECKING', 'boolean', true),
|
||||||
|
env('features.versionAPI', 'FEATURES_VERSION_API', 'string', true),
|
||||||
|
|
||||||
|
env('invites.enabled', 'INVITES_ENABLED', 'boolean', true),
|
||||||
|
env('invites.length', 'INVITES_LENGTH', 'number', true),
|
||||||
|
|
||||||
|
env('website.title', 'WEBSITE_TITLE', 'string', true),
|
||||||
|
env('website.titleLogo', 'WEBSITE_TITLE_LOGO', 'string', true),
|
||||||
|
env('website.externalLinks', 'WEBSITE_EXTERNAL_LINKS', 'json', true),
|
||||||
|
env('website.loginBackground', 'WEBSITE_LOGIN_BACKGROUND', 'string', true),
|
||||||
|
env('website.loginBackgroundBlur', 'WEBSITE_LOGIN_BACKGROUND_BLUR', 'number', true),
|
||||||
|
env('website.defaultAvatar', 'WEBSITE_DEFAULT_AVATAR', 'string', true),
|
||||||
|
env('website.tos', 'WEBSITE_TOS', 'string', true),
|
||||||
|
env('website.theme.default', 'WEBSITE_THEME_DEFAULT', 'string', true),
|
||||||
|
env('website.theme.dark', 'WEBSITE_THEME_DARK', 'string', true),
|
||||||
|
env('website.theme.light', 'WEBSITE_THEME_LIGHT', 'string', true),
|
||||||
|
|
||||||
|
env('oauth.bypassLocalLogin', 'OAUTH_BYPASS_LOCAL_LOGIN', 'boolean', true),
|
||||||
|
env('oauth.loginOnly', 'OAUTH_LOGIN_ONLY', 'boolean', true),
|
||||||
|
|
||||||
|
env('oauth.discord.clientId', 'OAUTH_DISCORD_CLIENT_ID', 'string', true),
|
||||||
|
env('oauth.discord.clientSecret', 'OAUTH_DISCORD_CLIENT_SECRET', 'string', true),
|
||||||
|
env('oauth.discord.redirectUri', 'OAUTH_DISCORD_REDIRECT_URI', 'string', true),
|
||||||
|
env('oauth.discord.allowedIds', 'OAUTH_DISCORD_ALLOWED_IDS', 'string[]', true),
|
||||||
|
env('oauth.discord.deniedIds', 'OAUTH_DISCORD_DENIED_IDS', 'string[]', true),
|
||||||
|
|
||||||
|
env('oauth.google.clientId', 'OAUTH_GOOGLE_CLIENT_ID', 'string', true),
|
||||||
|
env('oauth.google.clientSecret', 'OAUTH_GOOGLE_CLIENT_SECRET', 'string', true),
|
||||||
|
env('oauth.google.redirectUri', 'OAUTH_GOOGLE_REDIRECT_URI', 'string', true),
|
||||||
|
|
||||||
|
env('oauth.github.clientId', 'OAUTH_GITHUB_CLIENT_ID', 'string', true),
|
||||||
|
env('oauth.github.clientSecret', 'OAUTH_GITHUB_CLIENT_SECRET', 'string', true),
|
||||||
|
env('oauth.github.redirectUri', 'OAUTH_GITHUB_REDIRECT_URI', 'string', true),
|
||||||
|
|
||||||
|
env('oauth.oidc.clientId', 'OAUTH_OIDC_CLIENT_ID', 'string', true),
|
||||||
|
env('oauth.oidc.clientSecret', 'OAUTH_OIDC_CLIENT_SECRET', 'string', true),
|
||||||
|
env('oauth.oidc.authorizeUrl', 'OAUTH_OIDC_AUTHORIZE_URL', 'string', true),
|
||||||
|
env('oauth.oidc.userinfoUrl', 'OAUTH_OIDC_USERINFO_URL', 'string', true),
|
||||||
|
env('oauth.oidc.tokenUrl', 'OAUTH_OIDC_TOKEN_URL', 'string', true),
|
||||||
|
env('oauth.oidc.redirectUri', 'OAUTH_OIDC_REDIRECT_URI', 'string', true),
|
||||||
|
|
||||||
|
env('mfa.totp.enabled', 'MFA_TOTP_ENABLED', 'boolean', true),
|
||||||
|
env('mfa.totp.issuer', 'MFA_TOTP_ISSUER', 'string', true),
|
||||||
|
env('mfa.passkeys', 'MFA_PASSKEYS', 'boolean', true),
|
||||||
|
|
||||||
|
env('ratelimit.enabled', 'RATELIMIT_ENABLED', 'boolean', true),
|
||||||
|
env('ratelimit.max', 'RATELIMIT_MAX', 'number', true),
|
||||||
|
env('ratelimit.window', 'RATELIMIT_WINDOW', 'number', true),
|
||||||
|
env('ratelimit.adminBypass', 'RATELIMIT_ADMIN_BYPASS', 'boolean', true),
|
||||||
|
env('ratelimit.allowList', 'RATELIMIT_ALLOW_LIST', 'string[]', true),
|
||||||
|
|
||||||
|
env('httpWebhook.onUpload', 'HTTP_WEBHOOK_ON_UPLOAD', 'string', true),
|
||||||
|
env('httpWebhook.onShorten', 'HTTP_WEBHOOK_ON_SHORTEN', 'string', true),
|
||||||
|
|
||||||
|
env('discord.webhookUrl', 'DISCORD_WEBHOOK_URL', 'string', true),
|
||||||
|
env('discord.username', 'DISCORD_USERNAME', 'string', true),
|
||||||
|
env('discord.avatarUrl', 'DISCORD_AVATAR_URL', 'string', true),
|
||||||
|
env('discord.onUpload.webhookUrl', 'DISCORD_ON_UPLOAD_WEBHOOK_URL', 'string', true),
|
||||||
|
env('discord.onUpload.username', 'DISCORD_ON_UPLOAD_USERNAME', 'string', true),
|
||||||
|
env('discord.onUpload.avatarUrl', 'DISCORD_ON_UPLOAD_AVATAR_URL', 'string', true),
|
||||||
|
env('discord.onUpload.content', 'DISCORD_ON_UPLOAD_CONTENT', 'string', true),
|
||||||
|
env('discord.onUpload.embed', 'DISCORD_ON_UPLOAD_EMBED', 'json', true),
|
||||||
|
env('discord.onShorten.webhookUrl', 'DISCORD_ON_SHORTEN_WEBHOOK_URL', 'string', true),
|
||||||
|
env('discord.onShorten.username', 'DISCORD_ON_SHORTEN_USERNAME', 'string', true),
|
||||||
|
env('discord.onShorten.avatarUrl', 'DISCORD_ON_SHORTEN_AVATAR_URL', 'string', true),
|
||||||
|
env('discord.onShorten.content', 'DISCORD_ON_SHORTEN_CONTENT', 'string', true),
|
||||||
|
env('discord.onShorten.embed', 'DISCORD_ON_SHORTEN_EMBED', 'json', true),
|
||||||
|
|
||||||
|
env('pwa.enabled', 'PWA_ENABLED', 'boolean', true),
|
||||||
|
env('pwa.title', 'PWA_TITLE', 'string', true),
|
||||||
|
env('pwa.shortName', 'PWA_SHORT_NAME', 'string', true),
|
||||||
|
env('pwa.description', 'PWA_DESCRIPTION', 'string', true),
|
||||||
|
env('pwa.backgroundColor', 'PWA_BACKGROUND_COLOR', 'string', true),
|
||||||
|
env('pwa.themeColor', 'PWA_THEME_COLOR', 'string', true),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PROP_TO_ENV: Record<string, string | string[]> = Object.fromEntries(
|
||||||
|
ENVS.map((env) => [env.property, env.variable]),
|
||||||
|
);
|
||||||
|
|
||||||
|
type EnvResult = {
|
||||||
|
env: Record<string, any>;
|
||||||
|
dbEnv: Record<string, any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function readEnv(): EnvResult {
|
||||||
|
const logger = log('config').c('readEnv');
|
||||||
|
const envResult: EnvResult = {
|
||||||
|
env: {},
|
||||||
|
dbEnv: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i !== ENVS.length; ++i) {
|
||||||
|
const env = ENVS[i];
|
||||||
|
if (Array.isArray(env.variable)) {
|
||||||
|
env.variable = env.variable.find((v) => process.env[v] !== undefined) || 'DATABASE_URL';
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = process.env[env.variable];
|
||||||
|
|
||||||
|
if (value === undefined) continue;
|
||||||
|
|
||||||
|
if (env.variable === 'DATASOURCE_TYPE') {
|
||||||
|
if (value === 's3') {
|
||||||
|
envResult.env['datasource.s3.accessKeyId'] = undefined;
|
||||||
|
envResult.env['datasource.s3.secretAccessKey'] = undefined;
|
||||||
|
envResult.env['datasource.s3.region'] = undefined;
|
||||||
|
envResult.env['datasource.s3.bucket'] = undefined;
|
||||||
|
} else if (value === 'local') {
|
||||||
|
envResult.env['datasource.local.directory'] = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parse.bind({ logger })(value, env.type);
|
||||||
|
if (parsed === undefined) continue;
|
||||||
|
|
||||||
|
if (env.isDb) {
|
||||||
|
envResult.dbEnv[env.property] = parsed;
|
||||||
|
} else {
|
||||||
|
envResult.env[env.property] = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return envResult;
|
||||||
|
}
|
||||||
188
src/lib/config/read/index.ts
Executable file
188
src/lib/config/read/index.ts
Executable file
@@ -0,0 +1,188 @@
|
|||||||
|
import { log } from '@/lib/logger';
|
||||||
|
import { DATABASE_TO_PROP, DatabaseToPropKey, readDatabaseSettings } from './db';
|
||||||
|
import { readEnv } from './env';
|
||||||
|
import { setProperty } from './transform';
|
||||||
|
|
||||||
|
export type ParsedConfig = ReturnType<typeof read>;
|
||||||
|
|
||||||
|
export const rawConfig: any = {
|
||||||
|
core: {
|
||||||
|
port: undefined,
|
||||||
|
hostname: undefined,
|
||||||
|
secret: undefined,
|
||||||
|
databaseUrl: undefined,
|
||||||
|
returnHttpsUrls: undefined,
|
||||||
|
tempDirectory: undefined,
|
||||||
|
},
|
||||||
|
chunks: {
|
||||||
|
max: undefined,
|
||||||
|
size: undefined,
|
||||||
|
enabled: undefined,
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
deleteInterval: undefined,
|
||||||
|
clearInvitesInterval: undefined,
|
||||||
|
maxViewsInterval: undefined,
|
||||||
|
thumbnailsInterval: undefined,
|
||||||
|
metricsInterval: undefined,
|
||||||
|
},
|
||||||
|
files: {
|
||||||
|
route: undefined,
|
||||||
|
length: undefined,
|
||||||
|
defaultFormat: undefined,
|
||||||
|
disabledExtensions: undefined,
|
||||||
|
maxFileSize: undefined,
|
||||||
|
defaultExpiration: undefined,
|
||||||
|
assumeMimetypes: undefined,
|
||||||
|
defaultDateFormat: undefined,
|
||||||
|
removeGpsMetadata: undefined,
|
||||||
|
randomWordsNumAdjectives: undefined,
|
||||||
|
randomWordsSeparator: undefined,
|
||||||
|
},
|
||||||
|
urls: {
|
||||||
|
route: undefined,
|
||||||
|
length: undefined,
|
||||||
|
},
|
||||||
|
datasource: {
|
||||||
|
type: undefined,
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
imageCompression: undefined,
|
||||||
|
robotsTxt: undefined,
|
||||||
|
healthcheck: undefined,
|
||||||
|
invites: undefined,
|
||||||
|
userRegistration: undefined,
|
||||||
|
oauthRegistration: undefined,
|
||||||
|
deleteOnMaxViews: undefined,
|
||||||
|
thumbnails: {
|
||||||
|
enabled: undefined,
|
||||||
|
num_threads: undefined,
|
||||||
|
},
|
||||||
|
metrics: {
|
||||||
|
enabled: undefined,
|
||||||
|
adminOnly: undefined,
|
||||||
|
showUserSpecific: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
invites: {
|
||||||
|
enabled: undefined,
|
||||||
|
length: undefined,
|
||||||
|
},
|
||||||
|
website: {
|
||||||
|
title: undefined,
|
||||||
|
titleLogo: undefined,
|
||||||
|
externalLinks: undefined,
|
||||||
|
loginBackground: undefined,
|
||||||
|
defaultAvatar: undefined,
|
||||||
|
tos: undefined,
|
||||||
|
theme: {
|
||||||
|
default: undefined,
|
||||||
|
dark: undefined,
|
||||||
|
light: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mfa: {
|
||||||
|
totp: {
|
||||||
|
enabled: undefined,
|
||||||
|
issuer: undefined,
|
||||||
|
},
|
||||||
|
passkeys: undefined,
|
||||||
|
},
|
||||||
|
oauth: {
|
||||||
|
bypassLocalLogin: undefined,
|
||||||
|
loginOnly: undefined,
|
||||||
|
discord: {
|
||||||
|
clientId: undefined,
|
||||||
|
clientSecret: undefined,
|
||||||
|
},
|
||||||
|
github: {
|
||||||
|
clientId: undefined,
|
||||||
|
clientSecret: undefined,
|
||||||
|
},
|
||||||
|
google: {
|
||||||
|
clientId: undefined,
|
||||||
|
clientSecret: undefined,
|
||||||
|
},
|
||||||
|
oidc: {
|
||||||
|
clientId: undefined,
|
||||||
|
clientSecret: undefined,
|
||||||
|
authorizeUrl: undefined,
|
||||||
|
userinfoUrl: undefined,
|
||||||
|
tokenUrl: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
discord: null,
|
||||||
|
ratelimit: {
|
||||||
|
enabled: undefined,
|
||||||
|
max: undefined,
|
||||||
|
window: undefined,
|
||||||
|
adminBypass: undefined,
|
||||||
|
allowList: undefined,
|
||||||
|
},
|
||||||
|
httpWebhook: {
|
||||||
|
onUpload: undefined,
|
||||||
|
onShorten: undefined,
|
||||||
|
},
|
||||||
|
ssl: {
|
||||||
|
key: undefined,
|
||||||
|
cert: undefined,
|
||||||
|
},
|
||||||
|
pwa: {
|
||||||
|
enabled: undefined,
|
||||||
|
title: undefined,
|
||||||
|
shortName: undefined,
|
||||||
|
description: undefined,
|
||||||
|
backgroundColor: undefined,
|
||||||
|
themeColor: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const logger = log('config').c('read');
|
||||||
|
|
||||||
|
export async function read() {
|
||||||
|
const database = (await readDatabaseSettings()) as Record<string, any>;
|
||||||
|
const { dbEnv, env } = readEnv();
|
||||||
|
|
||||||
|
if (global.__tamperedConfig__) {
|
||||||
|
global.__tamperedConfig__ = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// this overwrites database settings with provided env vars if they exist
|
||||||
|
for (const [propPath, val] of Object.entries(dbEnv)) {
|
||||||
|
const col = Object.entries(DATABASE_TO_PROP).find(([_colName, path]) => path === propPath)?.[0];
|
||||||
|
if (col) {
|
||||||
|
database[col] = val;
|
||||||
|
if (!global.__tamperedConfig__) {
|
||||||
|
global.__tamperedConfig__ = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
global.__tamperedConfig__.push(col);
|
||||||
|
logger.info('overriding database value from env', { col, value: val });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = structuredClone(rawConfig);
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(database)) {
|
||||||
|
if (value === undefined) {
|
||||||
|
logger.warn('Missing database value', { key });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!DATABASE_TO_PROP[key as DatabaseToPropKey]) continue;
|
||||||
|
if (value == undefined) continue;
|
||||||
|
|
||||||
|
setProperty(raw, DATABASE_TO_PROP[key as DatabaseToPropKey], value);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(env)) {
|
||||||
|
if (value === undefined) {
|
||||||
|
logger.warn('Missing env value', { key });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProperty(raw, key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
88
src/lib/config/read/transform.ts
Normal file
88
src/lib/config/read/transform.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { bytes } from '@/lib/bytes';
|
||||||
|
import Logger from '@/lib/logger';
|
||||||
|
import ms, { StringValue } from 'ms';
|
||||||
|
import { EnvType } from './env';
|
||||||
|
|
||||||
|
export function isObject(value: any) {
|
||||||
|
return typeof value === 'object' && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setProperty(obj: any, path: string, value: any) {
|
||||||
|
if (!isObject(obj)) return obj;
|
||||||
|
|
||||||
|
const root = obj;
|
||||||
|
const dot = path.split('.');
|
||||||
|
|
||||||
|
for (let i = 0; i !== dot.length; ++i) {
|
||||||
|
const key = dot[i];
|
||||||
|
|
||||||
|
if (i === dot.length - 1) {
|
||||||
|
obj[key] = value;
|
||||||
|
} else if (!isObject(obj[key])) {
|
||||||
|
obj[key] = typeof dot[i + 1] === 'number' ? [] : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
obj = obj[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProperty(obj: any, path: string) {
|
||||||
|
if (!isObject(obj)) return undefined;
|
||||||
|
|
||||||
|
const dot = path.split('.');
|
||||||
|
|
||||||
|
for (let i = 0; i !== dot.length; ++i) {
|
||||||
|
const key = dot[i];
|
||||||
|
|
||||||
|
if (!isObject(obj) || !(key in obj)) return undefined;
|
||||||
|
|
||||||
|
obj = obj[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parse(this: { logger: Logger }, value: string, type: EnvType) {
|
||||||
|
switch (type) {
|
||||||
|
case 'string':
|
||||||
|
return value;
|
||||||
|
case 'string[]':
|
||||||
|
return value
|
||||||
|
.split(',')
|
||||||
|
.filter((s) => s.length !== 0)
|
||||||
|
.map((s) => s.trim());
|
||||||
|
case 'number':
|
||||||
|
return number(value);
|
||||||
|
case 'boolean':
|
||||||
|
return boolean(value);
|
||||||
|
case 'byte':
|
||||||
|
return bytes(value);
|
||||||
|
case 'ms':
|
||||||
|
return ms(value as StringValue);
|
||||||
|
case 'json':
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
this.logger.error('Failed to parse JSON object', { value });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function number(value: string) {
|
||||||
|
const num = Number(value);
|
||||||
|
if (isNaN(num)) return undefined;
|
||||||
|
|
||||||
|
return num;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function boolean(value: string) {
|
||||||
|
if (value === 'true') return true;
|
||||||
|
if (value === 'false') return false;
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { Config } from './validate';
|
|||||||
|
|
||||||
export type SafeConfig = Omit<
|
export type SafeConfig = Omit<
|
||||||
Config,
|
Config,
|
||||||
'oauth' | 'datasource' | 'core' | 'discord' | 'httpWebhook' | 'ratelimit'
|
'oauth' | 'datasource' | 'core' | 'discord' | 'httpWebhook' | 'ratelimit' | 'ssl'
|
||||||
> & {
|
> & {
|
||||||
oauthEnabled: ReturnType<typeof enabled>;
|
oauthEnabled: ReturnType<typeof enabled>;
|
||||||
oauth: {
|
oauth: {
|
||||||
@@ -14,7 +14,16 @@ export type SafeConfig = Omit<
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function safeConfig(config: Config): SafeConfig {
|
export function safeConfig(config: Config): SafeConfig {
|
||||||
const { datasource: _d, core: _c, oauth, discord: _di, ratelimit: _r, httpWebhook: _h, ...rest } = config;
|
const {
|
||||||
|
datasource: _d,
|
||||||
|
core: _c,
|
||||||
|
oauth,
|
||||||
|
discord: _di,
|
||||||
|
ratelimit: _r,
|
||||||
|
httpWebhook: _h,
|
||||||
|
ssl: _s,
|
||||||
|
...rest
|
||||||
|
} = config;
|
||||||
|
|
||||||
(rest as SafeConfig).oauthEnabled = enabled(config);
|
(rest as SafeConfig).oauthEnabled = enabled(config);
|
||||||
(rest as SafeConfig).oauth = {
|
(rest as SafeConfig).oauth = {
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { tmpdir } from 'os';
|
|||||||
import { join, resolve } from 'path';
|
import { join, resolve } from 'path';
|
||||||
import { type ZodIssue, z } from 'zod';
|
import { type ZodIssue, z } from 'zod';
|
||||||
import { log } from '../logger';
|
import { log } from '../logger';
|
||||||
import { PROP_TO_ENV, ParsedConfig } from './read';
|
import { ParsedConfig } from './read';
|
||||||
|
import { PROP_TO_ENV } from './read/env';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||||
@@ -95,7 +96,7 @@ export const schema = z.object({
|
|||||||
defaultDateFormat: z.string().default('YYYY-MM-DD_HH:mm:ss'),
|
defaultDateFormat: z.string().default('YYYY-MM-DD_HH:mm:ss'),
|
||||||
removeGpsMetadata: z.boolean().default(false),
|
removeGpsMetadata: z.boolean().default(false),
|
||||||
randomWordsNumAdjectives: z.number().default(3),
|
randomWordsNumAdjectives: z.number().default(3),
|
||||||
randomWordsSeperator: z.string().default('-'),
|
randomWordsSeparator: z.string().default('-'),
|
||||||
}),
|
}),
|
||||||
urls: z.object({
|
urls: z.object({
|
||||||
route: z.string().startsWith('/').min(1).trim().toLowerCase().default('/go'),
|
route: z.string().startsWith('/').min(1).trim().toLowerCase().default('/go'),
|
||||||
@@ -112,6 +113,7 @@ export const schema = z.object({
|
|||||||
bucket: z.string(),
|
bucket: z.string(),
|
||||||
endpoint: z.string().nullable().default(null),
|
endpoint: z.string().nullable().default(null),
|
||||||
forcePathStyle: z.boolean().default(false),
|
forcePathStyle: z.boolean().default(false),
|
||||||
|
subdirectory: z.string().nullable().default(null),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
local: z
|
local: z
|
||||||
@@ -159,6 +161,8 @@ export const schema = z.object({
|
|||||||
adminOnly: z.boolean().default(false),
|
adminOnly: z.boolean().default(false),
|
||||||
showUserSpecific: z.boolean().default(true),
|
showUserSpecific: z.boolean().default(true),
|
||||||
}),
|
}),
|
||||||
|
versionChecking: z.boolean().default(true),
|
||||||
|
versionAPI: z.string().url().default('https://zipline-version.diced.sh/'),
|
||||||
}),
|
}),
|
||||||
invites: z.object({
|
invites: z.object({
|
||||||
enabled: z.boolean().default(true),
|
enabled: z.boolean().default(true),
|
||||||
@@ -218,12 +222,16 @@ export const schema = z.object({
|
|||||||
clientId: z.string(),
|
clientId: z.string(),
|
||||||
clientSecret: z.string(),
|
clientSecret: z.string(),
|
||||||
redirectUri: z.string().url().nullable().default(null),
|
redirectUri: z.string().url().nullable().default(null),
|
||||||
|
allowedIds: z.array(z.string()).default([]),
|
||||||
|
deniedIds: z.array(z.string()).default([]),
|
||||||
})
|
})
|
||||||
.or(
|
.or(
|
||||||
z.object({
|
z.object({
|
||||||
clientId: z.undefined(),
|
clientId: z.undefined(),
|
||||||
clientSecret: z.undefined(),
|
clientSecret: z.undefined(),
|
||||||
redirectUri: z.undefined(),
|
redirectUri: z.undefined(),
|
||||||
|
allowedIds: z.undefined().or(z.array(z.string()).default([])),
|
||||||
|
deniedIds: z.undefined().or(z.array(z.string()).default([])),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
github: z
|
github: z
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ export abstract class Datasource {
|
|||||||
public name: string | undefined;
|
public name: string | undefined;
|
||||||
|
|
||||||
public abstract get(file: string): null | Readable | Promise<Readable | null>;
|
public abstract get(file: string): null | Readable | Promise<Readable | null>;
|
||||||
public abstract put(file: string, data: Buffer): Promise<void>;
|
public abstract put(file: string, data: Buffer | string, options?: { mimetype?: string }): Promise<void>;
|
||||||
public abstract delete(file: string): Promise<void>;
|
public abstract delete(file: string | string[]): Promise<void>;
|
||||||
public abstract size(file: string): Promise<number>;
|
public abstract size(file: string): Promise<number>;
|
||||||
public abstract totalSize(): Promise<number>;
|
public abstract totalSize(): Promise<number>;
|
||||||
public abstract clear(): Promise<void>;
|
public abstract clear(): Promise<void>;
|
||||||
public abstract range(file: string, start: number, end: number): Promise<Readable>;
|
public abstract range(file: string, start: number, end: number): Promise<Readable>;
|
||||||
|
public abstract rename(from: string, to: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import { createReadStream, existsSync } from 'fs';
|
import { createReadStream, existsSync } from 'fs';
|
||||||
import { readdir, rm, stat, writeFile } from 'fs/promises';
|
import { access, constants, copyFile, readdir, rename, rm, stat, writeFile } from 'fs/promises';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { Readable } from 'stream';
|
import { Readable } from 'stream';
|
||||||
import { Datasource } from './Datasource';
|
import { Datasource } from './Datasource';
|
||||||
|
|
||||||
|
async function existsAndCanRW(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await access(path, constants.R_OK | constants.W_OK);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class LocalDatasource extends Datasource {
|
export class LocalDatasource extends Datasource {
|
||||||
name = 'local';
|
name = 'local';
|
||||||
|
|
||||||
@@ -20,11 +29,33 @@ export class LocalDatasource extends Datasource {
|
|||||||
return readStream;
|
return readStream;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async put(file: string, data: Buffer): Promise<void> {
|
public async put(file: string, data: Buffer | string): Promise<void> {
|
||||||
return writeFile(join(this.dir, file), data);
|
const path = join(this.dir, file);
|
||||||
|
|
||||||
|
// handles if given a path to a file, it will just move it instead of doing unecessary writes
|
||||||
|
if (typeof data === 'string' && data.startsWith('/')) {
|
||||||
|
const exists = await existsAndCanRW(data);
|
||||||
|
if (!exists)
|
||||||
|
throw new Error(
|
||||||
|
"Something went very wrong! the temporary directory wasn't readable or the file doesn't exist.",
|
||||||
|
);
|
||||||
|
|
||||||
|
await copyFile(data, path);
|
||||||
|
await rm(data);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return writeFile(path, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async delete(file: string): Promise<void> {
|
public async delete(file: string | string[]): Promise<void> {
|
||||||
|
if (Array.isArray(file)) {
|
||||||
|
await Promise.all(file.map((f) => this.delete(f)));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const path = join(this.dir, file);
|
const path = join(this.dir, file);
|
||||||
if (!existsSync(path)) return Promise.resolve();
|
if (!existsSync(path)) return Promise.resolve();
|
||||||
|
|
||||||
@@ -59,4 +90,14 @@ export class LocalDatasource extends Datasource {
|
|||||||
|
|
||||||
return readStream;
|
return readStream;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async rename(from: string, to: string): Promise<void> {
|
||||||
|
const fromPath = join(this.dir, from);
|
||||||
|
const toPath = join(this.dir, to);
|
||||||
|
|
||||||
|
if (!existsSync(fromPath))
|
||||||
|
throw new Error(`Something went very wrong! File ${from} does not exist in local datasource.`);
|
||||||
|
|
||||||
|
return rename(fromPath, toPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import { Readable } from 'stream';
|
|
||||||
import { Datasource } from './Datasource';
|
|
||||||
import {
|
import {
|
||||||
|
CopyObjectCommand,
|
||||||
DeleteObjectCommand,
|
DeleteObjectCommand,
|
||||||
DeleteObjectsCommand,
|
DeleteObjectsCommand,
|
||||||
GetObjectCommand,
|
GetObjectCommand,
|
||||||
ListBucketsCommand,
|
|
||||||
ListObjectsCommand,
|
ListObjectsCommand,
|
||||||
PutObjectCommand,
|
PutObjectCommand,
|
||||||
S3Client,
|
S3Client,
|
||||||
} from '@aws-sdk/client-s3';
|
} from '@aws-sdk/client-s3';
|
||||||
import Logger, { log } from '../logger';
|
|
||||||
import { ReadableStream } from 'stream/web';
|
|
||||||
import { NodeHttpHandler } from '@smithy/node-http-handler';
|
import { NodeHttpHandler } from '@smithy/node-http-handler';
|
||||||
|
import { createReadStream } from 'fs';
|
||||||
import { Agent as HttpAgent } from 'http';
|
import { Agent as HttpAgent } from 'http';
|
||||||
import { Agent as HttpsAgent } from 'https';
|
import { Agent as HttpsAgent } from 'https';
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
import { ReadableStream } from 'stream/web';
|
||||||
|
import Logger, { log } from '../logger';
|
||||||
|
import { randomCharacters } from '../random';
|
||||||
|
import { Datasource } from './Datasource';
|
||||||
|
|
||||||
function isOk(code: number) {
|
function isOk(code: number) {
|
||||||
return code >= 200 && code < 300;
|
return code >= 200 && code < 300;
|
||||||
@@ -32,6 +34,7 @@ export class S3Datasource extends Datasource {
|
|||||||
bucket: string;
|
bucket: string;
|
||||||
endpoint?: string | null;
|
endpoint?: string | null;
|
||||||
forcePathStyle?: boolean;
|
forcePathStyle?: boolean;
|
||||||
|
subdirectory?: string | null;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
@@ -58,37 +61,83 @@ export class S3Datasource extends Datasource {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
this.ensureBucketExists();
|
this.ensureReadWriteAccess();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureBucketExists() {
|
private key(path: string): string {
|
||||||
|
if (this.options.subdirectory) {
|
||||||
|
return this.options.subdirectory.endsWith('/')
|
||||||
|
? this.options.subdirectory + path
|
||||||
|
: this.options.subdirectory + '/' + path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureReadWriteAccess() {
|
||||||
try {
|
try {
|
||||||
const res = await this.client.send(new ListBucketsCommand());
|
const putObject = new PutObjectCommand({
|
||||||
if (!isOk(res.$metadata.httpStatusCode || 0)) {
|
Bucket: this.options.bucket,
|
||||||
|
Key: this.key(`${randomCharacters(10)}-zipline`),
|
||||||
|
Body: randomCharacters(10),
|
||||||
|
});
|
||||||
|
|
||||||
|
const readObject = new GetObjectCommand({
|
||||||
|
Bucket: this.options.bucket,
|
||||||
|
Key: putObject.input.Key,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteObject = new DeleteObjectCommand({
|
||||||
|
Bucket: this.options.bucket,
|
||||||
|
Key: putObject.input.Key,
|
||||||
|
});
|
||||||
|
|
||||||
|
const writeRes = await this.client.send(putObject);
|
||||||
|
if (!isOk(writeRes.$metadata.httpStatusCode || 0)) {
|
||||||
this.logger
|
this.logger
|
||||||
.error('there was an error while listing buckets', res.$metadata as Record<string, unknown>)
|
.error(
|
||||||
|
'there was an error while testing write access',
|
||||||
|
writeRes.$metadata as Record<string, unknown>,
|
||||||
|
)
|
||||||
.error('zipline will now exit');
|
.error('zipline will now exit');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!res.Buckets?.find((bucket) => bucket.Name === this.options.bucket)) {
|
const readRes = await this.client.send(readObject);
|
||||||
this.logger.error(`bucket ${this.options.bucket} does not exist`).error('zipline will now exit');
|
if (!isOk(readRes.$metadata.httpStatusCode || 0)) {
|
||||||
|
this.logger
|
||||||
|
.error('there was an error while testing read access', readRes.$metadata as Record<string, unknown>)
|
||||||
|
.error('zipline will now exit');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deleteRes = await this.client.send(deleteObject);
|
||||||
|
if (!isOk(deleteRes.$metadata.httpStatusCode || 0)) {
|
||||||
|
this.logger
|
||||||
|
.error(
|
||||||
|
'there was an error while testing write access',
|
||||||
|
deleteRes.$metadata as Record<string, unknown>,
|
||||||
|
)
|
||||||
|
.error('zipline will now exit');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug('access test successful');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
this.logger
|
this.logger
|
||||||
.error('there was an error while listing buckets', e as Record<string, unknown>)
|
.error('there was an error while testing access', e as Record<string, unknown>)
|
||||||
.error('zipline will now exit');
|
.error('zipline will now exit');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
} finally {
|
} finally {
|
||||||
this.logger.debug(`bucket ${this.options.bucket} exists`);
|
this.logger.debug(`able to read/write bucket ${this.options.bucket}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async get(file: string): Promise<Readable | null> {
|
public async get(file: string): Promise<Readable | null> {
|
||||||
const command = new GetObjectCommand({
|
const command = new GetObjectCommand({
|
||||||
Bucket: this.options.bucket,
|
Bucket: this.options.bucket,
|
||||||
Key: file,
|
Key: this.key(file),
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -111,13 +160,32 @@ export class S3Datasource extends Datasource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async put(file: string, data: Buffer): Promise<void> {
|
public async put(
|
||||||
const command = new PutObjectCommand({
|
file: string,
|
||||||
|
data: Buffer | string,
|
||||||
|
options: {
|
||||||
|
mimetype?: string;
|
||||||
|
} = {},
|
||||||
|
): Promise<void> {
|
||||||
|
let command = new PutObjectCommand({
|
||||||
Bucket: this.options.bucket,
|
Bucket: this.options.bucket,
|
||||||
Key: file,
|
Key: this.key(file),
|
||||||
Body: data,
|
Body: data,
|
||||||
|
...(options.mimetype ? { ContentType: options.mimetype } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
const readStream = createReadStream(data);
|
||||||
|
command = new PutObjectCommand({
|
||||||
|
Bucket: this.options.bucket,
|
||||||
|
Key: this.key(file),
|
||||||
|
Body: readStream,
|
||||||
|
...(options.mimetype ? { ContentType: options.mimetype } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.debug('putting object from stream', { file, key: this.key(file) });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await this.client.send(command);
|
const res = await this.client.send(command);
|
||||||
|
|
||||||
@@ -132,14 +200,25 @@ export class S3Datasource extends Datasource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async delete(file: string): Promise<void> {
|
public async delete(file: string | string[]): Promise<void> {
|
||||||
const command = new DeleteObjectCommand({
|
let command: DeleteObjectCommand | DeleteObjectsCommand;
|
||||||
Bucket: this.options.bucket,
|
|
||||||
Key: file,
|
if (Array.isArray(file)) {
|
||||||
});
|
command = new DeleteObjectsCommand({
|
||||||
|
Bucket: this.options.bucket,
|
||||||
|
Delete: {
|
||||||
|
Objects: file.map((f) => ({ Key: this.key(f) })),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
command = new DeleteObjectCommand({
|
||||||
|
Bucket: this.options.bucket,
|
||||||
|
Key: this.key(file),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await this.client.send(command);
|
const res = await this.client.send(command as never);
|
||||||
|
|
||||||
if (!isOk(res.$metadata.httpStatusCode || 0)) {
|
if (!isOk(res.$metadata.httpStatusCode || 0)) {
|
||||||
this.logger.error('there was an error while deleting object');
|
this.logger.error('there was an error while deleting object');
|
||||||
@@ -154,7 +233,7 @@ export class S3Datasource extends Datasource {
|
|||||||
public async size(file: string): Promise<number> {
|
public async size(file: string): Promise<number> {
|
||||||
const command = new GetObjectCommand({
|
const command = new GetObjectCommand({
|
||||||
Bucket: this.options.bucket,
|
Bucket: this.options.bucket,
|
||||||
Key: file,
|
Key: this.key(file),
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -179,6 +258,8 @@ export class S3Datasource extends Datasource {
|
|||||||
public async totalSize(): Promise<number> {
|
public async totalSize(): Promise<number> {
|
||||||
const command = new ListObjectsCommand({
|
const command = new ListObjectsCommand({
|
||||||
Bucket: this.options.bucket,
|
Bucket: this.options.bucket,
|
||||||
|
Prefix: this.options.subdirectory ?? undefined,
|
||||||
|
Delimiter: this.options.subdirectory ? undefined : '/',
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -224,7 +305,7 @@ export class S3Datasource extends Datasource {
|
|||||||
public async range(file: string, start: number, end: number): Promise<Readable> {
|
public async range(file: string, start: number, end: number): Promise<Readable> {
|
||||||
const command = new GetObjectCommand({
|
const command = new GetObjectCommand({
|
||||||
Bucket: this.options.bucket,
|
Bucket: this.options.bucket,
|
||||||
Key: file,
|
Key: this.key(file),
|
||||||
Range: `bytes=${start}-${end}`,
|
Range: `bytes=${start}-${end}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -246,4 +327,38 @@ export class S3Datasource extends Datasource {
|
|||||||
return Readable.fromWeb(new ReadableStream());
|
return Readable.fromWeb(new ReadableStream());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async rename(from: string, to: string): Promise<void> {
|
||||||
|
const copyCommand = new CopyObjectCommand({
|
||||||
|
Bucket: this.options.bucket,
|
||||||
|
Key: this.key(to),
|
||||||
|
CopySource: this.options.bucket + '/' + this.key(from),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteCommand = new DeleteObjectCommand({
|
||||||
|
Bucket: this.options.bucket,
|
||||||
|
Key: this.key(from),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const copyRes = await this.client.send(copyCommand);
|
||||||
|
if (!isOk(copyRes.$metadata.httpStatusCode || 0)) {
|
||||||
|
this.logger.error('there was an error while copying object');
|
||||||
|
this.logger.error('error metadata', copyRes.$metadata as Record<string, unknown>);
|
||||||
|
throw new Error('Failed to copy object');
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteRes = await this.client.send(deleteCommand);
|
||||||
|
if (!isOk(deleteRes.$metadata.httpStatusCode || 0)) {
|
||||||
|
this.logger.error('there was an error while deleting old object');
|
||||||
|
this.logger.error('error metadata', deleteRes.$metadata as Record<string, unknown>);
|
||||||
|
throw new Error('Failed to delete old object');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error('there was an error while renaming object');
|
||||||
|
this.logger.error('error metadata', e as Record<string, unknown>);
|
||||||
|
|
||||||
|
throw new Error('Failed to rename object');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { config } from '../config';
|
import { isMainThread } from 'worker_threads';
|
||||||
|
import { Config } from '../config/validate';
|
||||||
import { log } from '../logger';
|
import { log } from '../logger';
|
||||||
import { Datasource } from './Datasource';
|
import { Datasource } from './Datasource';
|
||||||
import { LocalDatasource } from './Local';
|
import { LocalDatasource } from './Local';
|
||||||
@@ -7,12 +8,11 @@ import { S3Datasource } from './S3';
|
|||||||
let datasource: Datasource;
|
let datasource: Datasource;
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
// eslint-disable-next-line no-var
|
|
||||||
var __datasource__: Datasource;
|
var __datasource__: Datasource;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDatasource(conf?: typeof config): void {
|
function getDatasource(config?: Config): void {
|
||||||
if (!conf) return;
|
if (!config) return;
|
||||||
|
|
||||||
const logger = log('datasource');
|
const logger = log('datasource');
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ function getDatasource(conf?: typeof config): void {
|
|||||||
bucket: config.datasource.s3!.bucket,
|
bucket: config.datasource.s3!.bucket,
|
||||||
endpoint: config.datasource.s3?.endpoint,
|
endpoint: config.datasource.s3?.endpoint,
|
||||||
forcePathStyle: config.datasource.s3?.forcePathStyle,
|
forcePathStyle: config.datasource.s3?.forcePathStyle,
|
||||||
|
subdirectory: config.datasource.s3?.subdirectory,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
@@ -38,8 +39,16 @@ function getDatasource(conf?: typeof config): void {
|
|||||||
|
|
||||||
datasource = global.__datasource__;
|
datasource = global.__datasource__;
|
||||||
|
|
||||||
if (!global.__datasource__ && !datasource) {
|
// Don't instantiate datasource if we are not in the main thread since they handle their own initialization
|
||||||
getDatasource(config);
|
if (!global.__datasource__ && !datasource && isMainThread) {
|
||||||
|
import('../config/index.js')
|
||||||
|
.then(({ config }) => {
|
||||||
|
getDatasource(config);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to initialize datasource:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export { datasource, getDatasource };
|
export { datasource, getDatasource };
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { log } from '@/lib/logger';
|
import { log } from '@/lib/logger';
|
||||||
import { Prisma, PrismaClient } from '@prisma/client';
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
import { userViewSchema } from './models/user';
|
import { Prisma, PrismaClient } from '../../../generated/client';
|
||||||
import { metricDataSchema } from './models/metric';
|
|
||||||
import { metadataSchema } from './models/incompleteFile';
|
import { metadataSchema } from './models/incompleteFile';
|
||||||
|
import { metricDataSchema } from './models/metric';
|
||||||
|
import { userViewSchema } from './models/user';
|
||||||
|
|
||||||
const building = !!process.env.ZIPLINE_BUILD;
|
const building = !!process.env.ZIPLINE_BUILD;
|
||||||
|
|
||||||
let prisma: ExtendedPrismaClient;
|
let prisma: ExtendedPrismaClient;
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
// eslint-disable-next-line no-var
|
|
||||||
var __db__: ExtendedPrismaClient;
|
var __db__: ExtendedPrismaClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +36,9 @@ function getClient() {
|
|||||||
|
|
||||||
logger.info('connecting to database ' + process.env.DATABASE_URL);
|
logger.info('connecting to database ' + process.env.DATABASE_URL);
|
||||||
|
|
||||||
|
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
|
||||||
const client = new PrismaClient({
|
const client = new PrismaClient({
|
||||||
|
adapter,
|
||||||
log: process.env.ZIPLINE_DB_LOG ? parseDbLog(process.env.ZIPLINE_DB_LOG) : undefined,
|
log: process.env.ZIPLINE_DB_LOG ? parseDbLog(process.env.ZIPLINE_DB_LOG) : undefined,
|
||||||
}).$extends({
|
}).$extends({
|
||||||
result: {
|
result: {
|
||||||
|
|||||||
6
src/lib/db/migration/index.d.ts
vendored
Normal file
6
src/lib/db/migration/index.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { SchemaContext } from '@prisma/internals';
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
declare module '@prisma/migrate/dist/utils/ensureDatabaseExists' {
|
||||||
|
export function ensureDatabaseExists(schemaContext: SchemaContext): Promise<boolean>;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user