mirror of
https://github.com/diced/zipline.git
synced 2025-12-10 14:50:47 -08:00
Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
faf5098357 | ||
|
|
c4066fc851 | ||
|
|
22633b8601 | ||
|
|
b873f99d46 | ||
|
|
a60d9c58b8 | ||
|
|
a2562c5ea2 | ||
|
|
1c674d3d9f | ||
|
|
fb32e9f38e | ||
|
|
6babf73e07 | ||
|
|
d0eb442fdf | ||
|
|
d3cb9118ce | ||
|
|
7ec6d566b8 | ||
|
|
d695211030 | ||
|
|
907e43c860 | ||
|
|
d9fd771233 | ||
|
|
61c87aecdc | ||
|
|
5ef6c7a6de | ||
|
|
0e7dde2500 | ||
|
|
3ab3202b92 | ||
|
|
b02adca6db | ||
|
|
4a254c55c8 | ||
|
|
226d946ec8 | ||
|
|
a1bc2db336 | ||
|
|
86277a091c | ||
|
|
30dbfdaac5 | ||
|
|
5c424a2c6d | ||
|
|
f40d65a9f7 | ||
|
|
a2c085719a | ||
|
|
60d7b22dca | ||
|
|
d111b0811f | ||
|
|
b46e7b8ba2 | ||
|
|
39a8d52353 | ||
|
|
ec09458ad3 | ||
|
|
a7ad58b196 | ||
|
|
1ddd351242 | ||
|
|
24b06c76fb | ||
|
|
0a34b0cc21 | ||
|
|
ce26a414ac | ||
|
|
f71aab2cde | ||
|
|
5f76e9d383 | ||
|
|
4a46f15833 | ||
|
|
d6ce64ae21 | ||
|
|
7cbf828f3b | ||
|
|
3ff215366a | ||
|
|
d238e24f62 | ||
|
|
fd2746c2d0 | ||
|
|
61b2eff6a4 | ||
|
|
89a28bf50b | ||
|
|
5ded128263 | ||
|
|
eedeb89c7d | ||
|
|
bf40fa9cd2 | ||
|
|
bc58c1b56e | ||
|
|
c57a6e1700 | ||
|
|
8649a489d8 | ||
|
|
40f29907c7 | ||
|
|
34005ece43 | ||
|
|
8e6fc1e8a3 | ||
|
|
065f44b145 | ||
|
|
e5a07f568d |
@@ -1,46 +1,50 @@
|
||||
# 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 comment out the other datasources
|
||||
# if using s3/supabase make sure to uncomment or comment out the correct lines needed.
|
||||
|
||||
CORE_HTTPS=true
|
||||
CORE_RETURN_HTTPS=true
|
||||
CORE_SECRET="changethis"
|
||||
CORE_HOST=0.0.0.0
|
||||
CORE_PORT=3000
|
||||
CORE_DATABASE_URL="postgres://postgres:postgres@localhost/zip10"
|
||||
CORE_LOGGER=false
|
||||
CORE_STATS_INTERVAL=1800
|
||||
CORE_INVITES_INTERVAL=1800
|
||||
CORE_THUMBNAILS_INTERVAL=600
|
||||
|
||||
# default
|
||||
DATASOURCE_TYPE=local
|
||||
DATASOURCE_LOCAL_DIRECTORY=./uploads
|
||||
|
||||
# or you can choose to use s3
|
||||
DATASOURCE_TYPE=s3
|
||||
DATASOURCE_S3_ACCESS_KEY_ID=key
|
||||
DATASOURCE_S3_SECRET_ACCESS_KEY=secret
|
||||
DATASOURCE_S3_BUCKET=bucket
|
||||
DATASOURCE_S3_ENDPOINT=s3.amazonaws.com
|
||||
DATASOURCE_S3_REGION=us-west-2
|
||||
DATASOURCE_S3_FORCE_S3_PATH=false
|
||||
DATASOURCE_S3_USE_SSL=false
|
||||
# DATASOURCE_TYPE=s3
|
||||
# DATASOURCE_S3_ACCESS_KEY_ID=key
|
||||
# DATASOURCE_S3_SECRET_ACCESS_KEY=secret
|
||||
# DATASOURCE_S3_BUCKET=bucket
|
||||
# DATASOURCE_S3_ENDPOINT=s3.amazonaws.com
|
||||
# DATASOURCE_S3_REGION=us-west-2
|
||||
# DATASOURCE_S3_FORCE_S3_PATH=false
|
||||
# DATASOURCE_S3_USE_SSL=false
|
||||
|
||||
# or supabase
|
||||
DATASOURCE_TYPE=supabase
|
||||
DATASOURCE_SUPABASE_KEY=xxx
|
||||
# DATASOURCE_TYPE=supabase
|
||||
# DATASOURCE_SUPABASE_KEY=xxx
|
||||
# remember: no leading slash
|
||||
DATASOURCE_SUPABASE_URL=https://something.supabase.co
|
||||
DATASOURCE_SUPABASE_BUCKET=zipline
|
||||
# DATASOURCE_SUPABASE_URL=https://something.supabase.co
|
||||
# DATASOURCE_SUPABASE_BUCKET=zipline
|
||||
|
||||
UPLOADER_DEFAULT_FORMAT=RANDOM
|
||||
UPLOADER_ROUTE=/u
|
||||
UPLOADER_LENGTH=6
|
||||
UPLOADER_ADMIN_LIMIT=104900000
|
||||
UPLOADER_USER_LIMIT=104900000
|
||||
UPLOADER_DISABLED_EXTENSIONS=someext
|
||||
UPLOADER_DISABLED_EXTENSIONS=someext,anotherext
|
||||
|
||||
URLS_ROUTE=/go
|
||||
URLS_LENGTH=6
|
||||
|
||||
RATELIMIT_USER = 5
|
||||
RATELIMIT_ADMIN = 3
|
||||
RATELIMIT_USER=5
|
||||
RATELIMIT_ADMIN=3
|
||||
|
||||
# for more variables checkout the docs
|
||||
3
.github/FUNDING.yml
vendored
Normal file
3
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: diced
|
||||
4
.github/ISSUE_TEMPLATE/bug.yml
vendored
4
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -15,10 +15,10 @@ body:
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: What version of Zipline are you using?
|
||||
description: What version (or docker image) of Zipline are you using?
|
||||
options:
|
||||
- latest (ghcr.io/diced/zipline or ghcr.io/diced/zipline:latest)
|
||||
- upstream (ghcr.io/diced/zipline:trunk)
|
||||
- latest (ghcr.io/diced/zipline:latest)
|
||||
- other (provide version in additional info)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
4
.github/ISSUE_TEMPLATE/config.yml
vendored
4
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,11 +1,11 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Feature Request
|
||||
url: https://github.com/diced/zipline/discussions/new?category=ideas&title=Your%20breif%20description%20here&labels=feature
|
||||
url: https://github.com/diced/zipline/discussions/new?category=ideas&title=Your%20brief%20description%20here&labels=feature
|
||||
about: Ask for a new feature
|
||||
- name: Zipline Discord
|
||||
url: https://discord.gg/EAhCRfGxCF
|
||||
about: Ask for help with anything related to Zipline!
|
||||
- name: Zipline Docs
|
||||
url: https://zipline.diced.tech
|
||||
url: https://zipline.diced.vercel.app
|
||||
about: Maybe take a look a the docs?
|
||||
|
||||
17
.github/workflows/milestone.yml
vendored
17
.github/workflows/milestone.yml
vendored
@@ -1,24 +1,31 @@
|
||||
name: 'Issue/PR Milestones'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
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@v3
|
||||
- uses: actions/github-script@v6
|
||||
with:
|
||||
github-token: ${{secrets.GITHUB_TOKEN}}
|
||||
script: |
|
||||
const milestone = 2
|
||||
github.issues.update({
|
||||
const milestone = 3
|
||||
github.rest.issues.update({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
milestone
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,7 +14,7 @@ Create an issue on GitHub, please include the following (if one of them is not a
|
||||
|
||||
Create an discussion on GitHub, please include the following:
|
||||
|
||||
- Breif explanation of the feature in the title (very breif please)
|
||||
- Brief explanation of the feature in the title (very brief please)
|
||||
- How it would work (detailed, but optional)
|
||||
|
||||
## Pull Requests (contributions to the codebase)
|
||||
|
||||
29
Dockerfile
29
Dockerfile
@@ -9,14 +9,6 @@ WORKDIR /zipline
|
||||
|
||||
# Copy the necessary files from the project
|
||||
COPY prisma ./prisma
|
||||
COPY src ./src
|
||||
COPY next.config.js ./next.config.js
|
||||
COPY tsup.config.ts ./tsup.config.ts
|
||||
COPY tsconfig.json ./tsconfig.json
|
||||
COPY mimes.json ./mimes.json
|
||||
COPY public ./public
|
||||
|
||||
FROM base as builder
|
||||
|
||||
COPY .yarn ./.yarn
|
||||
COPY package*.json ./
|
||||
@@ -41,11 +33,21 @@ RUN cp -RL node_modules /tmp/node_modules
|
||||
# Install the dependencies
|
||||
RUN yarn install --immutable
|
||||
|
||||
FROM base as builder
|
||||
|
||||
COPY src ./src
|
||||
COPY next.config.js ./next.config.js
|
||||
COPY tsup.config.ts ./tsup.config.ts
|
||||
COPY tsconfig.json ./tsconfig.json
|
||||
COPY mimes.json ./mimes.json
|
||||
COPY public ./public
|
||||
|
||||
# Run the build
|
||||
RUN yarn build
|
||||
|
||||
# Use Alpine Linux as the final image
|
||||
FROM base
|
||||
|
||||
# Install the necessary packages
|
||||
RUN apk add --no-cache perl procps tini
|
||||
|
||||
@@ -63,13 +65,18 @@ COPY --from=builder /zipline/dist ./dist
|
||||
COPY --from=builder /zipline/.next ./.next
|
||||
COPY --from=builder /zipline/package.json ./package.json
|
||||
|
||||
COPY --from=builder /zipline/mimes.json ./mimes.json
|
||||
COPY --from=builder /zipline/next.config.js ./next.config.js
|
||||
COPY --from=builder /zipline/public ./public
|
||||
|
||||
COPY --from=builder /zipline/node_modules ./node_modules
|
||||
COPY --from=builder /zipline/node_modules/.prisma/client ./node_modules/.prisma/client
|
||||
COPY --from=builder /zipline/node_modules/@prisma/client ./node_modules/@prisma/client
|
||||
|
||||
|
||||
# Copy Startup Script
|
||||
COPY docker-entrypoint.sh /zipline
|
||||
|
||||
# Make Startup Script Executable
|
||||
RUN chmod a+x /zipline/docker-entrypoint.sh
|
||||
RUN chmod a+x /zipline/docker-entrypoint.sh && rm -rf /zipline/src
|
||||
|
||||
# Set the entrypoint to the startup script
|
||||
ENTRYPOINT ["tini", "--", "/zipline/docker-entrypoint.sh"]
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 dicedtomato
|
||||
Copyright (c) 2023 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
|
||||
|
||||
32
README.md
32
README.md
@@ -35,17 +35,9 @@ A ShareX/file upload server that is easy to use, packed with features, and with
|
||||
- User invites
|
||||
- File Chunking (for large files)
|
||||
- File deletion once it reaches a certain amount of views
|
||||
- Automatic video thumbnail generation
|
||||
- Easy setup instructions on [docs](https://zipl.vercel.app/) (One command install `docker compose up -d`)
|
||||
|
||||
<details>
|
||||
<summary>View upstream documentation</summary>
|
||||
|
||||
The website below provides documentation for more up-to-date features with the upstream branch. The normal documentation is for the latest release and is not updated unless a new release is made.
|
||||
|
||||
[https://trunk.zipline.diced.tech/](https://trunk.zipline.diced.tech/)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h2>Screenshots (click)</h2></summary>
|
||||
|
||||
@@ -76,17 +68,18 @@ Ways you could generate the string could be from a password managers generator,
|
||||
|
||||
## Building & running from source
|
||||
|
||||
This section requires [nodejs](https://nodejs.org), [yarn](https://yarnpkg.com/) or [npm](https://npmjs.com).
|
||||
This section requires [nodejs](https://nodejs.org), [yarn](https://yarnpkg.com/).
|
||||
|
||||
It is recommended to not use npm, as it can cause issues with the build process.
|
||||
|
||||
Before you run `yarn build`, you might want to configure Zipline, as when building from source Zipline will need to read some sort of configuration. The only two variables needed are `CORE_SECRET` and `CORE_DATABASE_URL`.
|
||||
|
||||
```shell
|
||||
git clone https://github.com/diced/zipline
|
||||
cd zipline
|
||||
|
||||
# npm install
|
||||
yarn install
|
||||
# npm run build
|
||||
yarn build
|
||||
# npm start
|
||||
yarn start
|
||||
```
|
||||
|
||||
@@ -119,7 +112,7 @@ This section requires [ShareX](https://www.getsharex.com/).
|
||||
|
||||
After navigating to Zipline, click on the top right corner where it says your username and click Manage Account. Scroll down to see "ShareX Config", select the one you would prefer using. After this you can import the .sxcu into sharex. [More information here](https://zipl.vercel.app/docs/guides/uploaders/sharex)
|
||||
|
||||
# Flameshot (Linux)
|
||||
# Flameshot (Linux(Xorg/Wayland) and macOS)
|
||||
|
||||
This section requires [Flameshot](https://www.flameshot.org/), [jq](https://stedolan.github.io/jq/), and [xsel](https://github.com/kfish/xsel).
|
||||
|
||||
@@ -134,6 +127,13 @@ After this, replace the `xsel -ib` with `wl-copy` in the script.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Mac instructions</summary>
|
||||
|
||||
If using macOS, you can replace the `xsel -ib` with `pbcopy` in the script.
|
||||
|
||||
</details>
|
||||
|
||||
You can either use the script below, or generate one directly from Zipline (just like how you can generate a ShareX config).
|
||||
To upload files using flameshot we will use a script. Replace $TOKEN and $HOST with your own values, you probably know how to do this if you use linux.
|
||||
|
||||
@@ -166,3 +166,7 @@ Create a discussion on GitHub, please include the following:
|
||||
## Pull Requests (contributions to the codebase)
|
||||
|
||||
Create a pull request on GitHub. If your PR does not pass the action checks, then please fix the errors. If your PR was submitted before a release, and I have pushed a new release, please make sure to update your PR to reflect any changes, usually this is handled by GitHub.
|
||||
|
||||
# Documentation
|
||||
|
||||
Documentation source code is located in [diced/zipline-docs](https://github.com/diced/zipline-docs), and can be accessed [here](https://zipl.vercel.app).
|
||||
@@ -23,8 +23,8 @@ services:
|
||||
env_file:
|
||||
- .env.local
|
||||
volumes:
|
||||
- '$PWD/uploads:/zipline/uploads'
|
||||
- '$PWD/public:/zipline/public'
|
||||
- './uploads:/zipline/uploads'
|
||||
- './public:/zipline/public'
|
||||
depends_on:
|
||||
- 'postgres'
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ services:
|
||||
- CORE_LOGGER=true
|
||||
volumes:
|
||||
- './uploads:/zipline/uploads'
|
||||
- '$PWD/public:/zipline/public'
|
||||
- './public:/zipline/public'
|
||||
depends_on:
|
||||
- 'postgres'
|
||||
|
||||
|
||||
@@ -2,4 +2,6 @@
|
||||
|
||||
set -e
|
||||
|
||||
unset ZIPLINE_DOCKER_BUILD
|
||||
|
||||
node --enable-source-maps dist/index.js
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "zipline",
|
||||
"version": "3.7.0",
|
||||
"version": "3.7.3",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "npm-run-all build:server dev:run",
|
||||
@@ -24,7 +24,8 @@
|
||||
"scripts:list-users": "node --enable-source-maps dist/scripts/list-users",
|
||||
"scripts:set-user": "node --enable-source-maps dist/scripts/set-user",
|
||||
"scripts:clear-zero-byte": "node --enable-source-maps dist/scripts/clear-zero-byte",
|
||||
"scripts:query-size": "node --enable-source-maps dist/scripts/query-size"
|
||||
"scripts:query-size": "node --enable-source-maps dist/scripts/query-size",
|
||||
"scripts:clear-temp": "node --enable-source-maps dist/scripts/clear-temp"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.10.6",
|
||||
@@ -53,6 +54,7 @@
|
||||
"fastify": "^4.15.0",
|
||||
"fastify-plugin": "^4.5.0",
|
||||
"fflate": "^0.7.4",
|
||||
"ffmpeg-static": "^5.1.0",
|
||||
"find-my-way": "^7.6.0",
|
||||
"katex": "^0.16.4",
|
||||
"mantine-datatable": "^2.2.6",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ProcessingStatus" AS ENUM ('PENDING', 'PROCESSING', 'COMPLETE');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "IncompleteFile" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"status" "ProcessingStatus" NOT NULL,
|
||||
"chunks" INTEGER NOT NULL,
|
||||
"chunksComplete" INTEGER NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"data" JSONB NOT NULL,
|
||||
|
||||
CONSTRAINT "IncompleteFile_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "IncompleteFile" ADD CONSTRAINT "IncompleteFile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
53
prisma/migrations/20230405024416_user_uuid/migration.sql
Normal file
53
prisma/migrations/20230405024416_user_uuid/migration.sql
Normal file
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[uuid]` on the table `User` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- PRISMA GENERATED BELOW
|
||||
-- -- DropForeignKey
|
||||
-- ALTER TABLE "OAuth" DROP CONSTRAINT "OAuth_userId_fkey";
|
||||
--
|
||||
-- -- AlterTable
|
||||
-- ALTER TABLE "OAuth" ALTER COLUMN "userId" SET DATA TYPE TEXT;
|
||||
--
|
||||
-- -- AlterTable
|
||||
-- ALTER TABLE "User" ADD COLUMN "uuid" UUID NOT NULL DEFAULT gen_random_uuid();
|
||||
--
|
||||
-- -- CreateIndex
|
||||
-- CREATE UNIQUE INDEX "User_uuid_key" ON "User"("uuid");
|
||||
--
|
||||
-- -- AddForeignKey
|
||||
-- ALTER TABLE "OAuth" ADD CONSTRAINT "OAuth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("uuid") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- User made changes below
|
||||
|
||||
-- Rename old foreign key
|
||||
ALTER TABLE "OAuth" RENAME CONSTRAINT "OAuth_userId_fkey" TO "OAuth_userId_old_fkey";
|
||||
|
||||
-- Rename old column
|
||||
ALTER TABLE "OAuth" RENAME COLUMN "userId" TO "userId_old";
|
||||
|
||||
-- Add new column
|
||||
ALTER TABLE "OAuth" ADD COLUMN "userId" UUID;
|
||||
|
||||
-- Add user uuid
|
||||
ALTER TABLE "User" ADD COLUMN "uuid" UUID NOT NULL DEFAULT gen_random_uuid();
|
||||
|
||||
-- Update table "OAuth" with uuid
|
||||
UPDATE "OAuth" SET "userId" = "User"."uuid" FROM "User" WHERE "OAuth"."userId_old" = "User"."id";
|
||||
|
||||
-- Alter table "OAuth" to make "userId" required
|
||||
ALTER TABLE "OAuth" ALTER COLUMN "userId" SET NOT NULL;
|
||||
|
||||
-- Create index
|
||||
CREATE UNIQUE INDEX "User_uuid_key" ON "User"("uuid");
|
||||
|
||||
-- Add new foreign key
|
||||
ALTER TABLE "OAuth" ADD CONSTRAINT "OAuth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("uuid") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- Drop old foreign key
|
||||
ALTER TABLE "OAuth" DROP CONSTRAINT "OAuth_userId_old_fkey";
|
||||
|
||||
-- Drop old column
|
||||
ALTER TABLE "OAuth" DROP COLUMN "userId_old";
|
||||
16
prisma/migrations/20230523025656_thumbnails/migration.sql
Normal file
16
prisma/migrations/20230523025656_thumbnails/migration.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Thumbnail" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"name" TEXT NOT NULL,
|
||||
"fileId" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "Thumbnail_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Thumbnail_fileId_key" ON "Thumbnail"("fileId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Thumbnail" ADD CONSTRAINT "Thumbnail_fileId_fkey" FOREIGN KEY ("fileId") REFERENCES "File"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -8,23 +8,25 @@ generator client {
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
username String
|
||||
password String?
|
||||
avatar String?
|
||||
token String
|
||||
administrator Boolean @default(false)
|
||||
superAdmin Boolean @default(false)
|
||||
systemTheme String @default("system")
|
||||
embed Json @default("{}")
|
||||
ratelimit DateTime?
|
||||
totpSecret String?
|
||||
domains String[]
|
||||
oauth OAuth[]
|
||||
files File[]
|
||||
urls Url[]
|
||||
Invite Invite[]
|
||||
Folder Folder[]
|
||||
id Int @id @default(autoincrement())
|
||||
uuid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
username String
|
||||
password String?
|
||||
avatar String?
|
||||
token String
|
||||
administrator Boolean @default(false)
|
||||
superAdmin Boolean @default(false)
|
||||
systemTheme String @default("system")
|
||||
embed Json @default("{}")
|
||||
ratelimit DateTime?
|
||||
totpSecret String?
|
||||
domains String[]
|
||||
oauth OAuth[]
|
||||
files File[]
|
||||
urls Url[]
|
||||
Invite Invite[]
|
||||
Folder Folder[]
|
||||
IncompleteFile IncompleteFile[]
|
||||
}
|
||||
|
||||
model Folder {
|
||||
@@ -60,6 +62,17 @@ model File {
|
||||
|
||||
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
|
||||
folderId Int?
|
||||
|
||||
thumbnail Thumbnail?
|
||||
}
|
||||
|
||||
model Thumbnail {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now())
|
||||
name String
|
||||
|
||||
fileId Int @unique
|
||||
file File @relation(fields: [fileId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model InvisibleFile {
|
||||
@@ -111,8 +124,8 @@ model Invite {
|
||||
model OAuth {
|
||||
id Int @id @default(autoincrement())
|
||||
provider OauthProviders
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [uuid], onDelete: Cascade)
|
||||
userId String @db.Uuid
|
||||
username String
|
||||
oauthId String?
|
||||
token String
|
||||
@@ -126,3 +139,23 @@ enum OauthProviders {
|
||||
GITHUB
|
||||
GOOGLE
|
||||
}
|
||||
|
||||
model IncompleteFile {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
status ProcessingStatus
|
||||
chunks Int
|
||||
chunksComplete Int
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId Int
|
||||
|
||||
data Json
|
||||
}
|
||||
|
||||
enum ProcessingStatus {
|
||||
PENDING
|
||||
PROCESSING
|
||||
COMPLETE
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ export default function FileModal({
|
||||
reducedActions = false,
|
||||
exifEnabled,
|
||||
compress,
|
||||
otherUser = false,
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
@@ -58,6 +59,7 @@ export default function FileModal({
|
||||
reducedActions?: boolean;
|
||||
exifEnabled?: boolean;
|
||||
compress: boolean;
|
||||
otherUser: boolean;
|
||||
}) {
|
||||
const deleteFile = useFileDelete();
|
||||
const favoriteFile = useFileFavorite();
|
||||
@@ -95,18 +97,12 @@ export default function FileModal({
|
||||
const handleCopy = () => {
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}${file.url}`);
|
||||
setOpen(false);
|
||||
if (!navigator.clipboard)
|
||||
showNotification({
|
||||
title: 'Unable to copy to clipboard',
|
||||
message: 'Zipline is unable to copy to clipboard due to security reasons.',
|
||||
color: 'red',
|
||||
});
|
||||
else
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: '',
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
});
|
||||
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: '',
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
});
|
||||
};
|
||||
|
||||
const handleFavorite = async () => {
|
||||
@@ -282,7 +278,7 @@ export default function FileModal({
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{reducedActions ? null : inFolder && !folders.isLoading ? (
|
||||
{reducedActions || otherUser ? null : inFolder && !folders.isLoading ? (
|
||||
<Tooltip
|
||||
label={`Remove from folder "${folders.data.find((f) => f.id === file.folderId)?.name ?? ''}"`}
|
||||
>
|
||||
|
||||
@@ -32,9 +32,10 @@ export default function File({
|
||||
image,
|
||||
disableMediaPreview,
|
||||
exifEnabled,
|
||||
refreshImages,
|
||||
refreshImages = undefined,
|
||||
reducedActions = false,
|
||||
onDash,
|
||||
otherUser = false,
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const deleteFile = useFileDelete();
|
||||
@@ -44,7 +45,7 @@ export default function File({
|
||||
const folders = useFolders();
|
||||
|
||||
const refresh = () => {
|
||||
refreshImages();
|
||||
if (!otherUser) refreshImages();
|
||||
folders.refetch();
|
||||
};
|
||||
|
||||
@@ -59,9 +60,22 @@ export default function File({
|
||||
reducedActions={reducedActions}
|
||||
exifEnabled={exifEnabled}
|
||||
compress={onDash}
|
||||
otherUser={otherUser}
|
||||
/>
|
||||
|
||||
<Card sx={{ maxWidth: '100%', height: '100%' }} shadow='md' onClick={() => setOpen(true)}>
|
||||
<Card
|
||||
sx={{
|
||||
maxWidth: '100%',
|
||||
height: '100%',
|
||||
'&:hover': {
|
||||
filter: 'brightness(0.75)',
|
||||
},
|
||||
transition: 'filter 0.2s ease-in-out',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
shadow='md'
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Card.Section>
|
||||
<LoadingOverlay visible={loading} />
|
||||
<Type
|
||||
|
||||
@@ -4,10 +4,8 @@ import {
|
||||
Box,
|
||||
Burger,
|
||||
Button,
|
||||
Group,
|
||||
Header,
|
||||
Image,
|
||||
Input,
|
||||
MediaQuery,
|
||||
Menu,
|
||||
Navbar,
|
||||
@@ -220,21 +218,14 @@ export default function Layout({ children, props }) {
|
||||
labels: { confirm: 'Copy', cancel: 'Cancel' },
|
||||
onConfirm: async () => {
|
||||
clipboard.copy(token);
|
||||
|
||||
if (!navigator.clipboard)
|
||||
showNotification({
|
||||
title: 'Unable to copy to clipboard',
|
||||
message: (
|
||||
<Text size='sm'>
|
||||
Zipline is unable to copy to clipboard due to security reasons. However, you can still copy
|
||||
the token manually.
|
||||
<br />
|
||||
<Group position='left' spacing='sm'>
|
||||
<Text>Your token is:</Text>
|
||||
<Input size='sm' onFocus={(e) => e.target.select()} type='text' value={token} />
|
||||
</Group>
|
||||
</Text>
|
||||
),
|
||||
title: 'Unable to copy token',
|
||||
message:
|
||||
"Zipline couldn't copy to your clipboard. Please copy the token manually from the settings page.",
|
||||
color: 'red',
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
});
|
||||
else
|
||||
showNotification({
|
||||
@@ -358,7 +349,11 @@ export default function Layout({ children, props }) {
|
||||
<Menu.Target>
|
||||
<Button
|
||||
leftIcon={
|
||||
avatar ? <Image src={avatar} height={32} radius='md' /> : <IconUserCog size='1rem' />
|
||||
avatar ? (
|
||||
<Image src={avatar} height={32} width={32} fit='cover' radius='md' />
|
||||
) : (
|
||||
<IconUserCog size='1rem' />
|
||||
)
|
||||
}
|
||||
variant='subtle'
|
||||
color='gray'
|
||||
|
||||
@@ -53,6 +53,35 @@ function Placeholder({ text, Icon, ...props }) {
|
||||
);
|
||||
}
|
||||
|
||||
function VideoThumbnailPlaceholder({ file, mediaPreview, ...props }) {
|
||||
if (!file.thumbnail || !mediaPreview)
|
||||
return <Placeholder Icon={IconPlayerPlay} text={`Click to view video (${file.name})`} {...props} />;
|
||||
|
||||
return (
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<Image
|
||||
src={file.thumbnail}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Center
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
height: '100%',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
>
|
||||
<IconPlayerPlay size={48} />
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Type({ file, popup = false, disableMediaPreview, ...props }) {
|
||||
const type =
|
||||
(file.type ?? file.mimetype) === ''
|
||||
@@ -159,7 +188,8 @@ export default function Type({ file, popup = false, disableMediaPreview, ...prop
|
||||
)
|
||||
) : media ? (
|
||||
{
|
||||
video: <Placeholder Icon={IconPlayerPlay} text={`Click to view video (${file.name})`} {...props} />,
|
||||
// video: <Placeholder Icon={IconPlayerPlay} text={`Click to view video (${file.name})`} {...props} />,
|
||||
video: <VideoThumbnailPlaceholder file={file} mediaPreview={!disableMediaPreview} />,
|
||||
image: (
|
||||
<Image
|
||||
placeholder={<PlaceholderContent Icon={IconPhotoCancel} text={'Image failed to load...'} />}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import FileModal from 'components/File/FileModal';
|
||||
import MutedText from 'components/MutedText';
|
||||
import useFetch from 'lib/hooks/useFetch';
|
||||
import { usePaginatedFiles, useRecent } from 'lib/queries/files';
|
||||
import { PaginatedFilesOptions, usePaginatedFiles, useRecent } from 'lib/queries/files';
|
||||
import { useStats } from 'lib/queries/stats';
|
||||
import { userSelector } from 'lib/recoil/user';
|
||||
import { bytesToHuman } from 'lib/utils/bytes';
|
||||
@@ -45,32 +45,24 @@ export default function Dashboard({ disableMediaPreview, exifEnabled, compress }
|
||||
})();
|
||||
}, [page]);
|
||||
|
||||
const files = usePaginatedFiles(page, 'none');
|
||||
|
||||
// sorting
|
||||
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
|
||||
columnAccessor: 'date',
|
||||
columnAccessor: 'createdAt',
|
||||
direction: 'asc',
|
||||
});
|
||||
const [records, setRecords] = useState(files.data);
|
||||
|
||||
useEffect(() => {
|
||||
setRecords(files.data);
|
||||
}, [files.data]);
|
||||
const files = usePaginatedFiles(page, {
|
||||
filter: 'none',
|
||||
|
||||
useEffect(() => {
|
||||
if (!records || records.length === 0) return;
|
||||
|
||||
const sortedRecords = [...records].sort((a, b) => {
|
||||
if (sortStatus.direction === 'asc') {
|
||||
return a[sortStatus.columnAccessor] > b[sortStatus.columnAccessor] ? 1 : -1;
|
||||
}
|
||||
|
||||
return a[sortStatus.columnAccessor] < b[sortStatus.columnAccessor] ? 1 : -1;
|
||||
});
|
||||
|
||||
setRecords(sortedRecords);
|
||||
}, [sortStatus]);
|
||||
// only query for correct results if there is more than one page
|
||||
// otherwise, querying has no effect
|
||||
...(numFiles > 1
|
||||
? {
|
||||
sortBy: sortStatus.columnAccessor as PaginatedFilesOptions['sortBy'],
|
||||
order: sortStatus.direction,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
// file modal on click
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -106,22 +98,16 @@ export default function Dashboard({ disableMediaPreview, exifEnabled, compress }
|
||||
|
||||
const copyFile = async (file) => {
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}${file.url}`);
|
||||
if (!navigator.clipboard)
|
||||
showNotification({
|
||||
title: 'Unable to copy to clipboard',
|
||||
message: 'Zipline is unable to copy to clipboard due to security reasons.',
|
||||
color: 'red',
|
||||
});
|
||||
else
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: (
|
||||
<a
|
||||
href={`${window.location.protocol}//${window.location.host}${file.url}`}
|
||||
>{`${window.location.protocol}//${window.location.host}${file.url}`}</a>
|
||||
),
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
});
|
||||
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: (
|
||||
<a
|
||||
href={`${window.location.protocol}//${window.location.host}${file.url}`}
|
||||
>{`${window.location.protocol}//${window.location.host}${file.url}`}</a>
|
||||
),
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
});
|
||||
};
|
||||
|
||||
const viewFile = async (file) => {
|
||||
@@ -140,6 +126,7 @@ export default function Dashboard({ disableMediaPreview, exifEnabled, compress }
|
||||
reducedActions={false}
|
||||
exifEnabled={exifEnabled}
|
||||
compress={compress}
|
||||
otherUser={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -209,7 +196,7 @@ export default function Dashboard({ disableMediaPreview, exifEnabled, compress }
|
||||
),
|
||||
},
|
||||
]}
|
||||
records={records ?? []}
|
||||
records={files.data ?? []}
|
||||
fetching={files.isLoading}
|
||||
loaderBackgroundBlur={5}
|
||||
loaderVariant='dots'
|
||||
|
||||
@@ -37,9 +37,17 @@ export default function FilePagation({ disableMediaPreview, exifEnabled, queryPa
|
||||
})();
|
||||
}, [page]);
|
||||
|
||||
const pages = usePaginatedFiles(page, !checked ? 'media' : null);
|
||||
const pages = usePaginatedFiles(page, {
|
||||
filter: !checked ? 'media' : 'none',
|
||||
});
|
||||
|
||||
if (pages.isSuccess && pages.data.length === 0) {
|
||||
if (page > 1 && numPages > 0) {
|
||||
setPage(page - 1);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Center sx={{ flexDirection: 'column' }}>
|
||||
<Group>
|
||||
|
||||
118
src/components/pages/Files/PendingFilesModal.tsx
Normal file
118
src/components/pages/Files/PendingFilesModal.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Button, Modal, Title, Tooltip } from '@mantine/core';
|
||||
import { IconTrash } from '@tabler/icons-react';
|
||||
import AnchorNext from 'components/AnchorNext';
|
||||
import MutedText from 'components/MutedText';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { DataTable } from 'mantine-datatable';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export type PendingFiles = {
|
||||
id: number;
|
||||
createdAt: string;
|
||||
status: string;
|
||||
chunks: number;
|
||||
chunksComplete: number;
|
||||
userId: number;
|
||||
data: {
|
||||
file: {
|
||||
filename: string;
|
||||
mimetype: string;
|
||||
lastchunk: boolean;
|
||||
identifier: string;
|
||||
totalBytes: number;
|
||||
};
|
||||
code?: number;
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default function PendingFilesModal({ open, onClose }) {
|
||||
const [incFiles, setIncFiles] = useState<PendingFiles[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [selectedFiles, setSelectedFiles] = useState<PendingFiles[]>([]);
|
||||
|
||||
async function updateIncFiles() {
|
||||
setLoading(true);
|
||||
|
||||
const files = await useFetch('/api/user/pending');
|
||||
setIncFiles(files);
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function deleteIncFiles() {
|
||||
await useFetch('/api/user/pending', 'DELETE', {
|
||||
id: selectedFiles.map((file) => file.id),
|
||||
});
|
||||
updateIncFiles();
|
||||
setSelectedFiles([]);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
updateIncFiles();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (open) updateIncFiles();
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Modal title={<Title>Pending Files</Title>} size='auto' opened={open} onClose={onClose}>
|
||||
<MutedText size='xs'>Refreshing every 5 seconds...</MutedText>
|
||||
<DataTable
|
||||
withBorder
|
||||
borderRadius='md'
|
||||
highlightOnHover
|
||||
verticalSpacing='sm'
|
||||
minHeight={200}
|
||||
records={incFiles ?? []}
|
||||
columns={[
|
||||
{ accessor: 'id', title: 'ID' },
|
||||
{ accessor: 'createdAt', render: (file) => new Date(file.createdAt).toLocaleString() },
|
||||
{ accessor: 'status', render: (file) => file.status.toLowerCase() },
|
||||
{
|
||||
accessor: 'progress',
|
||||
title: 'Progress',
|
||||
render: (file) => `${file.chunksComplete}/${file.chunks} chunks`,
|
||||
},
|
||||
{
|
||||
accessor: 'message',
|
||||
render: (file) =>
|
||||
file.data.code === 200 ? (
|
||||
<AnchorNext href={file.data.message} target='_blank'>
|
||||
view file
|
||||
</AnchorNext>
|
||||
) : (
|
||||
file.data.message
|
||||
),
|
||||
},
|
||||
]}
|
||||
fetching={loading}
|
||||
loaderBackgroundBlur={5}
|
||||
loaderVariant='dots'
|
||||
onSelectedRecordsChange={setSelectedFiles}
|
||||
selectedRecords={selectedFiles}
|
||||
/>
|
||||
|
||||
{selectedFiles.length ? (
|
||||
<Tooltip label='Clearing pending files will still leave the final file on the server.'>
|
||||
<Button
|
||||
variant='filled'
|
||||
my='md'
|
||||
color='red'
|
||||
onClick={deleteIncFiles}
|
||||
leftIcon={<IconTrash size='1rem' />}
|
||||
fullWidth
|
||||
>
|
||||
Clear {selectedFiles.length} pending file{selectedFiles.length > 1 ? 's' : ''}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,26 @@
|
||||
import { Accordion, ActionIcon, Box, Group, Pagination, SimpleGrid, Title } from '@mantine/core';
|
||||
import { IconFileUpload } from '@tabler/icons-react';
|
||||
import { Accordion, ActionIcon, Box, Group, Pagination, SimpleGrid, Title, Tooltip } from '@mantine/core';
|
||||
import { IconFileUpload, IconPhotoUp } from '@tabler/icons-react';
|
||||
import File from 'components/File';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { usePaginatedFiles } from 'lib/queries/files';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import FilePagation from './FilePagation';
|
||||
import PendingFilesModal from './PendingFilesModal';
|
||||
import { showNonMediaSelector } from 'lib/recoil/settings';
|
||||
import { useRecoilState } from 'recoil';
|
||||
|
||||
export default function Files({ disableMediaPreview, exifEnabled, queryPage, compress }) {
|
||||
const [checked] = useRecoilState(showNonMediaSelector);
|
||||
|
||||
const [favoritePage, setFavoritePage] = useState(1);
|
||||
const [favoriteNumPages, setFavoriteNumPages] = useState(0);
|
||||
const favoritePages = usePaginatedFiles(favoritePage, 'media', true);
|
||||
const favoritePages = usePaginatedFiles(favoritePage, {
|
||||
filter: checked ? 'none' : 'media',
|
||||
favorite: true,
|
||||
});
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
@@ -21,11 +31,19 @@ export default function Files({ disableMediaPreview, exifEnabled, queryPage, com
|
||||
|
||||
return (
|
||||
<>
|
||||
<PendingFilesModal open={open} onClose={() => setOpen(false)} />
|
||||
|
||||
<Group mb='md'>
|
||||
<Title>Files</Title>
|
||||
<ActionIcon component={Link} href='/dashboard/upload/file' variant='filled' color='primary'>
|
||||
<IconFileUpload size='1rem' />
|
||||
</ActionIcon>
|
||||
|
||||
<Tooltip label='View pending uploads'>
|
||||
<ActionIcon onClick={() => setOpen(true)} variant='filled' color='primary'>
|
||||
<IconPhotoUp size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
{favoritePages.isSuccess && favoritePages.data.length ? (
|
||||
<Accordion
|
||||
|
||||
@@ -112,7 +112,7 @@ export default function Folders({ disableMediaPreview, exifEnabled, compress })
|
||||
|
||||
const makePublic = async (folder) => {
|
||||
const res = await useFetch(`/api/user/folders/${folder.id}`, 'PATCH', {
|
||||
public: folder.public ? false : true,
|
||||
public: !folder.public,
|
||||
});
|
||||
|
||||
if (!res.error) {
|
||||
@@ -363,25 +363,18 @@ export default function Folders({ disableMediaPreview, exifEnabled, compress })
|
||||
aria-label='copy link'
|
||||
onClick={() => {
|
||||
clipboard.copy(`${window.location.origin}/folder/${folder.id}`);
|
||||
if (!navigator.clipboard)
|
||||
showNotification({
|
||||
title: 'Unable to copy to clipboard',
|
||||
message: 'Zipline is unable to copy to clipboard due to security reasons.',
|
||||
color: 'red',
|
||||
});
|
||||
else
|
||||
showNotification({
|
||||
title: 'Copied folder link',
|
||||
message: (
|
||||
<>
|
||||
Copied{' '}
|
||||
<AnchorNext href={`/folder/${folder.id}`}>folder link</AnchorNext> to
|
||||
clipboard
|
||||
</>
|
||||
),
|
||||
color: 'green',
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
});
|
||||
|
||||
showNotification({
|
||||
title: 'Copied folder link',
|
||||
message: (
|
||||
<>
|
||||
Copied <AnchorNext href={`/folder/${folder.id}`}>folder link</AnchorNext>{' '}
|
||||
to clipboard
|
||||
</>
|
||||
),
|
||||
color: 'green',
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<IconClipboardCopy size='1rem' />
|
||||
|
||||
@@ -30,18 +30,18 @@ import {
|
||||
import MutedText from 'components/MutedText';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { listViewInvitesSelector } from 'lib/recoil/settings';
|
||||
import { expireText, relativeTime } from 'lib/utils/client';
|
||||
import { expireReadToDate, expireText, relativeTime } from 'lib/utils/client';
|
||||
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
|
||||
const expires = ['30m', '1h', '6h', '12h', '1d', '3d', '5d', '7d', 'never'];
|
||||
const expires = ['30min', '1h', '6h', '12h', '1d', '3d', '5d', '7d', 'never'];
|
||||
|
||||
function CreateInviteModal({ open, setOpen, updateInvites }) {
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
expires: '30m',
|
||||
expires: '30min',
|
||||
count: 1,
|
||||
},
|
||||
});
|
||||
@@ -50,26 +50,12 @@ function CreateInviteModal({ open, setOpen, updateInvites }) {
|
||||
if (!expires.includes(values.expires)) return form.setFieldError('expires', 'Invalid expiration');
|
||||
if (values.count < 1 || values.count > 100)
|
||||
return form.setFieldError('count', 'Must be between 1 and 100');
|
||||
const expiresAt =
|
||||
values.expires === 'never'
|
||||
? null
|
||||
: new Date(
|
||||
{
|
||||
'30m': Date.now() + 30 * 60 * 1000,
|
||||
'1h': Date.now() + 60 * 60 * 1000,
|
||||
'6h': Date.now() + 6 * 60 * 60 * 1000,
|
||||
'12h': Date.now() + 12 * 60 * 60 * 1000,
|
||||
'1d': Date.now() + 24 * 60 * 60 * 1000,
|
||||
'3d': Date.now() + 3 * 24 * 60 * 60 * 1000,
|
||||
'5d': Date.now() + 5 * 24 * 60 * 60 * 1000,
|
||||
'7d': Date.now() + 7 * 24 * 60 * 60 * 1000,
|
||||
}[values.expires]
|
||||
);
|
||||
const expiresAt = expireReadToDate(values.expires);
|
||||
|
||||
setOpen(false);
|
||||
|
||||
const res = await useFetch('/api/auth/invite', 'POST', {
|
||||
expiresAt,
|
||||
expiresAt: `date=${expiresAt.toISOString()}`,
|
||||
count: values.count,
|
||||
});
|
||||
|
||||
@@ -99,8 +85,9 @@ function CreateInviteModal({ open, setOpen, updateInvites }) {
|
||||
label='Expires'
|
||||
id='expires'
|
||||
{...form.getInputProps('expires')}
|
||||
maxDropdownHeight={100}
|
||||
data={[
|
||||
{ value: '30m', label: '30 minutes' },
|
||||
{ value: '30min', label: '30 minutes' },
|
||||
{ value: '1h', label: '1 hour' },
|
||||
{ value: '6h', label: '6 hours' },
|
||||
{ value: '12h', label: '12 hours' },
|
||||
@@ -108,7 +95,6 @@ function CreateInviteModal({ open, setOpen, updateInvites }) {
|
||||
{ value: '3d', label: '3 days' },
|
||||
{ value: '5d', label: '5 days' },
|
||||
{ value: '7d', label: '7 days' },
|
||||
{ value: 'never', label: 'Never' },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -197,18 +183,12 @@ export default function Invites() {
|
||||
|
||||
const handleCopy = async (invite) => {
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}/auth/register?code=${invite.code}`);
|
||||
if (!navigator.clipboard)
|
||||
showNotification({
|
||||
title: 'Unable to copy to clipboard',
|
||||
message: 'Zipline is unable to copy to clipboard due to security reasons.',
|
||||
color: 'red',
|
||||
});
|
||||
else
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: '',
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
});
|
||||
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: '',
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
});
|
||||
};
|
||||
|
||||
const updateInvites = async () => {
|
||||
@@ -318,45 +298,65 @@ export default function Invites() {
|
||||
/>
|
||||
) : (
|
||||
<SimpleGrid cols={3} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
|
||||
{invites.length
|
||||
? invites.map((invite) => (
|
||||
<Card key={invite.id} sx={{ maxWidth: '100%' }}>
|
||||
<Group position='apart'>
|
||||
<Group position='left'>
|
||||
<Avatar size='lg' color={invite.used ? 'dark' : 'primary'}>
|
||||
{invite.id}
|
||||
</Avatar>
|
||||
<Stack spacing={0}>
|
||||
<Title>
|
||||
{invite.code}
|
||||
{invite.used && <> (Used)</>}
|
||||
</Title>
|
||||
<Tooltip label={new Date(invite.createdAt).toLocaleString()}>
|
||||
<div>
|
||||
<MutedText size='sm'>
|
||||
Created {relativeTime(new Date(invite.createdAt))}
|
||||
</MutedText>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip label={new Date(invite.expiresAt).toLocaleString()}>
|
||||
<div>
|
||||
<MutedText size='sm'>{expireText(invite.expiresAt.toString())}</MutedText>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Stack>
|
||||
<ActionIcon aria-label='copy' onClick={() => handleCopy(invite)}>
|
||||
<IconClipboardCopy size='1rem' />
|
||||
</ActionIcon>
|
||||
<ActionIcon aria-label='delete' onClick={() => openDeleteModal(invite)}>
|
||||
<IconTrash size='1rem' />
|
||||
</ActionIcon>
|
||||
{!ok && !invites.length && (
|
||||
<>
|
||||
{[1, 2, 3].map((x) => (
|
||||
<Skeleton key={x} width='100%' height={100} radius='sm' />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{invites.length && ok ? (
|
||||
invites.map((invite) => (
|
||||
<Card key={invite.id} sx={{ maxWidth: '100%' }}>
|
||||
<Group position='apart'>
|
||||
<Group position='left'>
|
||||
<Avatar size='lg' color={invite.used ? 'dark' : 'primary'}>
|
||||
{invite.id}
|
||||
</Avatar>
|
||||
<Stack spacing={0}>
|
||||
<Title>
|
||||
{invite.code}
|
||||
{invite.used && <> (Used)</>}
|
||||
</Title>
|
||||
<Tooltip label={new Date(invite.createdAt).toLocaleString()}>
|
||||
<div>
|
||||
<MutedText size='sm'>Created {relativeTime(new Date(invite.createdAt))}</MutedText>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip label={new Date(invite.expiresAt).toLocaleString()}>
|
||||
<div>
|
||||
<MutedText size='sm'>{expireText(invite.expiresAt.toString())}</MutedText>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
))
|
||||
: [1, 2, 3].map((x) => <Skeleton key={x} width='100%' height={100} radius='sm' />)}
|
||||
<Stack>
|
||||
<ActionIcon aria-label='copy' onClick={() => handleCopy(invite)}>
|
||||
<IconClipboardCopy size='1rem' />
|
||||
</ActionIcon>
|
||||
<ActionIcon aria-label='delete' onClick={() => openDeleteModal(invite)}>
|
||||
<IconTrash size='1rem' />
|
||||
</ActionIcon>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<div></div>
|
||||
<Group>
|
||||
<div>
|
||||
<IconTag size={48} />
|
||||
</div>
|
||||
<div>
|
||||
<Title>Nothing here</Title>
|
||||
<MutedText size='md'>Create some invites and they will show up here</MutedText>
|
||||
</div>
|
||||
</Group>
|
||||
<div></div>
|
||||
</>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -3,8 +3,10 @@ import { closeAllModals, openConfirmModal } from '@mantine/modals';
|
||||
import { showNotification, updateNotification } from '@mantine/notifications';
|
||||
import { IconFiles, IconFilesOff } from '@tabler/icons-react';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function ClearStorage({ open, setOpen, check, setCheck }) {
|
||||
export default function ClearStorage({ open, setOpen }) {
|
||||
const [check, setCheck] = useState(false);
|
||||
const handleDelete = async (datasource: boolean, orphaned?: boolean) => {
|
||||
showNotification({
|
||||
id: 'clear-uploads',
|
||||
@@ -38,7 +40,10 @@ export default function ClearStorage({ open, setOpen, check, setCheck }) {
|
||||
return (
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={() => setOpen(false)}
|
||||
onClose={() => {
|
||||
setOpen(false);
|
||||
setCheck(() => false);
|
||||
}}
|
||||
title={<Title size='sm'>Are you sure you want to clear all uploads in the database?</Title>}
|
||||
>
|
||||
<Checkbox
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Icon2fa, IconBarcodeOff, IconCheck } from '@tabler/icons-react';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function TotpModal({ opened, onClose, deleteTotp, setTotpEnabled }) {
|
||||
export function TotpModal({ opened, onClose, deleteTotp, setUser }) {
|
||||
const [secret, setSecret] = useState('');
|
||||
const [qrCode, setQrCode] = useState('');
|
||||
const [disabled, setDisabled] = useState(false);
|
||||
@@ -52,8 +52,7 @@ export function TotpModal({ opened, onClose, deleteTotp, setTotpEnabled }) {
|
||||
icon: <Icon2fa size='1rem' />,
|
||||
});
|
||||
|
||||
setTotpEnabled(false);
|
||||
|
||||
setUser((user) => ({ ...user, totpSecret: null }));
|
||||
onClose();
|
||||
}
|
||||
|
||||
@@ -83,8 +82,7 @@ export function TotpModal({ opened, onClose, deleteTotp, setTotpEnabled }) {
|
||||
icon: <Icon2fa size='1rem' />,
|
||||
});
|
||||
|
||||
setTotpEnabled(true);
|
||||
|
||||
setUser((user) => ({ ...user, totpSecret: secret }));
|
||||
onClose();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
ColorInput,
|
||||
CopyButton,
|
||||
FileInput,
|
||||
Group,
|
||||
Image,
|
||||
@@ -23,6 +25,8 @@ import {
|
||||
IconBrandDiscordFilled,
|
||||
IconBrandGithubFilled,
|
||||
IconBrandGoogle,
|
||||
IconCheck,
|
||||
IconClipboardCopy,
|
||||
IconFileExport,
|
||||
IconFiles,
|
||||
IconFilesOff,
|
||||
@@ -89,7 +93,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [fileDataURL, setFileDataURL] = useState(user.avatar ?? null);
|
||||
const [totpEnabled, setTotpEnabled] = useState(!!user.totpSecret);
|
||||
const [checked, setCheck] = useState(false);
|
||||
const [tokenShown, setTokenShown] = useState(false);
|
||||
|
||||
const getDataURL = (f: File): Promise<string> => {
|
||||
return new Promise((res, rej) => {
|
||||
@@ -355,16 +359,36 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
useEffect(() => {
|
||||
getExports();
|
||||
interval.start();
|
||||
}, [totpEnabled]);
|
||||
setTotpEnabled(() => !!user.totpSecret);
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title>Manage User</Title>
|
||||
<MutedText size='md'>
|
||||
Want to use variables in embed text? Visit{' '}
|
||||
<AnchorNext href='https://zipline.diced.tech/docs/guides/variables'>the docs</AnchorNext> for
|
||||
<AnchorNext href='https://zipline.diced.vercel.app/docs/guides/variables'>the docs</AnchorNext> for
|
||||
variables
|
||||
</MutedText>
|
||||
|
||||
<TextInput
|
||||
rightSection={
|
||||
<CopyButton value={user.token} timeout={1000}>
|
||||
{({ copied, copy }) => (
|
||||
<ActionIcon onClick={copy}>
|
||||
{copied ? <IconCheck color='green' size='1rem' /> : <IconClipboardCopy size='1rem' />}
|
||||
</ActionIcon>
|
||||
)}
|
||||
</CopyButton>
|
||||
}
|
||||
// @ts-ignore (this works even though ts doesn't allow for it)
|
||||
component='span'
|
||||
label='Token'
|
||||
onClick={() => setTokenShown(true)}
|
||||
>
|
||||
{tokenShown ? user.token : '[click to reveal]'}
|
||||
</TextInput>
|
||||
|
||||
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
|
||||
<TextInput id='username' label='Username' my='sm' {...form.getInputProps('username')} />
|
||||
<PasswordInput
|
||||
@@ -450,7 +474,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
opened={totpOpen}
|
||||
onClose={() => setTotpOpen(false)}
|
||||
deleteTotp={totpEnabled}
|
||||
setTotpEnabled={setTotpEnabled}
|
||||
setUser={setUser}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
@@ -626,7 +650,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
|
||||
<ShareX user={user} open={shareXOpen} setOpen={setShareXOpen} />
|
||||
<Flameshot user={user} open={flameshotOpen} setOpen={setFlameshotOpen} />
|
||||
<ClearStorage open={clrStorOpen} setOpen={setClrStorOpen} check={checked} setCheck={setCheck} />
|
||||
<ClearStorage open={clrStorOpen} setOpen={setClrStorOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,18 +27,12 @@ export default function MetadataView({ fileId }) {
|
||||
|
||||
const copy = (value) => {
|
||||
clipboard.copy(value);
|
||||
if (!navigator.clipboard)
|
||||
showNotification({
|
||||
title: 'Unable to copy to clipboard',
|
||||
message: 'Zipline is unable to copy to clipboard due to security reasons.',
|
||||
color: 'red',
|
||||
});
|
||||
else
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: value,
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
});
|
||||
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: value,
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
});
|
||||
};
|
||||
|
||||
const searchValue = (value) => {
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
import { Button, Collapse, Group, Progress, Stack, Title } from '@mantine/core';
|
||||
import { Anchor, Button, Collapse, Group, Progress, Stack, Text, Title } from '@mantine/core';
|
||||
import { randomId, useClipboard } from '@mantine/hooks';
|
||||
import { useModals } from '@mantine/modals';
|
||||
import { showNotification, updateNotification } from '@mantine/notifications';
|
||||
import { IconFileImport, IconFileTime, IconFileUpload, IconFileX } from '@tabler/icons-react';
|
||||
import { hideNotification, showNotification, updateNotification } from '@mantine/notifications';
|
||||
import {
|
||||
IconClipboardCopy,
|
||||
IconFileImport,
|
||||
IconFileTime,
|
||||
IconFileUpload,
|
||||
IconFileX,
|
||||
} from '@tabler/icons-react';
|
||||
import Dropzone from 'components/dropzone/Dropzone';
|
||||
import FileDropzone from 'components/dropzone/DropzoneFile';
|
||||
import MutedText from 'components/MutedText';
|
||||
import { invalidateFiles } from 'lib/queries/files';
|
||||
import { userSelector } from 'lib/recoil/user';
|
||||
import { expireReadToDate, randomChars } from 'lib/utils/client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import showFilesModal from './showFilesModal';
|
||||
import useUploadOptions from './useUploadOptions';
|
||||
import { useRouter } from 'next/router';
|
||||
import AnchorNext from 'components/AnchorNext';
|
||||
|
||||
export default function File({ chunks: chunks_config }) {
|
||||
const router = useRouter();
|
||||
|
||||
const clipboard = useClipboard();
|
||||
const modals = useModals();
|
||||
const user = useRecoilValue(userSelector);
|
||||
@@ -25,6 +35,30 @@ export default function File({ chunks: chunks_config }) {
|
||||
|
||||
const [options, setOpened, OptionsModal] = useUploadOptions();
|
||||
|
||||
const beforeUnload = useCallback(
|
||||
(e: BeforeUnloadEvent) => {
|
||||
if (loading) {
|
||||
e.preventDefault();
|
||||
e.returnValue = "Are you sure you want to leave? Your upload(s) won't be saved.";
|
||||
return e.returnValue;
|
||||
}
|
||||
},
|
||||
[loading]
|
||||
);
|
||||
|
||||
const beforeRouteChange = useCallback(
|
||||
(url: string) => {
|
||||
if (loading) {
|
||||
const confirmed = confirm("Are you sure you want to leave? Your upload(s) won't be saved.");
|
||||
if (!confirmed) {
|
||||
router.events.emit('routeChangeComplete', url);
|
||||
throw 'Route change aborted';
|
||||
}
|
||||
}
|
||||
},
|
||||
[loading]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (e: ClipboardEvent) => {
|
||||
const item = Array.from(e.clipboardData.items).find((x) => /^image/.test(x.type));
|
||||
@@ -41,10 +75,24 @@ export default function File({ chunks: chunks_config }) {
|
||||
};
|
||||
|
||||
document.addEventListener('paste', listener);
|
||||
return () => document.removeEventListener('paste', listener);
|
||||
}, []);
|
||||
window.addEventListener('beforeunload', beforeUnload, true);
|
||||
router.events.on('routeChangeStart', beforeRouteChange);
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', beforeUnload, true);
|
||||
router.events.off('routeChangeStart', beforeRouteChange);
|
||||
document.removeEventListener('paste', listener);
|
||||
};
|
||||
}, [loading, beforeUnload, beforeRouteChange]);
|
||||
|
||||
const handleChunkedFiles = async (expiresAt: Date, toChunkFiles: File[]) => {
|
||||
if (!chunks_config.enabled)
|
||||
return showNotification({
|
||||
id: 'upload-chunked',
|
||||
title: 'Chunked files are disabled',
|
||||
message: 'This should not be called, but some how got called...',
|
||||
color: 'red',
|
||||
});
|
||||
|
||||
for (let i = 0; i !== toChunkFiles.length; ++i) {
|
||||
const file = toChunkFiles[i];
|
||||
const identifier = randomChars(4);
|
||||
@@ -71,18 +119,6 @@ export default function File({ chunks: chunks_config }) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
// if last chunk send notif that it will take a while
|
||||
if (j === chunks.length - 1) {
|
||||
updateNotification({
|
||||
id: 'upload-chunked',
|
||||
title: 'Finalizing partial upload',
|
||||
message: 'This may take a while...',
|
||||
icon: <IconFileTime size='1rem' />,
|
||||
color: 'yellow',
|
||||
autoClose: false,
|
||||
});
|
||||
}
|
||||
|
||||
const body = new FormData();
|
||||
body.append('file', chunks[j].blob);
|
||||
|
||||
@@ -109,25 +145,37 @@ export default function File({ chunks: chunks_config }) {
|
||||
if (j === chunks.length - 1) {
|
||||
updateNotification({
|
||||
id: 'upload-chunked',
|
||||
title: 'Upload Successful',
|
||||
message: '',
|
||||
title: 'Finalizing partial upload',
|
||||
message: (
|
||||
<Text>
|
||||
The upload has been offloaded, and will complete in the background.
|
||||
<br />
|
||||
<Anchor
|
||||
component='span'
|
||||
onClick={() => {
|
||||
hideNotification('upload-chunked');
|
||||
clipboard.copy(json.files[0]);
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: <AnchorNext href={json.files[0]}>{json.files[0]}</AnchorNext>,
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Click here to copy the URL while it‘s being processed.
|
||||
</Anchor>
|
||||
</Text>
|
||||
),
|
||||
icon: <IconFileTime size='1rem' />,
|
||||
color: 'green',
|
||||
icon: <IconFileUpload size='1rem' />,
|
||||
autoClose: false,
|
||||
});
|
||||
showFilesModal(clipboard, modals, json.files);
|
||||
invalidateFiles();
|
||||
setFiles([]);
|
||||
setProgress(100);
|
||||
setLoading(false);
|
||||
|
||||
setTimeout(() => setProgress(0), 1000);
|
||||
|
||||
clipboard.copy(json.files[0]);
|
||||
if (!navigator.clipboard)
|
||||
showNotification({
|
||||
title: 'Unable to copy to clipboard',
|
||||
message: 'Zipline is unable to copy to clipboard due to security reasons.',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
|
||||
ready = true;
|
||||
@@ -182,7 +230,7 @@ export default function File({ chunks: chunks_config }) {
|
||||
|
||||
for (let i = 0; i !== files.length; ++i) {
|
||||
const file = files[i];
|
||||
if (file.size >= chunks_config.max_size) {
|
||||
if (chunks_config.enabled && file.size >= chunks_config.max_size) {
|
||||
toChunkFiles.push(file);
|
||||
} else {
|
||||
body.append('file', files[i]);
|
||||
|
||||
@@ -7,18 +7,12 @@ export default function showFilesModal(clipboard, modals, files: string[]) {
|
||||
const open = (idx: number) => window.open(files[idx], '_blank');
|
||||
const copy = (idx: number) => {
|
||||
clipboard.copy(files[idx]);
|
||||
if (!navigator.clipboard)
|
||||
showNotification({
|
||||
title: 'Unable to copy to clipboard',
|
||||
message: 'Zipline is unable to copy to clipboard due to security reasons.',
|
||||
color: 'red',
|
||||
});
|
||||
else
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: <AnchorNext href={files[idx]}>{files[idx]}</AnchorNext>,
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
});
|
||||
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: <AnchorNext href={files[idx]}>{files[idx]}</AnchorNext>,
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
});
|
||||
};
|
||||
|
||||
modals.openModal({
|
||||
|
||||
@@ -169,18 +169,12 @@ export default function Urls() {
|
||||
|
||||
const copyURL = (u) => {
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}${u.url}`);
|
||||
if (!navigator.clipboard)
|
||||
showNotification({
|
||||
title: 'Unable to copy to clipboard',
|
||||
message: 'Zipline is unable to copy to clipboard due to security reasons.',
|
||||
color: 'red',
|
||||
});
|
||||
else
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: '',
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
});
|
||||
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: '',
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
});
|
||||
};
|
||||
|
||||
const urlDelete = useURLDelete();
|
||||
|
||||
82
src/components/pages/Users/UserFiles.tsx
Normal file
82
src/components/pages/Users/UserFiles.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { ActionIcon, Button, Center, Group, SimpleGrid, Title } from '@mantine/core';
|
||||
import { File } from '@prisma/client';
|
||||
import { IconArrowLeft, IconFile } from '@tabler/icons-react';
|
||||
import FileComponent from 'components/File';
|
||||
import MutedText from 'components/MutedText';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { userSelector } from 'lib/recoil/user';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
|
||||
type UserFiles = {
|
||||
id: number;
|
||||
username: string;
|
||||
files?: File[];
|
||||
error?: unknown;
|
||||
};
|
||||
|
||||
export default function UserFiles({ userId, disableMediaPreview, exifEnabled, compress }) {
|
||||
const [currentUser, viewUser] = useState<UserFiles>({ id: 0, username: 'user' });
|
||||
const [self] = useRecoilState(userSelector);
|
||||
|
||||
const { push } = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (self.id == userId) push('/dashboard/files');
|
||||
(async () => {
|
||||
const user: UserFiles = await useFetch(`/api/user/${userId}`);
|
||||
if (!user.error) {
|
||||
viewUser(user);
|
||||
} else {
|
||||
push('/dashboard');
|
||||
}
|
||||
})();
|
||||
}, [userId]);
|
||||
|
||||
if (!currentUser.files || currentUser.files.length === 0) {
|
||||
return (
|
||||
<Center sx={{ flexDirection: 'column' }}>
|
||||
<Group>
|
||||
<div>
|
||||
<IconFile size={48} />
|
||||
</div>
|
||||
<div>
|
||||
<Title>Nothing here</Title>
|
||||
<MutedText size='md'>
|
||||
{currentUser.username} seems to have not uploaded any files... yet
|
||||
</MutedText>
|
||||
</div>
|
||||
<Button size='md' onClick={() => push('/dashboard/users')}>
|
||||
Head back?
|
||||
</Button>
|
||||
</Group>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group mb='md'>
|
||||
<ActionIcon size='lg' onClick={() => push('/dashboard/users')} color='primary'>
|
||||
<IconArrowLeft />
|
||||
</ActionIcon>
|
||||
<Title>{currentUser.username}'s Files</Title>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={3} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
|
||||
{currentUser.files.map((file) => (
|
||||
<div key={file.id}>
|
||||
<FileComponent
|
||||
image={file}
|
||||
disableMediaPreview={disableMediaPreview}
|
||||
exifEnabled={exifEnabled}
|
||||
onDash={compress}
|
||||
otherUser={true}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type { User } from '@prisma/client';
|
||||
import {
|
||||
IconClipboardCopy,
|
||||
IconEdit,
|
||||
IconExternalLink,
|
||||
IconGridDots,
|
||||
IconList,
|
||||
IconUserExclamation,
|
||||
@@ -116,6 +117,10 @@ export default function Users() {
|
||||
}
|
||||
};
|
||||
|
||||
const openUser = async (user) => {
|
||||
await router.push(`/dashboard/users/${user.id}`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateUsers();
|
||||
}, []);
|
||||
@@ -181,6 +186,13 @@ export default function Users() {
|
||||
<IconEdit size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{(!self.superAdmin && user.superAdmin) || (self.superAdmin && user.superAdmin) ? null : (
|
||||
<Tooltip label='Open user'>
|
||||
<ActionIcon color='cyan' onClick={() => openUser(user)}>
|
||||
<IconExternalLink size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export interface ConfigCore {
|
||||
return_https: boolean;
|
||||
temp_directory: string;
|
||||
secret: string;
|
||||
host: string;
|
||||
port: number;
|
||||
@@ -9,6 +10,7 @@ export interface ConfigCore {
|
||||
|
||||
stats_interval: number;
|
||||
invites_interval: number;
|
||||
thumbnails_interval: number;
|
||||
}
|
||||
|
||||
export interface ConfigCompression {
|
||||
@@ -119,9 +121,15 @@ export interface ConfigFeatures {
|
||||
headless: boolean;
|
||||
|
||||
default_avatar: string;
|
||||
|
||||
robots_txt: string;
|
||||
|
||||
thumbnails: boolean;
|
||||
}
|
||||
|
||||
export interface ConfigOAuth {
|
||||
bypass_local_login: boolean;
|
||||
|
||||
github_client_id?: string;
|
||||
github_client_secret?: string;
|
||||
|
||||
@@ -135,6 +143,7 @@ export interface ConfigOAuth {
|
||||
export interface ConfigChunks {
|
||||
max_size: number;
|
||||
chunks_size: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface ConfigMfa {
|
||||
|
||||
@@ -57,13 +57,17 @@ export default function readConfig() {
|
||||
|
||||
const maps = [
|
||||
map('CORE_RETURN_HTTPS', 'boolean', 'core.return_https'),
|
||||
map('CORE_TEMP_DIRECTORY', 'path', 'core.temp_directory'),
|
||||
map('CORE_SECRET', 'string', 'core.secret'),
|
||||
map('CORE_HOST', 'string', 'core.host'),
|
||||
map('CORE_PORT', 'number', 'core.port'),
|
||||
map('CORE_DATABASE_URL', 'string', 'core.database_url'),
|
||||
map('CORE_LOGGER', 'boolean', 'core.logger'),
|
||||
|
||||
map('CORE_STATS_INTERVAL', 'number', 'core.stats_interval'),
|
||||
map('CORE_INVITES_INTERVAL', 'number', 'core.invites_interval'),
|
||||
map('CORE_THUMBNAILS_INTERVAL', 'number', 'core.thumbnails_interval'),
|
||||
|
||||
map('CORE_COMPRESSION_ENABLED', 'boolean', 'core.compression.enabled'),
|
||||
map('CORE_COMPRESSION_THRESHOLD', 'human-to-byte', 'core.compression.threshold'),
|
||||
map('CORE_COMPRESSION_ON_DASHBOARD', 'boolean', 'core.compression.on_dashboard'),
|
||||
@@ -135,6 +139,8 @@ export default function readConfig() {
|
||||
map('DISCORD_SHORTEN_EMBED_THUMBNAIL', 'boolean', 'discord.shorten.embed.thumbnail'),
|
||||
map('DISCORD_SHORTEN_EMBED_TIMESTAMP', 'boolean', 'discord.shorten.embed.timestamp'),
|
||||
|
||||
map('OAUTH_BYPASS_LOCAL_LOGIN', 'boolean', 'oauth.bypass_local_login'),
|
||||
|
||||
map('OAUTH_GITHUB_CLIENT_ID', 'string', 'oauth.github_client_id'),
|
||||
map('OAUTH_GITHUB_CLIENT_SECRET', 'string', 'oauth.github_client_secret'),
|
||||
|
||||
@@ -155,8 +161,13 @@ export default function readConfig() {
|
||||
|
||||
map('FEATURES_DEFAULT_AVATAR', 'path', 'features.default_avatar'),
|
||||
|
||||
map('FEATURES_ROBOTS_TXT', 'boolean', 'features.robots_txt'),
|
||||
|
||||
map('FEATURES_THUMBNAILS', 'boolean', 'features.thumbnails'),
|
||||
|
||||
map('CHUNKS_MAX_SIZE', 'human-to-byte', 'chunks.max_size'),
|
||||
map('CHUNKS_CHUNKS_SIZE', 'human-to-byte', 'chunks.chunks_size'),
|
||||
map('CHUNKS_ENABLED', 'boolean', 'chunks.enabled'),
|
||||
|
||||
map('MFA_TOTP_ISSUER', 'string', 'mfa.totp_issuer'),
|
||||
map('MFA_TOTP_ENABLED', 'boolean', 'mfa.totp_enabled'),
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { Config } from './Config';
|
||||
import { inspect } from 'util';
|
||||
import Logger from 'lib/logger';
|
||||
import { humanToBytes } from 'utils/bytes';
|
||||
import { tmpdir } from 'os';
|
||||
import { join, resolve } from 'path';
|
||||
|
||||
const discord_content = s
|
||||
.object({
|
||||
@@ -27,13 +29,15 @@ const discord_content = s
|
||||
const validator = s.object({
|
||||
core: s.object({
|
||||
return_https: s.boolean.default(false),
|
||||
temp_directory: s.string.default(join(tmpdir(), 'zipline')),
|
||||
secret: s.string.lengthGreaterThanOrEqual(8),
|
||||
host: s.string.default('0.0.0.0'),
|
||||
port: s.number.default(3000),
|
||||
database_url: s.string,
|
||||
logger: s.boolean.default(false),
|
||||
stats_interval: s.number.default(1800),
|
||||
invites_interval: s.number.default(1800),
|
||||
stats_interval: s.number.default(1800), // 30m
|
||||
invites_interval: s.number.default(1800), // 30m
|
||||
thumbnails_interval: s.number.default(600), // 10m
|
||||
compression: s
|
||||
.object({
|
||||
enabled: s.boolean.default(false),
|
||||
@@ -50,7 +54,7 @@ const validator = s.object({
|
||||
type: s.enum('local', 's3', 'supabase').default('local'),
|
||||
local: s
|
||||
.object({
|
||||
directory: s.string.default('./uploads'),
|
||||
directory: s.string.default(resolve('./uploads')).transform((v) => resolve(v)),
|
||||
})
|
||||
.default({
|
||||
directory: './uploads',
|
||||
@@ -140,7 +144,7 @@ const validator = s.object({
|
||||
)
|
||||
.default([
|
||||
{ label: 'Zipline', link: 'https://github.com/diced/zipline' },
|
||||
{ label: 'Documentation', link: 'https://zipline.diced.tech/' },
|
||||
{ label: 'Documentation', link: 'https://zipline.diced.vercel.app/' },
|
||||
]),
|
||||
})
|
||||
.default({
|
||||
@@ -151,7 +155,7 @@ const validator = s.object({
|
||||
|
||||
external_links: [
|
||||
{ label: 'Zipline', link: 'https://github.com/diced/zipline' },
|
||||
{ label: 'Documentation', link: 'https://zipline.diced.tech/' },
|
||||
{ label: 'Documentation', link: 'https://zipline.diced.vercel.app/' },
|
||||
],
|
||||
}),
|
||||
discord: s
|
||||
@@ -165,6 +169,8 @@ const validator = s.object({
|
||||
.nullish.default(null),
|
||||
oauth: s
|
||||
.object({
|
||||
bypass_local_login: s.boolean.default(false),
|
||||
|
||||
github_client_id: s.string.nullable.default(null),
|
||||
github_client_secret: s.string.nullable.default(null),
|
||||
|
||||
@@ -184,6 +190,8 @@ const validator = s.object({
|
||||
user_registration: s.boolean.default(false),
|
||||
headless: s.boolean.default(false),
|
||||
default_avatar: s.string.nullable.default(null),
|
||||
robots_txt: s.boolean.default(false),
|
||||
thumbnails: s.boolean.default(false),
|
||||
})
|
||||
.default({
|
||||
invites: false,
|
||||
@@ -193,15 +201,19 @@ const validator = s.object({
|
||||
user_registration: false,
|
||||
headless: false,
|
||||
default_avatar: null,
|
||||
robots_txt: false,
|
||||
thumbnails: false,
|
||||
}),
|
||||
chunks: s
|
||||
.object({
|
||||
max_size: s.number.default(humanToBytes('90MB')),
|
||||
chunks_size: s.number.default(humanToBytes('20MB')),
|
||||
enabled: s.boolean.default(true),
|
||||
})
|
||||
.default({
|
||||
max_size: humanToBytes('90MB'),
|
||||
chunks_size: humanToBytes('20MB'),
|
||||
enabled: true,
|
||||
}),
|
||||
mfa: s
|
||||
.object({
|
||||
|
||||
@@ -11,22 +11,23 @@ export class Local extends Datasource {
|
||||
}
|
||||
|
||||
public async save(file: string, data: Buffer): Promise<void> {
|
||||
await writeFile(join(process.cwd(), this.path, file), data);
|
||||
await writeFile(join(this.path, file), data);
|
||||
}
|
||||
|
||||
public async delete(file: string): Promise<void> {
|
||||
await rm(join(process.cwd(), this.path, file));
|
||||
await rm(join(this.path, file));
|
||||
}
|
||||
|
||||
public async clear(): Promise<void> {
|
||||
const files = await readdir(join(process.cwd(), this.path));
|
||||
const files = await readdir(this.path);
|
||||
|
||||
for (let i = 0; i !== files.length; ++i) {
|
||||
await rm(join(process.cwd(), this.path, files[i]));
|
||||
await rm(join(this.path, files[i]));
|
||||
}
|
||||
}
|
||||
|
||||
public get(file: string): ReadStream {
|
||||
const full = join(process.cwd(), this.path, file);
|
||||
const full = join(this.path, file);
|
||||
if (!existsSync(full)) return null;
|
||||
|
||||
try {
|
||||
@@ -37,7 +38,9 @@ export class Local extends Datasource {
|
||||
}
|
||||
|
||||
public async size(file: string): Promise<number> {
|
||||
const stats = await stat(join(process.cwd(), this.path, file));
|
||||
const full = join(this.path, file);
|
||||
if (!existsSync(full)) return 0;
|
||||
const stats = await stat(full);
|
||||
|
||||
return stats.size;
|
||||
}
|
||||
|
||||
@@ -50,22 +50,22 @@ export class S3 extends Datasource {
|
||||
}
|
||||
|
||||
public size(file: string): Promise<number> {
|
||||
return new Promise((res, rej) => {
|
||||
return new Promise((res) => {
|
||||
this.s3.statObject(this.config.bucket, file, (err, stat) => {
|
||||
if (err) rej(err);
|
||||
if (err) res(0);
|
||||
else res(stat.size);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async fullSize(): Promise<number> {
|
||||
return new Promise((res, rej) => {
|
||||
return new Promise((res) => {
|
||||
const objects = this.s3.listObjectsV2(this.config.bucket, '', true);
|
||||
let size = 0;
|
||||
|
||||
objects.on('data', (item) => (size += item.size));
|
||||
objects.on('end', (err) => {
|
||||
if (err) rej(err);
|
||||
if (err) res(0);
|
||||
else res(size);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,10 +28,10 @@ export function parseContent(
|
||||
}
|
||||
|
||||
export async function sendUpload(user: User, file: File, raw_link: string, link: string) {
|
||||
if (!config.discord.upload) return;
|
||||
if (!config.discord.url && !config.discord.upload.url) return;
|
||||
if (!config.discord.upload) return logger.debug('no discord upload config, no webhook sent');
|
||||
if (!config.discord.url && !config.discord.upload.url)
|
||||
return logger.debug('no discord url, no webhook sent');
|
||||
|
||||
logger.debug(`discord config:\n${JSON.stringify(config.discord)}`);
|
||||
const parsed = parseContent(config.discord.upload, {
|
||||
file,
|
||||
user,
|
||||
@@ -63,13 +63,13 @@ export async function sendUpload(user: User, file: File, raw_link: string, link:
|
||||
thumbnail:
|
||||
isImage && parsed.embed.thumbnail
|
||||
? {
|
||||
url: parsed.url,
|
||||
url: raw_link,
|
||||
}
|
||||
: null,
|
||||
image:
|
||||
isImage && parsed.embed.image
|
||||
? {
|
||||
url: parsed.url,
|
||||
url: raw_link,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
@@ -97,8 +97,9 @@ export async function sendUpload(user: User, file: File, raw_link: string, link:
|
||||
}
|
||||
|
||||
export async function sendShorten(user: User, url: Url, link: string) {
|
||||
if (!config.discord.shorten) return;
|
||||
if (!config.discord.url && !config.discord.shorten.url) return;
|
||||
if (!config.discord.shorten) return logger.debug('no discord shorten config, no webhook sent');
|
||||
if (!config.discord.url && !config.discord.shorten.url)
|
||||
return logger.debug('no discord url, no webhook sent');
|
||||
|
||||
const parsed = parseContent(config.discord.shorten, {
|
||||
url,
|
||||
|
||||
@@ -2,6 +2,7 @@ import date from './date';
|
||||
import gfycat from './gfycat';
|
||||
import random from './random';
|
||||
import uuid from './uuid';
|
||||
import { parse } from 'path';
|
||||
|
||||
export type NameFormat = 'random' | 'date' | 'uuid' | 'name' | 'gfycat';
|
||||
export const NameFormats: NameFormat[] = ['random', 'date', 'uuid', 'name', 'gfycat'];
|
||||
@@ -14,7 +15,9 @@ export default async function formatFileName(nameFormat: NameFormat, originalNam
|
||||
case 'uuid':
|
||||
return uuid();
|
||||
case 'name':
|
||||
return originalName.split('.')[0];
|
||||
const { name } = parse(originalName);
|
||||
|
||||
return name;
|
||||
case 'gfycat':
|
||||
return gfycat();
|
||||
default:
|
||||
|
||||
@@ -16,8 +16,10 @@ export type ServerSideProps = {
|
||||
user_registration: boolean;
|
||||
oauth_registration: boolean;
|
||||
oauth_providers: string;
|
||||
bypass_local_login: boolean;
|
||||
chunks_size: number;
|
||||
max_size: number;
|
||||
chunks_enabled: boolean;
|
||||
totp_enabled: boolean;
|
||||
exif_enabled: boolean;
|
||||
fileId?: string;
|
||||
@@ -60,9 +62,11 @@ export const getServerSideProps: GetServerSideProps<ServerSideProps> = async (ct
|
||||
user_registration: config.features.user_registration,
|
||||
oauth_registration: config.features.oauth_registration,
|
||||
oauth_providers: JSON.stringify(oauth_providers),
|
||||
bypass_local_login: config.oauth?.bypass_local_login ?? false,
|
||||
chunks_size: config.chunks.chunks_size,
|
||||
max_size: config.chunks.max_size,
|
||||
totp_enabled: config.mfa.totp_enabled,
|
||||
chunks_enabled: config.chunks.enabled,
|
||||
exif_enabled: config.exif.enabled,
|
||||
compress: config.core.compression.on_dashboard,
|
||||
} as ServerSideProps,
|
||||
|
||||
@@ -135,7 +135,7 @@ export const withOAuth =
|
||||
} else throw e;
|
||||
}
|
||||
|
||||
res.setUserCookie(user.id);
|
||||
res.setUserCookie(user.uuid);
|
||||
logger.info(`User ${user.username} (${user.id}) linked account via oauth(${provider})`);
|
||||
|
||||
return res.redirect('/');
|
||||
@@ -153,7 +153,7 @@ export const withOAuth =
|
||||
},
|
||||
});
|
||||
|
||||
res.setUserCookie(user.id);
|
||||
res.setUserCookie(user.uuid);
|
||||
logger.info(`User ${user.username} (${user.id}) logged in via oauth(${provider})`);
|
||||
|
||||
return res.redirect('/dashboard');
|
||||
@@ -203,7 +203,7 @@ export const withOAuth =
|
||||
logger.debug(`created user ${JSON.stringify(nuser)} via oauth(${provider})`);
|
||||
logger.info(`Created user ${nuser.username} via oauth(${provider})`);
|
||||
|
||||
res.setUserCookie(nuser.id);
|
||||
res.setUserCookie(nuser.uuid);
|
||||
logger.info(`User ${nuser.username} (${nuser.id}) logged in via oauth(${provider})`);
|
||||
|
||||
return res.redirect('/dashboard');
|
||||
|
||||
@@ -54,7 +54,7 @@ export type NextApiRes = NextApiResponse &
|
||||
NextApiResExtraObj & {
|
||||
json: (json: Record<string, unknown>, status?: number) => void;
|
||||
setCookie: (name: string, value: unknown, options: CookieSerializeOptions) => void;
|
||||
setUserCookie: (id: number) => void;
|
||||
setUserCookie: (id: string) => void;
|
||||
};
|
||||
|
||||
export type ZiplineApiConfig = {
|
||||
@@ -184,7 +184,7 @@ export const withZipline =
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: Number(userId),
|
||||
uuid: userId,
|
||||
},
|
||||
include: {
|
||||
oauth: true,
|
||||
@@ -202,22 +202,22 @@ export const withZipline =
|
||||
}
|
||||
};
|
||||
|
||||
res.setCookie = (name: string, value: unknown, options: CookieSerializeOptions = {}) => {
|
||||
res.setCookie = (name: string, value: string, options: CookieSerializeOptions = {}) => {
|
||||
if ('maxAge' in options) {
|
||||
options.expires = new Date(Date.now() + options.maxAge * 1000);
|
||||
options.maxAge /= 1000;
|
||||
}
|
||||
|
||||
const signed = sign64(String(value), config.core.secret);
|
||||
const signed = sign64(value, config.core.secret);
|
||||
|
||||
Logger.get('api').debug(`headers(${JSON.stringify(req.headers)}): cookie(${name}, ${value})`);
|
||||
|
||||
res.setHeader('Set-Cookie', serialize(name, signed, options));
|
||||
};
|
||||
|
||||
res.setUserCookie = (id: number) => {
|
||||
res.setUserCookie = (id: string) => {
|
||||
req.cleanCookie('user');
|
||||
res.setCookie('user', String(id), {
|
||||
res.setCookie('user', id, {
|
||||
sameSite: 'lax',
|
||||
expires: new Date(Date.now() + 6.048e8 * 2),
|
||||
path: '/',
|
||||
|
||||
@@ -33,13 +33,23 @@ export const useFiles = (query: { [key: string]: string } = {}) => {
|
||||
);
|
||||
});
|
||||
};
|
||||
export const usePaginatedFiles = (page?: number, filter = 'media', favorite = null) => {
|
||||
const queryBuilder = new URLSearchParams({
|
||||
|
||||
export type PaginatedFilesOptions = {
|
||||
filter: 'media' | 'none';
|
||||
favorite: boolean;
|
||||
sortBy: 'createdAt' | 'views' | 'expiresAt' | 'size' | 'name' | 'mimetype';
|
||||
order: 'asc' | 'desc';
|
||||
};
|
||||
|
||||
export const usePaginatedFiles = (page?: number, options?: Partial<PaginatedFilesOptions>) => {
|
||||
const queryString = new URLSearchParams({
|
||||
page: Number(page || '1').toString(),
|
||||
filter,
|
||||
...(favorite !== null && { favorite: favorite.toString() }),
|
||||
});
|
||||
const queryString = queryBuilder.toString();
|
||||
filter: options?.filter ?? 'none',
|
||||
// ...(options?.favorite !== null && { favorite: options?.favorite?.toString() }),
|
||||
favorite: options.favorite ? 'true' : '',
|
||||
sortBy: options.sortBy ?? '',
|
||||
order: options.order ?? '',
|
||||
}).toString();
|
||||
|
||||
return useQuery<UserFilesResponse[]>(['files', queryString], async () => {
|
||||
return fetch('/api/user/paged?' + queryString)
|
||||
|
||||
@@ -17,27 +17,17 @@ export const useFolders = (query: { [key: string]: string } = {}) => {
|
||||
const queryString = queryBuilder.toString();
|
||||
|
||||
return useQuery<UserFoldersResponse[]>(['folders', queryString], async () => {
|
||||
return fetch('/api/user/folders?' + queryString)
|
||||
.then((res) => res.json() as Promise<UserFoldersResponse[]>)
|
||||
.then((data) =>
|
||||
data.map((x) => ({
|
||||
...x,
|
||||
createdAt: new Date(x.createdAt).toLocaleString(),
|
||||
updatedAt: new Date(x.updatedAt).toLocaleString(),
|
||||
}))
|
||||
);
|
||||
return fetch('/api/user/folders?' + queryString).then(
|
||||
(res) => res.json() as Promise<UserFoldersResponse[]>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const useFolder = (id: string, withFiles = false) => {
|
||||
return useQuery<UserFoldersResponse>(['folder', id], async () => {
|
||||
return fetch('/api/user/folders/' + id + (withFiles ? '?files=true' : ''))
|
||||
.then((res) => res.json() as Promise<UserFoldersResponse>)
|
||||
.then((data) => ({
|
||||
...data,
|
||||
createdAt: new Date(data.createdAt).toLocaleString(),
|
||||
updatedAt: new Date(data.updatedAt).toLocaleString(),
|
||||
}));
|
||||
return fetch('/api/user/folders/' + id + (withFiles ? '?files=true' : '')).then(
|
||||
(res) => res.json() as Promise<UserFoldersResponse>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -99,7 +99,13 @@ export const createSpotlightActions = (router: NextRouter): SpotlightAction[] =>
|
||||
});
|
||||
}),
|
||||
|
||||
actionLink('Help', 'Documentation', 'View the documentation', 'https://zipline.diced.tech', <IconHelp />),
|
||||
actionLink(
|
||||
'Help',
|
||||
'Documentation',
|
||||
'View the documentation',
|
||||
'https://zipline.diced.vercel.app',
|
||||
<IconHelp />
|
||||
),
|
||||
|
||||
// the list of actions here is very incomplete, and will be expanded in the future
|
||||
];
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { File } from '@prisma/client';
|
||||
import { createWriteStream } from 'fs';
|
||||
import { ExifTool, Tags } from 'exiftool-vendored';
|
||||
import { createWriteStream } from 'fs';
|
||||
import { readFile, rm } from 'fs/promises';
|
||||
import datasource from 'lib/datasource';
|
||||
import Logger from 'lib/logger';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { readFile, unlink } from 'fs/promises';
|
||||
|
||||
const logger = Logger.get('exif');
|
||||
|
||||
@@ -34,7 +33,7 @@ export async function readMetadata(filePath: string): Promise<Tags> {
|
||||
|
||||
export async function removeGPSData(image: File): Promise<void> {
|
||||
const exiftool = new ExifTool({ cleanupChildProcs: false });
|
||||
const file = join(tmpdir(), `zipline-exif-remove-${Date.now()}-${image.name}`);
|
||||
const file = join(config.core.temp_directory, `zipline-exif-remove-${Date.now()}-${image.name}`);
|
||||
logger.debug(`writing temp file to remove GPS data: ${file}`);
|
||||
|
||||
const stream = await datasource.get(image.name);
|
||||
@@ -44,47 +43,54 @@ export async function removeGPSData(image: File): Promise<void> {
|
||||
await new Promise((resolve) => writeStream.on('finish', resolve));
|
||||
|
||||
logger.debug(`removing GPS data from ${file}`);
|
||||
await exiftool.write(file, {
|
||||
GPSVersionID: null,
|
||||
GPSAltitude: null,
|
||||
GPSAltitudeRef: null,
|
||||
GPSAreaInformation: null,
|
||||
GPSDateStamp: null,
|
||||
GPSDateTime: null,
|
||||
GPSDestBearing: null,
|
||||
GPSDestBearingRef: null,
|
||||
GPSDestDistance: null,
|
||||
GPSDestLatitude: null,
|
||||
GPSDestLatitudeRef: null,
|
||||
GPSDestLongitude: null,
|
||||
GPSDestLongitudeRef: null,
|
||||
GPSDifferential: null,
|
||||
GPSDOP: null,
|
||||
GPSHPositioningError: null,
|
||||
GPSImgDirection: null,
|
||||
GPSImgDirectionRef: null,
|
||||
GPSLatitude: null,
|
||||
GPSLatitudeRef: null,
|
||||
GPSLongitude: null,
|
||||
GPSLongitudeRef: null,
|
||||
GPSMapDatum: null,
|
||||
GPSPosition: null,
|
||||
GPSProcessingMethod: null,
|
||||
GPSSatellites: null,
|
||||
GPSSpeed: null,
|
||||
GPSSpeedRef: null,
|
||||
GPSStatus: null,
|
||||
GPSTimeStamp: null,
|
||||
GPSTrack: null,
|
||||
GPSTrackRef: null,
|
||||
});
|
||||
try {
|
||||
await exiftool.write(file, {
|
||||
GPSVersionID: null,
|
||||
GPSAltitude: null,
|
||||
GPSAltitudeRef: null,
|
||||
GPSAreaInformation: null,
|
||||
GPSDateStamp: null,
|
||||
GPSDateTime: null,
|
||||
GPSDestBearing: null,
|
||||
GPSDestBearingRef: null,
|
||||
GPSDestDistance: null,
|
||||
GPSDestLatitude: null,
|
||||
GPSDestLatitudeRef: null,
|
||||
GPSDestLongitude: null,
|
||||
GPSDestLongitudeRef: null,
|
||||
GPSDifferential: null,
|
||||
GPSDOP: null,
|
||||
GPSHPositioningError: null,
|
||||
GPSImgDirection: null,
|
||||
GPSImgDirectionRef: null,
|
||||
GPSLatitude: null,
|
||||
GPSLatitudeRef: null,
|
||||
GPSLongitude: null,
|
||||
GPSLongitudeRef: null,
|
||||
GPSMapDatum: null,
|
||||
GPSPosition: null,
|
||||
GPSProcessingMethod: null,
|
||||
GPSSatellites: null,
|
||||
GPSSpeed: null,
|
||||
GPSSpeedRef: null,
|
||||
GPSStatus: null,
|
||||
GPSTimeStamp: null,
|
||||
GPSTrack: null,
|
||||
GPSTrackRef: null,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.debug(`removing temp file: ${file}`);
|
||||
await rm(file);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(`reading file to upload to datasource: ${file} -> ${image.name}`);
|
||||
const buffer = await readFile(file);
|
||||
await datasource.save(image.name, buffer);
|
||||
|
||||
logger.debug(`removing temp file: ${file}`);
|
||||
await unlink(file);
|
||||
await rm(file);
|
||||
|
||||
await exiftool.end(true);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { File, User, Url } from '@prisma/client';
|
||||
import { bytesToHuman } from './bytes';
|
||||
|
||||
export type ParseValue = {
|
||||
file?: File;
|
||||
@@ -11,7 +12,10 @@ export type ParseValue = {
|
||||
|
||||
export function parseString(str: string, value: ParseValue) {
|
||||
if (!str) return null;
|
||||
str = str.replace(/\{link\}/gi, value.link).replace(/\{raw_link\}/gi, value.raw_link);
|
||||
str = str
|
||||
.replace(/\{link\}/gi, value.link)
|
||||
.replace(/\{raw_link\}/gi, value.raw_link)
|
||||
.replace(/\\n/g, '\n');
|
||||
|
||||
const re = /\{(?<type>file|url|user)\.(?<prop>\w+)(::(?<mod>\w+))?\}/gi;
|
||||
let matches: RegExpMatchArray;
|
||||
@@ -24,11 +28,12 @@ export function parseString(str: string, value: ParseValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (['password', 'avatar'].includes(matches.groups.prop)) {
|
||||
if (['password', 'avatar', 'uuid'].includes(matches.groups.prop)) {
|
||||
str = replaceCharsFromString(str, '{unknown_property}', matches.index, re.lastIndex);
|
||||
re.lastIndex = matches.index;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (['originalName', 'name'].includes(matches.groups.prop)) {
|
||||
str = replaceCharsFromString(
|
||||
str,
|
||||
@@ -122,6 +127,8 @@ function modifier(mod: string, value: unknown): string {
|
||||
return value.toString(8);
|
||||
case 'binary':
|
||||
return value.toString(2);
|
||||
case 'bytes':
|
||||
return bytesToHuman(value);
|
||||
default:
|
||||
return '{unknown_int_modifier}';
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ import { Button, Stack, Title, Tooltip } from '@mantine/core';
|
||||
import MutedText from 'components/MutedText';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
export default function FiveHundred() {
|
||||
const { asPath } = useRouter();
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
@@ -24,9 +26,13 @@ export default function FiveHundred() {
|
||||
<Tooltip label={"Take a look at Zipline's logs and the browser console for more info"}>
|
||||
<MutedText>Internal server error</MutedText>
|
||||
</Tooltip>
|
||||
<Button component={Link} href='/dashboard'>
|
||||
Head to the Dashboard
|
||||
</Button>
|
||||
{asPath === '/dashboard' ? (
|
||||
<Button onClick={() => window.location.reload()}>Attempt Refresh</Button>
|
||||
) : (
|
||||
<Button component={Link} href='/dashboard'>
|
||||
Head to the Dashboard
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ const logger = Logger.get('admin');
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
try {
|
||||
const { datasource, orphaned } = req.body;
|
||||
const { orphaned } = req.body;
|
||||
if (orphaned) {
|
||||
const { count } = await prisma.file.deleteMany({
|
||||
where: {
|
||||
|
||||
@@ -7,6 +7,7 @@ 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: {
|
||||
|
||||
@@ -3,6 +3,7 @@ import Logger from 'lib/logger';
|
||||
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'lib/middleware/withZipline';
|
||||
import prisma from 'lib/prisma';
|
||||
import { randomChars } from 'lib/util';
|
||||
import { parseExpiry } from 'lib/utils/client';
|
||||
|
||||
const logger = Logger.get('invite');
|
||||
|
||||
@@ -15,11 +16,8 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
count: number;
|
||||
};
|
||||
|
||||
const expiry = expiresAt ? new Date(expiresAt) : null;
|
||||
if (expiry) {
|
||||
if (!expiry.getTime()) return res.badRequest('invalid date');
|
||||
if (expiry.getTime() < Date.now()) return res.badRequest('date is in the past');
|
||||
}
|
||||
const expiry = parseExpiry(expiresAt);
|
||||
if (!expiry) return res.badRequest('invalid date');
|
||||
const counts = count ? count : 1;
|
||||
|
||||
if (counts > 1) {
|
||||
|
||||
@@ -56,7 +56,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
if (!success) return res.badRequest('Invalid code', { totp: true });
|
||||
}
|
||||
|
||||
res.setUserCookie(user.id);
|
||||
res.setUserCookie(user.uuid);
|
||||
logger.info(`User ${user.username} (${user.id}) logged in`);
|
||||
|
||||
return res.json({ success: true });
|
||||
|
||||
@@ -6,7 +6,6 @@ import Logger from 'lib/logger';
|
||||
import prisma from 'lib/prisma';
|
||||
import { readMetadata } from 'lib/utils/exif';
|
||||
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
const logger = Logger.get('exif');
|
||||
@@ -41,7 +40,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
|
||||
return res.json(data);
|
||||
} else {
|
||||
const file = join(tmpdir(), `zipline-exif-read-${Date.now()}-${image.name}`);
|
||||
const file = join(config.core.temp_directory, `zipline-exif-read-${Date.now()}-${image.name}`);
|
||||
logger.debug(`writing temp file to view metadata: ${file}`);
|
||||
|
||||
const stream = await datasource.get(image.name);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { InvisibleFile } from '@prisma/client';
|
||||
import { readdir, readFile, unlink, writeFile } from 'fs/promises';
|
||||
import { writeFile } from 'fs/promises';
|
||||
import zconfig from 'lib/config';
|
||||
import datasource from 'lib/datasource';
|
||||
import { sendUpload } from 'lib/discord';
|
||||
@@ -12,9 +12,9 @@ import { createInvisImage, hashPassword } from 'lib/util';
|
||||
import { parseExpiry } from 'lib/utils/client';
|
||||
import { removeGPSData } from 'lib/utils/exif';
|
||||
import multer from 'multer';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { join, parse } from 'path';
|
||||
import sharp from 'sharp';
|
||||
import { Worker } from 'worker_threads';
|
||||
|
||||
const uploader = multer();
|
||||
const logger = Logger.get('upload');
|
||||
@@ -79,7 +79,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
if (fileMaxViews < 0) return res.badRequest('invalid max views (max views < 0)');
|
||||
|
||||
// handle partial uploads before ratelimits
|
||||
if (req.headers['content-range']) {
|
||||
if (req.headers['content-range'] && zconfig.chunks.enabled) {
|
||||
// parses content-range header (bytes start-end/total)
|
||||
const [start, end, total] = req.headers['content-range']
|
||||
.replace('bytes ', '')
|
||||
@@ -104,63 +104,23 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
})}`
|
||||
);
|
||||
|
||||
const tempFile = join(tmpdir(), `zipline_partial_${identifier}_${start}_${end}`);
|
||||
const tempFile = join(zconfig.core.temp_directory, `zipline_partial_${identifier}_${start}_${end}`);
|
||||
logger.debug(`writing partial to disk ${tempFile}`);
|
||||
await writeFile(tempFile, req.files[0].buffer);
|
||||
|
||||
if (lastchunk) {
|
||||
const partials = await readdir(tmpdir()).then((files) =>
|
||||
files.filter((x) => x.startsWith(`zipline_partial_${identifier}`))
|
||||
);
|
||||
|
||||
const readChunks = partials.map((x) => {
|
||||
const [, , , start, end] = x.split('_');
|
||||
return { start: Number(start), end: Number(end), filename: x };
|
||||
});
|
||||
|
||||
// combine chunks
|
||||
const chunks = new Uint8Array(total);
|
||||
|
||||
for (let i = 0; i !== readChunks.length; ++i) {
|
||||
const chunkData = readChunks[i];
|
||||
|
||||
const buffer = await readFile(join(tmpdir(), chunkData.filename));
|
||||
await unlink(join(tmpdir(), readChunks[i].filename));
|
||||
|
||||
chunks.set(buffer, chunkData.start);
|
||||
}
|
||||
|
||||
const ext = filename.split('.').length === 1 ? '' : filename.split('.').pop();
|
||||
if (zconfig.uploader.disabled_extensions.includes(ext))
|
||||
return res.error('disabled extension recieved: ' + ext);
|
||||
const fileName = await formatFileName(format, filename);
|
||||
|
||||
let password = null;
|
||||
if (req.headers.password) {
|
||||
password = await hashPassword(req.headers.password as string);
|
||||
}
|
||||
|
||||
const compressionUsed = imageCompressionPercent && mimetype.startsWith('image/');
|
||||
let invis: InvisibleFile;
|
||||
const ext = filename.split('.').length === 1 ? '' : filename.split('.').pop();
|
||||
|
||||
const file = await prisma.file.create({
|
||||
data: {
|
||||
name: `${fileName}${compressionUsed ? '.jpg' : `${ext ? '.' : ''}${ext}`}`,
|
||||
mimetype,
|
||||
name: `${fileName}${ext ? '.' : ''}${ext}`,
|
||||
mimetype: req.headers.uploadtext ? 'text/plain' : mimetype,
|
||||
userId: user.id,
|
||||
embed: !!req.headers.embed,
|
||||
password,
|
||||
expiresAt: expiry,
|
||||
maxViews: fileMaxViews,
|
||||
originalName: req.headers['original-name'] ? filename ?? null : null,
|
||||
},
|
||||
});
|
||||
|
||||
if (req.headers.zws) invis = await createInvisImage(zconfig.uploader.length, file.id);
|
||||
|
||||
await datasource.save(file.name, Buffer.from(chunks));
|
||||
|
||||
logger.info(`User ${user.username} (${user.id}) uploaded ${file.name} (${file.id}) (chunked)`);
|
||||
let domain;
|
||||
if (req.headers['override-domain']) {
|
||||
domain = `${zconfig.core.return_https ? 'https' : 'http'}://${req.headers['override-domain']}`;
|
||||
@@ -170,28 +130,34 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
domain = `${zconfig.core.return_https ? 'https' : 'http'}://${req.headers.host}`;
|
||||
}
|
||||
|
||||
const responseUrl = `${domain}${zconfig.uploader.route === '/' ? '/' : zconfig.uploader.route + '/'}${
|
||||
invis ? invis.invis : encodeURI(file.name)
|
||||
}`;
|
||||
const responseUrl = `${domain}${
|
||||
zconfig.uploader.route === '/' ? '/' : zconfig.uploader.route + '/'
|
||||
}${encodeURI(file.name)}`;
|
||||
|
||||
response.files.push(responseUrl);
|
||||
new Worker('./dist/worker/upload.js', {
|
||||
workerData: {
|
||||
user,
|
||||
file: {
|
||||
id: file.id,
|
||||
filename: file.name,
|
||||
mimetype: file.mimetype,
|
||||
identifier,
|
||||
lastchunk,
|
||||
totalBytes: total,
|
||||
},
|
||||
response: {
|
||||
expiresAt: expiry,
|
||||
format,
|
||||
fileMaxViews,
|
||||
},
|
||||
headers: req.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (zconfig.discord?.upload) {
|
||||
await sendUpload(user, file, `${domain}/r/${invis ? invis.invis : file.name}`, responseUrl);
|
||||
}
|
||||
|
||||
if (zconfig.exif.enabled && zconfig.exif.remove_gps && mimetype.startsWith('image/')) {
|
||||
try {
|
||||
await removeGPSData(file);
|
||||
response.removed_gps = true;
|
||||
} catch (e) {
|
||||
logger.error(`Failed to remove GPS data from ${file.name} (${file.id}) - ${e.message}`);
|
||||
|
||||
response.removed_gps = false;
|
||||
}
|
||||
}
|
||||
|
||||
return res.json(response);
|
||||
return res.json({
|
||||
pending: true,
|
||||
files: [responseUrl],
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
@@ -260,7 +226,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
let mimetype = file.mimetype;
|
||||
|
||||
if (file.mimetype === 'application/octet-stream' && zconfig.uploader.assume_mimetypes) {
|
||||
const ext = file.originalname.split('.').pop();
|
||||
const ext = parse(file.originalname).ext.replace('.', '');
|
||||
const mime = await guess(ext);
|
||||
|
||||
if (!mime) response.assumed_mimetype = false;
|
||||
@@ -286,7 +252,8 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
},
|
||||
});
|
||||
|
||||
if (req.headers.zws) invis = await createInvisImage(zconfig.uploader.length, fileUpload.id);
|
||||
if (typeof req.headers.zws !== 'undefined' && (req.headers.zws as string).toLowerCase().match('true'))
|
||||
invis = await createInvisImage(zconfig.uploader.length, fileUpload.id);
|
||||
|
||||
if (compressionUsed) {
|
||||
const buffer = await sharp(file.buffer).jpeg({ quality: imageCompressionPercent }).toBuffer();
|
||||
@@ -315,7 +282,12 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
response.files.push(responseUrl);
|
||||
|
||||
if (zconfig.discord?.upload) {
|
||||
await sendUpload(user, fileUpload, `${domain}/r/${invis ? invis.invis : fileUpload.name}`, responseUrl);
|
||||
await sendUpload(
|
||||
user,
|
||||
fileUpload,
|
||||
`${domain}/r/${invis ? invis.invis : encodeURI(fileUpload.name)}`,
|
||||
responseUrl
|
||||
);
|
||||
}
|
||||
|
||||
if (zconfig.exif.enabled && zconfig.exif.remove_gps && fileUpload.mimetype.startsWith('image/')) {
|
||||
|
||||
@@ -3,6 +3,8 @@ import Logger from 'lib/logger';
|
||||
import prisma from 'lib/prisma';
|
||||
import { hashPassword } from 'lib/util';
|
||||
import { jsonUserReplacer } from 'lib/utils/client';
|
||||
import { formatRootUrl } from 'lib/utils/urls';
|
||||
import zconfig from 'lib/config';
|
||||
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
|
||||
|
||||
const logger = Logger.get('user');
|
||||
@@ -14,6 +16,14 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
where: {
|
||||
id: Number(id),
|
||||
},
|
||||
include: {
|
||||
files: {
|
||||
include: {
|
||||
thumbnail: true,
|
||||
},
|
||||
},
|
||||
Folder: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!target) return res.notFound('user not found');
|
||||
@@ -175,6 +185,22 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
} else {
|
||||
delete target.password;
|
||||
|
||||
if (user.superAdmin && target.superAdmin) {
|
||||
delete target.files;
|
||||
return res.json(target);
|
||||
}
|
||||
if (user.administrator && !user.superAdmin && (target.administrator || target.superAdmin)) {
|
||||
delete target.files;
|
||||
return res.json(target);
|
||||
}
|
||||
|
||||
for (const file of target.files) {
|
||||
(file as unknown as { url: string }).url = formatRootUrl(zconfig.uploader.route, file.name);
|
||||
if (file.thumbnail) {
|
||||
(file.thumbnail as unknown as string) = formatRootUrl('/r', file.thumbnail.name);
|
||||
}
|
||||
}
|
||||
|
||||
return res.json(target);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,18 +3,19 @@ import prisma from 'lib/prisma';
|
||||
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
if (!config.features.user_registration && !req.body.code)
|
||||
return res.badRequest('user registration is disabled');
|
||||
else if (!config.features.invites && req.body.code) return res.forbidden('user/invites are disabled');
|
||||
const { code, username } = req.body as { code?: string; username?: string };
|
||||
|
||||
if (!req.body?.code) return res.badRequest('no code');
|
||||
if (!req.body?.username) return res.badRequest('no username');
|
||||
if (!config.features.user_registration && !code) return res.badRequest('user registration is disabled');
|
||||
else if (!config.features.invites && code) return res.forbidden('user invites are disabled');
|
||||
|
||||
const { code, username } = req.body as { code: string; username: string };
|
||||
const invite = await prisma.invite.findUnique({
|
||||
where: { code },
|
||||
});
|
||||
if (!invite) return res.badRequest('invalid invite code');
|
||||
if (config.features.invites && !code) return res.badRequest('no code');
|
||||
else if (config.features.invites && code) {
|
||||
const invite = await prisma.invite.findUnique({
|
||||
where: { code },
|
||||
});
|
||||
if (!invite) return res.badRequest('invalid invite code');
|
||||
}
|
||||
if (!username) return res.badRequest('no username');
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { username },
|
||||
|
||||
@@ -5,7 +5,6 @@ import datasource from 'lib/datasource';
|
||||
import Logger from 'lib/logger';
|
||||
import prisma from 'lib/prisma';
|
||||
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
const logger = Logger.get('user::export');
|
||||
@@ -22,7 +21,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
|
||||
const zip = new Zip();
|
||||
const export_name = `zipline_export_${user.id}_${Date.now()}.zip`;
|
||||
const path = join(tmpdir(), export_name);
|
||||
const path = join(config.core.temp_directory, export_name);
|
||||
|
||||
logger.debug(`creating write stream at ${path}`);
|
||||
const write_stream = createWriteStream(path);
|
||||
@@ -121,18 +120,18 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
const parts = export_name.split('_');
|
||||
if (Number(parts[2]) !== user.id) return res.unauthorized('cannot access export owned by another user');
|
||||
|
||||
const stream = createReadStream(join(tmpdir(), export_name));
|
||||
const stream = createReadStream(join(config.core.temp_directory, export_name));
|
||||
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${export_name}"`);
|
||||
stream.pipe(res);
|
||||
} else {
|
||||
const files = await readdir(tmpdir());
|
||||
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(tmpdir(), name));
|
||||
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 });
|
||||
|
||||
@@ -14,10 +14,14 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
include: {
|
||||
thumbnail: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (let i = 0; i !== files.length; ++i) {
|
||||
await datasource.delete(files[i].name);
|
||||
if (files[i].thumbnail?.name) await datasource.delete(files[i].thumbnail.name);
|
||||
}
|
||||
|
||||
const { count } = await prisma.file.deleteMany({
|
||||
@@ -31,15 +35,49 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
} else {
|
||||
if (!req.body.id) return res.badRequest('no file id');
|
||||
|
||||
const file = await prisma.file.delete({
|
||||
let file = await prisma.file.findFirst({
|
||||
where: {
|
||||
id: req.body.id,
|
||||
userId: user.id,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
administrator: true,
|
||||
superAdmin: true,
|
||||
username: true,
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
thumbnail: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!file && (!user.administrator || !user.superAdmin)) return res.notFound('file not found');
|
||||
|
||||
file = await prisma.file.delete({
|
||||
where: {
|
||||
id: req.body.id,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
administrator: true,
|
||||
superAdmin: true,
|
||||
username: true,
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
thumbnail: true,
|
||||
},
|
||||
});
|
||||
|
||||
await datasource.delete(file.name);
|
||||
if (file.thumbnail?.name) await datasource.delete(file.thumbnail.name);
|
||||
|
||||
logger.info(`User ${user.username} (${user.id}) deleted an image ${file.name} (${file.id})`);
|
||||
logger.info(
|
||||
`User ${user.username} (${user.id}) deleted an image ${file.name} (${file.id}) owned by ${file.user.username} (${file.user.id})`
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
if (file.password) file.password = true;
|
||||
@@ -51,14 +89,33 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
|
||||
let file;
|
||||
|
||||
if (req.body.favorite !== null)
|
||||
if (req.body.favorite !== null) {
|
||||
file = await prisma.file.findFirst({
|
||||
where: {
|
||||
id: req.body.id,
|
||||
userId: user.id,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
administrator: true,
|
||||
superAdmin: true,
|
||||
username: true,
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!file && (!user.administrator || !user.superAdmin)) return res.notFound('file not found');
|
||||
|
||||
file = await prisma.file.update({
|
||||
where: { id: req.body.id },
|
||||
data: {
|
||||
favorite: req.body.favorite,
|
||||
},
|
||||
});
|
||||
|
||||
}
|
||||
// @ts-ignore
|
||||
if (file.password) file.password = true;
|
||||
return res.json(file);
|
||||
@@ -83,6 +140,8 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
maxViews: number;
|
||||
views: number;
|
||||
size: number;
|
||||
originalName: string;
|
||||
thumbnail?: { name: string };
|
||||
}[] = await prisma.file.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
@@ -102,11 +161,17 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
folderId: true,
|
||||
maxViews: true,
|
||||
size: true,
|
||||
originalName: true,
|
||||
thumbnail: 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].thumbnail as unknown as string) = formatRootUrl('/r', files[i].thumbnail.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (req.query.filter && req.query.filter === 'media')
|
||||
|
||||
@@ -58,12 +58,13 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
|
||||
return res.json(folder);
|
||||
} else {
|
||||
if (req.query.files instanceof Array) req.query.files = req.query.files[0];
|
||||
const folders = await prisma.folder.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
files: !!req.query.files,
|
||||
files: ((req.query.files as string) ?? 'false').toLowerCase() === 'true',
|
||||
id: true,
|
||||
name: true,
|
||||
userId: true,
|
||||
@@ -76,7 +77,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
},
|
||||
});
|
||||
|
||||
if (req.query.files) {
|
||||
if (((req.query.files as string) ?? 'false').toLowerCase() === 'true') {
|
||||
for (let i = 0; i !== folders.length; ++i) {
|
||||
const folder = folders[i];
|
||||
for (let j = 0; j !== folders[i].files.length; ++j) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import config from 'lib/config';
|
||||
import zconfig from 'lib/config';
|
||||
import Logger from 'lib/logger';
|
||||
import { discord_auth, github_auth, google_auth } from 'lib/oauth';
|
||||
import prisma from 'lib/prisma';
|
||||
@@ -18,7 +18,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
|
||||
return res.json({
|
||||
error: 'oauth token expired',
|
||||
redirect_uri: github_auth.oauth_url(config.oauth.github_client_id),
|
||||
redirect_uri: github_auth.oauth_url(zconfig.oauth.github_client_id),
|
||||
});
|
||||
}
|
||||
} else if (user.oauth.find((o) => o.provider === 'DISCORD')) {
|
||||
@@ -35,8 +35,8 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
return res.json({
|
||||
error: 'oauth token expired',
|
||||
redirect_uri: discord_auth.oauth_url(
|
||||
config.oauth.discord_client_id,
|
||||
`${config.core.return_https ? 'https' : 'http'}://${req.headers.host}`
|
||||
zconfig.oauth.discord_client_id,
|
||||
`${zconfig.core.return_https ? 'https' : 'http'}://${req.headers.host}`
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -47,8 +47,8 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: config.oauth.discord_client_id,
|
||||
client_secret: config.oauth.discord_client_secret,
|
||||
client_id: zconfig.oauth.discord_client_id,
|
||||
client_secret: zconfig.oauth.discord_client_secret,
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: provider.refresh,
|
||||
}),
|
||||
@@ -59,8 +59,8 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
return res.json({
|
||||
error: 'oauth token expired',
|
||||
redirect_uri: discord_auth.oauth_url(
|
||||
config.oauth.discord_client_id,
|
||||
`${config.core.return_https ? 'https' : 'http'}://${req.headers.host}`
|
||||
zconfig.oauth.discord_client_id,
|
||||
`${zconfig.core.return_https ? 'https' : 'http'}://${req.headers.host}`
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -90,8 +90,8 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
return res.json({
|
||||
error: 'oauth token expired',
|
||||
redirect_uri: google_auth.oauth_url(
|
||||
config.oauth.google_client_id,
|
||||
`${config.core.return_https ? 'https' : 'http'}://${req.headers.host}`
|
||||
zconfig.oauth.google_client_id,
|
||||
`${zconfig.core.return_https ? 'https' : 'http'}://${req.headers.host}`
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -101,8 +101,8 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: config.oauth.google_client_id,
|
||||
client_secret: config.oauth.google_client_secret,
|
||||
client_id: zconfig.oauth.google_client_id,
|
||||
client_secret: zconfig.oauth.google_client_secret,
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: provider.refresh,
|
||||
}),
|
||||
@@ -113,8 +113,8 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
return res.json({
|
||||
error: 'oauth token expired',
|
||||
redirect_uri: google_auth.oauth_url(
|
||||
config.oauth.google_client_id,
|
||||
`${config.core.return_https ? 'https' : 'http'}://${req.headers.host}`
|
||||
zconfig.oauth.google_client_id,
|
||||
`${zconfig.core.return_https ? 'https' : 'http'}://${req.headers.host}`
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -241,6 +241,14 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
}
|
||||
}
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: {
|
||||
sizeLimit: '50mb',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default withZipline(handler, {
|
||||
methods: ['GET', 'PATCH'],
|
||||
user: true,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { s } from '@sapphire/shapeshift';
|
||||
import config from 'lib/config';
|
||||
import prisma from 'lib/prisma';
|
||||
import { formatRootUrl } from 'lib/utils/urls';
|
||||
@@ -5,12 +7,27 @@ import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/wi
|
||||
|
||||
const pageCount = 16;
|
||||
|
||||
const sortByValidator = s.enum(
|
||||
...([
|
||||
'createdAt',
|
||||
'views',
|
||||
'expiresAt',
|
||||
'size',
|
||||
'name',
|
||||
'mimetype',
|
||||
] satisfies (keyof Prisma.FileOrderByWithRelationInput)[])
|
||||
);
|
||||
|
||||
const orderValidator = s.enum('asc', 'desc');
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
const { page, filter, count, favorite } = req.query as {
|
||||
const { page, filter, count, favorite, ...rest } = req.query as {
|
||||
page: string;
|
||||
filter: string;
|
||||
count: string;
|
||||
favorite: string;
|
||||
sortBy: string;
|
||||
order: string;
|
||||
};
|
||||
|
||||
const where = {
|
||||
@@ -33,7 +50,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
} satisfies Prisma.FileWhereInput;
|
||||
|
||||
if (count) {
|
||||
const count = await prisma.file.count({
|
||||
@@ -48,6 +65,14 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
if (!page) return res.badRequest('no page');
|
||||
if (isNaN(Number(page))) return res.badRequest('page is not a number');
|
||||
|
||||
// validate sortBy
|
||||
const sortBy = sortByValidator.run(rest.sortBy || 'createdAt');
|
||||
if (!sortBy.isOk()) return res.badRequest('invalid sortBy option');
|
||||
|
||||
// validate order
|
||||
const order = orderValidator.run(rest.order || 'desc');
|
||||
if (!sortBy.isOk()) return res.badRequest('invalid order option');
|
||||
|
||||
const files: {
|
||||
favorite: boolean;
|
||||
createdAt: Date;
|
||||
@@ -60,10 +85,11 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
folderId: number;
|
||||
size: number;
|
||||
password: string | boolean;
|
||||
thumbnail?: { name: string };
|
||||
}[] = await prisma.file.findMany({
|
||||
where,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
[sortBy.value]: order.value,
|
||||
},
|
||||
select: {
|
||||
createdAt: true,
|
||||
@@ -77,6 +103,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
folderId: true,
|
||||
size: true,
|
||||
password: true,
|
||||
thumbnail: true,
|
||||
},
|
||||
skip: page ? (Number(page) - 1) * pageCount : undefined,
|
||||
take: page ? pageCount : undefined,
|
||||
@@ -87,6 +114,9 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
if (file.password) file.password = true;
|
||||
|
||||
(file as unknown as { url: string }).url = formatRootUrl(config.uploader.route, file.name);
|
||||
if (files[i].thumbnail) {
|
||||
(files[i].thumbnail as unknown as string) = formatRootUrl('/r', files[i].thumbnail.name);
|
||||
}
|
||||
}
|
||||
|
||||
return res.json(files);
|
||||
|
||||
40
src/pages/api/user/pending.ts
Normal file
40
src/pages/api/user/pending.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import prisma from 'lib/prisma';
|
||||
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
if (req.method === 'DELETE') {
|
||||
const fileIds = req.body.id as number[];
|
||||
|
||||
const existingFiles = await prisma.incompleteFile.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: fileIds,
|
||||
},
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
const incFiles = await prisma.incompleteFile.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: existingFiles.map((x) => x.id),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return res.json(incFiles);
|
||||
} else {
|
||||
const files = await prisma.incompleteFile.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
return res.json(files);
|
||||
}
|
||||
}
|
||||
|
||||
export default withZipline(handler, {
|
||||
methods: ['GET', 'DELETE'],
|
||||
user: true,
|
||||
});
|
||||
@@ -27,11 +27,15 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
folderId: true,
|
||||
size: true,
|
||||
favorite: true,
|
||||
thumbnail: 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].thumbnail as unknown as string) = formatRootUrl('/r', files[i].thumbnail.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (req.query.filter && req.query.filter === 'media')
|
||||
|
||||
@@ -7,7 +7,7 @@ async function handler(_: NextApiReq, res: NextApiRes) {
|
||||
|
||||
const pkg = JSON.parse(await readFile('package.json', 'utf8'));
|
||||
|
||||
const re = await fetch('https://zipline.diced.tech/api/version?c=' + pkg.version);
|
||||
const re = await fetch('https://zipline.diced.vercel.app/api/version?c=' + pkg.version);
|
||||
const json = await re.json();
|
||||
|
||||
let updateToType = 'stable';
|
||||
|
||||
@@ -22,7 +22,13 @@ import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
export { getServerSideProps } from 'middleware/getServerSideProps';
|
||||
|
||||
export default function Login({ title, user_registration, oauth_registration, oauth_providers: unparsed }) {
|
||||
export default function Login({
|
||||
title,
|
||||
user_registration,
|
||||
oauth_registration,
|
||||
bypass_local_login,
|
||||
oauth_providers: unparsed,
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
// totp modal
|
||||
@@ -34,6 +40,9 @@ export default function Login({ title, user_registration, oauth_registration, oa
|
||||
|
||||
const oauth_providers = JSON.parse(unparsed);
|
||||
|
||||
const show_local_login =
|
||||
router.query.local === 'true' || !(bypass_local_login && oauth_providers?.length > 0);
|
||||
|
||||
const icons = {
|
||||
GitHub: IconBrandGithub,
|
||||
Discord: IconBrandDiscordFilled,
|
||||
@@ -99,6 +108,12 @@ export default function Login({ title, user_registration, oauth_registration, oa
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
// if the user includes `local=true` as a query param, show the login form
|
||||
// otherwise, redirect to the oauth login if there is only one registered provider
|
||||
if (bypass_local_login && oauth_providers?.length === 1 && router.query.local !== 'true') {
|
||||
await router.push(oauth_providers[0].url);
|
||||
}
|
||||
|
||||
const a = await fetch('/api/user');
|
||||
if (a.ok) await router.push('/dashboard');
|
||||
})();
|
||||
@@ -152,7 +167,7 @@ export default function Login({ title, user_registration, oauth_registration, oa
|
||||
<Center sx={{ height: '100vh' }}>
|
||||
<Card radius='md'>
|
||||
<Title size={30} align='left'>
|
||||
{title}
|
||||
{bypass_local_login ? ` Login to ${title} with` : title}
|
||||
</Title>
|
||||
|
||||
{oauth_registration && (
|
||||
@@ -165,7 +180,7 @@ export default function Login({ title, user_registration, oauth_registration, oa
|
||||
variant='outline'
|
||||
radius='md'
|
||||
fullWidth
|
||||
leftIcon={<Icon height={'15'} width={'15'} />}
|
||||
leftIcon={<Icon size='1rem' />}
|
||||
my='xs'
|
||||
component={Link}
|
||||
href={url}
|
||||
@@ -174,41 +189,42 @@ export default function Login({ title, user_registration, oauth_registration, oa
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
|
||||
<Divider my='xs' label='or' labelPosition='center' />
|
||||
{show_local_login && <Divider my='xs' label='or' labelPosition='center' />}
|
||||
</>
|
||||
)}
|
||||
|
||||
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
|
||||
<TextInput
|
||||
my='xs'
|
||||
radius='md'
|
||||
size='md'
|
||||
id='username'
|
||||
label='Username'
|
||||
{...form.getInputProps('username')}
|
||||
/>
|
||||
<PasswordInput
|
||||
my='xs'
|
||||
radius='md'
|
||||
size='md'
|
||||
id='password'
|
||||
label='Password'
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
{show_local_login && (
|
||||
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
|
||||
<TextInput
|
||||
my='xs'
|
||||
radius='md'
|
||||
size='md'
|
||||
id='username'
|
||||
label='Username'
|
||||
{...form.getInputProps('username')}
|
||||
/>
|
||||
<PasswordInput
|
||||
my='xs'
|
||||
radius='md'
|
||||
size='md'
|
||||
id='password'
|
||||
label='Password'
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
|
||||
<Group position='apart'>
|
||||
{user_registration && (
|
||||
<Anchor size='xs' href='/auth/register' component={Link}>
|
||||
Don't have an account? Register
|
||||
</Anchor>
|
||||
)}
|
||||
<Group position='apart'>
|
||||
{user_registration && (
|
||||
<Anchor size='xs' href='/auth/register' component={Link}>
|
||||
Don't have an account? Register
|
||||
</Anchor>
|
||||
)}
|
||||
|
||||
<Button size='sm' p='xs' radius='md' my='xs' type='submit' loading={loading}>
|
||||
Login
|
||||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
<Button size='sm' p='xs' radius='md' my='xs' type='submit' loading={loading}>
|
||||
Login
|
||||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
)}
|
||||
</Card>
|
||||
</Center>
|
||||
</>
|
||||
|
||||
@@ -6,14 +6,13 @@ import useFetch from 'hooks/useFetch';
|
||||
import config from 'lib/config';
|
||||
import prisma from 'lib/prisma';
|
||||
import { userSelector } from 'lib/recoil/user';
|
||||
import { randomChars } from 'lib/util';
|
||||
import { GetServerSideProps } from 'next';
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
|
||||
export default function Register({ code, title, user_registration }) {
|
||||
export default function Register({ code = undefined, title, user_registration }) {
|
||||
const [active, setActive] = useState(0);
|
||||
const [username, setUsername] = useState('');
|
||||
const [usernameError, setUsernameError] = useState('');
|
||||
@@ -196,20 +195,9 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||
notFound: true,
|
||||
};
|
||||
|
||||
const code = randomChars(4);
|
||||
const temp = await prisma.invite.create({
|
||||
data: {
|
||||
code,
|
||||
createdById: 1,
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug(`request to access user registration, creating temporary invite ${JSON.stringify(temp)}`);
|
||||
|
||||
return {
|
||||
props: {
|
||||
title: config.website.title,
|
||||
code,
|
||||
user_registration: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -17,7 +17,9 @@ export default function UploadPage(props) {
|
||||
<title>{title}</title>
|
||||
</Head>
|
||||
<Layout props={props}>
|
||||
<File chunks={{ chunks_size: props.chunks_size, max_size: props.max_size }} />
|
||||
<File
|
||||
chunks={{ chunks_size: props.chunks_size, max_size: props.max_size, enabled: props.chunks_enabled }}
|
||||
/>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
|
||||
42
src/pages/dashboard/users/[id].tsx
Normal file
42
src/pages/dashboard/users/[id].tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { LoadingOverlay } from '@mantine/core';
|
||||
import Layout from 'components/Layout';
|
||||
import UserFiles from 'components/pages/Users/UserFiles';
|
||||
import useLogin from 'hooks/useLogin';
|
||||
import Head from 'next/head';
|
||||
import { getServerSideProps as middlewareProps } from 'middleware/getServerSideProps';
|
||||
import { GetServerSideProps } from 'next';
|
||||
|
||||
export default function UsersId(props) {
|
||||
const { loading } = useLogin();
|
||||
|
||||
if (loading) return <LoadingOverlay visible={loading} />;
|
||||
|
||||
const title = `${props.title} - User - ${props.userId}`;
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{title}</title>
|
||||
</Head>
|
||||
<Layout props={props}>
|
||||
<UserFiles
|
||||
userId={props.userId}
|
||||
disableMediaPreview={props.disable_media_preview}
|
||||
exifEnabled={props.exif_enabled}
|
||||
compress={props.compress}
|
||||
/>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||
const { id } = context.params as { id: string };
|
||||
// @ts-ignore
|
||||
const { props } = await middlewareProps(context);
|
||||
return {
|
||||
props: {
|
||||
userId: id,
|
||||
...props,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -10,7 +10,7 @@ export default function UsersPage(props) {
|
||||
|
||||
if (loading) return <LoadingOverlay visible={loading} />;
|
||||
|
||||
const title = `${props.title} - User`;
|
||||
const title = `${props.title} - Users`;
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
@@ -12,7 +12,8 @@ export default function OauthError({ error, provider }) {
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setRemaining((remaining) => remaining - 1);
|
||||
if (remaining > 0) setRemaining((remaining) => remaining - 1);
|
||||
else clearInterval(interval);
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
@@ -43,7 +44,7 @@ export default function OauthError({ error, provider }) {
|
||||
</Title>
|
||||
<MutedText sx={{ fontSize: 40, fontWeight: 500 }}>{error}</MutedText>
|
||||
<MutedText>
|
||||
Redirecting to login in {remaining} second{remaining === 1 ? 's' : ''}
|
||||
Redirecting to login in {remaining} second{remaining !== 1 ? 's' : ''}
|
||||
</MutedText>
|
||||
<Button component={Link} href='/dashboard'>
|
||||
Head to the Dashboard
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Box, Button, Modal, PasswordInput } from '@mantine/core';
|
||||
import type { File } from '@prisma/client';
|
||||
import type { File, Thumbnail } from '@prisma/client';
|
||||
import AnchorNext from 'components/AnchorNext';
|
||||
import config from 'lib/config';
|
||||
import exts from 'lib/exts';
|
||||
import prisma from 'lib/prisma';
|
||||
import { parseString } from 'lib/utils/parser';
|
||||
@@ -11,24 +10,24 @@ 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';
|
||||
|
||||
export default function EmbeddedFile({
|
||||
file,
|
||||
user,
|
||||
pass,
|
||||
prismRender,
|
||||
onDash,
|
||||
host,
|
||||
compress,
|
||||
}: {
|
||||
file: File & { imageProps?: HTMLImageElement };
|
||||
file: File & { imageProps?: HTMLImageElement; thumbnail: Thumbnail };
|
||||
user: UserExtended;
|
||||
pass: boolean;
|
||||
prismRender: boolean;
|
||||
onDash: boolean;
|
||||
host: string;
|
||||
compress?: boolean;
|
||||
}) {
|
||||
const dataURL = (route: string) =>
|
||||
`${route}/${encodeURI(file.name)}?compress=${compress == null ? onDash : compress}`;
|
||||
const dataURL = (route: string) => `${route}/${encodeURI(file.name)}?compress=${compress ?? false}`;
|
||||
|
||||
const router = useRouter();
|
||||
const [opened, setOpened] = useState(pass);
|
||||
@@ -103,26 +102,36 @@ export default function EmbeddedFile({
|
||||
{file.mimetype.startsWith('image') && (
|
||||
<>
|
||||
<meta property='og:type' content='image' />
|
||||
<meta property='og:image' itemProp='image' content={`/r/${file.name}`} />
|
||||
<meta property='og:url' content={`/r/${file.name}`} />
|
||||
<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} />
|
||||
</>
|
||||
)}
|
||||
{file.mimetype.startsWith('video') && (
|
||||
<>
|
||||
<meta name='twitter:card' content='player' />
|
||||
<meta name='twitter:player:stream' content={`/r/${file.name}`} />
|
||||
<meta name='twitter:player' content={`${host}/r/${file.name}`} />
|
||||
<meta name='twitter:player:stream' content={`${host}/r/${file.name}`} />
|
||||
<meta name='twitter:player:width' content='720' />
|
||||
<meta name='twitter:player:height' content='480' />
|
||||
<meta name='twitter:player:stream:content_type' content={file.mimetype} />
|
||||
<meta name='twitter:title' content={file.name} />
|
||||
|
||||
<meta property='og:url' content={`/r/${file.name}`} />
|
||||
<meta property='og:video' content={`/r/${file.name}`} />
|
||||
<meta property='og:video:url' content={`/r/${file.name}`} />
|
||||
<meta property='og:video:secure_url' content={`/r/${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: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' />
|
||||
@@ -131,19 +140,22 @@ export default function EmbeddedFile({
|
||||
{file.mimetype.startsWith('audio') && (
|
||||
<>
|
||||
<meta name='twitter:card' content='player' />
|
||||
<meta name='twitter:player:stream' content={`/r/${file.name}`} />
|
||||
<meta name='twitter:player' content={`${host}/r/${file.name}`} />
|
||||
<meta name='twitter:player:stream' content={`${host}/r/${file.name}`} />
|
||||
<meta name='twitter:player:stream:content_type' content={file.mimetype} />
|
||||
<meta name='twitter: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={`/r/${file.name}`} />
|
||||
<meta property='og:audio' content={`/r/${file.name}`} />
|
||||
<meta property='og:audio:secure_url' content={`/r/${file.name}`} />
|
||||
<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:type' content={file.mimetype} />
|
||||
</>
|
||||
)}
|
||||
{!file.mimetype.startsWith('video') && !file.mimetype.startsWith('image') && (
|
||||
<meta property='og:url' content={`/r/${file.name}`} />
|
||||
<meta property='og:url' content={`${host}/r/${file.name}`} />
|
||||
)}
|
||||
<title>{file.name}</title>
|
||||
</Head>
|
||||
@@ -206,9 +218,27 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||
where: {
|
||||
OR: [{ name: id }, { invisible: { invis: decodeURI(encodeURI(id)) } }],
|
||||
},
|
||||
include: {
|
||||
thumbnail: true,
|
||||
},
|
||||
});
|
||||
let host = context.req.headers.host;
|
||||
if (!file) return { notFound: true };
|
||||
|
||||
const proto = context.req.headers['x-forwarded-proto'];
|
||||
try {
|
||||
if (
|
||||
JSON.parse(context.req.headers['cf-visitor'] as string).scheme === 'https' ||
|
||||
proto === 'https' ||
|
||||
zconfig.core.return_https
|
||||
)
|
||||
host = `https://${host}`;
|
||||
else host = `http://${host}`;
|
||||
} catch (e) {
|
||||
if (proto === 'https' || zconfig.core.return_https) host = `https://${host}`;
|
||||
else host = `http://${host}`;
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: file.userId,
|
||||
@@ -235,10 +265,11 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||
if (file.password) file.password = true;
|
||||
return {
|
||||
props: {
|
||||
image: file,
|
||||
file,
|
||||
user,
|
||||
pass,
|
||||
prismRender: true,
|
||||
host,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -256,6 +287,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||
props: {
|
||||
file,
|
||||
user,
|
||||
host,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -268,7 +300,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||
file,
|
||||
user,
|
||||
pass: file.password ? true : false,
|
||||
onDash: config.core.compression.on_dashboard,
|
||||
host,
|
||||
compress,
|
||||
},
|
||||
};
|
||||
|
||||
30
src/scripts/clear-temp.ts
Normal file
30
src/scripts/clear-temp.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import config from 'lib/config';
|
||||
import { readdir, rm } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
|
||||
async function main() {
|
||||
const temp = config.core.temp_directory;
|
||||
|
||||
if (!existsSync(temp)) {
|
||||
console.log('Temp directory does not exist, exiting..');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const files = (await readdir(temp)).filter(
|
||||
(x) => x.startsWith('zipline_partial_') || x.startsWith('zipline_thumb_')
|
||||
);
|
||||
if (files.length === 0) {
|
||||
console.log('No partial files found, exiting..');
|
||||
process.exit(0);
|
||||
} else {
|
||||
for (const file of files) {
|
||||
console.log(`Deleting ${file}`);
|
||||
await rm(join(temp, file));
|
||||
}
|
||||
console.log('Done!');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -47,6 +47,9 @@ async function main() {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.$disconnect();
|
||||
|
||||
console.log(`Deleted ${count} files from the database.`);
|
||||
|
||||
for (let i = 0; i !== toDelete.length; ++i) {
|
||||
|
||||
@@ -52,6 +52,8 @@ async function main() {
|
||||
await datasource.save(file, await readFile(join(directory, file)));
|
||||
}
|
||||
console.log(`Finished copying files to ${config.datasource.type} storage.`);
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import config from 'lib/config';
|
||||
import { migrations } from 'server/util';
|
||||
import { inspect } from 'util';
|
||||
|
||||
async function main() {
|
||||
const extras = (process.argv[2] ?? '').split(',');
|
||||
@@ -13,6 +14,7 @@ async function main() {
|
||||
const select = {
|
||||
username: true,
|
||||
administrator: true,
|
||||
superAdmin: true,
|
||||
id: true,
|
||||
};
|
||||
for (let i = 0; i !== extras.length; ++i) {
|
||||
@@ -30,7 +32,11 @@ async function main() {
|
||||
select,
|
||||
});
|
||||
|
||||
console.log(JSON.stringify(users, null, 2));
|
||||
await prisma.$disconnect();
|
||||
|
||||
console.log(inspect(users, false, 4, true));
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
@@ -8,13 +8,42 @@ async function main() {
|
||||
await migrations();
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
let notFound = false;
|
||||
|
||||
const files = await prisma.file.findMany();
|
||||
const files = await prisma.file.findMany({
|
||||
...(process.argv.includes('--force-update')
|
||||
? undefined
|
||||
: {
|
||||
where: {
|
||||
size: 0,
|
||||
},
|
||||
}),
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
size: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`The script will attempt to query the size of ${files.length} files.`);
|
||||
|
||||
for (let i = 0; i !== files.length; ++i) {
|
||||
const file = files[i];
|
||||
if (!(await datasource.get(file.name))) {
|
||||
if (process.argv.includes('--force-delete')) {
|
||||
console.log(`File ${file.name} does not exist. Deleting...`);
|
||||
await prisma.file.delete({
|
||||
where: {
|
||||
id: file.id,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
} else {
|
||||
notFound ? null : (notFound = true);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const size = await datasource.size(file.name);
|
||||
if (size === 0) {
|
||||
console.log(`File ${file.name} has a size of 0 bytes. Ignoring...`);
|
||||
@@ -31,7 +60,14 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Done.');
|
||||
await prisma.$disconnect();
|
||||
|
||||
notFound
|
||||
? console.log(
|
||||
'At least one file has been found to not exist in the datasource but was on the database. To remove these files, run the script with the --force-delete flag.'
|
||||
)
|
||||
: console.log('Done.');
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import config from 'lib/config';
|
||||
import { inspect } from 'util';
|
||||
|
||||
console.log(JSON.stringify(config, null, 2));
|
||||
console.log(inspect(config, { depth: Infinity, colors: true }));
|
||||
|
||||
@@ -66,11 +66,15 @@ async function main() {
|
||||
data,
|
||||
});
|
||||
|
||||
await prisma.$disconnect();
|
||||
|
||||
if (args[1] === 'password') {
|
||||
parsed = '***';
|
||||
}
|
||||
|
||||
console.log(`Updated user ${user.id} with ${args[1]} = ${parsed}`);
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
@@ -7,6 +7,8 @@ function postUrlDecorator(fastify: FastifyInstance, _, done) {
|
||||
done();
|
||||
|
||||
async function postUrl(this: FastifyReply, url: Url) {
|
||||
if (!url) return true;
|
||||
|
||||
const nUrl = await this.server.prisma.url.update({
|
||||
where: {
|
||||
id: url.id,
|
||||
|
||||
@@ -7,6 +7,7 @@ function preFileDecorator(fastify: FastifyInstance, _, done) {
|
||||
done();
|
||||
|
||||
async function preFile(this: FastifyReply, file: File) {
|
||||
if (file.favorite) return false;
|
||||
if (file.expiresAt && file.expiresAt < new Date()) {
|
||||
await this.server.datasource.delete(file.name);
|
||||
await this.server.prisma.file.delete({ where: { id: file.id } });
|
||||
|
||||
@@ -11,7 +11,7 @@ function rawFileDecorator(fastify: FastifyInstance, _, done) {
|
||||
done();
|
||||
|
||||
async function rawFile(this: FastifyReply, id: string) {
|
||||
const { download, compress } = this.request.query as { download?: string; compress?: boolean };
|
||||
const { download, compress = 'false' } = this.request.query as { download?: string; compress?: string };
|
||||
|
||||
const data = await this.server.datasource.get(id);
|
||||
if (!data) return this.notFound();
|
||||
@@ -22,11 +22,11 @@ function rawFileDecorator(fastify: FastifyInstance, _, done) {
|
||||
|
||||
if (
|
||||
this.server.config.core.compression.enabled &&
|
||||
compress &&
|
||||
compress?.match(/^true$/i) &&
|
||||
!this.request.headers['X-Zipline-NoCompress'] &&
|
||||
!!this.request.headers['accept-encoding']
|
||||
)
|
||||
if (size > this.server.config.core.compression.threshold)
|
||||
if (size > this.server.config.core.compression.threshold && mimetype.match(/^(image|video|text)/))
|
||||
return this.send(useCompress.call(this, data));
|
||||
this.header('Content-Length', size);
|
||||
return this.send(data);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import config from 'lib/config';
|
||||
import datasource from 'lib/datasource';
|
||||
import Logger from 'lib/logger';
|
||||
import { version } from '../../package.json';
|
||||
import { getStats } from 'server/util';
|
||||
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';
|
||||
@@ -100,6 +101,18 @@ async function start() {
|
||||
return reply.type('image/x-icon').send(favicon);
|
||||
});
|
||||
|
||||
if (config.features.robots_txt) {
|
||||
server.get('/robots.txt', async (_, reply) => {
|
||||
return reply.type('text/plain').send(`User-Agent: *
|
||||
Disallow: /r/
|
||||
Disallow: /api/
|
||||
Disallow: /view/
|
||||
Disallow: ${config.uploader.route}
|
||||
Disallow: ${config.urls.route}
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
// makes sure to handle both in one route as you cant have two handlers with the same route
|
||||
if (config.urls.route === '/' && config.uploader.route === '/') {
|
||||
server.route({
|
||||
@@ -171,9 +184,12 @@ async function start() {
|
||||
|
||||
await clearInvites.bind(server)();
|
||||
await stats.bind(server)();
|
||||
if (config.features.thumbnails) await thumbs.bind(server)();
|
||||
|
||||
setInterval(() => clearInvites.bind(server)(), config.core.invites_interval * 1000);
|
||||
setInterval(() => stats.bind(server)(), config.core.stats_interval * 1000);
|
||||
if (config.features.thumbnails)
|
||||
setInterval(() => thumbs.bind(server)(), config.core.thumbnails_interval * 1000);
|
||||
}
|
||||
|
||||
async function stats(this: FastifyInstance) {
|
||||
@@ -205,6 +221,49 @@ async function clearInvites(this: FastifyInstance) {
|
||||
logger.child('invites').debug(`deleted ${count} used invites`);
|
||||
}
|
||||
|
||||
async function thumbs(this: FastifyInstance) {
|
||||
const videoFiles = await this.prisma.file.findMany({
|
||||
where: {
|
||||
mimetype: {
|
||||
startsWith: 'video/',
|
||||
},
|
||||
thumbnail: null,
|
||||
},
|
||||
include: {
|
||||
thumbnail: true,
|
||||
},
|
||||
});
|
||||
|
||||
// avoids reaching prisma connection limit
|
||||
const MAX_THUMB_THREADS = 4;
|
||||
|
||||
// make all the files fit into 4 arrays
|
||||
const chunks = [];
|
||||
|
||||
for (let i = 0; i !== MAX_THUMB_THREADS; ++i) {
|
||||
chunks.push([]);
|
||||
|
||||
for (let j = i; j < videoFiles.length; j += MAX_THUMB_THREADS) {
|
||||
chunks[i].push(videoFiles[j]);
|
||||
}
|
||||
}
|
||||
|
||||
logger.child('thumbnail').debug(`starting ${chunks.length} thumbnail threads`);
|
||||
|
||||
for (let i = 0; i !== chunks.length; ++i) {
|
||||
const chunk = chunks[i];
|
||||
if (chunk.length === 0) continue;
|
||||
|
||||
logger.child('thumbnail').debug(`starting thumbnail generation for ${chunk.length} videos`);
|
||||
|
||||
new Worker('./dist/worker/thumbnail.js', {
|
||||
workerData: {
|
||||
videos: chunk,
|
||||
},
|
||||
}).on('error', (err) => logger.child('thumbnail').error(err));
|
||||
}
|
||||
}
|
||||
|
||||
function genFastifyOpts(): FastifyServerOptions {
|
||||
const opts = {};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import fastifyPlugin from 'fastify-plugin';
|
||||
import { mkdir } from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import { mkdir, readdir } from 'fs/promises';
|
||||
import type { Config } from 'lib/config/Config';
|
||||
|
||||
async function configPlugin(fastify: FastifyInstance, config: Config) {
|
||||
@@ -16,7 +17,9 @@ async function configPlugin(fastify: FastifyInstance, config: Config) {
|
||||
.error(
|
||||
'The config file is located at `.env.local`, or if using docker-compose you can change the variables in the `docker-compose.yml` file.'
|
||||
)
|
||||
.error('It is recomended to use a secret that is alphanumeric and randomized.')
|
||||
.error(
|
||||
'It is recomended to use a secret that is alphanumeric and randomized. If you include special characters, surround the secret with quotes.'
|
||||
)
|
||||
.error('A way you can generate this is through a password manager you may have.');
|
||||
|
||||
process.exit(1);
|
||||
@@ -26,6 +29,24 @@ async function configPlugin(fastify: FastifyInstance, config: Config) {
|
||||
await mkdir(config.datasource.local.directory, { recursive: true });
|
||||
}
|
||||
|
||||
if (!existsSync(config.core.temp_directory)) {
|
||||
await mkdir(config.core.temp_directory, { recursive: true });
|
||||
} else {
|
||||
const files = await readdir(config.core.temp_directory);
|
||||
if (
|
||||
files.filter((x: string) => x.startsWith('zipline_partial_') || x.startsWith('zipline-exif-read-'))
|
||||
.length > 0
|
||||
)
|
||||
fastify.logger
|
||||
.error("Found temporary files in Zipline's temp directory.")
|
||||
.error('This can happen if Zipline crashes or is stopped while chunking a file.')
|
||||
.error(
|
||||
'If you are sure that no files are currently being processed, you can delete the files in the temp directory.'
|
||||
)
|
||||
.error('The temp directory is located at: ' + config.core.temp_directory)
|
||||
.error('If you are unsure, you can safely ignore this message.');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ export async function migrations() {
|
||||
logger.error(
|
||||
`Unable to connect to database \`${process.env.DATABASE_URL}\`, check your database connection`
|
||||
);
|
||||
logger.debug(error);
|
||||
} else {
|
||||
logger.error('Failed to migrate database... exiting...');
|
||||
logger.error(error);
|
||||
|
||||
131
src/worker/thumbnail.ts
Normal file
131
src/worker/thumbnail.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { type File, PrismaClient, type Thumbnail } from '@prisma/client';
|
||||
import { spawn } from 'child_process';
|
||||
import ffmpeg from 'ffmpeg-static';
|
||||
import { createWriteStream } from 'fs';
|
||||
import { rm } from 'fs/promises';
|
||||
import Logger from 'lib/logger';
|
||||
import { randomChars } from 'lib/util';
|
||||
import { join } from 'path';
|
||||
import { isMainThread, workerData } from 'worker_threads';
|
||||
import datasource from 'lib/datasource';
|
||||
import config from 'lib/config';
|
||||
|
||||
const { videos } = workerData as {
|
||||
videos: (File & {
|
||||
thumbnail: Thumbnail;
|
||||
})[];
|
||||
};
|
||||
|
||||
const logger = Logger.get('worker::thumbnail').child(randomChars(4));
|
||||
|
||||
logger.debug(`thumbnail generation for ${videos.length} videos`);
|
||||
|
||||
if (isMainThread) {
|
||||
logger.error('worker is not a thread');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function loadThumbnail(path) {
|
||||
const args = ['-i', path, '-frames:v', '1', '-f', 'mjpeg', 'pipe:1'];
|
||||
|
||||
const child = spawn(ffmpeg, args, { stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
|
||||
const data: Buffer = await new Promise((resolve, reject) => {
|
||||
const buffers = [];
|
||||
|
||||
child.stdout.on('data', (chunk) => {
|
||||
buffers.push(chunk);
|
||||
});
|
||||
|
||||
child.once('error', 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}`);
|
||||
|
||||
reject(new Error(`child exited with code ${code}`));
|
||||
} else {
|
||||
const buffer = Buffer.allocUnsafe(buffers.reduce((acc, val) => acc + val.length, 0));
|
||||
|
||||
let offset = 0;
|
||||
for (let i = 0; i !== buffers.length; ++i) {
|
||||
const chunk = buffers[i];
|
||||
chunk.copy(buffer, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
resolve(buffer);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
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 fileWriteStream = createWriteStream(tmpFile);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
stream.pipe(fileWriteStream);
|
||||
stream.once('error', reject);
|
||||
stream.once('end', resolve);
|
||||
});
|
||||
|
||||
return tmpFile;
|
||||
}
|
||||
|
||||
async function start() {
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
for (let i = 0; i !== videos.length; ++i) {
|
||||
const file = videos[i];
|
||||
if (!file.mimetype.startsWith('video/')) {
|
||||
logger.info('file is not a video');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (file.thumbnail) {
|
||||
logger.info('thumbnail already exists');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
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`);
|
||||
|
||||
const { thumbnail: thumb } = await prisma.file.update({
|
||||
where: {
|
||||
id: file.id,
|
||||
},
|
||||
data: {
|
||||
thumbnail: {
|
||||
create: {
|
||||
name: `.thumb-${file.id}.jpg`,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
thumbnail: true,
|
||||
},
|
||||
});
|
||||
|
||||
await datasource.save(thumb.name, thumbnail);
|
||||
|
||||
logger.info(`thumbnail saved - ${thumb.name}`);
|
||||
logger.debug(`thumbnail ${JSON.stringify(thumb)}`);
|
||||
|
||||
logger.debug(`removing tmp file: ${tmpFile}`);
|
||||
await rm(tmpFile);
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
start();
|
||||
218
src/worker/upload.ts
Normal file
218
src/worker/upload.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { readdir, readFile, open, rm } from 'fs/promises';
|
||||
import type { NameFormat } from 'lib/format';
|
||||
import Logger from 'lib/logger';
|
||||
import type { UserExtended } from 'middleware/withZipline';
|
||||
import { isMainThread, workerData } from 'worker_threads';
|
||||
|
||||
import prisma from 'lib/prisma';
|
||||
import { join } from 'path';
|
||||
import { IncompleteFile, InvisibleFile } from '@prisma/client';
|
||||
import { removeGPSData } from 'lib/utils/exif';
|
||||
import { sendUpload } from 'lib/discord';
|
||||
import { createInvisImage, hashPassword } from 'lib/util';
|
||||
|
||||
export type UploadWorkerData = {
|
||||
user: UserExtended;
|
||||
file: {
|
||||
id: number;
|
||||
filename: string;
|
||||
mimetype: string;
|
||||
identifier: string;
|
||||
lastchunk: boolean;
|
||||
totalBytes: number;
|
||||
};
|
||||
response: {
|
||||
expiresAt?: Date;
|
||||
format: NameFormat;
|
||||
fileMaxViews?: number;
|
||||
};
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
|
||||
const { user, file, response, headers } = workerData as UploadWorkerData;
|
||||
|
||||
const logger = Logger.get('worker::upload').child(file?.identifier ?? 'unknown-ident');
|
||||
|
||||
if (isMainThread) {
|
||||
logger.error('worker is not a thread');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!file.lastchunk) {
|
||||
logger.error('lastchunk is false, worker should not have been started');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!config.chunks.enabled) {
|
||||
logger.error('chunks are not enabled, worker should not have been started');
|
||||
if (file.id) {
|
||||
prisma.file.delete({ where: { id: file.id } }).then(() => {
|
||||
logger.debug('deleted a file entry due to anomalous worker start');
|
||||
process.exit(1);
|
||||
});
|
||||
} else process.exit(1);
|
||||
}
|
||||
|
||||
start();
|
||||
|
||||
async function start() {
|
||||
logger.debug('starting worker');
|
||||
|
||||
const partials = await readdir(config.core.temp_directory).then((files) =>
|
||||
files.filter((x) => x.startsWith(`zipline_partial_${file.identifier}`))
|
||||
);
|
||||
|
||||
const readChunks = partials.map((x) => {
|
||||
const [, , , start, end] = x.split('_');
|
||||
return { start: Number(start), end: Number(end), filename: x };
|
||||
});
|
||||
|
||||
const incompleteFile = await prisma.incompleteFile.create({
|
||||
data: {
|
||||
data: {
|
||||
file,
|
||||
},
|
||||
chunks: readChunks.length,
|
||||
chunksComplete: 0,
|
||||
status: 'PENDING',
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
const ext = file.filename.split('.').length === 1 ? '' : file.filename.split('.').pop();
|
||||
|
||||
let fd;
|
||||
|
||||
if (config.datasource.type === 'local') {
|
||||
fd = await open(join(config.datasource.local.directory, file.filename), 'w');
|
||||
} else {
|
||||
fd = new Uint8Array(file.totalBytes);
|
||||
}
|
||||
|
||||
for (let i = 0; i !== readChunks.length; ++i) {
|
||||
const chunk = readChunks[i];
|
||||
|
||||
const buffer = await readFile(join(config.core.temp_directory, chunk.filename));
|
||||
|
||||
if (config.datasource.type === 'local') {
|
||||
const { bytesWritten } = await fd.write(buffer, 0, buffer.length, chunk.start);
|
||||
logger.child('fd').debug(`wrote ${bytesWritten} bytes to file`);
|
||||
} else {
|
||||
fd.set(buffer, chunk.start);
|
||||
logger.child('bytes').debug(`wrote ${buffer.length} bytes to array`);
|
||||
}
|
||||
|
||||
await rm(join(config.core.temp_directory, chunk.filename));
|
||||
|
||||
await prisma.incompleteFile.update({
|
||||
where: {
|
||||
id: incompleteFile.id,
|
||||
},
|
||||
data: {
|
||||
chunksComplete: {
|
||||
increment: 1,
|
||||
},
|
||||
status: 'PROCESSING',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (config.datasource.type === 'local') {
|
||||
await fd.close();
|
||||
} else {
|
||||
logger.debug('writing file to datasource');
|
||||
await datasource.save(file.filename, Buffer.from(fd as Uint8Array));
|
||||
}
|
||||
|
||||
const final = await prisma.incompleteFile.update({
|
||||
where: {
|
||||
id: incompleteFile.id,
|
||||
},
|
||||
data: {
|
||||
status: 'COMPLETE',
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug('done writing file');
|
||||
|
||||
await runFileComplete(file.id, ext, final);
|
||||
|
||||
logger.debug('done running worker');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
async function setResponse(incompleteFile: IncompleteFile, code: number, message: string) {
|
||||
incompleteFile.data['code'] = code;
|
||||
incompleteFile.data['message'] = message;
|
||||
|
||||
if (code !== 200) {
|
||||
await datasource.delete(file.filename);
|
||||
await prisma.file.delete({ where: { id: file.id } });
|
||||
}
|
||||
|
||||
return prisma.incompleteFile.update({
|
||||
where: {
|
||||
id: incompleteFile.id,
|
||||
},
|
||||
data: {
|
||||
data: incompleteFile.data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function runFileComplete(id: number, ext: string, incompleteFile: IncompleteFile) {
|
||||
if (config.uploader.disabled_extensions.includes(ext))
|
||||
return setResponse(incompleteFile, 403, 'disabled extension');
|
||||
|
||||
let password = null;
|
||||
if (headers.password) {
|
||||
password = await hashPassword(headers.password as string);
|
||||
}
|
||||
|
||||
let invis: InvisibleFile;
|
||||
|
||||
const fFile = await prisma.file.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
embed: !!headers.embed,
|
||||
password,
|
||||
expiresAt: response.expiresAt,
|
||||
maxViews: response.fileMaxViews,
|
||||
originalName: headers['original-name'] ? file.filename ?? null : null,
|
||||
size: file.totalBytes,
|
||||
},
|
||||
});
|
||||
|
||||
if (typeof headers.zws !== 'undefined' && (headers.zws as string).toLowerCase().match('true'))
|
||||
invis = await createInvisImage(config.uploader.length, fFile.id);
|
||||
|
||||
logger.info(`User ${user.username} (${user.id}) uploaded ${fFile.name} (${fFile.id}) (chunked)`);
|
||||
let domain;
|
||||
if (headers['override-domain']) {
|
||||
domain = `${config.core.return_https ? 'https' : 'http'}://${headers['override-domain']}`;
|
||||
} else if (user.domains.length) {
|
||||
domain = user.domains[Math.floor(Math.random() * user.domains.length)];
|
||||
} else {
|
||||
domain = `${config.core.return_https ? 'https' : 'http'}://${headers.host}`;
|
||||
}
|
||||
|
||||
const responseUrl = `${domain}${config.uploader.route === '/' ? '/' : config.uploader.route + '/'}${
|
||||
invis ? invis.invis : encodeURI(fFile.name)
|
||||
}`;
|
||||
|
||||
if (config.discord?.upload) {
|
||||
await sendUpload(user, fFile, `${domain}/r/${invis ? invis.invis : fFile.name}`, responseUrl);
|
||||
}
|
||||
|
||||
if (config.exif.enabled && config.exif.remove_gps && fFile.mimetype.startsWith('image/')) {
|
||||
try {
|
||||
await removeGPSData(fFile);
|
||||
} catch (e) {
|
||||
logger.error(`Failed to remove GPS data from ${fFile.name} (${fFile.id}) - ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await setResponse(incompleteFile, 200, responseUrl);
|
||||
}
|
||||
@@ -13,6 +13,17 @@ export default defineConfig([
|
||||
entryPoints: ['src/server/index.ts'],
|
||||
...opts,
|
||||
},
|
||||
// workers
|
||||
{
|
||||
entryPoints: ['src/worker/upload.ts'],
|
||||
outDir: 'dist/worker',
|
||||
...opts,
|
||||
},
|
||||
{
|
||||
entryPoints: ['src/worker/thumbnail.ts'],
|
||||
outDir: 'dist/worker',
|
||||
...opts,
|
||||
},
|
||||
// scripts
|
||||
{
|
||||
entryPoints: ['src/scripts/import-dir.ts'],
|
||||
@@ -44,4 +55,9 @@ export default defineConfig([
|
||||
outDir: 'dist/scripts',
|
||||
...opts,
|
||||
},
|
||||
{
|
||||
entryPoints: ['src/scripts/clear-temp.ts'],
|
||||
outDir: 'dist/scripts',
|
||||
...opts,
|
||||
},
|
||||
]);
|
||||
|
||||
86
yarn.lock
86
yarn.lock
@@ -1142,6 +1142,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@derhuerst/http-basic@npm:^8.2.0":
|
||||
version: 8.2.4
|
||||
resolution: "@derhuerst/http-basic@npm:8.2.4"
|
||||
dependencies:
|
||||
caseless: ^0.12.0
|
||||
concat-stream: ^2.0.0
|
||||
http-response-object: ^3.0.1
|
||||
parse-cache-control: ^1.0.1
|
||||
checksum: dfb2f30c23fb907988d1c34318fa74c54dcd3c3ba6b4b0e64cdb584d03303ad212dd3b3874328a9367d7282a232976acbd33a20bb9c7a6ea20752e879459253b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@emotion/babel-plugin@npm:^11.10.6":
|
||||
version: 11.10.6
|
||||
resolution: "@emotion/babel-plugin@npm:11.10.6"
|
||||
@@ -2719,6 +2731,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/node@npm:^10.0.3":
|
||||
version: 10.17.60
|
||||
resolution: "@types/node@npm:10.17.60"
|
||||
checksum: 2cdb3a77d071ba8513e5e8306fa64bf50e3c3302390feeaeff1fd325dd25c8441369715dfc8e3701011a72fed5958c7dfa94eb9239a81b3c286caa4d97db6eef
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/node@npm:^17.0.45":
|
||||
version: 17.0.45
|
||||
resolution: "@types/node@npm:17.0.45"
|
||||
@@ -3822,9 +3841,16 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"caniuse-lite@npm:^1.0.30001406":
|
||||
version: 1.0.30001439
|
||||
resolution: "caniuse-lite@npm:1.0.30001439"
|
||||
checksum: 3912dd536c9735713ca85e47721988bbcefb881ddb4886b0b9923fa984247fd22cba032cf268e57d158af0e8a2ae2eae042ae01942a1d6d7849fa9fa5d62fb82
|
||||
version: 1.0.30001494
|
||||
resolution: "caniuse-lite@npm:1.0.30001494"
|
||||
checksum: 770b742ebba6076da72e94f979ef609bbc855369d1b937c52227935d966b11c3b02baa6511fba04a804802b6eb22af0a2a4a82405963bbb769772530e6be7a8e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"caseless@npm:^0.12.0":
|
||||
version: 0.12.0
|
||||
resolution: "caseless@npm:0.12.0"
|
||||
checksum: b43bd4c440aa1e8ee6baefee8063b4850fd0d7b378f6aabc796c9ec8cb26d27fb30b46885350777d9bd079c5256c0e1329ad0dc7c2817e0bb466810ebb353751
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -4136,6 +4162,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"concat-stream@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "concat-stream@npm:2.0.0"
|
||||
dependencies:
|
||||
buffer-from: ^1.0.0
|
||||
inherits: ^2.0.3
|
||||
readable-stream: ^3.0.2
|
||||
typedarray: ^0.0.6
|
||||
checksum: d7f75d48f0ecd356c1545d87e22f57b488172811b1181d96021c7c4b14ab8855f5313280263dca44bb06e5222f274d047da3e290a38841ef87b59719bde967c7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"console-control-strings@npm:^1.0.0, console-control-strings@npm:^1.1.0":
|
||||
version: 1.1.0
|
||||
resolution: "console-control-strings@npm:1.1.0"
|
||||
@@ -5625,6 +5663,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ffmpeg-static@npm:^5.1.0":
|
||||
version: 5.1.0
|
||||
resolution: "ffmpeg-static@npm:5.1.0"
|
||||
dependencies:
|
||||
"@derhuerst/http-basic": ^8.2.0
|
||||
env-paths: ^2.2.0
|
||||
https-proxy-agent: ^5.0.0
|
||||
progress: ^2.0.3
|
||||
checksum: 0e27d671a0be1f585ef03e48c2af7c2be14f4e61470ffa02e3b8919551243ee854028a898dfcd16cdf1e3c01916f3c5e9938f42cbc7e877d7dd80d566867db8b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"file-entry-cache@npm:^6.0.1":
|
||||
version: 6.0.1
|
||||
resolution: "file-entry-cache@npm:6.0.1"
|
||||
@@ -6335,6 +6385,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"http-response-object@npm:^3.0.1":
|
||||
version: 3.0.2
|
||||
resolution: "http-response-object@npm:3.0.2"
|
||||
dependencies:
|
||||
"@types/node": ^10.0.3
|
||||
checksum: 6cbdcb4ce7b27c9158a131b772c903ed54add2ba831e29cc165e91c3969fa6f8105ddf924aac5b954b534ad15a1ae697b693331b2be5281ee24d79aae20c3264
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"https-proxy-agent@npm:5.0.1, https-proxy-agent@npm:^5.0.0":
|
||||
version: 5.0.1
|
||||
resolution: "https-proxy-agent@npm:5.0.1"
|
||||
@@ -8874,6 +8933,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"parse-cache-control@npm:^1.0.1":
|
||||
version: 1.0.1
|
||||
resolution: "parse-cache-control@npm:1.0.1"
|
||||
checksum: 5a70868792124eb07c2dd07a78fcb824102e972e908254e9e59ce59a4796c51705ff28196d2b20d3b7353d14e9f98e65ed0e4eda9be072cc99b5297dc0466fee
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"parse-json@npm:^4.0.0":
|
||||
version: 4.0.0
|
||||
resolution: "parse-json@npm:4.0.0"
|
||||
@@ -9301,7 +9367,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"progress@npm:2.0.3":
|
||||
"progress@npm:2.0.3, progress@npm:^2.0.3":
|
||||
version: 2.0.3
|
||||
resolution: "progress@npm:2.0.3"
|
||||
checksum: f67403fe7b34912148d9252cb7481266a354bd99ce82c835f79070643bb3c6583d10dbcfda4d41e04bbc1d8437e9af0fb1e1f2135727878f5308682a579429b7
|
||||
@@ -9736,6 +9802,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"readable-stream@npm:^3.0.2":
|
||||
version: 3.6.2
|
||||
resolution: "readable-stream@npm:3.6.2"
|
||||
dependencies:
|
||||
inherits: ^2.0.3
|
||||
string_decoder: ^1.1.1
|
||||
util-deprecate: ^1.0.1
|
||||
checksum: bdcbe6c22e846b6af075e32cf8f4751c2576238c5043169a1c221c92ee2878458a816a4ea33f4c67623c0b6827c8a400409bfb3cf0bf3381392d0b1dfb52ac8d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"readable-stream@npm:^4.0.0":
|
||||
version: 4.2.0
|
||||
resolution: "readable-stream@npm:4.2.0"
|
||||
@@ -11902,6 +11979,7 @@ __metadata:
|
||||
fastify: ^4.15.0
|
||||
fastify-plugin: ^4.5.0
|
||||
fflate: ^0.7.4
|
||||
ffmpeg-static: ^5.1.0
|
||||
find-my-way: ^7.6.0
|
||||
katex: ^0.16.4
|
||||
mantine-datatable: ^2.2.6
|
||||
|
||||
Reference in New Issue
Block a user