mirror of
https://github.com/diced/zipline.git
synced 2025-12-06 12:51:13 -08:00
Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
920c996892 | ||
|
|
f896bfa413 | ||
|
|
61ab5a192b | ||
|
|
478baeca83 | ||
|
|
df84edd310 | ||
|
|
fc6060fe9c | ||
|
|
2de036c89f | ||
|
|
956fafb826 | ||
|
|
1be47b4d36 | ||
|
|
6646c1e591 | ||
|
|
1febd5aca0 | ||
|
|
41e197ed4a | ||
|
|
2f12b63753 | ||
|
|
b5f09673ac | ||
|
|
eb71c2bb54 | ||
|
|
f36ab9e7b6 | ||
|
|
34a993fcc6 | ||
|
|
aa9f0796ab | ||
|
|
c0b2dda7da | ||
|
|
1e507bbf9c | ||
|
|
6271b800c2 | ||
|
|
effe1f9ec1 | ||
|
|
b6615621e1 | ||
|
|
145b1ca727 | ||
|
|
6f75bbee7b | ||
|
|
58a4580cf0 | ||
|
|
48cfa41405 | ||
|
|
9c26d64420 | ||
|
|
f3638f3d6d | ||
|
|
8e59158769 | ||
|
|
317c7365f8 | ||
|
|
974e9f7fa2 | ||
|
|
4330bdcc4c | ||
|
|
7f9de82804 | ||
|
|
70050afb5f | ||
|
|
1f00dd51f9 | ||
|
|
5e37d89b18 | ||
|
|
08d3bfb36d | ||
|
|
56f07cb5ec | ||
|
|
658cc61df0 | ||
|
|
d3be545548 | ||
|
|
c8625c1e13 | ||
|
|
511f17e1a5 | ||
|
|
5b88b59724 | ||
|
|
1816e13879 | ||
|
|
1a837c02d2 | ||
|
|
f3634eff48 | ||
|
|
23ef407dd3 | ||
|
|
f40803f515 | ||
|
|
6b97d30a69 | ||
|
|
bd8d4e33fd | ||
|
|
70d48dd8c3 | ||
|
|
2e0a5f1d9c | ||
|
|
0ab814fc11 | ||
|
|
265760fb9c | ||
|
|
76ff3817af | ||
|
|
0dfe3fdcd1 | ||
|
|
5a522e0375 | ||
|
|
b15390f26c | ||
|
|
6fef197620 | ||
|
|
1d0bb2fa4f | ||
|
|
abb5bb5f25 | ||
|
|
4061da8622 | ||
|
|
6ef3c8274b | ||
|
|
e5ac971c8f | ||
|
|
b4ec1088d1 |
@@ -7,4 +7,6 @@ RUN usermod -l zipline node \
|
||||
&& chmod 0440 /etc/sudoers.d/zipline \
|
||||
&& sudo apt-get update && apt-get install gnupg2 -y
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
USER zipline
|
||||
@@ -41,7 +41,7 @@
|
||||
"remoteUser": "zipline",
|
||||
"updateRemoteUserUID": true,
|
||||
"remoteEnv": {
|
||||
"CORE_DATABASE_URL": "postgres://postgres:postgres@localhost/zip10"
|
||||
"CORE_DATABASE_URL": "postgres://postgres:postgres@db/zip10"
|
||||
},
|
||||
"portsAttributes": {
|
||||
"3000": {
|
||||
|
||||
@@ -2,6 +2,8 @@ node_modules/
|
||||
.next/
|
||||
uploads/
|
||||
.git/
|
||||
!.git/refs
|
||||
!.git/HEAD
|
||||
.yarn/*
|
||||
!.yarn/releases
|
||||
!.yarn/plugins
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# every field in here is optional except, CORE_SECRET and CORE_DATABASE_URL.
|
||||
# if CORE_SECRET is still "changethis" then zipline will exit and tell you to change it.
|
||||
|
||||
# if using s3/supabase make sure to uncomment or comment out the correct lines needed.
|
||||
# if using s3 make sure to uncomment or comment out the correct lines needed.
|
||||
|
||||
CORE_RETURN_HTTPS=true
|
||||
CORE_SECRET="changethis"
|
||||
CORE_HOST=0.0.0.0
|
||||
CORE_PORT=3000
|
||||
CORE_DATABASE_URL="postgres://postgres:postgres@localhost/zip10"
|
||||
CORE_DATABASE_URL="postgres://postgres:postgres@db/zip10"
|
||||
CORE_LOGGER=false
|
||||
CORE_STATS_INTERVAL=1800
|
||||
CORE_INVITES_INTERVAL=1800
|
||||
@@ -27,13 +27,6 @@ DATASOURCE_LOCAL_DIRECTORY=./uploads
|
||||
# DATASOURCE_S3_FORCE_S3_PATH=false
|
||||
# DATASOURCE_S3_USE_SSL=false
|
||||
|
||||
# or supabase
|
||||
# DATASOURCE_TYPE=supabase
|
||||
# DATASOURCE_SUPABASE_KEY=xxx
|
||||
# remember: no leading slash
|
||||
# DATASOURCE_SUPABASE_URL=https://something.supabase.co
|
||||
# DATASOURCE_SUPABASE_BUCKET=zipline
|
||||
|
||||
UPLOADER_DEFAULT_FORMAT=RANDOM
|
||||
UPLOADER_ROUTE=/u
|
||||
UPLOADER_LENGTH=6
|
||||
|
||||
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@@ -2,9 +2,9 @@ name: 'Build'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ trunk ]
|
||||
branches: [ v3 ]
|
||||
pull_request:
|
||||
branches: [ trunk ]
|
||||
branches: [ v3 ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
20
.github/workflows/docker-release.yml
vendored
20
.github/workflows/docker-release.yml
vendored
@@ -3,7 +3,7 @@ name: 'Push Release Docker Images'
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
- 'v3.*.*'
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'server/**'
|
||||
@@ -13,8 +13,8 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
push_to_ghcr:
|
||||
name: Push Release Image to GitHub Packages
|
||||
push:
|
||||
name: Push Release Image
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
@@ -32,20 +32,28 @@ jobs:
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to Github Packages
|
||||
- name: Login to GitHub Packages
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Docker Image
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and Push Docker Image
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
ghcr.io/diced/zipline:latest
|
||||
ghcr.io/diced/zipline:v3
|
||||
ghcr.io/diced/zipline:${{ steps.version.outputs.zipline_version }}
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/zipline:v3
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/zipline:${{ steps.version.outputs.zipline_version }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
24
.github/workflows/docker.yml
vendored
24
.github/workflows/docker.yml
vendored
@@ -2,7 +2,7 @@ name: 'Push Docker Images'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ trunk ]
|
||||
branches: [ v3 ]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'server/**'
|
||||
@@ -12,8 +12,8 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
push_to_ghcr:
|
||||
name: Push Image to GitHub Packages
|
||||
push:
|
||||
name: Push Commit Image
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
- name: Get version
|
||||
id: version
|
||||
run: |
|
||||
echo "zipline_version=$(jq .version package.json -r)" >> $GITHUB_OUTPUT
|
||||
echo "zipline_commit=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
@@ -38,13 +38,23 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Docker Image
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and Push Docker Image
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
ghcr.io/diced/zipline:trunk
|
||||
ghcr.io/diced/zipline:trunk-${{ steps.version.outputs.zipline_version }}
|
||||
ghcr.io/diced/zipline:v3-trunk
|
||||
ghcr.io/diced/zipline:v3-trunk-${{ steps.version.outputs.zipline_commit }}
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/zipline:v3-trunk
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/zipline:v3-trunk-${{ steps.version.outputs.zipline_commit }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
|
||||
|
||||
31
.github/workflows/milestone.yml
vendored
31
.github/workflows/milestone.yml
vendored
@@ -1,31 +0,0 @@
|
||||
name: 'Issue/PR Milestones'
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
issues:
|
||||
types: [opened, reopened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
checks: write
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
set:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/github-script@v6
|
||||
with:
|
||||
github-token: ${{secrets.GITHUB_TOKEN}}
|
||||
script: |
|
||||
const milestone = 3
|
||||
github.rest.issues.update({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
milestone
|
||||
})
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -31,6 +31,7 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
|
||||
21
Dockerfile
21
Dockerfile
@@ -1,8 +1,8 @@
|
||||
# Use the Prisma binaries image as the first stage
|
||||
FROM ghcr.io/diced/prisma-binaries:4.10.x as prisma
|
||||
FROM ghcr.io/diced/prisma-binaries:5.1.x AS prisma
|
||||
|
||||
# Use Alpine Linux as the second stage
|
||||
FROM node:18-alpine3.16 as base
|
||||
FROM node:18-alpine3.16 AS base
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /zipline
|
||||
@@ -18,9 +18,7 @@ COPY .yarnrc.yml ./
|
||||
# Copy the prisma binaries from prisma stage
|
||||
COPY --from=prisma /prisma-engines /prisma-engines
|
||||
ENV PRISMA_QUERY_ENGINE_BINARY=/prisma-engines/query-engine \
|
||||
PRISMA_MIGRATION_ENGINE_BINARY=/prisma-engines/migration-engine \
|
||||
PRISMA_INTROSPECTION_ENGINE_BINARY=/prisma-engines/introspection-engine \
|
||||
PRISMA_FMT_BINARY=/prisma-engines/prisma-fmt \
|
||||
PRISMA_SCHEMA_ENGINE_BINARY=/prisma-engines/schema-engine \
|
||||
PRISMA_CLI_QUERY_ENGINE_TYPE=binary \
|
||||
PRISMA_CLIENT_ENGINE_TYPE=binary \
|
||||
ZIPLINE_DOCKER_BUILD=true \
|
||||
@@ -29,8 +27,10 @@ ENV PRISMA_QUERY_ENGINE_BINARY=/prisma-engines/query-engine \
|
||||
# Install the dependencies
|
||||
RUN yarn install --immutable
|
||||
|
||||
FROM base as builder
|
||||
FROM base AS builder
|
||||
|
||||
COPY .git/refs ./.git/refs
|
||||
COPY .git/HEAD ./.git/HEAD
|
||||
COPY src ./src
|
||||
COPY next.config.js ./next.config.js
|
||||
COPY tsup.config.ts ./tsup.config.ts
|
||||
@@ -47,16 +47,17 @@ FROM base
|
||||
# Install the necessary packages
|
||||
RUN apk add --no-cache perl procps tini
|
||||
|
||||
COPY --from=builder /prisma-engines /prisma-engines
|
||||
COPY --from=prisma /prisma-engines /prisma-engines
|
||||
ENV PRISMA_QUERY_ENGINE_BINARY=/prisma-engines/query-engine \
|
||||
PRISMA_MIGRATION_ENGINE_BINARY=/prisma-engines/migration-engine \
|
||||
PRISMA_INTROSPECTION_ENGINE_BINARY=/prisma-engines/introspection-engine \
|
||||
PRISMA_FMT_BINARY=/prisma-engines/prisma-fmt \
|
||||
PRISMA_SCHEMA_ENGINE_BINARY=/prisma-engines/schema-engine \
|
||||
PRISMA_CLI_QUERY_ENGINE_TYPE=binary \
|
||||
PRISMA_CLIENT_ENGINE_TYPE=binary \
|
||||
ZIPLINE_DOCKER_BUILD=true \
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Copy only the necessary files from the previous stage
|
||||
COPY --from=builder /zipline/.git/refs ./.git/refs
|
||||
COPY --from=builder /zipline/.git/HEAD ./.git/HEAD
|
||||
COPY --from=builder /zipline/dist ./dist
|
||||
COPY --from=builder /zipline/.next ./.next
|
||||
|
||||
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 dicedtomato
|
||||
Copyright (c) 2024 dicedtomato
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -121,7 +121,7 @@ This section requires [Flameshot](https://www.flameshot.org/), [jq](https://sted
|
||||
|
||||
If using wayland you will need to have [wl-clipboard](https://github.com/bugaevc/wl-clipboard) installed, for the `wl-copy` command.
|
||||
|
||||
If you are not using GNOME/KDE/Qtile/Sway, and are using something like a wlroots-based compositor (ex. [Hyprland](https://github.com/hyprwm/Hyprland/), [River](https://github.com/riverwm/river), etc), you will need to set the `XDG_CURRENT_DESKTOP` environment variable to `sway`, which will just override it for this script. Adding `export XDG_CURRENT_DESKTOP=sway` to the start of the script will work.
|
||||
If you are not using GNOME/KDE/Qtile/Sway, and are using something like a wlroots-based or wlroots-compatible compositor (ex. [Hyprland](https://github.com/hyprwm/Hyprland/), [River](https://github.com/riverwm/river), etc), you will need to set the `XDG_CURRENT_DESKTOP` environment variable to `sway`, which will just override it for this script. Adding `export XDG_CURRENT_DESKTOP=sway` to the start of the script will work.
|
||||
|
||||
After this, replace the `xsel -ib` with `wl-copy` in the script.
|
||||
|
||||
@@ -141,7 +141,7 @@ To upload files using flameshot we will use a script. Replace $TOKEN and $HOST w
|
||||
DATE=$(date '+%h_%Y_%d_%I_%m_%S.png');
|
||||
flameshot gui -r > ~/Pictures/$DATE;
|
||||
|
||||
curl -H "Content-Type: multipart/form-data" -H "authorization: $TOKEN" -F file=@$1 $HOST/api/upload | jq -r 'files[0].url' | xsel -ib
|
||||
curl -H "Content-Type: multipart/form-data" -H "authorization: $TOKEN" -F file=@$1 $HOST/api/upload | jq -r '.files[0]' | xsel -ib
|
||||
```
|
||||
|
||||
# Contributing
|
||||
@@ -169,4 +169,4 @@ Create a pull request on GitHub. If your PR does not pass the action checks, the
|
||||
|
||||
# Documentation
|
||||
|
||||
Documentation source code is located in [diced/zipline-docs](https://github.com/diced/zipline-docs), and can be accessed [here](https://zipl.vercel.app).
|
||||
Documentation source code is located in [diced/zipline-docs](https://github.com/diced/zipline-docs), and can be accessed [here](https://zipline.diced.sh/docs/get-started).
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
version: '3'
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
version: '3'
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
|
||||
@@ -42,6 +42,9 @@
|
||||
["afm", ["application/octet-stream"]],
|
||||
["afp", ["application/vnd.ibm.modcap"]],
|
||||
["ahead", ["application/vnd.ahead.space"]],
|
||||
["ahk", ["text/autohotkey"]],
|
||||
["ahk1", ["text/autohotkey"]],
|
||||
["ahk2", ["text/autohotkey"]],
|
||||
["ai", ["application/postscript"]],
|
||||
["aif", ["audio/aiff"]],
|
||||
["aifc", ["audio/aiff"]],
|
||||
|
||||
34
package.json
34
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "zipline",
|
||||
"version": "3.7.6",
|
||||
"version": "3.7.13",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "npm-run-all build:server dev:run",
|
||||
@@ -30,18 +30,18 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/server": "^11.11.0",
|
||||
"@mantine/core": "^6.0.21",
|
||||
"@mantine/dropzone": "^6.0.21",
|
||||
"@mantine/form": "^6.0.21",
|
||||
"@mantine/hooks": "^6.0.21",
|
||||
"@mantine/modals": "^6.0.21",
|
||||
"@mantine/next": "^6.0.21",
|
||||
"@mantine/notifications": "^6.0.21",
|
||||
"@mantine/prism": "^6.0.21",
|
||||
"@mantine/spotlight": "^6.0.21",
|
||||
"@prisma/client": "^4.16.2",
|
||||
"@prisma/internals": "^4.16.2",
|
||||
"@prisma/migrate": "^4.16.2",
|
||||
"@mantine/core": "6.x",
|
||||
"@mantine/dropzone": "6.x",
|
||||
"@mantine/form": "6.x",
|
||||
"@mantine/hooks": "6.x",
|
||||
"@mantine/modals": "6.x",
|
||||
"@mantine/next": "6.x",
|
||||
"@mantine/notifications": "6.x",
|
||||
"@mantine/prism": "6.x",
|
||||
"@mantine/spotlight": "6.x",
|
||||
"@prisma/client": "^5.1.1",
|
||||
"@prisma/internals": "^5.1.1",
|
||||
"@prisma/migrate": "^5.1.1",
|
||||
"@sapphire/shapeshift": "^3.9.3",
|
||||
"@tabler/icons-react": "^2.41.0",
|
||||
"@tanstack/react-query": "^4.28.0",
|
||||
@@ -63,15 +63,15 @@
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"next": "^14.0.3",
|
||||
"otplib": "^12.0.1",
|
||||
"prisma": "^4.16.2",
|
||||
"prisma": "^5.1.1",
|
||||
"prismjs": "^1.29.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^8.0.6",
|
||||
"react-markdown": "^9.0.3",
|
||||
"recharts": "^2.10.1",
|
||||
"recoil": "^0.7.7",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sharp": "^0.32.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -79,7 +79,7 @@
|
||||
"@types/katex": "^0.16.6",
|
||||
"@types/minio": "^7.1.1",
|
||||
"@types/multer": "^1.4.10",
|
||||
"@types/node": "^18.18.10",
|
||||
"@types/node": "18",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/sharp": "^0.32.0",
|
||||
|
||||
14
prisma/migrations/20240912180249_exports/migration.sql
Normal file
14
prisma/migrations/20240912180249_exports/migration.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Export" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"complete" BOOLEAN NOT NULL DEFAULT false,
|
||||
"path" TEXT NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "Export_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Export" ADD CONSTRAINT "Export_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -27,6 +27,20 @@ model User {
|
||||
Invite Invite[]
|
||||
Folder Folder[]
|
||||
IncompleteFile IncompleteFile[]
|
||||
Exports Export[]
|
||||
}
|
||||
|
||||
model Export {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
complete Boolean @default(false)
|
||||
|
||||
path String
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId Int
|
||||
}
|
||||
|
||||
model Folder {
|
||||
|
||||
@@ -72,6 +72,9 @@ export default function File({
|
||||
},
|
||||
transition: 'filter 0.2s ease-in-out',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
shadow='md'
|
||||
onClick={() => setOpen(true)}
|
||||
|
||||
@@ -316,7 +316,9 @@ export default function Layout({ children, props }) {
|
||||
variant='dot'
|
||||
color={version.data.update ? 'red' : 'primary'}
|
||||
>
|
||||
{version.data.versions.current}
|
||||
{version.data.isUpstream
|
||||
? version.data.versions.current.slice(0, 7)
|
||||
: version.data.versions.current}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</Navbar.Section>
|
||||
@@ -356,7 +358,7 @@ export default function Layout({ children, props }) {
|
||||
)
|
||||
}
|
||||
variant='subtle'
|
||||
color='gray'
|
||||
color={theme.colorScheme === 'dark' ? 'dark' : 'gray'}
|
||||
compact
|
||||
size='xl'
|
||||
p='sm'
|
||||
|
||||
@@ -4,6 +4,10 @@ import { useEffect } from 'react';
|
||||
import ayu_dark from 'lib/themes/ayu_dark';
|
||||
import ayu_light from 'lib/themes/ayu_light';
|
||||
import ayu_mirage from 'lib/themes/ayu_mirage';
|
||||
import catppuccin_mocha from 'lib/themes/catppuccin_mocha';
|
||||
import catppuccin_macchiato from 'lib/themes/catppuccin_macchiato';
|
||||
import catppuccin_frappe from 'lib/themes/catppuccin_frappe';
|
||||
import catppuccin_latte from 'lib/themes/catppuccin_latte';
|
||||
import dark from 'lib/themes/dark';
|
||||
import dark_blue from 'lib/themes/dark_blue';
|
||||
import dracula from 'lib/themes/dracula';
|
||||
@@ -32,6 +36,10 @@ export const themes = {
|
||||
ayu_dark,
|
||||
ayu_mirage,
|
||||
ayu_light,
|
||||
catppuccin_mocha,
|
||||
catppuccin_macchiato,
|
||||
catppuccin_frappe,
|
||||
catppuccin_latte,
|
||||
nord,
|
||||
dracula,
|
||||
matcha_dark_azul,
|
||||
@@ -46,6 +54,10 @@ export const friendlyThemeName = {
|
||||
ayu_dark: 'Ayu Dark',
|
||||
ayu_mirage: 'Ayu Mirage',
|
||||
ayu_light: 'Ayu Light',
|
||||
catppuccin_mocha: 'Catppuccin Mocha',
|
||||
catppuccin_macchiato: 'Catppuccin Macchiato',
|
||||
catppuccin_frappe: 'Catppuccin Frappé',
|
||||
catppuccin_latte: 'Catppuccin Latte',
|
||||
nord: 'Nord',
|
||||
dracula: 'Dracula',
|
||||
matcha_dark_azul: 'Matcha Dark Azul',
|
||||
|
||||
@@ -27,7 +27,7 @@ import PrismCode from './render/PrismCode';
|
||||
|
||||
function PlaceholderContent({ text, Icon }) {
|
||||
return (
|
||||
<Group sx={(t) => ({ color: t.colors.dark[2] })}>
|
||||
<Group sx={(t) => ({ color: t.colors.dark[2], padding: 3, justifyContent: 'center' })}>
|
||||
<Icon size={48} />
|
||||
<Text size='md'>{text}</Text>
|
||||
</Group>
|
||||
@@ -60,7 +60,7 @@ function VideoThumbnailPlaceholder({ file, mediaPreview, ...props }) {
|
||||
return (
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<Image
|
||||
src={file.thumbnail}
|
||||
src={typeof file.thumbnail === 'string' ? file.thumbnail : `/r/${file.thumbnail.name}`}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
@@ -125,6 +125,17 @@ export default function Type({ file, popup = false, disableMediaPreview, ...prop
|
||||
);
|
||||
};
|
||||
|
||||
if (file.password) {
|
||||
return (
|
||||
<Placeholder
|
||||
Icon={IconFileAlert}
|
||||
text={`This file is password protected. Click to view file (${file.name})`}
|
||||
onClick={() => window.open(file.url)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if ((shouldRenderMarkdown || shouldRenderTex || shouldRenderCode) && !props.overrideRender && popup)
|
||||
return (
|
||||
<>
|
||||
@@ -143,17 +154,6 @@ export default function Type({ file, popup = false, disableMediaPreview, ...prop
|
||||
return <Placeholder Icon={IconFile} text={`Click to view file (${file.name})`} {...props} />;
|
||||
}
|
||||
|
||||
if (file.password) {
|
||||
return (
|
||||
<Placeholder
|
||||
Icon={IconFileAlert}
|
||||
text={`This file is password protected. Click to view file (${file.name})`}
|
||||
onClick={() => window.open(file.url)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return popup ? (
|
||||
media ? (
|
||||
{
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function Dropzone({ loading, onDrop, children }) {
|
||||
]}
|
||||
>
|
||||
<MantineDropzone loading={loading} onDrop={onDrop} styles={{ inner: { pointerEvents: 'none' } }}>
|
||||
<Group position='center' spacing='xl' style={{ minHeight: 440 }}>
|
||||
<Group position='center' spacing='xl' style={{ minHeight: 440, flexDirection: 'column' }}>
|
||||
<IconPhoto size={80} />
|
||||
|
||||
<Text size='xl' inline>
|
||||
|
||||
56
src/components/pages/Dashboard/Version4Notice.tsx
Normal file
56
src/components/pages/Dashboard/Version4Notice.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Alert, Stack, Anchor, Code, Text } from '@mantine/core';
|
||||
import { IconExclamationCircle } from '@tabler/icons-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export default function Version4Notice() {
|
||||
const key = 'zipline-v4-notice';
|
||||
|
||||
const [isClosed, setClosed] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dismissed = localStorage.getItem(key) === 'true';
|
||||
setClosed(dismissed);
|
||||
}, [key]);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
setClosed(true);
|
||||
localStorage.setItem(key, 'true');
|
||||
}, [key]);
|
||||
|
||||
if (isClosed === null) return null;
|
||||
if (isClosed) return null;
|
||||
|
||||
return (
|
||||
<Alert
|
||||
withCloseButton
|
||||
variant='outline'
|
||||
icon={<IconExclamationCircle size='1rem' />}
|
||||
title='⚠️ Important! ⚠️'
|
||||
p='md'
|
||||
mb='md'
|
||||
onClose={handleDismiss}
|
||||
color='red'
|
||||
>
|
||||
<Stack spacing='md'>
|
||||
<Text>
|
||||
Zipline v4 will be released soon, and is <b>NOT</b> compatible with v3 (the current version). If you
|
||||
are using external software to automatically update Zipline on new releases, it is{' '}
|
||||
<b>strongly advised</b> that you stop auto-updates for the time being until v4 is released. For more
|
||||
information, please visit{' '}
|
||||
<Anchor target='_blank' href='https://github.com/diced/zipline/tree/v4'>
|
||||
the <Code>v4</Code> branch
|
||||
</Anchor>{' '}
|
||||
on GitHub to view the progress of v4. If you have any questions, feel free to{' '}
|
||||
<Anchor target='_blank' href='https://zipline.diced.sh/discord'>
|
||||
join our discord
|
||||
</Anchor>
|
||||
.
|
||||
</Text>
|
||||
|
||||
<Text>
|
||||
If you are not the server administrator, please consider notifying them of this important message.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import RecentFiles from './RecentFiles';
|
||||
import { StatCards } from './StatCards';
|
||||
import Version4Notice from './Version4Notice';
|
||||
|
||||
export default function Dashboard({ disableMediaPreview, exifEnabled, compress }) {
|
||||
const user = useRecoilValue(userSelector);
|
||||
@@ -114,6 +115,8 @@ export default function Dashboard({ disableMediaPreview, exifEnabled, compress }
|
||||
window.open(`${window.location.protocol}//${window.location.host}${file.url}`);
|
||||
};
|
||||
|
||||
// local storage to whether to show alert or not
|
||||
|
||||
return (
|
||||
<div>
|
||||
{selectedFile && (
|
||||
@@ -130,6 +133,8 @@ export default function Dashboard({ disableMediaPreview, exifEnabled, compress }
|
||||
/>
|
||||
)}
|
||||
|
||||
<Version4Notice />
|
||||
|
||||
<Title>Welcome back, {user?.username}</Title>
|
||||
<MutedText size='md'>
|
||||
You have <b>{numFiles === 0 ? '...' : numFiles}</b> files
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useState } from 'react';
|
||||
|
||||
export default function ClearStorage({ open, setOpen }) {
|
||||
const [check, setCheck] = useState(false);
|
||||
const handleDelete = async (datasource: boolean, orphaned?: boolean) => {
|
||||
const handleDelete = async (orphaned?: boolean) => {
|
||||
showNotification({
|
||||
id: 'clear-uploads',
|
||||
title: 'Clearing...',
|
||||
@@ -16,7 +16,7 @@ export default function ClearStorage({ open, setOpen }) {
|
||||
autoClose: false,
|
||||
});
|
||||
|
||||
const res = await useFetch('/api/admin/clear', 'POST', { datasource, orphaned });
|
||||
const res = await useFetch('/api/admin/clear', 'POST', { orphaned });
|
||||
|
||||
if (res.error) {
|
||||
updateNotification({
|
||||
@@ -65,21 +65,13 @@ export default function ClearStorage({ open, setOpen }) {
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
openConfirmModal({
|
||||
title: 'Do you want to clear storage too?',
|
||||
labels: { confirm: 'Yes', cancel: check ? 'Ok' : 'No' },
|
||||
children: check && (
|
||||
<Text size='sm' color='gray'>
|
||||
Due to clearing orphaned files, storage clearing will be unavailable.
|
||||
</Text>
|
||||
),
|
||||
confirmProps: { disabled: check },
|
||||
title: 'Are you sure?',
|
||||
confirmProps: { color: 'red' },
|
||||
children: <Text size='sm'>This action is destructive and irreversible.</Text>,
|
||||
labels: { confirm: 'Yes', cancel: 'No' },
|
||||
onConfirm: () => {
|
||||
closeAllModals();
|
||||
handleDelete(true);
|
||||
},
|
||||
onCancel: () => {
|
||||
closeAllModals();
|
||||
handleDelete(false, check);
|
||||
handleDelete(check);
|
||||
},
|
||||
onClose: () => setCheck(false),
|
||||
});
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Anchor,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Code,
|
||||
ColorInput,
|
||||
CopyButton,
|
||||
FileInput,
|
||||
Group,
|
||||
Image,
|
||||
List,
|
||||
PasswordInput,
|
||||
SimpleGrid,
|
||||
Space,
|
||||
@@ -22,6 +25,7 @@ import { randomId, useInterval, useMediaQuery } from '@mantine/hooks';
|
||||
import { useModals } from '@mantine/modals';
|
||||
import { showNotification, updateNotification } from '@mantine/notifications';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconBrandDiscordFilled,
|
||||
IconBrandGithubFilled,
|
||||
IconBrandGoogle,
|
||||
@@ -41,6 +45,7 @@ import {
|
||||
IconUserExclamation,
|
||||
IconUserMinus,
|
||||
IconUserX,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import AnchorNext from 'components/AnchorNext';
|
||||
import { FlameshotIcon, ShareXIcon } from 'components/icons';
|
||||
@@ -264,7 +269,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
setExports(
|
||||
res.exports
|
||||
?.map((s) => ({
|
||||
date: new Date(Number(s.name.split('_')[3].slice(0, -4))),
|
||||
date: new Date(s.createdAt),
|
||||
size: s.size,
|
||||
full: s.name,
|
||||
}))
|
||||
@@ -272,6 +277,26 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
);
|
||||
};
|
||||
|
||||
const deleteExport = async (name) => {
|
||||
const res = await useFetch('/api/user/export?name=' + name, 'DELETE');
|
||||
if (res.error) {
|
||||
showNotification({
|
||||
title: 'Error deleting export',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <IconX size='1rem' />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
message: 'Deleted export',
|
||||
color: 'green',
|
||||
icon: <IconFileZip size='1rem' />,
|
||||
});
|
||||
|
||||
await getExports();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
const res = await useFetch('/api/user/files', 'DELETE', {
|
||||
all: true,
|
||||
@@ -355,6 +380,129 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
}
|
||||
};
|
||||
|
||||
const startFullExport = () => {
|
||||
modals.openConfirmModal({
|
||||
title: <Title>Are you sure?</Title>,
|
||||
size: 'xl',
|
||||
children: (
|
||||
<Box px='md'>
|
||||
<Alert color='red' icon={<IconAlertCircle size='1rem' />} title='Warning'>
|
||||
This export contains a significant amount of sensitive data, including user information,
|
||||
passwords, metadata, and system details. It is crucial to handle this file with care to prevent
|
||||
unauthorized access or misuse. Ensure it is stored securely and shared only with trusted parties.
|
||||
</Alert>
|
||||
|
||||
<p>
|
||||
The export provides a snapshot of Zipline's data and environment. Specifically, it includes:
|
||||
</p>
|
||||
|
||||
<List>
|
||||
<List.Item>
|
||||
<b>User Data:</b> Information about users, avatars, passwords, and registered OAuth providers.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<b>Files:</b> Metadata about uploaded files including filenames, passwords, sizes, and
|
||||
timestamps, linked users. <i>(Note: the actual contents of the files are not included.)</i>
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<b>URLs:</b> Metadata about shortened URLs, including the original URL, short URL, and vanity.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<b>Folders:</b> Metadata about folders, including names, visibility settings, and files.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<b>Thumbnails:</b> Metadata about thumbnails, includes the name and creation timestamp.{' '}
|
||||
<i>(Actual image data is excluded.)</i>
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<b>Invites:</b> Metadata about invites, includes the invite code, creator, and expiration date.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<b>Statistics:</b> Usage data that is used on the statistics page, including upload counts and
|
||||
such.
|
||||
</List.Item>
|
||||
</List>
|
||||
<p>
|
||||
Additionally, the export captures <b>system-specific information</b>:
|
||||
</p>
|
||||
<List>
|
||||
<List.Item>
|
||||
<b>CPU Count:</b> The number of processing cores available on the host system.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<b>Hostname:</b> The network identifier of the host system.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<b>Architecture:</b> The hardware architecture (e.g., <Code>x86</Code>, <Code>arm</Code>) on
|
||||
which Zipline is running.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<b>Platform:</b> The operating system platform (e.g., <Code>linux</Code>, <Code>darwin</Code>)
|
||||
on which Zipline is running.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<b>Version:</b> The current version of the operating system (kernel version)
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<b>Environment Variables:</b> The configuration settings and variables defined at the time of
|
||||
execution.
|
||||
</List.Item>
|
||||
</List>
|
||||
|
||||
<p>
|
||||
<i>Note:</i> By omitting the actual contents of files and thumbnails while including their
|
||||
metadata, the export ensures it captures enough detail for migration to another instance, or for
|
||||
v4.
|
||||
</p>
|
||||
</Box>
|
||||
),
|
||||
labels: { confirm: 'Yes', cancel: 'No' },
|
||||
cancelProps: { color: 'red' },
|
||||
onConfirm: async () => {
|
||||
modals.closeAll();
|
||||
showNotification({
|
||||
title: 'Exporting all server data...',
|
||||
message: 'This may take a while depending on the amount of data.',
|
||||
loading: true,
|
||||
id: 'export-all',
|
||||
autoClose: false,
|
||||
});
|
||||
|
||||
const res = await useFetch('/api/admin/export', 'GET');
|
||||
if (res.error) {
|
||||
updateNotification({
|
||||
id: 'export-all',
|
||||
title: 'Error exporting data',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <IconFileExport size='1rem' />,
|
||||
autoClose: true,
|
||||
});
|
||||
} else {
|
||||
updateNotification({
|
||||
title: 'Export created',
|
||||
message: 'Your browser will prompt you to download a JSON file with all the server data.',
|
||||
id: 'export-all',
|
||||
color: 'green',
|
||||
icon: <IconFileExport size='1rem' />,
|
||||
autoClose: true,
|
||||
});
|
||||
|
||||
const blob = new Blob([JSON.stringify(res)], { type: 'application/json' });
|
||||
const a = document.createElement('a');
|
||||
a.style.display = 'none';
|
||||
const url = URL.createObjectURL(blob);
|
||||
console.log(url, res);
|
||||
a.setAttribute('download', `zipline_export_${Date.now()}.json`);
|
||||
a.setAttribute('href', url);
|
||||
a.click();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const interval = useInterval(() => getExports(), 30000);
|
||||
useEffect(() => {
|
||||
getExports();
|
||||
@@ -580,6 +728,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
{ id: 'name', name: 'Name' },
|
||||
{ id: 'date', name: 'Date' },
|
||||
{ id: 'size', name: 'Size' },
|
||||
{ id: 'actions', name: '' },
|
||||
]}
|
||||
rows={
|
||||
exports
|
||||
@@ -591,6 +740,11 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
),
|
||||
date: x.date.toLocaleString(),
|
||||
size: bytesToHuman(x.size),
|
||||
actions: (
|
||||
<ActionIcon onClick={() => deleteExport(x.full)}>
|
||||
<IconTrash size='1rem' />
|
||||
</ActionIcon>
|
||||
),
|
||||
}))
|
||||
: []
|
||||
}
|
||||
@@ -615,6 +769,11 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
>
|
||||
Delete all uploads
|
||||
</Button>
|
||||
{user.superAdmin && (
|
||||
<Button size='md' onClick={startFullExport} rightIcon={<IconFileExport size='1rem' />}>
|
||||
Export all server data (JSON)
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -364,7 +364,8 @@ export default function File({ chunks: chunks_config }) {
|
||||
<Button
|
||||
leftIcon={<IconFileUpload size='1rem' />}
|
||||
onClick={handleUpload}
|
||||
disabled={files.length === 0 ? true : false}
|
||||
loading={loading}
|
||||
disabled={files.length === 0 || loading}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
|
||||
@@ -22,6 +22,7 @@ export default function Text() {
|
||||
|
||||
const [value, setValue] = useState('');
|
||||
const [lang, setLang] = useState('txt');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [options, setOpened, OptionsModal] = useUploadOptions();
|
||||
|
||||
@@ -29,6 +30,9 @@ export default function Text() {
|
||||
const shouldRenderTex = lang === 'tex';
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (value.trim().length === 0) return;
|
||||
|
||||
setLoading(true);
|
||||
const file = new File([value], 'text.' + lang);
|
||||
|
||||
const expiresAt = options.expires === 'never' ? null : expireReadToDate(options.expires);
|
||||
@@ -53,6 +57,16 @@ export default function Text() {
|
||||
message: '',
|
||||
});
|
||||
showFilesModal(clipboard, modals, json.files);
|
||||
setLoading(false);
|
||||
setValue('');
|
||||
} else {
|
||||
updateNotification({
|
||||
id: 'upload-text',
|
||||
title: 'Upload Failed',
|
||||
message: json.error,
|
||||
color: 'red',
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -136,7 +150,8 @@ export default function Text() {
|
||||
<Button
|
||||
leftIcon={<IconFileUpload size='1rem' />}
|
||||
onClick={handleUpload}
|
||||
disabled={value.trim().length === 0 ? true : false}
|
||||
disabled={value.trim().length === 0 || loading}
|
||||
loading={loading}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
|
||||
@@ -26,7 +26,7 @@ export function CreateUserModal({ open, setOpen, updateUsers }) {
|
||||
};
|
||||
|
||||
setOpen(false);
|
||||
const res = await useFetch('/api/auth/create', 'POST', data);
|
||||
const res = await useFetch('/api/auth/register', 'POST', data);
|
||||
if (res.error) {
|
||||
showNotification({
|
||||
title: 'Failed to create user',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ActionIcon, Button, Center, Group, SimpleGrid, Title } from '@mantine/core';
|
||||
import { File } from '@prisma/client';
|
||||
import type { File } from '@prisma/client';
|
||||
import { IconArrowLeft, IconFile } from '@tabler/icons-react';
|
||||
import FileComponent from 'components/File';
|
||||
import MutedText from 'components/MutedText';
|
||||
|
||||
@@ -7,16 +7,23 @@ import { Language } from 'prism-react-renderer';
|
||||
export default function Markdown({ code, ...props }) {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
remarkPlugins={[[remarkGfm, { singleTilde: false }]]}
|
||||
components={{
|
||||
code({ inline, className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
return !inline && match ? (
|
||||
<Prism language={match[1] as Language} {...props}>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
code({ children }) {
|
||||
return <Code>{children}</Code>;
|
||||
},
|
||||
pre({ children }) {
|
||||
// @ts-expect-error someone find the type for this :sob:
|
||||
const match = /language-(\w+)/.exec(children.props?.className || '');
|
||||
// @ts-ignore
|
||||
if (!children.props?.children) return code;
|
||||
return (
|
||||
<Prism language={match ? (match[1] as Language) : 'markdown'}>
|
||||
{
|
||||
// @ts-expect-error
|
||||
String(children.props?.children).replace(/\n$/, '')
|
||||
}
|
||||
</Prism>
|
||||
) : (
|
||||
<Code {...props}>{children}</Code>
|
||||
);
|
||||
},
|
||||
img(props) {
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function PrismCode({ code, ext, ...props }) {
|
||||
|
||||
return (
|
||||
<Prism
|
||||
sx={(t) => ({ height: '100vh', backgroundColor: t.colors.dark[8] })}
|
||||
sx={(t) => ({ height: '100vh', overflow: 'scroll', backgroundColor: t.colors.dark[8] })}
|
||||
withLineNumbers
|
||||
language={exts[ext]?.toLowerCase()}
|
||||
{...props}
|
||||
|
||||
@@ -20,10 +20,9 @@ export interface ConfigCompression {
|
||||
}
|
||||
|
||||
export interface ConfigDatasource {
|
||||
type: 'local' | 's3' | 'supabase';
|
||||
type: 'local' | 's3';
|
||||
local: ConfigLocalDatasource;
|
||||
s3?: ConfigS3Datasource;
|
||||
supabase?: ConfigSupabaseDatasource;
|
||||
}
|
||||
|
||||
export interface ConfigLocalDatasource {
|
||||
@@ -41,12 +40,6 @@ export interface ConfigS3Datasource {
|
||||
region?: string;
|
||||
}
|
||||
|
||||
export interface ConfigSupabaseDatasource {
|
||||
url: string;
|
||||
key: string;
|
||||
bucket: string;
|
||||
}
|
||||
|
||||
export interface ConfigUploader {
|
||||
default_format: string;
|
||||
route: string;
|
||||
@@ -126,6 +119,7 @@ export interface ConfigFeatures {
|
||||
robots_txt: string;
|
||||
|
||||
thumbnails: boolean;
|
||||
gif_thumbnails: boolean;
|
||||
}
|
||||
|
||||
export interface ConfigOAuth {
|
||||
|
||||
@@ -85,10 +85,6 @@ export default function readConfig() {
|
||||
map('DATASOURCE_S3_REGION', 'string', 'datasource.s3.region'),
|
||||
map('DATASOURCE_S3_USE_SSL', 'boolean', 'datasource.s3.use_ssl'),
|
||||
|
||||
map('DATASOURCE_SUPABASE_URL', 'string', 'datasource.supabase.url'),
|
||||
map('DATASOURCE_SUPABASE_KEY', 'string', 'datasource.supabase.key'),
|
||||
map('DATASOURCE_SUPABASE_BUCKET', 'string', 'datasource.supabase.bucket'),
|
||||
|
||||
map('UPLOADER_DEFAULT_FORMAT', 'string', 'uploader.default_format'),
|
||||
map('UPLOADER_ROUTE', 'string', 'uploader.route'),
|
||||
map('UPLOADER_LENGTH', 'number', 'uploader.length'),
|
||||
@@ -168,6 +164,7 @@ export default function readConfig() {
|
||||
map('FEATURES_ROBOTS_TXT', 'boolean', 'features.robots_txt'),
|
||||
|
||||
map('FEATURES_THUMBNAILS', 'boolean', 'features.thumbnails'),
|
||||
map('FEATURES_GIF_THUMBNAILS', 'boolean', 'features.gif_thumbnails'),
|
||||
|
||||
map('CHUNKS_MAX_SIZE', 'human-to-byte', 'chunks.max_size'),
|
||||
map('CHUNKS_CHUNKS_SIZE', 'human-to-byte', 'chunks.chunks_size'),
|
||||
|
||||
@@ -51,7 +51,7 @@ const validator = s.object({
|
||||
}),
|
||||
datasource: s
|
||||
.object({
|
||||
type: s.enum('local', 's3', 'supabase').default('local'),
|
||||
type: s.enum('local', 's3').default('local'),
|
||||
local: s
|
||||
.object({
|
||||
directory: s.string.default(resolve('./uploads')).transform((v) => resolve(v)),
|
||||
@@ -69,11 +69,6 @@ const validator = s.object({
|
||||
region: s.string.default('us-east-1'),
|
||||
use_ssl: s.boolean.default(false),
|
||||
}).optional,
|
||||
supabase: s.object({
|
||||
url: s.string,
|
||||
key: s.string,
|
||||
bucket: s.string,
|
||||
}).optional,
|
||||
})
|
||||
.default({
|
||||
type: 'local',
|
||||
@@ -196,6 +191,7 @@ const validator = s.object({
|
||||
default_avatar: s.string.nullable.default(null),
|
||||
robots_txt: s.boolean.default(false),
|
||||
thumbnails: s.boolean.default(false),
|
||||
gif_thumbnails: s.boolean.default(false),
|
||||
})
|
||||
.default({
|
||||
invites: false,
|
||||
@@ -207,6 +203,7 @@ const validator = s.object({
|
||||
default_avatar: null,
|
||||
robots_txt: false,
|
||||
thumbnails: false,
|
||||
gif_thumbnails: false,
|
||||
}),
|
||||
chunks: s
|
||||
.object({
|
||||
@@ -253,8 +250,8 @@ export default function validate(config): Config {
|
||||
logger.debug(`Attemping to validate ${JSON.stringify(config)}`);
|
||||
const validated = validator.parse(config);
|
||||
logger.debug(`Recieved config: ${JSON.stringify(validated)}`);
|
||||
switch (validated.datasource.type) {
|
||||
case 's3': {
|
||||
|
||||
if (validated.datasource.type === 's3') {
|
||||
const errors = [];
|
||||
if (!validated.datasource.s3.access_key_id)
|
||||
errors.push('datasource.s3.access_key_id is a required field');
|
||||
@@ -263,33 +260,19 @@ export default function validate(config): Config {
|
||||
if (!validated.datasource.s3.bucket) errors.push('datasource.s3.bucket is a required field');
|
||||
if (!validated.datasource.s3.endpoint) errors.push('datasource.s3.endpoint is a required field');
|
||||
if (errors.length) throw { errors };
|
||||
break;
|
||||
}
|
||||
case 'supabase': {
|
||||
const errors = [];
|
||||
|
||||
if (!validated.datasource.supabase.key) errors.push('datasource.supabase.key is a required field');
|
||||
if (!validated.datasource.supabase.url) errors.push('datasource.supabase.url is a required field');
|
||||
if (!validated.datasource.supabase.bucket)
|
||||
errors.push('datasource.supabase.bucket is a required field');
|
||||
if (errors.length) throw { errors };
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const reserved = ['/view', '/dashboard', '/code', '/folder', '/api', '/auth'];
|
||||
if (reserved.some((r) => validated.uploader.route.startsWith(r))) {
|
||||
const reserved = new RegExp(/^\/(view|code|folder|auth|r)(\/\S*)?$|^\/(api|dashboard)(\/\S*)*/);
|
||||
if (reserved.exec(validated.uploader.route))
|
||||
throw {
|
||||
errors: [`The uploader route cannot be ${validated.uploader.route}, this is a reserved route.`],
|
||||
show: true,
|
||||
};
|
||||
} else if (reserved.some((r) => validated.urls.route.startsWith(r))) {
|
||||
if (reserved.exec(validated.urls.route))
|
||||
throw {
|
||||
errors: [`The urls route cannot be ${validated.urls.route}, this is a reserved route.`],
|
||||
show: true,
|
||||
};
|
||||
}
|
||||
|
||||
return validated as unknown as Config;
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import config from './config';
|
||||
import { Datasource, Local, S3, Supabase } from './datasources';
|
||||
import { Datasource, Local, S3 } from './datasources';
|
||||
import Logger from './logger';
|
||||
|
||||
const logger = Logger.get('datasource');
|
||||
@@ -14,10 +14,6 @@ if (!global.datasource) {
|
||||
global.datasource = new Local(config.datasource.local.directory);
|
||||
logger.info(`using Local(${config.datasource.local.directory}) datasource`);
|
||||
break;
|
||||
case 'supabase':
|
||||
global.datasource = new Supabase(config.datasource.supabase);
|
||||
logger.info(`using Supabase(${config.datasource.supabase.bucket}) datasource`);
|
||||
break;
|
||||
default:
|
||||
throw new Error('Invalid datasource type');
|
||||
}
|
||||
|
||||
@@ -3,10 +3,11 @@ import { Readable } from 'stream';
|
||||
export abstract class Datasource {
|
||||
public name: string;
|
||||
|
||||
public abstract save(file: string, data: Buffer): Promise<void>;
|
||||
public abstract save(file: string, data: Buffer, options?: { type: string }): Promise<void>;
|
||||
public abstract delete(file: string): Promise<void>;
|
||||
public abstract clear(): Promise<void>;
|
||||
public abstract size(file: string): Promise<number>;
|
||||
public abstract size(file: string): Promise<number | null>;
|
||||
public abstract get(file: string): Readable | Promise<Readable>;
|
||||
public abstract fullSize(): Promise<number>;
|
||||
public abstract range(file: string, start: number, end: number): Promise<Readable>;
|
||||
}
|
||||
|
||||
@@ -11,11 +11,11 @@ export class Local extends Datasource {
|
||||
}
|
||||
|
||||
public async save(file: string, data: Buffer): Promise<void> {
|
||||
await writeFile(join(this.path, file), data);
|
||||
await writeFile(join(this.path, file), Uint8Array.from(data));
|
||||
}
|
||||
|
||||
public async delete(file: string): Promise<void> {
|
||||
await rm(join(this.path, file));
|
||||
await rm(join(this.path, file), { force: true });
|
||||
}
|
||||
|
||||
public async clear(): Promise<void> {
|
||||
@@ -37,9 +37,9 @@ export class Local extends Datasource {
|
||||
}
|
||||
}
|
||||
|
||||
public async size(file: string): Promise<number> {
|
||||
public async size(file: string): Promise<number | null> {
|
||||
const full = join(this.path, file);
|
||||
if (!existsSync(full)) return 0;
|
||||
if (!existsSync(full)) return null;
|
||||
const stats = await stat(full);
|
||||
|
||||
return stats.size;
|
||||
@@ -56,4 +56,11 @@ export class Local extends Datasource {
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
public async range(file: string, start: number, end: number): Promise<ReadStream> {
|
||||
const path = join(this.path, file);
|
||||
const readStream = createReadStream(path, { start, end });
|
||||
|
||||
return readStream;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Datasource } from '.';
|
||||
import { Readable } from 'stream';
|
||||
import { PassThrough, Readable } from 'stream';
|
||||
import { ConfigS3Datasource } from 'lib/config/Config';
|
||||
import { Client } from 'minio';
|
||||
import { BucketItemStat, Client } from 'minio';
|
||||
|
||||
export class S3 extends Datasource {
|
||||
public name = 'S3';
|
||||
@@ -20,12 +20,18 @@ export class S3 extends Datasource {
|
||||
});
|
||||
}
|
||||
|
||||
public async save(file: string, data: Buffer): Promise<void> {
|
||||
await this.s3.putObject(this.config.bucket, file, data);
|
||||
public async save(file: string, data: Buffer, options?: { type: string }): Promise<void> {
|
||||
await this.s3.putObject(
|
||||
this.config.bucket,
|
||||
file,
|
||||
new PassThrough().end(data),
|
||||
data.byteLength,
|
||||
options ? { 'Content-Type': options.type } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
public async delete(file: string): Promise<void> {
|
||||
await this.s3.removeObject(this.config.bucket, file);
|
||||
await this.s3.removeObject(this.config.bucket, file, { forceDelete: true });
|
||||
}
|
||||
|
||||
public async clear(): Promise<void> {
|
||||
@@ -49,10 +55,18 @@ export class S3 extends Datasource {
|
||||
});
|
||||
}
|
||||
|
||||
public async size(file: string): Promise<number> {
|
||||
const stat = await this.s3.statObject(this.config.bucket, file);
|
||||
|
||||
return stat.size;
|
||||
public size(file: string): Promise<number | null> {
|
||||
return new Promise((res) => {
|
||||
this.s3.statObject(
|
||||
this.config.bucket,
|
||||
file,
|
||||
// @ts-expect-error this callback is not in the types but the code for it is there
|
||||
(err: unknown, stat: BucketItemStat) => {
|
||||
if (err) res(null);
|
||||
else res(stat.size);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public async fullSize(): Promise<number> {
|
||||
@@ -67,4 +81,15 @@ export class S3 extends Datasource {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async range(file: string, start: number, end: number): Promise<Readable> {
|
||||
return new Promise((res) => {
|
||||
this.s3.getPartialObject(this.config.bucket, file, start, end, (err, stream) => {
|
||||
if (err) {
|
||||
console.log(err);
|
||||
res(null);
|
||||
} else res(stream);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
import { Datasource } from '.';
|
||||
import { ConfigSupabaseDatasource } from 'lib/config/Config';
|
||||
import { guess } from 'lib/mimes';
|
||||
import Logger from 'lib/logger';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
export class Supabase extends Datasource {
|
||||
public name = 'Supabase';
|
||||
public logger: Logger = Logger.get('datasource::supabase');
|
||||
|
||||
public constructor(public config: ConfigSupabaseDatasource) {
|
||||
super();
|
||||
}
|
||||
|
||||
public async save(file: string, data: Buffer): Promise<void> {
|
||||
const mimetype = await guess(file.split('.').pop());
|
||||
|
||||
const r = await fetch(`${this.config.url}/storage/v1/object/${this.config.bucket}/${file}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.config.key}`,
|
||||
'Content-Type': mimetype,
|
||||
},
|
||||
body: data,
|
||||
});
|
||||
|
||||
const j = await r.json();
|
||||
if (j.error) this.logger.error(`${j.error}: ${j.message}`);
|
||||
}
|
||||
|
||||
public async delete(file: string): Promise<void> {
|
||||
await fetch(`${this.config.url}/storage/v1/object/${this.config.bucket}/${file}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.config.key}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public async clear(): Promise<void> {
|
||||
try {
|
||||
const resp = await fetch(`${this.config.url}/storage/v1/object/list/${this.config.bucket}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.config.key}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prefix: '',
|
||||
}),
|
||||
});
|
||||
const objs = await resp.json();
|
||||
if (objs.error) throw new Error(`${objs.error}: ${objs.message}`);
|
||||
|
||||
const res = await fetch(`${this.config.url}/storage/v1/object/${this.config.bucket}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.config.key}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prefixes: objs.map((x: { name: string }) => x.name),
|
||||
}),
|
||||
});
|
||||
|
||||
const j = await res.json();
|
||||
if (j.error) throw new Error(`${j.error}: ${j.message}`);
|
||||
|
||||
return;
|
||||
} catch (e) {
|
||||
this.logger.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
public async get(file: string): Promise<Readable> {
|
||||
// get a readable stream from the request
|
||||
const r = await fetch(`${this.config.url}/storage/v1/object/${this.config.bucket}/${file}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.config.key}`,
|
||||
},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return Readable.fromWeb(r.body as any);
|
||||
}
|
||||
|
||||
public size(file: string): Promise<number> {
|
||||
return new Promise(async (res) => {
|
||||
fetch(`${this.config.url}/storage/v1/object/list/${this.config.bucket}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.config.key}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prefix: '',
|
||||
search: file,
|
||||
}),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((j) => {
|
||||
if (j.error) {
|
||||
this.logger.error(`${j.error}: ${j.message}`);
|
||||
res(0);
|
||||
}
|
||||
|
||||
if (j.length === 0) {
|
||||
res(0);
|
||||
} else {
|
||||
res(j[0].metadata.size);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async fullSize(): Promise<number> {
|
||||
return new Promise((res) => {
|
||||
fetch(`${this.config.url}/storage/v1/object/list/${this.config.bucket}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.config.key}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prefix: '',
|
||||
}),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((j) => {
|
||||
if (j.error) {
|
||||
this.logger.error(`${j.error}: ${j.message}`);
|
||||
res(0);
|
||||
}
|
||||
|
||||
res(j.reduce((a, b) => a + b.metadata.size, 0));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
export { Datasource } from './Datasource';
|
||||
export { Local } from './Local';
|
||||
export { S3 } from './S3';
|
||||
export { Supabase } from './Supabase';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { File, Url, User } from '@prisma/client';
|
||||
import type { File, Url, User } from '@prisma/client';
|
||||
import config from 'lib/config';
|
||||
import { ConfigDiscordContent } from 'config/Config';
|
||||
import Logger from 'lib/logger';
|
||||
|
||||
@@ -67,26 +67,7 @@ export const withOAuth =
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
logger.debug(`Failed to find existing oauth. Using fallback. ${e}`);
|
||||
if (e.code === 'P2022' || e.code === 'P2025') {
|
||||
const existing = await prisma.user.findFirst({
|
||||
where: {
|
||||
oauth: {
|
||||
some: {
|
||||
provider: provider.toUpperCase() as OauthProviders,
|
||||
username: oauth_resp.username,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
oauth: true,
|
||||
},
|
||||
});
|
||||
existingOauth = existing?.oauth?.find((o) => o.provider === provider.toUpperCase());
|
||||
if (existingOauth) existingOauth.fallback = true;
|
||||
} else {
|
||||
logger.error(`Failed to find existing oauth. ${e}`);
|
||||
}
|
||||
logger.error(`Failed to find existing oauth, this likely will result in a failure: ${e}`);
|
||||
}
|
||||
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
@@ -157,7 +138,7 @@ export const withOAuth =
|
||||
logger.info(`User ${user.username} (${user.id}) logged in via oauth(${provider})`);
|
||||
|
||||
return res.redirect('/dashboard');
|
||||
} else if ((existingOauth && existingOauth.fallback) || existingOauth) {
|
||||
} else if (existingOauth) {
|
||||
await prisma.oAuth.update({
|
||||
where: {
|
||||
id: existingOauth?.id,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import 'lib/config';
|
||||
|
||||
if (!global.prisma) {
|
||||
if (!process.env.ZIPLINE_DOCKER_BUILD) global.prisma = new PrismaClient();
|
||||
if (!process.env.ZIPLINE_DOCKER_BUILD) {
|
||||
process.env.DATABASE_URL = config.core.database_url;
|
||||
global.prisma = new PrismaClient();
|
||||
}
|
||||
}
|
||||
|
||||
export default global.prisma as PrismaClient;
|
||||
|
||||
39
src/lib/themes/catppuccin_frappe.ts
Normal file
39
src/lib/themes/catppuccin_frappe.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// https://github.com/SeaswimmerTheFsh
|
||||
// https://catppuccin.com/palette
|
||||
|
||||
import createTheme from '.';
|
||||
|
||||
export default createTheme({
|
||||
colorScheme: 'dark',
|
||||
primaryColor: 'blue',
|
||||
other: {
|
||||
AppShell_backgroundColor: '#232634',
|
||||
hover: '#414559',
|
||||
},
|
||||
colors: {
|
||||
dark: [
|
||||
'#c6d0f5',
|
||||
'#949cbb',
|
||||
'#838ba7',
|
||||
'#737994',
|
||||
'#626880',
|
||||
'#51576d',
|
||||
'#414559',
|
||||
'#303446',
|
||||
'#292c3c',
|
||||
'#232634',
|
||||
],
|
||||
blue: [
|
||||
'#FFFFFF',
|
||||
'#b8caf4',
|
||||
'#a2baf1',
|
||||
'#7599ea',
|
||||
'#5f89e7',
|
||||
'#8c99ee',
|
||||
'#8ca1ee',
|
||||
'#8cb2ee',
|
||||
'#8cbaee',
|
||||
'#8caaee',
|
||||
],
|
||||
},
|
||||
});
|
||||
39
src/lib/themes/catppuccin_latte.ts
Normal file
39
src/lib/themes/catppuccin_latte.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// https://github.com/SeaswimmerTheFsh
|
||||
// https://catppuccin.com/palette
|
||||
|
||||
import createTheme from '.';
|
||||
|
||||
export default createTheme({
|
||||
colorScheme: 'dark',
|
||||
primaryColor: 'blue',
|
||||
other: {
|
||||
AppShell_backgroundColor: '#dce0e8',
|
||||
hover: '#ccd0da',
|
||||
},
|
||||
colors: {
|
||||
dark: [
|
||||
'#4c4f69',
|
||||
'#8c8fa1',
|
||||
'#8c8fa1',
|
||||
'#9ca0b0',
|
||||
'#acb0be',
|
||||
'#bcc0cc',
|
||||
'#ccd0da',
|
||||
'#eff1f5',
|
||||
'#e6e9ef',
|
||||
'#dce0e8',
|
||||
],
|
||||
blue: [
|
||||
'#FFFFFF',
|
||||
'#3676f6',
|
||||
'#0a57ee',
|
||||
'#094ed6',
|
||||
'#1d42f5',
|
||||
'#1d54f5',
|
||||
'#1d65f5',
|
||||
'#1d77f5',
|
||||
'#1d89f5',
|
||||
'#1e66f5',
|
||||
],
|
||||
},
|
||||
});
|
||||
39
src/lib/themes/catppuccin_macchiato.ts
Normal file
39
src/lib/themes/catppuccin_macchiato.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// https://github.com/SeaswimmerTheFsh
|
||||
// https://catppuccin.com/palette
|
||||
|
||||
import createTheme from '.';
|
||||
|
||||
export default createTheme({
|
||||
colorScheme: 'dark',
|
||||
primaryColor: 'blue',
|
||||
other: {
|
||||
AppShell_backgroundColor: '#181926',
|
||||
hover: '#363a4f',
|
||||
},
|
||||
colors: {
|
||||
dark: [
|
||||
'#cad3f5',
|
||||
'#8087a2',
|
||||
'#8087a2',
|
||||
'#6e738d',
|
||||
'#5b6078',
|
||||
'#494d64',
|
||||
'#363a4f',
|
||||
'#24273a',
|
||||
'#1e2030',
|
||||
'#181926',
|
||||
],
|
||||
blue: [
|
||||
'#FFFFFF',
|
||||
'#a1bdf6',
|
||||
'#729cf1',
|
||||
'#5b8cef',
|
||||
'#899bf4',
|
||||
'#89a4f4',
|
||||
'#89acf4',
|
||||
'#89b5f4',
|
||||
'#89bef4',
|
||||
'#8aadf4',
|
||||
],
|
||||
},
|
||||
});
|
||||
39
src/lib/themes/catppuccin_mocha.ts
Normal file
39
src/lib/themes/catppuccin_mocha.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// https://github.com/SeaswimmerTheFsh
|
||||
// https://catppuccin.com/palette
|
||||
|
||||
import createTheme from '.';
|
||||
|
||||
export default createTheme({
|
||||
colorScheme: 'dark',
|
||||
primaryColor: 'blue',
|
||||
other: {
|
||||
AppShell_backgroundColor: '#11111b',
|
||||
hover: '#313244',
|
||||
},
|
||||
colors: {
|
||||
dark: [
|
||||
'#cdd6f4',
|
||||
'#9399b2',
|
||||
'#7f849c',
|
||||
'#6c7086',
|
||||
'#585b70',
|
||||
'#45475a',
|
||||
'#313244',
|
||||
'#1e1e2e',
|
||||
'#181825',
|
||||
'#11111b',
|
||||
],
|
||||
blue: [
|
||||
'#FFFFFF',
|
||||
'#b9d3fc',
|
||||
'#a1c3fb',
|
||||
'#70a4f8',
|
||||
'#5894f7',
|
||||
'#89a1fa',
|
||||
'#89aafa',
|
||||
'#89b4fa',
|
||||
'#89bdfa',
|
||||
'#89c6fa',
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { InvisibleFile, InvisibleUrl } from '@prisma/client';
|
||||
import type { InvisibleFile, InvisibleUrl } from '@prisma/client';
|
||||
import { hash, verify } from 'argon2';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { readdir, stat } from 'fs/promises';
|
||||
|
||||
@@ -24,16 +24,16 @@ export function humanToBytes(value: string): number {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function bytesToHuman(value: number): string {
|
||||
if (isNaN(value)) return '0.0 B';
|
||||
export function bytesToHuman(value: number | bigint): string {
|
||||
if (typeof value !== 'bigint' && isNaN(value)) return '0.0 B';
|
||||
if (value === Infinity) return '0.0 B';
|
||||
const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB'];
|
||||
const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB']; // if people upload stuff bigger than a petabyte then idk
|
||||
let num = 0;
|
||||
|
||||
while (value > 1024) {
|
||||
value /= 1024;
|
||||
value = Number(value) / 1024;
|
||||
++num;
|
||||
}
|
||||
|
||||
return `${value.toFixed(1)} ${units[num]}`;
|
||||
return `${Number(value).toFixed(1)} ${units[num] || ''}`;
|
||||
}
|
||||
|
||||
@@ -51,22 +51,22 @@ export function humanTime(string: StringValue | string): Date {
|
||||
}
|
||||
}
|
||||
|
||||
export function parseExpiry(header: string): Date | null {
|
||||
if (!header) return null;
|
||||
export function parseExpiry(header: string): Date {
|
||||
if (!header) throw new Error('no expiry provided');
|
||||
header = header.toLowerCase();
|
||||
|
||||
if (header.startsWith('date=')) {
|
||||
const date = new Date(header.substring(5));
|
||||
|
||||
if (!date.getTime()) return null;
|
||||
if (date.getTime() < Date.now()) return null;
|
||||
if (!date.getTime()) throw new Error('invalid date');
|
||||
if (date.getTime() < Date.now()) throw new Error('expiry must be in the future');
|
||||
return date;
|
||||
}
|
||||
|
||||
const human = humanTime(header);
|
||||
|
||||
if (!human) return null;
|
||||
if (human.getTime() < Date.now()) return null;
|
||||
if (!human) throw new Error('failed to parse human time');
|
||||
if (human.getTime() < Date.now()) throw new Error('expiry must be in the future');
|
||||
|
||||
return human;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { File } from '@prisma/client';
|
||||
import type { File } from '@prisma/client';
|
||||
import { ExifTool, Tags } from 'exiftool-vendored';
|
||||
import { createWriteStream } from 'fs';
|
||||
import { readFile, rm } from 'fs/promises';
|
||||
@@ -87,7 +87,7 @@ export async function removeGPSData(image: File): Promise<void> {
|
||||
|
||||
logger.debug(`reading file to upload to datasource: ${file} -> ${image.name}`);
|
||||
const buffer = await readFile(file);
|
||||
await datasource.save(image.name, buffer);
|
||||
await datasource.save(image.name, buffer, { type: image.mimetype });
|
||||
|
||||
logger.debug(`removing temp file: ${file}`);
|
||||
await rm(file);
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import type { File, User, Url } from '@prisma/client';
|
||||
import type { File, Url } from '@prisma/client';
|
||||
import { bytesToHuman } from './bytes';
|
||||
import Logger from 'lib/logger';
|
||||
import type { UserExtended } from 'middleware/withZipline';
|
||||
|
||||
export type ParseValue = {
|
||||
file?: File;
|
||||
file?: Omit<Partial<File>, 'password'>;
|
||||
url?: Url;
|
||||
user?: User;
|
||||
user?: Partial<UserExtended>;
|
||||
|
||||
link?: string;
|
||||
raw_link?: string;
|
||||
};
|
||||
|
||||
const logger = Logger.get('parser');
|
||||
|
||||
export function parseString(str: string, value: ParseValue) {
|
||||
if (!str) return null;
|
||||
str = str
|
||||
@@ -17,7 +21,7 @@ export function parseString(str: string, value: ParseValue) {
|
||||
.replace(/\{raw_link\}/gi, value.raw_link)
|
||||
.replace(/\\n/g, '\n');
|
||||
|
||||
const re = /\{(?<type>file|url|user)\.(?<prop>\w+)(::(?<mod>\w+))?\}/gi;
|
||||
const re = /\{(?<type>file|url|user)\.(?<prop>\w+)(::(?<mod>\w+))?(::(?<mod_tzlocale>\S+))?\}/gi;
|
||||
let matches: RegExpMatchArray;
|
||||
|
||||
while ((matches = re.exec(str))) {
|
||||
@@ -54,7 +58,12 @@ export function parseString(str: string, value: ParseValue) {
|
||||
}
|
||||
|
||||
if (matches.groups.mod) {
|
||||
str = replaceCharsFromString(str, modifier(matches.groups.mod, v), matches.index, re.lastIndex);
|
||||
str = replaceCharsFromString(
|
||||
str,
|
||||
modifier(matches.groups.mod, v, matches.groups.mod_tzlocale ?? undefined),
|
||||
matches.index,
|
||||
re.lastIndex,
|
||||
);
|
||||
re.lastIndex = matches.index;
|
||||
continue;
|
||||
}
|
||||
@@ -66,17 +75,42 @@ export function parseString(str: string, value: ParseValue) {
|
||||
return str;
|
||||
}
|
||||
|
||||
function modifier(mod: string, value: unknown): string {
|
||||
function modifier(mod: string, value: unknown, tzlocale?: string): string {
|
||||
mod = mod.toLowerCase();
|
||||
|
||||
if (value instanceof Date) {
|
||||
const args = [undefined, undefined];
|
||||
|
||||
if (tzlocale) {
|
||||
const [locale, tz] = tzlocale.split(/\s?,\s?/).map((v) => v.trim());
|
||||
|
||||
if (locale) {
|
||||
try {
|
||||
Intl.DateTimeFormat.supportedLocalesOf(locale);
|
||||
args[0] = locale;
|
||||
} catch (e) {
|
||||
args[0] = undefined;
|
||||
logger.error(`invalid locale provided ${locale}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (tz) {
|
||||
const intlTz = Intl.supportedValuesOf('timeZone').find((v) => v.toLowerCase() === tz.toLowerCase());
|
||||
if (intlTz) args[1] = { timeZone: intlTz };
|
||||
else {
|
||||
args[1] = undefined;
|
||||
logger.error(`invalid timezone provided ${tz}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch (mod) {
|
||||
case 'locale':
|
||||
return value.toLocaleString();
|
||||
return value.toLocaleString(...args);
|
||||
case 'time':
|
||||
return value.toLocaleTimeString();
|
||||
return value.toLocaleTimeString(...args);
|
||||
case 'date':
|
||||
return value.toLocaleDateString();
|
||||
return value.toLocaleDateString(...args);
|
||||
case 'unix':
|
||||
return Math.floor(value.getTime() / 1000).toString();
|
||||
case 'iso':
|
||||
@@ -95,6 +129,10 @@ function modifier(mod: string, value: unknown): string {
|
||||
return value.getMinutes().toString();
|
||||
case 'second':
|
||||
return value.getSeconds().toString();
|
||||
case 'ampm':
|
||||
return value.getHours() < 12 ? 'am' : 'pm';
|
||||
case 'AMPM':
|
||||
return value.getHours() < 12 ? 'AM' : 'PM';
|
||||
default:
|
||||
return '{unknown_date_modifier}';
|
||||
}
|
||||
@@ -117,7 +155,7 @@ function modifier(mod: string, value: unknown): string {
|
||||
default:
|
||||
return '{unknown_str_modifier}';
|
||||
}
|
||||
} else if (typeof value === 'number') {
|
||||
} else if (typeof value === 'number' || typeof value === 'bigint') {
|
||||
switch (mod) {
|
||||
case 'comma':
|
||||
return value.toLocaleString();
|
||||
|
||||
20
src/lib/utils/range.ts
Normal file
20
src/lib/utils/range.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export function parseRange(header: string, length: number): [number, number] {
|
||||
const range = header.trim().substring(6);
|
||||
|
||||
let start, end;
|
||||
|
||||
if (range.startsWith('-')) {
|
||||
end = length - 1;
|
||||
start = length - 1 - Number(range.substring(1));
|
||||
} else {
|
||||
const [s, e] = range.split('-').map(Number);
|
||||
start = s;
|
||||
end = e || length - 1;
|
||||
}
|
||||
|
||||
if (end > length - 1) {
|
||||
end = length - 1;
|
||||
}
|
||||
|
||||
return [start, end];
|
||||
}
|
||||
@@ -8,21 +8,23 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
try {
|
||||
const { orphaned } = req.body;
|
||||
if (orphaned) {
|
||||
const files = await prisma.file.findMany({
|
||||
where: {
|
||||
userId: null,
|
||||
},
|
||||
});
|
||||
const { count } = await prisma.file.deleteMany({
|
||||
where: {
|
||||
userId: null,
|
||||
},
|
||||
});
|
||||
for (const file of files) await datasource.delete(file.name);
|
||||
logger.info(`User ${user.username} (${user.id}) cleared the database of ${count} orphaned files`);
|
||||
return res.json({ message: 'cleared storage (orphaned only)' });
|
||||
}
|
||||
const { count } = await prisma.file.deleteMany({});
|
||||
logger.info(`User ${user.username} (${user.id}) cleared the database of ${count} files`);
|
||||
|
||||
if (req.body.datasource) {
|
||||
await datasource.clear();
|
||||
logger.info(`User ${user.username} (${user.id}) cleared storage`);
|
||||
}
|
||||
logger.info(`User ${user.username} (${user.id}) cleared the database of ${count} files`);
|
||||
} catch (e) {
|
||||
logger.error(`User ${user.username} (${user.id}) failed to clear the database or storage`);
|
||||
logger.error(e);
|
||||
|
||||
307
src/pages/api/admin/export.ts
Normal file
307
src/pages/api/admin/export.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import { readFile } from 'fs/promises';
|
||||
import Logger from 'lib/logger';
|
||||
import prisma from 'lib/prisma';
|
||||
import { randomChars } from 'lib/util';
|
||||
import { bytesToHuman } from 'lib/utils/bytes';
|
||||
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
|
||||
import os from 'os';
|
||||
|
||||
const logger = Logger.get('admin').child('export');
|
||||
|
||||
type Zipline3Export = {
|
||||
versions: {
|
||||
zipline: string;
|
||||
node: string;
|
||||
export: '3';
|
||||
};
|
||||
|
||||
request: {
|
||||
user: string;
|
||||
date: string;
|
||||
os: {
|
||||
platform: 'aix' | 'darwin' | 'freebsd' | 'linux' | 'openbsd' | 'sunos' | 'win32';
|
||||
arch:
|
||||
| 'arm'
|
||||
| 'arm64'
|
||||
| 'ia32'
|
||||
| 'loong64'
|
||||
| 'mips'
|
||||
| 'mipsel'
|
||||
| 'ppc'
|
||||
| 'ppc64'
|
||||
| 'riscv64'
|
||||
| 's390'
|
||||
| 's390x'
|
||||
| 'x64';
|
||||
cpus: number;
|
||||
hostname: string;
|
||||
release: string;
|
||||
};
|
||||
env: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
// Creates a unique identifier for each model
|
||||
// used to map the user's stuff to other data owned by the user
|
||||
user_map: Record<number, string>;
|
||||
thumbnail_map: Record<number, string>;
|
||||
folder_map: Record<number, string>;
|
||||
file_map: Record<number, string>;
|
||||
url_map: Record<number, string>;
|
||||
invite_map: Record<number, string>;
|
||||
|
||||
users: {
|
||||
[id: string]: {
|
||||
username: string;
|
||||
password: string;
|
||||
avatar: string;
|
||||
administrator: boolean;
|
||||
super_administrator: boolean;
|
||||
embed: {
|
||||
title?: string;
|
||||
site_name?: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
};
|
||||
totp_secret: string;
|
||||
oauth: {
|
||||
provider: 'DISCORD' | 'GITHUB' | 'GOOGLE';
|
||||
username: string;
|
||||
oauth_id: string;
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
|
||||
files: {
|
||||
[id: string]: {
|
||||
name: string;
|
||||
original_name: string;
|
||||
type: `${string}/${string}`;
|
||||
size: number | bigint;
|
||||
user: string | null;
|
||||
thumbnail?: string;
|
||||
max_views: number;
|
||||
views: number;
|
||||
expires_at?: string;
|
||||
created_at: string;
|
||||
favorite: boolean;
|
||||
password?: string;
|
||||
};
|
||||
};
|
||||
|
||||
thumbnails: {
|
||||
[id: string]: {
|
||||
name: string;
|
||||
created_at: string;
|
||||
};
|
||||
};
|
||||
|
||||
folders: {
|
||||
[id: string]: {
|
||||
name: string;
|
||||
public: boolean;
|
||||
created_at: string;
|
||||
user: string;
|
||||
files: string[];
|
||||
};
|
||||
};
|
||||
|
||||
urls: {
|
||||
[id: number]: {
|
||||
destination: string;
|
||||
vanity?: string;
|
||||
code: string;
|
||||
created_at: string;
|
||||
max_views: number;
|
||||
views: number;
|
||||
user: string;
|
||||
};
|
||||
};
|
||||
|
||||
invites: {
|
||||
[id: string]: {
|
||||
code: string;
|
||||
expites_at?: string;
|
||||
created_at: string;
|
||||
used: boolean;
|
||||
|
||||
created_by_user: string;
|
||||
};
|
||||
};
|
||||
|
||||
stats: {
|
||||
created_at: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
data: any;
|
||||
}[];
|
||||
};
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
if (!user.superAdmin) return res.forbidden('You must be a super administrator to export data');
|
||||
|
||||
const pkg = JSON.parse(await readFile('package.json', 'utf8'));
|
||||
|
||||
const exportData: Partial<Zipline3Export> = {
|
||||
versions: {
|
||||
zipline: pkg.version,
|
||||
node: process.version,
|
||||
export: '3',
|
||||
},
|
||||
request: {
|
||||
user: '',
|
||||
date: new Date().toISOString(),
|
||||
os: {
|
||||
platform: os.platform() as Zipline3Export['request']['os']['platform'],
|
||||
arch: os.arch() as Zipline3Export['request']['os']['arch'],
|
||||
cpus: os.cpus().length,
|
||||
hostname: os.hostname(),
|
||||
release: os.release(),
|
||||
},
|
||||
env: process.env,
|
||||
},
|
||||
user_map: {},
|
||||
thumbnail_map: {},
|
||||
folder_map: {},
|
||||
file_map: {},
|
||||
url_map: {},
|
||||
invite_map: {},
|
||||
|
||||
users: {},
|
||||
files: {},
|
||||
thumbnails: {},
|
||||
folders: {},
|
||||
urls: {},
|
||||
invites: {},
|
||||
stats: [],
|
||||
};
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
include: {
|
||||
oauth: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const user of users) {
|
||||
const uniqueId = randomChars(32);
|
||||
exportData.user_map[user.id] = uniqueId;
|
||||
|
||||
exportData.users[uniqueId] = {
|
||||
username: user.username,
|
||||
password: user.password,
|
||||
avatar: user.avatar,
|
||||
administrator: user.administrator,
|
||||
super_administrator: user.superAdmin,
|
||||
embed: user.embed as Zipline3Export['users'][string]['embed'],
|
||||
totp_secret: user.totpSecret,
|
||||
oauth: user.oauth.map((oauth) => ({
|
||||
provider: oauth.provider as Zipline3Export['users'][string]['oauth'][0]['provider'],
|
||||
username: oauth.username,
|
||||
oauth_id: oauth.oauthId,
|
||||
access_token: oauth.token,
|
||||
refresh_token: oauth.refresh,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const folders = await prisma.folder.findMany({ include: { files: true } });
|
||||
for (const folder of folders) {
|
||||
const uniqueId = randomChars(32);
|
||||
exportData.folder_map[folder.id] = uniqueId;
|
||||
|
||||
exportData.folders[uniqueId] = {
|
||||
name: folder.name,
|
||||
public: folder.public,
|
||||
created_at: folder.createdAt.toISOString(),
|
||||
user: exportData.user_map[folder.userId],
|
||||
files: [], // mapped later
|
||||
};
|
||||
}
|
||||
|
||||
const thumbnails = await prisma.thumbnail.findMany();
|
||||
for (const thumbnail of thumbnails) {
|
||||
const uniqueId = randomChars(32);
|
||||
exportData.thumbnail_map[thumbnail.id] = uniqueId;
|
||||
|
||||
exportData.thumbnails[uniqueId] = {
|
||||
name: thumbnail.name,
|
||||
created_at: thumbnail.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const files = await prisma.file.findMany({ include: { thumbnail: true } });
|
||||
|
||||
for (const file of files) {
|
||||
const uniqueId = randomChars(32);
|
||||
exportData.file_map[file.id] = uniqueId;
|
||||
|
||||
exportData.files[uniqueId] = {
|
||||
name: file.name,
|
||||
original_name: file.originalName,
|
||||
type: file.mimetype as Zipline3Export['files'][0]['type'],
|
||||
size: file.size,
|
||||
user: file.userId ? exportData.user_map[file.userId] : null,
|
||||
thumbnail: file.thumbnail ? exportData.thumbnail_map[file.thumbnail.id] : undefined,
|
||||
max_views: file.maxViews,
|
||||
views: file.views,
|
||||
expires_at: file.expiresAt?.toISOString(),
|
||||
created_at: file.createdAt.toISOString(),
|
||||
favorite: file.favorite,
|
||||
password: file.password,
|
||||
};
|
||||
}
|
||||
|
||||
const urls = await prisma.url.findMany();
|
||||
|
||||
for (const url of urls) {
|
||||
const uniqueId = randomChars(32);
|
||||
exportData.url_map[url.id] = uniqueId;
|
||||
|
||||
exportData.urls[uniqueId] = {
|
||||
destination: url.destination,
|
||||
vanity: url.vanity,
|
||||
created_at: url.createdAt.toISOString(),
|
||||
max_views: url.maxViews,
|
||||
views: url.views,
|
||||
user: exportData.user_map[url.userId],
|
||||
code: url.id,
|
||||
};
|
||||
}
|
||||
|
||||
const invites = await prisma.invite.findMany();
|
||||
|
||||
for (const invite of invites) {
|
||||
const uniqueId = randomChars(32);
|
||||
exportData.invite_map[invite.id] = uniqueId;
|
||||
|
||||
exportData.invites[uniqueId] = {
|
||||
code: invite.code,
|
||||
expites_at: invite.expiresAt?.toISOString() ?? undefined,
|
||||
created_at: invite.createdAt.toISOString(),
|
||||
used: invite.used,
|
||||
created_by_user: exportData.user_map[invite.createdById],
|
||||
};
|
||||
}
|
||||
|
||||
exportData.request.user = exportData.user_map[user.id];
|
||||
|
||||
for (const folder of folders) {
|
||||
exportData.folders[exportData.folder_map[folder.id]].files = folder.files.map(
|
||||
(file) => exportData.file_map[file.id],
|
||||
);
|
||||
}
|
||||
|
||||
const stringed = JSON.stringify(exportData);
|
||||
logger.info(`${user.id} created export of size ${bytesToHuman(stringed.length)}`);
|
||||
|
||||
return res
|
||||
.setHeader('Content-Disposition', `attachment; filename="zipline_export_${Date.now()}.json"`)
|
||||
.setHeader('Content-Type', 'application/json')
|
||||
.send(stringed);
|
||||
}
|
||||
|
||||
export default withZipline(handler, {
|
||||
methods: ['GET'],
|
||||
user: true,
|
||||
administrator: true,
|
||||
});
|
||||
@@ -1,132 +0,0 @@
|
||||
import { readFile } from 'fs/promises';
|
||||
import config from 'lib/config';
|
||||
import Logger from 'lib/logger';
|
||||
import { NextApiReq, NextApiRes, withZipline } from 'lib/middleware/withZipline';
|
||||
import { guess } from 'lib/mimes';
|
||||
import prisma from 'lib/prisma';
|
||||
import { createToken, hashPassword } from 'lib/util';
|
||||
import { jsonUserReplacer } from 'lib/utils/client';
|
||||
import { extname } from 'path';
|
||||
|
||||
const logger = Logger.get('user');
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
// handle invites
|
||||
if (req.body.code) {
|
||||
if (!config.features.invites) return res.badRequest('invites are disabled');
|
||||
|
||||
const { code, username, password } = req.body as {
|
||||
code?: string;
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
const invite = await prisma.invite.findUnique({
|
||||
where: { code: code ?? '' },
|
||||
});
|
||||
if (!invite && code) return res.badRequest('invalid invite code');
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { username },
|
||||
});
|
||||
|
||||
if (user) return res.badRequest('username already exists');
|
||||
const hashed = await hashPassword(password);
|
||||
|
||||
let avatar;
|
||||
if (config.features.default_avatar) {
|
||||
logger.debug(`using default avatar ${config.features.default_avatar}`);
|
||||
|
||||
const buf = await readFile(config.features.default_avatar);
|
||||
const mimetype = await guess(extname(config.features.default_avatar));
|
||||
logger.debug(`guessed mimetype ${mimetype} for ${config.features.default_avatar}`);
|
||||
|
||||
avatar = `data:${mimetype};base64,${buf.toString('base64')}`;
|
||||
}
|
||||
|
||||
const newUser = await prisma.user.create({
|
||||
data: {
|
||||
password: hashed,
|
||||
username,
|
||||
token: createToken(),
|
||||
administrator: false,
|
||||
avatar,
|
||||
},
|
||||
});
|
||||
|
||||
if (code) {
|
||||
await prisma.invite.update({
|
||||
where: {
|
||||
code,
|
||||
},
|
||||
data: {
|
||||
used: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug(`created user via invite ${code} ${JSON.stringify(newUser, jsonUserReplacer)}`);
|
||||
|
||||
logger.info(
|
||||
`Created user ${newUser.username} (${newUser.id}) ${
|
||||
code ? `from invite code ${code}` : 'via registration'
|
||||
}`,
|
||||
);
|
||||
|
||||
return res.json({ success: true });
|
||||
}
|
||||
|
||||
const user = await req.user();
|
||||
if (!user) return res.unauthorized('not logged in');
|
||||
if (!user.administrator) return res.forbidden('you arent an administrator');
|
||||
|
||||
const { username, password, administrator } = req.body as {
|
||||
username: string;
|
||||
password: string;
|
||||
administrator: boolean;
|
||||
};
|
||||
|
||||
if (!username) return res.badRequest('no username');
|
||||
if (!password) return res.badRequest('no password');
|
||||
|
||||
const existing = await prisma.user.findFirst({
|
||||
where: {
|
||||
username,
|
||||
},
|
||||
});
|
||||
if (existing) return res.badRequest('user exists');
|
||||
|
||||
const hashed = await hashPassword(password);
|
||||
|
||||
let avatar;
|
||||
if (config.features.default_avatar) {
|
||||
logger.debug(`using default avatar ${config.features.default_avatar}`);
|
||||
|
||||
const buf = await readFile(config.features.default_avatar);
|
||||
const mimetype = await guess(extname(config.features.default_avatar));
|
||||
logger.debug(`guessed mimetype ${mimetype} for ${config.features.default_avatar}`);
|
||||
|
||||
avatar = `data:${mimetype};base64,${buf.toString('base64')}`;
|
||||
}
|
||||
|
||||
const newUser = await prisma.user.create({
|
||||
data: {
|
||||
password: hashed,
|
||||
username,
|
||||
token: createToken(),
|
||||
administrator,
|
||||
avatar,
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug(`created user ${JSON.stringify(newUser, jsonUserReplacer)}`);
|
||||
|
||||
delete newUser.password;
|
||||
|
||||
logger.info(`Created user ${newUser.username} (${newUser.id})`);
|
||||
|
||||
return res.json(newUser);
|
||||
}
|
||||
|
||||
export default withZipline(handler, {
|
||||
methods: ['POST'],
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import datasource from 'lib/datasource';
|
||||
import { guess } from 'lib/mimes';
|
||||
import prisma from 'lib/prisma';
|
||||
import { checkPassword } from 'lib/util';
|
||||
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
|
||||
import { extname } from 'path';
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
const { id, password } = req.query;
|
||||
if (isNaN(Number(id))) return res.badRequest('invalid id');
|
||||
|
||||
const file = await prisma.file.findFirst({
|
||||
where: {
|
||||
id: Number(id),
|
||||
},
|
||||
});
|
||||
|
||||
if (!file) return res.notFound('image not found');
|
||||
if (!password) return res.badRequest('no password provided');
|
||||
|
||||
const decoded = decodeURIComponent(password as string);
|
||||
|
||||
const valid = await checkPassword(decoded, file.password);
|
||||
if (!valid) return res.badRequest('wrong password');
|
||||
|
||||
const data = await datasource.get(file.name);
|
||||
if (!data) return res.notFound('image not found');
|
||||
|
||||
const size = await datasource.size(file.name);
|
||||
|
||||
const mimetype = await guess(extname(file.name));
|
||||
res.setHeader('Content-Type', mimetype);
|
||||
res.setHeader('Content-Length', size);
|
||||
|
||||
data.pipe(res);
|
||||
data.on('error', () => res.notFound('image not found'));
|
||||
data.on('end', () => res.end());
|
||||
}
|
||||
|
||||
export default withZipline(handler);
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
|
||||
import config from 'lib/config';
|
||||
import Logger from 'lib/logger';
|
||||
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'lib/middleware/withZipline';
|
||||
@@ -16,8 +17,12 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
count: number;
|
||||
};
|
||||
|
||||
const expiry = parseExpiry(expiresAt);
|
||||
if (!expiry) return res.badRequest('invalid date');
|
||||
let expiry: Date;
|
||||
try {
|
||||
expiry = parseExpiry(expiresAt);
|
||||
} catch (error) {
|
||||
return res.badRequest(error.message);
|
||||
}
|
||||
const counts = count ? count : 1;
|
||||
|
||||
if (counts > 1) {
|
||||
@@ -60,19 +65,22 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
const { code } = req.query as { code: string };
|
||||
if (!code) return res.badRequest('no code');
|
||||
|
||||
try {
|
||||
const invite = await prisma.invite.delete({
|
||||
where: {
|
||||
code,
|
||||
},
|
||||
});
|
||||
|
||||
if (!invite) return res.notFound('invite not found');
|
||||
|
||||
logger.debug(`deleted invite ${JSON.stringify(invite)}`);
|
||||
|
||||
logger.info(`${user.username} (${user.id}) deleted invite ${invite.code}`);
|
||||
|
||||
return res.json(invite);
|
||||
} catch (error) {
|
||||
if (error instanceof PrismaClientKnownRequestError) return res.notFound('invite not found');
|
||||
else throw error;
|
||||
}
|
||||
} else {
|
||||
const invites = await prisma.invite.findMany({
|
||||
orderBy: {
|
||||
|
||||
@@ -14,8 +14,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
code?: string;
|
||||
};
|
||||
|
||||
const users = await prisma.user.findMany();
|
||||
if (users.length === 0) {
|
||||
if ((await prisma.user.count()) === 0) {
|
||||
logger.debug('no users found... creating default user...');
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
@@ -42,7 +41,9 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
else if (await checkPassword(password, user.password)) valid = true;
|
||||
else valid = false;
|
||||
|
||||
logger.debug(`body(${JSON.stringify(req.body)}): checkPassword(${password}, argon2-str) => ${valid}`);
|
||||
logger.debug(
|
||||
`body(${JSON.stringify(Object.keys(req.body))}): checkPassword(password, argon2-str) => ${valid}`,
|
||||
);
|
||||
|
||||
if (!valid) return res.unauthorized('Wrong password');
|
||||
|
||||
@@ -51,7 +52,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
|
||||
const success = verify_totp_code(user.totpSecret, code);
|
||||
logger.debug(
|
||||
`body(${JSON.stringify(req.body)}): verify_totp_code(${user.totpSecret}, ${code}) => ${success}`,
|
||||
`body(${JSON.stringify(Object.keys(req.body))}): verify_totp_code(totpSecret, ${code}) => ${success}`,
|
||||
);
|
||||
if (!success) return res.badRequest('Invalid code', { totp: true });
|
||||
}
|
||||
|
||||
@@ -11,23 +11,49 @@ import { extname } from 'path';
|
||||
const logger = Logger.get('user');
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
if (!config.features.user_registration) return res.badRequest('user registration is disabled');
|
||||
const user = await req.user();
|
||||
let badRequest,
|
||||
usedInvite = false;
|
||||
|
||||
const { username, password, administrator } = req.body as {
|
||||
if (!config.features.user_registration && !config.features.invites && !user?.administrator)
|
||||
return res.badRequest('This endpoint is unavailable due to current configurations');
|
||||
else if (!!user && !user?.administrator) return res.badRequest('Already logged in');
|
||||
|
||||
const { username, password, administrator, code } = req.body as {
|
||||
username: string;
|
||||
password: string;
|
||||
administrator: boolean;
|
||||
code?: string;
|
||||
};
|
||||
|
||||
if (!username) return res.badRequest('no username');
|
||||
if (!password) return res.badRequest('no password');
|
||||
if (!username) badRequest = true;
|
||||
if (!password) badRequest = true;
|
||||
|
||||
const existing = await prisma.user.findFirst({
|
||||
where: {
|
||||
username,
|
||||
},
|
||||
select: {
|
||||
username: true,
|
||||
},
|
||||
});
|
||||
if (existing) return res.badRequest('user exists');
|
||||
|
||||
if (existing) badRequest = true;
|
||||
|
||||
if (badRequest) return res.badRequest('Bad Username/Password');
|
||||
|
||||
if (code) {
|
||||
if (config.features.invites) {
|
||||
const invite = await prisma.invite.findUnique({
|
||||
where: {
|
||||
code,
|
||||
},
|
||||
});
|
||||
|
||||
if (!invite || invite?.used) return res.badRequest('Bad invite');
|
||||
usedInvite = true;
|
||||
} else return res.badRequest('Bad Username/Password');
|
||||
} else if (config.features.invites && !user?.administrator) return res.badRequest('Bad invite');
|
||||
|
||||
const hashed = await hashPassword(password);
|
||||
|
||||
@@ -47,12 +73,20 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
password: hashed,
|
||||
username,
|
||||
token: createToken(),
|
||||
administrator,
|
||||
administrator: user?.superAdmin ? administrator : false,
|
||||
avatar,
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug(`registered user ${JSON.stringify(newUser, jsonUserReplacer)}`);
|
||||
if (usedInvite)
|
||||
await prisma.invite.update({
|
||||
where: { code },
|
||||
data: { used: true },
|
||||
});
|
||||
|
||||
logger.debug(
|
||||
`registered user${usedInvite ? ' via invite ' + code : ''} ${JSON.stringify(newUser, jsonUserReplacer)}`,
|
||||
);
|
||||
|
||||
delete newUser.password;
|
||||
|
||||
|
||||
75
src/pages/api/oembed/[id].tsx
Normal file
75
src/pages/api/oembed/[id].tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
|
||||
import 'lib/prisma';
|
||||
import 'lib/config';
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
const { id } = req.query as { id: string };
|
||||
if (req.method === 'GET') {
|
||||
if (!id || isNaN(parseInt(id))) return res.badRequest('no id');
|
||||
|
||||
const file = await prisma.file.findUnique({
|
||||
where: {
|
||||
id: parseInt(id),
|
||||
},
|
||||
include: {
|
||||
thumbnail: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!file || !!file.password) return res.notFound('no such file exists');
|
||||
|
||||
const mediaType: 'image' | 'video' | 'audio' | 'other' =
|
||||
(new RegExp(/^(?<type>image|video|audio)/).exec(file.mimetype)?.groups?.type as
|
||||
| 'image'
|
||||
| 'video'
|
||||
| 'audio') || 'other';
|
||||
|
||||
let host = req.headers.host;
|
||||
const proto = req.headers['x-forwarded-proto'];
|
||||
try {
|
||||
if (
|
||||
JSON.parse(req.headers['cf-visitor'] as string).scheme === 'https' ||
|
||||
proto === 'https' ||
|
||||
config.core.return_https
|
||||
)
|
||||
host = `https://${host}`;
|
||||
else host = `http://${host}`;
|
||||
} catch (e) {
|
||||
if (proto === 'https' || config.core.return_https) host = `https://${host}`;
|
||||
else host = `http://${host}`;
|
||||
}
|
||||
|
||||
if (mediaType === 'image')
|
||||
return res.json({
|
||||
type: 'photo',
|
||||
version: '1.0',
|
||||
url: `${host}/r/${file.name}`,
|
||||
});
|
||||
if (mediaType === 'video')
|
||||
return res.json({
|
||||
type: 'video',
|
||||
version: '1.0',
|
||||
url: `${host}/r/${file.name}`,
|
||||
thumbnail_url: file.thumbnail ? `${host}/r/${file.thumbnail?.name}` : undefined,
|
||||
html: `<video><source src="${host}/r/${file.name}" type="${file.mimetype}"/></video>`,
|
||||
});
|
||||
return res.json({
|
||||
type: 'link',
|
||||
version: '1.0',
|
||||
url: `${host}${config.uploader.route}/${file.name}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default withZipline(handler, {
|
||||
methods: ['GET'],
|
||||
user: false,
|
||||
});
|
||||
@@ -54,7 +54,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
|
||||
logger.debug(`shortened ${JSON.stringify(url)}`);
|
||||
|
||||
logger.info(`User ${user.username} (${user.id}) shortenned a url ${url.destination} (${url.id})`);
|
||||
logger.info(`User ${user.username} (${user.id}) shortened a url ${url.destination} (${url.id})`);
|
||||
|
||||
let domain;
|
||||
if (req.headers['override-domain']) {
|
||||
|
||||
@@ -30,6 +30,45 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
|
||||
if (!user) return res.forbidden('authorization incorrect');
|
||||
|
||||
if (user.ratelimit && !req.headers['content-range']) {
|
||||
const remaining = user.ratelimit.getTime() - Date.now();
|
||||
logger.debug(`${user.id} encountered ratelimit, ${remaining}ms remaining`);
|
||||
if (remaining <= 0) {
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
ratelimit: null,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
return res.ratelimited(remaining);
|
||||
}
|
||||
} else if (!user.ratelimit && !req.headers['content-range']) {
|
||||
if (user.administrator && zconfig.ratelimit.admin > 0) {
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
ratelimit: new Date(Date.now() + zconfig.ratelimit.admin * 1000),
|
||||
},
|
||||
});
|
||||
} else if (!user.administrator && zconfig.ratelimit.user > 0) {
|
||||
if (user.administrator && zconfig.ratelimit.user > 0) {
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
ratelimit: new Date(Date.now() + zconfig.ratelimit.user * 1000),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
uploader.array('file')(req as never, res as never, (result: unknown) => {
|
||||
if (result instanceof Error) reject(result.message);
|
||||
@@ -42,6 +81,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
expiresAt?: Date;
|
||||
removed_gps?: boolean;
|
||||
assumed_mimetype?: string | boolean;
|
||||
folder?: number;
|
||||
} = {
|
||||
files: [],
|
||||
};
|
||||
@@ -49,16 +89,20 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
let expiry: Date;
|
||||
|
||||
if (expiresAt) {
|
||||
try {
|
||||
expiry = parseExpiry(expiresAt);
|
||||
if (!expiry) return res.badRequest('invalid date');
|
||||
else {
|
||||
response.expiresAt = expiry;
|
||||
} catch (error) {
|
||||
return res.badRequest(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (zconfig.uploader.default_expiration) {
|
||||
try {
|
||||
expiry = parseExpiry(zconfig.uploader.default_expiration);
|
||||
if (!expiry) return res.badRequest('invalid date (UPLOADER_DEFAULT_EXPIRATION)');
|
||||
} catch (error) {
|
||||
return res.badRequest(`${error.message} (UPLOADER_DEFAULT_EXPIRATION)`);
|
||||
}
|
||||
}
|
||||
|
||||
const rawFormat = ((req.headers['format'] as string) || zconfig.uploader.default_format).toLowerCase();
|
||||
@@ -78,6 +122,20 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
if (isNaN(fileMaxViews)) return res.badRequest('invalid max views (invalid number)');
|
||||
if (fileMaxViews < 0) return res.badRequest('invalid max views (max views < 0)');
|
||||
|
||||
const folderToAdd = req.headers['x-zipline-folder'] ? Number(req.headers['x-zipline-folder']) : null;
|
||||
if (folderToAdd) {
|
||||
if (isNaN(folderToAdd)) return res.badRequest('invalid folder id (invalid number)');
|
||||
const folder = await prisma.folder.findFirst({
|
||||
where: {
|
||||
id: folderToAdd,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
if (!folder) return res.badRequest('invalid folder id (no folder found)');
|
||||
|
||||
response.folder = folder.id;
|
||||
}
|
||||
|
||||
// handle partial uploads before ratelimits
|
||||
if (req.headers['content-range'] && zconfig.chunks.enabled) {
|
||||
if (format === 'name') {
|
||||
@@ -128,6 +186,9 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
mimetype: req.headers.uploadtext ? 'text/plain' : mimetype,
|
||||
userId: user.id,
|
||||
originalName: req.headers['original-name'] ? filename ?? null : null,
|
||||
...(folderToAdd && {
|
||||
folderId: folderToAdd,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -175,23 +236,6 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
});
|
||||
}
|
||||
|
||||
if (user.ratelimit) {
|
||||
const remaining = user.ratelimit.getTime() - Date.now();
|
||||
logger.debug(`${user.id} encountered ratelimit, ${remaining}ms remaining`);
|
||||
if (remaining <= 0) {
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
ratelimit: null,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
return res.ratelimited(remaining);
|
||||
}
|
||||
}
|
||||
|
||||
if (!req.files) return res.badRequest('no files');
|
||||
if (req.files && req.files.length === 0) return res.badRequest('no files');
|
||||
|
||||
@@ -262,6 +306,9 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
maxViews: fileMaxViews,
|
||||
originalName: req.headers['original-name'] ? decodedName ?? null : null,
|
||||
size: file.size,
|
||||
...(folderToAdd && {
|
||||
folderId: folderToAdd,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -270,12 +317,12 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
|
||||
if (compressionUsed) {
|
||||
const buffer = await sharp(file.buffer).jpeg({ quality: imageCompressionPercent }).toBuffer();
|
||||
await datasource.save(fileUpload.name, buffer);
|
||||
await datasource.save(fileUpload.name, buffer, { type: 'image/jpeg' });
|
||||
logger.info(
|
||||
`User ${user.username} (${user.id}) compressed image from ${file.buffer.length} -> ${buffer.length} bytes`,
|
||||
);
|
||||
} else {
|
||||
await datasource.save(fileUpload.name, file.buffer);
|
||||
await datasource.save(fileUpload.name, file.buffer, { type: file.mimetype });
|
||||
}
|
||||
|
||||
logger.info(`User ${user.username} (${user.id}) uploaded ${fileUpload.name} (${fileUpload.id})`);
|
||||
@@ -315,28 +362,6 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
}
|
||||
}
|
||||
|
||||
if (user.administrator && zconfig.ratelimit.admin > 0) {
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
ratelimit: new Date(Date.now() + zconfig.ratelimit.admin * 1000),
|
||||
},
|
||||
});
|
||||
} else if (!user.administrator && zconfig.ratelimit.user > 0) {
|
||||
if (user.administrator && zconfig.ratelimit.user > 0) {
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
ratelimit: new Date(Date.now() + zconfig.ratelimit.user * 1000),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (req.headers['no-json']) {
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
return res.end(response.files.join(','));
|
||||
|
||||
@@ -12,15 +12,20 @@ const logger = Logger.get('user');
|
||||
async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
const { id } = req.query as { id: string };
|
||||
|
||||
if (!id || isNaN(parseInt(id))) return res.notFound('no user provided');
|
||||
|
||||
const target = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: Number(id),
|
||||
id: parseInt(id),
|
||||
},
|
||||
include: {
|
||||
files: {
|
||||
include: {
|
||||
thumbnail: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
},
|
||||
Folder: true,
|
||||
},
|
||||
@@ -184,6 +189,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
return res.json(newUser);
|
||||
} else {
|
||||
delete target.password;
|
||||
delete target.totpSecret;
|
||||
|
||||
if (user.superAdmin && target.superAdmin) {
|
||||
delete target.files;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Zip, ZipPassThrough } from 'fflate';
|
||||
import { createReadStream, createWriteStream } from 'fs';
|
||||
import { readdir, stat } from 'fs/promises';
|
||||
import { rm, stat } from 'fs/promises';
|
||||
import datasource from 'lib/datasource';
|
||||
import Logger from 'lib/logger';
|
||||
import prisma from 'lib/prisma';
|
||||
@@ -23,6 +23,13 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
const export_name = `zipline_export_${user.id}_${Date.now()}.zip`;
|
||||
const path = join(config.core.temp_directory, export_name);
|
||||
|
||||
const exportDb = await prisma.export.create({
|
||||
data: {
|
||||
path: export_name,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug(`creating write stream at ${path}`);
|
||||
const write_stream = createWriteStream(path);
|
||||
|
||||
@@ -79,11 +86,27 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
logger.info(
|
||||
`Export for ${user.username} (${user.id}) has completed and is available at ${export_name}`,
|
||||
);
|
||||
|
||||
await prisma.export.update({
|
||||
where: {
|
||||
id: exportDb.id,
|
||||
},
|
||||
data: {
|
||||
complete: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
write_stream.close();
|
||||
logger.debug(`error while writing to zip: ${err}`);
|
||||
logger.error(`Export for ${user.username} (${user.id}) has failed\n${err}`);
|
||||
logger.error(
|
||||
`Export for ${user.username} (${user.id}) has failed and has been removed from the database\n${err}`,
|
||||
);
|
||||
|
||||
await prisma.export.delete({
|
||||
where: {
|
||||
id: exportDb.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -114,27 +137,62 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
res.json({
|
||||
url: '/api/user/export?name=' + export_name,
|
||||
});
|
||||
} else {
|
||||
const export_name = req.query.name as string;
|
||||
if (export_name) {
|
||||
const parts = export_name.split('_');
|
||||
if (Number(parts[2]) !== user.id) return res.unauthorized('cannot access export owned by another user');
|
||||
} else if (req.method === 'DELETE') {
|
||||
const name = req.query.name as string;
|
||||
if (!name) return res.badRequest('no name provided');
|
||||
|
||||
const stream = createReadStream(join(config.core.temp_directory, export_name));
|
||||
const exportDb = await prisma.export.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
path: name,
|
||||
},
|
||||
});
|
||||
|
||||
if (!exportDb) return res.notFound('export not found');
|
||||
|
||||
await prisma.export.delete({
|
||||
where: {
|
||||
id: exportDb.id,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await rm(join(config.core.temp_directory, exportDb.path));
|
||||
} catch (e) {
|
||||
logger
|
||||
.error(`export file ${exportDb.path} has been removed from the database`)
|
||||
.error(`but failed to remove the file from the filesystem: ${e}`);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
});
|
||||
} else {
|
||||
const exportsDb = await prisma.export.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
const name = req.query.name as string;
|
||||
if (name) {
|
||||
const exportDb = exportsDb.find((e) => e.path === name);
|
||||
if (!exportDb) return res.notFound('export not found');
|
||||
|
||||
const stream = createReadStream(join(config.core.temp_directory, exportDb.path));
|
||||
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${export_name}"`);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${exportDb.path}"`);
|
||||
stream.pipe(res);
|
||||
} else {
|
||||
const files = await readdir(config.core.temp_directory);
|
||||
const exp = files.filter((f) => f.startsWith('zipline_export_'));
|
||||
const exports = [];
|
||||
for (let i = 0; i !== exp.length; ++i) {
|
||||
const name = exp[i];
|
||||
const stats = await stat(join(config.core.temp_directory, name));
|
||||
|
||||
if (Number(exp[i].split('_')[2]) !== user.id) continue;
|
||||
exports.push({ name, size: stats.size });
|
||||
for (let i = 0; i !== exportsDb.length; ++i) {
|
||||
const exportDb = exportsDb[i];
|
||||
if (!exportDb.complete) continue;
|
||||
|
||||
const stats = await stat(join(config.core.temp_directory, exportDb.path));
|
||||
exports.push({ name: exportDb.path, size: stats.size, createdAt: exportDb.createdAt });
|
||||
}
|
||||
|
||||
res.json({
|
||||
@@ -145,6 +203,6 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
}
|
||||
|
||||
export default withZipline(handler, {
|
||||
methods: ['GET', 'POST'],
|
||||
methods: ['GET', 'POST', 'DELETE'],
|
||||
user: true,
|
||||
});
|
||||
|
||||
@@ -142,6 +142,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
size: bigint;
|
||||
originalName: string;
|
||||
thumbnail?: { name: string };
|
||||
password: string | boolean;
|
||||
}[] = await prisma.file.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
@@ -163,11 +164,13 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
size: true,
|
||||
originalName: true,
|
||||
thumbnail: true,
|
||||
password: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (let i = 0; i !== files.length; ++i) {
|
||||
(files[i] as unknown as { url: string }).url = formatRootUrl(config.uploader.route, files[i].name);
|
||||
files[i].password = !!files[i].password;
|
||||
|
||||
if (files[i].thumbnail) {
|
||||
(files[i].thumbnail as unknown as string) = formatRootUrl('/r', files[i].thumbnail.name);
|
||||
|
||||
@@ -16,7 +16,13 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
id: idParsed,
|
||||
},
|
||||
select: {
|
||||
files: !!req.query.files,
|
||||
files: req.query.files
|
||||
? {
|
||||
include: {
|
||||
thumbnail: true,
|
||||
},
|
||||
}
|
||||
: false,
|
||||
id: true,
|
||||
name: true,
|
||||
userId: true,
|
||||
@@ -70,7 +76,13 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
},
|
||||
},
|
||||
select: {
|
||||
files: !!req.query.files,
|
||||
files: req.query.files
|
||||
? {
|
||||
include: {
|
||||
thumbnail: true,
|
||||
},
|
||||
}
|
||||
: false,
|
||||
id: true,
|
||||
name: true,
|
||||
userId: true,
|
||||
@@ -111,7 +123,13 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
public: !!publicFolder,
|
||||
},
|
||||
select: {
|
||||
files: !!req.query.files,
|
||||
files: req.query.files
|
||||
? {
|
||||
include: {
|
||||
thumbnail: true,
|
||||
},
|
||||
}
|
||||
: false,
|
||||
id: true,
|
||||
name: true,
|
||||
userId: true,
|
||||
@@ -200,7 +218,13 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
},
|
||||
},
|
||||
select: {
|
||||
files: !!req.query.files,
|
||||
files: req.query.files
|
||||
? {
|
||||
include: {
|
||||
thumbnail: true,
|
||||
},
|
||||
}
|
||||
: false,
|
||||
id: true,
|
||||
name: true,
|
||||
userId: true,
|
||||
|
||||
@@ -8,7 +8,20 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
|
||||
if (take >= 50) return res.badRequest("take can't be more than 50");
|
||||
|
||||
let files = await prisma.file.findMany({
|
||||
let files: {
|
||||
favorite: boolean;
|
||||
createdAt: Date;
|
||||
id: number;
|
||||
name: string;
|
||||
mimetype: string;
|
||||
expiresAt: Date;
|
||||
maxViews: number;
|
||||
views: number;
|
||||
folderId: number;
|
||||
size: bigint;
|
||||
password: string | boolean;
|
||||
thumbnail?: { name: string };
|
||||
}[] = await prisma.file.findMany({
|
||||
take,
|
||||
where: {
|
||||
userId: user.id,
|
||||
@@ -28,15 +41,17 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
size: true,
|
||||
favorite: true,
|
||||
thumbnail: true,
|
||||
password: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (let i = 0; i !== files.length; ++i) {
|
||||
(files[i] as unknown as { url: string }).url = formatRootUrl(config.uploader.route, files[i].name);
|
||||
if (files[i].thumbnail) {
|
||||
files[i].password = !!files[i].password;
|
||||
|
||||
if (files[i].thumbnail)
|
||||
(files[i].thumbnail as unknown as string) = formatRootUrl('/r', files[i].thumbnail.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (req.query.filter && req.query.filter === 'media')
|
||||
files = files.filter((x) => /^(video|audio|image)/.test(x.mimetype));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
|
||||
import config from 'lib/config';
|
||||
import Logger from 'lib/logger';
|
||||
import prisma from 'lib/prisma';
|
||||
@@ -8,15 +9,22 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
if (req.method === 'DELETE') {
|
||||
if (!req.body.id) return res.badRequest('no url id');
|
||||
|
||||
try {
|
||||
const url = await prisma.url.delete({
|
||||
where: {
|
||||
id: req.body.id,
|
||||
},
|
||||
});
|
||||
|
||||
Logger.get('url').info(`User ${user.username} (${user.id}) deleted a url ${url.destination} (${url.id})`);
|
||||
Logger.get('url').info(
|
||||
`User ${user.username} (${user.id}) deleted a url ${url.destination} (${url.id})`,
|
||||
);
|
||||
|
||||
return res.json(url);
|
||||
} catch (err) {
|
||||
if (err instanceof PrismaClientKnownRequestError) return res.notFound('url not found');
|
||||
else throw err;
|
||||
}
|
||||
} else {
|
||||
const urls = await prisma.url.findMany({
|
||||
where: {
|
||||
|
||||
@@ -3,7 +3,10 @@ import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
|
||||
|
||||
async function handler(_: NextApiReq, res: NextApiRes) {
|
||||
const users = await prisma.user.findMany();
|
||||
for (let i = 0; i !== users.length; ++i) delete users[i].password;
|
||||
for (let i = 0; i !== users.length; ++i) {
|
||||
delete users[i].password;
|
||||
delete users[i].uuid;
|
||||
}
|
||||
|
||||
return res.json(users);
|
||||
}
|
||||
|
||||
@@ -5,24 +5,29 @@ import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
|
||||
async function handler(_: NextApiReq, res: NextApiRes) {
|
||||
if (!config.website.show_version) return res.forbidden('version hidden');
|
||||
|
||||
const pkg = JSON.parse(await readFile('package.json', 'utf8'));
|
||||
const pRev = await (async function () {
|
||||
try {
|
||||
return await readFile('.git/HEAD', 'utf8');
|
||||
} catch (e) {
|
||||
return JSON.parse(await readFile('package.json', 'utf8')).version;
|
||||
}
|
||||
})();
|
||||
const { groups } = new RegExp(/^ref: (?<ref>(\w+\/?)*)/).exec(pRev) || { groups: null };
|
||||
let rev: string;
|
||||
|
||||
const re = await fetch('https://zipline.diced.sh/api/version?c=' + pkg.version);
|
||||
if (!groups) rev = pRev;
|
||||
else rev = await readFile(`.git/${groups.ref}`, 'utf8');
|
||||
|
||||
const re = await fetch(`https://v3.zipline.diced.sh/api/version?c=?c=${rev}`);
|
||||
const json = await re.json();
|
||||
|
||||
if (!re.ok) return res.badRequest(json.error);
|
||||
let updateToType = 'stable';
|
||||
|
||||
if (json.isUpstream) {
|
||||
updateToType = 'upstream';
|
||||
|
||||
if (json.update?.stable) {
|
||||
updateToType = 'stable';
|
||||
}
|
||||
}
|
||||
if (json.isUpstream) updateToType = 'upstream';
|
||||
|
||||
return res.json({
|
||||
isUpstream: true,
|
||||
update: json.update?.stable || json.update?.upstream,
|
||||
isUpstream: json.isUpstream,
|
||||
update: json.isUpstream ? json.update?.upstream : json.update?.stable,
|
||||
updateToType,
|
||||
versions: {
|
||||
stable: json.git.stable,
|
||||
|
||||
@@ -67,7 +67,11 @@ export default function Login({
|
||||
const username = values.username.trim();
|
||||
const password = values.password.trim();
|
||||
|
||||
if (username === '') return form.setFieldError('username', "Username can't be nothing");
|
||||
if (username === '') {
|
||||
setLoading(false);
|
||||
setDisabled(false);
|
||||
return form.setFieldError('username', "Username can't be nothing");
|
||||
}
|
||||
|
||||
const res = await useFetch('/api/auth/login', 'POST', {
|
||||
username,
|
||||
@@ -96,7 +100,10 @@ export default function Login({
|
||||
setLoading(false);
|
||||
}
|
||||
} else {
|
||||
await router.push((router.query.url as string) || '/dashboard');
|
||||
let redirectUrl = (router.query.url as string) || '/dashboard';
|
||||
if (!redirectUrl.startsWith('/dashboard')) redirectUrl = '/dashboard';
|
||||
|
||||
await router.push(redirectUrl);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function Register({ code = undefined, title, user_registration })
|
||||
};
|
||||
|
||||
const createUser = async () => {
|
||||
const res = await useFetch(`/api/auth/${user_registration ? 'register' : 'create'}`, 'POST', {
|
||||
const res = await useFetch('/api/auth/register', 'POST', {
|
||||
code: user_registration ? null : code,
|
||||
username,
|
||||
password,
|
||||
|
||||
@@ -58,7 +58,7 @@ export default function Code({ code, id, title, render, renderType }) {
|
||||
|
||||
{!render && (
|
||||
<PrismCode
|
||||
sx={(t) => ({ height: '100vh', backgroundColor: t.colors.dark[8] })}
|
||||
sx={(t) => ({ height: '100vh', overflow: 'scroll', backgroundColor: t.colors.dark[8] })}
|
||||
code={code}
|
||||
ext={id.split('.').pop()}
|
||||
/>
|
||||
@@ -66,7 +66,7 @@ export default function Code({ code, id, title, render, renderType }) {
|
||||
|
||||
{render && overrideRender && (
|
||||
<PrismCode
|
||||
sx={(t) => ({ height: '100vh', backgroundColor: t.colors.dark[8] })}
|
||||
sx={(t) => ({ height: '100vh', overflow: 'scroll', backgroundColor: t.colors.dark[8] })}
|
||||
code={code}
|
||||
ext={id.split('.').pop()}
|
||||
/>
|
||||
@@ -115,6 +115,17 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||
renderType = null;
|
||||
}
|
||||
|
||||
await prisma.file.update({
|
||||
where: {
|
||||
id: file.id,
|
||||
},
|
||||
data: {
|
||||
views: {
|
||||
increment: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
props: {
|
||||
code: await streamToString(data),
|
||||
|
||||
@@ -85,6 +85,12 @@ export const getServerSideProps: GetServerSideProps<Props> = async (context) =>
|
||||
createdAt: true,
|
||||
password: true,
|
||||
size: true,
|
||||
thumbnail: {
|
||||
select: {
|
||||
name: true,
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
user: {
|
||||
@@ -106,6 +112,9 @@ export const getServerSideProps: GetServerSideProps<Props> = async (context) =>
|
||||
folder.files[j].name,
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
folder.files[j].size = Number(folder.files[j].size);
|
||||
|
||||
// @ts-ignore
|
||||
if (folder.files[j].password) folder.files[j].password = true;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Box, Button, Modal, PasswordInput } from '@mantine/core';
|
||||
import { Box, Button, Modal, PasswordInput, Title } from '@mantine/core';
|
||||
import type { File, Thumbnail } from '@prisma/client';
|
||||
import AnchorNext from 'components/AnchorNext';
|
||||
import exts from 'lib/exts';
|
||||
@@ -10,167 +10,228 @@ import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import zconfig from 'lib/config';
|
||||
import config from 'lib/config';
|
||||
|
||||
export default function EmbeddedFile({
|
||||
file,
|
||||
user,
|
||||
pass,
|
||||
prismRender,
|
||||
host,
|
||||
compress,
|
||||
mediaType,
|
||||
}: {
|
||||
file: File & { imageProps?: HTMLImageElement; thumbnail: Thumbnail };
|
||||
user: UserExtended;
|
||||
pass: boolean;
|
||||
file: Omit<File, 'password'> & {
|
||||
password: boolean;
|
||||
mediaProps?: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
thumbnail?: Pick<Thumbnail, 'name'>;
|
||||
};
|
||||
user: Omit<UserExtended, 'password' | 'secret' | 'totpSecret' | 'ratelimit'>;
|
||||
prismRender: boolean;
|
||||
host: string;
|
||||
compress?: boolean;
|
||||
mediaType: 'image' | 'video' | 'audio' | 'other';
|
||||
}) {
|
||||
const dataURL = (route: string) => `${route}/${encodeURI(file.name)}?compress=${compress ?? false}`;
|
||||
|
||||
const router = useRouter();
|
||||
const [opened, setOpened] = useState(pass);
|
||||
const [password, setPassword] = useState('');
|
||||
const {
|
||||
password: provPassword,
|
||||
compress = 'false',
|
||||
embed = 'false',
|
||||
} = router.query as {
|
||||
password?: string;
|
||||
compress?: string;
|
||||
embed?: string;
|
||||
};
|
||||
|
||||
const dataURL = (
|
||||
route: string,
|
||||
useThumb?: boolean,
|
||||
withoutHost?: boolean,
|
||||
pass?: string,
|
||||
forcedl?: boolean,
|
||||
) =>
|
||||
`${withoutHost ? '' : host}${route}/${encodeURIComponent(
|
||||
(useThumb && !!file.thumbnail && file.thumbnail.name) || file.name,
|
||||
)}${compress.match(/^true/i) ? '?compress=true' : '?compress=false'}${
|
||||
!!pass ? `&password=${encodeURIComponent(pass)}` : ''
|
||||
}${forcedl ? '&download=true' : ''}`;
|
||||
const [opened, setOpened] = useState(file.password);
|
||||
const [password, setPassword] = useState(provPassword || '');
|
||||
const [error, setError] = useState('');
|
||||
const [scale, setScale] = useState(2);
|
||||
|
||||
// reapply date from workaround
|
||||
file.createdAt = new Date(file ? file.createdAt : 0);
|
||||
|
||||
const check = async () => {
|
||||
const res = await fetch(`/api/auth/image?id=${file.id}&password=${encodeURIComponent(password)}`);
|
||||
const res = await fetch(dataURL('/r', false, true, password));
|
||||
|
||||
if (res.ok) {
|
||||
setError('');
|
||||
if (prismRender) return router.push(`/code/${file.name}?password=${password}`);
|
||||
updateImage(`/api/auth/image?id=${file.id}&password=${password}`);
|
||||
if (prismRender) return router.push(`/code/${file.name}?password=${encodeURIComponent(password)}`);
|
||||
updateMedia(dataURL('/r', false, true, password));
|
||||
setOpened(false);
|
||||
} else {
|
||||
setError('Invalid password');
|
||||
}
|
||||
};
|
||||
|
||||
const updateImage = async (url?: string) => {
|
||||
const imageEl = document.getElementById('image_content') as HTMLImageElement;
|
||||
const updateMedia: (url?: string) => void = function (url?: string) {
|
||||
if (mediaType === 'other') return;
|
||||
|
||||
const img = new Image();
|
||||
img.addEventListener('load', function () {
|
||||
if (this.naturalWidth > innerWidth)
|
||||
imageEl.width = Math.floor(
|
||||
this.naturalWidth * Math.min(innerHeight / this.naturalHeight, innerWidth / this.naturalWidth),
|
||||
);
|
||||
else imageEl.width = this.naturalWidth;
|
||||
});
|
||||
const mediaContent = document.getElementById(`${mediaType}_content`) as HTMLMediaElement;
|
||||
|
||||
img.src = url || dataURL('/r');
|
||||
if (url) {
|
||||
imageEl.src = url;
|
||||
if (document.head.getElementsByClassName('dynamic').length === 0) {
|
||||
const metas: HTMLMetaElement[][] = [];
|
||||
const twType = mediaType === 'video' ? 'player' : 'image';
|
||||
const ogType = mediaType === 'video' ? 'video' : 'image';
|
||||
for (let i = 0; i !== 2; i++) {
|
||||
const metaW = document.createElement('meta');
|
||||
const metaH = document.createElement('meta');
|
||||
metaW.setAttribute('name', i % 2 ? `twitter:${twType}:width` : `og:${ogType}:width`);
|
||||
metaH.setAttribute('name', i % 2 ? `twitter:${twType}:height` : `og:${ogType}:height`);
|
||||
metaW.className = 'dynamic';
|
||||
metaH.className = 'dynamic';
|
||||
metas.push([metaW, metaH]);
|
||||
}
|
||||
file.imageProps = img;
|
||||
if (mediaType === 'image') {
|
||||
const img = new Image();
|
||||
img.onload = function () {
|
||||
if (document.head.getElementsByClassName('dynamic').length !== 0) return;
|
||||
file.mediaProps = {
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight,
|
||||
};
|
||||
for (const meta of metas) {
|
||||
meta[0].setAttribute('content', file.mediaProps.width.toString());
|
||||
meta[1].setAttribute('content', file.mediaProps.height.toString());
|
||||
document.head.appendChild(meta[0]);
|
||||
document.head.appendChild(meta[1]);
|
||||
}
|
||||
img.remove();
|
||||
};
|
||||
img.src = dataURL('/r', false, false, password);
|
||||
}
|
||||
if (mediaType === 'video') {
|
||||
const vid = document.createElement('video');
|
||||
vid.onloadedmetadata = function () {
|
||||
if (document.head.getElementsByClassName('dynamic').length !== 0) return;
|
||||
file.mediaProps = {
|
||||
width: vid.videoWidth,
|
||||
height: vid.videoHeight,
|
||||
};
|
||||
for (const meta of metas) {
|
||||
meta[0].setAttribute('content', file.mediaProps.width.toString());
|
||||
meta[1].setAttribute('content', file.mediaProps.height.toString());
|
||||
document.head.appendChild(meta[0]);
|
||||
document.head.appendChild(meta[1]);
|
||||
}
|
||||
vid.remove();
|
||||
};
|
||||
vid.src = dataURL('/r', false, false, password);
|
||||
vid.load();
|
||||
}
|
||||
}
|
||||
|
||||
if (url) mediaContent.src = url;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (pass) {
|
||||
setOpened(true);
|
||||
} else {
|
||||
updateImage();
|
||||
if (file.password) {
|
||||
if (password) check();
|
||||
else setOpened(true);
|
||||
}
|
||||
|
||||
if (mediaType === 'other') return;
|
||||
updateMedia();
|
||||
return () => {
|
||||
const metas = document.head.getElementsByClassName('dynamic');
|
||||
for (const meta of metas) meta.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
{!embed.match(/^true/i) && !file.embed && mediaType === 'image' && (
|
||||
<link rel='alternate' type='application/json+oembed' href={`${host}/api/oembed/${file.id}`} />
|
||||
)}
|
||||
{user.embed.title && file.embed && (
|
||||
<meta property='og:title' content={parseString(user.embed.title, { file: file, user })} />
|
||||
<meta property='og:title' content={parseString(user.embed.title, { file, user })} />
|
||||
)}
|
||||
|
||||
{user.embed.description && file.embed && (
|
||||
<meta
|
||||
property='og:description'
|
||||
content={parseString(user.embed.description, { file: file, user })}
|
||||
/>
|
||||
<meta property='og:description' content={parseString(user.embed.description, { file, user })} />
|
||||
)}
|
||||
|
||||
{user.embed.siteName && file.embed && (
|
||||
<meta property='og:site_name' content={parseString(user.embed.siteName, { file: file, user })} />
|
||||
<meta property='og:site_name' content={parseString(user.embed.siteName, { file, user })} />
|
||||
)}
|
||||
|
||||
{user.embed.color && file.embed && (
|
||||
<meta property='theme-color' content={parseString(user.embed.color, { file: file, user })} />
|
||||
<meta property='theme-color' content={parseString(user.embed.color, { file, user })} />
|
||||
)}
|
||||
|
||||
{file.mimetype.startsWith('image') && (
|
||||
{(embed.match(/^true/i) || file.embed) && (
|
||||
<>
|
||||
<meta name='og:title' content={file.name} />
|
||||
<meta property='twitter:title' content={file.name} />
|
||||
{mediaType === 'image' && <meta property='twitter:card' content='summary_large_image' />}
|
||||
{mediaType === 'image' && (
|
||||
<meta name='twitter:image' content={dataURL('/r', false, false, password)} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{mediaType === 'image' && (
|
||||
<>
|
||||
<meta property='og:type' content='image' />
|
||||
<meta property='og:image' itemProp='image' content={`${host}/r/${file.name}`} />
|
||||
<meta property='og:url' content={`${host}/r/${file.name}`} />
|
||||
<meta property='og:image:width' content={file.imageProps?.naturalWidth.toString()} />
|
||||
<meta property='og:image:height' content={file.imageProps?.naturalHeight.toString()} />
|
||||
<meta property='twitter:card' content='summary_large_image' />
|
||||
<meta property='twitter:image' content={`${host}/r/${file.name}`} />
|
||||
<meta property='twitter:title' content={file.name} />
|
||||
<meta property='og:image' itemProp='image' content={dataURL('/r', false, false, password)} />
|
||||
<meta property='og:image:secure_url' content={dataURL('/r', false, false, password)} />
|
||||
<meta property='og:image:alt' content={file.name} />
|
||||
<meta property='og:image:type' content={file.mimetype} />
|
||||
</>
|
||||
)}
|
||||
{file.mimetype.startsWith('video') && (
|
||||
{mediaType === 'video' && [
|
||||
...(!!file.thumbnail
|
||||
? [
|
||||
<meta key={1} property='og:image' content={dataURL('/r', true, false, password)} />,
|
||||
<meta
|
||||
key={2}
|
||||
property='og:image:secure_url'
|
||||
content={dataURL('/r', true, false, password)}
|
||||
/>,
|
||||
<meta
|
||||
key={3}
|
||||
property='og:image:type'
|
||||
content={file.thumbnail.name.split('.').pop() === 'jpg' ? 'image/jpg' : 'image/gif'}
|
||||
/>,
|
||||
]
|
||||
: []),
|
||||
<meta key={4} property='og:type' content='video.other' />,
|
||||
<meta key={5} property='og:video:url' content={dataURL('/r', false, false, password)} />,
|
||||
<meta key={6} property='og:video:secure_url' content={dataURL('/r', false, false, password)} />,
|
||||
<meta key={7} property='og:video:type' content={file.mimetype} />,
|
||||
<meta key={8} name='twitter:card' content='player' />,
|
||||
<meta key={9} name='twitter:player' content={dataURL('/r', false, false, password)} />,
|
||||
<meta key={10} name='twitter:player:stream' content={dataURL('/r', false, false, password)} />,
|
||||
<meta key={11} name='twitter:player:stream:content_type' content={file.mimetype} />,
|
||||
]}
|
||||
{mediaType === 'audio' && (
|
||||
<>
|
||||
<meta name='twitter:card' content='player' />
|
||||
<meta name='twitter:player' content={`${host}/r/${file.name}`} />
|
||||
<meta name='twitter:player:stream' content={`${host}/r/${file.name}`} />
|
||||
<meta name='twitter:player:width' content='720' />
|
||||
<meta name='twitter:player:height' content='480' />
|
||||
<meta name='twitter:player:stream:content_type' content={file.mimetype} />
|
||||
<meta name='twitter:title' content={file.name} />
|
||||
|
||||
{file.thumbnail && (
|
||||
<>
|
||||
<meta name='twitter:image' content={`${host}/r/${file.thumbnail.name}`} />
|
||||
<meta property='og:image' content={`${host}/r/${file.thumbnail.name}`} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<meta property='og:type' content={'video.other'} />
|
||||
<meta property='og:url' content={`${host}/r/${file.name}`} />
|
||||
<meta property='og:video' content={`${host}/r/${file.name}`} />
|
||||
<meta property='og:video:url' content={`${host}/r/${file.name}`} />
|
||||
<meta property='og:video:secure_url' content={`${host}/r/${file.name}`} />
|
||||
<meta property='og:video:type' content={file.mimetype} />
|
||||
<meta property='og:video:width' content='720' />
|
||||
<meta property='og:video:height' content='480' />
|
||||
</>
|
||||
)}
|
||||
{file.mimetype.startsWith('audio') && (
|
||||
<>
|
||||
<meta name='twitter:card' content='player' />
|
||||
<meta name='twitter:player' content={`${host}/r/${file.name}`} />
|
||||
<meta name='twitter:player:stream' content={`${host}/r/${file.name}`} />
|
||||
<meta name='twitter:player:stream:content_type' content={file.mimetype} />
|
||||
<meta name='twitter:title' content={file.name} />
|
||||
<meta name='twitter:player:width' content='720' />
|
||||
<meta name='twitter:player:height' content='480' />
|
||||
|
||||
<meta property='og:type' content='music.song' />
|
||||
<meta property='og:url' content={`${host}/r/${file.name}`} />
|
||||
<meta property='og:audio' content={`${host}/r/${file.name}`} />
|
||||
<meta property='og:audio:secure_url' content={`${host}/r/${file.name}`} />
|
||||
<meta property='og:audio' content={dataURL('/r', false, false, password)} />
|
||||
<meta property='og:audio:secure_url' content={dataURL('/r', false, false, password)} />
|
||||
<meta property='og:audio:type' content={file.mimetype} />
|
||||
</>
|
||||
)}
|
||||
{!file.mimetype.startsWith('video') && !file.mimetype.startsWith('image') && (
|
||||
<meta property='og:url' content={`${host}/r/${file.name}`} />
|
||||
)}
|
||||
<title>{file.name}</title>
|
||||
</Head>
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => setOpened(false)}
|
||||
title='Password Protected'
|
||||
title={<Title order={3}>Password Protected</Title>}
|
||||
centered={true}
|
||||
withCloseButton={true}
|
||||
withCloseButton={false}
|
||||
closeOnEscape={false}
|
||||
closeOnClickOutside={false}
|
||||
>
|
||||
<PasswordInput
|
||||
label='Password'
|
||||
placeholder='Password'
|
||||
error={error}
|
||||
value={password}
|
||||
@@ -186,24 +247,97 @@ export default function EmbeddedFile({
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
if (mediaType !== 'image' || e.button !== 0) return;
|
||||
if (e.button !== 0) return;
|
||||
|
||||
e.preventDefault();
|
||||
const imageEl = document.getElementById('image_content') as HTMLImageElement,
|
||||
posX = e.pageX - (imageEl.x + imageEl.width / 2),
|
||||
posY = e.pageY - (imageEl.y + imageEl.height / 2);
|
||||
|
||||
if (imageEl.style.transform.startsWith('translate')) return;
|
||||
|
||||
imageEl.style.transform = `translate(${posX * -scale}px, ${posY * -scale}px) scale(${scale})`;
|
||||
return true;
|
||||
}}
|
||||
onMouseUp={(e) => {
|
||||
if (mediaType !== 'image' || e.button !== 0) return;
|
||||
|
||||
const imageEl = document.getElementById('image_content') as HTMLImageElement;
|
||||
if (!imageEl.style.transform.startsWith('translate')) return;
|
||||
imageEl.style.transform = 'scale(1)';
|
||||
setScale(2);
|
||||
return true;
|
||||
}}
|
||||
onMouseMove={(e) => {
|
||||
if (mediaType !== 'image' || e.button !== 0) return;
|
||||
if (e.button !== 0) return;
|
||||
|
||||
const imageEl = document.getElementById('image_content') as HTMLImageElement,
|
||||
posX = e.pageX - (imageEl.x + imageEl.width / 2),
|
||||
posY = e.pageY - (imageEl.y + imageEl.height / 2);
|
||||
|
||||
if (!imageEl.style.transform.startsWith('translate')) return;
|
||||
imageEl.style.transform = `translate(${posX * -scale}px, ${posY * -scale}px) scale(${scale})`;
|
||||
return true;
|
||||
}}
|
||||
onWheel={(e) => {
|
||||
if (mediaType !== 'image' || e.button !== 0) return;
|
||||
|
||||
const imageEl = document.getElementById('image_content') as HTMLImageElement,
|
||||
posX = e.pageX - (imageEl.x + imageEl.width / 2),
|
||||
posY = e.pageY - (imageEl.y + imageEl.height / 2);
|
||||
if (!imageEl.style.transform.startsWith('translate')) return;
|
||||
let newScale = 0;
|
||||
if (e.deltaY < 0) newScale = scale + 0.25;
|
||||
if (e.deltaY > 0) newScale = scale - 0.25 == 0 ? scale : scale - 0.25;
|
||||
setScale(newScale);
|
||||
imageEl.style.transform = `translate(${posX * -newScale}px, ${
|
||||
posY * -newScale
|
||||
}px) scale(${newScale})`;
|
||||
}}
|
||||
>
|
||||
{file.mimetype.startsWith('image') && (
|
||||
<img src={dataURL('/r')} alt={dataURL('/r')} id='image_content' />
|
||||
{mediaType === 'image' && (
|
||||
<img
|
||||
src={dataURL('/r', false, true, password)}
|
||||
alt={dataURL('/r', false, true, password)}
|
||||
id='image_content'
|
||||
style={{
|
||||
transition: 'transform 0.25s ease',
|
||||
maxHeight: '100vh',
|
||||
maxWidth: '100vw',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{file.mimetype.startsWith('video') && (
|
||||
<video src={dataURL('/r')} controls autoPlay muted id='video_content' />
|
||||
{mediaType === 'video' && (
|
||||
<video
|
||||
style={{
|
||||
maxHeight: '100vh',
|
||||
maxWidth: '100vw',
|
||||
}}
|
||||
controls
|
||||
muted
|
||||
poster={dataURL('/r', true, true, password)}
|
||||
id='video_content'
|
||||
>
|
||||
<source src={dataURL('/r', false, true, password)} />
|
||||
<AnchorNext component={Link} href={dataURL('/r', false, true, password, true)}>
|
||||
Can't preview this file. Click here to download it.
|
||||
</AnchorNext>
|
||||
</video>
|
||||
)}
|
||||
|
||||
{file.mimetype.startsWith('audio') && (
|
||||
<audio src={dataURL('/r')} controls autoPlay muted id='audio_content' />
|
||||
{mediaType === 'audio' && (
|
||||
<audio src={dataURL('/r', false, true)} controls autoPlay muted id='audio_content' />
|
||||
)}
|
||||
|
||||
{!file.mimetype.startsWith('video') &&
|
||||
!file.mimetype.startsWith('image') &&
|
||||
!file.mimetype.startsWith('audio') && (
|
||||
<AnchorNext component={Link} href={dataURL('/r')}>
|
||||
{mediaType === 'other' && (
|
||||
<AnchorNext component={Link} href={dataURL('/r', false, true, password, true)}>
|
||||
Can't preview this file. Click here to download it.
|
||||
</AnchorNext>
|
||||
)}
|
||||
@@ -214,32 +348,44 @@ export default function EmbeddedFile({
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||
const { id } = context.params as { id: string };
|
||||
const { compress = null } = context.query as unknown as { compress?: boolean };
|
||||
const file = await prisma.file.findFirst({
|
||||
where: {
|
||||
OR: [{ name: id }, { invisible: { invis: decodeURI(encodeURI(id)) } }],
|
||||
},
|
||||
include: {
|
||||
thumbnail: true,
|
||||
thumbnail: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
let host = context.req.headers.host;
|
||||
if (!file) return { notFound: true };
|
||||
|
||||
// @ts-ignore
|
||||
file.size = parseInt(file.size);
|
||||
|
||||
const proto = context.req.headers['x-forwarded-proto'];
|
||||
try {
|
||||
if (
|
||||
JSON.parse(context.req.headers['cf-visitor'] as string).scheme === 'https' ||
|
||||
proto === 'https' ||
|
||||
zconfig.core.return_https
|
||||
config.core.return_https
|
||||
)
|
||||
host = `https://${host}`;
|
||||
else host = `http://${host}`;
|
||||
} catch (e) {
|
||||
if (proto === 'https' || zconfig.core.return_https) host = `https://${host}`;
|
||||
if (proto === 'https' || config.core.return_https) host = `https://${host}`;
|
||||
else host = `http://${host}`;
|
||||
}
|
||||
|
||||
const mediaType: 'image' | 'video' | 'audio' | 'other' =
|
||||
(new RegExp(/^(?<type>image|video|audio)/).exec(file.mimetype)?.groups?.type as
|
||||
| 'image'
|
||||
| 'video'
|
||||
| 'audio') || 'other';
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: file.userId,
|
||||
@@ -248,9 +394,14 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||
delete user.password;
|
||||
delete user.totpSecret;
|
||||
delete user.token;
|
||||
delete user.ratelimit;
|
||||
|
||||
// @ts-ignore workaround because next wont allow date
|
||||
file.createdAt = file.createdAt.toString();
|
||||
// @ts-ignore ditto
|
||||
if (file.expiresAt) file.expiresAt = file.createdAt.toString();
|
||||
// @ts-ignore
|
||||
file.password = !!file.password;
|
||||
|
||||
const prismRender = Object.keys(exts).includes(file.name.split('.').pop());
|
||||
if (prismRender && !file.password)
|
||||
@@ -260,49 +411,22 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||
permanent: true,
|
||||
},
|
||||
};
|
||||
else if (prismRender && file.password) {
|
||||
const pass = file.password ? true : false;
|
||||
// @ts-ignore
|
||||
if (file.password) file.password = true;
|
||||
else if (prismRender && file.password)
|
||||
return {
|
||||
props: {
|
||||
file,
|
||||
user,
|
||||
pass,
|
||||
prismRender: true,
|
||||
host,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!file.mimetype.startsWith('image') && !file.mimetype.startsWith('video')) {
|
||||
const { default: datasource } = await import('lib/datasource');
|
||||
|
||||
const data = await datasource.get(file.name);
|
||||
if (!data) return { notFound: true };
|
||||
|
||||
// @ts-ignore
|
||||
if (file.password) file.password = true;
|
||||
|
||||
return {
|
||||
props: {
|
||||
file,
|
||||
user,
|
||||
host,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
if (file.password) file.password = true;
|
||||
|
||||
return {
|
||||
props: {
|
||||
file,
|
||||
user,
|
||||
pass: file.password ? true : false,
|
||||
host,
|
||||
compress,
|
||||
mediaType,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ async function main() {
|
||||
for (let i = 0; i !== files.length; ++i) {
|
||||
const file = files[i];
|
||||
const size = await datasource.size(file.name);
|
||||
if (size === 0) {
|
||||
if (size === 0 || size == null) {
|
||||
toDelete.push(file.name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,12 +29,12 @@ async function main() {
|
||||
const mime = await guess(files[i].split('.').pop());
|
||||
const { size } = statSync(join(directory, files[i]));
|
||||
|
||||
data.push({
|
||||
data[i] = {
|
||||
name: files[i],
|
||||
mimetype: mime,
|
||||
userId,
|
||||
size,
|
||||
});
|
||||
};
|
||||
|
||||
console.log(`Imported ${files[i]} (${bytesToHuman(size)}) (${mime} mimetype) to user ${userId}`);
|
||||
}
|
||||
@@ -53,8 +53,11 @@ async function main() {
|
||||
// copy files to local storage
|
||||
console.log(`Copying files to ${config.datasource.type} storage..`);
|
||||
for (let i = 0; i !== files.length; ++i) {
|
||||
const file = files[i];
|
||||
await datasource.save(file, await readFile(join(directory, file)));
|
||||
const file = files[i],
|
||||
fb = await readFile(join(directory, file));
|
||||
await datasource.save(file, fb, {
|
||||
type: data[i]?.mimetype ?? 'application/octet-stream',
|
||||
});
|
||||
}
|
||||
console.log(`Finished copying files to ${config.datasource.type} storage.`);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import config from 'lib/config';
|
||||
import 'lib/config';
|
||||
import { inspect } from 'util';
|
||||
|
||||
console.log(inspect(config, { depth: Infinity, colors: true }));
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { File } from '@prisma/client';
|
||||
import { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import exts from 'lib/exts';
|
||||
|
||||
function dbFileDecorator(fastify: FastifyInstance, _, done) {
|
||||
fastify.decorateReply('dbFile', dbFile);
|
||||
done();
|
||||
|
||||
async function dbFile(this: FastifyReply, file: File) {
|
||||
const { download } = this.request.query as { download?: string };
|
||||
|
||||
const ext = file.name.split('.').pop();
|
||||
if (Object.keys(exts).includes(ext)) return this.server.nextHandle(this.request.raw, this.raw);
|
||||
|
||||
const data = await this.server.datasource.get(file.name);
|
||||
if (!data) return this.notFound();
|
||||
|
||||
const size = await this.server.datasource.size(file.name);
|
||||
|
||||
this.header('Content-Length', size);
|
||||
this.header('Content-Type', download ? 'application/octet-stream' : file.mimetype);
|
||||
this.header('Content-Disposition', `inline; filename="${encodeURI(file.originalName || file.name)}"`);
|
||||
|
||||
return this.send(data);
|
||||
}
|
||||
}
|
||||
|
||||
export default fastifyPlugin(dbFileDecorator, {
|
||||
name: 'dbFile',
|
||||
decorators: {
|
||||
fastify: ['prisma', 'datasource', 'nextHandle', 'logger'],
|
||||
},
|
||||
});
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyReply {
|
||||
dbFile: (file: File) => Promise<void>;
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,11 @@ function notFound(fastify: FastifyInstance, _: unknown, done: () => void) {
|
||||
done();
|
||||
|
||||
function notFound(this: FastifyReply) {
|
||||
if (this.server.config.features.headless) {
|
||||
if (this.server.config.features.headless || process.env.NODE_ENV == 'development')
|
||||
return this.callNotFound();
|
||||
} else {
|
||||
return this.server.nextServer.render404(this.request.raw, this.raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default fastifyPlugin(notFound, {
|
||||
name: 'notFound',
|
||||
|
||||
@@ -2,12 +2,12 @@ import { Url } from '@prisma/client';
|
||||
import { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
|
||||
function postUrlDecorator(fastify: FastifyInstance, _, done) {
|
||||
fastify.decorateReply('postUrl', postUrl.bind(fastify));
|
||||
function postUrlDecorator(fastify: FastifyInstance, _, done: () => void) {
|
||||
fastify.decorateReply('postUrl', postUrl);
|
||||
done();
|
||||
|
||||
async function postUrl(this: FastifyReply, url: Url) {
|
||||
if (!url) return true;
|
||||
if (!url) return;
|
||||
|
||||
const nUrl = await this.server.prisma.url.update({
|
||||
where: {
|
||||
@@ -27,6 +27,7 @@ function postUrlDecorator(fastify: FastifyInstance, _, done) {
|
||||
|
||||
this.server.logger.child('url').info(`url deleted due to max views ${JSON.stringify(nUrl)}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,17 @@ function preFileDecorator(fastify: FastifyInstance, _, done) {
|
||||
done();
|
||||
|
||||
async function preFile(this: FastifyReply, file: File) {
|
||||
await prisma.file.update({
|
||||
where: {
|
||||
id: file.id,
|
||||
},
|
||||
data: {
|
||||
views: {
|
||||
increment: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (file.favorite) return false;
|
||||
if (file.expiresAt && file.expiresAt < new Date()) {
|
||||
await this.server.datasource.delete(file.name);
|
||||
@@ -16,6 +27,16 @@ function preFileDecorator(fastify: FastifyInstance, _, done) {
|
||||
|
||||
return true;
|
||||
}
|
||||
if (file.maxViews && file.views >= file.maxViews) {
|
||||
await datasource.delete(file.name);
|
||||
await prisma.file.delete({ where: { id: file.id } });
|
||||
|
||||
this.server.logger
|
||||
.child('file')
|
||||
.info(`File ${file.name} has been deleted due to max views (${file.maxViews})`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -30,6 +51,6 @@ export default fastifyPlugin(preFileDecorator, {
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyReply {
|
||||
preFile: (file: File) => Promise<boolean>;
|
||||
preFile: (file: Partial<File>) => Promise<boolean>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,62 +3,120 @@ import { guess } from 'lib/mimes';
|
||||
import { extname } from 'path';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import { createBrotliCompress, createDeflate, createGzip } from 'zlib';
|
||||
import pump from 'pump';
|
||||
import { Transform } from 'stream';
|
||||
import { parseRange } from 'lib/utils/range';
|
||||
import type { File, Thumbnail } from '@prisma/client';
|
||||
import { pipeline } from 'stream/promises';
|
||||
|
||||
function rawFileDecorator(fastify: FastifyInstance, _, done) {
|
||||
fastify.decorateReply('rawFile', rawFile);
|
||||
done();
|
||||
|
||||
async function rawFile(this: FastifyReply, id: string) {
|
||||
async function rawFile(this: FastifyReply, file: Partial<File> & { thumbnail?: Partial<Thumbnail> }) {
|
||||
const { download, compress = 'false' } = this.request.query as { download?: string; compress?: string };
|
||||
const isThumb = (this.request.params['id'] as string) === file.thumbnail?.name,
|
||||
filename = isThumb ? file.thumbnail?.name : file.name,
|
||||
fileMime = isThumb ? null : file.mimetype;
|
||||
|
||||
const data = await this.server.datasource.get(id);
|
||||
if (!data) return this.notFound();
|
||||
const logger = this.server.logger.child('rawRoute');
|
||||
|
||||
const mimetype = await guess(extname(id).slice(1));
|
||||
const size = await this.server.datasource.size(id);
|
||||
this.header('Content-Type', download ? 'application/octet-stream' : mimetype);
|
||||
const size = await this.server.datasource.size(filename);
|
||||
if (size === null) return this.notFound();
|
||||
|
||||
const mimetype = await guess(extname(filename).slice(1));
|
||||
|
||||
if (this.request.headers.range && !compress?.match(/^true$/i)) {
|
||||
logger.debug('responding raw file with ranged');
|
||||
const [start, end] = parseRange(this.request.headers.range, size);
|
||||
if (start >= size || end >= size) {
|
||||
const buf = await datasource.get(filename);
|
||||
if (!buf) return this.server.nextServer.render404(this.request.raw, this.raw);
|
||||
|
||||
return this.type(fileMime || mimetype || 'application/octet-stream')
|
||||
.headers({
|
||||
'Content-Length': size,
|
||||
'Content-Disposition': `${download ? 'attachment; ' : ''}filename="${encodeURIComponent(
|
||||
isThumb ? filename : file.originalName ?? filename,
|
||||
)}`,
|
||||
})
|
||||
.status(416)
|
||||
.send(buf);
|
||||
}
|
||||
|
||||
const buf = await datasource.range(filename, start || 0, end);
|
||||
if (!buf) return this.server.nextServer.render404(this.request.raw, this.raw);
|
||||
|
||||
return this.type(fileMime || mimetype || 'application/octet-stream')
|
||||
.headers({
|
||||
'Content-Range': `bytes ${start}-${end}/${size}`,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': end - start + 1,
|
||||
'Content-Disposition': `${download ? 'attachment; ' : ''}filename="${encodeURIComponent(
|
||||
isThumb ? filename : file.originalName ?? filename,
|
||||
)}`,
|
||||
})
|
||||
.status(206)
|
||||
.send(buf);
|
||||
}
|
||||
|
||||
const data = await datasource.get(filename);
|
||||
if (!data) return this.server.nextServer.render404(this.request.raw, this.raw);
|
||||
|
||||
if (
|
||||
this.server.config.core.compression.enabled &&
|
||||
compress?.match(/^true$/i) &&
|
||||
!this.request.headers['X-Zipline-NoCompress'] &&
|
||||
!!this.request.headers['accept-encoding']
|
||||
)
|
||||
if (size > this.server.config.core.compression.threshold && mimetype.match(/^(image|video|text)/))
|
||||
return this.send(useCompress.call(this, data));
|
||||
this.header('Content-Length', size);
|
||||
return this.send(data);
|
||||
!!this.request.headers['accept-encoding'] &&
|
||||
size > this.server.config.core.compression.threshold &&
|
||||
(fileMime || mimetype).match(/^(image(?!\/(webp))|video|text)/)
|
||||
) {
|
||||
logger.debug('responding raw file with compressed');
|
||||
this.hijack();
|
||||
return await useCompress.call(this, data);
|
||||
}
|
||||
|
||||
logger.debug('responding raw file with full size');
|
||||
|
||||
return this.type(mimetype || 'application/octet-stream')
|
||||
.headers({
|
||||
'Content-Length': size,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Disposition': `${download ? 'attachment; ' : ''}filename="${encodeURIComponent(
|
||||
isThumb ? filename : file.originalName ?? filename,
|
||||
)}`,
|
||||
})
|
||||
.status(200)
|
||||
.send(data);
|
||||
}
|
||||
}
|
||||
|
||||
function useCompress(this: FastifyReply, data: NodeJS.ReadableStream) {
|
||||
async function useCompress(this: FastifyReply, data: NodeJS.ReadableStream) {
|
||||
let compress: Transform;
|
||||
|
||||
switch ((this.request.headers['accept-encoding'] as string).split(', ')[0]) {
|
||||
case 'gzip':
|
||||
case 'x-gzip':
|
||||
compress = createGzip();
|
||||
this.header('Content-Encoding', 'gzip');
|
||||
this.raw.writeHead(200, { 'Content-Encoding': 'gzip' });
|
||||
break;
|
||||
case 'deflate':
|
||||
compress = createDeflate();
|
||||
this.header('Content-Encoding', 'deflate');
|
||||
this.raw.writeHead(200, { 'Content-Encoding': 'deflate' });
|
||||
break;
|
||||
case 'br':
|
||||
compress = createBrotliCompress();
|
||||
this.header('Content-Encoding', 'br');
|
||||
this.raw.writeHead(200, { 'Content-Encoding': 'br' });
|
||||
break;
|
||||
default:
|
||||
this.server.logger
|
||||
.child('response')
|
||||
.error(`Unsupported encoding: ${this.request.headers['accept-encoding']}}`);
|
||||
.debug(`Unsupported supplied encoding: ${this.request.headers['accept-encoding']}`);
|
||||
this.raw.writeHead(200, {});
|
||||
break;
|
||||
}
|
||||
if (!compress) return data;
|
||||
setTimeout(() => compress.destroy(), 2000);
|
||||
return pump(data, compress, (err) => (err ? this.server.logger.error(err) : null));
|
||||
if (!compress) return await pipeline(data, this.raw);
|
||||
|
||||
return await pipeline(data, compress, this.raw);
|
||||
}
|
||||
|
||||
export default fastifyPlugin(rawFileDecorator, {
|
||||
@@ -70,6 +128,6 @@ export default fastifyPlugin(rawFileDecorator, {
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyReply {
|
||||
rawFile: (id: string) => Promise<void>;
|
||||
rawFile: (file: Partial<File> & { thumbnail?: Partial<Thumbnail> }) => Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { version } from '../../package.json';
|
||||
import fastify, { FastifyInstance, FastifyServerOptions } from 'fastify';
|
||||
import { createReadStream, existsSync, readFileSync } from 'fs';
|
||||
import { Worker } from 'worker_threads';
|
||||
import dbFileDecorator from './decorators/dbFile';
|
||||
import notFound from './decorators/notFound';
|
||||
import postFileDecorator from './decorators/postFile';
|
||||
import postUrlDecorator from './decorators/postUrl';
|
||||
@@ -46,7 +45,7 @@ async function start() {
|
||||
logger.debug('Starting server');
|
||||
|
||||
// plugins
|
||||
server
|
||||
await server
|
||||
.register(loggerPlugin)
|
||||
.register(configPlugin, config)
|
||||
.register(datasourcePlugin, datasource)
|
||||
@@ -61,13 +60,12 @@ async function start() {
|
||||
.register(allPlugin);
|
||||
|
||||
// decorators
|
||||
server
|
||||
await server
|
||||
.register(notFound)
|
||||
.register(postUrlDecorator)
|
||||
.register(postFileDecorator)
|
||||
.register(preFileDecorator)
|
||||
.register(rawFileDecorator)
|
||||
.register(dbFileDecorator);
|
||||
.register(rawFileDecorator);
|
||||
|
||||
server.addHook('onRequest', (req, reply, done) => {
|
||||
if (config.features.headless) {
|
||||
@@ -82,12 +80,12 @@ async function start() {
|
||||
done();
|
||||
});
|
||||
|
||||
server.addHook('onResponse', (req, reply, done) => {
|
||||
server.addHook('onRequest', (req, reply, done) => {
|
||||
if (config.core.logger) {
|
||||
if (req.url.startsWith('/_next')) return done();
|
||||
|
||||
server.logger.child('response').info(`${req.method} ${req.url} -> ${reply.statusCode}`);
|
||||
server.logger.child('response').debug(
|
||||
server.logger.child('request').info(`${req.method} ${req.url} -> ${reply.statusCode}`);
|
||||
server.logger.child('request').debug(
|
||||
JSON.stringify({
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
@@ -100,6 +98,16 @@ async function start() {
|
||||
done();
|
||||
});
|
||||
|
||||
server.setErrorHandler((error, request, reply) => {
|
||||
console.error(error);
|
||||
|
||||
reply.status(500).send({
|
||||
statusCode: 500,
|
||||
error: 'Internal Server Error',
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
|
||||
server.get('/favicon.ico', async (_, reply) => {
|
||||
if (!existsSync('./public/favicon.ico')) return reply.notFound();
|
||||
|
||||
@@ -233,7 +241,9 @@ async function thumbs(this: FastifyInstance) {
|
||||
mimetype: {
|
||||
startsWith: 'video/',
|
||||
},
|
||||
thumbnail: null,
|
||||
thumbnail: {
|
||||
is: null,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
thumbnail: true,
|
||||
@@ -271,7 +281,9 @@ async function thumbs(this: FastifyInstance) {
|
||||
}
|
||||
|
||||
function genFastifyOpts(): FastifyServerOptions {
|
||||
const opts = {};
|
||||
const opts: FastifyServerOptions = {
|
||||
pluginTimeout: 25000,
|
||||
};
|
||||
|
||||
if (config.ssl?.cert && config.ssl?.key) {
|
||||
opts['https'] = {
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import { migrations } from 'server/util';
|
||||
|
||||
async function prismaPlugin(fastify: FastifyInstance) {
|
||||
process.env.DATABASE_URL = fastify.config.core?.database_url;
|
||||
await migrations();
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
fastify.decorate('prisma', prisma);
|
||||
fastify.decorate('prisma', new PrismaClient());
|
||||
return;
|
||||
}
|
||||
|
||||
export default fastifyPlugin(prismaPlugin, {
|
||||
|
||||
@@ -1,29 +1,36 @@
|
||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { checkPassword } from 'lib/util';
|
||||
|
||||
export default async function rawRoute(this: FastifyInstance, req: FastifyRequest, reply: FastifyReply) {
|
||||
const { id } = req.params as { id: string };
|
||||
const { password } = req.query as { password: string };
|
||||
if (id === '') return reply.notFound();
|
||||
|
||||
const file = await this.prisma.file.findFirst({
|
||||
where: {
|
||||
OR: [{ name: id }, { invisible: { invis: decodeURI(encodeURI(id)) } }],
|
||||
OR: [{ name: id }, { invisible: { invis: decodeURI(encodeURI(id)) } }, { thumbnail: { name: id } }],
|
||||
},
|
||||
include: {
|
||||
thumbnail: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!file) return reply.rawFile(id);
|
||||
else {
|
||||
const failed = await reply.preFile(file);
|
||||
if (failed) return reply.notFound();
|
||||
if (!file) return reply.notFound();
|
||||
|
||||
if (file.password) {
|
||||
if (!password)
|
||||
return reply
|
||||
.type('application/json')
|
||||
.code(403)
|
||||
.send({
|
||||
error: "can't view a raw file that has a password",
|
||||
url: `/view/${file.name}`,
|
||||
code: 403,
|
||||
});
|
||||
} else return reply.rawFile(file.name);
|
||||
.send({ error: 'incorrect password', url: `/view/${file.name}`, code: 403 });
|
||||
const success = await checkPassword(password, file.password);
|
||||
|
||||
if (!success)
|
||||
return reply
|
||||
.type('application/json')
|
||||
.code(403)
|
||||
.send({ error: 'incorrect password', url: `/view/${file.name}`, code: 403 });
|
||||
}
|
||||
|
||||
return (await reply.preFile(file)) ? reply.notFound() : reply.rawFile(file);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,24 @@
|
||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import exts from 'lib/exts';
|
||||
|
||||
export default async function uploadsRoute(this: FastifyInstance, req: FastifyRequest, reply: FastifyReply) {
|
||||
const { id } = req.params as { id: string };
|
||||
if (id === '') return reply.notFound();
|
||||
else if (id === 'dashboard' && !this.config.features.headless)
|
||||
else if (id === 'dashboard' && !this.config.features.headless && this.config.uploader.route === '/')
|
||||
return this.nextServer.render(req.raw, reply.raw, '/dashboard');
|
||||
|
||||
const image = await this.prisma.file.findFirst({
|
||||
const file = await this.prisma.file.findFirst({
|
||||
where: {
|
||||
OR: [{ name: id }, { name: decodeURI(id) }, { invisible: { invis: decodeURI(encodeURI(id)) } }],
|
||||
},
|
||||
});
|
||||
if (!image) return reply.rawFile(id);
|
||||
|
||||
const failed = await reply.preFile(image);
|
||||
if (!file) return reply.notFound();
|
||||
|
||||
const failed = await reply.preFile(file);
|
||||
if (failed) return reply.notFound();
|
||||
|
||||
const ext = image.name.split('.').pop();
|
||||
|
||||
if (image.password || image.embed || image.mimetype.startsWith('text/') || Object.keys(exts).includes(ext))
|
||||
return reply.redirect(`/view/${image.name}`);
|
||||
else return reply.dbFile(image);
|
||||
// @ts-ignore
|
||||
return this.nextServer.render(req.raw, reply.raw, `/view/${file.name}`, req.query);
|
||||
}
|
||||
|
||||
export async function uploadsRouteOnResponse(
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
export default async function urlsRoute(this: FastifyInstance, req: FastifyRequest, reply: FastifyReply) {
|
||||
const { id } = req.params as { id: string };
|
||||
if (id === '') return reply.notFound();
|
||||
else if (id === 'dashboard' && !this.config.features.headless)
|
||||
else if (id === 'dashboard' && !this.config.features.headless && this.config.urls.route === '/')
|
||||
return this.nextServer.render(req.raw, reply.raw, '/dashboard');
|
||||
|
||||
const url = await this.prisma.url.findFirst({
|
||||
@@ -13,9 +13,7 @@ export default async function urlsRoute(this: FastifyInstance, req: FastifyReque
|
||||
});
|
||||
if (!url) return reply.notFound();
|
||||
|
||||
reply.redirect(url.destination);
|
||||
|
||||
reply.postUrl(url);
|
||||
return await reply.redirect(url.destination);
|
||||
}
|
||||
|
||||
export async function urlsRouteOnResponse(
|
||||
@@ -24,7 +22,7 @@ export async function urlsRouteOnResponse(
|
||||
reply: FastifyReply,
|
||||
done: () => void,
|
||||
) {
|
||||
if (reply.statusCode === 200) {
|
||||
if (reply.statusCode === 302) {
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
const url = await this.prisma.url.findFirst({
|
||||
@@ -32,8 +30,7 @@ export async function urlsRouteOnResponse(
|
||||
OR: [{ id }, { vanity: id }, { invisible: { invis: decodeURI(id) } }],
|
||||
},
|
||||
});
|
||||
|
||||
reply.postUrl(url);
|
||||
await reply.postUrl(url);
|
||||
}
|
||||
|
||||
done();
|
||||
|
||||
@@ -83,7 +83,8 @@ export function redirect(res: ServerResponse, url: string) {
|
||||
|
||||
export async function getStats(prisma: PrismaClient, datasource: Datasource, logger: Logger) {
|
||||
const size = await datasource.fullSize();
|
||||
logger.debug(`full size: ${size}`);
|
||||
const llogger = logger.child('stats');
|
||||
llogger.debug(`full size: ${size}`);
|
||||
|
||||
const byUser = await prisma.file.groupBy({
|
||||
by: ['userId'],
|
||||
@@ -91,15 +92,15 @@ export async function getStats(prisma: PrismaClient, datasource: Datasource, log
|
||||
_all: true,
|
||||
},
|
||||
});
|
||||
logger.debug(`by user: ${JSON.stringify(byUser)}`);
|
||||
llogger.debug(`by user: ${JSON.stringify(byUser)}`);
|
||||
|
||||
const count_users = await prisma.user.count();
|
||||
logger.debug(`count users: ${count_users}`);
|
||||
llogger.debug(`count users: ${count_users}`);
|
||||
|
||||
const count_by_user = [];
|
||||
for (let i = 0, L = byUser.length; i !== L; ++i) {
|
||||
if (!byUser[i].userId) {
|
||||
logger.debug(`skipping user ${byUser[i]}`);
|
||||
llogger.debug(`skipping user ${byUser[i]}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -114,17 +115,17 @@ export async function getStats(prisma: PrismaClient, datasource: Datasource, log
|
||||
count: byUser[i]._count._all,
|
||||
});
|
||||
}
|
||||
logger.debug(`count by user: ${JSON.stringify(count_by_user)}`);
|
||||
llogger.debug(`count by user: ${JSON.stringify(count_by_user)}`);
|
||||
|
||||
const count = await prisma.file.count();
|
||||
logger.debug(`count files: ${JSON.stringify(count)}`);
|
||||
llogger.debug(`count files: ${JSON.stringify(count)}`);
|
||||
|
||||
const views = await prisma.file.aggregate({
|
||||
_sum: {
|
||||
views: true,
|
||||
},
|
||||
});
|
||||
logger.debug(`sum views: ${JSON.stringify(views)}`);
|
||||
llogger.debug(`sum views: ${JSON.stringify(views)}`);
|
||||
|
||||
const typesCount = await prisma.file.groupBy({
|
||||
by: ['mimetype'],
|
||||
@@ -132,7 +133,7 @@ export async function getStats(prisma: PrismaClient, datasource: Datasource, log
|
||||
mimetype: true,
|
||||
},
|
||||
});
|
||||
logger.debug(`types count: ${JSON.stringify(typesCount)}`);
|
||||
llogger.debug(`types count: ${JSON.stringify(typesCount)}`);
|
||||
const types_count = [];
|
||||
for (let i = 0, L = typesCount.length; i !== L; ++i)
|
||||
types_count.push({
|
||||
@@ -140,7 +141,7 @@ export async function getStats(prisma: PrismaClient, datasource: Datasource, log
|
||||
count: typesCount[i]._count.mimetype,
|
||||
});
|
||||
|
||||
logger.debug(`types count: ${JSON.stringify(types_count)}`);
|
||||
llogger.debug(`types count: ${JSON.stringify(types_count)}`);
|
||||
|
||||
return {
|
||||
size: bytesToHuman(size),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type File, PrismaClient, type Thumbnail } from '@prisma/client';
|
||||
import { spawn } from 'child_process';
|
||||
import { type ChildProcess, spawn } from 'child_process';
|
||||
import ffmpeg from 'ffmpeg-static';
|
||||
import { createWriteStream } from 'fs';
|
||||
import { rm } from 'fs/promises';
|
||||
@@ -25,26 +25,91 @@ if (isMainThread) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function loadThumbnail(path) {
|
||||
const args = ['-i', path, '-frames:v', '1', '-f', 'mjpeg', 'pipe:1'];
|
||||
async function getDuration(path): Promise<number> {
|
||||
const args = ['-hide_banner', '-nostdin', '-i', path, '-f', 'null', 'pipe:1'];
|
||||
const lengthMatch = new RegExp(/time=(?<time>(\d{2,}:){2}\d{2}\.\d{2})/);
|
||||
|
||||
const child = spawn(ffmpeg, args, { stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
const child = spawn(ffmpeg, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
|
||||
const data: Buffer = await new Promise((resolve, reject) => {
|
||||
const data: string = await new Promise((resolve, reject) => {
|
||||
const buffers: string[] = [];
|
||||
|
||||
child.stderr.on('data', (d) => child.stdout.emit('data', d));
|
||||
|
||||
child.stdout.on('data', (d) => buffers.push(d.toString()));
|
||||
|
||||
child.once('error', (...a) => {
|
||||
console.log(a);
|
||||
|
||||
reject();
|
||||
});
|
||||
child.once('close', (code) => {
|
||||
if (code !== 0) {
|
||||
const msg = buffers.join('').trim().split('\n');
|
||||
|
||||
logger.debug(`cmd: ${ffmpeg} ${args.join(' ')}\n${msg.join('\n')}`);
|
||||
logger.error(`child exited with code ${code}: ${msg[msg.length - 1]}`);
|
||||
|
||||
if (msg[msg.length - 1].includes('does not contain any stream')) {
|
||||
// mismatched mimetype, for example a video/ogg (.ogg) file with no video stream since
|
||||
// for this specific case just set the mimetype to audio/ogg
|
||||
// the method will return an empty buffer since there is no video stream
|
||||
|
||||
logger.error(`file ${path} does not contain any video stream, it is probably an audio file`);
|
||||
resolve('ow');
|
||||
}
|
||||
|
||||
reject(new Error(`child exited with code ${code} ffmpeg output:\n${msg.join('\n')}`));
|
||||
} else {
|
||||
const trimBuffs: string[] = buffers.filter((val) => lengthMatch.exec(val));
|
||||
resolve(trimBuffs[trimBuffs.length - 1].split('\n')[0]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const matchLength = lengthMatch.exec(data);
|
||||
if (!matchLength) return 0;
|
||||
|
||||
const timeArr = matchLength.groups.time.split(':');
|
||||
|
||||
return parseFloat(timeArr.reduce((prev, curr) => (parseFloat(prev) * 60 + parseFloat(curr)).toString()));
|
||||
}
|
||||
|
||||
async function handleChild(child: ChildProcess, path: string, args: string[]): Promise<Buffer> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const buffers = [];
|
||||
const errorBuffers = [];
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
errorBuffers.push(chunk);
|
||||
});
|
||||
|
||||
child.stdout.on('data', (chunk) => {
|
||||
buffers.push(chunk);
|
||||
});
|
||||
|
||||
child.once('error', reject);
|
||||
child.once('error', (...a) => {
|
||||
console.log(a);
|
||||
|
||||
reject();
|
||||
});
|
||||
child.once('close', (code) => {
|
||||
if (code !== 0) {
|
||||
const msg = buffers.join('').trim();
|
||||
logger.debug(`cmd: ${ffmpeg} ${args.join(' ')}`);
|
||||
logger.error(`while ${path} child exited with code ${code}: ${msg}`);
|
||||
const msg = errorBuffers.join('').trim().split('\n');
|
||||
|
||||
reject(new Error(`child exited with code ${code}`));
|
||||
logger.debug(`cmd: ${ffmpeg} ${args.join(' ')}\n${msg.join('\n')}`);
|
||||
logger.error(`child exited with code ${code}: ${msg[msg.length - 1]}`);
|
||||
|
||||
if (msg[msg.length - 1].includes('does not contain any stream')) {
|
||||
// mismatched mimetype, for example a video/ogg (.ogg) file with no video stream since
|
||||
// for this specific case just set the mimetype to audio/ogg
|
||||
// the method will return an empty buffer since there is no video stream
|
||||
|
||||
logger.error(`file ${path} does not contain any video stream, it is probably an audio file`);
|
||||
resolve(Buffer.alloc(0));
|
||||
}
|
||||
|
||||
reject(new Error(`child exited with code ${code} ffmpeg output:\n${msg.join('\n')}`));
|
||||
} else {
|
||||
const buffer = Buffer.allocUnsafe(buffers.reduce((acc, val) => acc + val.length, 0));
|
||||
|
||||
@@ -59,6 +124,47 @@ async function loadThumbnail(path) {
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadGifThumbnail(path): Promise<Buffer> {
|
||||
if (!config.features.gif_thumbnails) return;
|
||||
const duration = await getDuration(path);
|
||||
|
||||
if (duration <= 5) return;
|
||||
|
||||
let start: number = duration;
|
||||
const re = () => (start = Math.floor(Math.random() * duration * 100) / 100);
|
||||
while (start + 3 >= duration) re();
|
||||
|
||||
const args = [
|
||||
'-i',
|
||||
path,
|
||||
'-ss',
|
||||
start.toString(),
|
||||
'-t',
|
||||
'3',
|
||||
'-vf',
|
||||
'fps=10,scale=320:-1:flags=lanczos,split[s0][s1];[s0]palettegen=stats_mode=diff[p];[s1][p]paletteuse',
|
||||
'-loop',
|
||||
'0',
|
||||
'-f',
|
||||
'gif',
|
||||
'pipe:1',
|
||||
];
|
||||
|
||||
const child = spawn(ffmpeg, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
|
||||
const data: Buffer = await handleChild(child, path, args);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadThumbnail(path): Promise<Buffer> {
|
||||
const args = ['-i', path, '-frames:v', '1', '-f', 'mjpeg', 'pipe:1'];
|
||||
|
||||
const child = spawn(ffmpeg, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
|
||||
const data: Buffer = await handleChild(child, path, args);
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -67,7 +173,10 @@ async function loadFileTmp(file: File) {
|
||||
const stream = await datasource.get(file.name);
|
||||
|
||||
// pipe to tmp file
|
||||
const tmpFile = join(config.core.temp_directory, `zipline_thumb_${file.id}_${file.id}.tmp`);
|
||||
const tmpFile = join(
|
||||
config.core.temp_directory,
|
||||
`zipline_thumb_${file.id}_${file.mimetype.replace('/', '_')}.tmp`,
|
||||
);
|
||||
const fileWriteStream = createWriteStream(tmpFile);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
@@ -86,18 +195,39 @@ async function start() {
|
||||
const file = videos[i];
|
||||
if (!file.mimetype.startsWith('video/')) {
|
||||
logger.info('file is not a video');
|
||||
process.exit(0);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (file.thumbnail) {
|
||||
logger.info('thumbnail already exists');
|
||||
process.exit(0);
|
||||
continue;
|
||||
}
|
||||
|
||||
const tmpFile = await loadFileTmp(file);
|
||||
logger.debug(`loaded file to tmp: ${tmpFile}`);
|
||||
const thumbnail = await loadThumbnail(tmpFile);
|
||||
logger.debug(`loaded thumbnail: ${thumbnail.length} bytes mjpeg`);
|
||||
let useStill = false,
|
||||
thumbnail: Buffer = await loadGifThumbnail(tmpFile);
|
||||
if (!thumbnail) {
|
||||
useStill = true;
|
||||
thumbnail = await loadThumbnail(tmpFile);
|
||||
}
|
||||
logger.debug(`loaded thumbnail: ${thumbnail.length} bytes ${useStill ? 'mjpeg' : 'gif'}`);
|
||||
|
||||
if (thumbnail.length === 0 && file.mimetype === 'video/ogg') {
|
||||
logger.info('file might be an audio file, setting mimetype to audio/ogg to avoid future errors');
|
||||
await prisma.file.update({
|
||||
where: {
|
||||
id: file.id,
|
||||
},
|
||||
data: {
|
||||
mimetype: 'audio/ogg',
|
||||
},
|
||||
});
|
||||
|
||||
await rm(tmpFile);
|
||||
await prisma.$disconnect();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const { thumbnail: thumb } = await prisma.file.update({
|
||||
where: {
|
||||
@@ -106,7 +236,7 @@ async function start() {
|
||||
data: {
|
||||
thumbnail: {
|
||||
create: {
|
||||
name: `.thumb-${file.id}.jpg`,
|
||||
name: `.thumb-${file.id}.${useStill ? 'jpg' : 'gif'}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -115,7 +245,7 @@ async function start() {
|
||||
},
|
||||
});
|
||||
|
||||
await datasource.save(thumb.name, thumbnail);
|
||||
await datasource.save(thumb.name, thumbnail, { type: useStill ? 'image/jpeg' : 'image/gif' });
|
||||
|
||||
logger.info(`thumbnail saved - ${thumb.name}`);
|
||||
logger.debug(`thumbnail ${JSON.stringify(thumb)}`);
|
||||
|
||||
@@ -121,7 +121,9 @@ async function start() {
|
||||
await fd.close();
|
||||
} else {
|
||||
logger.debug('writing file to datasource');
|
||||
await datasource.save(file.filename, Buffer.from(fd as Uint8Array));
|
||||
await datasource.save(file.filename, Buffer.from(fd as Uint8Array), {
|
||||
type: file.mimetype ?? 'application/octet-stream',
|
||||
});
|
||||
}
|
||||
|
||||
const final = await prisma.incompleteFile.update({
|
||||
|
||||
Reference in New Issue
Block a user