Compare commits

...

91 Commits

Author SHA1 Message Date
diced
41e197ed4a feat(v3.7.12): version 2025-01-30 19:05:14 -08:00
diced
2f12b63753 fix: potential xss 2025-01-30 19:04:41 -08:00
diced
b5f09673ac fix: should fix ranged requests? 2025-01-22 23:00:48 -08:00
diced
eb71c2bb54 fix: s3 range requests 2025-01-09 22:54:03 -08:00
diced
f36ab9e7b6 feat(v3.7.11): version 2025-01-08 12:47:07 -08:00
diced
34a993fcc6 fix: oauth vulnerability 2025-01-08 12:46:41 -08:00
Jay
aa9f0796ab fix: Check if route was set to /r, as it's reserved. (#643) 2024-12-24 19:32:36 -08:00
ari
c0b2dda7da feat: proper range request handling (#635)
* fix: update to @types/node@18

this fixes the type error at  line 14 of lib/datasources/Local.ts

* feat: proper range request handling

* fix: docker casing warnings

* fix: infinity in header and cleanup

* fix: types for s3 and supabase size return value

* chore: remove unneeded newline

* chore: remove leftover dev comment

* fix: don't use 206 & content-range when client did not request it
2024-12-05 14:31:42 -08:00
diced
1e507bbf9c feat: export data as json 2024-11-26 17:51:31 -08:00
hegi
6271b800c2 fix(repo): update devcontainer defaults to use bundled postgres (#585)
Co-authored-by: Jayvin Hernandez <gogojayvin923@gmail.com>
Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2024-11-25 22:57:12 -08:00
Rovoska
effe1f9ec1 Update README.md (#627)
Fix documentation link to actually link to the docs
2024-10-19 23:37:19 -07:00
Guanzhong Chen
b6615621e1 fix: code scroll overflow handling (#620)
Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2024-10-16 10:59:16 -07:00
William Harrison
145b1ca727 feat(ci): push to docker hub (#613 #606)
* feat(ci): push to docker hub

* feat(ci): push non-release images
2024-09-21 23:29:04 -07:00
diced
6f75bbee7b fix: thumbnail generation for audio in video container 2024-09-19 00:36:19 -07:00
diced
58a4580cf0 fix: small images resizing for no reason 2024-09-17 15:16:03 -07:00
diced
48cfa41405 feat(v3.7.10): version 2024-09-12 11:56:05 -07:00
dicedtomato
9c26d64420 Merge commit from fork 2024-09-12 11:49:11 -07:00
diced
f3638f3d6d fix: delete file on maxViews in view route (#584) 2024-08-17 20:15:51 -07:00
polymo1
8e59158769 fix: hyprland is no longer wlroots-based (#581) 2024-08-05 17:45:47 -07:00
astrid
317c7365f8 fix: audio & video scrubbing (#576)
* fix video scrubbing

* fix scrubbing for audio as well
2024-07-19 12:40:52 -07:00
Matei Radu
974e9f7fa2 fix: fix flameshot script in readme (#575)
this commit fixes the json parsing in the example flameshot script. the previous example would just return a `jq` compile error
2024-07-15 14:27:54 -07:00
diced
4330bdcc4c fix: increment views on view/code routes (#572) 2024-07-12 12:22:01 -07:00
diced
7f9de82804 fix: apply loading and disabled to text upload button 2024-07-07 12:31:23 -07:00
diced
70050afb5f fix: ratelimit positioning 2024-07-07 11:02:53 -07:00
diced
1f00dd51f9 fix: thumbnails not showing up on folder view #563 2024-06-17 20:35:38 -07:00
diced
5e37d89b18 fix: latte & spelling 2024-06-07 18:11:01 -07:00
Seaswimmer
08d3bfb36d add various accenting colors 2024-06-07 16:07:45 -04:00
Seaswimmer
56f07cb5ec add Catppuccin themes 2024-06-07 15:47:07 -04:00
diced
658cc61df0 fix: order other user files by createdAt 2024-04-27 11:55:04 -07:00
diced
d3be545548 fix: prettier issue 2024-03-05 14:26:24 -08:00
reset
c8625c1e13 Merge pull request from GHSA-j2cw-9fvc-wr4r
https://github.com/diced/zipline/security/advisories/GHSA-j2cw-9fvc-wr4r
2024-03-05 14:22:35 -08:00
diced
511f17e1a5 feat(v3.7.9): version 2024-02-29 19:25:21 -08:00
diced
5b88b59724 fix: image resizing (#527) 2024-02-26 20:21:11 -08:00
diced
1816e13879 feat: ampm modifier for dates 2024-02-01 16:24:24 -08:00
diced
1a837c02d2 feat: auto-add to folder via api 2024-02-01 16:04:52 -08:00
diced
f3634eff48 fix: image resizing #527 2024-02-01 15:53:36 -08:00
diced
23ef407dd3 fix: bytesToHuman + bigint #532 2024-02-01 15:23:12 -08:00
diced
f40803f515 feat(v3.7.8): version 2024-01-04 23:53:24 -08:00
diced
6b97d30a69 fix: update copyright year 2024-01-04 23:27:08 -08:00
diced
bd8d4e33fd fix: max-width/height on image/video (#523) 2024-01-04 23:23:22 -08:00
Vetlix
70d48dd8c3 fix: prisma invite deletion errors (#522) (#520)
* fix: handle invite deletion error

* fix: handle url deletion error

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2023-12-24 21:12:54 -08:00
diced
2e0a5f1d9c feat: locale and tz options for localed date strings 2023-12-24 21:06:04 -08:00
Wingy
0ab814fc11 fix: better errors for expirations (#519)
* improve error handling for file expiry

* add missing semicolons

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2023-12-23 23:47:19 -08:00
Jayvin Hernandez
265760fb9c fix: merge create endpoint into register route (#517)
* fix: Merge create endpoint into register and prevent non admins from creating users.

* Why

* fix: Use `count` instead of `findMany` in consideration of RAM use.

* fix: Prevent repeats registers
2023-12-23 23:45:07 -08:00
diced
76ff3817af fix: apply mimetypes to s3 objects 2023-12-19 22:42:40 -08:00
Seaswimmer
0dfe3fdcd1 fix: ahk exts in mimes.json (#511)
* added autohotkey file extension (.ahk) to mimes.json

* added ahk1 and ahk2 file extensions

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2023-12-19 22:19:42 -08:00
William Harrison
5a522e0375 fix: typo (#513) 2023-12-19 22:17:45 -08:00
L7NEG
b15390f26c fix: remove pointless width/height tags (#509)
* Fix Discord Embed Res Bug

* Fixed Video Embed Res For Discord Mobile

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2023-12-11 22:01:28 -08:00
diced
6fef197620 fix: thumbnail not showing on folders (#510) 2023-12-11 21:34:16 -08:00
diced
1d0bb2fa4f fix: folder bigint (#505) 2023-12-05 15:51:30 -08:00
diced
abb5bb5f25 fix: align image (if present) to center #503 2023-12-05 15:48:07 -08:00
diced
4061da8622 feat(v3.7.7): version 2023-11-21 20:19:37 -08:00
diced
6ef3c8274b fix: password protected non-media files (#496) 2023-11-21 20:10:09 -08:00
Florian Gareis
e5ac971c8f fix: styling on view file/upload file (#494)
Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2023-11-21 19:48:16 -08:00
diced
b4ec1088d1 fix: update to prisma@5.1.x 2023-11-21 19:41:58 -08:00
diced
fe50bebeba feat(v3.7.6): version 2023-11-20 21:31:08 -08:00
Kashall
1f61c56f83 fix: pg int -> bigint (#484) (#459)
* fix: prisma int supports up to 2gb. Bigint supports a lot more than 2gb.

* yes, i ran `yarn migrate:dev`

* fix: cannot assign bigint to number

* fix: 'bigint' is not assignable to type 'number'.

* fix: 'bigint' is not assignable to type 'number'.

* jesus christ

* Well okay then next
2023-11-20 20:59:40 -08:00
neomoth
cabf932ca0 fix: flameshot abort (#490)
Exit script should file be 0B (aborted screenshot)

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2023-11-20 20:44:58 -08:00
diced
f6b995c28d feat: update pkgs & fix lint errors 2023-11-20 20:37:52 -08:00
diced
13a19ccd2b fix: build errors 2023-11-19 17:03:23 -08:00
diced
d1dea0cd92 fix: why did this randomly become a promise 2023-11-10 19:31:15 -08:00
diced
b39507b9a8 fix: actually fix dupe files #467 2023-11-10 19:24:45 -08:00
diced
633dfd4712 feat(v3.7.5): version 2023-11-05 22:35:12 -08:00
Digital
e6ed7a36d5 feat: whitelisted discord & redirect uri oauth (#469)
* Allow Redirect URI Configuration

* Prettier

* Add Whitelisted Users

* Update discord.ts

* Whitespace

* Whitespace

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2023-11-05 22:01:43 -08:00
diced
93cb9eec4c fix: overwriting existing files #467 2023-11-05 21:53:16 -08:00
diced
4849cd8221 fix: add warning when wordlist missing #478 2023-11-04 15:24:00 -07:00
diced
89c58044a3 fix: imported files incl size #468 2023-11-04 15:20:37 -07:00
diced
40fb11256f fix: non-english characters encoding (#471) 2023-10-08 11:06:16 -07:00
diced
d112c3a509 feat/fix: UPLOADER_RANDOM_WORDS_sEPERATOR 2023-09-29 20:06:18 -07:00
Jayvin Hernandez
23af36563f fix: no size on folders page (#465) 2023-09-29 19:53:06 -07:00
Kashall
28db15eb77 fix: util method to check if variable is not null (#458)
* chore: fix oauth truthyness

* chore: remove unused util function

* chore: lint

---------

Co-authored-by: Jayvin Hernandez <gogojayvin923@gmail.com>
2023-09-09 09:57:00 -07:00
Lucas Reis
e9054bd3e5 fix: og video type (#462) 2023-09-09 09:54:13 -07:00
diced
713f857e28 feat(v3.7.4): version 2023-08-29 15:36:43 -07:00
diced
5d6768029f fix: WEBSITE_SHOW_VERSION=false works now (#450) 2023-08-29 15:33:20 -07:00
Jayvin Hernandez
72e24a8b86 fix: trailing spaces giphy (#449) 2023-08-29 15:25:26 -07:00
diced
86c3e780d1 fix: docker size optimizations 2023-08-12 23:58:19 -07:00
diced
5102620953 fix: letters cut off #448 2023-08-08 23:48:26 -07:00
diced
4d728f9f8b fix: domain 2023-08-08 23:46:46 -07:00
dicedtomato
faf5098357 feat(v3..7.3): version 2023-07-31 19:06:28 -07:00
diced
c4066fc851 fix: change domains 2023-07-31 18:28:21 -07:00
dicedtomato
22633b8601 feat(v3.7.2): version 2023-07-31 18:12:18 -07:00
diced
b873f99d46 fix: disable chunked upload fr (#446) 2023-07-28 16:25:03 -07:00
diced
a60d9c58b8 fix: include stuff in docker build 2023-07-27 11:12:11 -07:00
Jayvin Hernandez
a2562c5ea2 fix: a lot of stuff (#441): (#410) (#440)
* fix: Compression setting works with the right files

* chore: clean up a debug log

* chore: add debug for why not webhook'd

* fix: No more temp invite when registering.

* fix: Add an error catch so server doesn't crash.

* chore: miniscule cleanup

* fix: Don't double fix dates. It was already fixed once.
2023-07-26 15:07:13 -07:00
diced
1c674d3d9f fix: unset ZIPLINE_DOCKER_BUILD in entrypoint 2023-07-24 11:26:12 -07:00
diced
fb32e9f38e fix: test caching 2023-07-23 21:38:52 -07:00
diced
6babf73e07 fix: docker caching 2023-07-23 21:06:03 -07:00
diced
d0eb442fdf fix: add debug/error logs to loadThumbnail 2023-07-23 20:36:20 -07:00
diced
d3cb9118ce fix: passing incompatible objects to workers 2023-07-22 22:39:09 -07:00
diced
7ec6d566b8 fix: readme updates 2023-07-16 11:48:08 -07:00
diced
d695211030 feat: modifier for bytes 2023-07-16 11:15:47 -07:00
110 changed files with 5623 additions and 4694 deletions

View File

@@ -7,4 +7,6 @@ RUN usermod -l zipline node \
&& chmod 0440 /etc/sudoers.d/zipline \
&& sudo apt-get update && apt-get install gnupg2 -y
USER zipline
EXPOSE 3000
USER zipline

View File

@@ -41,7 +41,7 @@
"remoteUser": "zipline",
"updateRemoteUserUID": true,
"remoteEnv": {
"CORE_DATABASE_URL": "postgres://postgres:postgres@localhost/zip10"
"CORE_DATABASE_URL": "postgres://postgres:postgres@db/zip10"
},
"portsAttributes": {
"3000": {

View File

@@ -7,7 +7,7 @@ CORE_RETURN_HTTPS=true
CORE_SECRET="changethis"
CORE_HOST=0.0.0.0
CORE_PORT=3000
CORE_DATABASE_URL="postgres://postgres:postgres@localhost/zip10"
CORE_DATABASE_URL="postgres://postgres:postgres@db/zip10"
CORE_LOGGER=false
CORE_STATS_INTERVAL=1800
CORE_INVITES_INTERVAL=1800
@@ -47,4 +47,4 @@ URLS_LENGTH=6
RATELIMIT_USER=5
RATELIMIT_ADMIN=3
# for more variables checkout the docs
# for more variables checkout the docs

View File

@@ -7,5 +7,5 @@ contact_links:
url: https://discord.gg/EAhCRfGxCF
about: Ask for help with anything related to Zipline!
- name: Zipline Docs
url: https://zipline.diced.tech
url: https://zipline.diced.sh
about: Maybe take a look a the docs?

View File

@@ -13,8 +13,8 @@ on:
workflow_dispatch:
jobs:
push_to_ghcr:
name: Push Release Image to GitHub Packages
push:
name: Push Release Image
runs-on: ubuntu-latest
steps:
- name: Check out the repo
@@ -32,14 +32,20 @@ jobs:
id: buildx
uses: docker/setup-buildx-action@v2
- name: Login to Github Packages
- name: Login to GitHub Packages
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker Image
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Docker Image
uses: docker/build-push-action@v3
with:
push: true
@@ -47,5 +53,7 @@ jobs:
tags: |
ghcr.io/diced/zipline:latest
ghcr.io/diced/zipline:${{ steps.version.outputs.zipline_version }}
${{ secrets.DOCKERHUB_USERNAME }}/zipline:latest
${{ secrets.DOCKERHUB_USERNAME }}/zipline:${{ steps.version.outputs.zipline_version }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -12,8 +12,8 @@ on:
workflow_dispatch:
jobs:
push_to_ghcr:
name: Push Image to GitHub Packages
push:
name: Push Image
runs-on: ubuntu-latest
steps:
- name: Check out the repo
@@ -38,7 +38,13 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker Image
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Docker Image
uses: docker/build-push-action@v3
with:
push: true
@@ -46,5 +52,7 @@ jobs:
tags: |
ghcr.io/diced/zipline:trunk
ghcr.io/diced/zipline:trunk-${{ steps.version.outputs.zipline_version }}
${{ secrets.DOCKERHUB_USERNAME }}/zipline:trunk
${{ secrets.DOCKERHUB_USERNAME }}/zipline:trunk-${{ steps.version.outputs.zipline_version }}
cache-from: type=gha
cache-to: type=gha,mode=max

0
.yarn/releases/yarn-3.3.1.cjs vendored Executable file → Normal file
View File

View File

@@ -1,22 +1,14 @@
# Use the Prisma binaries image as the first stage
FROM ghcr.io/diced/prisma-binaries:4.10.x as prisma
FROM ghcr.io/diced/prisma-binaries:5.1.x AS prisma
# Use Alpine Linux as the second stage
FROM node:18-alpine3.16 as base
FROM node:18-alpine3.16 AS base
# Set the working directory
WORKDIR /zipline
# Copy the necessary files from the project
COPY prisma ./prisma
COPY src ./src
COPY next.config.js ./next.config.js
COPY tsup.config.ts ./tsup.config.ts
COPY tsconfig.json ./tsconfig.json
COPY mimes.json ./mimes.json
COPY public ./public
FROM base as builder
COPY .yarn ./.yarn
COPY package*.json ./
@@ -26,51 +18,59 @@ COPY .yarnrc.yml ./
# Copy the prisma binaries from prisma stage
COPY --from=prisma /prisma-engines /prisma-engines
ENV PRISMA_QUERY_ENGINE_BINARY=/prisma-engines/query-engine \
PRISMA_MIGRATION_ENGINE_BINARY=/prisma-engines/migration-engine \
PRISMA_INTROSPECTION_ENGINE_BINARY=/prisma-engines/introspection-engine \
PRISMA_FMT_BINARY=/prisma-engines/prisma-fmt \
PRISMA_SCHEMA_ENGINE_BINARY=/prisma-engines/schema-engine \
PRISMA_CLI_QUERY_ENGINE_TYPE=binary \
PRISMA_CLIENT_ENGINE_TYPE=binary \
ZIPLINE_DOCKER_BUILD=true \
NEXT_TELEMETRY_DISABLED=1
# Install production dependencies then temporarily save
RUN yarn workspaces focus --production --all
RUN cp -RL node_modules /tmp/node_modules
# Install the dependencies
RUN yarn install --immutable
FROM base AS builder
COPY src ./src
COPY next.config.js ./next.config.js
COPY tsup.config.ts ./tsup.config.ts
COPY tsconfig.json ./tsconfig.json
COPY mimes.json ./mimes.json
COPY public ./public
# Run the build
RUN yarn build
# Use Alpine Linux as the final image
FROM base
# Install the necessary packages
RUN apk add --no-cache perl procps tini
COPY --from=builder /prisma-engines /prisma-engines
COPY --from=prisma /prisma-engines /prisma-engines
ENV PRISMA_QUERY_ENGINE_BINARY=/prisma-engines/query-engine \
PRISMA_MIGRATION_ENGINE_BINARY=/prisma-engines/migration-engine \
PRISMA_INTROSPECTION_ENGINE_BINARY=/prisma-engines/introspection-engine \
PRISMA_FMT_BINARY=/prisma-engines/prisma-fmt \
PRISMA_SCHEMA_ENGINE_BINARY=/prisma-engines/schema-engine \
PRISMA_CLI_QUERY_ENGINE_TYPE=binary \
PRISMA_CLIENT_ENGINE_TYPE=binary \
ZIPLINE_DOCKER_BUILD=true \
NEXT_TELEMETRY_DISABLED=1
# Copy only the necessary files from the previous stage
COPY --from=builder /zipline/dist ./dist
COPY --from=builder /zipline/.next ./.next
COPY --from=builder /zipline/package.json ./package.json
COPY --from=builder /zipline/node_modules ./node_modules
COPY --from=builder /zipline/node_modules/.prisma/client ./node_modules/.prisma/client
COPY --from=builder /zipline/node_modules/@prisma/client ./node_modules/@prisma/client
COPY --from=builder /zipline/mimes.json ./mimes.json
COPY --from=builder /zipline/next.config.js ./next.config.js
COPY --from=builder /zipline/public ./public
# Copy Startup Script
COPY docker-entrypoint.sh /zipline
# Make Startup Script Executable
RUN chmod a+x /zipline/docker-entrypoint.sh && rm -rf /zipline/src
# Clean up
RUN rm -rf /tmp/* /root/*
RUN yarn cache clean --all
# Set the entrypoint to the startup script
ENTRYPOINT ["tini", "--", "/zipline/docker-entrypoint.sh"]

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2023 dicedtomato
Copyright (c) 2024 dicedtomato
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -35,17 +35,9 @@ A ShareX/file upload server that is easy to use, packed with features, and with
- User invites
- File Chunking (for large files)
- File deletion once it reaches a certain amount of views
- Automatic video thumbnail generation
- Easy setup instructions on [docs](https://zipl.vercel.app/) (One command install `docker compose up -d`)
<details>
<summary>View upstream documentation</summary>
The website below provides documentation for more up-to-date features with the upstream branch. The normal documentation is for the latest release and is not updated unless a new release is made.
[https://trunk.zipline.diced.tech/](https://trunk.zipline.diced.tech/)
</details>
<details>
<summary><h2>Screenshots (click)</h2></summary>
@@ -76,17 +68,18 @@ Ways you could generate the string could be from a password managers generator,
## Building & running from source
This section requires [nodejs](https://nodejs.org), [yarn](https://yarnpkg.com/) or [npm](https://npmjs.com).
This section requires [nodejs](https://nodejs.org), [yarn](https://yarnpkg.com/).
It is recommended to not use npm, as it can cause issues with the build process.
Before you run `yarn build`, you might want to configure Zipline, as when building from source Zipline will need to read some sort of configuration. The only two variables needed are `CORE_SECRET` and `CORE_DATABASE_URL`.
```shell
git clone https://github.com/diced/zipline
cd zipline
# npm install
yarn install
# npm run build
yarn build
# npm start
yarn start
```
@@ -119,7 +112,7 @@ This section requires [ShareX](https://www.getsharex.com/).
After navigating to Zipline, click on the top right corner where it says your username and click Manage Account. Scroll down to see "ShareX Config", select the one you would prefer using. After this you can import the .sxcu into sharex. [More information here](https://zipl.vercel.app/docs/guides/uploaders/sharex)
# Flameshot (Linux)
# Flameshot (Linux(Xorg/Wayland) and macOS)
This section requires [Flameshot](https://www.flameshot.org/), [jq](https://stedolan.github.io/jq/), and [xsel](https://github.com/kfish/xsel).
@@ -128,12 +121,19 @@ This section requires [Flameshot](https://www.flameshot.org/), [jq](https://sted
If using wayland you will need to have [wl-clipboard](https://github.com/bugaevc/wl-clipboard) installed, for the `wl-copy` command.
If you are not using GNOME/KDE/Qtile/Sway, and are using something like a wlroots-based compositor (ex. [Hyprland](https://github.com/hyprwm/Hyprland/), [River](https://github.com/riverwm/river), etc), you will need to set the `XDG_CURRENT_DESKTOP` environment variable to `sway`, which will just override it for this script. Adding `export XDG_CURRENT_DESKTOP=sway` to the start of the script will work.
If you are not using GNOME/KDE/Qtile/Sway, and are using something like a wlroots-based or wlroots-compatible compositor (ex. [Hyprland](https://github.com/hyprwm/Hyprland/), [River](https://github.com/riverwm/river), etc), you will need to set the `XDG_CURRENT_DESKTOP` environment variable to `sway`, which will just override it for this script. Adding `export XDG_CURRENT_DESKTOP=sway` to the start of the script will work.
After this, replace the `xsel -ib` with `wl-copy` in the script.
</details>
<details>
<summary>Mac instructions</summary>
If using macOS, you can replace the `xsel -ib` with `pbcopy` in the script.
</details>
You can either use the script below, or generate one directly from Zipline (just like how you can generate a ShareX config).
To upload files using flameshot we will use a script. Replace $TOKEN and $HOST with your own values, you probably know how to do this if you use linux.
@@ -141,7 +141,7 @@ To upload files using flameshot we will use a script. Replace $TOKEN and $HOST w
DATE=$(date '+%h_%Y_%d_%I_%m_%S.png');
flameshot gui -r > ~/Pictures/$DATE;
curl -H "Content-Type: multipart/form-data" -H "authorization: $TOKEN" -F file=@$1 $HOST/api/upload | jq -r 'files[0].url' | xsel -ib
curl -H "Content-Type: multipart/form-data" -H "authorization: $TOKEN" -F file=@$1 $HOST/api/upload | jq -r '.files[0]' | xsel -ib
```
# Contributing
@@ -166,3 +166,7 @@ Create a discussion on GitHub, please include the following:
## Pull Requests (contributions to the codebase)
Create a pull request on GitHub. If your PR does not pass the action checks, then please fix the errors. If your PR was submitted before a release, and I have pushed a new release, please make sure to update your PR to reflect any changes, usually this is handled by GitHub.
# Documentation
Documentation source code is located in [diced/zipline-docs](https://github.com/diced/zipline-docs), and can be accessed [here](https://zipline.diced.sh/docs/get-started).

View File

@@ -2,4 +2,6 @@
set -e
unset ZIPLINE_DOCKER_BUILD
node --enable-source-maps dist/index.js

View File

@@ -42,6 +42,9 @@
["afm", ["application/octet-stream"]],
["afp", ["application/vnd.ibm.modcap"]],
["ahead", ["application/vnd.ahead.space"]],
["ahk", ["text/autohotkey"]],
["ahk1", ["text/autohotkey"]],
["ahk2", ["text/autohotkey"]],
["ai", ["application/postscript"]],
["aif", ["audio/aiff"]],
["aifc", ["audio/aiff"]],

View File

@@ -1,6 +1,6 @@
{
"name": "zipline",
"version": "3.7.1",
"version": "3.7.12",
"license": "MIT",
"scripts": {
"dev": "npm-run-all build:server dev:run",
@@ -28,73 +28,73 @@
"scripts:clear-temp": "node --enable-source-maps dist/scripts/clear-temp"
},
"dependencies": {
"@emotion/react": "^11.10.6",
"@emotion/server": "^11.10.0",
"@mantine/core": "^6.0.4",
"@mantine/dropzone": "^6.0.4",
"@mantine/form": "^6.0.4",
"@mantine/hooks": "^6.0.4",
"@mantine/modals": "^6.0.4",
"@mantine/next": "^6.0.4",
"@mantine/notifications": "^6.0.4",
"@mantine/prism": "^6.0.4",
"@mantine/spotlight": "^6.0.4",
"@prisma/client": "^4.10.1",
"@prisma/internals": "^4.10.1",
"@prisma/migrate": "^4.10.1",
"@sapphire/shapeshift": "^3.8.1",
"@tabler/icons-react": "^2.11.0",
"@emotion/react": "^11.11.1",
"@emotion/server": "^11.11.0",
"@mantine/core": "^6.0.21",
"@mantine/dropzone": "^6.0.21",
"@mantine/form": "^6.0.21",
"@mantine/hooks": "^6.0.21",
"@mantine/modals": "^6.0.21",
"@mantine/next": "^6.0.21",
"@mantine/notifications": "^6.0.21",
"@mantine/prism": "^6.0.21",
"@mantine/spotlight": "^6.0.21",
"@prisma/client": "^5.1.1",
"@prisma/internals": "^5.1.1",
"@prisma/migrate": "^5.1.1",
"@sapphire/shapeshift": "^3.9.3",
"@tabler/icons-react": "^2.41.0",
"@tanstack/react-query": "^4.28.0",
"argon2": "^0.30.3",
"cookie": "^0.5.0",
"dayjs": "^1.11.7",
"dotenv": "^16.0.3",
"argon2": "^0.31.2",
"cookie": "^0.6.0",
"dayjs": "^1.11.10",
"dotenv": "^16.3.1",
"dotenv-expand": "^10.0.0",
"exiftool-vendored": "^21.2.0",
"fastify": "^4.15.0",
"fastify-plugin": "^4.5.0",
"fflate": "^0.7.4",
"ffmpeg-static": "^5.1.0",
"find-my-way": "^7.6.0",
"katex": "^0.16.4",
"mantine-datatable": "^2.2.6",
"minio": "^7.0.33",
"exiftool-vendored": "^23.4.0",
"fastify": "^4.24.3",
"fastify-plugin": "^4.5.1",
"fflate": "^0.8.1",
"ffmpeg-static": "^5.2.0",
"find-my-way": "^7.7.0",
"katex": "^0.16.9",
"mantine-datatable": "^2.9.14",
"minio": "^7.1.3",
"ms": "canary",
"multer": "^1.4.5-lts.1",
"next": "^13.2.4",
"next": "^14.0.3",
"otplib": "^12.0.1",
"prisma": "^4.10.1",
"prisma": "^5.1.1",
"prismjs": "^1.29.0",
"qrcode": "^1.5.1",
"qrcode": "^1.5.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^8.0.6",
"recharts": "^2.5.0",
"recharts": "^2.10.1",
"recoil": "^0.7.7",
"remark-gfm": "^3.0.1",
"sharp": "^0.32.0"
"remark-gfm": "^4.0.0",
"sharp": "^0.32.6"
},
"devDependencies": {
"@types/cookie": "^0.5.1",
"@types/katex": "^0.16.0",
"@types/minio": "^7.0.17",
"@types/multer": "^1.4.7",
"@types/node": "^18.15.10",
"@types/qrcode": "^1.5.0",
"@types/react": "^18.0.29",
"@types/sharp": "^0.31.1",
"@typescript-eslint/eslint-plugin": "^5.56.0",
"@typescript-eslint/parser": "^5.56.0",
"@types/cookie": "^0.5.4",
"@types/katex": "^0.16.6",
"@types/minio": "^7.1.1",
"@types/multer": "^1.4.10",
"@types/node": "18",
"@types/qrcode": "^1.5.5",
"@types/react": "^18.2.37",
"@types/sharp": "^0.32.0",
"@typescript-eslint/eslint-plugin": "^6.11.0",
"@typescript-eslint/parser": "^6.11.0",
"cross-env": "^7.0.3",
"eslint": "^8.36.0",
"eslint-config-next": "^13.2.4",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-unused-imports": "^2.0.0",
"eslint": "^8.54.0",
"eslint-config-next": "^14.0.3",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.1",
"eslint-plugin-unused-imports": "^3.0.0",
"npm-run-all": "^4.1.5",
"prettier": "^2.8.7",
"tsup": "^6.7.0",
"typescript": "^5.0.2"
"prettier": "^3.1.0",
"tsup": "^8.0.0",
"typescript": "^5.2.2"
},
"repository": {
"type": "git",

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "File" ALTER COLUMN "size" SET DATA TYPE BIGINT;

View File

@@ -0,0 +1,14 @@
-- CreateTable
CREATE TABLE "Export" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"complete" BOOLEAN NOT NULL DEFAULT false,
"path" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
CONSTRAINT "Export_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Export" ADD CONSTRAINT "Export_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -27,6 +27,20 @@ model User {
Invite Invite[]
Folder Folder[]
IncompleteFile IncompleteFile[]
Exports Export[]
}
model Export {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
complete Boolean @default(false)
path String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
}
model Folder {
@@ -48,7 +62,7 @@ model File {
originalName String?
mimetype String @default("image/png")
createdAt DateTime @default(now())
size Int @default(0)
size BigInt @default(0)
expiresAt DateTime?
maxViews Int?
views Int @default(0)
@@ -63,7 +77,7 @@ model File {
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
folderId Int?
thumbnail Thumbnail?
thumbnail Thumbnail?
}
model Thumbnail {

View File

@@ -1498,4 +1498,4 @@ wheat
white
whitesmoke
yellow
yellowgreen
yellowgreen

View File

@@ -1747,4 +1747,4 @@ zigzagsalamander
zonetailedpigeon
zooplankton
zopilote
zorilla
zorilla

View File

@@ -125,7 +125,7 @@ export default function FileModal({
icon: <IconPhotoCancel size='1rem' />,
});
},
}
},
);
};

View File

@@ -72,6 +72,9 @@ export default function File({
},
transition: 'filter 0.2s ease-in-out',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
shadow='md'
onClick={() => setOpen(true)}

View File

@@ -280,7 +280,7 @@ export default function Layout({ children, props }) {
component={Link}
href={link}
/>
)
),
)}
</Navbar.Section>
<Navbar.Section>
@@ -356,10 +356,15 @@ export default function Layout({ children, props }) {
)
}
variant='subtle'
color='gray'
color={theme.colorScheme === 'dark' ? 'dark' : 'gray'}
compact
size='xl'
p='sm'
styles={{
label: {
overflow: 'unset',
},
}}
>
{user.username}
</Button>
@@ -411,16 +416,20 @@ export default function Layout({ children, props }) {
</Menu.Item>
<Menu.Divider />
<>
{oauth_providers.filter((x) =>
user.oauth?.map(({ provider }) => provider.toLowerCase()).includes(x.name.toLowerCase())
{oauth_providers.filter(
(x) =>
user.oauth
?.map(({ provider }) => provider.toLowerCase())
.includes(x.name.toLowerCase()),
).length ? (
<Menu.Label>Connected Accounts</Menu.Label>
) : null}
{oauth_providers
.filter((x) =>
user.oauth
?.map(({ provider }) => provider.toLowerCase())
.includes(x.name.toLowerCase())
.filter(
(x) =>
user.oauth
?.map(({ provider }) => provider.toLowerCase())
.includes(x.name.toLowerCase()),
)
.map(({ name, Icon }, i) => (
<>
@@ -433,8 +442,11 @@ export default function Layout({ children, props }) {
</Menu.Item>
</>
))}
{oauth_providers.filter((x) =>
user.oauth?.map(({ provider }) => provider.toLowerCase()).includes(x.name.toLowerCase())
{oauth_providers.filter(
(x) =>
user.oauth
?.map(({ provider }) => provider.toLowerCase())
.includes(x.name.toLowerCase()),
).length ? (
<Menu.Divider />
) : null}

View File

@@ -4,6 +4,10 @@ import { useEffect } from 'react';
import ayu_dark from 'lib/themes/ayu_dark';
import ayu_light from 'lib/themes/ayu_light';
import ayu_mirage from 'lib/themes/ayu_mirage';
import catppuccin_mocha from 'lib/themes/catppuccin_mocha';
import catppuccin_macchiato from 'lib/themes/catppuccin_macchiato';
import catppuccin_frappe from 'lib/themes/catppuccin_frappe';
import catppuccin_latte from 'lib/themes/catppuccin_latte';
import dark from 'lib/themes/dark';
import dark_blue from 'lib/themes/dark_blue';
import dracula from 'lib/themes/dracula';
@@ -32,6 +36,10 @@ export const themes = {
ayu_dark,
ayu_mirage,
ayu_light,
catppuccin_mocha,
catppuccin_macchiato,
catppuccin_frappe,
catppuccin_latte,
nord,
dracula,
matcha_dark_azul,
@@ -46,6 +54,10 @@ export const friendlyThemeName = {
ayu_dark: 'Ayu Dark',
ayu_mirage: 'Ayu Mirage',
ayu_light: 'Ayu Light',
catppuccin_mocha: 'Catppuccin Mocha',
catppuccin_macchiato: 'Catppuccin Macchiato',
catppuccin_frappe: 'Catppuccin Frappé',
catppuccin_latte: 'Catppuccin Latte',
nord: 'Nord',
dracula: 'Dracula',
matcha_dark_azul: 'Matcha Dark Azul',

View File

@@ -27,7 +27,7 @@ import PrismCode from './render/PrismCode';
function PlaceholderContent({ text, Icon }) {
return (
<Group sx={(t) => ({ color: t.colors.dark[2] })}>
<Group sx={(t) => ({ color: t.colors.dark[2], padding: 3, justifyContent: 'center' })}>
<Icon size={48} />
<Text size='md'>{text}</Text>
</Group>
@@ -60,7 +60,7 @@ function VideoThumbnailPlaceholder({ file, mediaPreview, ...props }) {
return (
<Box sx={{ position: 'relative' }}>
<Image
src={file.thumbnail}
src={typeof file.thumbnail === 'string' ? file.thumbnail : `/r/${file.thumbnail.name}`}
sx={{
width: '100%',
height: 'auto',

View File

@@ -12,7 +12,7 @@ export default function Dropzone({ loading, onDrop, children }) {
]}
>
<MantineDropzone loading={loading} onDrop={onDrop} styles={{ inner: { pointerEvents: 'none' } }}>
<Group position='center' spacing='xl' style={{ minHeight: 440 }}>
<Group position='center' spacing='xl' style={{ minHeight: 440, flexDirection: 'column' }}>
<IconPhoto size={80} />
<Text size='xl' inline>

View File

@@ -29,7 +29,7 @@ export default function FilePagation({ disableMediaPreview, exifEnabled, queryPa
},
},
undefined,
{ shallow: true }
{ shallow: true },
);
const { count } = await useFetch(`/api/user/paged?count=true${!checked ? '&filter=media' : ''}`);

View File

@@ -50,7 +50,7 @@ function CreateInviteModal({ open, setOpen, updateInvites }) {
if (!expires.includes(values.expires)) return form.setFieldError('expires', 'Invalid expiration');
if (values.count < 1 || values.count > 100)
return form.setFieldError('count', 'Must be between 1 and 100');
const expiresAt = values.expires === 'never' ? null : expireReadToDate(values.expires);
const expiresAt = expireReadToDate(values.expires);
setOpen(false);

View File

@@ -75,7 +75,7 @@ export default function Flameshot({ user, open, setOpen }) {
let shell;
if (values.type === 'upload-file') {
shell = `#!/bin/bash${values.wlCompositorNotSupported ? '\nexport XDG_CURRENT_DESKTOP=sway\n' : ''}
flameshot gui -r > /tmp/ss.png;
flameshot gui -r > /tmp/ss.png;if [ ! -s /tmp/ss.png ]; then\n exit 1\nfi
${curl.join(' ')}${values.noJSON ? '' : " | jq -r '.files[0]'"} | tr -d '\\n' | ${
values.wlCompatibility ? 'wl-copy' : 'xsel -ib'
};

View File

@@ -87,7 +87,7 @@ export default function ShareX({ user, open, setOpen }) {
const pseudoElement = document.createElement('a');
pseudoElement.setAttribute(
'href',
'data:application/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(config, null, '\t'))
'data:application/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(config, null, '\t')),
);
pseudoElement.setAttribute('download', `zipline${values.type === 'upload-file' ? '' : '-url'}.sxcu`);
pseudoElement.style.display = 'none';

View File

@@ -1,14 +1,17 @@
import {
ActionIcon,
Alert,
Anchor,
Box,
Button,
Card,
Code,
ColorInput,
CopyButton,
FileInput,
Group,
Image,
List,
PasswordInput,
SimpleGrid,
Space,
@@ -22,6 +25,7 @@ import { randomId, useInterval, useMediaQuery } from '@mantine/hooks';
import { useModals } from '@mantine/modals';
import { showNotification, updateNotification } from '@mantine/notifications';
import {
IconAlertCircle,
IconBrandDiscordFilled,
IconBrandGithubFilled,
IconBrandGoogle,
@@ -41,6 +45,7 @@ import {
IconUserExclamation,
IconUserMinus,
IconUserX,
IconX,
} from '@tabler/icons-react';
import AnchorNext from 'components/AnchorNext';
import { FlameshotIcon, ShareXIcon } from 'components/icons';
@@ -264,14 +269,34 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
setExports(
res.exports
?.map((s) => ({
date: new Date(Number(s.name.split('_')[3].slice(0, -4))),
date: new Date(s.createdAt),
size: s.size,
full: s.name,
}))
.sort((a, b) => a.date.getTime() - b.date.getTime())
.sort((a, b) => a.date.getTime() - b.date.getTime()),
);
};
const deleteExport = async (name) => {
const res = await useFetch('/api/user/export?name=' + name, 'DELETE');
if (res.error) {
showNotification({
title: 'Error deleting export',
message: res.error,
color: 'red',
icon: <IconX size='1rem' />,
});
} else {
showNotification({
message: 'Deleted export',
color: 'green',
icon: <IconFileZip size='1rem' />,
});
await getExports();
}
};
const handleDelete = async () => {
const res = await useFetch('/api/user/files', 'DELETE', {
all: true,
@@ -355,6 +380,129 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
}
};
const startFullExport = () => {
modals.openConfirmModal({
title: <Title>Are you sure?</Title>,
size: 'xl',
children: (
<Box px='md'>
<Alert color='red' icon={<IconAlertCircle size='1rem' />} title='Warning'>
This export contains a significant amount of sensitive data, including user information,
passwords, metadata, and system details. It is crucial to handle this file with care to prevent
unauthorized access or misuse. Ensure it is stored securely and shared only with trusted parties.
</Alert>
<p>
The export provides a snapshot of Zipline&apos;s data and environment. Specifically, it includes:
</p>
<List>
<List.Item>
<b>User Data:</b> Information about users, avatars, passwords, and registered OAuth providers.
</List.Item>
<List.Item>
<b>Files:</b> Metadata about uploaded files including filenames, passwords, sizes, and
timestamps, linked users. <i>(Note: the actual contents of the files are not included.)</i>
</List.Item>
<List.Item>
<b>URLs:</b> Metadata about shortened URLs, including the original URL, short URL, and vanity.
</List.Item>
<List.Item>
<b>Folders:</b> Metadata about folders, including names, visibility settings, and files.
</List.Item>
<List.Item>
<b>Thumbnails:</b> Metadata about thumbnails, includes the name and creation timestamp.{' '}
<i>(Actual image data is excluded.)</i>
</List.Item>
<List.Item>
<b>Invites:</b> Metadata about invites, includes the invite code, creator, and expiration date.
</List.Item>
<List.Item>
<b>Statistics:</b> Usage data that is used on the statistics page, including upload counts and
such.
</List.Item>
</List>
<p>
Additionally, the export captures <b>system-specific information</b>:
</p>
<List>
<List.Item>
<b>CPU Count:</b> The number of processing cores available on the host system.
</List.Item>
<List.Item>
<b>Hostname:</b> The network identifier of the host system.
</List.Item>
<List.Item>
<b>Architecture:</b> The hardware architecture (e.g., <Code>x86</Code>, <Code>arm</Code>) on
which Zipline is running.
</List.Item>
<List.Item>
<b>Platform:</b> The operating system platform (e.g., <Code>linux</Code>, <Code>darwin</Code>)
on which Zipline is running.
</List.Item>
<List.Item>
<b>Version:</b> The current version of the operating system (kernel version)
</List.Item>
<List.Item>
<b>Environment Variables:</b> The configuration settings and variables defined at the time of
execution.
</List.Item>
</List>
<p>
<i>Note:</i> By omitting the actual contents of files and thumbnails while including their
metadata, the export ensures it captures enough detail for migration to another instance, or for
v4.
</p>
</Box>
),
labels: { confirm: 'Yes', cancel: 'No' },
cancelProps: { color: 'red' },
onConfirm: async () => {
modals.closeAll();
showNotification({
title: 'Exporting all server data...',
message: 'This may take a while depending on the amount of data.',
loading: true,
id: 'export-all',
autoClose: false,
});
const res = await useFetch('/api/admin/export', 'GET');
if (res.error) {
updateNotification({
id: 'export-all',
title: 'Error exporting data',
message: res.error,
color: 'red',
icon: <IconFileExport size='1rem' />,
autoClose: true,
});
} else {
updateNotification({
title: 'Export created',
message: 'Your browser will prompt you to download a JSON file with all the server data.',
id: 'export-all',
color: 'green',
icon: <IconFileExport size='1rem' />,
autoClose: true,
});
const blob = new Blob([JSON.stringify(res)], { type: 'application/json' });
const a = document.createElement('a');
a.style.display = 'none';
const url = URL.createObjectURL(blob);
console.log(url, res);
a.setAttribute('download', `zipline_export_${Date.now()}.json`);
a.setAttribute('href', url);
a.click();
URL.revokeObjectURL(url);
}
},
});
};
const interval = useInterval(() => getExports(), 30000);
useEffect(() => {
getExports();
@@ -367,8 +515,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
<Title>Manage User</Title>
<MutedText size='md'>
Want to use variables in embed text? Visit{' '}
<AnchorNext href='https://zipline.diced.tech/docs/guides/variables'>the docs</AnchorNext> for
variables
<AnchorNext href='https://zipline.diced.sh/docs/guides/variables'>the docs</AnchorNext> for variables
</MutedText>
<TextInput
@@ -488,7 +635,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
{oauth_providers
.filter(
(x) =>
!user.oauth?.map(({ provider }) => provider.toLowerCase()).includes(x.name.toLowerCase())
!user.oauth?.map(({ provider }) => provider.toLowerCase()).includes(x.name.toLowerCase()),
)
.map(({ link_url, name, Icon }, i) => (
<Button key={i} size='lg' leftIcon={<Icon />} component={Link} href={link_url} my='sm'>
@@ -581,6 +728,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
{ id: 'name', name: 'Name' },
{ id: 'date', name: 'Date' },
{ id: 'size', name: 'Size' },
{ id: 'actions', name: '' },
]}
rows={
exports
@@ -592,6 +740,11 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
),
date: x.date.toLocaleString(),
size: bytesToHuman(x.size),
actions: (
<ActionIcon onClick={() => deleteExport(x.full)}>
<IconTrash size='1rem' />
</ActionIcon>
),
}))
: []
}
@@ -616,6 +769,11 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
>
Delete all uploads
</Button>
{user.superAdmin && (
<Button size='md' onClick={startFullExport} rightIcon={<IconFileExport size='1rem' />}>
Export all server data (JSON)
</Button>
)}
</Group>
</Box>
)}

View File

@@ -43,7 +43,7 @@ export default function File({ chunks: chunks_config }) {
return e.returnValue;
}
},
[loading]
[loading],
);
const beforeRouteChange = useCallback(
@@ -56,7 +56,7 @@ export default function File({ chunks: chunks_config }) {
}
}
},
[loading]
[loading],
);
useEffect(() => {
@@ -85,6 +85,14 @@ export default function File({ chunks: chunks_config }) {
}, [loading, beforeUnload, beforeRouteChange]);
const handleChunkedFiles = async (expiresAt: Date, toChunkFiles: File[]) => {
if (!chunks_config.enabled)
return showNotification({
id: 'upload-chunked',
title: 'Chunked files are disabled',
message: 'This should not be called, but some how got called...',
color: 'red',
});
for (let i = 0; i !== toChunkFiles.length; ++i) {
const file = toChunkFiles[i];
const identifier = randomChars(4);
@@ -183,7 +191,7 @@ export default function File({ chunks: chunks_config }) {
ready = false;
}
},
false
false,
);
req.open('POST', '/api/upload');
@@ -222,10 +230,10 @@ export default function File({ chunks: chunks_config }) {
for (let i = 0; i !== files.length; ++i) {
const file = files[i];
if (file.size >= chunks_config.max_size) {
if (chunks_config.enabled && file.size >= chunks_config.max_size) {
toChunkFiles.push(file);
} else {
body.append('file', files[i]);
body.append('file', files[i], encodeURIComponent(files[i].name));
}
}
@@ -299,7 +307,7 @@ export default function File({ chunks: chunks_config }) {
}
setProgress(0);
},
false
false,
);
if (bodyLength !== 0) {
@@ -356,7 +364,8 @@ export default function File({ chunks: chunks_config }) {
<Button
leftIcon={<IconFileUpload size='1rem' />}
onClick={handleUpload}
disabled={files.length === 0 ? true : false}
loading={loading}
disabled={files.length === 0 || loading}
>
Upload
</Button>

View File

@@ -22,6 +22,7 @@ export default function Text() {
const [value, setValue] = useState('');
const [lang, setLang] = useState('txt');
const [loading, setLoading] = useState(false);
const [options, setOpened, OptionsModal] = useUploadOptions();
@@ -29,6 +30,9 @@ export default function Text() {
const shouldRenderTex = lang === 'tex';
const handleUpload = async () => {
if (value.trim().length === 0) return;
setLoading(true);
const file = new File([value], 'text.' + lang);
const expiresAt = options.expires === 'never' ? null : expireReadToDate(options.expires);
@@ -53,6 +57,16 @@ export default function Text() {
message: '',
});
showFilesModal(clipboard, modals, json.files);
setLoading(false);
setValue('');
} else {
updateNotification({
id: 'upload-text',
title: 'Upload Failed',
message: json.error,
color: 'red',
});
setLoading(false);
}
});
@@ -136,7 +150,8 @@ export default function Text() {
<Button
leftIcon={<IconFileUpload size='1rem' />}
onClick={handleUpload}
disabled={value.trim().length === 0 ? true : false}
disabled={value.trim().length === 0 || loading}
loading={loading}
>
Upload
</Button>

View File

@@ -213,7 +213,7 @@ export function OptionsModal({
export default function useUploadOptions(): [
UploadOptionsState,
Dispatch<SetStateAction<boolean>>,
ReactNode
ReactNode,
] {
const [state, setState] = useReducer((state, newState) => ({ ...state, ...newState }), {
expires: 'never',

View File

@@ -26,7 +26,7 @@ export function CreateUserModal({ open, setOpen, updateUsers }) {
};
setOpen(false);
const res = await useFetch('/api/auth/create', 'POST', data);
const res = await useFetch('/api/auth/register', 'POST', data);
if (res.error) {
showNotification({
title: 'Failed to create user',

View File

@@ -15,7 +15,7 @@ export default function PrismCode({ code, ext, ...props }) {
return (
<Prism
sx={(t) => ({ height: '100vh', backgroundColor: t.colors.dark[8] })}
sx={(t) => ({ height: '100vh', overflow: 'scroll', backgroundColor: t.colors.dark[8] })}
withLineNumbers
language={exts[ext]?.toLowerCase()}
{...props}

View File

@@ -57,6 +57,7 @@ export interface ConfigUploader {
format_date: string;
default_expiration: string;
assume_mimetypes: boolean;
random_words_separator: string;
}
export interface ConfigUrls {
@@ -135,9 +136,12 @@ export interface ConfigOAuth {
discord_client_id?: string;
discord_client_secret?: string;
discord_redirect_uri?: string;
discord_whitelisted_users?: string[];
google_client_id?: string;
google_client_secret?: string;
google_redirect_uri?: string;
}
export interface ConfigChunks {

View File

@@ -98,6 +98,7 @@ export default function readConfig() {
map('UPLOADER_FORMAT_DATE', 'string', 'uploader.format_date'),
map('UPLOADER_DEFAULT_EXPIRATION', 'string', 'uploader.default_expiration'),
map('UPLOADER_ASSUME_MIMETYPES', 'boolean', 'uploader.assume_mimetypes'),
map('UPLOADER_RANDOM_WORDS_SEPARATOR', 'string', 'uploader.random_words_separator'),
map('URLS_ROUTE', 'string', 'urls.route'),
map('URLS_LENGTH', 'number', 'urls.length'),
@@ -146,9 +147,12 @@ export default function readConfig() {
map('OAUTH_DISCORD_CLIENT_ID', 'string', 'oauth.discord_client_id'),
map('OAUTH_DISCORD_CLIENT_SECRET', 'string', 'oauth.discord_client_secret'),
map('OAUTH_DISCORD_REDIRECT_URI', 'string', 'oauth.discord_redirect_uri'),
map('OAUTH_DISCORD_WHITELISTED_USERS', 'array', 'oauth.discord_whitelisted_users'),
map('OAUTH_GOOGLE_CLIENT_ID', 'string', 'oauth.google_client_id'),
map('OAUTH_GOOGLE_CLIENT_SECRET', 'string', 'oauth.google_client_secret'),
map('OAUTH_GOOGLE_REDIRECT_URI', 'string', 'oauth.google_redirect_uri'),
map('FEATURES_INVITES', 'boolean', 'features.invites'),
map('FEATURES_INVITES_LENGTH', 'number', 'features.invites_length'),

View File

@@ -97,6 +97,7 @@ const validator = s.object({
format_date: s.string.default('YYYY-MM-DD_HH:mm:ss'),
default_expiration: s.string.optional.default(null),
assume_mimetypes: s.boolean.default(false),
random_words_separator: s.string.default('-'),
})
.default({
default_format: 'RANDOM',
@@ -140,11 +141,11 @@ const validator = s.object({
s.object({
label: s.string,
link: s.string,
})
}),
)
.default([
{ label: 'Zipline', link: 'https://github.com/diced/zipline' },
{ label: 'Documentation', link: 'https://zipline.diced.tech/' },
{ label: 'Documentation', link: 'https://zipline.diced.sh/' },
]),
})
.default({
@@ -155,7 +156,7 @@ const validator = s.object({
external_links: [
{ label: 'Zipline', link: 'https://github.com/diced/zipline' },
{ label: 'Documentation', link: 'https://zipline.diced.tech/' },
{ label: 'Documentation', link: 'https://zipline.diced.sh/' },
],
}),
discord: s
@@ -176,9 +177,12 @@ const validator = s.object({
discord_client_id: s.string.nullable.default(null),
discord_client_secret: s.string.nullable.default(null),
discord_redirect_uri: s.string.nullable.default(null),
discord_whitelisted_users: s.string.array.default([]),
google_client_id: s.string.nullable.default(null),
google_client_secret: s.string.nullable.default(null),
google_redirect_uri: s.string.nullable.default(null),
})
.nullish.default(null),
features: s
@@ -274,7 +278,7 @@ export default function validate(config): Config {
}
}
const reserved = ['/view', '/dashboard', '/code', '/folder', '/api', '/auth'];
const reserved = ['/view', '/dashboard', '/code', '/folder', '/api', '/auth', '/r'];
if (reserved.some((r) => validated.uploader.route.startsWith(r))) {
throw {
errors: [`The uploader route cannot be ${validated.uploader.route}, this is a reserved route.`],

View File

@@ -3,10 +3,10 @@ import { Readable } from 'stream';
export abstract class Datasource {
public name: string;
public abstract save(file: string, data: Buffer): Promise<void>;
public abstract save(file: string, data: Buffer, options?: { type: string }): Promise<void>;
public abstract delete(file: string): Promise<void>;
public abstract clear(): Promise<void>;
public abstract size(file: string): Promise<number>;
public abstract get(file: string): Readable | Promise<Readable>;
public abstract size(file: string): Promise<number | null>;
public abstract get(file: string, start?: number, end?: number): Readable | Promise<Readable>;
public abstract fullSize(): Promise<number>;
}

View File

@@ -26,20 +26,20 @@ export class Local extends Datasource {
}
}
public get(file: string): ReadStream {
public get(file: string, start: number = 0, end: number = Infinity): ReadStream {
const full = join(this.path, file);
if (!existsSync(full)) return null;
try {
return createReadStream(full);
return createReadStream(full, { start, end });
} catch (e) {
return null;
}
}
public async size(file: string): Promise<number> {
public async size(file: string): Promise<number | null> {
const full = join(this.path, file);
if (!existsSync(full)) return 0;
if (!existsSync(full)) return null;
const stats = await stat(full);
return stats.size;

View File

@@ -1,7 +1,7 @@
import { Datasource } from '.';
import { Readable } from 'stream';
import { ConfigS3Datasource } from 'lib/config/Config';
import { Client } from 'minio';
import { BucketItemStat, Client } from 'minio';
export class S3 extends Datasource {
public name = 'S3';
@@ -20,8 +20,13 @@ export class S3 extends Datasource {
});
}
public async save(file: string, data: Buffer): Promise<void> {
await this.s3.putObject(this.config.bucket, file, data);
public async save(file: string, data: Buffer, options?: { type: string }): Promise<void> {
await this.s3.putObject(
this.config.bucket,
file,
data,
options ? { 'Content-Type': options.type } : undefined,
);
}
public async delete(file: string): Promise<void> {
@@ -40,21 +45,42 @@ export class S3 extends Datasource {
});
}
public get(file: string): Promise<Readable> {
return new Promise((res) => {
this.s3.getObject(this.config.bucket, file, (err, stream) => {
if (err) res(null);
else res(stream);
public get(file: string, start: number = 0, end: number = Infinity): Promise<Readable> {
if (start === 0 && end === Infinity) {
return new Promise((res) => {
this.s3.getObject(this.config.bucket, file, (err, stream) => {
if (err) res(null);
else res(stream);
});
});
}
return new Promise((res) => {
this.s3.getPartialObject(
this.config.bucket,
file,
start,
// undefined means to read the rest of the file from the start (offset)
end === Infinity ? undefined : end,
(err, stream) => {
if (err) res(null);
else res(stream);
},
);
});
}
public size(file: string): Promise<number> {
public size(file: string): Promise<number | null> {
return new Promise((res) => {
this.s3.statObject(this.config.bucket, file, (err, stat) => {
if (err) res(0);
else res(stat.size);
});
this.s3.statObject(
this.config.bucket,
file,
// @ts-expect-error this callback is not in the types but the code for it is there
(err: unknown, stat: BucketItemStat) => {
if (err) res(null);
else res(stat.size);
},
);
});
}

View File

@@ -72,12 +72,13 @@ export class Supabase extends Datasource {
}
}
public async get(file: string): Promise<Readable> {
public async get(file: string, start: number = 0, end: number = Infinity): Promise<Readable> {
// get a readable stream from the request
const r = await fetch(`${this.config.url}/storage/v1/object/${this.config.bucket}/${file}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${this.config.key}`,
Range: `bytes=${start}-${end === Infinity ? '' : end}`,
},
});
@@ -85,7 +86,7 @@ export class Supabase extends Datasource {
return Readable.fromWeb(r.body as any);
}
public size(file: string): Promise<number> {
public size(file: string): Promise<number | null> {
return new Promise(async (res) => {
fetch(`${this.config.url}/storage/v1/object/list/${this.config.bucket}`, {
method: 'POST',
@@ -102,11 +103,11 @@ export class Supabase extends Datasource {
.then((j) => {
if (j.error) {
this.logger.error(`${j.error}: ${j.message}`);
res(0);
res(null);
}
if (j.length === 0) {
res(0);
res(null);
} else {
res(j[0].metadata.size);
}

View File

@@ -8,7 +8,7 @@ const logger = Logger.get('discord');
export function parseContent(
content: ConfigDiscordContent,
args: ParseValue
args: ParseValue,
): ConfigDiscordContent & { url: string } {
return {
content: content.content ? parseString(content.content, args) : null,
@@ -28,10 +28,10 @@ export function parseContent(
}
export async function sendUpload(user: User, file: File, raw_link: string, link: string) {
if (!config.discord.upload) return;
if (!config.discord.url && !config.discord.upload.url) return;
if (!config.discord.upload) return logger.debug('no discord upload config, no webhook sent');
if (!config.discord.url && !config.discord.upload.url)
return logger.debug('no discord url, no webhook sent');
logger.debug(`discord config:\n${JSON.stringify(config.discord)}`);
const parsed = parseContent(config.discord.upload, {
file,
user,
@@ -97,8 +97,9 @@ export async function sendUpload(user: User, file: File, raw_link: string, link:
}
export async function sendShorten(user: User, url: Url, link: string) {
if (!config.discord.shorten) return;
if (!config.discord.url && !config.discord.shorten.url) return;
if (!config.discord.shorten) return logger.debug('no discord shorten config, no webhook sent');
if (!config.discord.url && !config.discord.shorten.url)
return logger.debug('no discord url, no webhook sent');
const parsed = parseContent(config.discord.shorten, {
url,

View File

@@ -1,26 +1,41 @@
import { readFile } from 'fs/promises';
import config from 'lib/config';
import Logger from 'lib/logger';
const logger = Logger.get('random_words');
export type GfyCatWords = {
adjectives: string[];
animals: string[];
};
export async function importWords(): Promise<GfyCatWords> {
const adjectives = (await readFile('public/adjectives.txt', 'utf-8')).split('\n');
const animals = (await readFile('public/animals.txt', 'utf-8')).split('\n');
export async function importWords(): Promise<GfyCatWords | null> {
try {
const adjectives = (await readFile('public/adjectives.txt', 'utf-8')).split('\n').map((x) => x.trim());
const animals = (await readFile('public/animals.txt', 'utf-8')).split('\n').map((x) => x.trim());
return {
adjectives,
animals,
};
return {
adjectives,
animals,
};
} catch {
logger.error('public/adjectives.txt or public/animals.txt do not exist, to fix this please retrieve.');
logger.error('to prevent this from happening again, remember to not delete your public/ directory.');
logger.error('file names will use the RANDOM format instead until fixed');
return null;
}
}
function randomWord(words: string[]) {
return words[Math.floor(Math.random() * words.length)];
}
export default async function gfycat() {
export default async function gfycat(): Promise<string | null> {
const words = await importWords();
return `${randomWord(words.adjectives)}${randomWord(words.adjectives)}${randomWord(words.animals)}`;
if (!words) return null;
return `${randomWord(words.adjectives)}${config.uploader.random_words_separator}${randomWord(
words.adjectives,
)}${config.uploader.random_words_separator}${randomWord(words.animals)}`;
}

View File

@@ -19,7 +19,7 @@ export default async function formatFileName(nameFormat: NameFormat, originalNam
return name;
case 'gfycat':
return gfycat();
return gfycat() ?? random();
default:
return random();
}

View File

@@ -7,7 +7,7 @@ export type ApiError = {
export default async function useFetch(
url: string,
method: 'GET' | 'POST' | 'PATCH' | 'DELETE' = 'GET',
body: ApiError | Record<string, unknown> = null
body: ApiError | Record<string, unknown> = null,
) {
const headers = {};
if (body) headers['content-type'] = 'application/json';

View File

@@ -60,8 +60,8 @@ export default class Logger {
this.formatMessage(
LoggerLevel.ERROR,
this.name,
args.map((error) => (typeof error === 'string' ? error : (error as Error).stack)).join(' ')
)
args.map((error) => (typeof error === 'string' ? error : (error as Error).stack)).join(' '),
),
);
return this;

View File

@@ -1,5 +1,5 @@
import config from 'lib/config';
import { notNull } from 'lib/util';
import { isNotNullOrUndefined } from 'lib/util';
import { GetServerSideProps } from 'next';
export type OauthProvider = {
@@ -19,6 +19,7 @@ export type ServerSideProps = {
bypass_local_login: boolean;
chunks_size: number;
max_size: number;
chunks_enabled: boolean;
totp_enabled: boolean;
exif_enabled: boolean;
fileId?: string;
@@ -26,9 +27,15 @@ export type ServerSideProps = {
};
export const getServerSideProps: GetServerSideProps<ServerSideProps> = async (ctx) => {
const ghEnabled = notNull(config.oauth?.github_client_id, config.oauth?.github_client_secret);
const discEnabled = notNull(config.oauth?.discord_client_id, config.oauth?.discord_client_secret);
const googleEnabled = notNull(config.oauth?.google_client_id, config.oauth?.google_client_secret);
const ghEnabled =
isNotNullOrUndefined(config.oauth?.github_client_id) &&
isNotNullOrUndefined(config.oauth?.github_client_secret);
const discEnabled =
isNotNullOrUndefined(config.oauth?.discord_client_id) &&
isNotNullOrUndefined(config.oauth?.discord_client_secret);
const googleEnabled =
isNotNullOrUndefined(config.oauth?.google_client_id) &&
isNotNullOrUndefined(config.oauth?.google_client_secret);
const oauth_providers: OauthProvider[] = [];
@@ -65,6 +72,7 @@ export const getServerSideProps: GetServerSideProps<ServerSideProps> = async (ct
chunks_size: config.chunks.chunks_size,
max_size: config.chunks.max_size,
totp_enabled: config.mfa.totp_enabled,
chunks_enabled: config.chunks.enabled,
exif_enabled: config.exif.enabled,
compress: config.core.compression.on_dashboard,
} as ServerSideProps,

View File

@@ -26,7 +26,7 @@ export interface OAuthResponse {
export const withOAuth =
(
provider: 'discord' | 'github' | 'google',
oauth: (query: OAuthQuery, logger: Logger) => Promise<OAuthResponse>
oauth: (query: OAuthQuery, logger: Logger) => Promise<OAuthResponse>,
) =>
async (req: NextApiReq, res: NextApiRes) => {
const logger = Logger.get(`oauth::${provider}`);
@@ -67,26 +67,7 @@ export const withOAuth =
},
});
} catch (e) {
logger.debug(`Failed to find existing oauth. Using fallback. ${e}`);
if (e.code === 'P2022' || e.code === 'P2025') {
const existing = await prisma.user.findFirst({
where: {
oauth: {
some: {
provider: provider.toUpperCase() as OauthProviders,
username: oauth_resp.username,
},
},
},
include: {
oauth: true,
},
});
existingOauth = existing?.oauth?.find((o) => o.provider === provider.toUpperCase());
if (existingOauth) existingOauth.fallback = true;
} else {
logger.error(`Failed to find existing oauth. ${e}`);
}
logger.error(`Failed to find existing oauth, this likely will result in a failure: ${e}`);
}
const existingUser = await prisma.user.findFirst({
@@ -157,7 +138,7 @@ export const withOAuth =
logger.info(`User ${user.username} (${user.id}) logged in via oauth(${provider})`);
return res.redirect('/dashboard');
} else if ((existingOauth && existingOauth.fallback) || existingOauth) {
} else if (existingOauth) {
await prisma.oAuth.update({
where: {
id: existingOauth?.id,
@@ -172,7 +153,7 @@ export const withOAuth =
res.setUserCookie(existingOauth.userId);
Logger.get('user').info(
`User ${existingOauth.username} (${existingOauth.id}) logged in via oauth(${provider})`
`User ${existingOauth.username} (${existingOauth.id}) logged in via oauth(${provider})`,
);
return res.redirect('/dashboard');

View File

@@ -66,7 +66,7 @@ export type ZiplineApiConfig = {
export const withZipline =
(
handler: (req: NextApiRequest, res: NextApiResponse, user?: UserExtended) => Promise<unknown>,
api_config: ZiplineApiConfig = { methods: ['GET', 'OPTIONS'] }
api_config: ZiplineApiConfig = { methods: ['GET', 'OPTIONS'] },
) =>
(req: NextApiReq, res: NextApiRes) => {
if (!api_config.methods.includes('OPTIONS')) api_config.methods.push('OPTIONS');
@@ -87,7 +87,7 @@ export const withZipline =
code: 400,
...extra,
},
400
400,
);
};
@@ -99,7 +99,7 @@ export const withZipline =
code: 401,
...extra,
},
401
401,
);
};
@@ -111,7 +111,7 @@ export const withZipline =
code: 403,
...extra,
},
403
403,
);
};
@@ -122,7 +122,7 @@ export const withZipline =
code: 404,
...extra,
},
404
404,
);
};
@@ -136,7 +136,7 @@ export const withZipline =
code: 429,
...extra,
},
429
429,
);
};
@@ -161,7 +161,7 @@ export const withZipline =
path: '/',
expires: new Date(1),
maxAge: undefined,
})
}),
);
};
@@ -230,7 +230,7 @@ export const withZipline =
error: 'method not allowed',
code: 405,
},
405
405,
);
}

View File

@@ -16,9 +16,9 @@ export const github_auth = {
};
export const discord_auth = {
oauth_url: (clientId: string, origin: string, state?: string) =>
oauth_url: (clientId: string, origin: string, state?: string, redirect_uri?: string) =>
`https://discord.com/api/oauth2/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(
`${origin}/api/auth/oauth/discord`
redirect_uri || `${origin}/api/auth/oauth/discord`,
)}&response_type=code&scope=identify${state ? `&state=${state}` : ''}`,
oauth_user: async (access_token: string) => {
const res = await fetch('https://discord.com/api/users/@me', {
@@ -33,15 +33,15 @@ export const discord_auth = {
};
export const google_auth = {
oauth_url: (clientId: string, origin: string, state?: string) =>
oauth_url: (clientId: string, origin: string, state?: string, redirect_uri?: string) =>
`https://accounts.google.com/o/oauth2/auth?client_id=${clientId}&redirect_uri=${encodeURIComponent(
`${origin}/api/auth/oauth/google`
redirect_uri || `${origin}/api/auth/oauth/google`,
)}&response_type=code&access_type=offline&scope=https://www.googleapis.com/auth/userinfo.profile${
state ? `&state=${state}` : ''
}`,
oauth_user: async (access_token: string) => {
const res = await fetch(
`https://people.googleapis.com/v1/people/me?access_token=${access_token}&personFields=names,photos`
`https://people.googleapis.com/v1/people/me?access_token=${access_token}&personFields=names,photos`,
);
if (!res.ok) return null;

View File

@@ -29,7 +29,7 @@ export const useFiles = (query: { [key: string]: string } = {}) => {
...x,
createdAt: new Date(x.createdAt),
expiresAt: x.expiresAt ? new Date(x.expiresAt) : null,
}))
})),
);
});
};
@@ -59,7 +59,7 @@ export const usePaginatedFiles = (page?: number, options?: Partial<PaginatedFile
...x,
createdAt: new Date(x.createdAt),
expiresAt: x.expiresAt ? new Date(x.expiresAt) : null,
}))
})),
);
});
};
@@ -73,7 +73,7 @@ export const useRecent = (filter?: string) => {
...x,
createdAt: new Date(x.createdAt),
expiresAt: x.expiresAt ? new Date(x.expiresAt) : null,
}))
})),
);
});
};
@@ -94,7 +94,7 @@ export function useFileDelete() {
onSuccess: () => {
queryClient.refetchQueries(['files']);
},
}
},
);
}
@@ -114,7 +114,7 @@ export function useFileFavorite() {
onSuccess: () => {
queryClient.refetchQueries(['files']);
},
}
},
);
}

View File

@@ -17,27 +17,17 @@ export const useFolders = (query: { [key: string]: string } = {}) => {
const queryString = queryBuilder.toString();
return useQuery<UserFoldersResponse[]>(['folders', queryString], async () => {
return fetch('/api/user/folders?' + queryString)
.then((res) => res.json() as Promise<UserFoldersResponse[]>)
.then((data) =>
data.map((x) => ({
...x,
createdAt: new Date(x.createdAt).toLocaleString(),
updatedAt: new Date(x.updatedAt).toLocaleString(),
}))
);
return fetch('/api/user/folders?' + queryString).then(
(res) => res.json() as Promise<UserFoldersResponse[]>,
);
});
};
export const useFolder = (id: string, withFiles = false) => {
return useQuery<UserFoldersResponse>(['folder', id], async () => {
return fetch('/api/user/folders/' + id + (withFiles ? '?files=true' : ''))
.then((res) => res.json() as Promise<UserFoldersResponse>)
.then((data) => ({
...data,
createdAt: new Date(data.createdAt).toLocaleString(),
updatedAt: new Date(data.updatedAt).toLocaleString(),
}));
return fetch('/api/user/folders/' + id + (withFiles ? '?files=true' : '')).then(
(res) => res.json() as Promise<UserFoldersResponse>,
);
});
};

View File

@@ -27,6 +27,6 @@ export const useStats = (amount = 2) => {
},
{
staleTime: 1000 * 60 * 5, // 5 minutes
}
},
);
};

View File

@@ -36,6 +36,6 @@ export function useURLDelete() {
?.filter((u) => u.id !== variables);
queryClient.setQueryData(['urls'], dataWithoutDeleted);
},
}
},
);
}

View File

@@ -15,10 +15,12 @@ export const useVersion = () => {
return useQuery<VersionResponse>(
['version'],
async () => {
return fetch('/api/version').then((res) => res.json());
return fetch('/api/version').then((res) => (res.ok ? res.json() : Promise.reject('')));
},
{
staleTime: Infinity,
}
refetchInterval: false,
refetchOnMount: false,
retry: false,
},
);
};

View File

@@ -36,7 +36,7 @@ export const createSpotlightActions = (router: NextRouter): SpotlightAction[] =>
title: string,
description: string,
link: string,
icon: ReactNode
icon: ReactNode,
): SpotlightAction => {
return actionDo(group, title, description, icon, () => linkTo(link));
};
@@ -46,7 +46,7 @@ export const createSpotlightActions = (router: NextRouter): SpotlightAction[] =>
title: string,
description: string,
icon: ReactNode,
action: () => void
action: () => void,
): SpotlightAction => {
return {
group,
@@ -70,7 +70,7 @@ export const createSpotlightActions = (router: NextRouter): SpotlightAction[] =>
'Manage Account',
'Manage your account settings',
'/dashboard/manage',
<IconUser />
<IconUser />,
),
// Actions
@@ -80,14 +80,14 @@ export const createSpotlightActions = (router: NextRouter): SpotlightAction[] =>
'Upload Files',
'Upload files of any kind',
'/dashboard/upload/file',
<IconFileUpload />
<IconFileUpload />,
),
actionLink(
'Actions',
'Upload Text',
'Upload code, or any other kind of text file',
'/dashboard/upload/text',
<IconFileText />
<IconFileText />,
),
actionDo('Actions', 'Copy Token', 'Copy your API token to your clipboard', <IconClipboardCopy />, () => {
clipboard.copy(user.token);
@@ -99,7 +99,7 @@ export const createSpotlightActions = (router: NextRouter): SpotlightAction[] =>
});
}),
actionLink('Help', 'Documentation', 'View the documentation', 'https://zipline.diced.tech', <IconHelp />),
actionLink('Help', 'Documentation', 'View the documentation', 'https://zipline.diced.sh', <IconHelp />),
// the list of actions here is very incomplete, and will be expanded in the future
];

View File

@@ -0,0 +1,39 @@
// https://github.com/SeaswimmerTheFsh
// https://catppuccin.com/palette
import createTheme from '.';
export default createTheme({
colorScheme: 'dark',
primaryColor: 'blue',
other: {
AppShell_backgroundColor: '#232634',
hover: '#414559',
},
colors: {
dark: [
'#c6d0f5',
'#949cbb',
'#838ba7',
'#737994',
'#626880',
'#51576d',
'#414559',
'#303446',
'#292c3c',
'#232634',
],
blue: [
'#FFFFFF',
'#b8caf4',
'#a2baf1',
'#7599ea',
'#5f89e7',
'#8c99ee',
'#8ca1ee',
'#8cb2ee',
'#8cbaee',
'#8caaee',
],
},
});

View File

@@ -0,0 +1,39 @@
// https://github.com/SeaswimmerTheFsh
// https://catppuccin.com/palette
import createTheme from '.';
export default createTheme({
colorScheme: 'dark',
primaryColor: 'blue',
other: {
AppShell_backgroundColor: '#dce0e8',
hover: '#ccd0da',
},
colors: {
dark: [
'#4c4f69',
'#8c8fa1',
'#8c8fa1',
'#9ca0b0',
'#acb0be',
'#bcc0cc',
'#ccd0da',
'#eff1f5',
'#e6e9ef',
'#dce0e8',
],
blue: [
'#FFFFFF',
'#3676f6',
'#0a57ee',
'#094ed6',
'#1d42f5',
'#1d54f5',
'#1d65f5',
'#1d77f5',
'#1d89f5',
'#1e66f5',
],
},
});

View File

@@ -0,0 +1,39 @@
// https://github.com/SeaswimmerTheFsh
// https://catppuccin.com/palette
import createTheme from '.';
export default createTheme({
colorScheme: 'dark',
primaryColor: 'blue',
other: {
AppShell_backgroundColor: '#181926',
hover: '#363a4f',
},
colors: {
dark: [
'#cad3f5',
'#8087a2',
'#8087a2',
'#6e738d',
'#5b6078',
'#494d64',
'#363a4f',
'#24273a',
'#1e2030',
'#181926',
],
blue: [
'#FFFFFF',
'#a1bdf6',
'#729cf1',
'#5b8cef',
'#899bf4',
'#89a4f4',
'#89acf4',
'#89b5f4',
'#89bef4',
'#8aadf4',
],
},
});

View File

@@ -0,0 +1,39 @@
// https://github.com/SeaswimmerTheFsh
// https://catppuccin.com/palette
import createTheme from '.';
export default createTheme({
colorScheme: 'dark',
primaryColor: 'blue',
other: {
AppShell_backgroundColor: '#11111b',
hover: '#313244',
},
colors: {
dark: [
'#cdd6f4',
'#9399b2',
'#7f849c',
'#6c7086',
'#585b70',
'#45475a',
'#313244',
'#1e1e2e',
'#181825',
'#11111b',
],
blue: [
'#FFFFFF',
'#b9d3fc',
'#a1c3fb',
'#70a4f8',
'#5894f7',
'#89a1fa',
'#89aafa',
'#89b4fa',
'#89bdfa',
'#89c6fa',
],
},
});

View File

@@ -120,6 +120,6 @@ export async function getBase64URLFromURL(url: string) {
return `data:${res.headers.get('content-type')};base64,${base64}`;
}
export function notNull(a: unknown, b: unknown) {
return a !== null && b !== null;
export function isNotNullOrUndefined(value: unknown) {
return value !== null && value !== undefined;
}

View File

@@ -24,16 +24,16 @@ export function humanToBytes(value: string): number {
return bytes;
}
export function bytesToHuman(value: number): string {
if (isNaN(value)) return '0.0 B';
export function bytesToHuman(value: number | bigint): string {
if (typeof value !== 'bigint' && isNaN(value)) return '0.0 B';
if (value === Infinity) return '0.0 B';
const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB'];
const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB']; // if people upload stuff bigger than a petabyte then idk
let num = 0;
while (value > 1024) {
value /= 1024;
value = Number(value) / 1024;
++num;
}
return `${value.toFixed(1)} ${units[num]}`;
return `${Number(value).toFixed(1)} ${units[num] || ''}`;
}

View File

@@ -51,22 +51,22 @@ export function humanTime(string: StringValue | string): Date {
}
}
export function parseExpiry(header: string): Date | null {
if (!header) return null;
export function parseExpiry(header: string): Date {
if (!header) throw new Error('no expiry provided');
header = header.toLowerCase();
if (header.startsWith('date=')) {
const date = new Date(header.substring(5));
if (!date.getTime()) return null;
if (date.getTime() < Date.now()) return null;
if (!date.getTime()) throw new Error('invalid date');
if (date.getTime() < Date.now()) throw new Error('expiry must be in the future');
return date;
}
const human = humanTime(header);
if (!human) return null;
if (human.getTime() < Date.now()) return null;
if (!human) throw new Error('failed to parse human time');
if (human.getTime() < Date.now()) throw new Error('expiry must be in the future');
return human;
}
@@ -125,7 +125,7 @@ export function expireReadToDate(expires: string): Date {
'6m': Date.now() + 6 * 30 * 24 * 60 * 60 * 1000,
'8m': Date.now() + 8 * 30 * 24 * 60 * 60 * 1000,
'1y': Date.now() + 365 * 24 * 60 * 60 * 1000,
}[expires]
}[expires],
);
}

View File

@@ -87,7 +87,7 @@ export async function removeGPSData(image: File): Promise<void> {
logger.debug(`reading file to upload to datasource: ${file} -> ${image.name}`);
const buffer = await readFile(file);
await datasource.save(image.name, buffer);
await datasource.save(image.name, buffer, { type: image.mimetype });
logger.debug(`removing temp file: ${file}`);
await rm(file);

View File

@@ -1,4 +1,6 @@
import type { File, User, Url } from '@prisma/client';
import { bytesToHuman } from './bytes';
import Logger from 'lib/logger';
export type ParseValue = {
file?: File;
@@ -9,6 +11,8 @@ export type ParseValue = {
raw_link?: string;
};
const logger = Logger.get('parser');
export function parseString(str: string, value: ParseValue) {
if (!str) return null;
str = str
@@ -16,7 +20,7 @@ export function parseString(str: string, value: ParseValue) {
.replace(/\{raw_link\}/gi, value.raw_link)
.replace(/\\n/g, '\n');
const re = /\{(?<type>file|url|user)\.(?<prop>\w+)(::(?<mod>\w+))?\}/gi;
const re = /\{(?<type>file|url|user)\.(?<prop>\w+)(::(?<mod>\w+))?(::(?<mod_tzlocale>\S+))?\}/gi;
let matches: RegExpMatchArray;
while ((matches = re.exec(str))) {
@@ -32,12 +36,13 @@ export function parseString(str: string, value: ParseValue) {
re.lastIndex = matches.index;
continue;
}
if (['originalName', 'name'].includes(matches.groups.prop)) {
str = replaceCharsFromString(
str,
decodeURIComponent(escape(getV[matches.groups.prop])),
matches.index,
re.lastIndex
re.lastIndex,
);
re.lastIndex = matches.index;
continue;
@@ -52,7 +57,12 @@ export function parseString(str: string, value: ParseValue) {
}
if (matches.groups.mod) {
str = replaceCharsFromString(str, modifier(matches.groups.mod, v), matches.index, re.lastIndex);
str = replaceCharsFromString(
str,
modifier(matches.groups.mod, v, matches.groups.mod_tzlocale ?? undefined),
matches.index,
re.lastIndex,
);
re.lastIndex = matches.index;
continue;
}
@@ -64,17 +74,42 @@ export function parseString(str: string, value: ParseValue) {
return str;
}
function modifier(mod: string, value: unknown): string {
function modifier(mod: string, value: unknown, tzlocale?: string): string {
mod = mod.toLowerCase();
if (value instanceof Date) {
const args = [undefined, undefined];
if (tzlocale) {
const [locale, tz] = tzlocale.split(/\s?,\s?/).map((v) => v.trim());
if (locale) {
try {
Intl.DateTimeFormat.supportedLocalesOf(locale);
args[0] = locale;
} catch (e) {
args[0] = undefined;
logger.error(`invalid locale provided ${locale}`);
}
}
if (tz) {
const intlTz = Intl.supportedValuesOf('timeZone').find((v) => v.toLowerCase() === tz.toLowerCase());
if (intlTz) args[1] = { timeZone: intlTz };
else {
args[1] = undefined;
logger.error(`invalid timezone provided ${tz}`);
}
}
}
switch (mod) {
case 'locale':
return value.toLocaleString();
return value.toLocaleString(...args);
case 'time':
return value.toLocaleTimeString();
return value.toLocaleTimeString(...args);
case 'date':
return value.toLocaleDateString();
return value.toLocaleDateString(...args);
case 'unix':
return Math.floor(value.getTime() / 1000).toString();
case 'iso':
@@ -93,6 +128,10 @@ function modifier(mod: string, value: unknown): string {
return value.getMinutes().toString();
case 'second':
return value.getSeconds().toString();
case 'ampm':
return value.getHours() < 12 ? 'am' : 'pm';
case 'AMPM':
return value.getHours() < 12 ? 'AM' : 'PM';
default:
return '{unknown_date_modifier}';
}
@@ -115,7 +154,7 @@ function modifier(mod: string, value: unknown): string {
default:
return '{unknown_str_modifier}';
}
} else if (typeof value === 'number') {
} else if (typeof value === 'number' || typeof value === 'bigint') {
switch (mod) {
case 'comma':
return value.toLocaleString();
@@ -125,6 +164,8 @@ function modifier(mod: string, value: unknown): string {
return value.toString(8);
case 'binary':
return value.toString(2);
case 'bytes':
return bytesToHuman(value);
default:
return '{unknown_int_modifier}';
}

9
src/lib/utils/range.ts Normal file
View File

@@ -0,0 +1,9 @@
export function parseRangeHeader(header?: string): [number, number] {
if (!header || !header.startsWith('bytes=')) return [0, Infinity];
const range = header.replace('bytes=', '').split('-');
const start = Number(range[0]) || 0;
const end = Number(range[1]) || Infinity;
return [start, end];
}

View File

@@ -0,0 +1,315 @@
import { readFile } from 'fs/promises';
import Logger from 'lib/logger';
import prisma from 'lib/prisma';
import { randomChars } from 'lib/util';
import { bytesToHuman } from 'lib/utils/bytes';
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
import os from 'os';
const logger = Logger.get('admin').child('export');
type Zipline3Export = {
versions: {
zipline: string;
node: string;
export: '3';
};
request: {
user: string;
date: string;
os: {
platform: 'aix' | 'darwin' | 'freebsd' | 'linux' | 'openbsd' | 'sunos' | 'win32';
arch:
| 'arm'
| 'arm64'
| 'ia32'
| 'loong64'
| 'mips'
| 'mipsel'
| 'ppc'
| 'ppc64'
| 'riscv64'
| 's390'
| 's390x'
| 'x64';
cpus: number;
hostname: string;
release: string;
};
env: NodeJS.ProcessEnv;
};
// Creates a unique identifier for each model
// used to map the user's stuff to other data owned by the user
user_map: Record<number, string>;
thumbnail_map: Record<number, string>;
folder_map: Record<number, string>;
file_map: Record<number, string>;
url_map: Record<number, string>;
invite_map: Record<number, string>;
users: {
[id: string]: {
username: string;
password: string;
avatar: string;
administrator: boolean;
super_administrator: boolean;
embed: {
title?: string;
site_name?: string;
description?: string;
color?: string;
};
totp_secret: string;
oauth: {
provider: 'DISCORD' | 'GITHUB' | 'GOOGLE';
username: string;
oauth_id: string;
access_token: string;
refresh_token: string;
}[];
};
};
files: {
[id: string]: {
name: string;
original_name: string;
type: `${string}/${string}`;
size: number | bigint;
user: string | null;
thumbnail?: string;
max_views: number;
views: number;
expires_at?: string;
created_at: string;
favorite: boolean;
password?: string;
};
};
thumbnails: {
[id: string]: {
name: string;
created_at: string;
};
};
folders: {
[id: string]: {
name: string;
public: boolean;
created_at: string;
user: string;
files: string[];
};
};
urls: {
[id: number]: {
destination: string;
vanity?: string;
code: string;
created_at: string;
max_views: number;
views: number;
user: string;
};
};
invites: {
[id: string]: {
code: string;
expites_at?: string;
created_at: string;
used: boolean;
created_by_user: string;
};
};
stats: {
created_at: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: any;
}[];
};
async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
if (!user.superAdmin) return res.forbidden('You must be a super administrator to export data');
const pkg = JSON.parse(await readFile('package.json', 'utf8'));
const exportData: Partial<Zipline3Export> = {
versions: {
zipline: pkg.version,
node: process.version,
export: '3',
},
request: {
user: '',
date: new Date().toISOString(),
os: {
platform: os.platform() as Zipline3Export['request']['os']['platform'],
arch: os.arch() as Zipline3Export['request']['os']['arch'],
cpus: os.cpus().length,
hostname: os.hostname(),
release: os.release(),
},
env: process.env,
},
user_map: {},
thumbnail_map: {},
folder_map: {},
file_map: {},
url_map: {},
invite_map: {},
users: {},
files: {},
thumbnails: {},
folders: {},
urls: {},
invites: {},
stats: [],
};
const users = await prisma.user.findMany({
include: {
oauth: true,
},
});
for (const user of users) {
const uniqueId = randomChars(32);
exportData.user_map[user.id] = uniqueId;
exportData.users[uniqueId] = {
username: user.username,
password: user.password,
avatar: user.avatar,
administrator: user.administrator,
super_administrator: user.superAdmin,
embed: user.embed as Zipline3Export['users'][string]['embed'],
totp_secret: user.totpSecret,
oauth: user.oauth.map((oauth) => ({
provider: oauth.provider as Zipline3Export['users'][string]['oauth'][0]['provider'],
username: oauth.username,
oauth_id: oauth.oauthId,
access_token: oauth.token,
refresh_token: oauth.refresh,
})),
};
}
const folders = await prisma.folder.findMany({ include: { files: true } });
for (const folder of folders) {
const uniqueId = randomChars(32);
exportData.folder_map[folder.id] = uniqueId;
exportData.folders[uniqueId] = {
name: folder.name,
public: folder.public,
created_at: folder.createdAt.toISOString(),
user: exportData.user_map[folder.userId],
files: [], // mapped later
};
}
const thumbnails = await prisma.thumbnail.findMany();
for (const thumbnail of thumbnails) {
const uniqueId = randomChars(32);
exportData.thumbnail_map[thumbnail.id] = uniqueId;
exportData.thumbnails[uniqueId] = {
name: thumbnail.name,
created_at: thumbnail.createdAt.toISOString(),
};
}
const files = await prisma.file.findMany({ include: { thumbnail: true } });
for (const file of files) {
const uniqueId = randomChars(32);
exportData.file_map[file.id] = uniqueId;
exportData.files[uniqueId] = {
name: file.name,
original_name: file.originalName,
type: file.mimetype as Zipline3Export['files'][0]['type'],
size: file.size,
user: file.userId ? exportData.user_map[file.userId] : null,
thumbnail: file.thumbnail ? exportData.thumbnail_map[file.thumbnail.id] : undefined,
max_views: file.maxViews,
views: file.views,
expires_at: file.expiresAt?.toISOString(),
created_at: file.createdAt.toISOString(),
favorite: file.favorite,
password: file.password,
};
}
const urls = await prisma.url.findMany();
for (const url of urls) {
const uniqueId = randomChars(32);
exportData.url_map[url.id] = uniqueId;
exportData.urls[uniqueId] = {
destination: url.destination,
vanity: url.vanity,
created_at: url.createdAt.toISOString(),
max_views: url.maxViews,
views: url.views,
user: exportData.user_map[url.userId],
code: url.id,
};
}
const invites = await prisma.invite.findMany();
for (const invite of invites) {
const uniqueId = randomChars(32);
exportData.invite_map[invite.id] = uniqueId;
exportData.invites[uniqueId] = {
code: invite.code,
expites_at: invite.expiresAt?.toISOString() ?? undefined,
created_at: invite.createdAt.toISOString(),
used: invite.used,
created_by_user: exportData.user_map[invite.createdById],
};
}
const stats = await prisma.stats.findMany();
for (const stat of stats) {
exportData.stats.push({
created_at: stat.createdAt.toISOString(),
data: stat.data,
});
}
exportData.request.user = exportData.user_map[user.id];
for (const folder of folders) {
exportData.folders[exportData.folder_map[folder.id]].files = folder.files.map(
(file) => exportData.file_map[file.id],
);
}
const stringed = JSON.stringify(exportData);
logger.info(`${user.id} created export of size ${bytesToHuman(stringed.length)}`);
return res
.setHeader('Content-Disposition', `attachment; filename="zipline_export_${Date.now()}.json"`)
.setHeader('Content-Type', 'application/json')
.send(stringed);
}
export default withZipline(handler, {
methods: ['GET'],
user: true,
administrator: true,
});

View File

@@ -1,132 +0,0 @@
import { readFile } from 'fs/promises';
import config from 'lib/config';
import Logger from 'lib/logger';
import { NextApiReq, NextApiRes, withZipline } from 'lib/middleware/withZipline';
import { guess } from 'lib/mimes';
import prisma from 'lib/prisma';
import { createToken, hashPassword } from 'lib/util';
import { jsonUserReplacer } from 'lib/utils/client';
import { extname } from 'path';
const logger = Logger.get('user');
async function handler(req: NextApiReq, res: NextApiRes) {
// handle invites
if (req.body.code) {
if (!config.features.invites) return res.badRequest('invites are disabled');
const { code, username, password } = req.body as {
code?: string;
username: string;
password: string;
};
const invite = await prisma.invite.findUnique({
where: { code: code ?? '' },
});
if (!invite && code) return res.badRequest('invalid invite code');
const user = await prisma.user.findFirst({
where: { username },
});
if (user) return res.badRequest('username already exists');
const hashed = await hashPassword(password);
let avatar;
if (config.features.default_avatar) {
logger.debug(`using default avatar ${config.features.default_avatar}`);
const buf = await readFile(config.features.default_avatar);
const mimetype = await guess(extname(config.features.default_avatar));
logger.debug(`guessed mimetype ${mimetype} for ${config.features.default_avatar}`);
avatar = `data:${mimetype};base64,${buf.toString('base64')}`;
}
const newUser = await prisma.user.create({
data: {
password: hashed,
username,
token: createToken(),
administrator: false,
avatar,
},
});
if (code) {
await prisma.invite.update({
where: {
code,
},
data: {
used: true,
},
});
}
logger.debug(`created user via invite ${code} ${JSON.stringify(newUser, jsonUserReplacer)}`);
logger.info(
`Created user ${newUser.username} (${newUser.id}) ${
code ? `from invite code ${code}` : 'via registration'
}`
);
return res.json({ success: true });
}
const user = await req.user();
if (!user) return res.unauthorized('not logged in');
if (!user.administrator) return res.forbidden('you arent an administrator');
const { username, password, administrator } = req.body as {
username: string;
password: string;
administrator: boolean;
};
if (!username) return res.badRequest('no username');
if (!password) return res.badRequest('no password');
const existing = await prisma.user.findFirst({
where: {
username,
},
});
if (existing) return res.badRequest('user exists');
const hashed = await hashPassword(password);
let avatar;
if (config.features.default_avatar) {
logger.debug(`using default avatar ${config.features.default_avatar}`);
const buf = await readFile(config.features.default_avatar);
const mimetype = await guess(extname(config.features.default_avatar));
logger.debug(`guessed mimetype ${mimetype} for ${config.features.default_avatar}`);
avatar = `data:${mimetype};base64,${buf.toString('base64')}`;
}
const newUser = await prisma.user.create({
data: {
password: hashed,
username,
token: createToken(),
administrator,
avatar,
},
});
logger.debug(`created user ${JSON.stringify(newUser, jsonUserReplacer)}`);
delete newUser.password;
logger.info(`Created user ${newUser.username} (${newUser.id})`);
return res.json(newUser);
}
export default withZipline(handler, {
methods: ['POST'],
});

View File

@@ -1,3 +1,4 @@
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
import config from 'lib/config';
import Logger from 'lib/logger';
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'lib/middleware/withZipline';
@@ -16,8 +17,12 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
count: number;
};
const expiry = parseExpiry(expiresAt);
if (!expiry) return res.badRequest('invalid date');
let expiry: Date;
try {
expiry = parseExpiry(expiresAt);
} catch (error) {
return res.badRequest(error.message);
}
const counts = count ? count : 1;
if (counts > 1) {
@@ -37,7 +42,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
logger.info(
`${user.username} (${user.id}) created ${data.length} invites with codes ${data
.map((invite) => invite.code)
.join(', ')}`
.join(', ')}`,
);
return res.json(data);
@@ -60,19 +65,22 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
const { code } = req.query as { code: string };
if (!code) return res.badRequest('no code');
const invite = await prisma.invite.delete({
where: {
code,
},
});
try {
const invite = await prisma.invite.delete({
where: {
code,
},
});
if (!invite) return res.notFound('invite not found');
logger.debug(`deleted invite ${JSON.stringify(invite)}`);
logger.debug(`deleted invite ${JSON.stringify(invite)}`);
logger.info(`${user.username} (${user.id}) deleted invite ${invite.code}`);
logger.info(`${user.username} (${user.id}) deleted invite ${invite.code}`);
return res.json(invite);
return res.json(invite);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) return res.notFound('invite not found');
else throw error;
}
} else {
const invites = await prisma.invite.findMany({
orderBy: {

View File

@@ -14,8 +14,8 @@ async function handler(req: NextApiReq, res: NextApiRes) {
code?: string;
};
const users = await prisma.user.findMany();
if (users.length === 0) {
const users = await prisma.user.count();
if (users === 0) {
logger.debug('no users found... creating default user...');
await prisma.user.create({
data: {
@@ -51,7 +51,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
const success = verify_totp_code(user.totpSecret, code);
logger.debug(
`body(${JSON.stringify(req.body)}): verify_totp_code(${user.totpSecret}, ${code}) => ${success}`
`body(${JSON.stringify(req.body)}): verify_totp_code(${user.totpSecret}, ${code}) => ${success}`,
);
if (!success) return res.badRequest('Invalid code', { totp: true });
}

View File

@@ -3,7 +3,7 @@ import Logger from 'lib/logger';
import { OAuthQuery, OAuthResponse, withOAuth } from 'lib/middleware/withOAuth';
import { withZipline } from 'lib/middleware/withZipline';
import { discord_auth } from 'lib/oauth';
import { getBase64URLFromURL, notNull } from 'lib/util';
import { getBase64URLFromURL, isNotNullOrUndefined } from 'lib/util';
async function handler({ code, state, host }: OAuthQuery, logger: Logger): Promise<OAuthResponse> {
if (!config.features.oauth_registration)
@@ -12,7 +12,10 @@ async function handler({ code, state, host }: OAuthQuery, logger: Logger): Promi
error: 'oauth registration is disabled',
};
if (!notNull(config.oauth.discord_client_id, config.oauth.discord_client_secret)) {
if (
!isNotNullOrUndefined(config.oauth.discord_client_id) &&
!isNotNullOrUndefined(config.oauth.discord_client_secret)
) {
logger.error('Discord OAuth is not configured');
return {
@@ -26,7 +29,8 @@ async function handler({ code, state, host }: OAuthQuery, logger: Logger): Promi
redirect: discord_auth.oauth_url(
config.oauth.discord_client_id,
`${config.core.return_https ? 'https' : 'http'}://${host}`,
state
state,
config.oauth.discord_redirect_uri,
),
};
@@ -35,7 +39,9 @@ async function handler({ code, state, host }: OAuthQuery, logger: Logger): Promi
client_secret: config.oauth.discord_client_secret,
code,
grant_type: 'authorization_code',
redirect_uri: `${config.core.return_https ? 'https' : 'http'}://${host}/api/auth/oauth/discord`,
redirect_uri:
config.oauth.discord_redirect_uri ||
`${config.core.return_https ? 'https' : 'http'}://${host}/api/auth/oauth/discord`,
scope: 'identify',
});
@@ -67,6 +73,12 @@ async function handler({ code, state, host }: OAuthQuery, logger: Logger): Promi
: `https://cdn.discordapp.com/embed/avatars/${userJson.discriminator % 5}.png`;
const avatarBase64 = await getBase64URLFromURL(avatar);
if (
config.oauth.discord_whitelisted_users?.length &&
!config.oauth.discord_whitelisted_users.includes(userJson.id)
)
return { error: 'user is not whitelisted' };
return {
username: userJson.username,
user_id: userJson.id,

View File

@@ -3,7 +3,7 @@ import Logger from 'lib/logger';
import { OAuthQuery, OAuthResponse, withOAuth } from 'lib/middleware/withOAuth';
import { withZipline } from 'lib/middleware/withZipline';
import { github_auth } from 'lib/oauth';
import { getBase64URLFromURL, notNull } from 'lib/util';
import { getBase64URLFromURL, isNotNullOrUndefined } from 'lib/util';
async function handler({ code, state }: OAuthQuery, logger: Logger): Promise<OAuthResponse> {
if (!config.features.oauth_registration)
@@ -12,7 +12,10 @@ async function handler({ code, state }: OAuthQuery, logger: Logger): Promise<OAu
error: 'oauth registration is disabled',
};
if (!notNull(config.oauth.github_client_id, config.oauth.github_client_secret)) {
if (
!isNotNullOrUndefined(config.oauth.github_client_id) &&
!isNotNullOrUndefined(config.oauth.github_client_secret)
) {
logger.error('GitHub OAuth is not configured');
return {
error_code: 401,

View File

@@ -3,7 +3,7 @@ import Logger from 'lib/logger';
import { OAuthQuery, OAuthResponse, withOAuth } from 'lib/middleware/withOAuth';
import { withZipline } from 'lib/middleware/withZipline';
import { google_auth } from 'lib/oauth';
import { getBase64URLFromURL, notNull } from 'lib/util';
import { getBase64URLFromURL, isNotNullOrUndefined } from 'lib/util';
async function handler({ code, state, host }: OAuthQuery, logger: Logger): Promise<OAuthResponse> {
if (!config.features.oauth_registration)
@@ -12,7 +12,10 @@ async function handler({ code, state, host }: OAuthQuery, logger: Logger): Promi
error: 'oauth registration is disabled',
};
if (!notNull(config.oauth.google_client_id, config.oauth.google_client_secret)) {
if (
!isNotNullOrUndefined(config.oauth.google_client_id) &&
!isNotNullOrUndefined(config.oauth.google_client_secret)
) {
logger.error('Google OAuth is not configured');
return {
error_code: 401,
@@ -25,7 +28,8 @@ async function handler({ code, state, host }: OAuthQuery, logger: Logger): Promi
redirect: google_auth.oauth_url(
config.oauth.google_client_id,
`${config.core.return_https ? 'https' : 'http'}://${host}`,
state
state,
config.oauth.google_redirect_uri,
),
};
@@ -33,7 +37,9 @@ async function handler({ code, state, host }: OAuthQuery, logger: Logger): Promi
code,
client_id: config.oauth.google_client_id,
client_secret: config.oauth.google_client_secret,
redirect_uri: `${config.core.return_https ? 'https' : 'http'}://${host}/api/auth/oauth/google`,
redirect_uri:
config.oauth.google_redirect_uri ||
`${config.core.return_https ? 'https' : 'http'}://${host}/api/auth/oauth/google`,
grant_type: 'authorization_code',
});

View File

@@ -11,23 +11,49 @@ import { extname } from 'path';
const logger = Logger.get('user');
async function handler(req: NextApiReq, res: NextApiRes) {
if (!config.features.user_registration) return res.badRequest('user registration is disabled');
const user = await req.user();
let badRequest,
usedInvite = false;
const { username, password, administrator } = req.body as {
if (!config.features.user_registration && !config.features.invites && !user?.administrator)
return res.badRequest('This endpoint is unavailable due to current configurations');
else if (!!user && !user?.administrator) return res.badRequest('Already logged in');
const { username, password, administrator, code } = req.body as {
username: string;
password: string;
administrator: boolean;
code?: string;
};
if (!username) return res.badRequest('no username');
if (!password) return res.badRequest('no password');
if (!username) badRequest = true;
if (!password) badRequest = true;
const existing = await prisma.user.findFirst({
where: {
username,
},
select: {
username: true,
},
});
if (existing) return res.badRequest('user exists');
if (existing) badRequest = true;
if (badRequest) return res.badRequest('Bad Username/Password');
if (code) {
if (config.features.invites) {
const invite = await prisma.invite.findUnique({
where: {
code,
},
});
if (!invite || invite?.used) return res.badRequest('Bad invite');
usedInvite = true;
} else return res.badRequest('Bad Username/Password');
} else if (config.features.invites && !user?.administrator) return res.badRequest('Bad invite');
const hashed = await hashPassword(password);
@@ -47,12 +73,20 @@ async function handler(req: NextApiReq, res: NextApiRes) {
password: hashed,
username,
token: createToken(),
administrator,
administrator: user?.superAdmin ? administrator : false,
avatar,
},
});
logger.debug(`registered user ${JSON.stringify(newUser, jsonUserReplacer)}`);
if (usedInvite)
await prisma.invite.update({
where: { code },
data: { used: true },
});
logger.debug(
`registered user${usedInvite ? ' via invite ' + code : ''} ${JSON.stringify(newUser, jsonUserReplacer)}`,
);
delete newUser.password;

View File

@@ -26,7 +26,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
if (!image) return res.notFound('image not found');
logger.info(
`${user.username} (${user.id}) requested to read exif metadata for image ${image.name} (${image.id})`
`${user.username} (${user.id}) requested to read exif metadata for image ${image.name} (${image.id})`,
);
if (config.datasource.type === 'local') {

View File

@@ -54,7 +54,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
logger.debug(`shortened ${JSON.stringify(url)}`);
logger.info(`User ${user.username} (${user.id}) shortenned a url ${url.destination} (${url.id})`);
logger.info(`User ${user.username} (${user.id}) shortened a url ${url.destination} (${url.id})`);
let domain;
if (req.headers['override-domain']) {

View File

@@ -30,6 +30,45 @@ async function handler(req: NextApiReq, res: NextApiRes) {
if (!user) return res.forbidden('authorization incorrect');
if (user.ratelimit && !req.headers['content-range']) {
const remaining = user.ratelimit.getTime() - Date.now();
logger.debug(`${user.id} encountered ratelimit, ${remaining}ms remaining`);
if (remaining <= 0) {
await prisma.user.update({
where: {
id: user.id,
},
data: {
ratelimit: null,
},
});
} else {
return res.ratelimited(remaining);
}
} else if (!user.ratelimit && !req.headers['content-range']) {
if (user.administrator && zconfig.ratelimit.admin > 0) {
await prisma.user.update({
where: {
id: user.id,
},
data: {
ratelimit: new Date(Date.now() + zconfig.ratelimit.admin * 1000),
},
});
} else if (!user.administrator && zconfig.ratelimit.user > 0) {
if (user.administrator && zconfig.ratelimit.user > 0) {
await prisma.user.update({
where: {
id: user.id,
},
data: {
ratelimit: new Date(Date.now() + zconfig.ratelimit.user * 1000),
},
});
}
}
}
await new Promise((resolve, reject) => {
uploader.array('file')(req as never, res as never, (result: unknown) => {
if (result instanceof Error) reject(result.message);
@@ -42,6 +81,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
expiresAt?: Date;
removed_gps?: boolean;
assumed_mimetype?: string | boolean;
folder?: number;
} = {
files: [],
};
@@ -49,16 +89,20 @@ async function handler(req: NextApiReq, res: NextApiRes) {
let expiry: Date;
if (expiresAt) {
expiry = parseExpiry(expiresAt);
if (!expiry) return res.badRequest('invalid date');
else {
try {
expiry = parseExpiry(expiresAt);
response.expiresAt = expiry;
} catch (error) {
return res.badRequest(error.message);
}
}
if (zconfig.uploader.default_expiration) {
expiry = parseExpiry(zconfig.uploader.default_expiration);
if (!expiry) return res.badRequest('invalid date (UPLOADER_DEFAULT_EXPIRATION)');
try {
expiry = parseExpiry(zconfig.uploader.default_expiration);
} catch (error) {
return res.badRequest(`${error.message} (UPLOADER_DEFAULT_EXPIRATION)`);
}
}
const rawFormat = ((req.headers['format'] as string) || zconfig.uploader.default_format).toLowerCase();
@@ -78,8 +122,32 @@ async function handler(req: NextApiReq, res: NextApiRes) {
if (isNaN(fileMaxViews)) return res.badRequest('invalid max views (invalid number)');
if (fileMaxViews < 0) return res.badRequest('invalid max views (max views < 0)');
const folderToAdd = req.headers['x-zipline-folder'] ? Number(req.headers['x-zipline-folder']) : null;
if (folderToAdd) {
if (isNaN(folderToAdd)) return res.badRequest('invalid folder id (invalid number)');
const folder = await prisma.folder.findFirst({
where: {
id: folderToAdd,
userId: user.id,
},
});
if (!folder) return res.badRequest('invalid folder id (no folder found)');
response.folder = folder.id;
}
// handle partial uploads before ratelimits
if (req.headers['content-range'] && zconfig.chunks.enabled) {
if (format === 'name') {
const existing = await prisma.file.findFirst({
where: {
name: req.headers['x-zipline-partial-filename'] as string,
},
});
if (existing) return res.badRequest('filename already exists (conflict: NAME format)');
}
// parses content-range header (bytes start-end/total)
const [start, end, total] = req.headers['content-range']
.replace('bytes ', '')
@@ -101,7 +169,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
start,
end,
total,
})}`
})}`,
);
const tempFile = join(zconfig.core.temp_directory, `zipline_partial_${identifier}_${start}_${end}`);
@@ -118,6 +186,9 @@ async function handler(req: NextApiReq, res: NextApiRes) {
mimetype: req.headers.uploadtext ? 'text/plain' : mimetype,
userId: user.id,
originalName: req.headers['original-name'] ? filename ?? null : null,
...(folderToAdd && {
folderId: folderToAdd,
}),
},
});
@@ -165,23 +236,6 @@ async function handler(req: NextApiReq, res: NextApiRes) {
});
}
if (user.ratelimit) {
const remaining = user.ratelimit.getTime() - Date.now();
logger.debug(`${user.id} encountered ratelimit, ${remaining}ms remaining`);
if (remaining <= 0) {
await prisma.user.update({
where: {
id: user.id,
},
data: {
ratelimit: null,
},
});
} else {
return res.ratelimited(remaining);
}
}
if (!req.files) return res.badRequest('no files');
if (req.files && req.files.length === 0) return res.badRequest('no files');
@@ -193,29 +247,32 @@ async function handler(req: NextApiReq, res: NextApiRes) {
mimetype: x.mimetype,
size: x.size,
encoding: x.encoding,
}))
)}`
})),
)}`,
);
for (let i = 0; i !== req.files.length; ++i) {
const file = req.files[i];
if (file.size > zconfig.uploader[user.administrator ? 'admin_limit' : 'user_limit'])
return res.badRequest(`file[${i}]: size too big`);
if (!file.originalname) return res.badRequest(`file[${i}]: no filename`);
const ext = file.originalname.split('.').length === 1 ? '' : file.originalname.split('.').pop();
const decodedName = decodeURI(file.originalname);
const ext = decodedName.split('.').length === 1 ? '' : decodedName.split('.').pop();
if (zconfig.uploader.disabled_extensions.includes(ext))
return res.badRequest(`file[${i}]: disabled extension recieved: ${ext}`);
let fileName = await formatFileName(format, file.originalname);
const fileName = await formatFileName(format, decodedName);
if (req.headers['x-zipline-filename']) {
fileName = req.headers['x-zipline-filename'] as string;
if (format === 'name' || req.headers['x-zipline-filename']) {
const exist = (req.headers['x-zipline-filename'] as string) || decodedName;
const existing = await prisma.file.findFirst({
where: {
name: fileName,
name: exist,
},
});
if (existing) return res.badRequest(`file[${i}]: filename already exists: '${fileName}'`);
if (existing) return res.badRequest(`file[${i}]: filename already exists: '${decodedName}'`);
}
let password = null;
@@ -226,7 +283,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
let mimetype = file.mimetype;
if (file.mimetype === 'application/octet-stream' && zconfig.uploader.assume_mimetypes) {
const ext = parse(file.originalname).ext.replace('.', '');
const ext = parse(decodedName).ext.replace('.', '');
const mime = await guess(ext);
if (!mime) response.assumed_mimetype = false;
@@ -247,8 +304,11 @@ async function handler(req: NextApiReq, res: NextApiRes) {
password,
expiresAt: expiry,
maxViews: fileMaxViews,
originalName: req.headers['original-name'] ? file.originalname ?? null : null,
originalName: req.headers['original-name'] ? decodedName ?? null : null,
size: file.size,
...(folderToAdd && {
folderId: folderToAdd,
}),
},
});
@@ -257,12 +317,12 @@ async function handler(req: NextApiReq, res: NextApiRes) {
if (compressionUsed) {
const buffer = await sharp(file.buffer).jpeg({ quality: imageCompressionPercent }).toBuffer();
await datasource.save(fileUpload.name, buffer);
await datasource.save(fileUpload.name, buffer, { type: 'image/jpeg' });
logger.info(
`User ${user.username} (${user.id}) compressed image from ${file.buffer.length} -> ${buffer.length} bytes`
`User ${user.username} (${user.id}) compressed image from ${file.buffer.length} -> ${buffer.length} bytes`,
);
} else {
await datasource.save(fileUpload.name, file.buffer);
await datasource.save(fileUpload.name, file.buffer, { type: file.mimetype });
}
logger.info(`User ${user.username} (${user.id}) uploaded ${fileUpload.name} (${fileUpload.id})`);
@@ -286,7 +346,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
user,
fileUpload,
`${domain}/r/${invis ? invis.invis : encodeURI(fileUpload.name)}`,
responseUrl
responseUrl,
);
}
@@ -302,28 +362,6 @@ async function handler(req: NextApiReq, res: NextApiRes) {
}
}
if (user.administrator && zconfig.ratelimit.admin > 0) {
await prisma.user.update({
where: {
id: user.id,
},
data: {
ratelimit: new Date(Date.now() + zconfig.ratelimit.admin * 1000),
},
});
} else if (!user.administrator && zconfig.ratelimit.user > 0) {
if (user.administrator && zconfig.ratelimit.user > 0) {
await prisma.user.update({
where: {
id: user.id,
},
data: {
ratelimit: new Date(Date.now() + zconfig.ratelimit.user * 1000),
},
});
}
}
if (req.headers['no-json']) {
res.setHeader('Content-Type', 'text/plain');
return res.end(response.files.join(','));

View File

@@ -21,6 +21,9 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
include: {
thumbnail: true,
},
orderBy: {
createdAt: 'desc',
},
},
Folder: true,
},
@@ -36,7 +39,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
promises.push(
prisma.user.delete({
where: { id: target.id },
})
}),
);
if (req.body.delete_files) {
@@ -61,7 +64,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
where: {
userId: target.id,
},
})
}),
);
}
Promise.all(promises).then((promised) => {
@@ -71,10 +74,10 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
req.body.delete_files
? logger.info(
`User ${user.username} (${user.id}) deleted ${count} files of user ${newTarget.username} (${newTarget.id})`
`User ${user.username} (${user.id}) deleted ${count} files of user ${newTarget.username} (${newTarget.id})`,
)
: logger.info(
`User ${user.username} (${user.id}) deleted user ${newTarget.username} (${newTarget.id})`
`User ${user.username} (${user.id}) deleted user ${newTarget.username} (${newTarget.id})`,
);
delete newTarget.password;
@@ -177,7 +180,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
logger.debug(`updated user ${id} with ${JSON.stringify(newUser, jsonUserReplacer)}`);
logger.info(
`User ${user.username} (${user.id}) updated ${target.username} (${newUser.username}) (${newUser.id})`
`User ${user.username} (${user.id}) updated ${target.username} (${newUser.username}) (${newUser.id})`,
);
delete newUser.password;

View File

@@ -3,18 +3,19 @@ import prisma from 'lib/prisma';
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
async function handler(req: NextApiReq, res: NextApiRes) {
if (!config.features.user_registration && !req.body.code)
return res.badRequest('user registration is disabled');
else if (!config.features.invites && req.body.code) return res.forbidden('user/invites are disabled');
const { code, username } = req.body as { code?: string; username?: string };
if (!req.body?.code) return res.badRequest('no code');
if (!req.body?.username) return res.badRequest('no username');
if (!config.features.user_registration && !code) return res.badRequest('user registration is disabled');
else if (!config.features.invites && code) return res.forbidden('user invites are disabled');
const { code, username } = req.body as { code: string; username: string };
const invite = await prisma.invite.findUnique({
where: { code },
});
if (!invite) return res.badRequest('invalid invite code');
if (config.features.invites && !code) return res.badRequest('no code');
else if (config.features.invites && code) {
const invite = await prisma.invite.findUnique({
where: { code },
});
if (!invite) return res.badRequest('invalid invite code');
}
if (!username) return res.badRequest('no username');
const user = await prisma.user.findFirst({
where: { username },

View File

@@ -1,6 +1,6 @@
import { Zip, ZipPassThrough } from 'fflate';
import { createReadStream, createWriteStream } from 'fs';
import { readdir, stat } from 'fs/promises';
import { rm, stat } from 'fs/promises';
import datasource from 'lib/datasource';
import Logger from 'lib/logger';
import prisma from 'lib/prisma';
@@ -23,6 +23,13 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
const export_name = `zipline_export_${user.id}_${Date.now()}.zip`;
const path = join(config.core.temp_directory, export_name);
const exportDb = await prisma.export.create({
data: {
path: export_name,
userId: user.id,
},
});
logger.debug(`creating write stream at ${path}`);
const write_stream = createWriteStream(path);
@@ -77,13 +84,29 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
write_stream.close();
logger.debug(`finished writing zip to ${path} at ${data.length} bytes written`);
logger.info(
`Export for ${user.username} (${user.id}) has completed and is available at ${export_name}`
`Export for ${user.username} (${user.id}) has completed and is available at ${export_name}`,
);
await prisma.export.update({
where: {
id: exportDb.id,
},
data: {
complete: true,
},
});
}
} else {
write_stream.close();
logger.debug(`error while writing to zip: ${err}`);
logger.error(`Export for ${user.username} (${user.id}) has failed\n${err}`);
logger.error(
`Export for ${user.username} (${user.id}) has failed and has been removed from the database\n${err}`,
);
await prisma.export.delete({
where: {
id: exportDb.id,
},
});
}
};
@@ -114,27 +137,62 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
res.json({
url: '/api/user/export?name=' + export_name,
});
} else {
const export_name = req.query.name as string;
if (export_name) {
const parts = export_name.split('_');
if (Number(parts[2]) !== user.id) return res.unauthorized('cannot access export owned by another user');
} else if (req.method === 'DELETE') {
const name = req.query.name as string;
if (!name) return res.badRequest('no name provided');
const stream = createReadStream(join(config.core.temp_directory, export_name));
const exportDb = await prisma.export.findFirst({
where: {
userId: user.id,
path: name,
},
});
if (!exportDb) return res.notFound('export not found');
await prisma.export.delete({
where: {
id: exportDb.id,
},
});
try {
await rm(join(config.core.temp_directory, exportDb.path));
} catch (e) {
logger
.error(`export file ${exportDb.path} has been removed from the database`)
.error(`but failed to remove the file from the filesystem: ${e}`);
}
res.json({
success: true,
});
} else {
const exportsDb = await prisma.export.findMany({
where: {
userId: user.id,
},
});
const name = req.query.name as string;
if (name) {
const exportDb = exportsDb.find((e) => e.path === name);
if (!exportDb) return res.notFound('export not found');
const stream = createReadStream(join(config.core.temp_directory, exportDb.path));
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${export_name}"`);
res.setHeader('Content-Disposition', `attachment; filename="${exportDb.path}"`);
stream.pipe(res);
} else {
const files = await readdir(config.core.temp_directory);
const exp = files.filter((f) => f.startsWith('zipline_export_'));
const exports = [];
for (let i = 0; i !== exp.length; ++i) {
const name = exp[i];
const stats = await stat(join(config.core.temp_directory, name));
if (Number(exp[i].split('_')[2]) !== user.id) continue;
exports.push({ name, size: stats.size });
for (let i = 0; i !== exportsDb.length; ++i) {
const exportDb = exportsDb[i];
if (!exportDb.complete) continue;
const stats = await stat(join(config.core.temp_directory, exportDb.path));
exports.push({ name: exportDb.path, size: stats.size, createdAt: exportDb.createdAt });
}
res.json({
@@ -145,6 +203,6 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
}
export default withZipline(handler, {
methods: ['GET', 'POST'],
methods: ['GET', 'POST', 'DELETE'],
user: true,
});

View File

@@ -76,7 +76,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
if (file.thumbnail?.name) await datasource.delete(file.thumbnail.name);
logger.info(
`User ${user.username} (${user.id}) deleted an image ${file.name} (${file.id}) owned by ${file.user.username} (${file.user.id})`
`User ${user.username} (${user.id}) deleted an image ${file.name} (${file.id}) owned by ${file.user.username} (${file.user.id})`,
);
// @ts-ignore
@@ -139,7 +139,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
expiresAt: Date;
maxViews: number;
views: number;
size: number;
size: bigint;
originalName: string;
thumbnail?: { name: string };
}[] = await prisma.file.findMany({

View File

@@ -16,7 +16,13 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
id: idParsed,
},
select: {
files: !!req.query.files,
files: req.query.files
? {
include: {
thumbnail: true,
},
}
: false,
id: true,
name: true,
userId: true,
@@ -70,7 +76,13 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
},
},
select: {
files: !!req.query.files,
files: req.query.files
? {
include: {
thumbnail: true,
},
}
: false,
id: true,
name: true,
userId: true,
@@ -83,7 +95,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
logger.debug(`added file ${fileIdParsed} to folder ${idParsed}`);
logger.info(
`Added file "${file.name}" to folder "${folder.name}" for user ${user.username} (${user.id})`
`Added file "${file.name}" to folder "${folder.name}" for user ${user.username} (${user.id})`,
);
if (req.query.files) {
@@ -94,7 +106,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
(folder.files[i] as unknown as { url: string }).url = formatRootUrl(
config.uploader.route,
folder.files[i].name
folder.files[i].name,
);
}
}
@@ -111,7 +123,13 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
public: !!publicFolder,
},
select: {
files: !!req.query.files,
files: req.query.files
? {
include: {
thumbnail: true,
},
}
: false,
id: true,
name: true,
userId: true,
@@ -129,7 +147,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
(folder.files[i] as unknown as { url: string }).url = formatRootUrl(
config.uploader.route,
folder.files[i].name
folder.files[i].name,
);
}
}
@@ -200,7 +218,13 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
},
},
select: {
files: !!req.query.files,
files: req.query.files
? {
include: {
thumbnail: true,
},
}
: false,
id: true,
name: true,
userId: true,
@@ -213,7 +237,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
logger.debug(`removed file ${fileIdParsed} from folder ${idParsed}`);
logger.info(
`Removed file "${file.name}" from folder "${folder.name}" for user ${user.username} (${user.id})`
`Removed file "${file.name}" from folder "${folder.name}" for user ${user.username} (${user.id})`,
);
if (req.query.files) {
@@ -224,7 +248,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
(folder.files[i] as unknown as { url: string }).url = formatRootUrl(
config.uploader.route,
folder.files[i].name
folder.files[i].name,
);
}
}
@@ -240,7 +264,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
(folder.files[i] as unknown as { url: string }).url = formatRootUrl(
config.uploader.route,
folder.files[i].name
folder.files[i].name,
);
}
}

View File

@@ -25,7 +25,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
if (files.length !== add.length)
return res.badRequest(
`files ${add.filter((id) => !files.find((file) => file.id === Number(id))).join(', ')} not found`
`files ${add.filter((id) => !files.find((file) => file.id === Number(id))).join(', ')} not found`,
);
const folder = await prisma.folder.create({
@@ -87,7 +87,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
(folder.files[j] as unknown as { url: string }).url = formatRootUrl(
config.uploader.route,
folder.files[j].name
folder.files[j].name,
);
}
}

View File

@@ -36,7 +36,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
error: 'oauth token expired',
redirect_uri: discord_auth.oauth_url(
zconfig.oauth.discord_client_id,
`${zconfig.core.return_https ? 'https' : 'http'}://${req.headers.host}`
`${zconfig.core.return_https ? 'https' : 'http'}://${req.headers.host}`,
),
});
}
@@ -60,7 +60,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
error: 'oauth token expired',
redirect_uri: discord_auth.oauth_url(
zconfig.oauth.discord_client_id,
`${zconfig.core.return_https ? 'https' : 'http'}://${req.headers.host}`
`${zconfig.core.return_https ? 'https' : 'http'}://${req.headers.host}`,
),
});
}
@@ -80,7 +80,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
const resp = await fetch(
`https://people.googleapis.com/v1/people/me?access_token=${
user.oauth.find((o) => o.provider === 'GOOGLE').token
}&personFields=names,photos`
}&personFields=names,photos`,
);
if (!resp.ok) {
const provider = user.oauth.find((o) => o.provider === 'GOOGLE');
@@ -91,7 +91,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
error: 'oauth token expired',
redirect_uri: google_auth.oauth_url(
zconfig.oauth.google_client_id,
`${zconfig.core.return_https ? 'https' : 'http'}://${req.headers.host}`
`${zconfig.core.return_https ? 'https' : 'http'}://${req.headers.host}`,
),
});
}
@@ -114,7 +114,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
error: 'oauth token expired',
redirect_uri: google_auth.oauth_url(
zconfig.oauth.google_client_id,
`${zconfig.core.return_https ? 'https' : 'http'}://${req.headers.host}`
`${zconfig.core.return_https ? 'https' : 'http'}://${req.headers.host}`,
),
});
}

View File

@@ -44,7 +44,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
logger.debug(
`body(${JSON.stringify(req.body)}): verify_totp_code(${user.totpSecret}, ${
req.body.code
}) => ${success}`
}) => ${success}`,
);
if (!success) return res.badRequest('Invalid code');

View File

@@ -15,7 +15,7 @@ const sortByValidator = s.enum(
'size',
'name',
'mimetype',
] satisfies (keyof Prisma.FileOrderByWithRelationInput)[])
] satisfies (keyof Prisma.FileOrderByWithRelationInput)[]),
);
const orderValidator = s.enum('asc', 'desc');
@@ -83,7 +83,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
maxViews: number;
views: number;
folderId: number;
size: number;
size: bigint;
password: string | boolean;
thumbnail?: { name: string };
}[] = await prisma.file.findMany({

View File

@@ -1,3 +1,4 @@
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
import config from 'lib/config';
import Logger from 'lib/logger';
import prisma from 'lib/prisma';
@@ -8,15 +9,22 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
if (req.method === 'DELETE') {
if (!req.body.id) return res.badRequest('no url id');
const url = await prisma.url.delete({
where: {
id: req.body.id,
},
});
try {
const url = await prisma.url.delete({
where: {
id: req.body.id,
},
});
Logger.get('url').info(`User ${user.username} (${user.id}) deleted a url ${url.destination} (${url.id})`);
Logger.get('url').info(
`User ${user.username} (${user.id}) deleted a url ${url.destination} (${url.id})`,
);
return res.json(url);
return res.json(url);
} catch (err) {
if (err instanceof PrismaClientKnownRequestError) return res.notFound('url not found');
else throw err;
}
} else {
const urls = await prisma.url.findMany({
where: {
@@ -38,7 +46,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
for (let i = 0; i !== urls.length; ++i) {
(urls[i] as unknown as { url: string }).url = formatRootUrl(
config.urls.route,
urls[i].vanity ?? urls[i].id
urls[i].vanity ?? urls[i].id,
);
}
return res.json(urls);

View File

@@ -7,7 +7,7 @@ async function handler(_: NextApiReq, res: NextApiRes) {
const pkg = JSON.parse(await readFile('package.json', 'utf8'));
const re = await fetch('https://zipline.diced.tech/api/version?c=' + pkg.version);
const re = await fetch('https://zipline.diced.sh/api/version?c=' + pkg.version);
const json = await re.json();
let updateToType = 'stable';

View File

@@ -96,7 +96,10 @@ export default function Login({
setLoading(false);
}
} else {
await router.push((router.query.url as string) || '/dashboard');
let redirectUrl = (router.query.url as string) || '/dashboard';
if (!redirectUrl.startsWith('/dashboard')) redirectUrl = '/dashboard';
await router.push(redirectUrl);
}
};

View File

@@ -6,14 +6,13 @@ import useFetch from 'hooks/useFetch';
import config from 'lib/config';
import prisma from 'lib/prisma';
import { userSelector } from 'lib/recoil/user';
import { randomChars } from 'lib/util';
import { GetServerSideProps } from 'next';
import Head from 'next/head';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { useSetRecoilState } from 'recoil';
export default function Register({ code, title, user_registration }) {
export default function Register({ code = undefined, title, user_registration }) {
const [active, setActive] = useState(0);
const [username, setUsername] = useState('');
const [usernameError, setUsernameError] = useState('');
@@ -51,7 +50,7 @@ export default function Register({ code, title, user_registration }) {
};
const createUser = async () => {
const res = await useFetch(`/api/auth/${user_registration ? 'register' : 'create'}`, 'POST', {
const res = await useFetch('/api/auth/register', 'POST', {
code: user_registration ? null : code,
username,
password,
@@ -196,20 +195,9 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
notFound: true,
};
const code = randomChars(4);
const temp = await prisma.invite.create({
data: {
code,
createdById: 1,
},
});
logger.debug(`request to access user registration, creating temporary invite ${JSON.stringify(temp)}`);
return {
props: {
title: config.website.title,
code,
user_registration: true,
},
};

View File

@@ -58,7 +58,7 @@ export default function Code({ code, id, title, render, renderType }) {
{!render && (
<PrismCode
sx={(t) => ({ height: '100vh', backgroundColor: t.colors.dark[8] })}
sx={(t) => ({ height: '100vh', overflow: 'scroll', backgroundColor: t.colors.dark[8] })}
code={code}
ext={id.split('.').pop()}
/>
@@ -66,7 +66,7 @@ export default function Code({ code, id, title, render, renderType }) {
{render && overrideRender && (
<PrismCode
sx={(t) => ({ height: '100vh', backgroundColor: t.colors.dark[8] })}
sx={(t) => ({ height: '100vh', overflow: 'scroll', backgroundColor: t.colors.dark[8] })}
code={code}
ext={id.split('.').pop()}
/>
@@ -115,6 +115,17 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
renderType = null;
}
await prisma.file.update({
where: {
id: file.id,
},
data: {
views: {
increment: 1,
},
},
});
return {
props: {
code: await streamToString(data),

View File

@@ -17,7 +17,9 @@ export default function UploadPage(props) {
<title>{title}</title>
</Head>
<Layout props={props}>
<File chunks={{ chunks_size: props.chunks_size, max_size: props.max_size }} />
<File
chunks={{ chunks_size: props.chunks_size, max_size: props.max_size, enabled: props.chunks_enabled }}
/>
</Layout>
</>
);

View File

@@ -12,6 +12,7 @@ type LimitedFolder = {
createdAt: Date | string;
mimetype: string;
views: number;
size: bigint;
}[];
user: {
username: string;
@@ -83,6 +84,13 @@ export const getServerSideProps: GetServerSideProps<Props> = async (context) =>
views: true,
createdAt: true,
password: true,
size: true,
thumbnail: {
select: {
name: true,
id: true,
},
},
},
},
user: {
@@ -101,9 +109,12 @@ export const getServerSideProps: GetServerSideProps<Props> = async (context) =>
for (let j = 0; j !== folder.files.length; ++j) {
(folder.files[j] as unknown as { url: string }).url = formatRootUrl(
config.uploader.route,
folder.files[j].name
folder.files[j].name,
);
// @ts-ignore
folder.files[j].size = Number(folder.files[j].size);
// @ts-ignore
if (folder.files[j].password) folder.files[j].password = true;

View File

@@ -1,4 +1,4 @@
import { Box, Button, Modal, PasswordInput } from '@mantine/core';
import { Box, Button, Modal, PasswordInput, Title } from '@mantine/core';
import type { File, Thumbnail } from '@prisma/client';
import AnchorNext from 'components/AnchorNext';
import exts from 'lib/exts';
@@ -11,6 +11,7 @@ import Link from 'next/link';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import zconfig from 'lib/config';
import { log } from 'server/util';
export default function EmbeddedFile({
file,
@@ -27,13 +28,18 @@ export default function EmbeddedFile({
host: string;
compress?: boolean;
}) {
const dataURL = (route: string) => `${route}/${encodeURI(file.name)}?compress=${compress ?? false}`;
const dataURL = (route: string, pass?: string) =>
`${route}/${encodeURIComponent(file.name)}?compress=${compress ?? false}${
pass ? `&password=${encodeURIComponent(pass)}` : ''
}`;
const router = useRouter();
const [opened, setOpened] = useState(pass);
const [opened, setOpened] = useState(pass || !!file.password);
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [downloadWPass, setDownloadWPass] = useState(false);
// reapply date from workaround
file.createdAt = new Date(file ? file.createdAt : 0);
@@ -45,21 +51,33 @@ export default function EmbeddedFile({
if (prismRender) return router.push(`/code/${file.name}?password=${password}`);
updateImage(`/api/auth/image?id=${file.id}&password=${password}`);
setOpened(false);
setDownloadWPass(true);
} else {
setError('Invalid password');
}
};
const updateImage = async (url?: string) => {
if (!file.mimetype.startsWith('image')) return;
const imageEl = document.getElementById('image_content') as HTMLImageElement;
const img = new Image();
img.addEventListener('load', function () {
if (this.naturalWidth > innerWidth)
imageEl.width = Math.floor(
this.naturalWidth * Math.min(innerHeight / this.naturalHeight, innerWidth / this.naturalWidth)
);
else imageEl.width = this.naturalWidth;
// my best attempt of recreating
// firefox: https://searchfox.org/mozilla-central/source/dom/html/ImageDocument.cpp#271-276
// chromium-based: https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/html/image_document.cc
// keeps image original if smaller than screen
if (this.width <= window.innerWidth && this.height <= window.innerHeight) return;
// resizes to fit screen
const ratio = Math.min(innerHeight / this.naturalHeight, innerWidth / this.naturalWidth);
const newWidth = Math.max(1, Math.floor(ratio * this.naturalWidth));
const newHeight = Math.max(1, Math.floor(ratio * this.naturalHeight));
imageEl.width = newWidth;
imageEl.height = newHeight;
});
img.src = url || dataURL('/r');
@@ -72,11 +90,19 @@ export default function EmbeddedFile({
useEffect(() => {
if (pass) {
setOpened(true);
} else {
updateImage();
}
}, []);
useEffect(() => {
if (!file?.mimetype?.startsWith('image')) return;
updateImage();
window.addEventListener('resize', () => updateImage());
return () => {
window.removeEventListener('resize', () => updateImage());
};
}, []);
return (
<>
<Head>
@@ -116,8 +142,6 @@ export default function EmbeddedFile({
<meta name='twitter:card' content='player' />
<meta name='twitter:player' content={`${host}/r/${file.name}`} />
<meta name='twitter:player:stream' content={`${host}/r/${file.name}`} />
<meta name='twitter:player:width' content='720' />
<meta name='twitter:player:height' content='480' />
<meta name='twitter:player:stream:content_type' content={file.mimetype} />
<meta name='twitter:title' content={file.name} />
@@ -128,13 +152,12 @@ export default function EmbeddedFile({
</>
)}
<meta property='og:type' content={'video.other'} />
<meta property='og:url' content={`${host}/r/${file.name}`} />
<meta property='og:video' content={`${host}/r/${file.name}`} />
<meta property='og:video:url' content={`${host}/r/${file.name}`} />
<meta property='og:video:secure_url' content={`${host}/r/${file.name}`} />
<meta property='og:video:type' content={file.mimetype} />
<meta property='og:video:width' content='720' />
<meta property='og:video:height' content='480' />
</>
)}
{file.mimetype.startsWith('audio') && (
@@ -162,14 +185,13 @@ export default function EmbeddedFile({
<Modal
opened={opened}
onClose={() => setOpened(false)}
title='Password Protected'
title={<Title order={3}>Password Protected</Title>}
centered={true}
withCloseButton={true}
withCloseButton={false}
closeOnEscape={false}
closeOnClickOutside={false}
>
<PasswordInput
label='Password'
placeholder='Password'
error={error}
value={password}
@@ -192,7 +214,17 @@ export default function EmbeddedFile({
)}
{file.mimetype.startsWith('video') && (
<video src={dataURL('/r')} controls autoPlay muted id='video_content' />
<video
style={{
maxHeight: '100vh',
maxWidth: '100vw',
}}
src={dataURL('/r')}
controls
autoPlay
muted
id='video_content'
/>
)}
{file.mimetype.startsWith('audio') && (
@@ -202,7 +234,7 @@ export default function EmbeddedFile({
{!file.mimetype.startsWith('video') &&
!file.mimetype.startsWith('image') &&
!file.mimetype.startsWith('audio') && (
<AnchorNext component={Link} href={dataURL('/r')}>
<AnchorNext component={Link} href={dataURL('/r', downloadWPass ? password : undefined)}>
Can&#39;t preview this file. Click here to download it.
</AnchorNext>
)}
@@ -225,6 +257,20 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
let host = context.req.headers.host;
if (!file) return { notFound: true };
const logger = log('view');
if (file.maxViews && file.views >= file.maxViews) {
await datasource.delete(file.name);
await prisma.file.delete({ where: { id: file.id } });
logger.child('file').info(`File ${file.name} has been deleted due to max views (${file.maxViews})`);
return { notFound: true };
}
// @ts-ignore
file.size = Number(file.size);
const proto = context.req.headers['x-forwarded-proto'];
try {
if (
@@ -247,6 +293,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
delete user.password;
delete user.totpSecret;
delete user.token;
delete user.ratelimit;
// @ts-ignore workaround because next wont allow date
file.createdAt = file.createdAt.toString();
@@ -295,6 +342,17 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
// @ts-ignore
if (file.password) file.password = true;
await prisma.file.update({
where: {
id: file.id,
},
data: {
views: {
increment: 1,
},
},
});
return {
props: {
file,

View File

@@ -12,7 +12,7 @@ async function main() {
}
const files = (await readdir(temp)).filter(
(x) => x.startsWith('zipline_partial_') || x.startsWith('zipline_thumb_')
(x) => x.startsWith('zipline_partial_') || x.startsWith('zipline_thumb_'),
);
if (files.length === 0) {
console.log('No partial files found, exiting..');

View File

@@ -1,10 +1,12 @@
import { PrismaClient } from '@prisma/client';
import { readdir, readFile } from 'fs/promises';
import { statSync } from 'fs';
import { join } from 'path';
import config from 'lib/config';
import datasource from 'lib/datasource';
import { guess } from 'lib/mimes';
import { migrations } from 'server/util';
import { bytesToHuman } from 'lib/utils/bytes';
async function main() {
const directory = process.argv[2];
@@ -25,13 +27,16 @@ async function main() {
for (let i = 0; i !== files.length; ++i) {
const mime = await guess(files[i].split('.').pop());
const { size } = statSync(join(directory, files[i]));
data.push({
data[i] = {
name: files[i],
mimetype: mime,
userId,
});
console.log(`Imported ${files[i]} (${mime} mimetype) to user ${userId}`);
size,
};
console.log(`Imported ${files[i]} (${bytesToHuman(size)}) (${mime} mimetype) to user ${userId}`);
}
process.env.DATABASE_URL = config.core.database_url;
@@ -49,7 +54,9 @@ async function main() {
console.log(`Copying files to ${config.datasource.type} storage..`);
for (let i = 0; i !== files.length; ++i) {
const file = files[i];
await datasource.save(file, await readFile(join(directory, file)));
await datasource.save(file, await readFile(join(directory, file)), {
type: data[i]?.mimetype ?? 'application/octet-stream',
});
}
console.log(`Finished copying files to ${config.datasource.type} storage.`);

View File

@@ -64,7 +64,7 @@ async function main() {
notFound
? console.log(
'At least one file has been found to not exist in the datasource but was on the database. To remove these files, run the script with the --force-delete flag.'
'At least one file has been found to not exist in the datasource but was on the database. To remove these files, run the script with the --force-delete flag.',
)
: console.log('Done.');

View File

@@ -2,6 +2,7 @@ import { File } from '@prisma/client';
import { FastifyInstance, FastifyReply } from 'fastify';
import fastifyPlugin from 'fastify-plugin';
import exts from 'lib/exts';
import { parseRangeHeader } from 'lib/utils/range';
function dbFileDecorator(fastify: FastifyInstance, _, done) {
fastify.decorateReply('dbFile', dbFile);
@@ -13,14 +14,29 @@ function dbFileDecorator(fastify: FastifyInstance, _, done) {
const ext = file.name.split('.').pop();
if (Object.keys(exts).includes(ext)) return this.server.nextHandle(this.request.raw, this.raw);
const data = await this.server.datasource.get(file.name);
if (!data) return this.notFound();
const size = await this.server.datasource.size(file.name);
if (size === null) return this.notFound();
this.header('Content-Length', size);
// eslint-disable-next-line prefer-const
let [rangeStart, rangeEnd] = parseRangeHeader(this.request.headers.range);
if (rangeStart >= rangeEnd)
return this.code(416)
.header('Content-Range', `bytes 0/${size - 1}`)
.send();
if (rangeEnd === Infinity) rangeEnd = size - 1;
const data = await this.server.datasource.get(file.name, rangeStart, rangeEnd);
// only send content-range if the client asked for it
if (this.request.headers.range) {
this.code(206);
this.header('Content-Range', `bytes ${rangeStart}-${rangeEnd}/${size}`);
}
this.header('Content-Length', rangeEnd - rangeStart + 1);
this.header('Content-Type', download ? 'application/octet-stream' : file.mimetype);
this.header('Content-Disposition', `inline; filename="${file.originalName || file.name}"`);
this.header('Content-Disposition', `inline; filename="${encodeURI(file.originalName || file.name)}"`);
this.header('Accept-Ranges', 'bytes');
return this.send(data);
}

View File

@@ -3,7 +3,7 @@ import { FastifyInstance, FastifyReply } from 'fastify';
import fastifyPlugin from 'fastify-plugin';
function postUrlDecorator(fastify: FastifyInstance, _, done) {
fastify.decorateReply('postUrl', postUrl);
fastify.decorateReply('postUrl', postUrl.bind(fastify));
done();
async function postUrl(this: FastifyReply, url: Url) {

View File

@@ -5,30 +5,48 @@ import fastifyPlugin from 'fastify-plugin';
import { createBrotliCompress, createDeflate, createGzip } from 'zlib';
import pump from 'pump';
import { Transform } from 'stream';
import { parseRangeHeader } from 'lib/utils/range';
function rawFileDecorator(fastify: FastifyInstance, _, done) {
fastify.decorateReply('rawFile', rawFile);
done();
async function rawFile(this: FastifyReply, id: string) {
const { download, compress } = this.request.query as { download?: string; compress?: boolean };
const data = await this.server.datasource.get(id);
if (!data) return this.notFound();
const { download, compress = 'false' } = this.request.query as { download?: string; compress?: string };
const size = await this.server.datasource.size(id);
if (size === null) return this.notFound();
const mimetype = await guess(extname(id).slice(1));
const size = await this.server.datasource.size(id);
// eslint-disable-next-line prefer-const
let [rangeStart, rangeEnd] = parseRangeHeader(this.request.headers.range);
if (rangeStart >= rangeEnd)
return this.code(416)
.header('Content-Range', `bytes 0/${size - 1}`)
.send();
if (rangeEnd === Infinity) rangeEnd = size - 1;
const data = await this.server.datasource.get(id, rangeStart, rangeEnd + 1);
// only send content-range if the client asked for it
if (this.request.headers.range) {
this.code(206);
this.header('Content-Range', `bytes ${rangeStart}-${rangeEnd}/${size}`);
}
this.header('Content-Length', rangeEnd - rangeStart + 1);
this.header('Content-Type', download ? 'application/octet-stream' : mimetype);
this.header('Accept-Ranges', 'bytes');
if (
this.server.config.core.compression.enabled &&
compress &&
compress?.match(/^true$/i) &&
!this.request.headers['X-Zipline-NoCompress'] &&
!!this.request.headers['accept-encoding']
)
if (size > this.server.config.core.compression.threshold)
if (size > this.server.config.core.compression.threshold && mimetype.match(/^(image|video|text)/))
return this.send(useCompress.call(this, data));
this.header('Content-Length', size);
return this.send(data);
}
}

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