mirror of
https://github.com/diced/zipline.git
synced 2025-12-23 07:28:57 -08:00
Compare commits
4 Commits
v3
...
feature/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adb984b2db | ||
|
|
3be9f1521e | ||
|
|
5d971a9fef | ||
|
|
2c86abbf4e |
@@ -7,6 +7,4 @@ RUN usermod -l zipline node \
|
||||
&& chmod 0440 /etc/sudoers.d/zipline \
|
||||
&& sudo apt-get update && apt-get install gnupg2 -y
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
USER zipline
|
||||
USER zipline
|
||||
@@ -41,7 +41,7 @@
|
||||
"remoteUser": "zipline",
|
||||
"updateRemoteUserUID": true,
|
||||
"remoteEnv": {
|
||||
"CORE_DATABASE_URL": "postgres://postgres:postgres@db/zip10"
|
||||
"CORE_DATABASE_URL": "postgres://postgres:postgres@localhost/zip10"
|
||||
},
|
||||
"portsAttributes": {
|
||||
"3000": {
|
||||
|
||||
@@ -2,8 +2,6 @@ node_modules/
|
||||
.next/
|
||||
uploads/
|
||||
.git/
|
||||
!.git/refs
|
||||
!.git/HEAD
|
||||
.yarn/*
|
||||
!.yarn/releases
|
||||
!.yarn/plugins
|
||||
|
||||
@@ -1,43 +1,46 @@
|
||||
# every field in here is optional except, CORE_SECRET and CORE_DATABASE_URL.
|
||||
# if CORE_SECRET is still "changethis" then zipline will exit and tell you to change it.
|
||||
|
||||
# if using s3 make sure to uncomment or comment out the correct lines needed.
|
||||
# if using s3/supabase make sure to comment out the other datasources
|
||||
|
||||
CORE_RETURN_HTTPS=true
|
||||
CORE_HTTPS=true
|
||||
CORE_SECRET="changethis"
|
||||
CORE_HOST=0.0.0.0
|
||||
CORE_PORT=3000
|
||||
CORE_DATABASE_URL="postgres://postgres:postgres@db/zip10"
|
||||
CORE_DATABASE_URL="postgres://postgres:postgres@localhost/zip10"
|
||||
CORE_LOGGER=false
|
||||
CORE_STATS_INTERVAL=1800
|
||||
CORE_INVITES_INTERVAL=1800
|
||||
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
|
||||
# remember: no leading slash
|
||||
DATASOURCE_SUPABASE_URL=https://something.supabase.co
|
||||
DATASOURCE_SUPABASE_BUCKET=zipline
|
||||
|
||||
UPLOADER_DEFAULT_FORMAT=RANDOM
|
||||
UPLOADER_ROUTE=/u
|
||||
UPLOADER_LENGTH=6
|
||||
UPLOADER_ADMIN_LIMIT=104900000
|
||||
UPLOADER_USER_LIMIT=104900000
|
||||
UPLOADER_DISABLED_EXTENSIONS=someext,anotherext
|
||||
UPLOADER_DISABLED_EXTENSIONS=someext
|
||||
|
||||
URLS_ROUTE=/go
|
||||
URLS_LENGTH=6
|
||||
|
||||
RATELIMIT_USER=5
|
||||
RATELIMIT_ADMIN=3
|
||||
|
||||
# for more variables checkout the docs
|
||||
RATELIMIT_USER = 5
|
||||
RATELIMIT_ADMIN = 3
|
||||
|
||||
3
.github/FUNDING.yml
vendored
3
.github/FUNDING.yml
vendored
@@ -1,3 +0,0 @@
|
||||
# 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 (or docker image) of Zipline are you using?
|
||||
description: What version 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%20brief%20description%20here&labels=feature
|
||||
url: https://github.com/diced/zipline/discussions/new?category=ideas&title=Your%20breif%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.sh
|
||||
url: https://zipline.diced.tech
|
||||
about: Maybe take a look a the docs?
|
||||
|
||||
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@@ -2,9 +2,9 @@ name: 'Build'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ v3 ]
|
||||
branches: [ trunk ]
|
||||
pull_request:
|
||||
branches: [ v3 ]
|
||||
branches: [ trunk ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
20
.github/workflows/docker-release.yml
vendored
20
.github/workflows/docker-release.yml
vendored
@@ -3,7 +3,7 @@ name: 'Push Release Docker Images'
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v3.*.*'
|
||||
- 'v*.*.*'
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'server/**'
|
||||
@@ -13,8 +13,8 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
push:
|
||||
name: Push Release Image
|
||||
push_to_ghcr:
|
||||
name: Push Release Image to GitHub Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
@@ -32,28 +32,20 @@ jobs:
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to GitHub Packages
|
||||
- name: Login to Github Packages
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and Push Docker Image
|
||||
- name: Build Docker Image
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
ghcr.io/diced/zipline:v3
|
||||
ghcr.io/diced/zipline:latest
|
||||
ghcr.io/diced/zipline:${{ steps.version.outputs.zipline_version }}
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/zipline:v3
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/zipline:${{ steps.version.outputs.zipline_version }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
24
.github/workflows/docker.yml
vendored
24
.github/workflows/docker.yml
vendored
@@ -2,7 +2,7 @@ name: 'Push Docker Images'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ v3 ]
|
||||
branches: [ trunk ]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'server/**'
|
||||
@@ -12,8 +12,8 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
push:
|
||||
name: Push Commit Image
|
||||
push_to_ghcr:
|
||||
name: Push Image to GitHub Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
- name: Get version
|
||||
id: version
|
||||
run: |
|
||||
echo "zipline_commit=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
echo "zipline_version=$(jq .version package.json -r)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
@@ -38,23 +38,13 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and Push Docker Image
|
||||
- name: Build Docker Image
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
ghcr.io/diced/zipline:v3-trunk
|
||||
ghcr.io/diced/zipline:v3-trunk-${{ steps.version.outputs.zipline_commit }}
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/zipline:v3-trunk
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/zipline:v3-trunk-${{ steps.version.outputs.zipline_commit }}
|
||||
ghcr.io/diced/zipline:trunk
|
||||
ghcr.io/diced/zipline:trunk-${{ steps.version.outputs.zipline_version }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
|
||||
|
||||
31
.github/workflows/milestone.yml
vendored
Normal file
31
.github/workflows/milestone.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
name: 'Issue/PR Milestones'
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
issues:
|
||||
types: [opened, reopened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
checks: write
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
set:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/github-script@v6
|
||||
with:
|
||||
github-token: ${{secrets.GITHUB_TOKEN}}
|
||||
script: |
|
||||
const milestone = 3
|
||||
github.rest.issues.update({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
milestone
|
||||
})
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -31,7 +31,6 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
|
||||
0
.yarn/releases/yarn-3.3.1.cjs
vendored
Normal file → Executable file
0
.yarn/releases/yarn-3.3.1.cjs
vendored
Normal file → Executable file
@@ -14,7 +14,7 @@ Create an issue on GitHub, please include the following (if one of them is not a
|
||||
|
||||
Create an discussion on GitHub, please include the following:
|
||||
|
||||
- Brief explanation of the feature in the title (very brief please)
|
||||
- Breif explanation of the feature in the title (very breif please)
|
||||
- How it would work (detailed, but optional)
|
||||
|
||||
## Pull Requests (contributions to the codebase)
|
||||
|
||||
53
Dockerfile
53
Dockerfile
@@ -1,14 +1,22 @@
|
||||
# Use the Prisma binaries image as the first stage
|
||||
FROM ghcr.io/diced/prisma-binaries:5.1.x AS prisma
|
||||
FROM ghcr.io/diced/prisma-binaries:4.10.x as prisma
|
||||
|
||||
# Use Alpine Linux as the second stage
|
||||
FROM node:18-alpine3.16 AS base
|
||||
FROM node:18-alpine3.16 as base
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /zipline
|
||||
|
||||
# Copy the necessary files from the project
|
||||
COPY prisma ./prisma
|
||||
COPY src ./src
|
||||
COPY next.config.js ./next.config.js
|
||||
COPY tsup.config.ts ./tsup.config.ts
|
||||
COPY tsconfig.json ./tsconfig.json
|
||||
COPY mimes.json ./mimes.json
|
||||
COPY public ./public
|
||||
|
||||
FROM base as builder
|
||||
|
||||
COPY .yarn ./.yarn
|
||||
COPY package*.json ./
|
||||
@@ -18,62 +26,51 @@ COPY .yarnrc.yml ./
|
||||
# Copy the prisma binaries from prisma stage
|
||||
COPY --from=prisma /prisma-engines /prisma-engines
|
||||
ENV PRISMA_QUERY_ENGINE_BINARY=/prisma-engines/query-engine \
|
||||
PRISMA_SCHEMA_ENGINE_BINARY=/prisma-engines/schema-engine \
|
||||
PRISMA_MIGRATION_ENGINE_BINARY=/prisma-engines/migration-engine \
|
||||
PRISMA_INTROSPECTION_ENGINE_BINARY=/prisma-engines/introspection-engine \
|
||||
PRISMA_FMT_BINARY=/prisma-engines/prisma-fmt \
|
||||
PRISMA_CLI_QUERY_ENGINE_TYPE=binary \
|
||||
PRISMA_CLIENT_ENGINE_TYPE=binary \
|
||||
ZIPLINE_DOCKER_BUILD=true \
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Install production dependencies then temporarily save
|
||||
RUN yarn workspaces focus --production --all
|
||||
RUN cp -RL node_modules /tmp/node_modules
|
||||
|
||||
# Install the dependencies
|
||||
RUN yarn install --immutable
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
COPY .git/refs ./.git/refs
|
||||
COPY .git/HEAD ./.git/HEAD
|
||||
COPY src ./src
|
||||
COPY next.config.js ./next.config.js
|
||||
COPY tsup.config.ts ./tsup.config.ts
|
||||
COPY tsconfig.json ./tsconfig.json
|
||||
COPY mimes.json ./mimes.json
|
||||
COPY public ./public
|
||||
|
||||
# Run the build
|
||||
RUN yarn build
|
||||
|
||||
# Use Alpine Linux as the final image
|
||||
FROM base
|
||||
|
||||
# Install the necessary packages
|
||||
RUN apk add --no-cache perl procps tini
|
||||
|
||||
COPY --from=prisma /prisma-engines /prisma-engines
|
||||
COPY --from=builder /prisma-engines /prisma-engines
|
||||
ENV PRISMA_QUERY_ENGINE_BINARY=/prisma-engines/query-engine \
|
||||
PRISMA_SCHEMA_ENGINE_BINARY=/prisma-engines/schema-engine \
|
||||
PRISMA_MIGRATION_ENGINE_BINARY=/prisma-engines/migration-engine \
|
||||
PRISMA_INTROSPECTION_ENGINE_BINARY=/prisma-engines/introspection-engine \
|
||||
PRISMA_FMT_BINARY=/prisma-engines/prisma-fmt \
|
||||
PRISMA_CLI_QUERY_ENGINE_TYPE=binary \
|
||||
PRISMA_CLIENT_ENGINE_TYPE=binary \
|
||||
ZIPLINE_DOCKER_BUILD=true \
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Copy only the necessary files from the previous stage
|
||||
COPY --from=builder /zipline/.git/refs ./.git/refs
|
||||
COPY --from=builder /zipline/.git/HEAD ./.git/HEAD
|
||||
COPY --from=builder /zipline/dist ./dist
|
||||
COPY --from=builder /zipline/.next ./.next
|
||||
COPY --from=builder /zipline/package.json ./package.json
|
||||
|
||||
COPY --from=builder /zipline/mimes.json ./mimes.json
|
||||
COPY --from=builder /zipline/next.config.js ./next.config.js
|
||||
COPY --from=builder /zipline/public ./public
|
||||
COPY --from=builder /zipline/node_modules ./node_modules
|
||||
COPY --from=builder /zipline/node_modules/.prisma/client ./node_modules/.prisma/client
|
||||
COPY --from=builder /zipline/node_modules/@prisma/client ./node_modules/@prisma/client
|
||||
|
||||
# Copy Startup Script
|
||||
COPY docker-entrypoint.sh /zipline
|
||||
|
||||
# Make Startup Script Executable
|
||||
RUN chmod a+x /zipline/docker-entrypoint.sh && rm -rf /zipline/src
|
||||
|
||||
# Clean up
|
||||
RUN rm -rf /tmp/* /root/*
|
||||
RUN yarn cache clean --all
|
||||
|
||||
# Set the entrypoint to the startup script
|
||||
ENTRYPOINT ["tini", "--", "/zipline/docker-entrypoint.sh"]
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 dicedtomato
|
||||
Copyright (c) 2022 dicedtomato
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
36
README.md
36
README.md
@@ -35,9 +35,17 @@ A ShareX/file upload server that is easy to use, packed with features, and with
|
||||
- User invites
|
||||
- File Chunking (for large files)
|
||||
- File deletion once it reaches a certain amount of views
|
||||
- Automatic video thumbnail generation
|
||||
- Easy setup instructions on [docs](https://zipl.vercel.app/) (One command install `docker compose up -d`)
|
||||
|
||||
<details>
|
||||
<summary>View upstream documentation</summary>
|
||||
|
||||
The website below provides documentation for more up-to-date features with the upstream branch. The normal documentation is for the latest release and is not updated unless a new release is made.
|
||||
|
||||
[https://trunk.zipline.diced.tech/](https://trunk.zipline.diced.tech/)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><h2>Screenshots (click)</h2></summary>
|
||||
|
||||
@@ -68,18 +76,17 @@ Ways you could generate the string could be from a password managers generator,
|
||||
|
||||
## Building & running from source
|
||||
|
||||
This section requires [nodejs](https://nodejs.org), [yarn](https://yarnpkg.com/).
|
||||
|
||||
It is recommended to not use npm, as it can cause issues with the build process.
|
||||
|
||||
Before you run `yarn build`, you might want to configure Zipline, as when building from source Zipline will need to read some sort of configuration. The only two variables needed are `CORE_SECRET` and `CORE_DATABASE_URL`.
|
||||
This section requires [nodejs](https://nodejs.org), [yarn](https://yarnpkg.com/) or [npm](https://npmjs.com).
|
||||
|
||||
```shell
|
||||
git clone https://github.com/diced/zipline
|
||||
cd zipline
|
||||
|
||||
# npm install
|
||||
yarn install
|
||||
# npm run build
|
||||
yarn build
|
||||
# npm start
|
||||
yarn start
|
||||
```
|
||||
|
||||
@@ -112,7 +119,7 @@ This section requires [ShareX](https://www.getsharex.com/).
|
||||
|
||||
After navigating to Zipline, click on the top right corner where it says your username and click Manage Account. Scroll down to see "ShareX Config", select the one you would prefer using. After this you can import the .sxcu into sharex. [More information here](https://zipl.vercel.app/docs/guides/uploaders/sharex)
|
||||
|
||||
# Flameshot (Linux(Xorg/Wayland) and macOS)
|
||||
# Flameshot (Linux)
|
||||
|
||||
This section requires [Flameshot](https://www.flameshot.org/), [jq](https://stedolan.github.io/jq/), and [xsel](https://github.com/kfish/xsel).
|
||||
|
||||
@@ -121,19 +128,12 @@ This section requires [Flameshot](https://www.flameshot.org/), [jq](https://sted
|
||||
|
||||
If using wayland you will need to have [wl-clipboard](https://github.com/bugaevc/wl-clipboard) installed, for the `wl-copy` command.
|
||||
|
||||
If you are not using GNOME/KDE/Qtile/Sway, and are using something like a wlroots-based or wlroots-compatible compositor (ex. [Hyprland](https://github.com/hyprwm/Hyprland/), [River](https://github.com/riverwm/river), etc), you will need to set the `XDG_CURRENT_DESKTOP` environment variable to `sway`, which will just override it for this script. Adding `export XDG_CURRENT_DESKTOP=sway` to the start of the script will work.
|
||||
If you are not using GNOME/KDE/Qtile/Sway, and are using something like a wlroots-based compositor (ex. [Hyprland](https://github.com/hyprwm/Hyprland/), [River](https://github.com/riverwm/river), etc), you will need to set the `XDG_CURRENT_DESKTOP` environment variable to `sway`, which will just override it for this script. Adding `export XDG_CURRENT_DESKTOP=sway` to the start of the script will work.
|
||||
|
||||
After this, replace the `xsel -ib` with `wl-copy` in the script.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Mac instructions</summary>
|
||||
|
||||
If using macOS, you can replace the `xsel -ib` with `pbcopy` in the script.
|
||||
|
||||
</details>
|
||||
|
||||
You can either use the script below, or generate one directly from Zipline (just like how you can generate a ShareX config).
|
||||
To upload files using flameshot we will use a script. Replace $TOKEN and $HOST with your own values, you probably know how to do this if you use linux.
|
||||
|
||||
@@ -141,7 +141,7 @@ To upload files using flameshot we will use a script. Replace $TOKEN and $HOST w
|
||||
DATE=$(date '+%h_%Y_%d_%I_%m_%S.png');
|
||||
flameshot gui -r > ~/Pictures/$DATE;
|
||||
|
||||
curl -H "Content-Type: multipart/form-data" -H "authorization: $TOKEN" -F file=@$1 $HOST/api/upload | jq -r '.files[0]' | xsel -ib
|
||||
curl -H "Content-Type: multipart/form-data" -H "authorization: $TOKEN" -F file=@$1 $HOST/api/upload | jq -r 'files[0].url' | xsel -ib
|
||||
```
|
||||
|
||||
# Contributing
|
||||
@@ -166,7 +166,3 @@ Create a discussion on GitHub, please include the following:
|
||||
## Pull Requests (contributions to the codebase)
|
||||
|
||||
Create a pull request on GitHub. If your PR does not pass the action checks, then please fix the errors. If your PR was submitted before a release, and I have pushed a new release, please make sure to update your PR to reflect any changes, usually this is handled by GitHub.
|
||||
|
||||
# Documentation
|
||||
|
||||
Documentation source code is located in [diced/zipline-docs](https://github.com/diced/zipline-docs), and can be accessed [here](https://zipline.diced.sh/docs/get-started).
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
version: '3'
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
@@ -22,8 +23,8 @@ services:
|
||||
env_file:
|
||||
- .env.local
|
||||
volumes:
|
||||
- './uploads:/zipline/uploads'
|
||||
- './public:/zipline/public'
|
||||
- '$PWD/uploads:/zipline/uploads'
|
||||
- '$PWD/public:/zipline/public'
|
||||
depends_on:
|
||||
- 'postgres'
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
version: '3'
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
@@ -28,7 +29,7 @@ services:
|
||||
- CORE_LOGGER=true
|
||||
volumes:
|
||||
- './uploads:/zipline/uploads'
|
||||
- './public:/zipline/public'
|
||||
- '$PWD/public:/zipline/public'
|
||||
depends_on:
|
||||
- 'postgres'
|
||||
|
||||
|
||||
@@ -2,6 +2,4 @@
|
||||
|
||||
set -e
|
||||
|
||||
unset ZIPLINE_DOCKER_BUILD
|
||||
|
||||
node --enable-source-maps dist/index.js
|
||||
@@ -42,9 +42,6 @@
|
||||
["afm", ["application/octet-stream"]],
|
||||
["afp", ["application/vnd.ibm.modcap"]],
|
||||
["ahead", ["application/vnd.ahead.space"]],
|
||||
["ahk", ["text/autohotkey"]],
|
||||
["ahk1", ["text/autohotkey"]],
|
||||
["ahk2", ["text/autohotkey"]],
|
||||
["ai", ["application/postscript"]],
|
||||
["aif", ["audio/aiff"]],
|
||||
["aifc", ["audio/aiff"]],
|
||||
|
||||
112
package.json
112
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "zipline",
|
||||
"version": "3.7.13",
|
||||
"version": "3.7.0",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "npm-run-all build:server dev:run",
|
||||
@@ -24,77 +24,75 @@
|
||||
"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:clear-temp": "node --enable-source-maps dist/scripts/clear-temp"
|
||||
"scripts:query-size": "node --enable-source-maps dist/scripts/query-size"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/server": "^11.11.0",
|
||||
"@mantine/core": "6.x",
|
||||
"@mantine/dropzone": "6.x",
|
||||
"@mantine/form": "6.x",
|
||||
"@mantine/hooks": "6.x",
|
||||
"@mantine/modals": "6.x",
|
||||
"@mantine/next": "6.x",
|
||||
"@mantine/notifications": "6.x",
|
||||
"@mantine/prism": "6.x",
|
||||
"@mantine/spotlight": "6.x",
|
||||
"@prisma/client": "^5.1.1",
|
||||
"@prisma/internals": "^5.1.1",
|
||||
"@prisma/migrate": "^5.1.1",
|
||||
"@sapphire/shapeshift": "^3.9.3",
|
||||
"@tabler/icons-react": "^2.41.0",
|
||||
"@emotion/react": "^11.10.6",
|
||||
"@emotion/server": "^11.10.0",
|
||||
"@mantine/core": "^6.0.4",
|
||||
"@mantine/dropzone": "^6.0.4",
|
||||
"@mantine/form": "^6.0.4",
|
||||
"@mantine/hooks": "^6.0.4",
|
||||
"@mantine/modals": "^6.0.4",
|
||||
"@mantine/next": "^6.0.4",
|
||||
"@mantine/notifications": "^6.0.4",
|
||||
"@mantine/prism": "^6.0.4",
|
||||
"@mantine/spotlight": "^6.0.4",
|
||||
"@prisma/client": "^4.10.1",
|
||||
"@prisma/internals": "^4.10.1",
|
||||
"@prisma/migrate": "^4.10.1",
|
||||
"@sapphire/shapeshift": "^3.8.1",
|
||||
"@tabler/icons-react": "^2.11.0",
|
||||
"@tanstack/react-query": "^4.28.0",
|
||||
"argon2": "^0.31.2",
|
||||
"cookie": "^0.6.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"dotenv": "^16.3.1",
|
||||
"argon2": "^0.30.3",
|
||||
"cookie": "^0.5.0",
|
||||
"dayjs": "^1.11.7",
|
||||
"dotenv": "^16.0.3",
|
||||
"dotenv-expand": "^10.0.0",
|
||||
"exiftool-vendored": "^23.4.0",
|
||||
"fastify": "^4.24.3",
|
||||
"fastify-plugin": "^4.5.1",
|
||||
"fflate": "^0.8.1",
|
||||
"ffmpeg-static": "^5.2.0",
|
||||
"find-my-way": "^7.7.0",
|
||||
"katex": "^0.16.9",
|
||||
"mantine-datatable": "^2.9.14",
|
||||
"minio": "^7.1.3",
|
||||
"exiftool-vendored": "^21.2.0",
|
||||
"fastify": "^4.15.0",
|
||||
"fastify-plugin": "^4.5.0",
|
||||
"fflate": "^0.7.4",
|
||||
"find-my-way": "^7.6.0",
|
||||
"katex": "^0.16.4",
|
||||
"mantine-datatable": "^2.2.6",
|
||||
"minio": "^7.0.33",
|
||||
"ms": "canary",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"next": "^14.0.3",
|
||||
"next": "^13.2.4",
|
||||
"otplib": "^12.0.1",
|
||||
"prisma": "^5.1.1",
|
||||
"prisma": "^4.10.1",
|
||||
"prismjs": "^1.29.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"qrcode": "^1.5.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^9.0.3",
|
||||
"recharts": "^2.10.1",
|
||||
"react-markdown": "^8.0.6",
|
||||
"recharts": "^2.5.0",
|
||||
"recoil": "^0.7.7",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sharp": "^0.32.6"
|
||||
"remark-gfm": "^3.0.1",
|
||||
"sharp": "^0.32.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cookie": "^0.5.4",
|
||||
"@types/katex": "^0.16.6",
|
||||
"@types/minio": "^7.1.1",
|
||||
"@types/multer": "^1.4.10",
|
||||
"@types/node": "18",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/sharp": "^0.32.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.11.0",
|
||||
"@typescript-eslint/parser": "^6.11.0",
|
||||
"@types/cookie": "^0.5.1",
|
||||
"@types/katex": "^0.16.0",
|
||||
"@types/minio": "^7.0.17",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^18.15.10",
|
||||
"@types/qrcode": "^1.5.0",
|
||||
"@types/react": "^18.0.29",
|
||||
"@types/sharp": "^0.31.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.56.0",
|
||||
"@typescript-eslint/parser": "^5.56.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.54.0",
|
||||
"eslint-config-next": "^14.0.3",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-prettier": "^5.0.1",
|
||||
"eslint-plugin-unused-imports": "^3.0.0",
|
||||
"eslint": "^8.36.0",
|
||||
"eslint-config-next": "^13.2.4",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-unused-imports": "^2.0.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^3.1.0",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.2.2"
|
||||
"prettier": "^2.8.7",
|
||||
"tsup": "^6.7.0",
|
||||
"typescript": "^5.0.2"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
26
prisma/migrations/20230401212405_file_tags/migration.sql
Normal file
26
prisma/migrations/20230401212405_file_tags/migration.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Tag" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"color" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "Tag_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_FileToTag" (
|
||||
"A" INTEGER NOT NULL,
|
||||
"B" TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "_FileToTag_AB_unique" ON "_FileToTag"("A", "B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_FileToTag_B_index" ON "_FileToTag"("B");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_FileToTag" ADD CONSTRAINT "_FileToTag_A_fkey" FOREIGN KEY ("A") REFERENCES "File"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_FileToTag" ADD CONSTRAINT "_FileToTag_B_fkey" FOREIGN KEY ("B") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -1,16 +0,0 @@
|
||||
|
||||
-- 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;
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "File" ALTER COLUMN "size" SET DATA TYPE BIGINT;
|
||||
@@ -1,14 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Export" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"complete" BOOLEAN NOT NULL DEFAULT false,
|
||||
"path" TEXT NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "Export_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Export" ADD CONSTRAINT "Export_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -8,39 +8,25 @@ generator client {
|
||||
}
|
||||
|
||||
model User {
|
||||
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[]
|
||||
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[]
|
||||
Exports Export[]
|
||||
}
|
||||
|
||||
model Export {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
complete Boolean @default(false)
|
||||
|
||||
path String
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId Int
|
||||
}
|
||||
|
||||
model Folder {
|
||||
@@ -62,7 +48,7 @@ model File {
|
||||
originalName String?
|
||||
mimetype String @default("image/png")
|
||||
createdAt DateTime @default(now())
|
||||
size BigInt @default(0)
|
||||
size Int @default(0)
|
||||
expiresAt DateTime?
|
||||
maxViews Int?
|
||||
views Int @default(0)
|
||||
@@ -74,19 +60,18 @@ model File {
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
userId Int?
|
||||
|
||||
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
|
||||
folderId Int?
|
||||
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
|
||||
folderId Int?
|
||||
|
||||
thumbnail Thumbnail?
|
||||
tags Tag[]
|
||||
}
|
||||
|
||||
model Thumbnail {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now())
|
||||
name String
|
||||
model Tag {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
color String
|
||||
|
||||
fileId Int @unique
|
||||
file File @relation(fields: [fileId], references: [id], onDelete: Cascade)
|
||||
files File[]
|
||||
}
|
||||
|
||||
model InvisibleFile {
|
||||
@@ -139,7 +124,7 @@ model OAuth {
|
||||
id Int @id @default(autoincrement())
|
||||
provider OauthProviders
|
||||
user User @relation(fields: [userId], references: [uuid], onDelete: Cascade)
|
||||
userId String @db.Uuid
|
||||
userId String
|
||||
username String
|
||||
oauthId String?
|
||||
token String
|
||||
|
||||
@@ -1498,4 +1498,4 @@ wheat
|
||||
white
|
||||
whitesmoke
|
||||
yellow
|
||||
yellowgreen
|
||||
yellowgreen
|
||||
|
||||
@@ -1747,4 +1747,4 @@ zigzagsalamander
|
||||
zonetailedpigeon
|
||||
zooplankton
|
||||
zopilote
|
||||
zorilla
|
||||
zorilla
|
||||
|
||||
@@ -3,11 +3,14 @@ import {
|
||||
Group,
|
||||
LoadingOverlay,
|
||||
Modal,
|
||||
MultiSelect,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Title,
|
||||
Tooltip,
|
||||
Text,
|
||||
Accordion,
|
||||
} from '@mantine/core';
|
||||
import { useClipboard, useMediaQuery } from '@mantine/hooks';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
@@ -24,21 +27,27 @@ import {
|
||||
IconFolderCancel,
|
||||
IconFolderMinus,
|
||||
IconFolderPlus,
|
||||
IconFolders,
|
||||
IconHash,
|
||||
IconInfoCircle,
|
||||
IconPhoto,
|
||||
IconPhotoCancel,
|
||||
IconPhotoMinus,
|
||||
IconPhotoStar,
|
||||
IconPlus,
|
||||
IconTags,
|
||||
} from '@tabler/icons-react';
|
||||
import useFetch, { ApiError } from 'hooks/useFetch';
|
||||
import { useFileDelete, useFileFavorite, UserFilesResponse } from 'lib/queries/files';
|
||||
import { useFolders } from 'lib/queries/folders';
|
||||
import { bytesToHuman } from 'lib/utils/bytes';
|
||||
import { relativeTime } from 'lib/utils/client';
|
||||
import { colorHash, relativeTime } from 'lib/utils/client';
|
||||
import { useState } from 'react';
|
||||
import { FileMeta } from '.';
|
||||
import Type from '../Type';
|
||||
import Tag from 'components/File/tag/Tag';
|
||||
import Item from 'components/File/tag/Item';
|
||||
import { useDeleteFileTags, useFileTags, useTags, useUpdateFileTags } from 'lib/queries/tags';
|
||||
|
||||
export default function FileModal({
|
||||
open,
|
||||
@@ -49,7 +58,6 @@ export default function FileModal({
|
||||
reducedActions = false,
|
||||
exifEnabled,
|
||||
compress,
|
||||
otherUser = false,
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
@@ -59,14 +67,18 @@ export default function FileModal({
|
||||
reducedActions?: boolean;
|
||||
exifEnabled?: boolean;
|
||||
compress: boolean;
|
||||
otherUser: boolean;
|
||||
}) {
|
||||
const deleteFile = useFileDelete();
|
||||
const favoriteFile = useFileFavorite();
|
||||
const folders = useFolders();
|
||||
const tags = useFileTags(file.id);
|
||||
const updateTags = useUpdateFileTags(file.id);
|
||||
const removeTags = useDeleteFileTags(file.id);
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const allTags = useTags();
|
||||
|
||||
const [overrideRender, setOverrideRender] = useState(false);
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const handleDelete = async () => {
|
||||
deleteFile.mutate(file.id, {
|
||||
@@ -97,12 +109,18 @@ export default function FileModal({
|
||||
const handleCopy = () => {
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}${file.url}`);
|
||||
setOpen(false);
|
||||
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: '',
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
});
|
||||
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' />,
|
||||
});
|
||||
};
|
||||
|
||||
const handleFavorite = async () => {
|
||||
@@ -125,7 +143,7 @@ export default function FileModal({
|
||||
icon: <IconPhotoCancel size='1rem' />,
|
||||
});
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -205,12 +223,50 @@ export default function FileModal({
|
||||
return { value: t, label: t };
|
||||
};
|
||||
|
||||
const handleTagsSave = () => {
|
||||
console.log('should save');
|
||||
};
|
||||
|
||||
const handleAddTags = (t: string[]) => {
|
||||
// filter out existing tags from t
|
||||
t = t.filter((tag) => !tags.data.find((t) => t.id === tag));
|
||||
|
||||
const fullTag = allTags.data.find((tag) => tag.id === t[0]);
|
||||
|
||||
if (!fullTag) return;
|
||||
|
||||
updateTags.mutate([...tags.data, fullTag], {
|
||||
onSuccess: () => {
|
||||
showNotification({
|
||||
title: 'Added tag',
|
||||
message: fullTag.name,
|
||||
color: 'green',
|
||||
icon: <IconTags size='1rem' />,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveTags = (t: string[]) => {
|
||||
const fullTag = allTags.data.find((tag) => tag.id === t[0]);
|
||||
|
||||
removeTags.mutate(t, {
|
||||
onSuccess: () =>
|
||||
showNotification({
|
||||
title: 'Removed tag',
|
||||
message: fullTag.name,
|
||||
color: 'green',
|
||||
icon: <IconTags size='1rem' />,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title={<Title>{file.name}</Title>}
|
||||
size='auto'
|
||||
size='lg'
|
||||
fullScreen={useMediaQuery('(max-width: 600px)')}
|
||||
>
|
||||
<LoadingOverlay visible={loading} />
|
||||
@@ -220,8 +276,6 @@ export default function FileModal({
|
||||
src={`/r/${encodeURI(file.name)}?compress=${compress}`}
|
||||
alt={file.name}
|
||||
popup
|
||||
sx={{ minHeight: 200 }}
|
||||
style={{ minHeight: 200 }}
|
||||
disableMediaPreview={false}
|
||||
overrideRender={overrideRender}
|
||||
setOverrideRender={setOverrideRender}
|
||||
@@ -265,6 +319,98 @@ export default function FileModal({
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
{!reducedActions ? (
|
||||
<Accordion
|
||||
variant='contained'
|
||||
mb='sm'
|
||||
styles={(t) => ({
|
||||
content: { backgroundColor: t.colorScheme === 'dark' ? t.colors.dark[7] : t.colors.gray[0] },
|
||||
control: { backgroundColor: t.colorScheme === 'dark' ? t.colors.dark[7] : t.colors.gray[0] },
|
||||
})}
|
||||
>
|
||||
<Accordion.Item value='tags'>
|
||||
<Accordion.Control icon={<IconTags size='1rem' />}>Tags</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<MultiSelect
|
||||
value={tags.data?.map((t) => t.id) ?? []}
|
||||
data={allTags.data?.map((t) => ({ value: t.id, label: t.name, color: t.color })) ?? []}
|
||||
placeholder={allTags.data?.length ? 'Add tags' : 'Add tags (optional)'}
|
||||
icon={<IconTags size='1rem' />}
|
||||
valueComponent={Tag}
|
||||
itemComponent={Item}
|
||||
searchable
|
||||
creatable
|
||||
getCreateLabel={(t) => (
|
||||
<Group>
|
||||
<IconPlus size='1rem' />
|
||||
<Text ml='sm' display='flex'>
|
||||
Create tag{' '}
|
||||
<Text ml={4} color={colorHash(t)}>
|
||||
"{t}"
|
||||
</Text>
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
// onChange={(t) => (t.length === 1 ? handleRemoveTags(t) : handleAddTags(t))}
|
||||
onChange={(t) => console.log(t)}
|
||||
onCreate={(t) => {
|
||||
const item = { value: t, label: t, color: colorHash(t) };
|
||||
// setLabelTags([...labelTags, item]);
|
||||
return item;
|
||||
}}
|
||||
onBlur={handleTagsSave}
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value='folders'>
|
||||
<Accordion.Control icon={<IconFolders size='1rem' />}>Folders</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
{inFolder && !folders.isLoading ? (
|
||||
<Group>
|
||||
<Tooltip
|
||||
label={`Remove from folder "${
|
||||
folders.data.find((f) => f.id === file.folderId)?.name ?? ''
|
||||
}"`}
|
||||
>
|
||||
<ActionIcon
|
||||
color='red'
|
||||
variant='filled'
|
||||
onClick={removeFromFolder}
|
||||
loading={folders.isLoading}
|
||||
>
|
||||
<IconFolderMinus size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Text display='flex' align='center'>
|
||||
Currently in folder "{folders.data.find((f) => f.id === file.folderId)?.name ?? ''}
|
||||
"
|
||||
</Text>
|
||||
</Group>
|
||||
) : (
|
||||
<Tooltip label='Add to folder'>
|
||||
<Select
|
||||
icon={<IconFolderPlus size='1rem' />}
|
||||
onChange={addToFolder}
|
||||
placeholder='Add to folder'
|
||||
data={[
|
||||
...(folders.data ? folders.data : []).map((folder) => ({
|
||||
value: String(folder.id),
|
||||
label: `${folder.id}: ${folder.name}`,
|
||||
})),
|
||||
]}
|
||||
searchable
|
||||
creatable
|
||||
getCreateLabel={(query) => `Create folder "${query}"`}
|
||||
onCreate={createFolder}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
) : null}
|
||||
|
||||
<Group position='apart' my='md'>
|
||||
<Group position='left'>
|
||||
{exifEnabled && !reducedActions && (
|
||||
@@ -278,32 +424,6 @@ export default function FileModal({
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{reducedActions || otherUser ? null : inFolder && !folders.isLoading ? (
|
||||
<Tooltip
|
||||
label={`Remove from folder "${folders.data.find((f) => f.id === file.folderId)?.name ?? ''}"`}
|
||||
>
|
||||
<ActionIcon color='red' variant='filled' onClick={removeFromFolder} loading={folders.isLoading}>
|
||||
<IconFolderMinus size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip label='Add to folder'>
|
||||
<Select
|
||||
onChange={addToFolder}
|
||||
placeholder='Add to folder'
|
||||
data={[
|
||||
...(folders.data ? folders.data : []).map((folder) => ({
|
||||
value: String(folder.id),
|
||||
label: `${folder.id}: ${folder.name}`,
|
||||
})),
|
||||
]}
|
||||
searchable
|
||||
creatable
|
||||
getCreateLabel={(query) => `Create folder "${query}"`}
|
||||
onCreate={createFolder}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
<Group position='right'>
|
||||
{reducedActions ? null : (
|
||||
|
||||
@@ -32,10 +32,9 @@ export default function File({
|
||||
image,
|
||||
disableMediaPreview,
|
||||
exifEnabled,
|
||||
refreshImages = undefined,
|
||||
refreshImages,
|
||||
reducedActions = false,
|
||||
onDash,
|
||||
otherUser = false,
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const deleteFile = useFileDelete();
|
||||
@@ -45,7 +44,7 @@ export default function File({
|
||||
const folders = useFolders();
|
||||
|
||||
const refresh = () => {
|
||||
if (!otherUser) refreshImages();
|
||||
refreshImages();
|
||||
folders.refetch();
|
||||
};
|
||||
|
||||
@@ -60,25 +59,9 @@ export default function File({
|
||||
reducedActions={reducedActions}
|
||||
exifEnabled={exifEnabled}
|
||||
compress={onDash}
|
||||
otherUser={otherUser}
|
||||
/>
|
||||
|
||||
<Card
|
||||
sx={{
|
||||
maxWidth: '100%',
|
||||
height: '100%',
|
||||
'&:hover': {
|
||||
filter: 'brightness(0.75)',
|
||||
},
|
||||
transition: 'filter 0.2s ease-in-out',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
shadow='md'
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Card sx={{ maxWidth: '100%', height: '100%' }} shadow='md' onClick={() => setOpen(true)}>
|
||||
<Card.Section>
|
||||
<LoadingOverlay visible={loading} />
|
||||
<Type
|
||||
|
||||
17
src/components/File/tag/Item.tsx
Normal file
17
src/components/File/tag/Item.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { ComponentPropsWithoutRef, forwardRef } from 'react';
|
||||
import { Group, Text } from '@mantine/core';
|
||||
|
||||
interface ItemProps extends ComponentPropsWithoutRef<'div'> {
|
||||
color: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const Item = forwardRef<HTMLDivElement, ItemProps>(({ color, label, ...others }: ItemProps, ref) => (
|
||||
<div ref={ref} {...others}>
|
||||
<Group noWrap>
|
||||
<Text color={color}>{label}</Text>
|
||||
</Group>
|
||||
</div>
|
||||
));
|
||||
|
||||
export default Item;
|
||||
26
src/components/File/tag/Tag.tsx
Normal file
26
src/components/File/tag/Tag.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Box, CloseButton, MultiSelectValueProps, rem } from '@mantine/core';
|
||||
|
||||
export default function Tag({
|
||||
label,
|
||||
onRemove,
|
||||
color,
|
||||
...others
|
||||
}: MultiSelectValueProps & { color: string }) {
|
||||
return (
|
||||
<div {...others}>
|
||||
<Box
|
||||
sx={(theme) => ({
|
||||
display: 'flex',
|
||||
cursor: 'default',
|
||||
alignItems: 'center',
|
||||
backgroundColor: color,
|
||||
paddingLeft: theme.spacing.xs,
|
||||
borderRadius: theme.radius.sm,
|
||||
})}
|
||||
>
|
||||
<Box sx={{ lineHeight: 1, fontSize: rem(12) }}>{label}</Box>
|
||||
<CloseButton onMouseDown={onRemove} variant='transparent' size={22} iconSize={14} tabIndex={-1} />
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
Box,
|
||||
Burger,
|
||||
Button,
|
||||
Group,
|
||||
Header,
|
||||
Image,
|
||||
Input,
|
||||
MediaQuery,
|
||||
Menu,
|
||||
Navbar,
|
||||
@@ -218,14 +220,21 @@ export default function Layout({ children, props }) {
|
||||
labels: { confirm: 'Copy', cancel: 'Cancel' },
|
||||
onConfirm: async () => {
|
||||
clipboard.copy(token);
|
||||
|
||||
if (!navigator.clipboard)
|
||||
showNotification({
|
||||
title: 'Unable to copy token',
|
||||
message:
|
||||
"Zipline couldn't copy to your clipboard. Please copy the token manually from the settings page.",
|
||||
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>
|
||||
),
|
||||
color: 'red',
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
});
|
||||
else
|
||||
showNotification({
|
||||
@@ -280,7 +289,7 @@ export default function Layout({ children, props }) {
|
||||
component={Link}
|
||||
href={link}
|
||||
/>
|
||||
),
|
||||
)
|
||||
)}
|
||||
</Navbar.Section>
|
||||
<Navbar.Section>
|
||||
@@ -316,9 +325,7 @@ export default function Layout({ children, props }) {
|
||||
variant='dot'
|
||||
color={version.data.update ? 'red' : 'primary'}
|
||||
>
|
||||
{version.data.isUpstream
|
||||
? version.data.versions.current.slice(0, 7)
|
||||
: version.data.versions.current}
|
||||
{version.data.versions.current}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</Navbar.Section>
|
||||
@@ -351,22 +358,13 @@ export default function Layout({ children, props }) {
|
||||
<Menu.Target>
|
||||
<Button
|
||||
leftIcon={
|
||||
avatar ? (
|
||||
<Image src={avatar} height={32} width={32} fit='cover' radius='md' />
|
||||
) : (
|
||||
<IconUserCog size='1rem' />
|
||||
)
|
||||
avatar ? <Image src={avatar} height={32} radius='md' /> : <IconUserCog size='1rem' />
|
||||
}
|
||||
variant='subtle'
|
||||
color={theme.colorScheme === 'dark' ? 'dark' : 'gray'}
|
||||
color='gray'
|
||||
compact
|
||||
size='xl'
|
||||
p='sm'
|
||||
styles={{
|
||||
label: {
|
||||
overflow: 'unset',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{user.username}
|
||||
</Button>
|
||||
@@ -418,20 +416,16 @@ export default function Layout({ children, props }) {
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<>
|
||||
{oauth_providers.filter(
|
||||
(x) =>
|
||||
user.oauth
|
||||
?.map(({ provider }) => provider.toLowerCase())
|
||||
.includes(x.name.toLowerCase()),
|
||||
{oauth_providers.filter((x) =>
|
||||
user.oauth?.map(({ provider }) => provider.toLowerCase()).includes(x.name.toLowerCase())
|
||||
).length ? (
|
||||
<Menu.Label>Connected Accounts</Menu.Label>
|
||||
) : null}
|
||||
{oauth_providers
|
||||
.filter(
|
||||
(x) =>
|
||||
user.oauth
|
||||
?.map(({ provider }) => provider.toLowerCase())
|
||||
.includes(x.name.toLowerCase()),
|
||||
.filter((x) =>
|
||||
user.oauth
|
||||
?.map(({ provider }) => provider.toLowerCase())
|
||||
.includes(x.name.toLowerCase())
|
||||
)
|
||||
.map(({ name, Icon }, i) => (
|
||||
<>
|
||||
@@ -444,11 +438,8 @@ export default function Layout({ children, props }) {
|
||||
</Menu.Item>
|
||||
</>
|
||||
))}
|
||||
{oauth_providers.filter(
|
||||
(x) =>
|
||||
user.oauth
|
||||
?.map(({ provider }) => provider.toLowerCase())
|
||||
.includes(x.name.toLowerCase()),
|
||||
{oauth_providers.filter((x) =>
|
||||
user.oauth?.map(({ provider }) => provider.toLowerCase()).includes(x.name.toLowerCase())
|
||||
).length ? (
|
||||
<Menu.Divider />
|
||||
) : null}
|
||||
|
||||
@@ -4,10 +4,6 @@ import { useEffect } from 'react';
|
||||
import ayu_dark from 'lib/themes/ayu_dark';
|
||||
import ayu_light from 'lib/themes/ayu_light';
|
||||
import ayu_mirage from 'lib/themes/ayu_mirage';
|
||||
import catppuccin_mocha from 'lib/themes/catppuccin_mocha';
|
||||
import catppuccin_macchiato from 'lib/themes/catppuccin_macchiato';
|
||||
import catppuccin_frappe from 'lib/themes/catppuccin_frappe';
|
||||
import catppuccin_latte from 'lib/themes/catppuccin_latte';
|
||||
import dark from 'lib/themes/dark';
|
||||
import dark_blue from 'lib/themes/dark_blue';
|
||||
import dracula from 'lib/themes/dracula';
|
||||
@@ -36,10 +32,6 @@ export const themes = {
|
||||
ayu_dark,
|
||||
ayu_mirage,
|
||||
ayu_light,
|
||||
catppuccin_mocha,
|
||||
catppuccin_macchiato,
|
||||
catppuccin_frappe,
|
||||
catppuccin_latte,
|
||||
nord,
|
||||
dracula,
|
||||
matcha_dark_azul,
|
||||
@@ -54,10 +46,6 @@ export const friendlyThemeName = {
|
||||
ayu_dark: 'Ayu Dark',
|
||||
ayu_mirage: 'Ayu Mirage',
|
||||
ayu_light: 'Ayu Light',
|
||||
catppuccin_mocha: 'Catppuccin Mocha',
|
||||
catppuccin_macchiato: 'Catppuccin Macchiato',
|
||||
catppuccin_frappe: 'Catppuccin Frappé',
|
||||
catppuccin_latte: 'Catppuccin Latte',
|
||||
nord: 'Nord',
|
||||
dracula: 'Dracula',
|
||||
matcha_dark_azul: 'Matcha Dark Azul',
|
||||
|
||||
@@ -27,7 +27,7 @@ import PrismCode from './render/PrismCode';
|
||||
|
||||
function PlaceholderContent({ text, Icon }) {
|
||||
return (
|
||||
<Group sx={(t) => ({ color: t.colors.dark[2], padding: 3, justifyContent: 'center' })}>
|
||||
<Group sx={(t) => ({ color: t.colors.dark[2] })}>
|
||||
<Icon size={48} />
|
||||
<Text size='md'>{text}</Text>
|
||||
</Group>
|
||||
@@ -53,35 +53,6 @@ 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={typeof file.thumbnail === 'string' ? file.thumbnail : `/r/${file.thumbnail.name}`}
|
||||
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) === ''
|
||||
@@ -125,17 +96,6 @@ export default function Type({ file, popup = false, disableMediaPreview, ...prop
|
||||
);
|
||||
};
|
||||
|
||||
if (file.password) {
|
||||
return (
|
||||
<Placeholder
|
||||
Icon={IconFileAlert}
|
||||
text={`This file is password protected. Click to view file (${file.name})`}
|
||||
onClick={() => window.open(file.url)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if ((shouldRenderMarkdown || shouldRenderTex || shouldRenderCode) && !props.overrideRender && popup)
|
||||
return (
|
||||
<>
|
||||
@@ -154,6 +114,17 @@ export default function Type({ file, popup = false, disableMediaPreview, ...prop
|
||||
return <Placeholder Icon={IconFile} text={`Click to view file (${file.name})`} {...props} />;
|
||||
}
|
||||
|
||||
if (file.password) {
|
||||
return (
|
||||
<Placeholder
|
||||
Icon={IconFileAlert}
|
||||
text={`This file is password protected. Click to view file (${file.name})`}
|
||||
onClick={() => window.open(file.url)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return popup ? (
|
||||
media ? (
|
||||
{
|
||||
@@ -188,8 +159,7 @@ export default function Type({ file, popup = false, disableMediaPreview, ...prop
|
||||
)
|
||||
) : media ? (
|
||||
{
|
||||
// video: <Placeholder Icon={IconPlayerPlay} text={`Click to view video (${file.name})`} {...props} />,
|
||||
video: <VideoThumbnailPlaceholder file={file} mediaPreview={!disableMediaPreview} />,
|
||||
video: <Placeholder Icon={IconPlayerPlay} text={`Click to view video (${file.name})`} {...props} />,
|
||||
image: (
|
||||
<Image
|
||||
placeholder={<PlaceholderContent Icon={IconPhotoCancel} text={'Image failed to load...'} />}
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function Dropzone({ loading, onDrop, children }) {
|
||||
]}
|
||||
>
|
||||
<MantineDropzone loading={loading} onDrop={onDrop} styles={{ inner: { pointerEvents: 'none' } }}>
|
||||
<Group position='center' spacing='xl' style={{ minHeight: 440, flexDirection: 'column' }}>
|
||||
<Group position='center' spacing='xl' style={{ minHeight: 440 }}>
|
||||
<IconPhoto size={80} />
|
||||
|
||||
<Text size='xl' inline>
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Alert, Stack, Anchor, Code, Text } from '@mantine/core';
|
||||
import { IconExclamationCircle } from '@tabler/icons-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export default function Version4Notice() {
|
||||
const key = 'zipline-v4-notice';
|
||||
|
||||
const [isClosed, setClosed] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dismissed = localStorage.getItem(key) === 'true';
|
||||
setClosed(dismissed);
|
||||
}, [key]);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
setClosed(true);
|
||||
localStorage.setItem(key, 'true');
|
||||
}, [key]);
|
||||
|
||||
if (isClosed === null) return null;
|
||||
if (isClosed) return null;
|
||||
|
||||
return (
|
||||
<Alert
|
||||
withCloseButton
|
||||
variant='outline'
|
||||
icon={<IconExclamationCircle size='1rem' />}
|
||||
title='⚠️ Important! ⚠️'
|
||||
p='md'
|
||||
mb='md'
|
||||
onClose={handleDismiss}
|
||||
color='red'
|
||||
>
|
||||
<Stack spacing='md'>
|
||||
<Text>
|
||||
Zipline v4 will be released soon, and is <b>NOT</b> compatible with v3 (the current version). If you
|
||||
are using external software to automatically update Zipline on new releases, it is{' '}
|
||||
<b>strongly advised</b> that you stop auto-updates for the time being until v4 is released. For more
|
||||
information, please visit{' '}
|
||||
<Anchor target='_blank' href='https://github.com/diced/zipline/tree/v4'>
|
||||
the <Code>v4</Code> branch
|
||||
</Anchor>{' '}
|
||||
on GitHub to view the progress of v4. If you have any questions, feel free to{' '}
|
||||
<Anchor target='_blank' href='https://zipline.diced.sh/discord'>
|
||||
join our discord
|
||||
</Anchor>
|
||||
.
|
||||
</Text>
|
||||
|
||||
<Text>
|
||||
If you are not the server administrator, please consider notifying them of this important message.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import FileModal from 'components/File/FileModal';
|
||||
import MutedText from 'components/MutedText';
|
||||
import useFetch from 'lib/hooks/useFetch';
|
||||
import { PaginatedFilesOptions, usePaginatedFiles, useRecent } from 'lib/queries/files';
|
||||
import { usePaginatedFiles, useRecent } from 'lib/queries/files';
|
||||
import { useStats } from 'lib/queries/stats';
|
||||
import { userSelector } from 'lib/recoil/user';
|
||||
import { bytesToHuman } from 'lib/utils/bytes';
|
||||
@@ -22,7 +22,6 @@ import { useEffect, useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import RecentFiles from './RecentFiles';
|
||||
import { StatCards } from './StatCards';
|
||||
import Version4Notice from './Version4Notice';
|
||||
|
||||
export default function Dashboard({ disableMediaPreview, exifEnabled, compress }) {
|
||||
const user = useRecoilValue(userSelector);
|
||||
@@ -46,24 +45,32 @@ export default function Dashboard({ disableMediaPreview, exifEnabled, compress }
|
||||
})();
|
||||
}, [page]);
|
||||
|
||||
const files = usePaginatedFiles(page, 'none');
|
||||
|
||||
// sorting
|
||||
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
|
||||
columnAccessor: 'createdAt',
|
||||
columnAccessor: 'date',
|
||||
direction: 'asc',
|
||||
});
|
||||
const [records, setRecords] = useState(files.data);
|
||||
|
||||
const files = usePaginatedFiles(page, {
|
||||
filter: 'none',
|
||||
useEffect(() => {
|
||||
setRecords(files.data);
|
||||
}, [files.data]);
|
||||
|
||||
// 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,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
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]);
|
||||
|
||||
// file modal on click
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -99,24 +106,28 @@ export default function Dashboard({ disableMediaPreview, exifEnabled, compress }
|
||||
|
||||
const copyFile = async (file) => {
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}${file.url}`);
|
||||
|
||||
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' />,
|
||||
});
|
||||
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' />,
|
||||
});
|
||||
};
|
||||
|
||||
const viewFile = async (file) => {
|
||||
window.open(`${window.location.protocol}//${window.location.host}${file.url}`);
|
||||
};
|
||||
|
||||
// local storage to whether to show alert or not
|
||||
|
||||
return (
|
||||
<div>
|
||||
{selectedFile && (
|
||||
@@ -129,12 +140,9 @@ export default function Dashboard({ disableMediaPreview, exifEnabled, compress }
|
||||
reducedActions={false}
|
||||
exifEnabled={exifEnabled}
|
||||
compress={compress}
|
||||
otherUser={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Version4Notice />
|
||||
|
||||
<Title>Welcome back, {user?.username}</Title>
|
||||
<MutedText size='md'>
|
||||
You have <b>{numFiles === 0 ? '...' : numFiles}</b> files
|
||||
@@ -201,7 +209,7 @@ export default function Dashboard({ disableMediaPreview, exifEnabled, compress }
|
||||
),
|
||||
},
|
||||
]}
|
||||
records={files.data ?? []}
|
||||
records={records ?? []}
|
||||
fetching={files.isLoading}
|
||||
loaderBackgroundBlur={5}
|
||||
loaderVariant='dots'
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function FilePagation({ disableMediaPreview, exifEnabled, queryPa
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true },
|
||||
{ shallow: true }
|
||||
);
|
||||
|
||||
const { count } = await useFetch(`/api/user/paged?count=true${!checked ? '&filter=media' : ''}`);
|
||||
@@ -37,17 +37,9 @@ export default function FilePagation({ disableMediaPreview, exifEnabled, queryPa
|
||||
})();
|
||||
}, [page]);
|
||||
|
||||
const pages = usePaginatedFiles(page, {
|
||||
filter: !checked ? 'media' : 'none',
|
||||
});
|
||||
const pages = usePaginatedFiles(page, !checked ? 'media' : null);
|
||||
|
||||
if (pages.isSuccess && pages.data.length === 0) {
|
||||
if (page > 1 && numPages > 0) {
|
||||
setPage(page - 1);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Center sx={{ flexDirection: 'column' }}>
|
||||
<Group>
|
||||
|
||||
197
src/components/pages/Files/TagsModal.tsx
Normal file
197
src/components/pages/Files/TagsModal.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
ColorInput,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { useDeleteTags, useTags } from 'lib/queries/tags';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IconRefresh, IconTag, IconTags, IconTagsOff } from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
import { colorHash } from 'utils/client';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useModals } from '@mantine/modals';
|
||||
import MutedText from 'components/MutedText';
|
||||
|
||||
export function TagCard({ tags, tag }) {
|
||||
const deleteTags = useDeleteTags();
|
||||
const modals = useModals();
|
||||
|
||||
const deleteTag = () => {
|
||||
modals.openConfirmModal({
|
||||
zIndex: 1000,
|
||||
size: 'auto',
|
||||
title: (
|
||||
<Title>
|
||||
Delete tag <b style={{ color: tag.color }}>{tag.name}</b>?
|
||||
</Title>
|
||||
),
|
||||
children: `This will remove the tag from ${tag.files.length} file${tag.files.length === 1 ? '' : 's'}`,
|
||||
labels: {
|
||||
confirm: 'Delete',
|
||||
cancel: 'Cancel',
|
||||
},
|
||||
onCancel() {
|
||||
modals.closeAll();
|
||||
},
|
||||
onConfirm() {
|
||||
deleteTags.mutate([tag.id], {
|
||||
onSuccess: () => {
|
||||
showNotification({
|
||||
title: 'Tag deleted',
|
||||
message: `Tag ${tag.name} was deleted`,
|
||||
color: 'green',
|
||||
icon: <IconTags size='1rem' />,
|
||||
});
|
||||
modals.closeAll();
|
||||
tags.refetch();
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
radius='sm'
|
||||
sx={(t) => ({
|
||||
backgroundColor: tag.color,
|
||||
'&:hover': {
|
||||
backgroundColor: t.fn.darken(tag.color, 0.1),
|
||||
},
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
px='xs'
|
||||
onClick={deleteTag}
|
||||
>
|
||||
<Group position='apart'>
|
||||
<Text>
|
||||
{tag.name} ({tag.files.length})
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreateTagModal({ tags, open, onClose }) {
|
||||
const [color, setColor] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
|
||||
const [colorError, setColorError] = useState('');
|
||||
const [nameError, setNameError] = useState('');
|
||||
|
||||
const onSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setNameError('');
|
||||
setColorError('');
|
||||
|
||||
const n = name.trim();
|
||||
const c = color.trim();
|
||||
|
||||
if (n.length === 0 && c.length === 0) {
|
||||
setNameError('Name is required');
|
||||
setColorError('Color is required');
|
||||
return;
|
||||
} else if (n.length === 0) {
|
||||
setNameError('Name is required');
|
||||
setColorError('');
|
||||
return;
|
||||
} else if (c.length === 0) {
|
||||
setNameError('');
|
||||
setColorError('Color is required');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await useFetch('/api/user/tags', 'POST', {
|
||||
tags: [
|
||||
{
|
||||
name: n,
|
||||
color: c,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!data.error) {
|
||||
showNotification({
|
||||
title: 'Tag created',
|
||||
message: (
|
||||
<>
|
||||
Tag <b style={{ color: color }}>{name}</b> was created
|
||||
</>
|
||||
),
|
||||
color: 'green',
|
||||
icon: <IconTags size='1rem' />,
|
||||
});
|
||||
tags.refetch();
|
||||
onClose();
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Error creating tag',
|
||||
message: data.error,
|
||||
color: 'red',
|
||||
icon: <IconTagsOff size='1rem' />,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title={<Title>Create Tag</Title>} size='xs' opened={open} onClose={onClose} zIndex={300}>
|
||||
<form onSubmit={onSubmit}>
|
||||
<TextInput
|
||||
icon={<IconTag size='1rem' />}
|
||||
label='Name'
|
||||
value={name}
|
||||
onChange={(e) => setName(e.currentTarget.value)}
|
||||
error={nameError}
|
||||
/>
|
||||
<ColorInput
|
||||
dropdownZIndex={301}
|
||||
label='Color'
|
||||
value={color}
|
||||
onChange={setColor}
|
||||
error={colorError}
|
||||
rightSection={
|
||||
<Tooltip label='Generate color from name'>
|
||||
<ActionIcon variant='subtle' onClick={() => setColor(colorHash(name))} color='primary'>
|
||||
<IconRefresh size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button type='submit' fullWidth variant='outline' my='sm'>
|
||||
Create Tag
|
||||
</Button>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TagsModal({ open, onClose }) {
|
||||
const tags = useTags();
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateTagModal tags={tags} open={createOpen} onClose={() => setCreateOpen(false)} />
|
||||
<Modal title={<Title>Tags</Title>} size='auto' opened={open} onClose={onClose}>
|
||||
<MutedText size='sm'>Click on a tag to delete it.</MutedText>
|
||||
<Stack>
|
||||
{tags.isSuccess && tags.data.map((tag) => <TagCard key={tag.id} tags={tags} tag={tag} />)}
|
||||
</Stack>
|
||||
|
||||
<Button mt='xl' variant='outline' onClick={() => setCreateOpen(true)} fullWidth compact>
|
||||
Create Tag
|
||||
</Button>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Accordion, ActionIcon, Box, Group, Pagination, SimpleGrid, Title, Tooltip } from '@mantine/core';
|
||||
import { IconFileUpload, IconPhotoUp } from '@tabler/icons-react';
|
||||
import { IconFileUpload, IconPhotoUp, IconTags } from '@tabler/icons-react';
|
||||
import File from 'components/File';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { usePaginatedFiles } from 'lib/queries/files';
|
||||
@@ -7,20 +7,15 @@ 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';
|
||||
import TagsModal from 'components/pages/Files/TagsModal';
|
||||
|
||||
export default function Files({ disableMediaPreview, exifEnabled, queryPage, compress }) {
|
||||
const [checked] = useRecoilState(showNonMediaSelector);
|
||||
|
||||
const [favoritePage, setFavoritePage] = useState(1);
|
||||
const [favoriteNumPages, setFavoriteNumPages] = useState(0);
|
||||
const favoritePages = usePaginatedFiles(favoritePage, {
|
||||
filter: checked ? 'none' : 'media',
|
||||
favorite: true,
|
||||
});
|
||||
const favoritePages = usePaginatedFiles(favoritePage, 'media', true);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pendingOpen, setPendingOpen] = useState(false);
|
||||
const [tagsOpen, setTagsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
@@ -31,7 +26,8 @@ export default function Files({ disableMediaPreview, exifEnabled, queryPage, com
|
||||
|
||||
return (
|
||||
<>
|
||||
<PendingFilesModal open={open} onClose={() => setOpen(false)} />
|
||||
<PendingFilesModal open={pendingOpen} onClose={() => setPendingOpen(false)} />
|
||||
<TagsModal open={tagsOpen} onClose={() => setTagsOpen(false)} />
|
||||
|
||||
<Group mb='md'>
|
||||
<Title>Files</Title>
|
||||
@@ -40,10 +36,15 @@ export default function Files({ disableMediaPreview, exifEnabled, queryPage, com
|
||||
</ActionIcon>
|
||||
|
||||
<Tooltip label='View pending uploads'>
|
||||
<ActionIcon onClick={() => setOpen(true)} variant='filled' color='primary'>
|
||||
<ActionIcon onClick={() => setPendingOpen(true)} variant='filled' color='primary'>
|
||||
<IconPhotoUp size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label='View tags'>
|
||||
<ActionIcon onClick={() => setTagsOpen(true)} variant='filled' color='primary'>
|
||||
<IconTags 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,
|
||||
public: folder.public ? false : true,
|
||||
});
|
||||
|
||||
if (!res.error) {
|
||||
@@ -363,18 +363,25 @@ export default function Folders({ disableMediaPreview, exifEnabled, compress })
|
||||
aria-label='copy link'
|
||||
onClick={() => {
|
||||
clipboard.copy(`${window.location.origin}/folder/${folder.id}`);
|
||||
|
||||
showNotification({
|
||||
title: 'Copied folder link',
|
||||
message: (
|
||||
<>
|
||||
Copied <AnchorNext href={`/folder/${folder.id}`}>folder link</AnchorNext>{' '}
|
||||
to clipboard
|
||||
</>
|
||||
),
|
||||
color: 'green',
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
});
|
||||
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' />,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<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 { expireReadToDate, expireText, relativeTime } from 'lib/utils/client';
|
||||
import { 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 = ['30min', '1h', '6h', '12h', '1d', '3d', '5d', '7d', 'never'];
|
||||
const expires = ['30m', '1h', '6h', '12h', '1d', '3d', '5d', '7d', 'never'];
|
||||
|
||||
function CreateInviteModal({ open, setOpen, updateInvites }) {
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
expires: '30min',
|
||||
expires: '30m',
|
||||
count: 1,
|
||||
},
|
||||
});
|
||||
@@ -50,12 +50,26 @@ function CreateInviteModal({ open, setOpen, updateInvites }) {
|
||||
if (!expires.includes(values.expires)) return form.setFieldError('expires', 'Invalid expiration');
|
||||
if (values.count < 1 || values.count > 100)
|
||||
return form.setFieldError('count', 'Must be between 1 and 100');
|
||||
const expiresAt = expireReadToDate(values.expires);
|
||||
const expiresAt =
|
||||
values.expires === 'never'
|
||||
? null
|
||||
: new Date(
|
||||
{
|
||||
'30m': Date.now() + 30 * 60 * 1000,
|
||||
'1h': Date.now() + 60 * 60 * 1000,
|
||||
'6h': Date.now() + 6 * 60 * 60 * 1000,
|
||||
'12h': Date.now() + 12 * 60 * 60 * 1000,
|
||||
'1d': Date.now() + 24 * 60 * 60 * 1000,
|
||||
'3d': Date.now() + 3 * 24 * 60 * 60 * 1000,
|
||||
'5d': Date.now() + 5 * 24 * 60 * 60 * 1000,
|
||||
'7d': Date.now() + 7 * 24 * 60 * 60 * 1000,
|
||||
}[values.expires]
|
||||
);
|
||||
|
||||
setOpen(false);
|
||||
|
||||
const res = await useFetch('/api/auth/invite', 'POST', {
|
||||
expiresAt: `date=${expiresAt.toISOString()}`,
|
||||
expiresAt,
|
||||
count: values.count,
|
||||
});
|
||||
|
||||
@@ -85,9 +99,8 @@ function CreateInviteModal({ open, setOpen, updateInvites }) {
|
||||
label='Expires'
|
||||
id='expires'
|
||||
{...form.getInputProps('expires')}
|
||||
maxDropdownHeight={100}
|
||||
data={[
|
||||
{ value: '30min', label: '30 minutes' },
|
||||
{ value: '30m', label: '30 minutes' },
|
||||
{ value: '1h', label: '1 hour' },
|
||||
{ value: '6h', label: '6 hours' },
|
||||
{ value: '12h', label: '12 hours' },
|
||||
@@ -95,6 +108,7 @@ function CreateInviteModal({ open, setOpen, updateInvites }) {
|
||||
{ value: '3d', label: '3 days' },
|
||||
{ value: '5d', label: '5 days' },
|
||||
{ value: '7d', label: '7 days' },
|
||||
{ value: 'never', label: 'Never' },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -183,12 +197,18 @@ export default function Invites() {
|
||||
|
||||
const handleCopy = async (invite) => {
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}/auth/register?code=${invite.code}`);
|
||||
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: '',
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
});
|
||||
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' />,
|
||||
});
|
||||
};
|
||||
|
||||
const updateInvites = async () => {
|
||||
@@ -298,65 +318,45 @@ export default function Invites() {
|
||||
/>
|
||||
) : (
|
||||
<SimpleGrid cols={3} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
|
||||
{!ok && !invites.length && (
|
||||
<>
|
||||
{[1, 2, 3].map((x) => (
|
||||
<Skeleton key={x} width='100%' height={100} radius='sm' />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{invites.length && ok ? (
|
||||
invites.map((invite) => (
|
||||
<Card key={invite.id} sx={{ maxWidth: '100%' }}>
|
||||
<Group position='apart'>
|
||||
<Group position='left'>
|
||||
<Avatar size='lg' color={invite.used ? 'dark' : 'primary'}>
|
||||
{invite.id}
|
||||
</Avatar>
|
||||
<Stack spacing={0}>
|
||||
<Title>
|
||||
{invite.code}
|
||||
{invite.used && <> (Used)</>}
|
||||
</Title>
|
||||
<Tooltip label={new Date(invite.createdAt).toLocaleString()}>
|
||||
<div>
|
||||
<MutedText size='sm'>Created {relativeTime(new Date(invite.createdAt))}</MutedText>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip label={new Date(invite.expiresAt).toLocaleString()}>
|
||||
<div>
|
||||
<MutedText size='sm'>{expireText(invite.expiresAt.toString())}</MutedText>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{invites.length
|
||||
? invites.map((invite) => (
|
||||
<Card key={invite.id} sx={{ maxWidth: '100%' }}>
|
||||
<Group position='apart'>
|
||||
<Group position='left'>
|
||||
<Avatar size='lg' color={invite.used ? 'dark' : 'primary'}>
|
||||
{invite.id}
|
||||
</Avatar>
|
||||
<Stack spacing={0}>
|
||||
<Title>
|
||||
{invite.code}
|
||||
{invite.used && <> (Used)</>}
|
||||
</Title>
|
||||
<Tooltip label={new Date(invite.createdAt).toLocaleString()}>
|
||||
<div>
|
||||
<MutedText size='sm'>
|
||||
Created {relativeTime(new Date(invite.createdAt))}
|
||||
</MutedText>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip label={new Date(invite.expiresAt).toLocaleString()}>
|
||||
<div>
|
||||
<MutedText size='sm'>{expireText(invite.expiresAt.toString())}</MutedText>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Stack>
|
||||
<ActionIcon aria-label='copy' onClick={() => handleCopy(invite)}>
|
||||
<IconClipboardCopy size='1rem' />
|
||||
</ActionIcon>
|
||||
<ActionIcon aria-label='delete' onClick={() => openDeleteModal(invite)}>
|
||||
<IconTrash size='1rem' />
|
||||
</ActionIcon>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Stack>
|
||||
<ActionIcon aria-label='copy' onClick={() => handleCopy(invite)}>
|
||||
<IconClipboardCopy size='1rem' />
|
||||
</ActionIcon>
|
||||
<ActionIcon aria-label='delete' onClick={() => openDeleteModal(invite)}>
|
||||
<IconTrash size='1rem' />
|
||||
</ActionIcon>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<div></div>
|
||||
<Group>
|
||||
<div>
|
||||
<IconTag size={48} />
|
||||
</div>
|
||||
<div>
|
||||
<Title>Nothing here</Title>
|
||||
<MutedText size='md'>Create some invites and they will show up here</MutedText>
|
||||
</div>
|
||||
</Group>
|
||||
<div></div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
))
|
||||
: [1, 2, 3].map((x) => <Skeleton key={x} width='100%' height={100} radius='sm' />)}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useState } from 'react';
|
||||
|
||||
export default function ClearStorage({ open, setOpen }) {
|
||||
const [check, setCheck] = useState(false);
|
||||
const handleDelete = async (orphaned?: boolean) => {
|
||||
const handleDelete = async (datasource: boolean, orphaned?: boolean) => {
|
||||
showNotification({
|
||||
id: 'clear-uploads',
|
||||
title: 'Clearing...',
|
||||
@@ -16,7 +16,7 @@ export default function ClearStorage({ open, setOpen }) {
|
||||
autoClose: false,
|
||||
});
|
||||
|
||||
const res = await useFetch('/api/admin/clear', 'POST', { orphaned });
|
||||
const res = await useFetch('/api/admin/clear', 'POST', { datasource, orphaned });
|
||||
|
||||
if (res.error) {
|
||||
updateNotification({
|
||||
@@ -65,13 +65,21 @@ export default function ClearStorage({ open, setOpen }) {
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
openConfirmModal({
|
||||
title: 'Are you sure?',
|
||||
confirmProps: { color: 'red' },
|
||||
children: <Text size='sm'>This action is destructive and irreversible.</Text>,
|
||||
labels: { confirm: 'Yes', cancel: 'No' },
|
||||
title: 'Do you want to clear storage too?',
|
||||
labels: { confirm: 'Yes', cancel: check ? 'Ok' : 'No' },
|
||||
children: check && (
|
||||
<Text size='sm' color='gray'>
|
||||
Due to clearing orphaned files, storage clearing will be unavailable.
|
||||
</Text>
|
||||
),
|
||||
confirmProps: { disabled: check },
|
||||
onConfirm: () => {
|
||||
closeAllModals();
|
||||
handleDelete(check);
|
||||
handleDelete(true);
|
||||
},
|
||||
onCancel: () => {
|
||||
closeAllModals();
|
||||
handleDelete(false, check);
|
||||
},
|
||||
onClose: () => setCheck(false),
|
||||
});
|
||||
|
||||
@@ -75,7 +75,7 @@ export default function Flameshot({ user, open, setOpen }) {
|
||||
let shell;
|
||||
if (values.type === 'upload-file') {
|
||||
shell = `#!/bin/bash${values.wlCompositorNotSupported ? '\nexport XDG_CURRENT_DESKTOP=sway\n' : ''}
|
||||
flameshot gui -r > /tmp/ss.png;if [ ! -s /tmp/ss.png ]; then\n exit 1\nfi
|
||||
flameshot gui -r > /tmp/ss.png;
|
||||
${curl.join(' ')}${values.noJSON ? '' : " | jq -r '.files[0]'"} | tr -d '\\n' | ${
|
||||
values.wlCompatibility ? 'wl-copy' : 'xsel -ib'
|
||||
};
|
||||
|
||||
@@ -87,7 +87,7 @@ export default function ShareX({ user, open, setOpen }) {
|
||||
const pseudoElement = document.createElement('a');
|
||||
pseudoElement.setAttribute(
|
||||
'href',
|
||||
'data:application/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(config, null, '\t')),
|
||||
'data:application/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(config, null, '\t'))
|
||||
);
|
||||
pseudoElement.setAttribute('download', `zipline${values.type === 'upload-file' ? '' : '-url'}.sxcu`);
|
||||
pseudoElement.style.display = 'none';
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Anchor,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Code,
|
||||
ColorInput,
|
||||
CopyButton,
|
||||
FileInput,
|
||||
Group,
|
||||
Image,
|
||||
List,
|
||||
PasswordInput,
|
||||
SimpleGrid,
|
||||
Space,
|
||||
@@ -25,12 +20,9 @@ import { randomId, useInterval, useMediaQuery } from '@mantine/hooks';
|
||||
import { useModals } from '@mantine/modals';
|
||||
import { showNotification, updateNotification } from '@mantine/notifications';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconBrandDiscordFilled,
|
||||
IconBrandGithubFilled,
|
||||
IconBrandGoogle,
|
||||
IconCheck,
|
||||
IconClipboardCopy,
|
||||
IconFileExport,
|
||||
IconFiles,
|
||||
IconFilesOff,
|
||||
@@ -45,7 +37,6 @@ import {
|
||||
IconUserExclamation,
|
||||
IconUserMinus,
|
||||
IconUserX,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import AnchorNext from 'components/AnchorNext';
|
||||
import { FlameshotIcon, ShareXIcon } from 'components/icons';
|
||||
@@ -98,7 +89,6 @@ 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 [tokenShown, setTokenShown] = useState(false);
|
||||
|
||||
const getDataURL = (f: File): Promise<string> => {
|
||||
return new Promise((res, rej) => {
|
||||
@@ -269,34 +259,14 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
setExports(
|
||||
res.exports
|
||||
?.map((s) => ({
|
||||
date: new Date(s.createdAt),
|
||||
date: new Date(Number(s.name.split('_')[3].slice(0, -4))),
|
||||
size: s.size,
|
||||
full: s.name,
|
||||
}))
|
||||
.sort((a, b) => a.date.getTime() - b.date.getTime()),
|
||||
.sort((a, b) => a.date.getTime() - b.date.getTime())
|
||||
);
|
||||
};
|
||||
|
||||
const deleteExport = async (name) => {
|
||||
const res = await useFetch('/api/user/export?name=' + name, 'DELETE');
|
||||
if (res.error) {
|
||||
showNotification({
|
||||
title: 'Error deleting export',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <IconX size='1rem' />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
message: 'Deleted export',
|
||||
color: 'green',
|
||||
icon: <IconFileZip size='1rem' />,
|
||||
});
|
||||
|
||||
await getExports();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
const res = await useFetch('/api/user/files', 'DELETE', {
|
||||
all: true,
|
||||
@@ -380,129 +350,6 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
}
|
||||
};
|
||||
|
||||
const startFullExport = () => {
|
||||
modals.openConfirmModal({
|
||||
title: <Title>Are you sure?</Title>,
|
||||
size: 'xl',
|
||||
children: (
|
||||
<Box px='md'>
|
||||
<Alert color='red' icon={<IconAlertCircle size='1rem' />} title='Warning'>
|
||||
This export contains a significant amount of sensitive data, including user information,
|
||||
passwords, metadata, and system details. It is crucial to handle this file with care to prevent
|
||||
unauthorized access or misuse. Ensure it is stored securely and shared only with trusted parties.
|
||||
</Alert>
|
||||
|
||||
<p>
|
||||
The export provides a snapshot of Zipline's data and environment. Specifically, it includes:
|
||||
</p>
|
||||
|
||||
<List>
|
||||
<List.Item>
|
||||
<b>User Data:</b> Information about users, avatars, passwords, and registered OAuth providers.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<b>Files:</b> Metadata about uploaded files including filenames, passwords, sizes, and
|
||||
timestamps, linked users. <i>(Note: the actual contents of the files are not included.)</i>
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<b>URLs:</b> Metadata about shortened URLs, including the original URL, short URL, and vanity.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<b>Folders:</b> Metadata about folders, including names, visibility settings, and files.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<b>Thumbnails:</b> Metadata about thumbnails, includes the name and creation timestamp.{' '}
|
||||
<i>(Actual image data is excluded.)</i>
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<b>Invites:</b> Metadata about invites, includes the invite code, creator, and expiration date.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<b>Statistics:</b> Usage data that is used on the statistics page, including upload counts and
|
||||
such.
|
||||
</List.Item>
|
||||
</List>
|
||||
<p>
|
||||
Additionally, the export captures <b>system-specific information</b>:
|
||||
</p>
|
||||
<List>
|
||||
<List.Item>
|
||||
<b>CPU Count:</b> The number of processing cores available on the host system.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<b>Hostname:</b> The network identifier of the host system.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<b>Architecture:</b> The hardware architecture (e.g., <Code>x86</Code>, <Code>arm</Code>) on
|
||||
which Zipline is running.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<b>Platform:</b> The operating system platform (e.g., <Code>linux</Code>, <Code>darwin</Code>)
|
||||
on which Zipline is running.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<b>Version:</b> The current version of the operating system (kernel version)
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<b>Environment Variables:</b> The configuration settings and variables defined at the time of
|
||||
execution.
|
||||
</List.Item>
|
||||
</List>
|
||||
|
||||
<p>
|
||||
<i>Note:</i> By omitting the actual contents of files and thumbnails while including their
|
||||
metadata, the export ensures it captures enough detail for migration to another instance, or for
|
||||
v4.
|
||||
</p>
|
||||
</Box>
|
||||
),
|
||||
labels: { confirm: 'Yes', cancel: 'No' },
|
||||
cancelProps: { color: 'red' },
|
||||
onConfirm: async () => {
|
||||
modals.closeAll();
|
||||
showNotification({
|
||||
title: 'Exporting all server data...',
|
||||
message: 'This may take a while depending on the amount of data.',
|
||||
loading: true,
|
||||
id: 'export-all',
|
||||
autoClose: false,
|
||||
});
|
||||
|
||||
const res = await useFetch('/api/admin/export', 'GET');
|
||||
if (res.error) {
|
||||
updateNotification({
|
||||
id: 'export-all',
|
||||
title: 'Error exporting data',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <IconFileExport size='1rem' />,
|
||||
autoClose: true,
|
||||
});
|
||||
} else {
|
||||
updateNotification({
|
||||
title: 'Export created',
|
||||
message: 'Your browser will prompt you to download a JSON file with all the server data.',
|
||||
id: 'export-all',
|
||||
color: 'green',
|
||||
icon: <IconFileExport size='1rem' />,
|
||||
autoClose: true,
|
||||
});
|
||||
|
||||
const blob = new Blob([JSON.stringify(res)], { type: 'application/json' });
|
||||
const a = document.createElement('a');
|
||||
a.style.display = 'none';
|
||||
const url = URL.createObjectURL(blob);
|
||||
console.log(url, res);
|
||||
a.setAttribute('download', `zipline_export_${Date.now()}.json`);
|
||||
a.setAttribute('href', url);
|
||||
a.click();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const interval = useInterval(() => getExports(), 30000);
|
||||
useEffect(() => {
|
||||
getExports();
|
||||
@@ -515,27 +362,9 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
<Title>Manage User</Title>
|
||||
<MutedText size='md'>
|
||||
Want to use variables in embed text? Visit{' '}
|
||||
<AnchorNext href='https://zipline.diced.sh/docs/guides/variables'>the docs</AnchorNext> for variables
|
||||
<AnchorNext href='https://zipline.diced.tech/docs/guides/variables'>the docs</AnchorNext> for
|
||||
variables
|
||||
</MutedText>
|
||||
|
||||
<TextInput
|
||||
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
|
||||
@@ -635,7 +464,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
{oauth_providers
|
||||
.filter(
|
||||
(x) =>
|
||||
!user.oauth?.map(({ provider }) => provider.toLowerCase()).includes(x.name.toLowerCase()),
|
||||
!user.oauth?.map(({ provider }) => provider.toLowerCase()).includes(x.name.toLowerCase())
|
||||
)
|
||||
.map(({ link_url, name, Icon }, i) => (
|
||||
<Button key={i} size='lg' leftIcon={<Icon />} component={Link} href={link_url} my='sm'>
|
||||
@@ -728,7 +557,6 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
{ id: 'name', name: 'Name' },
|
||||
{ id: 'date', name: 'Date' },
|
||||
{ id: 'size', name: 'Size' },
|
||||
{ id: 'actions', name: '' },
|
||||
]}
|
||||
rows={
|
||||
exports
|
||||
@@ -740,11 +568,6 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
),
|
||||
date: x.date.toLocaleString(),
|
||||
size: bytesToHuman(x.size),
|
||||
actions: (
|
||||
<ActionIcon onClick={() => deleteExport(x.full)}>
|
||||
<IconTrash size='1rem' />
|
||||
</ActionIcon>
|
||||
),
|
||||
}))
|
||||
: []
|
||||
}
|
||||
@@ -769,11 +592,6 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
|
||||
>
|
||||
Delete all uploads
|
||||
</Button>
|
||||
{user.superAdmin && (
|
||||
<Button size='md' onClick={startFullExport} rightIcon={<IconFileExport size='1rem' />}>
|
||||
Export all server data (JSON)
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -27,12 +27,18 @@ export default function MetadataView({ fileId }) {
|
||||
|
||||
const copy = (value) => {
|
||||
clipboard.copy(value);
|
||||
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: value,
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
});
|
||||
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' />,
|
||||
});
|
||||
};
|
||||
|
||||
const searchValue = (value) => {
|
||||
|
||||
@@ -1,26 +1,19 @@
|
||||
import { Anchor, Button, Collapse, Group, Progress, Stack, Text, Title } from '@mantine/core';
|
||||
import { Button, Collapse, Group, Progress, Stack, Title } from '@mantine/core';
|
||||
import { randomId, useClipboard } from '@mantine/hooks';
|
||||
import { useModals } from '@mantine/modals';
|
||||
import { hideNotification, showNotification, updateNotification } from '@mantine/notifications';
|
||||
import {
|
||||
IconClipboardCopy,
|
||||
IconFileImport,
|
||||
IconFileTime,
|
||||
IconFileUpload,
|
||||
IconFileX,
|
||||
} from '@tabler/icons-react';
|
||||
import { showNotification, updateNotification } from '@mantine/notifications';
|
||||
import { IconFileImport, IconFileTime, IconFileUpload, IconFileX } from '@tabler/icons-react';
|
||||
import Dropzone from 'components/dropzone/Dropzone';
|
||||
import FileDropzone from 'components/dropzone/DropzoneFile';
|
||||
import MutedText from 'components/MutedText';
|
||||
import { invalidateFiles } from 'lib/queries/files';
|
||||
import { userSelector } from 'lib/recoil/user';
|
||||
import { expireReadToDate, randomChars } from 'lib/utils/client';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import showFilesModal from './showFilesModal';
|
||||
import useUploadOptions from './useUploadOptions';
|
||||
import { useRouter } from 'next/router';
|
||||
import AnchorNext from 'components/AnchorNext';
|
||||
|
||||
export default function File({ chunks: chunks_config }) {
|
||||
const router = useRouter();
|
||||
@@ -35,29 +28,23 @@ export default function File({ chunks: chunks_config }) {
|
||||
|
||||
const [options, setOpened, OptionsModal] = useUploadOptions();
|
||||
|
||||
const beforeUnload = useCallback(
|
||||
(e: BeforeUnloadEvent) => {
|
||||
if (loading) {
|
||||
e.preventDefault();
|
||||
e.returnValue = "Are you sure you want to leave? Your upload(s) won't be saved.";
|
||||
return e.returnValue;
|
||||
}
|
||||
},
|
||||
[loading],
|
||||
);
|
||||
const beforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (loading) {
|
||||
e.preventDefault();
|
||||
e.returnValue = "Are you sure you want to leave? Your upload(s) won't be saved.";
|
||||
return e.returnValue;
|
||||
}
|
||||
};
|
||||
|
||||
const beforeRouteChange = useCallback(
|
||||
(url: string) => {
|
||||
if (loading) {
|
||||
const confirmed = confirm("Are you sure you want to leave? Your upload(s) won't be saved.");
|
||||
if (!confirmed) {
|
||||
router.events.emit('routeChangeComplete', url);
|
||||
throw 'Route change aborted';
|
||||
}
|
||||
const beforeRouteChange = (url: string) => {
|
||||
if (loading) {
|
||||
const confirmed = confirm("Are you sure you want to leave? Your upload(s) won't be saved.");
|
||||
if (!confirmed) {
|
||||
router.events.emit('routeChangeComplete', url);
|
||||
throw 'Route change aborted';
|
||||
}
|
||||
},
|
||||
[loading],
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (e: ClipboardEvent) => {
|
||||
@@ -75,24 +62,16 @@ export default function File({ chunks: chunks_config }) {
|
||||
};
|
||||
|
||||
document.addEventListener('paste', listener);
|
||||
window.addEventListener('beforeunload', beforeUnload, true);
|
||||
window.addEventListener('beforeunload', beforeUnload);
|
||||
router.events.on('routeChangeStart', beforeRouteChange);
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', beforeUnload, true);
|
||||
window.removeEventListener('beforeunload', beforeUnload);
|
||||
router.events.off('routeChangeStart', beforeRouteChange);
|
||||
document.removeEventListener('paste', listener);
|
||||
};
|
||||
}, [loading, beforeUnload, beforeRouteChange]);
|
||||
|
||||
const handleChunkedFiles = async (expiresAt: Date, toChunkFiles: File[]) => {
|
||||
if (!chunks_config.enabled)
|
||||
return showNotification({
|
||||
id: 'upload-chunked',
|
||||
title: 'Chunked files are disabled',
|
||||
message: 'This should not be called, but some how got called...',
|
||||
color: 'red',
|
||||
});
|
||||
|
||||
for (let i = 0; i !== toChunkFiles.length; ++i) {
|
||||
const file = toChunkFiles[i];
|
||||
const identifier = randomChars(4);
|
||||
@@ -146,34 +125,15 @@ export default function File({ chunks: chunks_config }) {
|
||||
updateNotification({
|
||||
id: 'upload-chunked',
|
||||
title: 'Finalizing partial upload',
|
||||
message: (
|
||||
<Text>
|
||||
The upload has been offloaded, and will complete in the background.
|
||||
<br />
|
||||
<Anchor
|
||||
component='span'
|
||||
onClick={() => {
|
||||
hideNotification('upload-chunked');
|
||||
clipboard.copy(json.files[0]);
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: <AnchorNext href={json.files[0]}>{json.files[0]}</AnchorNext>,
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Click here to copy the URL while it‘s being processed.
|
||||
</Anchor>
|
||||
</Text>
|
||||
),
|
||||
message:
|
||||
'The upload has been offloaded, and will complete in the background. You can see processing files in the files tab.',
|
||||
icon: <IconFileTime size='1rem' />,
|
||||
color: 'green',
|
||||
autoClose: false,
|
||||
autoClose: true,
|
||||
});
|
||||
invalidateFiles();
|
||||
setFiles([]);
|
||||
setProgress(100);
|
||||
setLoading(false);
|
||||
|
||||
setTimeout(() => setProgress(0), 1000);
|
||||
}
|
||||
@@ -191,7 +151,7 @@ export default function File({ chunks: chunks_config }) {
|
||||
ready = false;
|
||||
}
|
||||
},
|
||||
false,
|
||||
false
|
||||
);
|
||||
|
||||
req.open('POST', '/api/upload');
|
||||
@@ -230,10 +190,10 @@ export default function File({ chunks: chunks_config }) {
|
||||
|
||||
for (let i = 0; i !== files.length; ++i) {
|
||||
const file = files[i];
|
||||
if (chunks_config.enabled && file.size >= chunks_config.max_size) {
|
||||
if (file.size >= chunks_config.max_size) {
|
||||
toChunkFiles.push(file);
|
||||
} else {
|
||||
body.append('file', files[i], encodeURIComponent(files[i].name));
|
||||
body.append('file', files[i]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,7 +267,7 @@ export default function File({ chunks: chunks_config }) {
|
||||
}
|
||||
setProgress(0);
|
||||
},
|
||||
false,
|
||||
false
|
||||
);
|
||||
|
||||
if (bodyLength !== 0) {
|
||||
@@ -364,8 +324,7 @@ export default function File({ chunks: chunks_config }) {
|
||||
<Button
|
||||
leftIcon={<IconFileUpload size='1rem' />}
|
||||
onClick={handleUpload}
|
||||
loading={loading}
|
||||
disabled={files.length === 0 || loading}
|
||||
disabled={files.length === 0 ? true : false}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
|
||||
@@ -22,7 +22,6 @@ export default function Text() {
|
||||
|
||||
const [value, setValue] = useState('');
|
||||
const [lang, setLang] = useState('txt');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [options, setOpened, OptionsModal] = useUploadOptions();
|
||||
|
||||
@@ -30,9 +29,6 @@ export default function Text() {
|
||||
const shouldRenderTex = lang === 'tex';
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (value.trim().length === 0) return;
|
||||
|
||||
setLoading(true);
|
||||
const file = new File([value], 'text.' + lang);
|
||||
|
||||
const expiresAt = options.expires === 'never' ? null : expireReadToDate(options.expires);
|
||||
@@ -57,16 +53,6 @@ export default function Text() {
|
||||
message: '',
|
||||
});
|
||||
showFilesModal(clipboard, modals, json.files);
|
||||
setLoading(false);
|
||||
setValue('');
|
||||
} else {
|
||||
updateNotification({
|
||||
id: 'upload-text',
|
||||
title: 'Upload Failed',
|
||||
message: json.error,
|
||||
color: 'red',
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -150,8 +136,7 @@ export default function Text() {
|
||||
<Button
|
||||
leftIcon={<IconFileUpload size='1rem' />}
|
||||
onClick={handleUpload}
|
||||
disabled={value.trim().length === 0 || loading}
|
||||
loading={loading}
|
||||
disabled={value.trim().length === 0 ? true : false}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
|
||||
@@ -7,12 +7,18 @@ 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]);
|
||||
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: <AnchorNext href={files[idx]}>{files[idx]}</AnchorNext>,
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
});
|
||||
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' />,
|
||||
});
|
||||
};
|
||||
|
||||
modals.openModal({
|
||||
|
||||
@@ -213,7 +213,7 @@ export function OptionsModal({
|
||||
export default function useUploadOptions(): [
|
||||
UploadOptionsState,
|
||||
Dispatch<SetStateAction<boolean>>,
|
||||
ReactNode,
|
||||
ReactNode
|
||||
] {
|
||||
const [state, setState] = useReducer((state, newState) => ({ ...state, ...newState }), {
|
||||
expires: 'never',
|
||||
|
||||
@@ -169,12 +169,18 @@ export default function Urls() {
|
||||
|
||||
const copyURL = (u) => {
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}${u.url}`);
|
||||
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: '',
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
});
|
||||
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' />,
|
||||
});
|
||||
};
|
||||
|
||||
const urlDelete = useURLDelete();
|
||||
|
||||
@@ -26,7 +26,7 @@ export function CreateUserModal({ open, setOpen, updateUsers }) {
|
||||
};
|
||||
|
||||
setOpen(false);
|
||||
const res = await useFetch('/api/auth/register', 'POST', data);
|
||||
const res = await useFetch('/api/auth/create', 'POST', data);
|
||||
if (res.error) {
|
||||
showNotification({
|
||||
title: 'Failed to create user',
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { ActionIcon, Button, Center, Group, SimpleGrid, Title } from '@mantine/core';
|
||||
import type { 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,7 +6,6 @@ import type { User } from '@prisma/client';
|
||||
import {
|
||||
IconClipboardCopy,
|
||||
IconEdit,
|
||||
IconExternalLink,
|
||||
IconGridDots,
|
||||
IconList,
|
||||
IconUserExclamation,
|
||||
@@ -117,10 +116,6 @@ export default function Users() {
|
||||
}
|
||||
};
|
||||
|
||||
const openUser = async (user) => {
|
||||
await router.push(`/dashboard/users/${user.id}`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateUsers();
|
||||
}, []);
|
||||
@@ -186,13 +181,6 @@ 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>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -7,23 +7,16 @@ import { Language } from 'prism-react-renderer';
|
||||
export default function Markdown({ code, ...props }) {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[[remarkGfm, { singleTilde: false }]]}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code({ children }) {
|
||||
return <Code>{children}</Code>;
|
||||
},
|
||||
pre({ children }) {
|
||||
// @ts-expect-error someone find the type for this :sob:
|
||||
const match = /language-(\w+)/.exec(children.props?.className || '');
|
||||
// @ts-ignore
|
||||
if (!children.props?.children) return code;
|
||||
return (
|
||||
<Prism language={match ? (match[1] as Language) : 'markdown'}>
|
||||
{
|
||||
// @ts-expect-error
|
||||
String(children.props?.children).replace(/\n$/, '')
|
||||
}
|
||||
code({ inline, className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
return !inline && match ? (
|
||||
<Prism language={match[1] as Language} {...props}>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</Prism>
|
||||
) : (
|
||||
<Code {...props}>{children}</Code>
|
||||
);
|
||||
},
|
||||
img(props) {
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function PrismCode({ code, ext, ...props }) {
|
||||
|
||||
return (
|
||||
<Prism
|
||||
sx={(t) => ({ height: '100vh', overflow: 'scroll', backgroundColor: t.colors.dark[8] })}
|
||||
sx={(t) => ({ height: '100vh', backgroundColor: t.colors.dark[8] })}
|
||||
withLineNumbers
|
||||
language={exts[ext]?.toLowerCase()}
|
||||
{...props}
|
||||
|
||||
@@ -10,7 +10,6 @@ export interface ConfigCore {
|
||||
|
||||
stats_interval: number;
|
||||
invites_interval: number;
|
||||
thumbnails_interval: number;
|
||||
}
|
||||
|
||||
export interface ConfigCompression {
|
||||
@@ -20,9 +19,10 @@ export interface ConfigCompression {
|
||||
}
|
||||
|
||||
export interface ConfigDatasource {
|
||||
type: 'local' | 's3';
|
||||
type: 'local' | 's3' | 'supabase';
|
||||
local: ConfigLocalDatasource;
|
||||
s3?: ConfigS3Datasource;
|
||||
supabase?: ConfigSupabaseDatasource;
|
||||
}
|
||||
|
||||
export interface ConfigLocalDatasource {
|
||||
@@ -40,6 +40,12 @@ export interface ConfigS3Datasource {
|
||||
region?: string;
|
||||
}
|
||||
|
||||
export interface ConfigSupabaseDatasource {
|
||||
url: string;
|
||||
key: string;
|
||||
bucket: string;
|
||||
}
|
||||
|
||||
export interface ConfigUploader {
|
||||
default_format: string;
|
||||
route: string;
|
||||
@@ -50,7 +56,6 @@ export interface ConfigUploader {
|
||||
format_date: string;
|
||||
default_expiration: string;
|
||||
assume_mimetypes: boolean;
|
||||
random_words_separator: string;
|
||||
}
|
||||
|
||||
export interface ConfigUrls {
|
||||
@@ -115,27 +120,17 @@ export interface ConfigFeatures {
|
||||
headless: boolean;
|
||||
|
||||
default_avatar: string;
|
||||
|
||||
robots_txt: string;
|
||||
|
||||
thumbnails: boolean;
|
||||
gif_thumbnails: boolean;
|
||||
}
|
||||
|
||||
export interface ConfigOAuth {
|
||||
bypass_local_login: boolean;
|
||||
|
||||
github_client_id?: string;
|
||||
github_client_secret?: string;
|
||||
|
||||
discord_client_id?: string;
|
||||
discord_client_secret?: string;
|
||||
discord_redirect_uri?: string;
|
||||
discord_whitelisted_users?: string[];
|
||||
|
||||
google_client_id?: string;
|
||||
google_client_secret?: string;
|
||||
google_redirect_uri?: string;
|
||||
}
|
||||
|
||||
export interface ConfigChunks {
|
||||
|
||||
@@ -63,11 +63,8 @@ export default function readConfig() {
|
||||
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'),
|
||||
@@ -85,6 +82,10 @@ export default function readConfig() {
|
||||
map('DATASOURCE_S3_REGION', 'string', 'datasource.s3.region'),
|
||||
map('DATASOURCE_S3_USE_SSL', 'boolean', 'datasource.s3.use_ssl'),
|
||||
|
||||
map('DATASOURCE_SUPABASE_URL', 'string', 'datasource.supabase.url'),
|
||||
map('DATASOURCE_SUPABASE_KEY', 'string', 'datasource.supabase.key'),
|
||||
map('DATASOURCE_SUPABASE_BUCKET', 'string', 'datasource.supabase.bucket'),
|
||||
|
||||
map('UPLOADER_DEFAULT_FORMAT', 'string', 'uploader.default_format'),
|
||||
map('UPLOADER_ROUTE', 'string', 'uploader.route'),
|
||||
map('UPLOADER_LENGTH', 'number', 'uploader.length'),
|
||||
@@ -94,7 +95,6 @@ export default function readConfig() {
|
||||
map('UPLOADER_FORMAT_DATE', 'string', 'uploader.format_date'),
|
||||
map('UPLOADER_DEFAULT_EXPIRATION', 'string', 'uploader.default_expiration'),
|
||||
map('UPLOADER_ASSUME_MIMETYPES', 'boolean', 'uploader.assume_mimetypes'),
|
||||
map('UPLOADER_RANDOM_WORDS_SEPARATOR', 'string', 'uploader.random_words_separator'),
|
||||
|
||||
map('URLS_ROUTE', 'string', 'urls.route'),
|
||||
map('URLS_LENGTH', 'number', 'urls.length'),
|
||||
@@ -136,19 +136,14 @@ 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'),
|
||||
|
||||
map('OAUTH_DISCORD_CLIENT_ID', 'string', 'oauth.discord_client_id'),
|
||||
map('OAUTH_DISCORD_CLIENT_SECRET', 'string', 'oauth.discord_client_secret'),
|
||||
map('OAUTH_DISCORD_REDIRECT_URI', 'string', 'oauth.discord_redirect_uri'),
|
||||
map('OAUTH_DISCORD_WHITELISTED_USERS', 'array', 'oauth.discord_whitelisted_users'),
|
||||
|
||||
map('OAUTH_GOOGLE_CLIENT_ID', 'string', 'oauth.google_client_id'),
|
||||
map('OAUTH_GOOGLE_CLIENT_SECRET', 'string', 'oauth.google_client_secret'),
|
||||
map('OAUTH_GOOGLE_REDIRECT_URI', 'string', 'oauth.google_redirect_uri'),
|
||||
|
||||
map('FEATURES_INVITES', 'boolean', 'features.invites'),
|
||||
map('FEATURES_INVITES_LENGTH', 'number', 'features.invites_length'),
|
||||
@@ -161,11 +156,6 @@ 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('FEATURES_GIF_THUMBNAILS', 'boolean', 'features.gif_thumbnails'),
|
||||
|
||||
map('CHUNKS_MAX_SIZE', 'human-to-byte', 'chunks.max_size'),
|
||||
map('CHUNKS_CHUNKS_SIZE', 'human-to-byte', 'chunks.chunks_size'),
|
||||
map('CHUNKS_ENABLED', 'boolean', 'chunks.enabled'),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { inspect } from 'util';
|
||||
import Logger from 'lib/logger';
|
||||
import { humanToBytes } from 'utils/bytes';
|
||||
import { tmpdir } from 'os';
|
||||
import { join, resolve } from 'path';
|
||||
import { join } from 'path';
|
||||
|
||||
const discord_content = s
|
||||
.object({
|
||||
@@ -35,9 +35,8 @@ const validator = s.object({
|
||||
port: s.number.default(3000),
|
||||
database_url: s.string,
|
||||
logger: s.boolean.default(false),
|
||||
stats_interval: s.number.default(1800), // 30m
|
||||
invites_interval: s.number.default(1800), // 30m
|
||||
thumbnails_interval: s.number.default(600), // 10m
|
||||
stats_interval: s.number.default(1800),
|
||||
invites_interval: s.number.default(1800),
|
||||
compression: s
|
||||
.object({
|
||||
enabled: s.boolean.default(false),
|
||||
@@ -51,10 +50,10 @@ const validator = s.object({
|
||||
}),
|
||||
datasource: s
|
||||
.object({
|
||||
type: s.enum('local', 's3').default('local'),
|
||||
type: s.enum('local', 's3', 'supabase').default('local'),
|
||||
local: s
|
||||
.object({
|
||||
directory: s.string.default(resolve('./uploads')).transform((v) => resolve(v)),
|
||||
directory: s.string.default('./uploads'),
|
||||
})
|
||||
.default({
|
||||
directory: './uploads',
|
||||
@@ -69,6 +68,11 @@ const validator = s.object({
|
||||
region: s.string.default('us-east-1'),
|
||||
use_ssl: s.boolean.default(false),
|
||||
}).optional,
|
||||
supabase: s.object({
|
||||
url: s.string,
|
||||
key: s.string,
|
||||
bucket: s.string,
|
||||
}).optional,
|
||||
})
|
||||
.default({
|
||||
type: 'local',
|
||||
@@ -92,7 +96,6 @@ const validator = s.object({
|
||||
format_date: s.string.default('YYYY-MM-DD_HH:mm:ss'),
|
||||
default_expiration: s.string.optional.default(null),
|
||||
assume_mimetypes: s.boolean.default(false),
|
||||
random_words_separator: s.string.default('-'),
|
||||
})
|
||||
.default({
|
||||
default_format: 'RANDOM',
|
||||
@@ -136,11 +139,11 @@ const validator = s.object({
|
||||
s.object({
|
||||
label: s.string,
|
||||
link: s.string,
|
||||
}),
|
||||
})
|
||||
)
|
||||
.default([
|
||||
{ label: 'Zipline', link: 'https://github.com/diced/zipline' },
|
||||
{ label: 'Documentation', link: 'https://zipline.diced.sh/' },
|
||||
{ label: 'Documentation', link: 'https://zipline.diced.tech/' },
|
||||
]),
|
||||
})
|
||||
.default({
|
||||
@@ -151,7 +154,7 @@ const validator = s.object({
|
||||
|
||||
external_links: [
|
||||
{ label: 'Zipline', link: 'https://github.com/diced/zipline' },
|
||||
{ label: 'Documentation', link: 'https://zipline.diced.sh/' },
|
||||
{ label: 'Documentation', link: 'https://zipline.diced.tech/' },
|
||||
],
|
||||
}),
|
||||
discord: s
|
||||
@@ -165,19 +168,14 @@ 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),
|
||||
|
||||
discord_client_id: s.string.nullable.default(null),
|
||||
discord_client_secret: s.string.nullable.default(null),
|
||||
discord_redirect_uri: s.string.nullable.default(null),
|
||||
discord_whitelisted_users: s.string.array.default([]),
|
||||
|
||||
google_client_id: s.string.nullable.default(null),
|
||||
google_client_secret: s.string.nullable.default(null),
|
||||
google_redirect_uri: s.string.nullable.default(null),
|
||||
})
|
||||
.nullish.default(null),
|
||||
features: s
|
||||
@@ -189,9 +187,6 @@ 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),
|
||||
gif_thumbnails: s.boolean.default(false),
|
||||
})
|
||||
.default({
|
||||
invites: false,
|
||||
@@ -201,9 +196,6 @@ const validator = s.object({
|
||||
user_registration: false,
|
||||
headless: false,
|
||||
default_avatar: null,
|
||||
robots_txt: false,
|
||||
thumbnails: false,
|
||||
gif_thumbnails: false,
|
||||
}),
|
||||
chunks: s
|
||||
.object({
|
||||
@@ -250,29 +242,43 @@ export default function validate(config): Config {
|
||||
logger.debug(`Attemping to validate ${JSON.stringify(config)}`);
|
||||
const validated = validator.parse(config);
|
||||
logger.debug(`Recieved config: ${JSON.stringify(validated)}`);
|
||||
switch (validated.datasource.type) {
|
||||
case 's3': {
|
||||
const errors = [];
|
||||
if (!validated.datasource.s3.access_key_id)
|
||||
errors.push('datasource.s3.access_key_id is a required field');
|
||||
if (!validated.datasource.s3.secret_access_key)
|
||||
errors.push('datasource.s3.secret_access_key is a required field');
|
||||
if (!validated.datasource.s3.bucket) errors.push('datasource.s3.bucket is a required field');
|
||||
if (!validated.datasource.s3.endpoint) errors.push('datasource.s3.endpoint is a required field');
|
||||
if (errors.length) throw { errors };
|
||||
break;
|
||||
}
|
||||
case 'supabase': {
|
||||
const errors = [];
|
||||
|
||||
if (validated.datasource.type === 's3') {
|
||||
const errors = [];
|
||||
if (!validated.datasource.s3.access_key_id)
|
||||
errors.push('datasource.s3.access_key_id is a required field');
|
||||
if (!validated.datasource.s3.secret_access_key)
|
||||
errors.push('datasource.s3.secret_access_key is a required field');
|
||||
if (!validated.datasource.s3.bucket) errors.push('datasource.s3.bucket is a required field');
|
||||
if (!validated.datasource.s3.endpoint) errors.push('datasource.s3.endpoint is a required field');
|
||||
if (errors.length) throw { errors };
|
||||
if (!validated.datasource.supabase.key) errors.push('datasource.supabase.key is a required field');
|
||||
if (!validated.datasource.supabase.url) errors.push('datasource.supabase.url is a required field');
|
||||
if (!validated.datasource.supabase.bucket)
|
||||
errors.push('datasource.supabase.bucket is a required field');
|
||||
if (errors.length) throw { errors };
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const reserved = new RegExp(/^\/(view|code|folder|auth|r)(\/\S*)?$|^\/(api|dashboard)(\/\S*)*/);
|
||||
if (reserved.exec(validated.uploader.route))
|
||||
const reserved = ['/view', '/dashboard', '/code', '/folder', '/api', '/auth'];
|
||||
if (reserved.some((r) => validated.uploader.route.startsWith(r))) {
|
||||
throw {
|
||||
errors: [`The uploader route cannot be ${validated.uploader.route}, this is a reserved route.`],
|
||||
show: true,
|
||||
};
|
||||
if (reserved.exec(validated.urls.route))
|
||||
} else if (reserved.some((r) => validated.urls.route.startsWith(r))) {
|
||||
throw {
|
||||
errors: [`The urls route cannot be ${validated.urls.route}, this is a reserved route.`],
|
||||
show: true,
|
||||
};
|
||||
}
|
||||
|
||||
return validated as unknown as Config;
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import config from './config';
|
||||
import { Datasource, Local, S3 } from './datasources';
|
||||
import { Datasource, Local, S3, Supabase } from './datasources';
|
||||
import Logger from './logger';
|
||||
|
||||
const logger = Logger.get('datasource');
|
||||
@@ -14,6 +14,10 @@ if (!global.datasource) {
|
||||
global.datasource = new Local(config.datasource.local.directory);
|
||||
logger.info(`using Local(${config.datasource.local.directory}) datasource`);
|
||||
break;
|
||||
case 'supabase':
|
||||
global.datasource = new Supabase(config.datasource.supabase);
|
||||
logger.info(`using Supabase(${config.datasource.supabase.bucket}) datasource`);
|
||||
break;
|
||||
default:
|
||||
throw new Error('Invalid datasource type');
|
||||
}
|
||||
|
||||
@@ -3,11 +3,10 @@ import { Readable } from 'stream';
|
||||
export abstract class Datasource {
|
||||
public name: string;
|
||||
|
||||
public abstract save(file: string, data: Buffer, options?: { type: string }): Promise<void>;
|
||||
public abstract save(file: string, data: Buffer): Promise<void>;
|
||||
public abstract delete(file: string): Promise<void>;
|
||||
public abstract clear(): Promise<void>;
|
||||
public abstract size(file: string): Promise<number | null>;
|
||||
public abstract size(file: string): Promise<number>;
|
||||
public abstract get(file: string): Readable | Promise<Readable>;
|
||||
public abstract fullSize(): Promise<number>;
|
||||
public abstract range(file: string, start: number, end: number): Promise<Readable>;
|
||||
}
|
||||
|
||||
@@ -11,23 +11,22 @@ export class Local extends Datasource {
|
||||
}
|
||||
|
||||
public async save(file: string, data: Buffer): Promise<void> {
|
||||
await writeFile(join(this.path, file), Uint8Array.from(data));
|
||||
await writeFile(join(process.cwd(), this.path, file), data);
|
||||
}
|
||||
|
||||
public async delete(file: string): Promise<void> {
|
||||
await rm(join(this.path, file), { force: true });
|
||||
await rm(join(process.cwd(), this.path, file));
|
||||
}
|
||||
|
||||
public async clear(): Promise<void> {
|
||||
const files = await readdir(this.path);
|
||||
const files = await readdir(join(process.cwd(), this.path));
|
||||
|
||||
for (let i = 0; i !== files.length; ++i) {
|
||||
await rm(join(this.path, files[i]));
|
||||
await rm(join(process.cwd(), this.path, files[i]));
|
||||
}
|
||||
}
|
||||
|
||||
public get(file: string): ReadStream {
|
||||
const full = join(this.path, file);
|
||||
const full = join(process.cwd(), this.path, file);
|
||||
if (!existsSync(full)) return null;
|
||||
|
||||
try {
|
||||
@@ -37,10 +36,8 @@ export class Local extends Datasource {
|
||||
}
|
||||
}
|
||||
|
||||
public async size(file: string): Promise<number | null> {
|
||||
const full = join(this.path, file);
|
||||
if (!existsSync(full)) return null;
|
||||
const stats = await stat(full);
|
||||
public async size(file: string): Promise<number> {
|
||||
const stats = await stat(join(process.cwd(), this.path, file));
|
||||
|
||||
return stats.size;
|
||||
}
|
||||
@@ -56,11 +53,4 @@ export class Local extends Datasource {
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
public async range(file: string, start: number, end: number): Promise<ReadStream> {
|
||||
const path = join(this.path, file);
|
||||
const readStream = createReadStream(path, { start, end });
|
||||
|
||||
return readStream;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Datasource } from '.';
|
||||
import { PassThrough, Readable } from 'stream';
|
||||
import { Readable } from 'stream';
|
||||
import { ConfigS3Datasource } from 'lib/config/Config';
|
||||
import { BucketItemStat, Client } from 'minio';
|
||||
import { Client } from 'minio';
|
||||
|
||||
export class S3 extends Datasource {
|
||||
public name = 'S3';
|
||||
@@ -20,18 +20,12 @@ export class S3 extends Datasource {
|
||||
});
|
||||
}
|
||||
|
||||
public async save(file: string, data: Buffer, options?: { type: string }): Promise<void> {
|
||||
await this.s3.putObject(
|
||||
this.config.bucket,
|
||||
file,
|
||||
new PassThrough().end(data),
|
||||
data.byteLength,
|
||||
options ? { 'Content-Type': options.type } : undefined,
|
||||
);
|
||||
public async save(file: string, data: Buffer): Promise<void> {
|
||||
await this.s3.putObject(this.config.bucket, file, data);
|
||||
}
|
||||
|
||||
public async delete(file: string): Promise<void> {
|
||||
await this.s3.removeObject(this.config.bucket, file, { forceDelete: true });
|
||||
await this.s3.removeObject(this.config.bucket, file);
|
||||
}
|
||||
|
||||
public async clear(): Promise<void> {
|
||||
@@ -55,41 +49,25 @@ export class S3 extends Datasource {
|
||||
});
|
||||
}
|
||||
|
||||
public size(file: string): Promise<number | null> {
|
||||
return new Promise((res) => {
|
||||
this.s3.statObject(
|
||||
this.config.bucket,
|
||||
file,
|
||||
// @ts-expect-error this callback is not in the types but the code for it is there
|
||||
(err: unknown, stat: BucketItemStat) => {
|
||||
if (err) res(null);
|
||||
else res(stat.size);
|
||||
},
|
||||
);
|
||||
public size(file: string): Promise<number> {
|
||||
return new Promise((res, rej) => {
|
||||
this.s3.statObject(this.config.bucket, file, (err, stat) => {
|
||||
if (err) rej(err);
|
||||
else res(stat.size);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async fullSize(): Promise<number> {
|
||||
return new Promise((res) => {
|
||||
return new Promise((res, rej) => {
|
||||
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) res(0);
|
||||
if (err) rej(err);
|
||||
else res(size);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async range(file: string, start: number, end: number): Promise<Readable> {
|
||||
return new Promise((res) => {
|
||||
this.s3.getPartialObject(this.config.bucket, file, start, end, (err, stream) => {
|
||||
if (err) {
|
||||
console.log(err);
|
||||
res(null);
|
||||
} else res(stream);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
140
src/lib/datasources/Supabase.ts
Normal file
140
src/lib/datasources/Supabase.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { Datasource } from '.';
|
||||
import { ConfigSupabaseDatasource } from 'lib/config/Config';
|
||||
import { guess } from 'lib/mimes';
|
||||
import Logger from 'lib/logger';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
export class Supabase extends Datasource {
|
||||
public name = 'Supabase';
|
||||
public logger: Logger = Logger.get('datasource::supabase');
|
||||
|
||||
public constructor(public config: ConfigSupabaseDatasource) {
|
||||
super();
|
||||
}
|
||||
|
||||
public async save(file: string, data: Buffer): Promise<void> {
|
||||
const mimetype = await guess(file.split('.').pop());
|
||||
|
||||
const r = await fetch(`${this.config.url}/storage/v1/object/${this.config.bucket}/${file}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.config.key}`,
|
||||
'Content-Type': mimetype,
|
||||
},
|
||||
body: data,
|
||||
});
|
||||
|
||||
const j = await r.json();
|
||||
if (j.error) this.logger.error(`${j.error}: ${j.message}`);
|
||||
}
|
||||
|
||||
public async delete(file: string): Promise<void> {
|
||||
await fetch(`${this.config.url}/storage/v1/object/${this.config.bucket}/${file}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.config.key}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public async clear(): Promise<void> {
|
||||
try {
|
||||
const resp = await fetch(`${this.config.url}/storage/v1/object/list/${this.config.bucket}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.config.key}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prefix: '',
|
||||
}),
|
||||
});
|
||||
const objs = await resp.json();
|
||||
if (objs.error) throw new Error(`${objs.error}: ${objs.message}`);
|
||||
|
||||
const res = await fetch(`${this.config.url}/storage/v1/object/${this.config.bucket}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.config.key}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prefixes: objs.map((x: { name: string }) => x.name),
|
||||
}),
|
||||
});
|
||||
|
||||
const j = await res.json();
|
||||
if (j.error) throw new Error(`${j.error}: ${j.message}`);
|
||||
|
||||
return;
|
||||
} catch (e) {
|
||||
this.logger.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
public async get(file: string): Promise<Readable> {
|
||||
// get a readable stream from the request
|
||||
const r = await fetch(`${this.config.url}/storage/v1/object/${this.config.bucket}/${file}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.config.key}`,
|
||||
},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return Readable.fromWeb(r.body as any);
|
||||
}
|
||||
|
||||
public size(file: string): Promise<number> {
|
||||
return new Promise(async (res) => {
|
||||
fetch(`${this.config.url}/storage/v1/object/list/${this.config.bucket}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.config.key}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prefix: '',
|
||||
search: file,
|
||||
}),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((j) => {
|
||||
if (j.error) {
|
||||
this.logger.error(`${j.error}: ${j.message}`);
|
||||
res(0);
|
||||
}
|
||||
|
||||
if (j.length === 0) {
|
||||
res(0);
|
||||
} else {
|
||||
res(j[0].metadata.size);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async fullSize(): Promise<number> {
|
||||
return new Promise((res) => {
|
||||
fetch(`${this.config.url}/storage/v1/object/list/${this.config.bucket}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.config.key}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prefix: '',
|
||||
}),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((j) => {
|
||||
if (j.error) {
|
||||
this.logger.error(`${j.error}: ${j.message}`);
|
||||
res(0);
|
||||
}
|
||||
|
||||
res(j.reduce((a, b) => a + b.metadata.size, 0));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export { Datasource } from './Datasource';
|
||||
export { Local } from './Local';
|
||||
export { S3 } from './S3';
|
||||
export { Supabase } from './Supabase';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { File, Url, User } from '@prisma/client';
|
||||
import { File, Url, User } from '@prisma/client';
|
||||
import config from 'lib/config';
|
||||
import { ConfigDiscordContent } from 'config/Config';
|
||||
import Logger from 'lib/logger';
|
||||
@@ -8,7 +8,7 @@ const logger = Logger.get('discord');
|
||||
|
||||
export function parseContent(
|
||||
content: ConfigDiscordContent,
|
||||
args: ParseValue,
|
||||
args: ParseValue
|
||||
): ConfigDiscordContent & { url: string } {
|
||||
return {
|
||||
content: content.content ? parseString(content.content, args) : null,
|
||||
@@ -28,10 +28,10 @@ export function parseContent(
|
||||
}
|
||||
|
||||
export async function sendUpload(user: User, file: File, raw_link: string, link: string) {
|
||||
if (!config.discord.upload) return logger.debug('no discord upload config, no webhook sent');
|
||||
if (!config.discord.url && !config.discord.upload.url)
|
||||
return logger.debug('no discord url, no webhook sent');
|
||||
if (!config.discord.upload) return;
|
||||
if (!config.discord.url && !config.discord.upload.url) return;
|
||||
|
||||
logger.debug(`discord config:\n${JSON.stringify(config.discord)}`);
|
||||
const parsed = parseContent(config.discord.upload, {
|
||||
file,
|
||||
user,
|
||||
@@ -63,13 +63,13 @@ export async function sendUpload(user: User, file: File, raw_link: string, link:
|
||||
thumbnail:
|
||||
isImage && parsed.embed.thumbnail
|
||||
? {
|
||||
url: raw_link,
|
||||
url: parsed.url,
|
||||
}
|
||||
: null,
|
||||
image:
|
||||
isImage && parsed.embed.image
|
||||
? {
|
||||
url: raw_link,
|
||||
url: parsed.url,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
@@ -97,9 +97,8 @@ export async function sendUpload(user: User, file: File, raw_link: string, link:
|
||||
}
|
||||
|
||||
export async function sendShorten(user: User, url: Url, link: string) {
|
||||
if (!config.discord.shorten) return logger.debug('no discord shorten config, no webhook sent');
|
||||
if (!config.discord.url && !config.discord.shorten.url)
|
||||
return logger.debug('no discord url, no webhook sent');
|
||||
if (!config.discord.shorten) return;
|
||||
if (!config.discord.url && !config.discord.shorten.url) return;
|
||||
|
||||
const parsed = parseContent(config.discord.shorten, {
|
||||
url,
|
||||
|
||||
@@ -1,41 +1,26 @@
|
||||
import { readFile } from 'fs/promises';
|
||||
import config from 'lib/config';
|
||||
import Logger from 'lib/logger';
|
||||
|
||||
const logger = Logger.get('random_words');
|
||||
|
||||
export type GfyCatWords = {
|
||||
adjectives: string[];
|
||||
animals: string[];
|
||||
};
|
||||
|
||||
export async function importWords(): Promise<GfyCatWords | null> {
|
||||
try {
|
||||
const adjectives = (await readFile('public/adjectives.txt', 'utf-8')).split('\n').map((x) => x.trim());
|
||||
const animals = (await readFile('public/animals.txt', 'utf-8')).split('\n').map((x) => x.trim());
|
||||
export async function importWords(): Promise<GfyCatWords> {
|
||||
const adjectives = (await readFile('public/adjectives.txt', 'utf-8')).split('\n');
|
||||
const animals = (await readFile('public/animals.txt', 'utf-8')).split('\n');
|
||||
|
||||
return {
|
||||
adjectives,
|
||||
animals,
|
||||
};
|
||||
} catch {
|
||||
logger.error('public/adjectives.txt or public/animals.txt do not exist, to fix this please retrieve.');
|
||||
logger.error('to prevent this from happening again, remember to not delete your public/ directory.');
|
||||
logger.error('file names will use the RANDOM format instead until fixed');
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
adjectives,
|
||||
animals,
|
||||
};
|
||||
}
|
||||
|
||||
function randomWord(words: string[]) {
|
||||
return words[Math.floor(Math.random() * words.length)];
|
||||
}
|
||||
|
||||
export default async function gfycat(): Promise<string | null> {
|
||||
export default async function gfycat() {
|
||||
const words = await importWords();
|
||||
|
||||
if (!words) return null;
|
||||
|
||||
return `${randomWord(words.adjectives)}${config.uploader.random_words_separator}${randomWord(
|
||||
words.adjectives,
|
||||
)}${config.uploader.random_words_separator}${randomWord(words.animals)}`;
|
||||
return `${randomWord(words.adjectives)}${randomWord(words.adjectives)}${randomWord(words.animals)}`;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ 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'];
|
||||
@@ -15,11 +14,9 @@ export default async function formatFileName(nameFormat: NameFormat, originalNam
|
||||
case 'uuid':
|
||||
return uuid();
|
||||
case 'name':
|
||||
const { name } = parse(originalName);
|
||||
|
||||
return name;
|
||||
return originalName.split('.')[0];
|
||||
case 'gfycat':
|
||||
return gfycat() ?? random();
|
||||
return gfycat();
|
||||
default:
|
||||
return random();
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ export type ApiError = {
|
||||
export default async function useFetch(
|
||||
url: string,
|
||||
method: 'GET' | 'POST' | 'PATCH' | 'DELETE' = 'GET',
|
||||
body: ApiError | Record<string, unknown> = null,
|
||||
body: ApiError | Record<string, unknown> = null
|
||||
) {
|
||||
const headers = {};
|
||||
if (body) headers['content-type'] = 'application/json';
|
||||
|
||||
@@ -60,8 +60,8 @@ export default class Logger {
|
||||
this.formatMessage(
|
||||
LoggerLevel.ERROR,
|
||||
this.name,
|
||||
args.map((error) => (typeof error === 'string' ? error : (error as Error).stack)).join(' '),
|
||||
),
|
||||
args.map((error) => (typeof error === 'string' ? error : (error as Error).stack)).join(' ')
|
||||
)
|
||||
);
|
||||
|
||||
return this;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import config from 'lib/config';
|
||||
import { isNotNullOrUndefined } from 'lib/util';
|
||||
import { notNull } from 'lib/util';
|
||||
import { GetServerSideProps } from 'next';
|
||||
|
||||
export type OauthProvider = {
|
||||
@@ -16,10 +16,8 @@ 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;
|
||||
@@ -27,15 +25,9 @@ export type ServerSideProps = {
|
||||
};
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<ServerSideProps> = async (ctx) => {
|
||||
const ghEnabled =
|
||||
isNotNullOrUndefined(config.oauth?.github_client_id) &&
|
||||
isNotNullOrUndefined(config.oauth?.github_client_secret);
|
||||
const discEnabled =
|
||||
isNotNullOrUndefined(config.oauth?.discord_client_id) &&
|
||||
isNotNullOrUndefined(config.oauth?.discord_client_secret);
|
||||
const googleEnabled =
|
||||
isNotNullOrUndefined(config.oauth?.google_client_id) &&
|
||||
isNotNullOrUndefined(config.oauth?.google_client_secret);
|
||||
const ghEnabled = notNull(config.oauth?.github_client_id, config.oauth?.github_client_secret);
|
||||
const discEnabled = notNull(config.oauth?.discord_client_id, config.oauth?.discord_client_secret);
|
||||
const googleEnabled = notNull(config.oauth?.google_client_id, config.oauth?.google_client_secret);
|
||||
|
||||
const oauth_providers: OauthProvider[] = [];
|
||||
|
||||
@@ -68,11 +60,9 @@ 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,
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface OAuthResponse {
|
||||
export const withOAuth =
|
||||
(
|
||||
provider: 'discord' | 'github' | 'google',
|
||||
oauth: (query: OAuthQuery, logger: Logger) => Promise<OAuthResponse>,
|
||||
oauth: (query: OAuthQuery, logger: Logger) => Promise<OAuthResponse>
|
||||
) =>
|
||||
async (req: NextApiReq, res: NextApiRes) => {
|
||||
const logger = Logger.get(`oauth::${provider}`);
|
||||
@@ -67,7 +67,26 @@ export const withOAuth =
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error(`Failed to find existing oauth, this likely will result in a failure: ${e}`);
|
||||
logger.debug(`Failed to find existing oauth. Using fallback. ${e}`);
|
||||
if (e.code === 'P2022' || e.code === 'P2025') {
|
||||
const existing = await prisma.user.findFirst({
|
||||
where: {
|
||||
oauth: {
|
||||
some: {
|
||||
provider: provider.toUpperCase() as OauthProviders,
|
||||
username: oauth_resp.username,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
oauth: true,
|
||||
},
|
||||
});
|
||||
existingOauth = existing?.oauth?.find((o) => o.provider === provider.toUpperCase());
|
||||
if (existingOauth) existingOauth.fallback = true;
|
||||
} else {
|
||||
logger.error(`Failed to find existing oauth. ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
@@ -138,7 +157,7 @@ export const withOAuth =
|
||||
logger.info(`User ${user.username} (${user.id}) logged in via oauth(${provider})`);
|
||||
|
||||
return res.redirect('/dashboard');
|
||||
} else if (existingOauth) {
|
||||
} else if ((existingOauth && existingOauth.fallback) || existingOauth) {
|
||||
await prisma.oAuth.update({
|
||||
where: {
|
||||
id: existingOauth?.id,
|
||||
@@ -153,7 +172,7 @@ export const withOAuth =
|
||||
|
||||
res.setUserCookie(existingOauth.userId);
|
||||
Logger.get('user').info(
|
||||
`User ${existingOauth.username} (${existingOauth.id}) logged in via oauth(${provider})`,
|
||||
`User ${existingOauth.username} (${existingOauth.id}) logged in via oauth(${provider})`
|
||||
);
|
||||
|
||||
return res.redirect('/dashboard');
|
||||
|
||||
@@ -66,7 +66,7 @@ export type ZiplineApiConfig = {
|
||||
export const withZipline =
|
||||
(
|
||||
handler: (req: NextApiRequest, res: NextApiResponse, user?: UserExtended) => Promise<unknown>,
|
||||
api_config: ZiplineApiConfig = { methods: ['GET', 'OPTIONS'] },
|
||||
api_config: ZiplineApiConfig = { methods: ['GET', 'OPTIONS'] }
|
||||
) =>
|
||||
(req: NextApiReq, res: NextApiRes) => {
|
||||
if (!api_config.methods.includes('OPTIONS')) api_config.methods.push('OPTIONS');
|
||||
@@ -87,7 +87,7 @@ export const withZipline =
|
||||
code: 400,
|
||||
...extra,
|
||||
},
|
||||
400,
|
||||
400
|
||||
);
|
||||
};
|
||||
|
||||
@@ -99,7 +99,7 @@ export const withZipline =
|
||||
code: 401,
|
||||
...extra,
|
||||
},
|
||||
401,
|
||||
401
|
||||
);
|
||||
};
|
||||
|
||||
@@ -111,7 +111,7 @@ export const withZipline =
|
||||
code: 403,
|
||||
...extra,
|
||||
},
|
||||
403,
|
||||
403
|
||||
);
|
||||
};
|
||||
|
||||
@@ -122,7 +122,7 @@ export const withZipline =
|
||||
code: 404,
|
||||
...extra,
|
||||
},
|
||||
404,
|
||||
404
|
||||
);
|
||||
};
|
||||
|
||||
@@ -136,7 +136,7 @@ export const withZipline =
|
||||
code: 429,
|
||||
...extra,
|
||||
},
|
||||
429,
|
||||
429
|
||||
);
|
||||
};
|
||||
|
||||
@@ -161,7 +161,7 @@ export const withZipline =
|
||||
path: '/',
|
||||
expires: new Date(1),
|
||||
maxAge: undefined,
|
||||
}),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
@@ -230,7 +230,7 @@ export const withZipline =
|
||||
error: 'method not allowed',
|
||||
code: 405,
|
||||
},
|
||||
405,
|
||||
405
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,9 +16,9 @@ export const github_auth = {
|
||||
};
|
||||
|
||||
export const discord_auth = {
|
||||
oauth_url: (clientId: string, origin: string, state?: string, redirect_uri?: string) =>
|
||||
oauth_url: (clientId: string, origin: string, state?: string) =>
|
||||
`https://discord.com/api/oauth2/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(
|
||||
redirect_uri || `${origin}/api/auth/oauth/discord`,
|
||||
`${origin}/api/auth/oauth/discord`
|
||||
)}&response_type=code&scope=identify${state ? `&state=${state}` : ''}`,
|
||||
oauth_user: async (access_token: string) => {
|
||||
const res = await fetch('https://discord.com/api/users/@me', {
|
||||
@@ -33,15 +33,15 @@ export const discord_auth = {
|
||||
};
|
||||
|
||||
export const google_auth = {
|
||||
oauth_url: (clientId: string, origin: string, state?: string, redirect_uri?: string) =>
|
||||
oauth_url: (clientId: string, origin: string, state?: string) =>
|
||||
`https://accounts.google.com/o/oauth2/auth?client_id=${clientId}&redirect_uri=${encodeURIComponent(
|
||||
redirect_uri || `${origin}/api/auth/oauth/google`,
|
||||
`${origin}/api/auth/oauth/google`
|
||||
)}&response_type=code&access_type=offline&scope=https://www.googleapis.com/auth/userinfo.profile${
|
||||
state ? `&state=${state}` : ''
|
||||
}`,
|
||||
oauth_user: async (access_token: string) => {
|
||||
const res = await fetch(
|
||||
`https://people.googleapis.com/v1/people/me?access_token=${access_token}&personFields=names,photos`,
|
||||
`https://people.googleapis.com/v1/people/me?access_token=${access_token}&personFields=names,photos`
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import 'lib/config';
|
||||
|
||||
if (!global.prisma) {
|
||||
if (!process.env.ZIPLINE_DOCKER_BUILD) {
|
||||
process.env.DATABASE_URL = config.core.database_url;
|
||||
global.prisma = new PrismaClient();
|
||||
}
|
||||
if (!process.env.ZIPLINE_DOCKER_BUILD) global.prisma = new PrismaClient();
|
||||
}
|
||||
|
||||
export default global.prisma as PrismaClient;
|
||||
|
||||
@@ -29,27 +29,17 @@ export const useFiles = (query: { [key: string]: string } = {}) => {
|
||||
...x,
|
||||
createdAt: new Date(x.createdAt),
|
||||
expiresAt: x.expiresAt ? new Date(x.expiresAt) : null,
|
||||
})),
|
||||
}))
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
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({
|
||||
export const usePaginatedFiles = (page?: number, filter = 'media', favorite = null) => {
|
||||
const queryBuilder = new URLSearchParams({
|
||||
page: Number(page || '1').toString(),
|
||||
filter: options?.filter ?? 'none',
|
||||
// ...(options?.favorite !== null && { favorite: options?.favorite?.toString() }),
|
||||
favorite: options.favorite ? 'true' : '',
|
||||
sortBy: options.sortBy ?? '',
|
||||
order: options.order ?? '',
|
||||
}).toString();
|
||||
filter,
|
||||
...(favorite !== null && { favorite: favorite.toString() }),
|
||||
});
|
||||
const queryString = queryBuilder.toString();
|
||||
|
||||
return useQuery<UserFilesResponse[]>(['files', queryString], async () => {
|
||||
return fetch('/api/user/paged?' + queryString)
|
||||
@@ -59,7 +49,7 @@ export const usePaginatedFiles = (page?: number, options?: Partial<PaginatedFile
|
||||
...x,
|
||||
createdAt: new Date(x.createdAt),
|
||||
expiresAt: x.expiresAt ? new Date(x.expiresAt) : null,
|
||||
})),
|
||||
}))
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -73,7 +63,7 @@ export const useRecent = (filter?: string) => {
|
||||
...x,
|
||||
createdAt: new Date(x.createdAt),
|
||||
expiresAt: x.expiresAt ? new Date(x.expiresAt) : null,
|
||||
})),
|
||||
}))
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -94,7 +84,7 @@ export function useFileDelete() {
|
||||
onSuccess: () => {
|
||||
queryClient.refetchQueries(['files']);
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,7 +104,7 @@ export function useFileFavorite() {
|
||||
onSuccess: () => {
|
||||
queryClient.refetchQueries(['files']);
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,17 +17,27 @@ export const useFolders = (query: { [key: string]: string } = {}) => {
|
||||
const queryString = queryBuilder.toString();
|
||||
|
||||
return useQuery<UserFoldersResponse[]>(['folders', queryString], async () => {
|
||||
return fetch('/api/user/folders?' + queryString).then(
|
||||
(res) => res.json() as Promise<UserFoldersResponse[]>,
|
||||
);
|
||||
return fetch('/api/user/folders?' + queryString)
|
||||
.then((res) => res.json() as Promise<UserFoldersResponse[]>)
|
||||
.then((data) =>
|
||||
data.map((x) => ({
|
||||
...x,
|
||||
createdAt: new Date(x.createdAt).toLocaleString(),
|
||||
updatedAt: new Date(x.updatedAt).toLocaleString(),
|
||||
}))
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const useFolder = (id: string, withFiles = false) => {
|
||||
return useQuery<UserFoldersResponse>(['folder', id], async () => {
|
||||
return fetch('/api/user/folders/' + id + (withFiles ? '?files=true' : '')).then(
|
||||
(res) => res.json() as Promise<UserFoldersResponse>,
|
||||
);
|
||||
return fetch('/api/user/folders/' + id + (withFiles ? '?files=true' : ''))
|
||||
.then((res) => res.json() as Promise<UserFoldersResponse>)
|
||||
.then((data) => ({
|
||||
...data,
|
||||
createdAt: new Date(data.createdAt).toLocaleString(),
|
||||
updatedAt: new Date(data.updatedAt).toLocaleString(),
|
||||
}));
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -27,6 +27,6 @@ export const useStats = (amount = 2) => {
|
||||
},
|
||||
{
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
168
src/lib/queries/tags.ts
Normal file
168
src/lib/queries/tags.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import queryClient from 'lib/queries/client';
|
||||
|
||||
export type UserTagsResponse = {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
files: {
|
||||
id: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type TagsRequest = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
export const useTags = () => {
|
||||
return useQuery<UserTagsResponse[]>(['tags'], async () => {
|
||||
return fetch('/api/user/tags')
|
||||
.then((res) => res.json() as Promise<UserTagsResponse[]>)
|
||||
.then((data) => data);
|
||||
});
|
||||
};
|
||||
|
||||
export const useFileTags = (id: string) => {
|
||||
return useQuery<UserTagsResponse[]>(['tags', id], async () => {
|
||||
return fetch(`/api/user/file/${id}/tags`)
|
||||
.then((res) => res.json() as Promise<UserTagsResponse[]>)
|
||||
.then((data) => data);
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateFileTags = (id: string) => {
|
||||
return useMutation(
|
||||
(tags: TagsRequest[]) =>
|
||||
fetch(`/api/user/file/${id}/tags`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ tags }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}).then((res) => res.json()),
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.refetchQueries(['tags', id]);
|
||||
queryClient.refetchQueries(['files']);
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const useDeleteFileTags = (id: string) => {
|
||||
return useMutation(
|
||||
(tags: string[]) =>
|
||||
fetch(`/api/user/file/${id}/tags`, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ tags }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}).then((res) => res.json()),
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.refetchQueries(['tags', id]);
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const useDeleteTags = () => {
|
||||
return useMutation(
|
||||
(tags: string[]) =>
|
||||
fetch('/api/user/tags', {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ tags }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}).then((res) => res.json()),
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.refetchQueries(['tags']);
|
||||
queryClient.refetchQueries(['files']);
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// export const usePaginatedFiles = (page?: number, filter = 'media', favorite = null) => {
|
||||
// const queryBuilder = new URLSearchParams({
|
||||
// page: Number(page || '1').toString(),
|
||||
// filter,
|
||||
// ...(favorite !== null && { favorite: favorite.toString() }),
|
||||
// });
|
||||
// const queryString = queryBuilder.toString();
|
||||
//
|
||||
// return useQuery<UserFilesResponse[]>(['files', queryString], async () => {
|
||||
// return fetch('/api/user/paged?' + queryString)
|
||||
// .then((res) => res.json() as Promise<UserFilesResponse[]>)
|
||||
// .then((data) =>
|
||||
// data.map((x) => ({
|
||||
// ...x,
|
||||
// createdAt: new Date(x.createdAt),
|
||||
// expiresAt: x.expiresAt ? new Date(x.expiresAt) : null,
|
||||
// }))
|
||||
// );
|
||||
// });
|
||||
// };
|
||||
//
|
||||
// export const useRecent = (filter?: string) => {
|
||||
// return useQuery<UserFilesResponse[]>(['recent', filter], async () => {
|
||||
// return fetch(`/api/user/recent?filter=${encodeURIComponent(filter)}`)
|
||||
// .then((res) => res.json())
|
||||
// .then((data) =>
|
||||
// data.map((x) => ({
|
||||
// ...x,
|
||||
// createdAt: new Date(x.createdAt),
|
||||
// expiresAt: x.expiresAt ? new Date(x.expiresAt) : null,
|
||||
// }))
|
||||
// );
|
||||
// });
|
||||
// };
|
||||
//
|
||||
// export function useFileDelete() {
|
||||
// // '/api/user/files', 'DELETE', { id: image.id }
|
||||
// return useMutation(
|
||||
// async (id: string) => {
|
||||
// return fetch('/api/user/files', {
|
||||
// method: 'DELETE',
|
||||
// body: JSON.stringify({ id }),
|
||||
// headers: {
|
||||
// 'content-type': 'application/json',
|
||||
// },
|
||||
// }).then((res) => res.json());
|
||||
// },
|
||||
// {
|
||||
// onSuccess: () => {
|
||||
// queryClient.refetchQueries(['files']);
|
||||
// },
|
||||
// }
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// export function useFileFavorite() {
|
||||
// // /api/user/files', 'PATCH', { id: image.id, favorite: !image.favorite }
|
||||
// return useMutation(
|
||||
// async (data: { id: string; favorite: boolean }) => {
|
||||
// return fetch('/api/user/files', {
|
||||
// method: 'PATCH',
|
||||
// body: JSON.stringify(data),
|
||||
// headers: {
|
||||
// 'content-type': 'application/json',
|
||||
// },
|
||||
// }).then((res) => res.json());
|
||||
// },
|
||||
// {
|
||||
// onSuccess: () => {
|
||||
// queryClient.refetchQueries(['files']);
|
||||
// },
|
||||
// }
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// export function invalidateFiles() {
|
||||
// return queryClient.invalidateQueries(['files', 'recent', 'stats']);
|
||||
// }
|
||||
@@ -36,6 +36,6 @@ export function useURLDelete() {
|
||||
?.filter((u) => u.id !== variables);
|
||||
queryClient.setQueryData(['urls'], dataWithoutDeleted);
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,12 +15,10 @@ export const useVersion = () => {
|
||||
return useQuery<VersionResponse>(
|
||||
['version'],
|
||||
async () => {
|
||||
return fetch('/api/version').then((res) => (res.ok ? res.json() : Promise.reject('')));
|
||||
return fetch('/api/version').then((res) => res.json());
|
||||
},
|
||||
{
|
||||
refetchInterval: false,
|
||||
refetchOnMount: false,
|
||||
retry: false,
|
||||
},
|
||||
staleTime: Infinity,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@ export const createSpotlightActions = (router: NextRouter): SpotlightAction[] =>
|
||||
title: string,
|
||||
description: string,
|
||||
link: string,
|
||||
icon: ReactNode,
|
||||
icon: ReactNode
|
||||
): SpotlightAction => {
|
||||
return actionDo(group, title, description, icon, () => linkTo(link));
|
||||
};
|
||||
@@ -46,7 +46,7 @@ export const createSpotlightActions = (router: NextRouter): SpotlightAction[] =>
|
||||
title: string,
|
||||
description: string,
|
||||
icon: ReactNode,
|
||||
action: () => void,
|
||||
action: () => void
|
||||
): SpotlightAction => {
|
||||
return {
|
||||
group,
|
||||
@@ -70,7 +70,7 @@ export const createSpotlightActions = (router: NextRouter): SpotlightAction[] =>
|
||||
'Manage Account',
|
||||
'Manage your account settings',
|
||||
'/dashboard/manage',
|
||||
<IconUser />,
|
||||
<IconUser />
|
||||
),
|
||||
|
||||
// Actions
|
||||
@@ -80,14 +80,14 @@ export const createSpotlightActions = (router: NextRouter): SpotlightAction[] =>
|
||||
'Upload Files',
|
||||
'Upload files of any kind',
|
||||
'/dashboard/upload/file',
|
||||
<IconFileUpload />,
|
||||
<IconFileUpload />
|
||||
),
|
||||
actionLink(
|
||||
'Actions',
|
||||
'Upload Text',
|
||||
'Upload code, or any other kind of text file',
|
||||
'/dashboard/upload/text',
|
||||
<IconFileText />,
|
||||
<IconFileText />
|
||||
),
|
||||
actionDo('Actions', 'Copy Token', 'Copy your API token to your clipboard', <IconClipboardCopy />, () => {
|
||||
clipboard.copy(user.token);
|
||||
@@ -99,7 +99,7 @@ export const createSpotlightActions = (router: NextRouter): SpotlightAction[] =>
|
||||
});
|
||||
}),
|
||||
|
||||
actionLink('Help', 'Documentation', 'View the documentation', 'https://zipline.diced.sh', <IconHelp />),
|
||||
actionLink('Help', 'Documentation', 'View the documentation', 'https://zipline.diced.tech', <IconHelp />),
|
||||
|
||||
// the list of actions here is very incomplete, and will be expanded in the future
|
||||
];
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
// https://github.com/SeaswimmerTheFsh
|
||||
// https://catppuccin.com/palette
|
||||
|
||||
import createTheme from '.';
|
||||
|
||||
export default createTheme({
|
||||
colorScheme: 'dark',
|
||||
primaryColor: 'blue',
|
||||
other: {
|
||||
AppShell_backgroundColor: '#232634',
|
||||
hover: '#414559',
|
||||
},
|
||||
colors: {
|
||||
dark: [
|
||||
'#c6d0f5',
|
||||
'#949cbb',
|
||||
'#838ba7',
|
||||
'#737994',
|
||||
'#626880',
|
||||
'#51576d',
|
||||
'#414559',
|
||||
'#303446',
|
||||
'#292c3c',
|
||||
'#232634',
|
||||
],
|
||||
blue: [
|
||||
'#FFFFFF',
|
||||
'#b8caf4',
|
||||
'#a2baf1',
|
||||
'#7599ea',
|
||||
'#5f89e7',
|
||||
'#8c99ee',
|
||||
'#8ca1ee',
|
||||
'#8cb2ee',
|
||||
'#8cbaee',
|
||||
'#8caaee',
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
// https://github.com/SeaswimmerTheFsh
|
||||
// https://catppuccin.com/palette
|
||||
|
||||
import createTheme from '.';
|
||||
|
||||
export default createTheme({
|
||||
colorScheme: 'dark',
|
||||
primaryColor: 'blue',
|
||||
other: {
|
||||
AppShell_backgroundColor: '#dce0e8',
|
||||
hover: '#ccd0da',
|
||||
},
|
||||
colors: {
|
||||
dark: [
|
||||
'#4c4f69',
|
||||
'#8c8fa1',
|
||||
'#8c8fa1',
|
||||
'#9ca0b0',
|
||||
'#acb0be',
|
||||
'#bcc0cc',
|
||||
'#ccd0da',
|
||||
'#eff1f5',
|
||||
'#e6e9ef',
|
||||
'#dce0e8',
|
||||
],
|
||||
blue: [
|
||||
'#FFFFFF',
|
||||
'#3676f6',
|
||||
'#0a57ee',
|
||||
'#094ed6',
|
||||
'#1d42f5',
|
||||
'#1d54f5',
|
||||
'#1d65f5',
|
||||
'#1d77f5',
|
||||
'#1d89f5',
|
||||
'#1e66f5',
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
// https://github.com/SeaswimmerTheFsh
|
||||
// https://catppuccin.com/palette
|
||||
|
||||
import createTheme from '.';
|
||||
|
||||
export default createTheme({
|
||||
colorScheme: 'dark',
|
||||
primaryColor: 'blue',
|
||||
other: {
|
||||
AppShell_backgroundColor: '#181926',
|
||||
hover: '#363a4f',
|
||||
},
|
||||
colors: {
|
||||
dark: [
|
||||
'#cad3f5',
|
||||
'#8087a2',
|
||||
'#8087a2',
|
||||
'#6e738d',
|
||||
'#5b6078',
|
||||
'#494d64',
|
||||
'#363a4f',
|
||||
'#24273a',
|
||||
'#1e2030',
|
||||
'#181926',
|
||||
],
|
||||
blue: [
|
||||
'#FFFFFF',
|
||||
'#a1bdf6',
|
||||
'#729cf1',
|
||||
'#5b8cef',
|
||||
'#899bf4',
|
||||
'#89a4f4',
|
||||
'#89acf4',
|
||||
'#89b5f4',
|
||||
'#89bef4',
|
||||
'#8aadf4',
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
// https://github.com/SeaswimmerTheFsh
|
||||
// https://catppuccin.com/palette
|
||||
|
||||
import createTheme from '.';
|
||||
|
||||
export default createTheme({
|
||||
colorScheme: 'dark',
|
||||
primaryColor: 'blue',
|
||||
other: {
|
||||
AppShell_backgroundColor: '#11111b',
|
||||
hover: '#313244',
|
||||
},
|
||||
colors: {
|
||||
dark: [
|
||||
'#cdd6f4',
|
||||
'#9399b2',
|
||||
'#7f849c',
|
||||
'#6c7086',
|
||||
'#585b70',
|
||||
'#45475a',
|
||||
'#313244',
|
||||
'#1e1e2e',
|
||||
'#181825',
|
||||
'#11111b',
|
||||
],
|
||||
blue: [
|
||||
'#FFFFFF',
|
||||
'#b9d3fc',
|
||||
'#a1c3fb',
|
||||
'#70a4f8',
|
||||
'#5894f7',
|
||||
'#89a1fa',
|
||||
'#89aafa',
|
||||
'#89b4fa',
|
||||
'#89bdfa',
|
||||
'#89c6fa',
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { InvisibleFile, InvisibleUrl } from '@prisma/client';
|
||||
import { InvisibleFile, InvisibleUrl } from '@prisma/client';
|
||||
import { hash, verify } from 'argon2';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { readdir, stat } from 'fs/promises';
|
||||
@@ -120,6 +120,6 @@ export async function getBase64URLFromURL(url: string) {
|
||||
return `data:${res.headers.get('content-type')};base64,${base64}`;
|
||||
}
|
||||
|
||||
export function isNotNullOrUndefined(value: unknown) {
|
||||
return value !== null && value !== undefined;
|
||||
export function notNull(a: unknown, b: unknown) {
|
||||
return a !== null && b !== null;
|
||||
}
|
||||
|
||||
@@ -24,16 +24,16 @@ export function humanToBytes(value: string): number {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function bytesToHuman(value: number | bigint): string {
|
||||
if (typeof value !== 'bigint' && isNaN(value)) return '0.0 B';
|
||||
export function bytesToHuman(value: number): string {
|
||||
if (isNaN(value)) return '0.0 B';
|
||||
if (value === Infinity) return '0.0 B';
|
||||
const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB']; // if people upload stuff bigger than a petabyte then idk
|
||||
const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB'];
|
||||
let num = 0;
|
||||
|
||||
while (value > 1024) {
|
||||
value = Number(value) / 1024;
|
||||
value /= 1024;
|
||||
++num;
|
||||
}
|
||||
|
||||
return `${Number(value).toFixed(1)} ${units[num] || ''}`;
|
||||
return `${value.toFixed(1)} ${units[num]}`;
|
||||
}
|
||||
|
||||
@@ -51,22 +51,22 @@ export function humanTime(string: StringValue | string): Date {
|
||||
}
|
||||
}
|
||||
|
||||
export function parseExpiry(header: string): Date {
|
||||
if (!header) throw new Error('no expiry provided');
|
||||
export function parseExpiry(header: string): Date | null {
|
||||
if (!header) return null;
|
||||
header = header.toLowerCase();
|
||||
|
||||
if (header.startsWith('date=')) {
|
||||
const date = new Date(header.substring(5));
|
||||
|
||||
if (!date.getTime()) throw new Error('invalid date');
|
||||
if (date.getTime() < Date.now()) throw new Error('expiry must be in the future');
|
||||
if (!date.getTime()) return null;
|
||||
if (date.getTime() < Date.now()) return null;
|
||||
return date;
|
||||
}
|
||||
|
||||
const human = humanTime(header);
|
||||
|
||||
if (!human) throw new Error('failed to parse human time');
|
||||
if (human.getTime() < Date.now()) throw new Error('expiry must be in the future');
|
||||
if (!human) return null;
|
||||
if (human.getTime() < Date.now()) return null;
|
||||
|
||||
return human;
|
||||
}
|
||||
@@ -125,7 +125,7 @@ export function expireReadToDate(expires: string): Date {
|
||||
'6m': Date.now() + 6 * 30 * 24 * 60 * 60 * 1000,
|
||||
'8m': Date.now() + 8 * 30 * 24 * 60 * 60 * 1000,
|
||||
'1y': Date.now() + 365 * 24 * 60 * 60 * 1000,
|
||||
}[expires],
|
||||
}[expires]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
16
src/lib/utils/db.ts
Normal file
16
src/lib/utils/db.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export function exclude<T, Key extends keyof T>(obj: T, keys: Key[]): Omit<T, Key> {
|
||||
for (const key of keys) {
|
||||
delete obj[key];
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
export function pick<T, Key extends keyof T>(obj: T, keys: Key[]): Pick<T, Key> {
|
||||
const newObj: unknown = {};
|
||||
|
||||
for (const key of keys) {
|
||||
newObj[key] = obj[key];
|
||||
}
|
||||
|
||||
return newObj as Pick<T, Key>;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { File } from '@prisma/client';
|
||||
import { ExifTool, Tags } from 'exiftool-vendored';
|
||||
import { File } from '@prisma/client';
|
||||
import { createWriteStream } from 'fs';
|
||||
import { readFile, rm } from 'fs/promises';
|
||||
import { ExifTool, Tags } from 'exiftool-vendored';
|
||||
import datasource from 'lib/datasource';
|
||||
import Logger from 'lib/logger';
|
||||
import { join } from 'path';
|
||||
import { readFile, unlink } from 'fs/promises';
|
||||
|
||||
const logger = Logger.get('exif');
|
||||
|
||||
@@ -43,54 +43,47 @@ export async function removeGPSData(image: File): Promise<void> {
|
||||
await new Promise((resolve) => writeStream.on('finish', resolve));
|
||||
|
||||
logger.debug(`removing GPS data from ${file}`);
|
||||
try {
|
||||
await exiftool.write(file, {
|
||||
GPSVersionID: null,
|
||||
GPSAltitude: null,
|
||||
GPSAltitudeRef: null,
|
||||
GPSAreaInformation: null,
|
||||
GPSDateStamp: null,
|
||||
GPSDateTime: null,
|
||||
GPSDestBearing: null,
|
||||
GPSDestBearingRef: null,
|
||||
GPSDestDistance: null,
|
||||
GPSDestLatitude: null,
|
||||
GPSDestLatitudeRef: null,
|
||||
GPSDestLongitude: null,
|
||||
GPSDestLongitudeRef: null,
|
||||
GPSDifferential: null,
|
||||
GPSDOP: null,
|
||||
GPSHPositioningError: null,
|
||||
GPSImgDirection: null,
|
||||
GPSImgDirectionRef: null,
|
||||
GPSLatitude: null,
|
||||
GPSLatitudeRef: null,
|
||||
GPSLongitude: null,
|
||||
GPSLongitudeRef: null,
|
||||
GPSMapDatum: null,
|
||||
GPSPosition: null,
|
||||
GPSProcessingMethod: null,
|
||||
GPSSatellites: null,
|
||||
GPSSpeed: null,
|
||||
GPSSpeedRef: null,
|
||||
GPSStatus: null,
|
||||
GPSTimeStamp: null,
|
||||
GPSTrack: null,
|
||||
GPSTrackRef: null,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.debug(`removing temp file: ${file}`);
|
||||
await rm(file);
|
||||
|
||||
return;
|
||||
}
|
||||
await exiftool.write(file, {
|
||||
GPSVersionID: null,
|
||||
GPSAltitude: null,
|
||||
GPSAltitudeRef: null,
|
||||
GPSAreaInformation: null,
|
||||
GPSDateStamp: null,
|
||||
GPSDateTime: null,
|
||||
GPSDestBearing: null,
|
||||
GPSDestBearingRef: null,
|
||||
GPSDestDistance: null,
|
||||
GPSDestLatitude: null,
|
||||
GPSDestLatitudeRef: null,
|
||||
GPSDestLongitude: null,
|
||||
GPSDestLongitudeRef: null,
|
||||
GPSDifferential: null,
|
||||
GPSDOP: null,
|
||||
GPSHPositioningError: null,
|
||||
GPSImgDirection: null,
|
||||
GPSImgDirectionRef: null,
|
||||
GPSLatitude: null,
|
||||
GPSLatitudeRef: null,
|
||||
GPSLongitude: null,
|
||||
GPSLongitudeRef: null,
|
||||
GPSMapDatum: null,
|
||||
GPSPosition: null,
|
||||
GPSProcessingMethod: null,
|
||||
GPSSatellites: null,
|
||||
GPSSpeed: null,
|
||||
GPSSpeedRef: null,
|
||||
GPSStatus: null,
|
||||
GPSTimeStamp: null,
|
||||
GPSTrack: null,
|
||||
GPSTrackRef: null,
|
||||
});
|
||||
|
||||
logger.debug(`reading file to upload to datasource: ${file} -> ${image.name}`);
|
||||
const buffer = await readFile(file);
|
||||
await datasource.save(image.name, buffer, { type: image.mimetype });
|
||||
await datasource.save(image.name, buffer);
|
||||
|
||||
logger.debug(`removing temp file: ${file}`);
|
||||
await rm(file);
|
||||
await unlink(file);
|
||||
|
||||
await exiftool.end(true);
|
||||
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
import type { File, Url } from '@prisma/client';
|
||||
import { bytesToHuman } from './bytes';
|
||||
import Logger from 'lib/logger';
|
||||
import type { UserExtended } from 'middleware/withZipline';
|
||||
import type { File, User, Url } from '@prisma/client';
|
||||
|
||||
export type ParseValue = {
|
||||
file?: Omit<Partial<File>, 'password'>;
|
||||
file?: File;
|
||||
url?: Url;
|
||||
user?: Partial<UserExtended>;
|
||||
user?: User;
|
||||
|
||||
link?: string;
|
||||
raw_link?: string;
|
||||
};
|
||||
|
||||
const logger = Logger.get('parser');
|
||||
|
||||
export function parseString(str: string, value: ParseValue) {
|
||||
if (!str) return null;
|
||||
str = str
|
||||
@@ -21,7 +16,7 @@ export function parseString(str: string, value: ParseValue) {
|
||||
.replace(/\{raw_link\}/gi, value.raw_link)
|
||||
.replace(/\\n/g, '\n');
|
||||
|
||||
const re = /\{(?<type>file|url|user)\.(?<prop>\w+)(::(?<mod>\w+))?(::(?<mod_tzlocale>\S+))?\}/gi;
|
||||
const re = /\{(?<type>file|url|user)\.(?<prop>\w+)(::(?<mod>\w+))?\}/gi;
|
||||
let matches: RegExpMatchArray;
|
||||
|
||||
while ((matches = re.exec(str))) {
|
||||
@@ -37,13 +32,12 @@ export function parseString(str: string, value: ParseValue) {
|
||||
re.lastIndex = matches.index;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (['originalName', 'name'].includes(matches.groups.prop)) {
|
||||
str = replaceCharsFromString(
|
||||
str,
|
||||
decodeURIComponent(escape(getV[matches.groups.prop])),
|
||||
matches.index,
|
||||
re.lastIndex,
|
||||
re.lastIndex
|
||||
);
|
||||
re.lastIndex = matches.index;
|
||||
continue;
|
||||
@@ -58,12 +52,7 @@ export function parseString(str: string, value: ParseValue) {
|
||||
}
|
||||
|
||||
if (matches.groups.mod) {
|
||||
str = replaceCharsFromString(
|
||||
str,
|
||||
modifier(matches.groups.mod, v, matches.groups.mod_tzlocale ?? undefined),
|
||||
matches.index,
|
||||
re.lastIndex,
|
||||
);
|
||||
str = replaceCharsFromString(str, modifier(matches.groups.mod, v), matches.index, re.lastIndex);
|
||||
re.lastIndex = matches.index;
|
||||
continue;
|
||||
}
|
||||
@@ -75,42 +64,17 @@ export function parseString(str: string, value: ParseValue) {
|
||||
return str;
|
||||
}
|
||||
|
||||
function modifier(mod: string, value: unknown, tzlocale?: string): string {
|
||||
function modifier(mod: string, value: unknown): string {
|
||||
mod = mod.toLowerCase();
|
||||
|
||||
if (value instanceof Date) {
|
||||
const args = [undefined, undefined];
|
||||
|
||||
if (tzlocale) {
|
||||
const [locale, tz] = tzlocale.split(/\s?,\s?/).map((v) => v.trim());
|
||||
|
||||
if (locale) {
|
||||
try {
|
||||
Intl.DateTimeFormat.supportedLocalesOf(locale);
|
||||
args[0] = locale;
|
||||
} catch (e) {
|
||||
args[0] = undefined;
|
||||
logger.error(`invalid locale provided ${locale}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (tz) {
|
||||
const intlTz = Intl.supportedValuesOf('timeZone').find((v) => v.toLowerCase() === tz.toLowerCase());
|
||||
if (intlTz) args[1] = { timeZone: intlTz };
|
||||
else {
|
||||
args[1] = undefined;
|
||||
logger.error(`invalid timezone provided ${tz}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch (mod) {
|
||||
case 'locale':
|
||||
return value.toLocaleString(...args);
|
||||
return value.toLocaleString();
|
||||
case 'time':
|
||||
return value.toLocaleTimeString(...args);
|
||||
return value.toLocaleTimeString();
|
||||
case 'date':
|
||||
return value.toLocaleDateString(...args);
|
||||
return value.toLocaleDateString();
|
||||
case 'unix':
|
||||
return Math.floor(value.getTime() / 1000).toString();
|
||||
case 'iso':
|
||||
@@ -129,10 +93,6 @@ function modifier(mod: string, value: unknown, tzlocale?: string): string {
|
||||
return value.getMinutes().toString();
|
||||
case 'second':
|
||||
return value.getSeconds().toString();
|
||||
case 'ampm':
|
||||
return value.getHours() < 12 ? 'am' : 'pm';
|
||||
case 'AMPM':
|
||||
return value.getHours() < 12 ? 'AM' : 'PM';
|
||||
default:
|
||||
return '{unknown_date_modifier}';
|
||||
}
|
||||
@@ -155,7 +115,7 @@ function modifier(mod: string, value: unknown, tzlocale?: string): string {
|
||||
default:
|
||||
return '{unknown_str_modifier}';
|
||||
}
|
||||
} else if (typeof value === 'number' || typeof value === 'bigint') {
|
||||
} else if (typeof value === 'number') {
|
||||
switch (mod) {
|
||||
case 'comma':
|
||||
return value.toLocaleString();
|
||||
@@ -165,8 +125,6 @@ function modifier(mod: string, value: unknown, tzlocale?: string): string {
|
||||
return value.toString(8);
|
||||
case 'binary':
|
||||
return value.toString(2);
|
||||
case 'bytes':
|
||||
return bytesToHuman(value);
|
||||
default:
|
||||
return '{unknown_int_modifier}';
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
export function parseRange(header: string, length: number): [number, number] {
|
||||
const range = header.trim().substring(6);
|
||||
|
||||
let start, end;
|
||||
|
||||
if (range.startsWith('-')) {
|
||||
end = length - 1;
|
||||
start = length - 1 - Number(range.substring(1));
|
||||
} else {
|
||||
const [s, e] = range.split('-').map(Number);
|
||||
start = s;
|
||||
end = e || length - 1;
|
||||
}
|
||||
|
||||
if (end > length - 1) {
|
||||
end = length - 1;
|
||||
}
|
||||
|
||||
return [start, end];
|
||||
}
|
||||
@@ -2,10 +2,8 @@ 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>
|
||||
@@ -26,13 +24,9 @@ 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>
|
||||
{asPath === '/dashboard' ? (
|
||||
<Button onClick={() => window.location.reload()}>Attempt Refresh</Button>
|
||||
) : (
|
||||
<Button component={Link} href='/dashboard'>
|
||||
Head to the Dashboard
|
||||
</Button>
|
||||
)}
|
||||
<Button component={Link} href='/dashboard'>
|
||||
Head to the Dashboard
|
||||
</Button>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -6,25 +6,23 @@ const logger = Logger.get('admin');
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
try {
|
||||
const { orphaned } = req.body;
|
||||
const { datasource, orphaned } = req.body;
|
||||
if (orphaned) {
|
||||
const files = await prisma.file.findMany({
|
||||
where: {
|
||||
userId: null,
|
||||
},
|
||||
});
|
||||
const { count } = await prisma.file.deleteMany({
|
||||
where: {
|
||||
userId: null,
|
||||
},
|
||||
});
|
||||
for (const file of files) await datasource.delete(file.name);
|
||||
logger.info(`User ${user.username} (${user.id}) cleared the database of ${count} orphaned files`);
|
||||
return res.json({ message: 'cleared storage (orphaned only)' });
|
||||
}
|
||||
const { count } = await prisma.file.deleteMany({});
|
||||
await datasource.clear();
|
||||
logger.info(`User ${user.username} (${user.id}) cleared the database of ${count} files`);
|
||||
|
||||
if (req.body.datasource) {
|
||||
await datasource.clear();
|
||||
logger.info(`User ${user.username} (${user.id}) cleared storage`);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`User ${user.username} (${user.id}) failed to clear the database or storage`);
|
||||
logger.error(e);
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
import { readFile } from 'fs/promises';
|
||||
import Logger from 'lib/logger';
|
||||
import prisma from 'lib/prisma';
|
||||
import { randomChars } from 'lib/util';
|
||||
import { bytesToHuman } from 'lib/utils/bytes';
|
||||
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
|
||||
import os from 'os';
|
||||
|
||||
const logger = Logger.get('admin').child('export');
|
||||
|
||||
type Zipline3Export = {
|
||||
versions: {
|
||||
zipline: string;
|
||||
node: string;
|
||||
export: '3';
|
||||
};
|
||||
|
||||
request: {
|
||||
user: string;
|
||||
date: string;
|
||||
os: {
|
||||
platform: 'aix' | 'darwin' | 'freebsd' | 'linux' | 'openbsd' | 'sunos' | 'win32';
|
||||
arch:
|
||||
| 'arm'
|
||||
| 'arm64'
|
||||
| 'ia32'
|
||||
| 'loong64'
|
||||
| 'mips'
|
||||
| 'mipsel'
|
||||
| 'ppc'
|
||||
| 'ppc64'
|
||||
| 'riscv64'
|
||||
| 's390'
|
||||
| 's390x'
|
||||
| 'x64';
|
||||
cpus: number;
|
||||
hostname: string;
|
||||
release: string;
|
||||
};
|
||||
env: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
// Creates a unique identifier for each model
|
||||
// used to map the user's stuff to other data owned by the user
|
||||
user_map: Record<number, string>;
|
||||
thumbnail_map: Record<number, string>;
|
||||
folder_map: Record<number, string>;
|
||||
file_map: Record<number, string>;
|
||||
url_map: Record<number, string>;
|
||||
invite_map: Record<number, string>;
|
||||
|
||||
users: {
|
||||
[id: string]: {
|
||||
username: string;
|
||||
password: string;
|
||||
avatar: string;
|
||||
administrator: boolean;
|
||||
super_administrator: boolean;
|
||||
embed: {
|
||||
title?: string;
|
||||
site_name?: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
};
|
||||
totp_secret: string;
|
||||
oauth: {
|
||||
provider: 'DISCORD' | 'GITHUB' | 'GOOGLE';
|
||||
username: string;
|
||||
oauth_id: string;
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
|
||||
files: {
|
||||
[id: string]: {
|
||||
name: string;
|
||||
original_name: string;
|
||||
type: `${string}/${string}`;
|
||||
size: number | bigint;
|
||||
user: string | null;
|
||||
thumbnail?: string;
|
||||
max_views: number;
|
||||
views: number;
|
||||
expires_at?: string;
|
||||
created_at: string;
|
||||
favorite: boolean;
|
||||
password?: string;
|
||||
};
|
||||
};
|
||||
|
||||
thumbnails: {
|
||||
[id: string]: {
|
||||
name: string;
|
||||
created_at: string;
|
||||
};
|
||||
};
|
||||
|
||||
folders: {
|
||||
[id: string]: {
|
||||
name: string;
|
||||
public: boolean;
|
||||
created_at: string;
|
||||
user: string;
|
||||
files: string[];
|
||||
};
|
||||
};
|
||||
|
||||
urls: {
|
||||
[id: number]: {
|
||||
destination: string;
|
||||
vanity?: string;
|
||||
code: string;
|
||||
created_at: string;
|
||||
max_views: number;
|
||||
views: number;
|
||||
user: string;
|
||||
};
|
||||
};
|
||||
|
||||
invites: {
|
||||
[id: string]: {
|
||||
code: string;
|
||||
expites_at?: string;
|
||||
created_at: string;
|
||||
used: boolean;
|
||||
|
||||
created_by_user: string;
|
||||
};
|
||||
};
|
||||
|
||||
stats: {
|
||||
created_at: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
data: any;
|
||||
}[];
|
||||
};
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
if (!user.superAdmin) return res.forbidden('You must be a super administrator to export data');
|
||||
|
||||
const pkg = JSON.parse(await readFile('package.json', 'utf8'));
|
||||
|
||||
const exportData: Partial<Zipline3Export> = {
|
||||
versions: {
|
||||
zipline: pkg.version,
|
||||
node: process.version,
|
||||
export: '3',
|
||||
},
|
||||
request: {
|
||||
user: '',
|
||||
date: new Date().toISOString(),
|
||||
os: {
|
||||
platform: os.platform() as Zipline3Export['request']['os']['platform'],
|
||||
arch: os.arch() as Zipline3Export['request']['os']['arch'],
|
||||
cpus: os.cpus().length,
|
||||
hostname: os.hostname(),
|
||||
release: os.release(),
|
||||
},
|
||||
env: process.env,
|
||||
},
|
||||
user_map: {},
|
||||
thumbnail_map: {},
|
||||
folder_map: {},
|
||||
file_map: {},
|
||||
url_map: {},
|
||||
invite_map: {},
|
||||
|
||||
users: {},
|
||||
files: {},
|
||||
thumbnails: {},
|
||||
folders: {},
|
||||
urls: {},
|
||||
invites: {},
|
||||
stats: [],
|
||||
};
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
include: {
|
||||
oauth: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const user of users) {
|
||||
const uniqueId = randomChars(32);
|
||||
exportData.user_map[user.id] = uniqueId;
|
||||
|
||||
exportData.users[uniqueId] = {
|
||||
username: user.username,
|
||||
password: user.password,
|
||||
avatar: user.avatar,
|
||||
administrator: user.administrator,
|
||||
super_administrator: user.superAdmin,
|
||||
embed: user.embed as Zipline3Export['users'][string]['embed'],
|
||||
totp_secret: user.totpSecret,
|
||||
oauth: user.oauth.map((oauth) => ({
|
||||
provider: oauth.provider as Zipline3Export['users'][string]['oauth'][0]['provider'],
|
||||
username: oauth.username,
|
||||
oauth_id: oauth.oauthId,
|
||||
access_token: oauth.token,
|
||||
refresh_token: oauth.refresh,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const folders = await prisma.folder.findMany({ include: { files: true } });
|
||||
for (const folder of folders) {
|
||||
const uniqueId = randomChars(32);
|
||||
exportData.folder_map[folder.id] = uniqueId;
|
||||
|
||||
exportData.folders[uniqueId] = {
|
||||
name: folder.name,
|
||||
public: folder.public,
|
||||
created_at: folder.createdAt.toISOString(),
|
||||
user: exportData.user_map[folder.userId],
|
||||
files: [], // mapped later
|
||||
};
|
||||
}
|
||||
|
||||
const thumbnails = await prisma.thumbnail.findMany();
|
||||
for (const thumbnail of thumbnails) {
|
||||
const uniqueId = randomChars(32);
|
||||
exportData.thumbnail_map[thumbnail.id] = uniqueId;
|
||||
|
||||
exportData.thumbnails[uniqueId] = {
|
||||
name: thumbnail.name,
|
||||
created_at: thumbnail.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const files = await prisma.file.findMany({ include: { thumbnail: true } });
|
||||
|
||||
for (const file of files) {
|
||||
const uniqueId = randomChars(32);
|
||||
exportData.file_map[file.id] = uniqueId;
|
||||
|
||||
exportData.files[uniqueId] = {
|
||||
name: file.name,
|
||||
original_name: file.originalName,
|
||||
type: file.mimetype as Zipline3Export['files'][0]['type'],
|
||||
size: file.size,
|
||||
user: file.userId ? exportData.user_map[file.userId] : null,
|
||||
thumbnail: file.thumbnail ? exportData.thumbnail_map[file.thumbnail.id] : undefined,
|
||||
max_views: file.maxViews,
|
||||
views: file.views,
|
||||
expires_at: file.expiresAt?.toISOString(),
|
||||
created_at: file.createdAt.toISOString(),
|
||||
favorite: file.favorite,
|
||||
password: file.password,
|
||||
};
|
||||
}
|
||||
|
||||
const urls = await prisma.url.findMany();
|
||||
|
||||
for (const url of urls) {
|
||||
const uniqueId = randomChars(32);
|
||||
exportData.url_map[url.id] = uniqueId;
|
||||
|
||||
exportData.urls[uniqueId] = {
|
||||
destination: url.destination,
|
||||
vanity: url.vanity,
|
||||
created_at: url.createdAt.toISOString(),
|
||||
max_views: url.maxViews,
|
||||
views: url.views,
|
||||
user: exportData.user_map[url.userId],
|
||||
code: url.id,
|
||||
};
|
||||
}
|
||||
|
||||
const invites = await prisma.invite.findMany();
|
||||
|
||||
for (const invite of invites) {
|
||||
const uniqueId = randomChars(32);
|
||||
exportData.invite_map[invite.id] = uniqueId;
|
||||
|
||||
exportData.invites[uniqueId] = {
|
||||
code: invite.code,
|
||||
expites_at: invite.expiresAt?.toISOString() ?? undefined,
|
||||
created_at: invite.createdAt.toISOString(),
|
||||
used: invite.used,
|
||||
created_by_user: exportData.user_map[invite.createdById],
|
||||
};
|
||||
}
|
||||
|
||||
exportData.request.user = exportData.user_map[user.id];
|
||||
|
||||
for (const folder of folders) {
|
||||
exportData.folders[exportData.folder_map[folder.id]].files = folder.files.map(
|
||||
(file) => exportData.file_map[file.id],
|
||||
);
|
||||
}
|
||||
|
||||
const stringed = JSON.stringify(exportData);
|
||||
logger.info(`${user.id} created export of size ${bytesToHuman(stringed.length)}`);
|
||||
|
||||
return res
|
||||
.setHeader('Content-Disposition', `attachment; filename="zipline_export_${Date.now()}.json"`)
|
||||
.setHeader('Content-Type', 'application/json')
|
||||
.send(stringed);
|
||||
}
|
||||
|
||||
export default withZipline(handler, {
|
||||
methods: ['GET'],
|
||||
user: true,
|
||||
administrator: true,
|
||||
});
|
||||
132
src/pages/api/auth/create.ts
Normal file
132
src/pages/api/auth/create.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { readFile } from 'fs/promises';
|
||||
import config from 'lib/config';
|
||||
import Logger from 'lib/logger';
|
||||
import { NextApiReq, NextApiRes, withZipline } from 'lib/middleware/withZipline';
|
||||
import { guess } from 'lib/mimes';
|
||||
import prisma from 'lib/prisma';
|
||||
import { createToken, hashPassword } from 'lib/util';
|
||||
import { jsonUserReplacer } from 'lib/utils/client';
|
||||
import { extname } from 'path';
|
||||
|
||||
const logger = Logger.get('user');
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
// handle invites
|
||||
if (req.body.code) {
|
||||
if (!config.features.invites) return res.badRequest('invites are disabled');
|
||||
|
||||
const { code, username, password } = req.body as {
|
||||
code?: string;
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
const invite = await prisma.invite.findUnique({
|
||||
where: { code: code ?? '' },
|
||||
});
|
||||
if (!invite && code) return res.badRequest('invalid invite code');
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { username },
|
||||
});
|
||||
|
||||
if (user) return res.badRequest('username already exists');
|
||||
const hashed = await hashPassword(password);
|
||||
|
||||
let avatar;
|
||||
if (config.features.default_avatar) {
|
||||
logger.debug(`using default avatar ${config.features.default_avatar}`);
|
||||
|
||||
const buf = await readFile(config.features.default_avatar);
|
||||
const mimetype = await guess(extname(config.features.default_avatar));
|
||||
logger.debug(`guessed mimetype ${mimetype} for ${config.features.default_avatar}`);
|
||||
|
||||
avatar = `data:${mimetype};base64,${buf.toString('base64')}`;
|
||||
}
|
||||
|
||||
const newUser = await prisma.user.create({
|
||||
data: {
|
||||
password: hashed,
|
||||
username,
|
||||
token: createToken(),
|
||||
administrator: false,
|
||||
avatar,
|
||||
},
|
||||
});
|
||||
|
||||
if (code) {
|
||||
await prisma.invite.update({
|
||||
where: {
|
||||
code,
|
||||
},
|
||||
data: {
|
||||
used: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug(`created user via invite ${code} ${JSON.stringify(newUser, jsonUserReplacer)}`);
|
||||
|
||||
logger.info(
|
||||
`Created user ${newUser.username} (${newUser.id}) ${
|
||||
code ? `from invite code ${code}` : 'via registration'
|
||||
}`
|
||||
);
|
||||
|
||||
return res.json({ success: true });
|
||||
}
|
||||
|
||||
const user = await req.user();
|
||||
if (!user) return res.unauthorized('not logged in');
|
||||
if (!user.administrator) return res.forbidden('you arent an administrator');
|
||||
|
||||
const { username, password, administrator } = req.body as {
|
||||
username: string;
|
||||
password: string;
|
||||
administrator: boolean;
|
||||
};
|
||||
|
||||
if (!username) return res.badRequest('no username');
|
||||
if (!password) return res.badRequest('no password');
|
||||
|
||||
const existing = await prisma.user.findFirst({
|
||||
where: {
|
||||
username,
|
||||
},
|
||||
});
|
||||
if (existing) return res.badRequest('user exists');
|
||||
|
||||
const hashed = await hashPassword(password);
|
||||
|
||||
let avatar;
|
||||
if (config.features.default_avatar) {
|
||||
logger.debug(`using default avatar ${config.features.default_avatar}`);
|
||||
|
||||
const buf = await readFile(config.features.default_avatar);
|
||||
const mimetype = await guess(extname(config.features.default_avatar));
|
||||
logger.debug(`guessed mimetype ${mimetype} for ${config.features.default_avatar}`);
|
||||
|
||||
avatar = `data:${mimetype};base64,${buf.toString('base64')}`;
|
||||
}
|
||||
|
||||
const newUser = await prisma.user.create({
|
||||
data: {
|
||||
password: hashed,
|
||||
username,
|
||||
token: createToken(),
|
||||
administrator,
|
||||
avatar,
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug(`created user ${JSON.stringify(newUser, jsonUserReplacer)}`);
|
||||
|
||||
delete newUser.password;
|
||||
|
||||
logger.info(`Created user ${newUser.username} (${newUser.id})`);
|
||||
|
||||
return res.json(newUser);
|
||||
}
|
||||
|
||||
export default withZipline(handler, {
|
||||
methods: ['POST'],
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user