Compare commits

...

80 Commits

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

* fix(domains): fix linting errors

* fix(domains): remove unused imports

* fix(urls): fix typo

* feat(domains): remove expiration date from domains

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

* fix(domains): linter errors

---------

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

* chore: update file permissions

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2025-07-01 11:38:47 -07:00
curet
5c0830c6da fix: long code blocks (#823) (#810) 2025-07-01 10:58:25 -07:00
diced
ef33fcbe1d fix: lint error 2025-06-11 20:23:25 -07:00
diced
4b1ca07510 feat: better cache for versions 2025-06-11 20:21:52 -07:00
diced
438b9b5a67 feat: show alert when there are overridden settings 2025-06-08 12:02:51 -07:00
diced
ed1273efba feat: convert db settings to env vars cli 2025-06-08 11:52:32 -07:00
diced
e8518f92c7 fix: remove 2025-06-07 11:36:51 -07:00
diced
fbf9e10e56 feat: allow/denylist discord oauth 2025-06-07 11:36:23 -07:00
diced
a1ee1178ae feat: allow env vars that override database set settings 2025-06-07 11:17:43 -07:00
diced
e5eaaca5a0 feat: discord oauth whitelist 2025-06-06 20:33:41 -07:00
diced
6e9dea989e fix: use cmd icon on mac 2025-06-06 15:15:11 -07:00
diced
5bc9b6ef0a feat: add download button to file table view 2025-06-06 15:10:13 -07:00
diced
6362d06253 feat: new gps remover 2025-06-06 15:06:21 -07:00
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
diced
d0a613ab8e feat(v4.1.1): version 2025-06-05 22:52:44 -07:00
diced
1bff0564e7 feat: ratelimits for a bunch of routes (small) 2025-06-05 22:52:23 -07:00
diced
df449b1bcb fix: passkeys route, deleting works now 2025-06-05 15:08:11 -07:00
diced
bd057944ce chore: update dependencies 2025-06-05 14:53:03 -07:00
diced
856fa00d1d fix: linting errors 2025-06-04 23:54:58 -07:00
diced
1703cee75a fix: #817 2025-06-04 23:53:04 -07:00
diced
0a970da241 fix: implement workaround for video-audio contains thumbnail 2025-06-01 12:14:42 -07:00
diced
04b0a18b85 fix: remove ignore tls verify 2025-06-01 11:33:40 -07:00
diced
e7de1c9762 fix: show error when s3 fails 2025-06-01 11:03:12 -07:00
Josh
2df9098586 feat: add service_healthy + healthcheck to docker-compose (#811)
* Update docker-compose.yml

Since you use healthcheck anyway, you get to verify that postgres is healthy

* Update README.md

Update the README with 'depends_on: postgresql:  condition: service_healthy'

* Add zipline healthcheck to docker-compose.yml

* Add docker-compose healthcheck for zipline to README.md

* Update docker-compose.yml

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2025-05-31 15:44:14 -07:00
diced
e8380cc261 fix: allow unauthorized certs s3 2025-05-26 12:26:09 -07:00
diced
71a1ed9072 fix: stop scroll when zoomed in 2025-05-26 12:25:25 -07:00
diced
6b0bbad8d4 feat(v4.1.0): version 2025-05-20 20:58:51 -07:00
diced
8f12621315 feat: new view file showing options 2025-05-20 20:55:52 -07:00
diced
e5ee076e08 fix: better image width/height handling 2025-05-20 19:50:17 -07:00
diced
8382a1b55d fix: remove some paths from safe config 2025-05-18 22:03:44 -07:00
diced
a35d8b87ee feat: more version checking options 2025-05-15 21:09:44 -07:00
diced
f70eea97b0 fix: better DEBUG var handling 2025-05-15 15:09:19 -07:00
diced
7ab5c4e180 fix: add more debug logging when failing oauth 2025-05-13 15:58:20 -07:00
lajczi
486165625d ci: node.js 23 -> 24 (#809) 2025-05-13 14:47:14 -07:00
diced
08eb2df26c fix: typings for version api 2025-05-08 21:29:51 -07:00
diced
4a5d01c663 feat: version checking 2025-05-08 21:20:47 -07:00
diced
485f106a65 fix: remove avatar fetching every 30s 2025-05-08 17:15:33 -07:00
diced
3d3f519403 feat: s3 subdirectory 2025-05-08 15:13:31 -07:00
diced
617f42d3bf fix: remove prisma schema load message 2025-05-08 10:59:18 -07:00
diced
25a2a54d8a fix: unused imports in s3 2025-05-07 20:04:14 -07:00
diced
35c37c235f fix: change access test logic 2025-05-07 19:56:02 -07:00
diced
594dfa6ef9 fix: import issue 2025-05-06 01:19:08 -07:00
diced
5ab36a08b2 feat: expose git sha in versioning api 2025-05-06 01:14:13 -07:00
diced
90aef3dce1 feat: update mantine, prisma, etc. 2025-05-06 00:17:53 -07:00
diced
8b9303ed80 fix: pnpm 10.10 stuff 2025-05-01 17:49:55 -07:00
diced
ee9639ac65 fix: kys 2025-04-29 12:37:34 -07:00
diced
055bee6286 fix: finally fix ts dumb ahh error 2025-04-29 12:35:35 -07:00
diced
c3bc598016 fix: remove unused import 2025-04-29 12:33:39 -07:00
diced
c0261285af fix: don't overwrite sessions webauthn (#792) 2025-04-29 12:24:51 -07:00
diced
0538b792ac feat: better partial upload checking 2025-04-29 12:14:43 -07:00
rlko
567a855ba1 fix: shell script uploader/shorten (#786)
* fix filenames with spaces + fix mime type uploads

* Add proper MIME type for uploads (especially videos)
  in shell script

* Few fixes for shell script uploader

* encapsulating all headers
* tr not needed (anymore?)
* removing `echo` as it is not returning anything
  because it is already printing on stdout
* parsing correct key for url uploader

* `echo` string seems like not needed anymore

also passed prettier

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2025-04-29 11:49:18 -07:00
bigbenster702
2e59f5bd7f fix: discord auto continue on oauth screen (#795) 2025-04-29 11:29:00 -07:00
diced
ef0580655d fix: showing upload button on disabled upload folder (#776) 2025-04-16 21:47:17 -07:00
diced
8ece705eb5 fix: add mimetype to s3 uploads (#785) 2025-04-16 21:44:27 -07:00
diced
485fa62ed9 fix: oauth main api route 2025-04-09 23:04:10 -07:00
diced
b4819cd038 fix: import-dir script will work now 2025-04-08 19:47:42 -07:00
diced
cb2f2daf60 fix: formatting errors 2025-04-05 00:03:43 -07:00
diced
c2848f19c1 fix: malformed s3 multipart uploads (#771) 2025-04-04 19:47:01 -07:00
diced
55684528b8 feat: expose theme on view routes 2025-04-03 12:39:05 -07:00
diced
9611e6d5a5 feat: overhaul qs system 2025-04-03 12:32:27 -07:00
159 changed files with 6623 additions and 5357 deletions

View File

@@ -4,4 +4,5 @@ build
node_modules
uploads*
.env
.eslintcache
.eslintcache
generated

View File

@@ -11,7 +11,7 @@ jobs:
build:
strategy:
matrix:
node: [20.x, 22.x, 23.x]
node: [20.x, 22.x, 24.x]
arch: [amd64, arm64]
runs-on: ubuntu-24.04${{ matrix.arch == 'arm64' && '-arm' || '' }}

View File

@@ -46,6 +46,8 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false
build-args: |
ZIPLINE_GIT_SHA=${{ steps.sha.outputs.short_sha }}
tags: |
ghcr.io/diced/zipline:${{ steps.version.outputs.zipline_version }}-${{ matrix.arch }}
ghcr.io/diced/zipline:${{ steps.version.outputs.zipline_version }}-${{ steps.sha.outputs.short_sha }}-${{ matrix.arch }}

View File

@@ -40,6 +40,8 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false
build-args: |
ZIPLINE_GIT_SHA=${{ steps.sha.outputs.short_sha }}
tags: |
ghcr.io/diced/zipline:trunk-${{ matrix.arch }}
ghcr.io/diced/zipline:trunk-${{ steps.sha.outputs.short_sha }}-${{ matrix.arch }}

7
.gitignore vendored
View File

@@ -23,6 +23,7 @@
# misc
.DS_Store
*.pem
.idea
# debug
npm-debug.log*
@@ -45,4 +46,8 @@ next-env.d.ts
# zipline
uploads*/
*.crt
*.key
*.key
generated
# nix dev env
/.psql_db/

1
.prettierignore Executable file
View File

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

View File

@@ -3,9 +3,7 @@ FROM node:22-alpine3.21 AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN npm install -g corepack
RUN corepack enable pnpm
RUN corepack prepare pnpm@latest --activate
RUN corepack enable
RUN apk add --no-cache ffmpeg tzdata
@@ -42,6 +40,8 @@ COPY --from=builder /zipline/.next ./.next
COPY --from=builder /zipline/mimes.json ./mimes.json
COPY --from=builder /zipline/code.json ./code.json
COPY --from=builder /zipline/generated ./generated
RUN pnpm build:prisma
@@ -50,4 +50,7 @@ RUN rm -rf /tmp/* /root/*
ENV NODE_ENV=production
ARG ZIPLINE_GIT_SHA
ENV ZIPLINE_GIT_SHA=${ZIPLINE_GIT_SHA:-"unknown"}
CMD ["node", "--enable-source-maps", "build/server"]

View File

@@ -77,11 +77,17 @@ services:
environment:
- DATABASE_URL=postgres://${POSTGRESQL_USER:-zipline}:${POSTGRESQL_PASSWORD}@postgresql:5432/${POSTGRESQL_DB:-zipline}
depends_on:
- postgresql
postgresql:
condition: service_healthy
volumes:
- './uploads:/zipline/uploads'
- './public:/zipline/public'
- './themes:/zipline/themes'
healthcheck:
test: ['CMD', 'wget', '-q', '--spider', 'http://localhost:3000/api/healthcheck']
interval: 15s
timeout: 2s
retries: 2
volumes:
pgdata:
@@ -192,6 +198,24 @@ Create a pull request on GitHub. If your PR does not pass the action checks, the
Here's how to setup Zipline for development
#### Nix
If you have [Nix](https://nixos.org/) installed, you can use the provided dev shell to get started quickly. Just run:
```bash
nix develop
```
This will start a postgres server, and drop you into a shell with the necessary tools installed:
- nodejs
- corepack
- git
- ffmpeg
- postgres server
After hopping into the dev shell, you can follow the instructions below (skipping the prerequisites) to setup a configuration and start the server.
#### Prerequisites
- nodejs (lts -> 20.x, 22.x)

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

@@ -26,11 +26,17 @@ services:
environment:
- DATABASE_URL=postgres://${POSTGRESQL_USER:-zipline}:${POSTGRESQL_PASSWORD}@postgresql:5432/${POSTGRESQL_DB:-zipline}
depends_on:
- postgresql
postgresql:
condition: service_healthy
volumes:
- './uploads:/zipline/uploads'
- './public:/zipline/public'
- './themes:/zipline/themes'
healthcheck:
test: ['CMD', 'wget', '-q', '--spider', 'http://0.0.0.0:3000/api/healthcheck']
interval: 15s
timeout: 2s
retries: 2
volumes:
pgdata:

View File

@@ -1,44 +1,70 @@
// TODO: migrate everything to use eslint 9 features instead of compatibility layers
import unusedImports from 'eslint-plugin-unused-imports';
import typescriptEslint from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
import tseslint from 'typescript-eslint';
import prettier from 'eslint-plugin-prettier';
import prettierConfig from 'eslint-config-prettier';
import nextConfig from '@next/eslint-plugin-next';
import reactHooksPlugin from 'eslint-plugin-react-hooks';
import reactPlugin from 'eslint-plugin-react';
import jsxA11yPlugin from 'eslint-plugin-jsx-a11y';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import js from '@eslint/js';
import { FlatCompat } from '@eslint/eslintrc';
import { includeIgnoreFile } from '@eslint/compat';
import fs from 'node:fs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all,
});
const gitignorePath = path.resolve(__dirname, '.gitignore');
const gitignoreContent = fs.readFileSync(gitignorePath, 'utf8');
const gitignorePatterns = gitignoreContent
.split('\n')
.filter((line) => line.trim() && !line.startsWith('#'))
.map((pattern) => pattern.trim());
export default tseslint.config(
{ ignores: gitignorePatterns },
...tseslint.configs.recommended,
export default [
includeIgnoreFile(gitignorePath),
...compat.extends(
'next/core-web-vitals',
'plugin:prettier/recommended',
'plugin:@typescript-eslint/recommended',
),
{
files: ['**/*.{js,mjs,cjs,ts,tsx}'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
plugins: {
'unused-imports': unusedImports,
'@typescript-eslint': typescriptEslint,
prettier: prettier,
'@next/next': nextConfig,
'react-hooks': reactHooksPlugin,
react: reactPlugin,
'jsx-a11y': jsxA11yPlugin,
},
languageOptions: {
parser: tsParser,
},
rules: {
'linebreak-style': ['error', 'unix'],
...reactPlugin.configs.recommended.rules,
...reactHooksPlugin.configs.recommended.rules,
...nextConfig.configs.recommended.rules,
...nextConfig.configs['core-web-vitals'].rules,
...prettierConfig.rules,
'prettier/prettier': [
'error',
{},
{
fileInfoOptions: {
withNodeModules: false,
},
ignoreFileExtensions: ['pnpm-lock.yaml'],
},
],
'linebreak-style': ['error', 'unix'],
quotes: [
'error',
'single',
@@ -46,7 +72,6 @@ export default [
avoidEscape: true,
},
],
semi: ['error', 'always'],
'jsx-quotes': ['error', 'prefer-single'],
indent: 'off',
@@ -77,10 +102,17 @@ export default [
argsIgnorePattern: '^_',
},
],
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-expressions': 'off',
},
settings: {
react: {
version: 'detect',
},
next: {
rootDir: __dirname,
},
},
},
];
);

61
flake.lock generated Normal file
View File

@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1752827260,
"narHash": "sha256-noFjJbm/uWRcd2Lotr7ovedfhKVZT+LeJs9rU416lKQ=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "b527e89270879aaaf584c41f26b2796be634bc9d",
"type": "github"
},
"original": {
"owner": "nixos",
"repo": "nixpkgs",
"rev": "b527e89270879aaaf584c41f26b2796be634bc9d",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

84
flake.nix Normal file
View File

@@ -0,0 +1,84 @@
{
description = "dev env for zipline";
inputs = {
# node 24.4.1, postgres 17
nixpkgs.url = "github:nixos/nixpkgs/b527e89270879aaaf584c41f26b2796be634bc9d";
flake-utils.url = "github:numtide/flake-utils";
};
outputs =
{
self,
nixpkgs,
flake-utils,
}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = import nixpkgs {
inherit system;
};
nodejs = pkgs.nodejs_24;
postgres = pkgs.postgresql;
psqlDir = ".psql_db/data";
psqlUsername = "postgres";
psqlPassword = "postgres";
in
{
devShells.default = pkgs.mkShell {
name = "zipline";
buildInputs = [
nodejs
postgres
pkgs.git
pkgs.corepack
pkgs.ffmpeg
];
shellHook = ''
export PGDATA="$PWD/${psqlDir}"
export PGUSER="${psqlUsername}"
export PGPASSWORD="${psqlPassword}"
export PGPORT=5432
if [ ! -d "$PGDATA" ]; then
echo "Initializing PostgreSQL data directory at $PGDATA"
initdb -D "$PGDATA" --username="$PGUSER" --pwfile=<(echo "$PGPASSWORD")
fi
# listen on localhost
echo "host all all 127.0.0.1/32 password" >> "$PGDATA/pg_hba.conf"
echo "host all all ::1/128 password" >> "$PGDATA/pg_hba.conf"
sed -i "s/^#\?listen_addresses.*/listen_addresses = 'localhost'/" "$PGDATA/postgresql.conf"
echo "Starting PostgreSQL..."
pg_ctl -D "$PGDATA" -o "-p $PGPORT" -w start
echo -e "PostgreSQL is ready at postgresql://$PGUSER:$PGPASSWORD@localhost:$PGPORT/postgres\n\n"
stop_postgres() {
echo "Stopping PostgreSQL..."
pg_ctl -D "$PGDATA" stop
}
# trap pg to stop on exiting the dev shell
trap stop_postgres EXIT
# use zsh if zsh is available
if command -v zsh >/dev/null 2>&1; then
zsh
else
$SHELL
fi
exit
'';
};
}
);
}

View File

@@ -2,13 +2,14 @@
"name": "zipline",
"private": true,
"license": "MIT",
"version": "4.0.2",
"version": "4.2.1",
"scripts": {
"build": "cross-env pnpm run --stream \"/^build:.*/\"",
"build:prisma": "prisma generate --no-hints",
"build:next": "ZIPLINE_BUILD=true next build",
"build:server": "tsup",
"dev": "cross-env TURBOPACK=1 NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --enable-source-maps ./src/server",
"dev:nd": "cross-env TURBOPACK=1 NODE_ENV=development tsx --require dotenv/config --enable-source-maps ./src/server",
"dev:inspector": "cross-env TURBOPACK=1 NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --inspect=0.0.0.0:9229 --enable-source-maps ./src/server",
"start": "cross-env NODE_ENV=production node --trace-warnings --require dotenv/config --enable-source-maps ./build/server",
"start:inspector": "cross-env NODE_ENV=production node --require dotenv/config --inspect=0.0.0.0:9229 --enable-source-maps ./build/server",
@@ -17,99 +18,103 @@
"validate:lint": "eslint --cache --fix .",
"validate:format": "prettier --write --ignore-path .gitignore .",
"db:prototype": "prisma db push --skip-generate && prisma generate --no-hints",
"db:migrate": "prisma migrate dev --create-only"
"db:migrate": "prisma migrate dev --create-only",
"docker:engine": "colima start --mount $PWD/themes:w --mount $PWD/uploads:w --mount $PWD/public:w",
"docker:compose:dev:build": "docker-compose --file docker-compose.dev.yml build --build-arg ZIPLINE_GIT_SHA=$(git rev-parse HEAD)",
"docker:compose:dev:up": "docker-compose --file docker-compose.dev.yml up -d",
"docker:compose:dev:down": "docker-compose --file docker-compose.dev.yml down",
"docker:compose:dev:logs": "docker-compose --file docker-compose.dev.yml logs -f"
},
"dependencies": {
"@aws-sdk/client-s3": "3.726.1",
"@aws-sdk/client-s3": "3.832.0",
"@aws-sdk/lib-storage": "3.832.0",
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^10.0.2",
"@fastify/cors": "^11.0.1",
"@fastify/multipart": "^9.0.3",
"@fastify/rate-limit": "^10.2.2",
"@fastify/rate-limit": "^10.3.0",
"@fastify/sensible": "^6.0.3",
"@fastify/static": "^8.1.1",
"@fastify/static": "^8.2.0",
"@github/webauthn-json": "^2.1.1",
"@mantine/charts": "^7.17.0",
"@mantine/code-highlight": "^7.17.0",
"@mantine/core": "^7.17.0",
"@mantine/dates": "^7.17.0",
"@mantine/dropzone": "^7.17.0",
"@mantine/form": "^7.17.0",
"@mantine/hooks": "^7.17.0",
"@mantine/modals": "^7.17.0",
"@mantine/notifications": "^7.17.0",
"@prisma/client": "^6.4.1",
"@prisma/internals": "^6.4.1",
"@prisma/migrate": "^6.4.1",
"@smithy/node-http-handler": "^4.0.4",
"@tabler/icons-react": "^3.30.0",
"@xoi/gps-metadata-remover": "^1.1.2",
"argon2": "^0.41.1",
"@mantine/charts": "^8.1.1",
"@mantine/code-highlight": "^8.1.1",
"@mantine/core": "^8.1.1",
"@mantine/dates": "^8.1.1",
"@mantine/dropzone": "^8.1.1",
"@mantine/form": "^8.1.1",
"@mantine/hooks": "^8.1.1",
"@mantine/modals": "^8.1.1",
"@mantine/notifications": "^8.1.1",
"@prisma/client": "^6.10.1",
"@prisma/internals": "^6.10.1",
"@prisma/migrate": "^6.10.1",
"@smithy/node-http-handler": "^4.0.6",
"@tabler/icons-react": "^3.34.0",
"argon2": "^0.43.0",
"bytes": "^3.1.2",
"clsx": "^2.1.1",
"colorette": "^2.0.20",
"commander": "^13.1.0",
"commander": "^14.0.0",
"cross-env": "^7.0.3",
"dayjs": "^1.11.13",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"dotenv": "^16.5.0",
"fast-glob": "^3.3.3",
"fastify": "^5.2.1",
"fastify": "^5.4.0",
"fastify-plugin": "^5.0.1",
"fflate": "^0.8.2",
"fluent-ffmpeg": "^2.1.3",
"highlight.js": "^11.11.1",
"iron-session": "^8.0.4",
"isomorphic-dompurify": "^2.22.0",
"katex": "^0.16.21",
"mantine-datatable": "^7.15.1",
"isomorphic-dompurify": "^2.25.0",
"katex": "^0.16.22",
"mantine-datatable": "^8.1.1",
"ms": "^2.1.3",
"multer": "1.4.5-lts.1",
"next": "^15.2.4",
"multer": "2.0.1",
"next": "^15.3.4",
"nuqs": "^2.4.3",
"otplib": "^12.0.1",
"prisma": "^6.4.1",
"prisma": "^6.10.1",
"qrcode": "^1.5.4",
"react": "^19.0.0-rc.1",
"react-dom": "^19.0.0-rc.1",
"react-markdown": "^10.0.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"sharp": "^0.33.5",
"swr": "^2.3.2",
"zod": "^3.24.2",
"zustand": "^5.0.3"
"sharp": "^0.34.2",
"swr": "^2.3.3",
"typescript-eslint": "^8.34.1",
"zod": "^3.25.67",
"zustand": "^5.0.5"
},
"devDependencies": {
"@eslint/compat": "^1.2.7",
"@eslint/eslintrc": "^3.3.0",
"@eslint/js": "^9.21.0",
"@next/eslint-plugin-next": "^15.3.4",
"@types/bytes": "^3.1.5",
"@types/express": "^5.0.0",
"@types/fluent-ffmpeg": "^2.1.27",
"@types/katex": "^0.16.7",
"@types/ms": "^2.1.0",
"@types/multer": "^1.4.12",
"@types/node": "^22.13.5",
"@types/multer": "^1.4.13",
"@types/node": "^24.0.3",
"@types/qrcode": "^1.5.5",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@typescript-eslint/eslint-plugin": "^8.24.1",
"@typescript-eslint/parser": "^8.24.1",
"eslint": "^9.21.0",
"eslint-config-next": "^15.1.7",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.3",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"eslint": "^9.29.0",
"eslint-config-next": "^15.3.4",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.5.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-unused-imports": "^4.1.4",
"postcss": "^8.5.3",
"postcss": "^8.5.6",
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.5.2",
"sass": "^1.86.1",
"tsc-alias": "^1.8.10",
"tsup": "^8.3.6",
"tsx": "^4.19.3",
"typescript": "^5.7.3"
"prettier": "^3.5.3",
"sass": "^1.89.2",
"tsc-alias": "^1.8.16",
"tsup": "^8.5.0",
"tsx": "^4.20.3",
"typescript": "^5.8.3"
},
"engines": {
"node": ">=22"
},
"packageManager": "pnpm@10.2.0"
"packageManager": "pnpm@10.12.1"
}

6228
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

10
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,10 @@
ignoredBuiltDependencies:
- unrs-resolver
onlyBuiltDependencies:
- '@parcel/watcher'
- '@prisma/client'
- '@prisma/engines'
- argon2
- esbuild
- prisma
- sharp

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Zipline" ADD COLUMN "featuresVersionAPI" TEXT NOT NULL DEFAULT 'https://zipline-version.diced.sh',
ADD COLUMN "featuresVersionChecking" BOOLEAN NOT NULL DEFAULT true;

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,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
provider = "postgresql"

View File

@@ -1,5 +1,6 @@
generator client {
provider = "prisma-client-js"
output = "../generated/client"
}
datasource db {
@@ -57,6 +58,9 @@ model Zipline {
featuresMetricsAdminOnly Boolean @default(false)
featuresMetricsShowUserSpecific Boolean @default(true)
featuresVersionChecking Boolean @default(true)
featuresVersionAPI String @default("https://zipline-version.diced.sh")
invitesEnabled Boolean @default(true)
invitesLength Int @default(6)
@@ -78,6 +82,8 @@ model Zipline {
oauthDiscordClientId String?
oauthDiscordClientSecret String?
oauthDiscordRedirectUri String?
oauthDiscordAllowedIds String[] @default([])
oauthDiscordDeniedIds String[] @default([])
oauthGoogleClientId String?
oauthGoogleClientSecret String?
@@ -129,6 +135,8 @@ model Zipline {
pwaDescription String @default("Zipline")
pwaThemeColor String @default("#000000")
pwaBackgroundColor String @default("#000000")
domains String[] @default([])
}
model User {

View File

@@ -48,6 +48,7 @@ import Link from 'next/link';
import { useRouter } from 'next/router';
import { useState } from 'react';
import ConfigProvider from './ConfigProvider';
import VersionBadge from './VersionBadge';
type NavLinks = {
label: string;
@@ -357,23 +358,27 @@ export default function Layout({ children, config }: { children: React.ReactNode
}
})}
<Divider mt='auto' />
<div style={{ marginTop: 'auto' }}>
<VersionBadge />
<ScrollArea mah='auto'>
<Box>
{config.website.externalLinks.map(({ name, url }, i) => (
<NavLink
key={i}
label={name}
leftSection={<IconExternalLink size='1rem' />}
variant='light'
component={Link}
href={url}
target='_blank'
/>
))}
</Box>
</ScrollArea>
<Divider />
<ScrollArea mah='auto'>
<Box>
{config.website.externalLinks.map(({ name, url }, i) => (
<NavLink
key={i}
label={name}
leftSection={<IconExternalLink size='1rem' />}
variant='light'
component={Link}
href={url}
target='_blank'
/>
))}
</Box>
</ScrollArea>
</div>
</AppShell.Navbar>
<AppShell.Main>

View File

@@ -0,0 +1,173 @@
import useVersion from '@/lib/hooks/useVersion';
import {
Anchor,
Badge,
Button,
Flex,
Indicator,
Modal,
Paper,
Stack,
Text,
Title,
Tooltip,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
function DataDisplay({ items }: { items: { label: string; value: string; href?: string }[] }) {
return (
<Paper withBorder p='sm'>
<Stack gap='xs'>
{items.map((item, index) => (
<Flex justify='space-between' align='center' style={{ width: '100%' }} key={index}>
<Text c='dimmed' fw='bolder' style={{ flex: 1 }}>
{item.label}
</Text>
{item.href ? (
<Anchor href={item.href} target='_blank'>
{item.value}
</Anchor>
) : (
<Text>{item.value}</Text>
)}
</Flex>
))}
</Stack>
</Paper>
);
}
function VersionButton({ text, children, href }: { href: string; text: string; children: React.ReactNode }) {
return (
<Button
component='a'
href={href}
target='_blank'
variant='filled'
fullWidth
color='blue'
size='sm'
mt='xs'
leftSection={
<Text size='sm' fw='bolder'>
{text}
</Text>
}
>
{children}
</Button>
);
}
export default function VersionBadge() {
const { version, isLoading } = useVersion();
const [opened, { open, close }] = useDisclosure(false);
if (isLoading) return null;
if (!version) return null;
return (
<>
<Modal title='Zipline Version' opened={opened} onClose={close} size='lg'>
{version.isLatest && <Text>Running the latest version of Zipline.</Text>}
{version.isUpstream && (
<Text>
You are running an <b>unstable</b> version of Zipline. Upstream versions are not fully tested and
may contain bugs.
</Text>
)}
{!version.isLatest && !version.isUpstream && version.isRelease && (
<Text>
You are running an <b>outdated</b> version of Zipline. It is recommended to update to the{' '}
<Anchor href={version.latest.url}>latest version</Anchor>.
</Text>
)}
<Indicator
processing
position='middle-end'
inline
offset={-15}
color='red'
disabled={version.isLatest}
>
<Title order={3} my='sm'>
Current Version
</Title>
</Indicator>
<DataDisplay
items={[
{
label: 'Version',
value: version.version.tag!,
href: `https://github.com/diced/zipline/releases/${version.version.tag}`,
},
{
label: 'Commit',
value: version.version.sha!,
href: `https://github.com/diced/zipline/commit/${version.version.sha}`,
},
{ label: 'Upstream?', value: version.isUpstream ? 'Yes' : 'No' },
]}
/>
{!version.isLatest && version.isUpstream && version.latest.commit && (
<>
<Title order={3} mt='sm'>
Latest Commit Available
</Title>
<Text c='dimmed' size='sm' mb='sm'>
This is only visible when running an upstream version.
</Text>
<DataDisplay
items={[
{
label: 'Commit',
value: version.latest.commit.sha!.slice(0, 7)!,
href: `https://github.com/diced/zipline/commit/${version.latest.commit.sha}`,
},
{
label: 'Available to update',
value: version.latest.commit.pull ? 'Yes' : 'No',
},
]}
/>
</>
)}
{!version.isLatest && version.isRelease && (
<>
<Title order={3} mt='sm'>
{version.latest.tag} is available
</Title>
<VersionButton text='Changelogs' href={version.latest.url}>
{version.latest.tag}
</VersionButton>
<VersionButton text='Update' href='https://zipline.diced.sh/docs/get-started/docker#updating'>
{version.latest.tag}
</VersionButton>
</>
)}
</Modal>
<Tooltip label='Click to view more version information'>
<Badge
onClick={open}
style={{ cursor: 'pointer', textTransform: 'unset' }}
mx='sm'
my='xs'
color={version.isLatest ? 'green' : 'red'}
variant='dot'
size='lg'
radius='md'
>
{version.version?.tag}
</Badge>
</Tooltip>
</>
);
}

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 ? (
<>
@@ -305,7 +304,8 @@ export default function FileModal({
onClick={() => removeFromFolder(file)}
fullWidth
>
Remove from folder &quot;{folders?.find((f) => f.id === file.folderId)?.name ?? ''}
Remove from folder &quot;
{folders?.find((f: { id: string }) => f.id === file.folderId)?.name ?? ''}
&quot;
</Button>
) : (
@@ -337,18 +337,21 @@ export default function FileModal({
<Combobox.Dropdown>
<Combobox.Options>
{folders
?.filter((f) => f.name.toLowerCase().includes(search.toLowerCase().trim()))
.map((f) => (
?.filter((f: { name: string }) =>
f.name.toLowerCase().includes(search.toLowerCase().trim()),
)
.map((f: { name: string; id: string }) => (
<Combobox.Option value={f.id} key={f.id}>
{f.name}
</Combobox.Option>
))}
{!folders?.some((f) => f.name === search) && search.trim().length > 0 && (
<Combobox.Option value='$create'>
+ Create folder &quot;{search}&quot;
</Combobox.Option>
)}
{!folders?.some((f: { name: string }) => f.name === search) &&
search.trim().length > 0 && (
<Combobox.Option value='$create'>
+ Create folder &quot;{search}&quot;
</Combobox.Option>
)}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>

View File

@@ -11,10 +11,11 @@ import {
Text,
} from '@mantine/core';
import { Icon, IconFileUnknown, IconPlayerPlay, IconShieldLockFilled } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { useEffect, useState, useCallback, useMemo } from 'react';
import { renderMode } from '../pages/upload/renderMode';
import Render from '../render/Render';
import fileIcon from './fileIcon';
import { parseAsStringLiteral, useQueryState } from 'nuqs';
function PlaceholderContent({ text, Icon }: { text: string; Icon: Icon }) {
return (
@@ -29,7 +30,7 @@ function PlaceholderContent({ text, Icon }: { text: string; Icon: Icon }) {
function Placeholder({ text, Icon, ...props }: { text: string; Icon: Icon; onClick?: () => void }) {
return (
<Center py='xs' style={{ height: '100%', width: '100%', cursor: 'pointed' }} {...props}>
<Center py='xs' style={{ height: '100%', width: '100%', cursor: 'pointer' }} {...props}>
<PlaceholderContent text={text} Icon={Icon} />
</Center>
);
@@ -73,69 +74,82 @@ export default function DashboardFileType({
}: {
file: DbFile | File;
show?: boolean;
password?: string;
password?: string | null;
code?: boolean;
allowZoom?: boolean;
}) {
const [overrideType] = useQueryState('otype', parseAsStringLiteral(['video', 'audio', 'image', 'text']));
const disableMediaPreview = useSettingsStore((state) => state.settings.disableMediaPreview);
const dbFile = 'id' in file;
const renderIn = renderMode(file.name.split('.').pop() || '');
const renderIn = useMemo(() => renderMode(file.name.split('.').pop() || ''), [file.name]);
const [fileContent, setFileContent] = useState('');
const [type, setType] = useState<string>(file.type.split('/')[0]);
const [open, setOpen] = useState(false);
const gettext = async () => {
if (!dbFile) {
const reader = new FileReader();
reader.onload = () => {
if ((reader.result! as string).length > 1 * 1024 * 1024) {
setFileContent(
reader.result!.slice(0, 1 * 1024 * 1024) +
'\n...\nThe file is too big to display click the download icon to view/download it.',
);
} else {
setFileContent(reader.result as string);
}
};
reader.readAsText(file);
const getText = useCallback(async () => {
try {
if (!dbFile) {
const reader = new FileReader();
reader.onload = () => {
if ((reader.result! as string).length > 1 * 1024 * 1024) {
setFileContent(
reader.result!.slice(0, 1 * 1024 * 1024) +
'\n...\nThe file is too big to display click the download icon to view/download it.',
);
} else {
setFileContent(reader.result as string);
}
};
reader.readAsText(file);
return;
}
return;
}
if (file.size > 1 * 1024 * 1024) {
const res = await fetch(`/raw/${file.name}${password ? `?pw=${password}` : ''}`, {
headers: {
Range: 'bytes=0-' + 1 * 1024 * 1024, // 0 mb to 1 mb
},
});
if (file.size > 1 * 1024 * 1024) {
const res = await fetch(`/raw/${file.name}${password ? `?pw=${password}` : ''}`, {
headers: {
Range: 'bytes=0-' + 1 * 1024 * 1024, // 0 mb to 1 mb
},
});
if (!res.ok) throw new Error('Failed to fetch file');
const text = await res.text();
setFileContent(
text + '\n...\nThe file is too big to display click the download icon to view/download it.',
);
return;
}
const res = await fetch(`/raw/${file.name}${password ? `?pw=${password}` : ''}`);
if (!res.ok) throw new Error('Failed to fetch file');
const text = await res.text();
setFileContent(
text + '\n...\nThe file is too big to display click the download icon to view/download it.',
);
return;
setFileContent(text);
} catch {
setFileContent('Error loading file.');
}
const res = await fetch(`/raw/${file.name}${password ? `?pw=${password}` : ''}`);
const text = await res.text();
setFileContent(text);
};
}, [dbFile, file, password]);
useEffect(() => {
if (code) {
setType('text');
gettext();
} else if (type === 'text') {
gettext();
getText();
} else if (overrideType === 'text' || type === 'text') {
getText();
} else {
return;
}
}, []);
useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'auto';
}
}, [open]);
if (disableMediaPreview && !show)
return <Placeholder text={`Click to view file ${file.name}`} Icon={fileIcon(file.type)} />;
@@ -153,7 +167,7 @@ export default function DashboardFileType({
</Paper>
);
switch (type) {
switch (overrideType || type) {
case 'video':
return show ? (
<video
@@ -162,11 +176,14 @@ export default function DashboardFileType({
muted
controls
src={dbFile ? `/raw/${file.name}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
style={{ cursor: 'pointer' }}
style={{ cursor: 'pointer', maxWidth: '85vw', maxHeight: '85vh' }}
/>
) : (file as DbFile).thumbnail && dbFile ? (
<Box pos='relative'>
<MantineImage src={`/raw/${(file as DbFile).thumbnail!.path}`} alt={file.name} />
<MantineImage
src={`/raw/${(file as DbFile).thumbnail!.path}`}
alt={file.name || 'Video thumbnail'}
/>
<Center
pos='absolute'
@@ -191,14 +208,12 @@ export default function DashboardFileType({
return show ? (
<Center>
<MantineImage
mah={400}
src={dbFile ? `/raw/${file.name}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
alt={file.name}
alt={file.name || 'Image'}
style={{
maxWidth: '90vw',
maxHeight: '90vh',
cursor: allowZoom ? 'zoom-in' : 'default',
width: 'auto',
maxWidth: '70vw',
maxHeight: '70vw',
}}
onClick={() => setOpen(true)}
/>
@@ -208,10 +223,10 @@ export default function DashboardFileType({
src={
dbFile ? `/raw/${file.name}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)
}
alt={file.name}
alt={file.name || 'Image'}
style={{
maxWidth: '90vw',
maxHeight: '90vh',
maxWidth: '95vw',
maxHeight: '95vh',
objectFit: 'contain',
cursor: 'zoom-out',
width: 'auto',
@@ -225,7 +240,7 @@ export default function DashboardFileType({
fit='contain'
mah={400}
src={dbFile ? `/raw/${file.name}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
alt={file.name}
alt={file.name || 'Image'}
/>
);
case 'audio':

View File

@@ -3,10 +3,10 @@ import { IncompleteFile } from '@/lib/db/models/incompleteFile';
import { fetchApi } from '@/lib/fetchApi';
import { ActionIcon, Badge, Button, Card, Group, Modal, Paper, Stack, Text, Tooltip } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { IncompleteFileStatus } from '@prisma/client';
import { IncompleteFileStatus } from '../../../../generated/client';
import { IconFileDots, IconTrashFilled } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { ReactNode, useEffect, useState } from 'react';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { ReactNode } from 'react';
import useSWR from 'swr';
const badgeMap: Record<IncompleteFileStatus, ReactNode> = {
@@ -33,9 +33,7 @@ const badgeMap: Record<IncompleteFileStatus, ReactNode> = {
};
export default function PendingFilesButton() {
const router = useRouter();
const [open, setOpen] = useState(router.query.pending !== undefined);
const [open, setOpen] = useQueryState('popen', parseAsBoolean.withDefault(false));
const { data: incompleteFiles, mutate } = useSWR<
Extract<IncompleteFile[], Response['/api/user/files/incomplete']>
@@ -68,15 +66,6 @@ export default function PendingFilesButton() {
mutate();
};
useEffect(() => {
if (open) {
router.push({ query: { ...router.query, pending: 'true' } }, undefined, { shallow: true });
} else {
delete router.query.pending;
router.push({ query: router.query }, undefined, { shallow: true });
}
}, [open]);
return (
<>
<Modal opened={open} onClose={() => setOpen(false)} title='Pending Files'>

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

@@ -26,7 +26,7 @@ export default function CreateTagModal({ open, onClose }: { open: boolean; onClo
const color = values.color.trim() === '' ? colorHash(values.name) : values.color.trim();
if (!color.startsWith('#')) {
form.setFieldError('color', 'Color must start with #');
return form.setFieldError('color', 'Color must start with #');
}
const { data, error } = await fetchApi<Extract<Response['/api/user/tags'], Tag>>(

View File

@@ -1,11 +1,10 @@
import { Tag } from '@/lib/db/models/tag';
import { Pill, isLightColor } from '@mantine/core';
export default function TagPill({
tag,
...other
}: {
tag: Tag | null;
tag: { color: string; name: string } | null;
withRemoveButton?: boolean;
onRemove?: () => void;
}) {

View File

@@ -1,22 +1,20 @@
import { mutateFiles } from '@/components/file/actions';
import { Response } from '@/lib/api/response';
import { Tag } from '@/lib/db/models/tag';
import { ActionIcon, Group, Modal, Paper, Stack, Text, Title, Tooltip } from '@mantine/core';
import { IconPencil, IconPlus, IconTagOff, IconTags, IconTrashFilled } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import useSWR from 'swr';
import TagPill from './TagPill';
import { fetchApi } from '@/lib/fetchApi';
import { ActionIcon, Group, Modal, Paper, Stack, Text, Title, Tooltip } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { IconPencil, IconPlus, IconTagOff, IconTags, IconTrashFilled } from '@tabler/icons-react';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { useState } from 'react';
import useSWR from 'swr';
import CreateTagModal from './CreateTagModal';
import EditTagModal from './EditTagModal';
import { mutateFiles } from '@/components/file/actions';
import TagPill from './TagPill';
export default function TagsButton() {
const router = useRouter();
const [open, setOpen] = useState(router.query.tags !== undefined);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [open, setOpen] = useQueryState('topen', parseAsBoolean.withDefault(false));
const [createModalOpen, setCreateModalOpen] = useQueryState('ctopen', parseAsBoolean.withDefault(false));
const [selectedTag, setSelectedTag] = useState<Tag | null>(null);
const { data: tags, mutate } = useSWR<Extract<Tag[], Response['/api/user/tags']>>('/api/user/tags');
@@ -44,15 +42,6 @@ export default function TagsButton() {
mutateFiles();
};
useEffect(() => {
if (open) {
router.push({ query: { ...router.query, tags: 'true' } }, undefined, { shallow: true });
} else {
delete router.query.tags;
router.push({ query: router.query }, undefined, { shallow: true });
}
}, [open]);
return (
<>
<CreateTagModal open={createModalOpen} onClose={() => setCreateModalOpen(false)} />

View File

@@ -1,5 +1,4 @@
import { Response } from '@/lib/api/response';
import type { Prisma } from '@prisma/client';
import useSWR from 'swr';
type ApiPaginationOptions = {
@@ -7,7 +6,17 @@ type ApiPaginationOptions = {
filter?: string;
perpage?: number;
favorite?: boolean;
sort?: keyof Prisma.FileOrderByWithAggregationInput;
sort?:
| 'name'
| 'id'
| 'createdAt'
| 'updatedAt'
| 'deletesAt'
| 'originalName'
| 'size'
| 'type'
| 'views'
| 'favorite';
order?: 'asc' | 'desc';
id?: string;
search?: {

View File

@@ -12,47 +12,24 @@ import {
Title,
} from '@mantine/core';
import { IconFileUpload, IconFilesOff } from '@tabler/icons-react';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { useApiPagination } from '../useApiPagination';
import dynamic from 'next/dynamic';
import Link from 'next/link';
import { parseAsInteger, useQueryState } from 'nuqs';
import { useApiPagination } from '../useApiPagination';
const DashboardFile = dynamic(() => import('@/components/file/DashboardFile'), {
loading: () => <Skeleton height={350} animate />,
});
export default function FavoriteFiles() {
const router = useRouter();
const [page, setPage] = useQueryState('fpage', parseAsInteger.withDefault(1));
const [page, setPage] = useState<number>(
router.query.favoritePage ? parseInt(router.query.favoritePage as string) : 1,
);
const { data, isLoading } = useApiPagination({
page,
favorite: true,
filter: 'dashboard',
});
useEffect(() => {
router.replace(
{
query: {
...router.query,
favoritePage: page,
},
},
undefined,
{ shallow: true },
);
}, [page]);
if (!isLoading && !data?.page.length && router.query.favoritePage) {
delete router.query.favoritePage;
router.replace({ query: router.query }, undefined, { shallow: true });
setPage(1);
}
if (!isLoading && !data?.page.length) {
return null;
}

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';
@@ -28,9 +28,9 @@ import {
useCombobox,
} from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import type { Prisma } from '@prisma/client';
import {
IconCopy,
IconDownload,
IconExternalLink,
IconFile,
IconGridPatternFilled,
@@ -39,7 +39,7 @@ import {
} from '@tabler/icons-react';
import { DataTable } from 'mantine-datatable';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { parseAsBoolean, parseAsInteger, parseAsStringLiteral, useQueryState } from 'nuqs';
import { useEffect, useReducer, useState } from 'react';
import useSWR from 'swr';
import { bulkDelete, bulkFavorite } from '../bulk';
@@ -179,7 +179,6 @@ function TagsFilter({
}
export default function FileTable({ id }: { id?: string }) {
const router = useRouter();
const clipboard = useClipboard();
const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion);
@@ -187,13 +186,30 @@ export default function FileTable({ id }: { id?: string }) {
'/api/user/folders?noincl=true',
);
const [page, setPage] = useState<number>(router.query.page ? parseInt(router.query.page as string) : 1);
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
const [perpage, setPerpage] = useState<number>(20);
const [sort, setSort] = useState<keyof Prisma.FileOrderByWithAggregationInput>('createdAt');
const [order, setOrder] = useState<'asc' | 'desc'>('desc');
const [sort, setSort] = useQueryState(
'sort',
parseAsStringLiteral([
'id',
'createdAt',
'updatedAt',
'deletesAt',
'name',
'originalName',
'size',
'type',
'views',
'favorite',
]).withDefault('createdAt'),
);
const [order, setOrder] = useQueryState<'asc' | 'desc'>(
'order',
parseAsStringLiteral(['asc', 'desc']).withDefault('desc'),
);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [idSearchOpen, setIdSearchOpen] = useState(false);
const [idSearchOpen, setIdSearchOpen] = useQueryState('idsearch', parseAsBoolean.withDefault(false));
const [searchField, setSearchField] = useState<'name' | 'originalName' | 'type' | 'tags' | 'id'>('name');
const [searchQuery, setSearchQuery] = useReducer(
(state: ReducerQuery['state'], action: ReducerQuery['action']) => {
@@ -253,19 +269,6 @@ export default function FileTable({ id }: { id?: string }) {
}),
});
useEffect(() => {
router.replace(
{
query: {
...router.query,
page: page,
},
},
undefined,
{ shallow: true },
);
}, [page]);
useEffect(() => {
if (data && selectedFile) {
const file = data.page.find((x) => x.id === selectedFile.id);
@@ -304,9 +307,8 @@ export default function FileTable({ id }: { id?: string }) {
onClick={() => {
setIdSearchOpen((open) => !open);
}}
color='blue'
// lol if it works it works :shrug:
style={{ position: 'relative', top: '-36.4px', left: '219px', margin: 0 }}
style={{ position: 'relative', top: '-36.4px', left: '221px', margin: 0 }}
>
<IconGridPatternFilled size='1rem' />
</ActionIcon>
@@ -519,6 +521,18 @@ export default function FileTable({ id }: { id?: string }) {
</ActionIcon>
</Tooltip>
<Tooltip label='Download file'>
<ActionIcon
color='gray'
onClick={(e) => {
e.stopPropagation();
downloadFile(file);
}}
>
<IconDownload size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='Delete file'>
<ActionIcon
color='red'
@@ -546,7 +560,7 @@ export default function FileTable({ id }: { id?: string }) {
direction: order,
}}
onSortStatusChange={(data) => {
setSort(data.columnAccessor as keyof Prisma.FileOrderByWithAggregationInput);
setSort(data.columnAccessor as any);
setOrder(data.direction);
}}
onCellClick={({ record }) => setSelectedFile(record)}

View File

@@ -14,7 +14,7 @@ import {
import { IconFileUpload, IconFilesOff } from '@tabler/icons-react';
import dynamic from 'next/dynamic';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { parseAsInteger, useQueryState } from 'nuqs';
import { useEffect, useState } from 'react';
import { useApiPagination } from '../useApiPagination';
@@ -25,9 +25,7 @@ const DashboardFile = dynamic(() => import('@/components/file/DashboardFile'), {
const PER_PAGE_OPTIONS = [9, 12, 15, 30, 45];
export default function Files({ id }: { id?: string }) {
const router = useRouter();
const [page, setPage] = useState<number>(router.query.page ? parseInt(router.query.page as string) : 1);
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
const [perpage, setPerpage] = useState<number>(15);
const [cachedPages, setCachedPages] = useState<number>(1);
@@ -43,19 +41,6 @@ export default function Files({ id }: { id?: string }) {
}
}, [data?.pages]);
useEffect(() => {
router.replace(
{
query: {
...router.query,
page: page,
},
},
undefined,
{ shallow: true },
);
}, [page]);
const from = (page - 1) * perpage + 1;
const to = Math.min(page * perpage, data?.total ?? 0);
const totalRecords = data?.total ?? 0;

View File

@@ -13,35 +13,17 @@ import {
} from '@mantine/core';
import { IconFileUpload, IconFilesOff } from '@tabler/icons-react';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { parseAsInteger, useQueryState } from 'nuqs';
import { useApiPagination } from '../files/useApiPagination';
export default function FavoriteFiles() {
const router = useRouter();
const [page, setPage] = useState<number>(
router.query.favoritePage ? parseInt(router.query.favoritePage as string) : 1,
);
const [page, setPage] = useQueryState('fpage', parseAsInteger.withDefault(1));
const { data, isLoading } = useApiPagination({
page,
favorite: true,
filter: 'dashboard',
});
useEffect(() => {
router.replace(
{
query: {
...router.query,
favoritePage: page,
},
},
undefined,
{ shallow: true },
);
}, [page]);
if (!isLoading && data?.page.length === 0) return null;
return (

View File

@@ -7,7 +7,7 @@ import { ActionIcon, Button, Group, Modal, Stack, Switch, TextInput, Title, Tool
import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
import { IconFolderPlus, IconPlus } from '@tabler/icons-react';
import { useState } from 'react';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { mutate } from 'swr';
import FolderGridView from './views/FolderGridView';
import FolderTableView from './views/FolderTableView';
@@ -15,7 +15,7 @@ import FolderTableView from './views/FolderTableView';
export default function DashboardFolders() {
const view = useViewStore((state) => state.folders);
const [open, setOpen] = useState(false);
const [open, setOpen] = useQueryState('cfopen', parseAsBoolean.withDefault(false));
const form = useForm({
initialValues: {

View File

@@ -2,7 +2,6 @@ import GridTableSwitcher from '@/components/GridTableSwitcher';
import { useViewStore } from '@/lib/store/view';
import { ActionIcon, Button, Group, Modal, NumberInput, Select, Stack, Title, Tooltip } from '@mantine/core';
import { IconPlus, IconTagOff } from '@tabler/icons-react';
import { useState } from 'react';
import InviteGridView from './views/InviteGridView';
import InviteTableView from './views/InviteTableView';
import { useForm } from '@mantine/form';
@@ -11,10 +10,11 @@ import { Response } from '@/lib/api/response';
import { notifications } from '@mantine/notifications';
import { Invite } from '@/lib/db/models/invite';
import { mutate } from 'swr';
import { parseAsBoolean, useQueryState } from 'nuqs';
export default function DashboardInvites() {
const view = useViewStore((state) => state.invites);
const [open, setOpen] = useState(false);
const [open, setOpen] = useQueryState('ciopen', parseAsBoolean.withDefault(false));
const form = useForm<{
maxUses: number | '';

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,21 +15,23 @@ const StorageGraph = dynamic(() => import('./parts/StorageGraph'));
const ViewsGraph = dynamic(() => import('./parts/ViewsGraph'));
export default function DashboardMetrics() {
const [dateRange, setDateRange] = useState<[Date | null, Date | null]>([
new Date(Date.now() - 86400000 * 7),
new Date(),
]); // default: [7 days ago, now]
const today = dayjs();
const [dateRange, setDateRange] = useState<[string | null, string | null]>([
today.subtract(7, 'day').toISOString(),
today.toISOString(),
]);
const [open, setOpen] = useState(false);
const [allTime, setAllTime] = useState(false);
const { data, isLoading } = useApiStats({
from: dateRange[0]?.toISOString() ?? undefined,
to: dateRange[1]?.toISOString() ?? undefined,
from: allTime || !dateRange[0] ? undefined : new Date(dateRange[0]).toISOString(),
to: allTime || !dateRange[1] ? undefined : new Date(dateRange[1]).toISOString(),
all: allTime,
});
const handleDateChange = (value: [Date | null, Date | null]) => {
const handleDateChange = (value: [string | null, string | null]) => {
setAllTime(false);
setDateRange(value);
};
@@ -40,17 +43,49 @@ export default function DashboardMetrics() {
return (
<>
<Modal title='Change range' opened={open} onClose={() => setOpen(false)} size='auto'>
<Paper withBorder>
<Paper withBorder style={{ minHeight: 300 }}>
<DatePicker
type='range'
value={dateRange}
onChange={handleDateChange}
allowSingleDateInRange={false}
maxDate={new Date(Date.now() + 0)}
maxDate={new Date()}
presets={[
{
value: [today.subtract(2, 'day').format('YYYY-MM-DD'), today.format('YYYY-MM-DD')],
label: 'Last two days',
},
{
value: [today.subtract(7, 'day').format('YYYY-MM-DD'), today.format('YYYY-MM-DD')],
label: 'Last 7 days',
},
{
value: [today.startOf('month').format('YYYY-MM-DD'), today.format('YYYY-MM-DD')],
label: 'This month',
},
{
value: [
today.subtract(1, 'month').startOf('month').format('YYYY-MM-DD'),
today.subtract(1, 'month').endOf('month').format('YYYY-MM-DD'),
],
label: 'Last month',
},
{
value: [today.startOf('year').format('YYYY-MM-DD'), today.format('YYYY-MM-DD')],
label: 'This year',
},
{
value: [
today.subtract(1, 'year').startOf('year').format('YYYY-MM-DD'),
today.subtract(1, 'year').endOf('year').format('YYYY-MM-DD'),
],
label: 'Last year',
},
]}
/>
</Paper>
<Group mt='md'>
<Group mt='lg'>
<Button fullWidth onClick={() => setOpen(false)}>
Close
</Button>
@@ -69,25 +104,14 @@ export default function DashboardMetrics() {
</Button>
{!allTime ? (
<Text size='sm' c='dimmed'>
{data?.length ? (
<>
{new Date(data?.[0]?.createdAt).toLocaleDateString()}
{' to '}
{new Date(data?.[data.length - 1]?.createdAt).toLocaleDateString()}
</>
) : (
<>
{dateRange[0]?.toLocaleDateString()}{' '}
{dateRange[1] ? `to ${dateRange[1]?.toLocaleDateString()}` : ''}
</>
)}
{dateRange[0] ? new Date(dateRange[0]).toLocaleDateString() : '—'}
{dateRange[1] ? ` to ${new Date(dateRange[1]).toLocaleDateString()}` : ''}
</Text>
) : (
<Text size='sm' c='dimmed'>
All Time
</Text>
)}
{/* <Tooltip label='This may take longer than usual to load.'> */}
<Tooltip
label={!allTime ? 'This may take longer than usual to load.' : 'You are viewing all time stats.'}
>
@@ -107,22 +131,18 @@ export default function DashboardMetrics() {
{isLoading ? (
<div>
<StatsCardsSkeleton />
<StatsTablesSkeleton />
</div>
) : data?.length ? (
<div>
<StatsCards data={data!} />
<StatsTables data={data!} />
<StatsCards data={data} />
<StatsTables data={data} />
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }}>
<FilesUrlsCountGraph metrics={data!} />
<ViewsGraph metrics={data!} />
<FilesUrlsCountGraph metrics={data} />
<ViewsGraph metrics={data} />
</SimpleGrid>
<div>
<StorageGraph metrics={data!} />
<StorageGraph metrics={data} />
</div>
</div>
) : (

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

@@ -1,12 +1,22 @@
import { Response } from '@/lib/api/response';
import { Button, LoadingOverlay, NumberInput, Paper, SimpleGrid, Switch, Title } from '@mantine/core';
import {
Anchor,
Button,
LoadingOverlay,
NumberInput,
Paper,
SimpleGrid,
Switch,
TextInput,
Title,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { settingsOnSubmit } from '../settingsOnSubmit';
export default function ServerSettingsFeatures({
export default function Features({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
@@ -25,24 +35,33 @@ export default function ServerSettingsFeatures({
featuresMetricsEnabled: true,
featuresMetricsAdminOnly: false,
featuresMetricsShowUserSpecific: true,
featuresVersionChecking: true,
featuresVersionAPI: 'https://zipline-version.diced.sh/',
},
enhanceGetInputProps: (payload) => ({
disabled: data?.tampered?.includes(payload.field) || false,
}),
});
const onSubmit = settingsOnSubmit(router, form);
useEffect(() => {
if (!data) return;
form.setValues({
featuresImageCompression: data?.featuresImageCompression ?? true,
featuresRobotsTxt: data?.featuresRobotsTxt ?? true,
featuresHealthcheck: data?.featuresHealthcheck ?? true,
featuresUserRegistration: data?.featuresUserRegistration ?? false,
featuresOauthRegistration: data?.featuresOauthRegistration ?? true,
featuresDeleteOnMaxViews: data?.featuresDeleteOnMaxViews ?? true,
featuresThumbnailsEnabled: data?.featuresThumbnailsEnabled ?? true,
featuresThumbnailsNumberThreads: data?.featuresThumbnailsNumberThreads ?? 4,
featuresMetricsEnabled: data?.featuresMetricsEnabled ?? true,
featuresMetricsAdminOnly: data?.featuresMetricsAdminOnly ?? false,
featuresMetricsShowUserSpecific: data?.featuresMetricsShowUserSpecific ?? true,
featuresImageCompression: data.settings.featuresImageCompression ?? true,
featuresRobotsTxt: data.settings.featuresRobotsTxt ?? true,
featuresHealthcheck: data.settings.featuresHealthcheck ?? true,
featuresUserRegistration: data.settings.featuresUserRegistration ?? false,
featuresOauthRegistration: data.settings.featuresOauthRegistration ?? true,
featuresDeleteOnMaxViews: data.settings.featuresDeleteOnMaxViews ?? true,
featuresThumbnailsEnabled: data.settings.featuresThumbnailsEnabled ?? true,
featuresThumbnailsNumberThreads: data.settings.featuresThumbnailsNumberThreads ?? 4,
featuresMetricsEnabled: data.settings.featuresMetricsEnabled ?? true,
featuresMetricsAdminOnly: data.settings.featuresMetricsAdminOnly ?? false,
featuresMetricsShowUserSpecific: data.settings.featuresMetricsShowUserSpecific ?? true,
featuresVersionChecking: data.settings.featuresVersionChecking ?? true,
featuresVersionAPI: data.settings.featuresVersionAPI ?? 'https://zipline-version.diced.sh/',
});
}, [data]);
@@ -107,7 +126,7 @@ export default function ServerSettingsFeatures({
description='Shows metrics specific to each user, for all users.'
{...form.getInputProps('featuresMetricsShowUserSpecific', { type: 'checkbox' })}
/>
<div />
<Switch
label='Enable Thumbnails'
description='Enables thumbnail generation for images. Requires a server restart.'
@@ -122,6 +141,30 @@ export default function ServerSettingsFeatures({
max={16}
{...form.getInputProps('featuresThumbnailsNumberThreads')}
/>
<Switch
label='Version Checking'
description='Enable version checking for the server. This will check for updates and display the status on the sidebar to all users.'
{...form.getInputProps('featuresVersionChecking', { type: 'checkbox' })}
/>
<TextInput
label='Version API URL'
description={
<>
The URL of the version checking server. The default is{' '}
<Anchor size='xs' href='zipline-version.diced.sh' target='_blank'>
https://zipline-version.diced.sh
</Anchor>
. Visit the{' '}
<Anchor size='xs' href='https://github.com/diced/zipline-version-worker' target='_blank'>
GitHub
</Anchor>{' '}
to host your own version checking server.
</>
}
placeholder='https://zipline-version.diced.sh/'
{...form.getInputProps('featuresVersionAPI')}
/>
</SimpleGrid>
<Button type='submit' mt='md' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>

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

@@ -49,6 +49,8 @@ export default function SettingsFileView() {
embedColor: user?.view.embedColor ?? '',
align: user?.view.align ?? 'left',
showMimetype: user?.view.showMimetype ?? false,
showTags: user?.view.showTags ?? false,
showFolder: user?.view.showFolder ?? false,
},
});
@@ -63,6 +65,8 @@ export default function SettingsFileView() {
embedColor: values.embedColor.trim() || null,
align: values.align,
showMimetype: values.showMimetype,
showTags: values.showTags,
showFolder: values.showFolder,
};
const { data, error } = await fetchApi<Response['/api/user']>('/api/user', 'PATCH', {
@@ -110,6 +114,20 @@ export default function SettingsFileView() {
disabled={!form.values.enabled}
{...form.getInputProps('showMimetype', { type: 'checkbox' })}
/>
<Switch
label='Show tags'
description="Show the file's tags in the view-route"
disabled={!form.values.enabled}
{...form.getInputProps('showTags', { type: 'checkbox' })}
/>
<Switch
label='Show folder'
description='Show the name/link of the folder if possible in the view-route'
disabled={!form.values.enabled}
{...form.getInputProps('showFolder', { type: 'checkbox' })}
/>
</SimpleGrid>
<Textarea

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';
@@ -45,7 +44,6 @@ export type GeneratorOptions = {
};
export const copier = (options: GeneratorOptions) => {
if (options.unix_useEcho) return 'echo';
if (options.mac_enableCompatibility) return 'pbcopy';
if (options.wl_enableCompatibility) return 'wl-copy';
return 'xclip -selection clipboard';
@@ -106,10 +104,22 @@ export default function GeneratorButton({
);
const { data: tokenData, isLoading, error } = useSWR<Response['/api/user/token']>('/api/user/token');
const { data: settingsData } = useSWR<Response['/api/server/settings']>('/api/server/settings');
const isUnixLike = name === 'Flameshot' || name === 'Shell Script';
const onlyFile = generatorType === 'file';
const domains = Array.isArray(settingsData?.settings.domains)
? settingsData?.settings.domains.map((d) => String(d))
: [];
const domainOptions = [
{ value: '', label: 'Default Domain' },
...domains.map((domain) => ({
value: domain,
label: domain,
})),
] as { value: string; label: string; disabled?: boolean }[];
return (
<>
<Modal opened={opened} onClose={() => setOpen(false)} title={`Generate ${name} Uploader`}>
@@ -188,14 +198,21 @@ export default function GeneratorButton({
onChange={(value) => setOption({ maxViews: value === '' ? null : Number(value) })}
/>
<TextInput
<Select
data={domainOptions}
label='Override Domain'
description='Override the domain with this value. This will change the domain returned in your uploads. Leave blank to use the default domain.'
leftSection={<IconGlobe size='1rem' />}
value={options.overrides_returnDomain ?? ''}
onChange={(event) =>
setOption({ overrides_returnDomain: event.currentTarget.value.trim() || null })
}
onChange={(value) => setOption({ overrides_returnDomain: value || null })}
comboboxProps={{
withinPortal: true,
portalProps: {
style: {
zIndex: 100000000,
},
},
}}
/>
<Text c='dimmed' size='sm'>

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

@@ -10,7 +10,7 @@ export function shell(token: string, type: 'file' | 'url', options: GeneratorOpt
];
if (type === 'file') {
curl.push('-F', 'file=@$1');
curl.push('-F', '"file=@$1;type=$(file --mime-type -b "$1")"');
curl.push('-H', "'content-type: multipart/form-data'");
} else {
curl.push('-H', "'content-type: application/json'");
@@ -61,20 +61,22 @@ export function shell(token: string, type: 'file' | 'url', options: GeneratorOpt
}
for (const [key, value] of Object.entries(toAddHeaders)) {
curl.push('-H', `${key}: ${value}`);
curl.push('-H', `"${key}: ${value}"`);
}
let script;
if (type === 'file') {
script = `#!/bin/bash
${curl.join(' ')}${options.noJson ? '' : ' | jq -r .files[0].url'} | tr -d '\\n' | ${copier(options)}
${curl.join(' ')}${options.noJson ? '' : ' | jq -r .files[0].url'}${
options.unix_useEcho ? '' : ` | ${copier(options)}`
}
`;
} else {
script = `#!/bin/bash
${curl.join(' ')} -d "{\\"url\\": \\"$1\\"}"${
options.noJson ? '' : ' | jq -r .files[0].url'
} | tr -d '\\n' | ${copier(options)}
${curl.join(' ')} -d "{\\"destination\\": \\"$1\\"}"${
options.noJson ? '' : ' | jq -r .url'
}${options.unix_useEcho ? '' : ` | ${copier(options)}`}
`;
}

View File

@@ -79,6 +79,10 @@ export default function SettingsGenerators() {
<Code>curl</Code>
</Anchor>
,{' '}
<Anchor component={Link} href='https://darwinsys.com/file/'>
<Code>file</Code>
</Anchor>
,{' '}
<Anchor component={Link} href='https://github.com/stedolan/jq'>
<Code>jq</Code>
</Anchor>

View File

@@ -6,7 +6,7 @@ import { RegistrationResponseJSON } from '@github/webauthn-json/dist/types/brows
import { ActionIcon, Button, Group, Modal, Paper, Stack, Text, TextInput } from '@mantine/core';
import { modals } from '@mantine/modals';
import { notifications } from '@mantine/notifications';
import { UserPasskey } from '@prisma/client';
import { UserPasskey } from '../../../../../../generated/client';
import { IconKey, IconKeyOff, IconTrashFilled } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { mutate } from 'swr';

View File

@@ -6,7 +6,7 @@ import { useUserStore } from '@/lib/store/user';
import { darken } from '@/lib/theme/color';
import { Button, ButtonProps, Paper, SimpleGrid, Text, Title, useMantineTheme } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import type { OAuthProviderType } from '@prisma/client';
import type { OAuthProviderType } from '../../../../../../generated/client';
import {
IconBrandDiscordFilled,
IconBrandGithubFilled,

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,8 +30,8 @@ import {
IconTrashFilled,
IconWriting,
} from '@tabler/icons-react';
import ms, { StringValue } from 'ms';
import Link from 'next/link';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { useEffect, useState } from 'react';
import useSWR from 'swr';
import { useShallow } from 'zustand/shallow';
@@ -39,7 +39,7 @@ import { useShallow } from 'zustand/shallow';
export default function UploadOptionsButton({ folder, numFiles }: { folder?: string; numFiles: number }) {
const config = useConfig();
const [opened, setOpen] = useState(false);
const [opened, setOpen] = useQueryState('upopen', parseAsBoolean.withDefault(false));
const [options, ephemeral, setOption, setEphemeral, changes, clearEphemeral, clearOptions] =
useUploadOptionsStore(
useShallow((state) => [
@@ -62,9 +62,20 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str
const { data: folders } = useSWR<Extract<Response['/api/user/folders'], Folder[]>>(
'/api/user/folders?noincl=true',
);
const { data: settingsData } = useSWR<Response['/api/server/settings']>('/api/server/settings');
const combobox = useCombobox();
const [folderSearch, setFolderSearch] = useState('');
const domains = Array.isArray(settingsData?.settings.domains) ? settingsData.settings.domains : [];
const domainOptions = [
{ value: '', label: 'Default Domain' },
...domains.map((domain) => ({
value: domain,
label: domain,
})),
];
useEffect(() => {
if (folder) return;
@@ -84,6 +95,7 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str
<Stack gap='xs' my='sm'>
<Select
data={[
{ value: 'default', label: `Default (${config.files.defaultExpiration ?? 'never'})` },
{ value: 'never', label: 'Never' },
{ value: '5min', label: '5 minutes' },
{ value: '10min', label: '10 minutes' },
@@ -121,7 +133,7 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str
label={
<>
Deletes at{' '}
{options.deletesAt !== 'never' ? (
{options.deletesAt !== 'default' ? (
<Badge variant='outline' size='xs'>
saved
</Badge>
@@ -133,8 +145,8 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str
The file will automatically delete itself after this time.{' '}
{config.files.defaultExpiration ? (
<>
The default expiration time is <b>{ms(config.files.defaultExpiration as StringValue)}</b>{' '}
(you can override this with the below option).
The default expiration time is <b>{config.files.defaultExpiration}</b> (you can override
this with the below option).
</>
) : (
<>
@@ -147,7 +159,7 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str
}
leftSection={<IconAlarmFilled size='1rem' />}
value={options.deletesAt}
onChange={(value) => setOption('deletesAt', value || 'never')}
onChange={(value) => setOption('deletesAt', value || 'default')}
comboboxProps={{
withinPortal: true,
portalProps: {
@@ -263,9 +275,7 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str
<Combobox.Dropdown>
<Combobox.Options>
<Combobox.Option defaultChecked={true} value='no folder'>
No Folder
</Combobox.Option>
<Combobox.Option value='no folder'>No Folder</Combobox.Option>
{folders
?.filter((f) => f.name.toLowerCase().includes(folderSearch.toLowerCase().trim()))
@@ -278,7 +288,8 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str
</Combobox.Dropdown>
</Combobox>
<TextInput
<Select
data={domainOptions}
label={
<>
Override Domain{' '}
@@ -292,12 +303,15 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str
description='Override the domain with this value. This will change the domain returned in your uploads. Leave blank to use the default domain.'
leftSection={<IconGlobe size='1rem' />}
value={options.overrides_returnDomain ?? ''}
onChange={(event) =>
setOption(
'overrides_returnDomain',
event.currentTarget.value.trim() === '' ? null : event.currentTarget.value.trim(),
)
}
onChange={(value) => setOption('overrides_returnDomain', value || null)}
comboboxProps={{
withinPortal: true,
portalProps: {
style: {
zIndex: 100000000,
},
},
}}
/>
<TextInput

View File

@@ -1,12 +1,13 @@
export enum RenderMode {
Katex,
Markdown,
Highlight,
Katex = 'katex',
Markdown = 'md',
Highlight = 'hl',
}
export function renderMode(extension: string) {
switch (extension) {
case 'tex':
case 'katex':
return RenderMode.Katex;
case 'md':
return RenderMode.Markdown;

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

@@ -1,7 +1,6 @@
import { useConfig } from '@/components/ConfigProvider';
import { Response } from '@/lib/api/response';
import { bytes } from '@/lib/bytes';
import { randomCharacters } from '@/lib/random';
import { ErrorBody } from '@/lib/response';
import { UploadOptionsStore } from '@/lib/store/uploadOptions';
import { ActionIcon, Anchor, Group, Stack, Table, Text, Tooltip } from '@mantine/core';
@@ -101,7 +100,6 @@ export async function uploadPartialFiles(
for (let i = 0; i !== files.length; ++i) {
const file = files[i];
const identifier = randomCharacters(8);
const nChunks = Math.ceil(file.size / chunkSize);
const chunks: {
blob: Blob;
@@ -129,6 +127,7 @@ export async function uploadPartialFiles(
let ready = true;
let totalLoaded = 0;
let identifier: string | undefined;
const start = Date.now();
for (let j = 0; j !== nChunks; ++j) {
@@ -172,7 +171,7 @@ export async function uploadPartialFiles(
message: (res as ErrorBody).error,
color: 'red',
icon: <IconFileXFilled size='1rem' />,
autoClose: true,
autoClose: false,
loading: false,
});
ready = false;
@@ -190,6 +189,10 @@ export async function uploadPartialFiles(
autoClose: false,
});
if (j === 0) {
identifier = res.partialIdentifier;
}
if (j === chunks.length - 1) {
notifications.update({
id: 'upload-partial',
@@ -215,6 +218,10 @@ export async function uploadPartialFiles(
>
Click here to copy the URL to clipboard while it&apos;s being processed.
</Anchor>
<br />
<Anchor component={Link} href='/dashboard/files?popen=true'>
View processing files
</Anchor>
</Text>
),
color: 'green',
@@ -236,7 +243,7 @@ export async function uploadPartialFiles(
);
req.open('POST', '/api/upload');
options.deletesAt !== 'never' && req.setRequestHeader('x-zipline-deletes-at', options.deletesAt);
options.deletesAt !== 'default' && req.setRequestHeader('x-zipline-deletes-at', options.deletesAt);
options.format !== 'default' && req.setRequestHeader('x-zipline-format', options.format);
options.imageCompressionPercent &&
req.setRequestHeader(
@@ -258,7 +265,7 @@ export async function uploadPartialFiles(
req.setRequestHeader('x-zipline-folder', ephemeral.folderId);
}
req.setRequestHeader('x-zipline-p-identifier', identifier);
identifier && req.setRequestHeader('x-zipline-p-identifier', identifier);
req.setRequestHeader('x-zipline-p-filename', encodeURIComponent(file.name));
req.setRequestHeader('x-zipline-p-lastchunk', j === chunks.length - 1 ? 'true' : 'false');
req.setRequestHeader('x-zipline-p-content-type', file.type);

View File

@@ -1,5 +1,6 @@
import GridTableSwitcher from '@/components/GridTableSwitcher';
import { Response } from '@/lib/api/response';
import { Url } from '@/lib/db/models/url';
import { fetchApi } from '@/lib/fetchApi';
import { useViewStore } from '@/lib/store/view';
import {
@@ -23,17 +24,16 @@ import { modals } from '@mantine/modals';
import { notifications } from '@mantine/notifications';
import { IconClipboardCopy, IconExternalLink, IconLink, IconLinkOff } from '@tabler/icons-react';
import Link from 'next/link';
import { useState } from 'react';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { mutate } from 'swr';
import UrlGridView from './views/UrlGridView';
import UrlTableView from './views/UrlTableView';
import { Url } from '@/lib/db/models/url';
export default function DashboardURLs() {
const clipboard = useClipboard();
const view = useViewStore((state) => state.urls);
const [open, setOpen] = useState(false);
const [open, setOpen] = useQueryState('cuopen', parseAsBoolean.withDefault(false));
const form = useForm<{
url: string;

View File

@@ -22,7 +22,7 @@ import {
import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
import { IconPhotoMinus, IconUserCancel, IconUserPlus } from '@tabler/icons-react';
import { useState } from 'react';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { mutate } from 'swr';
import UserGridView from './views/UserGridView';
import UserTableView from './views/UserTableView';
@@ -30,7 +30,7 @@ import UserTableView from './views/UserTableView';
export default function DashboardUsers() {
const currentUser = useUserStore((state) => state.user);
const view = useViewStore((state) => state.users);
const [open, setOpen] = useState(false);
const [open, setOpen] = useQueryState('cuseropen', parseAsBoolean.withDefault(false));
const form = useForm<{
username: string;

View File

@@ -5,6 +5,7 @@ import { useState } from 'react';
import KaTeX from './KaTeX';
import Markdown from './Markdown';
import HighlightCode from './code/HighlightCode';
import { parseAsStringEnum, useQueryState } from 'nuqs';
export function RenderAlert({
renderer,
@@ -46,9 +47,11 @@ export default function Render({
language: string;
code: string;
}) {
const [overrideRender] = useQueryState('orender', parseAsStringEnum<RenderMode>(Object.values(RenderMode)));
const [highlight, setHighlight] = useState(false);
switch (mode) {
switch (overrideRender || mode) {
case RenderMode.Katex:
return (
<>

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

@@ -2,11 +2,18 @@ import { guess } from '@/lib/mimes';
import { statSync } from 'fs';
import { readFile, readdir } from 'fs/promises';
import { join, parse, resolve } from 'path';
import { reloadSettings } from '@/lib/config';
import { bytes } from '@/lib/bytes';
export async function importDir(directory: string, { id, folder }: { id?: string; folder?: string }) {
export async function importDir(
directory: string,
{ id, folder, skipDb }: { id?: string; folder?: string; skipDb?: boolean },
) {
const fullPath = resolve(directory);
if (!statSync(fullPath).isDirectory()) return console.error('Not a directory:', directory);
await reloadSettings();
const { prisma } = await import('@/lib/db/index.js');
let userId: string;
@@ -62,18 +69,41 @@ export async function importDir(directory: string, { id, folder }: { id?: string
};
}
const res = await prisma.file.createMany({
data,
});
if (!skipDb) {
const { count } = await prisma.file.createMany({
data,
});
console.log(`Inserted ${count} files into the database.`);
}
console.log('Imported', res.count, 'files');
const totalSize = data.reduce((acc, file) => acc + file.size, 0);
let completed = 0;
const { datasource } = await import('@/lib/datasource/index.js');
for (let i = 0; i !== files.length; ++i) {
const buff = await readFile(join(fullPath, files[i]));
console.log(`Uploading ${data[i].name} (${bytes(data[i].size)})...`);
await datasource.put(data[i].name, buff);
console.log('Uploaded', data[i].name);
const start = process.hrtime();
const buff = await readFile(join(fullPath, files[i]));
await datasource.put(data[i].name, buff, {
mimetype: data[i].type ?? 'application/octet-stream',
});
const diff = process.hrtime(start);
const time = diff[0] * 1e9 + diff[1];
const timeStr = time > 1e9 ? `${(time / 1e9).toFixed(2)}s` : `${(time / 1e6).toFixed(2)}ms`;
const uploadSpeed = (data[i].size / time) * 1e9;
const uploadSpeedStr =
uploadSpeed > 1e9 ? `${(uploadSpeed / 1e9).toFixed(2)} GB/s` : `${(uploadSpeed / 1e6).toFixed(2)} MB/s`;
completed += data[i].size;
console.log(
`Uploaded ${data[i].name} in ${timeStr} (${bytes(data[i].size)}) ${i + 1}/${files.length} ${bytes(completed)}/${bytes(totalSize)} ${uploadSpeedStr}`,
);
}
console.log('Done importing files.');

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();
@@ -39,8 +40,17 @@ cli
'-i, --id [user_id]',
'the id that imported files should belong to. if unspecificed the user with the "administrator" username as well as the "SUPERADMIN" role will be used',
)
.option('--skip-db', 'do not add the files to the database')
.option('--skip-ds', 'do not add the files to the datasource')
.option('-f, --folder [folder_id]', 'an optional folder to add the files to')
.argument('<directory>', 'the directory to import into Zipline')
.action(importDir);
cli
.command('export-config')
.summary('export the current configuration as environment variables')
.option('-y, --yml', 'export the configuration in a yml format', false)
.option('-d, --show-defaults', 'ignore default values and only export changed values', false)
.action(exportConfig);
cli.parse();

View File

@@ -7,10 +7,14 @@ import { formatFileName } from '@/lib/uploader/formatFileName';
import { UploadHeaders, UploadOptions } from '@/lib/uploader/parseHeaders';
import { ApiUploadResponse, MultipartFileBuffer } from '@/server/routes/api/upload';
import { FastifyRequest } from 'fastify';
import { writeFile } from 'fs/promises';
import { readdir, rm, writeFile } from 'fs/promises';
import { join } from 'path';
import { Worker } from 'worker_threads';
import { getExtension } from './upload';
import { randomCharacters } from '@/lib/random';
import { bytes } from '@/lib/bytes';
const partialsCache = new Map<string, { length: number; options: UploadOptions }>();
const logger = log('api').c('upload');
export async function handlePartialUpload({
@@ -29,8 +33,34 @@ export async function handlePartialUpload({
if (!options.partial) throw 'No partial upload options provided';
logger.debug('partial upload detected', { partial: options.partial });
if (!options.partial.identifier || !options.partial.range || options.partial.range.length !== 3)
throw 'Invalid partial upload';
if (!options.partial.range || options.partial.range.length !== 3) throw 'Invalid partial upload';
if (options.partial.range[0] === 0) {
const identifier = randomCharacters(8);
partialsCache.set(identifier, { length: file.buffer.length, options });
options.partial.identifier = identifier;
} else {
if (!options.partial.identifier || !partialsCache.has(options.partial.identifier))
throw 'No partial upload identifier provided';
}
const cache = partialsCache.get(options.partial.identifier);
if (!cache) throw 'No partial upload cache found';
const prefix = `zipline_partial_${options.partial.identifier}_`;
if (cache.length + file.buffer.length > bytes(config.files.maxFileSize)) {
partialsCache.delete(options.partial.identifier);
const tempFiles = await readdir(config.core.tempDirectory);
await Promise.all(
tempFiles.filter((f) => f.startsWith(prefix)).map((f) => rm(join(config.core.tempDirectory, f))),
);
throw 'File is too large';
}
cache.length += file.buffer.length;
const extension = getExtension(options.partial.filename, options.overrides?.extension);
@@ -72,7 +102,7 @@ export async function handlePartialUpload({
const tempFile = join(
config.core.tempDirectory,
`zipline_partial_${options.partial.identifier}_${options.partial.range[0]}_${options.partial.range[1]}`,
`${prefix}${options.partial.range[0]}_${options.partial.range[1]}`,
);
await writeFile(tempFile, file.buffer);
@@ -97,7 +127,11 @@ export async function handlePartialUpload({
},
});
new Worker('./build/offload/partial.js', {
const responseUrl = `${domain}${
config.files.route === '/' || config.files.route === '' ? '' : `${config.files.route}`
}/${fileUpload.name}`;
const worker = new Worker('./build/offload/partial.js', {
workerData: {
user: {
id: req.user ? req.user.id : options.folder ? folder?.userId : undefined,
@@ -109,17 +143,52 @@ export async function handlePartialUpload({
},
options,
domain,
responseUrl: `${domain}/${encodeURIComponent(fileUpload.name)}`,
responseUrl,
},
});
worker.on('message', async (msg) => {
if (msg.type === 'query') {
let result;
switch (msg.query) {
case 'incompleteFile.create':
result = await prisma.incompleteFile.create(msg.data);
break;
case 'incompleteFile.update':
result = await prisma.incompleteFile.update(msg.data);
break;
case 'file.update':
result = await prisma.file.update(msg.data);
break;
case 'user.findUnique':
result = await prisma.user.findUnique(msg.data);
break;
default:
console.error(`Unknown query type: ${msg.query}`);
result = null;
}
worker.postMessage({
type: 'response',
id: msg.id,
result: JSON.stringify(result),
});
}
});
response.files.push({
id: fileUpload.id,
type: fileUpload.type,
url: `${domain}/${encodeURIComponent(fileUpload.name)}`,
url: responseUrl,
pending: true,
});
partialsCache.delete(options.partial.identifier);
}
response.partialSuccess = true;
if (options.partial.range[0] === 0) {
response.partialIdentifier = options.partial.identifier;
}
}

View File

@@ -65,14 +65,14 @@ export async function handleFile({
if (options.overrides?.filename || format === 'name') {
if (options.overrides?.filename) fileName = decodeURIComponent(options.overrides!.filename!);
const fullFileName = `${fileName}${extension}`;
const existing = await prisma.file.findFirst({
where: {
name: {
startsWith: fileName,
},
name: fullFileName,
},
});
if (existing) throw `A file with the name "${fileName}*" already exists`;
if (existing) throw `A file with the name "${fullFileName}" already exists`;
}
let mimetype = file.mimetype;
@@ -110,11 +110,17 @@ export async function handleFile({
}
let removedGps = false;
if (mimetype.startsWith('image/') && config.files.removeGpsMetadata) {
removedGps = await removeGps(file.buffer);
if (removedGps) {
logger.c('gps').debug(`removed gps metadata from ${file.filename}`);
if (mimetype.startsWith('image/') && config.files.removeGpsMetadata) {
const removed = removeGps(file.buffer);
if (removed) {
logger.c('gps').debug(`removed gps metadata from ${file.filename}`, {
nsize: bytes(file.buffer.length),
osize: bytes(file.file.bytesRead),
});
removedGps = true;
}
}
@@ -130,14 +136,18 @@ export async function handleFile({
},
...(options.maxViews && { maxViews: options.maxViews }),
...(options.password && { password: await hashPassword(options.password) }),
...(options.deletesAt && { deletesAt: options.deletesAt }),
...(options.deletesAt && options.deletesAt !== 'never'
? { deletesAt: options.deletesAt }
: { deletesAt: null }),
...(options.folder && { Folder: { connect: { id: options.folder } } }),
...(options.addOriginalName && { originalName: file.filename }),
},
select: fileSelect,
});
await datasource.put(fileUpload.name, file.buffer);
await datasource.put(fileUpload.name, file.buffer, {
mimetype: fileUpload.type,
});
const responseUrl = `${domain}${
config.files.route === '/' || config.files.route === '' ? '' : `${config.files.route}`

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,474 +0,0 @@
import msFn, { StringValue } from 'ms';
import { log } from '../logger';
import { bytes } from '../bytes';
import { prisma } from '../db';
import { join } from 'path';
import { tmpdir } from 'os';
type EnvType = 'string' | 'string[]' | 'number' | 'boolean' | 'byte' | 'ms' | 'json[]';
export type ParsedConfig = ReturnType<typeof read>;
export const rawConfig: any = {
core: {
port: undefined,
hostname: undefined,
secret: undefined,
databaseUrl: undefined,
returnHttpsUrls: undefined,
tempDirectory: undefined,
},
chunks: {
max: undefined,
size: undefined,
enabled: undefined,
},
tasks: {
deleteInterval: undefined,
clearInvitesInterval: undefined,
maxViewsInterval: undefined,
thumbnailsInterval: undefined,
metricsInterval: undefined,
},
files: {
route: undefined,
length: undefined,
defaultFormat: undefined,
disabledExtensions: undefined,
maxFileSize: undefined,
defaultExpiration: undefined,
assumeMimetypes: undefined,
defaultDateFormat: undefined,
removeGpsMetadata: undefined,
randomWordsNumAdjectives: undefined,
randomWordsSeperator: undefined,
},
urls: {
route: undefined,
length: undefined,
},
datasource: {
type: undefined,
},
features: {
imageCompression: undefined,
robotsTxt: undefined,
healthcheck: undefined,
invites: undefined,
userRegistration: undefined,
oauthRegistration: undefined,
deleteOnMaxViews: undefined,
thumbnails: {
enabled: undefined,
num_threads: undefined,
},
metrics: {
enabled: undefined,
adminOnly: undefined,
showUserSpecific: undefined,
},
},
invites: {
enabled: undefined,
length: undefined,
},
website: {
title: undefined,
titleLogo: undefined,
externalLinks: undefined,
loginBackground: undefined,
defaultAvatar: undefined,
tos: undefined,
theme: {
default: undefined,
dark: undefined,
light: undefined,
},
},
mfa: {
totp: {
enabled: undefined,
issuer: undefined,
},
passkeys: undefined,
},
oauth: {
bypassLocalLogin: undefined,
loginOnly: undefined,
discord: {
clientId: undefined,
clientSecret: undefined,
},
github: {
clientId: undefined,
clientSecret: undefined,
},
google: {
clientId: undefined,
clientSecret: undefined,
},
oidc: {
clientId: undefined,
clientSecret: undefined,
authorizeUrl: undefined,
userinfoUrl: undefined,
tokenUrl: undefined,
},
},
discord: null,
ratelimit: {
enabled: undefined,
max: undefined,
window: undefined,
adminBypass: undefined,
allowList: undefined,
},
httpWebhook: {
onUpload: undefined,
onShorten: undefined,
},
ssl: {
key: undefined,
cert: undefined,
},
pwa: {
enabled: undefined,
title: undefined,
shortName: undefined,
description: undefined,
backgroundColor: undefined,
themeColor: undefined,
},
};
export const PROP_TO_ENV = {
'core.port': 'CORE_PORT',
'core.hostname': 'CORE_HOSTNAME',
'core.secret': 'CORE_SECRET',
'core.databaseUrl': ['CORE_DATABASE_URL', 'DATABASE_URL'],
'datasource.type': 'DATASOURCE_TYPE',
// only for errors, not used in readenv
'datasource.s3': 'DATASOURCE_S3_*',
'datasource.local': 'DATASOURCE_LOCAL_*',
'datasource.s3.accessKeyId': 'DATASOURCE_S3_ACCESS_KEY_ID',
'datasource.s3.secretAccessKey': 'DATASOURCE_S3_SECRET_ACCESS_KEY',
'datasource.s3.region': 'DATASOURCE_S3_REGION',
'datasource.s3.bucket': 'DATASOURCE_S3_BUCKET',
'datasource.s3.endpoint': 'DATASOURCE_S3_ENDPOINT',
'datasource.s3.forcePathStyle': 'DATASOURCE_S3_FORCE_PATH_STYLE',
'datasource.local.directory': 'DATASOURCE_LOCAL_DIRECTORY',
'ssl.key': 'SSL_KEY',
'ssl.cert': 'SSL_CERT',
};
export const DATABASE_TO_PROP = {
coreReturnHttpsUrls: 'core.returnHttpsUrls',
coreDefaultDomain: 'core.defaultDomain',
coreTempDirectory: 'core.tempDirectory',
chunksMax: 'chunks.max',
chunksSize: 'chunks.size',
chunksEnabled: 'chunks.enabled',
tasksDeleteInterval: 'tasks.deleteInterval',
tasksClearInvitesInterval: 'tasks.clearInvitesInterval',
tasksMaxViewsInterval: 'tasks.maxViewsInterval',
tasksThumbnailsInterval: 'tasks.thumbnailsInterval',
tasksMetricsInterval: 'tasks.metricsInterval',
filesRoute: 'files.route',
filesLength: 'files.length',
filesDefaultFormat: 'files.defaultFormat',
filesDisabledExtensions: 'files.disabledExtensions',
filesMaxFileSize: 'files.maxFileSize',
filesDefaultExpiration: 'files.defaultExpiration',
filesAssumeMimetypes: 'files.assumeMimetypes',
filesDefaultDateFormat: 'files.defaultDateFormat',
filesRemoveGpsMetadata: 'files.removeGpsMetadata',
filesRandomWordsNumAdjectives: 'files.randomWordsNumAdjectives',
filesRandomWordsSeperator: 'files.randomWordsSeperator',
urlsRoute: 'urls.route',
urlsLength: 'urls.length',
featuresImageCompression: 'features.imageCompression',
featuresRobotsTxt: 'features.robotsTxt',
featuresHealthcheck: 'features.healthcheck',
featuresUserRegistration: 'features.userRegistration',
featuresOauthRegistration: 'features.oauthRegistration',
featuresDeleteOnMaxViews: 'features.deleteOnMaxViews',
featuresThumbnailsEnabled: 'features.thumbnails.enabled',
featuresThumbnailsNumberThreads: 'features.thumbnails.num_threads',
featuresMetricsEnabled: 'features.metrics.enabled',
featuresMetricsAdminOnly: 'features.metrics.adminOnly',
featuresMetricsShowUserSpecific: 'features.metrics.showUserSpecific',
invitesEnabled: 'invites.enabled',
invitesLength: 'invites.length',
websiteTitle: 'website.title',
websiteTitleLogo: 'website.titleLogo',
websiteExternalLinks: 'website.externalLinks',
websiteLoginBackground: 'website.loginBackground',
websiteLoginBackgroundBlur: 'website.loginBackgroundBlur',
websiteDefaultAvatar: 'website.defaultAvatar',
websiteTos: 'website.tos',
websiteThemeDefault: 'website.theme.default',
websiteThemeDark: 'website.theme.dark',
websiteThemeLight: 'website.theme.light',
oauthBypassLocalLogin: 'oauth.bypassLocalLogin',
oauthLoginOnly: 'oauth.loginOnly',
oauthDiscordClientId: 'oauth.discord.clientId',
oauthDiscordClientSecret: 'oauth.discord.clientSecret',
oauthDiscordRedirectUri: 'oauth.discord.redirectUri',
oauthGoogleClientId: 'oauth.google.clientId',
oauthGoogleClientSecret: 'oauth.google.clientSecret',
oauthGoogleRedirectUri: 'oauth.google.redirectUri',
oauthGithubClientId: 'oauth.github.clientId',
oauthGithubClientSecret: 'oauth.github.clientSecret',
oauthGithubRedirectUri: 'oauth.github.redirectUri',
oauthOidcClientId: 'oauth.oidc.clientId',
oauthOidcClientSecret: 'oauth.oidc.clientSecret',
oauthOidcAuthorizeUrl: 'oauth.oidc.authorizeUrl',
oauthOidcUserinfoUrl: 'oauth.oidc.userinfoUrl',
oauthOidcTokenUrl: 'oauth.oidc.tokenUrl',
oauthOidcRedirectUri: 'oauth.oidc.redirectUri',
mfaTotpEnabled: 'mfa.totp.enabled',
mfaTotpIssuer: 'mfa.totp.issuer',
mfaPasskeys: 'mfa.passkeys',
ratelimitEnabled: 'ratelimit.enabled',
ratelimitMax: 'ratelimit.max',
ratelimitWindow: 'ratelimit.window',
ratelimitAdminBypass: 'ratelimit.adminBypass',
ratelimitAllowList: 'ratelimit.allowList',
httpWebhookOnUpload: 'httpWebhook.onUpload',
httpWebhookOnShorten: 'httpWebhook.onShorten',
discordWebhookUrl: 'discord.webhookUrl',
discordUsername: 'discord.username',
discordAvatarUrl: 'discord.avatarUrl',
discordOnUploadWebhookUrl: 'discord.onUpload.webhookUrl',
discordOnUploadUsername: 'discord.onUpload.username',
discordOnUploadAvatarUrl: 'discord.onUpload.avatarUrl',
discordOnUploadContent: 'discord.onUpload.content',
discordOnUploadEmbed: 'discord.onUpload.embed',
discordOnShortenWebhookUrl: 'discord.onShorten.webhookUrl',
discordOnShortenUsername: 'discord.onShorten.username',
discordOnShortenAvatarUrl: 'discord.onShorten.avatarUrl',
discordOnShortenContent: 'discord.onShorten.content',
discordOnShortenEmbed: 'discord.onShorten.embed',
pwaEnabled: 'pwa.enabled',
pwaTitle: 'pwa.title',
pwaShortName: 'pwa.shortName',
pwaDescription: 'pwa.description',
pwaThemeColor: 'pwa.themeColor',
pwaBackgroundColor: 'pwa.backgroundColor',
};
const logger = log('config').c('read');
export async function readDatabaseSettings() {
let ziplineTable = await prisma.zipline.findFirst({
omit: {
createdAt: true,
updatedAt: true,
id: true,
firstSetup: true,
},
});
if (!ziplineTable) {
ziplineTable = await prisma.zipline.create({
data: {
coreTempDirectory: join(tmpdir(), 'zipline'),
},
omit: {
createdAt: true,
updatedAt: true,
id: true,
firstSetup: true,
},
});
}
return ziplineTable;
}
export function readEnv() {
const envs = [
env('core.port', 'number'),
env('core.hostname', 'string'),
env('core.secret', 'string'),
env('core.databaseUrl', 'string'),
env('datasource.type', 'string'),
env('datasource.s3.accessKeyId', 'string'),
env('datasource.s3.secretAccessKey', 'string'),
env('datasource.s3.region', 'string'),
env('datasource.s3.bucket', 'string'),
env('datasource.s3.endpoint', 'string'),
env('datasource.s3.forcePathStyle', 'boolean'),
env('datasource.local.directory', 'string'),
env('ssl.key', 'string'),
env('ssl.cert', 'string'),
];
const raw: Record<keyof typeof rawConfig, any> = {};
for (let i = 0; i !== envs.length; ++i) {
const env = envs[i];
if (Array.isArray(env.variable)) {
env.variable = env.variable.find((v) => process.env[v] !== undefined) || 'DATABASE_URL';
}
const value = process.env[env.variable];
if (value === undefined) continue;
if (env.variable === 'DATASOURCE_TYPE') {
if (value === 's3') {
raw['datasource.s3.accessKeyId'] = undefined;
raw['datasource.s3.secretAccessKey'] = undefined;
raw['datasource.s3.region'] = undefined;
raw['datasource.s3.bucket'] = undefined;
} else if (value === 'local') {
raw['datasource.local.directory'] = undefined;
}
}
const parsed = parse(value, env.type);
if (parsed === undefined) continue;
raw[env.property] = parsed;
}
return raw;
}
export async function read() {
const database = await readDatabaseSettings();
const env = readEnv();
const raw = structuredClone(rawConfig);
for (const [key, value] of Object.entries(database as Record<string, any>)) {
if (value === undefined) {
logger.warn('Missing database value', { key });
continue;
}
if (!DATABASE_TO_PROP[key as keyof typeof DATABASE_TO_PROP]) continue;
if (value == undefined) continue;
setProperty(raw, DATABASE_TO_PROP[key as keyof typeof DATABASE_TO_PROP], value);
}
for (const [key, value] of Object.entries(env)) {
if (value === undefined) {
logger.warn('Missing env value', { key });
continue;
}
setProperty(raw, key, value);
}
return raw;
}
function isObject(value: any) {
return typeof value === 'object' && value !== null;
}
function setProperty(obj: any, path: string, value: any) {
if (!isObject(obj)) return obj;
const root = obj;
const dot = path.split('.');
for (let i = 0; i !== dot.length; ++i) {
const key = dot[i];
if (i === dot.length - 1) {
obj[key] = value;
} else if (!isObject(obj[key])) {
obj[key] = typeof dot[i + 1] === 'number' ? [] : {};
}
obj = obj[key];
}
return root;
}
function env(property: keyof typeof PROP_TO_ENV, type: EnvType) {
return {
variable: PROP_TO_ENV[property],
property,
type,
};
}
function parse(value: string, type: EnvType) {
switch (type) {
case 'string':
return value;
case 'string[]':
return value
.split(',')
.filter((s) => s.length !== 0)
.map((s) => s.trim());
case 'number':
return number(value);
case 'boolean':
return boolean(value);
case 'byte':
return bytes(value);
case 'ms':
return msFn(value as StringValue);
case 'json[]':
try {
return JSON.parse(value);
} catch {
logger.error('Failed to parse JSON array', { value });
return undefined;
}
default:
return undefined;
}
}
function number(value: string) {
const num = Number(value);
if (isNaN(num)) return undefined;
return num;
}
function boolean(value: string) {
if (value === 'true') return true;
if (value === 'false') return false;
return undefined;
}

156
src/lib/config/read/db.ts Normal file
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

@@ -3,7 +3,7 @@ import { Config } from './validate';
export type SafeConfig = Omit<
Config,
'oauth' | 'datasource' | 'core' | 'discord' | 'httpWebhook' | 'ratelimit'
'oauth' | 'datasource' | 'core' | 'discord' | 'httpWebhook' | 'ratelimit' | 'ssl'
> & {
oauthEnabled: ReturnType<typeof enabled>;
oauth: {
@@ -14,7 +14,16 @@ export type SafeConfig = Omit<
};
export function safeConfig(config: Config): SafeConfig {
const { datasource: _d, core: _c, oauth, discord: _di, ratelimit: _r, httpWebhook: _h, ...rest } = config;
const {
datasource: _d,
core: _c,
oauth,
discord: _di,
ratelimit: _r,
httpWebhook: _h,
ssl: _s,
...rest
} = config;
(rest as SafeConfig).oauthEnabled = enabled(config);
(rest as SafeConfig).oauth = {

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'),
@@ -112,6 +113,7 @@ export const schema = z.object({
bucket: z.string(),
endpoint: z.string().nullable().default(null),
forcePathStyle: z.boolean().default(false),
subdirectory: z.string().nullable().default(null),
})
.optional(),
local: z
@@ -159,6 +161,8 @@ export const schema = z.object({
adminOnly: z.boolean().default(false),
showUserSpecific: z.boolean().default(true),
}),
versionChecking: z.boolean().default(true),
versionAPI: z.string().url().default('https://zipline-version.diced.sh/'),
}),
invites: z.object({
enabled: z.boolean().default(true),
@@ -218,12 +222,16 @@ export const schema = z.object({
clientId: z.string(),
clientSecret: z.string(),
redirectUri: z.string().url().nullable().default(null),
allowedIds: z.array(z.string()).default([]),
deniedIds: z.array(z.string()).default([]),
})
.or(
z.object({
clientId: z.undefined(),
clientSecret: z.undefined(),
redirectUri: z.undefined(),
allowedIds: z.undefined().or(z.array(z.string()).default([])),
deniedIds: z.undefined().or(z.array(z.string()).default([])),
}),
),
github: z

View File

@@ -4,7 +4,7 @@ export abstract class Datasource {
public name: string | undefined;
public abstract get(file: string): null | Readable | Promise<Readable | null>;
public abstract put(file: string, data: Buffer): Promise<void>;
public abstract put(file: string, data: Buffer, options?: { mimetype?: string }): Promise<void>;
public abstract delete(file: string): Promise<void>;
public abstract size(file: string): Promise<number>;
public abstract totalSize(): Promise<number>;

View File

@@ -1,19 +1,19 @@
import { Readable } from 'stream';
import { Datasource } from './Datasource';
import {
DeleteObjectCommand,
DeleteObjectsCommand,
GetObjectCommand,
ListBucketsCommand,
ListObjectsCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import Logger, { log } from '../logger';
import { ReadableStream } from 'stream/web';
import { NodeHttpHandler } from '@smithy/node-http-handler';
import { Agent as HttpAgent } from 'http';
import { Agent as HttpsAgent } from 'https';
import { Readable } from 'stream';
import { ReadableStream } from 'stream/web';
import Logger, { log } from '../logger';
import { randomCharacters } from '../random';
import { Datasource } from './Datasource';
function isOk(code: number) {
return code >= 200 && code < 300;
@@ -32,6 +32,7 @@ export class S3Datasource extends Datasource {
bucket: string;
endpoint?: string | null;
forcePathStyle?: boolean;
subdirectory?: string | null;
},
) {
super();
@@ -58,37 +59,83 @@ export class S3Datasource extends Datasource {
}),
});
this.ensureBucketExists();
this.ensureReadWriteAccess();
}
private async ensureBucketExists() {
private key(path: string): string {
if (this.options.subdirectory) {
return this.options.subdirectory.endsWith('/')
? this.options.subdirectory + path
: this.options.subdirectory + '/' + path;
}
return path;
}
private async ensureReadWriteAccess() {
try {
const res = await this.client.send(new ListBucketsCommand());
if (!isOk(res.$metadata.httpStatusCode || 0)) {
const putObject = new PutObjectCommand({
Bucket: this.options.bucket,
Key: this.key(`${randomCharacters(10)}-zipline`),
Body: randomCharacters(10),
});
const readObject = new GetObjectCommand({
Bucket: this.options.bucket,
Key: putObject.input.Key,
});
const deleteObject = new DeleteObjectCommand({
Bucket: this.options.bucket,
Key: putObject.input.Key,
});
const writeRes = await this.client.send(putObject);
if (!isOk(writeRes.$metadata.httpStatusCode || 0)) {
this.logger
.error('there was an error while listing buckets', res.$metadata as Record<string, unknown>)
.error(
'there was an error while testing write access',
writeRes.$metadata as Record<string, unknown>,
)
.error('zipline will now exit');
process.exit(1);
}
if (!res.Buckets?.find((bucket) => bucket.Name === this.options.bucket)) {
this.logger.error(`bucket ${this.options.bucket} does not exist`).error('zipline will now exit');
const readRes = await this.client.send(readObject);
if (!isOk(readRes.$metadata.httpStatusCode || 0)) {
this.logger
.error('there was an error while testing read access', readRes.$metadata as Record<string, unknown>)
.error('zipline will now exit');
process.exit(1);
}
const deleteRes = await this.client.send(deleteObject);
if (!isOk(deleteRes.$metadata.httpStatusCode || 0)) {
this.logger
.error(
'there was an error while testing write access',
deleteRes.$metadata as Record<string, unknown>,
)
.error('zipline will now exit');
process.exit(1);
}
this.logger.debug('access test successful');
} catch (e) {
console.error(e);
this.logger
.error('there was an error while listing buckets', e as Record<string, unknown>)
.error('there was an error while testing access', e as Record<string, unknown>)
.error('zipline will now exit');
process.exit(1);
} finally {
this.logger.debug(`bucket ${this.options.bucket} exists`);
this.logger.debug(`able to read/write bucket ${this.options.bucket}`);
}
}
public async get(file: string): Promise<Readable | null> {
const command = new GetObjectCommand({
Bucket: this.options.bucket,
Key: file,
Key: this.key(file),
});
try {
@@ -111,11 +158,18 @@ export class S3Datasource extends Datasource {
}
}
public async put(file: string, data: Buffer): Promise<void> {
public async put(
file: string,
data: Buffer,
options: {
mimetype?: string;
} = {},
): Promise<void> {
const command = new PutObjectCommand({
Bucket: this.options.bucket,
Key: file,
Key: this.key(file),
Body: data,
...(options.mimetype ? { ContentType: options.mimetype } : {}),
});
try {
@@ -135,7 +189,7 @@ export class S3Datasource extends Datasource {
public async delete(file: string): Promise<void> {
const command = new DeleteObjectCommand({
Bucket: this.options.bucket,
Key: file,
Key: this.key(file),
});
try {
@@ -154,7 +208,7 @@ export class S3Datasource extends Datasource {
public async size(file: string): Promise<number> {
const command = new GetObjectCommand({
Bucket: this.options.bucket,
Key: file,
Key: this.key(file),
});
try {
@@ -179,6 +233,8 @@ export class S3Datasource extends Datasource {
public async totalSize(): Promise<number> {
const command = new ListObjectsCommand({
Bucket: this.options.bucket,
Prefix: this.options.subdirectory ?? undefined,
Delimiter: this.options.subdirectory ? undefined : '/',
});
try {
@@ -224,7 +280,7 @@ export class S3Datasource extends Datasource {
public async range(file: string, start: number, end: number): Promise<Readable> {
const command = new GetObjectCommand({
Bucket: this.options.bucket,
Key: file,
Key: this.key(file),
Range: `bytes=${start}-${end}`,
});

View File

@@ -7,7 +7,6 @@ import { S3Datasource } from './S3';
let datasource: Datasource;
declare global {
// eslint-disable-next-line no-var
var __datasource__: Datasource;
}
@@ -28,6 +27,7 @@ function getDatasource(conf?: typeof config): void {
bucket: config.datasource.s3!.bucket,
endpoint: config.datasource.s3?.endpoint,
forcePathStyle: config.datasource.s3?.forcePathStyle,
subdirectory: config.datasource.s3?.subdirectory,
});
break;
default:

View File

@@ -1,5 +1,5 @@
import { log } from '@/lib/logger';
import { Prisma, PrismaClient } from '@prisma/client';
import { Prisma, PrismaClient } from '../../../generated/client';
import { userViewSchema } from './models/user';
import { metricDataSchema } from './models/metric';
import { metadataSchema } from './models/incompleteFile';
@@ -9,7 +9,6 @@ const building = !!process.env.ZIPLINE_BUILD;
let prisma: ExtendedPrismaClient;
declare global {
// eslint-disable-next-line no-var
var __db__: ExtendedPrismaClient;
}

6
src/lib/db/migration/index.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
import { SchemaContext } from '@prisma/internals';
// @ts-ignore
declare module '@prisma/migrate/dist/utils/ensureDatabaseExists' {
export function ensureDatabaseExists(schemaContext: SchemaContext): Promise<boolean>;
}

View File

@@ -1,15 +1,28 @@
import { Migrate } from '@prisma/migrate/dist/Migrate';
import { ensureDatabaseExists } from '@prisma/migrate/dist/utils/ensureDatabaseExists';
import { Migrate } from '@prisma/migrate';
import { log } from '@/lib/logger';
import { loadSchemaContext } from '@prisma/internals';
// @ts-ignore
import { ensureDatabaseExists } from '@prisma/migrate/dist/utils/ensureDatabaseExists';
export async function runMigrations() {
const migrate = new Migrate('./prisma/schema.prisma');
const schemaContext = await loadSchemaContext({
schemaPathFromArg: './prisma/schema.prisma',
printLoadMessage: false,
});
const migrate = await Migrate.setup({
schemaContext,
migrationsDirPath: './prisma/migrations',
});
const logger = log('migrations');
logger.debug('running migrations...');
try {
logger.debug('ensuring database exists...');
const dbCreated = await ensureDatabaseExists('apply', './prisma/schema.prisma');
const dbCreated = await ensureDatabaseExists(schemaContext.primaryDatasource);
if (dbCreated) {
logger.info('database created');
}

View File

@@ -1,13 +0,0 @@
// types for @prisma/migrate so vscode cant complain
declare module '@prisma/migrate/dist/Migrate' {
export class Migrate {
constructor(schemaPath: string);
public applyMigrations(): Promise<{ appliedMigrationNames: string[] }>;
public stop(): void;
}
}
declare module '@prisma/migrate/dist/utils/ensureDatabaseExists' {
export function ensureDatabaseExists(command: string, schemaPath: string): Promise<boolean>;
}

View File

@@ -1,4 +1,4 @@
import type { Folder as PrismaFolder } from '@prisma/client';
import type { Folder as PrismaFolder } from '../../../../generated/client';
import { File, cleanFiles } from './file';
export type Folder = PrismaFolder & {

View File

@@ -1,4 +1,4 @@
import { IncompleteFileStatus } from '@prisma/client';
import { IncompleteFileStatus } from '../../../../generated/client';
import { z } from 'zod';
export type IncompleteFile = {

View File

@@ -1,4 +1,4 @@
import type { Invite as PrismaInvite } from '@prisma/client';
import type { Invite as PrismaInvite } from '../../../../generated/client';
import type { User } from './user';
export type Invite = PrismaInvite & {

View File

@@ -1,4 +1,4 @@
import type { Url as PrismaUrl } from '@prisma/client';
import type { Url as PrismaUrl } from '../../../../generated/client';
export type Url = PrismaUrl & {
similarity?: number;

View File

@@ -1,4 +1,4 @@
import { OAuthProvider, UserPasskey, UserQuota } from '@prisma/client';
import { OAuthProvider, UserPasskey, UserQuota } from '../../../../generated/client';
import { z } from 'zod';
export type User = {
@@ -43,6 +43,8 @@ export const userViewSchema = z
enabled: z.boolean().nullish(),
align: z.enum(['left', 'center', 'right']).nullish(),
showMimetype: z.boolean().nullish(),
showTags: z.boolean().nullish(),
showFolder: z.boolean().nullish(),
content: z.string().nullish(),
embed: z.boolean().nullish(),
embedTitle: z.string().nullish(),

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;

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

@@ -0,0 +1,131 @@
// heavily modified from @xoi/gps-metadata-remover to fit the needs of zipline
import {
PNG_TAG,
PNG_IEND,
EXIF_PNG_TAG,
JPEG_EXIF_TAG,
JPEG_JFIF_TAG,
JPEG_APP1_TAG,
EXIF_JPEG_TAG,
TIFF_LE,
TIFF_BE,
GPS_IFD_TAG,
} from './constants';
function isLE(buffer: Buffer): boolean {
return buffer.readUInt32BE(0) === TIFF_LE;
}
function removeGpsEntries(buffer: Buffer, offset: number, le: boolean): void {
const numEntries = le ? buffer.readUInt16LE(offset) : buffer.readUInt16BE(offset);
const fieldsStart = offset + 2;
const toClear = numEntries * 12;
const zeroBuffer = Buffer.alloc(toClear);
zeroBuffer.copy(buffer, fieldsStart);
}
function parseExifTag(buffer: Buffer, offset: number, le: boolean) {
const tag = le ? buffer.readUInt16LE(offset) : buffer.readUInt16BE(offset);
const field = le ? buffer.readUInt16LE(offset + 2) : buffer.readUInt16BE(offset + 2);
const count = le ? buffer.readUInt32LE(offset + 4) : buffer.readUInt32BE(offset + 4);
const valueOffset = le ? buffer.readUInt32LE(offset + 8) : buffer.readUInt32BE(offset + 8);
return { tag, field, count, valueOffset };
}
function locateGpsTagOffset(buffer: Buffer, offset: number, le: boolean): number {
const numEntries = le ? buffer.readUInt16LE(offset) : buffer.readUInt16BE(offset);
const fieldsStart = offset + 2;
for (let i = 0; i < numEntries; i++) {
const entryOffset = fieldsStart + i * 12;
const { tag, field, count, valueOffset } = parseExifTag(buffer, entryOffset, le);
if (tag === GPS_IFD_TAG && field === 4 && count === 1) {
return valueOffset;
}
}
return -1;
}
function stripGpsFromTiff(buffer: Buffer, offset: number, le: boolean): boolean {
const gpsDirectoryOffset = locateGpsTagOffset(buffer, offset, le);
if (gpsDirectoryOffset >= 0) {
removeGpsEntries(buffer, gpsDirectoryOffset, le);
return true;
}
return false;
}
function stripGpsFromExif(buffer: Buffer, offset: number): boolean {
const headerSlice = buffer.subarray(offset, offset + 8);
const littleEndian = isLE(headerSlice);
const gpsDirectoryOffset = locateGpsTagOffset(buffer, offset + 8, littleEndian);
if (gpsDirectoryOffset >= 0) {
removeGpsEntries(buffer, gpsDirectoryOffset + offset, littleEndian);
return true;
}
return false;
}
export function removeGps(buffer: Buffer): boolean {
const signature = buffer.readUInt32BE(0);
let offset = 0;
let removed = false;
if (signature === PNG_TAG) {
offset += 8;
let chunkLength = 0;
let chunkType = 0;
while (chunkType !== PNG_IEND) {
chunkLength = buffer.readUInt32BE(offset);
chunkType = buffer.readUInt32BE(offset + 4);
if (chunkType === EXIF_PNG_TAG) {
const exifDataOffset = offset + 8;
removed = stripGpsFromExif(buffer, exifDataOffset);
}
if (chunkType !== PNG_IEND) {
offset += 12 + chunkLength;
}
}
} else if (signature === JPEG_EXIF_TAG || signature === JPEG_JFIF_TAG) {
offset += 4;
if (signature === JPEG_JFIF_TAG) {
const jfifSegmentSize = buffer.readUInt16BE(offset);
offset += jfifSegmentSize;
const nextMarker = buffer.readUInt16BE(offset);
if (nextMarker === JPEG_APP1_TAG) {
offset += 2;
} else {
return removed;
}
}
const exifSignature = buffer.readUInt32BE(offset + 2);
if (exifSignature === EXIF_JPEG_TAG) {
offset += 8;
removed = stripGpsFromExif(buffer, offset);
}
} else if (signature === TIFF_LE || signature === TIFF_BE) {
const littleEndian = isLE(buffer);
offset += 4;
const tiffIfdOffset = littleEndian ? buffer.readUInt32LE(offset) : buffer.readUInt32BE(offset);
removed = stripGpsFromTiff(buffer, tiffIfdOffset, littleEndian);
}
return removed;
}

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