Compare commits

...

82 Commits

Author SHA1 Message Date
diced
fe50bebeba feat(v3.7.6): version 2023-11-20 21:31:08 -08:00
Kashall
1f61c56f83 fix: pg int -> bigint (#484) (#459)
* fix: prisma int supports up to 2gb. Bigint supports a lot more than 2gb.

* yes, i ran `yarn migrate:dev`

* fix: cannot assign bigint to number

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

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

* jesus christ

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

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

* Prettier

* Add Whitelisted Users

* Update discord.ts

* Whitespace

* Whitespace

---------

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

* chore: remove unused util function

* chore: lint

---------

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

* chore: clean up a debug log

* chore: add debug for why not webhook'd

* fix: No more temp invite when registering.

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

* chore: miniscule cleanup

* fix: Don't double fix dates. It was already fixed once.
2023-07-26 15:07:13 -07:00
diced
1c674d3d9f fix: unset ZIPLINE_DOCKER_BUILD in entrypoint 2023-07-24 11:26:12 -07:00
diced
fb32e9f38e fix: test caching 2023-07-23 21:38:52 -07:00
diced
6babf73e07 fix: docker caching 2023-07-23 21:06:03 -07:00
diced
d0eb442fdf fix: add debug/error logs to loadThumbnail 2023-07-23 20:36:20 -07:00
diced
d3cb9118ce fix: passing incompatible objects to workers 2023-07-22 22:39:09 -07:00
diced
7ec6d566b8 fix: readme updates 2023-07-16 11:48:08 -07:00
diced
d695211030 feat: modifier for bytes 2023-07-16 11:15:47 -07:00
dicedtomato
907e43c860 feat(v3.7.1): version 2023-07-02 14:58:54 -07:00
Jayvin Hernandez
d9fd771233 fix: Add url response for final chunk uploading. (#439)
* fix: Add url response for final chunk uploading.
Kind of like pre-releasing your URL.

* fix: Whoopsie!

* I forgor'd

* fix: remove beforeunload listener finished uploading

* fix: Redundant change.

* fix: Copy URL after chunked upload! :D

* fix: Clicc to copy URL! :D
2023-07-02 11:22:43 -07:00
Jayvin Hernandez
61c87aecdc Added optionally enabling thumbnail generation and fix meta tags for /view endpoint (#437)
* fix: Not null but is false header

* Forgot it was a string 💀

* fix: Improved twitter embedding for images

* fix: Improved twitter embedding for videos

* notFix: Add twitter's meta tags for audio

* fix: Use the full domain + raw path for meta tags.

* fix: You can now optionally enable thumbnails.

* fix: other thing ran 😔

* fix: not-null zws header check

* fix: account for return_https in core config
2023-07-01 19:04:14 -07:00
dicedtomato
5ef6c7a6de feat: funding 2023-06-22 19:08:59 -07:00
diced
0e7dde2500 feat: license (c) 2023 2023-06-19 15:11:46 -07:00
diced
3ab3202b92 fix: hidden non-media favorites (#428) 2023-06-19 11:34:10 -07:00
diced
b02adca6db fix: show no invites message (#427) 2023-06-19 11:31:15 -07:00
diced
4a254c55c8 fix: excessive worker count (#425) 2023-06-19 11:24:04 -07:00
Jayvin Hernandez
226d946ec8 fix: stuff (#423)
* fix: copying and opening another user's upload url

* fix: delete thumbnails too

* fix: return target after removing files from output

* fix: add width to fix diced/zipline#419 (can't test)

* Minor script tune-ups.

* Remove the catcher for when upload has been offloaded to chunk
2023-06-18 19:28:20 -07:00
diced
a1bc2db336 fix: thtumbnail box sizing (#415) 2023-05-30 18:33:15 -07:00
diced
86277a091c fix(invites): remvoe never option 2023-05-29 19:26:16 -07:00
diced
30dbfdaac5 fix: remove exif temp files 2023-05-29 19:14:30 -07:00
dicedtomato
5c424a2c6d feat: video thumbnails (#413) (#376)
* feat: thumbnails workers

* feat: thumbnails final

* fix: no thumbnailId

* fix: unecessary stuff
2023-05-29 19:02:18 -07:00
Jayvin Hernandez
f40d65a9f7 feat: view other user files (#408)
* feat: Add the capability of viewing another user's images as admin.

* fix: add columns, oops...

* fix: Gotta check if the user's legit before letting them see

* fix: made administrators non-viewable

* Please don't reference yourself

* fix: superAdmin > admin

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2023-05-23 22:34:21 -07:00
Jayvin Hernandez
a2c085719a feat: new opt bug.yml (#411)
Hopefully this is a better explanation for the version selector.

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2023-05-22 15:36:53 -07:00
Jayvin Hernandez
60d7b22dca fix: ability to delete other users images (#407)
* fix: Worst, but minimally working, fix so other users do not delete each other's files.

* fix: include previous fix for PATCH

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2023-05-22 15:36:19 -07:00
Jayvin Hernandez
d111b0811f fix: add a debug log (#406) 2023-05-22 15:32:26 -07:00
NebulaBC
b46e7b8ba2 Fix branding oversight caused by 0a34b0c (#405) 2023-05-16 14:46:48 -07:00
diced
39a8d52353 feat: built-in robots.txt (#402) 2023-05-13 16:25:27 -07:00
diced
ec09458ad3 feat: allow full paths (#393) 2023-05-13 00:12:29 -07:00
Jayvin Hernandez
a7ad58b196 fix: embed links #390 (#403)
* fix: Use raw route for embedding images.

* fix: there was already a raw link passed 😔
2023-05-12 23:14:59 -07:00
Jayvin Hernandez
1ddd351242 fix: things (#401)
* fix: use relative path for volumes

* fix: null check oauth config

* fix: attempt refresh if error is on /dashboard

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2023-05-10 22:39:08 -07:00
Derock
24b06c76fb feat: server-side sorting (#366)
Co-authored-by: Jayvin Hernandez <gogojayvin923@gmail.com>
Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2023-05-10 22:32:13 -07:00
Dane
0a34b0cc21 feat: bypass local login (#373)
* adding option to bypass local login

* oops

* fix: add descriptive title

---------

Co-authored-by: Jayvin Hernandez <gogojayvin923@gmail.com>
Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2023-05-06 11:04:52 -07:00
diced
ce26a414ac fix: update reference (#392) 2023-05-06 10:41:46 -07:00
diced
f71aab2cde fix: body size limit for pfp (#389) 2023-05-06 10:37:38 -07:00
diced
5f76e9d383 refactor: copy errors removed (#344) 2023-05-06 10:36:08 -07:00
diced
4a46f15833 fix: ext finding (#384) 2023-04-30 15:41:27 -07:00
diced
d6ce64ae21 fix: go back one page (#371) 2023-04-30 15:31:19 -07:00
Jayvin Hernandez
7cbf828f3b fix: properly type the relation field for OAuth (#370)
Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2023-04-28 20:51:34 -07:00
Jayvin Hernandez
3ff215366a fix: a lot of things (#386)
* @diced forgor

* check if proper id

* fix(?): await datasource's get

* fix (diced/zipline#350): Return size 0 for not found
2023-04-28 20:32:51 -07:00
IThundxr
d238e24f62 feat: Add check to prevent favorited files from being deleted (#369)
* additions

* add proper lock system

* migreation

* Revert "migreation"

This reverts commit 4058146c28.

* remove that

* get rid of bad code

---------

Co-authored-by: Jayvin Hernandez <gogojayvin923@gmail.com>
2023-04-12 16:59:30 -07:00
Jayvin Hernandez
fd2746c2d0 fix: clearing (#367)
+ added convenient clear-temp script, no flags yet
2023-04-11 18:37:38 -07:00
Jayvin Hernandez
61b2eff6a4 fix: #364
* some fixes for diced/zipline-docs#50

* appropriately changed items

* tight fit for a selector
2023-04-06 20:02:07 -07:00
Jayvin Hernandez
89a28bf50b fix: query-size (#363)
* feat: add flags for querying & delete option

* return 0 for no file

* include size
2023-04-05 20:25:19 -07:00
Jayvin Hernandez
5ded128263 fix: user uuid (#355)
* fix: user uuid is used instead of user id for its uniqueness

* fix: use cuid instead & exclude from parser

* fix: apply new foreign key constraints to existing data

* fix: migration partly done

* not-fix: General form of migration achieved, still broken

* fix: migrate and use db's uuid function for existing users

* fix: Proper not nulling!

* fix: #354

* fix: migration & use uuid instead

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
Co-authored-by: diced <pranaco2@gmail.com>
2023-04-04 20:07:41 -07:00
dicedtomato
eedeb89c7d feat: offloaded chunked uploads (#356)
* feat: offloaded chunked uploads

* fix: use temp_directory instead of tmpdir()

* feat: CHUNKS_ENABLED config
2023-04-03 22:42:27 -07:00
Jayvin Hernandez
bf40fa9cd2 feat: many things (#351)
* remove source from final image

* move check state to ClearStorage

* use inspect for fancy colors

* newlines are now possible! yay!

* Catch user's leave if uploading

* feat?: Temp directory can be specified by the user.
Default is /tmp/zipline (or os equivalent)

* fix: ignore onDash config, use only ?compress query

---------

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2023-03-31 22:25:00 -07:00
diced
bc58c1b56e fix: milestone again again again again again again 2023-03-31 22:12:13 -07:00
diced
c57a6e1700 fix: milestone again again again again again 2023-03-31 22:07:17 -07:00
diced
8649a489d8 fix: milestone again again again again 2023-03-31 22:05:59 -07:00
diced
40f29907c7 fix: milestone again again again 2023-03-31 21:55:26 -07:00
diced
34005ece43 fix: milestone again again 2023-03-31 21:53:30 -07:00
diced
8e6fc1e8a3 fix: milestone again 2023-03-31 21:49:50 -07:00
diced
065f44b145 fix: milestone 2023-03-31 21:41:19 -07:00
diced
e5a07f568d fix: update milestone action 2023-03-27 16:48:56 -07:00
120 changed files with 6936 additions and 4486 deletions

View File

@@ -1,46 +1,50 @@
# every field in here is optional except, CORE_SECRET and CORE_DATABASE_URL. # 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 CORE_SECRET is still "changethis" then zipline will exit and tell you to change it.
# if using s3/supabase make sure to comment out the other datasources # if using s3/supabase make sure to uncomment or comment out the correct lines needed.
CORE_HTTPS=true CORE_RETURN_HTTPS=true
CORE_SECRET="changethis" CORE_SECRET="changethis"
CORE_HOST=0.0.0.0 CORE_HOST=0.0.0.0
CORE_PORT=3000 CORE_PORT=3000
CORE_DATABASE_URL="postgres://postgres:postgres@localhost/zip10" CORE_DATABASE_URL="postgres://postgres:postgres@localhost/zip10"
CORE_LOGGER=false CORE_LOGGER=false
CORE_STATS_INTERVAL=1800 CORE_STATS_INTERVAL=1800
CORE_INVITES_INTERVAL=1800
CORE_THUMBNAILS_INTERVAL=600
# default # default
DATASOURCE_TYPE=local DATASOURCE_TYPE=local
DATASOURCE_LOCAL_DIRECTORY=./uploads DATASOURCE_LOCAL_DIRECTORY=./uploads
# or you can choose to use s3 # or you can choose to use s3
DATASOURCE_TYPE=s3 # DATASOURCE_TYPE=s3
DATASOURCE_S3_ACCESS_KEY_ID=key # DATASOURCE_S3_ACCESS_KEY_ID=key
DATASOURCE_S3_SECRET_ACCESS_KEY=secret # DATASOURCE_S3_SECRET_ACCESS_KEY=secret
DATASOURCE_S3_BUCKET=bucket # DATASOURCE_S3_BUCKET=bucket
DATASOURCE_S3_ENDPOINT=s3.amazonaws.com # DATASOURCE_S3_ENDPOINT=s3.amazonaws.com
DATASOURCE_S3_REGION=us-west-2 # DATASOURCE_S3_REGION=us-west-2
DATASOURCE_S3_FORCE_S3_PATH=false # DATASOURCE_S3_FORCE_S3_PATH=false
DATASOURCE_S3_USE_SSL=false # DATASOURCE_S3_USE_SSL=false
# or supabase # or supabase
DATASOURCE_TYPE=supabase # DATASOURCE_TYPE=supabase
DATASOURCE_SUPABASE_KEY=xxx # DATASOURCE_SUPABASE_KEY=xxx
# remember: no leading slash # remember: no leading slash
DATASOURCE_SUPABASE_URL=https://something.supabase.co # DATASOURCE_SUPABASE_URL=https://something.supabase.co
DATASOURCE_SUPABASE_BUCKET=zipline # DATASOURCE_SUPABASE_BUCKET=zipline
UPLOADER_DEFAULT_FORMAT=RANDOM UPLOADER_DEFAULT_FORMAT=RANDOM
UPLOADER_ROUTE=/u UPLOADER_ROUTE=/u
UPLOADER_LENGTH=6 UPLOADER_LENGTH=6
UPLOADER_ADMIN_LIMIT=104900000 UPLOADER_ADMIN_LIMIT=104900000
UPLOADER_USER_LIMIT=104900000 UPLOADER_USER_LIMIT=104900000
UPLOADER_DISABLED_EXTENSIONS=someext UPLOADER_DISABLED_EXTENSIONS=someext,anotherext
URLS_ROUTE=/go URLS_ROUTE=/go
URLS_LENGTH=6 URLS_LENGTH=6
RATELIMIT_USER = 5 RATELIMIT_USER=5
RATELIMIT_ADMIN = 3 RATELIMIT_ADMIN=3
# for more variables checkout the docs

3
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,3 @@
# These are supported funding model platforms
github: diced

View File

@@ -15,10 +15,10 @@ body:
id: version id: version
attributes: attributes:
label: Version label: Version
description: What version of Zipline are you using? description: What version (or docker image) of Zipline are you using?
options: options:
- latest (ghcr.io/diced/zipline or ghcr.io/diced/zipline:latest)
- upstream (ghcr.io/diced/zipline:trunk) - upstream (ghcr.io/diced/zipline:trunk)
- latest (ghcr.io/diced/zipline:latest)
- other (provide version in additional info) - other (provide version in additional info)
validations: validations:
required: true required: true

View File

@@ -1,11 +1,11 @@
blank_issues_enabled: false blank_issues_enabled: false
contact_links: contact_links:
- name: Feature Request - name: Feature Request
url: https://github.com/diced/zipline/discussions/new?category=ideas&title=Your%20breif%20description%20here&labels=feature url: https://github.com/diced/zipline/discussions/new?category=ideas&title=Your%20brief%20description%20here&labels=feature
about: Ask for a new feature about: Ask for a new feature
- name: Zipline Discord - name: Zipline Discord
url: https://discord.gg/EAhCRfGxCF url: https://discord.gg/EAhCRfGxCF
about: Ask for help with anything related to Zipline! about: Ask for help with anything related to Zipline!
- name: Zipline Docs - name: Zipline Docs
url: https://zipline.diced.tech url: https://zipline.diced.sh
about: Maybe take a look a the docs? about: Maybe take a look a the docs?

View File

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

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

View File

@@ -14,7 +14,7 @@ Create an issue on GitHub, please include the following (if one of them is not a
Create an discussion on GitHub, please include the following: Create an discussion on GitHub, please include the following:
- Breif explanation of the feature in the title (very breif please) - Brief explanation of the feature in the title (very brief please)
- How it would work (detailed, but optional) - How it would work (detailed, but optional)
## Pull Requests (contributions to the codebase) ## Pull Requests (contributions to the codebase)

View File

@@ -9,14 +9,6 @@ WORKDIR /zipline
# Copy the necessary files from the project # Copy the necessary files from the project
COPY prisma ./prisma COPY prisma ./prisma
COPY src ./src
COPY next.config.js ./next.config.js
COPY tsup.config.ts ./tsup.config.ts
COPY tsconfig.json ./tsconfig.json
COPY mimes.json ./mimes.json
COPY public ./public
FROM base as builder
COPY .yarn ./.yarn COPY .yarn ./.yarn
COPY package*.json ./ COPY package*.json ./
@@ -34,18 +26,24 @@ ENV PRISMA_QUERY_ENGINE_BINARY=/prisma-engines/query-engine \
ZIPLINE_DOCKER_BUILD=true \ ZIPLINE_DOCKER_BUILD=true \
NEXT_TELEMETRY_DISABLED=1 NEXT_TELEMETRY_DISABLED=1
# Install production dependencies then temporarily save
RUN yarn workspaces focus --production --all
RUN cp -RL node_modules /tmp/node_modules
# Install the dependencies # Install the dependencies
RUN yarn install --immutable RUN yarn install --immutable
FROM base as builder
COPY src ./src
COPY next.config.js ./next.config.js
COPY tsup.config.ts ./tsup.config.ts
COPY tsconfig.json ./tsconfig.json
COPY mimes.json ./mimes.json
COPY public ./public
# Run the build # Run the build
RUN yarn build RUN yarn build
# Use Alpine Linux as the final image # Use Alpine Linux as the final image
FROM base FROM base
# Install the necessary packages # Install the necessary packages
RUN apk add --no-cache perl procps tini RUN apk add --no-cache perl procps tini
@@ -61,15 +59,20 @@ ENV PRISMA_QUERY_ENGINE_BINARY=/prisma-engines/query-engine \
# Copy only the necessary files from the previous stage # Copy only the necessary files from the previous stage
COPY --from=builder /zipline/dist ./dist COPY --from=builder /zipline/dist ./dist
COPY --from=builder /zipline/.next ./.next COPY --from=builder /zipline/.next ./.next
COPY --from=builder /zipline/package.json ./package.json
COPY --from=builder /zipline/node_modules ./node_modules COPY --from=builder /zipline/mimes.json ./mimes.json
COPY --from=builder /zipline/node_modules/.prisma/client ./node_modules/.prisma/client COPY --from=builder /zipline/next.config.js ./next.config.js
COPY --from=builder /zipline/node_modules/@prisma/client ./node_modules/@prisma/client COPY --from=builder /zipline/public ./public
# Copy Startup Script # Copy Startup Script
COPY docker-entrypoint.sh /zipline COPY docker-entrypoint.sh /zipline
# Make Startup Script Executable # Make Startup Script Executable
RUN chmod a+x /zipline/docker-entrypoint.sh RUN chmod a+x /zipline/docker-entrypoint.sh && rm -rf /zipline/src
# Clean up
RUN rm -rf /tmp/* /root/*
RUN yarn cache clean --all
# Set the entrypoint to the startup script # Set the entrypoint to the startup script
ENTRYPOINT ["tini", "--", "/zipline/docker-entrypoint.sh"] ENTRYPOINT ["tini", "--", "/zipline/docker-entrypoint.sh"]

View File

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

View File

@@ -35,17 +35,9 @@ A ShareX/file upload server that is easy to use, packed with features, and with
- User invites - User invites
- File Chunking (for large files) - File Chunking (for large files)
- File deletion once it reaches a certain amount of views - File deletion once it reaches a certain amount of views
- Automatic video thumbnail generation
- Easy setup instructions on [docs](https://zipl.vercel.app/) (One command install `docker compose up -d`) - Easy setup instructions on [docs](https://zipl.vercel.app/) (One command install `docker compose up -d`)
<details>
<summary>View upstream documentation</summary>
The website below provides documentation for more up-to-date features with the upstream branch. The normal documentation is for the latest release and is not updated unless a new release is made.
[https://trunk.zipline.diced.tech/](https://trunk.zipline.diced.tech/)
</details>
<details> <details>
<summary><h2>Screenshots (click)</h2></summary> <summary><h2>Screenshots (click)</h2></summary>
@@ -76,17 +68,18 @@ Ways you could generate the string could be from a password managers generator,
## Building & running from source ## Building & running from source
This section requires [nodejs](https://nodejs.org), [yarn](https://yarnpkg.com/) or [npm](https://npmjs.com). This section requires [nodejs](https://nodejs.org), [yarn](https://yarnpkg.com/).
It is recommended to not use npm, as it can cause issues with the build process.
Before you run `yarn build`, you might want to configure Zipline, as when building from source Zipline will need to read some sort of configuration. The only two variables needed are `CORE_SECRET` and `CORE_DATABASE_URL`.
```shell ```shell
git clone https://github.com/diced/zipline git clone https://github.com/diced/zipline
cd zipline cd zipline
# npm install
yarn install yarn install
# npm run build
yarn build yarn build
# npm start
yarn start yarn start
``` ```
@@ -119,7 +112,7 @@ This section requires [ShareX](https://www.getsharex.com/).
After navigating to Zipline, click on the top right corner where it says your username and click Manage Account. Scroll down to see "ShareX Config", select the one you would prefer using. After this you can import the .sxcu into sharex. [More information here](https://zipl.vercel.app/docs/guides/uploaders/sharex) After navigating to Zipline, click on the top right corner where it says your username and click Manage Account. Scroll down to see "ShareX Config", select the one you would prefer using. After this you can import the .sxcu into sharex. [More information here](https://zipl.vercel.app/docs/guides/uploaders/sharex)
# Flameshot (Linux) # Flameshot (Linux(Xorg/Wayland) and macOS)
This section requires [Flameshot](https://www.flameshot.org/), [jq](https://stedolan.github.io/jq/), and [xsel](https://github.com/kfish/xsel). This section requires [Flameshot](https://www.flameshot.org/), [jq](https://stedolan.github.io/jq/), and [xsel](https://github.com/kfish/xsel).
@@ -134,6 +127,13 @@ After this, replace the `xsel -ib` with `wl-copy` in the script.
</details> </details>
<details>
<summary>Mac instructions</summary>
If using macOS, you can replace the `xsel -ib` with `pbcopy` in the script.
</details>
You can either use the script below, or generate one directly from Zipline (just like how you can generate a ShareX config). You can either use the script below, or generate one directly from Zipline (just like how you can generate a ShareX config).
To upload files using flameshot we will use a script. Replace $TOKEN and $HOST with your own values, you probably know how to do this if you use linux. To upload files using flameshot we will use a script. Replace $TOKEN and $HOST with your own values, you probably know how to do this if you use linux.
@@ -166,3 +166,7 @@ Create a discussion on GitHub, please include the following:
## Pull Requests (contributions to the codebase) ## Pull Requests (contributions to the codebase)
Create a pull request on GitHub. If your PR does not pass the action checks, then please fix the errors. If your PR was submitted before a release, and I have pushed a new release, please make sure to update your PR to reflect any changes, usually this is handled by GitHub. Create a pull request on GitHub. If your PR does not pass the action checks, then please fix the errors. If your PR was submitted before a release, and I have pushed a new release, please make sure to update your PR to reflect any changes, usually this is handled by GitHub.
# Documentation
Documentation source code is located in [diced/zipline-docs](https://github.com/diced/zipline-docs), and can be accessed [here](https://zipl.vercel.app).

View File

@@ -23,8 +23,8 @@ services:
env_file: env_file:
- .env.local - .env.local
volumes: volumes:
- '$PWD/uploads:/zipline/uploads' - './uploads:/zipline/uploads'
- '$PWD/public:/zipline/public' - './public:/zipline/public'
depends_on: depends_on:
- 'postgres' - 'postgres'

View File

@@ -29,7 +29,7 @@ services:
- CORE_LOGGER=true - CORE_LOGGER=true
volumes: volumes:
- './uploads:/zipline/uploads' - './uploads:/zipline/uploads'
- '$PWD/public:/zipline/public' - './public:/zipline/public'
depends_on: depends_on:
- 'postgres' - 'postgres'

View File

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

View File

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

View File

@@ -0,0 +1,18 @@
-- CreateEnum
CREATE TYPE "ProcessingStatus" AS ENUM ('PENDING', 'PROCESSING', 'COMPLETE');
-- CreateTable
CREATE TABLE "IncompleteFile" (
"id" SERIAL NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"status" "ProcessingStatus" NOT NULL,
"chunks" INTEGER NOT NULL,
"chunksComplete" INTEGER NOT NULL,
"userId" INTEGER NOT NULL,
"data" JSONB NOT NULL,
CONSTRAINT "IncompleteFile_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "IncompleteFile" ADD CONSTRAINT "IncompleteFile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,53 @@
/*
Warnings:
- A unique constraint covering the columns `[uuid]` on the table `User` will be added. If there are existing duplicate values, this will fail.
*/
-- PRISMA GENERATED BELOW
-- -- DropForeignKey
-- ALTER TABLE "OAuth" DROP CONSTRAINT "OAuth_userId_fkey";
--
-- -- AlterTable
-- ALTER TABLE "OAuth" ALTER COLUMN "userId" SET DATA TYPE TEXT;
--
-- -- AlterTable
-- ALTER TABLE "User" ADD COLUMN "uuid" UUID NOT NULL DEFAULT gen_random_uuid();
--
-- -- CreateIndex
-- CREATE UNIQUE INDEX "User_uuid_key" ON "User"("uuid");
--
-- -- AddForeignKey
-- ALTER TABLE "OAuth" ADD CONSTRAINT "OAuth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("uuid") ON DELETE CASCADE ON UPDATE CASCADE;
-- User made changes below
-- Rename old foreign key
ALTER TABLE "OAuth" RENAME CONSTRAINT "OAuth_userId_fkey" TO "OAuth_userId_old_fkey";
-- Rename old column
ALTER TABLE "OAuth" RENAME COLUMN "userId" TO "userId_old";
-- Add new column
ALTER TABLE "OAuth" ADD COLUMN "userId" UUID;
-- Add user uuid
ALTER TABLE "User" ADD COLUMN "uuid" UUID NOT NULL DEFAULT gen_random_uuid();
-- Update table "OAuth" with uuid
UPDATE "OAuth" SET "userId" = "User"."uuid" FROM "User" WHERE "OAuth"."userId_old" = "User"."id";
-- Alter table "OAuth" to make "userId" required
ALTER TABLE "OAuth" ALTER COLUMN "userId" SET NOT NULL;
-- Create index
CREATE UNIQUE INDEX "User_uuid_key" ON "User"("uuid");
-- Add new foreign key
ALTER TABLE "OAuth" ADD CONSTRAINT "OAuth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("uuid") ON DELETE CASCADE ON UPDATE CASCADE;
-- Drop old foreign key
ALTER TABLE "OAuth" DROP CONSTRAINT "OAuth_userId_old_fkey";
-- Drop old column
ALTER TABLE "OAuth" DROP COLUMN "userId_old";

View File

@@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "Thumbnail" (
"id" SERIAL NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"name" TEXT NOT NULL,
"fileId" INTEGER NOT NULL,
CONSTRAINT "Thumbnail_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Thumbnail_fileId_key" ON "Thumbnail"("fileId");
-- AddForeignKey
ALTER TABLE "Thumbnail" ADD CONSTRAINT "Thumbnail_fileId_fkey" FOREIGN KEY ("fileId") REFERENCES "File"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

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

View File

@@ -8,23 +8,25 @@ generator client {
} }
model User { model User {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
username String uuid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid
password String? username String
avatar String? password String?
token String avatar String?
administrator Boolean @default(false) token String
superAdmin Boolean @default(false) administrator Boolean @default(false)
systemTheme String @default("system") superAdmin Boolean @default(false)
embed Json @default("{}") systemTheme String @default("system")
ratelimit DateTime? embed Json @default("{}")
totpSecret String? ratelimit DateTime?
domains String[] totpSecret String?
oauth OAuth[] domains String[]
files File[] oauth OAuth[]
urls Url[] files File[]
Invite Invite[] urls Url[]
Folder Folder[] Invite Invite[]
Folder Folder[]
IncompleteFile IncompleteFile[]
} }
model Folder { model Folder {
@@ -46,7 +48,7 @@ model File {
originalName String? originalName String?
mimetype String @default("image/png") mimetype String @default("image/png")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
size Int @default(0) size BigInt @default(0)
expiresAt DateTime? expiresAt DateTime?
maxViews Int? maxViews Int?
views Int @default(0) views Int @default(0)
@@ -60,6 +62,17 @@ model File {
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull) folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
folderId Int? folderId Int?
thumbnail Thumbnail?
}
model Thumbnail {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
name String
fileId Int @unique
file File @relation(fields: [fileId], references: [id], onDelete: Cascade)
} }
model InvisibleFile { model InvisibleFile {
@@ -111,8 +124,8 @@ model Invite {
model OAuth { model OAuth {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
provider OauthProviders provider OauthProviders
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [uuid], onDelete: Cascade)
userId Int userId String @db.Uuid
username String username String
oauthId String? oauthId String?
token String token String
@@ -126,3 +139,23 @@ enum OauthProviders {
GITHUB GITHUB
GOOGLE GOOGLE
} }
model IncompleteFile {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
status ProcessingStatus
chunks Int
chunksComplete Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
data Json
}
enum ProcessingStatus {
PENDING
PROCESSING
COMPLETE
}

View File

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

View File

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

View File

@@ -49,6 +49,7 @@ export default function FileModal({
reducedActions = false, reducedActions = false,
exifEnabled, exifEnabled,
compress, compress,
otherUser = false,
}: { }: {
open: boolean; open: boolean;
setOpen: (open: boolean) => void; setOpen: (open: boolean) => void;
@@ -58,6 +59,7 @@ export default function FileModal({
reducedActions?: boolean; reducedActions?: boolean;
exifEnabled?: boolean; exifEnabled?: boolean;
compress: boolean; compress: boolean;
otherUser: boolean;
}) { }) {
const deleteFile = useFileDelete(); const deleteFile = useFileDelete();
const favoriteFile = useFileFavorite(); const favoriteFile = useFileFavorite();
@@ -95,18 +97,12 @@ export default function FileModal({
const handleCopy = () => { const handleCopy = () => {
clipboard.copy(`${window.location.protocol}//${window.location.host}${file.url}`); clipboard.copy(`${window.location.protocol}//${window.location.host}${file.url}`);
setOpen(false); setOpen(false);
if (!navigator.clipboard)
showNotification({ showNotification({
title: 'Unable to copy to clipboard', title: 'Copied to clipboard',
message: 'Zipline is unable to copy to clipboard due to security reasons.', message: '',
color: 'red', icon: <IconClipboardCopy size='1rem' />,
}); });
else
showNotification({
title: 'Copied to clipboard',
message: '',
icon: <IconClipboardCopy size='1rem' />,
});
}; };
const handleFavorite = async () => { const handleFavorite = async () => {
@@ -129,7 +125,7 @@ export default function FileModal({
icon: <IconPhotoCancel size='1rem' />, icon: <IconPhotoCancel size='1rem' />,
}); });
}, },
} },
); );
}; };
@@ -282,7 +278,7 @@ export default function FileModal({
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
)} )}
{reducedActions ? null : inFolder && !folders.isLoading ? ( {reducedActions || otherUser ? null : inFolder && !folders.isLoading ? (
<Tooltip <Tooltip
label={`Remove from folder "${folders.data.find((f) => f.id === file.folderId)?.name ?? ''}"`} label={`Remove from folder "${folders.data.find((f) => f.id === file.folderId)?.name ?? ''}"`}
> >

View File

@@ -32,9 +32,10 @@ export default function File({
image, image,
disableMediaPreview, disableMediaPreview,
exifEnabled, exifEnabled,
refreshImages, refreshImages = undefined,
reducedActions = false, reducedActions = false,
onDash, onDash,
otherUser = false,
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const deleteFile = useFileDelete(); const deleteFile = useFileDelete();
@@ -44,7 +45,7 @@ export default function File({
const folders = useFolders(); const folders = useFolders();
const refresh = () => { const refresh = () => {
refreshImages(); if (!otherUser) refreshImages();
folders.refetch(); folders.refetch();
}; };
@@ -59,9 +60,22 @@ export default function File({
reducedActions={reducedActions} reducedActions={reducedActions}
exifEnabled={exifEnabled} exifEnabled={exifEnabled}
compress={onDash} compress={onDash}
otherUser={otherUser}
/> />
<Card sx={{ maxWidth: '100%', height: '100%' }} shadow='md' onClick={() => setOpen(true)}> <Card
sx={{
maxWidth: '100%',
height: '100%',
'&:hover': {
filter: 'brightness(0.75)',
},
transition: 'filter 0.2s ease-in-out',
cursor: 'pointer',
}}
shadow='md'
onClick={() => setOpen(true)}
>
<Card.Section> <Card.Section>
<LoadingOverlay visible={loading} /> <LoadingOverlay visible={loading} />
<Type <Type

View File

@@ -4,10 +4,8 @@ import {
Box, Box,
Burger, Burger,
Button, Button,
Group,
Header, Header,
Image, Image,
Input,
MediaQuery, MediaQuery,
Menu, Menu,
Navbar, Navbar,
@@ -220,21 +218,14 @@ export default function Layout({ children, props }) {
labels: { confirm: 'Copy', cancel: 'Cancel' }, labels: { confirm: 'Copy', cancel: 'Cancel' },
onConfirm: async () => { onConfirm: async () => {
clipboard.copy(token); clipboard.copy(token);
if (!navigator.clipboard) if (!navigator.clipboard)
showNotification({ showNotification({
title: 'Unable to copy to clipboard', title: 'Unable to copy token',
message: ( message:
<Text size='sm'> "Zipline couldn't copy to your clipboard. Please copy the token manually from the settings page.",
Zipline is unable to copy to clipboard due to security reasons. However, you can still copy
the token manually.
<br />
<Group position='left' spacing='sm'>
<Text>Your token is:</Text>
<Input size='sm' onFocus={(e) => e.target.select()} type='text' value={token} />
</Group>
</Text>
),
color: 'red', color: 'red',
icon: <IconClipboardCopy size='1rem' />,
}); });
else else
showNotification({ showNotification({
@@ -289,7 +280,7 @@ export default function Layout({ children, props }) {
component={Link} component={Link}
href={link} href={link}
/> />
) ),
)} )}
</Navbar.Section> </Navbar.Section>
<Navbar.Section> <Navbar.Section>
@@ -358,13 +349,22 @@ export default function Layout({ children, props }) {
<Menu.Target> <Menu.Target>
<Button <Button
leftIcon={ leftIcon={
avatar ? <Image src={avatar} height={32} radius='md' /> : <IconUserCog size='1rem' /> avatar ? (
<Image src={avatar} height={32} width={32} fit='cover' radius='md' />
) : (
<IconUserCog size='1rem' />
)
} }
variant='subtle' variant='subtle'
color='gray' color='gray'
compact compact
size='xl' size='xl'
p='sm' p='sm'
styles={{
label: {
overflow: 'unset',
},
}}
> >
{user.username} {user.username}
</Button> </Button>
@@ -416,16 +416,20 @@ export default function Layout({ children, props }) {
</Menu.Item> </Menu.Item>
<Menu.Divider /> <Menu.Divider />
<> <>
{oauth_providers.filter((x) => {oauth_providers.filter(
user.oauth?.map(({ provider }) => provider.toLowerCase()).includes(x.name.toLowerCase()) (x) =>
user.oauth
?.map(({ provider }) => provider.toLowerCase())
.includes(x.name.toLowerCase()),
).length ? ( ).length ? (
<Menu.Label>Connected Accounts</Menu.Label> <Menu.Label>Connected Accounts</Menu.Label>
) : null} ) : null}
{oauth_providers {oauth_providers
.filter((x) => .filter(
user.oauth (x) =>
?.map(({ provider }) => provider.toLowerCase()) user.oauth
.includes(x.name.toLowerCase()) ?.map(({ provider }) => provider.toLowerCase())
.includes(x.name.toLowerCase()),
) )
.map(({ name, Icon }, i) => ( .map(({ name, Icon }, i) => (
<> <>
@@ -438,8 +442,11 @@ export default function Layout({ children, props }) {
</Menu.Item> </Menu.Item>
</> </>
))} ))}
{oauth_providers.filter((x) => {oauth_providers.filter(
user.oauth?.map(({ provider }) => provider.toLowerCase()).includes(x.name.toLowerCase()) (x) =>
user.oauth
?.map(({ provider }) => provider.toLowerCase())
.includes(x.name.toLowerCase()),
).length ? ( ).length ? (
<Menu.Divider /> <Menu.Divider />
) : null} ) : null}

View File

@@ -53,6 +53,35 @@ function Placeholder({ text, Icon, ...props }) {
); );
} }
function VideoThumbnailPlaceholder({ file, mediaPreview, ...props }) {
if (!file.thumbnail || !mediaPreview)
return <Placeholder Icon={IconPlayerPlay} text={`Click to view video (${file.name})`} {...props} />;
return (
<Box sx={{ position: 'relative' }}>
<Image
src={file.thumbnail}
sx={{
width: '100%',
height: 'auto',
}}
/>
<Center
sx={{
position: 'absolute',
height: '100%',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
}}
>
<IconPlayerPlay size={48} />
</Center>
</Box>
);
}
export default function Type({ file, popup = false, disableMediaPreview, ...props }) { export default function Type({ file, popup = false, disableMediaPreview, ...props }) {
const type = const type =
(file.type ?? file.mimetype) === '' (file.type ?? file.mimetype) === ''
@@ -159,7 +188,8 @@ export default function Type({ file, popup = false, disableMediaPreview, ...prop
) )
) : media ? ( ) : media ? (
{ {
video: <Placeholder Icon={IconPlayerPlay} text={`Click to view video (${file.name})`} {...props} />, // video: <Placeholder Icon={IconPlayerPlay} text={`Click to view video (${file.name})`} {...props} />,
video: <VideoThumbnailPlaceholder file={file} mediaPreview={!disableMediaPreview} />,
image: ( image: (
<Image <Image
placeholder={<PlaceholderContent Icon={IconPhotoCancel} text={'Image failed to load...'} />} placeholder={<PlaceholderContent Icon={IconPhotoCancel} text={'Image failed to load...'} />}

View File

@@ -12,7 +12,7 @@ import {
import FileModal from 'components/File/FileModal'; import FileModal from 'components/File/FileModal';
import MutedText from 'components/MutedText'; import MutedText from 'components/MutedText';
import useFetch from 'lib/hooks/useFetch'; import useFetch from 'lib/hooks/useFetch';
import { usePaginatedFiles, useRecent } from 'lib/queries/files'; import { PaginatedFilesOptions, usePaginatedFiles, useRecent } from 'lib/queries/files';
import { useStats } from 'lib/queries/stats'; import { useStats } from 'lib/queries/stats';
import { userSelector } from 'lib/recoil/user'; import { userSelector } from 'lib/recoil/user';
import { bytesToHuman } from 'lib/utils/bytes'; import { bytesToHuman } from 'lib/utils/bytes';
@@ -45,32 +45,24 @@ export default function Dashboard({ disableMediaPreview, exifEnabled, compress }
})(); })();
}, [page]); }, [page]);
const files = usePaginatedFiles(page, 'none');
// sorting // sorting
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({ const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
columnAccessor: 'date', columnAccessor: 'createdAt',
direction: 'asc', direction: 'asc',
}); });
const [records, setRecords] = useState(files.data);
useEffect(() => { const files = usePaginatedFiles(page, {
setRecords(files.data); filter: 'none',
}, [files.data]);
useEffect(() => { // only query for correct results if there is more than one page
if (!records || records.length === 0) return; // otherwise, querying has no effect
...(numFiles > 1
const sortedRecords = [...records].sort((a, b) => { ? {
if (sortStatus.direction === 'asc') { sortBy: sortStatus.columnAccessor as PaginatedFilesOptions['sortBy'],
return a[sortStatus.columnAccessor] > b[sortStatus.columnAccessor] ? 1 : -1; order: sortStatus.direction,
} }
: {}),
return a[sortStatus.columnAccessor] < b[sortStatus.columnAccessor] ? 1 : -1; });
});
setRecords(sortedRecords);
}, [sortStatus]);
// file modal on click // file modal on click
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -106,22 +98,16 @@ export default function Dashboard({ disableMediaPreview, exifEnabled, compress }
const copyFile = async (file) => { const copyFile = async (file) => {
clipboard.copy(`${window.location.protocol}//${window.location.host}${file.url}`); clipboard.copy(`${window.location.protocol}//${window.location.host}${file.url}`);
if (!navigator.clipboard)
showNotification({ showNotification({
title: 'Unable to copy to clipboard', title: 'Copied to clipboard',
message: 'Zipline is unable to copy to clipboard due to security reasons.', message: (
color: 'red', <a
}); href={`${window.location.protocol}//${window.location.host}${file.url}`}
else >{`${window.location.protocol}//${window.location.host}${file.url}`}</a>
showNotification({ ),
title: 'Copied to clipboard', icon: <IconClipboardCopy size='1rem' />,
message: ( });
<a
href={`${window.location.protocol}//${window.location.host}${file.url}`}
>{`${window.location.protocol}//${window.location.host}${file.url}`}</a>
),
icon: <IconClipboardCopy size='1rem' />,
});
}; };
const viewFile = async (file) => { const viewFile = async (file) => {
@@ -140,6 +126,7 @@ export default function Dashboard({ disableMediaPreview, exifEnabled, compress }
reducedActions={false} reducedActions={false}
exifEnabled={exifEnabled} exifEnabled={exifEnabled}
compress={compress} compress={compress}
otherUser={false}
/> />
)} )}
@@ -209,7 +196,7 @@ export default function Dashboard({ disableMediaPreview, exifEnabled, compress }
), ),
}, },
]} ]}
records={records ?? []} records={files.data ?? []}
fetching={files.isLoading} fetching={files.isLoading}
loaderBackgroundBlur={5} loaderBackgroundBlur={5}
loaderVariant='dots' loaderVariant='dots'

View File

@@ -29,7 +29,7 @@ export default function FilePagation({ disableMediaPreview, exifEnabled, queryPa
}, },
}, },
undefined, undefined,
{ shallow: true } { shallow: true },
); );
const { count } = await useFetch(`/api/user/paged?count=true${!checked ? '&filter=media' : ''}`); const { count } = await useFetch(`/api/user/paged?count=true${!checked ? '&filter=media' : ''}`);
@@ -37,9 +37,17 @@ export default function FilePagation({ disableMediaPreview, exifEnabled, queryPa
})(); })();
}, [page]); }, [page]);
const pages = usePaginatedFiles(page, !checked ? 'media' : null); const pages = usePaginatedFiles(page, {
filter: !checked ? 'media' : 'none',
});
if (pages.isSuccess && pages.data.length === 0) { if (pages.isSuccess && pages.data.length === 0) {
if (page > 1 && numPages > 0) {
setPage(page - 1);
return null;
}
return ( return (
<Center sx={{ flexDirection: 'column' }}> <Center sx={{ flexDirection: 'column' }}>
<Group> <Group>

View File

@@ -0,0 +1,118 @@
import { Button, Modal, Title, Tooltip } from '@mantine/core';
import { IconTrash } from '@tabler/icons-react';
import AnchorNext from 'components/AnchorNext';
import MutedText from 'components/MutedText';
import useFetch from 'hooks/useFetch';
import { DataTable } from 'mantine-datatable';
import { useEffect, useState } from 'react';
export type PendingFiles = {
id: number;
createdAt: string;
status: string;
chunks: number;
chunksComplete: number;
userId: number;
data: {
file: {
filename: string;
mimetype: string;
lastchunk: boolean;
identifier: string;
totalBytes: number;
};
code?: number;
message?: string;
};
};
export default function PendingFilesModal({ open, onClose }) {
const [incFiles, setIncFiles] = useState<PendingFiles[]>([]);
const [loading, setLoading] = useState(true);
const [selectedFiles, setSelectedFiles] = useState<PendingFiles[]>([]);
async function updateIncFiles() {
setLoading(true);
const files = await useFetch('/api/user/pending');
setIncFiles(files);
setLoading(false);
}
async function deleteIncFiles() {
await useFetch('/api/user/pending', 'DELETE', {
id: selectedFiles.map((file) => file.id),
});
updateIncFiles();
setSelectedFiles([]);
}
useEffect(() => {
updateIncFiles();
}, []);
useEffect(() => {
const interval = setInterval(() => {
if (open) updateIncFiles();
}, 5000);
return () => clearInterval(interval);
}, [open]);
return (
<Modal title={<Title>Pending Files</Title>} size='auto' opened={open} onClose={onClose}>
<MutedText size='xs'>Refreshing every 5 seconds...</MutedText>
<DataTable
withBorder
borderRadius='md'
highlightOnHover
verticalSpacing='sm'
minHeight={200}
records={incFiles ?? []}
columns={[
{ accessor: 'id', title: 'ID' },
{ accessor: 'createdAt', render: (file) => new Date(file.createdAt).toLocaleString() },
{ accessor: 'status', render: (file) => file.status.toLowerCase() },
{
accessor: 'progress',
title: 'Progress',
render: (file) => `${file.chunksComplete}/${file.chunks} chunks`,
},
{
accessor: 'message',
render: (file) =>
file.data.code === 200 ? (
<AnchorNext href={file.data.message} target='_blank'>
view file
</AnchorNext>
) : (
file.data.message
),
},
]}
fetching={loading}
loaderBackgroundBlur={5}
loaderVariant='dots'
onSelectedRecordsChange={setSelectedFiles}
selectedRecords={selectedFiles}
/>
{selectedFiles.length ? (
<Tooltip label='Clearing pending files will still leave the final file on the server.'>
<Button
variant='filled'
my='md'
color='red'
onClick={deleteIncFiles}
leftIcon={<IconTrash size='1rem' />}
fullWidth
>
Clear {selectedFiles.length} pending file{selectedFiles.length > 1 ? 's' : ''}
</Button>
</Tooltip>
) : null}
</Modal>
);
}

View File

@@ -1,16 +1,26 @@
import { Accordion, ActionIcon, Box, Group, Pagination, SimpleGrid, Title } from '@mantine/core'; import { Accordion, ActionIcon, Box, Group, Pagination, SimpleGrid, Title, Tooltip } from '@mantine/core';
import { IconFileUpload } from '@tabler/icons-react'; import { IconFileUpload, IconPhotoUp } from '@tabler/icons-react';
import File from 'components/File'; import File from 'components/File';
import useFetch from 'hooks/useFetch'; import useFetch from 'hooks/useFetch';
import { usePaginatedFiles } from 'lib/queries/files'; import { usePaginatedFiles } from 'lib/queries/files';
import Link from 'next/link'; import Link from 'next/link';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import FilePagation from './FilePagation'; import FilePagation from './FilePagation';
import PendingFilesModal from './PendingFilesModal';
import { showNonMediaSelector } from 'lib/recoil/settings';
import { useRecoilState } from 'recoil';
export default function Files({ disableMediaPreview, exifEnabled, queryPage, compress }) { export default function Files({ disableMediaPreview, exifEnabled, queryPage, compress }) {
const [checked] = useRecoilState(showNonMediaSelector);
const [favoritePage, setFavoritePage] = useState(1); const [favoritePage, setFavoritePage] = useState(1);
const [favoriteNumPages, setFavoriteNumPages] = useState(0); const [favoriteNumPages, setFavoriteNumPages] = useState(0);
const favoritePages = usePaginatedFiles(favoritePage, 'media', true); const favoritePages = usePaginatedFiles(favoritePage, {
filter: checked ? 'none' : 'media',
favorite: true,
});
const [open, setOpen] = useState(false);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@@ -21,11 +31,19 @@ export default function Files({ disableMediaPreview, exifEnabled, queryPage, com
return ( return (
<> <>
<PendingFilesModal open={open} onClose={() => setOpen(false)} />
<Group mb='md'> <Group mb='md'>
<Title>Files</Title> <Title>Files</Title>
<ActionIcon component={Link} href='/dashboard/upload/file' variant='filled' color='primary'> <ActionIcon component={Link} href='/dashboard/upload/file' variant='filled' color='primary'>
<IconFileUpload size='1rem' /> <IconFileUpload size='1rem' />
</ActionIcon> </ActionIcon>
<Tooltip label='View pending uploads'>
<ActionIcon onClick={() => setOpen(true)} variant='filled' color='primary'>
<IconPhotoUp size='1rem' />
</ActionIcon>
</Tooltip>
</Group> </Group>
{favoritePages.isSuccess && favoritePages.data.length ? ( {favoritePages.isSuccess && favoritePages.data.length ? (
<Accordion <Accordion

View File

@@ -112,7 +112,7 @@ export default function Folders({ disableMediaPreview, exifEnabled, compress })
const makePublic = async (folder) => { const makePublic = async (folder) => {
const res = await useFetch(`/api/user/folders/${folder.id}`, 'PATCH', { const res = await useFetch(`/api/user/folders/${folder.id}`, 'PATCH', {
public: folder.public ? false : true, public: !folder.public,
}); });
if (!res.error) { if (!res.error) {
@@ -363,25 +363,18 @@ export default function Folders({ disableMediaPreview, exifEnabled, compress })
aria-label='copy link' aria-label='copy link'
onClick={() => { onClick={() => {
clipboard.copy(`${window.location.origin}/folder/${folder.id}`); clipboard.copy(`${window.location.origin}/folder/${folder.id}`);
if (!navigator.clipboard)
showNotification({ showNotification({
title: 'Unable to copy to clipboard', title: 'Copied folder link',
message: 'Zipline is unable to copy to clipboard due to security reasons.', message: (
color: 'red', <>
}); Copied <AnchorNext href={`/folder/${folder.id}`}>folder link</AnchorNext>{' '}
else to clipboard
showNotification({ </>
title: 'Copied folder link', ),
message: ( color: 'green',
<> icon: <IconClipboardCopy size='1rem' />,
Copied{' '} });
<AnchorNext href={`/folder/${folder.id}`}>folder link</AnchorNext> to
clipboard
</>
),
color: 'green',
icon: <IconClipboardCopy size='1rem' />,
});
}} }}
> >
<IconClipboardCopy size='1rem' /> <IconClipboardCopy size='1rem' />

View File

@@ -30,18 +30,18 @@ import {
import MutedText from 'components/MutedText'; import MutedText from 'components/MutedText';
import useFetch from 'hooks/useFetch'; import useFetch from 'hooks/useFetch';
import { listViewInvitesSelector } from 'lib/recoil/settings'; import { listViewInvitesSelector } from 'lib/recoil/settings';
import { expireText, relativeTime } from 'lib/utils/client'; import { expireReadToDate, expireText, relativeTime } from 'lib/utils/client';
import { DataTable, DataTableSortStatus } from 'mantine-datatable'; import { DataTable, DataTableSortStatus } from 'mantine-datatable';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useRecoilState } from 'recoil'; import { useRecoilState } from 'recoil';
const expires = ['30m', '1h', '6h', '12h', '1d', '3d', '5d', '7d', 'never']; const expires = ['30min', '1h', '6h', '12h', '1d', '3d', '5d', '7d', 'never'];
function CreateInviteModal({ open, setOpen, updateInvites }) { function CreateInviteModal({ open, setOpen, updateInvites }) {
const form = useForm({ const form = useForm({
initialValues: { initialValues: {
expires: '30m', expires: '30min',
count: 1, count: 1,
}, },
}); });
@@ -50,26 +50,12 @@ function CreateInviteModal({ open, setOpen, updateInvites }) {
if (!expires.includes(values.expires)) return form.setFieldError('expires', 'Invalid expiration'); if (!expires.includes(values.expires)) return form.setFieldError('expires', 'Invalid expiration');
if (values.count < 1 || values.count > 100) if (values.count < 1 || values.count > 100)
return form.setFieldError('count', 'Must be between 1 and 100'); return form.setFieldError('count', 'Must be between 1 and 100');
const expiresAt = const expiresAt = expireReadToDate(values.expires);
values.expires === 'never'
? null
: new Date(
{
'30m': Date.now() + 30 * 60 * 1000,
'1h': Date.now() + 60 * 60 * 1000,
'6h': Date.now() + 6 * 60 * 60 * 1000,
'12h': Date.now() + 12 * 60 * 60 * 1000,
'1d': Date.now() + 24 * 60 * 60 * 1000,
'3d': Date.now() + 3 * 24 * 60 * 60 * 1000,
'5d': Date.now() + 5 * 24 * 60 * 60 * 1000,
'7d': Date.now() + 7 * 24 * 60 * 60 * 1000,
}[values.expires]
);
setOpen(false); setOpen(false);
const res = await useFetch('/api/auth/invite', 'POST', { const res = await useFetch('/api/auth/invite', 'POST', {
expiresAt, expiresAt: `date=${expiresAt.toISOString()}`,
count: values.count, count: values.count,
}); });
@@ -99,8 +85,9 @@ function CreateInviteModal({ open, setOpen, updateInvites }) {
label='Expires' label='Expires'
id='expires' id='expires'
{...form.getInputProps('expires')} {...form.getInputProps('expires')}
maxDropdownHeight={100}
data={[ data={[
{ value: '30m', label: '30 minutes' }, { value: '30min', label: '30 minutes' },
{ value: '1h', label: '1 hour' }, { value: '1h', label: '1 hour' },
{ value: '6h', label: '6 hours' }, { value: '6h', label: '6 hours' },
{ value: '12h', label: '12 hours' }, { value: '12h', label: '12 hours' },
@@ -108,7 +95,6 @@ function CreateInviteModal({ open, setOpen, updateInvites }) {
{ value: '3d', label: '3 days' }, { value: '3d', label: '3 days' },
{ value: '5d', label: '5 days' }, { value: '5d', label: '5 days' },
{ value: '7d', label: '7 days' }, { value: '7d', label: '7 days' },
{ value: 'never', label: 'Never' },
]} ]}
/> />
@@ -197,18 +183,12 @@ export default function Invites() {
const handleCopy = async (invite) => { const handleCopy = async (invite) => {
clipboard.copy(`${window.location.protocol}//${window.location.host}/auth/register?code=${invite.code}`); clipboard.copy(`${window.location.protocol}//${window.location.host}/auth/register?code=${invite.code}`);
if (!navigator.clipboard)
showNotification({ showNotification({
title: 'Unable to copy to clipboard', title: 'Copied to clipboard',
message: 'Zipline is unable to copy to clipboard due to security reasons.', message: '',
color: 'red', icon: <IconClipboardCopy size='1rem' />,
}); });
else
showNotification({
title: 'Copied to clipboard',
message: '',
icon: <IconClipboardCopy size='1rem' />,
});
}; };
const updateInvites = async () => { const updateInvites = async () => {
@@ -318,45 +298,65 @@ export default function Invites() {
/> />
) : ( ) : (
<SimpleGrid cols={3} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}> <SimpleGrid cols={3} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
{invites.length {!ok && !invites.length && (
? invites.map((invite) => ( <>
<Card key={invite.id} sx={{ maxWidth: '100%' }}> {[1, 2, 3].map((x) => (
<Group position='apart'> <Skeleton key={x} width='100%' height={100} radius='sm' />
<Group position='left'> ))}
<Avatar size='lg' color={invite.used ? 'dark' : 'primary'}> </>
{invite.id} )}
</Avatar>
<Stack spacing={0}> {invites.length && ok ? (
<Title> invites.map((invite) => (
{invite.code} <Card key={invite.id} sx={{ maxWidth: '100%' }}>
{invite.used && <> (Used)</>} <Group position='apart'>
</Title> <Group position='left'>
<Tooltip label={new Date(invite.createdAt).toLocaleString()}> <Avatar size='lg' color={invite.used ? 'dark' : 'primary'}>
<div> {invite.id}
<MutedText size='sm'> </Avatar>
Created {relativeTime(new Date(invite.createdAt))} <Stack spacing={0}>
</MutedText> <Title>
</div> {invite.code}
</Tooltip> {invite.used && <> (Used)</>}
<Tooltip label={new Date(invite.expiresAt).toLocaleString()}> </Title>
<div> <Tooltip label={new Date(invite.createdAt).toLocaleString()}>
<MutedText size='sm'>{expireText(invite.expiresAt.toString())}</MutedText> <div>
</div> <MutedText size='sm'>Created {relativeTime(new Date(invite.createdAt))}</MutedText>
</Tooltip> </div>
</Stack> </Tooltip>
</Group> <Tooltip label={new Date(invite.expiresAt).toLocaleString()}>
<Stack> <div>
<ActionIcon aria-label='copy' onClick={() => handleCopy(invite)}> <MutedText size='sm'>{expireText(invite.expiresAt.toString())}</MutedText>
<IconClipboardCopy size='1rem' /> </div>
</ActionIcon> </Tooltip>
<ActionIcon aria-label='delete' onClick={() => openDeleteModal(invite)}>
<IconTrash size='1rem' />
</ActionIcon>
</Stack> </Stack>
</Group> </Group>
</Card> <Stack>
)) <ActionIcon aria-label='copy' onClick={() => handleCopy(invite)}>
: [1, 2, 3].map((x) => <Skeleton key={x} width='100%' height={100} radius='sm' />)} <IconClipboardCopy size='1rem' />
</ActionIcon>
<ActionIcon aria-label='delete' onClick={() => openDeleteModal(invite)}>
<IconTrash size='1rem' />
</ActionIcon>
</Stack>
</Group>
</Card>
))
) : (
<>
<div></div>
<Group>
<div>
<IconTag size={48} />
</div>
<div>
<Title>Nothing here</Title>
<MutedText size='md'>Create some invites and they will show up here</MutedText>
</div>
</Group>
<div></div>
</>
)}
</SimpleGrid> </SimpleGrid>
)} )}
</> </>

View File

@@ -3,8 +3,10 @@ import { closeAllModals, openConfirmModal } from '@mantine/modals';
import { showNotification, updateNotification } from '@mantine/notifications'; import { showNotification, updateNotification } from '@mantine/notifications';
import { IconFiles, IconFilesOff } from '@tabler/icons-react'; import { IconFiles, IconFilesOff } from '@tabler/icons-react';
import useFetch from 'hooks/useFetch'; import useFetch from 'hooks/useFetch';
import { useState } from 'react';
export default function ClearStorage({ open, setOpen, check, setCheck }) { export default function ClearStorage({ open, setOpen }) {
const [check, setCheck] = useState(false);
const handleDelete = async (datasource: boolean, orphaned?: boolean) => { const handleDelete = async (datasource: boolean, orphaned?: boolean) => {
showNotification({ showNotification({
id: 'clear-uploads', id: 'clear-uploads',
@@ -38,7 +40,10 @@ export default function ClearStorage({ open, setOpen, check, setCheck }) {
return ( return (
<Modal <Modal
opened={open} opened={open}
onClose={() => setOpen(false)} onClose={() => {
setOpen(false);
setCheck(() => false);
}}
title={<Title size='sm'>Are you sure you want to clear all uploads in the database?</Title>} title={<Title size='sm'>Are you sure you want to clear all uploads in the database?</Title>}
> >
<Checkbox <Checkbox

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import { Icon2fa, IconBarcodeOff, IconCheck } from '@tabler/icons-react';
import useFetch from 'hooks/useFetch'; import useFetch from 'hooks/useFetch';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
export function TotpModal({ opened, onClose, deleteTotp, setTotpEnabled }) { export function TotpModal({ opened, onClose, deleteTotp, setUser }) {
const [secret, setSecret] = useState(''); const [secret, setSecret] = useState('');
const [qrCode, setQrCode] = useState(''); const [qrCode, setQrCode] = useState('');
const [disabled, setDisabled] = useState(false); const [disabled, setDisabled] = useState(false);
@@ -52,8 +52,7 @@ export function TotpModal({ opened, onClose, deleteTotp, setTotpEnabled }) {
icon: <Icon2fa size='1rem' />, icon: <Icon2fa size='1rem' />,
}); });
setTotpEnabled(false); setUser((user) => ({ ...user, totpSecret: null }));
onClose(); onClose();
} }
@@ -83,8 +82,7 @@ export function TotpModal({ opened, onClose, deleteTotp, setTotpEnabled }) {
icon: <Icon2fa size='1rem' />, icon: <Icon2fa size='1rem' />,
}); });
setTotpEnabled(true); setUser((user) => ({ ...user, totpSecret: secret }));
onClose(); onClose();
} }

View File

@@ -1,9 +1,11 @@
import { import {
ActionIcon,
Anchor, Anchor,
Box, Box,
Button, Button,
Card, Card,
ColorInput, ColorInput,
CopyButton,
FileInput, FileInput,
Group, Group,
Image, Image,
@@ -23,6 +25,8 @@ import {
IconBrandDiscordFilled, IconBrandDiscordFilled,
IconBrandGithubFilled, IconBrandGithubFilled,
IconBrandGoogle, IconBrandGoogle,
IconCheck,
IconClipboardCopy,
IconFileExport, IconFileExport,
IconFiles, IconFiles,
IconFilesOff, IconFilesOff,
@@ -89,7 +93,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [fileDataURL, setFileDataURL] = useState(user.avatar ?? null); const [fileDataURL, setFileDataURL] = useState(user.avatar ?? null);
const [totpEnabled, setTotpEnabled] = useState(!!user.totpSecret); const [totpEnabled, setTotpEnabled] = useState(!!user.totpSecret);
const [checked, setCheck] = useState(false); const [tokenShown, setTokenShown] = useState(false);
const getDataURL = (f: File): Promise<string> => { const getDataURL = (f: File): Promise<string> => {
return new Promise((res, rej) => { return new Promise((res, rej) => {
@@ -264,7 +268,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
size: s.size, size: s.size,
full: s.name, full: s.name,
})) }))
.sort((a, b) => a.date.getTime() - b.date.getTime()) .sort((a, b) => a.date.getTime() - b.date.getTime()),
); );
}; };
@@ -355,16 +359,35 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
useEffect(() => { useEffect(() => {
getExports(); getExports();
interval.start(); interval.start();
}, [totpEnabled]); setTotpEnabled(() => !!user.totpSecret);
}, [user]);
return ( return (
<> <>
<Title>Manage User</Title> <Title>Manage User</Title>
<MutedText size='md'> <MutedText size='md'>
Want to use variables in embed text? Visit{' '} Want to use variables in embed text? Visit{' '}
<AnchorNext href='https://zipline.diced.tech/docs/guides/variables'>the docs</AnchorNext> for <AnchorNext href='https://zipline.diced.sh/docs/guides/variables'>the docs</AnchorNext> for variables
variables
</MutedText> </MutedText>
<TextInput
rightSection={
<CopyButton value={user.token} timeout={1000}>
{({ copied, copy }) => (
<ActionIcon onClick={copy}>
{copied ? <IconCheck color='green' size='1rem' /> : <IconClipboardCopy size='1rem' />}
</ActionIcon>
)}
</CopyButton>
}
// @ts-ignore (this works even though ts doesn't allow for it)
component='span'
label='Token'
onClick={() => setTokenShown(true)}
>
{tokenShown ? user.token : '[click to reveal]'}
</TextInput>
<form onSubmit={form.onSubmit((v) => onSubmit(v))}> <form onSubmit={form.onSubmit((v) => onSubmit(v))}>
<TextInput id='username' label='Username' my='sm' {...form.getInputProps('username')} /> <TextInput id='username' label='Username' my='sm' {...form.getInputProps('username')} />
<PasswordInput <PasswordInput
@@ -450,7 +473,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
opened={totpOpen} opened={totpOpen}
onClose={() => setTotpOpen(false)} onClose={() => setTotpOpen(false)}
deleteTotp={totpEnabled} deleteTotp={totpEnabled}
setTotpEnabled={setTotpEnabled} setUser={setUser}
/> />
</Box> </Box>
)} )}
@@ -464,7 +487,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
{oauth_providers {oauth_providers
.filter( .filter(
(x) => (x) =>
!user.oauth?.map(({ provider }) => provider.toLowerCase()).includes(x.name.toLowerCase()) !user.oauth?.map(({ provider }) => provider.toLowerCase()).includes(x.name.toLowerCase()),
) )
.map(({ link_url, name, Icon }, i) => ( .map(({ link_url, name, Icon }, i) => (
<Button key={i} size='lg' leftIcon={<Icon />} component={Link} href={link_url} my='sm'> <Button key={i} size='lg' leftIcon={<Icon />} component={Link} href={link_url} my='sm'>
@@ -626,7 +649,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
<ShareX user={user} open={shareXOpen} setOpen={setShareXOpen} /> <ShareX user={user} open={shareXOpen} setOpen={setShareXOpen} />
<Flameshot user={user} open={flameshotOpen} setOpen={setFlameshotOpen} /> <Flameshot user={user} open={flameshotOpen} setOpen={setFlameshotOpen} />
<ClearStorage open={clrStorOpen} setOpen={setClrStorOpen} check={checked} setCheck={setCheck} /> <ClearStorage open={clrStorOpen} setOpen={setClrStorOpen} />
</> </>
); );
} }

View File

@@ -27,18 +27,12 @@ export default function MetadataView({ fileId }) {
const copy = (value) => { const copy = (value) => {
clipboard.copy(value); clipboard.copy(value);
if (!navigator.clipboard)
showNotification({ showNotification({
title: 'Unable to copy to clipboard', title: 'Copied to clipboard',
message: 'Zipline is unable to copy to clipboard due to security reasons.', message: value,
color: 'red', icon: <IconClipboardCopy size='1rem' />,
}); });
else
showNotification({
title: 'Copied to clipboard',
message: value,
icon: <IconClipboardCopy size='1rem' />,
});
}; };
const searchValue = (value) => { const searchValue = (value) => {

View File

@@ -1,20 +1,30 @@
import { Button, Collapse, Group, Progress, Stack, Title } from '@mantine/core'; import { Anchor, Button, Collapse, Group, Progress, Stack, Text, Title } from '@mantine/core';
import { randomId, useClipboard } from '@mantine/hooks'; import { randomId, useClipboard } from '@mantine/hooks';
import { useModals } from '@mantine/modals'; import { useModals } from '@mantine/modals';
import { showNotification, updateNotification } from '@mantine/notifications'; import { hideNotification, showNotification, updateNotification } from '@mantine/notifications';
import { IconFileImport, IconFileTime, IconFileUpload, IconFileX } from '@tabler/icons-react'; import {
IconClipboardCopy,
IconFileImport,
IconFileTime,
IconFileUpload,
IconFileX,
} from '@tabler/icons-react';
import Dropzone from 'components/dropzone/Dropzone'; import Dropzone from 'components/dropzone/Dropzone';
import FileDropzone from 'components/dropzone/DropzoneFile'; import FileDropzone from 'components/dropzone/DropzoneFile';
import MutedText from 'components/MutedText'; import MutedText from 'components/MutedText';
import { invalidateFiles } from 'lib/queries/files'; import { invalidateFiles } from 'lib/queries/files';
import { userSelector } from 'lib/recoil/user'; import { userSelector } from 'lib/recoil/user';
import { expireReadToDate, randomChars } from 'lib/utils/client'; import { expireReadToDate, randomChars } from 'lib/utils/client';
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import showFilesModal from './showFilesModal'; import showFilesModal from './showFilesModal';
import useUploadOptions from './useUploadOptions'; import useUploadOptions from './useUploadOptions';
import { useRouter } from 'next/router';
import AnchorNext from 'components/AnchorNext';
export default function File({ chunks: chunks_config }) { export default function File({ chunks: chunks_config }) {
const router = useRouter();
const clipboard = useClipboard(); const clipboard = useClipboard();
const modals = useModals(); const modals = useModals();
const user = useRecoilValue(userSelector); const user = useRecoilValue(userSelector);
@@ -25,6 +35,30 @@ export default function File({ chunks: chunks_config }) {
const [options, setOpened, OptionsModal] = useUploadOptions(); const [options, setOpened, OptionsModal] = useUploadOptions();
const beforeUnload = useCallback(
(e: BeforeUnloadEvent) => {
if (loading) {
e.preventDefault();
e.returnValue = "Are you sure you want to leave? Your upload(s) won't be saved.";
return e.returnValue;
}
},
[loading],
);
const beforeRouteChange = useCallback(
(url: string) => {
if (loading) {
const confirmed = confirm("Are you sure you want to leave? Your upload(s) won't be saved.");
if (!confirmed) {
router.events.emit('routeChangeComplete', url);
throw 'Route change aborted';
}
}
},
[loading],
);
useEffect(() => { useEffect(() => {
const listener = (e: ClipboardEvent) => { const listener = (e: ClipboardEvent) => {
const item = Array.from(e.clipboardData.items).find((x) => /^image/.test(x.type)); const item = Array.from(e.clipboardData.items).find((x) => /^image/.test(x.type));
@@ -41,10 +75,24 @@ export default function File({ chunks: chunks_config }) {
}; };
document.addEventListener('paste', listener); document.addEventListener('paste', listener);
return () => document.removeEventListener('paste', listener); window.addEventListener('beforeunload', beforeUnload, true);
}, []); router.events.on('routeChangeStart', beforeRouteChange);
return () => {
window.removeEventListener('beforeunload', beforeUnload, true);
router.events.off('routeChangeStart', beforeRouteChange);
document.removeEventListener('paste', listener);
};
}, [loading, beforeUnload, beforeRouteChange]);
const handleChunkedFiles = async (expiresAt: Date, toChunkFiles: File[]) => { const handleChunkedFiles = async (expiresAt: Date, toChunkFiles: File[]) => {
if (!chunks_config.enabled)
return showNotification({
id: 'upload-chunked',
title: 'Chunked files are disabled',
message: 'This should not be called, but some how got called...',
color: 'red',
});
for (let i = 0; i !== toChunkFiles.length; ++i) { for (let i = 0; i !== toChunkFiles.length; ++i) {
const file = toChunkFiles[i]; const file = toChunkFiles[i];
const identifier = randomChars(4); const identifier = randomChars(4);
@@ -71,18 +119,6 @@ export default function File({ chunks: chunks_config }) {
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
} }
// if last chunk send notif that it will take a while
if (j === chunks.length - 1) {
updateNotification({
id: 'upload-chunked',
title: 'Finalizing partial upload',
message: 'This may take a while...',
icon: <IconFileTime size='1rem' />,
color: 'yellow',
autoClose: false,
});
}
const body = new FormData(); const body = new FormData();
body.append('file', chunks[j].blob); body.append('file', chunks[j].blob);
@@ -109,25 +145,37 @@ export default function File({ chunks: chunks_config }) {
if (j === chunks.length - 1) { if (j === chunks.length - 1) {
updateNotification({ updateNotification({
id: 'upload-chunked', id: 'upload-chunked',
title: 'Upload Successful', title: 'Finalizing partial upload',
message: '', message: (
<Text>
The upload has been offloaded, and will complete in the background.
<br />
<Anchor
component='span'
onClick={() => {
hideNotification('upload-chunked');
clipboard.copy(json.files[0]);
showNotification({
title: 'Copied to clipboard',
message: <AnchorNext href={json.files[0]}>{json.files[0]}</AnchorNext>,
icon: <IconClipboardCopy size='1rem' />,
});
}}
>
Click here to copy the URL while it&lsquo;s being processed.
</Anchor>
</Text>
),
icon: <IconFileTime size='1rem' />,
color: 'green', color: 'green',
icon: <IconFileUpload size='1rem' />, autoClose: false,
}); });
showFilesModal(clipboard, modals, json.files);
invalidateFiles(); invalidateFiles();
setFiles([]); setFiles([]);
setProgress(100); setProgress(100);
setLoading(false);
setTimeout(() => setProgress(0), 1000); setTimeout(() => setProgress(0), 1000);
clipboard.copy(json.files[0]);
if (!navigator.clipboard)
showNotification({
title: 'Unable to copy to clipboard',
message: 'Zipline is unable to copy to clipboard due to security reasons.',
color: 'red',
});
} }
ready = true; ready = true;
@@ -143,7 +191,7 @@ export default function File({ chunks: chunks_config }) {
ready = false; ready = false;
} }
}, },
false false,
); );
req.open('POST', '/api/upload'); req.open('POST', '/api/upload');
@@ -182,10 +230,10 @@ export default function File({ chunks: chunks_config }) {
for (let i = 0; i !== files.length; ++i) { for (let i = 0; i !== files.length; ++i) {
const file = files[i]; const file = files[i];
if (file.size >= chunks_config.max_size) { if (chunks_config.enabled && file.size >= chunks_config.max_size) {
toChunkFiles.push(file); toChunkFiles.push(file);
} else { } else {
body.append('file', files[i]); body.append('file', files[i], encodeURIComponent(files[i].name));
} }
} }
@@ -259,7 +307,7 @@ export default function File({ chunks: chunks_config }) {
} }
setProgress(0); setProgress(0);
}, },
false false,
); );
if (bodyLength !== 0) { if (bodyLength !== 0) {

View File

@@ -7,18 +7,12 @@ export default function showFilesModal(clipboard, modals, files: string[]) {
const open = (idx: number) => window.open(files[idx], '_blank'); const open = (idx: number) => window.open(files[idx], '_blank');
const copy = (idx: number) => { const copy = (idx: number) => {
clipboard.copy(files[idx]); clipboard.copy(files[idx]);
if (!navigator.clipboard)
showNotification({ showNotification({
title: 'Unable to copy to clipboard', title: 'Copied to clipboard',
message: 'Zipline is unable to copy to clipboard due to security reasons.', message: <AnchorNext href={files[idx]}>{files[idx]}</AnchorNext>,
color: 'red', icon: <IconClipboardCopy size='1rem' />,
}); });
else
showNotification({
title: 'Copied to clipboard',
message: <AnchorNext href={files[idx]}>{files[idx]}</AnchorNext>,
icon: <IconClipboardCopy size='1rem' />,
});
}; };
modals.openModal({ modals.openModal({

View File

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

View File

@@ -169,18 +169,12 @@ export default function Urls() {
const copyURL = (u) => { const copyURL = (u) => {
clipboard.copy(`${window.location.protocol}//${window.location.host}${u.url}`); clipboard.copy(`${window.location.protocol}//${window.location.host}${u.url}`);
if (!navigator.clipboard)
showNotification({ showNotification({
title: 'Unable to copy to clipboard', title: 'Copied to clipboard',
message: 'Zipline is unable to copy to clipboard due to security reasons.', message: '',
color: 'red', icon: <IconClipboardCopy size='1rem' />,
}); });
else
showNotification({
title: 'Copied to clipboard',
message: '',
icon: <IconClipboardCopy size='1rem' />,
});
}; };
const urlDelete = useURLDelete(); const urlDelete = useURLDelete();

View File

@@ -0,0 +1,82 @@
import { ActionIcon, Button, Center, Group, SimpleGrid, Title } from '@mantine/core';
import { File } from '@prisma/client';
import { IconArrowLeft, IconFile } from '@tabler/icons-react';
import FileComponent from 'components/File';
import MutedText from 'components/MutedText';
import useFetch from 'hooks/useFetch';
import { userSelector } from 'lib/recoil/user';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { useRecoilState } from 'recoil';
type UserFiles = {
id: number;
username: string;
files?: File[];
error?: unknown;
};
export default function UserFiles({ userId, disableMediaPreview, exifEnabled, compress }) {
const [currentUser, viewUser] = useState<UserFiles>({ id: 0, username: 'user' });
const [self] = useRecoilState(userSelector);
const { push } = useRouter();
useEffect(() => {
if (self.id == userId) push('/dashboard/files');
(async () => {
const user: UserFiles = await useFetch(`/api/user/${userId}`);
if (!user.error) {
viewUser(user);
} else {
push('/dashboard');
}
})();
}, [userId]);
if (!currentUser.files || currentUser.files.length === 0) {
return (
<Center sx={{ flexDirection: 'column' }}>
<Group>
<div>
<IconFile size={48} />
</div>
<div>
<Title>Nothing here</Title>
<MutedText size='md'>
{currentUser.username} seems to have not uploaded any files... yet
</MutedText>
</div>
<Button size='md' onClick={() => push('/dashboard/users')}>
Head back?
</Button>
</Group>
</Center>
);
}
return (
<>
<Group mb='md'>
<ActionIcon size='lg' onClick={() => push('/dashboard/users')} color='primary'>
<IconArrowLeft />
</ActionIcon>
<Title>{currentUser.username}&apos;s Files</Title>
</Group>
<SimpleGrid cols={3} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
{currentUser.files.map((file) => (
<div key={file.id}>
<FileComponent
image={file}
disableMediaPreview={disableMediaPreview}
exifEnabled={exifEnabled}
onDash={compress}
otherUser={true}
/>
</div>
))}
</SimpleGrid>
</>
);
}

View File

@@ -6,6 +6,7 @@ import type { User } from '@prisma/client';
import { import {
IconClipboardCopy, IconClipboardCopy,
IconEdit, IconEdit,
IconExternalLink,
IconGridDots, IconGridDots,
IconList, IconList,
IconUserExclamation, IconUserExclamation,
@@ -116,6 +117,10 @@ export default function Users() {
} }
}; };
const openUser = async (user) => {
await router.push(`/dashboard/users/${user.id}`);
};
useEffect(() => { useEffect(() => {
updateUsers(); updateUsers();
}, []); }, []);
@@ -181,6 +186,13 @@ export default function Users() {
<IconEdit size='1rem' /> <IconEdit size='1rem' />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
{(!self.superAdmin && user.superAdmin) || (self.superAdmin && user.superAdmin) ? null : (
<Tooltip label='Open user'>
<ActionIcon color='cyan' onClick={() => openUser(user)}>
<IconExternalLink size='1rem' />
</ActionIcon>
</Tooltip>
)}
</Group> </Group>
), ),
}, },

View File

@@ -1,5 +1,6 @@
export interface ConfigCore { export interface ConfigCore {
return_https: boolean; return_https: boolean;
temp_directory: string;
secret: string; secret: string;
host: string; host: string;
port: number; port: number;
@@ -9,6 +10,7 @@ export interface ConfigCore {
stats_interval: number; stats_interval: number;
invites_interval: number; invites_interval: number;
thumbnails_interval: number;
} }
export interface ConfigCompression { export interface ConfigCompression {
@@ -55,6 +57,7 @@ export interface ConfigUploader {
format_date: string; format_date: string;
default_expiration: string; default_expiration: string;
assume_mimetypes: boolean; assume_mimetypes: boolean;
random_words_separator: string;
} }
export interface ConfigUrls { export interface ConfigUrls {
@@ -119,22 +122,32 @@ export interface ConfigFeatures {
headless: boolean; headless: boolean;
default_avatar: string; default_avatar: string;
robots_txt: string;
thumbnails: boolean;
} }
export interface ConfigOAuth { export interface ConfigOAuth {
bypass_local_login: boolean;
github_client_id?: string; github_client_id?: string;
github_client_secret?: string; github_client_secret?: string;
discord_client_id?: string; discord_client_id?: string;
discord_client_secret?: string; discord_client_secret?: string;
discord_redirect_uri?: string;
discord_whitelisted_users?: string[];
google_client_id?: string; google_client_id?: string;
google_client_secret?: string; google_client_secret?: string;
google_redirect_uri?: string;
} }
export interface ConfigChunks { export interface ConfigChunks {
max_size: number; max_size: number;
chunks_size: number; chunks_size: number;
enabled: boolean;
} }
export interface ConfigMfa { export interface ConfigMfa {

View File

@@ -57,13 +57,17 @@ export default function readConfig() {
const maps = [ const maps = [
map('CORE_RETURN_HTTPS', 'boolean', 'core.return_https'), map('CORE_RETURN_HTTPS', 'boolean', 'core.return_https'),
map('CORE_TEMP_DIRECTORY', 'path', 'core.temp_directory'),
map('CORE_SECRET', 'string', 'core.secret'), map('CORE_SECRET', 'string', 'core.secret'),
map('CORE_HOST', 'string', 'core.host'), map('CORE_HOST', 'string', 'core.host'),
map('CORE_PORT', 'number', 'core.port'), map('CORE_PORT', 'number', 'core.port'),
map('CORE_DATABASE_URL', 'string', 'core.database_url'), map('CORE_DATABASE_URL', 'string', 'core.database_url'),
map('CORE_LOGGER', 'boolean', 'core.logger'), map('CORE_LOGGER', 'boolean', 'core.logger'),
map('CORE_STATS_INTERVAL', 'number', 'core.stats_interval'), map('CORE_STATS_INTERVAL', 'number', 'core.stats_interval'),
map('CORE_INVITES_INTERVAL', 'number', 'core.invites_interval'), map('CORE_INVITES_INTERVAL', 'number', 'core.invites_interval'),
map('CORE_THUMBNAILS_INTERVAL', 'number', 'core.thumbnails_interval'),
map('CORE_COMPRESSION_ENABLED', 'boolean', 'core.compression.enabled'), map('CORE_COMPRESSION_ENABLED', 'boolean', 'core.compression.enabled'),
map('CORE_COMPRESSION_THRESHOLD', 'human-to-byte', 'core.compression.threshold'), map('CORE_COMPRESSION_THRESHOLD', 'human-to-byte', 'core.compression.threshold'),
map('CORE_COMPRESSION_ON_DASHBOARD', 'boolean', 'core.compression.on_dashboard'), map('CORE_COMPRESSION_ON_DASHBOARD', 'boolean', 'core.compression.on_dashboard'),
@@ -94,6 +98,7 @@ export default function readConfig() {
map('UPLOADER_FORMAT_DATE', 'string', 'uploader.format_date'), map('UPLOADER_FORMAT_DATE', 'string', 'uploader.format_date'),
map('UPLOADER_DEFAULT_EXPIRATION', 'string', 'uploader.default_expiration'), map('UPLOADER_DEFAULT_EXPIRATION', 'string', 'uploader.default_expiration'),
map('UPLOADER_ASSUME_MIMETYPES', 'boolean', 'uploader.assume_mimetypes'), map('UPLOADER_ASSUME_MIMETYPES', 'boolean', 'uploader.assume_mimetypes'),
map('UPLOADER_RANDOM_WORDS_SEPARATOR', 'string', 'uploader.random_words_separator'),
map('URLS_ROUTE', 'string', 'urls.route'), map('URLS_ROUTE', 'string', 'urls.route'),
map('URLS_LENGTH', 'number', 'urls.length'), map('URLS_LENGTH', 'number', 'urls.length'),
@@ -135,14 +140,19 @@ export default function readConfig() {
map('DISCORD_SHORTEN_EMBED_THUMBNAIL', 'boolean', 'discord.shorten.embed.thumbnail'), map('DISCORD_SHORTEN_EMBED_THUMBNAIL', 'boolean', 'discord.shorten.embed.thumbnail'),
map('DISCORD_SHORTEN_EMBED_TIMESTAMP', 'boolean', 'discord.shorten.embed.timestamp'), map('DISCORD_SHORTEN_EMBED_TIMESTAMP', 'boolean', 'discord.shorten.embed.timestamp'),
map('OAUTH_BYPASS_LOCAL_LOGIN', 'boolean', 'oauth.bypass_local_login'),
map('OAUTH_GITHUB_CLIENT_ID', 'string', 'oauth.github_client_id'), map('OAUTH_GITHUB_CLIENT_ID', 'string', 'oauth.github_client_id'),
map('OAUTH_GITHUB_CLIENT_SECRET', 'string', 'oauth.github_client_secret'), map('OAUTH_GITHUB_CLIENT_SECRET', 'string', 'oauth.github_client_secret'),
map('OAUTH_DISCORD_CLIENT_ID', 'string', 'oauth.discord_client_id'), map('OAUTH_DISCORD_CLIENT_ID', 'string', 'oauth.discord_client_id'),
map('OAUTH_DISCORD_CLIENT_SECRET', 'string', 'oauth.discord_client_secret'), map('OAUTH_DISCORD_CLIENT_SECRET', 'string', 'oauth.discord_client_secret'),
map('OAUTH_DISCORD_REDIRECT_URI', 'string', 'oauth.discord_redirect_uri'),
map('OAUTH_DISCORD_WHITELISTED_USERS', 'array', 'oauth.discord_whitelisted_users'),
map('OAUTH_GOOGLE_CLIENT_ID', 'string', 'oauth.google_client_id'), map('OAUTH_GOOGLE_CLIENT_ID', 'string', 'oauth.google_client_id'),
map('OAUTH_GOOGLE_CLIENT_SECRET', 'string', 'oauth.google_client_secret'), map('OAUTH_GOOGLE_CLIENT_SECRET', 'string', 'oauth.google_client_secret'),
map('OAUTH_GOOGLE_REDIRECT_URI', 'string', 'oauth.google_redirect_uri'),
map('FEATURES_INVITES', 'boolean', 'features.invites'), map('FEATURES_INVITES', 'boolean', 'features.invites'),
map('FEATURES_INVITES_LENGTH', 'number', 'features.invites_length'), map('FEATURES_INVITES_LENGTH', 'number', 'features.invites_length'),
@@ -155,8 +165,13 @@ export default function readConfig() {
map('FEATURES_DEFAULT_AVATAR', 'path', 'features.default_avatar'), map('FEATURES_DEFAULT_AVATAR', 'path', 'features.default_avatar'),
map('FEATURES_ROBOTS_TXT', 'boolean', 'features.robots_txt'),
map('FEATURES_THUMBNAILS', 'boolean', 'features.thumbnails'),
map('CHUNKS_MAX_SIZE', 'human-to-byte', 'chunks.max_size'), map('CHUNKS_MAX_SIZE', 'human-to-byte', 'chunks.max_size'),
map('CHUNKS_CHUNKS_SIZE', 'human-to-byte', 'chunks.chunks_size'), map('CHUNKS_CHUNKS_SIZE', 'human-to-byte', 'chunks.chunks_size'),
map('CHUNKS_ENABLED', 'boolean', 'chunks.enabled'),
map('MFA_TOTP_ISSUER', 'string', 'mfa.totp_issuer'), map('MFA_TOTP_ISSUER', 'string', 'mfa.totp_issuer'),
map('MFA_TOTP_ENABLED', 'boolean', 'mfa.totp_enabled'), map('MFA_TOTP_ENABLED', 'boolean', 'mfa.totp_enabled'),

View File

@@ -3,6 +3,8 @@ import type { Config } from './Config';
import { inspect } from 'util'; import { inspect } from 'util';
import Logger from 'lib/logger'; import Logger from 'lib/logger';
import { humanToBytes } from 'utils/bytes'; import { humanToBytes } from 'utils/bytes';
import { tmpdir } from 'os';
import { join, resolve } from 'path';
const discord_content = s const discord_content = s
.object({ .object({
@@ -27,13 +29,15 @@ const discord_content = s
const validator = s.object({ const validator = s.object({
core: s.object({ core: s.object({
return_https: s.boolean.default(false), return_https: s.boolean.default(false),
temp_directory: s.string.default(join(tmpdir(), 'zipline')),
secret: s.string.lengthGreaterThanOrEqual(8), secret: s.string.lengthGreaterThanOrEqual(8),
host: s.string.default('0.0.0.0'), host: s.string.default('0.0.0.0'),
port: s.number.default(3000), port: s.number.default(3000),
database_url: s.string, database_url: s.string,
logger: s.boolean.default(false), logger: s.boolean.default(false),
stats_interval: s.number.default(1800), stats_interval: s.number.default(1800), // 30m
invites_interval: s.number.default(1800), invites_interval: s.number.default(1800), // 30m
thumbnails_interval: s.number.default(600), // 10m
compression: s compression: s
.object({ .object({
enabled: s.boolean.default(false), enabled: s.boolean.default(false),
@@ -50,7 +54,7 @@ const validator = s.object({
type: s.enum('local', 's3', 'supabase').default('local'), type: s.enum('local', 's3', 'supabase').default('local'),
local: s local: s
.object({ .object({
directory: s.string.default('./uploads'), directory: s.string.default(resolve('./uploads')).transform((v) => resolve(v)),
}) })
.default({ .default({
directory: './uploads', directory: './uploads',
@@ -93,6 +97,7 @@ const validator = s.object({
format_date: s.string.default('YYYY-MM-DD_HH:mm:ss'), format_date: s.string.default('YYYY-MM-DD_HH:mm:ss'),
default_expiration: s.string.optional.default(null), default_expiration: s.string.optional.default(null),
assume_mimetypes: s.boolean.default(false), assume_mimetypes: s.boolean.default(false),
random_words_separator: s.string.default('-'),
}) })
.default({ .default({
default_format: 'RANDOM', default_format: 'RANDOM',
@@ -136,11 +141,11 @@ const validator = s.object({
s.object({ s.object({
label: s.string, label: s.string,
link: s.string, link: s.string,
}) }),
) )
.default([ .default([
{ label: 'Zipline', link: 'https://github.com/diced/zipline' }, { label: 'Zipline', link: 'https://github.com/diced/zipline' },
{ label: 'Documentation', link: 'https://zipline.diced.tech/' }, { label: 'Documentation', link: 'https://zipline.diced.sh/' },
]), ]),
}) })
.default({ .default({
@@ -151,7 +156,7 @@ const validator = s.object({
external_links: [ external_links: [
{ label: 'Zipline', link: 'https://github.com/diced/zipline' }, { label: 'Zipline', link: 'https://github.com/diced/zipline' },
{ label: 'Documentation', link: 'https://zipline.diced.tech/' }, { label: 'Documentation', link: 'https://zipline.diced.sh/' },
], ],
}), }),
discord: s discord: s
@@ -165,14 +170,19 @@ const validator = s.object({
.nullish.default(null), .nullish.default(null),
oauth: s oauth: s
.object({ .object({
bypass_local_login: s.boolean.default(false),
github_client_id: s.string.nullable.default(null), github_client_id: s.string.nullable.default(null),
github_client_secret: s.string.nullable.default(null), github_client_secret: s.string.nullable.default(null),
discord_client_id: s.string.nullable.default(null), discord_client_id: s.string.nullable.default(null),
discord_client_secret: s.string.nullable.default(null), discord_client_secret: s.string.nullable.default(null),
discord_redirect_uri: s.string.nullable.default(null),
discord_whitelisted_users: s.string.array.default([]),
google_client_id: s.string.nullable.default(null), google_client_id: s.string.nullable.default(null),
google_client_secret: s.string.nullable.default(null), google_client_secret: s.string.nullable.default(null),
google_redirect_uri: s.string.nullable.default(null),
}) })
.nullish.default(null), .nullish.default(null),
features: s features: s
@@ -184,6 +194,8 @@ const validator = s.object({
user_registration: s.boolean.default(false), user_registration: s.boolean.default(false),
headless: s.boolean.default(false), headless: s.boolean.default(false),
default_avatar: s.string.nullable.default(null), default_avatar: s.string.nullable.default(null),
robots_txt: s.boolean.default(false),
thumbnails: s.boolean.default(false),
}) })
.default({ .default({
invites: false, invites: false,
@@ -193,15 +205,19 @@ const validator = s.object({
user_registration: false, user_registration: false,
headless: false, headless: false,
default_avatar: null, default_avatar: null,
robots_txt: false,
thumbnails: false,
}), }),
chunks: s chunks: s
.object({ .object({
max_size: s.number.default(humanToBytes('90MB')), max_size: s.number.default(humanToBytes('90MB')),
chunks_size: s.number.default(humanToBytes('20MB')), chunks_size: s.number.default(humanToBytes('20MB')),
enabled: s.boolean.default(true),
}) })
.default({ .default({
max_size: humanToBytes('90MB'), max_size: humanToBytes('90MB'),
chunks_size: humanToBytes('20MB'), chunks_size: humanToBytes('20MB'),
enabled: true,
}), }),
mfa: s mfa: s
.object({ .object({

View File

@@ -11,22 +11,23 @@ export class Local extends Datasource {
} }
public async save(file: string, data: Buffer): Promise<void> { public async save(file: string, data: Buffer): Promise<void> {
await writeFile(join(process.cwd(), this.path, file), data); await writeFile(join(this.path, file), data);
} }
public async delete(file: string): Promise<void> { public async delete(file: string): Promise<void> {
await rm(join(process.cwd(), this.path, file)); await rm(join(this.path, file));
} }
public async clear(): Promise<void> { public async clear(): Promise<void> {
const files = await readdir(join(process.cwd(), this.path)); const files = await readdir(this.path);
for (let i = 0; i !== files.length; ++i) { for (let i = 0; i !== files.length; ++i) {
await rm(join(process.cwd(), this.path, files[i])); await rm(join(this.path, files[i]));
} }
} }
public get(file: string): ReadStream { public get(file: string): ReadStream {
const full = join(process.cwd(), this.path, file); const full = join(this.path, file);
if (!existsSync(full)) return null; if (!existsSync(full)) return null;
try { try {
@@ -37,7 +38,9 @@ export class Local extends Datasource {
} }
public async size(file: string): Promise<number> { public async size(file: string): Promise<number> {
const stats = await stat(join(process.cwd(), this.path, file)); const full = join(this.path, file);
if (!existsSync(full)) return 0;
const stats = await stat(full);
return stats.size; return stats.size;
} }

View File

@@ -49,23 +49,20 @@ export class S3 extends Datasource {
}); });
} }
public size(file: string): Promise<number> { public async size(file: string): Promise<number> {
return new Promise((res, rej) => { const stat = await this.s3.statObject(this.config.bucket, file);
this.s3.statObject(this.config.bucket, file, (err, stat) => {
if (err) rej(err); return stat.size;
else res(stat.size);
});
});
} }
public async fullSize(): Promise<number> { public async fullSize(): Promise<number> {
return new Promise((res, rej) => { return new Promise((res) => {
const objects = this.s3.listObjectsV2(this.config.bucket, '', true); const objects = this.s3.listObjectsV2(this.config.bucket, '', true);
let size = 0; let size = 0;
objects.on('data', (item) => (size += item.size)); objects.on('data', (item) => (size += item.size));
objects.on('end', (err) => { objects.on('end', (err) => {
if (err) rej(err); if (err) res(0);
else res(size); else res(size);
}); });
}); });

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import date from './date';
import gfycat from './gfycat'; import gfycat from './gfycat';
import random from './random'; import random from './random';
import uuid from './uuid'; import uuid from './uuid';
import { parse } from 'path';
export type NameFormat = 'random' | 'date' | 'uuid' | 'name' | 'gfycat'; export type NameFormat = 'random' | 'date' | 'uuid' | 'name' | 'gfycat';
export const NameFormats: NameFormat[] = ['random', 'date', 'uuid', 'name', 'gfycat']; export const NameFormats: NameFormat[] = ['random', 'date', 'uuid', 'name', 'gfycat'];
@@ -14,9 +15,11 @@ export default async function formatFileName(nameFormat: NameFormat, originalNam
case 'uuid': case 'uuid':
return uuid(); return uuid();
case 'name': case 'name':
return originalName.split('.')[0]; const { name } = parse(originalName);
return name;
case 'gfycat': case 'gfycat':
return gfycat(); return gfycat() ?? random();
default: default:
return random(); return random();
} }

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import config from 'lib/config'; import config from 'lib/config';
import { notNull } from 'lib/util'; import { isNotNullOrUndefined } from 'lib/util';
import { GetServerSideProps } from 'next'; import { GetServerSideProps } from 'next';
export type OauthProvider = { export type OauthProvider = {
@@ -16,8 +16,10 @@ export type ServerSideProps = {
user_registration: boolean; user_registration: boolean;
oauth_registration: boolean; oauth_registration: boolean;
oauth_providers: string; oauth_providers: string;
bypass_local_login: boolean;
chunks_size: number; chunks_size: number;
max_size: number; max_size: number;
chunks_enabled: boolean;
totp_enabled: boolean; totp_enabled: boolean;
exif_enabled: boolean; exif_enabled: boolean;
fileId?: string; fileId?: string;
@@ -25,9 +27,15 @@ export type ServerSideProps = {
}; };
export const getServerSideProps: GetServerSideProps<ServerSideProps> = async (ctx) => { export const getServerSideProps: GetServerSideProps<ServerSideProps> = async (ctx) => {
const ghEnabled = notNull(config.oauth?.github_client_id, config.oauth?.github_client_secret); const ghEnabled =
const discEnabled = notNull(config.oauth?.discord_client_id, config.oauth?.discord_client_secret); isNotNullOrUndefined(config.oauth?.github_client_id) &&
const googleEnabled = notNull(config.oauth?.google_client_id, config.oauth?.google_client_secret); isNotNullOrUndefined(config.oauth?.github_client_secret);
const discEnabled =
isNotNullOrUndefined(config.oauth?.discord_client_id) &&
isNotNullOrUndefined(config.oauth?.discord_client_secret);
const googleEnabled =
isNotNullOrUndefined(config.oauth?.google_client_id) &&
isNotNullOrUndefined(config.oauth?.google_client_secret);
const oauth_providers: OauthProvider[] = []; const oauth_providers: OauthProvider[] = [];
@@ -60,9 +68,11 @@ export const getServerSideProps: GetServerSideProps<ServerSideProps> = async (ct
user_registration: config.features.user_registration, user_registration: config.features.user_registration,
oauth_registration: config.features.oauth_registration, oauth_registration: config.features.oauth_registration,
oauth_providers: JSON.stringify(oauth_providers), oauth_providers: JSON.stringify(oauth_providers),
bypass_local_login: config.oauth?.bypass_local_login ?? false,
chunks_size: config.chunks.chunks_size, chunks_size: config.chunks.chunks_size,
max_size: config.chunks.max_size, max_size: config.chunks.max_size,
totp_enabled: config.mfa.totp_enabled, totp_enabled: config.mfa.totp_enabled,
chunks_enabled: config.chunks.enabled,
exif_enabled: config.exif.enabled, exif_enabled: config.exif.enabled,
compress: config.core.compression.on_dashboard, compress: config.core.compression.on_dashboard,
} as ServerSideProps, } as ServerSideProps,

View File

@@ -26,7 +26,7 @@ export interface OAuthResponse {
export const withOAuth = export const withOAuth =
( (
provider: 'discord' | 'github' | 'google', provider: 'discord' | 'github' | 'google',
oauth: (query: OAuthQuery, logger: Logger) => Promise<OAuthResponse> oauth: (query: OAuthQuery, logger: Logger) => Promise<OAuthResponse>,
) => ) =>
async (req: NextApiReq, res: NextApiRes) => { async (req: NextApiReq, res: NextApiRes) => {
const logger = Logger.get(`oauth::${provider}`); const logger = Logger.get(`oauth::${provider}`);
@@ -135,7 +135,7 @@ export const withOAuth =
} else throw e; } else throw e;
} }
res.setUserCookie(user.id); res.setUserCookie(user.uuid);
logger.info(`User ${user.username} (${user.id}) linked account via oauth(${provider})`); logger.info(`User ${user.username} (${user.id}) linked account via oauth(${provider})`);
return res.redirect('/'); return res.redirect('/');
@@ -153,7 +153,7 @@ export const withOAuth =
}, },
}); });
res.setUserCookie(user.id); res.setUserCookie(user.uuid);
logger.info(`User ${user.username} (${user.id}) logged in via oauth(${provider})`); logger.info(`User ${user.username} (${user.id}) logged in via oauth(${provider})`);
return res.redirect('/dashboard'); return res.redirect('/dashboard');
@@ -172,7 +172,7 @@ export const withOAuth =
res.setUserCookie(existingOauth.userId); res.setUserCookie(existingOauth.userId);
Logger.get('user').info( Logger.get('user').info(
`User ${existingOauth.username} (${existingOauth.id}) logged in via oauth(${provider})` `User ${existingOauth.username} (${existingOauth.id}) logged in via oauth(${provider})`,
); );
return res.redirect('/dashboard'); return res.redirect('/dashboard');
@@ -203,7 +203,7 @@ export const withOAuth =
logger.debug(`created user ${JSON.stringify(nuser)} via oauth(${provider})`); logger.debug(`created user ${JSON.stringify(nuser)} via oauth(${provider})`);
logger.info(`Created user ${nuser.username} via oauth(${provider})`); logger.info(`Created user ${nuser.username} via oauth(${provider})`);
res.setUserCookie(nuser.id); res.setUserCookie(nuser.uuid);
logger.info(`User ${nuser.username} (${nuser.id}) logged in via oauth(${provider})`); logger.info(`User ${nuser.username} (${nuser.id}) logged in via oauth(${provider})`);
return res.redirect('/dashboard'); return res.redirect('/dashboard');

View File

@@ -54,7 +54,7 @@ export type NextApiRes = NextApiResponse &
NextApiResExtraObj & { NextApiResExtraObj & {
json: (json: Record<string, unknown>, status?: number) => void; json: (json: Record<string, unknown>, status?: number) => void;
setCookie: (name: string, value: unknown, options: CookieSerializeOptions) => void; setCookie: (name: string, value: unknown, options: CookieSerializeOptions) => void;
setUserCookie: (id: number) => void; setUserCookie: (id: string) => void;
}; };
export type ZiplineApiConfig = { export type ZiplineApiConfig = {
@@ -66,7 +66,7 @@ export type ZiplineApiConfig = {
export const withZipline = export const withZipline =
( (
handler: (req: NextApiRequest, res: NextApiResponse, user?: UserExtended) => Promise<unknown>, handler: (req: NextApiRequest, res: NextApiResponse, user?: UserExtended) => Promise<unknown>,
api_config: ZiplineApiConfig = { methods: ['GET', 'OPTIONS'] } api_config: ZiplineApiConfig = { methods: ['GET', 'OPTIONS'] },
) => ) =>
(req: NextApiReq, res: NextApiRes) => { (req: NextApiReq, res: NextApiRes) => {
if (!api_config.methods.includes('OPTIONS')) api_config.methods.push('OPTIONS'); if (!api_config.methods.includes('OPTIONS')) api_config.methods.push('OPTIONS');
@@ -87,7 +87,7 @@ export const withZipline =
code: 400, code: 400,
...extra, ...extra,
}, },
400 400,
); );
}; };
@@ -99,7 +99,7 @@ export const withZipline =
code: 401, code: 401,
...extra, ...extra,
}, },
401 401,
); );
}; };
@@ -111,7 +111,7 @@ export const withZipline =
code: 403, code: 403,
...extra, ...extra,
}, },
403 403,
); );
}; };
@@ -122,7 +122,7 @@ export const withZipline =
code: 404, code: 404,
...extra, ...extra,
}, },
404 404,
); );
}; };
@@ -136,7 +136,7 @@ export const withZipline =
code: 429, code: 429,
...extra, ...extra,
}, },
429 429,
); );
}; };
@@ -161,7 +161,7 @@ export const withZipline =
path: '/', path: '/',
expires: new Date(1), expires: new Date(1),
maxAge: undefined, maxAge: undefined,
}) }),
); );
}; };
@@ -184,7 +184,7 @@ export const withZipline =
const user = await prisma.user.findFirst({ const user = await prisma.user.findFirst({
where: { where: {
id: Number(userId), uuid: userId,
}, },
include: { include: {
oauth: true, oauth: true,
@@ -202,22 +202,22 @@ export const withZipline =
} }
}; };
res.setCookie = (name: string, value: unknown, options: CookieSerializeOptions = {}) => { res.setCookie = (name: string, value: string, options: CookieSerializeOptions = {}) => {
if ('maxAge' in options) { if ('maxAge' in options) {
options.expires = new Date(Date.now() + options.maxAge * 1000); options.expires = new Date(Date.now() + options.maxAge * 1000);
options.maxAge /= 1000; options.maxAge /= 1000;
} }
const signed = sign64(String(value), config.core.secret); const signed = sign64(value, config.core.secret);
Logger.get('api').debug(`headers(${JSON.stringify(req.headers)}): cookie(${name}, ${value})`); Logger.get('api').debug(`headers(${JSON.stringify(req.headers)}): cookie(${name}, ${value})`);
res.setHeader('Set-Cookie', serialize(name, signed, options)); res.setHeader('Set-Cookie', serialize(name, signed, options));
}; };
res.setUserCookie = (id: number) => { res.setUserCookie = (id: string) => {
req.cleanCookie('user'); req.cleanCookie('user');
res.setCookie('user', String(id), { res.setCookie('user', id, {
sameSite: 'lax', sameSite: 'lax',
expires: new Date(Date.now() + 6.048e8 * 2), expires: new Date(Date.now() + 6.048e8 * 2),
path: '/', path: '/',
@@ -230,7 +230,7 @@ export const withZipline =
error: 'method not allowed', error: 'method not allowed',
code: 405, code: 405,
}, },
405 405,
); );
} }

View File

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

View File

@@ -29,17 +29,27 @@ export const useFiles = (query: { [key: string]: string } = {}) => {
...x, ...x,
createdAt: new Date(x.createdAt), createdAt: new Date(x.createdAt),
expiresAt: x.expiresAt ? new Date(x.expiresAt) : null, expiresAt: x.expiresAt ? new Date(x.expiresAt) : null,
})) })),
); );
}); });
}; };
export const usePaginatedFiles = (page?: number, filter = 'media', favorite = null) => {
const queryBuilder = new URLSearchParams({ export type PaginatedFilesOptions = {
filter: 'media' | 'none';
favorite: boolean;
sortBy: 'createdAt' | 'views' | 'expiresAt' | 'size' | 'name' | 'mimetype';
order: 'asc' | 'desc';
};
export const usePaginatedFiles = (page?: number, options?: Partial<PaginatedFilesOptions>) => {
const queryString = new URLSearchParams({
page: Number(page || '1').toString(), page: Number(page || '1').toString(),
filter, filter: options?.filter ?? 'none',
...(favorite !== null && { favorite: favorite.toString() }), // ...(options?.favorite !== null && { favorite: options?.favorite?.toString() }),
}); favorite: options.favorite ? 'true' : '',
const queryString = queryBuilder.toString(); sortBy: options.sortBy ?? '',
order: options.order ?? '',
}).toString();
return useQuery<UserFilesResponse[]>(['files', queryString], async () => { return useQuery<UserFilesResponse[]>(['files', queryString], async () => {
return fetch('/api/user/paged?' + queryString) return fetch('/api/user/paged?' + queryString)
@@ -49,7 +59,7 @@ export const usePaginatedFiles = (page?: number, filter = 'media', favorite = nu
...x, ...x,
createdAt: new Date(x.createdAt), createdAt: new Date(x.createdAt),
expiresAt: x.expiresAt ? new Date(x.expiresAt) : null, expiresAt: x.expiresAt ? new Date(x.expiresAt) : null,
})) })),
); );
}); });
}; };
@@ -63,7 +73,7 @@ export const useRecent = (filter?: string) => {
...x, ...x,
createdAt: new Date(x.createdAt), createdAt: new Date(x.createdAt),
expiresAt: x.expiresAt ? new Date(x.expiresAt) : null, expiresAt: x.expiresAt ? new Date(x.expiresAt) : null,
})) })),
); );
}); });
}; };
@@ -84,7 +94,7 @@ export function useFileDelete() {
onSuccess: () => { onSuccess: () => {
queryClient.refetchQueries(['files']); queryClient.refetchQueries(['files']);
}, },
} },
); );
} }
@@ -104,7 +114,7 @@ export function useFileFavorite() {
onSuccess: () => { onSuccess: () => {
queryClient.refetchQueries(['files']); queryClient.refetchQueries(['files']);
}, },
} },
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -125,7 +125,7 @@ export function expireReadToDate(expires: string): Date {
'6m': Date.now() + 6 * 30 * 24 * 60 * 60 * 1000, '6m': Date.now() + 6 * 30 * 24 * 60 * 60 * 1000,
'8m': Date.now() + 8 * 30 * 24 * 60 * 60 * 1000, '8m': Date.now() + 8 * 30 * 24 * 60 * 60 * 1000,
'1y': Date.now() + 365 * 24 * 60 * 60 * 1000, '1y': Date.now() + 365 * 24 * 60 * 60 * 1000,
}[expires] }[expires],
); );
} }

View File

@@ -1,11 +1,10 @@
import { File } from '@prisma/client'; import { File } from '@prisma/client';
import { createWriteStream } from 'fs';
import { ExifTool, Tags } from 'exiftool-vendored'; import { ExifTool, Tags } from 'exiftool-vendored';
import { createWriteStream } from 'fs';
import { readFile, rm } from 'fs/promises';
import datasource from 'lib/datasource'; import datasource from 'lib/datasource';
import Logger from 'lib/logger'; import Logger from 'lib/logger';
import { tmpdir } from 'os';
import { join } from 'path'; import { join } from 'path';
import { readFile, unlink } from 'fs/promises';
const logger = Logger.get('exif'); const logger = Logger.get('exif');
@@ -34,7 +33,7 @@ export async function readMetadata(filePath: string): Promise<Tags> {
export async function removeGPSData(image: File): Promise<void> { export async function removeGPSData(image: File): Promise<void> {
const exiftool = new ExifTool({ cleanupChildProcs: false }); const exiftool = new ExifTool({ cleanupChildProcs: false });
const file = join(tmpdir(), `zipline-exif-remove-${Date.now()}-${image.name}`); const file = join(config.core.temp_directory, `zipline-exif-remove-${Date.now()}-${image.name}`);
logger.debug(`writing temp file to remove GPS data: ${file}`); logger.debug(`writing temp file to remove GPS data: ${file}`);
const stream = await datasource.get(image.name); const stream = await datasource.get(image.name);
@@ -44,47 +43,54 @@ export async function removeGPSData(image: File): Promise<void> {
await new Promise((resolve) => writeStream.on('finish', resolve)); await new Promise((resolve) => writeStream.on('finish', resolve));
logger.debug(`removing GPS data from ${file}`); logger.debug(`removing GPS data from ${file}`);
await exiftool.write(file, { try {
GPSVersionID: null, await exiftool.write(file, {
GPSAltitude: null, GPSVersionID: null,
GPSAltitudeRef: null, GPSAltitude: null,
GPSAreaInformation: null, GPSAltitudeRef: null,
GPSDateStamp: null, GPSAreaInformation: null,
GPSDateTime: null, GPSDateStamp: null,
GPSDestBearing: null, GPSDateTime: null,
GPSDestBearingRef: null, GPSDestBearing: null,
GPSDestDistance: null, GPSDestBearingRef: null,
GPSDestLatitude: null, GPSDestDistance: null,
GPSDestLatitudeRef: null, GPSDestLatitude: null,
GPSDestLongitude: null, GPSDestLatitudeRef: null,
GPSDestLongitudeRef: null, GPSDestLongitude: null,
GPSDifferential: null, GPSDestLongitudeRef: null,
GPSDOP: null, GPSDifferential: null,
GPSHPositioningError: null, GPSDOP: null,
GPSImgDirection: null, GPSHPositioningError: null,
GPSImgDirectionRef: null, GPSImgDirection: null,
GPSLatitude: null, GPSImgDirectionRef: null,
GPSLatitudeRef: null, GPSLatitude: null,
GPSLongitude: null, GPSLatitudeRef: null,
GPSLongitudeRef: null, GPSLongitude: null,
GPSMapDatum: null, GPSLongitudeRef: null,
GPSPosition: null, GPSMapDatum: null,
GPSProcessingMethod: null, GPSPosition: null,
GPSSatellites: null, GPSProcessingMethod: null,
GPSSpeed: null, GPSSatellites: null,
GPSSpeedRef: null, GPSSpeed: null,
GPSStatus: null, GPSSpeedRef: null,
GPSTimeStamp: null, GPSStatus: null,
GPSTrack: null, GPSTimeStamp: null,
GPSTrackRef: null, GPSTrack: null,
}); GPSTrackRef: null,
});
} catch (e) {
logger.debug(`removing temp file: ${file}`);
await rm(file);
return;
}
logger.debug(`reading file to upload to datasource: ${file} -> ${image.name}`); logger.debug(`reading file to upload to datasource: ${file} -> ${image.name}`);
const buffer = await readFile(file); const buffer = await readFile(file);
await datasource.save(image.name, buffer); await datasource.save(image.name, buffer);
logger.debug(`removing temp file: ${file}`); logger.debug(`removing temp file: ${file}`);
await unlink(file); await rm(file);
await exiftool.end(true); await exiftool.end(true);

View File

@@ -1,4 +1,5 @@
import type { File, User, Url } from '@prisma/client'; import type { File, User, Url } from '@prisma/client';
import { bytesToHuman } from './bytes';
export type ParseValue = { export type ParseValue = {
file?: File; file?: File;
@@ -11,7 +12,10 @@ export type ParseValue = {
export function parseString(str: string, value: ParseValue) { export function parseString(str: string, value: ParseValue) {
if (!str) return null; if (!str) return null;
str = str.replace(/\{link\}/gi, value.link).replace(/\{raw_link\}/gi, value.raw_link); str = str
.replace(/\{link\}/gi, value.link)
.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+))?\}/gi;
let matches: RegExpMatchArray; let matches: RegExpMatchArray;
@@ -24,17 +28,18 @@ export function parseString(str: string, value: ParseValue) {
continue; continue;
} }
if (['password', 'avatar'].includes(matches.groups.prop)) { if (['password', 'avatar', 'uuid'].includes(matches.groups.prop)) {
str = replaceCharsFromString(str, '{unknown_property}', matches.index, re.lastIndex); str = replaceCharsFromString(str, '{unknown_property}', matches.index, re.lastIndex);
re.lastIndex = matches.index; re.lastIndex = matches.index;
continue; continue;
} }
if (['originalName', 'name'].includes(matches.groups.prop)) { if (['originalName', 'name'].includes(matches.groups.prop)) {
str = replaceCharsFromString( str = replaceCharsFromString(
str, str,
decodeURIComponent(escape(getV[matches.groups.prop])), decodeURIComponent(escape(getV[matches.groups.prop])),
matches.index, matches.index,
re.lastIndex re.lastIndex,
); );
re.lastIndex = matches.index; re.lastIndex = matches.index;
continue; continue;
@@ -122,6 +127,8 @@ function modifier(mod: string, value: unknown): string {
return value.toString(8); return value.toString(8);
case 'binary': case 'binary':
return value.toString(2); return value.toString(2);
case 'bytes':
return bytesToHuman(value);
default: default:
return '{unknown_int_modifier}'; return '{unknown_int_modifier}';
} }

View File

@@ -2,8 +2,10 @@ import { Button, Stack, Title, Tooltip } from '@mantine/core';
import MutedText from 'components/MutedText'; import MutedText from 'components/MutedText';
import Head from 'next/head'; import Head from 'next/head';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/router';
export default function FiveHundred() { export default function FiveHundred() {
const { asPath } = useRouter();
return ( return (
<> <>
<Head> <Head>
@@ -24,9 +26,13 @@ export default function FiveHundred() {
<Tooltip label={"Take a look at Zipline's logs and the browser console for more info"}> <Tooltip label={"Take a look at Zipline's logs and the browser console for more info"}>
<MutedText>Internal server error</MutedText> <MutedText>Internal server error</MutedText>
</Tooltip> </Tooltip>
<Button component={Link} href='/dashboard'> {asPath === '/dashboard' ? (
Head to the Dashboard <Button onClick={() => window.location.reload()}>Attempt Refresh</Button>
</Button> ) : (
<Button component={Link} href='/dashboard'>
Head to the Dashboard
</Button>
)}
</Stack> </Stack>
</> </>
); );

View File

@@ -6,7 +6,7 @@ const logger = Logger.get('admin');
async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) { async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
try { try {
const { datasource, orphaned } = req.body; const { orphaned } = req.body;
if (orphaned) { if (orphaned) {
const { count } = await prisma.file.deleteMany({ const { count } = await prisma.file.deleteMany({
where: { where: {

View File

@@ -69,7 +69,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
logger.info( logger.info(
`Created user ${newUser.username} (${newUser.id}) ${ `Created user ${newUser.username} (${newUser.id}) ${
code ? `from invite code ${code}` : 'via registration' code ? `from invite code ${code}` : 'via registration'
}` }`,
); );
return res.json({ success: true }); return res.json({ success: true });

View File

@@ -7,6 +7,7 @@ import { extname } from 'path';
async function handler(req: NextApiReq, res: NextApiRes) { async function handler(req: NextApiReq, res: NextApiRes) {
const { id, password } = req.query; const { id, password } = req.query;
if (isNaN(Number(id))) return res.badRequest('invalid id');
const file = await prisma.file.findFirst({ const file = await prisma.file.findFirst({
where: { where: {

View File

@@ -3,6 +3,7 @@ import Logger from 'lib/logger';
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'lib/middleware/withZipline'; import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'lib/middleware/withZipline';
import prisma from 'lib/prisma'; import prisma from 'lib/prisma';
import { randomChars } from 'lib/util'; import { randomChars } from 'lib/util';
import { parseExpiry } from 'lib/utils/client';
const logger = Logger.get('invite'); const logger = Logger.get('invite');
@@ -15,11 +16,8 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
count: number; count: number;
}; };
const expiry = expiresAt ? new Date(expiresAt) : null; const expiry = parseExpiry(expiresAt);
if (expiry) { if (!expiry) return res.badRequest('invalid date');
if (!expiry.getTime()) return res.badRequest('invalid date');
if (expiry.getTime() < Date.now()) return res.badRequest('date is in the past');
}
const counts = count ? count : 1; const counts = count ? count : 1;
if (counts > 1) { if (counts > 1) {
@@ -39,7 +37,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
logger.info( logger.info(
`${user.username} (${user.id}) created ${data.length} invites with codes ${data `${user.username} (${user.id}) created ${data.length} invites with codes ${data
.map((invite) => invite.code) .map((invite) => invite.code)
.join(', ')}` .join(', ')}`,
); );
return res.json(data); return res.json(data);

View File

@@ -51,12 +51,12 @@ async function handler(req: NextApiReq, res: NextApiRes) {
const success = verify_totp_code(user.totpSecret, code); const success = verify_totp_code(user.totpSecret, code);
logger.debug( logger.debug(
`body(${JSON.stringify(req.body)}): verify_totp_code(${user.totpSecret}, ${code}) => ${success}` `body(${JSON.stringify(req.body)}): verify_totp_code(${user.totpSecret}, ${code}) => ${success}`,
); );
if (!success) return res.badRequest('Invalid code', { totp: true }); if (!success) return res.badRequest('Invalid code', { totp: true });
} }
res.setUserCookie(user.id); res.setUserCookie(user.uuid);
logger.info(`User ${user.username} (${user.id}) logged in`); logger.info(`User ${user.username} (${user.id}) logged in`);
return res.json({ success: true }); return res.json({ success: true });

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ import Logger from 'lib/logger';
import prisma from 'lib/prisma'; import prisma from 'lib/prisma';
import { readMetadata } from 'lib/utils/exif'; import { readMetadata } from 'lib/utils/exif';
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline'; import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
import { tmpdir } from 'os';
import { join } from 'path'; import { join } from 'path';
const logger = Logger.get('exif'); const logger = Logger.get('exif');
@@ -27,7 +26,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
if (!image) return res.notFound('image not found'); if (!image) return res.notFound('image not found');
logger.info( logger.info(
`${user.username} (${user.id}) requested to read exif metadata for image ${image.name} (${image.id})` `${user.username} (${user.id}) requested to read exif metadata for image ${image.name} (${image.id})`,
); );
if (config.datasource.type === 'local') { if (config.datasource.type === 'local') {
@@ -41,7 +40,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
return res.json(data); return res.json(data);
} else { } else {
const file = join(tmpdir(), `zipline-exif-read-${Date.now()}-${image.name}`); const file = join(config.core.temp_directory, `zipline-exif-read-${Date.now()}-${image.name}`);
logger.debug(`writing temp file to view metadata: ${file}`); logger.debug(`writing temp file to view metadata: ${file}`);
const stream = await datasource.get(image.name); const stream = await datasource.get(image.name);

View File

@@ -1,5 +1,5 @@
import { InvisibleFile } from '@prisma/client'; import { InvisibleFile } from '@prisma/client';
import { readdir, readFile, unlink, writeFile } from 'fs/promises'; import { writeFile } from 'fs/promises';
import zconfig from 'lib/config'; import zconfig from 'lib/config';
import datasource from 'lib/datasource'; import datasource from 'lib/datasource';
import { sendUpload } from 'lib/discord'; import { sendUpload } from 'lib/discord';
@@ -12,9 +12,9 @@ import { createInvisImage, hashPassword } from 'lib/util';
import { parseExpiry } from 'lib/utils/client'; import { parseExpiry } from 'lib/utils/client';
import { removeGPSData } from 'lib/utils/exif'; import { removeGPSData } from 'lib/utils/exif';
import multer from 'multer'; import multer from 'multer';
import { tmpdir } from 'os'; import { join, parse } from 'path';
import { join } from 'path';
import sharp from 'sharp'; import sharp from 'sharp';
import { Worker } from 'worker_threads';
const uploader = multer(); const uploader = multer();
const logger = Logger.get('upload'); const logger = Logger.get('upload');
@@ -79,7 +79,17 @@ async function handler(req: NextApiReq, res: NextApiRes) {
if (fileMaxViews < 0) return res.badRequest('invalid max views (max views < 0)'); if (fileMaxViews < 0) return res.badRequest('invalid max views (max views < 0)');
// handle partial uploads before ratelimits // handle partial uploads before ratelimits
if (req.headers['content-range']) { if (req.headers['content-range'] && zconfig.chunks.enabled) {
if (format === 'name') {
const existing = await prisma.file.findFirst({
where: {
name: req.headers['x-zipline-partial-filename'] as string,
},
});
if (existing) return res.badRequest('filename already exists (conflict: NAME format)');
}
// parses content-range header (bytes start-end/total) // parses content-range header (bytes start-end/total)
const [start, end, total] = req.headers['content-range'] const [start, end, total] = req.headers['content-range']
.replace('bytes ', '') .replace('bytes ', '')
@@ -101,66 +111,26 @@ async function handler(req: NextApiReq, res: NextApiRes) {
start, start,
end, end,
total, total,
})}` })}`,
); );
const tempFile = join(tmpdir(), `zipline_partial_${identifier}_${start}_${end}`); const tempFile = join(zconfig.core.temp_directory, `zipline_partial_${identifier}_${start}_${end}`);
logger.debug(`writing partial to disk ${tempFile}`); logger.debug(`writing partial to disk ${tempFile}`);
await writeFile(tempFile, req.files[0].buffer); await writeFile(tempFile, req.files[0].buffer);
if (lastchunk) { if (lastchunk) {
const partials = await readdir(tmpdir()).then((files) =>
files.filter((x) => x.startsWith(`zipline_partial_${identifier}`))
);
const readChunks = partials.map((x) => {
const [, , , start, end] = x.split('_');
return { start: Number(start), end: Number(end), filename: x };
});
// combine chunks
const chunks = new Uint8Array(total);
for (let i = 0; i !== readChunks.length; ++i) {
const chunkData = readChunks[i];
const buffer = await readFile(join(tmpdir(), chunkData.filename));
await unlink(join(tmpdir(), readChunks[i].filename));
chunks.set(buffer, chunkData.start);
}
const ext = filename.split('.').length === 1 ? '' : filename.split('.').pop();
if (zconfig.uploader.disabled_extensions.includes(ext))
return res.error('disabled extension recieved: ' + ext);
const fileName = await formatFileName(format, filename); const fileName = await formatFileName(format, filename);
const ext = filename.split('.').length === 1 ? '' : filename.split('.').pop();
let password = null;
if (req.headers.password) {
password = await hashPassword(req.headers.password as string);
}
const compressionUsed = imageCompressionPercent && mimetype.startsWith('image/');
let invis: InvisibleFile;
const file = await prisma.file.create({ const file = await prisma.file.create({
data: { data: {
name: `${fileName}${compressionUsed ? '.jpg' : `${ext ? '.' : ''}${ext}`}`, name: `${fileName}${ext ? '.' : ''}${ext}`,
mimetype, mimetype: req.headers.uploadtext ? 'text/plain' : mimetype,
userId: user.id, userId: user.id,
embed: !!req.headers.embed,
password,
expiresAt: expiry,
maxViews: fileMaxViews,
originalName: req.headers['original-name'] ? filename ?? null : null, originalName: req.headers['original-name'] ? filename ?? null : null,
}, },
}); });
if (req.headers.zws) invis = await createInvisImage(zconfig.uploader.length, file.id);
await datasource.save(file.name, Buffer.from(chunks));
logger.info(`User ${user.username} (${user.id}) uploaded ${file.name} (${file.id}) (chunked)`);
let domain; let domain;
if (req.headers['override-domain']) { if (req.headers['override-domain']) {
domain = `${zconfig.core.return_https ? 'https' : 'http'}://${req.headers['override-domain']}`; domain = `${zconfig.core.return_https ? 'https' : 'http'}://${req.headers['override-domain']}`;
@@ -170,28 +140,34 @@ async function handler(req: NextApiReq, res: NextApiRes) {
domain = `${zconfig.core.return_https ? 'https' : 'http'}://${req.headers.host}`; domain = `${zconfig.core.return_https ? 'https' : 'http'}://${req.headers.host}`;
} }
const responseUrl = `${domain}${zconfig.uploader.route === '/' ? '/' : zconfig.uploader.route + '/'}${ const responseUrl = `${domain}${
invis ? invis.invis : encodeURI(file.name) zconfig.uploader.route === '/' ? '/' : zconfig.uploader.route + '/'
}`; }${encodeURI(file.name)}`;
response.files.push(responseUrl); new Worker('./dist/worker/upload.js', {
workerData: {
user,
file: {
id: file.id,
filename: file.name,
mimetype: file.mimetype,
identifier,
lastchunk,
totalBytes: total,
},
response: {
expiresAt: expiry,
format,
fileMaxViews,
},
headers: req.headers,
},
});
if (zconfig.discord?.upload) { return res.json({
await sendUpload(user, file, `${domain}/r/${invis ? invis.invis : file.name}`, responseUrl); pending: true,
} files: [responseUrl],
});
if (zconfig.exif.enabled && zconfig.exif.remove_gps && mimetype.startsWith('image/')) {
try {
await removeGPSData(file);
response.removed_gps = true;
} catch (e) {
logger.error(`Failed to remove GPS data from ${file.name} (${file.id}) - ${e.message}`);
response.removed_gps = false;
}
}
return res.json(response);
} }
return res.json({ return res.json({
@@ -227,29 +203,32 @@ async function handler(req: NextApiReq, res: NextApiRes) {
mimetype: x.mimetype, mimetype: x.mimetype,
size: x.size, size: x.size,
encoding: x.encoding, encoding: x.encoding,
})) })),
)}` )}`,
); );
for (let i = 0; i !== req.files.length; ++i) { for (let i = 0; i !== req.files.length; ++i) {
const file = req.files[i]; const file = req.files[i];
if (file.size > zconfig.uploader[user.administrator ? 'admin_limit' : 'user_limit']) if (file.size > zconfig.uploader[user.administrator ? 'admin_limit' : 'user_limit'])
return res.badRequest(`file[${i}]: size too big`); return res.badRequest(`file[${i}]: size too big`);
if (!file.originalname) return res.badRequest(`file[${i}]: no filename`); if (!file.originalname) return res.badRequest(`file[${i}]: no filename`);
const ext = file.originalname.split('.').length === 1 ? '' : file.originalname.split('.').pop(); const decodedName = decodeURI(file.originalname);
const ext = decodedName.split('.').length === 1 ? '' : decodedName.split('.').pop();
if (zconfig.uploader.disabled_extensions.includes(ext)) if (zconfig.uploader.disabled_extensions.includes(ext))
return res.badRequest(`file[${i}]: disabled extension recieved: ${ext}`); return res.badRequest(`file[${i}]: disabled extension recieved: ${ext}`);
let fileName = await formatFileName(format, file.originalname); const fileName = await formatFileName(format, decodedName);
if (req.headers['x-zipline-filename']) { if (format === 'name' || req.headers['x-zipline-filename']) {
fileName = req.headers['x-zipline-filename'] as string; const exist = (req.headers['x-zipline-filename'] as string) || decodedName;
const existing = await prisma.file.findFirst({ const existing = await prisma.file.findFirst({
where: { where: {
name: fileName, name: exist,
}, },
}); });
if (existing) return res.badRequest(`file[${i}]: filename already exists: '${fileName}'`); if (existing) return res.badRequest(`file[${i}]: filename already exists: '${decodedName}'`);
} }
let password = null; let password = null;
@@ -260,7 +239,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
let mimetype = file.mimetype; let mimetype = file.mimetype;
if (file.mimetype === 'application/octet-stream' && zconfig.uploader.assume_mimetypes) { if (file.mimetype === 'application/octet-stream' && zconfig.uploader.assume_mimetypes) {
const ext = file.originalname.split('.').pop(); const ext = parse(decodedName).ext.replace('.', '');
const mime = await guess(ext); const mime = await guess(ext);
if (!mime) response.assumed_mimetype = false; if (!mime) response.assumed_mimetype = false;
@@ -281,18 +260,19 @@ async function handler(req: NextApiReq, res: NextApiRes) {
password, password,
expiresAt: expiry, expiresAt: expiry,
maxViews: fileMaxViews, maxViews: fileMaxViews,
originalName: req.headers['original-name'] ? file.originalname ?? null : null, originalName: req.headers['original-name'] ? decodedName ?? null : null,
size: file.size, size: file.size,
}, },
}); });
if (req.headers.zws) invis = await createInvisImage(zconfig.uploader.length, fileUpload.id); if (typeof req.headers.zws !== 'undefined' && (req.headers.zws as string).toLowerCase().match('true'))
invis = await createInvisImage(zconfig.uploader.length, fileUpload.id);
if (compressionUsed) { if (compressionUsed) {
const buffer = await sharp(file.buffer).jpeg({ quality: imageCompressionPercent }).toBuffer(); const buffer = await sharp(file.buffer).jpeg({ quality: imageCompressionPercent }).toBuffer();
await datasource.save(fileUpload.name, buffer); await datasource.save(fileUpload.name, buffer);
logger.info( logger.info(
`User ${user.username} (${user.id}) compressed image from ${file.buffer.length} -> ${buffer.length} bytes` `User ${user.username} (${user.id}) compressed image from ${file.buffer.length} -> ${buffer.length} bytes`,
); );
} else { } else {
await datasource.save(fileUpload.name, file.buffer); await datasource.save(fileUpload.name, file.buffer);
@@ -315,7 +295,12 @@ async function handler(req: NextApiReq, res: NextApiRes) {
response.files.push(responseUrl); response.files.push(responseUrl);
if (zconfig.discord?.upload) { if (zconfig.discord?.upload) {
await sendUpload(user, fileUpload, `${domain}/r/${invis ? invis.invis : fileUpload.name}`, responseUrl); await sendUpload(
user,
fileUpload,
`${domain}/r/${invis ? invis.invis : encodeURI(fileUpload.name)}`,
responseUrl,
);
} }
if (zconfig.exif.enabled && zconfig.exif.remove_gps && fileUpload.mimetype.startsWith('image/')) { if (zconfig.exif.enabled && zconfig.exif.remove_gps && fileUpload.mimetype.startsWith('image/')) {

View File

@@ -3,6 +3,8 @@ import Logger from 'lib/logger';
import prisma from 'lib/prisma'; import prisma from 'lib/prisma';
import { hashPassword } from 'lib/util'; import { hashPassword } from 'lib/util';
import { jsonUserReplacer } from 'lib/utils/client'; import { jsonUserReplacer } from 'lib/utils/client';
import { formatRootUrl } from 'lib/utils/urls';
import zconfig from 'lib/config';
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline'; import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
const logger = Logger.get('user'); const logger = Logger.get('user');
@@ -14,6 +16,14 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
where: { where: {
id: Number(id), id: Number(id),
}, },
include: {
files: {
include: {
thumbnail: true,
},
},
Folder: true,
},
}); });
if (!target) return res.notFound('user not found'); if (!target) return res.notFound('user not found');
@@ -26,7 +36,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
promises.push( promises.push(
prisma.user.delete({ prisma.user.delete({
where: { id: target.id }, where: { id: target.id },
}) }),
); );
if (req.body.delete_files) { if (req.body.delete_files) {
@@ -51,7 +61,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
where: { where: {
userId: target.id, userId: target.id,
}, },
}) }),
); );
} }
Promise.all(promises).then((promised) => { Promise.all(promises).then((promised) => {
@@ -61,10 +71,10 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
req.body.delete_files req.body.delete_files
? logger.info( ? logger.info(
`User ${user.username} (${user.id}) deleted ${count} files of user ${newTarget.username} (${newTarget.id})` `User ${user.username} (${user.id}) deleted ${count} files of user ${newTarget.username} (${newTarget.id})`,
) )
: logger.info( : logger.info(
`User ${user.username} (${user.id}) deleted user ${newTarget.username} (${newTarget.id})` `User ${user.username} (${user.id}) deleted user ${newTarget.username} (${newTarget.id})`,
); );
delete newTarget.password; delete newTarget.password;
@@ -167,7 +177,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
logger.debug(`updated user ${id} with ${JSON.stringify(newUser, jsonUserReplacer)}`); logger.debug(`updated user ${id} with ${JSON.stringify(newUser, jsonUserReplacer)}`);
logger.info( logger.info(
`User ${user.username} (${user.id}) updated ${target.username} (${newUser.username}) (${newUser.id})` `User ${user.username} (${user.id}) updated ${target.username} (${newUser.username}) (${newUser.id})`,
); );
delete newUser.password; delete newUser.password;
@@ -175,6 +185,22 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
} else { } else {
delete target.password; delete target.password;
if (user.superAdmin && target.superAdmin) {
delete target.files;
return res.json(target);
}
if (user.administrator && !user.superAdmin && (target.administrator || target.superAdmin)) {
delete target.files;
return res.json(target);
}
for (const file of target.files) {
(file as unknown as { url: string }).url = formatRootUrl(zconfig.uploader.route, file.name);
if (file.thumbnail) {
(file.thumbnail as unknown as string) = formatRootUrl('/r', file.thumbnail.name);
}
}
return res.json(target); return res.json(target);
} }
} }

View File

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

View File

@@ -5,7 +5,6 @@ import datasource from 'lib/datasource';
import Logger from 'lib/logger'; import Logger from 'lib/logger';
import prisma from 'lib/prisma'; import prisma from 'lib/prisma';
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline'; import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
import { tmpdir } from 'os';
import { join } from 'path'; import { join } from 'path';
const logger = Logger.get('user::export'); const logger = Logger.get('user::export');
@@ -22,7 +21,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
const zip = new Zip(); const zip = new Zip();
const export_name = `zipline_export_${user.id}_${Date.now()}.zip`; const export_name = `zipline_export_${user.id}_${Date.now()}.zip`;
const path = join(tmpdir(), export_name); const path = join(config.core.temp_directory, export_name);
logger.debug(`creating write stream at ${path}`); logger.debug(`creating write stream at ${path}`);
const write_stream = createWriteStream(path); const write_stream = createWriteStream(path);
@@ -78,7 +77,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
write_stream.close(); write_stream.close();
logger.debug(`finished writing zip to ${path} at ${data.length} bytes written`); logger.debug(`finished writing zip to ${path} at ${data.length} bytes written`);
logger.info( logger.info(
`Export for ${user.username} (${user.id}) has completed and is available at ${export_name}` `Export for ${user.username} (${user.id}) has completed and is available at ${export_name}`,
); );
} }
} else { } else {
@@ -121,18 +120,18 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
const parts = export_name.split('_'); const parts = export_name.split('_');
if (Number(parts[2]) !== user.id) return res.unauthorized('cannot access export owned by another user'); if (Number(parts[2]) !== user.id) return res.unauthorized('cannot access export owned by another user');
const stream = createReadStream(join(tmpdir(), export_name)); const stream = createReadStream(join(config.core.temp_directory, export_name));
res.setHeader('Content-Type', 'application/zip'); res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${export_name}"`); res.setHeader('Content-Disposition', `attachment; filename="${export_name}"`);
stream.pipe(res); stream.pipe(res);
} else { } else {
const files = await readdir(tmpdir()); const files = await readdir(config.core.temp_directory);
const exp = files.filter((f) => f.startsWith('zipline_export_')); const exp = files.filter((f) => f.startsWith('zipline_export_'));
const exports = []; const exports = [];
for (let i = 0; i !== exp.length; ++i) { for (let i = 0; i !== exp.length; ++i) {
const name = exp[i]; const name = exp[i];
const stats = await stat(join(tmpdir(), name)); const stats = await stat(join(config.core.temp_directory, name));
if (Number(exp[i].split('_')[2]) !== user.id) continue; if (Number(exp[i].split('_')[2]) !== user.id) continue;
exports.push({ name, size: stats.size }); exports.push({ name, size: stats.size });

View File

@@ -14,10 +14,14 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
where: { where: {
userId: user.id, userId: user.id,
}, },
include: {
thumbnail: true,
},
}); });
for (let i = 0; i !== files.length; ++i) { for (let i = 0; i !== files.length; ++i) {
await datasource.delete(files[i].name); await datasource.delete(files[i].name);
if (files[i].thumbnail?.name) await datasource.delete(files[i].thumbnail.name);
} }
const { count } = await prisma.file.deleteMany({ const { count } = await prisma.file.deleteMany({
@@ -31,15 +35,49 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
} else { } else {
if (!req.body.id) return res.badRequest('no file id'); if (!req.body.id) return res.badRequest('no file id');
const file = await prisma.file.delete({ let file = await prisma.file.findFirst({
where: { where: {
id: req.body.id, id: req.body.id,
userId: user.id,
},
include: {
user: {
select: {
administrator: true,
superAdmin: true,
username: true,
id: true,
},
},
thumbnail: true,
},
});
if (!file && (!user.administrator || !user.superAdmin)) return res.notFound('file not found');
file = await prisma.file.delete({
where: {
id: req.body.id,
},
include: {
user: {
select: {
administrator: true,
superAdmin: true,
username: true,
id: true,
},
},
thumbnail: true,
}, },
}); });
await datasource.delete(file.name); await datasource.delete(file.name);
if (file.thumbnail?.name) await datasource.delete(file.thumbnail.name);
logger.info(`User ${user.username} (${user.id}) deleted an image ${file.name} (${file.id})`); logger.info(
`User ${user.username} (${user.id}) deleted an image ${file.name} (${file.id}) owned by ${file.user.username} (${file.user.id})`,
);
// @ts-ignore // @ts-ignore
if (file.password) file.password = true; if (file.password) file.password = true;
@@ -51,14 +89,33 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
let file; let file;
if (req.body.favorite !== null) if (req.body.favorite !== null) {
file = await prisma.file.findFirst({
where: {
id: req.body.id,
userId: user.id,
},
include: {
user: {
select: {
administrator: true,
superAdmin: true,
username: true,
id: true,
},
},
},
});
if (!file && (!user.administrator || !user.superAdmin)) return res.notFound('file not found');
file = await prisma.file.update({ file = await prisma.file.update({
where: { id: req.body.id }, where: { id: req.body.id },
data: { data: {
favorite: req.body.favorite, favorite: req.body.favorite,
}, },
}); });
}
// @ts-ignore // @ts-ignore
if (file.password) file.password = true; if (file.password) file.password = true;
return res.json(file); return res.json(file);
@@ -82,7 +139,9 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
expiresAt: Date; expiresAt: Date;
maxViews: number; maxViews: number;
views: number; views: number;
size: number; size: bigint;
originalName: string;
thumbnail?: { name: string };
}[] = await prisma.file.findMany({ }[] = await prisma.file.findMany({
where: { where: {
userId: user.id, userId: user.id,
@@ -102,11 +161,17 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
folderId: true, folderId: true,
maxViews: true, maxViews: true,
size: true, size: true,
originalName: true,
thumbnail: true,
}, },
}); });
for (let i = 0; i !== files.length; ++i) { 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] as unknown as { url: string }).url = formatRootUrl(config.uploader.route, files[i].name);
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') if (req.query.filter && req.query.filter === 'media')

View File

@@ -83,7 +83,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
logger.debug(`added file ${fileIdParsed} to folder ${idParsed}`); logger.debug(`added file ${fileIdParsed} to folder ${idParsed}`);
logger.info( logger.info(
`Added file "${file.name}" to folder "${folder.name}" for user ${user.username} (${user.id})` `Added file "${file.name}" to folder "${folder.name}" for user ${user.username} (${user.id})`,
); );
if (req.query.files) { if (req.query.files) {
@@ -94,7 +94,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
(folder.files[i] as unknown as { url: string }).url = formatRootUrl( (folder.files[i] as unknown as { url: string }).url = formatRootUrl(
config.uploader.route, config.uploader.route,
folder.files[i].name folder.files[i].name,
); );
} }
} }
@@ -129,7 +129,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
(folder.files[i] as unknown as { url: string }).url = formatRootUrl( (folder.files[i] as unknown as { url: string }).url = formatRootUrl(
config.uploader.route, config.uploader.route,
folder.files[i].name folder.files[i].name,
); );
} }
} }
@@ -213,7 +213,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
logger.debug(`removed file ${fileIdParsed} from folder ${idParsed}`); logger.debug(`removed file ${fileIdParsed} from folder ${idParsed}`);
logger.info( logger.info(
`Removed file "${file.name}" from folder "${folder.name}" for user ${user.username} (${user.id})` `Removed file "${file.name}" from folder "${folder.name}" for user ${user.username} (${user.id})`,
); );
if (req.query.files) { if (req.query.files) {
@@ -224,7 +224,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
(folder.files[i] as unknown as { url: string }).url = formatRootUrl( (folder.files[i] as unknown as { url: string }).url = formatRootUrl(
config.uploader.route, config.uploader.route,
folder.files[i].name folder.files[i].name,
); );
} }
} }
@@ -240,7 +240,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
(folder.files[i] as unknown as { url: string }).url = formatRootUrl( (folder.files[i] as unknown as { url: string }).url = formatRootUrl(
config.uploader.route, config.uploader.route,
folder.files[i].name folder.files[i].name,
); );
} }
} }

View File

@@ -25,7 +25,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
if (files.length !== add.length) if (files.length !== add.length)
return res.badRequest( return res.badRequest(
`files ${add.filter((id) => !files.find((file) => file.id === Number(id))).join(', ')} not found` `files ${add.filter((id) => !files.find((file) => file.id === Number(id))).join(', ')} not found`,
); );
const folder = await prisma.folder.create({ const folder = await prisma.folder.create({
@@ -58,12 +58,13 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
return res.json(folder); return res.json(folder);
} else { } else {
if (req.query.files instanceof Array) req.query.files = req.query.files[0];
const folders = await prisma.folder.findMany({ const folders = await prisma.folder.findMany({
where: { where: {
userId: user.id, userId: user.id,
}, },
select: { select: {
files: !!req.query.files, files: ((req.query.files as string) ?? 'false').toLowerCase() === 'true',
id: true, id: true,
name: true, name: true,
userId: true, userId: true,
@@ -76,7 +77,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
}, },
}); });
if (req.query.files) { if (((req.query.files as string) ?? 'false').toLowerCase() === 'true') {
for (let i = 0; i !== folders.length; ++i) { for (let i = 0; i !== folders.length; ++i) {
const folder = folders[i]; const folder = folders[i];
for (let j = 0; j !== folders[i].files.length; ++j) { for (let j = 0; j !== folders[i].files.length; ++j) {
@@ -86,7 +87,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
(folder.files[j] as unknown as { url: string }).url = formatRootUrl( (folder.files[j] as unknown as { url: string }).url = formatRootUrl(
config.uploader.route, config.uploader.route,
folder.files[j].name folder.files[j].name,
); );
} }
} }

View File

@@ -1,4 +1,4 @@
import config from 'lib/config'; import zconfig from 'lib/config';
import Logger from 'lib/logger'; import Logger from 'lib/logger';
import { discord_auth, github_auth, google_auth } from 'lib/oauth'; import { discord_auth, github_auth, google_auth } from 'lib/oauth';
import prisma from 'lib/prisma'; import prisma from 'lib/prisma';
@@ -18,7 +18,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
return res.json({ return res.json({
error: 'oauth token expired', error: 'oauth token expired',
redirect_uri: github_auth.oauth_url(config.oauth.github_client_id), redirect_uri: github_auth.oauth_url(zconfig.oauth.github_client_id),
}); });
} }
} else if (user.oauth.find((o) => o.provider === 'DISCORD')) { } else if (user.oauth.find((o) => o.provider === 'DISCORD')) {
@@ -35,8 +35,8 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
return res.json({ return res.json({
error: 'oauth token expired', error: 'oauth token expired',
redirect_uri: discord_auth.oauth_url( redirect_uri: discord_auth.oauth_url(
config.oauth.discord_client_id, zconfig.oauth.discord_client_id,
`${config.core.return_https ? 'https' : 'http'}://${req.headers.host}` `${zconfig.core.return_https ? 'https' : 'http'}://${req.headers.host}`,
), ),
}); });
} }
@@ -47,8 +47,8 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
}, },
body: new URLSearchParams({ body: new URLSearchParams({
client_id: config.oauth.discord_client_id, client_id: zconfig.oauth.discord_client_id,
client_secret: config.oauth.discord_client_secret, client_secret: zconfig.oauth.discord_client_secret,
grant_type: 'refresh_token', grant_type: 'refresh_token',
refresh_token: provider.refresh, refresh_token: provider.refresh,
}), }),
@@ -59,8 +59,8 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
return res.json({ return res.json({
error: 'oauth token expired', error: 'oauth token expired',
redirect_uri: discord_auth.oauth_url( redirect_uri: discord_auth.oauth_url(
config.oauth.discord_client_id, zconfig.oauth.discord_client_id,
`${config.core.return_https ? 'https' : 'http'}://${req.headers.host}` `${zconfig.core.return_https ? 'https' : 'http'}://${req.headers.host}`,
), ),
}); });
} }
@@ -80,7 +80,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
const resp = await fetch( const resp = await fetch(
`https://people.googleapis.com/v1/people/me?access_token=${ `https://people.googleapis.com/v1/people/me?access_token=${
user.oauth.find((o) => o.provider === 'GOOGLE').token user.oauth.find((o) => o.provider === 'GOOGLE').token
}&personFields=names,photos` }&personFields=names,photos`,
); );
if (!resp.ok) { if (!resp.ok) {
const provider = user.oauth.find((o) => o.provider === 'GOOGLE'); const provider = user.oauth.find((o) => o.provider === 'GOOGLE');
@@ -90,8 +90,8 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
return res.json({ return res.json({
error: 'oauth token expired', error: 'oauth token expired',
redirect_uri: google_auth.oauth_url( redirect_uri: google_auth.oauth_url(
config.oauth.google_client_id, zconfig.oauth.google_client_id,
`${config.core.return_https ? 'https' : 'http'}://${req.headers.host}` `${zconfig.core.return_https ? 'https' : 'http'}://${req.headers.host}`,
), ),
}); });
} }
@@ -101,8 +101,8 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
}, },
body: new URLSearchParams({ body: new URLSearchParams({
client_id: config.oauth.google_client_id, client_id: zconfig.oauth.google_client_id,
client_secret: config.oauth.google_client_secret, client_secret: zconfig.oauth.google_client_secret,
grant_type: 'refresh_token', grant_type: 'refresh_token',
refresh_token: provider.refresh, refresh_token: provider.refresh,
}), }),
@@ -113,8 +113,8 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
return res.json({ return res.json({
error: 'oauth token expired', error: 'oauth token expired',
redirect_uri: google_auth.oauth_url( redirect_uri: google_auth.oauth_url(
config.oauth.google_client_id, zconfig.oauth.google_client_id,
`${config.core.return_https ? 'https' : 'http'}://${req.headers.host}` `${zconfig.core.return_https ? 'https' : 'http'}://${req.headers.host}`,
), ),
}); });
} }
@@ -241,6 +241,14 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
} }
} }
export const config = {
api: {
bodyParser: {
sizeLimit: '50mb',
},
},
};
export default withZipline(handler, { export default withZipline(handler, {
methods: ['GET', 'PATCH'], methods: ['GET', 'PATCH'],
user: true, user: true,

View File

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

View File

@@ -1,3 +1,5 @@
import { Prisma } from '@prisma/client';
import { s } from '@sapphire/shapeshift';
import config from 'lib/config'; import config from 'lib/config';
import prisma from 'lib/prisma'; import prisma from 'lib/prisma';
import { formatRootUrl } from 'lib/utils/urls'; import { formatRootUrl } from 'lib/utils/urls';
@@ -5,12 +7,27 @@ import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/wi
const pageCount = 16; const pageCount = 16;
const sortByValidator = s.enum(
...([
'createdAt',
'views',
'expiresAt',
'size',
'name',
'mimetype',
] satisfies (keyof Prisma.FileOrderByWithRelationInput)[]),
);
const orderValidator = s.enum('asc', 'desc');
async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) { async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
const { page, filter, count, favorite } = req.query as { const { page, filter, count, favorite, ...rest } = req.query as {
page: string; page: string;
filter: string; filter: string;
count: string; count: string;
favorite: string; favorite: string;
sortBy: string;
order: string;
}; };
const where = { const where = {
@@ -33,7 +50,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
}, },
], ],
}), }),
}; } satisfies Prisma.FileWhereInput;
if (count) { if (count) {
const count = await prisma.file.count({ const count = await prisma.file.count({
@@ -48,6 +65,14 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
if (!page) return res.badRequest('no page'); if (!page) return res.badRequest('no page');
if (isNaN(Number(page))) return res.badRequest('page is not a number'); if (isNaN(Number(page))) return res.badRequest('page is not a number');
// validate sortBy
const sortBy = sortByValidator.run(rest.sortBy || 'createdAt');
if (!sortBy.isOk()) return res.badRequest('invalid sortBy option');
// validate order
const order = orderValidator.run(rest.order || 'desc');
if (!sortBy.isOk()) return res.badRequest('invalid order option');
const files: { const files: {
favorite: boolean; favorite: boolean;
createdAt: Date; createdAt: Date;
@@ -58,12 +83,13 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
maxViews: number; maxViews: number;
views: number; views: number;
folderId: number; folderId: number;
size: number; size: bigint;
password: string | boolean; password: string | boolean;
thumbnail?: { name: string };
}[] = await prisma.file.findMany({ }[] = await prisma.file.findMany({
where, where,
orderBy: { orderBy: {
createdAt: 'desc', [sortBy.value]: order.value,
}, },
select: { select: {
createdAt: true, createdAt: true,
@@ -77,6 +103,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
folderId: true, folderId: true,
size: true, size: true,
password: true, password: true,
thumbnail: true,
}, },
skip: page ? (Number(page) - 1) * pageCount : undefined, skip: page ? (Number(page) - 1) * pageCount : undefined,
take: page ? pageCount : undefined, take: page ? pageCount : undefined,
@@ -87,6 +114,9 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
if (file.password) file.password = true; if (file.password) file.password = true;
(file as unknown as { url: string }).url = formatRootUrl(config.uploader.route, file.name); (file as unknown as { url: string }).url = formatRootUrl(config.uploader.route, file.name);
if (files[i].thumbnail) {
(files[i].thumbnail as unknown as string) = formatRootUrl('/r', files[i].thumbnail.name);
}
} }
return res.json(files); return res.json(files);

View File

@@ -0,0 +1,40 @@
import prisma from 'lib/prisma';
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
if (req.method === 'DELETE') {
const fileIds = req.body.id as number[];
const existingFiles = await prisma.incompleteFile.findMany({
where: {
id: {
in: fileIds,
},
userId: user.id,
},
});
const incFiles = await prisma.incompleteFile.deleteMany({
where: {
id: {
in: existingFiles.map((x) => x.id),
},
},
});
return res.json(incFiles);
} else {
const files = await prisma.incompleteFile.findMany({
where: {
userId: user.id,
},
});
return res.json(files);
}
}
export default withZipline(handler, {
methods: ['GET', 'DELETE'],
user: true,
});

View File

@@ -27,11 +27,15 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
folderId: true, folderId: true,
size: true, size: true,
favorite: true, favorite: true,
thumbnail: true,
}, },
}); });
for (let i = 0; i !== files.length; ++i) { 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] as unknown as { url: string }).url = formatRootUrl(config.uploader.route, files[i].name);
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') if (req.query.filter && req.query.filter === 'media')

View File

@@ -38,7 +38,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
for (let i = 0; i !== urls.length; ++i) { for (let i = 0; i !== urls.length; ++i) {
(urls[i] as unknown as { url: string }).url = formatRootUrl( (urls[i] as unknown as { url: string }).url = formatRootUrl(
config.urls.route, config.urls.route,
urls[i].vanity ?? urls[i].id urls[i].vanity ?? urls[i].id,
); );
} }
return res.json(urls); return res.json(urls);

View File

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

View File

@@ -22,7 +22,13 @@ import { useRouter } from 'next/router';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
export { getServerSideProps } from 'middleware/getServerSideProps'; export { getServerSideProps } from 'middleware/getServerSideProps';
export default function Login({ title, user_registration, oauth_registration, oauth_providers: unparsed }) { export default function Login({
title,
user_registration,
oauth_registration,
bypass_local_login,
oauth_providers: unparsed,
}) {
const router = useRouter(); const router = useRouter();
// totp modal // totp modal
@@ -34,6 +40,9 @@ export default function Login({ title, user_registration, oauth_registration, oa
const oauth_providers = JSON.parse(unparsed); const oauth_providers = JSON.parse(unparsed);
const show_local_login =
router.query.local === 'true' || !(bypass_local_login && oauth_providers?.length > 0);
const icons = { const icons = {
GitHub: IconBrandGithub, GitHub: IconBrandGithub,
Discord: IconBrandDiscordFilled, Discord: IconBrandDiscordFilled,
@@ -99,6 +108,12 @@ export default function Login({ title, user_registration, oauth_registration, oa
useEffect(() => { useEffect(() => {
(async () => { (async () => {
// if the user includes `local=true` as a query param, show the login form
// otherwise, redirect to the oauth login if there is only one registered provider
if (bypass_local_login && oauth_providers?.length === 1 && router.query.local !== 'true') {
await router.push(oauth_providers[0].url);
}
const a = await fetch('/api/user'); const a = await fetch('/api/user');
if (a.ok) await router.push('/dashboard'); if (a.ok) await router.push('/dashboard');
})(); })();
@@ -152,7 +167,7 @@ export default function Login({ title, user_registration, oauth_registration, oa
<Center sx={{ height: '100vh' }}> <Center sx={{ height: '100vh' }}>
<Card radius='md'> <Card radius='md'>
<Title size={30} align='left'> <Title size={30} align='left'>
{title} {bypass_local_login ? ` Login to ${title} with` : title}
</Title> </Title>
{oauth_registration && ( {oauth_registration && (
@@ -165,7 +180,7 @@ export default function Login({ title, user_registration, oauth_registration, oa
variant='outline' variant='outline'
radius='md' radius='md'
fullWidth fullWidth
leftIcon={<Icon height={'15'} width={'15'} />} leftIcon={<Icon size='1rem' />}
my='xs' my='xs'
component={Link} component={Link}
href={url} href={url}
@@ -174,41 +189,42 @@ export default function Login({ title, user_registration, oauth_registration, oa
</Button> </Button>
))} ))}
</Group> </Group>
{show_local_login && <Divider my='xs' label='or' labelPosition='center' />}
<Divider my='xs' label='or' labelPosition='center' />
</> </>
)} )}
<form onSubmit={form.onSubmit((v) => onSubmit(v))}> {show_local_login && (
<TextInput <form onSubmit={form.onSubmit((v) => onSubmit(v))}>
my='xs' <TextInput
radius='md' my='xs'
size='md' radius='md'
id='username' size='md'
label='Username' id='username'
{...form.getInputProps('username')} label='Username'
/> {...form.getInputProps('username')}
<PasswordInput />
my='xs' <PasswordInput
radius='md' my='xs'
size='md' radius='md'
id='password' size='md'
label='Password' id='password'
{...form.getInputProps('password')} label='Password'
/> {...form.getInputProps('password')}
/>
<Group position='apart'> <Group position='apart'>
{user_registration && ( {user_registration && (
<Anchor size='xs' href='/auth/register' component={Link}> <Anchor size='xs' href='/auth/register' component={Link}>
Don&apos;t have an account? Register Don&apos;t have an account? Register
</Anchor> </Anchor>
)} )}
<Button size='sm' p='xs' radius='md' my='xs' type='submit' loading={loading}> <Button size='sm' p='xs' radius='md' my='xs' type='submit' loading={loading}>
Login Login
</Button> </Button>
</Group> </Group>
</form> </form>
)}
</Card> </Card>
</Center> </Center>
</> </>

View File

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

View File

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

View File

@@ -0,0 +1,42 @@
import { LoadingOverlay } from '@mantine/core';
import Layout from 'components/Layout';
import UserFiles from 'components/pages/Users/UserFiles';
import useLogin from 'hooks/useLogin';
import Head from 'next/head';
import { getServerSideProps as middlewareProps } from 'middleware/getServerSideProps';
import { GetServerSideProps } from 'next';
export default function UsersId(props) {
const { loading } = useLogin();
if (loading) return <LoadingOverlay visible={loading} />;
const title = `${props.title} - User - ${props.userId}`;
return (
<>
<Head>
<title>{title}</title>
</Head>
<Layout props={props}>
<UserFiles
userId={props.userId}
disableMediaPreview={props.disable_media_preview}
exifEnabled={props.exif_enabled}
compress={props.compress}
/>
</Layout>
</>
);
}
export const getServerSideProps: GetServerSideProps = async (context) => {
const { id } = context.params as { id: string };
// @ts-ignore
const { props } = await middlewareProps(context);
return {
props: {
userId: id,
...props,
},
};
};

View File

@@ -10,7 +10,7 @@ export default function UsersPage(props) {
if (loading) return <LoadingOverlay visible={loading} />; if (loading) return <LoadingOverlay visible={loading} />;
const title = `${props.title} - User`; const title = `${props.title} - Users`;
return ( return (
<> <>
<Head> <Head>

View File

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

View File

@@ -12,7 +12,8 @@ export default function OauthError({ error, provider }) {
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
setRemaining((remaining) => remaining - 1); if (remaining > 0) setRemaining((remaining) => remaining - 1);
else clearInterval(interval);
}, 1000); }, 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
@@ -43,7 +44,7 @@ export default function OauthError({ error, provider }) {
</Title> </Title>
<MutedText sx={{ fontSize: 40, fontWeight: 500 }}>{error}</MutedText> <MutedText sx={{ fontSize: 40, fontWeight: 500 }}>{error}</MutedText>
<MutedText> <MutedText>
Redirecting to login in {remaining} second{remaining === 1 ? 's' : ''} Redirecting to login in {remaining} second{remaining !== 1 ? 's' : ''}
</MutedText> </MutedText>
<Button component={Link} href='/dashboard'> <Button component={Link} href='/dashboard'>
Head to the Dashboard Head to the Dashboard

View File

@@ -1,7 +1,6 @@
import { Box, Button, Modal, PasswordInput } from '@mantine/core'; import { Box, Button, Modal, PasswordInput } from '@mantine/core';
import type { File } from '@prisma/client'; import type { File, Thumbnail } from '@prisma/client';
import AnchorNext from 'components/AnchorNext'; import AnchorNext from 'components/AnchorNext';
import config from 'lib/config';
import exts from 'lib/exts'; import exts from 'lib/exts';
import prisma from 'lib/prisma'; import prisma from 'lib/prisma';
import { parseString } from 'lib/utils/parser'; import { parseString } from 'lib/utils/parser';
@@ -11,24 +10,24 @@ import Head from 'next/head';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import zconfig from 'lib/config';
export default function EmbeddedFile({ export default function EmbeddedFile({
file, file,
user, user,
pass, pass,
prismRender, prismRender,
onDash, host,
compress, compress,
}: { }: {
file: File & { imageProps?: HTMLImageElement }; file: File & { imageProps?: HTMLImageElement; thumbnail: Thumbnail };
user: UserExtended; user: UserExtended;
pass: boolean; pass: boolean;
prismRender: boolean; prismRender: boolean;
onDash: boolean; host: string;
compress?: boolean; compress?: boolean;
}) { }) {
const dataURL = (route: string) => const dataURL = (route: string) => `${route}/${encodeURI(file.name)}?compress=${compress ?? false}`;
`${route}/${encodeURI(file.name)}?compress=${compress == null ? onDash : compress}`;
const router = useRouter(); const router = useRouter();
const [opened, setOpened] = useState(pass); const [opened, setOpened] = useState(pass);
@@ -58,7 +57,7 @@ export default function EmbeddedFile({
img.addEventListener('load', function () { img.addEventListener('load', function () {
if (this.naturalWidth > innerWidth) if (this.naturalWidth > innerWidth)
imageEl.width = Math.floor( imageEl.width = Math.floor(
this.naturalWidth * Math.min(innerHeight / this.naturalHeight, innerWidth / this.naturalWidth) this.naturalWidth * Math.min(innerHeight / this.naturalHeight, innerWidth / this.naturalWidth),
); );
else imageEl.width = this.naturalWidth; else imageEl.width = this.naturalWidth;
}); });
@@ -103,26 +102,37 @@ export default function EmbeddedFile({
{file.mimetype.startsWith('image') && ( {file.mimetype.startsWith('image') && (
<> <>
<meta property='og:type' content='image' /> <meta property='og:type' content='image' />
<meta property='og:image' itemProp='image' content={`/r/${file.name}`} /> <meta property='og:image' itemProp='image' content={`${host}/r/${file.name}`} />
<meta property='og:url' content={`/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:width' content={file.imageProps?.naturalWidth.toString()} />
<meta property='og:image:height' content={file.imageProps?.naturalHeight.toString()} /> <meta property='og:image:height' content={file.imageProps?.naturalHeight.toString()} />
<meta property='twitter:card' content='summary_large_image' /> <meta property='twitter:card' content='summary_large_image' />
<meta property='twitter:image' content={`${host}/r/${file.name}`} />
<meta property='twitter:title' content={file.name} />
</> </>
)} )}
{file.mimetype.startsWith('video') && ( {file.mimetype.startsWith('video') && (
<> <>
<meta name='twitter:card' content='player' /> <meta name='twitter:card' content='player' />
<meta name='twitter:player:stream' content={`/r/${file.name}`} /> <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:width' content='720' />
<meta name='twitter:player:height' content='480' /> <meta name='twitter:player:height' content='480' />
<meta name='twitter:player:stream:content_type' content={file.mimetype} /> <meta name='twitter:player:stream:content_type' content={file.mimetype} />
<meta name='twitter:title' content={file.name} /> <meta name='twitter:title' content={file.name} />
<meta property='og:url' content={`/r/${file.name}`} /> {file.thumbnail && (
<meta property='og:video' content={`/r/${file.name}`} /> <>
<meta property='og:video:url' content={`/r/${file.name}`} /> <meta name='twitter:image' content={`${host}/r/${file.thumbnail.name}`} />
<meta property='og:video:secure_url' content={`/r/${file.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:type' content={file.mimetype} />
<meta property='og:video:width' content='720' /> <meta property='og:video:width' content='720' />
<meta property='og:video:height' content='480' /> <meta property='og:video:height' content='480' />
@@ -131,19 +141,22 @@ export default function EmbeddedFile({
{file.mimetype.startsWith('audio') && ( {file.mimetype.startsWith('audio') && (
<> <>
<meta name='twitter:card' content='player' /> <meta name='twitter:card' content='player' />
<meta name='twitter:player:stream' content={`/r/${file.name}`} /> <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:player:stream:content_type' content={file.mimetype} />
<meta name='twitter:title' content={file.name} /> <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:type' content='music.song' />
<meta property='og:url' content={`/r/${file.name}`} /> <meta property='og:url' content={`${host}/r/${file.name}`} />
<meta property='og:audio' content={`/r/${file.name}`} /> <meta property='og:audio' content={`${host}/r/${file.name}`} />
<meta property='og:audio:secure_url' content={`/r/${file.name}`} /> <meta property='og:audio:secure_url' content={`${host}/r/${file.name}`} />
<meta property='og:audio:type' content={file.mimetype} /> <meta property='og:audio:type' content={file.mimetype} />
</> </>
)} )}
{!file.mimetype.startsWith('video') && !file.mimetype.startsWith('image') && ( {!file.mimetype.startsWith('video') && !file.mimetype.startsWith('image') && (
<meta property='og:url' content={`/r/${file.name}`} /> <meta property='og:url' content={`${host}/r/${file.name}`} />
)} )}
<title>{file.name}</title> <title>{file.name}</title>
</Head> </Head>
@@ -206,9 +219,27 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
where: { where: {
OR: [{ name: id }, { invisible: { invis: decodeURI(encodeURI(id)) } }], OR: [{ name: id }, { invisible: { invis: decodeURI(encodeURI(id)) } }],
}, },
include: {
thumbnail: true,
},
}); });
let host = context.req.headers.host;
if (!file) return { notFound: true }; if (!file) return { notFound: true };
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
)
host = `https://${host}`;
else host = `http://${host}`;
} catch (e) {
if (proto === 'https' || zconfig.core.return_https) host = `https://${host}`;
else host = `http://${host}`;
}
const user = await prisma.user.findFirst({ const user = await prisma.user.findFirst({
where: { where: {
id: file.userId, id: file.userId,
@@ -235,10 +266,11 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
if (file.password) file.password = true; if (file.password) file.password = true;
return { return {
props: { props: {
image: file, file,
user, user,
pass, pass,
prismRender: true, prismRender: true,
host,
}, },
}; };
} }
@@ -256,6 +288,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
props: { props: {
file, file,
user, user,
host,
}, },
}; };
} }
@@ -268,7 +301,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
file, file,
user, user,
pass: file.password ? true : false, pass: file.password ? true : false,
onDash: config.core.compression.on_dashboard, host,
compress, compress,
}, },
}; };

30
src/scripts/clear-temp.ts Normal file
View File

@@ -0,0 +1,30 @@
import config from 'lib/config';
import { readdir, rm } from 'fs/promises';
import { join } from 'path';
import { existsSync } from 'fs';
async function main() {
const temp = config.core.temp_directory;
if (!existsSync(temp)) {
console.log('Temp directory does not exist, exiting..');
process.exit(0);
}
const files = (await readdir(temp)).filter(
(x) => x.startsWith('zipline_partial_') || x.startsWith('zipline_thumb_'),
);
if (files.length === 0) {
console.log('No partial files found, exiting..');
process.exit(0);
} else {
for (const file of files) {
console.log(`Deleting ${file}`);
await rm(join(temp, file));
}
console.log('Done!');
process.exit(0);
}
}
main();

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