Compare commits

..

5 Commits

Author SHA1 Message Date
diced 88fdb2fcc1 fix: unecessary stuff 2023-05-29 17:55:39 -07:00
diced e92d78f671 fix: no thumbnailId 2023-05-28 22:20:15 -07:00
dicedtomato bcd2897c4e Merge branch 'trunk' into feature/vid-thumb 2023-05-28 21:38:49 -07:00
diced 24dacb478d feat: thumbnails final 2023-05-28 21:38:27 -07:00
diced 4893f4a09e feat: thumbnails workers 2023-05-23 22:46:46 -07:00
137 changed files with 5681 additions and 6734 deletions
+1 -3
View File
@@ -7,6 +7,4 @@ RUN usermod -l zipline node \
&& chmod 0440 /etc/sudoers.d/zipline \
&& sudo apt-get update && apt-get install gnupg2 -y
EXPOSE 3000
USER zipline
USER zipline
+1 -1
View File
@@ -41,7 +41,7 @@
"remoteUser": "zipline",
"updateRemoteUserUID": true,
"remoteEnv": {
"CORE_DATABASE_URL": "postgres://postgres:postgres@db/zip10"
"CORE_DATABASE_URL": "postgres://postgres:postgres@localhost/zip10"
},
"portsAttributes": {
"3000": {
-2
View File
@@ -2,8 +2,6 @@ node_modules/
.next/
uploads/
.git/
!.git/refs
!.git/HEAD
.yarn/*
!.yarn/releases
!.yarn/plugins
+10 -3
View File
@@ -1,13 +1,13 @@
# every field in here is optional except, CORE_SECRET and CORE_DATABASE_URL.
# if CORE_SECRET is still "changethis" then zipline will exit and tell you to change it.
# if using s3 make sure to uncomment or comment out the correct lines needed.
# if using s3/supabase make sure to uncomment or comment out the correct lines needed.
CORE_RETURN_HTTPS=true
CORE_SECRET="changethis"
CORE_HOST=0.0.0.0
CORE_PORT=3000
CORE_DATABASE_URL="postgres://postgres:postgres@db/zip10"
CORE_DATABASE_URL="postgres://postgres:postgres@localhost/zip10"
CORE_LOGGER=false
CORE_STATS_INTERVAL=1800
CORE_INVITES_INTERVAL=1800
@@ -27,6 +27,13 @@ DATASOURCE_LOCAL_DIRECTORY=./uploads
# DATASOURCE_S3_FORCE_S3_PATH=false
# DATASOURCE_S3_USE_SSL=false
# or supabase
# DATASOURCE_TYPE=supabase
# DATASOURCE_SUPABASE_KEY=xxx
# remember: no leading slash
# DATASOURCE_SUPABASE_URL=https://something.supabase.co
# DATASOURCE_SUPABASE_BUCKET=zipline
UPLOADER_DEFAULT_FORMAT=RANDOM
UPLOADER_ROUTE=/u
UPLOADER_LENGTH=6
@@ -40,4 +47,4 @@ URLS_LENGTH=6
RATELIMIT_USER=5
RATELIMIT_ADMIN=3
# for more variables checkout the docs
# for more variables checkout the docs
-3
View File
@@ -1,3 +0,0 @@
# These are supported funding model platforms
github: diced
+1 -1
View File
@@ -7,5 +7,5 @@ contact_links:
url: https://discord.gg/EAhCRfGxCF
about: Ask for help with anything related to Zipline!
- name: Zipline Docs
url: https://zipline.diced.sh
url: https://zipline.diced.tech
about: Maybe take a look a the docs?
+2 -2
View File
@@ -2,9 +2,9 @@ name: 'Build'
on:
push:
branches: [ v3 ]
branches: [ trunk ]
pull_request:
branches: [ v3 ]
branches: [ trunk ]
workflow_dispatch:
jobs:
+6 -14
View File
@@ -3,7 +3,7 @@ name: 'Push Release Docker Images'
on:
push:
tags:
- 'v3.*.*'
- 'v*.*.*'
paths:
- 'src/**'
- 'server/**'
@@ -13,8 +13,8 @@ on:
workflow_dispatch:
jobs:
push:
name: Push Release Image
push_to_ghcr:
name: Push Release Image to GitHub Packages
runs-on: ubuntu-latest
steps:
- name: Check out the repo
@@ -32,28 +32,20 @@ jobs:
id: buildx
uses: docker/setup-buildx-action@v2
- name: Login to GitHub Packages
- name: Login to Github Packages
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Docker Image
- name: Build Docker Image
uses: docker/build-push-action@v3
with:
push: true
platforms: linux/amd64,linux/arm64
tags: |
ghcr.io/diced/zipline:v3
ghcr.io/diced/zipline:latest
ghcr.io/diced/zipline:${{ steps.version.outputs.zipline_version }}
${{ secrets.DOCKERHUB_USERNAME }}/zipline:v3
${{ secrets.DOCKERHUB_USERNAME }}/zipline:${{ steps.version.outputs.zipline_version }}
cache-from: type=gha
cache-to: type=gha,mode=max
+7 -17
View File
@@ -2,7 +2,7 @@ name: 'Push Docker Images'
on:
push:
branches: [ v3 ]
branches: [ trunk ]
paths:
- 'src/**'
- 'server/**'
@@ -12,8 +12,8 @@ on:
workflow_dispatch:
jobs:
push:
name: Push Commit Image
push_to_ghcr:
name: Push Image to GitHub Packages
runs-on: ubuntu-latest
steps:
- name: Check out the repo
@@ -22,7 +22,7 @@ jobs:
- name: Get version
id: version
run: |
echo "zipline_commit=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
echo "zipline_version=$(jq .version package.json -r)" >> $GITHUB_OUTPUT
- name: Setup QEMU
uses: docker/setup-qemu-action@v2
@@ -38,23 +38,13 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Docker Image
- name: Build Docker Image
uses: docker/build-push-action@v3
with:
push: true
platforms: linux/amd64,linux/arm64
tags: |
ghcr.io/diced/zipline:v3-trunk
ghcr.io/diced/zipline:v3-trunk-${{ steps.version.outputs.zipline_commit }}
${{ secrets.DOCKERHUB_USERNAME }}/zipline:v3-trunk
${{ secrets.DOCKERHUB_USERNAME }}/zipline:v3-trunk-${{ steps.version.outputs.zipline_commit }}
ghcr.io/diced/zipline:trunk
ghcr.io/diced/zipline:trunk-${{ steps.version.outputs.zipline_version }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
+31
View File
@@ -0,0 +1,31 @@
name: 'Issue/PR Milestones'
on:
pull_request_target:
types: [opened, reopened]
issues:
types: [opened, reopened]
permissions:
issues: write
checks: write
contents: read
pull-requests: write
jobs:
set:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/github-script@v6
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
const milestone = 3
github.rest.issues.update({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
milestone
})
-1
View File
@@ -31,7 +31,6 @@ yarn-debug.log*
yarn-error.log*
# local env files
.env
.env.local
.env.development.local
.env.test.local
Vendored Regular → Executable
View File
+25 -28
View File
@@ -1,14 +1,22 @@
# Use the Prisma binaries image as the first stage
FROM ghcr.io/diced/prisma-binaries:5.1.x AS prisma
FROM ghcr.io/diced/prisma-binaries:4.10.x as prisma
# Use Alpine Linux as the second stage
FROM node:18-alpine3.16 AS base
FROM node:18-alpine3.16 as base
# Set the working directory
WORKDIR /zipline
# Copy the necessary files from the project
COPY prisma ./prisma
COPY src ./src
COPY next.config.js ./next.config.js
COPY tsup.config.ts ./tsup.config.ts
COPY tsconfig.json ./tsconfig.json
COPY mimes.json ./mimes.json
COPY public ./public
FROM base as builder
COPY .yarn ./.yarn
COPY package*.json ./
@@ -18,62 +26,51 @@ COPY .yarnrc.yml ./
# Copy the prisma binaries from prisma stage
COPY --from=prisma /prisma-engines /prisma-engines
ENV PRISMA_QUERY_ENGINE_BINARY=/prisma-engines/query-engine \
PRISMA_SCHEMA_ENGINE_BINARY=/prisma-engines/schema-engine \
PRISMA_MIGRATION_ENGINE_BINARY=/prisma-engines/migration-engine \
PRISMA_INTROSPECTION_ENGINE_BINARY=/prisma-engines/introspection-engine \
PRISMA_FMT_BINARY=/prisma-engines/prisma-fmt \
PRISMA_CLI_QUERY_ENGINE_TYPE=binary \
PRISMA_CLIENT_ENGINE_TYPE=binary \
ZIPLINE_DOCKER_BUILD=true \
NEXT_TELEMETRY_DISABLED=1
# Install production dependencies then temporarily save
RUN yarn workspaces focus --production --all
RUN cp -RL node_modules /tmp/node_modules
# Install the dependencies
RUN yarn install --immutable
FROM base AS builder
COPY .git/refs ./.git/refs
COPY .git/HEAD ./.git/HEAD
COPY src ./src
COPY next.config.js ./next.config.js
COPY tsup.config.ts ./tsup.config.ts
COPY tsconfig.json ./tsconfig.json
COPY mimes.json ./mimes.json
COPY public ./public
# Run the build
RUN yarn build
# Use Alpine Linux as the final image
FROM base
# Install the necessary packages
RUN apk add --no-cache perl procps tini
COPY --from=prisma /prisma-engines /prisma-engines
COPY --from=builder /prisma-engines /prisma-engines
ENV PRISMA_QUERY_ENGINE_BINARY=/prisma-engines/query-engine \
PRISMA_SCHEMA_ENGINE_BINARY=/prisma-engines/schema-engine \
PRISMA_MIGRATION_ENGINE_BINARY=/prisma-engines/migration-engine \
PRISMA_INTROSPECTION_ENGINE_BINARY=/prisma-engines/introspection-engine \
PRISMA_FMT_BINARY=/prisma-engines/prisma-fmt \
PRISMA_CLI_QUERY_ENGINE_TYPE=binary \
PRISMA_CLIENT_ENGINE_TYPE=binary \
ZIPLINE_DOCKER_BUILD=true \
NEXT_TELEMETRY_DISABLED=1
# Copy only the necessary files from the previous stage
COPY --from=builder /zipline/.git/refs ./.git/refs
COPY --from=builder /zipline/.git/HEAD ./.git/HEAD
COPY --from=builder /zipline/dist ./dist
COPY --from=builder /zipline/.next ./.next
COPY --from=builder /zipline/package.json ./package.json
COPY --from=builder /zipline/mimes.json ./mimes.json
COPY --from=builder /zipline/next.config.js ./next.config.js
COPY --from=builder /zipline/public ./public
COPY --from=builder /zipline/node_modules ./node_modules
COPY --from=builder /zipline/node_modules/.prisma/client ./node_modules/.prisma/client
COPY --from=builder /zipline/node_modules/@prisma/client ./node_modules/@prisma/client
# Copy Startup Script
COPY docker-entrypoint.sh /zipline
# Make Startup Script Executable
RUN chmod a+x /zipline/docker-entrypoint.sh && rm -rf /zipline/src
# Clean up
RUN rm -rf /tmp/* /root/*
RUN yarn cache clean --all
# Set the entrypoint to the startup script
ENTRYPOINT ["tini", "--", "/zipline/docker-entrypoint.sh"]
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2024 dicedtomato
Copyright (c) 2022 dicedtomato
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+16 -20
View File
@@ -35,9 +35,17 @@ A ShareX/file upload server that is easy to use, packed with features, and with
- User invites
- File Chunking (for large files)
- File deletion once it reaches a certain amount of views
- Automatic video thumbnail generation
- Easy setup instructions on [docs](https://zipl.vercel.app/) (One command install `docker compose up -d`)
<details>
<summary>View upstream documentation</summary>
The website below provides documentation for more up-to-date features with the upstream branch. The normal documentation is for the latest release and is not updated unless a new release is made.
[https://trunk.zipline.diced.tech/](https://trunk.zipline.diced.tech/)
</details>
<details>
<summary><h2>Screenshots (click)</h2></summary>
@@ -68,18 +76,17 @@ Ways you could generate the string could be from a password managers generator,
## Building & running from source
This section requires [nodejs](https://nodejs.org), [yarn](https://yarnpkg.com/).
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`.
This section requires [nodejs](https://nodejs.org), [yarn](https://yarnpkg.com/) or [npm](https://npmjs.com).
```shell
git clone https://github.com/diced/zipline
cd zipline
# npm install
yarn install
# npm run build
yarn build
# npm start
yarn start
```
@@ -112,7 +119,7 @@ This section requires [ShareX](https://www.getsharex.com/).
After navigating to Zipline, click on the top right corner where it says your username and click Manage Account. Scroll down to see "ShareX Config", select the one you would prefer using. After this you can import the .sxcu into sharex. [More information here](https://zipl.vercel.app/docs/guides/uploaders/sharex)
# Flameshot (Linux(Xorg/Wayland) and macOS)
# Flameshot (Linux)
This section requires [Flameshot](https://www.flameshot.org/), [jq](https://stedolan.github.io/jq/), and [xsel](https://github.com/kfish/xsel).
@@ -121,19 +128,12 @@ This section requires [Flameshot](https://www.flameshot.org/), [jq](https://sted
If using wayland you will need to have [wl-clipboard](https://github.com/bugaevc/wl-clipboard) installed, for the `wl-copy` command.
If you are not using GNOME/KDE/Qtile/Sway, and are using something like a wlroots-based or wlroots-compatible compositor (ex. [Hyprland](https://github.com/hyprwm/Hyprland/), [River](https://github.com/riverwm/river), etc), you will need to set the `XDG_CURRENT_DESKTOP` environment variable to `sway`, which will just override it for this script. Adding `export XDG_CURRENT_DESKTOP=sway` to the start of the script will work.
If you are not using GNOME/KDE/Qtile/Sway, and are using something like a wlroots-based compositor (ex. [Hyprland](https://github.com/hyprwm/Hyprland/), [River](https://github.com/riverwm/river), etc), you will need to set the `XDG_CURRENT_DESKTOP` environment variable to `sway`, which will just override it for this script. Adding `export XDG_CURRENT_DESKTOP=sway` to the start of the script will work.
After this, replace the `xsel -ib` with `wl-copy` in the script.
</details>
<details>
<summary>Mac instructions</summary>
If using macOS, you can replace the `xsel -ib` with `pbcopy` in the script.
</details>
You can either use the script below, or generate one directly from Zipline (just like how you can generate a ShareX config).
To upload files using flameshot we will use a script. Replace $TOKEN and $HOST with your own values, you probably know how to do this if you use linux.
@@ -141,7 +141,7 @@ To upload files using flameshot we will use a script. Replace $TOKEN and $HOST w
DATE=$(date '+%h_%Y_%d_%I_%m_%S.png');
flameshot gui -r > ~/Pictures/$DATE;
curl -H "Content-Type: multipart/form-data" -H "authorization: $TOKEN" -F file=@$1 $HOST/api/upload | jq -r '.files[0]' | xsel -ib
curl -H "Content-Type: multipart/form-data" -H "authorization: $TOKEN" -F file=@$1 $HOST/api/upload | jq -r 'files[0].url' | xsel -ib
```
# Contributing
@@ -166,7 +166,3 @@ Create a discussion on GitHub, please include the following:
## Pull Requests (contributions to the codebase)
Create a pull request on GitHub. If your PR does not pass the action checks, then please fix the errors. If your PR was submitted before a release, and I have pushed a new release, please make sure to update your PR to reflect any changes, usually this is handled by GitHub.
# Documentation
Documentation source code is located in [diced/zipline-docs](https://github.com/diced/zipline-docs), and can be accessed [here](https://zipline.diced.sh/docs/get-started).
+1
View File
@@ -1,3 +1,4 @@
version: '3'
services:
postgres:
image: postgres:15
+1
View File
@@ -1,3 +1,4 @@
version: '3'
services:
postgres:
image: postgres:15
-2
View File
@@ -2,6 +2,4 @@
set -e
unset ZIPLINE_DOCKER_BUILD
node --enable-source-maps dist/index.js
-3
View File
@@ -42,9 +42,6 @@
["afm", ["application/octet-stream"]],
["afp", ["application/vnd.ibm.modcap"]],
["ahead", ["application/vnd.ahead.space"]],
["ahk", ["text/autohotkey"]],
["ahk1", ["text/autohotkey"]],
["ahk2", ["text/autohotkey"]],
["ai", ["application/postscript"]],
["aif", ["audio/aiff"]],
["aifc", ["audio/aiff"]],
+55 -55
View File
@@ -1,6 +1,6 @@
{
"name": "zipline",
"version": "3.7.13",
"version": "3.7.0",
"license": "MIT",
"scripts": {
"dev": "npm-run-all build:server dev:run",
@@ -28,73 +28,73 @@
"scripts:clear-temp": "node --enable-source-maps dist/scripts/clear-temp"
},
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/server": "^11.11.0",
"@mantine/core": "6.x",
"@mantine/dropzone": "6.x",
"@mantine/form": "6.x",
"@mantine/hooks": "6.x",
"@mantine/modals": "6.x",
"@mantine/next": "6.x",
"@mantine/notifications": "6.x",
"@mantine/prism": "6.x",
"@mantine/spotlight": "6.x",
"@prisma/client": "^5.1.1",
"@prisma/internals": "^5.1.1",
"@prisma/migrate": "^5.1.1",
"@sapphire/shapeshift": "^3.9.3",
"@tabler/icons-react": "^2.41.0",
"@emotion/react": "^11.10.6",
"@emotion/server": "^11.10.0",
"@mantine/core": "^6.0.4",
"@mantine/dropzone": "^6.0.4",
"@mantine/form": "^6.0.4",
"@mantine/hooks": "^6.0.4",
"@mantine/modals": "^6.0.4",
"@mantine/next": "^6.0.4",
"@mantine/notifications": "^6.0.4",
"@mantine/prism": "^6.0.4",
"@mantine/spotlight": "^6.0.4",
"@prisma/client": "^4.10.1",
"@prisma/internals": "^4.10.1",
"@prisma/migrate": "^4.10.1",
"@sapphire/shapeshift": "^3.8.1",
"@tabler/icons-react": "^2.11.0",
"@tanstack/react-query": "^4.28.0",
"argon2": "^0.31.2",
"cookie": "^0.6.0",
"dayjs": "^1.11.10",
"dotenv": "^16.3.1",
"argon2": "^0.30.3",
"cookie": "^0.5.0",
"dayjs": "^1.11.7",
"dotenv": "^16.0.3",
"dotenv-expand": "^10.0.0",
"exiftool-vendored": "^23.4.0",
"fastify": "^4.24.3",
"fastify-plugin": "^4.5.1",
"fflate": "^0.8.1",
"ffmpeg-static": "^5.2.0",
"find-my-way": "^7.7.0",
"katex": "^0.16.9",
"mantine-datatable": "^2.9.14",
"minio": "^7.1.3",
"exiftool-vendored": "^21.2.0",
"fastify": "^4.15.0",
"fastify-plugin": "^4.5.0",
"fflate": "^0.7.4",
"ffmpeg-static": "^5.1.0",
"find-my-way": "^7.6.0",
"katex": "^0.16.4",
"mantine-datatable": "^2.2.6",
"minio": "^7.0.33",
"ms": "canary",
"multer": "^1.4.5-lts.1",
"next": "^14.0.3",
"next": "^13.2.4",
"otplib": "^12.0.1",
"prisma": "^5.1.1",
"prisma": "^4.10.1",
"prismjs": "^1.29.0",
"qrcode": "^1.5.3",
"qrcode": "^1.5.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^9.0.3",
"recharts": "^2.10.1",
"react-markdown": "^8.0.6",
"recharts": "^2.5.0",
"recoil": "^0.7.7",
"remark-gfm": "^4.0.1",
"sharp": "^0.32.6"
"remark-gfm": "^3.0.1",
"sharp": "^0.32.0"
},
"devDependencies": {
"@types/cookie": "^0.5.4",
"@types/katex": "^0.16.6",
"@types/minio": "^7.1.1",
"@types/multer": "^1.4.10",
"@types/node": "18",
"@types/qrcode": "^1.5.5",
"@types/react": "^18.2.37",
"@types/sharp": "^0.32.0",
"@typescript-eslint/eslint-plugin": "^6.11.0",
"@typescript-eslint/parser": "^6.11.0",
"@types/cookie": "^0.5.1",
"@types/katex": "^0.16.0",
"@types/minio": "^7.0.17",
"@types/multer": "^1.4.7",
"@types/node": "^18.15.10",
"@types/qrcode": "^1.5.0",
"@types/react": "^18.0.29",
"@types/sharp": "^0.31.1",
"@typescript-eslint/eslint-plugin": "^5.56.0",
"@typescript-eslint/parser": "^5.56.0",
"cross-env": "^7.0.3",
"eslint": "^8.54.0",
"eslint-config-next": "^14.0.3",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.1",
"eslint-plugin-unused-imports": "^3.0.0",
"eslint": "^8.36.0",
"eslint-config-next": "^13.2.4",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-unused-imports": "^2.0.0",
"npm-run-all": "^4.1.5",
"prettier": "^3.1.0",
"tsup": "^8.0.0",
"typescript": "^5.2.2"
"prettier": "^2.8.7",
"tsup": "^6.7.0",
"typescript": "^5.0.2"
},
"repository": {
"type": "git",
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "File" ALTER COLUMN "size" SET DATA TYPE BIGINT;
@@ -1,14 +0,0 @@
-- CreateTable
CREATE TABLE "Export" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"complete" BOOLEAN NOT NULL DEFAULT false,
"path" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
CONSTRAINT "Export_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Export" ADD CONSTRAINT "Export_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+2 -16
View File
@@ -27,20 +27,6 @@ model User {
Invite Invite[]
Folder Folder[]
IncompleteFile IncompleteFile[]
Exports Export[]
}
model Export {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
complete Boolean @default(false)
path String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
}
model Folder {
@@ -62,7 +48,7 @@ model File {
originalName String?
mimetype String @default("image/png")
createdAt DateTime @default(now())
size BigInt @default(0)
size Int @default(0)
expiresAt DateTime?
maxViews Int?
views Int @default(0)
@@ -77,7 +63,7 @@ model File {
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
folderId Int?
thumbnail Thumbnail?
thumbnail Thumbnail?
}
model Thumbnail {
+1 -1
View File
@@ -1498,4 +1498,4 @@ wheat
white
whitesmoke
yellow
yellowgreen
yellowgreen
+1 -1
View File
@@ -1747,4 +1747,4 @@ zigzagsalamander
zonetailedpigeon
zooplankton
zopilote
zorilla
zorilla
+1 -1
View File
@@ -125,7 +125,7 @@ export default function FileModal({
icon: <IconPhotoCancel size='1rem' />,
});
},
},
}
);
};
-3
View File
@@ -72,9 +72,6 @@ export default function File({
},
transition: 'filter 0.2s ease-in-out',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
shadow='md'
onClick={() => setOpen(true)}
+12 -30
View File
@@ -280,7 +280,7 @@ export default function Layout({ children, props }) {
component={Link}
href={link}
/>
),
)
)}
</Navbar.Section>
<Navbar.Section>
@@ -316,9 +316,7 @@ export default function Layout({ children, props }) {
variant='dot'
color={version.data.update ? 'red' : 'primary'}
>
{version.data.isUpstream
? version.data.versions.current.slice(0, 7)
: version.data.versions.current}
{version.data.versions.current}
</Badge>
</Tooltip>
</Navbar.Section>
@@ -351,22 +349,13 @@ export default function Layout({ children, props }) {
<Menu.Target>
<Button
leftIcon={
avatar ? (
<Image src={avatar} height={32} width={32} fit='cover' radius='md' />
) : (
<IconUserCog size='1rem' />
)
avatar ? <Image src={avatar} height={32} radius='md' /> : <IconUserCog size='1rem' />
}
variant='subtle'
color={theme.colorScheme === 'dark' ? 'dark' : 'gray'}
color='gray'
compact
size='xl'
p='sm'
styles={{
label: {
overflow: 'unset',
},
}}
>
{user.username}
</Button>
@@ -418,20 +407,16 @@ export default function Layout({ children, props }) {
</Menu.Item>
<Menu.Divider />
<>
{oauth_providers.filter(
(x) =>
user.oauth
?.map(({ provider }) => provider.toLowerCase())
.includes(x.name.toLowerCase()),
{oauth_providers.filter((x) =>
user.oauth?.map(({ provider }) => provider.toLowerCase()).includes(x.name.toLowerCase())
).length ? (
<Menu.Label>Connected Accounts</Menu.Label>
) : null}
{oauth_providers
.filter(
(x) =>
user.oauth
?.map(({ provider }) => provider.toLowerCase())
.includes(x.name.toLowerCase()),
.filter((x) =>
user.oauth
?.map(({ provider }) => provider.toLowerCase())
.includes(x.name.toLowerCase())
)
.map(({ name, Icon }, i) => (
<>
@@ -444,11 +429,8 @@ export default function Layout({ children, props }) {
</Menu.Item>
</>
))}
{oauth_providers.filter(
(x) =>
user.oauth
?.map(({ provider }) => provider.toLowerCase())
.includes(x.name.toLowerCase()),
{oauth_providers.filter((x) =>
user.oauth?.map(({ provider }) => provider.toLowerCase()).includes(x.name.toLowerCase())
).length ? (
<Menu.Divider />
) : null}
-12
View File
@@ -4,10 +4,6 @@ import { useEffect } from 'react';
import ayu_dark from 'lib/themes/ayu_dark';
import ayu_light from 'lib/themes/ayu_light';
import ayu_mirage from 'lib/themes/ayu_mirage';
import catppuccin_mocha from 'lib/themes/catppuccin_mocha';
import catppuccin_macchiato from 'lib/themes/catppuccin_macchiato';
import catppuccin_frappe from 'lib/themes/catppuccin_frappe';
import catppuccin_latte from 'lib/themes/catppuccin_latte';
import dark from 'lib/themes/dark';
import dark_blue from 'lib/themes/dark_blue';
import dracula from 'lib/themes/dracula';
@@ -36,10 +32,6 @@ export const themes = {
ayu_dark,
ayu_mirage,
ayu_light,
catppuccin_mocha,
catppuccin_macchiato,
catppuccin_frappe,
catppuccin_latte,
nord,
dracula,
matcha_dark_azul,
@@ -54,10 +46,6 @@ export const friendlyThemeName = {
ayu_dark: 'Ayu Dark',
ayu_mirage: 'Ayu Mirage',
ayu_light: 'Ayu Light',
catppuccin_mocha: 'Catppuccin Mocha',
catppuccin_macchiato: 'Catppuccin Macchiato',
catppuccin_frappe: 'Catppuccin Frappé',
catppuccin_latte: 'Catppuccin Latte',
nord: 'Nord',
dracula: 'Dracula',
matcha_dark_azul: 'Matcha Dark Azul',
+20 -24
View File
@@ -27,7 +27,7 @@ import PrismCode from './render/PrismCode';
function PlaceholderContent({ text, Icon }) {
return (
<Group sx={(t) => ({ color: t.colors.dark[2], padding: 3, justifyContent: 'center' })}>
<Group sx={(t) => ({ color: t.colors.dark[2] })}>
<Icon size={48} />
<Text size='md'>{text}</Text>
</Group>
@@ -58,27 +58,23 @@ function VideoThumbnailPlaceholder({ file, mediaPreview, ...props }) {
return <Placeholder Icon={IconPlayerPlay} text={`Click to view video (${file.name})`} {...props} />;
return (
<Box sx={{ position: 'relative' }}>
<Box>
<Image
src={typeof file.thumbnail === 'string' ? file.thumbnail : `/r/${file.thumbnail.name}`}
src={file.thumbnail}
sx={{
position: 'absolute',
width: '100%',
height: 'auto',
height: '100%',
objectFit: 'cover',
}}
/>
<Center
sx={{
position: 'absolute',
height: '100%',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
}}
>
<Center sx={{ position: 'absolute', width: '100%', height: '100%' }}>
<IconPlayerPlay size={48} />
</Center>
</Box>
// </Placeholder>
);
}
@@ -125,17 +121,6 @@ export default function Type({ file, popup = false, disableMediaPreview, ...prop
);
};
if (file.password) {
return (
<Placeholder
Icon={IconFileAlert}
text={`This file is password protected. Click to view file (${file.name})`}
onClick={() => window.open(file.url)}
{...props}
/>
);
}
if ((shouldRenderMarkdown || shouldRenderTex || shouldRenderCode) && !props.overrideRender && popup)
return (
<>
@@ -154,6 +139,17 @@ export default function Type({ file, popup = false, disableMediaPreview, ...prop
return <Placeholder Icon={IconFile} text={`Click to view file (${file.name})`} {...props} />;
}
if (file.password) {
return (
<Placeholder
Icon={IconFileAlert}
text={`This file is password protected. Click to view file (${file.name})`}
onClick={() => window.open(file.url)}
{...props}
/>
);
}
return popup ? (
media ? (
{
+1 -1
View File
@@ -12,7 +12,7 @@ export default function Dropzone({ loading, onDrop, children }) {
]}
>
<MantineDropzone loading={loading} onDrop={onDrop} styles={{ inner: { pointerEvents: 'none' } }}>
<Group position='center' spacing='xl' style={{ minHeight: 440, flexDirection: 'column' }}>
<Group position='center' spacing='xl' style={{ minHeight: 440 }}>
<IconPhoto size={80} />
<Text size='xl' inline>
@@ -1,56 +0,0 @@
import { Alert, Stack, Anchor, Code, Text } from '@mantine/core';
import { IconExclamationCircle } from '@tabler/icons-react';
import { useCallback, useEffect, useState } from 'react';
export default function Version4Notice() {
const key = 'zipline-v4-notice';
const [isClosed, setClosed] = useState<boolean | null>(null);
useEffect(() => {
const dismissed = localStorage.getItem(key) === 'true';
setClosed(dismissed);
}, [key]);
const handleDismiss = useCallback(() => {
setClosed(true);
localStorage.setItem(key, 'true');
}, [key]);
if (isClosed === null) return null;
if (isClosed) return null;
return (
<Alert
withCloseButton
variant='outline'
icon={<IconExclamationCircle size='1rem' />}
title='⚠️ Important! ⚠️'
p='md'
mb='md'
onClose={handleDismiss}
color='red'
>
<Stack spacing='md'>
<Text>
Zipline v4 will be released soon, and is <b>NOT</b> compatible with v3 (the current version). If you
are using external software to automatically update Zipline on new releases, it is{' '}
<b>strongly advised</b> that you stop auto-updates for the time being until v4 is released. For more
information, please visit{' '}
<Anchor target='_blank' href='https://github.com/diced/zipline/tree/v4'>
the <Code>v4</Code> branch
</Anchor>{' '}
on GitHub to view the progress of v4. If you have any questions, feel free to{' '}
<Anchor target='_blank' href='https://zipline.diced.sh/discord'>
join our discord
</Anchor>
.
</Text>
<Text>
If you are not the server administrator, please consider notifying them of this important message.
</Text>
</Stack>
</Alert>
);
}
-5
View File
@@ -22,7 +22,6 @@ import { useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil';
import RecentFiles from './RecentFiles';
import { StatCards } from './StatCards';
import Version4Notice from './Version4Notice';
export default function Dashboard({ disableMediaPreview, exifEnabled, compress }) {
const user = useRecoilValue(userSelector);
@@ -115,8 +114,6 @@ export default function Dashboard({ disableMediaPreview, exifEnabled, compress }
window.open(`${window.location.protocol}//${window.location.host}${file.url}`);
};
// local storage to whether to show alert or not
return (
<div>
{selectedFile && (
@@ -133,8 +130,6 @@ export default function Dashboard({ disableMediaPreview, exifEnabled, compress }
/>
)}
<Version4Notice />
<Title>Welcome back, {user?.username}</Title>
<MutedText size='md'>
You have <b>{numFiles === 0 ? '...' : numFiles}</b> files
+1 -1
View File
@@ -29,7 +29,7 @@ export default function FilePagation({ disableMediaPreview, exifEnabled, queryPa
},
},
undefined,
{ shallow: true },
{ shallow: true }
);
const { count } = await useFetch(`/api/user/paged?count=true${!checked ? '&filter=media' : ''}`);
+1 -5
View File
@@ -7,16 +7,12 @@ import Link from 'next/link';
import { useEffect, useState } from 'react';
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 }) {
const [checked] = useRecoilState(showNonMediaSelector);
const [favoritePage, setFavoritePage] = useState(1);
const [favoriteNumPages, setFavoriteNumPages] = useState(0);
const favoritePages = usePaginatedFiles(favoritePage, {
filter: checked ? 'none' : 'media',
filter: 'media',
favorite: true,
});
+40 -59
View File
@@ -50,12 +50,12 @@ function CreateInviteModal({ open, setOpen, updateInvites }) {
if (!expires.includes(values.expires)) return form.setFieldError('expires', 'Invalid expiration');
if (values.count < 1 || values.count > 100)
return form.setFieldError('count', 'Must be between 1 and 100');
const expiresAt = expireReadToDate(values.expires);
const expiresAt = values.expires === 'never' ? null : expireReadToDate(values.expires);
setOpen(false);
const res = await useFetch('/api/auth/invite', 'POST', {
expiresAt: `date=${expiresAt.toISOString()}`,
expiresAt: expiresAt === null ? null : `date=${expiresAt.toISOString()}`,
count: values.count,
});
@@ -95,6 +95,7 @@ function CreateInviteModal({ open, setOpen, updateInvites }) {
{ value: '3d', label: '3 days' },
{ value: '5d', label: '5 days' },
{ value: '7d', label: '7 days' },
{ value: 'never', label: 'Never' },
]}
/>
@@ -298,65 +299,45 @@ export default function Invites() {
/>
) : (
<SimpleGrid cols={3} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
{!ok && !invites.length && (
<>
{[1, 2, 3].map((x) => (
<Skeleton key={x} width='100%' height={100} radius='sm' />
))}
</>
)}
{invites.length && ok ? (
invites.map((invite) => (
<Card key={invite.id} sx={{ maxWidth: '100%' }}>
<Group position='apart'>
<Group position='left'>
<Avatar size='lg' color={invite.used ? 'dark' : 'primary'}>
{invite.id}
</Avatar>
<Stack spacing={0}>
<Title>
{invite.code}
{invite.used && <> (Used)</>}
</Title>
<Tooltip label={new Date(invite.createdAt).toLocaleString()}>
<div>
<MutedText size='sm'>Created {relativeTime(new Date(invite.createdAt))}</MutedText>
</div>
</Tooltip>
<Tooltip label={new Date(invite.expiresAt).toLocaleString()}>
<div>
<MutedText size='sm'>{expireText(invite.expiresAt.toString())}</MutedText>
</div>
</Tooltip>
{invites.length
? invites.map((invite) => (
<Card key={invite.id} sx={{ maxWidth: '100%' }}>
<Group position='apart'>
<Group position='left'>
<Avatar size='lg' color={invite.used ? 'dark' : 'primary'}>
{invite.id}
</Avatar>
<Stack spacing={0}>
<Title>
{invite.code}
{invite.used && <> (Used)</>}
</Title>
<Tooltip label={new Date(invite.createdAt).toLocaleString()}>
<div>
<MutedText size='sm'>
Created {relativeTime(new Date(invite.createdAt))}
</MutedText>
</div>
</Tooltip>
<Tooltip label={new Date(invite.expiresAt).toLocaleString()}>
<div>
<MutedText size='sm'>{expireText(invite.expiresAt.toString())}</MutedText>
</div>
</Tooltip>
</Stack>
</Group>
<Stack>
<ActionIcon aria-label='copy' onClick={() => handleCopy(invite)}>
<IconClipboardCopy size='1rem' />
</ActionIcon>
<ActionIcon aria-label='delete' onClick={() => openDeleteModal(invite)}>
<IconTrash size='1rem' />
</ActionIcon>
</Stack>
</Group>
<Stack>
<ActionIcon aria-label='copy' onClick={() => handleCopy(invite)}>
<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>
</>
)}
</Card>
))
: [1, 2, 3].map((x) => <Skeleton key={x} width='100%' height={100} radius='sm' />)}
</SimpleGrid>
)}
</>
+15 -7
View File
@@ -7,7 +7,7 @@ import { useState } from 'react';
export default function ClearStorage({ open, setOpen }) {
const [check, setCheck] = useState(false);
const handleDelete = async (orphaned?: boolean) => {
const handleDelete = async (datasource: boolean, orphaned?: boolean) => {
showNotification({
id: 'clear-uploads',
title: 'Clearing...',
@@ -16,7 +16,7 @@ export default function ClearStorage({ open, setOpen }) {
autoClose: false,
});
const res = await useFetch('/api/admin/clear', 'POST', { orphaned });
const res = await useFetch('/api/admin/clear', 'POST', { datasource, orphaned });
if (res.error) {
updateNotification({
@@ -65,13 +65,21 @@ export default function ClearStorage({ open, setOpen }) {
onClick={() => {
setOpen(false);
openConfirmModal({
title: 'Are you sure?',
confirmProps: { color: 'red' },
children: <Text size='sm'>This action is destructive and irreversible.</Text>,
labels: { confirm: 'Yes', cancel: 'No' },
title: 'Do you want to clear storage too?',
labels: { confirm: 'Yes', cancel: check ? 'Ok' : 'No' },
children: check && (
<Text size='sm' color='gray'>
Due to clearing orphaned files, storage clearing will be unavailable.
</Text>
),
confirmProps: { disabled: check },
onConfirm: () => {
closeAllModals();
handleDelete(check);
handleDelete(true);
},
onCancel: () => {
closeAllModals();
handleDelete(false, check);
},
onClose: () => setCheck(false),
});
+1 -1
View File
@@ -75,7 +75,7 @@ export default function Flameshot({ user, open, setOpen }) {
let shell;
if (values.type === 'upload-file') {
shell = `#!/bin/bash${values.wlCompositorNotSupported ? '\nexport XDG_CURRENT_DESKTOP=sway\n' : ''}
flameshot gui -r > /tmp/ss.png;if [ ! -s /tmp/ss.png ]; then\n exit 1\nfi
flameshot gui -r > /tmp/ss.png;
${curl.join(' ')}${values.noJSON ? '' : " | jq -r '.files[0]'"} | tr -d '\\n' | ${
values.wlCompatibility ? 'wl-copy' : 'xsel -ib'
};
+1 -1
View File
@@ -87,7 +87,7 @@ export default function ShareX({ user, open, setOpen }) {
const pseudoElement = document.createElement('a');
pseudoElement.setAttribute(
'href',
'data:application/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(config, null, '\t')),
'data:application/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(config, null, '\t'))
);
pseudoElement.setAttribute('download', `zipline${values.type === 'upload-file' ? '' : '-url'}.sxcu`);
pseudoElement.style.display = 'none';
+5 -163
View File
@@ -1,17 +1,14 @@
import {
ActionIcon,
Alert,
Anchor,
Box,
Button,
Card,
Code,
ColorInput,
CopyButton,
FileInput,
Group,
Image,
List,
PasswordInput,
SimpleGrid,
Space,
@@ -25,7 +22,6 @@ import { randomId, useInterval, useMediaQuery } from '@mantine/hooks';
import { useModals } from '@mantine/modals';
import { showNotification, updateNotification } from '@mantine/notifications';
import {
IconAlertCircle,
IconBrandDiscordFilled,
IconBrandGithubFilled,
IconBrandGoogle,
@@ -45,7 +41,6 @@ import {
IconUserExclamation,
IconUserMinus,
IconUserX,
IconX,
} from '@tabler/icons-react';
import AnchorNext from 'components/AnchorNext';
import { FlameshotIcon, ShareXIcon } from 'components/icons';
@@ -269,34 +264,14 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
setExports(
res.exports
?.map((s) => ({
date: new Date(s.createdAt),
date: new Date(Number(s.name.split('_')[3].slice(0, -4))),
size: s.size,
full: s.name,
}))
.sort((a, b) => a.date.getTime() - b.date.getTime()),
.sort((a, b) => a.date.getTime() - b.date.getTime())
);
};
const deleteExport = async (name) => {
const res = await useFetch('/api/user/export?name=' + name, 'DELETE');
if (res.error) {
showNotification({
title: 'Error deleting export',
message: res.error,
color: 'red',
icon: <IconX size='1rem' />,
});
} else {
showNotification({
message: 'Deleted export',
color: 'green',
icon: <IconFileZip size='1rem' />,
});
await getExports();
}
};
const handleDelete = async () => {
const res = await useFetch('/api/user/files', 'DELETE', {
all: true,
@@ -380,129 +355,6 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
}
};
const startFullExport = () => {
modals.openConfirmModal({
title: <Title>Are you sure?</Title>,
size: 'xl',
children: (
<Box px='md'>
<Alert color='red' icon={<IconAlertCircle size='1rem' />} title='Warning'>
This export contains a significant amount of sensitive data, including user information,
passwords, metadata, and system details. It is crucial to handle this file with care to prevent
unauthorized access or misuse. Ensure it is stored securely and shared only with trusted parties.
</Alert>
<p>
The export provides a snapshot of Zipline&apos;s data and environment. Specifically, it includes:
</p>
<List>
<List.Item>
<b>User Data:</b> Information about users, avatars, passwords, and registered OAuth providers.
</List.Item>
<List.Item>
<b>Files:</b> Metadata about uploaded files including filenames, passwords, sizes, and
timestamps, linked users. <i>(Note: the actual contents of the files are not included.)</i>
</List.Item>
<List.Item>
<b>URLs:</b> Metadata about shortened URLs, including the original URL, short URL, and vanity.
</List.Item>
<List.Item>
<b>Folders:</b> Metadata about folders, including names, visibility settings, and files.
</List.Item>
<List.Item>
<b>Thumbnails:</b> Metadata about thumbnails, includes the name and creation timestamp.{' '}
<i>(Actual image data is excluded.)</i>
</List.Item>
<List.Item>
<b>Invites:</b> Metadata about invites, includes the invite code, creator, and expiration date.
</List.Item>
<List.Item>
<b>Statistics:</b> Usage data that is used on the statistics page, including upload counts and
such.
</List.Item>
</List>
<p>
Additionally, the export captures <b>system-specific information</b>:
</p>
<List>
<List.Item>
<b>CPU Count:</b> The number of processing cores available on the host system.
</List.Item>
<List.Item>
<b>Hostname:</b> The network identifier of the host system.
</List.Item>
<List.Item>
<b>Architecture:</b> The hardware architecture (e.g., <Code>x86</Code>, <Code>arm</Code>) on
which Zipline is running.
</List.Item>
<List.Item>
<b>Platform:</b> The operating system platform (e.g., <Code>linux</Code>, <Code>darwin</Code>)
on which Zipline is running.
</List.Item>
<List.Item>
<b>Version:</b> The current version of the operating system (kernel version)
</List.Item>
<List.Item>
<b>Environment Variables:</b> The configuration settings and variables defined at the time of
execution.
</List.Item>
</List>
<p>
<i>Note:</i> By omitting the actual contents of files and thumbnails while including their
metadata, the export ensures it captures enough detail for migration to another instance, or for
v4.
</p>
</Box>
),
labels: { confirm: 'Yes', cancel: 'No' },
cancelProps: { color: 'red' },
onConfirm: async () => {
modals.closeAll();
showNotification({
title: 'Exporting all server data...',
message: 'This may take a while depending on the amount of data.',
loading: true,
id: 'export-all',
autoClose: false,
});
const res = await useFetch('/api/admin/export', 'GET');
if (res.error) {
updateNotification({
id: 'export-all',
title: 'Error exporting data',
message: res.error,
color: 'red',
icon: <IconFileExport size='1rem' />,
autoClose: true,
});
} else {
updateNotification({
title: 'Export created',
message: 'Your browser will prompt you to download a JSON file with all the server data.',
id: 'export-all',
color: 'green',
icon: <IconFileExport size='1rem' />,
autoClose: true,
});
const blob = new Blob([JSON.stringify(res)], { type: 'application/json' });
const a = document.createElement('a');
a.style.display = 'none';
const url = URL.createObjectURL(blob);
console.log(url, res);
a.setAttribute('download', `zipline_export_${Date.now()}.json`);
a.setAttribute('href', url);
a.click();
URL.revokeObjectURL(url);
}
},
});
};
const interval = useInterval(() => getExports(), 30000);
useEffect(() => {
getExports();
@@ -515,7 +367,8 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
<Title>Manage User</Title>
<MutedText size='md'>
Want to use variables in embed text? Visit{' '}
<AnchorNext href='https://zipline.diced.sh/docs/guides/variables'>the docs</AnchorNext> for variables
<AnchorNext href='https://zipline.diced.tech/docs/guides/variables'>the docs</AnchorNext> for
variables
</MutedText>
<TextInput
@@ -635,7 +488,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
{oauth_providers
.filter(
(x) =>
!user.oauth?.map(({ provider }) => provider.toLowerCase()).includes(x.name.toLowerCase()),
!user.oauth?.map(({ provider }) => provider.toLowerCase()).includes(x.name.toLowerCase())
)
.map(({ link_url, name, Icon }, i) => (
<Button key={i} size='lg' leftIcon={<Icon />} component={Link} href={link_url} my='sm'>
@@ -728,7 +581,6 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
{ id: 'name', name: 'Name' },
{ id: 'date', name: 'Date' },
{ id: 'size', name: 'Size' },
{ id: 'actions', name: '' },
]}
rows={
exports
@@ -740,11 +592,6 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
),
date: x.date.toLocaleString(),
size: bytesToHuman(x.size),
actions: (
<ActionIcon onClick={() => deleteExport(x.full)}>
<IconTrash size='1rem' />
</ActionIcon>
),
}))
: []
}
@@ -769,11 +616,6 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
>
Delete all uploads
</Button>
{user.superAdmin && (
<Button size='md' onClick={startFullExport} rightIcon={<IconFileExport size='1rem' />}>
Export all server data (JSON)
</Button>
)}
</Group>
</Box>
)}
+29 -70
View File
@@ -1,26 +1,19 @@
import { Anchor, Button, Collapse, Group, Progress, Stack, Text, Title } from '@mantine/core';
import { Button, Collapse, Group, Progress, Stack, Title } from '@mantine/core';
import { randomId, useClipboard } from '@mantine/hooks';
import { useModals } from '@mantine/modals';
import { hideNotification, showNotification, updateNotification } from '@mantine/notifications';
import {
IconClipboardCopy,
IconFileImport,
IconFileTime,
IconFileUpload,
IconFileX,
} from '@tabler/icons-react';
import { showNotification, updateNotification } from '@mantine/notifications';
import { IconFileImport, IconFileTime, IconFileUpload, IconFileX } from '@tabler/icons-react';
import Dropzone from 'components/dropzone/Dropzone';
import FileDropzone from 'components/dropzone/DropzoneFile';
import MutedText from 'components/MutedText';
import { invalidateFiles } from 'lib/queries/files';
import { userSelector } from 'lib/recoil/user';
import { expireReadToDate, randomChars } from 'lib/utils/client';
import { useCallback, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil';
import showFilesModal from './showFilesModal';
import useUploadOptions from './useUploadOptions';
import { useRouter } from 'next/router';
import AnchorNext from 'components/AnchorNext';
export default function File({ chunks: chunks_config }) {
const router = useRouter();
@@ -35,29 +28,23 @@ export default function File({ chunks: chunks_config }) {
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 beforeUnload = (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;
}
};
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';
}
const beforeRouteChange = (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(() => {
const listener = (e: ClipboardEvent) => {
@@ -75,24 +62,16 @@ export default function File({ chunks: chunks_config }) {
};
document.addEventListener('paste', listener);
window.addEventListener('beforeunload', beforeUnload, true);
window.addEventListener('beforeunload', beforeUnload);
router.events.on('routeChangeStart', beforeRouteChange);
return () => {
window.removeEventListener('beforeunload', beforeUnload, true);
window.removeEventListener('beforeunload', beforeUnload);
router.events.off('routeChangeStart', beforeRouteChange);
document.removeEventListener('paste', listener);
};
}, [loading, beforeUnload, beforeRouteChange]);
const handleChunkedFiles = async (expiresAt: Date, toChunkFiles: File[]) => {
if (!chunks_config.enabled)
return showNotification({
id: 'upload-chunked',
title: 'Chunked files are disabled',
message: 'This should not be called, but some how got called...',
color: 'red',
});
for (let i = 0; i !== toChunkFiles.length; ++i) {
const file = toChunkFiles[i];
const identifier = randomChars(4);
@@ -146,34 +125,15 @@ export default function File({ chunks: chunks_config }) {
updateNotification({
id: 'upload-chunked',
title: 'Finalizing partial upload',
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>
),
message:
'The upload has been offloaded, and will complete in the background. You can see processing files in the files tab.',
icon: <IconFileTime size='1rem' />,
color: 'green',
autoClose: false,
autoClose: true,
});
invalidateFiles();
setFiles([]);
setProgress(100);
setLoading(false);
setTimeout(() => setProgress(0), 1000);
}
@@ -191,7 +151,7 @@ export default function File({ chunks: chunks_config }) {
ready = false;
}
},
false,
false
);
req.open('POST', '/api/upload');
@@ -230,10 +190,10 @@ export default function File({ chunks: chunks_config }) {
for (let i = 0; i !== files.length; ++i) {
const file = files[i];
if (chunks_config.enabled && file.size >= chunks_config.max_size) {
if (file.size >= chunks_config.max_size) {
toChunkFiles.push(file);
} else {
body.append('file', files[i], encodeURIComponent(files[i].name));
body.append('file', files[i]);
}
}
@@ -307,7 +267,7 @@ export default function File({ chunks: chunks_config }) {
}
setProgress(0);
},
false,
false
);
if (bodyLength !== 0) {
@@ -364,8 +324,7 @@ export default function File({ chunks: chunks_config }) {
<Button
leftIcon={<IconFileUpload size='1rem' />}
onClick={handleUpload}
loading={loading}
disabled={files.length === 0 || loading}
disabled={files.length === 0 ? true : false}
>
Upload
</Button>
+1 -16
View File
@@ -22,7 +22,6 @@ export default function Text() {
const [value, setValue] = useState('');
const [lang, setLang] = useState('txt');
const [loading, setLoading] = useState(false);
const [options, setOpened, OptionsModal] = useUploadOptions();
@@ -30,9 +29,6 @@ export default function Text() {
const shouldRenderTex = lang === 'tex';
const handleUpload = async () => {
if (value.trim().length === 0) return;
setLoading(true);
const file = new File([value], 'text.' + lang);
const expiresAt = options.expires === 'never' ? null : expireReadToDate(options.expires);
@@ -57,16 +53,6 @@ export default function Text() {
message: '',
});
showFilesModal(clipboard, modals, json.files);
setLoading(false);
setValue('');
} else {
updateNotification({
id: 'upload-text',
title: 'Upload Failed',
message: json.error,
color: 'red',
});
setLoading(false);
}
});
@@ -150,8 +136,7 @@ export default function Text() {
<Button
leftIcon={<IconFileUpload size='1rem' />}
onClick={handleUpload}
disabled={value.trim().length === 0 || loading}
loading={loading}
disabled={value.trim().length === 0 ? true : false}
>
Upload
</Button>
@@ -213,7 +213,7 @@ export function OptionsModal({
export default function useUploadOptions(): [
UploadOptionsState,
Dispatch<SetStateAction<boolean>>,
ReactNode,
ReactNode
] {
const [state, setState] = useReducer((state, newState) => ({ ...state, ...newState }), {
expires: 'never',
@@ -26,7 +26,7 @@ export function CreateUserModal({ open, setOpen, updateUsers }) {
};
setOpen(false);
const res = await useFetch('/api/auth/register', 'POST', data);
const res = await useFetch('/api/auth/create', 'POST', data);
if (res.error) {
showNotification({
title: 'Failed to create user',
+1 -1
View File
@@ -1,5 +1,5 @@
import { ActionIcon, Button, Center, Group, SimpleGrid, Title } from '@mantine/core';
import type { File } from '@prisma/client';
import { File } from '@prisma/client';
import { IconArrowLeft, IconFile } from '@tabler/icons-react';
import FileComponent from 'components/File';
import MutedText from 'components/MutedText';
+8 -15
View File
@@ -7,23 +7,16 @@ import { Language } from 'prism-react-renderer';
export default function Markdown({ code, ...props }) {
return (
<ReactMarkdown
remarkPlugins={[[remarkGfm, { singleTilde: false }]]}
remarkPlugins={[remarkGfm]}
components={{
code({ children }) {
return <Code>{children}</Code>;
},
pre({ children }) {
// @ts-expect-error someone find the type for this :sob:
const match = /language-(\w+)/.exec(children.props?.className || '');
// @ts-ignore
if (!children.props?.children) return code;
return (
<Prism language={match ? (match[1] as Language) : 'markdown'}>
{
// @ts-expect-error
String(children.props?.children).replace(/\n$/, '')
}
code({ inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');
return !inline && match ? (
<Prism language={match[1] as Language} {...props}>
{String(children).replace(/\n$/, '')}
</Prism>
) : (
<Code {...props}>{children}</Code>
);
},
img(props) {
+1 -1
View File
@@ -15,7 +15,7 @@ export default function PrismCode({ code, ext, ...props }) {
return (
<Prism
sx={(t) => ({ height: '100vh', overflow: 'scroll', backgroundColor: t.colors.dark[8] })}
sx={(t) => ({ height: '100vh', backgroundColor: t.colors.dark[8] })}
withLineNumbers
language={exts[ext]?.toLowerCase()}
{...props}
+8 -8
View File
@@ -20,9 +20,10 @@ export interface ConfigCompression {
}
export interface ConfigDatasource {
type: 'local' | 's3';
type: 'local' | 's3' | 'supabase';
local: ConfigLocalDatasource;
s3?: ConfigS3Datasource;
supabase?: ConfigSupabaseDatasource;
}
export interface ConfigLocalDatasource {
@@ -40,6 +41,12 @@ export interface ConfigS3Datasource {
region?: string;
}
export interface ConfigSupabaseDatasource {
url: string;
key: string;
bucket: string;
}
export interface ConfigUploader {
default_format: string;
route: string;
@@ -50,7 +57,6 @@ export interface ConfigUploader {
format_date: string;
default_expiration: string;
assume_mimetypes: boolean;
random_words_separator: string;
}
export interface ConfigUrls {
@@ -117,9 +123,6 @@ export interface ConfigFeatures {
default_avatar: string;
robots_txt: string;
thumbnails: boolean;
gif_thumbnails: boolean;
}
export interface ConfigOAuth {
@@ -130,12 +133,9 @@ export interface ConfigOAuth {
discord_client_id?: string;
discord_client_secret?: string;
discord_redirect_uri?: string;
discord_whitelisted_users?: string[];
google_client_id?: string;
google_client_secret?: string;
google_redirect_uri?: string;
}
export interface ConfigChunks {
+4 -7
View File
@@ -85,6 +85,10 @@ export default function readConfig() {
map('DATASOURCE_S3_REGION', 'string', 'datasource.s3.region'),
map('DATASOURCE_S3_USE_SSL', 'boolean', 'datasource.s3.use_ssl'),
map('DATASOURCE_SUPABASE_URL', 'string', 'datasource.supabase.url'),
map('DATASOURCE_SUPABASE_KEY', 'string', 'datasource.supabase.key'),
map('DATASOURCE_SUPABASE_BUCKET', 'string', 'datasource.supabase.bucket'),
map('UPLOADER_DEFAULT_FORMAT', 'string', 'uploader.default_format'),
map('UPLOADER_ROUTE', 'string', 'uploader.route'),
map('UPLOADER_LENGTH', 'number', 'uploader.length'),
@@ -94,7 +98,6 @@ export default function readConfig() {
map('UPLOADER_FORMAT_DATE', 'string', 'uploader.format_date'),
map('UPLOADER_DEFAULT_EXPIRATION', 'string', 'uploader.default_expiration'),
map('UPLOADER_ASSUME_MIMETYPES', 'boolean', 'uploader.assume_mimetypes'),
map('UPLOADER_RANDOM_WORDS_SEPARATOR', 'string', 'uploader.random_words_separator'),
map('URLS_ROUTE', 'string', 'urls.route'),
map('URLS_LENGTH', 'number', 'urls.length'),
@@ -143,12 +146,9 @@ export default function readConfig() {
map('OAUTH_DISCORD_CLIENT_ID', 'string', 'oauth.discord_client_id'),
map('OAUTH_DISCORD_CLIENT_SECRET', 'string', 'oauth.discord_client_secret'),
map('OAUTH_DISCORD_REDIRECT_URI', 'string', 'oauth.discord_redirect_uri'),
map('OAUTH_DISCORD_WHITELISTED_USERS', 'array', 'oauth.discord_whitelisted_users'),
map('OAUTH_GOOGLE_CLIENT_ID', 'string', 'oauth.google_client_id'),
map('OAUTH_GOOGLE_CLIENT_SECRET', 'string', 'oauth.google_client_secret'),
map('OAUTH_GOOGLE_REDIRECT_URI', 'string', 'oauth.google_redirect_uri'),
map('FEATURES_INVITES', 'boolean', 'features.invites'),
map('FEATURES_INVITES_LENGTH', 'number', 'features.invites_length'),
@@ -163,9 +163,6 @@ export default function readConfig() {
map('FEATURES_ROBOTS_TXT', 'boolean', 'features.robots_txt'),
map('FEATURES_THUMBNAILS', 'boolean', 'features.thumbnails'),
map('FEATURES_GIF_THUMBNAILS', 'boolean', 'features.gif_thumbnails'),
map('CHUNKS_MAX_SIZE', 'human-to-byte', 'chunks.max_size'),
map('CHUNKS_CHUNKS_SIZE', 'human-to-byte', 'chunks.chunks_size'),
map('CHUNKS_ENABLED', 'boolean', 'chunks.enabled'),
+35 -24
View File
@@ -51,7 +51,7 @@ const validator = s.object({
}),
datasource: s
.object({
type: s.enum('local', 's3').default('local'),
type: s.enum('local', 's3', 'supabase').default('local'),
local: s
.object({
directory: s.string.default(resolve('./uploads')).transform((v) => resolve(v)),
@@ -69,6 +69,11 @@ const validator = s.object({
region: s.string.default('us-east-1'),
use_ssl: s.boolean.default(false),
}).optional,
supabase: s.object({
url: s.string,
key: s.string,
bucket: s.string,
}).optional,
})
.default({
type: 'local',
@@ -92,7 +97,6 @@ const validator = s.object({
format_date: s.string.default('YYYY-MM-DD_HH:mm:ss'),
default_expiration: s.string.optional.default(null),
assume_mimetypes: s.boolean.default(false),
random_words_separator: s.string.default('-'),
})
.default({
default_format: 'RANDOM',
@@ -136,11 +140,11 @@ const validator = s.object({
s.object({
label: s.string,
link: s.string,
}),
})
)
.default([
{ label: 'Zipline', link: 'https://github.com/diced/zipline' },
{ label: 'Documentation', link: 'https://zipline.diced.sh/' },
{ label: 'Documentation', link: 'https://zipline.diced.tech/' },
]),
})
.default({
@@ -151,7 +155,7 @@ const validator = s.object({
external_links: [
{ label: 'Zipline', link: 'https://github.com/diced/zipline' },
{ label: 'Documentation', link: 'https://zipline.diced.sh/' },
{ label: 'Documentation', link: 'https://zipline.diced.tech/' },
],
}),
discord: s
@@ -172,12 +176,9 @@ const validator = s.object({
discord_client_id: s.string.nullable.default(null),
discord_client_secret: s.string.nullable.default(null),
discord_redirect_uri: s.string.nullable.default(null),
discord_whitelisted_users: s.string.array.default([]),
google_client_id: s.string.nullable.default(null),
google_client_secret: s.string.nullable.default(null),
google_redirect_uri: s.string.nullable.default(null),
})
.nullish.default(null),
features: s
@@ -190,8 +191,6 @@ const validator = s.object({
headless: s.boolean.default(false),
default_avatar: s.string.nullable.default(null),
robots_txt: s.boolean.default(false),
thumbnails: s.boolean.default(false),
gif_thumbnails: s.boolean.default(false),
})
.default({
invites: false,
@@ -202,8 +201,6 @@ const validator = s.object({
headless: false,
default_avatar: null,
robots_txt: false,
thumbnails: false,
gif_thumbnails: false,
}),
chunks: s
.object({
@@ -250,29 +247,43 @@ export default function validate(config): Config {
logger.debug(`Attemping to validate ${JSON.stringify(config)}`);
const validated = validator.parse(config);
logger.debug(`Recieved config: ${JSON.stringify(validated)}`);
switch (validated.datasource.type) {
case 's3': {
const errors = [];
if (!validated.datasource.s3.access_key_id)
errors.push('datasource.s3.access_key_id is a required field');
if (!validated.datasource.s3.secret_access_key)
errors.push('datasource.s3.secret_access_key is a required field');
if (!validated.datasource.s3.bucket) errors.push('datasource.s3.bucket is a required field');
if (!validated.datasource.s3.endpoint) errors.push('datasource.s3.endpoint is a required field');
if (errors.length) throw { errors };
break;
}
case 'supabase': {
const errors = [];
if (validated.datasource.type === 's3') {
const errors = [];
if (!validated.datasource.s3.access_key_id)
errors.push('datasource.s3.access_key_id is a required field');
if (!validated.datasource.s3.secret_access_key)
errors.push('datasource.s3.secret_access_key is a required field');
if (!validated.datasource.s3.bucket) errors.push('datasource.s3.bucket is a required field');
if (!validated.datasource.s3.endpoint) errors.push('datasource.s3.endpoint is a required field');
if (errors.length) throw { errors };
if (!validated.datasource.supabase.key) errors.push('datasource.supabase.key is a required field');
if (!validated.datasource.supabase.url) errors.push('datasource.supabase.url is a required field');
if (!validated.datasource.supabase.bucket)
errors.push('datasource.supabase.bucket is a required field');
if (errors.length) throw { errors };
break;
}
}
const reserved = new RegExp(/^\/(view|code|folder|auth|r)(\/\S*)?$|^\/(api|dashboard)(\/\S*)*/);
if (reserved.exec(validated.uploader.route))
const reserved = ['/view', '/dashboard', '/code', '/folder', '/api', '/auth'];
if (reserved.some((r) => validated.uploader.route.startsWith(r))) {
throw {
errors: [`The uploader route cannot be ${validated.uploader.route}, this is a reserved route.`],
show: true,
};
if (reserved.exec(validated.urls.route))
} else if (reserved.some((r) => validated.urls.route.startsWith(r))) {
throw {
errors: [`The urls route cannot be ${validated.urls.route}, this is a reserved route.`],
show: true,
};
}
return validated as unknown as Config;
} catch (e) {
+5 -1
View File
@@ -1,5 +1,5 @@
import config from './config';
import { Datasource, Local, S3 } from './datasources';
import { Datasource, Local, S3, Supabase } from './datasources';
import Logger from './logger';
const logger = Logger.get('datasource');
@@ -14,6 +14,10 @@ if (!global.datasource) {
global.datasource = new Local(config.datasource.local.directory);
logger.info(`using Local(${config.datasource.local.directory}) datasource`);
break;
case 'supabase':
global.datasource = new Supabase(config.datasource.supabase);
logger.info(`using Supabase(${config.datasource.supabase.bucket}) datasource`);
break;
default:
throw new Error('Invalid datasource type');
}
+2 -3
View File
@@ -3,11 +3,10 @@ import { Readable } from 'stream';
export abstract class Datasource {
public name: string;
public abstract save(file: string, data: Buffer, options?: { type: string }): Promise<void>;
public abstract save(file: string, data: Buffer): Promise<void>;
public abstract delete(file: string): Promise<void>;
public abstract clear(): Promise<void>;
public abstract size(file: string): Promise<number | null>;
public abstract size(file: string): Promise<number>;
public abstract get(file: string): Readable | Promise<Readable>;
public abstract fullSize(): Promise<number>;
public abstract range(file: string, start: number, end: number): Promise<Readable>;
}
+4 -11
View File
@@ -11,11 +11,11 @@ export class Local extends Datasource {
}
public async save(file: string, data: Buffer): Promise<void> {
await writeFile(join(this.path, file), Uint8Array.from(data));
await writeFile(join(this.path, file), data);
}
public async delete(file: string): Promise<void> {
await rm(join(this.path, file), { force: true });
await rm(join(this.path, file));
}
public async clear(): Promise<void> {
@@ -37,9 +37,9 @@ export class Local extends Datasource {
}
}
public async size(file: string): Promise<number | null> {
public async size(file: string): Promise<number> {
const full = join(this.path, file);
if (!existsSync(full)) return null;
if (!existsSync(full)) return 0;
const stats = await stat(full);
return stats.size;
@@ -56,11 +56,4 @@ export class Local extends Datasource {
return size;
}
public async range(file: string, start: number, end: number): Promise<ReadStream> {
const path = join(this.path, file);
const readStream = createReadStream(path, { start, end });
return readStream;
}
}
+10 -32
View File
@@ -1,7 +1,7 @@
import { Datasource } from '.';
import { PassThrough, Readable } from 'stream';
import { Readable } from 'stream';
import { ConfigS3Datasource } from 'lib/config/Config';
import { BucketItemStat, Client } from 'minio';
import { Client } from 'minio';
export class S3 extends Datasource {
public name = 'S3';
@@ -20,18 +20,12 @@ export class S3 extends Datasource {
});
}
public async save(file: string, data: Buffer, options?: { type: string }): Promise<void> {
await this.s3.putObject(
this.config.bucket,
file,
new PassThrough().end(data),
data.byteLength,
options ? { 'Content-Type': options.type } : undefined,
);
public async save(file: string, data: Buffer): Promise<void> {
await this.s3.putObject(this.config.bucket, file, data);
}
public async delete(file: string): Promise<void> {
await this.s3.removeObject(this.config.bucket, file, { forceDelete: true });
await this.s3.removeObject(this.config.bucket, file);
}
public async clear(): Promise<void> {
@@ -55,17 +49,12 @@ export class S3 extends Datasource {
});
}
public size(file: string): Promise<number | null> {
public size(file: string): Promise<number> {
return new Promise((res) => {
this.s3.statObject(
this.config.bucket,
file,
// @ts-expect-error this callback is not in the types but the code for it is there
(err: unknown, stat: BucketItemStat) => {
if (err) res(null);
else res(stat.size);
},
);
this.s3.statObject(this.config.bucket, file, (err, stat) => {
if (err) res(0);
else res(stat.size);
});
});
}
@@ -81,15 +70,4 @@ export class S3 extends Datasource {
});
});
}
public async range(file: string, start: number, end: number): Promise<Readable> {
return new Promise((res) => {
this.s3.getPartialObject(this.config.bucket, file, start, end, (err, stream) => {
if (err) {
console.log(err);
res(null);
} else res(stream);
});
});
}
}
+140
View File
@@ -0,0 +1,140 @@
import { Datasource } from '.';
import { ConfigSupabaseDatasource } from 'lib/config/Config';
import { guess } from 'lib/mimes';
import Logger from 'lib/logger';
import { Readable } from 'stream';
export class Supabase extends Datasource {
public name = 'Supabase';
public logger: Logger = Logger.get('datasource::supabase');
public constructor(public config: ConfigSupabaseDatasource) {
super();
}
public async save(file: string, data: Buffer): Promise<void> {
const mimetype = await guess(file.split('.').pop());
const r = await fetch(`${this.config.url}/storage/v1/object/${this.config.bucket}/${file}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.config.key}`,
'Content-Type': mimetype,
},
body: data,
});
const j = await r.json();
if (j.error) this.logger.error(`${j.error}: ${j.message}`);
}
public async delete(file: string): Promise<void> {
await fetch(`${this.config.url}/storage/v1/object/${this.config.bucket}/${file}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${this.config.key}`,
},
});
}
public async clear(): Promise<void> {
try {
const resp = await fetch(`${this.config.url}/storage/v1/object/list/${this.config.bucket}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.config.key}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
prefix: '',
}),
});
const objs = await resp.json();
if (objs.error) throw new Error(`${objs.error}: ${objs.message}`);
const res = await fetch(`${this.config.url}/storage/v1/object/${this.config.bucket}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${this.config.key}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
prefixes: objs.map((x: { name: string }) => x.name),
}),
});
const j = await res.json();
if (j.error) throw new Error(`${j.error}: ${j.message}`);
return;
} catch (e) {
this.logger.error(e);
}
}
public async get(file: string): Promise<Readable> {
// get a readable stream from the request
const r = await fetch(`${this.config.url}/storage/v1/object/${this.config.bucket}/${file}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${this.config.key}`,
},
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return Readable.fromWeb(r.body as any);
}
public size(file: string): Promise<number> {
return new Promise(async (res) => {
fetch(`${this.config.url}/storage/v1/object/list/${this.config.bucket}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.config.key}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
prefix: '',
search: file,
}),
})
.then((r) => r.json())
.then((j) => {
if (j.error) {
this.logger.error(`${j.error}: ${j.message}`);
res(0);
}
if (j.length === 0) {
res(0);
} else {
res(j[0].metadata.size);
}
});
});
}
public async fullSize(): Promise<number> {
return new Promise((res) => {
fetch(`${this.config.url}/storage/v1/object/list/${this.config.bucket}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.config.key}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
prefix: '',
}),
})
.then((r) => r.json())
.then((j) => {
if (j.error) {
this.logger.error(`${j.error}: ${j.message}`);
res(0);
}
res(j.reduce((a, b) => a + b.metadata.size, 0));
});
});
}
}
+1
View File
@@ -1,3 +1,4 @@
export { Datasource } from './Datasource';
export { Local } from './Local';
export { S3 } from './S3';
export { Supabase } from './Supabase';
+7 -8
View File
@@ -1,4 +1,4 @@
import type { File, Url, User } from '@prisma/client';
import { File, Url, User } from '@prisma/client';
import config from 'lib/config';
import { ConfigDiscordContent } from 'config/Config';
import Logger from 'lib/logger';
@@ -8,7 +8,7 @@ const logger = Logger.get('discord');
export function parseContent(
content: ConfigDiscordContent,
args: ParseValue,
args: ParseValue
): ConfigDiscordContent & { url: string } {
return {
content: content.content ? parseString(content.content, args) : null,
@@ -28,10 +28,10 @@ export function parseContent(
}
export async function sendUpload(user: User, file: File, raw_link: string, link: string) {
if (!config.discord.upload) return logger.debug('no discord upload config, no webhook sent');
if (!config.discord.url && !config.discord.upload.url)
return logger.debug('no discord url, no webhook sent');
if (!config.discord.upload) return;
if (!config.discord.url && !config.discord.upload.url) return;
logger.debug(`discord config:\n${JSON.stringify(config.discord)}`);
const parsed = parseContent(config.discord.upload, {
file,
user,
@@ -97,9 +97,8 @@ export async function sendUpload(user: User, file: File, raw_link: string, link:
}
export async function sendShorten(user: User, url: Url, link: string) {
if (!config.discord.shorten) return logger.debug('no discord shorten config, no webhook sent');
if (!config.discord.url && !config.discord.shorten.url)
return logger.debug('no discord url, no webhook sent');
if (!config.discord.shorten) return;
if (!config.discord.url && !config.discord.shorten.url) return;
const parsed = parseContent(config.discord.shorten, {
url,
+9 -24
View File
@@ -1,41 +1,26 @@
import { readFile } from 'fs/promises';
import config from 'lib/config';
import Logger from 'lib/logger';
const logger = Logger.get('random_words');
export type GfyCatWords = {
adjectives: string[];
animals: string[];
};
export async function importWords(): Promise<GfyCatWords | null> {
try {
const adjectives = (await readFile('public/adjectives.txt', 'utf-8')).split('\n').map((x) => x.trim());
const animals = (await readFile('public/animals.txt', 'utf-8')).split('\n').map((x) => x.trim());
export async function importWords(): Promise<GfyCatWords> {
const adjectives = (await readFile('public/adjectives.txt', 'utf-8')).split('\n');
const animals = (await readFile('public/animals.txt', 'utf-8')).split('\n');
return {
adjectives,
animals,
};
} catch {
logger.error('public/adjectives.txt or public/animals.txt do not exist, to fix this please retrieve.');
logger.error('to prevent this from happening again, remember to not delete your public/ directory.');
logger.error('file names will use the RANDOM format instead until fixed');
return null;
}
return {
adjectives,
animals,
};
}
function randomWord(words: string[]) {
return words[Math.floor(Math.random() * words.length)];
}
export default async function gfycat(): Promise<string | null> {
export default async function gfycat() {
const words = await importWords();
if (!words) return null;
return `${randomWord(words.adjectives)}${config.uploader.random_words_separator}${randomWord(
words.adjectives,
)}${config.uploader.random_words_separator}${randomWord(words.animals)}`;
return `${randomWord(words.adjectives)}${randomWord(words.adjectives)}${randomWord(words.animals)}`;
}
+1 -1
View File
@@ -19,7 +19,7 @@ export default async function formatFileName(nameFormat: NameFormat, originalNam
return name;
case 'gfycat':
return gfycat() ?? random();
return gfycat();
default:
return random();
}
+1 -1
View File
@@ -7,7 +7,7 @@ export type ApiError = {
export default async function useFetch(
url: string,
method: 'GET' | 'POST' | 'PATCH' | 'DELETE' = 'GET',
body: ApiError | Record<string, unknown> = null,
body: ApiError | Record<string, unknown> = null
) {
const headers = {};
if (body) headers['content-type'] = 'application/json';
+2 -2
View File
@@ -60,8 +60,8 @@ export default class Logger {
this.formatMessage(
LoggerLevel.ERROR,
this.name,
args.map((error) => (typeof error === 'string' ? error : (error as Error).stack)).join(' '),
),
args.map((error) => (typeof error === 'string' ? error : (error as Error).stack)).join(' ')
)
);
return this;
+4 -12
View File
@@ -1,5 +1,5 @@
import config from 'lib/config';
import { isNotNullOrUndefined } from 'lib/util';
import { notNull } from 'lib/util';
import { GetServerSideProps } from 'next';
export type OauthProvider = {
@@ -19,7 +19,6 @@ export type ServerSideProps = {
bypass_local_login: boolean;
chunks_size: number;
max_size: number;
chunks_enabled: boolean;
totp_enabled: boolean;
exif_enabled: boolean;
fileId?: string;
@@ -27,15 +26,9 @@ export type ServerSideProps = {
};
export const getServerSideProps: GetServerSideProps<ServerSideProps> = async (ctx) => {
const ghEnabled =
isNotNullOrUndefined(config.oauth?.github_client_id) &&
isNotNullOrUndefined(config.oauth?.github_client_secret);
const discEnabled =
isNotNullOrUndefined(config.oauth?.discord_client_id) &&
isNotNullOrUndefined(config.oauth?.discord_client_secret);
const googleEnabled =
isNotNullOrUndefined(config.oauth?.google_client_id) &&
isNotNullOrUndefined(config.oauth?.google_client_secret);
const ghEnabled = notNull(config.oauth?.github_client_id, config.oauth?.github_client_secret);
const discEnabled = notNull(config.oauth?.discord_client_id, config.oauth?.discord_client_secret);
const googleEnabled = notNull(config.oauth?.google_client_id, config.oauth?.google_client_secret);
const oauth_providers: OauthProvider[] = [];
@@ -72,7 +65,6 @@ export const getServerSideProps: GetServerSideProps<ServerSideProps> = async (ct
chunks_size: config.chunks.chunks_size,
max_size: config.chunks.max_size,
totp_enabled: config.mfa.totp_enabled,
chunks_enabled: config.chunks.enabled,
exif_enabled: config.exif.enabled,
compress: config.core.compression.on_dashboard,
} as ServerSideProps,
+23 -4
View File
@@ -26,7 +26,7 @@ export interface OAuthResponse {
export const withOAuth =
(
provider: 'discord' | 'github' | 'google',
oauth: (query: OAuthQuery, logger: Logger) => Promise<OAuthResponse>,
oauth: (query: OAuthQuery, logger: Logger) => Promise<OAuthResponse>
) =>
async (req: NextApiReq, res: NextApiRes) => {
const logger = Logger.get(`oauth::${provider}`);
@@ -67,7 +67,26 @@ export const withOAuth =
},
});
} catch (e) {
logger.error(`Failed to find existing oauth, this likely will result in a failure: ${e}`);
logger.debug(`Failed to find existing oauth. Using fallback. ${e}`);
if (e.code === 'P2022' || e.code === 'P2025') {
const existing = await prisma.user.findFirst({
where: {
oauth: {
some: {
provider: provider.toUpperCase() as OauthProviders,
username: oauth_resp.username,
},
},
},
include: {
oauth: true,
},
});
existingOauth = existing?.oauth?.find((o) => o.provider === provider.toUpperCase());
if (existingOauth) existingOauth.fallback = true;
} else {
logger.error(`Failed to find existing oauth. ${e}`);
}
}
const existingUser = await prisma.user.findFirst({
@@ -138,7 +157,7 @@ export const withOAuth =
logger.info(`User ${user.username} (${user.id}) logged in via oauth(${provider})`);
return res.redirect('/dashboard');
} else if (existingOauth) {
} else if ((existingOauth && existingOauth.fallback) || existingOauth) {
await prisma.oAuth.update({
where: {
id: existingOauth?.id,
@@ -153,7 +172,7 @@ export const withOAuth =
res.setUserCookie(existingOauth.userId);
Logger.get('user').info(
`User ${existingOauth.username} (${existingOauth.id}) logged in via oauth(${provider})`,
`User ${existingOauth.username} (${existingOauth.id}) logged in via oauth(${provider})`
);
return res.redirect('/dashboard');
+8 -8
View File
@@ -66,7 +66,7 @@ export type ZiplineApiConfig = {
export const withZipline =
(
handler: (req: NextApiRequest, res: NextApiResponse, user?: UserExtended) => Promise<unknown>,
api_config: ZiplineApiConfig = { methods: ['GET', 'OPTIONS'] },
api_config: ZiplineApiConfig = { methods: ['GET', 'OPTIONS'] }
) =>
(req: NextApiReq, res: NextApiRes) => {
if (!api_config.methods.includes('OPTIONS')) api_config.methods.push('OPTIONS');
@@ -87,7 +87,7 @@ export const withZipline =
code: 400,
...extra,
},
400,
400
);
};
@@ -99,7 +99,7 @@ export const withZipline =
code: 401,
...extra,
},
401,
401
);
};
@@ -111,7 +111,7 @@ export const withZipline =
code: 403,
...extra,
},
403,
403
);
};
@@ -122,7 +122,7 @@ export const withZipline =
code: 404,
...extra,
},
404,
404
);
};
@@ -136,7 +136,7 @@ export const withZipline =
code: 429,
...extra,
},
429,
429
);
};
@@ -161,7 +161,7 @@ export const withZipline =
path: '/',
expires: new Date(1),
maxAge: undefined,
}),
})
);
};
@@ -230,7 +230,7 @@ export const withZipline =
error: 'method not allowed',
code: 405,
},
405,
405
);
}
+5 -5
View File
@@ -16,9 +16,9 @@ export const github_auth = {
};
export const discord_auth = {
oauth_url: (clientId: string, origin: string, state?: string, redirect_uri?: string) =>
oauth_url: (clientId: string, origin: string, state?: string) =>
`https://discord.com/api/oauth2/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(
redirect_uri || `${origin}/api/auth/oauth/discord`,
`${origin}/api/auth/oauth/discord`
)}&response_type=code&scope=identify${state ? `&state=${state}` : ''}`,
oauth_user: async (access_token: string) => {
const res = await fetch('https://discord.com/api/users/@me', {
@@ -33,15 +33,15 @@ export const discord_auth = {
};
export const google_auth = {
oauth_url: (clientId: string, origin: string, state?: string, redirect_uri?: string) =>
oauth_url: (clientId: string, origin: string, state?: string) =>
`https://accounts.google.com/o/oauth2/auth?client_id=${clientId}&redirect_uri=${encodeURIComponent(
redirect_uri || `${origin}/api/auth/oauth/google`,
`${origin}/api/auth/oauth/google`
)}&response_type=code&access_type=offline&scope=https://www.googleapis.com/auth/userinfo.profile${
state ? `&state=${state}` : ''
}`,
oauth_user: async (access_token: string) => {
const res = await fetch(
`https://people.googleapis.com/v1/people/me?access_token=${access_token}&personFields=names,photos`,
`https://people.googleapis.com/v1/people/me?access_token=${access_token}&personFields=names,photos`
);
if (!res.ok) return null;
+1 -5
View File
@@ -1,11 +1,7 @@
import { PrismaClient } from '@prisma/client';
import 'lib/config';
if (!global.prisma) {
if (!process.env.ZIPLINE_DOCKER_BUILD) {
process.env.DATABASE_URL = config.core.database_url;
global.prisma = new PrismaClient();
}
if (!process.env.ZIPLINE_DOCKER_BUILD) global.prisma = new PrismaClient();
}
export default global.prisma as PrismaClient;
+5 -5
View File
@@ -29,7 +29,7 @@ export const useFiles = (query: { [key: string]: string } = {}) => {
...x,
createdAt: new Date(x.createdAt),
expiresAt: x.expiresAt ? new Date(x.expiresAt) : null,
})),
}))
);
});
};
@@ -59,7 +59,7 @@ export const usePaginatedFiles = (page?: number, options?: Partial<PaginatedFile
...x,
createdAt: new Date(x.createdAt),
expiresAt: x.expiresAt ? new Date(x.expiresAt) : null,
})),
}))
);
});
};
@@ -73,7 +73,7 @@ export const useRecent = (filter?: string) => {
...x,
createdAt: new Date(x.createdAt),
expiresAt: x.expiresAt ? new Date(x.expiresAt) : null,
})),
}))
);
});
};
@@ -94,7 +94,7 @@ export function useFileDelete() {
onSuccess: () => {
queryClient.refetchQueries(['files']);
},
},
}
);
}
@@ -114,7 +114,7 @@ export function useFileFavorite() {
onSuccess: () => {
queryClient.refetchQueries(['files']);
},
},
}
);
}
+16 -6
View File
@@ -17,17 +17,27 @@ export const useFolders = (query: { [key: string]: string } = {}) => {
const queryString = queryBuilder.toString();
return useQuery<UserFoldersResponse[]>(['folders', queryString], async () => {
return fetch('/api/user/folders?' + queryString).then(
(res) => res.json() as Promise<UserFoldersResponse[]>,
);
return fetch('/api/user/folders?' + queryString)
.then((res) => res.json() as Promise<UserFoldersResponse[]>)
.then((data) =>
data.map((x) => ({
...x,
createdAt: new Date(x.createdAt).toLocaleString(),
updatedAt: new Date(x.updatedAt).toLocaleString(),
}))
);
});
};
export const useFolder = (id: string, withFiles = false) => {
return useQuery<UserFoldersResponse>(['folder', id], async () => {
return fetch('/api/user/folders/' + id + (withFiles ? '?files=true' : '')).then(
(res) => res.json() as Promise<UserFoldersResponse>,
);
return fetch('/api/user/folders/' + id + (withFiles ? '?files=true' : ''))
.then((res) => res.json() as Promise<UserFoldersResponse>)
.then((data) => ({
...data,
createdAt: new Date(data.createdAt).toLocaleString(),
updatedAt: new Date(data.updatedAt).toLocaleString(),
}));
});
};
+1 -1
View File
@@ -27,6 +27,6 @@ export const useStats = (amount = 2) => {
},
{
staleTime: 1000 * 60 * 5, // 5 minutes
},
}
);
};
+1 -1
View File
@@ -36,6 +36,6 @@ export function useURLDelete() {
?.filter((u) => u.id !== variables);
queryClient.setQueryData(['urls'], dataWithoutDeleted);
},
},
}
);
}
+3 -5
View File
@@ -15,12 +15,10 @@ export const useVersion = () => {
return useQuery<VersionResponse>(
['version'],
async () => {
return fetch('/api/version').then((res) => (res.ok ? res.json() : Promise.reject('')));
return fetch('/api/version').then((res) => res.json());
},
{
refetchInterval: false,
refetchOnMount: false,
retry: false,
},
staleTime: Infinity,
}
);
};
+6 -6
View File
@@ -36,7 +36,7 @@ export const createSpotlightActions = (router: NextRouter): SpotlightAction[] =>
title: string,
description: string,
link: string,
icon: ReactNode,
icon: ReactNode
): SpotlightAction => {
return actionDo(group, title, description, icon, () => linkTo(link));
};
@@ -46,7 +46,7 @@ export const createSpotlightActions = (router: NextRouter): SpotlightAction[] =>
title: string,
description: string,
icon: ReactNode,
action: () => void,
action: () => void
): SpotlightAction => {
return {
group,
@@ -70,7 +70,7 @@ export const createSpotlightActions = (router: NextRouter): SpotlightAction[] =>
'Manage Account',
'Manage your account settings',
'/dashboard/manage',
<IconUser />,
<IconUser />
),
// Actions
@@ -80,14 +80,14 @@ export const createSpotlightActions = (router: NextRouter): SpotlightAction[] =>
'Upload Files',
'Upload files of any kind',
'/dashboard/upload/file',
<IconFileUpload />,
<IconFileUpload />
),
actionLink(
'Actions',
'Upload Text',
'Upload code, or any other kind of text file',
'/dashboard/upload/text',
<IconFileText />,
<IconFileText />
),
actionDo('Actions', 'Copy Token', 'Copy your API token to your clipboard', <IconClipboardCopy />, () => {
clipboard.copy(user.token);
@@ -99,7 +99,7 @@ export const createSpotlightActions = (router: NextRouter): SpotlightAction[] =>
});
}),
actionLink('Help', 'Documentation', 'View the documentation', 'https://zipline.diced.sh', <IconHelp />),
actionLink('Help', 'Documentation', 'View the documentation', 'https://zipline.diced.tech', <IconHelp />),
// the list of actions here is very incomplete, and will be expanded in the future
];
-39
View File
@@ -1,39 +0,0 @@
// https://github.com/SeaswimmerTheFsh
// https://catppuccin.com/palette
import createTheme from '.';
export default createTheme({
colorScheme: 'dark',
primaryColor: 'blue',
other: {
AppShell_backgroundColor: '#232634',
hover: '#414559',
},
colors: {
dark: [
'#c6d0f5',
'#949cbb',
'#838ba7',
'#737994',
'#626880',
'#51576d',
'#414559',
'#303446',
'#292c3c',
'#232634',
],
blue: [
'#FFFFFF',
'#b8caf4',
'#a2baf1',
'#7599ea',
'#5f89e7',
'#8c99ee',
'#8ca1ee',
'#8cb2ee',
'#8cbaee',
'#8caaee',
],
},
});
-39
View File
@@ -1,39 +0,0 @@
// https://github.com/SeaswimmerTheFsh
// https://catppuccin.com/palette
import createTheme from '.';
export default createTheme({
colorScheme: 'dark',
primaryColor: 'blue',
other: {
AppShell_backgroundColor: '#dce0e8',
hover: '#ccd0da',
},
colors: {
dark: [
'#4c4f69',
'#8c8fa1',
'#8c8fa1',
'#9ca0b0',
'#acb0be',
'#bcc0cc',
'#ccd0da',
'#eff1f5',
'#e6e9ef',
'#dce0e8',
],
blue: [
'#FFFFFF',
'#3676f6',
'#0a57ee',
'#094ed6',
'#1d42f5',
'#1d54f5',
'#1d65f5',
'#1d77f5',
'#1d89f5',
'#1e66f5',
],
},
});
-39
View File
@@ -1,39 +0,0 @@
// https://github.com/SeaswimmerTheFsh
// https://catppuccin.com/palette
import createTheme from '.';
export default createTheme({
colorScheme: 'dark',
primaryColor: 'blue',
other: {
AppShell_backgroundColor: '#181926',
hover: '#363a4f',
},
colors: {
dark: [
'#cad3f5',
'#8087a2',
'#8087a2',
'#6e738d',
'#5b6078',
'#494d64',
'#363a4f',
'#24273a',
'#1e2030',
'#181926',
],
blue: [
'#FFFFFF',
'#a1bdf6',
'#729cf1',
'#5b8cef',
'#899bf4',
'#89a4f4',
'#89acf4',
'#89b5f4',
'#89bef4',
'#8aadf4',
],
},
});
-39
View File
@@ -1,39 +0,0 @@
// https://github.com/SeaswimmerTheFsh
// https://catppuccin.com/palette
import createTheme from '.';
export default createTheme({
colorScheme: 'dark',
primaryColor: 'blue',
other: {
AppShell_backgroundColor: '#11111b',
hover: '#313244',
},
colors: {
dark: [
'#cdd6f4',
'#9399b2',
'#7f849c',
'#6c7086',
'#585b70',
'#45475a',
'#313244',
'#1e1e2e',
'#181825',
'#11111b',
],
blue: [
'#FFFFFF',
'#b9d3fc',
'#a1c3fb',
'#70a4f8',
'#5894f7',
'#89a1fa',
'#89aafa',
'#89b4fa',
'#89bdfa',
'#89c6fa',
],
},
});
+3 -3
View File
@@ -1,4 +1,4 @@
import type { InvisibleFile, InvisibleUrl } from '@prisma/client';
import { InvisibleFile, InvisibleUrl } from '@prisma/client';
import { hash, verify } from 'argon2';
import { randomBytes } from 'crypto';
import { readdir, stat } from 'fs/promises';
@@ -120,6 +120,6 @@ export async function getBase64URLFromURL(url: string) {
return `data:${res.headers.get('content-type')};base64,${base64}`;
}
export function isNotNullOrUndefined(value: unknown) {
return value !== null && value !== undefined;
export function notNull(a: unknown, b: unknown) {
return a !== null && b !== null;
}
+5 -5
View File
@@ -24,16 +24,16 @@ export function humanToBytes(value: string): number {
return bytes;
}
export function bytesToHuman(value: number | bigint): string {
if (typeof value !== 'bigint' && isNaN(value)) return '0.0 B';
export function bytesToHuman(value: number): string {
if (isNaN(value)) return '0.0 B';
if (value === Infinity) return '0.0 B';
const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB']; // if people upload stuff bigger than a petabyte then idk
const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB'];
let num = 0;
while (value > 1024) {
value = Number(value) / 1024;
value /= 1024;
++num;
}
return `${Number(value).toFixed(1)} ${units[num] || ''}`;
return `${value.toFixed(1)} ${units[num]}`;
}
+7 -7
View File
@@ -51,22 +51,22 @@ export function humanTime(string: StringValue | string): Date {
}
}
export function parseExpiry(header: string): Date {
if (!header) throw new Error('no expiry provided');
export function parseExpiry(header: string): Date | null {
if (!header) return null;
header = header.toLowerCase();
if (header.startsWith('date=')) {
const date = new Date(header.substring(5));
if (!date.getTime()) throw new Error('invalid date');
if (date.getTime() < Date.now()) throw new Error('expiry must be in the future');
if (!date.getTime()) return null;
if (date.getTime() < Date.now()) return null;
return date;
}
const human = humanTime(header);
if (!human) throw new Error('failed to parse human time');
if (human.getTime() < Date.now()) throw new Error('expiry must be in the future');
if (!human) return null;
if (human.getTime() < Date.now()) return null;
return human;
}
@@ -125,7 +125,7 @@ export function expireReadToDate(expires: string): Date {
'6m': Date.now() + 6 * 30 * 24 * 60 * 60 * 1000,
'8m': Date.now() + 8 * 30 * 24 * 60 * 60 * 1000,
'1y': Date.now() + 365 * 24 * 60 * 60 * 1000,
}[expires],
}[expires]
);
}
+39 -46
View File
@@ -1,10 +1,10 @@
import type { File } from '@prisma/client';
import { ExifTool, Tags } from 'exiftool-vendored';
import { File } from '@prisma/client';
import { createWriteStream } from 'fs';
import { readFile, rm } from 'fs/promises';
import { ExifTool, Tags } from 'exiftool-vendored';
import datasource from 'lib/datasource';
import Logger from 'lib/logger';
import { join } from 'path';
import { readFile, unlink } from 'fs/promises';
const logger = Logger.get('exif');
@@ -43,54 +43,47 @@ export async function removeGPSData(image: File): Promise<void> {
await new Promise((resolve) => writeStream.on('finish', resolve));
logger.debug(`removing GPS data from ${file}`);
try {
await exiftool.write(file, {
GPSVersionID: null,
GPSAltitude: null,
GPSAltitudeRef: null,
GPSAreaInformation: null,
GPSDateStamp: null,
GPSDateTime: null,
GPSDestBearing: null,
GPSDestBearingRef: null,
GPSDestDistance: null,
GPSDestLatitude: null,
GPSDestLatitudeRef: null,
GPSDestLongitude: null,
GPSDestLongitudeRef: null,
GPSDifferential: null,
GPSDOP: null,
GPSHPositioningError: null,
GPSImgDirection: null,
GPSImgDirectionRef: null,
GPSLatitude: null,
GPSLatitudeRef: null,
GPSLongitude: null,
GPSLongitudeRef: null,
GPSMapDatum: null,
GPSPosition: null,
GPSProcessingMethod: null,
GPSSatellites: null,
GPSSpeed: null,
GPSSpeedRef: null,
GPSStatus: null,
GPSTimeStamp: null,
GPSTrack: null,
GPSTrackRef: null,
});
} catch (e) {
logger.debug(`removing temp file: ${file}`);
await rm(file);
return;
}
await exiftool.write(file, {
GPSVersionID: null,
GPSAltitude: null,
GPSAltitudeRef: null,
GPSAreaInformation: null,
GPSDateStamp: null,
GPSDateTime: null,
GPSDestBearing: null,
GPSDestBearingRef: null,
GPSDestDistance: null,
GPSDestLatitude: null,
GPSDestLatitudeRef: null,
GPSDestLongitude: null,
GPSDestLongitudeRef: null,
GPSDifferential: null,
GPSDOP: null,
GPSHPositioningError: null,
GPSImgDirection: null,
GPSImgDirectionRef: null,
GPSLatitude: null,
GPSLatitudeRef: null,
GPSLongitude: null,
GPSLongitudeRef: null,
GPSMapDatum: null,
GPSPosition: null,
GPSProcessingMethod: null,
GPSSatellites: null,
GPSSpeed: null,
GPSSpeedRef: null,
GPSStatus: null,
GPSTimeStamp: null,
GPSTrack: null,
GPSTrackRef: null,
});
logger.debug(`reading file to upload to datasource: ${file} -> ${image.name}`);
const buffer = await readFile(file);
await datasource.save(image.name, buffer, { type: image.mimetype });
await datasource.save(image.name, buffer);
logger.debug(`removing temp file: ${file}`);
await rm(file);
await unlink(file);
await exiftool.end(true);
+11 -53
View File
@@ -1,19 +1,14 @@
import type { File, Url } from '@prisma/client';
import { bytesToHuman } from './bytes';
import Logger from 'lib/logger';
import type { UserExtended } from 'middleware/withZipline';
import type { File, User, Url } from '@prisma/client';
export type ParseValue = {
file?: Omit<Partial<File>, 'password'>;
file?: File;
url?: Url;
user?: Partial<UserExtended>;
user?: User;
link?: string;
raw_link?: string;
};
const logger = Logger.get('parser');
export function parseString(str: string, value: ParseValue) {
if (!str) return null;
str = str
@@ -21,7 +16,7 @@ export function parseString(str: string, value: ParseValue) {
.replace(/\{raw_link\}/gi, value.raw_link)
.replace(/\\n/g, '\n');
const re = /\{(?<type>file|url|user)\.(?<prop>\w+)(::(?<mod>\w+))?(::(?<mod_tzlocale>\S+))?\}/gi;
const re = /\{(?<type>file|url|user)\.(?<prop>\w+)(::(?<mod>\w+))?\}/gi;
let matches: RegExpMatchArray;
while ((matches = re.exec(str))) {
@@ -37,13 +32,12 @@ export function parseString(str: string, value: ParseValue) {
re.lastIndex = matches.index;
continue;
}
if (['originalName', 'name'].includes(matches.groups.prop)) {
str = replaceCharsFromString(
str,
decodeURIComponent(escape(getV[matches.groups.prop])),
matches.index,
re.lastIndex,
re.lastIndex
);
re.lastIndex = matches.index;
continue;
@@ -58,12 +52,7 @@ export function parseString(str: string, value: ParseValue) {
}
if (matches.groups.mod) {
str = replaceCharsFromString(
str,
modifier(matches.groups.mod, v, matches.groups.mod_tzlocale ?? undefined),
matches.index,
re.lastIndex,
);
str = replaceCharsFromString(str, modifier(matches.groups.mod, v), matches.index, re.lastIndex);
re.lastIndex = matches.index;
continue;
}
@@ -75,42 +64,17 @@ export function parseString(str: string, value: ParseValue) {
return str;
}
function modifier(mod: string, value: unknown, tzlocale?: string): string {
function modifier(mod: string, value: unknown): string {
mod = mod.toLowerCase();
if (value instanceof Date) {
const args = [undefined, undefined];
if (tzlocale) {
const [locale, tz] = tzlocale.split(/\s?,\s?/).map((v) => v.trim());
if (locale) {
try {
Intl.DateTimeFormat.supportedLocalesOf(locale);
args[0] = locale;
} catch (e) {
args[0] = undefined;
logger.error(`invalid locale provided ${locale}`);
}
}
if (tz) {
const intlTz = Intl.supportedValuesOf('timeZone').find((v) => v.toLowerCase() === tz.toLowerCase());
if (intlTz) args[1] = { timeZone: intlTz };
else {
args[1] = undefined;
logger.error(`invalid timezone provided ${tz}`);
}
}
}
switch (mod) {
case 'locale':
return value.toLocaleString(...args);
return value.toLocaleString();
case 'time':
return value.toLocaleTimeString(...args);
return value.toLocaleTimeString();
case 'date':
return value.toLocaleDateString(...args);
return value.toLocaleDateString();
case 'unix':
return Math.floor(value.getTime() / 1000).toString();
case 'iso':
@@ -129,10 +93,6 @@ function modifier(mod: string, value: unknown, tzlocale?: string): string {
return value.getMinutes().toString();
case 'second':
return value.getSeconds().toString();
case 'ampm':
return value.getHours() < 12 ? 'am' : 'pm';
case 'AMPM':
return value.getHours() < 12 ? 'AM' : 'PM';
default:
return '{unknown_date_modifier}';
}
@@ -155,7 +115,7 @@ function modifier(mod: string, value: unknown, tzlocale?: string): string {
default:
return '{unknown_str_modifier}';
}
} else if (typeof value === 'number' || typeof value === 'bigint') {
} else if (typeof value === 'number') {
switch (mod) {
case 'comma':
return value.toLocaleString();
@@ -165,8 +125,6 @@ function modifier(mod: string, value: unknown, tzlocale?: string): string {
return value.toString(8);
case 'binary':
return value.toString(2);
case 'bytes':
return bytesToHuman(value);
default:
return '{unknown_int_modifier}';
}
-20
View File
@@ -1,20 +0,0 @@
export function parseRange(header: string, length: number): [number, number] {
const range = header.trim().substring(6);
let start, end;
if (range.startsWith('-')) {
end = length - 1;
start = length - 1 - Number(range.substring(1));
} else {
const [s, e] = range.split('-').map(Number);
start = s;
end = e || length - 1;
}
if (end > length - 1) {
end = length - 1;
}
return [start, end];
}
+5 -7
View File
@@ -8,23 +8,21 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
try {
const { orphaned } = req.body;
if (orphaned) {
const files = await prisma.file.findMany({
where: {
userId: null,
},
});
const { count } = await prisma.file.deleteMany({
where: {
userId: null,
},
});
for (const file of files) await datasource.delete(file.name);
logger.info(`User ${user.username} (${user.id}) cleared the database of ${count} orphaned files`);
return res.json({ message: 'cleared storage (orphaned only)' });
}
const { count } = await prisma.file.deleteMany({});
await datasource.clear();
logger.info(`User ${user.username} (${user.id}) cleared the database of ${count} files`);
if (req.body.datasource) {
await datasource.clear();
logger.info(`User ${user.username} (${user.id}) cleared storage`);
}
} catch (e) {
logger.error(`User ${user.username} (${user.id}) failed to clear the database or storage`);
logger.error(e);
-307
View File
@@ -1,307 +0,0 @@
import { readFile } from 'fs/promises';
import Logger from 'lib/logger';
import prisma from 'lib/prisma';
import { randomChars } from 'lib/util';
import { bytesToHuman } from 'lib/utils/bytes';
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
import os from 'os';
const logger = Logger.get('admin').child('export');
type Zipline3Export = {
versions: {
zipline: string;
node: string;
export: '3';
};
request: {
user: string;
date: string;
os: {
platform: 'aix' | 'darwin' | 'freebsd' | 'linux' | 'openbsd' | 'sunos' | 'win32';
arch:
| 'arm'
| 'arm64'
| 'ia32'
| 'loong64'
| 'mips'
| 'mipsel'
| 'ppc'
| 'ppc64'
| 'riscv64'
| 's390'
| 's390x'
| 'x64';
cpus: number;
hostname: string;
release: string;
};
env: NodeJS.ProcessEnv;
};
// Creates a unique identifier for each model
// used to map the user's stuff to other data owned by the user
user_map: Record<number, string>;
thumbnail_map: Record<number, string>;
folder_map: Record<number, string>;
file_map: Record<number, string>;
url_map: Record<number, string>;
invite_map: Record<number, string>;
users: {
[id: string]: {
username: string;
password: string;
avatar: string;
administrator: boolean;
super_administrator: boolean;
embed: {
title?: string;
site_name?: string;
description?: string;
color?: string;
};
totp_secret: string;
oauth: {
provider: 'DISCORD' | 'GITHUB' | 'GOOGLE';
username: string;
oauth_id: string;
access_token: string;
refresh_token: string;
}[];
};
};
files: {
[id: string]: {
name: string;
original_name: string;
type: `${string}/${string}`;
size: number | bigint;
user: string | null;
thumbnail?: string;
max_views: number;
views: number;
expires_at?: string;
created_at: string;
favorite: boolean;
password?: string;
};
};
thumbnails: {
[id: string]: {
name: string;
created_at: string;
};
};
folders: {
[id: string]: {
name: string;
public: boolean;
created_at: string;
user: string;
files: string[];
};
};
urls: {
[id: number]: {
destination: string;
vanity?: string;
code: string;
created_at: string;
max_views: number;
views: number;
user: string;
};
};
invites: {
[id: string]: {
code: string;
expites_at?: string;
created_at: string;
used: boolean;
created_by_user: string;
};
};
stats: {
created_at: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: any;
}[];
};
async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
if (!user.superAdmin) return res.forbidden('You must be a super administrator to export data');
const pkg = JSON.parse(await readFile('package.json', 'utf8'));
const exportData: Partial<Zipline3Export> = {
versions: {
zipline: pkg.version,
node: process.version,
export: '3',
},
request: {
user: '',
date: new Date().toISOString(),
os: {
platform: os.platform() as Zipline3Export['request']['os']['platform'],
arch: os.arch() as Zipline3Export['request']['os']['arch'],
cpus: os.cpus().length,
hostname: os.hostname(),
release: os.release(),
},
env: process.env,
},
user_map: {},
thumbnail_map: {},
folder_map: {},
file_map: {},
url_map: {},
invite_map: {},
users: {},
files: {},
thumbnails: {},
folders: {},
urls: {},
invites: {},
stats: [],
};
const users = await prisma.user.findMany({
include: {
oauth: true,
},
});
for (const user of users) {
const uniqueId = randomChars(32);
exportData.user_map[user.id] = uniqueId;
exportData.users[uniqueId] = {
username: user.username,
password: user.password,
avatar: user.avatar,
administrator: user.administrator,
super_administrator: user.superAdmin,
embed: user.embed as Zipline3Export['users'][string]['embed'],
totp_secret: user.totpSecret,
oauth: user.oauth.map((oauth) => ({
provider: oauth.provider as Zipline3Export['users'][string]['oauth'][0]['provider'],
username: oauth.username,
oauth_id: oauth.oauthId,
access_token: oauth.token,
refresh_token: oauth.refresh,
})),
};
}
const folders = await prisma.folder.findMany({ include: { files: true } });
for (const folder of folders) {
const uniqueId = randomChars(32);
exportData.folder_map[folder.id] = uniqueId;
exportData.folders[uniqueId] = {
name: folder.name,
public: folder.public,
created_at: folder.createdAt.toISOString(),
user: exportData.user_map[folder.userId],
files: [], // mapped later
};
}
const thumbnails = await prisma.thumbnail.findMany();
for (const thumbnail of thumbnails) {
const uniqueId = randomChars(32);
exportData.thumbnail_map[thumbnail.id] = uniqueId;
exportData.thumbnails[uniqueId] = {
name: thumbnail.name,
created_at: thumbnail.createdAt.toISOString(),
};
}
const files = await prisma.file.findMany({ include: { thumbnail: true } });
for (const file of files) {
const uniqueId = randomChars(32);
exportData.file_map[file.id] = uniqueId;
exportData.files[uniqueId] = {
name: file.name,
original_name: file.originalName,
type: file.mimetype as Zipline3Export['files'][0]['type'],
size: file.size,
user: file.userId ? exportData.user_map[file.userId] : null,
thumbnail: file.thumbnail ? exportData.thumbnail_map[file.thumbnail.id] : undefined,
max_views: file.maxViews,
views: file.views,
expires_at: file.expiresAt?.toISOString(),
created_at: file.createdAt.toISOString(),
favorite: file.favorite,
password: file.password,
};
}
const urls = await prisma.url.findMany();
for (const url of urls) {
const uniqueId = randomChars(32);
exportData.url_map[url.id] = uniqueId;
exportData.urls[uniqueId] = {
destination: url.destination,
vanity: url.vanity,
created_at: url.createdAt.toISOString(),
max_views: url.maxViews,
views: url.views,
user: exportData.user_map[url.userId],
code: url.id,
};
}
const invites = await prisma.invite.findMany();
for (const invite of invites) {
const uniqueId = randomChars(32);
exportData.invite_map[invite.id] = uniqueId;
exportData.invites[uniqueId] = {
code: invite.code,
expites_at: invite.expiresAt?.toISOString() ?? undefined,
created_at: invite.createdAt.toISOString(),
used: invite.used,
created_by_user: exportData.user_map[invite.createdById],
};
}
exportData.request.user = exportData.user_map[user.id];
for (const folder of folders) {
exportData.folders[exportData.folder_map[folder.id]].files = folder.files.map(
(file) => exportData.file_map[file.id],
);
}
const stringed = JSON.stringify(exportData);
logger.info(`${user.id} created export of size ${bytesToHuman(stringed.length)}`);
return res
.setHeader('Content-Disposition', `attachment; filename="zipline_export_${Date.now()}.json"`)
.setHeader('Content-Type', 'application/json')
.send(stringed);
}
export default withZipline(handler, {
methods: ['GET'],
user: true,
administrator: true,
});
+132
View File
@@ -0,0 +1,132 @@
import { readFile } from 'fs/promises';
import config from 'lib/config';
import Logger from 'lib/logger';
import { NextApiReq, NextApiRes, withZipline } from 'lib/middleware/withZipline';
import { guess } from 'lib/mimes';
import prisma from 'lib/prisma';
import { createToken, hashPassword } from 'lib/util';
import { jsonUserReplacer } from 'lib/utils/client';
import { extname } from 'path';
const logger = Logger.get('user');
async function handler(req: NextApiReq, res: NextApiRes) {
// handle invites
if (req.body.code) {
if (!config.features.invites) return res.badRequest('invites are disabled');
const { code, username, password } = req.body as {
code?: string;
username: string;
password: string;
};
const invite = await prisma.invite.findUnique({
where: { code: code ?? '' },
});
if (!invite && code) return res.badRequest('invalid invite code');
const user = await prisma.user.findFirst({
where: { username },
});
if (user) return res.badRequest('username already exists');
const hashed = await hashPassword(password);
let avatar;
if (config.features.default_avatar) {
logger.debug(`using default avatar ${config.features.default_avatar}`);
const buf = await readFile(config.features.default_avatar);
const mimetype = await guess(extname(config.features.default_avatar));
logger.debug(`guessed mimetype ${mimetype} for ${config.features.default_avatar}`);
avatar = `data:${mimetype};base64,${buf.toString('base64')}`;
}
const newUser = await prisma.user.create({
data: {
password: hashed,
username,
token: createToken(),
administrator: false,
avatar,
},
});
if (code) {
await prisma.invite.update({
where: {
code,
},
data: {
used: true,
},
});
}
logger.debug(`created user via invite ${code} ${JSON.stringify(newUser, jsonUserReplacer)}`);
logger.info(
`Created user ${newUser.username} (${newUser.id}) ${
code ? `from invite code ${code}` : 'via registration'
}`
);
return res.json({ success: true });
}
const user = await req.user();
if (!user) return res.unauthorized('not logged in');
if (!user.administrator) return res.forbidden('you arent an administrator');
const { username, password, administrator } = req.body as {
username: string;
password: string;
administrator: boolean;
};
if (!username) return res.badRequest('no username');
if (!password) return res.badRequest('no password');
const existing = await prisma.user.findFirst({
where: {
username,
},
});
if (existing) return res.badRequest('user exists');
const hashed = await hashPassword(password);
let avatar;
if (config.features.default_avatar) {
logger.debug(`using default avatar ${config.features.default_avatar}`);
const buf = await readFile(config.features.default_avatar);
const mimetype = await guess(extname(config.features.default_avatar));
logger.debug(`guessed mimetype ${mimetype} for ${config.features.default_avatar}`);
avatar = `data:${mimetype};base64,${buf.toString('base64')}`;
}
const newUser = await prisma.user.create({
data: {
password: hashed,
username,
token: createToken(),
administrator,
avatar,
},
});
logger.debug(`created user ${JSON.stringify(newUser, jsonUserReplacer)}`);
delete newUser.password;
logger.info(`Created user ${newUser.username} (${newUser.id})`);
return res.json(newUser);
}
export default withZipline(handler, {
methods: ['POST'],
});
+40
View File
@@ -0,0 +1,40 @@
import datasource from 'lib/datasource';
import { guess } from 'lib/mimes';
import prisma from 'lib/prisma';
import { checkPassword } from 'lib/util';
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
import { extname } from 'path';
async function handler(req: NextApiReq, res: NextApiRes) {
const { id, password } = req.query;
if (isNaN(Number(id))) return res.badRequest('invalid id');
const file = await prisma.file.findFirst({
where: {
id: Number(id),
},
});
if (!file) return res.notFound('image not found');
if (!password) return res.badRequest('no password provided');
const decoded = decodeURIComponent(password as string);
const valid = await checkPassword(decoded, file.password);
if (!valid) return res.badRequest('wrong password');
const data = await datasource.get(file.name);
if (!data) return res.notFound('image not found');
const size = await datasource.size(file.name);
const mimetype = await guess(extname(file.name));
res.setHeader('Content-Type', mimetype);
res.setHeader('Content-Length', size);
data.pipe(res);
data.on('error', () => res.notFound('image not found'));
data.on('end', () => res.end());
}
export default withZipline(handler);
+13 -21
View File
@@ -1,4 +1,3 @@
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
import config from 'lib/config';
import Logger from 'lib/logger';
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'lib/middleware/withZipline';
@@ -17,12 +16,8 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
count: number;
};
let expiry: Date;
try {
expiry = parseExpiry(expiresAt);
} catch (error) {
return res.badRequest(error.message);
}
const expiry = parseExpiry(expiresAt);
if (!expiry) return res.badRequest('invalid date');
const counts = count ? count : 1;
if (counts > 1) {
@@ -42,7 +37,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
logger.info(
`${user.username} (${user.id}) created ${data.length} invites with codes ${data
.map((invite) => invite.code)
.join(', ')}`,
.join(', ')}`
);
return res.json(data);
@@ -65,22 +60,19 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
const { code } = req.query as { code: string };
if (!code) return res.badRequest('no code');
try {
const invite = await prisma.invite.delete({
where: {
code,
},
});
const invite = await prisma.invite.delete({
where: {
code,
},
});
logger.debug(`deleted invite ${JSON.stringify(invite)}`);
if (!invite) return res.notFound('invite not found');
logger.info(`${user.username} (${user.id}) deleted invite ${invite.code}`);
logger.debug(`deleted invite ${JSON.stringify(invite)}`);
return res.json(invite);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) return res.notFound('invite not found');
else throw error;
}
logger.info(`${user.username} (${user.id}) deleted invite ${invite.code}`);
return res.json(invite);
} else {
const invites = await prisma.invite.findMany({
orderBy: {
+4 -5
View File
@@ -14,7 +14,8 @@ async function handler(req: NextApiReq, res: NextApiRes) {
code?: string;
};
if ((await prisma.user.count()) === 0) {
const users = await prisma.user.findMany();
if (users.length === 0) {
logger.debug('no users found... creating default user...');
await prisma.user.create({
data: {
@@ -41,9 +42,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
else if (await checkPassword(password, user.password)) valid = true;
else valid = false;
logger.debug(
`body(${JSON.stringify(Object.keys(req.body))}): checkPassword(password, argon2-str) => ${valid}`,
);
logger.debug(`body(${JSON.stringify(req.body)}): checkPassword(${password}, argon2-str) => ${valid}`);
if (!valid) return res.unauthorized('Wrong password');
@@ -52,7 +51,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
const success = verify_totp_code(user.totpSecret, code);
logger.debug(
`body(${JSON.stringify(Object.keys(req.body))}): verify_totp_code(totpSecret, ${code}) => ${success}`,
`body(${JSON.stringify(req.body)}): verify_totp_code(${user.totpSecret}, ${code}) => ${success}`
);
if (!success) return res.badRequest('Invalid code', { totp: true });
}
+4 -16
View File
@@ -3,7 +3,7 @@ import Logger from 'lib/logger';
import { OAuthQuery, OAuthResponse, withOAuth } from 'lib/middleware/withOAuth';
import { withZipline } from 'lib/middleware/withZipline';
import { discord_auth } from 'lib/oauth';
import { getBase64URLFromURL, isNotNullOrUndefined } from 'lib/util';
import { getBase64URLFromURL, notNull } from 'lib/util';
async function handler({ code, state, host }: OAuthQuery, logger: Logger): Promise<OAuthResponse> {
if (!config.features.oauth_registration)
@@ -12,10 +12,7 @@ async function handler({ code, state, host }: OAuthQuery, logger: Logger): Promi
error: 'oauth registration is disabled',
};
if (
!isNotNullOrUndefined(config.oauth.discord_client_id) &&
!isNotNullOrUndefined(config.oauth.discord_client_secret)
) {
if (!notNull(config.oauth.discord_client_id, config.oauth.discord_client_secret)) {
logger.error('Discord OAuth is not configured');
return {
@@ -29,8 +26,7 @@ async function handler({ code, state, host }: OAuthQuery, logger: Logger): Promi
redirect: discord_auth.oauth_url(
config.oauth.discord_client_id,
`${config.core.return_https ? 'https' : 'http'}://${host}`,
state,
config.oauth.discord_redirect_uri,
state
),
};
@@ -39,9 +35,7 @@ async function handler({ code, state, host }: OAuthQuery, logger: Logger): Promi
client_secret: config.oauth.discord_client_secret,
code,
grant_type: 'authorization_code',
redirect_uri:
config.oauth.discord_redirect_uri ||
`${config.core.return_https ? 'https' : 'http'}://${host}/api/auth/oauth/discord`,
redirect_uri: `${config.core.return_https ? 'https' : 'http'}://${host}/api/auth/oauth/discord`,
scope: 'identify',
});
@@ -73,12 +67,6 @@ async function handler({ code, state, host }: OAuthQuery, logger: Logger): Promi
: `https://cdn.discordapp.com/embed/avatars/${userJson.discriminator % 5}.png`;
const avatarBase64 = await getBase64URLFromURL(avatar);
if (
config.oauth.discord_whitelisted_users?.length &&
!config.oauth.discord_whitelisted_users.includes(userJson.id)
)
return { error: 'user is not whitelisted' };
return {
username: userJson.username,
user_id: userJson.id,
+2 -5
View File
@@ -3,7 +3,7 @@ import Logger from 'lib/logger';
import { OAuthQuery, OAuthResponse, withOAuth } from 'lib/middleware/withOAuth';
import { withZipline } from 'lib/middleware/withZipline';
import { github_auth } from 'lib/oauth';
import { getBase64URLFromURL, isNotNullOrUndefined } from 'lib/util';
import { getBase64URLFromURL, notNull } from 'lib/util';
async function handler({ code, state }: OAuthQuery, logger: Logger): Promise<OAuthResponse> {
if (!config.features.oauth_registration)
@@ -12,10 +12,7 @@ async function handler({ code, state }: OAuthQuery, logger: Logger): Promise<OAu
error: 'oauth registration is disabled',
};
if (
!isNotNullOrUndefined(config.oauth.github_client_id) &&
!isNotNullOrUndefined(config.oauth.github_client_secret)
) {
if (!notNull(config.oauth.github_client_id, config.oauth.github_client_secret)) {
logger.error('GitHub OAuth is not configured');
return {
error_code: 401,
+4 -10
View File
@@ -3,7 +3,7 @@ import Logger from 'lib/logger';
import { OAuthQuery, OAuthResponse, withOAuth } from 'lib/middleware/withOAuth';
import { withZipline } from 'lib/middleware/withZipline';
import { google_auth } from 'lib/oauth';
import { getBase64URLFromURL, isNotNullOrUndefined } from 'lib/util';
import { getBase64URLFromURL, notNull } from 'lib/util';
async function handler({ code, state, host }: OAuthQuery, logger: Logger): Promise<OAuthResponse> {
if (!config.features.oauth_registration)
@@ -12,10 +12,7 @@ async function handler({ code, state, host }: OAuthQuery, logger: Logger): Promi
error: 'oauth registration is disabled',
};
if (
!isNotNullOrUndefined(config.oauth.google_client_id) &&
!isNotNullOrUndefined(config.oauth.google_client_secret)
) {
if (!notNull(config.oauth.google_client_id, config.oauth.google_client_secret)) {
logger.error('Google OAuth is not configured');
return {
error_code: 401,
@@ -28,8 +25,7 @@ async function handler({ code, state, host }: OAuthQuery, logger: Logger): Promi
redirect: google_auth.oauth_url(
config.oauth.google_client_id,
`${config.core.return_https ? 'https' : 'http'}://${host}`,
state,
config.oauth.google_redirect_uri,
state
),
};
@@ -37,9 +33,7 @@ async function handler({ code, state, host }: OAuthQuery, logger: Logger): Promi
code,
client_id: config.oauth.google_client_id,
client_secret: config.oauth.google_client_secret,
redirect_uri:
config.oauth.google_redirect_uri ||
`${config.core.return_https ? 'https' : 'http'}://${host}/api/auth/oauth/google`,
redirect_uri: `${config.core.return_https ? 'https' : 'http'}://${host}/api/auth/oauth/google`,
grant_type: 'authorization_code',
});
+7 -41
View File
@@ -11,49 +11,23 @@ import { extname } from 'path';
const logger = Logger.get('user');
async function handler(req: NextApiReq, res: NextApiRes) {
const user = await req.user();
let badRequest,
usedInvite = false;
if (!config.features.user_registration) return res.badRequest('user registration is disabled');
if (!config.features.user_registration && !config.features.invites && !user?.administrator)
return res.badRequest('This endpoint is unavailable due to current configurations');
else if (!!user && !user?.administrator) return res.badRequest('Already logged in');
const { username, password, administrator, code } = req.body as {
const { username, password, administrator } = req.body as {
username: string;
password: string;
administrator: boolean;
code?: string;
};
if (!username) badRequest = true;
if (!password) badRequest = true;
if (!username) return res.badRequest('no username');
if (!password) return res.badRequest('no password');
const existing = await prisma.user.findFirst({
where: {
username,
},
select: {
username: true,
},
});
if (existing) badRequest = true;
if (badRequest) return res.badRequest('Bad Username/Password');
if (code) {
if (config.features.invites) {
const invite = await prisma.invite.findUnique({
where: {
code,
},
});
if (!invite || invite?.used) return res.badRequest('Bad invite');
usedInvite = true;
} else return res.badRequest('Bad Username/Password');
} else if (config.features.invites && !user?.administrator) return res.badRequest('Bad invite');
if (existing) return res.badRequest('user exists');
const hashed = await hashPassword(password);
@@ -73,20 +47,12 @@ async function handler(req: NextApiReq, res: NextApiRes) {
password: hashed,
username,
token: createToken(),
administrator: user?.superAdmin ? administrator : false,
administrator,
avatar,
},
});
if (usedInvite)
await prisma.invite.update({
where: { code },
data: { used: true },
});
logger.debug(
`registered user${usedInvite ? ' via invite ' + code : ''} ${JSON.stringify(newUser, jsonUserReplacer)}`,
);
logger.debug(`registered user ${JSON.stringify(newUser, jsonUserReplacer)}`);
delete newUser.password;
+1 -1
View File
@@ -26,7 +26,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
if (!image) return res.notFound('image not found');
logger.info(
`${user.username} (${user.id}) requested to read exif metadata for image ${image.name} (${image.id})`,
`${user.username} (${user.id}) requested to read exif metadata for image ${image.name} (${image.id})`
);
if (config.datasource.type === 'local') {
-75
View File
@@ -1,75 +0,0 @@
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
import 'lib/prisma';
import 'lib/config';
async function handler(req: NextApiReq, res: NextApiRes) {
const { id } = req.query as { id: string };
if (req.method === 'GET') {
if (!id || isNaN(parseInt(id))) return res.badRequest('no id');
const file = await prisma.file.findUnique({
where: {
id: parseInt(id),
},
include: {
thumbnail: {
select: {
name: true,
},
},
user: {
select: {
username: true,
},
},
},
});
if (!file || !!file.password) return res.notFound('no such file exists');
const mediaType: 'image' | 'video' | 'audio' | 'other' =
(new RegExp(/^(?<type>image|video|audio)/).exec(file.mimetype)?.groups?.type as
| 'image'
| 'video'
| 'audio') || 'other';
let host = req.headers.host;
const proto = req.headers['x-forwarded-proto'];
try {
if (
JSON.parse(req.headers['cf-visitor'] as string).scheme === 'https' ||
proto === 'https' ||
config.core.return_https
)
host = `https://${host}`;
else host = `http://${host}`;
} catch (e) {
if (proto === 'https' || config.core.return_https) host = `https://${host}`;
else host = `http://${host}`;
}
if (mediaType === 'image')
return res.json({
type: 'photo',
version: '1.0',
url: `${host}/r/${file.name}`,
});
if (mediaType === 'video')
return res.json({
type: 'video',
version: '1.0',
url: `${host}/r/${file.name}`,
thumbnail_url: file.thumbnail ? `${host}/r/${file.thumbnail?.name}` : undefined,
html: `<video><source src="${host}/r/${file.name}" type="${file.mimetype}"/></video>`,
});
return res.json({
type: 'link',
version: '1.0',
url: `${host}${config.uploader.route}/${file.name}`,
});
}
}
export default withZipline(handler, {
methods: ['GET'],
user: false,
});
+1 -1
View File
@@ -54,7 +54,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
logger.debug(`shortened ${JSON.stringify(url)}`);
logger.info(`User ${user.username} (${user.id}) shortened a url ${url.destination} (${url.id})`);
logger.info(`User ${user.username} (${user.id}) shortenned a url ${url.destination} (${url.id})`);
let domain;
if (req.headers['override-domain']) {
+63 -128
View File
@@ -30,45 +30,6 @@ async function handler(req: NextApiReq, res: NextApiRes) {
if (!user) return res.forbidden('authorization incorrect');
if (user.ratelimit && !req.headers['content-range']) {
const remaining = user.ratelimit.getTime() - Date.now();
logger.debug(`${user.id} encountered ratelimit, ${remaining}ms remaining`);
if (remaining <= 0) {
await prisma.user.update({
where: {
id: user.id,
},
data: {
ratelimit: null,
},
});
} else {
return res.ratelimited(remaining);
}
} else if (!user.ratelimit && !req.headers['content-range']) {
if (user.administrator && zconfig.ratelimit.admin > 0) {
await prisma.user.update({
where: {
id: user.id,
},
data: {
ratelimit: new Date(Date.now() + zconfig.ratelimit.admin * 1000),
},
});
} else if (!user.administrator && zconfig.ratelimit.user > 0) {
if (user.administrator && zconfig.ratelimit.user > 0) {
await prisma.user.update({
where: {
id: user.id,
},
data: {
ratelimit: new Date(Date.now() + zconfig.ratelimit.user * 1000),
},
});
}
}
}
await new Promise((resolve, reject) => {
uploader.array('file')(req as never, res as never, (result: unknown) => {
if (result instanceof Error) reject(result.message);
@@ -81,7 +42,6 @@ async function handler(req: NextApiReq, res: NextApiRes) {
expiresAt?: Date;
removed_gps?: boolean;
assumed_mimetype?: string | boolean;
folder?: number;
} = {
files: [],
};
@@ -89,20 +49,16 @@ async function handler(req: NextApiReq, res: NextApiRes) {
let expiry: Date;
if (expiresAt) {
try {
expiry = parseExpiry(expiresAt);
expiry = parseExpiry(expiresAt);
if (!expiry) return res.badRequest('invalid date');
else {
response.expiresAt = expiry;
} catch (error) {
return res.badRequest(error.message);
}
}
if (zconfig.uploader.default_expiration) {
try {
expiry = parseExpiry(zconfig.uploader.default_expiration);
} catch (error) {
return res.badRequest(`${error.message} (UPLOADER_DEFAULT_EXPIRATION)`);
}
expiry = parseExpiry(zconfig.uploader.default_expiration);
if (!expiry) return res.badRequest('invalid date (UPLOADER_DEFAULT_EXPIRATION)');
}
const rawFormat = ((req.headers['format'] as string) || zconfig.uploader.default_format).toLowerCase();
@@ -122,32 +78,8 @@ async function handler(req: NextApiReq, res: NextApiRes) {
if (isNaN(fileMaxViews)) return res.badRequest('invalid max views (invalid number)');
if (fileMaxViews < 0) return res.badRequest('invalid max views (max views < 0)');
const folderToAdd = req.headers['x-zipline-folder'] ? Number(req.headers['x-zipline-folder']) : null;
if (folderToAdd) {
if (isNaN(folderToAdd)) return res.badRequest('invalid folder id (invalid number)');
const folder = await prisma.folder.findFirst({
where: {
id: folderToAdd,
userId: user.id,
},
});
if (!folder) return res.badRequest('invalid folder id (no folder found)');
response.folder = folder.id;
}
// handle partial uploads before ratelimits
if (req.headers['content-range'] && zconfig.chunks.enabled) {
if (format === 'name') {
const existing = await prisma.file.findFirst({
where: {
name: req.headers['x-zipline-partial-filename'] as string,
},
});
if (existing) return res.badRequest('filename already exists (conflict: NAME format)');
}
// parses content-range header (bytes start-end/total)
const [start, end, total] = req.headers['content-range']
.replace('bytes ', '')
@@ -169,7 +101,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
start,
end,
total,
})}`,
})}`
);
const tempFile = join(zconfig.core.temp_directory, `zipline_partial_${identifier}_${start}_${end}`);
@@ -177,41 +109,12 @@ async function handler(req: NextApiReq, res: NextApiRes) {
await writeFile(tempFile, req.files[0].buffer);
if (lastchunk) {
const fileName = await formatFileName(format, filename);
const ext = filename.split('.').length === 1 ? '' : filename.split('.').pop();
const file = await prisma.file.create({
data: {
name: `${fileName}${ext ? '.' : ''}${ext}`,
mimetype: req.headers.uploadtext ? 'text/plain' : mimetype,
userId: user.id,
originalName: req.headers['original-name'] ? filename ?? null : null,
...(folderToAdd && {
folderId: folderToAdd,
}),
},
});
let domain;
if (req.headers['override-domain']) {
domain = `${zconfig.core.return_https ? 'https' : 'http'}://${req.headers['override-domain']}`;
} else if (user.domains.length) {
domain = user.domains[Math.floor(Math.random() * user.domains.length)];
} else {
domain = `${zconfig.core.return_https ? 'https' : 'http'}://${req.headers.host}`;
}
const responseUrl = `${domain}${
zconfig.uploader.route === '/' ? '/' : zconfig.uploader.route + '/'
}${encodeURI(file.name)}`;
new Worker('./dist/worker/upload.js', {
workerData: {
user,
file: {
id: file.id,
filename: file.name,
mimetype: file.mimetype,
filename,
mimetype,
identifier,
lastchunk,
totalBytes: total,
@@ -219,6 +122,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
response: {
expiresAt: expiry,
format,
imageCompressionPercent,
fileMaxViews,
},
headers: req.headers,
@@ -227,7 +131,6 @@ async function handler(req: NextApiReq, res: NextApiRes) {
return res.json({
pending: true,
files: [responseUrl],
});
}
@@ -236,6 +139,23 @@ async function handler(req: NextApiReq, res: NextApiRes) {
});
}
if (user.ratelimit) {
const remaining = user.ratelimit.getTime() - Date.now();
logger.debug(`${user.id} encountered ratelimit, ${remaining}ms remaining`);
if (remaining <= 0) {
await prisma.user.update({
where: {
id: user.id,
},
data: {
ratelimit: null,
},
});
} else {
return res.ratelimited(remaining);
}
}
if (!req.files) return res.badRequest('no files');
if (req.files && req.files.length === 0) return res.badRequest('no files');
@@ -247,32 +167,29 @@ async function handler(req: NextApiReq, res: NextApiRes) {
mimetype: x.mimetype,
size: x.size,
encoding: x.encoding,
})),
)}`,
}))
)}`
);
for (let i = 0; i !== req.files.length; ++i) {
const file = req.files[i];
if (file.size > zconfig.uploader[user.administrator ? 'admin_limit' : 'user_limit'])
return res.badRequest(`file[${i}]: size too big`);
if (!file.originalname) return res.badRequest(`file[${i}]: no filename`);
const decodedName = decodeURI(file.originalname);
const ext = decodedName.split('.').length === 1 ? '' : decodedName.split('.').pop();
const ext = file.originalname.split('.').length === 1 ? '' : file.originalname.split('.').pop();
if (zconfig.uploader.disabled_extensions.includes(ext))
return res.badRequest(`file[${i}]: disabled extension recieved: ${ext}`);
const fileName = await formatFileName(format, decodedName);
let fileName = await formatFileName(format, file.originalname);
if (format === 'name' || req.headers['x-zipline-filename']) {
const exist = (req.headers['x-zipline-filename'] as string) || decodedName;
if (req.headers['x-zipline-filename']) {
fileName = req.headers['x-zipline-filename'] as string;
const existing = await prisma.file.findFirst({
where: {
name: exist,
name: fileName,
},
});
if (existing) return res.badRequest(`file[${i}]: filename already exists: '${decodedName}'`);
if (existing) return res.badRequest(`file[${i}]: filename already exists: '${fileName}'`);
}
let password = null;
@@ -283,7 +200,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
let mimetype = file.mimetype;
if (file.mimetype === 'application/octet-stream' && zconfig.uploader.assume_mimetypes) {
const ext = parse(decodedName).ext.replace('.', '');
const ext = parse(file.originalname).ext.replace('.', '');
const mime = await guess(ext);
if (!mime) response.assumed_mimetype = false;
@@ -304,25 +221,21 @@ async function handler(req: NextApiReq, res: NextApiRes) {
password,
expiresAt: expiry,
maxViews: fileMaxViews,
originalName: req.headers['original-name'] ? decodedName ?? null : null,
originalName: req.headers['original-name'] ? file.originalname ?? null : null,
size: file.size,
...(folderToAdd && {
folderId: folderToAdd,
}),
},
});
if (typeof req.headers.zws !== 'undefined' && (req.headers.zws as string).toLowerCase().match('true'))
invis = await createInvisImage(zconfig.uploader.length, fileUpload.id);
if (req.headers.zws) invis = await createInvisImage(zconfig.uploader.length, fileUpload.id);
if (compressionUsed) {
const buffer = await sharp(file.buffer).jpeg({ quality: imageCompressionPercent }).toBuffer();
await datasource.save(fileUpload.name, buffer, { type: 'image/jpeg' });
await datasource.save(fileUpload.name, buffer);
logger.info(
`User ${user.username} (${user.id}) compressed image from ${file.buffer.length} -> ${buffer.length} bytes`,
`User ${user.username} (${user.id}) compressed image from ${file.buffer.length} -> ${buffer.length} bytes`
);
} else {
await datasource.save(fileUpload.name, file.buffer, { type: file.mimetype });
await datasource.save(fileUpload.name, file.buffer);
}
logger.info(`User ${user.username} (${user.id}) uploaded ${fileUpload.name} (${fileUpload.id})`);
@@ -346,7 +259,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
user,
fileUpload,
`${domain}/r/${invis ? invis.invis : encodeURI(fileUpload.name)}`,
responseUrl,
responseUrl
);
}
@@ -362,6 +275,28 @@ async function handler(req: NextApiReq, res: NextApiRes) {
}
}
if (user.administrator && zconfig.ratelimit.admin > 0) {
await prisma.user.update({
where: {
id: user.id,
},
data: {
ratelimit: new Date(Date.now() + zconfig.ratelimit.admin * 1000),
},
});
} else if (!user.administrator && zconfig.ratelimit.user > 0) {
if (user.administrator && zconfig.ratelimit.user > 0) {
await prisma.user.update({
where: {
id: user.id,
},
data: {
ratelimit: new Date(Date.now() + zconfig.ratelimit.user * 1000),
},
});
}
}
if (req.headers['no-json']) {
res.setHeader('Content-Type', 'text/plain');
return res.end(response.files.join(','));
+9 -33
View File
@@ -3,8 +3,6 @@ import Logger from 'lib/logger';
import prisma from 'lib/prisma';
import { hashPassword } from 'lib/util';
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';
const logger = Logger.get('user');
@@ -12,21 +10,12 @@ const logger = Logger.get('user');
async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
const { id } = req.query as { id: string };
if (!id || isNaN(parseInt(id))) return res.notFound('no user provided');
const target = await prisma.user.findFirst({
where: {
id: parseInt(id),
id: Number(id),
},
include: {
files: {
include: {
thumbnail: true,
},
orderBy: {
createdAt: 'desc',
},
},
files: true,
Folder: true,
},
});
@@ -41,7 +30,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
promises.push(
prisma.user.delete({
where: { id: target.id },
}),
})
);
if (req.body.delete_files) {
@@ -66,7 +55,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
where: {
userId: target.id,
},
}),
})
);
}
Promise.all(promises).then((promised) => {
@@ -76,10 +65,10 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
req.body.delete_files
? logger.info(
`User ${user.username} (${user.id}) deleted ${count} files of user ${newTarget.username} (${newTarget.id})`,
`User ${user.username} (${user.id}) deleted ${count} files of user ${newTarget.username} (${newTarget.id})`
)
: logger.info(
`User ${user.username} (${user.id}) deleted user ${newTarget.username} (${newTarget.id})`,
`User ${user.username} (${user.id}) deleted user ${newTarget.username} (${newTarget.id})`
);
delete newTarget.password;
@@ -182,30 +171,17 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
logger.debug(`updated user ${id} with ${JSON.stringify(newUser, jsonUserReplacer)}`);
logger.info(
`User ${user.username} (${user.id}) updated ${target.username} (${newUser.username}) (${newUser.id})`,
`User ${user.username} (${user.id}) updated ${target.username} (${newUser.username}) (${newUser.id})`
);
delete newUser.password;
return res.json(newUser);
} else {
delete target.password;
delete target.totpSecret;
if (user.superAdmin && target.superAdmin) {
if (user.superAdmin && target.superAdmin) delete target.files;
if (user.administrator && !user.superAdmin && (target.administrator || 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);
}
+10 -11
View File
@@ -3,19 +3,18 @@ import prisma from 'lib/prisma';
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
async function handler(req: NextApiReq, res: NextApiRes) {
const { code, username } = req.body as { code?: string; username?: string };
if (!config.features.user_registration && !req.body.code)
return res.badRequest('user registration is disabled');
else if (!config.features.invites && req.body.code) return res.forbidden('user/invites are disabled');
if (!config.features.user_registration && !code) return res.badRequest('user registration is disabled');
else if (!config.features.invites && code) return res.forbidden('user invites are disabled');
if (!req.body?.code) return res.badRequest('no code');
if (!req.body?.username) return res.badRequest('no username');
if (config.features.invites && !code) return res.badRequest('no code');
else if (config.features.invites && code) {
const invite = await prisma.invite.findUnique({
where: { code },
});
if (!invite) return res.badRequest('invalid invite code');
}
if (!username) return res.badRequest('no username');
const { code, username } = req.body as { code: string; username: string };
const invite = await prisma.invite.findUnique({
where: { code },
});
if (!invite) return res.badRequest('invalid invite code');
const user = await prisma.user.findFirst({
where: { username },
+18 -76
View File
@@ -1,6 +1,6 @@
import { Zip, ZipPassThrough } from 'fflate';
import { createReadStream, createWriteStream } from 'fs';
import { rm, stat } from 'fs/promises';
import { readdir, stat } from 'fs/promises';
import datasource from 'lib/datasource';
import Logger from 'lib/logger';
import prisma from 'lib/prisma';
@@ -23,13 +23,6 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
const export_name = `zipline_export_${user.id}_${Date.now()}.zip`;
const path = join(config.core.temp_directory, export_name);
const exportDb = await prisma.export.create({
data: {
path: export_name,
userId: user.id,
},
});
logger.debug(`creating write stream at ${path}`);
const write_stream = createWriteStream(path);
@@ -84,29 +77,13 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
write_stream.close();
logger.debug(`finished writing zip to ${path} at ${data.length} bytes written`);
logger.info(
`Export for ${user.username} (${user.id}) has completed and is available at ${export_name}`,
`Export for ${user.username} (${user.id}) has completed and is available at ${export_name}`
);
await prisma.export.update({
where: {
id: exportDb.id,
},
data: {
complete: true,
},
});
}
} else {
write_stream.close();
logger.error(
`Export for ${user.username} (${user.id}) has failed and has been removed from the database\n${err}`,
);
await prisma.export.delete({
where: {
id: exportDb.id,
},
});
logger.debug(`error while writing to zip: ${err}`);
logger.error(`Export for ${user.username} (${user.id}) has failed\n${err}`);
}
};
@@ -137,62 +114,27 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
res.json({
url: '/api/user/export?name=' + export_name,
});
} else if (req.method === 'DELETE') {
const name = req.query.name as string;
if (!name) return res.badRequest('no name provided');
const exportDb = await prisma.export.findFirst({
where: {
userId: user.id,
path: name,
},
});
if (!exportDb) return res.notFound('export not found');
await prisma.export.delete({
where: {
id: exportDb.id,
},
});
try {
await rm(join(config.core.temp_directory, exportDb.path));
} catch (e) {
logger
.error(`export file ${exportDb.path} has been removed from the database`)
.error(`but failed to remove the file from the filesystem: ${e}`);
}
res.json({
success: true,
});
} else {
const exportsDb = await prisma.export.findMany({
where: {
userId: user.id,
},
});
const export_name = req.query.name as string;
if (export_name) {
const parts = export_name.split('_');
if (Number(parts[2]) !== user.id) return res.unauthorized('cannot access export owned by another user');
const name = req.query.name as string;
if (name) {
const exportDb = exportsDb.find((e) => e.path === name);
if (!exportDb) return res.notFound('export not found');
const stream = createReadStream(join(config.core.temp_directory, exportDb.path));
const stream = createReadStream(join(config.core.temp_directory, export_name));
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${exportDb.path}"`);
res.setHeader('Content-Disposition', `attachment; filename="${export_name}"`);
stream.pipe(res);
} else {
const files = await readdir(config.core.temp_directory);
const exp = files.filter((f) => f.startsWith('zipline_export_'));
const exports = [];
for (let i = 0; i !== exp.length; ++i) {
const name = exp[i];
const stats = await stat(join(config.core.temp_directory, name));
for (let i = 0; i !== exportsDb.length; ++i) {
const exportDb = exportsDb[i];
if (!exportDb.complete) continue;
const stats = await stat(join(config.core.temp_directory, exportDb.path));
exports.push({ name: exportDb.path, size: stats.size, createdAt: exportDb.createdAt });
if (Number(exp[i].split('_')[2]) !== user.id) continue;
exports.push({ name, size: stats.size });
}
res.json({
@@ -203,6 +145,6 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
}
export default withZipline(handler, {
methods: ['GET', 'POST', 'DELETE'],
methods: ['GET', 'POST'],
user: true,
});
+2 -12
View File
@@ -14,14 +14,10 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
where: {
userId: user.id,
},
include: {
thumbnail: true,
},
});
for (let i = 0; i !== files.length; ++i) {
await datasource.delete(files[i].name);
if (files[i].thumbnail?.name) await datasource.delete(files[i].thumbnail.name);
}
const { count } = await prisma.file.deleteMany({
@@ -49,7 +45,6 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
id: true,
},
},
thumbnail: true,
},
});
@@ -68,15 +63,13 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
id: true,
},
},
thumbnail: true,
},
});
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}) owned by ${file.user.username} (${file.user.id})`,
`User ${user.username} (${user.id}) deleted an image ${file.name} (${file.id}) owned by ${file.user.username} (${file.user.id})`
);
// @ts-ignore
@@ -139,10 +132,9 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
expiresAt: Date;
maxViews: number;
views: number;
size: bigint;
size: number;
originalName: string;
thumbnail?: { name: string };
password: string | boolean;
}[] = await prisma.file.findMany({
where: {
userId: user.id,
@@ -164,13 +156,11 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
size: true,
originalName: true,
thumbnail: true,
password: true,
},
});
for (let i = 0; i !== files.length; ++i) {
(files[i] as unknown as { url: string }).url = formatRootUrl(config.uploader.route, files[i].name);
files[i].password = !!files[i].password;
if (files[i].thumbnail) {
(files[i].thumbnail as unknown as string) = formatRootUrl('/r', files[i].thumbnail.name);

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