Compare commits

..

32 Commits

Author SHA1 Message Date
diced
ac08f4f797 feat(v4.2.1): version 2025-07-28 12:21:26 -07:00
diced
91a2c05d3b feat: nix dev shell 2025-07-27 12:34:25 -07:00
diced
3ccc108d43 fix: search by id color 2025-07-19 14:32:34 -07:00
diced
aaaf0cf5aa fix: prolly fix #843 2025-07-19 14:27:40 -07:00
diced
db7cf70bca fix: favorite transactional 2025-07-11 11:47:58 -07:00
diced
8b59e1dc53 fix: properly handle custom components 2025-07-08 19:34:59 -07:00
diced
da066db07e fix: discord oauth #833 2025-07-04 14:19:46 -07:00
diced
b566d13c8d fix: random visual bugs + enhancements 2025-07-02 20:41:37 -07:00
diced
6a76c5243f fix: typo separator 2025-07-02 14:12:35 -07:00
curet
38a90787d0 feat: predefined domains (#822)
* feat(domains): add domains to server settings

* fix(domains): fix linting errors

* fix(domains): remove unused imports

* fix(urls): fix typo

* feat(domains): remove expiration date from domains

* feat(domains): changed domains from JSONB to TEXT[]

* fix(domains): linter errors

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2025-07-02 10:52:33 -07:00
diced
4652ada85e feat(v4.2.0): version 2025-07-01 17:43:12 -07:00
diced
5f96c762e0 fix: lint errors 2025-07-01 17:30:49 -07:00
diced
651f32e7ba fix: remove split user/pass error 2025-07-01 17:27:32 -07:00
diced
dcbd9e40f0 fix: use absolute path for mac flameshot 2025-07-01 17:22:19 -07:00
diced
3486e9880e feat: midnight pink theme 2025-07-01 17:15:41 -07:00
diced
b058c15f26 fix: up cookie age 2 weeks 2025-07-01 16:58:35 -07:00
diced
96f60edaee fix: try to fix insane db connections #778 2025-07-01 16:55:57 -07:00
diced
d7f3e1503f fix: broken link partial file #816 2025-07-01 15:53:20 -07:00
diced
dfc8fca3e0 fix: default expiration #821 2025-07-01 15:33:29 -07:00
lajczi
28f7d3f618 chore: update ESLint config (#826)
* chore: update ESLint config

* chore: update file permissions

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2025-07-01 11:38:47 -07:00
curet
5c0830c6da fix: long code blocks (#823) (#810) 2025-07-01 10:58:25 -07:00
diced
ef33fcbe1d fix: lint error 2025-06-11 20:23:25 -07:00
diced
4b1ca07510 feat: better cache for versions 2025-06-11 20:21:52 -07:00
diced
438b9b5a67 feat: show alert when there are overridden settings 2025-06-08 12:02:51 -07:00
diced
ed1273efba feat: convert db settings to env vars cli 2025-06-08 11:52:32 -07:00
diced
e8518f92c7 fix: remove 2025-06-07 11:36:51 -07:00
diced
fbf9e10e56 feat: allow/denylist discord oauth 2025-06-07 11:36:23 -07:00
diced
a1ee1178ae feat: allow env vars that override database set settings 2025-06-07 11:17:43 -07:00
diced
e5eaaca5a0 feat: discord oauth whitelist 2025-06-06 20:33:41 -07:00
diced
6e9dea989e fix: use cmd icon on mac 2025-06-06 15:15:11 -07:00
diced
5bc9b6ef0a feat: add download button to file table view 2025-06-06 15:10:13 -07:00
diced
6362d06253 feat: new gps remover 2025-06-06 15:06:21 -07:00
81 changed files with 3611 additions and 2961 deletions

6
.gitignore vendored
View File

@@ -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
View File

@@ -0,0 +1 @@
pnpm-lock.yaml

View File

@@ -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)

View File

@@ -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

View File

@@ -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
View 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
View 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
'';
};
}
);
}

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Zipline" ADD COLUMN "oauthDiscordWhitelistIds" TEXT[] DEFAULT ARRAY[]::TEXT[];

View File

@@ -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[];

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Zipline" ADD COLUMN "domains" TEXT[] DEFAULT ARRAY[]::TEXT[];

View File

@@ -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 {

View File

@@ -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 ? (
<>

View 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':

View File

@@ -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,
});
}

View File

@@ -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'

View File

@@ -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>

View File

@@ -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>
</>
);

View File

@@ -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]);

View File

@@ -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]);

View File

@@ -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]);

View 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>
);
}

View File

@@ -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]);

View File

@@ -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]);

View File

@@ -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]);

View File

@@ -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>

View File

@@ -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]);

View File

@@ -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.'

View File

@@ -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>

View File

@@ -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>

View File

@@ -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]);

View File

@@ -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]);

View File

@@ -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>

View File

@@ -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'>

View File

@@ -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 {

View File

@@ -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} />}

View File

@@ -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: () => {},

View File

@@ -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

View File

@@ -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());

View File

@@ -218,6 +218,10 @@ export async function uploadPartialFiles(
>
Click here to copy the URL to clipboard while it&apos;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(

View File

@@ -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>
);
}

View 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}`);
}
}

View File

@@ -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();

View File

@@ -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,
});

View File

@@ -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 }),
},

View File

@@ -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 () => {

View File

@@ -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
View 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
View 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
View 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;
}

View 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;
}

View File

@@ -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

View File

@@ -7,7 +7,6 @@ import { S3Datasource } from './S3';
let datasource: Datasource;
declare global {
// eslint-disable-next-line no-var
var __datasource__: Datasource;
}

View File

@@ -9,7 +9,6 @@ const building = !!process.env.ZIPLINE_BUILD;
let prisma: ExtendedPrismaClient;
declare global {
// eslint-disable-next-line no-var
var __db__: ExtendedPrismaClient;
}

View File

@@ -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
View 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
View 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;
}

View File

@@ -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;
};

View File

@@ -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,

View File

@@ -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>);

View 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"
}

View File

@@ -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;

View File

@@ -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: {

View File

@@ -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);
}

View File

@@ -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));

View File

@@ -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)];

View File

@@ -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
View 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,
});
});
}

View File

@@ -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;
}
});

View File

@@ -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) {

View File

@@ -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));

View File

@@ -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) {

View File

@@ -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`;

View File

@@ -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__ || [] });
},
);

View File

@@ -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) }),
};

View File

@@ -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,
},
});

View File

@@ -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;

View File

@@ -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);

View File

@@ -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,

View File

@@ -15,7 +15,7 @@
"jsx": "preserve",
"incremental": true,
"paths": {
"@/*": ["./src/*", "./generated/*"],
"@/*": ["./src/*", "./generated/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "src/client/mount.js"],