Compare commits

..

51 Commits

Author SHA1 Message Date
diced
cd8b892a90 feat(v4.2.2): version 2025-08-07 19:48:52 -07:00
diced
3575981984 fix: exdev error workaround #856 2025-08-07 19:31:56 -07:00
dicedtomato
81c880b1ca Merge commit from fork 2025-08-07 19:29:28 -07:00
diced
9b8e57bda0 fix: do not add new sessions on session save (#855) 2025-08-04 11:44:06 -07:00
diced
4a8f90a901 fix: #855 session override bug 2025-08-03 16:24:00 -07:00
diced
6acdc72776 fix: multiple db connections on offloaded threads 2025-08-02 16:53:53 -07:00
diced
f78c873aae fix: revert zod 2025-08-02 16:52:14 -07:00
diced
0f82bf8d90 fix: formatting errors 2025-08-02 16:52:03 -07:00
diced
82a7f1d0bf feat(prisma): use non-rust engines 2025-08-02 16:36:08 -07:00
diced
2fd1007e4b chore: lint + upgrade packages 2025-08-02 15:40:09 -07:00
diced
c360235fa8 fix: better thumbnail logic 2025-08-02 15:29:27 -07:00
diced
a4404f1ae8 fix: refactor routes to be separated 2025-08-02 11:25:16 -07:00
diced
56d1492377 feat: ability to rename files 2025-08-01 16:43:20 -07:00
diced
fa9bf185d5 fix: improve logic in uploading + partial 2025-08-01 12:31:07 -07:00
diced
eca6a0c5fd feat(unstable): implement new uploading logic 2025-07-31 23:23:31 -07:00
diced
f58ed2f368 fix: add minio to flake 2025-07-31 23:22:06 -07:00
diced
64c39dab76 fix: update nix flake to use devenv 2025-07-31 20:22:10 -07:00
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
diced
81866b4b50 feat(v4.1.2): version hotfix 2025-06-06 10:40:57 -07:00
diced
4b3878d553 feat: switch metadata remover 2025-06-06 10:40:38 -07:00
107 changed files with 5911 additions and 4376 deletions

1
.envrc Normal file
View File

@@ -0,0 +1 @@
use flake . --no-pure-eval

6
.gitignore vendored
View File

@@ -23,6 +23,7 @@
# misc
.DS_Store
*.pem
.idea
# debug
npm-debug.log*
@@ -42,6 +43,11 @@ next-env.d.ts
# eslint
.eslintcache
# nix dev env
!.envrc
.direnv
.devenv
# zipline
uploads*/
*.crt

1
.prettierignore Executable file
View File

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

View File

@@ -198,6 +198,32 @@ Create a pull request on GitHub. If your PR does not pass the action checks, the
Here's how to setup Zipline for development
#### Nix
If you have [direnv](https://direnv.net/) installed, you can simply cd into the cloned directory and run the following command:
```bash
direnv allow
```
Granted that you have direnv setup properly, you will now be in a new nix shell with all the dependencies and PostgreSQL installed.
If you aren't using direnv, you can run the following command to enter the nix shell:
```bash
nix develop --no-pure-eval
```
Useful commands regarding the postgres server:
| Command | Description |
| --------------- | --------------------------------------------------- |
| `pgup` | Starts the postgres server in the background. |
| `pgdown` | Stops the postgres server running in the background |
| `pg_ctl status` | See if the postgres server is running |
After familiarizing yourself with the environment, you can continue below (skipping the prerequisites since they are already installed).
#### Prerequisites
- 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,
},
},
},
];
);

254
flake.lock generated Normal file
View File

@@ -0,0 +1,254 @@
{
"nodes": {
"cachix": {
"inputs": {
"devenv": [
"devenv"
],
"flake-compat": [
"devenv"
],
"git-hooks": [
"devenv",
"git-hooks"
],
"nixpkgs": [
"devenv",
"nixpkgs"
]
},
"locked": {
"lastModified": 1748883665,
"narHash": "sha256-R0W7uAg+BLoHjMRMQ8+oiSbTq8nkGz5RDpQ+ZfxxP3A=",
"owner": "cachix",
"repo": "cachix",
"rev": "f707778d902af4d62d8dd92c269f8e70de09acbe",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "latest",
"repo": "cachix",
"type": "github"
}
},
"devenv": {
"inputs": {
"cachix": "cachix",
"flake-compat": "flake-compat",
"git-hooks": "git-hooks",
"nix": "nix",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1753888869,
"narHash": "sha256-VRYrrUmvXnBzfzuJVoI3os1H/0l8cJQ2KnrrxWkTB3E=",
"owner": "cachix",
"repo": "devenv",
"rev": "bdf26a4453eff6bae835f33d519a36f77e0ca257",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "devenv",
"type": "github"
}
},
"devenv-root": {
"flake": false,
"locked": {
"narHash": "sha256-d6xi4mKdjkX2JFicDIv5niSzpyI0m/Hnm8GGAIU04kY=",
"type": "file",
"url": "file:///dev/null"
},
"original": {
"type": "file",
"url": "file:///dev/null"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1747046372,
"narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
"devenv",
"nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1733312601,
"narHash": "sha256-4pDvzqnegAfRkPwO3wmwBhVi/Sye1mzps0zHWYnP88c=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "205b12d8b7cd4802fbcb8e8ef6a0f1408781a4f9",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"flake-parts_2": {
"inputs": {
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1753121425,
"narHash": "sha256-TVcTNvOeWWk1DXljFxVRp+E0tzG1LhrVjOGGoMHuXio=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "644e0fc48951a860279da645ba77fe4a6e814c5e",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"git-hooks": {
"inputs": {
"flake-compat": [
"devenv",
"flake-compat"
],
"gitignore": "gitignore",
"nixpkgs": [
"devenv",
"nixpkgs"
]
},
"locked": {
"lastModified": 1750779888,
"narHash": "sha256-wibppH3g/E2lxU43ZQHC5yA/7kIKLGxVEnsnVK1BtRg=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "16ec914f6fb6f599ce988427d9d94efddf25fe6d",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"devenv",
"git-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"nix": {
"inputs": {
"flake-compat": [
"devenv",
"flake-compat"
],
"flake-parts": "flake-parts",
"git-hooks-nix": [
"devenv",
"git-hooks"
],
"nixpkgs": [
"devenv",
"nixpkgs"
],
"nixpkgs-23-11": [
"devenv"
],
"nixpkgs-regression": [
"devenv"
]
},
"locked": {
"lastModified": 1752773918,
"narHash": "sha256-dOi/M6yNeuJlj88exI+7k154z+hAhFcuB8tZktiW7rg=",
"owner": "cachix",
"repo": "nix",
"rev": "031c3cf42d2e9391eee373507d8c12e0f9606779",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "devenv-2.30",
"repo": "nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1752827260,
"narHash": "sha256-noFjJbm/uWRcd2Lotr7ovedfhKVZT+LeJs9rU416lKQ=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "b527e89270879aaaf584c41f26b2796be634bc9d",
"type": "github"
},
"original": {
"owner": "nixos",
"repo": "nixpkgs",
"rev": "b527e89270879aaaf584c41f26b2796be634bc9d",
"type": "github"
}
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1751159883,
"narHash": "sha256-urW/Ylk9FIfvXfliA1ywh75yszAbiTEVgpPeinFyVZo=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "14a40a1d7fb9afa4739275ac642ed7301a9ba1ab",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nixpkgs.lib",
"type": "github"
}
},
"root": {
"inputs": {
"devenv": "devenv",
"devenv-root": "devenv-root",
"flake-parts": "flake-parts_2",
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

128
flake.nix Normal file
View File

@@ -0,0 +1,128 @@
{
inputs = {
# required for some reason when entering the shell for devenv
devenv-root = {
url = "file+file:///dev/null";
flake = false;
};
# node 24.4.1, postgres 17
nixpkgs.url = "github:nixos/nixpkgs/b527e89270879aaaf584c41f26b2796be634bc9d";
flake-parts.url = "github:hercules-ci/flake-parts";
devenv.url = "github:cachix/devenv";
devenv.inputs.nixpkgs.follows = "nixpkgs";
};
nixConfig = {
extra-trusted-public-keys = "devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw=";
extra-substituters = "https://devenv.cachix.org";
};
outputs =
inputs@{ flake-parts, devenv-root, ... }:
flake-parts.lib.mkFlake { inherit inputs; } {
imports = [
inputs.devenv.flakeModule
];
systems = [
"x86_64-linux"
"x86_64-darwin"
"aarch64-linux"
"aarch64-darwin"
];
perSystem =
{
config,
self',
inputs',
pkgs,
system,
...
}:
let
psqlConfig = {
username = "postgres";
password = "postgres";
database = "zipline";
};
in
{
devenv.shells.default = {
packages = with pkgs; [
git
# to generate thumbnails
ffmpeg
# for testing docker
colima
docker
docker-compose
];
scripts = {
pgup.exec = ''
process-compose up postgres -D
'';
minioup.exec = ''
process-compose up minio -D
'';
downall.exec = ''
process-compose down
'';
# ensure that volumes are mounted with write access for docker containers
start_colima.exec = ''
colima start --mount $PWD/themes:w --mount $PWD/uploads:w --mount $PWD/public:w
'';
};
enterShell = ''
export name="zipline-env";
echo -e "\n[$name]: run 'pgup' to start services, 'pgdown' to stop services";
'';
languages.javascript = {
enable = true;
package = pkgs.nodejs_24;
corepack.enable = true;
};
services = {
postgres = {
enable = true;
package = pkgs.postgresql_17;
initialScript = ''
CREATE ROLE "${psqlConfig.username}" WITH LOGIN PASSWORD '${psqlConfig.password}' SUPERUSER;
'';
initialDatabases = [
{
name = psqlConfig.database;
user = psqlConfig.username;
}
];
listen_addresses = "0.0.0.0";
port = 5432;
};
minio = {
enable = true;
};
};
process.managers.process-compose = {
tui.enable = false;
};
};
};
};
}

View File

@@ -2,7 +2,7 @@
"name": "zipline",
"private": true,
"license": "MIT",
"version": "4.1.1",
"version": "4.2.2",
"scripts": {
"build": "cross-env pnpm run --stream \"/^build:.*/\"",
"build:prisma": "prisma generate --no-hints",
@@ -26,97 +26,96 @@
"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.859.0",
"@aws-sdk/lib-storage": "3.859.0",
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.0.1",
"@fastify/cors": "^11.1.0",
"@fastify/multipart": "^9.0.3",
"@fastify/rate-limit": "^10.3.0",
"@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",
"@smithy/node-http-handler": "^4.0.6",
"@tabler/icons-react": "^3.34.0",
"@xoi/gps-metadata-remover": "^2.0.0",
"argon2": "^0.43.0",
"@mantine/charts": "^8.2.2",
"@mantine/code-highlight": "^8.2.2",
"@mantine/core": "^8.2.2",
"@mantine/dates": "^8.2.2",
"@mantine/dropzone": "^8.2.2",
"@mantine/form": "^8.2.2",
"@mantine/hooks": "^8.2.2",
"@mantine/modals": "^8.2.2",
"@mantine/notifications": "^8.2.2",
"@prisma/adapter-pg": "^6.13.0",
"@prisma/client": "^6.13.0",
"@prisma/internals": "^6.13.0",
"@prisma/migrate": "^6.13.0",
"@smithy/node-http-handler": "^4.1.0",
"@tabler/icons-react": "^3.34.1",
"argon2": "^0.43.1",
"bytes": "^3.1.2",
"clsx": "^2.1.1",
"colorette": "^2.0.20",
"commander": "^14.0.0",
"cross-env": "^7.0.3",
"cross-env": "^10.0.0",
"dayjs": "^1.11.13",
"dotenv": "^16.5.0",
"dotenv": "^17.2.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",
"highlight.js": "^11.11.1",
"iron-session": "^8.0.4",
"isomorphic-dompurify": "^2.25.0",
"isomorphic-dompurify": "^2.26.0",
"katex": "^0.16.22",
"mantine-datatable": "^7.17.1",
"mantine-datatable": "^8.2.0",
"ms": "^2.1.3",
"multer": "2.0.1",
"next": "^15.3.3",
"multer": "2.0.2",
"next": "^15.4.5",
"nuqs": "^2.4.3",
"otplib": "^12.0.1",
"prisma": "^6.9.0",
"prisma": "^6.13.0",
"qrcode": "^1.5.4",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"sharp": "^0.34.2",
"swr": "^2.3.3",
"zod": "^3.25.51",
"zustand": "^5.0.5"
"sharp": "^0.34.3",
"swr": "^2.3.4",
"typescript-eslint": "^8.38.0",
"zod": "^3.25.67",
"zustand": "^5.0.7"
},
"devDependencies": {
"@eslint/compat": "^1.2.9",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.28.0",
"@next/eslint-plugin-next": "^15.4.5",
"@types/bytes": "^3.1.5",
"@types/express": "^5.0.2",
"@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": "^2.0.0",
"@types/node": "^24.1.0",
"@types/qrcode": "^1.5.5",
"@types/react": "^19.1.6",
"@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-config-prettier": "^10.1.5",
"eslint-plugin-prettier": "^5.4.1",
"@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7",
"eslint": "^9.32.0",
"eslint-config-next": "^15.4.5",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.5.3",
"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-preset-mantine": "^1.17.0",
"postcss": "^8.5.6",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.5.3",
"sass": "^1.89.1",
"prettier": "^3.6.2",
"sass": "^1.89.2",
"tsc-alias": "^1.8.16",
"tsup": "^8.5.0",
"tsx": "^4.19.4",
"typescript": "^5.8.3"
"tsx": "^4.20.3",
"typescript": "^5.9.2"
},
"engines": {
"node": ">=22"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
"packageManager": "pnpm@10.12.1"
}

4751
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

@@ -1,6 +1,7 @@
generator client {
provider = "prisma-client-js"
output = "../generated/client"
previewFeatures = ["queryCompiler", "driverAdapters"]
}
datasource db {
@@ -82,6 +83,8 @@ model Zipline {
oauthDiscordClientId String?
oauthDiscordClientSecret String?
oauthDiscordRedirectUri String?
oauthDiscordAllowedIds String[] @default([])
oauthDiscordDeniedIds String[] @default([])
oauthGoogleClientId String?
oauthGoogleClientSecret String?
@@ -133,6 +136,8 @@ model Zipline {
pwaDescription String @default("Zipline")
pwaThemeColor String @default("#000000")
pwaBackgroundColor String @default("#000000")
domains String[] @default([])
}
model User {

View File

@@ -3,7 +3,7 @@ import { fetchApi } from '@/lib/fetchApi';
import { Button, Divider, Modal, NumberInput, PasswordInput, Stack, TextInput } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { IconEye, IconKey, IconPencil, IconPencilOff, IconTrashFilled } from '@tabler/icons-react';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { mutateFiles } from '../actions';
export default function EditFileDetailsModal({
@@ -17,6 +17,7 @@ export default function EditFileDetailsModal({
}) {
if (!file) return null;
const [name, setName] = useState<string>(file.name ?? '');
const [maxViews, setMaxViews] = useState<number | null>(file?.maxViews ?? null);
const [password, setPassword] = useState<string | null>('');
const [originalName, setOriginalName] = useState<string | null>(file?.originalName ?? null);
@@ -54,12 +55,16 @@ export default function EditFileDetailsModal({
password?: string;
originalName?: string;
type?: string;
name?: string;
} = {};
if (maxViews !== null) data['maxViews'] = maxViews;
if (password !== null) data['password'] = password?.trim();
if (originalName !== null) data['originalName'] = originalName?.trim();
if (type !== null) data['type'] = type?.trim();
if (name !== file.name) data['name'] = name.trim();
const passwordTrimmed = password?.trim();
if (passwordTrimmed !== '') data['password'] = passwordTrimmed;
const { error } = await fetchApi(`/api/user/files/${file.id}`, 'PATCH', data);
@@ -85,9 +90,26 @@ export default function EditFileDetailsModal({
}
};
useEffect(() => {
if (open) {
setName(file.name ?? '');
setMaxViews(file.maxViews ?? null);
setPassword(file.password ? '' : null);
setOriginalName(file.originalName ?? null);
setType(file.type ?? null);
}
}, [open, file]);
return (
<Modal zIndex={300} title={`Editing "${file.name}"`} onClose={onClose} opened={open}>
<Stack gap='xs' my='sm'>
<TextInput
label='Name'
description='Rename the file.'
value={name}
onChange={(event) => setName(event.currentTarget.value.trim())}
/>
<NumberInput
label='Max Views'
placeholder='Unlimited'

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

@@ -55,7 +55,9 @@ export default function ViewFilesModal({
}}
pos='relative'
>
{folder?.files?.map((file) => <DashboardFile file={file} key={file.id} />)}
{folder?.files?.map((file) => (
<DashboardFile file={file} key={file.id} />
))}
</SimpleGrid>
)}

View File

@@ -37,7 +37,9 @@ export default function FolderGridView() {
}}
pos='relative'
>
{folders?.map((folder) => <FolderCard key={folder.id} folder={folder} />)}
{folders?.map((folder) => (
<FolderCard key={folder.id} folder={folder} />
))}
</SimpleGrid>
) : (
<Paper withBorder p='sm' my='sm'>

View File

@@ -37,7 +37,9 @@ export default function InviteGridView() {
}}
pos='relative'
>
{folders?.map((invite) => <InviteCard key={invite.id} invite={invite} />)}
{folders?.map((invite) => (
<InviteCard key={invite.id} invite={invite} />
))}
</SimpleGrid>
) : (
<Paper withBorder p='sm' my='sm'>

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

@@ -162,7 +162,7 @@ export async function uploadPartialFiles(
req.addEventListener(
'load',
() => {
const res: Response['/api/upload'] = JSON.parse(req.responseText);
const res: Response['/api/upload/partial'] = JSON.parse(req.responseText);
if ((res as ErrorBody).error) {
notifications.update({
@@ -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',
@@ -238,8 +242,8 @@ export async function uploadPartialFiles(
false,
);
req.open('POST', '/api/upload');
options.deletesAt !== 'never' && req.setRequestHeader('x-zipline-deletes-at', options.deletesAt);
req.open('POST', '/api/upload/partial');
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

@@ -41,7 +41,9 @@ export default function UrlGridView() {
}}
pos='relative'
>
{urls?.map((url) => <UrlCard setSelectedUrl={setSelectedUrl} key={url.id} url={url} />)}
{urls?.map((url) => (
<UrlCard setSelectedUrl={setSelectedUrl} key={url.id} url={url} />
))}
</SimpleGrid>
) : (
<Paper withBorder p='sm' my='sm'>

View File

@@ -37,7 +37,9 @@ export default function UserGridView() {
}}
pos='relative'
>
{users?.map((user) => <UserCard key={user.id} user={user} />)}
{users?.map((user) => (
<UserCard key={user.id} user={user} />
))}
</SimpleGrid>
) : (
<Paper withBorder p='sm' my='sm'>

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

@@ -15,6 +15,7 @@ import { ApiServerThumbnailsResponse } from '@/server/routes/api/server/thumbnai
import { ApiSetupResponse } from '@/server/routes/api/setup';
import { ApiStatsResponse } from '@/server/routes/api/stats';
import { ApiUploadResponse } from '@/server/routes/api/upload';
import { ApiUploadPartialResponse } from '@/server/routes/api/upload/partial';
import { ApiUserResponse } from '@/server/routes/api/user';
import { ApiUserExportResponse } from '@/server/routes/api/user/export';
import { ApiUserFilesResponse } from '@/server/routes/api/user/files';
@@ -76,6 +77,7 @@ export type Response = {
'/api/healthcheck': ApiHealthcheckResponse;
'/api/setup': ApiSetupResponse;
'/api/upload': ApiUploadResponse;
'/api/upload/partial': ApiUploadPartialResponse;
'/api/version': ApiVersionResponse;
'/api/stats': ApiStatsResponse;
};

View File

@@ -1,160 +0,0 @@
import { config } from '@/lib/config';
import { hashPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { log } from '@/lib/logger';
import { guess } from '@/lib/mimes';
import { formatFileName } from '@/lib/uploader/formatFileName';
import { UploadHeaders, UploadOptions } from '@/lib/uploader/parseHeaders';
import { ApiUploadResponse, MultipartFileBuffer } from '@/server/routes/api/upload';
import { FastifyRequest } from 'fastify';
import { readdir, rm, writeFile } from 'fs/promises';
import { join } from 'path';
import { Worker } from 'worker_threads';
import { getExtension } from './upload';
import { randomCharacters } from '@/lib/random';
import { bytes } from '@/lib/bytes';
const partialsCache = new Map<string, { length: number; options: UploadOptions }>();
const logger = log('api').c('upload');
export async function handlePartialUpload({
file,
options,
domain,
response,
req,
}: {
file: MultipartFileBuffer;
options: UploadOptions;
domain: string;
response: ApiUploadResponse;
req: FastifyRequest<{ Headers: UploadHeaders }>;
}) {
if (!options.partial) throw 'No partial upload options provided';
logger.debug('partial upload detected', { partial: options.partial });
if (!options.partial.range || options.partial.range.length !== 3) throw 'Invalid partial upload';
if (options.partial.range[0] === 0) {
const identifier = randomCharacters(8);
partialsCache.set(identifier, { length: file.buffer.length, options });
options.partial.identifier = identifier;
} else {
if (!options.partial.identifier || !partialsCache.has(options.partial.identifier))
throw 'No partial upload identifier provided';
}
const cache = partialsCache.get(options.partial.identifier);
if (!cache) throw 'No partial upload cache found';
const prefix = `zipline_partial_${options.partial.identifier}_`;
if (cache.length + file.buffer.length > bytes(config.files.maxFileSize)) {
partialsCache.delete(options.partial.identifier);
const tempFiles = await readdir(config.core.tempDirectory);
await Promise.all(
tempFiles.filter((f) => f.startsWith(prefix)).map((f) => rm(join(config.core.tempDirectory, f))),
);
throw 'File is too large';
}
cache.length += file.buffer.length;
const extension = getExtension(options.partial.filename, options.overrides?.extension);
if (config.files.disabledExtensions.includes(extension)) throw `File extension ${extension} is not allowed`;
const format = options.format || config.files.defaultFormat;
let fileName = formatFileName(format, decodeURIComponent(options.partial.filename));
if (options.overrides?.filename || format === 'name') {
if (options.overrides?.filename) fileName = decodeURIComponent(options.overrides!.filename!);
const existing = await prisma.file.findFirst({
where: {
name: {
startsWith: fileName,
},
},
});
if (existing) throw `A file with the name "${fileName}*" already exists`;
}
let mimetype = options.partial.contentType;
if (mimetype === 'application/octet-stream' && config.files.assumeMimetypes) {
const mime = await guess(extension.substring(1));
if (mime) mimetype = mime;
}
let folder = null;
if (options.folder) {
folder = await prisma.folder.findFirst({
where: {
id: options.folder,
},
});
if (!folder) throw 'Folder does not exist';
if (!folder.allowUploads && folder.userId !== req.user?.id) throw 'Folder is not open';
}
const tempFile = join(
config.core.tempDirectory,
`${prefix}${options.partial.range[0]}_${options.partial.range[1]}`,
);
await writeFile(tempFile, file.buffer);
if (options.partial.lastchunk) {
const fileUpload = await prisma.file.create({
data: {
name: `${fileName}${extension}`,
size: 0,
type: mimetype,
User: {
connect: {
id: req.user ? req.user.id : options.folder ? folder?.userId : undefined,
},
},
...(options.password && { password: await hashPassword(options.password) }),
...(options.folder && { Folder: { connect: { id: options.folder } } }),
...(options.addOriginalName && {
originalName: options.partial.filename
? decodeURIComponent(options.partial.filename)
: file.filename,
}),
},
});
new Worker('./build/offload/partial.js', {
workerData: {
user: {
id: req.user ? req.user.id : options.folder ? folder?.userId : undefined,
},
file: {
id: fileUpload.id,
filename: fileUpload.name,
type: fileUpload.type,
},
options,
domain,
responseUrl: `${domain}/${encodeURIComponent(fileUpload.name)}`,
},
});
response.files.push({
id: fileUpload.id,
type: fileUpload.type,
url: `${domain}/${encodeURIComponent(fileUpload.name)}`,
pending: true,
});
partialsCache.delete(options.partial.identifier);
}
response.partialSuccess = true;
if (options.partial.range[0] === 0) {
response.partialIdentifier = options.partial.identifier;
}
}

View File

@@ -1,178 +0,0 @@
import { ApiUploadResponse, MultipartFileBuffer } from '@/server/routes/api/upload';
import { FastifyRequest } from 'fastify';
import { extname } from 'path';
import { bytes } from '@/lib/bytes';
import { compress } from '@/lib/compress';
import { config } from '@/lib/config';
import { hashPassword } from '@/lib/crypto';
import { datasource } from '@/lib/datasource';
import { prisma } from '@/lib/db';
import { fileSelect } from '@/lib/db/models/file';
import { onUpload } from '@/lib/webhooks';
import { removeGps } from '@/lib/gps';
import { log } from '@/lib/logger';
import { guess } from '@/lib/mimes';
import { formatFileName } from '@/lib/uploader/formatFileName';
import { UploadHeaders, UploadOptions } from '@/lib/uploader/parseHeaders';
const logger = log('api').c('upload');
const commonDoubleExts = [
'.tar.gz',
'.tar.xz',
'.tar.bz2',
'.tar.lz',
'.tar.lzma',
'.tar.Z',
'.tar.7z',
'.zip.gz',
'.zip.xz',
'.rar.gz',
'.log.gz',
'.csv.gz',
'.pdf.gz',
// feel free to PR more
];
export const getExtension = (filename: string, override?: string): string => {
return override ?? commonDoubleExts.find((ext) => filename.endsWith(ext)) ?? extname(filename);
};
export async function handleFile({
file,
i,
options,
domain,
response,
req,
}: {
file: MultipartFileBuffer;
i: number;
options: UploadOptions;
domain: string;
response: ApiUploadResponse;
req: FastifyRequest<{ Headers: UploadHeaders }>;
}) {
const extension = getExtension(file.filename, options.overrides?.extension);
if (config.files.disabledExtensions.includes(extension)) throw `File extension ${extension} is not allowed`;
if (file.file.bytesRead > bytes(config.files.maxFileSize))
throw `File size is too large. Maximum file size is ${bytes(config.files.maxFileSize)} bytes`;
const format = options.format || config.files.defaultFormat;
let fileName = formatFileName(format, file.filename);
if (options.overrides?.filename || format === 'name') {
if (options.overrides?.filename) fileName = decodeURIComponent(options.overrides!.filename!);
const existing = await prisma.file.findFirst({
where: {
name: {
startsWith: fileName,
},
},
});
if (existing) throw `A file with the name "${fileName}*" already exists`;
}
let mimetype = file.mimetype;
if (mimetype === 'application/octet-stream' && config.files.assumeMimetypes) {
const mime = await guess(extension.substring(1));
if (!mime) response.assumedMimetypes![i] = false;
else {
response.assumedMimetypes![i] = true;
mimetype = mime;
}
}
let folder = null;
if (options.folder) {
folder = await prisma.folder.findFirst({
where: {
id: options.folder,
},
});
if (!folder) throw 'Folder does not exist';
if (!folder.allowUploads && folder.userId !== req.user?.id) throw 'Folder is not open';
}
let compressed = false;
if (mimetype.startsWith('image/') && options.imageCompressionPercent) {
file.buffer = await compress(file.buffer, options.imageCompressionPercent);
logger.c('jpg').debug(`compressed file ${file.filename}`, {
nsize: bytes(file.buffer.length),
});
compressed = true;
}
let removedGps = false;
if (mimetype.startsWith('image/') && config.files.removeGpsMetadata) {
removedGps = await removeGps(file.buffer);
if (removedGps) {
logger.c('gps').debug(`removed gps metadata from ${file.filename}`);
}
}
const fileUpload = await prisma.file.create({
data: {
name: `${fileName}${compressed ? '.jpg' : extension}`,
size: file.buffer ? file.buffer.length : file.file.bytesRead,
type: compressed ? 'image/jpeg' : mimetype,
User: {
connect: {
id: req.user ? req.user.id : options.folder ? folder?.userId : undefined,
},
},
...(options.maxViews && { maxViews: options.maxViews }),
...(options.password && { password: await hashPassword(options.password) }),
...(options.deletesAt && { deletesAt: options.deletesAt }),
...(options.folder && { Folder: { connect: { id: options.folder } } }),
...(options.addOriginalName && { originalName: file.filename }),
},
select: fileSelect,
});
await datasource.put(fileUpload.name, file.buffer, {
mimetype: fileUpload.type,
});
const responseUrl = `${domain}${
config.files.route === '/' || config.files.route === '' ? '' : `${config.files.route}`
}/${fileUpload.name}`;
response.files.push({
id: fileUpload.id,
type: fileUpload.type,
url: encodeURI(responseUrl),
...(removedGps && { removedGps: true }),
...(compressed && { compressed: true }),
});
logger.info(`${req.user ? req.user.username : '[anonymous folder upload]'} uploaded ${fileUpload.name}`, {
size: bytes(fileUpload.size),
ip: req.ip,
});
await onUpload({
user: req.user ?? {
id: 'anonymous',
username: 'anonymous',
createdAt: new Date(),
updatedAt: new Date(),
role: 'USER',
},
file: fileUpload,
link: {
raw: `${domain}/raw/${encodeURIComponent(fileUpload.name)}`,
returned: encodeURI(responseUrl),
},
});
return;
}

View File

@@ -1,7 +1,11 @@
import sharp from 'sharp';
export function compress(buffer: Buffer, qualty: number) {
return sharp(buffer).withMetadata().jpeg({ quality: qualty }).toBuffer();
export function compressFile(filePath: string, quality: number) {
const buffer = sharp(filePath).withMetadata().jpeg({ quality: quality }).toBuffer();
return buffer.then((data) => {
return sharp(data).toFile(filePath);
});
}
export function replaceFileNameJpg(original: string, when?: boolean) {

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

@@ -4,10 +4,11 @@ export abstract class Datasource {
public name: string | undefined;
public abstract get(file: string): null | Readable | Promise<Readable | null>;
public abstract put(file: string, data: Buffer, options?: { mimetype?: string }): Promise<void>;
public abstract delete(file: string): Promise<void>;
public abstract put(file: string, data: Buffer | string, options?: { mimetype?: string }): Promise<void>;
public abstract delete(file: string | string[]): Promise<void>;
public abstract size(file: string): Promise<number>;
public abstract totalSize(): Promise<number>;
public abstract clear(): Promise<void>;
public abstract range(file: string, start: number, end: number): Promise<Readable>;
public abstract rename(from: string, to: string): Promise<void>;
}

View File

@@ -1,9 +1,18 @@
import { createReadStream, existsSync } from 'fs';
import { readdir, rm, stat, writeFile } from 'fs/promises';
import { access, constants, copyFile, readdir, rename, rm, stat, writeFile } from 'fs/promises';
import { join } from 'path';
import { Readable } from 'stream';
import { Datasource } from './Datasource';
async function existsAndCanRW(path: string): Promise<boolean> {
try {
await access(path, constants.R_OK | constants.W_OK);
return true;
} catch {
return false;
}
}
export class LocalDatasource extends Datasource {
name = 'local';
@@ -20,11 +29,41 @@ export class LocalDatasource extends Datasource {
return readStream;
}
public async put(file: string, data: Buffer): Promise<void> {
return writeFile(join(this.dir, file), data);
public async put(file: string, data: Buffer | string): Promise<void> {
const path = join(this.dir, file);
// handles if given a path to a file, it will just move it instead of doing unecessary writes
if (typeof data === 'string') {
const exists = await existsAndCanRW(data);
if (!exists)
throw new Error(
"Something went very wrong! the temporary directory wasn't readable or the file doesn't exist.",
);
try {
await rename(data, path);
} catch (err) {
// docker may throw exdev errors when renaming across volumes (/tmp to something else)
if ((err as NodeJS.ErrnoException).code === 'EXDEV') {
await copyFile(data, path);
await rm(data);
return;
} else {
throw err;
}
}
}
return writeFile(path, data);
}
public async delete(file: string): Promise<void> {
public async delete(file: string | string[]): Promise<void> {
if (Array.isArray(file)) {
await Promise.all(file.map((f) => this.delete(f)));
return;
}
const path = join(this.dir, file);
if (!existsSync(path)) return Promise.resolve();
@@ -59,4 +98,14 @@ export class LocalDatasource extends Datasource {
return readStream;
}
public async rename(from: string, to: string): Promise<void> {
const fromPath = join(this.dir, from);
const toPath = join(this.dir, to);
if (!existsSync(fromPath))
throw new Error(`Something went very wrong! File ${from} does not exist in local datasource.`);
return rename(fromPath, toPath);
}
}

View File

@@ -1,4 +1,5 @@
import {
CopyObjectCommand,
DeleteObjectCommand,
DeleteObjectsCommand,
GetObjectCommand,
@@ -7,6 +8,7 @@ import {
S3Client,
} from '@aws-sdk/client-s3';
import { NodeHttpHandler } from '@smithy/node-http-handler';
import { createReadStream } from 'fs';
import { Agent as HttpAgent } from 'http';
import { Agent as HttpsAgent } from 'https';
import { Readable } from 'stream';
@@ -160,18 +162,30 @@ export class S3Datasource extends Datasource {
public async put(
file: string,
data: Buffer,
data: Buffer | string,
options: {
mimetype?: string;
} = {},
): Promise<void> {
const command = new PutObjectCommand({
let command = new PutObjectCommand({
Bucket: this.options.bucket,
Key: this.key(file),
Body: data,
...(options.mimetype ? { ContentType: options.mimetype } : {}),
});
if (typeof data === 'string') {
const readStream = createReadStream(data);
command = new PutObjectCommand({
Bucket: this.options.bucket,
Key: this.key(file),
Body: readStream,
...(options.mimetype ? { ContentType: options.mimetype } : {}),
});
this.logger.debug('putting object from stream', { file, key: this.key(file) });
}
try {
const res = await this.client.send(command);
@@ -186,14 +200,25 @@ export class S3Datasource extends Datasource {
}
}
public async delete(file: string): Promise<void> {
const command = new DeleteObjectCommand({
Bucket: this.options.bucket,
Key: this.key(file),
});
public async delete(file: string | string[]): Promise<void> {
let command: DeleteObjectCommand | DeleteObjectsCommand;
if (Array.isArray(file)) {
command = new DeleteObjectsCommand({
Bucket: this.options.bucket,
Delete: {
Objects: file.map((f) => ({ Key: this.key(f) })),
},
});
} else {
command = new DeleteObjectCommand({
Bucket: this.options.bucket,
Key: this.key(file),
});
}
try {
const res = await this.client.send(command);
const res = await this.client.send(command as never);
if (!isOk(res.$metadata.httpStatusCode || 0)) {
this.logger.error('there was an error while deleting object');
@@ -302,4 +327,38 @@ export class S3Datasource extends Datasource {
return Readable.fromWeb(new ReadableStream());
}
}
public async rename(from: string, to: string): Promise<void> {
const copyCommand = new CopyObjectCommand({
Bucket: this.options.bucket,
Key: this.key(to),
CopySource: this.options.bucket + '/' + this.key(from),
});
const deleteCommand = new DeleteObjectCommand({
Bucket: this.options.bucket,
Key: this.key(from),
});
try {
const copyRes = await this.client.send(copyCommand);
if (!isOk(copyRes.$metadata.httpStatusCode || 0)) {
this.logger.error('there was an error while copying object');
this.logger.error('error metadata', copyRes.$metadata as Record<string, unknown>);
throw new Error('Failed to copy object');
}
const deleteRes = await this.client.send(deleteCommand);
if (!isOk(deleteRes.$metadata.httpStatusCode || 0)) {
this.logger.error('there was an error while deleting old object');
this.logger.error('error metadata', deleteRes.$metadata as Record<string, unknown>);
throw new Error('Failed to delete old object');
}
} catch (e) {
this.logger.error('there was an error while renaming object');
this.logger.error('error metadata', e as Record<string, unknown>);
throw new Error('Failed to rename object');
}
}
}

View File

@@ -1,4 +1,5 @@
import { config } from '../config';
import { isMainThread } from 'worker_threads';
import { Config } from '../config/validate';
import { log } from '../logger';
import { Datasource } from './Datasource';
import { LocalDatasource } from './Local';
@@ -7,12 +8,11 @@ import { S3Datasource } from './S3';
let datasource: Datasource;
declare global {
// eslint-disable-next-line no-var
var __datasource__: Datasource;
}
function getDatasource(conf?: typeof config): void {
if (!conf) return;
function getDatasource(config?: Config): void {
if (!config) return;
const logger = log('datasource');
@@ -39,8 +39,16 @@ function getDatasource(conf?: typeof config): void {
datasource = global.__datasource__;
if (!global.__datasource__ && !datasource) {
getDatasource(config);
// Don't instantiate datasource if we are not in the main thread since they handle their own initialization
if (!global.__datasource__ && !datasource && isMainThread) {
import('../config/index.js')
.then(({ config }) => {
getDatasource(config);
})
.catch((error) => {
console.error('Failed to initialize datasource:', error);
process.exit(1);
});
}
export { datasource, getDatasource };

View File

@@ -1,15 +1,15 @@
import { log } from '@/lib/logger';
import { PrismaPg } from '@prisma/adapter-pg';
import { Prisma, PrismaClient } from '../../../generated/client';
import { userViewSchema } from './models/user';
import { metricDataSchema } from './models/metric';
import { metadataSchema } from './models/incompleteFile';
import { metricDataSchema } from './models/metric';
import { userViewSchema } from './models/user';
const building = !!process.env.ZIPLINE_BUILD;
let prisma: ExtendedPrismaClient;
declare global {
// eslint-disable-next-line no-var
var __db__: ExtendedPrismaClient;
}
@@ -36,7 +36,9 @@ function getClient() {
logger.info('connecting to database ' + process.env.DATABASE_URL);
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const client = new PrismaClient({
adapter,
log: process.env.ZIPLINE_DB_LOG ? parseDbLog(process.env.ZIPLINE_DB_LOG) : undefined,
}).$extends({
result: {

View File

@@ -1,21 +0,0 @@
// @ts-ignore
import * as gmr from '@xoi/gps-metadata-remover';
export const removeLocation = gmr.removeLocation as (
photoUri: string,
read: ReadFunction,
write: WriteFunction,
) => Promise<boolean>;
export type ReadFunction = (size: number, offset: number) => Promise<Buffer>;
export type WriteFunction = (writeValue: string, entryOffset: number, encoding: string) => Promise<void>;
export async function removeGps(buffer: Buffer): Promise<boolean> {
const read = (size: number, offset: number) => Promise.resolve(buffer.subarray(offset, offset + size));
const write = (writeValue: string, entryOffset: number, encoding: string) => {
buffer.write(writeValue, entryOffset, encoding as BufferEncoding);
return Promise.resolve();
};
return removeLocation('', read, write);
}

14
src/lib/gps/constants.ts Normal file
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;

140
src/lib/gps/index.ts Normal file
View File

@@ -0,0 +1,140 @@
// heavily modified from @xoi/gps-metadata-remover to fit the needs of zipline
import { readFileSync } from 'fs';
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(input: Buffer | string): boolean {
let buffer: Buffer;
if (typeof input === 'string') {
buffer = readFileSync(input);
} else {
buffer = input;
}
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

@@ -1,5 +1,6 @@
import { Worker } from 'worker_threads';
import Logger, { log } from '../logger';
import { config } from '../config';
export interface Task {
id: string;
@@ -14,6 +15,8 @@ export interface WorkerTask<Data = any> extends Task {
path: string;
data: Data;
onMessage?: (message: any) => void;
worker?: Worker;
}
@@ -93,6 +96,10 @@ export class Tasks {
private startWorker(task: WorkerTask) {
task.started = true;
if (task.data) {
task.data.config = config;
}
const worker = new Worker(task.path, {
workerData: task.data,
});
@@ -109,6 +116,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 +138,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

@@ -1,6 +1,6 @@
import { bytes } from '@/lib/bytes';
import { datasource } from '@/lib/datasource';
import { IntervalTask } from '..';
import { bytes } from '@/lib/bytes';
export default function deleteFiles(prisma: typeof globalThis.__db__) {
return async function (this: IntervalTask) {

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

@@ -1,7 +1,6 @@
import { z } from 'zod';
import { discordContent } from '../config/validate';
import { Config, discordContent } from '../config/validate';
import { ParseValue, parseString } from '../parser';
import { config } from '../config';
import { File } from '../db/models/file';
import { User } from '../db/models/user';
import { log } from '../logger';
@@ -46,6 +45,7 @@ export function hexString(value?: string | null): number | null {
}
export function parseContent(
config: Config,
content: DiscordContent | null,
value: ParseValue,
): (DiscordContent & { raw: string }) | null {
@@ -111,7 +111,10 @@ export function buildResponse(
};
}
export async function onUpload({ user, file, link }: { user: User; file: File; link: ParseValue['link'] }) {
export async function onUpload(
config: Config,
{ user, file, link }: { user: User; file: File; link: ParseValue['link'] },
) {
if (!config.discord?.onUpload) return logger.debug('no onUpload config, no webhook executed');
const webhookUrl = config.discord?.onUpload?.webhookUrl || config.discord?.webhookUrl;
@@ -119,7 +122,7 @@ export async function onUpload({ user, file, link }: { user: User; file: File; l
const metrics = await parserMetrics(user.id);
const content = parseContent(config.discord?.onUpload, { user, file, link, ...metrics });
const content = parseContent(config, config.discord?.onUpload, { user, file, link, ...metrics });
if (!content) return logger.debug('no content somehow, no webhook executed');
const response = buildResponse(content, file);
@@ -142,15 +145,18 @@ export async function onUpload({ user, file, link }: { user: User; file: File; l
return;
}
export async function onShorten({
user,
url,
link,
}: {
user: User;
url: Partial<Url>;
link: ParseValue['link'];
}) {
export async function onShorten(
config: Config,
{
user,
url,
link,
}: {
user: User;
url: Partial<Url>;
link: ParseValue['link'];
},
) {
if (!config.discord?.onShorten) return logger.debug('no onShorten config, no webhook executed');
const webhookUrl = config.discord?.onShorten?.webhookUrl || config.discord?.webhookUrl;
@@ -158,7 +164,7 @@ export async function onShorten({
const metrics = await parserMetrics(user.id);
const content = parseContent(config.discord?.onShorten, { user, url, link, ...metrics });
const content = parseContent(config, config.discord?.onShorten, { user, url, link, ...metrics });
if (!content) return logger.debug('no content somehow, no webhook executed');
const response = buildResponse(content, undefined, url);

View File

@@ -1,10 +1,10 @@
import { config } from '../config';
import { Config } from '../config/validate';
import { log } from '../logger';
import { onUpload as discordOnUpload, onShorten as discordOnShorten } from './discord';
const logger = log('webhooks').c('http');
export async function onUpload({ user, file, link }: Parameters<typeof discordOnUpload>[0]) {
export async function onUpload(config: Config, { user, file, link }: Parameters<typeof discordOnUpload>[1]) {
if (!config.httpWebhook.onUpload) return;
if (!URL.canParse(config.httpWebhook.onUpload)) {
logger.debug('invalid url for http onUpload');
@@ -51,7 +51,7 @@ export async function onUpload({ user, file, link }: Parameters<typeof discordOn
return;
}
export async function onShorten({ user, url, link }: Parameters<typeof discordOnShorten>[0]) {
export async function onShorten(config: Config, { user, url, link }: Parameters<typeof discordOnShorten>[1]) {
if (!config.httpWebhook.onShorten) return;
if (!URL.canParse(config.httpWebhook.onShorten)) {
logger.debug('invalid url for http onShorten');

View File

@@ -1,14 +1,15 @@
import { Config } from '../config/validate';
import { onUpload as discordOnUpload, onShorten as discordOnShorten } from './discord';
import { onUpload as httpOnUpload, onShorten as httpOnShorten } from './http';
export async function onUpload(args: Parameters<typeof discordOnUpload>[0]) {
Promise.all([discordOnUpload(args), httpOnUpload(args)]);
export async function onUpload(config: Config, args: Parameters<typeof discordOnUpload>[1]) {
Promise.all([discordOnUpload(config, args), httpOnUpload(config, args)]);
return;
}
export async function onShorten(args: Parameters<typeof discordOnShorten>[0]) {
Promise.all([discordOnShorten(args), httpOnShorten(args)]);
export async function onShorten(config: Config, args: Parameters<typeof discordOnShorten>[1]) {
Promise.all([discordOnShorten(config, args), httpOnShorten(config, args)]);
return;
}

View File

@@ -1,11 +1,10 @@
import { bytes } from '@/lib/bytes';
import { reloadSettings } from '@/lib/config';
import { Config } from '@/lib/config/validate';
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: {
@@ -28,9 +28,10 @@ export type PartialWorkerData = {
options: UploadOptions;
domain: string;
responseUrl: string;
config: Config;
};
const { user, file, options, responseUrl, domain } = workerData as PartialWorkerData;
const { user, file, options, responseUrl, domain, config } = workerData as PartialWorkerData;
const logger = log('tasks').c('partial').c(file.filename);
if (isMainThread) {
@@ -48,12 +49,13 @@ if (!options.partial.lastchunk) {
process.exit(1);
}
let finalPath: string | undefined;
main();
async function main() {
await reloadSettings();
global.__config__ = config;
const config = global.__config__;
getDatasource(config);
if (!config.chunks.enabled) {
@@ -76,7 +78,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,
@@ -88,7 +90,7 @@ async function main() {
},
});
const finalPath =
finalPath =
config.datasource.type === 'local'
? join(config.datasource.local!.directory, file.filename)
: join(config.core.tempDirectory, randomCharacters(16));
@@ -104,7 +106,7 @@ async function main() {
try {
await new Promise<void>((resolve, reject) => {
const readStream = createReadStream(chunkPath);
const writeStream = createWriteStream(finalPath, { start: chunk.start, flags: 'r+' });
const writeStream = createWriteStream(finalPath!, { start: chunk.start, flags: 'r+' });
readStream.pipe(writeStream);
@@ -114,7 +116,7 @@ async function main() {
});
await rm(chunkPath);
await prisma.incompleteFile.update({
await dbProxy('incompleteFile.update', {
where: {
id: incompleteFile.id,
},
@@ -133,7 +135,7 @@ async function main() {
} catch (e) {
logger.error('error while combining chunks');
console.error(e);
await failPartial(incompleteFile);
await failPartial(config, incompleteFile);
process.exit(1);
}
@@ -165,13 +167,13 @@ async function main() {
} catch (e) {
logger.error('error while uploading multipart file');
console.error(e);
await failPartial(incompleteFile);
await failPartial(config, incompleteFile);
process.exit(1);
}
}
await prisma.incompleteFile.update({
await dbProxy('incompleteFile.update', {
where: {
id: incompleteFile.id,
},
@@ -184,7 +186,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,21 +194,26 @@ 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,
});
logger.info(`${userr.username} uploaded ${fileUpload.name}`, { size: bytes(fileUpload.size) });
logger.info(`${userr.username} uploaded ${fileUpload.name}`, {
size: bytes(fileUpload.size),
partial: true,
});
await onUpload({
await onUpload(config, {
user: userr,
file: fileUpload,
link: {
@@ -216,9 +223,20 @@ async function runComplete(id: string) {
});
}
function failPartial(incompleteFile: IncompleteFile) {
async function failPartial(config: Config, incompleteFile: IncompleteFile) {
logger.error('failing incomplete file', { id: incompleteFile.id });
return prisma.incompleteFile.update({
const partials = await readdir(config.core.tempDirectory).then((files) =>
files.filter((file) => file.startsWith(`zipline_partial_${options.partial!.identifier}`)),
);
await Promise.all(partials.map((file) => rm(join(config.core.tempDirectory, file)).catch(() => {})));
if (finalPath) {
try {
await rm(finalPath);
} catch {}
}
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

@@ -1,21 +1,24 @@
import { bytes } from '@/lib/bytes';
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 type { File } from '@/lib/db/models/file';
import { log } from '@/lib/logger';
import ffmpeg from 'fluent-ffmpeg';
import { createWriteStream, readFileSync, unlinkSync } from 'fs';
import { createWriteStream, existsSync, 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;
config: Config;
};
const { id, enabled } = workerData as ThumbnailWorkerData;
type ThumbnailId = File['thumbnail'] & { id: string };
const { id, enabled, config } = workerData as ThumbnailWorkerData;
const logger = log('tasks').c(id);
@@ -57,6 +60,12 @@ function genThumbnail(file: string, thumbnailTmp: string): Promise<Buffer | unde
reject(err);
})
.on('end', () => {
if (!existsSync(thumbnailTmp)) {
logger.error('expected thumbnail file does not exist', { thumbnailTmp });
unlinkSync(file);
return resolve(undefined);
}
const buffer = readFileSync(thumbnailTmp);
unlinkSync(thumbnailTmp);
@@ -71,7 +80,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 +120,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 +128,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,
},
@@ -141,16 +150,13 @@ async function generate(config: Config, datasource: Datasource, ids: string[]) {
}
async function main() {
await reloadSettings();
const config = global.__config__;
getDatasource(config);
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 +168,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

@@ -42,7 +42,9 @@ export default function ViewFolderId({
}}
spacing='md'
>
{folder.files?.map((file) => <DashboardFile key={file.id} file={file} reduce />)}
{folder.files?.map((file) => (
<DashboardFile key={file.id} file={file} reduce />
))}
</SimpleGrid>
</Container>
</>

View File

@@ -274,7 +274,11 @@ export default function ViewFileId({
{file.name}{' '}
</Text>
{user?.view!.showTags && (
<Group gap={4}>{file.tags?.map((tag) => <TagPill key={tag.id} tag={tag} />)}</Group>
<Group gap={4}>
{file.tags?.map((tag) => (
<TagPill key={tag.id} tag={tag} />
))}
</Group>
)}
{user?.view!.showFolder &&
file.Folder &&

View File

@@ -219,24 +219,53 @@ async function main() {
// Tasks
tasks.interval('deletefiles', ms(config.tasks.deleteInterval as StringValue), deleteFiles(prisma));
tasks.interval('maxviews', ms(config.tasks.maxViewsInterval as StringValue), maxViews(prisma));
tasks.interval('clearinvites', ms(config.tasks.clearInvitesInterval as StringValue), clearInvites(prisma));
if (config.features.metrics)
tasks.interval('metrics', ms(config.tasks.metricsInterval as StringValue), metrics(prisma));
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.interval('thumbnails', ms(config.tasks.thumbnailsInterval as StringValue), thumbnails(prisma));
tasks.interval(
'clearinvites',
ms(config.tasks.clearInvitesInterval as StringValue),
clearInvites(prisma),
);
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,
},
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.start();

View File

@@ -1,3 +1,4 @@
import { Prisma } from '../../../../../../generated/client';
import { prisma } from '@/lib/db';
import { Invite, inviteInviterSelect } from '@/lib/db/models/invite';
import { log } from '@/lib/logger';
@@ -16,14 +17,10 @@ const logger = log('api').c('auth').c('invites').c('[id]');
export const PATH = '/api/auth/invites/:id';
export default fastifyPlugin(
(server, _, done) => {
server.route<{
Body: Body;
Params: Params;
}>({
url: PATH,
method: ['GET', 'DELETE'],
preHandler: [userMiddleware, administratorMiddleware],
handler: async (req, res) => {
server.get<{ Params: Params }>(
PATH,
{ preHandler: [userMiddleware, administratorMiddleware] },
async (req, res) => {
const { id } = req.params;
const invite = await prisma.invite.findFirst({
@@ -36,10 +33,20 @@ export default fastifyPlugin(
});
if (!invite) return res.notFound('Invite not found through id or code');
if (req.method === 'DELETE') {
const nInvite = await prisma.invite.delete({
return res.send(invite);
},
);
server.delete<{ Params: Params }>(
PATH,
{ preHandler: [userMiddleware, administratorMiddleware] },
async (req, res) => {
const { id } = req.params;
try {
const invite = await prisma.invite.delete({
where: {
id: invite.id,
id: id,
},
include: {
inviter: inviteInviterSelect,
@@ -51,12 +58,17 @@ export default fastifyPlugin(
code: invite.code,
});
return res.send(nInvite);
}
return res.send(invite);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025') {
return res.notFound('Invite not found');
}
return res.send(invite);
logger.error(`Failed to delete invite with id ${id}`, { error });
return res.internalServerError('Failed to delete invite');
}
},
});
);
done();
},

View File

@@ -54,7 +54,7 @@ export default fastifyPlugin(
},
);
server.get(PATH, { preHandler: [userMiddleware, administratorMiddleware] }, async (req, res) => {
server.get(PATH, { preHandler: [userMiddleware, administratorMiddleware] }, async (_, res) => {
const invites = await prisma.invite.findMany({
include: {
inviter: inviteInviterSelect,

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

@@ -1,177 +0,0 @@
import { handlePartialUpload } from '@/lib/api/upload/partialUpload';
import { handleFile } from '@/lib/api/upload/upload';
import { bytes } from '@/lib/bytes';
import { config } from '@/lib/config';
import { prisma } from '@/lib/db';
import { log } from '@/lib/logger';
import { UploadHeaders, parseHeaders } from '@/lib/uploader/parseHeaders';
import { userMiddleware } from '@/server/middleware/user';
import { MultipartFile } from '@fastify/multipart';
import fastifyPlugin from 'fastify-plugin';
export type MultipartFileBuffer = MultipartFile & { buffer: Buffer };
export type ApiUploadResponse = {
files: {
id: string;
type: string;
url: string;
pending?: boolean;
}[];
deletesAt?: string;
assumedMimetypes?: boolean[];
partialSuccess?: boolean;
partialIdentifier?: string;
};
const logger = log('api').c('upload');
export const PATH = '/api/upload';
export default fastifyPlugin(
(server, _, done) => {
const rateLimit = server.rateLimit
? server.rateLimit()
: (_req: any, _res: any, next: () => any) => next();
server.post<{
Headers: UploadHeaders;
}>(PATH, { preHandler: [userMiddleware, rateLimit] }, async (req, res) => {
const options = parseHeaders(req.headers, config.files);
if (options.header) return res.badRequest('bad options, receieved: ' + JSON.stringify(options));
if (options.folder) {
const folder = await prisma.folder.findFirst({
where: {
id: options.folder,
},
});
if (!folder) return res.badRequest('folder not found');
if (!req.user && !folder.allowUploads) return res.forbidden('folder is not open');
}
const filesIterable = req.files();
const files: MultipartFileBuffer[] = [];
for await (const file of filesIterable) {
(<MultipartFileBuffer>file).buffer = await file.toBuffer();
files.push(<MultipartFileBuffer>file);
}
if (req.user?.quota) {
const totalFileSize = options.partial
? options.partial.contentLength
: files.reduce((acc, x) => acc + x.file.bytesRead, 0);
const userAggregateStats = await prisma.file.aggregate({
where: {
userId: req.user.id,
},
_sum: {
size: true,
},
_count: {
_all: true,
},
});
const aggSize: bigint =
userAggregateStats!._sum?.size === null
? 0n
: (userAggregateStats!._sum?.size as unknown as bigint);
if (
req.user.quota.filesQuota === 'BY_BYTES' &&
Number(aggSize) + totalFileSize > bytes(req.user.quota.maxBytes!)
)
return res.payloadTooLarge(
`uploading will exceed your storage quota of ${bytes(req.user.quota.maxBytes!)} bytes`,
);
if (
req.user.quota.filesQuota === 'BY_FILES' &&
userAggregateStats!._count?._all + req.files.length > req.user.quota.maxFiles!
)
return res.payloadTooLarge(
`uploading will exceed your file count quota of ${req.user.quota.maxFiles} files`,
);
}
const response: ApiUploadResponse = {
files: [],
...(options.deletesAt && { deletesAt: options.deletesAt.toISOString() }),
...(config.files.assumeMimetypes && { assumedMimetypes: Array(req.files.length) }),
};
let domain;
if (options.overrides?.returnDomain) {
domain = `${config.core.returnHttpsUrls ? 'https' : 'http'}://${options.overrides.returnDomain}`;
} else if (config.core.defaultDomain) {
domain = `${config.core.returnHttpsUrls ? 'https' : 'http'}://${config.core.defaultDomain}`;
} else {
domain = `${config.core.returnHttpsUrls ? 'https' : 'http'}://${req.headers.host}`;
}
logger.debug('uploading files', { files: files.map((x) => x.filename) });
if (options.partial && config.chunks.enabled) {
if (files.length > 1) return res.badRequest('partial uploads only support one file field');
try {
await handlePartialUpload({
req,
file: files[0],
options,
domain,
response,
});
} catch (e) {
console.log(e);
if (typeof e === 'string') {
return res.badRequest(e);
} else {
logger.error('error while processing partial file ' + e);
return res.internalServerError('An error occurred while processing the file');
}
}
return res.send(response);
}
for (let i = 0; i !== files.length; ++i) {
try {
await handleFile({
file: files[i],
i,
options,
domain,
response,
req,
});
} catch (e) {
console.log(e);
if (typeof e === 'string') {
return res.badRequest(e);
} else {
logger.error('error while processing file ' + e);
return res.internalServerError('An error occurred while processing the file');
}
}
}
if (options.noJson)
return res
.status(200)
.type('text/plain')
.send(response.files.map((x) => x.url).join(','));
return res.send(response);
});
done();
},
{ name: PATH },
);

View File

@@ -0,0 +1,250 @@
import { Prisma } from '../../../../../generated/client';
import { bytes } from '@/lib/bytes';
import { compressFile } from '@/lib/compress';
import { config } from '@/lib/config';
import { hashPassword } from '@/lib/crypto';
import { datasource } from '@/lib/datasource';
import { prisma } from '@/lib/db';
import { fileSelect } from '@/lib/db/models/file';
import { removeGps } from '@/lib/gps';
import { log } from '@/lib/logger';
import { guess } from '@/lib/mimes';
import { formatFileName } from '@/lib/uploader/formatFileName';
import { UploadHeaders, parseHeaders } from '@/lib/uploader/parseHeaders';
import { onUpload } from '@/lib/webhooks';
import { userMiddleware } from '@/server/middleware/user';
import fastifyPlugin from 'fastify-plugin';
import { stat } from 'fs/promises';
import { extname } from 'path';
const commonDoubleExts = [
'.tar.gz',
'.tar.xz',
'.tar.bz2',
'.tar.lz',
'.tar.lzma',
'.tar.Z',
'.tar.7z',
'.zip.gz',
'.zip.xz',
'.rar.gz',
'.log.gz',
'.csv.gz',
'.pdf.gz',
// feel free to PR more
];
export const getExtension = (filename: string, override?: string): string => {
return override ?? commonDoubleExts.find((ext) => filename.endsWith(ext)) ?? extname(filename);
};
export type ApiUploadResponse = {
files: {
id: string;
type: string;
url: string;
pending?: boolean;
removedGps?: boolean;
compressed?: boolean;
}[];
deletesAt?: string;
assumedMimetypes?: boolean[];
};
const logger = log('api').c('upload');
export const PATH = '/api/upload';
export default fastifyPlugin(
(server, _, done) => {
const rateLimit = server.rateLimit
? server.rateLimit()
: (_req: any, _res: any, next: () => any) => next();
server.post<{
Headers: UploadHeaders;
}>(PATH, { preHandler: [userMiddleware, rateLimit] }, async (req, res) => {
const options = parseHeaders(req.headers, config.files);
if (options.header) return res.badRequest('bad options, receieved: ' + JSON.stringify(options));
let folder = null;
if (options.folder) {
folder = await prisma.folder.findFirst({
where: {
id: options.folder,
},
});
if (!folder) return res.badRequest('folder not found');
if (!req.user && !folder.allowUploads) return res.forbidden('folder is not open');
}
const files = await req.saveRequestFiles({ tmpdir: config.core.tempDirectory });
if (req.user?.quota) {
const totalFileSize = files.reduce((acc, x) => acc + x.file.bytesRead, 0);
const userAggregateStats = await prisma.file.aggregate({
where: {
userId: req.user.id,
},
_sum: {
size: true,
},
_count: {
_all: true,
},
});
const aggSize: bigint =
userAggregateStats!._sum?.size === null
? 0n
: (userAggregateStats!._sum?.size as unknown as bigint);
if (
req.user.quota.filesQuota === 'BY_BYTES' &&
Number(aggSize) + totalFileSize > bytes(req.user.quota.maxBytes!)
)
return res.payloadTooLarge(
`uploading will exceed your storage quota of ${bytes(req.user.quota.maxBytes!)} bytes`,
);
if (
req.user.quota.filesQuota === 'BY_FILES' &&
userAggregateStats!._count?._all + req.files.length > req.user.quota.maxFiles!
)
return res.payloadTooLarge(
`uploading will exceed your file count quota of ${req.user.quota.maxFiles} files`,
);
}
const response: ApiUploadResponse = {
files: [],
...(options.deletesAt && {
deletesAt: options.deletesAt === 'never' ? 'never' : options.deletesAt.toISOString(),
}),
...(config.files.assumeMimetypes && { assumedMimetypes: Array(req.files.length) }),
};
let domain;
if (options.overrides?.returnDomain) {
domain = `${config.core.returnHttpsUrls ? 'https' : 'http'}://${options.overrides.returnDomain}`;
} else if (config.core.defaultDomain) {
domain = `${config.core.returnHttpsUrls ? 'https' : 'http'}://${config.core.defaultDomain}`;
} else {
domain = `${config.core.returnHttpsUrls ? 'https' : 'http'}://${req.headers.host}`;
}
logger.debug('uploading files', { files: files.map((x) => x.filename) });
for (let i = 0; i !== files.length; ++i) {
const file = files[i];
const extension = getExtension(file.filename, options.overrides?.extension);
if (config.files.disabledExtensions.includes(extension))
return res.badRequest(`file[${i}]: File extension ${extension} is not allowed`);
if (file.file.bytesRead > bytes(config.files.maxFileSize))
return res.payloadTooLarge(
`file[${i}]: File size is too large. Maximum file size is ${bytes(config.files.maxFileSize)} bytes`,
);
// determine filename
const format = options.format || config.files.defaultFormat;
let fileName = formatFileName(format, file.filename);
if (options.overrides?.filename || format === 'name') {
if (options.overrides?.filename) fileName = decodeURIComponent(options.overrides!.filename!);
const fullFileName = `${fileName}${extension}`;
const existing = await prisma.file.findFirst({ where: { name: fullFileName } });
if (existing)
return res.badRequest(`file[${i}]: A file with the name "${fullFileName}" already exists`);
}
// determine mimetype
let mimetype = file.mimetype;
if (mimetype === 'application/octet-stream' && config.files.assumeMimetypes) {
const mime = await guess(extension.substring(1));
response.assumedMimetypes![i] = !!mime;
if (mime) mimetype = mime;
}
// compress the image if requested
let compressed = false;
if (mimetype.startsWith('image/') && options.imageCompressionPercent) {
await compressFile(file.filepath, options.imageCompressionPercent);
logger.c('jpg').debug(`compressed file ${file.filename}`);
compressed = true;
}
// remove gps metadata if requested
let removedGps = false;
if (mimetype.startsWith('image/') && config.files.removeGpsMetadata) {
const removed = removeGps(file.filepath);
if (removed) logger.c('gps').debug(`removed gps metadata from ${file.filename}`);
removedGps = removed;
}
const tempFileStats = await stat(file.filepath);
const data: Prisma.FileCreateInput = {
name: `${fileName}${compressed ? '.jpg' : extension}`,
size: tempFileStats.size,
type: compressed ? 'image/jpeg' : mimetype,
User: { connect: { id: req.user ? req.user.id : options.folder ? folder?.userId : undefined } },
};
if (options.maxViews) data.maxViews = options.maxViews;
if (options.password) data.password = await hashPassword(options.password);
if (folder) data.Folder = { connect: { id: folder.id } };
if (options.addOriginalName) data.originalName = file.filename;
data.deletesAt = options.deletesAt && options.deletesAt !== 'never' ? options.deletesAt : null;
const fileUpload = await prisma.file.create({
data,
select: fileSelect,
});
await datasource.put(fileUpload.name, file.filepath, { mimetype: fileUpload.type });
const responseUrl = `${domain}${config.files.route === '/' || config.files.route === '' ? '' : `${config.files.route}`}/${fileUpload.name}`;
response.files.push({
id: fileUpload.id,
type: fileUpload.type,
url: encodeURI(responseUrl),
removedGps: removedGps || undefined,
compressed: compressed || undefined,
});
logger.info(
`${req.user ? req.user.username : '[anonymous folder upload]'} uploaded ${fileUpload.name}`,
{ size: bytes(fileUpload.size), ip: req.ip },
);
await onUpload(config, {
user: req.user ?? {
id: 'anonymous',
username: 'anonymous',
createdAt: new Date(),
updatedAt: new Date(),
role: 'USER',
},
file: fileUpload,
link: {
raw: `${domain}/raw/${encodeURIComponent(fileUpload.name)}`,
returned: encodeURI(responseUrl),
},
});
}
if (options.noJson)
return res
.status(200)
.type('text/plain')
.send(response.files.map((x) => x.url).join(','));
return res.send(response);
});
done();
},
{ name: PATH },
);

View File

@@ -0,0 +1,280 @@
import { bytes } from '@/lib/bytes';
import { config } from '@/lib/config';
import { hashPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { log } from '@/lib/logger';
import { guess } from '@/lib/mimes';
import { randomCharacters } from '@/lib/random';
import { formatFileName } from '@/lib/uploader/formatFileName';
import { UploadHeaders, UploadOptions, parseHeaders } from '@/lib/uploader/parseHeaders';
import { userMiddleware } from '@/server/middleware/user';
import fastifyPlugin from 'fastify-plugin';
import { readdir, rename, rm } from 'fs/promises';
import { join } from 'path';
import { Worker } from 'worker_threads';
import { ApiUploadResponse, getExtension } from '.';
import { Prisma } from '../../../../../generated/client';
const logger = log('api').c('upload').c('partial');
const partialsCache = new Map<string, { length: number; options: UploadOptions }>();
export type ApiUploadPartialResponse = ApiUploadResponse & {
partialSuccess?: boolean;
partialIdentifier?: string;
};
export const PATH = '/api/upload/partial';
export default fastifyPlugin(
(server, _, done) => {
const rateLimit = server.rateLimit
? server.rateLimit()
: (_req: any, _res: any, next: () => any) => next();
server.post<{
Headers: UploadHeaders;
}>(PATH, { preHandler: [userMiddleware, rateLimit] }, async (req, res) => {
const options = parseHeaders(req.headers, config.files);
if (options.header) return res.badRequest('bad options, receieved: ' + JSON.stringify(options));
if (!options.partial) return res.badRequest('partial upload was not detected');
if (!options.partial.range || options.partial.range.length !== 3)
return res.badRequest('Invalid partial upload');
let folder = null;
if (options.folder) {
folder = await prisma.folder.findFirst({
where: {
id: options.folder,
},
});
if (!folder) return res.badRequest('folder not found');
if (!req.user && !folder.allowUploads) return res.forbidden('folder is not open');
}
const files = await req.saveRequestFiles({ tmpdir: config.core.tempDirectory });
if (req.user?.quota) {
const totalFileSize = files.reduce((acc, x) => acc + x.file.bytesRead, 0);
const userAggregateStats = await prisma.file.aggregate({
where: {
userId: req.user.id,
},
_sum: {
size: true,
},
_count: {
_all: true,
},
});
const aggSize: bigint =
userAggregateStats!._sum?.size === null
? 0n
: (userAggregateStats!._sum?.size as unknown as bigint);
if (
req.user.quota.filesQuota === 'BY_BYTES' &&
Number(aggSize) + totalFileSize > bytes(req.user.quota.maxBytes!)
)
return res.payloadTooLarge(
`uploading will exceed your storage quota of ${bytes(req.user.quota.maxBytes!)} bytes`,
);
if (
req.user.quota.filesQuota === 'BY_FILES' &&
userAggregateStats!._count?._all + req.files.length > req.user.quota.maxFiles!
)
return res.payloadTooLarge(
`uploading will exceed your file count quota of ${req.user.quota.maxFiles} files`,
);
}
const response: ApiUploadPartialResponse = {
files: [],
...(options.deletesAt && {
deletesAt: options.deletesAt === 'never' ? 'never' : options.deletesAt.toISOString(),
}),
...(config.files.assumeMimetypes && { assumedMimetypes: Array(req.files.length) }),
};
let domain;
if (options.overrides?.returnDomain) {
domain = `${config.core.returnHttpsUrls ? 'https' : 'http'}://${options.overrides.returnDomain}`;
} else if (config.core.defaultDomain) {
domain = `${config.core.returnHttpsUrls ? 'https' : 'http'}://${config.core.defaultDomain}`;
} else {
domain = `${config.core.returnHttpsUrls ? 'https' : 'http'}://${req.headers.host}`;
}
logger.debug('saving partial files', { partial: options.partial, files: files.map((x) => x.filename) });
if (files.length > 1) return res.badRequest('partial uploads only support one file field');
const file = files[0];
const fileSize = file.file.bytesRead;
// caching for partial uploads server side checks and performance
if (options.partial.range[0] === 0) {
const identifier = randomCharacters(8);
partialsCache.set(identifier, { length: fileSize, options });
options.partial.identifier = identifier;
} else {
if (!options.partial.identifier || !partialsCache.has(options.partial.identifier))
return res.badRequest('No partial upload identifier provided');
}
const cache = partialsCache.get(options.partial.identifier);
if (!cache) throw 'No partial upload cache found';
const prefix = `zipline_partial_${options.partial.identifier}_`;
// file is too large so we delete everything
if (cache.length + fileSize > bytes(config.files.maxFileSize)) {
partialsCache.delete(options.partial.identifier);
const tempFiles = await readdir(config.core.tempDirectory);
await Promise.all(
tempFiles.filter((f) => f.startsWith(prefix)).map((f) => rm(join(config.core.tempDirectory, f))),
);
return res.payloadTooLarge('File is too large');
}
cache.length += fileSize;
// handle partial stuff
const tempFile = join(
config.core.tempDirectory,
`${prefix}${options.partial.range[0]}_${options.partial.range[1]}`,
);
await rename(file.filepath, tempFile);
if (options.partial.lastchunk) {
const extension = getExtension(options.partial.filename, options.overrides?.extension);
if (config.files.disabledExtensions.includes(extension))
return res.badRequest(`File extension ${extension} is not allowed`);
// determine filename
const format = options.format || config.files.defaultFormat;
let fileName = formatFileName(format, decodeURIComponent(options.partial.filename));
if (options.overrides?.filename || format === 'name') {
if (options.overrides?.filename) fileName = decodeURIComponent(options.overrides!.filename!);
const fullFileName = `${fileName}${extension}`;
const existing = await prisma.file.findFirst({
where: {
name: fullFileName,
},
});
if (existing) return res.badRequest(`A file with the name "${fullFileName}" already exists`);
}
// determine mimetype
let mimetype = options.partial.contentType;
if (mimetype === 'application/octet-stream' && config.files.assumeMimetypes) {
const mime = await guess(extension.substring(1));
if (!mime) response.assumedMimetypes![0] = false;
else {
response.assumedMimetypes![0] = true;
mimetype = mime;
}
}
const data: Prisma.FileCreateInput = {
name: `${fileName}${extension}`,
size: 0,
type: mimetype,
User: {
connect: {
id: req.user ? req.user.id : options.folder ? folder?.userId : undefined,
},
},
};
if (options.password) data.password = await hashPassword(options.password);
if (options.maxViews) data.maxViews = options.maxViews;
if (folder) data.Folder = { connect: { id: folder.id } };
if (options.addOriginalName)
data.originalName = options.partial.filename
? decodeURIComponent(options.partial.filename)
: file.filename; // this will prolly be "blob" but should hopefully never happen
const fileUpload = await prisma.file.create({
data,
});
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,
},
file: {
id: fileUpload.id,
filename: fileUpload.name,
type: fileUpload.type,
},
options,
domain,
responseUrl,
config,
},
});
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: responseUrl,
pending: true,
});
partialsCache.delete(options.partial.identifier);
}
response.partialSuccess = true;
// send an identifier if this is the first chunk for server-side checks
if (options.partial.range[0] === 0) {
response.partialIdentifier = options.partial.identifier;
}
return res.send(response);
});
done();
},
{ name: PATH },
);

View File

@@ -1,3 +1,4 @@
import { Prisma } from '../../../../../../../generated/client';
import { bytes } from '@/lib/bytes';
import { hashPassword } from '@/lib/crypto';
import { datasource } from '@/lib/datasource';
@@ -16,6 +17,7 @@ type Body = {
originalName?: string;
type?: string;
tags?: string[];
name?: string;
};
type Params = {
@@ -27,94 +29,131 @@ const logger = log('api').c('user').c('files').c('[id]');
export const PATH = '/api/user/files/:id';
export default fastifyPlugin(
(server, _, done) => {
server.route<{
server.get<{ Params: Params }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
const file = await prisma.file.findFirst({
where: {
OR: [{ id: req.params.id }, { name: req.params.id }],
userId: req.user.id,
},
select: fileSelect,
});
if (!file) return res.notFound();
return res.send(file);
});
server.patch<{
Body: Body;
Params: Params;
}>({
url: PATH,
method: ['GET', 'PATCH', 'DELETE'],
preHandler: [userMiddleware],
handler: async (req, res) => {
const file = await prisma.file.findFirst({
where: {
OR: [{ id: req.params.id }, { name: req.params.id }],
},
select: fileSelect,
});
if (!file) return res.notFound();
}>(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
const file = await prisma.file.findFirst({
where: {
OR: [{ id: req.params.id }, { name: req.params.id }],
userId: req.user.id,
},
select: fileSelect,
});
if (!file) return res.notFound();
if (req.method === 'PATCH') {
if (req.body.maxViews !== undefined && req.body.maxViews < 0)
return res.badRequest('maxViews must be >= 0');
const data: Prisma.FileUpdateInput = {};
let password: string | null | undefined = undefined;
if (req.body.password !== undefined) {
if (req.body.password === null || req.body.password === '') {
password = null;
} else if (typeof req.body.password === 'string') {
password = await hashPassword(req.body.password);
} else {
return res.badRequest('password must be a string');
}
}
if (req.body.favorite !== undefined) data.favorite = req.body.favorite;
if (req.body.originalName !== undefined) data.originalName = req.body.originalName;
if (req.body.type !== undefined) data.type = req.body.type;
if (req.body.tags !== undefined) {
const tags = await prisma.tag.findMany({
where: {
userId: req.user.id,
id: {
in: req.body.tags,
},
},
});
if (req.body.maxViews !== undefined) {
if (req.body.maxViews < 0) return res.badRequest('maxViews must be >= 0');
if (tags.length !== req.body.tags.length) return res.badRequest('invalid tag somewhere');
}
data.maxViews = req.body.maxViews;
}
const newFile = await prisma.file.update({
where: {
id: req.params.id,
},
data: {
...(req.body.favorite !== undefined && { favorite: req.body.favorite }),
...(req.body.maxViews !== undefined && { maxViews: req.body.maxViews }),
...(req.body.originalName !== undefined && { originalName: req.body.originalName }),
...(req.body.type !== undefined && { type: req.body.type }),
...(password !== undefined && { password }),
...(req.body.tags !== undefined && {
tags: {
set: req.body.tags.map((tag) => ({ id: tag })),
},
}),
},
select: fileSelect,
});
logger.info(`${req.user.username} updated file ${newFile.name}`, {
updated: Object.keys(req.body),
id: newFile.id,
});
return res.send(newFile);
} else if (req.method === 'DELETE') {
const deletedFile = await prisma.file.delete({
where: {
id: req.params.id,
},
select: fileSelect,
});
await datasource.delete(deletedFile.name);
logger.info(`${req.user.username} deleted file ${deletedFile.name}`, {
size: bytes(deletedFile.size),
});
return res.send(deletedFile);
if (req.body.password !== undefined) {
if (req.body.password === null || req.body.password === '') {
data.password = null;
} else if (typeof req.body.password === 'string') {
data.password = await hashPassword(req.body.password);
} else {
return res.badRequest('password must be a string');
}
}
return res.send(file);
},
if (req.body.tags !== undefined) {
const tags = await prisma.tag.findMany({
where: {
userId: req.user.id,
id: {
in: req.body.tags,
},
},
});
if (tags.length !== req.body.tags.length) return res.badRequest('invalid tag somewhere');
data.tags = {
set: req.body.tags.map((tag) => ({ id: tag })),
};
}
if (req.body.name !== undefined && req.body.name !== file.name) {
const name = req.body.name.trim();
const existingFile = await prisma.file.findFirst({
where: {
name,
},
});
if (existingFile && existingFile.id !== file.id)
return res.badRequest('File with this name already exists');
data.name = name;
try {
await datasource.rename(file.name, data.name);
} catch (error) {
logger.error('Failed to rename file in datasource', { error });
return res.internalServerError('Failed to rename file in datasource');
}
}
const newFile = await prisma.file.update({
where: {
id: req.params.id,
},
data,
select: fileSelect,
});
logger.info(`${req.user.username} updated file ${newFile.name}`, {
updated: Object.keys(req.body),
id: newFile.id,
});
return res.send(newFile);
});
server.delete<{ Params: Params }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
const file = await prisma.file.findFirst({
where: {
OR: [{ id: req.params.id }, { name: req.params.id }],
userId: req.user.id,
},
});
if (!file) return res.notFound();
const deletedFile = await prisma.file.delete({
where: {
id: file.id,
},
select: fileSelect,
});
await datasource.delete(deletedFile.name);
logger.info(`${req.user.username} deleted file ${deletedFile.name}`, {
size: bytes(deletedFile.size),
});
return res.send(deletedFile);
});
done();

View File

@@ -52,211 +52,204 @@ const validateOrder = z.enum(['asc', 'desc']).default('desc');
export const PATH = '/api/user/files';
export default fastifyPlugin(
(server, _, done) => {
server.route<{
Querystring: Query;
}>({
url: PATH,
method: ['GET'],
preHandler: [userMiddleware],
handler: async (req, res) => {
const user = await prisma.user.findUnique({
where: {
id: req.query.id ?? req.user.id,
server.get<{ Querystring: Query }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
const user = await prisma.user.findUnique({
where: {
id: req.query.id ?? req.user.id,
},
});
if (user && user.id !== req.user.id && !canInteract(req.user.role, user.role))
return res.forbidden("You can't view this user's files.");
if (!user) return res.notFound('User not found');
const perpage = Number(req.query.perpage || '15');
if (isNaN(Number(perpage))) return res.badRequest('Perpage must be a number');
const searchQuery = req.query.searchQuery
? (decodeURIComponent(req.query.searchQuery.trim()) ?? null)
: null;
const { page, filter, favorite } = req.query;
if (!page && !searchQuery) return res.badRequest('Page is required');
if (isNaN(Number(page)) && !searchQuery) return res.badRequest('Page must be a number');
const sortBy = validateSortBy.safeParse(req.query.sortBy || 'createdAt');
if (!sortBy.success) return res.badRequest('Invalid sortBy value');
const order = validateOrder.safeParse(req.query.order || 'desc');
if (!order.success) return res.badRequest('Invalid order value');
const searchField = validateSearchField.safeParse(req.query.searchField || 'name');
if (!searchField.success) return res.badRequest('Invalid searchField value');
const incompleteFiles = await prisma.incompleteFile.findMany({
where: {
userId: user.id,
status: {
not: 'COMPLETE',
},
});
},
});
if (user && user.id !== req.user.id && !canInteract(req.user.role, user.role))
return res.forbidden("You can't view this user's files.");
if (searchQuery) {
let tagFiles: string[] = [];
if (!user) return res.notFound('User not found');
if (searchField.data === 'tags') {
const parsedTags = searchQuery
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag);
const perpage = Number(req.query.perpage || '15');
if (isNaN(Number(perpage))) return res.badRequest('Perpage must be a number');
const searchQuery = req.query.searchQuery
? (decodeURIComponent(req.query.searchQuery.trim()) ?? null)
: null;
const { page, filter, favorite } = req.query;
if (!page && !searchQuery) return res.badRequest('Page is required');
if (isNaN(Number(page)) && !searchQuery) return res.badRequest('Page must be a number');
const sortBy = validateSortBy.safeParse(req.query.sortBy || 'createdAt');
if (!sortBy.success) return res.badRequest('Invalid sortBy value');
const order = validateOrder.safeParse(req.query.order || 'desc');
if (!order.success) return res.badRequest('Invalid order value');
const searchField = validateSearchField.safeParse(req.query.searchField || 'name');
if (!searchField.success) return res.badRequest('Invalid searchField value');
const incompleteFiles = await prisma.incompleteFile.findMany({
where: {
userId: user.id,
status: {
not: 'COMPLETE',
},
},
});
if (searchQuery) {
let tagFiles: string[] = [];
if (searchField.data === 'tags') {
const parsedTags = searchQuery
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag);
const foundTags = await prisma.tag.findMany({
where: {
userId: user.id,
id: {
in: searchQuery
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag),
},
},
include: {
files: {
select: {
id: true,
},
},
},
});
if (foundTags.length !== parsedTags.length) return res.badRequest('invalid tag somewhere');
tagFiles = foundTags
.map((tag) => tag.files.map((file) => file.id))
.reduce((a, b) => a.filter((c) => b.includes(c)));
}
const similarityResult = await prisma.file.findMany({
const foundTags = await prisma.tag.findMany({
where: {
userId: user.id,
...(filter === 'dashboard' && {
OR: [
{
type: { startsWith: 'image/' },
},
{
type: { startsWith: 'video/' },
},
{
type: { startsWith: 'audio/' },
},
{
type: { startsWith: 'text/' },
},
],
}),
...(favorite === 'true' &&
filter !== 'all' && {
favorite: true,
}),
...(searchField.data === 'tags'
? {
id: {
in: tagFiles,
notIn: incompleteFiles.map((file) => file.metadata.file.id),
},
}
: searchField.data === 'id'
? {
id: {
contains: searchQuery,
notIn: incompleteFiles.map((file) => file.metadata.file.id),
mode: 'insensitive',
},
}
: {
[searchField.data]: {
contains: searchQuery,
mode: 'insensitive',
},
id: {
notIn: incompleteFiles.map((file) => file.metadata.file.id),
},
}),
id: {
in: searchQuery
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag),
},
},
select: fileSelect,
orderBy: {
[sortBy.data]: order.data,
include: {
files: {
select: {
id: true,
},
},
},
skip: (Number(page) - 1) * perpage,
take: perpage,
});
return res.send({
page: cleanFiles(similarityResult),
search: {
field: searchField.data,
query:
searchField.data === 'tags'
? searchQuery
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag)
: searchQuery,
},
});
if (foundTags.length !== parsedTags.length) return res.badRequest('invalid tag somewhere');
tagFiles = foundTags
.map((tag) => tag.files.map((file) => file.id))
.reduce((a, b) => a.filter((c) => b.includes(c)));
}
const where = {
userId: user.id,
...(filter === 'dashboard' && {
OR: [
{
type: { startsWith: 'image/' },
},
{
type: { startsWith: 'video/' },
},
{
type: { startsWith: 'audio/' },
},
{
type: { startsWith: 'text/' },
},
],
}),
...(favorite === 'true' &&
filter !== 'all' && {
favorite: true,
const similarityResult = await prisma.file.findMany({
where: {
userId: user.id,
...(filter === 'dashboard' && {
OR: [
{
type: { startsWith: 'image/' },
},
{
type: { startsWith: 'video/' },
},
{
type: { startsWith: 'audio/' },
},
{
type: { startsWith: 'text/' },
},
],
}),
id: {
notIn: incompleteFiles.map((file) => file.metadata.file.id),
...(favorite === 'true' &&
filter !== 'all' && {
favorite: true,
}),
...(searchField.data === 'tags'
? {
id: {
in: tagFiles,
notIn: incompleteFiles.map((file) => file.metadata.file.id),
},
}
: searchField.data === 'id'
? {
id: {
contains: searchQuery,
notIn: incompleteFiles.map((file) => file.metadata.file.id),
mode: 'insensitive',
},
}
: {
[searchField.data]: {
contains: searchQuery,
mode: 'insensitive',
},
id: {
notIn: incompleteFiles.map((file) => file.metadata.file.id),
},
}),
},
};
const count = await prisma.file.count({
where,
select: fileSelect,
orderBy: {
[sortBy.data]: order.data,
},
skip: (Number(page) - 1) * perpage,
take: perpage,
});
const files = cleanFiles(
await prisma.file.findMany({
where,
select: {
...fileSelect,
password: true,
},
orderBy: {
[sortBy.data]: order.data,
},
skip: (Number(page) - 1) * perpage,
take: perpage,
}),
);
return res.send({
page: files,
total: count,
pages: Math.ceil(count / perpage),
page: cleanFiles(similarityResult),
search: {
field: searchField.data,
query:
searchField.data === 'tags'
? searchQuery
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag)
: searchQuery,
},
});
},
}
const where = {
userId: user.id,
...(filter === 'dashboard' && {
OR: [
{
type: { startsWith: 'image/' },
},
{
type: { startsWith: 'video/' },
},
{
type: { startsWith: 'audio/' },
},
{
type: { startsWith: 'text/' },
},
],
}),
...(favorite === 'true' &&
filter !== 'all' && {
favorite: true,
}),
id: {
notIn: incompleteFiles.map((file) => file.metadata.file.id),
},
};
const count = await prisma.file.count({
where,
});
const files = cleanFiles(
await prisma.file.findMany({
where,
select: {
...fileSelect,
password: true,
},
orderBy: {
[sortBy.data]: order.data,
},
skip: (Number(page) - 1) * perpage,
take: perpage,
}),
);
return res.send({
page: files,
total: count,
pages: Math.ceil(count / perpage),
});
});
done();

Some files were not shown because too many files have changed in this diff Show More