mirror of
https://github.com/diced/zipline.git
synced 2025-12-07 05:10:43 -08:00
Compare commits
80 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
uploads*
|
||||
.env
|
||||
.eslintcache
|
||||
.eslintcache
|
||||
generated
|
||||
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
node: [20.x, 22.x, 23.x]
|
||||
node: [20.x, 22.x, 24.x]
|
||||
arch: [amd64, arm64]
|
||||
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-to: type=gha,mode=max
|
||||
provenance: false
|
||||
build-args: |
|
||||
ZIPLINE_GIT_SHA=${{ steps.sha.outputs.short_sha }}
|
||||
tags: |
|
||||
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 }}
|
||||
|
||||
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
@@ -40,6 +40,8 @@ jobs:
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: false
|
||||
build-args: |
|
||||
ZIPLINE_GIT_SHA=${{ steps.sha.outputs.short_sha }}
|
||||
tags: |
|
||||
ghcr.io/diced/zipline:trunk-${{ matrix.arch }}
|
||||
ghcr.io/diced/zipline:trunk-${{ steps.sha.outputs.short_sha }}-${{ matrix.arch }}
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -23,6 +23,7 @@
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
.idea
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
@@ -45,4 +46,8 @@ next-env.d.ts
|
||||
# zipline
|
||||
uploads*/
|
||||
*.crt
|
||||
*.key
|
||||
*.key
|
||||
generated
|
||||
|
||||
# nix dev env
|
||||
/.psql_db/
|
||||
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 PATH="$PNPM_HOME:$PATH"
|
||||
|
||||
RUN npm install -g corepack
|
||||
RUN corepack enable pnpm
|
||||
RUN corepack prepare pnpm@latest --activate
|
||||
RUN corepack enable
|
||||
|
||||
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/code.json ./code.json
|
||||
COPY --from=builder /zipline/generated ./generated
|
||||
|
||||
|
||||
RUN pnpm build:prisma
|
||||
|
||||
@@ -50,4 +50,7 @@ RUN rm -rf /tmp/* /root/*
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
ARG ZIPLINE_GIT_SHA
|
||||
ENV ZIPLINE_GIT_SHA=${ZIPLINE_GIT_SHA:-"unknown"}
|
||||
|
||||
CMD ["node", "--enable-source-maps", "build/server"]
|
||||
|
||||
26
README.md
26
README.md
@@ -77,11 +77,17 @@ services:
|
||||
environment:
|
||||
- DATABASE_URL=postgres://${POSTGRESQL_USER:-zipline}:${POSTGRESQL_PASSWORD}@postgresql:5432/${POSTGRESQL_DB:-zipline}
|
||||
depends_on:
|
||||
- postgresql
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- './uploads:/zipline/uploads'
|
||||
- './public:/zipline/public'
|
||||
- './themes:/zipline/themes'
|
||||
healthcheck:
|
||||
test: ['CMD', 'wget', '-q', '--spider', 'http://localhost:3000/api/healthcheck']
|
||||
interval: 15s
|
||||
timeout: 2s
|
||||
retries: 2
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
@@ -192,6 +198,24 @@ Create a pull request on GitHub. If your PR does not pass the action checks, the
|
||||
|
||||
Here's how to setup Zipline for development
|
||||
|
||||
#### Nix
|
||||
|
||||
If you have [Nix](https://nixos.org/) installed, you can use the provided dev shell to get started quickly. Just run:
|
||||
|
||||
```bash
|
||||
nix develop
|
||||
```
|
||||
|
||||
This will start a postgres server, and drop you into a shell with the necessary tools installed:
|
||||
|
||||
- nodejs
|
||||
- corepack
|
||||
- git
|
||||
- ffmpeg
|
||||
- postgres server
|
||||
|
||||
After hopping into the dev shell, you can follow the instructions below (skipping the prerequisites) to setup a configuration and start the server.
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
- nodejs (lts -> 20.x, 22.x)
|
||||
|
||||
10
SECURITY.md
10
SECURITY.md
@@ -2,11 +2,11 @@
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------------------------- |
|
||||
| 4.x.x | :white_check_mark: |
|
||||
| < 3 | :white_check_mark: (EOL at June 2025) |
|
||||
| < 2 | :x: |
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 4.2.x | :white_check_mark: |
|
||||
| < 3 | :x: |
|
||||
| < 2 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
|
||||
@@ -26,11 +26,17 @@ services:
|
||||
environment:
|
||||
- DATABASE_URL=postgres://${POSTGRESQL_USER:-zipline}:${POSTGRESQL_PASSWORD}@postgresql:5432/${POSTGRESQL_DB:-zipline}
|
||||
depends_on:
|
||||
- postgresql
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- './uploads:/zipline/uploads'
|
||||
- './public:/zipline/public'
|
||||
- './themes:/zipline/themes'
|
||||
healthcheck:
|
||||
test: ['CMD', 'wget', '-q', '--spider', 'http://0.0.0.0:3000/api/healthcheck']
|
||||
interval: 15s
|
||||
timeout: 2s
|
||||
retries: 2
|
||||
|
||||
volumes:
|
||||
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 typescriptEslint from '@typescript-eslint/eslint-plugin';
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
import tseslint from 'typescript-eslint';
|
||||
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 { fileURLToPath } from 'node:url';
|
||||
import js from '@eslint/js';
|
||||
import { FlatCompat } from '@eslint/eslintrc';
|
||||
import { includeIgnoreFile } from '@eslint/compat';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
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 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: {
|
||||
'unused-imports': unusedImports,
|
||||
'@typescript-eslint': typescriptEslint,
|
||||
prettier: prettier,
|
||||
'@next/next': nextConfig,
|
||||
'react-hooks': reactHooksPlugin,
|
||||
react: reactPlugin,
|
||||
'jsx-a11y': jsxA11yPlugin,
|
||||
},
|
||||
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
},
|
||||
|
||||
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: [
|
||||
'error',
|
||||
'single',
|
||||
@@ -46,7 +72,6 @@ export default [
|
||||
avoidEscape: true,
|
||||
},
|
||||
],
|
||||
|
||||
semi: ['error', 'always'],
|
||||
'jsx-quotes': ['error', 'prefer-single'],
|
||||
indent: 'off',
|
||||
@@ -77,10 +102,17 @@ export default [
|
||||
argsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-expressions': 'off',
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
next: {
|
||||
rootDir: __dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
);
|
||||
|
||||
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
84
flake.nix
Normal file
84
flake.nix
Normal file
@@ -0,0 +1,84 @@
|
||||
{
|
||||
description = "dev env for zipline";
|
||||
|
||||
inputs = {
|
||||
# node 24.4.1, postgres 17
|
||||
nixpkgs.url = "github:nixos/nixpkgs/b527e89270879aaaf584c41f26b2796be634bc9d";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
flake-utils,
|
||||
}:
|
||||
flake-utils.lib.eachDefaultSystem (
|
||||
system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
};
|
||||
|
||||
nodejs = pkgs.nodejs_24;
|
||||
postgres = pkgs.postgresql;
|
||||
psqlDir = ".psql_db/data";
|
||||
|
||||
psqlUsername = "postgres";
|
||||
psqlPassword = "postgres";
|
||||
in
|
||||
{
|
||||
devShells.default = pkgs.mkShell {
|
||||
name = "zipline";
|
||||
|
||||
buildInputs = [
|
||||
nodejs
|
||||
postgres
|
||||
|
||||
pkgs.git
|
||||
pkgs.corepack
|
||||
pkgs.ffmpeg
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
export PGDATA="$PWD/${psqlDir}"
|
||||
export PGUSER="${psqlUsername}"
|
||||
export PGPASSWORD="${psqlPassword}"
|
||||
export PGPORT=5432
|
||||
|
||||
if [ ! -d "$PGDATA" ]; then
|
||||
echo "Initializing PostgreSQL data directory at $PGDATA"
|
||||
initdb -D "$PGDATA" --username="$PGUSER" --pwfile=<(echo "$PGPASSWORD")
|
||||
fi
|
||||
|
||||
# listen on localhost
|
||||
echo "host all all 127.0.0.1/32 password" >> "$PGDATA/pg_hba.conf"
|
||||
echo "host all all ::1/128 password" >> "$PGDATA/pg_hba.conf"
|
||||
sed -i "s/^#\?listen_addresses.*/listen_addresses = 'localhost'/" "$PGDATA/postgresql.conf"
|
||||
|
||||
echo "Starting PostgreSQL..."
|
||||
pg_ctl -D "$PGDATA" -o "-p $PGPORT" -w start
|
||||
|
||||
echo -e "PostgreSQL is ready at postgresql://$PGUSER:$PGPASSWORD@localhost:$PGPORT/postgres\n\n"
|
||||
|
||||
stop_postgres() {
|
||||
echo "Stopping PostgreSQL..."
|
||||
pg_ctl -D "$PGDATA" stop
|
||||
}
|
||||
|
||||
# trap pg to stop on exiting the dev shell
|
||||
trap stop_postgres EXIT
|
||||
|
||||
# use zsh if zsh is available
|
||||
if command -v zsh >/dev/null 2>&1; then
|
||||
zsh
|
||||
else
|
||||
$SHELL
|
||||
fi
|
||||
|
||||
exit
|
||||
'';
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
127
package.json
127
package.json
@@ -2,13 +2,14 @@
|
||||
"name": "zipline",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"version": "4.0.2",
|
||||
"version": "4.2.1",
|
||||
"scripts": {
|
||||
"build": "cross-env pnpm run --stream \"/^build:.*/\"",
|
||||
"build:prisma": "prisma generate --no-hints",
|
||||
"build:next": "ZIPLINE_BUILD=true next build",
|
||||
"build:server": "tsup",
|
||||
"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",
|
||||
"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",
|
||||
@@ -17,99 +18,103 @@
|
||||
"validate:lint": "eslint --cache --fix .",
|
||||
"validate:format": "prettier --write --ignore-path .gitignore .",
|
||||
"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": {
|
||||
"@aws-sdk/client-s3": "3.726.1",
|
||||
"@aws-sdk/client-s3": "3.832.0",
|
||||
"@aws-sdk/lib-storage": "3.832.0",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^10.0.2",
|
||||
"@fastify/cors": "^11.0.1",
|
||||
"@fastify/multipart": "^9.0.3",
|
||||
"@fastify/rate-limit": "^10.2.2",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/sensible": "^6.0.3",
|
||||
"@fastify/static": "^8.1.1",
|
||||
"@fastify/static": "^8.2.0",
|
||||
"@github/webauthn-json": "^2.1.1",
|
||||
"@mantine/charts": "^7.17.0",
|
||||
"@mantine/code-highlight": "^7.17.0",
|
||||
"@mantine/core": "^7.17.0",
|
||||
"@mantine/dates": "^7.17.0",
|
||||
"@mantine/dropzone": "^7.17.0",
|
||||
"@mantine/form": "^7.17.0",
|
||||
"@mantine/hooks": "^7.17.0",
|
||||
"@mantine/modals": "^7.17.0",
|
||||
"@mantine/notifications": "^7.17.0",
|
||||
"@prisma/client": "^6.4.1",
|
||||
"@prisma/internals": "^6.4.1",
|
||||
"@prisma/migrate": "^6.4.1",
|
||||
"@smithy/node-http-handler": "^4.0.4",
|
||||
"@tabler/icons-react": "^3.30.0",
|
||||
"@xoi/gps-metadata-remover": "^1.1.2",
|
||||
"argon2": "^0.41.1",
|
||||
"@mantine/charts": "^8.1.1",
|
||||
"@mantine/code-highlight": "^8.1.1",
|
||||
"@mantine/core": "^8.1.1",
|
||||
"@mantine/dates": "^8.1.1",
|
||||
"@mantine/dropzone": "^8.1.1",
|
||||
"@mantine/form": "^8.1.1",
|
||||
"@mantine/hooks": "^8.1.1",
|
||||
"@mantine/modals": "^8.1.1",
|
||||
"@mantine/notifications": "^8.1.1",
|
||||
"@prisma/client": "^6.10.1",
|
||||
"@prisma/internals": "^6.10.1",
|
||||
"@prisma/migrate": "^6.10.1",
|
||||
"@smithy/node-http-handler": "^4.0.6",
|
||||
"@tabler/icons-react": "^3.34.0",
|
||||
"argon2": "^0.43.0",
|
||||
"bytes": "^3.1.2",
|
||||
"clsx": "^2.1.1",
|
||||
"colorette": "^2.0.20",
|
||||
"commander": "^13.1.0",
|
||||
"commander": "^14.0.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"dayjs": "^1.11.13",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"dotenv": "^16.5.0",
|
||||
"fast-glob": "^3.3.3",
|
||||
"fastify": "^5.2.1",
|
||||
"fastify": "^5.4.0",
|
||||
"fastify-plugin": "^5.0.1",
|
||||
"fflate": "^0.8.2",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"highlight.js": "^11.11.1",
|
||||
"iron-session": "^8.0.4",
|
||||
"isomorphic-dompurify": "^2.22.0",
|
||||
"katex": "^0.16.21",
|
||||
"mantine-datatable": "^7.15.1",
|
||||
"isomorphic-dompurify": "^2.25.0",
|
||||
"katex": "^0.16.22",
|
||||
"mantine-datatable": "^8.1.1",
|
||||
"ms": "^2.1.3",
|
||||
"multer": "1.4.5-lts.1",
|
||||
"next": "^15.2.4",
|
||||
"multer": "2.0.1",
|
||||
"next": "^15.3.4",
|
||||
"nuqs": "^2.4.3",
|
||||
"otplib": "^12.0.1",
|
||||
"prisma": "^6.4.1",
|
||||
"prisma": "^6.10.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.0.0-rc.1",
|
||||
"react-dom": "^19.0.0-rc.1",
|
||||
"react-markdown": "^10.0.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sharp": "^0.33.5",
|
||||
"swr": "^2.3.2",
|
||||
"zod": "^3.24.2",
|
||||
"zustand": "^5.0.3"
|
||||
"sharp": "^0.34.2",
|
||||
"swr": "^2.3.3",
|
||||
"typescript-eslint": "^8.34.1",
|
||||
"zod": "^3.25.67",
|
||||
"zustand": "^5.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.2.7",
|
||||
"@eslint/eslintrc": "^3.3.0",
|
||||
"@eslint/js": "^9.21.0",
|
||||
"@next/eslint-plugin-next": "^15.3.4",
|
||||
"@types/bytes": "^3.1.5",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/fluent-ffmpeg": "^2.1.27",
|
||||
"@types/katex": "^0.16.7",
|
||||
"@types/ms": "^2.1.0",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.13.5",
|
||||
"@types/multer": "^1.4.13",
|
||||
"@types/node": "^24.0.3",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@typescript-eslint/eslint-plugin": "^8.24.1",
|
||||
"@typescript-eslint/parser": "^8.24.1",
|
||||
"eslint": "^9.21.0",
|
||||
"eslint-config-next": "^15.1.7",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.3",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint-config-next": "^15.3.4",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-prettier": "^5.5.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"postcss": "^8.5.3",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"prettier": "^3.5.2",
|
||||
"sass": "^1.86.1",
|
||||
"tsc-alias": "^1.8.10",
|
||||
"tsup": "^8.3.6",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.7.3"
|
||||
"prettier": "^3.5.3",
|
||||
"sass": "^1.89.2",
|
||||
"tsc-alias": "^1.8.16",
|
||||
"tsup": "^8.5.0",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"packageManager": "pnpm@10.2.0"
|
||||
"packageManager": "pnpm@10.12.1"
|
||||
}
|
||||
|
||||
6228
pnpm-lock.yaml
generated
6228
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
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
provider = "postgresql"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
output = "../generated/client"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
@@ -57,6 +58,9 @@ model Zipline {
|
||||
featuresMetricsAdminOnly Boolean @default(false)
|
||||
featuresMetricsShowUserSpecific Boolean @default(true)
|
||||
|
||||
featuresVersionChecking Boolean @default(true)
|
||||
featuresVersionAPI String @default("https://zipline-version.diced.sh")
|
||||
|
||||
invitesEnabled Boolean @default(true)
|
||||
invitesLength Int @default(6)
|
||||
|
||||
@@ -78,6 +82,8 @@ model Zipline {
|
||||
oauthDiscordClientId String?
|
||||
oauthDiscordClientSecret String?
|
||||
oauthDiscordRedirectUri String?
|
||||
oauthDiscordAllowedIds String[] @default([])
|
||||
oauthDiscordDeniedIds String[] @default([])
|
||||
|
||||
oauthGoogleClientId String?
|
||||
oauthGoogleClientSecret String?
|
||||
@@ -129,6 +135,8 @@ model Zipline {
|
||||
pwaDescription String @default("Zipline")
|
||||
pwaThemeColor String @default("#000000")
|
||||
pwaBackgroundColor String @default("#000000")
|
||||
|
||||
domains String[] @default([])
|
||||
}
|
||||
|
||||
model User {
|
||||
|
||||
@@ -48,6 +48,7 @@ import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import ConfigProvider from './ConfigProvider';
|
||||
import VersionBadge from './VersionBadge';
|
||||
|
||||
type NavLinks = {
|
||||
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'>
|
||||
<Box>
|
||||
{config.website.externalLinks.map(({ name, url }, i) => (
|
||||
<NavLink
|
||||
key={i}
|
||||
label={name}
|
||||
leftSection={<IconExternalLink size='1rem' />}
|
||||
variant='light'
|
||||
component={Link}
|
||||
href={url}
|
||||
target='_blank'
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</ScrollArea>
|
||||
<Divider />
|
||||
|
||||
<ScrollArea mah='auto'>
|
||||
<Box>
|
||||
{config.website.externalLinks.map(({ name, url }, i) => (
|
||||
<NavLink
|
||||
key={i}
|
||||
label={name}
|
||||
leftSection={<IconExternalLink size='1rem' />}
|
||||
variant='light'
|
||||
component={Link}
|
||||
href={url}
|
||||
target='_blank'
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</AppShell.Navbar>
|
||||
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
Modal,
|
||||
Pill,
|
||||
PillsInput,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Text,
|
||||
Title,
|
||||
@@ -61,8 +60,8 @@ import {
|
||||
removeFromFolder,
|
||||
viewFile,
|
||||
} from '../actions';
|
||||
import FileStat from './FileStat';
|
||||
import EditFileDetailsModal from './EditFileDetailsModal';
|
||||
import FileStat from './FileStat';
|
||||
|
||||
function ActionButton({
|
||||
Icon,
|
||||
@@ -189,9 +188,9 @@ export default function FileModal({
|
||||
</Text>
|
||||
}
|
||||
size='auto'
|
||||
maw='90vw'
|
||||
centered
|
||||
zIndex={200}
|
||||
scrollAreaComponent={ScrollArea.Autosize}
|
||||
>
|
||||
{file ? (
|
||||
<>
|
||||
@@ -305,7 +304,8 @@ export default function FileModal({
|
||||
onClick={() => removeFromFolder(file)}
|
||||
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>
|
||||
) : (
|
||||
@@ -337,18 +337,21 @@ export default function FileModal({
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options>
|
||||
{folders
|
||||
?.filter((f) => f.name.toLowerCase().includes(search.toLowerCase().trim()))
|
||||
.map((f) => (
|
||||
?.filter((f: { name: string }) =>
|
||||
f.name.toLowerCase().includes(search.toLowerCase().trim()),
|
||||
)
|
||||
.map((f: { name: string; id: string }) => (
|
||||
<Combobox.Option value={f.id} key={f.id}>
|
||||
{f.name}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
|
||||
{!folders?.some((f) => f.name === search) && search.trim().length > 0 && (
|
||||
<Combobox.Option value='$create'>
|
||||
+ Create folder "{search}"
|
||||
</Combobox.Option>
|
||||
)}
|
||||
{!folders?.some((f: { name: string }) => f.name === search) &&
|
||||
search.trim().length > 0 && (
|
||||
<Combobox.Option value='$create'>
|
||||
+ Create folder "{search}"
|
||||
</Combobox.Option>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
|
||||
@@ -11,10 +11,11 @@ import {
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
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 Render from '../render/Render';
|
||||
import fileIcon from './fileIcon';
|
||||
import { parseAsStringLiteral, useQueryState } from 'nuqs';
|
||||
|
||||
function PlaceholderContent({ text, Icon }: { text: string; Icon: Icon }) {
|
||||
return (
|
||||
@@ -29,7 +30,7 @@ function PlaceholderContent({ text, Icon }: { text: string; Icon: Icon }) {
|
||||
|
||||
function Placeholder({ text, Icon, ...props }: { text: string; Icon: Icon; onClick?: () => void }) {
|
||||
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} />
|
||||
</Center>
|
||||
);
|
||||
@@ -73,69 +74,82 @@ export default function DashboardFileType({
|
||||
}: {
|
||||
file: DbFile | File;
|
||||
show?: boolean;
|
||||
password?: string;
|
||||
password?: string | null;
|
||||
code?: boolean;
|
||||
allowZoom?: boolean;
|
||||
}) {
|
||||
const [overrideType] = useQueryState('otype', parseAsStringLiteral(['video', 'audio', 'image', 'text']));
|
||||
|
||||
const disableMediaPreview = useSettingsStore((state) => state.settings.disableMediaPreview);
|
||||
|
||||
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 [type, setType] = useState<string>(file.type.split('/')[0]);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const gettext = async () => {
|
||||
if (!dbFile) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if ((reader.result! as string).length > 1 * 1024 * 1024) {
|
||||
setFileContent(
|
||||
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);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
const getText = useCallback(async () => {
|
||||
try {
|
||||
if (!dbFile) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if ((reader.result! as string).length > 1 * 1024 * 1024) {
|
||||
setFileContent(
|
||||
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);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 1 * 1024 * 1024) {
|
||||
const res = await fetch(`/raw/${file.name}${password ? `?pw=${password}` : ''}`, {
|
||||
headers: {
|
||||
Range: 'bytes=0-' + 1 * 1024 * 1024, // 0 mb to 1 mb
|
||||
},
|
||||
});
|
||||
if (file.size > 1 * 1024 * 1024) {
|
||||
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();
|
||||
setFileContent(
|
||||
text + '\n...\nThe file is too big to display click the download icon to view/download it.',
|
||||
);
|
||||
return;
|
||||
setFileContent(text);
|
||||
} catch {
|
||||
setFileContent('Error loading file.');
|
||||
}
|
||||
|
||||
const res = await fetch(`/raw/${file.name}${password ? `?pw=${password}` : ''}`);
|
||||
const text = await res.text();
|
||||
|
||||
setFileContent(text);
|
||||
};
|
||||
}, [dbFile, file, password]);
|
||||
|
||||
useEffect(() => {
|
||||
if (code) {
|
||||
setType('text');
|
||||
gettext();
|
||||
} else if (type === 'text') {
|
||||
gettext();
|
||||
getText();
|
||||
} else if (overrideType === 'text' || type === 'text') {
|
||||
getText();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = 'auto';
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
if (disableMediaPreview && !show)
|
||||
return <Placeholder text={`Click to view file ${file.name}`} Icon={fileIcon(file.type)} />;
|
||||
|
||||
@@ -153,7 +167,7 @@ export default function DashboardFileType({
|
||||
</Paper>
|
||||
);
|
||||
|
||||
switch (type) {
|
||||
switch (overrideType || type) {
|
||||
case 'video':
|
||||
return show ? (
|
||||
<video
|
||||
@@ -162,11 +176,14 @@ export default function DashboardFileType({
|
||||
muted
|
||||
controls
|
||||
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 ? (
|
||||
<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
|
||||
pos='absolute'
|
||||
@@ -191,14 +208,12 @@ export default function DashboardFileType({
|
||||
return show ? (
|
||||
<Center>
|
||||
<MantineImage
|
||||
mah={400}
|
||||
src={dbFile ? `/raw/${file.name}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
|
||||
alt={file.name}
|
||||
alt={file.name || 'Image'}
|
||||
style={{
|
||||
maxWidth: '90vw',
|
||||
maxHeight: '90vh',
|
||||
cursor: allowZoom ? 'zoom-in' : 'default',
|
||||
width: 'auto',
|
||||
maxWidth: '70vw',
|
||||
maxHeight: '70vw',
|
||||
}}
|
||||
onClick={() => setOpen(true)}
|
||||
/>
|
||||
@@ -208,10 +223,10 @@ export default function DashboardFileType({
|
||||
src={
|
||||
dbFile ? `/raw/${file.name}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)
|
||||
}
|
||||
alt={file.name}
|
||||
alt={file.name || 'Image'}
|
||||
style={{
|
||||
maxWidth: '90vw',
|
||||
maxHeight: '90vh',
|
||||
maxWidth: '95vw',
|
||||
maxHeight: '95vh',
|
||||
objectFit: 'contain',
|
||||
cursor: 'zoom-out',
|
||||
width: 'auto',
|
||||
@@ -225,7 +240,7 @@ export default function DashboardFileType({
|
||||
fit='contain'
|
||||
mah={400}
|
||||
src={dbFile ? `/raw/${file.name}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
|
||||
alt={file.name}
|
||||
alt={file.name || 'Image'}
|
||||
/>
|
||||
);
|
||||
case 'audio':
|
||||
|
||||
@@ -3,10 +3,10 @@ import { IncompleteFile } from '@/lib/db/models/incompleteFile';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { ActionIcon, Badge, Button, Card, Group, Modal, Paper, Stack, Text, Tooltip } from '@mantine/core';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IncompleteFileStatus } from '@prisma/client';
|
||||
import { IncompleteFileStatus } from '../../../../generated/client';
|
||||
import { IconFileDots, IconTrashFilled } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { ReactNode, useEffect, useState } from 'react';
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||
import { ReactNode } from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const badgeMap: Record<IncompleteFileStatus, ReactNode> = {
|
||||
@@ -33,9 +33,7 @@ const badgeMap: Record<IncompleteFileStatus, ReactNode> = {
|
||||
};
|
||||
|
||||
export default function PendingFilesButton() {
|
||||
const router = useRouter();
|
||||
|
||||
const [open, setOpen] = useState(router.query.pending !== undefined);
|
||||
const [open, setOpen] = useQueryState('popen', parseAsBoolean.withDefault(false));
|
||||
|
||||
const { data: incompleteFiles, mutate } = useSWR<
|
||||
Extract<IncompleteFile[], Response['/api/user/files/incomplete']>
|
||||
@@ -68,15 +66,6 @@ export default function PendingFilesButton() {
|
||||
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 (
|
||||
<>
|
||||
<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' />,
|
||||
id: 'bulk-delete',
|
||||
autoClose: true,
|
||||
loading: false,
|
||||
});
|
||||
} else if (data) {
|
||||
notifications.update({
|
||||
@@ -107,6 +108,7 @@ export async function bulkFavorite(ids: string[]) {
|
||||
icon: <IconStarsOff size='1rem' />,
|
||||
id: 'bulk-favorite',
|
||||
autoClose: true,
|
||||
loading: false,
|
||||
});
|
||||
} else if (data) {
|
||||
notifications.update({
|
||||
@@ -116,6 +118,7 @@ export async function bulkFavorite(ids: string[]) {
|
||||
icon: <IconStarsFilled size='1rem' />,
|
||||
id: 'bulk-favorite',
|
||||
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();
|
||||
|
||||
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>>(
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Tag } from '@/lib/db/models/tag';
|
||||
import { Pill, isLightColor } from '@mantine/core';
|
||||
|
||||
export default function TagPill({
|
||||
tag,
|
||||
...other
|
||||
}: {
|
||||
tag: Tag | null;
|
||||
tag: { color: string; name: string } | null;
|
||||
withRemoveButton?: boolean;
|
||||
onRemove?: () => void;
|
||||
}) {
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import { mutateFiles } from '@/components/file/actions';
|
||||
import { Response } from '@/lib/api/response';
|
||||
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 { ActionIcon, Group, Modal, Paper, Stack, Text, Title, Tooltip } from '@mantine/core';
|
||||
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 EditTagModal from './EditTagModal';
|
||||
import { mutateFiles } from '@/components/file/actions';
|
||||
import TagPill from './TagPill';
|
||||
|
||||
export default function TagsButton() {
|
||||
const router = useRouter();
|
||||
|
||||
const [open, setOpen] = useState(router.query.tags !== undefined);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [open, setOpen] = useQueryState('topen', parseAsBoolean.withDefault(false));
|
||||
const [createModalOpen, setCreateModalOpen] = useQueryState('ctopen', parseAsBoolean.withDefault(false));
|
||||
const [selectedTag, setSelectedTag] = useState<Tag | null>(null);
|
||||
|
||||
const { data: tags, mutate } = useSWR<Extract<Tag[], Response['/api/user/tags']>>('/api/user/tags');
|
||||
@@ -44,15 +42,6 @@ export default function TagsButton() {
|
||||
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 (
|
||||
<>
|
||||
<CreateTagModal open={createModalOpen} onClose={() => setCreateModalOpen(false)} />
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import useSWR from 'swr';
|
||||
|
||||
type ApiPaginationOptions = {
|
||||
@@ -7,7 +6,17 @@ type ApiPaginationOptions = {
|
||||
filter?: string;
|
||||
perpage?: number;
|
||||
favorite?: boolean;
|
||||
sort?: keyof Prisma.FileOrderByWithAggregationInput;
|
||||
sort?:
|
||||
| 'name'
|
||||
| 'id'
|
||||
| 'createdAt'
|
||||
| 'updatedAt'
|
||||
| 'deletesAt'
|
||||
| 'originalName'
|
||||
| 'size'
|
||||
| 'type'
|
||||
| 'views'
|
||||
| 'favorite';
|
||||
order?: 'asc' | 'desc';
|
||||
id?: string;
|
||||
search?: {
|
||||
|
||||
@@ -12,47 +12,24 @@ import {
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
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 Link from 'next/link';
|
||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||
import { useApiPagination } from '../useApiPagination';
|
||||
|
||||
const DashboardFile = dynamic(() => import('@/components/file/DashboardFile'), {
|
||||
loading: () => <Skeleton height={350} animate />,
|
||||
});
|
||||
|
||||
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({
|
||||
page,
|
||||
favorite: true,
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import RelativeDate from '@/components/RelativeDate';
|
||||
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 { bytes } from '@/lib/bytes';
|
||||
import { type File } from '@/lib/db/models/file';
|
||||
@@ -28,9 +28,9 @@ import {
|
||||
useCombobox,
|
||||
} from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import {
|
||||
IconCopy,
|
||||
IconDownload,
|
||||
IconExternalLink,
|
||||
IconFile,
|
||||
IconGridPatternFilled,
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
} from '@tabler/icons-react';
|
||||
import { DataTable } from 'mantine-datatable';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { parseAsBoolean, parseAsInteger, parseAsStringLiteral, useQueryState } from 'nuqs';
|
||||
import { useEffect, useReducer, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { bulkDelete, bulkFavorite } from '../bulk';
|
||||
@@ -179,7 +179,6 @@ function TagsFilter({
|
||||
}
|
||||
|
||||
export default function FileTable({ id }: { id?: string }) {
|
||||
const router = useRouter();
|
||||
const clipboard = useClipboard();
|
||||
const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion);
|
||||
|
||||
@@ -187,13 +186,30 @@ export default function FileTable({ id }: { id?: string }) {
|
||||
'/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 [sort, setSort] = useState<keyof Prisma.FileOrderByWithAggregationInput>('createdAt');
|
||||
const [order, setOrder] = useState<'asc' | 'desc'>('desc');
|
||||
const [sort, setSort] = useQueryState(
|
||||
'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 [idSearchOpen, setIdSearchOpen] = useState(false);
|
||||
const [idSearchOpen, setIdSearchOpen] = useQueryState('idsearch', parseAsBoolean.withDefault(false));
|
||||
const [searchField, setSearchField] = useState<'name' | 'originalName' | 'type' | 'tags' | 'id'>('name');
|
||||
const [searchQuery, setSearchQuery] = useReducer(
|
||||
(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(() => {
|
||||
if (data && selectedFile) {
|
||||
const file = data.page.find((x) => x.id === selectedFile.id);
|
||||
@@ -304,9 +307,8 @@ export default function FileTable({ id }: { id?: string }) {
|
||||
onClick={() => {
|
||||
setIdSearchOpen((open) => !open);
|
||||
}}
|
||||
color='blue'
|
||||
// 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' />
|
||||
</ActionIcon>
|
||||
@@ -519,6 +521,18 @@ export default function FileTable({ id }: { id?: string }) {
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label='Download file'>
|
||||
<ActionIcon
|
||||
color='gray'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
downloadFile(file);
|
||||
}}
|
||||
>
|
||||
<IconDownload size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label='Delete file'>
|
||||
<ActionIcon
|
||||
color='red'
|
||||
@@ -546,7 +560,7 @@ export default function FileTable({ id }: { id?: string }) {
|
||||
direction: order,
|
||||
}}
|
||||
onSortStatusChange={(data) => {
|
||||
setSort(data.columnAccessor as keyof Prisma.FileOrderByWithAggregationInput);
|
||||
setSort(data.columnAccessor as any);
|
||||
setOrder(data.direction);
|
||||
}}
|
||||
onCellClick={({ record }) => setSelectedFile(record)}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { IconFileUpload, IconFilesOff } from '@tabler/icons-react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useApiPagination } from '../useApiPagination';
|
||||
|
||||
@@ -25,9 +25,7 @@ const DashboardFile = dynamic(() => import('@/components/file/DashboardFile'), {
|
||||
const PER_PAGE_OPTIONS = [9, 12, 15, 30, 45];
|
||||
|
||||
export default function Files({ id }: { id?: string }) {
|
||||
const router = useRouter();
|
||||
|
||||
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>(15);
|
||||
const [cachedPages, setCachedPages] = useState<number>(1);
|
||||
|
||||
@@ -43,19 +41,6 @@ export default function Files({ id }: { id?: string }) {
|
||||
}
|
||||
}, [data?.pages]);
|
||||
|
||||
useEffect(() => {
|
||||
router.replace(
|
||||
{
|
||||
query: {
|
||||
...router.query,
|
||||
page: page,
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true },
|
||||
);
|
||||
}, [page]);
|
||||
|
||||
const from = (page - 1) * perpage + 1;
|
||||
const to = Math.min(page * perpage, data?.total ?? 0);
|
||||
const totalRecords = data?.total ?? 0;
|
||||
|
||||
@@ -13,35 +13,17 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { IconFileUpload, IconFilesOff } from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||
import { useApiPagination } from '../files/useApiPagination';
|
||||
|
||||
export default function FavoriteFiles() {
|
||||
const router = useRouter();
|
||||
|
||||
const [page, setPage] = useState<number>(
|
||||
router.query.favoritePage ? parseInt(router.query.favoritePage as string) : 1,
|
||||
);
|
||||
const [page, setPage] = useQueryState('fpage', parseAsInteger.withDefault(1));
|
||||
const { data, isLoading } = useApiPagination({
|
||||
page,
|
||||
favorite: true,
|
||||
filter: 'dashboard',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
router.replace(
|
||||
{
|
||||
query: {
|
||||
...router.query,
|
||||
favoritePage: page,
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true },
|
||||
);
|
||||
}, [page]);
|
||||
|
||||
if (!isLoading && data?.page.length === 0) return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ActionIcon, Button, Group, Modal, Stack, Switch, TextInput, Title, Tool
|
||||
import { useForm } from '@mantine/form';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconFolderPlus, IconPlus } from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||
import { mutate } from 'swr';
|
||||
import FolderGridView from './views/FolderGridView';
|
||||
import FolderTableView from './views/FolderTableView';
|
||||
@@ -15,7 +15,7 @@ import FolderTableView from './views/FolderTableView';
|
||||
export default function DashboardFolders() {
|
||||
const view = useViewStore((state) => state.folders);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [open, setOpen] = useQueryState('cfopen', parseAsBoolean.withDefault(false));
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
|
||||
@@ -2,7 +2,6 @@ import GridTableSwitcher from '@/components/GridTableSwitcher';
|
||||
import { useViewStore } from '@/lib/store/view';
|
||||
import { ActionIcon, Button, Group, Modal, NumberInput, Select, Stack, Title, Tooltip } from '@mantine/core';
|
||||
import { IconPlus, IconTagOff } from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
import InviteGridView from './views/InviteGridView';
|
||||
import InviteTableView from './views/InviteTableView';
|
||||
import { useForm } from '@mantine/form';
|
||||
@@ -11,10 +10,11 @@ import { Response } from '@/lib/api/response';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { Invite } from '@/lib/db/models/invite';
|
||||
import { mutate } from 'swr';
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||
|
||||
export default function DashboardInvites() {
|
||||
const view = useViewStore((state) => state.invites);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [open, setOpen] = useQueryState('ciopen', parseAsBoolean.withDefault(false));
|
||||
|
||||
const form = useForm<{
|
||||
maxUses: number | '';
|
||||
|
||||
@@ -7,6 +7,7 @@ import FilesUrlsCountGraph from './parts/FilesUrlsCountGraph';
|
||||
import { useApiStats } from './useStats';
|
||||
import { StatsCardsSkeleton } from './parts/StatsCards';
|
||||
import { StatsTablesSkeleton } from './parts/StatsTables';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const StatsCards = dynamic(() => import('./parts/StatsCards'));
|
||||
const StatsTables = dynamic(() => import('./parts/StatsTables'));
|
||||
@@ -14,21 +15,23 @@ const StorageGraph = dynamic(() => import('./parts/StorageGraph'));
|
||||
const ViewsGraph = dynamic(() => import('./parts/ViewsGraph'));
|
||||
|
||||
export default function DashboardMetrics() {
|
||||
const [dateRange, setDateRange] = useState<[Date | null, Date | null]>([
|
||||
new Date(Date.now() - 86400000 * 7),
|
||||
new Date(),
|
||||
]); // default: [7 days ago, now]
|
||||
const today = dayjs();
|
||||
|
||||
const [dateRange, setDateRange] = useState<[string | null, string | null]>([
|
||||
today.subtract(7, 'day').toISOString(),
|
||||
today.toISOString(),
|
||||
]);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [allTime, setAllTime] = useState(false);
|
||||
|
||||
const { data, isLoading } = useApiStats({
|
||||
from: dateRange[0]?.toISOString() ?? undefined,
|
||||
to: dateRange[1]?.toISOString() ?? undefined,
|
||||
from: allTime || !dateRange[0] ? undefined : new Date(dateRange[0]).toISOString(),
|
||||
to: allTime || !dateRange[1] ? undefined : new Date(dateRange[1]).toISOString(),
|
||||
all: allTime,
|
||||
});
|
||||
|
||||
const handleDateChange = (value: [Date | null, Date | null]) => {
|
||||
const handleDateChange = (value: [string | null, string | null]) => {
|
||||
setAllTime(false);
|
||||
setDateRange(value);
|
||||
};
|
||||
@@ -40,17 +43,49 @@ export default function DashboardMetrics() {
|
||||
return (
|
||||
<>
|
||||
<Modal title='Change range' opened={open} onClose={() => setOpen(false)} size='auto'>
|
||||
<Paper withBorder>
|
||||
<Paper withBorder style={{ minHeight: 300 }}>
|
||||
<DatePicker
|
||||
type='range'
|
||||
value={dateRange}
|
||||
onChange={handleDateChange}
|
||||
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>
|
||||
|
||||
<Group mt='md'>
|
||||
<Group mt='lg'>
|
||||
<Button fullWidth onClick={() => setOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
@@ -69,25 +104,14 @@ export default function DashboardMetrics() {
|
||||
</Button>
|
||||
{!allTime ? (
|
||||
<Text size='sm' c='dimmed'>
|
||||
{data?.length ? (
|
||||
<>
|
||||
{new Date(data?.[0]?.createdAt).toLocaleDateString()}
|
||||
{' to '}
|
||||
{new Date(data?.[data.length - 1]?.createdAt).toLocaleDateString()}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{dateRange[0]?.toLocaleDateString()}{' '}
|
||||
{dateRange[1] ? `to ${dateRange[1]?.toLocaleDateString()}` : ''}
|
||||
</>
|
||||
)}
|
||||
{dateRange[0] ? new Date(dateRange[0]).toLocaleDateString() : '—'}
|
||||
{dateRange[1] ? ` to ${new Date(dateRange[1]).toLocaleDateString()}` : ''}
|
||||
</Text>
|
||||
) : (
|
||||
<Text size='sm' c='dimmed'>
|
||||
All Time
|
||||
</Text>
|
||||
)}
|
||||
{/* <Tooltip label='This may take longer than usual to load.'> */}
|
||||
<Tooltip
|
||||
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 ? (
|
||||
<div>
|
||||
<StatsCardsSkeleton />
|
||||
|
||||
<StatsTablesSkeleton />
|
||||
</div>
|
||||
) : data?.length ? (
|
||||
<div>
|
||||
<StatsCards data={data!} />
|
||||
|
||||
<StatsTables data={data!} />
|
||||
|
||||
<StatsCards data={data} />
|
||||
<StatsTables data={data} />
|
||||
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }}>
|
||||
<FilesUrlsCountGraph metrics={data!} />
|
||||
<ViewsGraph metrics={data!} />
|
||||
<FilesUrlsCountGraph metrics={data} />
|
||||
<ViewsGraph metrics={data} />
|
||||
</SimpleGrid>
|
||||
|
||||
<div>
|
||||
<StorageGraph metrics={data!} />
|
||||
<StorageGraph metrics={data} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,57 +1,60 @@
|
||||
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 dynamic from 'next/dynamic';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import Domains from './parts/Domains';
|
||||
|
||||
function SettingsSkeleton() {
|
||||
return <Skeleton height={280} animate />;
|
||||
}
|
||||
|
||||
const ServerSettingsCore = dynamic(() => import('./parts/ServerSettingsCore'), {
|
||||
const Core = dynamic(() => import('./parts/Core'), {
|
||||
loading: () => <SettingsSkeleton />,
|
||||
});
|
||||
const ServerSettingsChunks = dynamic(() => import('./parts/ServerSettingsChunks'), {
|
||||
const Chunks = dynamic(() => import('./parts/Chunks'), {
|
||||
loading: () => <SettingsSkeleton />,
|
||||
});
|
||||
const ServerSettingsDiscord = dynamic(() => import('./parts/ServerSettingsDiscord'), {
|
||||
const Discord = dynamic(() => import('./parts/Discord'), {
|
||||
loading: () => <SettingsSkeleton />,
|
||||
});
|
||||
const ServerSettingsFeatures = dynamic(() => import('./parts/ServerSettingsFeatures'), {
|
||||
const Features = dynamic(() => import('./parts/Features'), {
|
||||
loading: () => <SettingsSkeleton />,
|
||||
});
|
||||
const ServerSettingsFiles = dynamic(() => import('./parts/ServerSettingsFiles'), {
|
||||
const Files = dynamic(() => import('./parts/Files'), {
|
||||
loading: () => <SettingsSkeleton />,
|
||||
});
|
||||
const ServerSettingsHttpWebhook = dynamic(() => import('./parts/ServerSettingsHttpWebhook'), {
|
||||
const HttpWebhook = dynamic(() => import('./parts/HttpWebhook'), {
|
||||
loading: () => <SettingsSkeleton />,
|
||||
});
|
||||
const ServerSettingsInvites = dynamic(() => import('./parts/ServerSettingsInvites'), {
|
||||
const Invites = dynamic(() => import('./parts/Invites'), {
|
||||
loading: () => <SettingsSkeleton />,
|
||||
});
|
||||
const ServerSettingsMfa = dynamic(() => import('./parts/ServerSettingsMfa'), {
|
||||
const Mfa = dynamic(() => import('./parts/Mfa'), {
|
||||
loading: () => <SettingsSkeleton />,
|
||||
});
|
||||
const ServerSettingsOauth = dynamic(() => import('./parts/ServerSettingsOauth'), {
|
||||
const Oauth = dynamic(() => import('./parts/Oauth'), {
|
||||
loading: () => <SettingsSkeleton />,
|
||||
});
|
||||
const ServerSettingsRatelimit = dynamic(() => import('./parts/ServerSettingsRatelimit'), {
|
||||
const Ratelimit = dynamic(() => import('./parts/Ratelimit'), {
|
||||
loading: () => <SettingsSkeleton />,
|
||||
});
|
||||
const ServerSettingsTasks = dynamic(() => import('./parts/ServerSettingsTasks'), {
|
||||
const Tasks = dynamic(() => import('./parts/Tasks'), {
|
||||
loading: () => <SettingsSkeleton />,
|
||||
});
|
||||
const ServerSettingsUrls = dynamic(() => import('./parts/ServerSettingsUrls'), {
|
||||
const Urls = dynamic(() => import('./parts/Urls'), {
|
||||
loading: () => <SettingsSkeleton />,
|
||||
});
|
||||
const ServerSettingsWebsite = dynamic(() => import('./parts/ServerSettingsWebsite'), {
|
||||
const Website = dynamic(() => import('./parts/Website'), {
|
||||
loading: () => <SettingsSkeleton />,
|
||||
});
|
||||
const ServerSettingsPWA = dynamic(() => import('./parts/ServerSettingsPWA'), {
|
||||
const PWA = dynamic(() => import('./parts/PWA'), {
|
||||
loading: () => <SettingsSkeleton />,
|
||||
});
|
||||
|
||||
export default function DashboardSettings() {
|
||||
const { data, isLoading, error } = useSWR<Response['/api/server/settings']>('/api/server/settings');
|
||||
const [opened, { toggle }] = useDisclosure(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -59,36 +62,58 @@ export default function DashboardSettings() {
|
||||
<Title order={1}>Server Settings</Title>
|
||||
</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'>
|
||||
{error ? (
|
||||
<div>Error loading server settings</div>
|
||||
) : (
|
||||
<>
|
||||
<ServerSettingsCore swr={{ data, isLoading }} />
|
||||
<ServerSettingsChunks swr={{ data, isLoading }} />
|
||||
<ServerSettingsTasks swr={{ data, isLoading }} />
|
||||
<ServerSettingsMfa swr={{ data, isLoading }} />
|
||||
<Core swr={{ data, isLoading }} />
|
||||
<Chunks swr={{ data, isLoading }} />
|
||||
<Tasks swr={{ data, isLoading }} />
|
||||
<Mfa swr={{ data, isLoading }} />
|
||||
|
||||
<ServerSettingsFeatures swr={{ data, isLoading }} />
|
||||
<ServerSettingsFiles swr={{ data, isLoading }} />
|
||||
<Features swr={{ data, isLoading }} />
|
||||
<Files swr={{ data, isLoading }} />
|
||||
<Stack>
|
||||
<ServerSettingsUrls swr={{ data, isLoading }} />
|
||||
<ServerSettingsInvites swr={{ data, isLoading }} />
|
||||
<Urls swr={{ data, isLoading }} />
|
||||
<Invites swr={{ data, isLoading }} />
|
||||
</Stack>
|
||||
|
||||
<ServerSettingsRatelimit swr={{ data, isLoading }} />
|
||||
<ServerSettingsWebsite swr={{ data, isLoading }} />
|
||||
<ServerSettingsOauth swr={{ data, isLoading }} />
|
||||
<Ratelimit swr={{ data, isLoading }} />
|
||||
<Website 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>
|
||||
|
||||
<Stack mt='md' gap='md'>
|
||||
{error ? null : <ServerSettingsDiscord swr={{ data, isLoading }} />}
|
||||
{error ? null : <Discord swr={{ data, isLoading }} />}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||
|
||||
export default function ServerSettingsChunks({
|
||||
export default function Chunks({
|
||||
swr: { data, isLoading },
|
||||
}: {
|
||||
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||
@@ -18,6 +18,12 @@ export default function ServerSettingsChunks({
|
||||
chunksMax: '95mb',
|
||||
chunksSize: '25mb',
|
||||
},
|
||||
enhanceGetInputProps: (payload: any): object => ({
|
||||
disabled:
|
||||
data?.tampered?.includes(payload.field) ||
|
||||
(payload.field !== 'chunksEnabled' && !form.values.chunksEnabled) ||
|
||||
false,
|
||||
}),
|
||||
});
|
||||
|
||||
const onSubmit = settingsOnSubmit(router, form);
|
||||
@@ -26,9 +32,9 @@ export default function ServerSettingsChunks({
|
||||
if (!data) return;
|
||||
|
||||
form.setValues({
|
||||
chunksEnabled: data?.chunksEnabled ?? true,
|
||||
chunksMax: data!.chunksMax ?? '',
|
||||
chunksSize: data!.chunksSize ?? '',
|
||||
chunksEnabled: data.settings.chunksEnabled ?? true,
|
||||
chunksMax: data.settings.chunksMax ?? '',
|
||||
chunksSize: data.settings.chunksSize ?? '',
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||
|
||||
export default function ServerSettingsCore({
|
||||
export default function Core({
|
||||
swr: { data, isLoading },
|
||||
}: {
|
||||
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||
@@ -22,6 +22,9 @@ export default function ServerSettingsCore({
|
||||
coreDefaultDomain: '',
|
||||
coreTempDirectory: '/tmp/zipline',
|
||||
},
|
||||
enhanceGetInputProps: (payload) => ({
|
||||
disabled: data?.tampered?.includes(payload.field) || false,
|
||||
}),
|
||||
});
|
||||
|
||||
const onSubmit = async (values: typeof form.values) => {
|
||||
@@ -35,10 +38,12 @@ export default function ServerSettingsCore({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
|
||||
form.setValues({
|
||||
coreReturnHttpsUrls: data?.coreReturnHttpsUrls ?? false,
|
||||
coreDefaultDomain: data?.coreDefaultDomain ?? '',
|
||||
coreTempDirectory: data?.coreTempDirectory ?? '/tmp/zipline',
|
||||
coreReturnHttpsUrls: data.settings.coreReturnHttpsUrls ?? false,
|
||||
coreDefaultDomain: data.settings.coreDefaultDomain ?? '',
|
||||
coreTempDirectory: data.settings.coreTempDirectory ?? '/tmp/zipline',
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
@@ -19,7 +19,7 @@ import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||
|
||||
type DiscordEmbed = Record<string, any>;
|
||||
|
||||
export default function ServerSettingsDiscord({
|
||||
export default function Discord({
|
||||
swr: { data, isLoading },
|
||||
}: {
|
||||
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||
@@ -65,6 +65,9 @@ export default function ServerSettingsDiscord({
|
||||
discordOnUploadEmbedTimestamp: false,
|
||||
discordOnUploadEmbedUrl: false,
|
||||
},
|
||||
enhanceGetInputProps: (payload) => ({
|
||||
disabled: data?.tampered?.includes(payload.field) || false,
|
||||
}),
|
||||
});
|
||||
|
||||
const formOnShorten = useForm({
|
||||
@@ -124,41 +127,45 @@ export default function ServerSettingsDiscord({
|
||||
if (!data) return;
|
||||
|
||||
formMain.setValues({
|
||||
discordWebhookUrl: data?.discordWebhookUrl ?? '',
|
||||
discordUsername: data?.discordUsername ?? '',
|
||||
discordAvatarUrl: data?.discordAvatarUrl ?? '',
|
||||
discordWebhookUrl: data.settings.discordWebhookUrl ?? '',
|
||||
discordUsername: data.settings.discordUsername ?? '',
|
||||
discordAvatarUrl: data.settings.discordAvatarUrl ?? '',
|
||||
});
|
||||
|
||||
formOnUpload.setValues({
|
||||
discordOnUploadWebhookUrl: data?.discordOnUploadWebhookUrl ?? '',
|
||||
discordOnUploadUsername: data?.discordOnUploadUsername ?? '',
|
||||
discordOnUploadAvatarUrl: data?.discordOnUploadAvatarUrl ?? '',
|
||||
discordOnUploadWebhookUrl: data.settings.discordOnUploadWebhookUrl ?? '',
|
||||
discordOnUploadUsername: data.settings.discordOnUploadUsername ?? '',
|
||||
discordOnUploadAvatarUrl: data.settings.discordOnUploadAvatarUrl ?? '',
|
||||
|
||||
discordOnUploadContent: data?.discordOnUploadContent ?? '',
|
||||
discordOnUploadEmbed: data?.discordOnUploadEmbed ? true : false,
|
||||
discordOnUploadEmbedTitle: (data?.discordOnUploadEmbed as DiscordEmbed)?.title ?? '',
|
||||
discordOnUploadEmbedDescription: (data?.discordOnUploadEmbed as DiscordEmbed)?.description ?? '',
|
||||
discordOnUploadEmbedFooter: (data?.discordOnUploadEmbed as DiscordEmbed)?.footer ?? '',
|
||||
discordOnUploadEmbedColor: (data?.discordOnUploadEmbed as DiscordEmbed)?.color ?? '',
|
||||
discordOnUploadEmbedThumbnail: (data?.discordOnUploadEmbed as DiscordEmbed)?.thumbnail ?? false,
|
||||
discordOnUploadEmbedImageOrVideo: (data?.discordOnUploadEmbed as DiscordEmbed)?.imageOrVideo ?? false,
|
||||
discordOnUploadEmbedTimestamp: (data?.discordOnUploadEmbed as DiscordEmbed)?.timestamp ?? false,
|
||||
discordOnUploadEmbedUrl: (data?.discordOnUploadEmbed as DiscordEmbed)?.url ?? false,
|
||||
discordOnUploadContent: data.settings.discordOnUploadContent ?? '',
|
||||
discordOnUploadEmbed: data.settings.discordOnUploadEmbed ? true : false,
|
||||
discordOnUploadEmbedTitle: (data.settings.discordOnUploadEmbed as DiscordEmbed)?.title ?? '',
|
||||
discordOnUploadEmbedDescription:
|
||||
(data.settings.discordOnUploadEmbed as DiscordEmbed)?.description ?? '',
|
||||
discordOnUploadEmbedFooter: (data.settings.discordOnUploadEmbed as DiscordEmbed)?.footer ?? '',
|
||||
discordOnUploadEmbedColor: (data.settings.discordOnUploadEmbed as DiscordEmbed)?.color ?? '',
|
||||
discordOnUploadEmbedThumbnail: (data.settings.discordOnUploadEmbed as DiscordEmbed)?.thumbnail ?? false,
|
||||
discordOnUploadEmbedImageOrVideo:
|
||||
(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({
|
||||
discordOnShortenWebhookUrl: data?.discordOnShortenWebhookUrl ?? '',
|
||||
discordOnShortenUsername: data?.discordOnShortenUsername ?? '',
|
||||
discordOnShortenAvatarUrl: data?.discordOnShortenAvatarUrl ?? '',
|
||||
discordOnShortenWebhookUrl: data.settings.discordOnShortenWebhookUrl ?? '',
|
||||
discordOnShortenUsername: data.settings.discordOnShortenUsername ?? '',
|
||||
discordOnShortenAvatarUrl: data.settings.discordOnShortenAvatarUrl ?? '',
|
||||
|
||||
discordOnShortenContent: data?.discordOnShortenContent ?? '',
|
||||
discordOnShortenEmbed: data?.discordOnShortenEmbed ? true : false,
|
||||
discordOnShortenEmbedTitle: (data?.discordOnShortenEmbed as DiscordEmbed)?.title ?? '',
|
||||
discordOnShortenEmbedDescription: (data?.discordOnShortenEmbed as DiscordEmbed)?.description ?? '',
|
||||
discordOnShortenEmbedFooter: (data?.discordOnShortenEmbed as DiscordEmbed)?.footer ?? '',
|
||||
discordOnShortenEmbedColor: (data?.discordOnShortenEmbed as DiscordEmbed)?.color ?? '',
|
||||
discordOnShortenEmbedTimestamp: (data?.discordOnShortenEmbed as DiscordEmbed)?.timestamp ?? false,
|
||||
discordOnShortenEmbedUrl: (data?.discordOnShortenEmbed as DiscordEmbed)?.url ?? false,
|
||||
discordOnShortenContent: data.settings.discordOnShortenContent ?? '',
|
||||
discordOnShortenEmbed: data.settings.discordOnShortenEmbed ? true : false,
|
||||
discordOnShortenEmbedTitle: (data.settings.discordOnShortenEmbed as DiscordEmbed)?.title ?? '',
|
||||
discordOnShortenEmbedDescription:
|
||||
(data.settings.discordOnShortenEmbed as DiscordEmbed)?.description ?? '',
|
||||
discordOnShortenEmbedFooter: (data.settings.discordOnShortenEmbed as DiscordEmbed)?.footer ?? '',
|
||||
discordOnShortenEmbedColor: (data.settings.discordOnShortenEmbed as DiscordEmbed)?.color ?? '',
|
||||
discordOnShortenEmbedTimestamp:
|
||||
(data.settings.discordOnShortenEmbed as DiscordEmbed)?.timestamp ?? false,
|
||||
discordOnShortenEmbedUrl: (data.settings.discordOnShortenEmbed as DiscordEmbed)?.url ?? false,
|
||||
});
|
||||
}, [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 { 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 { IconDeviceFloppy } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||
|
||||
export default function ServerSettingsFeatures({
|
||||
export default function Features({
|
||||
swr: { data, isLoading },
|
||||
}: {
|
||||
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||
@@ -25,24 +35,33 @@ export default function ServerSettingsFeatures({
|
||||
featuresMetricsEnabled: true,
|
||||
featuresMetricsAdminOnly: false,
|
||||
featuresMetricsShowUserSpecific: true,
|
||||
featuresVersionChecking: true,
|
||||
featuresVersionAPI: 'https://zipline-version.diced.sh/',
|
||||
},
|
||||
enhanceGetInputProps: (payload) => ({
|
||||
disabled: data?.tampered?.includes(payload.field) || false,
|
||||
}),
|
||||
});
|
||||
|
||||
const onSubmit = settingsOnSubmit(router, form);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
|
||||
form.setValues({
|
||||
featuresImageCompression: data?.featuresImageCompression ?? true,
|
||||
featuresRobotsTxt: data?.featuresRobotsTxt ?? true,
|
||||
featuresHealthcheck: data?.featuresHealthcheck ?? true,
|
||||
featuresUserRegistration: data?.featuresUserRegistration ?? false,
|
||||
featuresOauthRegistration: data?.featuresOauthRegistration ?? true,
|
||||
featuresDeleteOnMaxViews: data?.featuresDeleteOnMaxViews ?? true,
|
||||
featuresThumbnailsEnabled: data?.featuresThumbnailsEnabled ?? true,
|
||||
featuresThumbnailsNumberThreads: data?.featuresThumbnailsNumberThreads ?? 4,
|
||||
featuresMetricsEnabled: data?.featuresMetricsEnabled ?? true,
|
||||
featuresMetricsAdminOnly: data?.featuresMetricsAdminOnly ?? false,
|
||||
featuresMetricsShowUserSpecific: data?.featuresMetricsShowUserSpecific ?? true,
|
||||
featuresImageCompression: data.settings.featuresImageCompression ?? true,
|
||||
featuresRobotsTxt: data.settings.featuresRobotsTxt ?? true,
|
||||
featuresHealthcheck: data.settings.featuresHealthcheck ?? true,
|
||||
featuresUserRegistration: data.settings.featuresUserRegistration ?? false,
|
||||
featuresOauthRegistration: data.settings.featuresOauthRegistration ?? true,
|
||||
featuresDeleteOnMaxViews: data.settings.featuresDeleteOnMaxViews ?? true,
|
||||
featuresThumbnailsEnabled: data.settings.featuresThumbnailsEnabled ?? true,
|
||||
featuresThumbnailsNumberThreads: data.settings.featuresThumbnailsNumberThreads ?? 4,
|
||||
featuresMetricsEnabled: data.settings.featuresMetricsEnabled ?? true,
|
||||
featuresMetricsAdminOnly: data.settings.featuresMetricsAdminOnly ?? false,
|
||||
featuresMetricsShowUserSpecific: data.settings.featuresMetricsShowUserSpecific ?? true,
|
||||
featuresVersionChecking: data.settings.featuresVersionChecking ?? true,
|
||||
featuresVersionAPI: data.settings.featuresVersionAPI ?? 'https://zipline-version.diced.sh/',
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
@@ -107,7 +126,7 @@ export default function ServerSettingsFeatures({
|
||||
description='Shows metrics specific to each user, for all users.'
|
||||
{...form.getInputProps('featuresMetricsShowUserSpecific', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<div />
|
||||
<Switch
|
||||
label='Enable Thumbnails'
|
||||
description='Enables thumbnail generation for images. Requires a server restart.'
|
||||
@@ -122,6 +141,30 @@ export default function ServerSettingsFeatures({
|
||||
max={16}
|
||||
{...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>
|
||||
|
||||
<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 { settingsOnSubmit } from '../settingsOnSubmit';
|
||||
|
||||
export default function ServerSettingsFiles({
|
||||
export default function Files({
|
||||
swr: { data, isLoading },
|
||||
}: {
|
||||
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||
@@ -48,6 +48,9 @@ export default function ServerSettingsFiles({
|
||||
filesRandomWordsNumAdjectives: 3,
|
||||
filesRandomWordsSeparator: '-',
|
||||
},
|
||||
enhanceGetInputProps: (payload) => ({
|
||||
disabled: data?.tampered?.includes(payload.field) || false,
|
||||
}),
|
||||
});
|
||||
|
||||
const onSubmit = async (values: typeof form.values) => {
|
||||
@@ -80,18 +83,20 @@ export default function ServerSettingsFiles({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
|
||||
form.setValues({
|
||||
filesRoute: data?.filesRoute ?? '/u',
|
||||
filesLength: data?.filesLength ?? 6,
|
||||
filesDefaultFormat: data?.filesDefaultFormat ?? 'random',
|
||||
filesDisabledExtensions: data?.filesDisabledExtensions.join(', ') ?? '',
|
||||
filesMaxFileSize: data?.filesMaxFileSize ?? '100mb',
|
||||
filesDefaultExpiration: data?.filesDefaultExpiration ?? '',
|
||||
filesAssumeMimetypes: data?.filesAssumeMimetypes ?? false,
|
||||
filesDefaultDateFormat: data?.filesDefaultDateFormat ?? 'YYYY-MM-DD_HH:mm:ss',
|
||||
filesRemoveGpsMetadata: data?.filesRemoveGpsMetadata ?? false,
|
||||
filesRandomWordsNumAdjectives: data?.filesRandomWordsNumAdjectives ?? 3,
|
||||
filesRandomWordsSeparator: data?.filesRandomWordsSeparator ?? '-',
|
||||
filesRoute: data.settings.filesRoute ?? '/u',
|
||||
filesLength: data.settings.filesLength ?? 6,
|
||||
filesDefaultFormat: data.settings.filesDefaultFormat ?? 'random',
|
||||
filesDisabledExtensions: data.settings.filesDisabledExtensions.join(', ') ?? '',
|
||||
filesMaxFileSize: data.settings.filesMaxFileSize ?? '100mb',
|
||||
filesDefaultExpiration: data.settings.filesDefaultExpiration ?? '',
|
||||
filesAssumeMimetypes: data.settings.filesAssumeMimetypes ?? false,
|
||||
filesDefaultDateFormat: data.settings.filesDefaultDateFormat ?? 'YYYY-MM-DD_HH:mm:ss',
|
||||
filesRemoveGpsMetadata: data.settings.filesRemoveGpsMetadata ?? false,
|
||||
filesRandomWordsNumAdjectives: data.settings.filesRandomWordsNumAdjectives ?? 3,
|
||||
filesRandomWordsSeparator: data.settings.filesRandomWordsSeparator ?? '-',
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||
|
||||
export default function ServerSettingsHttpWebhook({
|
||||
export default function HttpWebhook({
|
||||
swr: { data, isLoading },
|
||||
}: {
|
||||
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||
@@ -17,6 +17,9 @@ export default function ServerSettingsHttpWebhook({
|
||||
httpWebhookOnUpload: '',
|
||||
httpWebhookOnShorten: '',
|
||||
},
|
||||
enhanceGetInputProps: (payload) => ({
|
||||
disabled: data?.tampered?.includes(payload.field) || false,
|
||||
}),
|
||||
});
|
||||
|
||||
const onSubmit = async (values: typeof form.values) => {
|
||||
@@ -37,8 +40,8 @@ export default function ServerSettingsHttpWebhook({
|
||||
if (!data) return;
|
||||
|
||||
form.setValues({
|
||||
httpWebhookOnUpload: data?.httpWebhookOnUpload ?? '',
|
||||
httpWebhookOnShorten: data?.httpWebhookOnShorten ?? '',
|
||||
httpWebhookOnUpload: data.settings.httpWebhookOnUpload ?? '',
|
||||
httpWebhookOnShorten: data.settings.httpWebhookOnShorten ?? '',
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||
|
||||
export default function ServerSettingsInvites({
|
||||
export default function Invites({
|
||||
swr: { data, isLoading },
|
||||
}: {
|
||||
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||
@@ -17,6 +17,12 @@ export default function ServerSettingsInvites({
|
||||
invitesEnabled: true,
|
||||
invitesLength: 6,
|
||||
},
|
||||
enhanceGetInputProps: (payload: any): object => ({
|
||||
disabled:
|
||||
data?.tampered?.includes(payload.field) ||
|
||||
(payload.field !== 'invitesEnabled' && !form.values.invitesEnabled) ||
|
||||
false,
|
||||
}),
|
||||
});
|
||||
|
||||
const onSubmit = settingsOnSubmit(router, form);
|
||||
@@ -25,8 +31,8 @@ export default function ServerSettingsInvites({
|
||||
if (!data) return;
|
||||
|
||||
form.setValues({
|
||||
invitesEnabled: data?.invitesEnabled ?? true,
|
||||
invitesLength: data?.invitesLength ?? 6,
|
||||
invitesEnabled: data.settings.invitesEnabled ?? true,
|
||||
invitesLength: data.settings.invitesLength ?? 6,
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
@@ -50,7 +56,6 @@ export default function ServerSettingsInvites({
|
||||
placeholder='6'
|
||||
min={1}
|
||||
max={64}
|
||||
disabled={!form.values.invitesEnabled}
|
||||
{...form.getInputProps('invitesLength')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
@@ -6,7 +6,7 @@ import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||
|
||||
export default function ServerSettingsMfa({
|
||||
export default function Mfa({
|
||||
swr: { data, isLoading },
|
||||
}: {
|
||||
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||
@@ -18,6 +18,9 @@ export default function ServerSettingsMfa({
|
||||
mfaTotpIssuer: 'Zipline',
|
||||
mfaPasskeys: false,
|
||||
},
|
||||
enhanceGetInputProps: (payload) => ({
|
||||
disabled: data?.tampered?.includes(payload.field) || false,
|
||||
}),
|
||||
});
|
||||
|
||||
const onSubmit = settingsOnSubmit(router, form);
|
||||
@@ -26,9 +29,9 @@ export default function ServerSettingsMfa({
|
||||
if (!data) return;
|
||||
|
||||
form.setValues({
|
||||
mfaTotpEnabled: data?.mfaTotpEnabled ?? false,
|
||||
mfaTotpIssuer: data?.mfaTotpIssuer ?? 'Zipline',
|
||||
mfaPasskeys: data?.mfaPasskeys,
|
||||
mfaTotpEnabled: data.settings.mfaTotpEnabled ?? false,
|
||||
mfaTotpIssuer: data.settings.mfaTotpIssuer ?? 'Zipline',
|
||||
mfaPasskeys: data.settings.mfaPasskeys,
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||
|
||||
export default function ServerSettingsOauth({
|
||||
export default function Oauth({
|
||||
swr: { data, isLoading },
|
||||
}: {
|
||||
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||
@@ -30,6 +30,8 @@ export default function ServerSettingsOauth({
|
||||
oauthDiscordClientId: '',
|
||||
oauthDiscordClientSecret: '',
|
||||
oauthDiscordRedirectUri: '',
|
||||
oauthDiscordAllowedIds: '',
|
||||
oauthDiscordDeniedIds: '',
|
||||
|
||||
oauthGoogleClientId: '',
|
||||
oauthGoogleClientSecret: '',
|
||||
@@ -46,11 +48,21 @@ export default function ServerSettingsOauth({
|
||||
oauthOidcUserinfoUrl: '',
|
||||
oauthOidcRedirectUri: '',
|
||||
},
|
||||
enhanceGetInputProps: (payload) => ({
|
||||
disabled: data?.tampered?.includes(payload.field) || false,
|
||||
}),
|
||||
});
|
||||
|
||||
const onSubmit = async (values: typeof form.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() === '') {
|
||||
// @ts-ignore
|
||||
values[key as keyof typeof form.values] = null;
|
||||
@@ -61,6 +73,16 @@ export default function ServerSettingsOauth({
|
||||
)?.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);
|
||||
@@ -70,27 +92,33 @@ export default function ServerSettingsOauth({
|
||||
if (!data) return;
|
||||
|
||||
form.setValues({
|
||||
oauthBypassLocalLogin: data?.oauthBypassLocalLogin ?? false,
|
||||
oauthLoginOnly: data?.oauthLoginOnly ?? false,
|
||||
oauthBypassLocalLogin: data.settings.oauthBypassLocalLogin ?? false,
|
||||
oauthLoginOnly: data.settings.oauthLoginOnly ?? false,
|
||||
|
||||
oauthDiscordClientId: data?.oauthDiscordClientId ?? '',
|
||||
oauthDiscordClientSecret: data?.oauthDiscordClientSecret ?? '',
|
||||
oauthDiscordRedirectUri: data?.oauthDiscordRedirectUri ?? '',
|
||||
oauthDiscordClientId: data.settings.oauthDiscordClientId ?? '',
|
||||
oauthDiscordClientSecret: data.settings.oauthDiscordClientSecret ?? '',
|
||||
oauthDiscordRedirectUri: data.settings.oauthDiscordRedirectUri ?? '',
|
||||
oauthDiscordAllowedIds: data.settings.oauthDiscordAllowedIds
|
||||
? data.settings.oauthDiscordAllowedIds.join(', ')
|
||||
: '',
|
||||
oauthDiscordDeniedIds: data.settings.oauthDiscordDeniedIds
|
||||
? data.settings.oauthDiscordDeniedIds.join(', ')
|
||||
: '',
|
||||
|
||||
oauthGoogleClientId: data?.oauthGoogleClientId ?? '',
|
||||
oauthGoogleClientSecret: data?.oauthGoogleClientSecret ?? '',
|
||||
oauthGoogleRedirectUri: data?.oauthGoogleRedirectUri ?? '',
|
||||
oauthGoogleClientId: data.settings.oauthGoogleClientId ?? '',
|
||||
oauthGoogleClientSecret: data.settings.oauthGoogleClientSecret ?? '',
|
||||
oauthGoogleRedirectUri: data.settings.oauthGoogleRedirectUri ?? '',
|
||||
|
||||
oauthGithubClientId: data?.oauthGithubClientId ?? '',
|
||||
oauthGithubClientSecret: data?.oauthGithubClientSecret ?? '',
|
||||
oauthGithubRedirectUri: data?.oauthGithubRedirectUri ?? '',
|
||||
oauthGithubClientId: data.settings.oauthGithubClientId ?? '',
|
||||
oauthGithubClientSecret: data.settings.oauthGithubClientSecret ?? '',
|
||||
oauthGithubRedirectUri: data.settings.oauthGithubRedirectUri ?? '',
|
||||
|
||||
oauthOidcClientId: data?.oauthOidcClientId ?? '',
|
||||
oauthOidcClientSecret: data?.oauthOidcClientSecret ?? '',
|
||||
oauthOidcAuthorizeUrl: data?.oauthOidcAuthorizeUrl ?? '',
|
||||
oauthOidcTokenUrl: data?.oauthOidcTokenUrl ?? '',
|
||||
oauthOidcUserinfoUrl: data?.oauthOidcUserinfoUrl ?? '',
|
||||
oauthOidcRedirectUri: data?.oauthOidcRedirectUri ?? '',
|
||||
oauthOidcClientId: data.settings.oauthOidcClientId ?? '',
|
||||
oauthOidcClientSecret: data.settings.oauthOidcClientSecret ?? '',
|
||||
oauthOidcAuthorizeUrl: data.settings.oauthOidcAuthorizeUrl ?? '',
|
||||
oauthOidcTokenUrl: data.settings.oauthOidcTokenUrl ?? '',
|
||||
oauthOidcUserinfoUrl: data.settings.oauthOidcUserinfoUrl ?? '',
|
||||
oauthOidcRedirectUri: data.settings.oauthOidcRedirectUri ?? '',
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
@@ -129,6 +157,16 @@ export default function ServerSettingsOauth({
|
||||
|
||||
<TextInput label='Discord Client ID' {...form.getInputProps('oauthDiscordClientId')} />
|
||||
<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
|
||||
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.'
|
||||
@@ -17,7 +17,7 @@ import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||
|
||||
export default function ServerSettingsPWA({
|
||||
export default function PWA({
|
||||
swr: { data, isLoading },
|
||||
}: {
|
||||
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||
@@ -32,6 +32,12 @@ export default function ServerSettingsPWA({
|
||||
pwaThemeColor: '',
|
||||
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) => {
|
||||
@@ -53,13 +59,15 @@ export default function ServerSettingsPWA({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
|
||||
form.setValues({
|
||||
pwaEnabled: data?.pwaEnabled ?? false,
|
||||
pwaTitle: data?.pwaTitle ?? '',
|
||||
pwaShortName: data?.pwaShortName ?? '',
|
||||
pwaDescription: data?.pwaDescription ?? '',
|
||||
pwaThemeColor: data?.pwaThemeColor ?? '',
|
||||
pwaBackgroundColor: data?.pwaBackgroundColor ?? '',
|
||||
pwaEnabled: data.settings.pwaEnabled ?? false,
|
||||
pwaTitle: data.settings.pwaTitle ?? '',
|
||||
pwaShortName: data.settings.pwaShortName ?? '',
|
||||
pwaDescription: data.settings.pwaDescription ?? '',
|
||||
pwaThemeColor: data.settings.pwaThemeColor ?? '',
|
||||
pwaBackgroundColor: data.settings.pwaBackgroundColor ?? '',
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
@@ -86,7 +94,6 @@ export default function ServerSettingsPWA({
|
||||
label='Title'
|
||||
description='The title for the PWA'
|
||||
placeholder='Zipline'
|
||||
disabled={!form.values.pwaEnabled}
|
||||
{...form.getInputProps('pwaTitle')}
|
||||
/>
|
||||
|
||||
@@ -94,7 +101,6 @@ export default function ServerSettingsPWA({
|
||||
label='Short Name'
|
||||
description='The short name for the PWA'
|
||||
placeholder='Zipline'
|
||||
disabled={!form.values.pwaEnabled}
|
||||
{...form.getInputProps('pwaShortName')}
|
||||
/>
|
||||
|
||||
@@ -102,7 +108,6 @@ export default function ServerSettingsPWA({
|
||||
label='Description'
|
||||
description='The description for the PWA'
|
||||
placeholder='Zipline'
|
||||
disabled={!form.values.pwaEnabled}
|
||||
{...form.getInputProps('pwaDescription')}
|
||||
/>
|
||||
|
||||
@@ -110,7 +115,6 @@ export default function ServerSettingsPWA({
|
||||
label='Theme Color'
|
||||
description='The theme color for the PWA'
|
||||
placeholder='#000000'
|
||||
disabled={!form.values.pwaEnabled}
|
||||
{...form.getInputProps('pwaThemeColor')}
|
||||
/>
|
||||
|
||||
@@ -118,7 +122,6 @@ export default function ServerSettingsPWA({
|
||||
label='Background Color'
|
||||
description='The background color for the PWA'
|
||||
placeholder='#ffffff'
|
||||
disabled={!form.values.pwaEnabled}
|
||||
{...form.getInputProps('pwaBackgroundColor')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
@@ -16,7 +16,7 @@ import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||
|
||||
export default function ServerSettingsRatelimit({
|
||||
export default function Ratelimit({
|
||||
swr: { data, isLoading },
|
||||
}: {
|
||||
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||
@@ -36,6 +36,12 @@ export default function ServerSettingsRatelimit({
|
||||
ratelimitAdminBypass: false,
|
||||
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) => {
|
||||
@@ -62,11 +68,11 @@ export default function ServerSettingsRatelimit({
|
||||
if (!data) return;
|
||||
|
||||
form.setValues({
|
||||
ratelimitEnabled: data?.ratelimitEnabled ?? true,
|
||||
ratelimitMax: data?.ratelimitMax ?? 10,
|
||||
ratelimitWindow: data?.ratelimitWindow ?? '',
|
||||
ratelimitAdminBypass: data?.ratelimitAdminBypass ?? false,
|
||||
ratelimitAllowList: data?.ratelimitAllowList.join(', ') ?? '',
|
||||
ratelimitEnabled: data.settings.ratelimitEnabled ?? true,
|
||||
ratelimitMax: data.settings.ratelimitMax ?? 10,
|
||||
ratelimitWindow: data.settings.ratelimitWindow ?? '',
|
||||
ratelimitAdminBypass: data.settings.ratelimitAdminBypass ?? false,
|
||||
ratelimitAllowList: data.settings.ratelimitAllowList.join(', ') ?? '',
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
@@ -91,7 +97,6 @@ export default function ServerSettingsRatelimit({
|
||||
<Switch
|
||||
label='Admin Bypass'
|
||||
description='Allow admins to bypass the ratelimit.'
|
||||
disabled={!form.values.ratelimitEnabled}
|
||||
{...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.'
|
||||
placeholder='10'
|
||||
min={1}
|
||||
disabled={!form.values.ratelimitEnabled}
|
||||
{...form.getInputProps('ratelimitMax')}
|
||||
/>
|
||||
|
||||
@@ -109,7 +113,6 @@ export default function ServerSettingsRatelimit({
|
||||
description='The window in seconds to allow the max requests.'
|
||||
placeholder='60'
|
||||
min={1}
|
||||
disabled={!form.values.ratelimitEnabled}
|
||||
{...form.getInputProps('ratelimitWindow')}
|
||||
/>
|
||||
|
||||
@@ -117,7 +120,6 @@ export default function ServerSettingsRatelimit({
|
||||
label='Allow List'
|
||||
description='A comma-separated list of IP addresses to bypass the ratelimit.'
|
||||
placeholder='1.1.1.1, 8.8.8.8'
|
||||
disabled={!form.values.ratelimitEnabled}
|
||||
{...form.getInputProps('ratelimitAllowList')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
@@ -6,7 +6,7 @@ import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||
|
||||
export default function ServerSettingsTasks({
|
||||
export default function Tasks({
|
||||
swr: { data, isLoading },
|
||||
}: {
|
||||
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||
@@ -20,6 +20,9 @@ export default function ServerSettingsTasks({
|
||||
tasksThumbnailsInterval: '30m',
|
||||
tasksMetricsInterval: '30m',
|
||||
},
|
||||
enhanceGetInputProps: (payload) => ({
|
||||
disabled: data?.tampered?.includes(payload.field) || false,
|
||||
}),
|
||||
});
|
||||
|
||||
const onSubmit = settingsOnSubmit(router, form);
|
||||
@@ -28,11 +31,11 @@ export default function ServerSettingsTasks({
|
||||
if (!data) return;
|
||||
|
||||
form.setValues({
|
||||
tasksDeleteInterval: data?.tasksDeleteInterval ?? '30m',
|
||||
tasksClearInvitesInterval: data?.tasksClearInvitesInterval ?? '30m',
|
||||
tasksMaxViewsInterval: data?.tasksMaxViewsInterval ?? '30m',
|
||||
tasksThumbnailsInterval: data?.tasksThumbnailsInterval ?? '30m',
|
||||
tasksMetricsInterval: data?.tasksMetricsInterval ?? '30m',
|
||||
tasksDeleteInterval: data.settings.tasksDeleteInterval ?? '30m',
|
||||
tasksClearInvitesInterval: data.settings.tasksClearInvitesInterval ?? '30m',
|
||||
tasksMaxViewsInterval: data.settings.tasksMaxViewsInterval ?? '30m',
|
||||
tasksThumbnailsInterval: data.settings.tasksThumbnailsInterval ?? '30m',
|
||||
tasksMetricsInterval: data.settings.tasksMetricsInterval ?? '30m',
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
import { settingsOnSubmit } from '../settingsOnSubmit';
|
||||
|
||||
export default function ServerSettingsUrls({
|
||||
export default function Urls({
|
||||
swr: { data, isLoading },
|
||||
}: {
|
||||
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||
@@ -17,6 +17,9 @@ export default function ServerSettingsUrls({
|
||||
urlsRoute: '/go',
|
||||
urlsLength: 6,
|
||||
},
|
||||
enhanceGetInputProps: (payload) => ({
|
||||
disabled: data?.tampered?.includes(payload.field) || false,
|
||||
}),
|
||||
});
|
||||
|
||||
const onSubmit = settingsOnSubmit(router, form);
|
||||
@@ -25,8 +28,8 @@ export default function ServerSettingsUrls({
|
||||
if (!data) return;
|
||||
|
||||
form.setValues({
|
||||
urlsRoute: data?.urlsRoute ?? '/go',
|
||||
urlsLength: data?.urlsLength ?? 6,
|
||||
urlsRoute: data.settings.urlsRoute ?? '/go',
|
||||
urlsLength: data.settings.urlsLength ?? 6,
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
@@ -17,7 +17,7 @@ const defaultExternalLinks = [
|
||||
},
|
||||
];
|
||||
|
||||
export default function ServerSettingsWebsite({
|
||||
export default function Website({
|
||||
swr: { data, isLoading },
|
||||
}: {
|
||||
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
|
||||
@@ -37,6 +37,9 @@ export default function ServerSettingsWebsite({
|
||||
websiteThemeDark: 'builtin:dark_gray',
|
||||
websiteThemeLight: 'builtin:light_gray',
|
||||
},
|
||||
enhanceGetInputProps: (payload) => ({
|
||||
disabled: data?.tampered?.includes(payload.field) || false,
|
||||
}),
|
||||
});
|
||||
|
||||
const onSubmit = async (values: typeof form.values) => {
|
||||
@@ -76,16 +79,20 @@ export default function ServerSettingsWebsite({
|
||||
if (!data) return;
|
||||
|
||||
form.setValues({
|
||||
websiteTitle: data?.websiteTitle ?? 'Zipline',
|
||||
websiteTitleLogo: data?.websiteTitleLogo ?? '',
|
||||
websiteExternalLinks: JSON.stringify(data?.websiteExternalLinks ?? defaultExternalLinks, null, 2),
|
||||
websiteLoginBackground: data?.websiteLoginBackground ?? '',
|
||||
websiteLoginBackgroundBlur: data?.websiteLoginBackgroundBlur ?? true,
|
||||
websiteDefaultAvatar: data?.websiteDefaultAvatar ?? '',
|
||||
websiteTos: data?.websiteTos ?? '',
|
||||
websiteThemeDefault: data?.websiteThemeDefault ?? 'system',
|
||||
websiteThemeDark: data?.websiteThemeDark ?? 'builtin:dark_gray',
|
||||
websiteThemeLight: data?.websiteThemeLight ?? 'builtin:light_gray',
|
||||
websiteTitle: data.settings.websiteTitle ?? 'Zipline',
|
||||
websiteTitleLogo: data.settings.websiteTitleLogo ?? '',
|
||||
websiteExternalLinks: JSON.stringify(
|
||||
data.settings.websiteExternalLinks ?? defaultExternalLinks,
|
||||
null,
|
||||
2,
|
||||
),
|
||||
websiteLoginBackground: data.settings.websiteLoginBackground ?? '',
|
||||
websiteLoginBackgroundBlur: data.settings.websiteLoginBackgroundBlur ?? true,
|
||||
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]);
|
||||
|
||||
@@ -176,7 +183,6 @@ export default function ServerSettingsWebsite({
|
||||
label='Dark Theme'
|
||||
description='The dark theme to use for the website when the default theme is "system".'
|
||||
placeholder='builtin:dark_gray'
|
||||
disabled={form.values.websiteThemeDefault !== 'system'}
|
||||
{...form.getInputProps('websiteThemeDark')}
|
||||
/>
|
||||
</Grid.Col>
|
||||
@@ -186,7 +192,6 @@ export default function ServerSettingsWebsite({
|
||||
label='Light Theme'
|
||||
description='The light theme to use for the website when the default theme is "system".'
|
||||
placeholder='builtin:light_gray'
|
||||
disabled={form.values.websiteThemeDefault !== 'system'}
|
||||
{...form.getInputProps('websiteThemeLight')}
|
||||
/>
|
||||
</Grid.Col>
|
||||
@@ -49,6 +49,8 @@ export default function SettingsFileView() {
|
||||
embedColor: user?.view.embedColor ?? '',
|
||||
align: user?.view.align ?? 'left',
|
||||
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,
|
||||
align: values.align,
|
||||
showMimetype: values.showMimetype,
|
||||
showTags: values.showTags,
|
||||
showFolder: values.showFolder,
|
||||
};
|
||||
|
||||
const { data, error } = await fetchApi<Response['/api/user']>('/api/user', 'PATCH', {
|
||||
@@ -110,6 +114,20 @@ export default function SettingsFileView() {
|
||||
disabled={!form.values.enabled}
|
||||
{...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>
|
||||
|
||||
<Textarea
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { IconDownload, IconEyeFilled, IconGlobe, IconPercentage, IconWriting } from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
@@ -45,7 +44,6 @@ export type GeneratorOptions = {
|
||||
};
|
||||
|
||||
export const copier = (options: GeneratorOptions) => {
|
||||
if (options.unix_useEcho) return 'echo';
|
||||
if (options.mac_enableCompatibility) return 'pbcopy';
|
||||
if (options.wl_enableCompatibility) return 'wl-copy';
|
||||
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: settingsData } = useSWR<Response['/api/server/settings']>('/api/server/settings');
|
||||
|
||||
const isUnixLike = name === 'Flameshot' || name === 'Shell Script';
|
||||
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 (
|
||||
<>
|
||||
<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) })}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
<Select
|
||||
data={domainOptions}
|
||||
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.'
|
||||
leftSection={<IconGlobe size='1rem' />}
|
||||
value={options.overrides_returnDomain ?? ''}
|
||||
onChange={(event) =>
|
||||
setOption({ overrides_returnDomain: event.currentTarget.value.trim() || null })
|
||||
}
|
||||
onChange={(value) => setOption({ overrides_returnDomain: value || null })}
|
||||
comboboxProps={{
|
||||
withinPortal: true,
|
||||
portalProps: {
|
||||
style: {
|
||||
zIndex: 100000000,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Text c='dimmed' size='sm'>
|
||||
|
||||
@@ -68,7 +68,7 @@ export function flameshot(token: string, type: 'file' | 'url', options: Generato
|
||||
|
||||
if (type === 'file') {
|
||||
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)}
|
||||
`;
|
||||
} else {
|
||||
|
||||
@@ -10,7 +10,7 @@ export function shell(token: string, type: 'file' | 'url', options: GeneratorOpt
|
||||
];
|
||||
|
||||
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'");
|
||||
} else {
|
||||
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)) {
|
||||
curl.push('-H', `${key}: ${value}`);
|
||||
curl.push('-H', `"${key}: ${value}"`);
|
||||
}
|
||||
|
||||
let script;
|
||||
|
||||
if (type === 'file') {
|
||||
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 {
|
||||
script = `#!/bin/bash
|
||||
${curl.join(' ')} -d "{\\"url\\": \\"$1\\"}"${
|
||||
options.noJson ? '' : ' | jq -r .files[0].url'
|
||||
} | tr -d '\\n' | ${copier(options)}
|
||||
${curl.join(' ')} -d "{\\"destination\\": \\"$1\\"}"${
|
||||
options.noJson ? '' : ' | jq -r .url'
|
||||
}${options.unix_useEcho ? '' : ` | ${copier(options)}`}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,10 @@ export default function SettingsGenerators() {
|
||||
<Code>curl</Code>
|
||||
</Anchor>
|
||||
,{' '}
|
||||
<Anchor component={Link} href='https://darwinsys.com/file/'>
|
||||
<Code>file</Code>
|
||||
</Anchor>
|
||||
,{' '}
|
||||
<Anchor component={Link} href='https://github.com/stedolan/jq'>
|
||||
<Code>jq</Code>
|
||||
</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 { modals } from '@mantine/modals';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { UserPasskey } from '@prisma/client';
|
||||
import { UserPasskey } from '../../../../../../generated/client';
|
||||
import { IconKey, IconKeyOff, IconTrashFilled } from '@tabler/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { mutate } from 'swr';
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useUserStore } from '@/lib/store/user';
|
||||
import { darken } from '@/lib/theme/color';
|
||||
import { Button, ButtonProps, Paper, SimpleGrid, Text, Title, useMantineTheme } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import type { OAuthProviderType } from '@prisma/client';
|
||||
import type { OAuthProviderType } from '../../../../../../generated/client';
|
||||
import {
|
||||
IconBrandDiscordFilled,
|
||||
IconBrandGithubFilled,
|
||||
|
||||
@@ -20,7 +20,7 @@ import { useClipboard, useColorScheme } from '@mantine/hooks';
|
||||
import { notifications, showNotification } from '@mantine/notifications';
|
||||
import { IconDeviceSdCard, IconFiles, IconUpload, IconX } from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import UploadOptionsButton from '../UploadOptionsButton';
|
||||
import { uploadFiles } from '../uploadFiles';
|
||||
import ToUploadFile from './ToUploadFile';
|
||||
@@ -35,6 +35,8 @@ export default function UploadFile({ title, folder }: { title?: string; folder?:
|
||||
const clipboard = useClipboard();
|
||||
const config = useConfig();
|
||||
|
||||
const isMac = typeof navigator !== 'undefined' && navigator.userAgent.includes('Macintosh');
|
||||
|
||||
const [options, ephemeral, clearEphemeral] = useUploadOptionsStore(
|
||||
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 handlePaste = (e: ClipboardEvent) => {
|
||||
if (!e.clipboardData) return;
|
||||
const aggSize = useCallback(() => files.reduce((acc, file) => acc + file.size, 0), [files]);
|
||||
|
||||
const handlePaste = useCallback((e: ClipboardEvent) => {
|
||||
if (!e.clipboardData) return;
|
||||
for (let i = 0; i !== e.clipboardData.items.length; ++i) {
|
||||
if (!e.clipboardData.items[i].type.startsWith('image')) return;
|
||||
|
||||
const blob = e.clipboardData.items[i].getAsFile();
|
||||
if (!blob) return;
|
||||
|
||||
setFiles([...files, blob]);
|
||||
showNotification({
|
||||
message: `Image ${blob.name} pasted from clipboard`,
|
||||
color: 'blue',
|
||||
});
|
||||
setFiles((prev) => [...prev, blob]);
|
||||
showNotification({ message: `Image ${blob.name} pasted from clipboard`, color: 'blue' });
|
||||
}
|
||||
};
|
||||
|
||||
const aggSize = () => files.reduce((acc, file) => acc + file.size, 0);
|
||||
}, []);
|
||||
|
||||
const upload = () => {
|
||||
const toPartialFiles: File[] = [];
|
||||
for (let i = 0; i !== files.length; ++i) {
|
||||
const file = files[i];
|
||||
if (config.chunks.enabled && file.size >= bytes(config.chunks.max)) {
|
||||
toPartialFiles.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
const toPartialFiles: File[] = files.filter(
|
||||
(file) => config.chunks.enabled && file.size >= bytes(config.chunks.max),
|
||||
);
|
||||
if (toPartialFiles.length > 0) {
|
||||
uploadPartialFiles(toPartialFiles, {
|
||||
setFiles,
|
||||
@@ -89,7 +80,7 @@ export default function UploadFile({ title, folder }: { title?: string; folder?:
|
||||
});
|
||||
} else {
|
||||
const size = aggSize();
|
||||
if (size > bytes(config.files.maxFileSize) && !toPartialFiles.length) {
|
||||
if (size > bytes(config.files.maxFileSize)) {
|
||||
notifications.show({
|
||||
title: 'Upload may fail',
|
||||
color: 'yellow',
|
||||
@@ -103,7 +94,6 @@ export default function UploadFile({ title, folder }: { title?: string; folder?:
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
uploadFiles(files, {
|
||||
setFiles,
|
||||
setLoading,
|
||||
@@ -119,11 +109,22 @@ export default function UploadFile({ title, folder }: { title?: string; folder?:
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('paste', handlePaste);
|
||||
|
||||
return () => {
|
||||
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 (
|
||||
<>
|
||||
@@ -140,7 +141,7 @@ export default function UploadFile({ title, folder }: { title?: string; folder?:
|
||||
</Group>
|
||||
|
||||
<Dropzone
|
||||
onDrop={(f) => setFiles([...f, ...files])}
|
||||
onDrop={(f) => setFiles((prev) => [...f, ...prev])}
|
||||
my='sm'
|
||||
loading={dropLoading}
|
||||
disabled={dropLoading}
|
||||
@@ -165,7 +166,8 @@ export default function UploadFile({ title, folder }: { title?: string; folder?:
|
||||
Drag images here or click to select files
|
||||
</Text>
|
||||
<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 size='sm' c='dimmed' inline mt={7}>
|
||||
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'>
|
||||
<UploadOptionsButton folder={folder} numFiles={files.length} />
|
||||
|
||||
<Button
|
||||
variant='outline'
|
||||
leftSection={<IconUpload size={18} />}
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { IconCursorText, IconEyeFilled, IconFiles, IconUpload } from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import UploadOptionsButton from '../UploadOptionsButton';
|
||||
import { renderMode } from '../renderMode';
|
||||
import { uploadFiles } from '../uploadFiles';
|
||||
@@ -30,15 +30,26 @@ export default function UploadText({
|
||||
codeMeta: Parameters<typeof DashboardUploadText>[0]['codeMeta'];
|
||||
}) {
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const [options, ephemeral, clearEphemeral] = useUploadOptionsStore(
|
||||
useShallow((state) => [state.options, state.ephemeral, state.clearEphemeral]),
|
||||
);
|
||||
|
||||
const [selectedLanguage, setSelectedLanguage] = useState('txt');
|
||||
const [text, setText] = useState('');
|
||||
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 handleTab = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
@@ -52,12 +63,10 @@ export default function UploadText({
|
||||
|
||||
const upload = () => {
|
||||
const blob = new Blob([text]);
|
||||
|
||||
const file = new File([blob], `text.${selectedLanguage}`, {
|
||||
type: codeMeta.find((meta) => meta.ext === selectedLanguage)?.mime,
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
|
||||
uploadFiles([file], {
|
||||
clipboard,
|
||||
setFiles: () => {},
|
||||
|
||||
@@ -30,8 +30,8 @@ import {
|
||||
IconTrashFilled,
|
||||
IconWriting,
|
||||
} from '@tabler/icons-react';
|
||||
import ms, { StringValue } from 'ms';
|
||||
import Link from 'next/link';
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||
import { useEffect, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
@@ -39,7 +39,7 @@ import { useShallow } from 'zustand/shallow';
|
||||
export default function UploadOptionsButton({ folder, numFiles }: { folder?: string; numFiles: number }) {
|
||||
const config = useConfig();
|
||||
|
||||
const [opened, setOpen] = useState(false);
|
||||
const [opened, setOpen] = useQueryState('upopen', parseAsBoolean.withDefault(false));
|
||||
const [options, ephemeral, setOption, setEphemeral, changes, clearEphemeral, clearOptions] =
|
||||
useUploadOptionsStore(
|
||||
useShallow((state) => [
|
||||
@@ -62,9 +62,20 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str
|
||||
const { data: folders } = useSWR<Extract<Response['/api/user/folders'], Folder[]>>(
|
||||
'/api/user/folders?noincl=true',
|
||||
);
|
||||
const { data: settingsData } = useSWR<Response['/api/server/settings']>('/api/server/settings');
|
||||
|
||||
const combobox = useCombobox();
|
||||
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(() => {
|
||||
if (folder) return;
|
||||
|
||||
@@ -84,6 +95,7 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str
|
||||
<Stack gap='xs' my='sm'>
|
||||
<Select
|
||||
data={[
|
||||
{ value: 'default', label: `Default (${config.files.defaultExpiration ?? 'never'})` },
|
||||
{ value: 'never', label: 'Never' },
|
||||
{ value: '5min', label: '5 minutes' },
|
||||
{ value: '10min', label: '10 minutes' },
|
||||
@@ -121,7 +133,7 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str
|
||||
label={
|
||||
<>
|
||||
Deletes at{' '}
|
||||
{options.deletesAt !== 'never' ? (
|
||||
{options.deletesAt !== 'default' ? (
|
||||
<Badge variant='outline' size='xs'>
|
||||
saved
|
||||
</Badge>
|
||||
@@ -133,8 +145,8 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str
|
||||
The file will automatically delete itself after this time.{' '}
|
||||
{config.files.defaultExpiration ? (
|
||||
<>
|
||||
The default expiration time is <b>{ms(config.files.defaultExpiration as StringValue)}</b>{' '}
|
||||
(you can override this with the below option).
|
||||
The default expiration time is <b>{config.files.defaultExpiration}</b> (you can override
|
||||
this with the below option).
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -147,7 +159,7 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str
|
||||
}
|
||||
leftSection={<IconAlarmFilled size='1rem' />}
|
||||
value={options.deletesAt}
|
||||
onChange={(value) => setOption('deletesAt', value || 'never')}
|
||||
onChange={(value) => setOption('deletesAt', value || 'default')}
|
||||
comboboxProps={{
|
||||
withinPortal: true,
|
||||
portalProps: {
|
||||
@@ -263,9 +275,7 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options>
|
||||
<Combobox.Option defaultChecked={true} value='no folder'>
|
||||
No Folder
|
||||
</Combobox.Option>
|
||||
<Combobox.Option value='no folder'>No Folder</Combobox.Option>
|
||||
|
||||
{folders
|
||||
?.filter((f) => f.name.toLowerCase().includes(folderSearch.toLowerCase().trim()))
|
||||
@@ -278,7 +288,8 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
|
||||
<TextInput
|
||||
<Select
|
||||
data={domainOptions}
|
||||
label={
|
||||
<>
|
||||
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.'
|
||||
leftSection={<IconGlobe size='1rem' />}
|
||||
value={options.overrides_returnDomain ?? ''}
|
||||
onChange={(event) =>
|
||||
setOption(
|
||||
'overrides_returnDomain',
|
||||
event.currentTarget.value.trim() === '' ? null : event.currentTarget.value.trim(),
|
||||
)
|
||||
}
|
||||
onChange={(value) => setOption('overrides_returnDomain', value || null)}
|
||||
comboboxProps={{
|
||||
withinPortal: true,
|
||||
portalProps: {
|
||||
style: {
|
||||
zIndex: 100000000,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
export enum RenderMode {
|
||||
Katex,
|
||||
Markdown,
|
||||
Highlight,
|
||||
Katex = 'katex',
|
||||
Markdown = 'md',
|
||||
Highlight = 'hl',
|
||||
}
|
||||
|
||||
export function renderMode(extension: string) {
|
||||
switch (extension) {
|
||||
case 'tex':
|
||||
case 'katex':
|
||||
return RenderMode.Katex;
|
||||
case 'md':
|
||||
return RenderMode.Markdown;
|
||||
|
||||
@@ -185,7 +185,7 @@ export function uploadFiles(
|
||||
|
||||
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.imageCompressionPercent &&
|
||||
req.setRequestHeader('x-zipline-image-compression-percent', options.imageCompressionPercent.toString());
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useConfig } from '@/components/ConfigProvider';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import { randomCharacters } from '@/lib/random';
|
||||
import { ErrorBody } from '@/lib/response';
|
||||
import { UploadOptionsStore } from '@/lib/store/uploadOptions';
|
||||
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) {
|
||||
const file = files[i];
|
||||
const identifier = randomCharacters(8);
|
||||
const nChunks = Math.ceil(file.size / chunkSize);
|
||||
const chunks: {
|
||||
blob: Blob;
|
||||
@@ -129,6 +127,7 @@ export async function uploadPartialFiles(
|
||||
|
||||
let ready = true;
|
||||
let totalLoaded = 0;
|
||||
let identifier: string | undefined;
|
||||
const start = Date.now();
|
||||
|
||||
for (let j = 0; j !== nChunks; ++j) {
|
||||
@@ -172,7 +171,7 @@ export async function uploadPartialFiles(
|
||||
message: (res as ErrorBody).error,
|
||||
color: 'red',
|
||||
icon: <IconFileXFilled size='1rem' />,
|
||||
autoClose: true,
|
||||
autoClose: false,
|
||||
loading: false,
|
||||
});
|
||||
ready = false;
|
||||
@@ -190,6 +189,10 @@ export async function uploadPartialFiles(
|
||||
autoClose: false,
|
||||
});
|
||||
|
||||
if (j === 0) {
|
||||
identifier = res.partialIdentifier;
|
||||
}
|
||||
|
||||
if (j === chunks.length - 1) {
|
||||
notifications.update({
|
||||
id: 'upload-partial',
|
||||
@@ -215,6 +218,10 @@ export async function uploadPartialFiles(
|
||||
>
|
||||
Click here to copy the URL to clipboard while it's being processed.
|
||||
</Anchor>
|
||||
<br />
|
||||
<Anchor component={Link} href='/dashboard/files?popen=true'>
|
||||
View processing files
|
||||
</Anchor>
|
||||
</Text>
|
||||
),
|
||||
color: 'green',
|
||||
@@ -236,7 +243,7 @@ export async function uploadPartialFiles(
|
||||
);
|
||||
|
||||
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.imageCompressionPercent &&
|
||||
req.setRequestHeader(
|
||||
@@ -258,7 +265,7 @@ export async function uploadPartialFiles(
|
||||
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-lastchunk', j === chunks.length - 1 ? 'true' : 'false');
|
||||
req.setRequestHeader('x-zipline-p-content-type', file.type);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import GridTableSwitcher from '@/components/GridTableSwitcher';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Url } from '@/lib/db/models/url';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { useViewStore } from '@/lib/store/view';
|
||||
import {
|
||||
@@ -23,17 +24,16 @@ import { modals } from '@mantine/modals';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconClipboardCopy, IconExternalLink, IconLink, IconLinkOff } from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||
import { mutate } from 'swr';
|
||||
import UrlGridView from './views/UrlGridView';
|
||||
import UrlTableView from './views/UrlTableView';
|
||||
import { Url } from '@/lib/db/models/url';
|
||||
|
||||
export default function DashboardURLs() {
|
||||
const clipboard = useClipboard();
|
||||
const view = useViewStore((state) => state.urls);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [open, setOpen] = useQueryState('cuopen', parseAsBoolean.withDefault(false));
|
||||
|
||||
const form = useForm<{
|
||||
url: string;
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
import { useForm } from '@mantine/form';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconPhotoMinus, IconUserCancel, IconUserPlus } from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||
import { mutate } from 'swr';
|
||||
import UserGridView from './views/UserGridView';
|
||||
import UserTableView from './views/UserTableView';
|
||||
@@ -30,7 +30,7 @@ import UserTableView from './views/UserTableView';
|
||||
export default function DashboardUsers() {
|
||||
const currentUser = useUserStore((state) => state.user);
|
||||
const view = useViewStore((state) => state.users);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [open, setOpen] = useQueryState('cuseropen', parseAsBoolean.withDefault(false));
|
||||
|
||||
const form = useForm<{
|
||||
username: string;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useState } from 'react';
|
||||
import KaTeX from './KaTeX';
|
||||
import Markdown from './Markdown';
|
||||
import HighlightCode from './code/HighlightCode';
|
||||
import { parseAsStringEnum, useQueryState } from 'nuqs';
|
||||
|
||||
export function RenderAlert({
|
||||
renderer,
|
||||
@@ -46,9 +47,11 @@ export default function Render({
|
||||
language: string;
|
||||
code: string;
|
||||
}) {
|
||||
const [overrideRender] = useQueryState('orender', parseAsStringEnum<RenderMode>(Object.values(RenderMode)));
|
||||
|
||||
const [highlight, setHighlight] = useState(false);
|
||||
|
||||
switch (mode) {
|
||||
switch (overrideRender || mode) {
|
||||
case RenderMode.Katex:
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { ActionIcon, CopyButton, Paper, ScrollArea, Text, useMantineTheme } from '@mantine/core';
|
||||
import { IconCheck, IconClipboardCopy } from '@tabler/icons-react';
|
||||
import { ActionIcon, Button, CopyButton, Paper, ScrollArea, Text, useMantineTheme } from '@mantine/core';
|
||||
import { IconCheck, IconClipboardCopy, IconChevronDown, IconChevronUp } from '@tabler/icons-react';
|
||||
import hljs from 'highlight.js';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function HighlightCode({ language, code }: { language: string; code: string }) {
|
||||
const theme = useMantineTheme();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const lines = code.split('\n');
|
||||
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)) {
|
||||
language = 'text';
|
||||
@@ -33,9 +37,9 @@ export default function HighlightCode({ language, code }: { language: string; co
|
||||
</CopyButton>
|
||||
|
||||
<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'>
|
||||
{lines.map((line, i) => (
|
||||
{displayLines.map((line, i) => (
|
||||
<div key={i}>
|
||||
<Text
|
||||
component='span'
|
||||
@@ -44,7 +48,7 @@ export default function HighlightCode({ language, code }: { language: string; co
|
||||
mr='md'
|
||||
style={{ userSelect: 'none', fontFamily: 'monospace' }}
|
||||
>
|
||||
{lineNumbers[i]}
|
||||
{displayLineNumbers[i]}
|
||||
</Text>
|
||||
<span
|
||||
className='line'
|
||||
@@ -57,6 +61,18 @@ export default function HighlightCode({ language, code }: { language: string; co
|
||||
</code>
|
||||
</pre>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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 { readFile, readdir } from 'fs/promises';
|
||||
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);
|
||||
if (!statSync(fullPath).isDirectory()) return console.error('Not a directory:', directory);
|
||||
|
||||
await reloadSettings();
|
||||
|
||||
const { prisma } = await import('@/lib/db/index.js');
|
||||
let userId: string;
|
||||
|
||||
@@ -62,18 +69,41 @@ export async function importDir(directory: string, { id, folder }: { id?: string
|
||||
};
|
||||
}
|
||||
|
||||
const res = await prisma.file.createMany({
|
||||
data,
|
||||
});
|
||||
if (!skipDb) {
|
||||
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');
|
||||
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);
|
||||
console.log('Uploaded', data[i].name);
|
||||
const start = process.hrtime();
|
||||
|
||||
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.');
|
||||
|
||||
@@ -4,6 +4,7 @@ import { listUsers } from './commands/list-users';
|
||||
import { readConfig } from './commands/read-config';
|
||||
import { setUser } from './commands/set-user';
|
||||
import { importDir } from './commands/import-dir';
|
||||
import { exportConfig } from './commands/export-config';
|
||||
|
||||
const cli = new Command();
|
||||
|
||||
@@ -39,8 +40,17 @@ cli
|
||||
'-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',
|
||||
)
|
||||
.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')
|
||||
.argument('<directory>', 'the directory to import into Zipline')
|
||||
.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();
|
||||
|
||||
@@ -7,10 +7,14 @@ 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 { readdir, rm, writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { Worker } from 'worker_threads';
|
||||
import { getExtension } from './upload';
|
||||
import { randomCharacters } from '@/lib/random';
|
||||
import { bytes } from '@/lib/bytes';
|
||||
|
||||
const partialsCache = new Map<string, { length: number; options: UploadOptions }>();
|
||||
|
||||
const logger = log('api').c('upload');
|
||||
export async function handlePartialUpload({
|
||||
@@ -29,8 +33,34 @@ export async function handlePartialUpload({
|
||||
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';
|
||||
if (!options.partial.range || options.partial.range.length !== 3) throw 'Invalid partial upload';
|
||||
|
||||
if (options.partial.range[0] === 0) {
|
||||
const identifier = randomCharacters(8);
|
||||
partialsCache.set(identifier, { length: file.buffer.length, options });
|
||||
options.partial.identifier = identifier;
|
||||
} else {
|
||||
if (!options.partial.identifier || !partialsCache.has(options.partial.identifier))
|
||||
throw 'No partial upload identifier provided';
|
||||
}
|
||||
|
||||
const cache = partialsCache.get(options.partial.identifier);
|
||||
if (!cache) throw 'No partial upload cache found';
|
||||
|
||||
const prefix = `zipline_partial_${options.partial.identifier}_`;
|
||||
|
||||
if (cache.length + file.buffer.length > bytes(config.files.maxFileSize)) {
|
||||
partialsCache.delete(options.partial.identifier);
|
||||
|
||||
const tempFiles = await readdir(config.core.tempDirectory);
|
||||
await Promise.all(
|
||||
tempFiles.filter((f) => f.startsWith(prefix)).map((f) => rm(join(config.core.tempDirectory, f))),
|
||||
);
|
||||
|
||||
throw 'File is too large';
|
||||
}
|
||||
|
||||
cache.length += file.buffer.length;
|
||||
|
||||
const extension = getExtension(options.partial.filename, options.overrides?.extension);
|
||||
|
||||
@@ -72,7 +102,7 @@ export async function handlePartialUpload({
|
||||
|
||||
const tempFile = join(
|
||||
config.core.tempDirectory,
|
||||
`zipline_partial_${options.partial.identifier}_${options.partial.range[0]}_${options.partial.range[1]}`,
|
||||
`${prefix}${options.partial.range[0]}_${options.partial.range[1]}`,
|
||||
);
|
||||
await writeFile(tempFile, file.buffer);
|
||||
|
||||
@@ -97,7 +127,11 @@ export async function handlePartialUpload({
|
||||
},
|
||||
});
|
||||
|
||||
new Worker('./build/offload/partial.js', {
|
||||
const responseUrl = `${domain}${
|
||||
config.files.route === '/' || config.files.route === '' ? '' : `${config.files.route}`
|
||||
}/${fileUpload.name}`;
|
||||
|
||||
const worker = new Worker('./build/offload/partial.js', {
|
||||
workerData: {
|
||||
user: {
|
||||
id: req.user ? req.user.id : options.folder ? folder?.userId : undefined,
|
||||
@@ -109,17 +143,52 @@ export async function handlePartialUpload({
|
||||
},
|
||||
options,
|
||||
domain,
|
||||
responseUrl: `${domain}/${encodeURIComponent(fileUpload.name)}`,
|
||||
responseUrl,
|
||||
},
|
||||
});
|
||||
|
||||
worker.on('message', async (msg) => {
|
||||
if (msg.type === 'query') {
|
||||
let result;
|
||||
|
||||
switch (msg.query) {
|
||||
case 'incompleteFile.create':
|
||||
result = await prisma.incompleteFile.create(msg.data);
|
||||
break;
|
||||
case 'incompleteFile.update':
|
||||
result = await prisma.incompleteFile.update(msg.data);
|
||||
break;
|
||||
case 'file.update':
|
||||
result = await prisma.file.update(msg.data);
|
||||
break;
|
||||
case 'user.findUnique':
|
||||
result = await prisma.user.findUnique(msg.data);
|
||||
break;
|
||||
default:
|
||||
console.error(`Unknown query type: ${msg.query}`);
|
||||
result = null;
|
||||
}
|
||||
|
||||
worker.postMessage({
|
||||
type: 'response',
|
||||
id: msg.id,
|
||||
result: JSON.stringify(result),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
response.files.push({
|
||||
id: fileUpload.id,
|
||||
type: fileUpload.type,
|
||||
url: `${domain}/${encodeURIComponent(fileUpload.name)}`,
|
||||
url: responseUrl,
|
||||
pending: true,
|
||||
});
|
||||
|
||||
partialsCache.delete(options.partial.identifier);
|
||||
}
|
||||
|
||||
response.partialSuccess = true;
|
||||
if (options.partial.range[0] === 0) {
|
||||
response.partialIdentifier = options.partial.identifier;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,14 +65,14 @@ export async function handleFile({
|
||||
|
||||
if (options.overrides?.filename || format === 'name') {
|
||||
if (options.overrides?.filename) fileName = decodeURIComponent(options.overrides!.filename!);
|
||||
const fullFileName = `${fileName}${extension}`;
|
||||
|
||||
const existing = await prisma.file.findFirst({
|
||||
where: {
|
||||
name: {
|
||||
startsWith: fileName,
|
||||
},
|
||||
name: fullFileName,
|
||||
},
|
||||
});
|
||||
if (existing) throw `A file with the name "${fileName}*" already exists`;
|
||||
if (existing) throw `A file with the name "${fullFileName}" already exists`;
|
||||
}
|
||||
|
||||
let mimetype = file.mimetype;
|
||||
@@ -110,11 +110,17 @@ export async function handleFile({
|
||||
}
|
||||
|
||||
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}`);
|
||||
if (mimetype.startsWith('image/') && config.files.removeGpsMetadata) {
|
||||
const removed = removeGps(file.buffer);
|
||||
|
||||
if (removed) {
|
||||
logger.c('gps').debug(`removed gps metadata from ${file.filename}`, {
|
||||
nsize: bytes(file.buffer.length),
|
||||
osize: bytes(file.file.bytesRead),
|
||||
});
|
||||
|
||||
removedGps = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,14 +136,18 @@ export async function handleFile({
|
||||
},
|
||||
...(options.maxViews && { maxViews: options.maxViews }),
|
||||
...(options.password && { password: await hashPassword(options.password) }),
|
||||
...(options.deletesAt && { deletesAt: options.deletesAt }),
|
||||
...(options.deletesAt && options.deletesAt !== 'never'
|
||||
? { deletesAt: options.deletesAt }
|
||||
: { deletesAt: null }),
|
||||
...(options.folder && { Folder: { connect: { id: options.folder } } }),
|
||||
...(options.addOriginalName && { originalName: file.filename }),
|
||||
},
|
||||
select: fileSelect,
|
||||
});
|
||||
|
||||
await datasource.put(fileUpload.name, file.buffer);
|
||||
await datasource.put(fileUpload.name, file.buffer, {
|
||||
mimetype: fileUpload.type,
|
||||
});
|
||||
|
||||
const responseUrl = `${domain}${
|
||||
config.files.route === '/' || config.files.route === '' ? '' : `${config.files.route}`
|
||||
|
||||
@@ -4,8 +4,8 @@ import { validateConfigObject, Config } from './validate';
|
||||
let config: Config;
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __config__: Config;
|
||||
var __tamperedConfig__: string[];
|
||||
}
|
||||
|
||||
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', 'string', 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<
|
||||
Config,
|
||||
'oauth' | 'datasource' | 'core' | 'discord' | 'httpWebhook' | 'ratelimit'
|
||||
'oauth' | 'datasource' | 'core' | 'discord' | 'httpWebhook' | 'ratelimit' | 'ssl'
|
||||
> & {
|
||||
oauthEnabled: ReturnType<typeof enabled>;
|
||||
oauth: {
|
||||
@@ -14,7 +14,16 @@ export type SafeConfig = Omit<
|
||||
};
|
||||
|
||||
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).oauth = {
|
||||
|
||||
@@ -2,7 +2,8 @@ import { tmpdir } from 'os';
|
||||
import { join, resolve } from 'path';
|
||||
import { type ZodIssue, z } from 'zod';
|
||||
import { log } from '../logger';
|
||||
import { PROP_TO_ENV, ParsedConfig } from './read';
|
||||
import { ParsedConfig } from './read';
|
||||
import { PROP_TO_ENV } from './read/env';
|
||||
|
||||
declare global {
|
||||
// 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'),
|
||||
removeGpsMetadata: z.boolean().default(false),
|
||||
randomWordsNumAdjectives: z.number().default(3),
|
||||
randomWordsSeperator: z.string().default('-'),
|
||||
randomWordsSeparator: z.string().default('-'),
|
||||
}),
|
||||
urls: z.object({
|
||||
route: z.string().startsWith('/').min(1).trim().toLowerCase().default('/go'),
|
||||
@@ -112,6 +113,7 @@ export const schema = z.object({
|
||||
bucket: z.string(),
|
||||
endpoint: z.string().nullable().default(null),
|
||||
forcePathStyle: z.boolean().default(false),
|
||||
subdirectory: z.string().nullable().default(null),
|
||||
})
|
||||
.optional(),
|
||||
local: z
|
||||
@@ -159,6 +161,8 @@ export const schema = z.object({
|
||||
adminOnly: z.boolean().default(false),
|
||||
showUserSpecific: z.boolean().default(true),
|
||||
}),
|
||||
versionChecking: z.boolean().default(true),
|
||||
versionAPI: z.string().url().default('https://zipline-version.diced.sh/'),
|
||||
}),
|
||||
invites: z.object({
|
||||
enabled: z.boolean().default(true),
|
||||
@@ -218,12 +222,16 @@ export const schema = z.object({
|
||||
clientId: z.string(),
|
||||
clientSecret: z.string(),
|
||||
redirectUri: z.string().url().nullable().default(null),
|
||||
allowedIds: z.array(z.string()).default([]),
|
||||
deniedIds: z.array(z.string()).default([]),
|
||||
})
|
||||
.or(
|
||||
z.object({
|
||||
clientId: z.undefined(),
|
||||
clientSecret: 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
|
||||
|
||||
@@ -4,7 +4,7 @@ export abstract class Datasource {
|
||||
public name: string | undefined;
|
||||
|
||||
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, options?: { mimetype?: string }): Promise<void>;
|
||||
public abstract delete(file: string): Promise<void>;
|
||||
public abstract size(file: string): Promise<number>;
|
||||
public abstract totalSize(): Promise<number>;
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { Readable } from 'stream';
|
||||
import { Datasource } from './Datasource';
|
||||
import {
|
||||
DeleteObjectCommand,
|
||||
DeleteObjectsCommand,
|
||||
GetObjectCommand,
|
||||
ListBucketsCommand,
|
||||
ListObjectsCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import Logger, { log } from '../logger';
|
||||
import { ReadableStream } from 'stream/web';
|
||||
import { NodeHttpHandler } from '@smithy/node-http-handler';
|
||||
import { Agent as HttpAgent } from 'http';
|
||||
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) {
|
||||
return code >= 200 && code < 300;
|
||||
@@ -32,6 +32,7 @@ export class S3Datasource extends Datasource {
|
||||
bucket: string;
|
||||
endpoint?: string | null;
|
||||
forcePathStyle?: boolean;
|
||||
subdirectory?: string | null;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
@@ -58,37 +59,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 {
|
||||
const res = await this.client.send(new ListBucketsCommand());
|
||||
if (!isOk(res.$metadata.httpStatusCode || 0)) {
|
||||
const putObject = new PutObjectCommand({
|
||||
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
|
||||
.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');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!res.Buckets?.find((bucket) => bucket.Name === this.options.bucket)) {
|
||||
this.logger.error(`bucket ${this.options.bucket} does not exist`).error('zipline will now exit');
|
||||
const readRes = await this.client.send(readObject);
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
console.error(e);
|
||||
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');
|
||||
process.exit(1);
|
||||
} 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> {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: this.options.bucket,
|
||||
Key: file,
|
||||
Key: this.key(file),
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -111,11 +158,18 @@ export class S3Datasource extends Datasource {
|
||||
}
|
||||
}
|
||||
|
||||
public async put(file: string, data: Buffer): Promise<void> {
|
||||
public async put(
|
||||
file: string,
|
||||
data: Buffer,
|
||||
options: {
|
||||
mimetype?: string;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: this.options.bucket,
|
||||
Key: file,
|
||||
Key: this.key(file),
|
||||
Body: data,
|
||||
...(options.mimetype ? { ContentType: options.mimetype } : {}),
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -135,7 +189,7 @@ export class S3Datasource extends Datasource {
|
||||
public async delete(file: string): Promise<void> {
|
||||
const command = new DeleteObjectCommand({
|
||||
Bucket: this.options.bucket,
|
||||
Key: file,
|
||||
Key: this.key(file),
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -154,7 +208,7 @@ export class S3Datasource extends Datasource {
|
||||
public async size(file: string): Promise<number> {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: this.options.bucket,
|
||||
Key: file,
|
||||
Key: this.key(file),
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -179,6 +233,8 @@ export class S3Datasource extends Datasource {
|
||||
public async totalSize(): Promise<number> {
|
||||
const command = new ListObjectsCommand({
|
||||
Bucket: this.options.bucket,
|
||||
Prefix: this.options.subdirectory ?? undefined,
|
||||
Delimiter: this.options.subdirectory ? undefined : '/',
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -224,7 +280,7 @@ export class S3Datasource extends Datasource {
|
||||
public async range(file: string, start: number, end: number): Promise<Readable> {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: this.options.bucket,
|
||||
Key: file,
|
||||
Key: this.key(file),
|
||||
Range: `bytes=${start}-${end}`,
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import { S3Datasource } from './S3';
|
||||
let datasource: Datasource;
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __datasource__: Datasource;
|
||||
}
|
||||
|
||||
@@ -28,6 +27,7 @@ function getDatasource(conf?: typeof config): void {
|
||||
bucket: config.datasource.s3!.bucket,
|
||||
endpoint: config.datasource.s3?.endpoint,
|
||||
forcePathStyle: config.datasource.s3?.forcePathStyle,
|
||||
subdirectory: config.datasource.s3?.subdirectory,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { log } from '@/lib/logger';
|
||||
import { Prisma, PrismaClient } from '@prisma/client';
|
||||
import { Prisma, PrismaClient } from '../../../generated/client';
|
||||
import { userViewSchema } from './models/user';
|
||||
import { metricDataSchema } from './models/metric';
|
||||
import { metadataSchema } from './models/incompleteFile';
|
||||
@@ -9,7 +9,6 @@ const building = !!process.env.ZIPLINE_BUILD;
|
||||
let prisma: ExtendedPrismaClient;
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __db__: ExtendedPrismaClient;
|
||||
}
|
||||
|
||||
|
||||
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>;
|
||||
}
|
||||
@@ -1,15 +1,28 @@
|
||||
import { Migrate } from '@prisma/migrate/dist/Migrate';
|
||||
import { ensureDatabaseExists } from '@prisma/migrate/dist/utils/ensureDatabaseExists';
|
||||
import { Migrate } from '@prisma/migrate';
|
||||
import { log } from '@/lib/logger';
|
||||
import { loadSchemaContext } from '@prisma/internals';
|
||||
|
||||
// @ts-ignore
|
||||
import { ensureDatabaseExists } from '@prisma/migrate/dist/utils/ensureDatabaseExists';
|
||||
|
||||
export async function runMigrations() {
|
||||
const migrate = new Migrate('./prisma/schema.prisma');
|
||||
const schemaContext = await loadSchemaContext({
|
||||
schemaPathFromArg: './prisma/schema.prisma',
|
||||
printLoadMessage: false,
|
||||
});
|
||||
|
||||
const migrate = await Migrate.setup({
|
||||
schemaContext,
|
||||
migrationsDirPath: './prisma/migrations',
|
||||
});
|
||||
|
||||
const logger = log('migrations');
|
||||
logger.debug('running migrations...');
|
||||
|
||||
try {
|
||||
logger.debug('ensuring database exists...');
|
||||
const dbCreated = await ensureDatabaseExists('apply', './prisma/schema.prisma');
|
||||
|
||||
const dbCreated = await ensureDatabaseExists(schemaContext.primaryDatasource);
|
||||
if (dbCreated) {
|
||||
logger.info('database created');
|
||||
}
|
||||
|
||||
13
src/lib/db/migration/types.d.ts
vendored
13
src/lib/db/migration/types.d.ts
vendored
@@ -1,13 +0,0 @@
|
||||
// types for @prisma/migrate so vscode cant complain
|
||||
|
||||
declare module '@prisma/migrate/dist/Migrate' {
|
||||
export class Migrate {
|
||||
constructor(schemaPath: string);
|
||||
public applyMigrations(): Promise<{ appliedMigrationNames: string[] }>;
|
||||
public stop(): void;
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@prisma/migrate/dist/utils/ensureDatabaseExists' {
|
||||
export function ensureDatabaseExists(command: string, schemaPath: string): Promise<boolean>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Folder as PrismaFolder } from '@prisma/client';
|
||||
import type { Folder as PrismaFolder } from '../../../../generated/client';
|
||||
import { File, cleanFiles } from './file';
|
||||
|
||||
export type Folder = PrismaFolder & {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IncompleteFileStatus } from '@prisma/client';
|
||||
import { IncompleteFileStatus } from '../../../../generated/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
export type IncompleteFile = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Invite as PrismaInvite } from '@prisma/client';
|
||||
import type { Invite as PrismaInvite } from '../../../../generated/client';
|
||||
import type { User } from './user';
|
||||
|
||||
export type Invite = PrismaInvite & {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Url as PrismaUrl } from '@prisma/client';
|
||||
import type { Url as PrismaUrl } from '../../../../generated/client';
|
||||
|
||||
export type Url = PrismaUrl & {
|
||||
similarity?: number;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { OAuthProvider, UserPasskey, UserQuota } from '@prisma/client';
|
||||
import { OAuthProvider, UserPasskey, UserQuota } from '../../../../generated/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
export type User = {
|
||||
@@ -43,6 +43,8 @@ export const userViewSchema = z
|
||||
enabled: z.boolean().nullish(),
|
||||
align: z.enum(['left', 'center', 'right']).nullish(),
|
||||
showMimetype: z.boolean().nullish(),
|
||||
showTags: z.boolean().nullish(),
|
||||
showFolder: z.boolean().nullish(),
|
||||
content: z.string().nullish(),
|
||||
embed: z.boolean().nullish(),
|
||||
embedTitle: z.string().nullish(),
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
// @ts-ignore
|
||||
import * as gmr from '@xoi/gps-metadata-remover';
|
||||
|
||||
export const removeLocation = gmr.removeLocation as (
|
||||
photoUri: string,
|
||||
read: ReadFunction,
|
||||
write: WriteFunction,
|
||||
) => Promise<boolean>;
|
||||
|
||||
export type ReadFunction = (size: number, offset: number) => Promise<Buffer>;
|
||||
export type WriteFunction = (writeValue: string, entryOffset: number, encoding: string) => Promise<void>;
|
||||
|
||||
export async function removeGps(buffer: Buffer): Promise<boolean> {
|
||||
const read = (size: number, offset: number) => Promise.resolve(buffer.subarray(offset, offset + size));
|
||||
const write = (writeValue: string, entryOffset: number, encoding: string) => {
|
||||
buffer.write(writeValue, entryOffset, encoding as BufferEncoding);
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
return removeLocation('', read, write);
|
||||
}
|
||||
14
src/lib/gps/constants.ts
Normal file
14
src/lib/gps/constants.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export const EXIF_JPEG_TAG = 0x45786966;
|
||||
export const EXIF_PNG_TAG = 0x65584966;
|
||||
|
||||
export const GPS_IFD_TAG = 0x8825;
|
||||
|
||||
export const JPEG_EXIF_TAG = 0xffd8ffe1;
|
||||
export const JPEG_APP1_TAG = 0xffe1;
|
||||
export const JPEG_JFIF_TAG = 0xffd8ffe0;
|
||||
|
||||
export const TIFF_LE = 0x49492a00;
|
||||
export const TIFF_BE = 0x4d4d002a;
|
||||
|
||||
export const PNG_TAG = 0x89504e47;
|
||||
export const PNG_IEND = 0x49454e44;
|
||||
131
src/lib/gps/index.ts
Normal file
131
src/lib/gps/index.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
// heavily modified from @xoi/gps-metadata-remover to fit the needs of zipline
|
||||
|
||||
import {
|
||||
PNG_TAG,
|
||||
PNG_IEND,
|
||||
EXIF_PNG_TAG,
|
||||
JPEG_EXIF_TAG,
|
||||
JPEG_JFIF_TAG,
|
||||
JPEG_APP1_TAG,
|
||||
EXIF_JPEG_TAG,
|
||||
TIFF_LE,
|
||||
TIFF_BE,
|
||||
GPS_IFD_TAG,
|
||||
} from './constants';
|
||||
|
||||
function isLE(buffer: Buffer): boolean {
|
||||
return buffer.readUInt32BE(0) === TIFF_LE;
|
||||
}
|
||||
|
||||
function removeGpsEntries(buffer: Buffer, offset: number, le: boolean): void {
|
||||
const numEntries = le ? buffer.readUInt16LE(offset) : buffer.readUInt16BE(offset);
|
||||
|
||||
const fieldsStart = offset + 2;
|
||||
const toClear = numEntries * 12;
|
||||
const zeroBuffer = Buffer.alloc(toClear);
|
||||
|
||||
zeroBuffer.copy(buffer, fieldsStart);
|
||||
}
|
||||
|
||||
function parseExifTag(buffer: Buffer, offset: number, le: boolean) {
|
||||
const tag = le ? buffer.readUInt16LE(offset) : buffer.readUInt16BE(offset);
|
||||
const field = le ? buffer.readUInt16LE(offset + 2) : buffer.readUInt16BE(offset + 2);
|
||||
const count = le ? buffer.readUInt32LE(offset + 4) : buffer.readUInt32BE(offset + 4);
|
||||
const valueOffset = le ? buffer.readUInt32LE(offset + 8) : buffer.readUInt32BE(offset + 8);
|
||||
|
||||
return { tag, field, count, valueOffset };
|
||||
}
|
||||
|
||||
function locateGpsTagOffset(buffer: Buffer, offset: number, le: boolean): number {
|
||||
const numEntries = le ? buffer.readUInt16LE(offset) : buffer.readUInt16BE(offset);
|
||||
const fieldsStart = offset + 2;
|
||||
|
||||
for (let i = 0; i < numEntries; i++) {
|
||||
const entryOffset = fieldsStart + i * 12;
|
||||
const { tag, field, count, valueOffset } = parseExifTag(buffer, entryOffset, le);
|
||||
|
||||
if (tag === GPS_IFD_TAG && field === 4 && count === 1) {
|
||||
return valueOffset;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
function stripGpsFromTiff(buffer: Buffer, offset: number, le: boolean): boolean {
|
||||
const gpsDirectoryOffset = locateGpsTagOffset(buffer, offset, le);
|
||||
|
||||
if (gpsDirectoryOffset >= 0) {
|
||||
removeGpsEntries(buffer, gpsDirectoryOffset, le);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function stripGpsFromExif(buffer: Buffer, offset: number): boolean {
|
||||
const headerSlice = buffer.subarray(offset, offset + 8);
|
||||
const littleEndian = isLE(headerSlice);
|
||||
const gpsDirectoryOffset = locateGpsTagOffset(buffer, offset + 8, littleEndian);
|
||||
|
||||
if (gpsDirectoryOffset >= 0) {
|
||||
removeGpsEntries(buffer, gpsDirectoryOffset + offset, littleEndian);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function removeGps(buffer: Buffer): boolean {
|
||||
const signature = buffer.readUInt32BE(0);
|
||||
let offset = 0;
|
||||
let removed = false;
|
||||
|
||||
if (signature === PNG_TAG) {
|
||||
offset += 8;
|
||||
|
||||
let chunkLength = 0;
|
||||
let chunkType = 0;
|
||||
while (chunkType !== PNG_IEND) {
|
||||
chunkLength = buffer.readUInt32BE(offset);
|
||||
chunkType = buffer.readUInt32BE(offset + 4);
|
||||
|
||||
if (chunkType === EXIF_PNG_TAG) {
|
||||
const exifDataOffset = offset + 8;
|
||||
removed = stripGpsFromExif(buffer, exifDataOffset);
|
||||
}
|
||||
|
||||
if (chunkType !== PNG_IEND) {
|
||||
offset += 12 + chunkLength;
|
||||
}
|
||||
}
|
||||
} else if (signature === JPEG_EXIF_TAG || signature === JPEG_JFIF_TAG) {
|
||||
offset += 4;
|
||||
|
||||
if (signature === JPEG_JFIF_TAG) {
|
||||
const jfifSegmentSize = buffer.readUInt16BE(offset);
|
||||
offset += jfifSegmentSize;
|
||||
const nextMarker = buffer.readUInt16BE(offset);
|
||||
|
||||
if (nextMarker === JPEG_APP1_TAG) {
|
||||
offset += 2;
|
||||
} else {
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
|
||||
const exifSignature = buffer.readUInt32BE(offset + 2);
|
||||
|
||||
if (exifSignature === EXIF_JPEG_TAG) {
|
||||
offset += 8;
|
||||
removed = stripGpsFromExif(buffer, offset);
|
||||
}
|
||||
} else if (signature === TIFF_LE || signature === TIFF_BE) {
|
||||
const littleEndian = isLE(buffer);
|
||||
offset += 4;
|
||||
const tiffIfdOffset = littleEndian ? buffer.readUInt32LE(offset) : buffer.readUInt32BE(offset);
|
||||
removed = stripGpsFromTiff(buffer, tiffIfdOffset, littleEndian);
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user