Compare commits

...

66 Commits
v3.7.6 ... v3

Author SHA1 Message Date
Jay
920c996892 fix: I beg, actions, work pls (#741)
* fix: I beg, actions, work pls

* fix: problem was from docker action!! O.O
2025-03-06 12:07:43 -08:00
Jay
f896bfa413 fix: actions didn't action. It was the wrong order (#740) 2025-03-04 21:23:34 -08:00
Jay
61ab5a192b fix: do not include stats in export + other version stuff (#721)
* notFix: Removed the option to only clear db. It'd be a dangerous thing to continue allowing it.

* fix: Exclude returning user's uuid from /api/users.

* fix: Use a different method of version checking.

* fix: Upstream docker images now have an extended commit tag.

* fix: Exclude stats from the export. Has caused a lot of memory problems.
2025-03-03 16:03:25 -08:00
Jay
478baeca83 fix: merge #694
* fix: password check before any checking if previewable

* chore: add debug logs for raw route and use nodejs' built in pipeline instead of pump

* chore: hook request instead of reply for debug request logger

* fix: the meta tags return to their natural order, and add a fallback text if the browser can't play the video

* chore: narrower typing

* feat: gif thumbnails, discord doesn't play it sadly :(

* fix: turn gif thumbnails into an opt in feature. might be taxing

* fix: nevermind on that narrower typing

* fix: prettier :(
2025-02-17 12:43:50 -08:00
diced
df84edd310 refactor: move all trunk -> v3 2025-02-15 12:49:33 -08:00
diced
fc6060fe9c feat(v3.7.13): version 2025-02-14 12:09:06 -08:00
diced
2de036c89f feat: better v4 notice 2025-02-14 11:51:03 -08:00
Jay
956fafb826 fix: #685 #657 (?) (#692)
* fix: Less debug info for login. That stuff's sacred

* notFix: increase plugin timeout, perhaps this is enough time to let zipline start

* fix: Some dependency upgrades to fix a bug.

* remove console log
2025-02-13 21:59:04 -08:00
diced
1be47b4d36 feat: message for those with dumb auto-updaters 2025-02-12 22:58:13 -08:00
Jay
6646c1e591 feat: some cool stuff (#686)
* feat: a very cool zoomy thing, pc only :(

* fix: a sort of 404 for when dev'ing cuz next didn't care to compile the og 404

* fix: at least check the password before anything else >:(
and no more rawdogging it if the db doesn't have it!

* fix: just in case someone goes /upload(or /url)/dashboard

* fix: whoopsids

* fix: size can be null if it doesn't exist

* fix: sometimes it doesn't exists, oops

* notFix: force delete to avoid error

* feat: Do we really overcomplicate things? Added passwordy thingy to raw route. Use that instead!

* ack, message fix

* facepalm

* fix: it's a little shorter, didn't realize this works too

* there wasn't an export const config for this one

* got thumbnails working again :D

* ugh fiiine

* no more repeat thumbnail generations >:(

* bump fastify

* pass queries too

* add thumbnail for type

* notFeat: Don't expect much from this added route, I won't add more than essential.

* fix: my own mistake of breaking embeds with sites. Images should have their links hidden with discord. Videos can embed and play. Others? Good luck

* fix: any edge case where thumbnail don't exist

* fix??: I unno, discord hasn't embedded without the link showing but maybe other places could

* chore: some trimming that I could
2025-02-12 21:50:09 -08:00
Jay
1febd5aca0 fix: a lot of stuff (#683)
* fix: No more infinite loading button! :D

* chore: buhbai version!

* chore: update browserlist db

* fix: a totp secret that shouldn't be /probably/ shouldn't be revealed

* fix: revert range getting for datasource

* chore: a line lost! :O

* chore: this probably should've been ignored for a long while

* fix: Don't compress webm or webp. They go breaky

* fix: issue 659, it was the wrong statusCode to look for

* fix: I'll just regex it.

* fix: let s3 in on the fun with partial uploads

* chore&fix: they're files now :3 & unlock video and/or audio files

* fix: Maybe prisma plugin needs a return?

* fix: super focused regex this time :D

* I guess this works? So cool :O

* fix: bad id check

* fix: Byte me! >:3

* fix: add password bool to file's prop

* fix(?): this might fix some people's weard errors.

* chore: I discovered more typing

* fix: stats logger

* fix(?): await the registers

* chore: typeer typer

* fix: This looks to properly fix issue 659. I dunno how, don't ask

* More like uglier >:(

* fix: actions don't like dis

* fix: ranged requests handled properly

* feat: remove supabase datasource

---------

Co-authored-by: diced <pranaco2@gmail.com>
2025-02-03 12:00:49 -08:00
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
95 changed files with 2795 additions and 3073 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
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

@@ -2,6 +2,8 @@ node_modules/
.next/
uploads/
.git/
!.git/refs
!.git/HEAD
.yarn/*
!.yarn/releases
!.yarn/plugins

View File

@@ -1,13 +1,13 @@
# every field in here is optional except, CORE_SECRET and CORE_DATABASE_URL.
# if CORE_SECRET is still "changethis" then zipline will exit and tell you to change it.
# if using s3/supabase make sure to uncomment or comment out the correct lines needed.
# if using s3 make sure to uncomment or comment out the correct lines needed.
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
@@ -27,13 +27,6 @@ DATASOURCE_LOCAL_DIRECTORY=./uploads
# DATASOURCE_S3_FORCE_S3_PATH=false
# DATASOURCE_S3_USE_SSL=false
# or supabase
# DATASOURCE_TYPE=supabase
# DATASOURCE_SUPABASE_KEY=xxx
# remember: no leading slash
# DATASOURCE_SUPABASE_URL=https://something.supabase.co
# DATASOURCE_SUPABASE_BUCKET=zipline
UPLOADER_DEFAULT_FORMAT=RANDOM
UPLOADER_ROUTE=/u
UPLOADER_LENGTH=6

View File

@@ -2,9 +2,9 @@ name: 'Build'
on:
push:
branches: [ trunk ]
branches: [ v3 ]
pull_request:
branches: [ trunk ]
branches: [ v3 ]
workflow_dispatch:
jobs:

View File

@@ -3,7 +3,7 @@ name: 'Push Release Docker Images'
on:
push:
tags:
- 'v*.*.*'
- 'v3.*.*'
paths:
- 'src/**'
- 'server/**'
@@ -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,20 +32,28 @@ 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
platforms: linux/amd64,linux/arm64
tags: |
ghcr.io/diced/zipline:latest
ghcr.io/diced/zipline:v3
ghcr.io/diced/zipline:${{ steps.version.outputs.zipline_version }}
${{ secrets.DOCKERHUB_USERNAME }}/zipline:v3
${{ secrets.DOCKERHUB_USERNAME }}/zipline:${{ steps.version.outputs.zipline_version }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -2,7 +2,7 @@ name: 'Push Docker Images'
on:
push:
branches: [ trunk ]
branches: [ v3 ]
paths:
- 'src/**'
- 'server/**'
@@ -12,8 +12,8 @@ on:
workflow_dispatch:
jobs:
push_to_ghcr:
name: Push Image to GitHub Packages
push:
name: Push Commit Image
runs-on: ubuntu-latest
steps:
- name: Check out the repo
@@ -22,7 +22,7 @@ jobs:
- name: Get version
id: version
run: |
echo "zipline_version=$(jq .version package.json -r)" >> $GITHUB_OUTPUT
echo "zipline_commit=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
- name: Setup QEMU
uses: docker/setup-qemu-action@v2
@@ -38,13 +38,23 @@ 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
platforms: linux/amd64,linux/arm64
tags: |
ghcr.io/diced/zipline:trunk
ghcr.io/diced/zipline:trunk-${{ steps.version.outputs.zipline_version }}
ghcr.io/diced/zipline:v3-trunk
ghcr.io/diced/zipline:v3-trunk-${{ steps.version.outputs.zipline_commit }}
${{ secrets.DOCKERHUB_USERNAME }}/zipline:v3-trunk
${{ secrets.DOCKERHUB_USERNAME }}/zipline:v3-trunk-${{ steps.version.outputs.zipline_commit }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
BUILDKIT_CONTEXT_KEEP_GIT_DIR=1

View File

@@ -1,31 +0,0 @@
name: 'Issue/PR Milestones'
on:
pull_request_target:
types: [opened, reopened]
issues:
types: [opened, reopened]
permissions:
issues: write
checks: write
contents: read
pull-requests: write
jobs:
set:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/github-script@v6
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
const milestone = 3
github.rest.issues.update({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
milestone
})

1
.gitignore vendored
View File

@@ -31,6 +31,7 @@ yarn-debug.log*
yarn-error.log*
# local env files
.env
.env.local
.env.development.local
.env.test.local

View File

@@ -1,8 +1,8 @@
# 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
@@ -18,9 +18,7 @@ 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 \
@@ -29,8 +27,10 @@ ENV PRISMA_QUERY_ENGINE_BINARY=/prisma-engines/query-engine \
# Install the dependencies
RUN yarn install --immutable
FROM base as builder
FROM base AS builder
COPY .git/refs ./.git/refs
COPY .git/HEAD ./.git/HEAD
COPY src ./src
COPY next.config.js ./next.config.js
COPY tsup.config.ts ./tsup.config.ts
@@ -47,16 +47,17 @@ 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/.git/refs ./.git/refs
COPY --from=builder /zipline/.git/HEAD ./.git/HEAD
COPY --from=builder /zipline/dist ./dist
COPY --from=builder /zipline/.next ./.next

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

@@ -121,7 +121,7 @@ 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.
@@ -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
@@ -169,4 +169,4 @@ Create a pull request on GitHub. If your PR does not pass the action checks, the
# Documentation
Documentation source code is located in [diced/zipline-docs](https://github.com/diced/zipline-docs), and can be accessed [here](https://zipl.vercel.app).
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

@@ -1,4 +1,3 @@
version: '3'
services:
postgres:
image: postgres:15

View File

@@ -1,4 +1,3 @@
version: '3'
services:
postgres:
image: postgres:15

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.6",
"version": "3.7.13",
"license": "MIT",
"scripts": {
"dev": "npm-run-all build:server dev:run",
@@ -30,18 +30,18 @@
"dependencies": {
"@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": "^4.16.2",
"@prisma/internals": "^4.16.2",
"@prisma/migrate": "^4.16.2",
"@mantine/core": "6.x",
"@mantine/dropzone": "6.x",
"@mantine/form": "6.x",
"@mantine/hooks": "6.x",
"@mantine/modals": "6.x",
"@mantine/next": "6.x",
"@mantine/notifications": "6.x",
"@mantine/prism": "6.x",
"@mantine/spotlight": "6.x",
"@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",
@@ -63,15 +63,15 @@
"multer": "^1.4.5-lts.1",
"next": "^14.0.3",
"otplib": "^12.0.1",
"prisma": "^4.16.2",
"prisma": "^5.1.1",
"prismjs": "^1.29.0",
"qrcode": "^1.5.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^8.0.6",
"react-markdown": "^9.0.3",
"recharts": "^2.10.1",
"recoil": "^0.7.7",
"remark-gfm": "^4.0.0",
"remark-gfm": "^4.0.1",
"sharp": "^0.32.6"
},
"devDependencies": {
@@ -79,7 +79,7 @@
"@types/katex": "^0.16.6",
"@types/minio": "^7.1.1",
"@types/multer": "^1.4.10",
"@types/node": "^18.18.10",
"@types/node": "18",
"@types/qrcode": "^1.5.5",
"@types/react": "^18.2.37",
"@types/sharp": "^0.32.0",

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 {

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

@@ -316,7 +316,9 @@ export default function Layout({ children, props }) {
variant='dot'
color={version.data.update ? 'red' : 'primary'}
>
{version.data.versions.current}
{version.data.isUpstream
? version.data.versions.current.slice(0, 7)
: version.data.versions.current}
</Badge>
</Tooltip>
</Navbar.Section>
@@ -356,7 +358,7 @@ export default function Layout({ children, props }) {
)
}
variant='subtle'
color='gray'
color={theme.colorScheme === 'dark' ? 'dark' : 'gray'}
compact
size='xl'
p='sm'

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',
@@ -125,6 +125,17 @@ export default function Type({ file, popup = false, disableMediaPreview, ...prop
);
};
if (file.password) {
return (
<Placeholder
Icon={IconFileAlert}
text={`This file is password protected. Click to view file (${file.name})`}
onClick={() => window.open(file.url)}
{...props}
/>
);
}
if ((shouldRenderMarkdown || shouldRenderTex || shouldRenderCode) && !props.overrideRender && popup)
return (
<>
@@ -143,17 +154,6 @@ export default function Type({ file, popup = false, disableMediaPreview, ...prop
return <Placeholder Icon={IconFile} text={`Click to view file (${file.name})`} {...props} />;
}
if (file.password) {
return (
<Placeholder
Icon={IconFileAlert}
text={`This file is password protected. Click to view file (${file.name})`}
onClick={() => window.open(file.url)}
{...props}
/>
);
}
return popup ? (
media ? (
{

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

@@ -0,0 +1,56 @@
import { Alert, Stack, Anchor, Code, Text } from '@mantine/core';
import { IconExclamationCircle } from '@tabler/icons-react';
import { useCallback, useEffect, useState } from 'react';
export default function Version4Notice() {
const key = 'zipline-v4-notice';
const [isClosed, setClosed] = useState<boolean | null>(null);
useEffect(() => {
const dismissed = localStorage.getItem(key) === 'true';
setClosed(dismissed);
}, [key]);
const handleDismiss = useCallback(() => {
setClosed(true);
localStorage.setItem(key, 'true');
}, [key]);
if (isClosed === null) return null;
if (isClosed) return null;
return (
<Alert
withCloseButton
variant='outline'
icon={<IconExclamationCircle size='1rem' />}
title='⚠️ Important! ⚠️'
p='md'
mb='md'
onClose={handleDismiss}
color='red'
>
<Stack spacing='md'>
<Text>
Zipline v4 will be released soon, and is <b>NOT</b> compatible with v3 (the current version). If you
are using external software to automatically update Zipline on new releases, it is{' '}
<b>strongly advised</b> that you stop auto-updates for the time being until v4 is released. For more
information, please visit{' '}
<Anchor target='_blank' href='https://github.com/diced/zipline/tree/v4'>
the <Code>v4</Code> branch
</Anchor>{' '}
on GitHub to view the progress of v4. If you have any questions, feel free to{' '}
<Anchor target='_blank' href='https://zipline.diced.sh/discord'>
join our discord
</Anchor>
.
</Text>
<Text>
If you are not the server administrator, please consider notifying them of this important message.
</Text>
</Stack>
</Alert>
);
}

View File

@@ -22,6 +22,7 @@ import { useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil';
import RecentFiles from './RecentFiles';
import { StatCards } from './StatCards';
import Version4Notice from './Version4Notice';
export default function Dashboard({ disableMediaPreview, exifEnabled, compress }) {
const user = useRecoilValue(userSelector);
@@ -114,6 +115,8 @@ export default function Dashboard({ disableMediaPreview, exifEnabled, compress }
window.open(`${window.location.protocol}//${window.location.host}${file.url}`);
};
// local storage to whether to show alert or not
return (
<div>
{selectedFile && (
@@ -130,6 +133,8 @@ export default function Dashboard({ disableMediaPreview, exifEnabled, compress }
/>
)}
<Version4Notice />
<Title>Welcome back, {user?.username}</Title>
<MutedText size='md'>
You have <b>{numFiles === 0 ? '...' : numFiles}</b> files

View File

@@ -7,7 +7,7 @@ import { useState } from 'react';
export default function ClearStorage({ open, setOpen }) {
const [check, setCheck] = useState(false);
const handleDelete = async (datasource: boolean, orphaned?: boolean) => {
const handleDelete = async (orphaned?: boolean) => {
showNotification({
id: 'clear-uploads',
title: 'Clearing...',
@@ -16,7 +16,7 @@ export default function ClearStorage({ open, setOpen }) {
autoClose: false,
});
const res = await useFetch('/api/admin/clear', 'POST', { datasource, orphaned });
const res = await useFetch('/api/admin/clear', 'POST', { orphaned });
if (res.error) {
updateNotification({
@@ -65,21 +65,13 @@ export default function ClearStorage({ open, setOpen }) {
onClick={() => {
setOpen(false);
openConfirmModal({
title: 'Do you want to clear storage too?',
labels: { confirm: 'Yes', cancel: check ? 'Ok' : 'No' },
children: check && (
<Text size='sm' color='gray'>
Due to clearing orphaned files, storage clearing will be unavailable.
</Text>
),
confirmProps: { disabled: check },
title: 'Are you sure?',
confirmProps: { color: 'red' },
children: <Text size='sm'>This action is destructive and irreversible.</Text>,
labels: { confirm: 'Yes', cancel: 'No' },
onConfirm: () => {
closeAllModals();
handleDelete(true);
},
onCancel: () => {
closeAllModals();
handleDelete(false, check);
handleDelete(check);
},
onClose: () => setCheck(false),
});

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,7 +269,7 @@ 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,
}))
@@ -272,6 +277,26 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
);
};
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();
@@ -580,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
@@ -591,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>
),
}))
: []
}
@@ -615,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

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

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

@@ -1,5 +1,5 @@
import { ActionIcon, Button, Center, Group, SimpleGrid, Title } from '@mantine/core';
import { File } from '@prisma/client';
import type { File } from '@prisma/client';
import { IconArrowLeft, IconFile } from '@tabler/icons-react';
import FileComponent from 'components/File';
import MutedText from 'components/MutedText';

View File

@@ -7,16 +7,23 @@ import { Language } from 'prism-react-renderer';
export default function Markdown({ code, ...props }) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
remarkPlugins={[[remarkGfm, { singleTilde: false }]]}
components={{
code({ inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');
return !inline && match ? (
<Prism language={match[1] as Language} {...props}>
{String(children).replace(/\n$/, '')}
code({ children }) {
return <Code>{children}</Code>;
},
pre({ children }) {
// @ts-expect-error someone find the type for this :sob:
const match = /language-(\w+)/.exec(children.props?.className || '');
// @ts-ignore
if (!children.props?.children) return code;
return (
<Prism language={match ? (match[1] as Language) : 'markdown'}>
{
// @ts-expect-error
String(children.props?.children).replace(/\n$/, '')
}
</Prism>
) : (
<Code {...props}>{children}</Code>
);
},
img(props) {

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

@@ -20,10 +20,9 @@ export interface ConfigCompression {
}
export interface ConfigDatasource {
type: 'local' | 's3' | 'supabase';
type: 'local' | 's3';
local: ConfigLocalDatasource;
s3?: ConfigS3Datasource;
supabase?: ConfigSupabaseDatasource;
}
export interface ConfigLocalDatasource {
@@ -41,12 +40,6 @@ export interface ConfigS3Datasource {
region?: string;
}
export interface ConfigSupabaseDatasource {
url: string;
key: string;
bucket: string;
}
export interface ConfigUploader {
default_format: string;
route: string;
@@ -126,6 +119,7 @@ export interface ConfigFeatures {
robots_txt: string;
thumbnails: boolean;
gif_thumbnails: boolean;
}
export interface ConfigOAuth {

View File

@@ -85,10 +85,6 @@ export default function readConfig() {
map('DATASOURCE_S3_REGION', 'string', 'datasource.s3.region'),
map('DATASOURCE_S3_USE_SSL', 'boolean', 'datasource.s3.use_ssl'),
map('DATASOURCE_SUPABASE_URL', 'string', 'datasource.supabase.url'),
map('DATASOURCE_SUPABASE_KEY', 'string', 'datasource.supabase.key'),
map('DATASOURCE_SUPABASE_BUCKET', 'string', 'datasource.supabase.bucket'),
map('UPLOADER_DEFAULT_FORMAT', 'string', 'uploader.default_format'),
map('UPLOADER_ROUTE', 'string', 'uploader.route'),
map('UPLOADER_LENGTH', 'number', 'uploader.length'),
@@ -168,6 +164,7 @@ export default function readConfig() {
map('FEATURES_ROBOTS_TXT', 'boolean', 'features.robots_txt'),
map('FEATURES_THUMBNAILS', 'boolean', 'features.thumbnails'),
map('FEATURES_GIF_THUMBNAILS', 'boolean', 'features.gif_thumbnails'),
map('CHUNKS_MAX_SIZE', 'human-to-byte', 'chunks.max_size'),
map('CHUNKS_CHUNKS_SIZE', 'human-to-byte', 'chunks.chunks_size'),

View File

@@ -51,7 +51,7 @@ const validator = s.object({
}),
datasource: s
.object({
type: s.enum('local', 's3', 'supabase').default('local'),
type: s.enum('local', 's3').default('local'),
local: s
.object({
directory: s.string.default(resolve('./uploads')).transform((v) => resolve(v)),
@@ -69,11 +69,6 @@ const validator = s.object({
region: s.string.default('us-east-1'),
use_ssl: s.boolean.default(false),
}).optional,
supabase: s.object({
url: s.string,
key: s.string,
bucket: s.string,
}).optional,
})
.default({
type: 'local',
@@ -196,6 +191,7 @@ const validator = s.object({
default_avatar: s.string.nullable.default(null),
robots_txt: s.boolean.default(false),
thumbnails: s.boolean.default(false),
gif_thumbnails: s.boolean.default(false),
})
.default({
invites: false,
@@ -207,6 +203,7 @@ const validator = s.object({
default_avatar: null,
robots_txt: false,
thumbnails: false,
gif_thumbnails: false,
}),
chunks: s
.object({
@@ -253,8 +250,8 @@ export default function validate(config): Config {
logger.debug(`Attemping to validate ${JSON.stringify(config)}`);
const validated = validator.parse(config);
logger.debug(`Recieved config: ${JSON.stringify(validated)}`);
switch (validated.datasource.type) {
case 's3': {
if (validated.datasource.type === 's3') {
const errors = [];
if (!validated.datasource.s3.access_key_id)
errors.push('datasource.s3.access_key_id is a required field');
@@ -263,33 +260,19 @@ export default function validate(config): Config {
if (!validated.datasource.s3.bucket) errors.push('datasource.s3.bucket is a required field');
if (!validated.datasource.s3.endpoint) errors.push('datasource.s3.endpoint is a required field');
if (errors.length) throw { errors };
break;
}
case 'supabase': {
const errors = [];
if (!validated.datasource.supabase.key) errors.push('datasource.supabase.key is a required field');
if (!validated.datasource.supabase.url) errors.push('datasource.supabase.url is a required field');
if (!validated.datasource.supabase.bucket)
errors.push('datasource.supabase.bucket is a required field');
if (errors.length) throw { errors };
break;
}
}
const reserved = ['/view', '/dashboard', '/code', '/folder', '/api', '/auth'];
if (reserved.some((r) => validated.uploader.route.startsWith(r))) {
const reserved = new RegExp(/^\/(view|code|folder|auth|r)(\/\S*)?$|^\/(api|dashboard)(\/\S*)*/);
if (reserved.exec(validated.uploader.route))
throw {
errors: [`The uploader route cannot be ${validated.uploader.route}, this is a reserved route.`],
show: true,
};
} else if (reserved.some((r) => validated.urls.route.startsWith(r))) {
if (reserved.exec(validated.urls.route))
throw {
errors: [`The urls route cannot be ${validated.urls.route}, this is a reserved route.`],
show: true,
};
}
return validated as unknown as Config;
} catch (e) {

View File

@@ -1,5 +1,5 @@
import config from './config';
import { Datasource, Local, S3, Supabase } from './datasources';
import { Datasource, Local, S3 } from './datasources';
import Logger from './logger';
const logger = Logger.get('datasource');
@@ -14,10 +14,6 @@ if (!global.datasource) {
global.datasource = new Local(config.datasource.local.directory);
logger.info(`using Local(${config.datasource.local.directory}) datasource`);
break;
case 'supabase':
global.datasource = new Supabase(config.datasource.supabase);
logger.info(`using Supabase(${config.datasource.supabase.bucket}) datasource`);
break;
default:
throw new Error('Invalid datasource type');
}

View File

@@ -3,10 +3,11 @@ 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 size(file: string): Promise<number | null>;
public abstract get(file: string): Readable | Promise<Readable>;
public abstract fullSize(): Promise<number>;
public abstract range(file: string, start: number, end: number): Promise<Readable>;
}

View File

@@ -11,11 +11,11 @@ export class Local extends Datasource {
}
public async save(file: string, data: Buffer): Promise<void> {
await writeFile(join(this.path, file), data);
await writeFile(join(this.path, file), Uint8Array.from(data));
}
public async delete(file: string): Promise<void> {
await rm(join(this.path, file));
await rm(join(this.path, file), { force: true });
}
public async clear(): Promise<void> {
@@ -37,9 +37,9 @@ export class Local extends Datasource {
}
}
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;
@@ -56,4 +56,11 @@ export class Local extends Datasource {
return size;
}
public async range(file: string, start: number, end: number): Promise<ReadStream> {
const path = join(this.path, file);
const readStream = createReadStream(path, { start, end });
return readStream;
}
}

View File

@@ -1,7 +1,7 @@
import { Datasource } from '.';
import { Readable } from 'stream';
import { PassThrough, 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,12 +20,18 @@ 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,
new PassThrough().end(data),
data.byteLength,
options ? { 'Content-Type': options.type } : undefined,
);
}
public async delete(file: string): Promise<void> {
await this.s3.removeObject(this.config.bucket, file);
await this.s3.removeObject(this.config.bucket, file, { forceDelete: true });
}
public async clear(): Promise<void> {
@@ -49,10 +55,18 @@ export class S3 extends Datasource {
});
}
public async size(file: string): Promise<number> {
const stat = await this.s3.statObject(this.config.bucket, file);
return stat.size;
public size(file: string): Promise<number | null> {
return new Promise((res) => {
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);
},
);
});
}
public async fullSize(): Promise<number> {
@@ -67,4 +81,15 @@ export class S3 extends Datasource {
});
});
}
public async range(file: string, start: number, end: number): Promise<Readable> {
return new Promise((res) => {
this.s3.getPartialObject(this.config.bucket, file, start, end, (err, stream) => {
if (err) {
console.log(err);
res(null);
} else res(stream);
});
});
}
}

View File

@@ -1,140 +0,0 @@
import { Datasource } from '.';
import { ConfigSupabaseDatasource } from 'lib/config/Config';
import { guess } from 'lib/mimes';
import Logger from 'lib/logger';
import { Readable } from 'stream';
export class Supabase extends Datasource {
public name = 'Supabase';
public logger: Logger = Logger.get('datasource::supabase');
public constructor(public config: ConfigSupabaseDatasource) {
super();
}
public async save(file: string, data: Buffer): Promise<void> {
const mimetype = await guess(file.split('.').pop());
const r = await fetch(`${this.config.url}/storage/v1/object/${this.config.bucket}/${file}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.config.key}`,
'Content-Type': mimetype,
},
body: data,
});
const j = await r.json();
if (j.error) this.logger.error(`${j.error}: ${j.message}`);
}
public async delete(file: string): Promise<void> {
await fetch(`${this.config.url}/storage/v1/object/${this.config.bucket}/${file}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${this.config.key}`,
},
});
}
public async clear(): Promise<void> {
try {
const resp = await fetch(`${this.config.url}/storage/v1/object/list/${this.config.bucket}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.config.key}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
prefix: '',
}),
});
const objs = await resp.json();
if (objs.error) throw new Error(`${objs.error}: ${objs.message}`);
const res = await fetch(`${this.config.url}/storage/v1/object/${this.config.bucket}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${this.config.key}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
prefixes: objs.map((x: { name: string }) => x.name),
}),
});
const j = await res.json();
if (j.error) throw new Error(`${j.error}: ${j.message}`);
return;
} catch (e) {
this.logger.error(e);
}
}
public async get(file: string): 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}`,
},
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return Readable.fromWeb(r.body as any);
}
public size(file: string): Promise<number> {
return new Promise(async (res) => {
fetch(`${this.config.url}/storage/v1/object/list/${this.config.bucket}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.config.key}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
prefix: '',
search: file,
}),
})
.then((r) => r.json())
.then((j) => {
if (j.error) {
this.logger.error(`${j.error}: ${j.message}`);
res(0);
}
if (j.length === 0) {
res(0);
} else {
res(j[0].metadata.size);
}
});
});
}
public async fullSize(): Promise<number> {
return new Promise((res) => {
fetch(`${this.config.url}/storage/v1/object/list/${this.config.bucket}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.config.key}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
prefix: '',
}),
})
.then((r) => r.json())
.then((j) => {
if (j.error) {
this.logger.error(`${j.error}: ${j.message}`);
res(0);
}
res(j.reduce((a, b) => a + b.metadata.size, 0));
});
});
}
}

View File

@@ -1,4 +1,3 @@
export { Datasource } from './Datasource';
export { Local } from './Local';
export { S3 } from './S3';
export { Supabase } from './Supabase';

View File

@@ -1,4 +1,4 @@
import { File, Url, User } from '@prisma/client';
import type { File, Url, User } from '@prisma/client';
import config from 'lib/config';
import { ConfigDiscordContent } from 'config/Config';
import Logger from 'lib/logger';

View File

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

View File

@@ -1,7 +1,11 @@
import { PrismaClient } from '@prisma/client';
import 'lib/config';
if (!global.prisma) {
if (!process.env.ZIPLINE_DOCKER_BUILD) global.prisma = new PrismaClient();
if (!process.env.ZIPLINE_DOCKER_BUILD) {
process.env.DATABASE_URL = config.core.database_url;
global.prisma = new PrismaClient();
}
}
export default global.prisma as PrismaClient;

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

@@ -1,4 +1,4 @@
import { InvisibleFile, InvisibleUrl } from '@prisma/client';
import type { InvisibleFile, InvisibleUrl } from '@prisma/client';
import { hash, verify } from 'argon2';
import { randomBytes } from 'crypto';
import { readdir, stat } from 'fs/promises';

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

View File

@@ -1,4 +1,4 @@
import { File } from '@prisma/client';
import type { File } from '@prisma/client';
import { ExifTool, Tags } from 'exiftool-vendored';
import { createWriteStream } from 'fs';
import { readFile, rm } from 'fs/promises';
@@ -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,15 +1,19 @@
import type { File, User, Url } from '@prisma/client';
import type { File, Url } from '@prisma/client';
import { bytesToHuman } from './bytes';
import Logger from 'lib/logger';
import type { UserExtended } from 'middleware/withZipline';
export type ParseValue = {
file?: File;
file?: Omit<Partial<File>, 'password'>;
url?: Url;
user?: User;
user?: Partial<UserExtended>;
link?: string;
raw_link?: string;
};
const logger = Logger.get('parser');
export function parseString(str: string, value: ParseValue) {
if (!str) return null;
str = str
@@ -17,7 +21,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))) {
@@ -54,7 +58,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;
}
@@ -66,17 +75,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':
@@ -95,6 +129,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}';
}
@@ -117,7 +155,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();

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

@@ -0,0 +1,20 @@
export function parseRange(header: string, length: number): [number, number] {
const range = header.trim().substring(6);
let start, end;
if (range.startsWith('-')) {
end = length - 1;
start = length - 1 - Number(range.substring(1));
} else {
const [s, e] = range.split('-').map(Number);
start = s;
end = e || length - 1;
}
if (end > length - 1) {
end = length - 1;
}
return [start, end];
}

View File

@@ -8,21 +8,23 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
try {
const { orphaned } = req.body;
if (orphaned) {
const files = await prisma.file.findMany({
where: {
userId: null,
},
});
const { count } = await prisma.file.deleteMany({
where: {
userId: null,
},
});
for (const file of files) await datasource.delete(file.name);
logger.info(`User ${user.username} (${user.id}) cleared the database of ${count} orphaned files`);
return res.json({ message: 'cleared storage (orphaned only)' });
}
const { count } = await prisma.file.deleteMany({});
logger.info(`User ${user.username} (${user.id}) cleared the database of ${count} files`);
if (req.body.datasource) {
await datasource.clear();
logger.info(`User ${user.username} (${user.id}) cleared storage`);
}
logger.info(`User ${user.username} (${user.id}) cleared the database of ${count} files`);
} catch (e) {
logger.error(`User ${user.username} (${user.id}) failed to clear the database or storage`);
logger.error(e);

View File

@@ -0,0 +1,307 @@
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],
};
}
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,40 +0,0 @@
import datasource from 'lib/datasource';
import { guess } from 'lib/mimes';
import prisma from 'lib/prisma';
import { checkPassword } from 'lib/util';
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
import { extname } from 'path';
async function handler(req: NextApiReq, res: NextApiRes) {
const { id, password } = req.query;
if (isNaN(Number(id))) return res.badRequest('invalid id');
const file = await prisma.file.findFirst({
where: {
id: Number(id),
},
});
if (!file) return res.notFound('image not found');
if (!password) return res.badRequest('no password provided');
const decoded = decodeURIComponent(password as string);
const valid = await checkPassword(decoded, file.password);
if (!valid) return res.badRequest('wrong password');
const data = await datasource.get(file.name);
if (!data) return res.notFound('image not found');
const size = await datasource.size(file.name);
const mimetype = await guess(extname(file.name));
res.setHeader('Content-Type', mimetype);
res.setHeader('Content-Length', size);
data.pipe(res);
data.on('error', () => res.notFound('image not found'));
data.on('end', () => res.end());
}
export default withZipline(handler);

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) {
@@ -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');
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.info(`${user.username} (${user.id}) deleted invite ${invite.code}`);
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,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
code?: string;
};
const users = await prisma.user.findMany();
if (users.length === 0) {
if ((await prisma.user.count()) === 0) {
logger.debug('no users found... creating default user...');
await prisma.user.create({
data: {
@@ -42,7 +41,9 @@ async function handler(req: NextApiReq, res: NextApiRes) {
else if (await checkPassword(password, user.password)) valid = true;
else valid = false;
logger.debug(`body(${JSON.stringify(req.body)}): checkPassword(${password}, argon2-str) => ${valid}`);
logger.debug(
`body(${JSON.stringify(Object.keys(req.body))}): checkPassword(password, argon2-str) => ${valid}`,
);
if (!valid) return res.unauthorized('Wrong password');
@@ -51,7 +52,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(Object.keys(req.body))}): verify_totp_code(totpSecret, ${code}) => ${success}`,
);
if (!success) return res.badRequest('Invalid code', { totp: true });
}

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

@@ -0,0 +1,75 @@
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
import 'lib/prisma';
import 'lib/config';
async function handler(req: NextApiReq, res: NextApiRes) {
const { id } = req.query as { id: string };
if (req.method === 'GET') {
if (!id || isNaN(parseInt(id))) return res.badRequest('no id');
const file = await prisma.file.findUnique({
where: {
id: parseInt(id),
},
include: {
thumbnail: {
select: {
name: true,
},
},
user: {
select: {
username: true,
},
},
},
});
if (!file || !!file.password) return res.notFound('no such file exists');
const mediaType: 'image' | 'video' | 'audio' | 'other' =
(new RegExp(/^(?<type>image|video|audio)/).exec(file.mimetype)?.groups?.type as
| 'image'
| 'video'
| 'audio') || 'other';
let host = req.headers.host;
const proto = req.headers['x-forwarded-proto'];
try {
if (
JSON.parse(req.headers['cf-visitor'] as string).scheme === 'https' ||
proto === 'https' ||
config.core.return_https
)
host = `https://${host}`;
else host = `http://${host}`;
} catch (e) {
if (proto === 'https' || config.core.return_https) host = `https://${host}`;
else host = `http://${host}`;
}
if (mediaType === 'image')
return res.json({
type: 'photo',
version: '1.0',
url: `${host}/r/${file.name}`,
});
if (mediaType === 'video')
return res.json({
type: 'video',
version: '1.0',
url: `${host}/r/${file.name}`,
thumbnail_url: file.thumbnail ? `${host}/r/${file.thumbnail?.name}` : undefined,
html: `<video><source src="${host}/r/${file.name}" type="${file.mimetype}"/></video>`,
});
return res.json({
type: 'link',
version: '1.0',
url: `${host}${config.uploader.route}/${file.name}`,
});
}
}
export default withZipline(handler, {
methods: ['GET'],
user: false,
});

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) {
try {
expiry = parseExpiry(expiresAt);
if (!expiry) return res.badRequest('invalid date');
else {
response.expiresAt = expiry;
} catch (error) {
return res.badRequest(error.message);
}
}
if (zconfig.uploader.default_expiration) {
try {
expiry = parseExpiry(zconfig.uploader.default_expiration);
if (!expiry) return res.badRequest('invalid date (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,6 +122,20 @@ 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') {
@@ -128,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,
}),
},
});
@@ -175,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');
@@ -262,6 +306,9 @@ async function handler(req: NextApiReq, res: NextApiRes) {
maxViews: fileMaxViews,
originalName: req.headers['original-name'] ? decodedName ?? null : null,
size: file.size,
...(folderToAdd && {
folderId: folderToAdd,
}),
},
});
@@ -270,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`,
);
} 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})`);
@@ -315,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

@@ -12,15 +12,20 @@ const logger = Logger.get('user');
async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
const { id } = req.query as { id: string };
if (!id || isNaN(parseInt(id))) return res.notFound('no user provided');
const target = await prisma.user.findFirst({
where: {
id: Number(id),
id: parseInt(id),
},
include: {
files: {
include: {
thumbnail: true,
},
orderBy: {
createdAt: 'desc',
},
},
Folder: true,
},
@@ -184,6 +189,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
return res.json(newUser);
} else {
delete target.password;
delete target.totpSecret;
if (user.superAdmin && target.superAdmin) {
delete target.files;

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);
@@ -79,11 +86,27 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
logger.info(
`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

@@ -142,6 +142,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
size: bigint;
originalName: string;
thumbnail?: { name: string };
password: string | boolean;
}[] = await prisma.file.findMany({
where: {
userId: user.id,
@@ -163,11 +164,13 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
size: true,
originalName: true,
thumbnail: true,
password: true,
},
});
for (let i = 0; i !== files.length; ++i) {
(files[i] as unknown as { url: string }).url = formatRootUrl(config.uploader.route, files[i].name);
files[i].password = !!files[i].password;
if (files[i].thumbnail) {
(files[i].thumbnail as unknown as string) = formatRootUrl('/r', files[i].thumbnail.name);

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

View File

@@ -8,7 +8,20 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
if (take >= 50) return res.badRequest("take can't be more than 50");
let files = await prisma.file.findMany({
let files: {
favorite: boolean;
createdAt: Date;
id: number;
name: string;
mimetype: string;
expiresAt: Date;
maxViews: number;
views: number;
folderId: number;
size: bigint;
password: string | boolean;
thumbnail?: { name: string };
}[] = await prisma.file.findMany({
take,
where: {
userId: user.id,
@@ -28,15 +41,17 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
size: true,
favorite: true,
thumbnail: true,
password: true,
},
});
for (let i = 0; i !== files.length; ++i) {
(files[i] as unknown as { url: string }).url = formatRootUrl(config.uploader.route, files[i].name);
if (files[i].thumbnail) {
files[i].password = !!files[i].password;
if (files[i].thumbnail)
(files[i].thumbnail as unknown as string) = formatRootUrl('/r', files[i].thumbnail.name);
}
}
if (req.query.filter && req.query.filter === 'media')
files = files.filter((x) => /^(video|audio|image)/.test(x.mimetype));

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');
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);
} catch (err) {
if (err instanceof PrismaClientKnownRequestError) return res.notFound('url not found');
else throw err;
}
} else {
const urls = await prisma.url.findMany({
where: {

View File

@@ -3,7 +3,10 @@ import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
async function handler(_: NextApiReq, res: NextApiRes) {
const users = await prisma.user.findMany();
for (let i = 0; i !== users.length; ++i) delete users[i].password;
for (let i = 0; i !== users.length; ++i) {
delete users[i].password;
delete users[i].uuid;
}
return res.json(users);
}

View File

@@ -5,24 +5,29 @@ import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
async function handler(_: NextApiReq, res: NextApiRes) {
if (!config.website.show_version) return res.forbidden('version hidden');
const pkg = JSON.parse(await readFile('package.json', 'utf8'));
const pRev = await (async function () {
try {
return await readFile('.git/HEAD', 'utf8');
} catch (e) {
return JSON.parse(await readFile('package.json', 'utf8')).version;
}
})();
const { groups } = new RegExp(/^ref: (?<ref>(\w+\/?)*)/).exec(pRev) || { groups: null };
let rev: string;
const re = await fetch('https://zipline.diced.sh/api/version?c=' + pkg.version);
if (!groups) rev = pRev;
else rev = await readFile(`.git/${groups.ref}`, 'utf8');
const re = await fetch(`https://v3.zipline.diced.sh/api/version?c=?c=${rev}`);
const json = await re.json();
if (!re.ok) return res.badRequest(json.error);
let updateToType = 'stable';
if (json.isUpstream) {
updateToType = 'upstream';
if (json.update?.stable) {
updateToType = 'stable';
}
}
if (json.isUpstream) updateToType = 'upstream';
return res.json({
isUpstream: true,
update: json.update?.stable || json.update?.upstream,
isUpstream: json.isUpstream,
update: json.isUpstream ? json.update?.upstream : json.update?.stable,
updateToType,
versions: {
stable: json.git.stable,

View File

@@ -67,7 +67,11 @@ export default function Login({
const username = values.username.trim();
const password = values.password.trim();
if (username === '') return form.setFieldError('username', "Username can't be nothing");
if (username === '') {
setLoading(false);
setDisabled(false);
return form.setFieldError('username', "Username can't be nothing");
}
const res = await useFetch('/api/auth/login', 'POST', {
username,
@@ -96,7 +100,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

@@ -50,7 +50,7 @@ export default function Register({ code = undefined, 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,

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

@@ -85,6 +85,12 @@ export const getServerSideProps: GetServerSideProps<Props> = async (context) =>
createdAt: true,
password: true,
size: true,
thumbnail: {
select: {
name: true,
id: true,
},
},
},
},
user: {
@@ -106,6 +112,9 @@ export const getServerSideProps: GetServerSideProps<Props> = async (context) =>
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';
@@ -10,167 +10,228 @@ import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import zconfig from 'lib/config';
import config from 'lib/config';
export default function EmbeddedFile({
file,
user,
pass,
prismRender,
host,
compress,
mediaType,
}: {
file: File & { imageProps?: HTMLImageElement; thumbnail: Thumbnail };
user: UserExtended;
pass: boolean;
file: Omit<File, 'password'> & {
password: boolean;
mediaProps?: {
width: number;
height: number;
};
thumbnail?: Pick<Thumbnail, 'name'>;
};
user: Omit<UserExtended, 'password' | 'secret' | 'totpSecret' | 'ratelimit'>;
prismRender: boolean;
host: string;
compress?: boolean;
mediaType: 'image' | 'video' | 'audio' | 'other';
}) {
const dataURL = (route: string) => `${route}/${encodeURI(file.name)}?compress=${compress ?? false}`;
const router = useRouter();
const [opened, setOpened] = useState(pass);
const [password, setPassword] = useState('');
const {
password: provPassword,
compress = 'false',
embed = 'false',
} = router.query as {
password?: string;
compress?: string;
embed?: string;
};
const dataURL = (
route: string,
useThumb?: boolean,
withoutHost?: boolean,
pass?: string,
forcedl?: boolean,
) =>
`${withoutHost ? '' : host}${route}/${encodeURIComponent(
(useThumb && !!file.thumbnail && file.thumbnail.name) || file.name,
)}${compress.match(/^true/i) ? '?compress=true' : '?compress=false'}${
!!pass ? `&password=${encodeURIComponent(pass)}` : ''
}${forcedl ? '&download=true' : ''}`;
const [opened, setOpened] = useState(file.password);
const [password, setPassword] = useState(provPassword || '');
const [error, setError] = useState('');
const [scale, setScale] = useState(2);
// reapply date from workaround
file.createdAt = new Date(file ? file.createdAt : 0);
const check = async () => {
const res = await fetch(`/api/auth/image?id=${file.id}&password=${encodeURIComponent(password)}`);
const res = await fetch(dataURL('/r', false, true, password));
if (res.ok) {
setError('');
if (prismRender) return router.push(`/code/${file.name}?password=${password}`);
updateImage(`/api/auth/image?id=${file.id}&password=${password}`);
if (prismRender) return router.push(`/code/${file.name}?password=${encodeURIComponent(password)}`);
updateMedia(dataURL('/r', false, true, password));
setOpened(false);
} else {
setError('Invalid password');
}
};
const updateImage = async (url?: string) => {
const imageEl = document.getElementById('image_content') as HTMLImageElement;
const updateMedia: (url?: string) => void = function (url?: string) {
if (mediaType === 'other') return;
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;
});
const mediaContent = document.getElementById(`${mediaType}_content`) as HTMLMediaElement;
img.src = url || dataURL('/r');
if (url) {
imageEl.src = url;
if (document.head.getElementsByClassName('dynamic').length === 0) {
const metas: HTMLMetaElement[][] = [];
const twType = mediaType === 'video' ? 'player' : 'image';
const ogType = mediaType === 'video' ? 'video' : 'image';
for (let i = 0; i !== 2; i++) {
const metaW = document.createElement('meta');
const metaH = document.createElement('meta');
metaW.setAttribute('name', i % 2 ? `twitter:${twType}:width` : `og:${ogType}:width`);
metaH.setAttribute('name', i % 2 ? `twitter:${twType}:height` : `og:${ogType}:height`);
metaW.className = 'dynamic';
metaH.className = 'dynamic';
metas.push([metaW, metaH]);
}
file.imageProps = img;
if (mediaType === 'image') {
const img = new Image();
img.onload = function () {
if (document.head.getElementsByClassName('dynamic').length !== 0) return;
file.mediaProps = {
width: img.naturalWidth,
height: img.naturalHeight,
};
for (const meta of metas) {
meta[0].setAttribute('content', file.mediaProps.width.toString());
meta[1].setAttribute('content', file.mediaProps.height.toString());
document.head.appendChild(meta[0]);
document.head.appendChild(meta[1]);
}
img.remove();
};
img.src = dataURL('/r', false, false, password);
}
if (mediaType === 'video') {
const vid = document.createElement('video');
vid.onloadedmetadata = function () {
if (document.head.getElementsByClassName('dynamic').length !== 0) return;
file.mediaProps = {
width: vid.videoWidth,
height: vid.videoHeight,
};
for (const meta of metas) {
meta[0].setAttribute('content', file.mediaProps.width.toString());
meta[1].setAttribute('content', file.mediaProps.height.toString());
document.head.appendChild(meta[0]);
document.head.appendChild(meta[1]);
}
vid.remove();
};
vid.src = dataURL('/r', false, false, password);
vid.load();
}
}
if (url) mediaContent.src = url;
};
useEffect(() => {
if (pass) {
setOpened(true);
} else {
updateImage();
if (file.password) {
if (password) check();
else setOpened(true);
}
if (mediaType === 'other') return;
updateMedia();
return () => {
const metas = document.head.getElementsByClassName('dynamic');
for (const meta of metas) meta.remove();
};
}, []);
return (
<>
<Head>
{!embed.match(/^true/i) && !file.embed && mediaType === 'image' && (
<link rel='alternate' type='application/json+oembed' href={`${host}/api/oembed/${file.id}`} />
)}
{user.embed.title && file.embed && (
<meta property='og:title' content={parseString(user.embed.title, { file: file, user })} />
<meta property='og:title' content={parseString(user.embed.title, { file, user })} />
)}
{user.embed.description && file.embed && (
<meta
property='og:description'
content={parseString(user.embed.description, { file: file, user })}
/>
<meta property='og:description' content={parseString(user.embed.description, { file, user })} />
)}
{user.embed.siteName && file.embed && (
<meta property='og:site_name' content={parseString(user.embed.siteName, { file: file, user })} />
<meta property='og:site_name' content={parseString(user.embed.siteName, { file, user })} />
)}
{user.embed.color && file.embed && (
<meta property='theme-color' content={parseString(user.embed.color, { file: file, user })} />
<meta property='theme-color' content={parseString(user.embed.color, { file, user })} />
)}
{file.mimetype.startsWith('image') && (
{(embed.match(/^true/i) || file.embed) && (
<>
<meta name='og:title' content={file.name} />
<meta property='twitter:title' content={file.name} />
{mediaType === 'image' && <meta property='twitter:card' content='summary_large_image' />}
{mediaType === 'image' && (
<meta name='twitter:image' content={dataURL('/r', false, false, password)} />
)}
</>
)}
{mediaType === 'image' && (
<>
<meta property='og:type' content='image' />
<meta property='og:image' itemProp='image' content={`${host}/r/${file.name}`} />
<meta property='og:url' content={`${host}/r/${file.name}`} />
<meta property='og:image:width' content={file.imageProps?.naturalWidth.toString()} />
<meta property='og:image:height' content={file.imageProps?.naturalHeight.toString()} />
<meta property='twitter:card' content='summary_large_image' />
<meta property='twitter:image' content={`${host}/r/${file.name}`} />
<meta property='twitter:title' content={file.name} />
<meta property='og:image' itemProp='image' content={dataURL('/r', false, false, password)} />
<meta property='og:image:secure_url' content={dataURL('/r', false, false, password)} />
<meta property='og:image:alt' content={file.name} />
<meta property='og:image:type' content={file.mimetype} />
</>
)}
{file.mimetype.startsWith('video') && (
{mediaType === 'video' && [
...(!!file.thumbnail
? [
<meta key={1} property='og:image' content={dataURL('/r', true, false, password)} />,
<meta
key={2}
property='og:image:secure_url'
content={dataURL('/r', true, false, password)}
/>,
<meta
key={3}
property='og:image:type'
content={file.thumbnail.name.split('.').pop() === 'jpg' ? 'image/jpg' : 'image/gif'}
/>,
]
: []),
<meta key={4} property='og:type' content='video.other' />,
<meta key={5} property='og:video:url' content={dataURL('/r', false, false, password)} />,
<meta key={6} property='og:video:secure_url' content={dataURL('/r', false, false, password)} />,
<meta key={7} property='og:video:type' content={file.mimetype} />,
<meta key={8} name='twitter:card' content='player' />,
<meta key={9} name='twitter:player' content={dataURL('/r', false, false, password)} />,
<meta key={10} name='twitter:player:stream' content={dataURL('/r', false, false, password)} />,
<meta key={11} name='twitter:player:stream:content_type' content={file.mimetype} />,
]}
{mediaType === 'audio' && (
<>
<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} />
{file.thumbnail && (
<>
<meta name='twitter:image' content={`${host}/r/${file.thumbnail.name}`} />
<meta property='og:image' content={`${host}/r/${file.thumbnail.name}`} />
</>
)}
<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') && (
<>
<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:stream:content_type' content={file.mimetype} />
<meta name='twitter:title' content={file.name} />
<meta name='twitter:player:width' content='720' />
<meta name='twitter:player:height' content='480' />
<meta property='og:type' content='music.song' />
<meta property='og:url' content={`${host}/r/${file.name}`} />
<meta property='og:audio' content={`${host}/r/${file.name}`} />
<meta property='og:audio:secure_url' content={`${host}/r/${file.name}`} />
<meta property='og:audio' content={dataURL('/r', false, false, password)} />
<meta property='og:audio:secure_url' content={dataURL('/r', false, false, password)} />
<meta property='og:audio:type' content={file.mimetype} />
</>
)}
{!file.mimetype.startsWith('video') && !file.mimetype.startsWith('image') && (
<meta property='og:url' content={`${host}/r/${file.name}`} />
)}
<title>{file.name}</title>
</Head>
<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}
@@ -186,24 +247,97 @@ export default function EmbeddedFile({
alignItems: 'center',
minHeight: '100vh',
justifyContent: 'center',
overflow: 'hidden',
}}
onMouseDown={(e) => {
if (mediaType !== 'image' || e.button !== 0) return;
if (e.button !== 0) return;
e.preventDefault();
const imageEl = document.getElementById('image_content') as HTMLImageElement,
posX = e.pageX - (imageEl.x + imageEl.width / 2),
posY = e.pageY - (imageEl.y + imageEl.height / 2);
if (imageEl.style.transform.startsWith('translate')) return;
imageEl.style.transform = `translate(${posX * -scale}px, ${posY * -scale}px) scale(${scale})`;
return true;
}}
onMouseUp={(e) => {
if (mediaType !== 'image' || e.button !== 0) return;
const imageEl = document.getElementById('image_content') as HTMLImageElement;
if (!imageEl.style.transform.startsWith('translate')) return;
imageEl.style.transform = 'scale(1)';
setScale(2);
return true;
}}
onMouseMove={(e) => {
if (mediaType !== 'image' || e.button !== 0) return;
if (e.button !== 0) return;
const imageEl = document.getElementById('image_content') as HTMLImageElement,
posX = e.pageX - (imageEl.x + imageEl.width / 2),
posY = e.pageY - (imageEl.y + imageEl.height / 2);
if (!imageEl.style.transform.startsWith('translate')) return;
imageEl.style.transform = `translate(${posX * -scale}px, ${posY * -scale}px) scale(${scale})`;
return true;
}}
onWheel={(e) => {
if (mediaType !== 'image' || e.button !== 0) return;
const imageEl = document.getElementById('image_content') as HTMLImageElement,
posX = e.pageX - (imageEl.x + imageEl.width / 2),
posY = e.pageY - (imageEl.y + imageEl.height / 2);
if (!imageEl.style.transform.startsWith('translate')) return;
let newScale = 0;
if (e.deltaY < 0) newScale = scale + 0.25;
if (e.deltaY > 0) newScale = scale - 0.25 == 0 ? scale : scale - 0.25;
setScale(newScale);
imageEl.style.transform = `translate(${posX * -newScale}px, ${
posY * -newScale
}px) scale(${newScale})`;
}}
>
{file.mimetype.startsWith('image') && (
<img src={dataURL('/r')} alt={dataURL('/r')} id='image_content' />
{mediaType === 'image' && (
<img
src={dataURL('/r', false, true, password)}
alt={dataURL('/r', false, true, password)}
id='image_content'
style={{
transition: 'transform 0.25s ease',
maxHeight: '100vh',
maxWidth: '100vw',
objectFit: 'contain',
}}
/>
)}
{file.mimetype.startsWith('video') && (
<video src={dataURL('/r')} controls autoPlay muted id='video_content' />
{mediaType === 'video' && (
<video
style={{
maxHeight: '100vh',
maxWidth: '100vw',
}}
controls
muted
poster={dataURL('/r', true, true, password)}
id='video_content'
>
<source src={dataURL('/r', false, true, password)} />
<AnchorNext component={Link} href={dataURL('/r', false, true, password, true)}>
Can&#39;t preview this file. Click here to download it.
</AnchorNext>
</video>
)}
{file.mimetype.startsWith('audio') && (
<audio src={dataURL('/r')} controls autoPlay muted id='audio_content' />
{mediaType === 'audio' && (
<audio src={dataURL('/r', false, true)} controls autoPlay muted id='audio_content' />
)}
{!file.mimetype.startsWith('video') &&
!file.mimetype.startsWith('image') &&
!file.mimetype.startsWith('audio') && (
<AnchorNext component={Link} href={dataURL('/r')}>
{mediaType === 'other' && (
<AnchorNext component={Link} href={dataURL('/r', false, true, password, true)}>
Can&#39;t preview this file. Click here to download it.
</AnchorNext>
)}
@@ -214,32 +348,44 @@ export default function EmbeddedFile({
export const getServerSideProps: GetServerSideProps = async (context) => {
const { id } = context.params as { id: string };
const { compress = null } = context.query as unknown as { compress?: boolean };
const file = await prisma.file.findFirst({
where: {
OR: [{ name: id }, { invisible: { invis: decodeURI(encodeURI(id)) } }],
},
include: {
thumbnail: true,
thumbnail: {
select: {
name: true,
},
},
},
});
let host = context.req.headers.host;
if (!file) return { notFound: true };
// @ts-ignore
file.size = parseInt(file.size);
const proto = context.req.headers['x-forwarded-proto'];
try {
if (
JSON.parse(context.req.headers['cf-visitor'] as string).scheme === 'https' ||
proto === 'https' ||
zconfig.core.return_https
config.core.return_https
)
host = `https://${host}`;
else host = `http://${host}`;
} catch (e) {
if (proto === 'https' || zconfig.core.return_https) host = `https://${host}`;
if (proto === 'https' || config.core.return_https) host = `https://${host}`;
else host = `http://${host}`;
}
const mediaType: 'image' | 'video' | 'audio' | 'other' =
(new RegExp(/^(?<type>image|video|audio)/).exec(file.mimetype)?.groups?.type as
| 'image'
| 'video'
| 'audio') || 'other';
const user = await prisma.user.findFirst({
where: {
id: file.userId,
@@ -248,9 +394,14 @@ 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();
// @ts-ignore ditto
if (file.expiresAt) file.expiresAt = file.createdAt.toString();
// @ts-ignore
file.password = !!file.password;
const prismRender = Object.keys(exts).includes(file.name.split('.').pop());
if (prismRender && !file.password)
@@ -260,49 +411,22 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
permanent: true,
},
};
else if (prismRender && file.password) {
const pass = file.password ? true : false;
// @ts-ignore
if (file.password) file.password = true;
else if (prismRender && file.password)
return {
props: {
file,
user,
pass,
prismRender: true,
host,
},
};
}
if (!file.mimetype.startsWith('image') && !file.mimetype.startsWith('video')) {
const { default: datasource } = await import('lib/datasource');
const data = await datasource.get(file.name);
if (!data) return { notFound: true };
// @ts-ignore
if (file.password) file.password = true;
return {
props: {
file,
user,
host,
},
};
}
// @ts-ignore
if (file.password) file.password = true;
return {
props: {
file,
user,
pass: file.password ? true : false,
host,
compress,
mediaType,
},
};
};

View File

@@ -16,7 +16,7 @@ async function main() {
for (let i = 0; i !== files.length; ++i) {
const file = files[i];
const size = await datasource.size(file.name);
if (size === 0) {
if (size === 0 || size == null) {
toDelete.push(file.name);
}
}

View File

@@ -29,12 +29,12 @@ async function main() {
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,
size,
});
};
console.log(`Imported ${files[i]} (${bytesToHuman(size)}) (${mime} mimetype) to user ${userId}`);
}
@@ -53,8 +53,11 @@ async function main() {
// copy files to local storage
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)));
const file = files[i],
fb = await readFile(join(directory, file));
await datasource.save(file, fb, {
type: data[i]?.mimetype ?? 'application/octet-stream',
});
}
console.log(`Finished copying files to ${config.datasource.type} storage.`);

View File

@@ -1,4 +1,4 @@
import config from 'lib/config';
import 'lib/config';
import { inspect } from 'util';
console.log(inspect(config, { depth: Infinity, colors: true }));

View File

@@ -1,40 +0,0 @@
import { File } from '@prisma/client';
import { FastifyInstance, FastifyReply } from 'fastify';
import fastifyPlugin from 'fastify-plugin';
import exts from 'lib/exts';
function dbFileDecorator(fastify: FastifyInstance, _, done) {
fastify.decorateReply('dbFile', dbFile);
done();
async function dbFile(this: FastifyReply, file: File) {
const { download } = this.request.query as { download?: string };
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);
this.header('Content-Length', size);
this.header('Content-Type', download ? 'application/octet-stream' : file.mimetype);
this.header('Content-Disposition', `inline; filename="${encodeURI(file.originalName || file.name)}"`);
return this.send(data);
}
}
export default fastifyPlugin(dbFileDecorator, {
name: 'dbFile',
decorators: {
fastify: ['prisma', 'datasource', 'nextHandle', 'logger'],
},
});
declare module 'fastify' {
interface FastifyReply {
dbFile: (file: File) => Promise<void>;
}
}

View File

@@ -6,13 +6,11 @@ function notFound(fastify: FastifyInstance, _: unknown, done: () => void) {
done();
function notFound(this: FastifyReply) {
if (this.server.config.features.headless) {
if (this.server.config.features.headless || process.env.NODE_ENV == 'development')
return this.callNotFound();
} else {
return this.server.nextServer.render404(this.request.raw, this.raw);
}
}
}
export default fastifyPlugin(notFound, {
name: 'notFound',

View File

@@ -2,12 +2,12 @@ import { Url } from '@prisma/client';
import { FastifyInstance, FastifyReply } from 'fastify';
import fastifyPlugin from 'fastify-plugin';
function postUrlDecorator(fastify: FastifyInstance, _, done) {
fastify.decorateReply('postUrl', postUrl.bind(fastify));
function postUrlDecorator(fastify: FastifyInstance, _, done: () => void) {
fastify.decorateReply('postUrl', postUrl);
done();
async function postUrl(this: FastifyReply, url: Url) {
if (!url) return true;
if (!url) return;
const nUrl = await this.server.prisma.url.update({
where: {
@@ -27,6 +27,7 @@ function postUrlDecorator(fastify: FastifyInstance, _, done) {
this.server.logger.child('url').info(`url deleted due to max views ${JSON.stringify(nUrl)}`);
}
return;
}
}

View File

@@ -7,6 +7,17 @@ function preFileDecorator(fastify: FastifyInstance, _, done) {
done();
async function preFile(this: FastifyReply, file: File) {
await prisma.file.update({
where: {
id: file.id,
},
data: {
views: {
increment: 1,
},
},
});
if (file.favorite) return false;
if (file.expiresAt && file.expiresAt < new Date()) {
await this.server.datasource.delete(file.name);
@@ -16,6 +27,16 @@ function preFileDecorator(fastify: FastifyInstance, _, done) {
return true;
}
if (file.maxViews && file.views >= file.maxViews) {
await datasource.delete(file.name);
await prisma.file.delete({ where: { id: file.id } });
this.server.logger
.child('file')
.info(`File ${file.name} has been deleted due to max views (${file.maxViews})`);
return true;
}
return false;
}
@@ -30,6 +51,6 @@ export default fastifyPlugin(preFileDecorator, {
declare module 'fastify' {
interface FastifyReply {
preFile: (file: File) => Promise<boolean>;
preFile: (file: Partial<File>) => Promise<boolean>;
}
}

View File

@@ -3,62 +3,120 @@ import { guess } from 'lib/mimes';
import { extname } from 'path';
import fastifyPlugin from 'fastify-plugin';
import { createBrotliCompress, createDeflate, createGzip } from 'zlib';
import pump from 'pump';
import { Transform } from 'stream';
import { parseRange } from 'lib/utils/range';
import type { File, Thumbnail } from '@prisma/client';
import { pipeline } from 'stream/promises';
function rawFileDecorator(fastify: FastifyInstance, _, done) {
fastify.decorateReply('rawFile', rawFile);
done();
async function rawFile(this: FastifyReply, id: string) {
async function rawFile(this: FastifyReply, file: Partial<File> & { thumbnail?: Partial<Thumbnail> }) {
const { download, compress = 'false' } = this.request.query as { download?: string; compress?: string };
const isThumb = (this.request.params['id'] as string) === file.thumbnail?.name,
filename = isThumb ? file.thumbnail?.name : file.name,
fileMime = isThumb ? null : file.mimetype;
const data = await this.server.datasource.get(id);
if (!data) return this.notFound();
const logger = this.server.logger.child('rawRoute');
const mimetype = await guess(extname(id).slice(1));
const size = await this.server.datasource.size(id);
this.header('Content-Type', download ? 'application/octet-stream' : mimetype);
const size = await this.server.datasource.size(filename);
if (size === null) return this.notFound();
const mimetype = await guess(extname(filename).slice(1));
if (this.request.headers.range && !compress?.match(/^true$/i)) {
logger.debug('responding raw file with ranged');
const [start, end] = parseRange(this.request.headers.range, size);
if (start >= size || end >= size) {
const buf = await datasource.get(filename);
if (!buf) return this.server.nextServer.render404(this.request.raw, this.raw);
return this.type(fileMime || mimetype || 'application/octet-stream')
.headers({
'Content-Length': size,
'Content-Disposition': `${download ? 'attachment; ' : ''}filename="${encodeURIComponent(
isThumb ? filename : file.originalName ?? filename,
)}`,
})
.status(416)
.send(buf);
}
const buf = await datasource.range(filename, start || 0, end);
if (!buf) return this.server.nextServer.render404(this.request.raw, this.raw);
return this.type(fileMime || mimetype || 'application/octet-stream')
.headers({
'Content-Range': `bytes ${start}-${end}/${size}`,
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
'Content-Disposition': `${download ? 'attachment; ' : ''}filename="${encodeURIComponent(
isThumb ? filename : file.originalName ?? filename,
)}`,
})
.status(206)
.send(buf);
}
const data = await datasource.get(filename);
if (!data) return this.server.nextServer.render404(this.request.raw, this.raw);
if (
this.server.config.core.compression.enabled &&
compress?.match(/^true$/i) &&
!this.request.headers['X-Zipline-NoCompress'] &&
!!this.request.headers['accept-encoding']
)
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);
!!this.request.headers['accept-encoding'] &&
size > this.server.config.core.compression.threshold &&
(fileMime || mimetype).match(/^(image(?!\/(webp))|video|text)/)
) {
logger.debug('responding raw file with compressed');
this.hijack();
return await useCompress.call(this, data);
}
logger.debug('responding raw file with full size');
return this.type(mimetype || 'application/octet-stream')
.headers({
'Content-Length': size,
'Accept-Ranges': 'bytes',
'Content-Disposition': `${download ? 'attachment; ' : ''}filename="${encodeURIComponent(
isThumb ? filename : file.originalName ?? filename,
)}`,
})
.status(200)
.send(data);
}
}
function useCompress(this: FastifyReply, data: NodeJS.ReadableStream) {
async function useCompress(this: FastifyReply, data: NodeJS.ReadableStream) {
let compress: Transform;
switch ((this.request.headers['accept-encoding'] as string).split(', ')[0]) {
case 'gzip':
case 'x-gzip':
compress = createGzip();
this.header('Content-Encoding', 'gzip');
this.raw.writeHead(200, { 'Content-Encoding': 'gzip' });
break;
case 'deflate':
compress = createDeflate();
this.header('Content-Encoding', 'deflate');
this.raw.writeHead(200, { 'Content-Encoding': 'deflate' });
break;
case 'br':
compress = createBrotliCompress();
this.header('Content-Encoding', 'br');
this.raw.writeHead(200, { 'Content-Encoding': 'br' });
break;
default:
this.server.logger
.child('response')
.error(`Unsupported encoding: ${this.request.headers['accept-encoding']}}`);
.debug(`Unsupported supplied encoding: ${this.request.headers['accept-encoding']}`);
this.raw.writeHead(200, {});
break;
}
if (!compress) return data;
setTimeout(() => compress.destroy(), 2000);
return pump(data, compress, (err) => (err ? this.server.logger.error(err) : null));
if (!compress) return await pipeline(data, this.raw);
return await pipeline(data, compress, this.raw);
}
export default fastifyPlugin(rawFileDecorator, {
@@ -70,6 +128,6 @@ export default fastifyPlugin(rawFileDecorator, {
declare module 'fastify' {
interface FastifyReply {
rawFile: (id: string) => Promise<void>;
rawFile: (file: Partial<File> & { thumbnail?: Partial<Thumbnail> }) => Promise<void>;
}
}

View File

@@ -7,7 +7,6 @@ import { version } from '../../package.json';
import fastify, { FastifyInstance, FastifyServerOptions } from 'fastify';
import { createReadStream, existsSync, readFileSync } from 'fs';
import { Worker } from 'worker_threads';
import dbFileDecorator from './decorators/dbFile';
import notFound from './decorators/notFound';
import postFileDecorator from './decorators/postFile';
import postUrlDecorator from './decorators/postUrl';
@@ -46,7 +45,7 @@ async function start() {
logger.debug('Starting server');
// plugins
server
await server
.register(loggerPlugin)
.register(configPlugin, config)
.register(datasourcePlugin, datasource)
@@ -61,13 +60,12 @@ async function start() {
.register(allPlugin);
// decorators
server
await server
.register(notFound)
.register(postUrlDecorator)
.register(postFileDecorator)
.register(preFileDecorator)
.register(rawFileDecorator)
.register(dbFileDecorator);
.register(rawFileDecorator);
server.addHook('onRequest', (req, reply, done) => {
if (config.features.headless) {
@@ -82,12 +80,12 @@ async function start() {
done();
});
server.addHook('onResponse', (req, reply, done) => {
server.addHook('onRequest', (req, reply, done) => {
if (config.core.logger) {
if (req.url.startsWith('/_next')) return done();
server.logger.child('response').info(`${req.method} ${req.url} -> ${reply.statusCode}`);
server.logger.child('response').debug(
server.logger.child('request').info(`${req.method} ${req.url} -> ${reply.statusCode}`);
server.logger.child('request').debug(
JSON.stringify({
method: req.method,
url: req.url,
@@ -100,6 +98,16 @@ async function start() {
done();
});
server.setErrorHandler((error, request, reply) => {
console.error(error);
reply.status(500).send({
statusCode: 500,
error: 'Internal Server Error',
message: error.message,
});
});
server.get('/favicon.ico', async (_, reply) => {
if (!existsSync('./public/favicon.ico')) return reply.notFound();
@@ -233,7 +241,9 @@ async function thumbs(this: FastifyInstance) {
mimetype: {
startsWith: 'video/',
},
thumbnail: null,
thumbnail: {
is: null,
},
},
include: {
thumbnail: true,
@@ -271,7 +281,9 @@ async function thumbs(this: FastifyInstance) {
}
function genFastifyOpts(): FastifyServerOptions {
const opts = {};
const opts: FastifyServerOptions = {
pluginTimeout: 25000,
};
if (config.ssl?.cert && config.ssl?.key) {
opts['https'] = {

View File

@@ -1,15 +1,13 @@
import { PrismaClient } from '@prisma/client';
import { FastifyInstance } from 'fastify';
import type { FastifyInstance } from 'fastify';
import fastifyPlugin from 'fastify-plugin';
import { migrations } from 'server/util';
async function prismaPlugin(fastify: FastifyInstance) {
process.env.DATABASE_URL = fastify.config.core?.database_url;
await migrations();
const prisma = new PrismaClient();
fastify.decorate('prisma', prisma);
fastify.decorate('prisma', new PrismaClient());
return;
}
export default fastifyPlugin(prismaPlugin, {

View File

@@ -1,29 +1,36 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { checkPassword } from 'lib/util';
export default async function rawRoute(this: FastifyInstance, req: FastifyRequest, reply: FastifyReply) {
const { id } = req.params as { id: string };
const { password } = req.query as { password: string };
if (id === '') return reply.notFound();
const file = await this.prisma.file.findFirst({
where: {
OR: [{ name: id }, { invisible: { invis: decodeURI(encodeURI(id)) } }],
OR: [{ name: id }, { invisible: { invis: decodeURI(encodeURI(id)) } }, { thumbnail: { name: id } }],
},
include: {
thumbnail: true,
},
});
if (!file) return reply.rawFile(id);
else {
const failed = await reply.preFile(file);
if (failed) return reply.notFound();
if (!file) return reply.notFound();
if (file.password) {
if (!password)
return reply
.type('application/json')
.code(403)
.send({
error: "can't view a raw file that has a password",
url: `/view/${file.name}`,
code: 403,
});
} else return reply.rawFile(file.name);
.send({ error: 'incorrect password', url: `/view/${file.name}`, code: 403 });
const success = await checkPassword(password, file.password);
if (!success)
return reply
.type('application/json')
.code(403)
.send({ error: 'incorrect password', url: `/view/${file.name}`, code: 403 });
}
return (await reply.preFile(file)) ? reply.notFound() : reply.rawFile(file);
}

View File

@@ -1,27 +1,24 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import exts from 'lib/exts';
export default async function uploadsRoute(this: FastifyInstance, req: FastifyRequest, reply: FastifyReply) {
const { id } = req.params as { id: string };
if (id === '') return reply.notFound();
else if (id === 'dashboard' && !this.config.features.headless)
else if (id === 'dashboard' && !this.config.features.headless && this.config.uploader.route === '/')
return this.nextServer.render(req.raw, reply.raw, '/dashboard');
const image = await this.prisma.file.findFirst({
const file = await this.prisma.file.findFirst({
where: {
OR: [{ name: id }, { name: decodeURI(id) }, { invisible: { invis: decodeURI(encodeURI(id)) } }],
},
});
if (!image) return reply.rawFile(id);
const failed = await reply.preFile(image);
if (!file) return reply.notFound();
const failed = await reply.preFile(file);
if (failed) return reply.notFound();
const ext = image.name.split('.').pop();
if (image.password || image.embed || image.mimetype.startsWith('text/') || Object.keys(exts).includes(ext))
return reply.redirect(`/view/${image.name}`);
else return reply.dbFile(image);
// @ts-ignore
return this.nextServer.render(req.raw, reply.raw, `/view/${file.name}`, req.query);
}
export async function uploadsRouteOnResponse(

View File

@@ -3,7 +3,7 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
export default async function urlsRoute(this: FastifyInstance, req: FastifyRequest, reply: FastifyReply) {
const { id } = req.params as { id: string };
if (id === '') return reply.notFound();
else if (id === 'dashboard' && !this.config.features.headless)
else if (id === 'dashboard' && !this.config.features.headless && this.config.urls.route === '/')
return this.nextServer.render(req.raw, reply.raw, '/dashboard');
const url = await this.prisma.url.findFirst({
@@ -13,9 +13,7 @@ export default async function urlsRoute(this: FastifyInstance, req: FastifyReque
});
if (!url) return reply.notFound();
reply.redirect(url.destination);
reply.postUrl(url);
return await reply.redirect(url.destination);
}
export async function urlsRouteOnResponse(
@@ -24,7 +22,7 @@ export async function urlsRouteOnResponse(
reply: FastifyReply,
done: () => void,
) {
if (reply.statusCode === 200) {
if (reply.statusCode === 302) {
const { id } = req.params as { id: string };
const url = await this.prisma.url.findFirst({
@@ -32,8 +30,7 @@ export async function urlsRouteOnResponse(
OR: [{ id }, { vanity: id }, { invisible: { invis: decodeURI(id) } }],
},
});
reply.postUrl(url);
await reply.postUrl(url);
}
done();

View File

@@ -83,7 +83,8 @@ export function redirect(res: ServerResponse, url: string) {
export async function getStats(prisma: PrismaClient, datasource: Datasource, logger: Logger) {
const size = await datasource.fullSize();
logger.debug(`full size: ${size}`);
const llogger = logger.child('stats');
llogger.debug(`full size: ${size}`);
const byUser = await prisma.file.groupBy({
by: ['userId'],
@@ -91,15 +92,15 @@ export async function getStats(prisma: PrismaClient, datasource: Datasource, log
_all: true,
},
});
logger.debug(`by user: ${JSON.stringify(byUser)}`);
llogger.debug(`by user: ${JSON.stringify(byUser)}`);
const count_users = await prisma.user.count();
logger.debug(`count users: ${count_users}`);
llogger.debug(`count users: ${count_users}`);
const count_by_user = [];
for (let i = 0, L = byUser.length; i !== L; ++i) {
if (!byUser[i].userId) {
logger.debug(`skipping user ${byUser[i]}`);
llogger.debug(`skipping user ${byUser[i]}`);
continue;
}
@@ -114,17 +115,17 @@ export async function getStats(prisma: PrismaClient, datasource: Datasource, log
count: byUser[i]._count._all,
});
}
logger.debug(`count by user: ${JSON.stringify(count_by_user)}`);
llogger.debug(`count by user: ${JSON.stringify(count_by_user)}`);
const count = await prisma.file.count();
logger.debug(`count files: ${JSON.stringify(count)}`);
llogger.debug(`count files: ${JSON.stringify(count)}`);
const views = await prisma.file.aggregate({
_sum: {
views: true,
},
});
logger.debug(`sum views: ${JSON.stringify(views)}`);
llogger.debug(`sum views: ${JSON.stringify(views)}`);
const typesCount = await prisma.file.groupBy({
by: ['mimetype'],
@@ -132,7 +133,7 @@ export async function getStats(prisma: PrismaClient, datasource: Datasource, log
mimetype: true,
},
});
logger.debug(`types count: ${JSON.stringify(typesCount)}`);
llogger.debug(`types count: ${JSON.stringify(typesCount)}`);
const types_count = [];
for (let i = 0, L = typesCount.length; i !== L; ++i)
types_count.push({
@@ -140,7 +141,7 @@ export async function getStats(prisma: PrismaClient, datasource: Datasource, log
count: typesCount[i]._count.mimetype,
});
logger.debug(`types count: ${JSON.stringify(types_count)}`);
llogger.debug(`types count: ${JSON.stringify(types_count)}`);
return {
size: bytesToHuman(size),

View File

@@ -1,5 +1,5 @@
import { type File, PrismaClient, type Thumbnail } from '@prisma/client';
import { spawn } from 'child_process';
import { type ChildProcess, spawn } from 'child_process';
import ffmpeg from 'ffmpeg-static';
import { createWriteStream } from 'fs';
import { rm } from 'fs/promises';
@@ -25,26 +25,91 @@ if (isMainThread) {
process.exit(1);
}
async function loadThumbnail(path) {
const args = ['-i', path, '-frames:v', '1', '-f', 'mjpeg', 'pipe:1'];
async function getDuration(path): Promise<number> {
const args = ['-hide_banner', '-nostdin', '-i', path, '-f', 'null', 'pipe:1'];
const lengthMatch = new RegExp(/time=(?<time>(\d{2,}:){2}\d{2}\.\d{2})/);
const child = spawn(ffmpeg, args, { stdio: ['ignore', 'pipe', 'ignore'] });
const child = spawn(ffmpeg, args, { stdio: ['ignore', 'pipe', 'pipe'] });
const data: Buffer = await new Promise((resolve, reject) => {
const data: string = await new Promise((resolve, reject) => {
const buffers: string[] = [];
child.stderr.on('data', (d) => child.stdout.emit('data', d));
child.stdout.on('data', (d) => buffers.push(d.toString()));
child.once('error', (...a) => {
console.log(a);
reject();
});
child.once('close', (code) => {
if (code !== 0) {
const msg = buffers.join('').trim().split('\n');
logger.debug(`cmd: ${ffmpeg} ${args.join(' ')}\n${msg.join('\n')}`);
logger.error(`child exited with code ${code}: ${msg[msg.length - 1]}`);
if (msg[msg.length - 1].includes('does not contain any stream')) {
// mismatched mimetype, for example a video/ogg (.ogg) file with no video stream since
// for this specific case just set the mimetype to audio/ogg
// the method will return an empty buffer since there is no video stream
logger.error(`file ${path} does not contain any video stream, it is probably an audio file`);
resolve('ow');
}
reject(new Error(`child exited with code ${code} ffmpeg output:\n${msg.join('\n')}`));
} else {
const trimBuffs: string[] = buffers.filter((val) => lengthMatch.exec(val));
resolve(trimBuffs[trimBuffs.length - 1].split('\n')[0]);
}
});
});
const matchLength = lengthMatch.exec(data);
if (!matchLength) return 0;
const timeArr = matchLength.groups.time.split(':');
return parseFloat(timeArr.reduce((prev, curr) => (parseFloat(prev) * 60 + parseFloat(curr)).toString()));
}
async function handleChild(child: ChildProcess, path: string, args: string[]): Promise<Buffer> {
return await new Promise((resolve, reject) => {
const buffers = [];
const errorBuffers = [];
child.stderr.on('data', (chunk) => {
errorBuffers.push(chunk);
});
child.stdout.on('data', (chunk) => {
buffers.push(chunk);
});
child.once('error', reject);
child.once('error', (...a) => {
console.log(a);
reject();
});
child.once('close', (code) => {
if (code !== 0) {
const msg = buffers.join('').trim();
logger.debug(`cmd: ${ffmpeg} ${args.join(' ')}`);
logger.error(`while ${path} child exited with code ${code}: ${msg}`);
const msg = errorBuffers.join('').trim().split('\n');
reject(new Error(`child exited with code ${code}`));
logger.debug(`cmd: ${ffmpeg} ${args.join(' ')}\n${msg.join('\n')}`);
logger.error(`child exited with code ${code}: ${msg[msg.length - 1]}`);
if (msg[msg.length - 1].includes('does not contain any stream')) {
// mismatched mimetype, for example a video/ogg (.ogg) file with no video stream since
// for this specific case just set the mimetype to audio/ogg
// the method will return an empty buffer since there is no video stream
logger.error(`file ${path} does not contain any video stream, it is probably an audio file`);
resolve(Buffer.alloc(0));
}
reject(new Error(`child exited with code ${code} ffmpeg output:\n${msg.join('\n')}`));
} else {
const buffer = Buffer.allocUnsafe(buffers.reduce((acc, val) => acc + val.length, 0));
@@ -59,6 +124,47 @@ async function loadThumbnail(path) {
}
});
});
}
async function loadGifThumbnail(path): Promise<Buffer> {
if (!config.features.gif_thumbnails) return;
const duration = await getDuration(path);
if (duration <= 5) return;
let start: number = duration;
const re = () => (start = Math.floor(Math.random() * duration * 100) / 100);
while (start + 3 >= duration) re();
const args = [
'-i',
path,
'-ss',
start.toString(),
'-t',
'3',
'-vf',
'fps=10,scale=320:-1:flags=lanczos,split[s0][s1];[s0]palettegen=stats_mode=diff[p];[s1][p]paletteuse',
'-loop',
'0',
'-f',
'gif',
'pipe:1',
];
const child = spawn(ffmpeg, args, { stdio: ['ignore', 'pipe', 'pipe'] });
const data: Buffer = await handleChild(child, path, args);
return data;
}
async function loadThumbnail(path): Promise<Buffer> {
const args = ['-i', path, '-frames:v', '1', '-f', 'mjpeg', 'pipe:1'];
const child = spawn(ffmpeg, args, { stdio: ['ignore', 'pipe', 'pipe'] });
const data: Buffer = await handleChild(child, path, args);
return data;
}
@@ -67,7 +173,10 @@ async function loadFileTmp(file: File) {
const stream = await datasource.get(file.name);
// pipe to tmp file
const tmpFile = join(config.core.temp_directory, `zipline_thumb_${file.id}_${file.id}.tmp`);
const tmpFile = join(
config.core.temp_directory,
`zipline_thumb_${file.id}_${file.mimetype.replace('/', '_')}.tmp`,
);
const fileWriteStream = createWriteStream(tmpFile);
await new Promise((resolve, reject) => {
@@ -86,18 +195,39 @@ async function start() {
const file = videos[i];
if (!file.mimetype.startsWith('video/')) {
logger.info('file is not a video');
process.exit(0);
continue;
}
if (file.thumbnail) {
logger.info('thumbnail already exists');
process.exit(0);
continue;
}
const tmpFile = await loadFileTmp(file);
logger.debug(`loaded file to tmp: ${tmpFile}`);
const thumbnail = await loadThumbnail(tmpFile);
logger.debug(`loaded thumbnail: ${thumbnail.length} bytes mjpeg`);
let useStill = false,
thumbnail: Buffer = await loadGifThumbnail(tmpFile);
if (!thumbnail) {
useStill = true;
thumbnail = await loadThumbnail(tmpFile);
}
logger.debug(`loaded thumbnail: ${thumbnail.length} bytes ${useStill ? 'mjpeg' : 'gif'}`);
if (thumbnail.length === 0 && file.mimetype === 'video/ogg') {
logger.info('file might be an audio file, setting mimetype to audio/ogg to avoid future errors');
await prisma.file.update({
where: {
id: file.id,
},
data: {
mimetype: 'audio/ogg',
},
});
await rm(tmpFile);
await prisma.$disconnect();
process.exit(0);
}
const { thumbnail: thumb } = await prisma.file.update({
where: {
@@ -106,7 +236,7 @@ async function start() {
data: {
thumbnail: {
create: {
name: `.thumb-${file.id}.jpg`,
name: `.thumb-${file.id}.${useStill ? 'jpg' : 'gif'}`,
},
},
},
@@ -115,7 +245,7 @@ async function start() {
},
});
await datasource.save(thumb.name, thumbnail);
await datasource.save(thumb.name, thumbnail, { type: useStill ? 'image/jpeg' : 'image/gif' });
logger.info(`thumbnail saved - ${thumb.name}`);
logger.debug(`thumbnail ${JSON.stringify(thumb)}`);

View File

@@ -121,7 +121,9 @@ async function start() {
await fd.close();
} else {
logger.debug('writing file to datasource');
await datasource.save(file.filename, Buffer.from(fd as Uint8Array));
await datasource.save(file.filename, Buffer.from(fd as Uint8Array), {
type: file.mimetype ?? 'application/octet-stream',
});
}
const final = await prisma.incompleteFile.update({

2846
yarn.lock

File diff suppressed because it is too large Load Diff