mirror of
https://github.com/diced/zipline.git
synced 2025-12-22 23:26:36 -08:00
Compare commits
32 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 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -23,6 +23,7 @@
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
.idea
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
@@ -46,4 +47,7 @@ next-env.d.ts
|
||||
uploads*/
|
||||
*.crt
|
||||
*.key
|
||||
generated
|
||||
generated
|
||||
|
||||
# nix dev env
|
||||
/.psql_db/
|
||||
1
.prettierignore
Executable file
1
.prettierignore
Executable file
@@ -0,0 +1 @@
|
||||
pnpm-lock.yaml
|
||||
18
README.md
18
README.md
@@ -198,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
|
||||
|
||||
|
||||
@@ -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
|
||||
'';
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
71
package.json
71
package.json
@@ -2,7 +2,7 @@
|
||||
"name": "zipline",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"version": "4.1.2",
|
||||
"version": "4.2.1",
|
||||
"scripts": {
|
||||
"build": "cross-env pnpm run --stream \"/^build:.*/\"",
|
||||
"build:prisma": "prisma generate --no-hints",
|
||||
@@ -26,8 +26,8 @@
|
||||
"docker:compose:dev:logs": "docker-compose --file docker-compose.dev.yml logs -f"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.726.1",
|
||||
"@aws-sdk/lib-storage": "3.726.1",
|
||||
"@aws-sdk/client-s3": "3.832.0",
|
||||
"@aws-sdk/lib-storage": "3.832.0",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.0.1",
|
||||
"@fastify/multipart": "^9.0.3",
|
||||
@@ -35,18 +35,18 @@
|
||||
"@fastify/sensible": "^6.0.3",
|
||||
"@fastify/static": "^8.2.0",
|
||||
"@github/webauthn-json": "^2.1.1",
|
||||
"@mantine/charts": "^8.0.2",
|
||||
"@mantine/code-highlight": "^8.0.2",
|
||||
"@mantine/core": "^8.0.2",
|
||||
"@mantine/dates": "^8.0.2",
|
||||
"@mantine/dropzone": "^8.0.2",
|
||||
"@mantine/form": "^8.0.2",
|
||||
"@mantine/hooks": "^8.0.2",
|
||||
"@mantine/modals": "^8.0.2",
|
||||
"@mantine/notifications": "^8.0.2",
|
||||
"@prisma/client": "^6.9.0",
|
||||
"@prisma/internals": "^6.9.0",
|
||||
"@prisma/migrate": "^6.9.0",
|
||||
"@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",
|
||||
@@ -57,9 +57,8 @@
|
||||
"cross-env": "^7.0.3",
|
||||
"dayjs": "^1.11.13",
|
||||
"dotenv": "^16.5.0",
|
||||
"exif-be-gone": "^1.5.1",
|
||||
"fast-glob": "^3.3.3",
|
||||
"fastify": "^5.3.3",
|
||||
"fastify": "^5.4.0",
|
||||
"fastify-plugin": "^5.0.1",
|
||||
"fflate": "^0.8.2",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
@@ -67,13 +66,13 @@
|
||||
"iron-session": "^8.0.4",
|
||||
"isomorphic-dompurify": "^2.25.0",
|
||||
"katex": "^0.16.22",
|
||||
"mantine-datatable": "^7.17.1",
|
||||
"mantine-datatable": "^8.1.1",
|
||||
"ms": "^2.1.3",
|
||||
"multer": "2.0.1",
|
||||
"next": "^15.3.3",
|
||||
"next": "^15.3.4",
|
||||
"nuqs": "^2.4.3",
|
||||
"otplib": "^12.0.1",
|
||||
"prisma": "^6.9.0",
|
||||
"prisma": "^6.10.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
@@ -81,41 +80,41 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sharp": "^0.34.2",
|
||||
"swr": "^2.3.3",
|
||||
"zod": "^3.25.51",
|
||||
"typescript-eslint": "^8.34.1",
|
||||
"zod": "^3.25.67",
|
||||
"zustand": "^5.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.2.9",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@eslint/js": "^9.28.0",
|
||||
"@next/eslint-plugin-next": "^15.3.4",
|
||||
"@types/bytes": "^3.1.5",
|
||||
"@types/fluent-ffmpeg": "^2.1.27",
|
||||
"@types/katex": "^0.16.7",
|
||||
"@types/ms": "^2.1.0",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.15.30",
|
||||
"@types/multer": "^1.4.13",
|
||||
"@types/node": "^24.0.3",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^19.1.6",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@typescript-eslint/eslint-plugin": "^8.33.1",
|
||||
"@typescript-eslint/parser": "^8.33.1",
|
||||
"eslint": "^9.28.0",
|
||||
"eslint-config-next": "^15.3.3",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint-config-next": "^15.3.4",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-plugin-prettier": "^5.4.1",
|
||||
"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.4",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"prettier": "^3.5.3",
|
||||
"sass": "^1.89.1",
|
||||
"sass": "^1.89.2",
|
||||
"tsc-alias": "^1.8.16",
|
||||
"tsup": "^8.5.0",
|
||||
"tsx": "^4.19.4",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||
"packageManager": "pnpm@10.12.1"
|
||||
}
|
||||
|
||||
3560
pnpm-lock.yaml
generated
3560
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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[];
|
||||
@@ -82,6 +82,8 @@ model Zipline {
|
||||
oauthDiscordClientId String?
|
||||
oauthDiscordClientSecret String?
|
||||
oauthDiscordRedirectUri String?
|
||||
oauthDiscordAllowedIds String[] @default([])
|
||||
oauthDiscordDeniedIds String[] @default([])
|
||||
|
||||
oauthGoogleClientId String?
|
||||
oauthGoogleClientSecret String?
|
||||
@@ -133,6 +135,8 @@ model Zipline {
|
||||
pwaDescription String @default("Zipline")
|
||||
pwaThemeColor String @default("#000000")
|
||||
pwaBackgroundColor String @default("#000000")
|
||||
|
||||
domains String[] @default([])
|
||||
}
|
||||
|
||||
model User {
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -11,7 +11,7 @@ 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';
|
||||
@@ -30,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>
|
||||
);
|
||||
@@ -83,57 +83,60 @@ export default function DashboardFileType({
|
||||
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();
|
||||
getText();
|
||||
} else if (overrideType === 'text' || type === 'text') {
|
||||
gettext();
|
||||
getText();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
@@ -177,7 +180,10 @@ export default function DashboardFileType({
|
||||
/>
|
||||
) : (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'
|
||||
@@ -203,7 +209,7 @@ export default function DashboardFileType({
|
||||
<Center>
|
||||
<MantineImage
|
||||
src={dbFile ? `/raw/${file.name}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
|
||||
alt={file.name}
|
||||
alt={file.name || 'Image'}
|
||||
style={{
|
||||
cursor: allowZoom ? 'zoom-in' : 'default',
|
||||
maxWidth: '70vw',
|
||||
@@ -217,7 +223,7 @@ export default function DashboardFileType({
|
||||
src={
|
||||
dbFile ? `/raw/${file.name}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)
|
||||
}
|
||||
alt={file.name}
|
||||
alt={file.name || 'Image'}
|
||||
style={{
|
||||
maxWidth: '95vw',
|
||||
maxHeight: '95vh',
|
||||
@@ -234,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':
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import {
|
||||
IconCopy,
|
||||
IconDownload,
|
||||
IconExternalLink,
|
||||
IconFile,
|
||||
IconGridPatternFilled,
|
||||
@@ -306,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>
|
||||
@@ -521,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'
|
||||
|
||||
@@ -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,9 +15,11 @@ const StorageGraph = dynamic(() => import('./parts/StorageGraph'));
|
||||
const ViewsGraph = dynamic(() => import('./parts/ViewsGraph'));
|
||||
|
||||
export default function DashboardMetrics() {
|
||||
const today = dayjs();
|
||||
|
||||
const [dateRange, setDateRange] = useState<[string | null, string | null]>([
|
||||
new Date(Date.now() - 86400000 * 7).toISOString(),
|
||||
new Date().toISOString(),
|
||||
today.subtract(7, 'day').toISOString(),
|
||||
today.toISOString(),
|
||||
]);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -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()}
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,7 @@ 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 };
|
||||
@@ -38,25 +38,30 @@ export default function ServerSettingsFeatures({
|
||||
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,
|
||||
featuresVersionChecking: data?.featuresVersionChecking ?? true,
|
||||
featuresVersionAPI: data?.featuresVersionAPI ?? 'https://zipline-version.diced.sh/',
|
||||
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]);
|
||||
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
@@ -105,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`}>
|
||||
@@ -187,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 {
|
||||
|
||||
@@ -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,7 +30,6 @@ 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';
|
||||
@@ -63,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;
|
||||
|
||||
@@ -85,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' },
|
||||
@@ -122,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>
|
||||
@@ -134,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).
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -148,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: {
|
||||
@@ -264,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()))
|
||||
@@ -279,7 +288,8 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
|
||||
<TextInput
|
||||
<Select
|
||||
data={domainOptions}
|
||||
label={
|
||||
<>
|
||||
Override Domain{' '}
|
||||
@@ -293,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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -218,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',
|
||||
@@ -239,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(
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -45,4 +46,11 @@ cli
|
||||
.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();
|
||||
|
||||
@@ -127,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,
|
||||
@@ -139,14 +143,44 @@ 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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -112,9 +112,9 @@ export async function handleFile({
|
||||
let removedGps = false;
|
||||
|
||||
if (mimetype.startsWith('image/') && config.files.removeGpsMetadata) {
|
||||
file.buffer = await removeGps(file.buffer);
|
||||
const removed = removeGps(file.buffer);
|
||||
|
||||
if (file.buffer.length < file.file.bytesRead) {
|
||||
if (removed) {
|
||||
logger.c('gps').debug(`removed gps metadata from ${file.filename}`, {
|
||||
nsize: bytes(file.buffer.length),
|
||||
osize: bytes(file.file.bytesRead),
|
||||
@@ -136,7 +136,9 @@ 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 }),
|
||||
},
|
||||
|
||||
@@ -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,479 +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.s3.subdirectory': 'DATASOURCE_S3_SUBDIRECTORY',
|
||||
|
||||
'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',
|
||||
|
||||
featuresVersionChecking: 'features.versionChecking',
|
||||
featuresVersionAPI: 'features.versionAPI',
|
||||
|
||||
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.s3.subdirectory', 'string'),
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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'),
|
||||
@@ -221,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
|
||||
|
||||
@@ -7,7 +7,6 @@ import { S3Datasource } from './S3';
|
||||
let datasource: Datasource;
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __datasource__: Datasource;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ const building = !!process.env.ZIPLINE_BUILD;
|
||||
let prisma: ExtendedPrismaClient;
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __db__: ExtendedPrismaClient;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import ExifTransformer from 'exif-be-gone';
|
||||
import { PassThrough } from 'stream';
|
||||
|
||||
export async function removeGps(buffer: Buffer): Promise<Buffer> {
|
||||
return new Promise<Buffer>((resolve, reject) => {
|
||||
const input = new PassThrough();
|
||||
input.end(buffer);
|
||||
|
||||
const transformer = new ExifTransformer();
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
transformer.on('data', (chunk: Buffer) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
transformer.once('error', (err: Error) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
transformer.once('end', () => {
|
||||
const stripped = Buffer.concat(chunks);
|
||||
resolve(stripped);
|
||||
});
|
||||
|
||||
input.pipe(transformer);
|
||||
});
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import useSWR from 'swr';
|
||||
import { Response } from '../api/response';
|
||||
const f = async () => {
|
||||
const res = await fetch('/api/version', {
|
||||
cache: 'force-cache',
|
||||
});
|
||||
const res = await fetch('/api/version');
|
||||
if (!res.ok) throw new Error('Failed to fetch version');
|
||||
|
||||
const r = await res.json();
|
||||
return r;
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import { persist, subscribeWithSelector } from 'zustand/middleware';
|
||||
import type { Config } from '../config/validate';
|
||||
|
||||
export const defaultUploadOptions: UploadOptionsStore['options'] = {
|
||||
deletesAt: 'never',
|
||||
deletesAt: 'default',
|
||||
format: 'default',
|
||||
imageCompressionPercent: null,
|
||||
maxViews: null,
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface WorkerTask<Data = any> extends Task {
|
||||
path: string;
|
||||
data: Data;
|
||||
|
||||
onMessage?: (message: any) => void;
|
||||
|
||||
worker?: Worker;
|
||||
}
|
||||
|
||||
@@ -109,6 +111,10 @@ export class Tasks {
|
||||
this.tasks.splice(index, 1);
|
||||
});
|
||||
|
||||
if (task.onMessage) {
|
||||
worker.on('message', task.onMessage.bind(worker));
|
||||
}
|
||||
|
||||
task.worker = worker;
|
||||
|
||||
this.logger.debug('started worker', {
|
||||
@@ -127,12 +133,19 @@ export class Tasks {
|
||||
if (start) this.startInterval(this.tasks[len - 1] as IntervalTask);
|
||||
}
|
||||
|
||||
public worker<Data = any>(id: string, path: string, data: Data, start: boolean = false): WorkerTask<Data> {
|
||||
public worker<Data = any>(
|
||||
id: string,
|
||||
path: string,
|
||||
data: Data,
|
||||
onMessage: WorkerTask['onMessage'],
|
||||
start: boolean = false,
|
||||
): WorkerTask<Data> {
|
||||
const len = this.tasks.push({
|
||||
id,
|
||||
path,
|
||||
data,
|
||||
started: false,
|
||||
onMessage,
|
||||
} as WorkerTask<Data>);
|
||||
|
||||
if (start) this.startWorker(this.tasks[len - 1] as WorkerTask<Data>);
|
||||
|
||||
33
src/lib/theme/builtins/midnight_purple.theme.json
Normal file
33
src/lib/theme/builtins/midnight_purple.theme.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "Midnight Purple",
|
||||
"id": "builtin:midnight_purple",
|
||||
"colorScheme": "dark",
|
||||
"colors": {
|
||||
"pink": [
|
||||
"#f6ecff",
|
||||
"#e6d7fa",
|
||||
"#caabef",
|
||||
"#ac7de6",
|
||||
"#9356dd",
|
||||
"#833ed9",
|
||||
"#7b31d7",
|
||||
"#6422b5",
|
||||
"#5e1fab",
|
||||
"#511797"
|
||||
],
|
||||
"dark": [
|
||||
"#FFFFFF",
|
||||
"#999999",
|
||||
"#a8a8a8",
|
||||
"#666666",
|
||||
"#282828",
|
||||
"#181818",
|
||||
"#151515",
|
||||
"#111111",
|
||||
"#181818",
|
||||
"#00001E"
|
||||
]
|
||||
},
|
||||
"primaryColor": "pink",
|
||||
"mainBackgroundColor": "#0a0a0a"
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import cat_mocha from './builtins/catppuccin_mocha.theme.json';
|
||||
|
||||
import midnight_orange from './builtins/midnight_orange.theme.json';
|
||||
import midnight_blue from './builtins/midnight_blue.theme.json';
|
||||
import midnight_purple from './builtins/midnight_purple.theme.json';
|
||||
|
||||
import { log } from '../logger';
|
||||
|
||||
@@ -45,14 +46,18 @@ export async function readThemes(): Promise<ZiplineTheme[]> {
|
||||
handleOverrideColors(dark_gray as ZiplineTheme),
|
||||
handleOverrideColors(light_gray as unknown as ZiplineTheme),
|
||||
handleOverrideColors(black_dark as unknown as ZiplineTheme),
|
||||
|
||||
handleOverrideColors(light_blue as unknown as ZiplineTheme),
|
||||
handleOverrideColors(dark_blue as unknown as ZiplineTheme),
|
||||
|
||||
handleOverrideColors(cat_frappe as unknown as ZiplineTheme),
|
||||
handleOverrideColors(cat_latte as unknown as ZiplineTheme),
|
||||
handleOverrideColors(cat_macchiato as unknown as ZiplineTheme),
|
||||
handleOverrideColors(cat_mocha as unknown as ZiplineTheme),
|
||||
|
||||
handleOverrideColors(midnight_orange as unknown as ZiplineTheme),
|
||||
handleOverrideColors(midnight_blue as unknown as ZiplineTheme),
|
||||
handleOverrideColors(midnight_purple as unknown as ZiplineTheme),
|
||||
);
|
||||
|
||||
return parsedThemes;
|
||||
|
||||
@@ -41,10 +41,12 @@ const variantColorResolver: VariantColorsResolver = (input) => {
|
||||
};
|
||||
|
||||
export function themeComponents(theme: ZiplineTheme): MantineThemeOverride {
|
||||
const { components, ...rest } = theme;
|
||||
return {
|
||||
...theme,
|
||||
...rest,
|
||||
variantColorResolver: variantColorResolver,
|
||||
components: {
|
||||
...components,
|
||||
AppShell: AppShell.extend({
|
||||
styles: {
|
||||
main: {
|
||||
|
||||
@@ -20,7 +20,7 @@ export function formatFileName(nameFormat: Config['files']['defaultFormat'], ori
|
||||
return name;
|
||||
case 'random-words':
|
||||
case 'gfycat':
|
||||
return randomWords(config.files.randomWordsNumAdjectives, config.files.randomWordsSeperator);
|
||||
return randomWords(config.files.randomWordsNumAdjectives, config.files.randomWordsSeparator);
|
||||
default:
|
||||
return randomCharacters(config.files.length);
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export type UploadHeaders = {
|
||||
};
|
||||
|
||||
export type UploadOptions = {
|
||||
deletesAt?: Date;
|
||||
deletesAt?: Date | 'never';
|
||||
format?: Config['files']['defaultFormat'];
|
||||
imageCompressionPercent?: number;
|
||||
password?: string;
|
||||
@@ -141,10 +141,14 @@ export function parseHeaders(headers: UploadHeaders, fileConfig: Config['files']
|
||||
const response: UploadOptions = {};
|
||||
|
||||
if (headers['x-zipline-deletes-at']) {
|
||||
const expiresAt = parseExpiry(headers['x-zipline-deletes-at']);
|
||||
if (!expiresAt) return headerError('x-zipline-deletes-at', 'Invalid expiry date');
|
||||
if (headers['x-zipline-deletes-at'].toLowerCase() === 'never') {
|
||||
response.deletesAt = 'never' as any;
|
||||
} else {
|
||||
const expiresAt = parseExpiry(headers['x-zipline-deletes-at']);
|
||||
if (!expiresAt) return headerError('x-zipline-deletes-at', 'Invalid expiry date');
|
||||
|
||||
response.deletesAt = expiresAt;
|
||||
response.deletesAt = expiresAt;
|
||||
}
|
||||
} else {
|
||||
if (fileConfig.defaultExpiration) {
|
||||
const expiresAt = new Date(Date.now() + ms(fileConfig.defaultExpiration as StringValue));
|
||||
|
||||
@@ -28,13 +28,13 @@ function importWords(): {
|
||||
}
|
||||
}
|
||||
|
||||
export function randomWords(numAdjectives: number = 2, seperator: string = '-') {
|
||||
export function randomWords(numAdjectives: number = 2, separator: string = '-') {
|
||||
const { adjectives, animals } = importWords();
|
||||
|
||||
let words = '';
|
||||
|
||||
for (let i = 0; i !== numAdjectives; ++i) {
|
||||
words += adjectives[randomIndex(adjectives.length)] + seperator;
|
||||
words += adjectives[randomIndex(adjectives.length)] + separator;
|
||||
}
|
||||
|
||||
words += animals[randomIndex(animals.length)];
|
||||
|
||||
@@ -2,10 +2,9 @@ import { bytes } from '@/lib/bytes';
|
||||
import { reloadSettings } from '@/lib/config';
|
||||
import { getDatasource } from '@/lib/datasource';
|
||||
import { S3Datasource } from '@/lib/datasource/S3';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { fileSelect } from '@/lib/db/models/file';
|
||||
import { File, fileSelect } from '@/lib/db/models/file';
|
||||
import { IncompleteFile } from '@/lib/db/models/incompleteFile';
|
||||
import { userSelect } from '@/lib/db/models/user';
|
||||
import { User, userSelect } from '@/lib/db/models/user';
|
||||
import { log } from '@/lib/logger';
|
||||
import { randomCharacters } from '@/lib/random';
|
||||
import { UploadOptions } from '@/lib/uploader/parseHeaders';
|
||||
@@ -15,6 +14,7 @@ import { createReadStream, createWriteStream } from 'fs';
|
||||
import { open, readdir, rm } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { isMainThread, workerData } from 'worker_threads';
|
||||
import { dbProxy } from './proxiedDb';
|
||||
|
||||
export type PartialWorkerData = {
|
||||
user: {
|
||||
@@ -76,7 +76,7 @@ async function main() {
|
||||
})
|
||||
.sort((a, b) => a.start - b.start);
|
||||
|
||||
const incompleteFile = await prisma.incompleteFile.create({
|
||||
const incompleteFile = await dbProxy<IncompleteFile>('incompleteFile.create', {
|
||||
data: {
|
||||
chunksTotal: readChunks.length,
|
||||
chunksComplete: 0,
|
||||
@@ -114,7 +114,7 @@ async function main() {
|
||||
});
|
||||
|
||||
await rm(chunkPath);
|
||||
await prisma.incompleteFile.update({
|
||||
await dbProxy('incompleteFile.update', {
|
||||
where: {
|
||||
id: incompleteFile.id,
|
||||
},
|
||||
@@ -171,7 +171,7 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.incompleteFile.update({
|
||||
await dbProxy('incompleteFile.update', {
|
||||
where: {
|
||||
id: incompleteFile.id,
|
||||
},
|
||||
@@ -184,7 +184,7 @@ async function main() {
|
||||
}
|
||||
|
||||
async function runComplete(id: string) {
|
||||
const userr = await prisma.user.findUnique({
|
||||
const userr = await dbProxy<User>('user.findUnique', {
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
@@ -192,14 +192,16 @@ async function runComplete(id: string) {
|
||||
});
|
||||
if (!userr) return;
|
||||
|
||||
const fileUpload = await prisma.file.update({
|
||||
const fileUpload = await dbProxy<File>('file.update', {
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
size: options.partial!.range[2],
|
||||
...(options.maxViews && { maxViews: options.maxViews }),
|
||||
...(options.deletesAt && { deletesAt: options.deletesAt }),
|
||||
...(options.deletesAt && options.deletesAt !== 'never'
|
||||
? { deletesAt: options.deletesAt }
|
||||
: { deletesAt: null }),
|
||||
},
|
||||
select: fileSelect,
|
||||
});
|
||||
@@ -218,7 +220,7 @@ async function runComplete(id: string) {
|
||||
|
||||
function failPartial(incompleteFile: IncompleteFile) {
|
||||
logger.error('failing incomplete file', { id: incompleteFile.id });
|
||||
return prisma.incompleteFile.update({
|
||||
return dbProxy('incompleteFile.update', {
|
||||
where: {
|
||||
id: incompleteFile.id,
|
||||
},
|
||||
|
||||
33
src/offload/proxiedDb.ts
Normal file
33
src/offload/proxiedDb.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { randomCharacters } from '@/lib/random';
|
||||
import { parentPort } from 'worker_threads';
|
||||
|
||||
export const pending: Record<string, (result: any) => void> = {};
|
||||
|
||||
parentPort?.on('message', (message) => {
|
||||
if (message.type === 'response') {
|
||||
const { id, result } = message;
|
||||
if (pending[id]) {
|
||||
try {
|
||||
pending[id](JSON.parse(result));
|
||||
} catch (e) {
|
||||
pending[id](null);
|
||||
console.error(e);
|
||||
}
|
||||
delete pending[id];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export function dbProxy<T>(query: string, data: any): Promise<T> {
|
||||
return new Promise((resolve) => {
|
||||
const id = randomCharacters(32);
|
||||
pending[id] = resolve;
|
||||
|
||||
parentPort?.postMessage({
|
||||
type: 'query',
|
||||
id,
|
||||
query,
|
||||
data,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -3,18 +3,21 @@ import { reloadSettings } from '@/lib/config';
|
||||
import { Config } from '@/lib/config/validate';
|
||||
import { getDatasource } from '@/lib/datasource';
|
||||
import { Datasource } from '@/lib/datasource/Datasource';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { File } from '@/lib/db/models/file';
|
||||
import { log } from '@/lib/logger';
|
||||
import ffmpeg from 'fluent-ffmpeg';
|
||||
import { createWriteStream, readFileSync, unlinkSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { isMainThread, parentPort, workerData } from 'worker_threads';
|
||||
import { dbProxy, pending } from './proxiedDb';
|
||||
|
||||
export type ThumbnailWorkerData = {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type ThumbnailId = File['thumbnail'] & { id: string };
|
||||
|
||||
const { id, enabled } = workerData as ThumbnailWorkerData;
|
||||
|
||||
const logger = log('tasks').c(id);
|
||||
@@ -71,7 +74,7 @@ function genThumbnail(file: string, thumbnailTmp: string): Promise<Buffer | unde
|
||||
|
||||
async function generate(config: Config, datasource: Datasource, ids: string[]) {
|
||||
for (const id of ids) {
|
||||
const file = await prisma.file.findUnique({
|
||||
const file = await dbProxy<File>('file.findUnique', {
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
@@ -111,7 +114,7 @@ async function generate(config: Config, datasource: Datasource, ids: string[]) {
|
||||
mimetype: 'image/jpeg',
|
||||
});
|
||||
|
||||
const existingThumbnail = await prisma.thumbnail.findFirst({
|
||||
const existingThumbnail = await dbProxy<ThumbnailId>('thumbnail.findFirst', {
|
||||
where: {
|
||||
fileId: file.id,
|
||||
},
|
||||
@@ -119,14 +122,14 @@ async function generate(config: Config, datasource: Datasource, ids: string[]) {
|
||||
|
||||
let t;
|
||||
if (!existingThumbnail) {
|
||||
t = await prisma.thumbnail.create({
|
||||
t = await dbProxy<ThumbnailId>('thumbnail.create', {
|
||||
data: {
|
||||
fileId: file.id,
|
||||
path: `.thumbnail.${file.id}.jpg`,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
t = await prisma.thumbnail.update({
|
||||
t = await dbProxy<ThumbnailId>('thumbnail.update', {
|
||||
where: {
|
||||
id: existingThumbnail.id,
|
||||
},
|
||||
@@ -148,9 +151,9 @@ async function main() {
|
||||
|
||||
const datasource = global.__datasource__;
|
||||
|
||||
parentPort!.on('message', async (d) => {
|
||||
const { type, data } = d as {
|
||||
type: 0 | 1;
|
||||
parentPort!.on('message', async (message) => {
|
||||
const { type, data } = message as {
|
||||
type: 0 | 1 | 'response';
|
||||
data?: string[];
|
||||
};
|
||||
|
||||
@@ -162,8 +165,20 @@ async function main() {
|
||||
case 1:
|
||||
logger.debug('received kill request');
|
||||
process.exit(0);
|
||||
case 'response':
|
||||
const { id, result } = message;
|
||||
if (pending[id]) {
|
||||
try {
|
||||
pending[id](JSON.parse(result));
|
||||
} catch (e) {
|
||||
pending[id](null);
|
||||
console.error(e);
|
||||
}
|
||||
delete pending[id];
|
||||
}
|
||||
break;
|
||||
default:
|
||||
logger.error('received unknown message type', { type });
|
||||
logger.error('unknown message type', { type, message });
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -95,9 +95,10 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
|
||||
});
|
||||
|
||||
if (error) {
|
||||
if (error.error === 'Invalid username') form.setFieldError('username', 'Invalid username');
|
||||
else if (error.error === 'Invalid password') form.setFieldError('password', 'Invalid password');
|
||||
else if (error.error === 'Invalid code') setPinError(error.error!);
|
||||
if (error.error === 'Invalid username or password') {
|
||||
form.setFieldError('username', 'Invalid username');
|
||||
form.setFieldError('password', 'Invalid password');
|
||||
} else if (error.error === 'Invalid code') setPinError(error.error!);
|
||||
setPinDisabled(false);
|
||||
} else {
|
||||
if (data!.totp) {
|
||||
|
||||
@@ -225,10 +225,43 @@ async function main() {
|
||||
|
||||
if (config.features.thumbnails.enabled) {
|
||||
for (let i = 0; i !== config.features.thumbnails.num_threads; ++i) {
|
||||
tasks.worker(`thumbnail-${i}`, './build/offload/thumbnails.js', {
|
||||
id: `thumbnail-${i}`,
|
||||
enabled: config.features.thumbnails.enabled,
|
||||
});
|
||||
tasks.worker(
|
||||
`thumbnail-${i}`,
|
||||
'./build/offload/thumbnails.js',
|
||||
{
|
||||
id: `thumbnail-${i}`,
|
||||
enabled: config.features.thumbnails.enabled,
|
||||
},
|
||||
async function (this: Worker, message: any) {
|
||||
if (message.type === 'query') {
|
||||
const { id, query, data } = message;
|
||||
|
||||
let result: any = null;
|
||||
switch (query) {
|
||||
case 'file.findUnique':
|
||||
result = await prisma.file.findUnique(data);
|
||||
break;
|
||||
case 'thumbnail.findFirst':
|
||||
result = await prisma.thumbnail.findFirst(data);
|
||||
break;
|
||||
case 'thumbnail.create':
|
||||
result = await prisma.thumbnail.create(data);
|
||||
break;
|
||||
case 'thumbnail.update':
|
||||
result = await prisma.thumbnail.update(data);
|
||||
break;
|
||||
default:
|
||||
console.error(`Unknown DB query: ${query}`);
|
||||
}
|
||||
|
||||
this.postMessage({
|
||||
type: 'response',
|
||||
id,
|
||||
result: JSON.stringify(result),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
tasks.interval('thumbnails', ms(config.tasks.thumbnailsInterval as StringValue), thumbnails(prisma));
|
||||
|
||||
@@ -2,6 +2,7 @@ import { verifyPassword } from '@/lib/crypto';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { User, userSelect } from '@/lib/db/models/user';
|
||||
import { log } from '@/lib/logger';
|
||||
import { secondlyRatelimit } from '@/lib/ratelimits';
|
||||
import { verifyTotpCode } from '@/lib/totp';
|
||||
import { getSession, saveSession } from '@/server/session';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
@@ -22,7 +23,7 @@ const logger = log('api').c('auth').c('login');
|
||||
export const PATH = '/api/auth/login';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
server.post<{ Body: Body }>(PATH, async (req, res) => {
|
||||
server.post<{ Body: Body }>(PATH, secondlyRatelimit(2), async (req, res) => {
|
||||
const session = await getSession(req, res);
|
||||
|
||||
session.id = null;
|
||||
@@ -43,7 +44,7 @@ export default fastifyPlugin(
|
||||
token: true,
|
||||
},
|
||||
});
|
||||
if (!user) return res.badRequest('Invalid username');
|
||||
if (!user) return res.badRequest('Invalid username or password');
|
||||
|
||||
if (!user.password) return res.badRequest('User does not have a password, login through a provider');
|
||||
const valid = await verifyPassword(password, user.password);
|
||||
@@ -53,7 +54,7 @@ export default fastifyPlugin(
|
||||
ip: req.ip ?? 'unknown',
|
||||
ua: req.headers['user-agent'],
|
||||
});
|
||||
return res.badRequest('Invalid password');
|
||||
return res.badRequest('Invalid username or password');
|
||||
}
|
||||
|
||||
if (user.totpSecret && code) {
|
||||
|
||||
@@ -80,6 +80,15 @@ async function discordOauth({ code, host, state }: OAuthQuery, logger: Logger):
|
||||
|
||||
logger.debug('user', { '@me': userJson });
|
||||
|
||||
const allowedIds = config.oauth.discord.allowedIds;
|
||||
const deniedIds = config.oauth.discord.deniedIds;
|
||||
if (deniedIds && deniedIds.length > 0 && deniedIds.includes(userJson.id)) {
|
||||
return { error: 'You are not allowed to log in with Discord.' };
|
||||
}
|
||||
if (allowedIds && allowedIds.length > 0 && !allowedIds.includes(userJson.id)) {
|
||||
return { error: 'You are not allowed to log in with Discord.' };
|
||||
}
|
||||
|
||||
const avatar = userJson.avatar
|
||||
? `https://cdn.discordapp.com/avatars/${userJson.id}/${userJson.avatar}.png`
|
||||
: `https://cdn.discordapp.com/embed/avatars/${userJson.discriminator % 5}.png`;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import { reloadSettings } from '@/lib/config';
|
||||
import { readDatabaseSettings } from '@/lib/config/read';
|
||||
import type { readDatabaseSettings } from '@/lib/config/read/db';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { log } from '@/lib/logger';
|
||||
import { secondlyRatelimit } from '@/lib/ratelimits';
|
||||
@@ -16,7 +16,7 @@ import { z } from 'zod';
|
||||
|
||||
type Settings = Awaited<ReturnType<typeof readDatabaseSettings>>;
|
||||
|
||||
export type ApiServerSettingsResponse = Settings;
|
||||
export type ApiServerSettingsResponse = { settings: Settings; tampered: string[] };
|
||||
type Body = Partial<Settings>;
|
||||
|
||||
const reservedRoutes = ['/dashboard', '/api', '/raw', '/robots.txt', '/manifest.json', '/favicon.ico'];
|
||||
@@ -72,7 +72,7 @@ export default fastifyPlugin(
|
||||
|
||||
if (!settings) return res.notFound('no settings table found');
|
||||
|
||||
return res.send(settings);
|
||||
return res.send({ settings, tampered: global.__tamperedConfig__ || [] });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -222,6 +222,26 @@ export default fastifyPlugin(
|
||||
oauthDiscordClientId: z.string().nullable(),
|
||||
oauthDiscordClientSecret: z.string().nullable(),
|
||||
oauthDiscordRedirectUri: z.string().url().endsWith('/api/auth/oauth/discord').nullable(),
|
||||
oauthDiscordAllowedIds: z
|
||||
.union([
|
||||
z.array(z.string().refine((s) => /^\d+$/.test(s), 'Discord ID must be a number')),
|
||||
z
|
||||
.string()
|
||||
.refine((s) => s === '' || /^\d+(,\d+)*$/.test(s), 'Discord IDs must be comma-separated'),
|
||||
])
|
||||
.transform((value) =>
|
||||
typeof value === 'string' ? value.split(',').map((id) => id.trim()) : value,
|
||||
),
|
||||
oauthDiscordDeniedIds: z
|
||||
.union([
|
||||
z.array(z.string().refine((s) => /^\d+$/.test(s), 'Discord ID must be a number')),
|
||||
z
|
||||
.string()
|
||||
.refine((s) => s === '' || /^\d+(,\d+)*$/.test(s), 'Discord IDs must be comma-separated'),
|
||||
])
|
||||
.transform((value) =>
|
||||
typeof value === 'string' ? value.split(',').map((id) => id.trim()) : value,
|
||||
),
|
||||
|
||||
oauthGoogleClientId: z.string().nullable(),
|
||||
oauthGoogleClientSecret: z.string().nullable(),
|
||||
@@ -275,6 +295,11 @@ export default fastifyPlugin(
|
||||
pwaDescription: z.string(),
|
||||
pwaThemeColor: z.string().regex(/^#?([a-f0-9]{6}|[a-f0-9]{3})$/),
|
||||
pwaBackgroundColor: z.string().regex(/^#?([a-f0-9]{6}|[a-f0-9]{3})/),
|
||||
|
||||
domains: z.union([
|
||||
z.array(z.string()),
|
||||
z.string().transform((value) => value.split(',').map((s) => s.trim())),
|
||||
]),
|
||||
})
|
||||
.partial()
|
||||
.refine(
|
||||
@@ -367,7 +392,7 @@ export default fastifyPlugin(
|
||||
by: req.user.username,
|
||||
});
|
||||
|
||||
return res.send(newSettings);
|
||||
return res.send({ settings: newSettings, tampered: global.__tamperedConfig__ || [] });
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -98,7 +98,9 @@ export default fastifyPlugin(
|
||||
|
||||
const response: ApiUploadResponse = {
|
||||
files: [],
|
||||
...(options.deletesAt && { deletesAt: options.deletesAt.toISOString() }),
|
||||
...(options.deletesAt && {
|
||||
deletesAt: options.deletesAt === 'never' ? 'never' : options.deletesAt.toISOString(),
|
||||
}),
|
||||
...(config.files.assumeMimetypes && { assumedMimetypes: Array(req.files.length) }),
|
||||
};
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ export default fastifyPlugin(
|
||||
return res.send(resp);
|
||||
}
|
||||
|
||||
if (favorite) {
|
||||
if (typeof favorite === 'boolean') {
|
||||
const resp = await prisma.file.updateMany({
|
||||
where: {
|
||||
id: {
|
||||
@@ -87,7 +87,7 @@ export default fastifyPlugin(
|
||||
},
|
||||
|
||||
data: {
|
||||
favorite: favorite ?? false,
|
||||
favorite: favorite,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ export default fastifyPlugin(
|
||||
});
|
||||
if (req.user.quota && req.user.quota.maxUrls && countUrls + 1 > req.user.quota.maxUrls)
|
||||
return res.forbidden(
|
||||
`shortenning this url would exceed your quota of ${req.user.quota.maxUrls} urls`,
|
||||
`Shortening this URL would exceed your quota of ${req.user.quota.maxUrls} URLs.`,
|
||||
);
|
||||
|
||||
let maxViews: number | undefined;
|
||||
|
||||
@@ -7,6 +7,7 @@ import fastifyPlugin from 'fastify-plugin';
|
||||
export type ApiVersionResponse = {
|
||||
details: ReturnType<typeof getVersion>;
|
||||
data: VersionAPI;
|
||||
cached: true;
|
||||
};
|
||||
|
||||
interface VersionAPI {
|
||||
@@ -31,6 +32,9 @@ interface VersionAPI {
|
||||
|
||||
const logger = log('api').c('version');
|
||||
|
||||
let cachedData: VersionAPI | null = null;
|
||||
let cachedAt = 0;
|
||||
|
||||
export const PATH = '/api/version';
|
||||
export default fastifyPlugin(
|
||||
(server, _, done) => {
|
||||
@@ -39,6 +43,11 @@ export default fastifyPlugin(
|
||||
|
||||
const details = getVersion();
|
||||
|
||||
// 6 hrs cache
|
||||
if (cachedData && Date.now() - cachedAt < 6 * 60 * 60 * 1000) {
|
||||
return res.send({ data: cachedData, details, cached: true });
|
||||
}
|
||||
|
||||
const url = new URL(config.features.versionAPI);
|
||||
url.pathname = '/';
|
||||
url.searchParams.set('details', JSON.stringify(details));
|
||||
@@ -52,9 +61,13 @@ export default fastifyPlugin(
|
||||
|
||||
const data: VersionAPI = await resp.json();
|
||||
|
||||
cachedData = data;
|
||||
cachedAt = Date.now();
|
||||
|
||||
return res.send({
|
||||
data,
|
||||
details,
|
||||
cached: false,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('failed to fetch version details').error(e as Error);
|
||||
|
||||
@@ -6,9 +6,9 @@ import { IncomingMessage, ServerResponse } from 'http';
|
||||
import { getIronSession, type SessionOptions } from 'iron-session';
|
||||
|
||||
const cookieOptions: SessionOptions['cookieOptions'] = {
|
||||
// week
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
expires: new Date(Date.now() + 60 * 60 * 24 * 7 * 1000),
|
||||
// 2 weeks
|
||||
maxAge: 60 * 60 * 24 * 14,
|
||||
expires: new Date(Date.now() + 60 * 60 * 24 * 14 * 1000),
|
||||
path: '/',
|
||||
sameSite: 'lax',
|
||||
httpOnly: false,
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*", "./generated/*"],
|
||||
"@/*": ["./src/*", "./generated/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "src/client/mount.js"],
|
||||
|
||||
Reference in New Issue
Block a user