Compare commits

...

88 Commits

Author SHA1 Message Date
diced ec21dbce36 fix: build stage order 2025-08-14 12:30:06 -07:00
diced 152876657d fix: regex when adding domains 2025-08-13 16:30:14 -07:00
diced 01b5fc8930 fix: linting error 2025-08-13 00:30:39 -07:00
diced ca149f920b feat: small ui improvements 2025-08-12 22:15:04 -07:00
diced 9c473ae30d feat: small ui fixes and improvements 2025-08-12 22:14:43 -07:00
dicedtomato 4377c396d6 Merge branch 'trunk' into no-next 2025-08-09 22:56:45 -07:00
diced 71dbbb584a feat(v4.2.3): version 2025-08-09 22:46:30 -07:00
Snipcola f03bd74865 fix: wrong env vars (#858)
* capitalize `random words separator` environment variable

* change `RATELIMIT_WINDOW` environment variable type to number
2025-08-09 22:42:01 -07:00
dicedtomato 17a6abb882 Merge branch 'trunk' into no-next 2025-08-08 22:49:12 -07:00
diced f059dcca35 fix: once and for all fix #854 2025-08-08 22:47:51 -07:00
diced eccbdd9792 fix: removed unused vars 2025-08-08 15:14:51 -07:00
dicedtomato 74c8208b6a Merge branch 'trunk' into no-next 2025-08-08 15:11:53 -07:00
diced 8cfd3a1c4b feat: dynamic import optimizations + titled pages 2025-08-08 15:11:33 -07:00
diced 531ba13daf fix: no longer use rename since it's weird 2025-08-08 15:02:38 -07:00
dicedtomato 42e0438b00 Merge branch 'trunk' into no-next 2025-08-08 11:13:00 -07:00
diced 03fa98bed6 fix: remove node@20 from actions 2025-08-07 20:03:54 -07:00
diced cd8b892a90 feat(v4.2.2): version 2025-08-07 19:48:52 -07:00
diced 7997143024 fix: generate prisma before types 2025-08-07 19:47:39 -07:00
dicedtomato 2f211722d8 Merge branch 'trunk' into no-next 2025-08-07 19:41:36 -07:00
diced 3575981984 fix: exdev error workaround #856 2025-08-07 19:31:56 -07:00
dicedtomato 81c880b1ca Merge commit from fork 2025-08-07 19:29:28 -07:00
diced 09e445b9ae fix: docker + exdev error 2025-08-07 15:26:47 -07:00
diced 2e70cf0add fix: ignores + cache action 2025-08-07 14:55:09 -07:00
diced 896e44a10e fix: caching on pnpm action 2025-08-07 13:03:45 -07:00
diced bc52bc887c fix: build process 2025-08-07 13:01:25 -07:00
diced af3f23a241 feat: all functionality added + client/ -> src/client/ 2025-08-06 21:52:24 -07:00
diced e5a01c5034 feat: working ssr + dev + prod env 2025-08-06 13:20:10 -07:00
diced 35a56a72c4 feat: start removing next.js 2025-08-05 01:00:33 -07:00
diced 9b8e57bda0 fix: do not add new sessions on session save (#855) 2025-08-04 11:44:06 -07:00
diced 4a8f90a901 fix: #855 session override bug 2025-08-03 16:24:00 -07:00
diced 6acdc72776 fix: multiple db connections on offloaded threads 2025-08-02 16:53:53 -07:00
diced f78c873aae fix: revert zod 2025-08-02 16:52:14 -07:00
diced 0f82bf8d90 fix: formatting errors 2025-08-02 16:52:03 -07:00
diced 82a7f1d0bf feat(prisma): use non-rust engines 2025-08-02 16:36:08 -07:00
diced 2fd1007e4b chore: lint + upgrade packages 2025-08-02 15:40:09 -07:00
diced c360235fa8 fix: better thumbnail logic 2025-08-02 15:29:27 -07:00
diced a4404f1ae8 fix: refactor routes to be separated 2025-08-02 11:25:16 -07:00
diced 56d1492377 feat: ability to rename files 2025-08-01 16:43:20 -07:00
diced fa9bf185d5 fix: improve logic in uploading + partial 2025-08-01 12:31:07 -07:00
diced eca6a0c5fd feat(unstable): implement new uploading logic 2025-07-31 23:23:31 -07:00
diced f58ed2f368 fix: add minio to flake 2025-07-31 23:22:06 -07:00
diced 64c39dab76 fix: update nix flake to use devenv 2025-07-31 20:22:10 -07:00
diced ac08f4f797 feat(v4.2.1): version 2025-07-28 12:21:26 -07:00
diced 91a2c05d3b feat: nix dev shell 2025-07-27 12:34:25 -07:00
diced 3ccc108d43 fix: search by id color 2025-07-19 14:32:34 -07:00
diced aaaf0cf5aa fix: prolly fix #843 2025-07-19 14:27:40 -07:00
diced db7cf70bca fix: favorite transactional 2025-07-11 11:47:58 -07:00
diced 8b59e1dc53 fix: properly handle custom components 2025-07-08 19:34:59 -07:00
diced da066db07e fix: discord oauth #833 2025-07-04 14:19:46 -07:00
diced b566d13c8d fix: random visual bugs + enhancements 2025-07-02 20:41:37 -07:00
diced 6a76c5243f fix: typo separator 2025-07-02 14:12:35 -07:00
curet 38a90787d0 feat: predefined domains (#822)
* feat(domains): add domains to server settings

* fix(domains): fix linting errors

* fix(domains): remove unused imports

* fix(urls): fix typo

* feat(domains): remove expiration date from domains

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

* fix(domains): linter errors

---------

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

* chore: update file permissions

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2025-07-01 11:38:47 -07:00
curet 5c0830c6da fix: long code blocks (#823) (#810) 2025-07-01 10:58:25 -07:00
diced ef33fcbe1d fix: lint error 2025-06-11 20:23:25 -07:00
diced 4b1ca07510 feat: better cache for versions 2025-06-11 20:21:52 -07:00
diced 438b9b5a67 feat: show alert when there are overridden settings 2025-06-08 12:02:51 -07:00
diced ed1273efba feat: convert db settings to env vars cli 2025-06-08 11:52:32 -07:00
diced e8518f92c7 fix: remove 2025-06-07 11:36:51 -07:00
diced fbf9e10e56 feat: allow/denylist discord oauth 2025-06-07 11:36:23 -07:00
diced a1ee1178ae feat: allow env vars that override database set settings 2025-06-07 11:17:43 -07:00
diced e5eaaca5a0 feat: discord oauth whitelist 2025-06-06 20:33:41 -07:00
diced 6e9dea989e fix: use cmd icon on mac 2025-06-06 15:15:11 -07:00
diced 5bc9b6ef0a feat: add download button to file table view 2025-06-06 15:10:13 -07:00
diced 6362d06253 feat: new gps remover 2025-06-06 15:06:21 -07:00
diced 81866b4b50 feat(v4.1.2): version hotfix 2025-06-06 10:40:57 -07:00
diced 4b3878d553 feat: switch metadata remover 2025-06-06 10:40:38 -07:00
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
269 changed files with 10281 additions and 8875 deletions
+1 -2
View File
@@ -1,8 +1,7 @@
.github
.next
build
node_modules
uploads*
.env
.eslintcache
generated
src/prisma
+1
View File
@@ -0,0 +1 @@
use flake . --no-pure-eval
+4 -5
View File
@@ -17,10 +17,8 @@ body:
label: Version
description: What version (or docker image) of Zipline are you using?
options:
- Latest v4 release (ghcr.io/diced/zipline or ghcr.io/diced/zipline:latest)
- Latest v4 commit (ghcr.io/diced/zipline:trunk)
- Latest v3 release (ghcr.io/diced/zipline:v3)
- Latest v3 commit (ghcr.io/diced/zipline:v3-trunk)
- Latest release (ghcr.io/diced/zipline or ghcr.io/diced/zipline:latest)
- Latest commit (ghcr.io/diced/zipline:trunk)
- other (provide version in additional info)
validations:
required: true
@@ -33,13 +31,14 @@ body:
- Firefox
- Chromium-based (Chrome, Edge, Brave, Opera, mobile chrome/chromium based, etc)
- Safari
- Chromium-based Mobile (Chrome, Edge, Brave, Android WebView, etc)
- Firefox Mobile
- Safari Mobile
- type: textarea
id: zipline-logs
attributes:
label: Zipline Logs
description: Please copy and paste any relevant log output. Not seeing anything interesting? Try adding the `DEBUG=zipline` (v4) or `DEBUG=true` (v3) environment variable to see more logs, make sure to review the output and remove any sensitive information as it can be VERY verbose at times.
description: Please copy and paste any relevant log output. Not seeing anything interesting? Try adding the `DEBUG=zipline` (v4) environment variable to see more logs, make sure to review the output and remove any sensitive information as it can be VERY verbose at times.
render: shell
- type: textarea
id: browser-logs
+13 -6
View File
@@ -11,7 +11,7 @@ jobs:
build:
strategy:
matrix:
node: [20.x, 22.x, 24.x]
node: [22.x, 24.x]
arch: [amd64, arm64]
runs-on: ubuntu-24.04${{ matrix.arch == 'arm64' && '-arm' || '' }}
@@ -37,16 +37,23 @@ jobs:
with:
path: |
${{ steps.pnpm-cache.outputs.store_path }}
${{ github.workspace }}/.next/cache
key: ${{ runner.os }}-pnpm-next-store-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
key: ${{ runner.os }}-${{ matrix.arch }}-${{ matrix.node }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
restore-keys: |
${{ runner.os }}-pnpm-next-store-${{ hashFiles('**/package-lock.json') }}-
${{ runner.os }}-${{ matrix.arch }}-${{ matrix.node }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}-
- name: Install
run: pnpm install
- name: Lint
run: pnpm lint
- name: Generate Prisma
run: pnpm build:prisma
- name: Type Check
run: pnpm build:types
- name: Build
env:
ZIPLINE_BUILD: 'true'
NEXT_TELEMETRY_DISABLED: '1'
run: pnpm build
run: pnpm build:skip
+9 -7
View File
@@ -13,16 +13,14 @@
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
build/
# misc
.DS_Store
*.pem
.idea
.vscode
# debug
npm-debug.log*
@@ -37,13 +35,17 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# eslint
.eslintcache
# nix dev env
!.envrc
.direnv
.devenv
# zipline
uploads*/
*.crt
*.key
generated
src/prisma
+1
View File
@@ -0,0 +1 @@
pnpm-lock.yaml
+8 -6
View File
@@ -20,14 +20,19 @@ FROM base AS builder
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
COPY src ./src
COPY next.config.js ./next.config.js
COPY .gitignore ./.gitignore
COPY postcss.config.cjs ./postcss.config.cjs
COPY prettier.config.cjs ./prettier.config.cjs
COPY eslint.config.mjs ./eslint.config.mjs
COPY vite.config.ts ./vite.config.ts
COPY tsup.config.ts ./tsup.config.ts
COPY tsconfig.json ./tsconfig.json
COPY mimes.json ./mimes.json
COPY code.json ./code.json
COPY vite-env.d.ts ./vite-env.d.ts
ENV NEXT_TELEMETRY_DISABLED=1 \
NODE_ENV=production
ENV NODE_ENV=production
RUN ZIPLINE_BUILD=true pnpm run build
@@ -36,12 +41,9 @@ FROM base
COPY --from=deps /zipline/node_modules ./node_modules
COPY --from=builder /zipline/build ./build
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
+33 -1
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,32 @@ Create a pull request on GitHub. If your PR does not pass the action checks, the
Here's how to setup Zipline for development
#### Nix
If you have [direnv](https://direnv.net/) installed, you can simply cd into the cloned directory and run the following command:
```bash
direnv allow
```
Granted that you have direnv setup properly, you will now be in a new nix shell with all the dependencies and PostgreSQL installed.
If you aren't using direnv, you can run the following command to enter the nix shell:
```bash
nix develop --no-pure-eval
```
Useful commands regarding the postgres server:
| Command | Description |
| --------------- | --------------------------------------------------- |
| `pgup` | Starts the postgres server in the background. |
| `pgdown` | Stops the postgres server running in the background |
| `pg_ctl status` | See if the postgres server is running |
After familiarizing yourself with the environment, you can continue below (skipping the prerequisites since they are already installed).
#### Prerequisites
- nodejs (lts -> 20.x, 22.x)
+5 -5
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
+7 -1
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:
+57 -29
View File
@@ -1,44 +1,68 @@
// 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 reactHooksPlugin from 'eslint-plugin-react-hooks';
import reactPlugin from 'eslint-plugin-react';
import reactRefreshPlugin from 'eslint-plugin-react-refresh';
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 },
export default [
includeIgnoreFile(gitignorePath),
...compat.extends(
'next/core-web-vitals',
'plugin:prettier/recommended',
'plugin:@typescript-eslint/recommended',
),
{
extends: [
tseslint.configs.recommended,
reactHooksPlugin.configs['recommended-latest'],
reactRefreshPlugin.configs.vite,
],
},
{
files: ['**/*.{js,mjs,cjs,ts,tsx}'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
plugins: {
'unused-imports': unusedImports,
'@typescript-eslint': typescriptEslint,
prettier: prettier,
react: reactPlugin,
'jsx-a11y': jsxA11yPlugin,
},
languageOptions: {
parser: tsParser,
},
rules: {
'linebreak-style': ['error', 'unix'],
...reactPlugin.configs.recommended.rules,
...prettierConfig.rules,
'prettier/prettier': [
'error',
{},
{
fileInfoOptions: {
withNodeModules: false,
},
},
],
'linebreak-style': ['error', 'unix'],
quotes: [
'error',
'single',
@@ -46,13 +70,13 @@ export default [
avoidEscape: true,
},
],
semi: ['error', 'always'],
'jsx-quotes': ['error', 'prefer-single'],
indent: 'off',
'react/prop-types': 'off',
'react-hooks/rules-of-hooks': 'off',
'react-hooks/exhaustive-deps': 'off',
'react-refresh/only-export-components': 'off',
'react/jsx-uses-react': 'warn',
'react/jsx-uses-vars': 'warn',
'react/no-danger-with-children': 'warn',
@@ -77,10 +101,14 @@ 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',
},
},
},
];
);
Generated
+254
View File
@@ -0,0 +1,254 @@
{
"nodes": {
"cachix": {
"inputs": {
"devenv": [
"devenv"
],
"flake-compat": [
"devenv"
],
"git-hooks": [
"devenv",
"git-hooks"
],
"nixpkgs": [
"devenv",
"nixpkgs"
]
},
"locked": {
"lastModified": 1748883665,
"narHash": "sha256-R0W7uAg+BLoHjMRMQ8+oiSbTq8nkGz5RDpQ+ZfxxP3A=",
"owner": "cachix",
"repo": "cachix",
"rev": "f707778d902af4d62d8dd92c269f8e70de09acbe",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "latest",
"repo": "cachix",
"type": "github"
}
},
"devenv": {
"inputs": {
"cachix": "cachix",
"flake-compat": "flake-compat",
"git-hooks": "git-hooks",
"nix": "nix",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1753888869,
"narHash": "sha256-VRYrrUmvXnBzfzuJVoI3os1H/0l8cJQ2KnrrxWkTB3E=",
"owner": "cachix",
"repo": "devenv",
"rev": "bdf26a4453eff6bae835f33d519a36f77e0ca257",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "devenv",
"type": "github"
}
},
"devenv-root": {
"flake": false,
"locked": {
"narHash": "sha256-d6xi4mKdjkX2JFicDIv5niSzpyI0m/Hnm8GGAIU04kY=",
"type": "file",
"url": "file:///dev/null"
},
"original": {
"type": "file",
"url": "file:///dev/null"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1747046372,
"narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
"devenv",
"nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1733312601,
"narHash": "sha256-4pDvzqnegAfRkPwO3wmwBhVi/Sye1mzps0zHWYnP88c=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "205b12d8b7cd4802fbcb8e8ef6a0f1408781a4f9",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"flake-parts_2": {
"inputs": {
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1753121425,
"narHash": "sha256-TVcTNvOeWWk1DXljFxVRp+E0tzG1LhrVjOGGoMHuXio=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "644e0fc48951a860279da645ba77fe4a6e814c5e",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"git-hooks": {
"inputs": {
"flake-compat": [
"devenv",
"flake-compat"
],
"gitignore": "gitignore",
"nixpkgs": [
"devenv",
"nixpkgs"
]
},
"locked": {
"lastModified": 1750779888,
"narHash": "sha256-wibppH3g/E2lxU43ZQHC5yA/7kIKLGxVEnsnVK1BtRg=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "16ec914f6fb6f599ce988427d9d94efddf25fe6d",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"devenv",
"git-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"nix": {
"inputs": {
"flake-compat": [
"devenv",
"flake-compat"
],
"flake-parts": "flake-parts",
"git-hooks-nix": [
"devenv",
"git-hooks"
],
"nixpkgs": [
"devenv",
"nixpkgs"
],
"nixpkgs-23-11": [
"devenv"
],
"nixpkgs-regression": [
"devenv"
]
},
"locked": {
"lastModified": 1752773918,
"narHash": "sha256-dOi/M6yNeuJlj88exI+7k154z+hAhFcuB8tZktiW7rg=",
"owner": "cachix",
"repo": "nix",
"rev": "031c3cf42d2e9391eee373507d8c12e0f9606779",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "devenv-2.30",
"repo": "nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1752827260,
"narHash": "sha256-noFjJbm/uWRcd2Lotr7ovedfhKVZT+LeJs9rU416lKQ=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "b527e89270879aaaf584c41f26b2796be634bc9d",
"type": "github"
},
"original": {
"owner": "nixos",
"repo": "nixpkgs",
"rev": "b527e89270879aaaf584c41f26b2796be634bc9d",
"type": "github"
}
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1751159883,
"narHash": "sha256-urW/Ylk9FIfvXfliA1ywh75yszAbiTEVgpPeinFyVZo=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "14a40a1d7fb9afa4739275ac642ed7301a9ba1ab",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nixpkgs.lib",
"type": "github"
}
},
"root": {
"inputs": {
"devenv": "devenv",
"devenv-root": "devenv-root",
"flake-parts": "flake-parts_2",
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+128
View File
@@ -0,0 +1,128 @@
{
inputs = {
# required for some reason when entering the shell for devenv
devenv-root = {
url = "file+file:///dev/null";
flake = false;
};
# node 24.4.1, postgres 17
nixpkgs.url = "github:nixos/nixpkgs/b527e89270879aaaf584c41f26b2796be634bc9d";
flake-parts.url = "github:hercules-ci/flake-parts";
devenv.url = "github:cachix/devenv";
devenv.inputs.nixpkgs.follows = "nixpkgs";
};
nixConfig = {
extra-trusted-public-keys = "devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw=";
extra-substituters = "https://devenv.cachix.org";
};
outputs =
inputs@{ flake-parts, devenv-root, ... }:
flake-parts.lib.mkFlake { inherit inputs; } {
imports = [
inputs.devenv.flakeModule
];
systems = [
"x86_64-linux"
"x86_64-darwin"
"aarch64-linux"
"aarch64-darwin"
];
perSystem =
{
config,
self',
inputs',
pkgs,
system,
...
}:
let
psqlConfig = {
username = "postgres";
password = "postgres";
database = "zipline";
};
in
{
devenv.shells.default = {
packages = with pkgs; [
git
# to generate thumbnails
ffmpeg
# for testing docker
colima
docker
docker-compose
];
scripts = {
pgup.exec = ''
process-compose up postgres -D
'';
minioup.exec = ''
process-compose up minio -D
'';
downall.exec = ''
process-compose down
'';
# ensure that volumes are mounted with write access for docker containers
start_colima.exec = ''
colima start --mount $PWD/themes:w --mount $PWD/uploads:w --mount $PWD/public:w
'';
};
enterShell = ''
export name="zipline-env";
echo -e "\n[$name]: run 'pgup' to start services, 'pgdown' to stop services";
'';
languages.javascript = {
enable = true;
package = pkgs.nodejs_24;
corepack.enable = true;
};
services = {
postgres = {
enable = true;
package = pkgs.postgresql_17;
initialScript = ''
CREATE ROLE "${psqlConfig.username}" WITH LOGIN PASSWORD '${psqlConfig.password}' SUPERUSER;
'';
initialDatabases = [
{
name = psqlConfig.database;
user = psqlConfig.username;
}
];
listen_addresses = "0.0.0.0";
port = 5432;
};
minio = {
enable = true;
};
};
process.managers.process-compose = {
tui.enable = false;
};
};
};
};
}
-24
View File
@@ -1,24 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
rewrites: async () => [
{
source: '/invite/:code',
destination: '/auth/register?code=:code',
},
],
redirects: async () => [
{
source: '/r/:id',
destination: '/raw/:id',
permanent: true,
},
],
webpack: (config) => {
config.resolve.fallback = { worker_threads: false };
return config;
},
};
module.exports = nextConfig;
+71 -66
View File
@@ -2,16 +2,21 @@
"name": "zipline",
"private": true,
"license": "MIT",
"version": "4.1.0",
"version": "4.2.3",
"scripts": {
"build": "cross-env pnpm run --stream \"/^build:.*/\"",
"lint": "eslint .",
"build:skip": "pnpm run --stream build:prisma && pnpm run --stream build:server && pnpm run --stream build:client",
"build": "pnpm run --stream lint && pnpm run --stream build:prisma && pnpm run --stream build:types && pnpm run --stream build:server && pnpm run --stream build:client",
"build:types": "tsc",
"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",
"build:client": "vite build && pnpm run --stream \"/^build-ssr:.*/\"",
"build-ssr:view": "vite build --ssr ssr-view/server.tsx -m ssr-view --outDir ../../build/ssr --emptyOutDir=false",
"build-ssr:view-url": "vite build --ssr ssr-view-url/server.tsx -m ssr-view-url --outDir ../../build/ssr --emptyOutDir=false",
"dev": "cross-env NODE_ENV=development DEBUG=zipline tsx --require dotenv/config --enable-source-maps ./src/server",
"dev:nd": "cross-env NODE_ENV=development tsx --require dotenv/config --enable-source-maps ./src/server",
"dev:inspector": "cross-env 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 ./build/server",
"start:inspector": "cross-env NODE_ENV=production node --require dotenv/config --inspect=0.0.0.0:9229 --enable-source-maps ./build/server",
"ctl": "NODE_ENV=production node --require dotenv/config --enable-source-maps ./build/ctl",
"validate": "pnpm run --stream \"/^validate:.*/\"",
@@ -26,97 +31,97 @@
"docker:compose:dev:logs": "docker-compose --file docker-compose.dev.yml logs -f"
},
"dependencies": {
"@aws-sdk/client-s3": "3.726.1",
"@aws-sdk/lib-storage": "3.726.1",
"@aws-sdk/client-s3": "3.859.0",
"@aws-sdk/lib-storage": "3.859.0",
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.0.1",
"@fastify/cors": "^11.1.0",
"@fastify/multipart": "^9.0.3",
"@fastify/rate-limit": "^10.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": "^8.0.0",
"@mantine/code-highlight": "^8.0.0",
"@mantine/core": "^8.0.0",
"@mantine/dates": "^8.0.0",
"@mantine/dropzone": "^8.0.0",
"@mantine/form": "^8.0.0",
"@mantine/hooks": "^8.0.0",
"@mantine/modals": "^8.0.0",
"@mantine/notifications": "^8.0.0",
"@prisma/client": "^6.7.0",
"@prisma/internals": "^6.7.0",
"@prisma/migrate": "^6.7.0",
"@smithy/node-http-handler": "^4.0.4",
"@tabler/icons-react": "^3.31.0",
"@xoi/gps-metadata-remover": "^1.1.2",
"argon2": "^0.43.0",
"@mantine/charts": "^8.2.2",
"@mantine/code-highlight": "^8.2.2",
"@mantine/core": "^8.2.2",
"@mantine/dates": "^8.2.2",
"@mantine/dropzone": "^8.2.2",
"@mantine/form": "^8.2.2",
"@mantine/hooks": "^8.2.2",
"@mantine/modals": "^8.2.2",
"@mantine/notifications": "^8.2.2",
"@prisma/adapter-pg": "^6.13.0",
"@prisma/client": "^6.13.0",
"@prisma/internals": "^6.13.0",
"@prisma/migrate": "^6.13.0",
"@smithy/node-http-handler": "^4.1.0",
"@tabler/icons-react": "^3.34.1",
"argon2": "^0.43.1",
"bytes": "^3.1.2",
"clsx": "^2.1.1",
"colorette": "^2.0.20",
"commander": "^13.1.0",
"cross-env": "^7.0.3",
"commander": "^14.0.0",
"cookie": "^1.0.2",
"cross-env": "^10.0.0",
"dayjs": "^1.11.13",
"dotenv": "^16.5.0",
"dotenv": "^17.2.1",
"fast-glob": "^3.3.3",
"fastify": "^5.3.2",
"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.24.0",
"isomorphic-dompurify": "^2.26.0",
"katex": "^0.16.22",
"mantine-datatable": "^7.17.1",
"mantine-datatable": "^8.2.0",
"ms": "^2.1.3",
"multer": "1.4.5-lts.2",
"next": "^15.3.1",
"nuqs": "^2.4.3",
"multer": "2.0.2",
"otplib": "^12.0.1",
"prisma": "^6.7.0",
"prisma": "^6.13.0",
"qrcode": "^1.5.4",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.7.1",
"remark-gfm": "^4.0.1",
"sharp": "^0.34.1",
"swr": "^2.3.3",
"zod": "^3.24.4",
"zustand": "^5.0.4"
"sharp": "^0.34.3",
"swr": "^2.3.4",
"typescript-eslint": "^8.39.0",
"vite": "^7.1.0",
"zod": "^3.25.67",
"zustand": "^5.0.7"
},
"devDependencies": {
"@eslint/compat": "^1.2.9",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.26.0",
"@types/bytes": "^3.1.5",
"@types/express": "^5.0.1",
"@types/fluent-ffmpeg": "^2.1.27",
"@types/katex": "^0.16.7",
"@types/ms": "^2.1.0",
"@types/multer": "^1.4.12",
"@types/node": "^22.15.11",
"@types/multer": "^2.0.0",
"@types/node": "^24.2.0",
"@types/qrcode": "^1.5.5",
"@types/react": "^19.1.3",
"@types/react-dom": "^19.1.3",
"@typescript-eslint/eslint-plugin": "^8.32.0",
"@typescript-eslint/parser": "^8.32.0",
"eslint": "^9.26.0",
"eslint-config-next": "^15.3.1",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-prettier": "^5.4.0",
"@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^5.0.0",
"eslint": "^9.32.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"eslint-plugin-unused-imports": "^4.1.4",
"postcss": "^8.5.3",
"postcss-preset-mantine": "^1.17.0",
"postcss": "^8.5.6",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.5.3",
"sass": "^1.87.0",
"prettier": "^3.6.2",
"sass": "^1.90.0",
"tsc-alias": "^1.8.16",
"tsup": "^8.4.0",
"tsx": "^4.19.4",
"typescript": "^5.8.3"
"tsup": "^8.5.0",
"tsx": "^4.20.3",
"typescript": "^5.9.2"
},
"engines": {
"node": ">=22"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
"packageManager": "pnpm@10.12.1"
}
+3188 -3580
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1,3 +1,5 @@
ignoredBuiltDependencies:
- unrs-resolver
onlyBuiltDependencies:
- '@parcel/watcher'
- '@prisma/client'
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Zipline" ADD COLUMN "oauthDiscordWhitelistIds" TEXT[] DEFAULT ARRAY[]::TEXT[];
@@ -0,0 +1,10 @@
/*
Warnings:
- You are about to drop the column `oauthDiscordWhitelistIds` on the `Zipline` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Zipline" DROP COLUMN "oauthDiscordWhitelistIds",
ADD COLUMN "oauthDiscordAllowedIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
ADD COLUMN "oauthDiscordDeniedIds" TEXT[] DEFAULT ARRAY[]::TEXT[];
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Zipline" ADD COLUMN "domains" TEXT[] DEFAULT ARRAY[]::TEXT[];
+8 -2
View File
@@ -1,6 +1,8 @@
generator client {
provider = "prisma-client-js"
output = "../generated/client"
provider = "prisma-client"
output = "../src/prisma"
moduleFormat = "cjs"
previewFeatures = ["queryCompiler", "driverAdapters"]
}
datasource db {
@@ -82,6 +84,8 @@ model Zipline {
oauthDiscordClientId String?
oauthDiscordClientSecret String?
oauthDiscordRedirectUri String?
oauthDiscordAllowedIds String[] @default([])
oauthDiscordDeniedIds String[] @default([])
oauthGoogleClientId String?
oauthGoogleClientSecret String?
@@ -133,6 +137,8 @@ model Zipline {
pwaDescription String @default("Zipline")
pwaThemeColor String @default("#000000")
pwaBackgroundColor String @default("#000000")
domains String[] @default([])
}
model User {
+47
View File
@@ -0,0 +1,47 @@
import { ModalsProvider } from '@mantine/modals';
import { Notifications } from '@mantine/notifications';
import { Outlet } from 'react-router-dom';
import { SWRConfig } from 'swr';
import ThemeProvider from '@/components/ThemeProvider';
import { type ZiplineTheme } from '@/lib/theme';
import { type Config } from '@/lib/config/validate';
export default function Root({
themes,
defaultTheme,
}: {
themes?: ZiplineTheme[];
defaultTheme?: Config['website']['theme'];
}) {
return (
<SWRConfig
value={{
fetcher: async (url: RequestInfo | URL) => {
const res = await fetch(url);
if (!res.ok) {
const json = await res.json();
throw new Error(json.message);
}
return res.json();
},
}}
>
<ThemeProvider ssrThemes={themes} ssrDefaultTheme={defaultTheme}>
<ModalsProvider
modalProps={{
overlayProps: {
blur: 6,
},
centered: true,
}}
>
<Notifications zIndex={10000000} />
<Outlet />
</ModalsProvider>
</ThemeProvider>
</SWRConfig>
);
}
@@ -0,0 +1,11 @@
import GenericError from './GenericError';
export default function DashboardErrorBoundary(props: Record<string, any>) {
return (
<GenericError
title='Dashboard Client Error'
message='Something went wrong while loading the dashboard. Please try again later, or report this issue if it persists.'
details={props}
/>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { Container, Paper, Stack, Text, Title } from '@mantine/core';
export default function GenericError({
title,
message,
details,
}: {
title?: string;
message?: string;
details?: Record<string, any>;
}) {
return (
<Container my='lg'>
<Stack gap='xs'>
<Title order={5}>{title || 'An error occurred'}</Title>
<Text c='dimmed'>
{message || 'Something went wrong. Please try again later, or report this issue if it persists.'}
</Text>
{details && (
<Paper withBorder px={3} py={3}>
<pre style={{ margin: 0 }}>{JSON.stringify(details, null, 2)}</pre>
</Paper>
)}
</Stack>
</Container>
);
}
+11
View File
@@ -0,0 +1,11 @@
import GenericError from './GenericError';
export default function RootErrorBoundary(props: Record<string, any>) {
return (
<GenericError
title='Dashboard Client Error'
message='Something went wrong while loading the dashboard. Please try again later, or report this issue if it persists.'
details={props}
/>
);
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Zipline</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>
+18
View File
@@ -0,0 +1,18 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import { router } from './routes';
import '@mantine/charts/styles.css';
import '@mantine/core/styles.css';
import '@mantine/dates/styles.css';
import '@mantine/dropzone/styles.css';
import '@mantine/notifications/styles.css';
import 'mantine-datatable/styles.css';
import './styles/global.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
);
@@ -1,6 +1,6 @@
import { Button, Center, Stack, Text, Title } from '@mantine/core';
import { IconArrowLeft } from '@tabler/icons-react';
import Link from 'next/link';
import { Link } from 'react-router-dom';
export default function FourOhFour() {
return (
@@ -11,12 +11,16 @@ export default function FourOhFour() {
Page not found
</Text>
<Button component={Link} href='/' color='blue' fullWidth leftSection={<IconArrowLeft size='1rem' />}>
<Button
component={Link}
to='/auth/login'
color='blue'
fullWidth
leftSection={<IconArrowLeft size='1rem' />}
>
Go home
</Button>
</Stack>
</Center>
);
}
FourOhFour.title = '404';
+99 -84
View File
@@ -1,11 +1,8 @@
import ExternalAuthButton from '@/components/pages/login/ExternalAuthButton';
import { Response } from '@/lib/api/response';
import { SafeConfig } from '@/lib/config/safe';
import { getZipline } from '@/lib/db/models/zipline';
import { fetchApi } from '@/lib/fetchApi';
import { withSafeConfig } from '@/lib/middleware/next/withSafeConfig';
import useLogin from '@/lib/hooks/useLogin';
import { authenticateWeb } from '@/lib/passkey';
import { eitherTrue } from '@/lib/primitive';
import {
Button,
Center,
@@ -34,28 +31,43 @@ import {
IconUserPlus,
IconX,
} from '@tabler/icons-react';
import { InferGetServerSidePropsType } from 'next';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { Link, redirect, useLocation, useNavigate } from 'react-router-dom';
import useSWR from 'swr';
import GenericError from '../../error/GenericError';
import { useTitle } from '@/lib/hooks/useTitle';
export default function Login({ config }: InferGetServerSidePropsType<typeof getServerSideProps>) {
const router = useRouter();
const { data, isLoading, mutate } = useSWR<Response['/api/user']>('/api/user', {
refreshInterval: 120000,
export default function Login() {
useTitle('Login');
const location = useLocation();
const query = new URLSearchParams(location.search);
const { user, mutate } = useLogin();
const navigate = useNavigate();
const {
data: config,
error: configError,
isLoading: configLoading,
} = useSWR<Response['/api/server/public']>('/api/server/public', {
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshWhenHidden: false,
revalidateIfStale: false,
});
const showLocalLogin =
router.query.local === 'true' ||
query.get('local') === 'true' ||
!(
config.oauth.bypassLocalLogin && Object.values(config.oauthEnabled).filter((x) => x === true).length > 0
config?.oauth?.bypassLocalLogin &&
Object.values(config?.oauthEnabled ?? {}).filter((x) => x === true).length > 0
);
const willRedirect =
config.oauth.bypassLocalLogin &&
Object.values(config.oauthEnabled).filter((x) => x === true).length === 1 &&
router.query.local !== 'true';
config?.oauth?.bypassLocalLogin &&
Object.values(config?.oauthEnabled ?? {}).filter((x) => x === true).length === 1 &&
query.get('local') !== 'true';
const [totpOpen, setTotpOpen] = useState(false);
const [pinDisabled, setPinDisabled] = useState(false);
@@ -65,12 +77,6 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
const [passkeyErrored, setPasskeyErrored] = useState(false);
const [passkeyLoading, setPasskeyLoading] = useState(false);
useEffect(() => {
if (data?.user) {
router.push('/dashboard');
}
}, [data]);
const form = useForm({
initialValues: {
username: '',
@@ -95,9 +101,10 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
});
if (error) {
if (error.error === 'Invalid username') form.setFieldError('username', 'Invalid username');
else if (error.error === 'Invalid password') form.setFieldError('password', 'Invalid password');
else if (error.error === 'Invalid code') setPinError(error.error!);
if (error.error === 'Invalid username or password') {
form.setFieldError('username', 'Invalid username');
form.setFieldError('password', 'Invalid password');
} else if (error.error === 'Invalid code') setPinError(error.error!);
setPinDisabled(false);
} else {
if (data!.totp) {
@@ -122,7 +129,6 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
try {
setPasskeyLoading(true);
const res = await authenticateWeb();
const { data, error } = await fetchApi<Response['/api/auth/webauthn']>('/api/auth/webauthn', 'POST', {
auth: res.toJSON(),
});
@@ -145,16 +151,22 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
};
useEffect(() => {
if (willRedirect) {
if (user) {
navigate('/dashboard');
}
}, [user]);
useEffect(() => {
if (willRedirect && config) {
const provider = Object.keys(config.oauthEnabled).find(
(x) => config.oauthEnabled[x as keyof SafeConfig['oauthEnabled']] === true,
(x) => config.oauthEnabled[x as keyof typeof config.oauthEnabled] === true,
);
if (provider) {
router.push(`/api/auth/oauth/${provider}`);
redirect(`/api/auth/oauth/${provider.toLowerCase()}`);
}
}
}, []);
}, [willRedirect, config]);
useEffect(() => {
if (passkeyErrored) {
@@ -171,6 +183,23 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
}
}, [passkeyErrored]);
useEffect(() => {
if (config?.firstSetup) navigate('/auth/setup');
}, [config]);
if (configLoading) return <LoadingOverlay visible />;
if (configError)
return (
<GenericError
title='Error loading configuration'
message='Could not load server configuration...'
details={configError}
/>
);
if (!config) return <LoadingOverlay visible />;
return (
<>
{willRedirect && !showLocalLogin && <LoadingOverlay visible />}
@@ -254,7 +283,10 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
ta='center'
style={{
whiteSpace: 'normal',
fontSize: `clamp(20px, ${Math.max(50 - (config.website.title?.length ?? 0) / 2, 20)}px, 50px)`,
fontSize: `clamp(20px, ${Math.max(
50 - (config.website.title?.length ?? 0) / 2,
20,
)}px, 50px)`,
}}
>
<b>{config.website.title ?? 'Zipline'}</b>
@@ -262,47 +294,45 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
</div>
{showLocalLogin && (
<>
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
<Stack my='sm'>
<TextInput
size='md'
placeholder='Enter your username...'
styles={{
input: {
backgroundColor: config.website.loginBackground ? 'transparent' : undefined,
},
}}
{...form.getInputProps('username', { withError: true })}
/>
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
<Stack my='sm'>
<TextInput
size='md'
placeholder='Enter your username...'
styles={{
input: {
backgroundColor: config.website.loginBackground ? 'transparent' : undefined,
},
}}
{...form.getInputProps('username', { withError: true })}
/>
<PasswordInput
size='md'
placeholder='Enter your password...'
styles={{
input: {
backgroundColor: config.website.loginBackground ? 'transparent' : undefined,
},
}}
{...form.getInputProps('password')}
/>
<PasswordInput
size='md'
placeholder='Enter your password...'
styles={{
input: {
backgroundColor: config.website.loginBackground ? 'transparent' : undefined,
},
}}
{...form.getInputProps('password')}
/>
<Button
size='md'
fullWidth
type='submit'
loading={isLoading}
variant={config.website.loginBackground ? 'outline' : 'filled'}
>
Login
</Button>
</Stack>
</form>
</>
<Button
size='md'
fullWidth
type='submit'
loading={!config}
variant={config.website.loginBackground ? 'outline' : 'filled'}
>
Login
</Button>
</Stack>
</form>
)}
<Stack my='xs'>
{eitherTrue(config.features.oauthRegistration, config.features.userRegistration) && (
{(config.features.oauthRegistration || config.features.userRegistration) && (
<Divider label='or' />
)}
@@ -323,7 +353,7 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
{config.features.userRegistration && (
<Button
component={Link}
href='/auth/register'
to='/auth/register'
size='md'
fullWidth
variant='outline'
@@ -332,6 +362,7 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
Sign up
</Button>
)}
<Group grow>
{config.oauthEnabled.discord && (
<ExternalAuthButton
@@ -358,19 +389,3 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
</>
);
}
export const getServerSideProps = withSafeConfig(async () => {
const { firstSetup } = await getZipline();
if (firstSetup)
return {
redirect: {
destination: '/setup',
permanent: false,
},
};
return {};
});
Login.title = 'Login';
+11 -11
View File
@@ -1,35 +1,35 @@
import { useTitle } from '@/lib/hooks/useTitle';
import { useUserStore } from '@/lib/store/user';
import { LoadingOverlay } from '@mantine/core';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { mutate } from 'swr';
export default function Logout() {
const router = useRouter();
useTitle('Log out');
const setUser = useUserStore((state) => state.setUser);
const navigate = useNavigate();
useEffect(() => {
(async () => {
const userRes = await fetch('/api/user');
if (userRes.ok) {
const res = await fetch('/api/auth/logout');
if (res.ok) {
setUser(null);
mutate('/api/user', null);
await router.push('/auth/login');
navigate('/auth/login');
} else {
navigate('/dashboard');
}
} else {
await router.push('/dashboard');
navigate('/dashboard');
}
})();
}, []);
return (
<>
<LoadingOverlay visible />
</>
);
return <LoadingOverlay visible />;
}
Logout.title = 'Logout';
+260
View File
@@ -0,0 +1,260 @@
import { Response } from '@/lib/api/response';
import { fetchApi } from '@/lib/fetchApi';
import {
Button,
Center,
Checkbox,
Divider,
Image,
LoadingOverlay,
Paper,
PasswordInput,
Stack,
Text,
TextInput,
Title,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
import { IconLogin, IconPlus, IconUserPlus, IconX } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { Link, redirect, useLocation, useNavigate } from 'react-router-dom';
import useSWR, { mutate } from 'swr';
import GenericError from '../../error/GenericError';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('Register');
const location = useLocation();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [invite, setInvite] = useState<any>(null);
const {
data: config,
error: configError,
isLoading: configLoading,
} = useSWR<Response['/api/server/public']>('/api/server/public', {
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshWhenHidden: false,
revalidateIfStale: false,
});
const code = new URLSearchParams(location.search).get('code') ?? undefined;
const form = useForm({
initialValues: {
username: '',
password: '',
tos: false,
},
validate: {
username: (value) => (value.length < 1 ? 'Username is required' : null),
password: (value) => (value.length < 1 ? 'Password is required' : null),
},
});
useEffect(() => {
(async () => {
const res = await fetch('/api/user');
if (res.ok) {
redirect('/dashboard');
} else {
setLoading(false);
}
})();
}, []);
useEffect(() => {
(async () => {
if (!code) return;
const res = await fetch(`/api/auth/invite/web?code=${code}`);
if (res.ok) {
const json = await res.json();
setInvite(json.invite);
} else {
redirect('/auth/login');
}
})();
}, [code]);
useEffect(() => {
if (!config?.features.userRegistration) {
navigate('/auth/login');
}
}, [code, config]);
const onSubmit = async (values: typeof form.values) => {
const { username, password, tos } = values;
if (tos === false && config!.website.tos) {
form.setFieldError('tos', 'You must agree to the Terms of Service to continue');
return;
}
const { data, error } = await fetchApi('/api/auth/register', 'POST', {
username,
password,
code,
});
if (error) {
if (error.error === 'Username is taken') {
form.setFieldError('username', 'Username is taken');
} else {
notifications.show({
title: 'Failed to register',
message: error.error,
color: 'red',
icon: <IconX size='1rem' />,
});
}
} else {
notifications.show({
title: 'Complete!',
message: `Your "${data?.user?.username}" account has been created.`,
color: 'green',
icon: <IconPlus size='1rem' />,
});
mutate('/api/user');
redirect('/dashboard');
}
};
if (loading || configLoading) return <LoadingOverlay visible />;
if (!config || configError) {
return (
<GenericError
title='Error loading configuration'
message='Could not load server configuration...'
details={configError}
/>
);
}
return (
<Center h='100vh'>
{config.website.loginBackground && (
<Image
src={config.website.loginBackground}
alt='Background'
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
...(config.website.loginBackgroundBlur && { filter: 'blur(10px)' }),
}}
/>
)}
<Paper
w='350px'
p='xl'
shadow='xl'
withBorder
style={{
backgroundColor: config.website.loginBackground ? 'rgba(0, 0, 0, 0)' : undefined,
backdropFilter: config.website.loginBackgroundBlur ? 'blur(35px)' : undefined,
}}
>
<div style={{ width: '100%', overflowWrap: 'break-word' }}>
<Title
order={1}
ta='center'
style={{
whiteSpace: 'normal',
fontSize: `clamp(20px, ${Math.max(50 - (config.website.title?.length ?? 0) / 2, 20)}px, 50px)`,
}}
>
<b>{config.website.title ?? 'Zipline'}</b>
</Title>
</div>
{invite && (
<Text ta='center' size='sm' c='dimmed'>
Youve been invited to join <b>{config?.website?.title ?? 'Zipline'}</b> by{' '}
<b>{invite.inviter?.username}</b>
</Text>
)}
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack my='sm'>
<TextInput
size='md'
placeholder='Enter your username...'
styles={{
input: {
backgroundColor: config.website.loginBackground ? 'transparent' : undefined,
},
}}
{...form.getInputProps('username', { withError: true })}
/>
<PasswordInput
size='md'
placeholder='Enter your password...'
styles={{
input: {
backgroundColor: config.website.loginBackground ? 'transparent' : undefined,
},
}}
{...form.getInputProps('password')}
/>
{config.website.tos && (
<Checkbox
label={
<Text size='xs'>
I agree to the{' '}
<Link to='/auth/tos' target='_blank'>
Terms of Service
</Link>
</Text>
}
required
{...form.getInputProps('tos', { type: 'checkbox' })}
/>
)}
<Button
size='md'
fullWidth
type='submit'
variant={config.website.loginBackground ? 'outline' : 'filled'}
leftSection={<IconUserPlus size='1rem' />}
>
Register
</Button>
</Stack>
</form>
<Stack my='xs'>
<Divider label='or' />
<Button
component={Link}
to='/auth/login'
size='md'
fullWidth
variant='outline'
leftSection={<IconLogin size='1rem' />}
>
Login
</Button>
</Stack>
</Paper>
</Center>
);
}
Component.displayName = 'Register';
+26 -32
View File
@@ -1,6 +1,6 @@
import { Response } from '@/lib/api/response';
import { getZipline } from '@/lib/db/models/zipline';
import { type Response } from '@/lib/api/response';
import { fetchApi } from '@/lib/fetchApi';
import { useTitle } from '@/lib/hooks/useTitle';
import {
Anchor,
Button,
@@ -18,11 +18,8 @@ import {
import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
import { IconArrowBackUp, IconArrowForwardUp, IconCheck, IconX } from '@tabler/icons-react';
import { GetServerSideProps } from 'next';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { redirect, useNavigate } from 'react-router-dom';
import { mutate } from 'swr';
function LinkToDoc({ href, title, children }: { href: string; title: string; children: React.ReactNode }) {
@@ -36,8 +33,22 @@ function LinkToDoc({ href, title, children }: { href: string; title: string; chi
);
}
export default function Setup() {
const router = useRouter();
export async function loader() {
const res = await fetch('/api/server/public');
if (!res.ok) {
throw new Response('Failed to fetch server settings', { status: res.status });
}
const data = await res.json();
if (!data.firstSetup) return redirect('/auth/login');
return {};
}
export function Component() {
useTitle('Setup');
const navigate = useNavigate();
const [active, setActive] = useState(0);
const nextStep = () => setActive((current) => (current < 3 ? current + 1 : current));
@@ -99,18 +110,13 @@ export default function Setup() {
setActive(2);
} else {
mutate('/api/user', data as Response['/api/user']);
router.push('/dashboard');
navigate('/dashboard');
}
}
};
return (
<>
<Head>
<title>Zipline Setup</title>
<meta name='viewport' content='width=device-width, initial-scale=1' />
</Head>
<Paper withBorder p='xs' m='sm'>
<Stepper active={active} onStepClick={setActive} m='md'>
<Stepper.Step label='Welcome!' description='Setup Zipline'>
@@ -145,7 +151,11 @@ export default function Setup() {
<Text>
To see all of the available environment variables, please refer to the documentation{' '}
<Anchor component={Link} href='https://zipline.diced.sh/docs/config'>
<Anchor
href='https://zipline.diced.sh/docs/config'
target='_blank'
rel='noopener noreferrer'
>
here.
</Anchor>
</Text>
@@ -236,20 +246,4 @@ export default function Setup() {
);
}
export const getServerSideProps: GetServerSideProps = async () => {
const { firstSetup } = await getZipline();
if (!firstSetup)
return {
redirect: {
destination: '/dashboard',
permanent: false,
},
};
return {
props: {},
};
};
Setup.title = 'Setup';
Component.displayName = 'Setup';
+41
View File
@@ -0,0 +1,41 @@
import Markdown from '@/components/render/Markdown';
import { Response } from '@/lib/api/response';
import { Container, LoadingOverlay } from '@mantine/core';
import useSWR from 'swr';
import GenericError from '../../error/GenericError';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('Terms of Service');
const {
data: config,
error,
isLoading,
} = useSWR<Response['/api/server/public']>('/api/server/public', {
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshWhenHidden: false,
revalidateIfStale: false,
});
if (isLoading) return <LoadingOverlay visible />;
if (error) {
return (
<GenericError
title='Error loading TOS'
message='Could not load Terms of Service file...'
details={error}
/>
);
}
return (
<Container my='lg'>
<Markdown md={config?.tos || ''} />
</Container>
);
}
Component.displayName = 'Tos';
@@ -0,0 +1,10 @@
import DashboardInvites from '@/components/pages/invites';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('Invites');
return <DashboardInvites />;
}
Component.displayName = 'Dashboard/Admin/Invites';
@@ -0,0 +1,10 @@
import DashboardServerSettings from '@/components/pages/serverSettings';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('Server Settings');
return <DashboardServerSettings />;
}
Component.displayName = 'Dashboard/Admin/Settings';
@@ -0,0 +1,23 @@
import ViewUserFiles from '@/components/pages/users/ViewUserFiles';
import { useTitle } from '@/lib/hooks/useTitle';
import { Params, redirect, useLoaderData } from 'react-router-dom';
export async function loader({ params }: { params: Params<string> }) {
const res = await fetch('/api/users/' + params.id);
if (!res.ok) {
console.log("can't get user", res.status);
return redirect('/dashboard/admin/users');
}
const user = await res.json();
return { user };
}
export function Component() {
const { user } = useLoaderData<typeof loader>();
useTitle(`${user ? user.username : 'User'}'s files`);
return <ViewUserFiles />;
}
Component.displayName = 'DashboardAdminViewUserFiles';
@@ -0,0 +1,10 @@
import DashboardUsers from '@/components/pages/users';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('Users');
return <DashboardUsers />;
}
Component.displayName = 'Dashboard/Admin/Users';
+10
View File
@@ -0,0 +1,10 @@
import DashboardFiles from '@/components/pages/files';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('Files');
return <DashboardFiles />;
}
Component.displayName = 'Dashboard/Files';
+10
View File
@@ -0,0 +1,10 @@
import DashboardFolders from '@/components/pages/folders';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('Folders');
return <DashboardFolders />;
}
Component.displayName = 'Dashboard/Folders';
+10
View File
@@ -0,0 +1,10 @@
import DashboardHome from '@/components/pages/dashboard';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle();
return <DashboardHome />;
}
Component.displayName = 'Dashboard/';
+10
View File
@@ -0,0 +1,10 @@
import DashboardMetrics from '@/components/pages/metrics';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('Metrics');
return <DashboardMetrics />;
}
Component.displayName = 'Dashboard/Metrics';
+10
View File
@@ -0,0 +1,10 @@
import DashboardSettings from '@/components/pages/settings';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('Settings');
return <DashboardSettings />;
}
Component.displayName = 'Dashboard/Settings';
@@ -0,0 +1,10 @@
import UploadFile from '@/components/pages/upload/File';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('Upload File');
return <UploadFile />;
}
Component.displayName = 'Dashboard/Upload/File';
@@ -0,0 +1,10 @@
import UploadText from '@/components/pages/upload/Text';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('Upload Text');
return <UploadText />;
}
Component.displayName = 'Dashboard/Upload/Text';
+10
View File
@@ -0,0 +1,10 @@
import DashboardURLs from '@/components/pages/urls';
import { useTitle } from '@/lib/hooks/useTitle';
export function Component() {
useTitle('URLs');
return <DashboardURLs />;
}
Component.displayName = 'Dashboard/URLs';
+57
View File
@@ -0,0 +1,57 @@
import { type Response } from '@/lib/api/response';
import { ActionIcon, Container, Group, SimpleGrid, Skeleton, Title } from '@mantine/core';
import { IconUpload } from '@tabler/icons-react';
import { lazy, Suspense } from 'react';
import { Link, Params, useLoaderData } from 'react-router-dom';
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
export async function loader({ params }: { params: Params<string> }) {
const res = await fetch(`/api/server/folder/${params.id}`);
if (!res.ok) {
throw new Response('Folder not found', { status: 404 });
}
return {
folder: (await res.json()) as Response['/api/server/folder/[id]'],
};
}
export function Component() {
const { folder } = useLoaderData<typeof loader>();
return (
<>
<Container my='lg'>
<Group>
<Title order={1}>{folder.name}</Title>
{folder.allowUploads && (
<Link to={`/folder/${folder.id}/upload`}>
<ActionIcon variant='outline'>
<IconUpload size='1rem' />
</ActionIcon>
</Link>
)}
</Group>
<SimpleGrid
my='sm'
cols={{
base: 1,
lg: 3,
md: 2,
}}
spacing='md'
>
{folder.files?.map((file: any) => (
<Suspense fallback={<Skeleton height={350} animate />} key={file.id}>
<DashboardFile file={file} reduce />
</Suspense>
))}
</SimpleGrid>
</Container>
</>
);
}
Component.displayName = 'ViewFolderId';
+55
View File
@@ -0,0 +1,55 @@
import ConfigProvider from '@/components/ConfigProvider';
import UploadFile from '@/components/pages/upload/File';
import { type Response } from '@/lib/api/response';
import { SafeConfig } from '@/lib/config/safe';
import { Anchor, Center, Container, Text } from '@mantine/core';
import { Link, Params, useLoaderData } from 'react-router-dom';
import useSWR from 'swr';
export async function loader({ params }: { params: Params<string> }) {
const res = await fetch(`/api/server/folder/${params.id}?upload=true`);
if (!res.ok) {
throw new Response('Folder not found', { status: 404 });
}
return {
folder: (await res.json()) as Response['/api/server/folder/[id]'],
};
}
export function Component() {
const { folder } = useLoaderData<typeof loader>();
const { data: config } = useSWR<Response['/api/server/public']>('/api/server/public', {
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshWhenHidden: false,
revalidateIfStale: false,
});
return (
<>
<Container my='lg'>
<ConfigProvider data={{ config: config as unknown as SafeConfig, codeMap: [] }}>
<UploadFile title={`Upload files to ${folder.name}`} folder={folder.id} />
<Center>
<Text c='dimmed' ta='center'>
{folder.public ? (
<>
This folder is{' '}
<Anchor component={Link} to={`/folder/${folder.id}`}>
public
</Anchor>
. Anyone with the link can view its contents and upload files.
</>
) : (
"Only the owner can view this folder's contents. However, anyone can upload files, and they can still access their uploaded files if they have the link to the specific file."
)}
</Text>
</Center>
</ConfigProvider>
</Container>
</>
);
}
Component.displayName = 'ViewFolderIdUpload';
+245
View File
@@ -0,0 +1,245 @@
import DashboardFileType from '@/components/file/DashboardFileType';
import TagPill from '@/components/pages/files/tags/TagPill';
import { File } from '@/lib/db/models/file';
import { User } from '@/lib/db/models/user';
import { parseString } from '@/lib/parser';
import { type parserMetrics } from '@/lib/parser/metrics';
import { formatRootUrl } from '@/lib/url';
import {
ActionIcon,
Anchor,
Box,
Button,
Center,
Collapse,
Group,
Modal,
Paper,
PasswordInput,
Text,
Tooltip,
Typography,
} from '@mantine/core';
import { IconDownload, IconExternalLink, IconInfoCircleFilled } from '@tabler/icons-react';
import * as sanitize from 'isomorphic-dompurify';
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useSsrData } from '../../../components/ZiplineSSRProvider';
import { getFile } from '../../ssr-view/server';
type SsrData = {
file: Partial<NonNullable<Awaited<ReturnType<typeof getFile>>>>;
password?: boolean;
code: boolean;
user?: Partial<User>;
host: string;
pw?: string | null;
metrics?: Awaited<ReturnType<typeof parserMetrics>>;
filesRoute?: string;
};
export default function ViewFileId() {
const data = useSsrData<SsrData>();
if (!data) return null;
const { file, password, code, user, host, metrics, filesRoute, pw } = data;
const [passwordValue, setPassword] = useState<string>('');
const [passwordError, setPasswordError] = useState<string>('');
const [detailsOpen, setDetailsOpen] = useState<boolean>(false);
return password && !pw ? (
<Modal onClose={() => {}} opened={true} withCloseButton={false} centered title='Password required'>
<form
onSubmit={async (e) => {
e.preventDefault();
const res = await fetch(`/api/user/files/${file.id}/password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: passwordValue.trim() }),
});
if (res.ok) {
window.location.reload();
} else {
setPasswordError('Invalid password');
}
}}
>
<PasswordInput
description='This file is password protected, enter password to view it'
required
mb='sm'
value={passwordValue}
onChange={(event) => setPassword(event.currentTarget.value)}
error={passwordError}
/>
<Button
fullWidth
variant='outline'
my='sm'
type='submit'
disabled={passwordValue.trim().length === 0}
>
Verify
</Button>
</form>
</Modal>
) : code ? (
<>
<Paper withBorder style={{ borderTop: 0, borderLeft: 0, borderRight: 0 }}>
<Group justify='space-between' py={5} px='xs'>
<Text c='dimmed'>{file.name}</Text>
<Group>
<ActionIcon size='md' variant='outline' onClick={() => setDetailsOpen((o) => !o)}>
<IconInfoCircleFilled size='1rem' />
</ActionIcon>
<ActionIcon
size='md'
variant='outline'
component={Link}
to={`/raw/${file.name}?download=true${pw ? `&pw=${pw}` : ''}`}
target='_blank'
>
<IconDownload size='1rem' />
</ActionIcon>
</Group>
</Group>
</Paper>
<Collapse in={detailsOpen}>
<Paper m='md' p='md' withBorder>
{user?.view!.content && (
<Typography>
<Text
ta={user?.view!.align ?? 'left'}
dangerouslySetInnerHTML={{
__html: sanitize.sanitize(
parseString(user.view.content, {
file: file as unknown as File,
link: {
returned: `${host}${formatRootUrl(filesRoute ?? '/u', file.name!)}`,
raw: `${host}/raw/${file.name}`,
},
...metrics,
}) ?? '',
{
USE_PROFILES: { html: true },
FORBID_TAGS: ['style', 'script'],
},
),
}}
/>
</Typography>
)}
</Paper>
</Collapse>
{file.name!.endsWith('.md') || file.name!.endsWith('.tex') ? (
<Paper m='md' p='md' withBorder>
<DashboardFileType file={file as unknown as File} password={pw} show code={code} />
</Paper>
) : (
<Box m='sm'>
<DashboardFileType file={file as unknown as File} password={pw} show code={code} />
</Box>
)}
</>
) : (
<>
<Center h='100%'>
<Paper m='md' p='md' shadow='md' radius='md' withBorder>
<Group justify='space-between' mb='sm'>
<Group>
<Text size='lg' fw={700} display='flex'>
{file.name}{' '}
</Text>
{user?.view!.showTags && (
<Group gap={4}>
{file.tags?.map((tag) => (
<TagPill key={tag.id} tag={tag} />
))}
</Group>
)}
{user?.view!.showFolder &&
file.Folder &&
(file.Folder.public ? (
<Tooltip label='View folder'>
<Anchor component={Link} ml='sm' to={`/folder/${file.Folder.id}`}>
{file.Folder.name}
</Anchor>
</Tooltip>
) : (
<Text ml='sm' size='sm' c='dimmed'>
{file.Folder.name}
</Text>
))}
{user?.view!.showMimetype && (
<Text size='sm' c='dimmed' ml='sm' style={{ alignSelf: 'center' }}>
{file.type}
</Text>
)}
</Group>
<ActionIcon.Group>
<Tooltip label='View raw file'>
<ActionIcon
size='md'
variant='outline'
component={Link}
to={`/raw/${file.name}${pw ? `?pw=${pw}` : ''}`}
target='_blank'
>
<IconExternalLink size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='Download file'>
<ActionIcon
size='md'
variant='outline'
component={Link}
to={`/raw/${file.name}?download=true${pw ? `&pw=${pw}` : ''}`}
target='_blank'
>
<IconDownload size='1rem' />
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
</Group>
<DashboardFileType allowZoom file={file as unknown as File} password={pw} show />
{user?.view!.content && (
<Typography>
<Text
mt='sm'
ta={user?.view.align ?? 'left'}
dangerouslySetInnerHTML={{
__html: sanitize.sanitize(
parseString(user?.view.content, {
file: file as unknown as File,
link: {
returned: `${host}${formatRootUrl(filesRoute ?? '/u', file.name!)}`,
raw: `${host}/raw/${file.name}`,
},
user: user as User,
...metrics,
}) ?? '',
{
USE_PROFILES: { html: true },
FORBID_TAGS: ['script'],
},
),
}}
/>
</Typography>
)}
</Paper>
</Center>
</>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { useSsrData } from '@/components/ZiplineSSRProvider';
import { Anchor, Button, Modal, PasswordInput } from '@mantine/core';
import { useEffect, useState } from 'react';
export default function ViewUrlId() {
const data = useSsrData<{
url: { id: string; destination?: string };
password?: boolean;
}>();
if (!data) return null;
const { url, password } = data;
const [passwordValue, setPassword] = useState<string>('');
const [passwordError, setPasswordError] = useState<string>('');
useEffect(() => {
if (!password && url.destination) window.location.href = url.destination;
}, []);
return password ? (
<Modal onClose={() => {}} opened={true} withCloseButton={false} centered title='Password required'>
<form
onSubmit={async (e) => {
e.preventDefault();
const res = await fetch(`/api/user/urls/${url.id}/password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: passwordValue.trim() }),
});
if (res.ok) {
window.location.reload();
} else {
setPasswordError('Invalid password');
}
}}
>
<PasswordInput
description='This link is password protected, enter password to view it'
required
mb='sm'
value={passwordValue}
onChange={(event) => setPassword(event.currentTarget.value)}
error={passwordError}
/>
<Button
fullWidth
variant='outline'
my='sm'
type='submit'
disabled={passwordValue.trim().length === 0}
>
Verify
</Button>
</form>
</Modal>
) : (
<p>
Redirecting to <Anchor href={url.destination!}>{url.destination!}</Anchor>
</p>
);
}
+114
View File
@@ -0,0 +1,114 @@
import Layout from '@/components/Layout';
import { Response as ApiResponse } from '@/lib/api/response';
import { isAdministrator } from '@/lib/role';
import { createBrowserRouter, redirect } from 'react-router-dom';
import DashboardErrorBoundary from './error/DashboardErrorBoundary';
import RootErrorBoundary from './error/RootErrorBoundary';
import FourOhFour from './pages/404';
import Login from './pages/auth/login';
import Logout from './pages/auth/logout';
import Root from './Root';
export async function dashboardLoader() {
const res = await fetch('/api/server/settings/web');
if (!res.ok) {
return redirect('/auth/login');
}
const data = await res.json();
console.log('Loaded settings:', data);
return data as ApiResponse['/api/server/settings/web'];
}
export const router = createBrowserRouter([
{
Component: Root,
path: '/',
children: [
{
ErrorBoundary: RootErrorBoundary,
children: [
{ path: '*', Component: FourOhFour },
{
path: '/auth',
children: [
{ path: 'login', Component: Login },
{ path: 'logout', Component: Logout },
{ path: 'register', lazy: () => import('./pages/auth/register') },
{
path: 'setup',
lazy: () => import('./pages/auth/setup'),
},
{ path: 'tos', lazy: () => import('./pages/auth/tos') },
],
},
{
path: '/dashboard',
Component: Layout,
loader: dashboardLoader,
children: [
{
ErrorBoundary: DashboardErrorBoundary,
children: [
{ index: true, lazy: () => import('./pages/dashboard/index') },
{ path: 'metrics', lazy: () => import('./pages/dashboard/metrics') },
{ path: 'settings', lazy: () => import('./pages/dashboard/settings') },
{ path: 'files', lazy: () => import('./pages/dashboard/files') },
{ path: 'folders', lazy: () => import('./pages/dashboard/folders') },
{ path: 'urls', lazy: () => import('./pages/dashboard/urls') },
{
path: 'upload',
children: [
{ path: 'file', lazy: () => import('./pages/dashboard/upload/file') },
{ path: 'text', lazy: () => import('./pages/dashboard/upload/text') },
],
},
{
path: 'admin',
loader: async () => {
const res = await fetch('/api/user');
if (!res.ok) {
return redirect('/auth/login');
}
const { user } = await res.json();
if (!isAdministrator(user.role)) return redirect('/dashboard');
},
children: [
{ path: 'invites', lazy: () => import('./pages/dashboard/admin/invites') },
{ path: 'settings', lazy: () => import('./pages/dashboard/admin/settings') },
{
path: 'users',
children: [
{ index: true, lazy: () => import('./pages/dashboard/admin/users') },
{
path: ':id/files',
lazy: () => import('./pages/dashboard/admin/users/[id]/files'),
},
],
},
],
},
],
},
],
},
{
path: 'folder/:id',
children: [
{
index: true,
lazy: () => import('./pages/folder/[id]'),
},
{
path: 'upload',
lazy: () => import('./pages/folder/[id]/upload'),
},
],
},
],
},
],
},
]);
+25
View File
@@ -0,0 +1,25 @@
import '@mantine/charts/styles.css';
import '@mantine/core/styles.css';
import '@mantine/dates/styles.css';
import '@mantine/dropzone/styles.css';
import '@mantine/notifications/styles.css';
import 'mantine-datatable/styles.css';
import ZiplineSSRProvider from '@/components/ZiplineSSRProvider';
import { ZIPLINE_SSR_PROP } from '@/lib/ssr/constants';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { createRoutes } from './routes';
const router = createBrowserRouter(createRoutes());
const initialData = (window as any)[ZIPLINE_SSR_PROP];
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ZiplineSSRProvider ssrData={initialData}>
<RouterProvider router={router} />
</ZiplineSSRProvider>
</StrictMode>,
);
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!--zipline-ssr-meta-->
</head>
<body>
<div id="root"><!--zipline-ssr-insert--></div>
<script type="module" src="/ssr-view-url/client.tsx"></script>
</body>
</html>
+18
View File
@@ -0,0 +1,18 @@
import { ZiplineTheme } from '@/lib/theme';
import Root from '../Root';
import { Config } from '@/lib/config/validate';
import ViewUrlId from '../pages/view/url/[id]';
export const createRoutes = (themes?: ZiplineTheme[], defaultTheme?: Config['website']['theme']) => [
{
path: '/view/url',
Component:
typeof window === 'undefined' ? undefined : () => <Root themes={themes} defaultTheme={defaultTheme} />,
children: [
{
path: ':id',
Component: () => <ViewUrlId />,
},
],
},
];
+95
View File
@@ -0,0 +1,95 @@
import * as cookie from 'cookie';
import { FastifyRequest } from 'fastify';
import { config as zConfig } from '@/lib/config';
import { Config } from '@/lib/config/validate';
import { verifyPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { renderHtml } from '@/lib/ssr/renderHtml';
import { ZiplineTheme } from '@/lib/theme';
import { createRoutes } from './routes'; // This should include the `/url/:id` route
export async function render(
{
themes,
defaultTheme,
req,
}: {
themes: ZiplineTheme[];
defaultTheme: Config['website']['theme'];
req: FastifyRequest;
},
url: string,
) {
const routes = createRoutes(themes, defaultTheme);
const id = url.split('/').pop();
if (!id) return { html: 'Not Found', meta: '', status: 404 };
const { config: libConfig, reloadSettings } = await import('@/lib/config');
if (!libConfig) await reloadSettings();
const urlEntry = await prisma.url.findFirst({
where: {
OR: [{ vanity: id }, { code: id }, { id }],
},
select: {
id: true,
password: true,
destination: true,
maxViews: true,
views: true,
enabled: true,
},
});
if (!urlEntry || !urlEntry.enabled) return { html: 'Not Found', meta: '', status: 404 };
if (urlEntry.maxViews && urlEntry.views >= urlEntry.maxViews) {
if (zConfig.features.deleteOnMaxViews) {
await prisma.url.delete({ where: { id: urlEntry.id } });
}
return { html: 'Gone', meta: '', status: 410 };
}
const cookies = cookie.parse(req.headers.cookie || '');
const pw = cookies[`url_pw_${urlEntry.id}`];
const hasPassword = !!urlEntry.password;
const data = {
url: { ...urlEntry },
password: hasPassword,
};
if (hasPassword) {
delete (data.url as any).password;
if (pw) {
const verified = await verifyPassword(pw, urlEntry.password!);
if (!verified) {
delete (data.url as any).destination;
return renderHtml(routes, { url, data, status: 403 });
}
} else {
delete (data.url as any).destination;
return renderHtml(routes, { url, data, status: 403 });
}
}
delete (data.url as any).password;
await prisma.url.update({
where: { id: urlEntry.id },
data: { views: { increment: 1 } },
});
if (data.url.destination) {
return {
html: '',
meta: '',
redirect: data.url.destination,
status: 301,
};
}
return renderHtml(routes, { url, data, status: 200 });
}
+25
View File
@@ -0,0 +1,25 @@
import '@mantine/charts/styles.css';
import '@mantine/core/styles.css';
import '@mantine/dates/styles.css';
import '@mantine/dropzone/styles.css';
import '@mantine/notifications/styles.css';
import 'mantine-datatable/styles.css';
import ZiplineSSRProvider from '@/components/ZiplineSSRProvider';
import { ZIPLINE_SSR_PROP } from '@/lib/ssr/constants';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { createRoutes } from './routes';
const router = createBrowserRouter(createRoutes());
const initialData = (window as any)[ZIPLINE_SSR_PROP];
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ZiplineSSRProvider ssrData={initialData}>
<RouterProvider router={router} />
</ZiplineSSRProvider>
</StrictMode>,
);
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!--zipline-ssr-meta-->
</head>
<body>
<div id="root"><!--zipline-ssr-insert--></div>
<script type="module" src="/ssr-view/client.tsx"></script>
</body>
</html>
+18
View File
@@ -0,0 +1,18 @@
import { ZiplineTheme } from '@/lib/theme';
import Root from '../Root';
import ViewFileId from '../pages/view/[id]';
import { Config } from '@/lib/config/validate';
export const createRoutes = (themes?: ZiplineTheme[], defaultTheme?: Config['website']['theme']) => [
{
path: '/view',
Component:
typeof window === 'undefined' ? undefined : () => <Root themes={themes} defaultTheme={defaultTheme} />,
children: [
{
path: ':id',
Component: () => <ViewFileId />,
},
],
},
];
+287
View File
@@ -0,0 +1,287 @@
import '@mantine/charts/styles.css';
import '@mantine/core/styles.css';
import '@mantine/dates/styles.css';
import '@mantine/dropzone/styles.css';
import '@mantine/notifications/styles.css';
import 'mantine-datatable/styles.css';
import { isCode } from '@/lib/code';
import { config as zConfig } from '@/lib/config';
import type { Config } from '@/lib/config/validate';
import { verifyPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { File, fileSelect } from '@/lib/db/models/file';
import { User, userSelect } from '@/lib/db/models/user';
import { parseString } from '@/lib/parser';
import { parserMetrics } from '@/lib/parser/metrics';
import { createZiplineSsr } from '@/lib/ssr/createZiplineSsr';
import type { ZiplineTheme } from '@/lib/theme';
import { readThemes } from '@/lib/theme/file';
import * as cookie from 'cookie';
import { FastifyRequest } from 'fastify';
import { renderToString } from 'react-dom/server';
import { createStaticHandler, createStaticRouter, StaticRouterProvider } from 'react-router-dom';
import { createRoutes } from './routes';
export const getFile = async (id: string) =>
prisma.file.findFirst({
where: { name: id as string },
select: {
...fileSelect,
password: true,
userId: true,
thumbnail: { select: { path: true } },
tags: { select: { id: true, name: true, color: true } },
Folder: { select: { id: true, public: true, name: true } },
},
});
export async function render(
{
defaultTheme,
req,
}: {
themes: ZiplineTheme[];
defaultTheme: Config['website']['theme'];
req: FastifyRequest;
},
url: string,
) {
const id = url.split('/').pop();
if (!id) return { html: 'Not Found', meta: '', status: 404 };
const { config: libConfig, reloadSettings } = await import('@/lib/config');
if (!libConfig) await reloadSettings();
const file = await getFile(id);
if (!file || !file.userId) return { html: 'Not Found', meta: '', status: 404 };
if (file.maxViews && file.views >= file.maxViews) return { html: 'Gone', meta: '', status: 410 };
if (file.deletesAt && file.deletesAt <= new Date()) return { html: 'Expired', meta: '', status: 410 };
const user = await prisma.user.findFirst({
where: { id: file.userId },
select: {
...userSelect,
oauthProviders: false,
passkeys: false,
sessions: false,
totpSecret: false,
quota: false,
},
});
if (!user) return { html: 'Not Found', meta: '', status: 404 };
let host = req.headers.host || 'localhost';
const proto = req.headers['x-forwarded-proto'];
try {
if (
JSON.parse(req.headers['cf-visitor'] as string)?.scheme === 'https' ||
proto === 'https' ||
zConfig.core.returnHttpsUrls
) {
host = `https://${host}`;
} else {
host = `http://${host}`;
}
} catch {
host = proto === 'https' || zConfig.core.returnHttpsUrls ? `https://${host}` : `http://${host}`;
}
// Date normalization
(file as any).createdAt = file.createdAt.toISOString();
(file as any).updatedAt = file.updatedAt.toISOString();
(file as any).deletesAt = file.deletesAt?.toISOString() || null;
(user as any).createdAt = user.createdAt.toISOString();
(user as any).updatedAt = user.updatedAt.toISOString();
const code = await isCode(file.name);
const themes = await readThemes();
const metrics = await parserMetrics(user.id);
const config = { website: { theme: zConfig.website.theme } };
const cookies = cookie.parse(req.headers.cookie || '');
const pw = cookies[`file_pw_${file.id}`];
const hasPassword = !!file.password;
if (hasPassword) {
if (pw) {
const verified = await verifyPassword(pw, file.password!);
if (!verified) return { html: 'Forbidden', meta: '', status: 403 };
delete (file as any).password;
} else {
delete (file as any).password;
const data = {
file: { id: file.id, name: file.name, type: file.type },
password: true,
code,
user,
host,
themes,
metrics,
config,
};
const routes = createRoutes(themes, defaultTheme);
const { query } = createStaticHandler(routes);
const context = await query(
new Request('http://client' + url, {
method: 'GET',
headers: new Headers({ accept: 'text/html' }),
}),
);
if (context instanceof Response) {
return context;
}
const router = createStaticRouter(routes, context);
const html = renderToString(<StaticRouterProvider context={context} router={router} />);
return {
html,
meta: `<title>Password Protected</title>\n${createZiplineSsr(data)}`,
};
}
}
await prisma.file.update({
where: { id: file.id },
data: { views: { increment: 1 } },
});
const data = {
file,
password: hasPassword,
pw: pw || null,
code,
user,
host,
themes,
metrics,
filesRoute: zConfig.files.route,
config,
};
const routes = createRoutes(themes, defaultTheme);
const { query } = createStaticHandler(routes);
const context = await query(
new Request('http://client' + url, {
method: 'GET',
headers: new Headers({ accept: 'text/html' }),
}),
);
if (context instanceof Response) {
return context;
}
const router = createStaticRouter(routes, context);
const html = renderToString(<StaticRouterProvider context={context} router={router} />);
const meta = `
${
user?.view?.embedTitle && user.view.embed
? `<meta property="og:title" content="${
parseString(user.view.embedTitle, {
file: file as unknown as File,
user: user as User,
...metrics,
}) ?? ''
}" />`
: ''
}
${
user?.view?.embedDescription && user.view.embed
? `<meta property="og:description" content="${
parseString(user.view.embedDescription, {
file: file as unknown as File,
user: user as User,
...metrics,
}) ?? ''
}" />`
: ''
}
${
user?.view?.embedSiteName && user.view.embed
? `<meta property="og:site_name" content="${
parseString(user.view.embedSiteName, {
file: file as unknown as File,
user: user as User,
...metrics,
}) ?? ''
}" />`
: ''
}
${
user?.view?.embedColor && user.view.embed
? `<meta property="theme-color" content="${
parseString(user.view.embedColor, {
file: file as unknown as File,
user: user as User,
...metrics,
}) ?? ''
}" />`
: ''
}
${
file.type?.startsWith('image')
? `
<meta property="og:type" content="image" />
<meta property="og:image" itemProp="image" content="${host}/raw/${file.name}" />
<meta property="og:url" content="${host}/raw/${file.name}" />
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:image" content="${host}/raw/${file.name}" />
<meta property="twitter:title" content="${file.name}" />
`
: ''
}
${
file.type?.startsWith('video')
? `
${file.thumbnail ? `<meta property="og:image" content="${host}/raw/${file.thumbnail.path}" />` : ''}
<meta property="og:type" content="video.other" />
<meta property="og:video:url" content="${host}/raw/${file.name}" />
<meta property="og:video:width" content="1920" />
<meta property="og:video:height" content="1080" />
`
: ''
}
${
file.type?.startsWith('audio')
? `
<meta name="twitter:card" content="player" />
<meta name="twitter:player" content="${host}/raw/${file.name}" />
<meta name="twitter:player:stream" content="${host}/raw/${file.name}" />
<meta name="twitter:player:stream:content_type" content="${file.type}" />
<meta name="twitter:title" content="${file.name}" />
<meta name="twitter:player:width" content="720" />
<meta name="twitter:player:height" content="480" />
<meta property="og:type" content="music.song" />
<meta property="og:url" content="${host}/raw/${file.name}" />
<meta property="og:audio" content="${host}/raw/${file.name}" />
<meta property="og:audio:secure_url" content="${host}/raw/${file.name}" />
<meta property="og:audio:type" content="${file.type}" />
`
: ''
}
${
!file.type?.startsWith('video') && !file.type?.startsWith('image')
? `
<meta property="og:url" content="${host}/raw/${file.name}" />
`
: ''
}
<title>${file.name}</title>
`;
return {
html,
meta: `${meta}\n${createZiplineSsr(data)}`,
};
}
+15 -6
View File
@@ -1,21 +1,30 @@
import { SafeConfig } from '@/lib/config/safe';
import { ApiServerSettingsWebResponse } from '@/server/routes/api/server/settings';
import { createContext, useContext } from 'react';
const ConfigContext = createContext<SafeConfig | null>(null);
type ConfigContextType = ApiServerSettingsWebResponse;
const ConfigContext = createContext<ConfigContextType | null>(null);
export function useConfig() {
const ctx = useContext(ConfigContext);
if (!ctx) throw new Error('useConfig must be used within a ConfigProvider');
return ctx;
return ctx.config;
}
export function useCodeMap() {
const ctx = useContext(ConfigContext);
if (!ctx) throw new Error('useCodeMap must be used within a ConfigProvider');
return ctx.codeMap;
}
export default function ConfigProvider({
config,
data,
children,
}: {
config: SafeConfig;
data: ConfigContextType;
children: React.ReactNode;
}) {
return <ConfigContext.Provider value={config}>{children}</ConfigContext.Provider>;
return <ConfigContext.Provider value={data}>{children}</ConfigContext.Provider>;
}
+23 -15
View File
@@ -3,6 +3,7 @@ import type { SafeConfig } from '@/lib/config/safe';
import { fetchApi } from '@/lib/fetchApi';
import useAvatar from '@/lib/hooks/useAvatar';
import useLogin from '@/lib/hooks/useLogin';
import { Outlet, useLocation } from 'react-router-dom';
import { isAdministrator } from '@/lib/role';
import { useUserStore } from '@/lib/store/user';
import {
@@ -44,11 +45,11 @@ import {
IconUpload,
IconUsersGroup,
} from '@tabler/icons-react';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useState } from 'react';
import ConfigProvider from './ConfigProvider';
import VersionBadge from './VersionBadge';
import { Link, useLoaderData } from 'react-router-dom';
import { dashboardLoader } from '../client/routes';
type NavLinks = {
label: string;
@@ -142,14 +143,17 @@ const navLinks: NavLinks[] = [
},
];
export default function Layout({ children, config }: { children: React.ReactNode; config: SafeConfig }) {
export default function Layout() {
const theme = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
const [opened, setOpened] = useState(false);
const router = useRouter();
const modals = useModals();
const clipboard = useClipboard();
const setUser = useUserStore((s) => s.setUser);
const location = useLocation();
const loaderData = useLoaderData<typeof dashboardLoader>();
const config = loaderData.config;
const { user, mutate } = useLogin();
const { avatar } = useAvatar();
@@ -275,7 +279,8 @@ export default function Layout({ children, config }: { children: React.ReactNode
<Menu.Item
leftSection={<IconSettingsFilled size='1rem' />}
component={Link}
href='/dashboard/settings'
to='/dashboard/settings'
prefetch='intent'
>
Settings
</Menu.Item>
@@ -284,7 +289,8 @@ export default function Layout({ children, config }: { children: React.ReactNode
<Menu.Item
leftSection={<IconAdjustments size='1rem' />}
component={Link}
href='/dashboard/admin/settings'
to='/dashboard/admin/settings'
prefetch='intent'
>
Server Settings
</Menu.Item>
@@ -295,7 +301,7 @@ export default function Layout({ children, config }: { children: React.ReactNode
color='red'
leftSection={<IconLogout size='1rem' />}
component={Link}
href='/auth/logout'
to='/auth/logout'
>
Logout
</Menu.Item>
@@ -322,9 +328,10 @@ export default function Layout({ children, config }: { children: React.ReactNode
leftSection={link.icon}
variant='light'
rightSection={<IconChevronRight size='0.7rem' />}
active={router.pathname === link.href}
active={location.pathname === link.href}
component={Link}
href={link.href || ''}
to={link.href || ''}
prefetch='intent'
/>
);
} else {
@@ -335,7 +342,7 @@ export default function Layout({ children, config }: { children: React.ReactNode
leftSection={link.icon}
variant='light'
rightSection={<IconChevronRight size='0.7rem' />}
defaultOpened={link.active(router.pathname)}
defaultOpened={link.active(location.pathname)}
>
{link.links
.filter(
@@ -348,9 +355,10 @@ export default function Layout({ children, config }: { children: React.ReactNode
leftSection={sublink.icon}
rightSection={<IconChevronRight size='0.7rem' />}
variant='light'
active={router.pathname === sublink.href}
active={location.pathname === sublink.href}
component={Link}
href={sublink.href || ''}
to={sublink.href || ''}
prefetch='intent'
/>
))}
</NavLink>
@@ -372,7 +380,7 @@ export default function Layout({ children, config }: { children: React.ReactNode
leftSection={<IconExternalLink size='1rem' />}
variant='light'
component={Link}
href={url}
to={url}
target='_blank'
/>
))}
@@ -382,9 +390,9 @@ export default function Layout({ children, config }: { children: React.ReactNode
</AppShell.Navbar>
<AppShell.Main>
<ConfigProvider config={config}>
<ConfigProvider data={loaderData}>
<Paper m='lg' withBorder p='xs'>
{children}
<Outlet />
</Paper>
</ConfigProvider>
</AppShell.Main>
+18 -6
View File
@@ -1,3 +1,4 @@
import { Response } from '@/lib/api/response';
import { Config } from '@/lib/config/validate';
import { useSettingsStore } from '@/lib/store/settings';
import { useUserStore } from '@/lib/store/user';
@@ -6,6 +7,7 @@ import dark_blue from '@/lib/theme/builtins/dark_blue';
import { MantineProvider, createTheme } from '@mantine/core';
import { useColorScheme } from '@mantine/hooks';
import { createContext, useContext } from 'react';
import useSWR from 'swr';
import { useShallow } from 'zustand/shallow';
const ThemeContext = createContext<{
@@ -21,15 +23,25 @@ export function useThemes() {
return ctx.themes;
}
export default function Theming({
themes,
defaultTheme,
export default function ThemeProvider({
ssrThemes,
ssrDefaultTheme,
children,
}: {
themes: ZiplineTheme[];
ssrThemes?: ZiplineTheme[];
ssrDefaultTheme?: Config['website']['theme'];
children: React.ReactNode;
defaultTheme?: Config['website']['theme'];
}) {
const { data: clientThemes } = useSWR<Response['/api/server/themes']>('/api/server/themes', {
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshWhenHidden: false,
revalidateIfStale: false,
});
const themes = ssrThemes ?? clientThemes?.themes;
const defaultTheme = ssrDefaultTheme ?? clientThemes?.defaultTheme;
const user = useUserStore((state) => state.user);
const [userTheme, preferredDark, preferredLight] = useSettingsStore(
useShallow((state) => [state.settings.theme, state.settings.themeDark, state.settings.themeLight]),
@@ -53,7 +65,7 @@ export default function Theming({
}
return (
<ThemeContext.Provider value={{ themes }}>
<ThemeContext.Provider value={{ themes: themes ?? [] }}>
<MantineProvider
defaultColorScheme={theme.colorScheme as unknown as any}
forceColorScheme={theme.colorScheme as unknown as any}
+19
View File
@@ -0,0 +1,19 @@
import { createContext, useContext } from 'react';
export const ZiplineSSRContext = createContext<any>(null);
export function useSsrData<T>(): T {
const ctx = useContext(ZiplineSSRContext);
return ctx as T;
}
export default function ZiplineSSRProvider({
children,
ssrData,
}: {
children: React.ReactNode;
ssrData: any;
}) {
return <ZiplineSSRContext.Provider value={ssrData}>{children}</ZiplineSSRContext.Provider>;
}
@@ -3,7 +3,7 @@ import { fetchApi } from '@/lib/fetchApi';
import { Button, Divider, Modal, NumberInput, PasswordInput, Stack, TextInput } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { IconEye, IconKey, IconPencil, IconPencilOff, IconTrashFilled } from '@tabler/icons-react';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { mutateFiles } from '../actions';
export default function EditFileDetailsModal({
@@ -17,6 +17,7 @@ export default function EditFileDetailsModal({
}) {
if (!file) return null;
const [name, setName] = useState<string>(file.name ?? '');
const [maxViews, setMaxViews] = useState<number | null>(file?.maxViews ?? null);
const [password, setPassword] = useState<string | null>('');
const [originalName, setOriginalName] = useState<string | null>(file?.originalName ?? null);
@@ -54,12 +55,16 @@ export default function EditFileDetailsModal({
password?: string;
originalName?: string;
type?: string;
name?: string;
} = {};
if (maxViews !== null) data['maxViews'] = maxViews;
if (password !== null) data['password'] = password?.trim();
if (originalName !== null) data['originalName'] = originalName?.trim();
if (type !== null) data['type'] = type?.trim();
if (name !== file.name) data['name'] = name.trim();
const passwordTrimmed = password?.trim();
if (passwordTrimmed !== '') data['password'] = passwordTrimmed;
const { error } = await fetchApi(`/api/user/files/${file.id}`, 'PATCH', data);
@@ -85,9 +90,26 @@ export default function EditFileDetailsModal({
}
};
useEffect(() => {
if (open) {
setName(file.name ?? '');
setMaxViews(file.maxViews ?? null);
setPassword(file.password ? '' : null);
setOriginalName(file.originalName ?? null);
setType(file.type ?? null);
}
}, [open, file]);
return (
<Modal zIndex={300} title={`Editing "${file.name}"`} onClose={onClose} opened={open}>
<Stack gap='xs' my='sm'>
<TextInput
label='Name'
description='Rename the file.'
value={name}
onChange={(event) => setName(event.currentTarget.value.trim())}
/>
<NumberInput
label='Max Views'
placeholder='Unlimited'
@@ -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 ? (
<>
+59 -48
View File
@@ -11,11 +11,10 @@ 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 (
@@ -30,7 +29,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>
);
@@ -74,71 +73,80 @@ 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 (overrideType === 'text' || type === 'text') {
gettext();
getText();
} else if (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)} />;
@@ -156,7 +164,7 @@ export default function DashboardFileType({
</Paper>
);
switch (overrideType || type) {
switch (type) {
case 'video':
return show ? (
<video
@@ -169,7 +177,10 @@ export default function DashboardFileType({
/>
) : (file as DbFile).thumbnail && dbFile ? (
<Box pos='relative'>
<MantineImage src={`/raw/${(file as DbFile).thumbnail!.path}`} alt={file.name} />
<MantineImage
src={`/raw/${(file as DbFile).thumbnail!.path}`}
alt={file.name || 'Video thumbnail'}
/>
<Center
pos='absolute'
@@ -195,7 +206,7 @@ export default function DashboardFileType({
<Center>
<MantineImage
src={dbFile ? `/raw/${file.name}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)}
alt={file.name}
alt={file.name || 'Image'}
style={{
cursor: allowZoom ? 'zoom-in' : 'default',
maxWidth: '70vw',
@@ -209,7 +220,7 @@ export default function DashboardFileType({
src={
dbFile ? `/raw/${file.name}${password ? `?pw=${password}` : ''}` : URL.createObjectURL(file)
}
alt={file.name}
alt={file.name || 'Image'}
style={{
maxWidth: '95vw',
maxHeight: '95vh',
@@ -226,7 +237,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':
+2 -2
View File
@@ -16,7 +16,7 @@ import {
IconTrashFilled,
IconTrashXFilled,
} from '@tabler/icons-react';
import Link from 'next/link';
import { Link } from 'react-router-dom';
import { mutate } from 'swr';
export function viewFile(file: File) {
@@ -37,7 +37,7 @@ export function copyFile(file: File, clipboard: ReturnType<typeof useClipboard>)
notifications.show({
title: 'Copied link',
message: (
<Anchor component={Link} href={url}>
<Anchor component={Link} to={url}>
{url}
</Anchor>
),
+5 -6
View File
@@ -4,18 +4,15 @@ import { bytes } from '@/lib/bytes';
import useLogin from '@/lib/hooks/useLogin';
import { Paper, ScrollArea, SimpleGrid, Skeleton, Table, Text, Title } from '@mantine/core';
import { IconDeviceSdCard, IconEyeFilled, IconFiles, IconLink, IconStarFilled } from '@tabler/icons-react';
import dynamic from 'next/dynamic';
import { lazy, Suspense } from 'react';
import useSWR from 'swr';
const DashboardFile = dynamic(() => import('@/components/file/DashboardFile'), {
loading: () => <Skeleton height={350} animate />,
});
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
export default function DashboardHome() {
const { user } = useLogin();
const { data: recent, isLoading: recentLoading } = useSWR<Response['/api/user/recent']>('/api/user/recent');
const { data: stats, isLoading: statsLoading } = useSWR<Response['/api/user/stats']>('/api/user/stats');
return (
<>
<Title>
@@ -63,7 +60,9 @@ export default function DashboardHome() {
) : recent?.length !== 0 ? (
<SimpleGrid cols={{ base: 1, md: 2, lg: 3 }} spacing={{ base: 'sm', md: 'md' }}>
{recent!.map((file, i) => (
<DashboardFile key={i} file={file} />
<Suspense fallback={<Skeleton height={350} animate />} key={i}>
<DashboardFile file={file} />
</Suspense>
))}
</SimpleGrid>
) : (
@@ -3,10 +3,9 @@ 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 '../../../../generated/client';
import { IncompleteFileStatus } from '@/prisma/client';
import { IconFileDots, IconTrashFilled } from '@tabler/icons-react';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { ReactNode } from 'react';
import { ReactNode, useState } from 'react';
import useSWR from 'swr';
const badgeMap: Record<IncompleteFileStatus, ReactNode> = {
@@ -33,7 +32,7 @@ const badgeMap: Record<IncompleteFileStatus, ReactNode> = {
};
export default function PendingFilesButton() {
const [open, setOpen] = useQueryState('popen', parseAsBoolean.withDefault(false));
const [open, setOpen] = useState(false);
const { data: incompleteFiles, mutate } = useSWR<
Extract<IncompleteFile[], Response['/api/user/files/incomplete']>
+3
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,
});
}
+3 -3
View File
@@ -6,10 +6,10 @@ import FileTable from './views/FileTable';
import Files from './views/Files';
import TagsButton from './tags/TagsButton';
import PendingFilesButton from './PendingFilesButton';
import Link from 'next/link';
import { IconFileUpload } from '@tabler/icons-react';
import { Link } from 'react-router-dom';
export default function DashbaordFiles() {
export default function DashboardFiles() {
const view = useViewStore((state) => state.files);
return (
@@ -18,7 +18,7 @@ export default function DashbaordFiles() {
<Title>Files</Title>
<Tooltip label='Upload a file'>
<Link href='/dashboard/upload/file'>
<Link to='/dashboard/upload/file'>
<ActionIcon variant='outline'>
<IconFileUpload size='1rem' />
</ActionIcon>
@@ -5,7 +5,6 @@ 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';
@@ -13,8 +12,8 @@ import EditTagModal from './EditTagModal';
import TagPill from './TagPill';
export default function TagsButton() {
const [open, setOpen] = useQueryState('topen', parseAsBoolean.withDefault(false));
const [createModalOpen, setCreateModalOpen] = useQueryState('ctopen', parseAsBoolean.withDefault(false));
const [open, setOpen] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [selectedTag, setSelectedTag] = useState<Tag | null>(null);
const { data: tags, mutate } = useSWR<Extract<Tag[], Response['/api/user/tags']>>('/api/user/tags');
@@ -1,3 +1,4 @@
import { useQueryState } from '@/lib/hooks/useQueryState';
import {
Accordion,
Button,
@@ -12,17 +13,14 @@ import {
Title,
} from '@mantine/core';
import { IconFileUpload, IconFilesOff } from '@tabler/icons-react';
import dynamic from 'next/dynamic';
import Link from 'next/link';
import { parseAsInteger, useQueryState } from 'nuqs';
import { Link } from 'react-router-dom';
import { useApiPagination } from '../useApiPagination';
import { lazy, Suspense } from 'react';
const DashboardFile = dynamic(() => import('@/components/file/DashboardFile'), {
loading: () => <Skeleton height={350} animate />,
});
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
export default function FavoriteFiles() {
const [page, setPage] = useQueryState('fpage', parseAsInteger.withDefault(1));
const [page, setPage] = useQueryState('fpage', 1);
const { data, isLoading } = useApiPagination({
page,
@@ -55,7 +53,11 @@ export default function FavoriteFiles() {
<LoadingOverlay visible />
</Paper>
) : (data?.page.length ?? 0 > 0) ? (
data?.page.map((file) => <DashboardFile key={file.id} file={file} />)
data?.page.map((file) => (
<Suspense fallback={<Skeleton height={350} animate />} key={file.id}>
<DashboardFile file={file} />
</Suspense>
))
) : (
<Paper withBorder p='sm'>
<Center>
@@ -69,7 +71,7 @@ export default function FavoriteFiles() {
size='compact-sm'
leftSection={<IconFileUpload size='1rem' />}
component={Link}
href='/dashboard/upload/file'
to='/dashboard/upload/file'
>
Upload a file
</Button>
+37 -30
View File
@@ -1,6 +1,5 @@
import RelativeDate from '@/components/RelativeDate';
import FileModal from '@/components/file/DashboardFile/FileModal';
import { addMultipleToFolder, copyFile, deleteFile } from '@/components/file/actions';
import { addMultipleToFolder, copyFile, deleteFile, downloadFile } from '@/components/file/actions';
import { Response } from '@/lib/api/response';
import { bytes } from '@/lib/bytes';
import { type File } from '@/lib/db/models/file';
@@ -30,6 +29,7 @@ import {
import { useClipboard } from '@mantine/hooks';
import {
IconCopy,
IconDownload,
IconExternalLink,
IconFile,
IconGridPatternFilled,
@@ -37,13 +37,15 @@ import {
IconTrashFilled,
} from '@tabler/icons-react';
import { DataTable } from 'mantine-datatable';
import Link from 'next/link';
import { parseAsBoolean, parseAsInteger, parseAsStringLiteral, useQueryState } from 'nuqs';
import { useEffect, useReducer, useState } from 'react';
import { lazy, useEffect, useReducer, useState } from 'react';
import { Link } from 'react-router-dom';
import useSWR from 'swr';
import { bulkDelete, bulkFavorite } from '../bulk';
import TagPill from '../tags/TagPill';
import { useApiPagination } from '../useApiPagination';
import { useQueryState } from '@/lib/hooks/useQueryState';
const FileModal = lazy(() => import('@/components/file/DashboardFile/FileModal'));
type ReducerQuery = {
state: { name: string; originalName: string; type: string; tags: string; id: string };
@@ -185,30 +187,24 @@ export default function FileTable({ id }: { id?: string }) {
'/api/user/folders?noincl=true',
);
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
const [perpage, setPerpage] = useState<number>(20);
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 [page, setPage] = useQueryState('page', 1);
const [perpage, setPerpage] = useState(20);
const [sort, setSort] = useState<
| 'id'
| 'createdAt'
| 'updatedAt'
| 'deletesAt'
| 'name'
| 'originalName'
| 'size'
| 'type'
| 'views'
| 'favorite'
>('createdAt');
const [order, setOrder] = useState<'asc' | 'desc'>('desc');
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [idSearchOpen, setIdSearchOpen] = useQueryState('idsearch', parseAsBoolean.withDefault(false));
const [idSearchOpen, setIdSearchOpen] = useState(false);
const [searchField, setSearchField] = useState<'name' | 'originalName' | 'type' | 'tags' | 'id'>('name');
const [searchQuery, setSearchQuery] = useReducer(
(state: ReducerQuery['state'], action: ReducerQuery['action']) => {
@@ -306,9 +302,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>
@@ -503,7 +498,7 @@ export default function FileTable({ id }: { id?: string }) {
</Tooltip>
<Tooltip label='View file in new tab'>
<Link href={`/view/${file.name}`} target='_blank'>
<Link to={`/view/${file.name}`} target='_blank'>
<ActionIcon color='blue'>
<IconExternalLink size='1rem' />
</ActionIcon>
@@ -521,6 +516,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'
+13 -12
View File
@@ -12,22 +12,19 @@ import {
Title,
} from '@mantine/core';
import { IconFileUpload, IconFilesOff } from '@tabler/icons-react';
import dynamic from 'next/dynamic';
import Link from 'next/link';
import { parseAsInteger, useQueryState } from 'nuqs';
import { useEffect, useState } from 'react';
import { lazy, Suspense, useEffect, useState } from 'react';
import { useApiPagination } from '../useApiPagination';
import { Link } from 'react-router-dom';
import { useQueryState } from '@/lib/hooks/useQueryState';
const DashboardFile = dynamic(() => import('@/components/file/DashboardFile'), {
loading: () => <Skeleton height={350} animate />,
});
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
const PER_PAGE_OPTIONS = [9, 12, 15, 30, 45];
export default function Files({ id }: { id?: string }) {
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
const [perpage, setPerpage] = useState<number>(15);
const [cachedPages, setCachedPages] = useState<number>(1);
const [page, setPage] = useQueryState('page', 1);
const [perpage, setPerpage] = useState(15);
const [cachedPages, setCachedPages] = useState(1);
const { data, isLoading } = useApiPagination({
page,
@@ -60,7 +57,11 @@ export default function Files({ id }: { id?: string }) {
{isLoading ? (
[...Array(9)].map((_, i) => <Skeleton key={i} height={350} animate />)
) : (data?.page?.length ?? 0 > 0) ? (
data?.page.map((file) => <DashboardFile key={file.id} file={file} />)
data?.page.map((file) => (
<Suspense fallback={<Skeleton height={350} animate />} key={file.id}>
<DashboardFile file={file} />
</Suspense>
))
) : (
<Paper withBorder p='sm'>
<Center>
@@ -75,7 +76,7 @@ export default function Files({ id }: { id?: string }) {
size='compact-sm'
leftSection={<IconFileUpload size='1rem' />}
component={Link}
href='/dashboard/upload/file'
to='/dashboard/upload/file'
>
Upload a file
</Button>
+13 -6
View File
@@ -1,4 +1,4 @@
import DashboardFile from '@/components/file/DashboardFile';
import { useQueryState } from '@/lib/hooks/useQueryState';
import {
Accordion,
Button,
@@ -8,16 +8,19 @@ import {
Pagination,
Paper,
SimpleGrid,
Skeleton,
Stack,
Title,
} from '@mantine/core';
import { IconFileUpload, IconFilesOff } from '@tabler/icons-react';
import Link from 'next/link';
import { parseAsInteger, useQueryState } from 'nuqs';
import { Link } from 'react-router-dom';
import { useApiPagination } from '../files/useApiPagination';
import { lazy, Suspense } from 'react';
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
export default function FavoriteFiles() {
const [page, setPage] = useQueryState('fpage', parseAsInteger.withDefault(1));
const [page, setPage] = useQueryState('fpage', 1);
const { data, isLoading } = useApiPagination({
page,
favorite: true,
@@ -47,7 +50,11 @@ export default function FavoriteFiles() {
<LoadingOverlay visible />
</Paper>
) : (data?.page.length ?? 0 > 0) ? (
data?.page.map((file) => <DashboardFile key={file.id} file={file} />)
data?.page.map((file) => (
<Suspense fallback={<Skeleton height={350} animate />} key={file.id}>
<DashboardFile file={file} />
</Suspense>
))
) : (
<Paper withBorder p='sm'>
<Center>
@@ -61,7 +68,7 @@ export default function FavoriteFiles() {
size='compact-sm'
leftSection={<IconFileUpload size='1rem' />}
component={Link}
href='/dashboard/upload/file'
to='/dashboard/upload/file'
>
Upload a file
</Button>
@@ -1,7 +1,9 @@
import DashboardFile from '@/components/file/DashboardFile';
import { Folder } from '@/lib/db/models/folder';
import { Alert, Anchor, Button, CopyButton, Group, Modal, SimpleGrid, Text } from '@mantine/core';
import { Alert, Anchor, Button, CopyButton, Group, Modal, SimpleGrid, Skeleton, Text } from '@mantine/core';
import { IconShare } from '@tabler/icons-react';
import { lazy, Suspense } from 'react';
const DashboardFile = lazy(() => import('@/components/file/DashboardFile'));
export default function ViewFilesModal({
folder,
@@ -55,7 +57,11 @@ export default function ViewFilesModal({
}}
pos='relative'
>
{folder?.files?.map((file) => <DashboardFile file={file} key={file.id} />)}
{folder?.files?.map((file) => (
<Suspense fallback={<Skeleton height={350} animate />} key={file.id}>
<DashboardFile file={file} key={file.id} />
</Suspense>
))}
</SimpleGrid>
)}
+2 -2
View File
@@ -6,7 +6,7 @@ import { useClipboard } from '@mantine/hooks';
import { modals } from '@mantine/modals';
import { notifications } from '@mantine/notifications';
import { IconCheck, IconCopy, IconFolderOff } from '@tabler/icons-react';
import Link from 'next/link';
import { Link } from 'react-router-dom';
import { mutate } from 'swr';
export async function deleteFolder(folder: Folder) {
@@ -30,7 +30,7 @@ export function copyFolderUrl(folder: Folder, clipboard: ReturnType<typeof useCl
notifications.show({
title: 'Copied link',
message: (
<Anchor component={Link} href={`/folder/${folder.id}`}>
<Anchor component={Link} to={`/folder/${folder.id}`}>
{`${window.location.protocol}//${window.location.host}/folder/${folder.id}`}
</Anchor>
),
+2 -2
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 { parseAsBoolean, useQueryState } from 'nuqs';
import { useState } from 'react';
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] = useQueryState('cfopen', parseAsBoolean.withDefault(false));
const [open, setOpen] = useState(false);
const form = useForm({
initialValues: {
@@ -37,7 +37,9 @@ export default function FolderGridView() {
}}
pos='relative'
>
{folders?.map((folder) => <FolderCard key={folder.id} folder={folder} />)}
{folders?.map((folder) => (
<FolderCard key={folder.id} folder={folder} />
))}
</SimpleGrid>
) : (
<Paper withBorder p='sm' my='sm'>
+2 -2
View File
@@ -6,7 +6,7 @@ import { Anchor } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { IconCheck, IconCopy, IconTagOff } from '@tabler/icons-react';
import Link from 'next/link';
import { Link } from 'react-router-dom';
import { mutate } from 'swr';
export async function deleteInvite(warnDeletion: boolean, invite: Invite) {
@@ -23,7 +23,7 @@ export function copyInviteUrl(invite: Invite, clipboard: ReturnType<typeof useCl
notifications.show({
title: 'Copied link',
message: (
<Anchor component={Link} href={`/invite/${invite.code}`}>
<Anchor component={Link} to={`/invite/${invite.code}`}>
{`${window.location.protocol}//${window.location.host}/invite/${invite.code}`}
</Anchor>
),
+8 -8
View File
@@ -1,20 +1,20 @@
import GridTableSwitcher from '@/components/GridTableSwitcher';
import { Response } from '@/lib/api/response';
import { Invite } from '@/lib/db/models/invite';
import { fetchApi } from '@/lib/fetchApi';
import { useViewStore } from '@/lib/store/view';
import { ActionIcon, Button, Group, Modal, NumberInput, Select, Stack, Title, Tooltip } from '@mantine/core';
import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
import { IconPlus, IconTagOff } from '@tabler/icons-react';
import { useState } from 'react';
import { mutate } from 'swr';
import InviteGridView from './views/InviteGridView';
import InviteTableView from './views/InviteTableView';
import { useForm } from '@mantine/form';
import { fetchApi } from '@/lib/fetchApi';
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] = useQueryState('ciopen', parseAsBoolean.withDefault(false));
const [open, setOpen] = useState(false);
const form = useForm<{
maxUses: number | '';
@@ -37,7 +37,9 @@ export default function InviteGridView() {
}}
pos='relative'
>
{folders?.map((invite) => <InviteCard key={invite.id} invite={invite} />)}
{folders?.map((invite) => (
<InviteCard key={invite.id} invite={invite} />
))}
</SimpleGrid>
) : (
<Paper withBorder p='sm' my='sm'>
@@ -1,5 +1,4 @@
import { ActionIcon, Tooltip } from '@mantine/core';
import Link from 'next/link';
import styles from './ExternalAuthButton.module.css';
export default function ExternalAuthButton({
@@ -12,7 +11,7 @@ export default function ExternalAuthButton({
return (
<Tooltip label={`Continue with ${provider}`}>
<ActionIcon
component={Link}
component={'a'}
href={`/api/auth/oauth/${provider.toLowerCase()}`}
color={`${provider.toLowerCase()}.0`}
className={styles.button}
+44 -10
View File
@@ -1,22 +1,24 @@
import { Box, Button, Group, Modal, Paper, SimpleGrid, Text, Title, Tooltip } from '@mantine/core';
import { DatePicker } from '@mantine/dates';
import { IconCalendarSearch, IconCalendarTime } from '@tabler/icons-react';
import dynamic from 'next/dynamic';
import { useEffect, useState } from 'react';
import { lazy, useEffect, useState } from 'react';
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'));
const StorageGraph = dynamic(() => import('./parts/StorageGraph'));
const ViewsGraph = dynamic(() => import('./parts/ViewsGraph'));
const StorageGraph = lazy(() => import('./parts/StorageGraph'));
const ViewsGraph = lazy(() => import('./parts/ViewsGraph'));
const StatsCards = lazy(() => import('./parts/StatsCards'));
const StatsTables = lazy(() => import('./parts/StatsTables'));
export default function DashboardMetrics() {
const today = dayjs();
const [dateRange, setDateRange] = useState<[string | null, string | null]>([
new Date(Date.now() - 86400000 * 7).toISOString(),
new Date().toISOString(),
today.subtract(7, 'day').toISOString(),
today.toISOString(),
]);
const [open, setOpen] = useState(false);
@@ -40,17 +42,49 @@ export default function DashboardMetrics() {
return (
<>
<Modal title='Change range' opened={open} onClose={() => setOpen(false)} size='auto'>
<Paper withBorder>
<Paper withBorder style={{ minHeight: 300 }}>
<DatePicker
type='range'
value={dateRange}
onChange={handleDateChange}
allowSingleDateInRange={false}
maxDate={new Date()}
presets={[
{
value: [today.subtract(2, 'day').format('YYYY-MM-DD'), today.format('YYYY-MM-DD')],
label: 'Last two days',
},
{
value: [today.subtract(7, 'day').format('YYYY-MM-DD'), today.format('YYYY-MM-DD')],
label: 'Last 7 days',
},
{
value: [today.startOf('month').format('YYYY-MM-DD'), today.format('YYYY-MM-DD')],
label: 'This month',
},
{
value: [
today.subtract(1, 'month').startOf('month').format('YYYY-MM-DD'),
today.subtract(1, 'month').endOf('month').format('YYYY-MM-DD'),
],
label: 'Last month',
},
{
value: [today.startOf('year').format('YYYY-MM-DD'), today.format('YYYY-MM-DD')],
label: 'This year',
},
{
value: [
today.subtract(1, 'year').startOf('year').format('YYYY-MM-DD'),
today.subtract(1, 'year').endOf('year').format('YYYY-MM-DD'),
],
label: 'Last year',
},
]}
/>
</Paper>
<Group mt='md'>
<Group mt='lg'>
<Button fullWidth onClick={() => setOpen(false)}>
Close
</Button>
+100 -63
View File
@@ -1,57 +1,69 @@
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 { useDisclosure } from '@mantine/hooks';
import useSWR from 'swr';
import dynamic from 'next/dynamic';
import { lazy, Suspense, useMemo } from 'react';
const Core = lazy(() => import('./parts/Core'));
const Chunks = lazy(() => import('./parts/Chunks'));
const Discord = lazy(() => import('./parts/Discord'));
const Domains = lazy(() => import('./parts/Domains'));
const Features = lazy(() => import('./parts/Features'));
const Files = lazy(() => import('./parts/Files'));
const HttpWebhook = lazy(() => import('./parts/HttpWebhook'));
const Invites = lazy(() => import('./parts/Invites'));
const Mfa = lazy(() => import('./parts/Mfa'));
const Oauth = lazy(() => import('./parts/Oauth'));
const PWA = lazy(() => import('./parts/PWA'));
const Ratelimit = lazy(() => import('./parts/Ratelimit'));
const Tasks = lazy(() => import('./parts/Tasks'));
const Urls = lazy(() => import('./parts/Urls'));
const Website = lazy(() => import('./parts/Website'));
function SettingsSkeleton() {
return <Skeleton height={280} animate />;
return Array(17)
.fill(null)
.map((_, index) => <Skeleton key={index} height={280} animate />);
}
const ServerSettingsCore = dynamic(() => import('./parts/ServerSettingsCore'), {
loading: () => <SettingsSkeleton />,
});
const ServerSettingsChunks = dynamic(() => import('./parts/ServerSettingsChunks'), {
loading: () => <SettingsSkeleton />,
});
const ServerSettingsDiscord = dynamic(() => import('./parts/ServerSettingsDiscord'), {
loading: () => <SettingsSkeleton />,
});
const ServerSettingsFeatures = dynamic(() => import('./parts/ServerSettingsFeatures'), {
loading: () => <SettingsSkeleton />,
});
const ServerSettingsFiles = dynamic(() => import('./parts/ServerSettingsFiles'), {
loading: () => <SettingsSkeleton />,
});
const ServerSettingsHttpWebhook = dynamic(() => import('./parts/ServerSettingsHttpWebhook'), {
loading: () => <SettingsSkeleton />,
});
const ServerSettingsInvites = dynamic(() => import('./parts/ServerSettingsInvites'), {
loading: () => <SettingsSkeleton />,
});
const ServerSettingsMfa = dynamic(() => import('./parts/ServerSettingsMfa'), {
loading: () => <SettingsSkeleton />,
});
const ServerSettingsOauth = dynamic(() => import('./parts/ServerSettingsOauth'), {
loading: () => <SettingsSkeleton />,
});
const ServerSettingsRatelimit = dynamic(() => import('./parts/ServerSettingsRatelimit'), {
loading: () => <SettingsSkeleton />,
});
const ServerSettingsTasks = dynamic(() => import('./parts/ServerSettingsTasks'), {
loading: () => <SettingsSkeleton />,
});
const ServerSettingsUrls = dynamic(() => import('./parts/ServerSettingsUrls'), {
loading: () => <SettingsSkeleton />,
});
const ServerSettingsWebsite = dynamic(() => import('./parts/ServerSettingsWebsite'), {
loading: () => <SettingsSkeleton />,
});
const ServerSettingsPWA = dynamic(() => import('./parts/ServerSettingsPWA'), {
loading: () => <SettingsSkeleton />,
});
export default function DashboardSettings() {
export default function DashboardServerSettings() {
const { data, isLoading, error } = useSWR<Response['/api/server/settings']>('/api/server/settings');
const [opened, { toggle }] = useDisclosure(false);
const scrollToSetting = useMemo(() => {
return (setting: string) => {
console.log('scrolling to setting:', setting);
const input = document.querySelector<HTMLInputElement>(`[data-path="${setting}"]`);
if (input) {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
observer.disconnect();
const parent = input.parentElement?.parentElement;
if (parent) {
parent.style.transition = 'transform 0.35s';
parent.style.transform = 'scale(1.2)';
setTimeout(() => {
parent.style.transform = 'scale(1)';
}, 350);
}
}
},
{ threshold: 1.0 },
);
observer.observe(input);
input.scrollIntoView({ behavior: 'smooth', block: 'center' });
input.focus();
}
};
}, []);
const onTamperedClick = (e: React.MouseEvent<HTMLAnchorElement>, setting: string) => {
e.preventDefault();
scrollToSetting(setting);
};
return (
<>
@@ -59,36 +71,61 @@ 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}>
<Anchor onClick={(e) => onTamperedClick(e, setting)}>{setting}</Anchor>
</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 }} />
<Suspense fallback={<SettingsSkeleton />}>
<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 }} />
<Stack>
<Website swr={{ data, isLoading }} />
<PWA swr={{ data, isLoading }} />
</Stack>
<Oauth swr={{ data, isLoading }} />
<ServerSettingsPWA swr={{ data, isLoading }} />
<HttpWebhook swr={{ data, isLoading }} />
<ServerSettingsHttpWebhook swr={{ data, isLoading }} />
</>
<Domains swr={{ data, isLoading }} />
</Suspense>
)}
</SimpleGrid>
<Stack mt='md' gap='md'>
{error ? null : <ServerSettingsDiscord swr={{ data, isLoading }} />}
{error ? null : <Discord swr={{ data, isLoading }} />}
</Stack>
</>
);
@@ -2,33 +2,40 @@ import { Response } from '@/lib/api/response';
import { Button, LoadingOverlay, 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 { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
export default function ServerSettingsChunks({
export default function Chunks({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
const router = useRouter();
const navigate = useNavigate();
const form = useForm({
initialValues: {
chunksEnabled: true,
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);
const onSubmit = settingsOnSubmit(navigate, form);
useEffect(() => {
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]);
@@ -2,16 +2,17 @@ import { Response } from '@/lib/api/response';
import { Button, LoadingOverlay, 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 { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
export default function ServerSettingsCore({
export default function Core({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
const router = useRouter();
const navigate = useNavigate();
const form = useForm<{
coreReturnHttpsUrls: boolean;
coreDefaultDomain: string | null | undefined;
@@ -22,6 +23,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) => {
@@ -31,14 +35,16 @@ export default function ServerSettingsCore({
values.coreDefaultDomain = values.coreDefaultDomain.trim();
}
return settingsOnSubmit(router, form)(values);
return settingsOnSubmit(navigate, form)(values);
};
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]);
@@ -13,18 +13,18 @@ import {
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
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 };
}) {
const router = useRouter();
const navigate = useNavigate();
const formMain = useForm({
initialValues: {
@@ -44,7 +44,7 @@ export default function ServerSettingsDiscord({
sendValues.discordAvatarUrl =
values.discordAvatarUrl?.trim() === '' ? null : values.discordAvatarUrl?.trim();
return settingsOnSubmit(router, formMain)(sendValues);
return settingsOnSubmit(navigate, formMain)(sendValues);
};
const formOnUpload = useForm({
@@ -65,6 +65,9 @@ export default function ServerSettingsDiscord({
discordOnUploadEmbedTimestamp: false,
discordOnUploadEmbedUrl: false,
},
enhanceGetInputProps: (payload) => ({
disabled: data?.tampered?.includes(payload.field) || false,
}),
});
const formOnShorten = useForm({
@@ -117,48 +120,52 @@ export default function ServerSettingsDiscord({
};
}
return settingsOnSubmit(router, type === 'upload' ? formOnUpload : formOnShorten)(sendValues);
return settingsOnSubmit(navigate, type === 'upload' ? formOnUpload : formOnShorten)(sendValues);
};
useEffect(() => {
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]);
@@ -0,0 +1,125 @@
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 { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
const DOMAIN_REGEX =
/^[a-zA-Z0-9][a-zA-Z0-9-_]{0,61}[a-zA-Z0-9]{0,1}\.([a-zA-Z]{1,6}|[a-zA-Z0-9-]{1,30}\.[a-zA-Z]{2,3})$/gim;
export default function Domains({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
const navigate = useNavigate();
const [domains, setDomains] = useState<string[]>([]);
const form = useForm({
initialValues: {
newDomain: '',
},
});
const onSubmit = settingsOnSubmit(navigate, 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;
if (!DOMAIN_REGEX.test(newDomain)) {
return form.setFieldError('newDomain', 'Invalid Domain');
}
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>
);
}
@@ -12,16 +12,17 @@ import {
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
export default function ServerSettingsFeatures({
export default function Features({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
const router = useRouter();
const navigate = useNavigate();
const form = useForm({
initialValues: {
featuresImageCompression: true,
@@ -38,25 +39,30 @@ export default function ServerSettingsFeatures({
featuresVersionChecking: true,
featuresVersionAPI: 'https://zipline-version.diced.sh/',
},
enhanceGetInputProps: (payload) => ({
disabled: data?.tampered?.includes(payload.field) || false,
}),
});
const onSubmit = settingsOnSubmit(router, form);
const onSubmit = settingsOnSubmit(navigate, form);
useEffect(() => {
if (!data) return;
form.setValues({
featuresImageCompression: data?.featuresImageCompression ?? true,
featuresRobotsTxt: data?.featuresRobotsTxt ?? true,
featuresHealthcheck: data?.featuresHealthcheck ?? true,
featuresUserRegistration: data?.featuresUserRegistration ?? false,
featuresOauthRegistration: data?.featuresOauthRegistration ?? true,
featuresDeleteOnMaxViews: data?.featuresDeleteOnMaxViews ?? true,
featuresThumbnailsEnabled: data?.featuresThumbnailsEnabled ?? true,
featuresThumbnailsNumberThreads: data?.featuresThumbnailsNumberThreads ?? 4,
featuresMetricsEnabled: data?.featuresMetricsEnabled ?? true,
featuresMetricsAdminOnly: data?.featuresMetricsAdminOnly ?? false,
featuresMetricsShowUserSpecific: data?.featuresMetricsShowUserSpecific ?? true,
featuresVersionChecking: data?.featuresVersionChecking ?? true,
featuresVersionAPI: data?.featuresVersionAPI ?? 'https://zipline-version.diced.sh/',
featuresImageCompression: data.settings.featuresImageCompression ?? true,
featuresRobotsTxt: data.settings.featuresRobotsTxt ?? true,
featuresHealthcheck: data.settings.featuresHealthcheck ?? true,
featuresUserRegistration: data.settings.featuresUserRegistration ?? false,
featuresOauthRegistration: data.settings.featuresOauthRegistration ?? true,
featuresDeleteOnMaxViews: data.settings.featuresDeleteOnMaxViews ?? true,
featuresThumbnailsEnabled: data.settings.featuresThumbnailsEnabled ?? true,
featuresThumbnailsNumberThreads: data.settings.featuresThumbnailsNumberThreads ?? 4,
featuresMetricsEnabled: data.settings.featuresMetricsEnabled ?? true,
featuresMetricsAdminOnly: data.settings.featuresMetricsAdminOnly ?? false,
featuresMetricsShowUserSpecific: data.settings.featuresMetricsShowUserSpecific ?? true,
featuresVersionChecking: data.settings.featuresVersionChecking ?? true,
featuresVersionAPI: data.settings.featuresVersionAPI ?? 'https://zipline-version.diced.sh/',
});
}, [data]);
@@ -12,16 +12,17 @@ import {
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
export default function ServerSettingsFiles({
export default function Files({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
const router = useRouter();
const navigate = useNavigate();
const form = useForm<{
filesRoute: string;
filesLength: number;
@@ -48,6 +49,9 @@ export default function ServerSettingsFiles({
filesRandomWordsNumAdjectives: 3,
filesRandomWordsSeparator: '-',
},
enhanceGetInputProps: (payload) => ({
disabled: data?.tampered?.includes(payload.field) || false,
}),
});
const onSubmit = async (values: typeof form.values) => {
@@ -76,22 +80,24 @@ export default function ServerSettingsFiles({
.filter((ext) => ext !== '');
}
return settingsOnSubmit(router, form)(values);
return settingsOnSubmit(navigate, form)(values);
};
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]);
@@ -2,21 +2,25 @@ import { Response } from '@/lib/api/response';
import { Button, LoadingOverlay, Paper, SimpleGrid, 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 { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
export default function ServerSettingsHttpWebhook({
export default function HttpWebhook({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
const router = useRouter();
const navigate = useNavigate();
const form = useForm({
initialValues: {
httpWebhookOnUpload: '',
httpWebhookOnShorten: '',
},
enhanceGetInputProps: (payload) => ({
disabled: data?.tampered?.includes(payload.field) || false,
}),
});
const onSubmit = async (values: typeof form.values) => {
@@ -30,15 +34,15 @@ export default function ServerSettingsHttpWebhook({
}
}
return settingsOnSubmit(router, form)(values);
return settingsOnSubmit(navigate, form)(values);
};
useEffect(() => {
if (!data) return;
form.setValues({
httpWebhookOnUpload: data?.httpWebhookOnUpload ?? '',
httpWebhookOnShorten: data?.httpWebhookOnShorten ?? '',
httpWebhookOnUpload: data.settings.httpWebhookOnUpload ?? '',
httpWebhookOnShorten: data.settings.httpWebhookOnShorten ?? '',
});
}, [data]);
@@ -2,31 +2,38 @@ import { Response } from '@/lib/api/response';
import { Button, LoadingOverlay, NumberInput, Paper, SimpleGrid, Switch, 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 { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
export default function ServerSettingsInvites({
export default function Invites({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
const router = useRouter();
const navigate = useNavigate();
const form = useForm({
initialValues: {
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);
const onSubmit = settingsOnSubmit(navigate, form);
useEffect(() => {
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 +57,6 @@ export default function ServerSettingsInvites({
placeholder='6'
min={1}
max={64}
disabled={!form.values.invitesEnabled}
{...form.getInputProps('invitesLength')}
/>
</SimpleGrid>
@@ -2,33 +2,37 @@ import { Response } from '@/lib/api/response';
import { Button, LoadingOverlay, 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 { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
export default function ServerSettingsMfa({
export default function Mfa({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
const router = useRouter();
const navigate = useNavigate();
const form = useForm({
initialValues: {
mfaTotpEnabled: false,
mfaTotpIssuer: 'Zipline',
mfaPasskeys: false,
},
enhanceGetInputProps: (payload) => ({
disabled: data?.tampered?.includes(payload.field) || false,
}),
});
const onSubmit = settingsOnSubmit(router, form);
const onSubmit = settingsOnSubmit(navigate, form);
useEffect(() => {
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]);
@@ -12,16 +12,17 @@ import {
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
export default function ServerSettingsOauth({
export default function Oauth({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
const router = useRouter();
const navigate = useNavigate();
const form = useForm({
initialValues: {
oauthBypassLocalLogin: false,
@@ -30,6 +31,8 @@ export default function ServerSettingsOauth({
oauthDiscordClientId: '',
oauthDiscordClientSecret: '',
oauthDiscordRedirectUri: '',
oauthDiscordAllowedIds: '',
oauthDiscordDeniedIds: '',
oauthGoogleClientId: '',
oauthGoogleClientSecret: '',
@@ -46,11 +49,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,36 +74,52 @@ 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);
return settingsOnSubmit(navigate, form)(values);
};
useEffect(() => {
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]);
@@ -119,22 +148,34 @@ export default function ServerSettingsOauth({
{...form.getInputProps('oauthLoginOnly', { type: 'checkbox' })}
/>
</SimpleGrid>
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
<Paper withBorder p='sm'>
<Anchor href='https://discord.com/developers/applications' target='_blank'>
<Title order={4} mb='sm'>
Discord
</Title>
</Anchor>
<TextInput label='Discord Client ID' {...form.getInputProps('oauthDiscordClientId')} />
<TextInput label='Discord Client Secret' {...form.getInputProps('oauthDiscordClientSecret')} />
<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.'
{...form.getInputProps('oauthDiscordRedirectUri')}
/>
</Paper>
<Paper withBorder p='sm' my='sm'>
<Anchor href='https://discord.com/developers/applications' target='_blank'>
<Title order={4} mb='sm'>
Discord
</Title>
</Anchor>
<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.'
{...form.getInputProps('oauthDiscordRedirectUri')}
/>
</Paper>
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
<Paper withBorder p='sm'>
<Anchor href='https://console.developers.google.com/' target='_blank'>
<Title order={4} mb='sm'>
@@ -150,16 +191,14 @@ export default function ServerSettingsOauth({
{...form.getInputProps('oauthGoogleRedirectUri')}
/>
</Paper>
</SimpleGrid>
<Paper withBorder p='sm' my='md'>
<Anchor href='https://github.com/settings/developers' target='_blank'>
<Title order={4} mb='sm'>
GitHub
</Title>
</Anchor>
<Paper withBorder p='sm'>
<Anchor href='https://github.com/settings/developers' target='_blank'>
<Title order={4} mb='sm'>
GitHub
</Title>
</Anchor>
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }} spacing='lg'>
<TextInput label='GitHub Client ID' {...form.getInputProps('oauthGithubClientId')} />
<TextInput label='GitHub Client Secret' {...form.getInputProps('oauthGithubClientSecret')} />
<TextInput
@@ -167,8 +206,8 @@ export default function ServerSettingsOauth({
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.'
{...form.getInputProps('oauthGithubRedirectUri')}
/>
</SimpleGrid>
</Paper>
</Paper>
</SimpleGrid>
<Paper withBorder p='sm' my='md'>
<Title order={4}>OpenID Connect</Title>
@@ -13,16 +13,17 @@ import {
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy, IconRefresh } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
export default function ServerSettingsPWA({
export default function PWA({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
const router = useRouter();
const navigate = useNavigate();
const form = useForm({
initialValues: {
pwaEnabled: false,
@@ -32,6 +33,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) => {
@@ -42,7 +49,7 @@ export default function ServerSettingsPWA({
sendValues.pwaDescription = values.pwaDescription.trim() === '' ? null : values.pwaDescription.trim();
return settingsOnSubmit(
router,
navigate,
form,
)({
...sendValues,
@@ -53,18 +60,20 @@ 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]);
return (
<Paper withBorder p='sm' pos='relative'>
<Paper withBorder p='sm' pos='relative' h='100%'>
<LoadingOverlay visible={isLoading} />
<Title order={2}>PWA</Title>
@@ -86,7 +95,6 @@ export default function ServerSettingsPWA({
label='Title'
description='The title for the PWA'
placeholder='Zipline'
disabled={!form.values.pwaEnabled}
{...form.getInputProps('pwaTitle')}
/>
@@ -94,7 +102,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 +109,6 @@ export default function ServerSettingsPWA({
label='Description'
description='The description for the PWA'
placeholder='Zipline'
disabled={!form.values.pwaEnabled}
{...form.getInputProps('pwaDescription')}
/>
@@ -110,7 +116,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 +123,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>
@@ -127,7 +131,7 @@ export default function ServerSettingsPWA({
<Button type='submit' loading={isLoading} leftSection={<IconDeviceFloppy size='1rem' />}>
Save
</Button>
<Button onClick={() => router.reload()} leftSection={<IconRefresh size='1rem' />}>
<Button onClick={() => window.location.reload()} leftSection={<IconRefresh size='1rem' />}>
Refresh
</Button>
</Group>
@@ -12,16 +12,17 @@ import {
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
export default function ServerSettingsRatelimit({
export default function Ratelimit({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
const router = useRouter();
const navigate = useNavigate();
const form = useForm<{
ratelimitEnabled: boolean;
ratelimitMax: number;
@@ -36,6 +37,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) => {
@@ -55,18 +62,18 @@ export default function ServerSettingsRatelimit({
values.ratelimitWindow = null;
}
return settingsOnSubmit(router, form)(values);
return settingsOnSubmit(navigate, form)(values);
};
useEffect(() => {
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 +98,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 +106,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,15 +114,13 @@ 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')}
/>
<TextInput
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}
placeholder='192.168.1.1, 127.0.0.1, 0.0.0.0'
{...form.getInputProps('ratelimitAllowList')}
/>
</SimpleGrid>
@@ -2,16 +2,17 @@ import { Response } from '@/lib/api/response';
import { Button, LoadingOverlay, Paper, SimpleGrid, Text, 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 { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
export default function ServerSettingsTasks({
export default function Tasks({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
const router = useRouter();
const navigate = useNavigate();
const form = useForm({
initialValues: {
tasksDeleteInterval: '30m',
@@ -20,19 +21,22 @@ export default function ServerSettingsTasks({
tasksThumbnailsInterval: '30m',
tasksMetricsInterval: '30m',
},
enhanceGetInputProps: (payload) => ({
disabled: data?.tampered?.includes(payload.field) || false,
}),
});
const onSubmit = settingsOnSubmit(router, form);
const onSubmit = settingsOnSubmit(navigate, form);
useEffect(() => {
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]);
@@ -2,31 +2,35 @@ import { Response } from '@/lib/api/response';
import { Button, LoadingOverlay, NumberInput, Paper, SimpleGrid, 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 { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit';
export default function ServerSettingsUrls({
export default function Urls({
swr: { data, isLoading },
}: {
swr: { data: Response['/api/server/settings'] | undefined; isLoading: boolean };
}) {
const router = useRouter();
const navigate = useNavigate();
const form = useForm({
initialValues: {
urlsRoute: '/go',
urlsLength: 6,
},
enhanceGetInputProps: (payload) => ({
disabled: data?.tampered?.includes(payload.field) || false,
}),
});
const onSubmit = settingsOnSubmit(router, form);
const onSubmit = settingsOnSubmit(navigate, form);
useEffect(() => {
if (!data) return;
form.setValues({
urlsRoute: data?.urlsRoute ?? '/go',
urlsLength: data?.urlsLength ?? 6,
urlsRoute: data.settings.urlsRoute ?? '/go',
urlsLength: data.settings.urlsLength ?? 6,
});
}, [data]);

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