mirror of
https://github.com/diced/zipline.git
synced 2025-12-25 04:15:41 -08:00
Compare commits
2 Commits
v3.7.11
...
feature/ma
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45f29c7b68 | ||
|
|
114a7a05a9 |
@@ -1,12 +0,0 @@
|
||||
FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-18
|
||||
|
||||
RUN usermod -l zipline node \
|
||||
&& groupmod -n zipline node \
|
||||
&& usermod -d /home/zipline zipline \
|
||||
&& echo "zipline ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers.d/zipline \
|
||||
&& chmod 0440 /etc/sudoers.d/zipline \
|
||||
&& sudo apt-get update && apt-get install gnupg2 -y
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
USER zipline
|
||||
@@ -1,56 +0,0 @@
|
||||
{
|
||||
"name": "Zipline Codespace",
|
||||
"dockerComposeFile": "docker-compose.yml",
|
||||
"service": "app",
|
||||
"workspaceFolder": "/zipline",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/common-utils:2": {
|
||||
"username": "zipline"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:1": {
|
||||
"dockerDashComposeVersion": "v2",
|
||||
"installDockerBuildx": true
|
||||
}
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"settings": {
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"files.autoSave": "afterDelay",
|
||||
"terminal.integrated.persistentSessionReviveProcess": "never",
|
||||
"terminal.integrated.defaultProfile.linux": "zsh",
|
||||
"terminal.integrated.profiles.linux": {
|
||||
"zsh": {
|
||||
"path": "/bin/zsh",
|
||||
"env": {
|
||||
"ZSH_THEME": "devcontainers"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"extensions": ["prisma.prisma", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
|
||||
}
|
||||
},
|
||||
"remoteUser": "zipline",
|
||||
"updateRemoteUserUID": true,
|
||||
"remoteEnv": {
|
||||
"CORE_DATABASE_URL": "postgres://postgres:postgres@db/zip10"
|
||||
},
|
||||
"portsAttributes": {
|
||||
"3000": {
|
||||
"label": "Zipline",
|
||||
"onAutoForward": "openBrowser"
|
||||
},
|
||||
"5432": {
|
||||
"label": "Postgres"
|
||||
}
|
||||
},
|
||||
"postCreateCommand": "sudo chown -R zipline:zipline /zipline && yarn install"
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: ./
|
||||
dockerfile: Dockerfile
|
||||
volumes:
|
||||
- ../:/zipline:cached
|
||||
- uploads:/zipline/uploads
|
||||
- node_modules:/zipline/node_modules
|
||||
command: sleep infinity
|
||||
db:
|
||||
image: postgres:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_DATABASE=postgres
|
||||
volumes:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
pg_data:
|
||||
uploads:
|
||||
node_modules:
|
||||
@@ -2,6 +2,3 @@ node_modules/
|
||||
.next/
|
||||
uploads/
|
||||
.git/
|
||||
.yarn/*
|
||||
!.yarn/releases
|
||||
!.yarn/plugins
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
# every field in here is optional except, CORE_SECRET and CORE_DATABASE_URL.
|
||||
# if CORE_SECRET is still "changethis" then zipline will exit and tell you to change it.
|
||||
|
||||
# if using s3/supabase make sure to uncomment or comment out the correct lines needed.
|
||||
|
||||
CORE_RETURN_HTTPS=true
|
||||
CORE_SECRET="changethis"
|
||||
CORE_HOST=0.0.0.0
|
||||
CORE_PORT=3000
|
||||
CORE_DATABASE_URL="postgres://postgres:postgres@db/zip10"
|
||||
CORE_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
|
||||
|
||||
# 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
|
||||
|
||||
URLS_ROUTE=/go
|
||||
URLS_LENGTH=6
|
||||
|
||||
RATELIMIT_USER=5
|
||||
RATELIMIT_ADMIN=3
|
||||
|
||||
# for more variables checkout the docs
|
||||
@@ -1,7 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
.yarn
|
||||
.devcontainer
|
||||
.github
|
||||
.next
|
||||
.vscode
|
||||
@@ -1,26 +1,12 @@
|
||||
{
|
||||
"root": true,
|
||||
"extends": [
|
||||
"next",
|
||||
"next/core-web-vitals",
|
||||
"plugin:prettier/recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"plugins": ["unused-imports", "@typescript-eslint"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"extends": ["next", "next/core-web-vitals"],
|
||||
"rules": {
|
||||
"indent": ["error", 2],
|
||||
"linebreak-style": ["error", "unix"],
|
||||
"quotes": [
|
||||
"error",
|
||||
"single",
|
||||
{
|
||||
"avoidEscape": true
|
||||
}
|
||||
],
|
||||
"quotes": ["error", "single"],
|
||||
"semi": ["error", "always"],
|
||||
"comma-dangle": ["error", "always-multiline"],
|
||||
"jsx-quotes": ["error", "prefer-single"],
|
||||
"indent": "off",
|
||||
"react/prop-types": "off",
|
||||
"react-hooks/rules-of-hooks": "off",
|
||||
"react-hooks/exhaustive-deps": "off",
|
||||
@@ -31,19 +17,9 @@
|
||||
"react/no-direct-mutation-state": "warn",
|
||||
"react/no-is-mounted": "warn",
|
||||
"react/no-typos": "error",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/react-in-jsx-scope": "error",
|
||||
"react/require-render-return": "error",
|
||||
"react/style-prop-object": "warn",
|
||||
"@next/next/no-img-element": "off",
|
||||
"jsx-a11y/alt-text": "off",
|
||||
"react/display-name": "off",
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
"unused-imports/no-unused-vars": [
|
||||
"error",
|
||||
{ "vars": "all", "varsIgnorePattern": "^_", "args": "after-used", "argsIgnorePattern": "^_" }
|
||||
],
|
||||
"@typescript-eslint/ban-ts-comment": "off"
|
||||
"@next/next/no-img-element": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
14
.gitattributes
vendored
14
.gitattributes
vendored
@@ -1,14 +0,0 @@
|
||||
# Set the default behavior, in case people don't have core.autocrlf set.
|
||||
* text eol=lf
|
||||
|
||||
# Explicitly declare text files you want to always be normalized and converted
|
||||
# to native line endings on checkout.
|
||||
*.c text
|
||||
*.h text
|
||||
|
||||
# Declare files that will always have CRLF line endings on checkout.
|
||||
*.sln text eol=crlf
|
||||
|
||||
# Denote all files that are truly binary and should not be modified.
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
3
.github/FUNDING.yml
vendored
3
.github/FUNDING.yml
vendored
@@ -1,3 +0,0 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: diced
|
||||
52
.github/ISSUE_TEMPLATE/bug.yml
vendored
52
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -1,52 +0,0 @@
|
||||
name: Bug
|
||||
description: File a bug report
|
||||
title: 'Bug: '
|
||||
labels: ['bug']
|
||||
body:
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: Provide steps to reproduce the bug, and some context.
|
||||
value: 'A bug happened!'
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: What version (or docker image) of Zipline are you using?
|
||||
options:
|
||||
- latest (ghcr.io/diced/zipline or ghcr.io/diced/zipline:latest)
|
||||
- upstream (ghcr.io/diced/zipline:trunk)
|
||||
- other (provide version in additional info)
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: browsers
|
||||
attributes:
|
||||
label: What browser(s) are you seeing the problem on?
|
||||
multiple: true
|
||||
options:
|
||||
- Firefox
|
||||
- Chromium-based (Chrome, Edge, Brave, Opera, mobile chrome/chromium based, etc)
|
||||
- Safari
|
||||
- Firefox Mobile
|
||||
- Safari Mobile
|
||||
- type: textarea
|
||||
id: zipline-logs
|
||||
attributes:
|
||||
label: Zipline Logs
|
||||
description: Please copy and paste any relevant log output. Not seeing anything interesting? Try adding the `DEBUG=true` environment variable to see more logs, make sure to review the output and remove any sensitive information as it can be VERY verbose at times.
|
||||
render: shell
|
||||
- type: textarea
|
||||
id: browser-logs
|
||||
attributes:
|
||||
label: Browser Logs
|
||||
description: Please copy and paste any relevant log output.
|
||||
render: shell
|
||||
- type: textarea
|
||||
id: additional-info
|
||||
attributes:
|
||||
label: Additional Info
|
||||
description: Anything else that could be used to narrow down the issue, like your config.
|
||||
11
.github/ISSUE_TEMPLATE/config.yml
vendored
11
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,11 +0,0 @@
|
||||
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
|
||||
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
|
||||
about: Maybe take a look a the docs?
|
||||
25
.github/workflows/build.yml
vendored
25
.github/workflows/build.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: 'Build'
|
||||
name: 'CI: Build'
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -11,28 +11,23 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: '18.x'
|
||||
|
||||
node-version: '16.x'
|
||||
- name: 'Restore dependency cache'
|
||||
id: cache-restore
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
${{ github.workspace }}/.next/cache
|
||||
key: ${{ runner.os }}-node${{ matrix.node }}-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node${{ matrix.node }}-${{ hashFiles('**/yarn.lock') }}-
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node${{ matrix.node }}-${{ hashFiles('**/yarn.lock') }}
|
||||
|
||||
- name: Create mock config
|
||||
run: echo -e "[core]\nsecret = '12345678'\ndatabase_url = 'postgres://postgres:postgres@postgres/postgres'\n[uploader]\nroute = '/u'\ndirectory = './uploads'\n[urls]\nroute = '/go'" > config.toml
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.cache-restore.outputs.cache-hit != 'true'
|
||||
run: yarn install
|
||||
|
||||
- name: Build
|
||||
run: yarn build
|
||||
env:
|
||||
ZIPLINE_DOCKER_BUILD: true
|
||||
run: yarn build
|
||||
59
.github/workflows/docker-release.yml
vendored
59
.github/workflows/docker-release.yml
vendored
@@ -1,59 +0,0 @@
|
||||
name: 'Push Release Docker Images'
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'server/**'
|
||||
- 'prisma/**'
|
||||
- '.github/**'
|
||||
- 'Dockerfile'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
push:
|
||||
name: Push Release Image
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Get version
|
||||
id: version
|
||||
run: |
|
||||
echo "zipline_version=$(jq .version package.json -r)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- 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
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
ghcr.io/diced/zipline:latest
|
||||
ghcr.io/diced/zipline:${{ steps.version.outputs.zipline_version }}
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/zipline:latest
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/zipline:${{ steps.version.outputs.zipline_version }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
59
.github/workflows/docker.yml
vendored
59
.github/workflows/docker.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: 'Push Docker Images'
|
||||
name: 'CD: Push Docker Images'
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -8,51 +8,38 @@ on:
|
||||
- 'server/**'
|
||||
- 'prisma/**'
|
||||
- '.github/**'
|
||||
- 'Dockerfile'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
push:
|
||||
name: Push Image
|
||||
push_to_ghcr:
|
||||
name: Push Image to GitHub Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Get version
|
||||
id: version
|
||||
run: |
|
||||
echo "zipline_version=$(jq .version package.json -r)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to Github Packages
|
||||
uses: docker/login-action@v2
|
||||
- name: Push to GitHub Packages
|
||||
uses: docker/build-push-action@v1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
registry: docker.pkg.github.com
|
||||
repository: diced/zipline/zipline
|
||||
dockerfile: Dockerfile
|
||||
tag_with_ref: true
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
push_to_dockerhub:
|
||||
name: Push Image to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Build and Push Docker Image
|
||||
uses: docker/build-push-action@v3
|
||||
- name: Push to Docker Hub
|
||||
uses: docker/build-push-action@v1
|
||||
with:
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
ghcr.io/diced/zipline:trunk
|
||||
ghcr.io/diced/zipline:trunk-${{ steps.version.outputs.zipline_version }}
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/zipline:trunk
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/zipline:trunk-${{ steps.version.outputs.zipline_version }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
repository: diced/zipline
|
||||
dockerfile: Dockerfile
|
||||
tag_with_ref: true
|
||||
31
.github/workflows/milestone.yml
vendored
31
.github/workflows/milestone.yml
vendored
@@ -1,31 +0,0 @@
|
||||
name: 'Issue/PR Milestones'
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
issues:
|
||||
types: [opened, reopened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
checks: write
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
set:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/github-script@v6
|
||||
with:
|
||||
github-token: ${{secrets.GITHUB_TOKEN}}
|
||||
script: |
|
||||
const milestone = 3
|
||||
github.rest.issues.update({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
milestone
|
||||
})
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -5,11 +5,6 @@
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# yarn
|
||||
.yarn/*
|
||||
!.yarn/releases
|
||||
!.yarn/plugins
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
@@ -41,5 +36,4 @@ yarn-error.log*
|
||||
|
||||
# zipline
|
||||
config.toml
|
||||
uploads*/
|
||||
dist/
|
||||
uploads/
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true,
|
||||
"printWidth": 110
|
||||
}
|
||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"editor.tabSize": 2,
|
||||
"files.eol": "\n",
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
||||
546
.yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
vendored
546
.yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
823
.yarn/releases/yarn-3.3.1.cjs
vendored
823
.yarn/releases/yarn-3.3.1.cjs
vendored
File diff suppressed because one or more lines are too long
11
.yarnrc.yml
11
.yarnrc.yml
@@ -1,11 +0,0 @@
|
||||
checksumBehavior: update
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
plugins:
|
||||
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
|
||||
spec: "@yarnpkg/plugin-interactive-tools"
|
||||
- path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs
|
||||
spec: "@yarnpkg/plugin-workspace-tools"
|
||||
|
||||
yarnPath: .yarn/releases/yarn-3.3.1.cjs
|
||||
@@ -1,23 +0,0 @@
|
||||
# Contributing
|
||||
|
||||
## Bug reports
|
||||
|
||||
Create an issue on GitHub, please include the following (if one of them is not applicable to the issue then it's not needed):
|
||||
|
||||
- The steps to reproduce the bug
|
||||
- Logs of Zipline
|
||||
- The version of Zipline
|
||||
- Your OS & Browser including server OS
|
||||
- What you were expecting to see
|
||||
|
||||
## Feature requests
|
||||
|
||||
Create an discussion on GitHub, please include the following:
|
||||
|
||||
- Brief explanation of the feature in the title (very brief please)
|
||||
- How it would work (detailed, but optional)
|
||||
|
||||
## 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.
|
||||
Please make sure your code also reflects the style of the rest of the code.
|
||||
94
Dockerfile
94
Dockerfile
@@ -1,76 +1,46 @@
|
||||
# Use the Prisma binaries image as the first stage
|
||||
FROM ghcr.io/diced/prisma-binaries:5.1.x AS prisma
|
||||
FROM node:16-alpine AS deps
|
||||
WORKDIR /build
|
||||
|
||||
# Use Alpine Linux as the second stage
|
||||
FROM node:18-alpine3.16 AS base
|
||||
COPY package.json yarn.lock ./
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /zipline
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
# Copy the necessary files from the project
|
||||
COPY prisma ./prisma
|
||||
|
||||
COPY .yarn ./.yarn
|
||||
COPY package*.json ./
|
||||
COPY yarn*.lock ./
|
||||
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_CLI_QUERY_ENGINE_TYPE=binary \
|
||||
PRISMA_CLIENT_ENGINE_TYPE=binary \
|
||||
ZIPLINE_DOCKER_BUILD=true \
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Install the dependencies
|
||||
RUN yarn install --immutable
|
||||
|
||||
FROM base AS builder
|
||||
FROM node:16-alpine AS builder
|
||||
WORKDIR /build
|
||||
|
||||
COPY --from=deps /build/node_modules ./node_modules
|
||||
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
|
||||
COPY server ./server
|
||||
COPY scripts ./scripts
|
||||
COPY prisma ./prisma
|
||||
COPY package.json yarn.lock next.config.js next-env.d.ts zip-env.d.ts tsconfig.json ./
|
||||
|
||||
ENV ZIPLINE_DOCKER_BUILD 1
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
# Run the build
|
||||
RUN yarn build
|
||||
|
||||
# Use Alpine Linux as the final image
|
||||
FROM base
|
||||
FROM node:16-alpine AS runner
|
||||
WORKDIR /zipline
|
||||
|
||||
# Install the necessary packages
|
||||
RUN apk add --no-cache perl procps tini
|
||||
ENV NODE_ENV production
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
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_CLI_QUERY_ENGINE_TYPE=binary \
|
||||
PRISMA_CLIENT_ENGINE_TYPE=binary \
|
||||
ZIPLINE_DOCKER_BUILD=true \
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
RUN addgroup --system --gid 1001 zipline
|
||||
RUN adduser --system --uid 1001 zipline
|
||||
|
||||
COPY --from=builder --chown=zipline:zipline /build/.next ./.next
|
||||
COPY --from=builder --chown=zipline:zipline /build/node_modules ./node_modules
|
||||
|
||||
# Copy only the necessary files from the previous stage
|
||||
COPY --from=builder /zipline/dist ./dist
|
||||
COPY --from=builder /zipline/.next ./.next
|
||||
COPY --from=builder /build/next.config.js ./next.config.js
|
||||
COPY --from=builder /build/src ./src
|
||||
COPY --from=builder /build/server ./server
|
||||
COPY --from=builder /build/scripts ./scripts
|
||||
COPY --from=builder /build/prisma ./prisma
|
||||
COPY --from=builder /build/tsconfig.json ./tsconfig.json
|
||||
COPY --from=builder /build/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
|
||||
USER zipline
|
||||
|
||||
# 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"]
|
||||
CMD ["node", "server"]
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 dicedtomato
|
||||
Copyright (c) 2021 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
|
||||
|
||||
172
README.md
172
README.md
@@ -1,172 +1,34 @@
|
||||
<div align="center">
|
||||
<img src="https://raw.githubusercontent.com/diced/zipline/trunk/public/zipline_small.png"/>
|
||||
|
||||
A ShareX/file upload server that is easy to use, packed with features, and with an easy setup!
|
||||
|
||||

|
||||

|
||||

|
||||
[](https://discord.gg/EAhCRfGxCF)
|
||||
|
||||

|
||||
[](https://github.com/diced/zipline/pkgs/container/zipline/?tag=trunk)
|
||||
[](https://github.com/diced/zipline/pkgs/container/zipline/?tag=latest)
|
||||
|
||||
|
||||
Zipline is a ShareX/file upload server that is easy to use, packed with features and can be setup in one command!
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
[](https://discord.gg/EAhCRfGxCF)
|
||||
|
||||
</div>
|
||||
|
||||
## Features
|
||||
|
||||
- Configurable
|
||||
- Fast
|
||||
- Built with Next.js & React
|
||||
- Token protected uploading
|
||||
- Image uploading
|
||||
- Image compression
|
||||
- Password Protected Uploads
|
||||
- URL shortening
|
||||
- Text uploading
|
||||
- URL Formats (uuid, dates, random alphanumeric, original name, zws, gfycat -> [animals](https://assets.gfycat.com/animals) [adjectives](https://assets.gfycat.com/adjectives))
|
||||
- URL Formats (uuid, dates, random alphanumeric, original name, zws)
|
||||
- Discord embeds (OG metadata)
|
||||
- Gallery viewer, and multiple file format support
|
||||
- Code highlighting
|
||||
- Fully customizable Discord webhook notifications
|
||||
- OAuth2 registration (Discord and GitHub)
|
||||
- Two-Factor authentication with Google Authenticator, Authy, etc (totp services).
|
||||
- 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`)
|
||||
- Easy setup instructions on [docs](https://zipline.diced.tech/) (One command install `docker-compose up -d`)
|
||||
|
||||
<details>
|
||||
<summary><h2>Screenshots (click)</h2></summary>
|
||||
|
||||
View full album at [imgur](https://imgur.com/a/GzyowZ7)
|
||||
|
||||

|
||||

|
||||

|
||||
</details>
|
||||
## Installing
|
||||
[See how to install here](https://zipline.diced.tech/docs/get-started)
|
||||
|
||||
# Usage
|
||||
## Configuration
|
||||
[See how to configure here](https://zipline.diced.tech/docs/config/overview)
|
||||
|
||||
## Install & run with Docker
|
||||
|
||||
This section requires [Docker](https://docs.docker.com/get-docker/) and [docker compose](https://docs.docker.com/compose/install/).
|
||||
|
||||
```shell
|
||||
git clone https://github.com/diced/zipline
|
||||
cd zipline
|
||||
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### After installing
|
||||
|
||||
After installing, please edit the `docker-compose.yml` file and find the line that says `SECRET=changethis` and replace `changethis` with a random string.
|
||||
Ways you could generate the string could be from a password managers generator, or you could just slam your keyboard and hope for the best.
|
||||
|
||||
## 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`.
|
||||
|
||||
```shell
|
||||
git clone https://github.com/diced/zipline
|
||||
cd zipline
|
||||
|
||||
yarn install
|
||||
yarn build
|
||||
yarn start
|
||||
```
|
||||
|
||||
# NGINX Proxy
|
||||
|
||||
This section requires [NGINX](https://nginx.org/).
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80 default_server;
|
||||
client_max_body_size 100M;
|
||||
server_name <your domain (optional)>;
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
# Website
|
||||
|
||||
The default port is `3000`, once you have accessed it you can see a login screen. The default credentials are "administrator" and "password". Once you login please immediately change the details to something more secure. You can do this by clicking on the top right corner where it says "administrator" with a gear icon and clicking Manage Account.
|
||||
|
||||
# ShareX (Windows)
|
||||
|
||||
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)
|
||||
|
||||
This section requires [Flameshot](https://www.flameshot.org/), [jq](https://stedolan.github.io/jq/), and [xsel](https://github.com/kfish/xsel).
|
||||
|
||||
<details>
|
||||
<summary>Wayland instructions</summary>
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
```shell
|
||||
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
|
||||
```
|
||||
|
||||
# Contributing
|
||||
|
||||
## Bug reports
|
||||
|
||||
Create an issue on GitHub and use the template, please include the following (if one of them is not applicable to the issue then it's not needed):
|
||||
|
||||
- The steps to reproduce the bug
|
||||
- Logs of Zipline
|
||||
- The version of Zipline
|
||||
- Your OS & Browser including server OS
|
||||
- What you were expecting to see
|
||||
|
||||
## Feature requests
|
||||
|
||||
Create a discussion on GitHub, please include the following:
|
||||
|
||||
- Brief explanation of the feature in the title (very brief please)
|
||||
- How it would work (Be detailed!)
|
||||
|
||||
## 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).
|
||||
## Theming
|
||||
[See how to theme here](https://zipline.diced.tech/docs/themes/reference)
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 3.6.x | :white_check_mark: |
|
||||
| 3.2.x | :white_check_mark: |
|
||||
| < 3 | :x: |
|
||||
| < 2 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Report a Vulnerability by issuing a bug report, with exact details with how the vulnerability happened, what "exploits" can happen, and possible fixes (optional). Vulnerability reports are treated with high priority and will be resolved most of the time quickly.
|
||||
|
||||
19
config.example.toml
Normal file
19
config.example.toml
Normal file
@@ -0,0 +1,19 @@
|
||||
[core]
|
||||
secure = true
|
||||
secret = 'some secret'
|
||||
host = '0.0.0.0'
|
||||
port = 3000
|
||||
database_url = 'postgres://postgres:postgres@postgres/postgres'
|
||||
|
||||
[urls]
|
||||
route = '/go'
|
||||
length = 6
|
||||
|
||||
[uploader]
|
||||
route = '/u'
|
||||
embed_route = '/a'
|
||||
length = 6
|
||||
directory = './uploads'
|
||||
user_limit = 104900000 # 100mb
|
||||
admin_limit = 104900000 # 100mb
|
||||
disabled_extentions = ['jpg']
|
||||
@@ -1,12 +1,12 @@
|
||||
version: '3'
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
environment:
|
||||
image: postgres
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_DATABASE=postgres
|
||||
volumes:
|
||||
volumes:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U postgres']
|
||||
@@ -20,13 +20,27 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- '3000:3000'
|
||||
env_file:
|
||||
- .env.local
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- SECURE=false
|
||||
- SECRET=changethis
|
||||
- HOST=0.0.0.0
|
||||
- PORT=3000
|
||||
- DATABASE_URL=postgresql://postgres:postgres@postgres/postgres/
|
||||
- UPLOADER_ROUTE=/u
|
||||
- UPLOADER_EMBED_ROUTE=/a
|
||||
- UPLOADER_LENGTH=6
|
||||
- UPLOADER_DIRECTORY=./uploads
|
||||
- UPLOADER_ADMIN_LIMIT=104900000
|
||||
- UPLOADER_USER_LIMIT=104900000
|
||||
- UPLOADER_DISABLED_EXTS=
|
||||
- URLS_ROUTE=/go
|
||||
- URLS_LENGTH=6
|
||||
volumes:
|
||||
- './uploads:/zipline/uploads'
|
||||
- './public:/zipline/public'
|
||||
- '$PWD/uploads:/zipline/uploads'
|
||||
- '$PWD/public:/zipline/public'
|
||||
depends_on:
|
||||
- 'postgres'
|
||||
|
||||
volumes:
|
||||
pg_data:
|
||||
pg_data:
|
||||
@@ -1,13 +1,12 @@
|
||||
version: '3'
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
image: postgres
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_DATABASE=postgres
|
||||
volumes:
|
||||
volumes:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U postgres']
|
||||
@@ -16,22 +15,30 @@ services:
|
||||
retries: 5
|
||||
|
||||
zipline:
|
||||
image: ghcr.io/diced/zipline
|
||||
image: ghcr.io/diced/zipline/zipline:trunk
|
||||
ports:
|
||||
- '3000:3000'
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- CORE_RETURN_HTTPS=false
|
||||
- CORE_SECRET=changethis
|
||||
- CORE_HOST=0.0.0.0
|
||||
- CORE_PORT=3000
|
||||
- CORE_DATABASE_URL=postgres://postgres:postgres@postgres/postgres
|
||||
- CORE_LOGGER=true
|
||||
environment:
|
||||
- SECURE=false
|
||||
- SECRET=changethis
|
||||
- HOST=0.0.0.0
|
||||
- PORT=3000
|
||||
- DATABASE_URL=postgresql://postgres:postgres@postgres/postgres/
|
||||
- UPLOADER_ROUTE=/u
|
||||
- UPLOADER_EMBED_ROUTE=/a
|
||||
- UPLOADER_LENGTH=6
|
||||
- UPLOADER_DIRECTORY=./uploads
|
||||
- UPLOADER_ADMIN_LIMIT=104900000
|
||||
- UPLOADER_USER_LIMIT=104900000
|
||||
- UPLOADER_DISABLED_EXTS=
|
||||
- URLS_ROUTE=/go
|
||||
- URLS_LENGTH=6
|
||||
volumes:
|
||||
- './uploads:/zipline/uploads'
|
||||
- './public:/zipline/public'
|
||||
- '$PWD/uploads:/zipline/uploads'
|
||||
- '$PWD/public:/zipline/public'
|
||||
depends_on:
|
||||
- 'postgres'
|
||||
|
||||
volumes:
|
||||
pg_data:
|
||||
pg_data:
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
unset ZIPLINE_DOCKER_BUILD
|
||||
|
||||
node --enable-source-maps dist/index.js
|
||||
1387
mimes.json
1387
mimes.json
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,11 @@
|
||||
/**
|
||||
* @type {import('next').NextConfig}
|
||||
**/
|
||||
module.exports = {
|
||||
images: {
|
||||
domains: [
|
||||
// For sharex icon in manage user
|
||||
'getsharex.com',
|
||||
// For flameshot icon, and maybe in the future other stuff from github
|
||||
'raw.githubusercontent.com',
|
||||
// Google Icon
|
||||
'madeby.google.com',
|
||||
],
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: '/',
|
||||
destination: '/dashboard',
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
poweredByHeader: false,
|
||||
reactStrictMode: true,
|
||||
};
|
||||
};
|
||||
133
package.json
133
package.json
@@ -1,104 +1,63 @@
|
||||
{
|
||||
"name": "zipline",
|
||||
"version": "3.7.11",
|
||||
"name": "zip3",
|
||||
"version": "3.4.0",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "npm-run-all build:server dev:run",
|
||||
"dev:run": "cross-env DEBUG=true REACT_EDITOR=code NODE_ENV=development RECOIL_DUPLICATE_ATOM_KEY_CHECKING_ENABLED=false node --enable-source-maps dist",
|
||||
"build": "npm-run-all build:server build:schema build:next",
|
||||
"build-ci": "cross-env ZIPLINE_DOCKER_BUILD=1 npm-run-all build:server build:schema build:next",
|
||||
"build:server": "tsup",
|
||||
"dev": "NODE_ENV=development node server",
|
||||
"build": "npm-run-all build:schema build:next",
|
||||
"build:next": "next build",
|
||||
"build:schema": "prisma generate --schema=prisma/schema.prisma",
|
||||
"format": "prettier --write ./src/**/*.{ts,tsx} ./*.{md,js,json,yml}",
|
||||
"migrate:dev": "prisma migrate dev --create-only",
|
||||
"start": "node dist",
|
||||
"start": "node server",
|
||||
"lint": "next lint",
|
||||
"compose:up": "docker compose up",
|
||||
"compose:down": "docker compose down",
|
||||
"compose:build-dev": "docker compose --file docker-compose.dev.yml up --build",
|
||||
"compose:up-dev": "docker compose --file docker-compose.dev.yml up",
|
||||
"compose:down-dev": "docker compose --file docker-compose.dev.yml down",
|
||||
"scripts:read-config": "node --enable-source-maps dist/scripts/read-config",
|
||||
"scripts:import-dir": "node --enable-source-maps dist/scripts/import-dir",
|
||||
"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"
|
||||
"seed": "ts-node --compiler-options \"{\\\"module\\\":\\\"commonjs\\\"}\" --transpile-only prisma/seed.ts",
|
||||
"docker:run": "docker-compose up -d",
|
||||
"docker:down": "docker-compose down",
|
||||
"docker:build-dev": "docker-compose --file docker-compose.dev.yml up --build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/server": "^11.11.0",
|
||||
"@mantine/core": "^6.0.21",
|
||||
"@mantine/dropzone": "^6.0.21",
|
||||
"@mantine/form": "^6.0.21",
|
||||
"@mantine/hooks": "^6.0.21",
|
||||
"@mantine/modals": "^6.0.21",
|
||||
"@mantine/next": "^6.0.21",
|
||||
"@mantine/notifications": "^6.0.21",
|
||||
"@mantine/prism": "^6.0.21",
|
||||
"@mantine/spotlight": "^6.0.21",
|
||||
"@prisma/client": "^5.1.1",
|
||||
"@prisma/internals": "^5.1.1",
|
||||
"@prisma/migrate": "^5.1.1",
|
||||
"@sapphire/shapeshift": "^3.9.3",
|
||||
"@tabler/icons-react": "^2.41.0",
|
||||
"@tanstack/react-query": "^4.28.0",
|
||||
"argon2": "^0.31.2",
|
||||
"cookie": "^0.6.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"dotenv": "^16.3.1",
|
||||
"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",
|
||||
"ms": "canary",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"next": "^14.0.3",
|
||||
"otplib": "^12.0.1",
|
||||
"prisma": "^5.1.1",
|
||||
"prismjs": "^1.29.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^8.0.6",
|
||||
"recharts": "^2.10.1",
|
||||
"recoil": "^0.7.7",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"sharp": "^0.32.6"
|
||||
"@iarna/toml": "2.2.5",
|
||||
"@mantine/core": "^3.6.9",
|
||||
"@mantine/dropzone": "^3.6.9",
|
||||
"@mantine/hooks": "^3.6.9",
|
||||
"@mantine/modals": "^3.6.9",
|
||||
"@mantine/next": "^3.6.9",
|
||||
"@mantine/notifications": "^3.6.9",
|
||||
"@mantine/prism": "^3.6.11",
|
||||
"@modulz/radix-icons": "^4.0.0",
|
||||
"@prisma/client": "^3.9.2",
|
||||
"@prisma/migrate": "^3.9.2",
|
||||
"@prisma/sdk": "^3.9.2",
|
||||
"@reduxjs/toolkit": "^1.6.0",
|
||||
"argon2": "^0.28.2",
|
||||
"colorette": "^1.2.2",
|
||||
"cookie": "^0.4.1",
|
||||
"fecha": "^4.2.1",
|
||||
"multer": "^1.4.2",
|
||||
"next": "^12.1.0",
|
||||
"prisma": "^3.9.2",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-redux": "^7.2.4",
|
||||
"react-table": "^7.7.0",
|
||||
"redux": "^4.1.0",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"uuid": "^8.3.2",
|
||||
"yup": "^0.32.9"
|
||||
},
|
||||
"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",
|
||||
"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",
|
||||
"@types/cookie": "^0.4.0",
|
||||
"@types/multer": "^1.4.6",
|
||||
"@types/node": "^15.12.2",
|
||||
"babel-plugin-import": "^1.13.3",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-config-next": "11.0.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^3.1.0",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.2.2"
|
||||
"ts-node": "^10.0.0",
|
||||
"typescript": "^4.3.2"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/diced/zipline.git"
|
||||
},
|
||||
"packageManager": "yarn@3.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the `Theme` table. If the table is not empty, all the data it contains will be lost.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Theme" DROP CONSTRAINT "Theme_userId_fkey";
|
||||
@@ -9,4 +11,4 @@ ALTER TABLE "Theme" DROP CONSTRAINT "Theme_userId_fkey";
|
||||
ALTER TABLE "User" ALTER COLUMN "systemTheme" SET DEFAULT E'system';
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "Theme";
|
||||
DROP TABLE "Theme";
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "domains" TEXT[];
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Image" ADD COLUMN "password" TEXT;
|
||||
@@ -1,9 +0,0 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `ratelimited` on the `User` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" DROP COLUMN "ratelimited",
|
||||
ADD COLUMN "ratelimit" TIMESTAMP(3);
|
||||
@@ -1,17 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Invite" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"used" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdById" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "Invite_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Invite_code_key" ON "Invite"("code");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Invite" ADD CONSTRAINT "Invite_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -1,3 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Invite" ALTER COLUMN "expires_at" DROP NOT NULL,
|
||||
ALTER COLUMN "expires_at" DROP DEFAULT;
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Image" ADD COLUMN "expires_at" TIMESTAMP(3);
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "avatar" TEXT;
|
||||
@@ -1,4 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "oauth" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "oauthProvider" TEXT,
|
||||
ALTER COLUMN "password" DROP NOT NULL;
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "oauthAccessToken" TEXT;
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "superAdmin" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Image" ADD COLUMN "maxViews" INTEGER;
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Url" ADD COLUMN "maxViews" INTEGER;
|
||||
@@ -1,31 +0,0 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `oauth` on the `User` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `oauthAccessToken` on the `User` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `oauthProvider` on the `User` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- CreateEnum
|
||||
CREATE TYPE "OauthProviders" AS ENUM ('DISCORD', 'GITHUB');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" DROP COLUMN "oauth",
|
||||
DROP COLUMN "oauthAccessToken",
|
||||
DROP COLUMN "oauthProvider";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "OAuth" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"provider" "OauthProviders" NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "OAuth_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "OAuth_provider_key" ON "OAuth"("provider");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OAuth" ADD CONSTRAINT "OAuth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -1,5 +0,0 @@
|
||||
-- DropIndex
|
||||
DROP INDEX "OAuth_provider_key";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "OAuth" ADD COLUMN "refresh" TEXT;
|
||||
@@ -1,8 +0,0 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Image" DROP CONSTRAINT "Image_userId_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Image" ALTER COLUMN "userId" DROP NOT NULL;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Image" ADD CONSTRAINT "Image_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "OauthProviders" ADD VALUE 'GOOGLE';
|
||||
@@ -1,8 +0,0 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `username` to the `OAuth` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "OAuth" ADD COLUMN "username" TEXT NOT NULL;
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "totpSecret" TEXT;
|
||||
@@ -1,26 +0,0 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "InvisibleImage" DROP CONSTRAINT "InvisibleImage_imageId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "InvisibleUrl" DROP CONSTRAINT "InvisibleUrl_urlId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Invite" DROP CONSTRAINT "Invite_createdById_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Url" DROP CONSTRAINT "Url_userId_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Url" ALTER COLUMN "userId" DROP NOT NULL;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InvisibleImage" ADD CONSTRAINT "InvisibleImage_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "Image"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Url" ADD CONSTRAINT "Url_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InvisibleUrl" ADD CONSTRAINT "InvisibleUrl_urlId_fkey" FOREIGN KEY ("urlId") REFERENCES "Url"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Invite" ADD CONSTRAINT "Invite_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -1,12 +0,0 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[provider,oauthId]` on the table `OAuth` will be added. If there are existing duplicate values, this will fail.
|
||||
- Added the required column `oauthId` to the `OAuth` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "OAuth" ADD COLUMN "oauthId" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "OAuth_provider_oauthId_key" ON "OAuth"("provider", "oauthId");
|
||||
@@ -1,13 +0,0 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `embedColor` on the `User` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `embedSiteName` on the `User` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `embedTitle` on the `User` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" DROP COLUMN "embedColor",
|
||||
DROP COLUMN "embedSiteName",
|
||||
DROP COLUMN "embedTitle",
|
||||
ADD COLUMN "embed" JSONB NOT NULL DEFAULT '{}';
|
||||
@@ -1,8 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Image" RENAME COLUMN "created_at" TO "createdAt";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Image" RENAME COLUMN "expires_at" TO "expiresAt";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Image" RENAME COLUMN "file" TO "name";
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Url" RENAME COLUMN "created_at" TO "createdAt";
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Stats" RENAME COLUMN "created_at" TO "createdAt";
|
||||
@@ -1,5 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Invite" RENAME COLUMN "created_at" TO "createdAt";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Invite" RENAME COLUMN "expires_at" TO "expiresAt";
|
||||
@@ -1,19 +0,0 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "ImageFormat" RENAME TO "FileNameFormat";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Image" RENAME TO "File";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "InvisibleImage" RENAME TO "InvisibleFile";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "InvisibleFile" RENAME COLUMN "imageId" TO "fileId";
|
||||
|
||||
-- AlterForeignKey
|
||||
ALTER TABLE "InvisibleFile" RENAME CONSTRAINT "InvisibleImage_imageId_fkey" TO "InvisibleFile_fileId_fkey";
|
||||
ALTER INDEX "InvisibleImage_imageId_key" RENAME TO "InvisibleFile_fileId_key";
|
||||
|
||||
-- AlterForeignKey
|
||||
ALTER TABLE "InvisibleFile" RENAME CONSTRAINT "InvisibleImage_pkey" TO "InvisibleFile_pkey";
|
||||
ALTER TABLE "File" RENAME CONSTRAINT "Image_pkey" TO "File_pkey";
|
||||
@@ -1,8 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "File" ADD COLUMN "originalName" TEXT;
|
||||
|
||||
-- RenameForeignKey
|
||||
ALTER TABLE "File" RENAME CONSTRAINT "Image_userId_fkey" TO "File_userId_fkey";
|
||||
|
||||
-- RenameIndex
|
||||
ALTER INDEX "InvisibleImage_invis_key" RENAME TO "InvisibleFile_invis_key";
|
||||
@@ -1,19 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "File" ADD COLUMN "folderId" INTEGER;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Folder" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "Folder_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Folder" ADD CONSTRAINT "Folder_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "File" ADD CONSTRAINT "File_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "Folder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Folder" ADD COLUMN "public" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "File" ADD COLUMN "size" INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -1,11 +0,0 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `format` on the `File` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "File" DROP COLUMN "format";
|
||||
|
||||
-- DropEnum
|
||||
DROP TYPE "FileNameFormat";
|
||||
@@ -1,18 +0,0 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ProcessingStatus" AS ENUM ('PENDING', 'PROCESSING', 'COMPLETE');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "IncompleteFile" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"status" "ProcessingStatus" NOT NULL,
|
||||
"chunks" INTEGER NOT NULL,
|
||||
"chunksComplete" INTEGER NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"data" JSONB NOT NULL,
|
||||
|
||||
CONSTRAINT "IncompleteFile_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "IncompleteFile" ADD CONSTRAINT "IncompleteFile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -1,53 +0,0 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[uuid]` on the table `User` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- PRISMA GENERATED BELOW
|
||||
-- -- DropForeignKey
|
||||
-- ALTER TABLE "OAuth" DROP CONSTRAINT "OAuth_userId_fkey";
|
||||
--
|
||||
-- -- AlterTable
|
||||
-- ALTER TABLE "OAuth" ALTER COLUMN "userId" SET DATA TYPE TEXT;
|
||||
--
|
||||
-- -- AlterTable
|
||||
-- ALTER TABLE "User" ADD COLUMN "uuid" UUID NOT NULL DEFAULT gen_random_uuid();
|
||||
--
|
||||
-- -- CreateIndex
|
||||
-- CREATE UNIQUE INDEX "User_uuid_key" ON "User"("uuid");
|
||||
--
|
||||
-- -- AddForeignKey
|
||||
-- ALTER TABLE "OAuth" ADD CONSTRAINT "OAuth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("uuid") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- User made changes below
|
||||
|
||||
-- Rename old foreign key
|
||||
ALTER TABLE "OAuth" RENAME CONSTRAINT "OAuth_userId_fkey" TO "OAuth_userId_old_fkey";
|
||||
|
||||
-- Rename old column
|
||||
ALTER TABLE "OAuth" RENAME COLUMN "userId" TO "userId_old";
|
||||
|
||||
-- Add new column
|
||||
ALTER TABLE "OAuth" ADD COLUMN "userId" UUID;
|
||||
|
||||
-- Add user uuid
|
||||
ALTER TABLE "User" ADD COLUMN "uuid" UUID NOT NULL DEFAULT gen_random_uuid();
|
||||
|
||||
-- Update table "OAuth" with uuid
|
||||
UPDATE "OAuth" SET "userId" = "User"."uuid" FROM "User" WHERE "OAuth"."userId_old" = "User"."id";
|
||||
|
||||
-- Alter table "OAuth" to make "userId" required
|
||||
ALTER TABLE "OAuth" ALTER COLUMN "userId" SET NOT NULL;
|
||||
|
||||
-- Create index
|
||||
CREATE UNIQUE INDEX "User_uuid_key" ON "User"("uuid");
|
||||
|
||||
-- Add new foreign key
|
||||
ALTER TABLE "OAuth" ADD CONSTRAINT "OAuth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("uuid") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- Drop old foreign key
|
||||
ALTER TABLE "OAuth" DROP CONSTRAINT "OAuth_userId_old_fkey";
|
||||
|
||||
-- Drop old column
|
||||
ALTER TABLE "OAuth" DROP COLUMN "userId_old";
|
||||
@@ -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,168 +8,68 @@ 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[]
|
||||
IncompleteFile IncompleteFile[]
|
||||
Exports Export[]
|
||||
id Int @id @default(autoincrement())
|
||||
username String
|
||||
password String
|
||||
token String
|
||||
administrator Boolean @default(false)
|
||||
systemTheme String @default("system")
|
||||
embedTitle String?
|
||||
embedColor String @default("#2f3136")
|
||||
embedSiteName String? @default("{image.file} • {user.name}")
|
||||
ratelimited Boolean @default(false)
|
||||
images Image[]
|
||||
urls Url[]
|
||||
}
|
||||
|
||||
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
|
||||
enum ImageFormat {
|
||||
UUID
|
||||
DATE
|
||||
RANDOM
|
||||
NAME
|
||||
}
|
||||
|
||||
model Folder {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
public Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId Int
|
||||
|
||||
files File[]
|
||||
model Image {
|
||||
id Int @id @default(autoincrement())
|
||||
file String
|
||||
mimetype String @default("image/png")
|
||||
created_at DateTime @default(now())
|
||||
views Int @default(0)
|
||||
favorite Boolean @default(false)
|
||||
embed Boolean @default(false)
|
||||
invisible InvisibleImage?
|
||||
format ImageFormat @default(RANDOM)
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
}
|
||||
|
||||
model File {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
originalName String?
|
||||
mimetype String @default("image/png")
|
||||
createdAt DateTime @default(now())
|
||||
size BigInt @default(0)
|
||||
expiresAt DateTime?
|
||||
maxViews Int?
|
||||
views Int @default(0)
|
||||
favorite Boolean @default(false)
|
||||
embed Boolean @default(false)
|
||||
password String?
|
||||
invisible InvisibleFile?
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
userId Int?
|
||||
|
||||
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
|
||||
folderId Int?
|
||||
|
||||
thumbnail Thumbnail?
|
||||
}
|
||||
|
||||
model Thumbnail {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now())
|
||||
name String
|
||||
|
||||
fileId Int @unique
|
||||
file File @relation(fields: [fileId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model InvisibleFile {
|
||||
id Int @id @default(autoincrement())
|
||||
invis String @unique
|
||||
|
||||
fileId Int @unique
|
||||
file File @relation(fields: [fileId], references: [id], onDelete: Cascade)
|
||||
model InvisibleImage {
|
||||
id Int @id @default(autoincrement())
|
||||
invis String @unique
|
||||
imageId Int
|
||||
image Image @relation(fields: [imageId], references: [id])
|
||||
}
|
||||
|
||||
model Url {
|
||||
id String @id @unique
|
||||
destination String
|
||||
vanity String?
|
||||
createdAt DateTime @default(now())
|
||||
maxViews Int?
|
||||
created_at DateTime @default(now())
|
||||
views Int @default(0)
|
||||
invisible InvisibleUrl?
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
userId Int?
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
}
|
||||
|
||||
model InvisibleUrl {
|
||||
id Int @id @default(autoincrement())
|
||||
invis String @unique
|
||||
|
||||
urlId String @unique
|
||||
url Url @relation(fields: [urlId], references: [id], onDelete: Cascade)
|
||||
urlId String
|
||||
url Url @relation(fields: [urlId], references: [id])
|
||||
}
|
||||
|
||||
model Stats {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now())
|
||||
data Json
|
||||
}
|
||||
|
||||
model Invite {
|
||||
id Int @id @default(autoincrement())
|
||||
code String @unique
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime?
|
||||
used Boolean @default(false)
|
||||
|
||||
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
|
||||
createdById Int
|
||||
}
|
||||
|
||||
model OAuth {
|
||||
id Int @id @default(autoincrement())
|
||||
provider OauthProviders
|
||||
user User @relation(fields: [userId], references: [uuid], onDelete: Cascade)
|
||||
userId String @db.Uuid
|
||||
username String
|
||||
oauthId String?
|
||||
token String
|
||||
refresh String?
|
||||
|
||||
@@unique([provider, oauthId])
|
||||
}
|
||||
|
||||
enum OauthProviders {
|
||||
DISCORD
|
||||
GITHUB
|
||||
GOOGLE
|
||||
}
|
||||
|
||||
model IncompleteFile {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
status ProcessingStatus
|
||||
chunks Int
|
||||
chunksComplete Int
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId Int
|
||||
|
||||
data Json
|
||||
}
|
||||
|
||||
enum ProcessingStatus {
|
||||
PENDING
|
||||
PROCESSING
|
||||
COMPLETE
|
||||
}
|
||||
id Int @id @default(autoincrement())
|
||||
created_at DateTime @default(now())
|
||||
data Json
|
||||
}
|
||||
31
prisma/seed.ts
Normal file
31
prisma/seed.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { hashPassword, createToken } from '../src/lib/util';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: 'administrator',
|
||||
password: await hashPassword('password'),
|
||||
token: createToken(),
|
||||
administrator: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`
|
||||
When logging into Zipline for the first time, use these credentials:
|
||||
|
||||
Username: "${user.username}"
|
||||
Password: "password"
|
||||
`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(e => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
1750
public/animals.txt
1750
public/animals.txt
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 279 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 193 KiB After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 4.8 KiB |
38
scripts/exts.js
Normal file
38
scripts/exts.js
Normal file
@@ -0,0 +1,38 @@
|
||||
// https://github.com/toptal/haste-server/blob/master/static/application.js#L167-L174
|
||||
// Popular extension map
|
||||
module.exports = {
|
||||
rb: 'ruby',
|
||||
py: 'python',
|
||||
pl: 'perl',
|
||||
php: 'php',
|
||||
scala: 'scala',
|
||||
go: 'go',
|
||||
xml: 'xml',
|
||||
html: 'xml',
|
||||
htm: 'xml',
|
||||
css: 'css',
|
||||
js: 'javascript',
|
||||
json: 'json',
|
||||
vbs: 'vbscript',
|
||||
lua: 'lua',
|
||||
pas: 'delphi',
|
||||
java: 'java',
|
||||
cpp: 'cpp',
|
||||
cc: 'cpp',
|
||||
m: 'objectivec',
|
||||
vala: 'vala',
|
||||
sql: 'sql',
|
||||
sm: 'smalltalk',
|
||||
lisp: 'lisp',
|
||||
ini: 'ini',
|
||||
diff: 'diff',
|
||||
bash: 'bash',
|
||||
sh: 'bash',
|
||||
tex: 'tex',
|
||||
erl: 'erlang',
|
||||
hs: 'haskell',
|
||||
md: 'markdown',
|
||||
txt: '',
|
||||
coffee: 'coffee',
|
||||
swift: 'swift',
|
||||
};
|
||||
35
scripts/migrate-v2-v3.js
Normal file
35
scripts/migrate-v2-v3.js
Normal file
@@ -0,0 +1,35 @@
|
||||
const { readdir } = require('fs/promises');
|
||||
const { extname } = require('path');
|
||||
const validateConfig = require('../server/validateConfig');
|
||||
const Logger = require('../src/lib/logger');
|
||||
const readConfig = require('../src/lib/readConfig');
|
||||
const mimes = require('./mimes');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
|
||||
(async () => {
|
||||
const config = readConfig();
|
||||
|
||||
await validateConfig(config);
|
||||
|
||||
process.env.DATABASE_URL = config.core.database_url;
|
||||
|
||||
const files = await readdir(process.argv[2]);
|
||||
const data = files.map(x => {
|
||||
const mime = mimes[extname(x)] ?? 'application/octet-stream';
|
||||
|
||||
return {
|
||||
file: x,
|
||||
mimetype: mime,
|
||||
userId: 1,
|
||||
};
|
||||
});
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
Logger.get('migrator').info('starting migrations...');
|
||||
await prisma.image.createMany({
|
||||
data,
|
||||
});
|
||||
Logger.get('migrator').info('finished migrations! It is recomended to move your old uploads folder (' + process.argv[2] + ') to the current one which is ' + config.uploader.directory);
|
||||
process.exit();
|
||||
})();
|
||||
78
scripts/mimes.js
Normal file
78
scripts/mimes.js
Normal file
@@ -0,0 +1,78 @@
|
||||
module.exports = {
|
||||
'.aac': 'audio/aac',
|
||||
'.abw': 'application/x-abiword',
|
||||
'.arc': 'application/x-freearc',
|
||||
'.avi': 'video/x-msvideo',
|
||||
'.azw': 'application/vnd.amazon.ebook',
|
||||
'.bin': 'application/octet-stream',
|
||||
'.bmp': 'image/bmp',
|
||||
'.bz': 'application/x-bzip',
|
||||
'.bz2': 'application/x-bzip2',
|
||||
'.cda': 'application/x-cdf',
|
||||
'.csh': 'application/x-csh',
|
||||
'.css': 'text/css',
|
||||
'.csv': 'text/csv',
|
||||
'.doc': 'application/msword',
|
||||
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'.eot': 'application/vnd.ms-fontobject',
|
||||
'.epub': 'application/epub+zip',
|
||||
'.gz': 'application/gzip',
|
||||
'.gif': 'image/gif',
|
||||
'.htm': 'text/html',
|
||||
'.html': 'text/html',
|
||||
'.ico': 'image/vnd.microsoft.icon',
|
||||
'.ics': 'text/calendar',
|
||||
'.jar': 'application/java-archive',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.js': 'text/javascript',
|
||||
'.json': 'application/json',
|
||||
'.jsonld': 'application/ld+json',
|
||||
'.mid': 'audio/midi',
|
||||
'.midi': 'audio/midi',
|
||||
'.mjs': 'text/javascript',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.mp4': 'video/mp4',
|
||||
'.mpeg': 'video/mpeg',
|
||||
'.mpkg': 'application/vnd.apple.installer+xml',
|
||||
'.odp': 'application/vnd.oasis.opendocument.presentation',
|
||||
'.ods': 'application/vnd.oasis.opendocument.spreadsheet',
|
||||
'.odt': 'application/vnd.oasis.opendocument.text',
|
||||
'.oga': 'audio/ogg',
|
||||
'.ogv': 'video/ogg',
|
||||
'.ogx': 'application/ogg',
|
||||
'.opus': 'audio/opus',
|
||||
'.otf': 'font/otf',
|
||||
'.png': 'image/png',
|
||||
'.pdf': 'application/pdf',
|
||||
'.php': 'application/x-httpd-php',
|
||||
'.ppt': 'application/vnd.ms-powerpoint',
|
||||
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'.rar': 'application/vnd.rar',
|
||||
'.rtf': 'application/rtf',
|
||||
'.sh': 'application/x-sh',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.swf': 'application/x-shockwave-flash',
|
||||
'.tar': 'application/x-tar',
|
||||
'.tif': 'image/tiff',
|
||||
'.tiff': 'image/tiff',
|
||||
'.ts': 'video/mp2t',
|
||||
'.ttf': 'font/ttf',
|
||||
'.txt': 'text/plain',
|
||||
'.vsd': 'application/vnd.visio',
|
||||
'.wav': 'audio/wav',
|
||||
'.weba': 'audio/webm',
|
||||
'.webm': 'video/webm',
|
||||
'.webp': 'image/webp',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.xhtml': 'application/xhtml+xml',
|
||||
'.xls': 'application/vnd.ms-excel',
|
||||
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'.xml': 'application/xml',
|
||||
'.xul': 'application/vnd.mozilla.xul+xml',
|
||||
'.zip': 'application/zip',
|
||||
'.3gp': 'video/3gpp',
|
||||
'.3g2': 'video/3gpp2',
|
||||
'.7z': 'application/x-7z-compressed',
|
||||
};
|
||||
164
server/index.js
Normal file
164
server/index.js
Normal file
@@ -0,0 +1,164 @@
|
||||
const next = require('next').default;
|
||||
const { createServer } = require('http');
|
||||
const { mkdir } = require('fs/promises');
|
||||
const { extname } = require('path');
|
||||
const validateConfig = require('./validateConfig');
|
||||
const Logger = require('../src/lib/logger');
|
||||
const readConfig = require('../src/lib/readConfig');
|
||||
const mimes = require('../scripts/mimes');
|
||||
const { log, getStats, getFile, migrations } = require('./util');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { version } = require('../package.json');
|
||||
const exts = require('../scripts/exts');
|
||||
const serverLog = Logger.get('server');
|
||||
|
||||
serverLog.info(`starting zipline@${version} server`);
|
||||
|
||||
const dev = process.env.NODE_ENV === 'development';
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await run();
|
||||
} catch (e) {
|
||||
serverLog.error(e);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
|
||||
async function run() {
|
||||
const a = readConfig();
|
||||
const config = validateConfig(a);
|
||||
|
||||
process.env.DATABASE_URL = config.core.database_url;
|
||||
await migrations();
|
||||
|
||||
await mkdir(config.uploader.directory, { recursive: true });
|
||||
|
||||
const app = next({
|
||||
dir: '.',
|
||||
dev,
|
||||
quiet: !dev,
|
||||
hostname: config.core.host,
|
||||
port: config.core.port,
|
||||
});
|
||||
|
||||
await app.prepare();
|
||||
|
||||
const handle = app.getRequestHandler();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const srv = createServer(async (req, res) => {
|
||||
if (req.url.startsWith('/r')) {
|
||||
const parts = req.url.split('/');
|
||||
if (!parts[2] || parts[2] === '') return;
|
||||
|
||||
let image = await prisma.image.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ file: parts[2] },
|
||||
{ invisible:{ invis: decodeURI(parts[2]) } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
mimetype: true,
|
||||
id: true,
|
||||
file: true,
|
||||
invisible: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!image) {
|
||||
const data = await getFile(config.uploader.directory, parts[2]);
|
||||
if (!data) return app.render404(req, res);
|
||||
|
||||
const mimetype = mimes[extname(parts[2])] ?? 'application/octet-stream';
|
||||
res.setHeader('Content-Type', mimetype);
|
||||
res.end(data);
|
||||
} else {
|
||||
const data = await getFile(config.uploader.directory, image.file);
|
||||
if (!data) return app.render404(req, res);
|
||||
|
||||
await prisma.image.update({
|
||||
where: { id: image.id },
|
||||
data: { views: { increment: 1 } },
|
||||
});
|
||||
res.setHeader('Content-Type', image.mimetype);
|
||||
res.end(data);
|
||||
}
|
||||
} else if (req.url.startsWith(config.uploader.route)) {
|
||||
const parts = req.url.split('/');
|
||||
if (!parts[2] || parts[2] === '') return;
|
||||
|
||||
let image = await prisma.image.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ file: parts[2] },
|
||||
{ invisible:{ invis: decodeURI(parts[2]) } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
mimetype: true,
|
||||
id: true,
|
||||
file: true,
|
||||
invisible: true,
|
||||
embed: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!image) {
|
||||
const data = await getFile(config.uploader.directory, parts[2]);
|
||||
if (!data) return app.render404(req, res);
|
||||
|
||||
const mimetype = mimes[extname(parts[2])] ?? 'application/octet-stream';
|
||||
res.setHeader('Content-Type', mimetype);
|
||||
res.end(data);
|
||||
} else if (image.embed) {
|
||||
handle(req, res);
|
||||
} else {
|
||||
const ext = image.file.split('.').pop();
|
||||
if (Object.keys(exts).includes(ext)) return handle(req, res);
|
||||
const data = await getFile(config.uploader.directory, image.file);
|
||||
if (!data) return app.render404(req, res);
|
||||
|
||||
await prisma.image.update({
|
||||
where: { id: image.id },
|
||||
data: { views: { increment: 1 } },
|
||||
});
|
||||
res.setHeader('Content-Type', image.mimetype);
|
||||
res.end(data);
|
||||
}
|
||||
} else {
|
||||
handle(req, res);
|
||||
}
|
||||
|
||||
if (config.core.logger) log(req.url, res.statusCode);
|
||||
});
|
||||
|
||||
srv.on('error', (e) => {
|
||||
serverLog.error(e);
|
||||
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
srv.on('listening', () => {
|
||||
serverLog.info(`listening on ${config.core.host}:${config.core.port}`);
|
||||
});
|
||||
|
||||
srv.listen(config.core.port, config.core.host ?? '0.0.0.0');
|
||||
|
||||
const stats = await getStats(prisma, config);
|
||||
await prisma.stats.create({
|
||||
data: {
|
||||
data: stats,
|
||||
},
|
||||
});
|
||||
setInterval(async () => {
|
||||
const stats = await getStats(prisma, config);
|
||||
await prisma.stats.create({
|
||||
data: {
|
||||
data: stats,
|
||||
},
|
||||
});
|
||||
if (config.core.logger) serverLog.info('stats updated');
|
||||
}, config.core.stats_interval * 1000);
|
||||
}
|
||||
130
server/util.js
Normal file
130
server/util.js
Normal file
@@ -0,0 +1,130 @@
|
||||
const { readFile, readdir, stat } = require('fs/promises');
|
||||
const { join } = require('path');
|
||||
const { Migrate } = require('@prisma/migrate/dist/Migrate.js');
|
||||
const Logger = require('../src/lib/logger.js');
|
||||
|
||||
async function migrations() {
|
||||
const migrate = new Migrate('./prisma/schema.prisma');
|
||||
const diagnose = await migrate.diagnoseMigrationHistory({
|
||||
optInToShadowDatabase: false,
|
||||
});
|
||||
|
||||
if (diagnose.history?.diagnostic === 'databaseIsBehind') {
|
||||
Logger.get('database').info('migrating database');
|
||||
await migrate.applyMigrations();
|
||||
Logger.get('database').info('finished migrating database');
|
||||
}
|
||||
|
||||
migrate.stop();
|
||||
}
|
||||
|
||||
function log(url) {
|
||||
if (url.startsWith('/_next') || url.startsWith('/__nextjs')) return;
|
||||
return Logger.get('url').info(url);
|
||||
}
|
||||
|
||||
function shouldUseYarn() {
|
||||
try {
|
||||
execSync('yarnpkg --version', { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function getFile(dir, file) {
|
||||
try {
|
||||
const data = await readFile(join(process.cwd(), dir, file));
|
||||
return data;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function sizeOfDir(directory) {
|
||||
const files = await readdir(directory);
|
||||
|
||||
let size = 0;
|
||||
for (let i = 0, L = files.length; i !== L; ++i) {
|
||||
const sta = await stat(join(directory, files[i]));
|
||||
size += sta.size;
|
||||
}
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
function bytesToRead(bytes) {
|
||||
const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB'];
|
||||
let num = 0;
|
||||
|
||||
while (bytes > 1024) {
|
||||
bytes /= 1024;
|
||||
++num;
|
||||
}
|
||||
|
||||
return `${bytes.toFixed(1)} ${units[num]}`;
|
||||
}
|
||||
|
||||
|
||||
async function getStats(prisma, config) {
|
||||
const size = await sizeOfDir(join(process.cwd(), config.uploader.directory));
|
||||
const byUser = await prisma.image.groupBy({
|
||||
by: ['userId'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
});
|
||||
const count_users = await prisma.user.count();
|
||||
|
||||
const count_by_user = [];
|
||||
for (let i = 0, L = byUser.length; i !== L; ++i) {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: byUser[i].userId,
|
||||
},
|
||||
});
|
||||
|
||||
count_by_user.push({
|
||||
username: user.username,
|
||||
count: byUser[i]._count._all,
|
||||
});
|
||||
}
|
||||
|
||||
const count = await prisma.image.count();
|
||||
const viewsCount = await prisma.image.groupBy({
|
||||
by: ['views'],
|
||||
_sum: {
|
||||
views: true,
|
||||
},
|
||||
});
|
||||
|
||||
const typesCount = await prisma.image.groupBy({
|
||||
by: ['mimetype'],
|
||||
_count: {
|
||||
mimetype: true,
|
||||
},
|
||||
});
|
||||
const types_count = [];
|
||||
for (let i = 0, L = typesCount.length; i !== L; ++i) types_count.push({ mimetype: typesCount[i].mimetype, count: typesCount[i]._count.mimetype });
|
||||
|
||||
return {
|
||||
size: bytesToRead(size),
|
||||
size_num: size,
|
||||
count,
|
||||
count_by_user: count_by_user.sort((a,b) => b.count-a.count),
|
||||
count_users,
|
||||
views_count: (viewsCount[0]?._sum?.views ?? 0),
|
||||
types_count: types_count.sort((a,b) => b.count-a.count),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
migrations,
|
||||
bytesToRead,
|
||||
getFile,
|
||||
getStats,
|
||||
log,
|
||||
sizeOfDir,
|
||||
shouldUseYarn,
|
||||
};
|
||||
40
server/validateConfig.js
Normal file
40
server/validateConfig.js
Normal file
@@ -0,0 +1,40 @@
|
||||
const { object, bool, string, number, boolean, array } = require('yup');
|
||||
|
||||
const validator = object({
|
||||
core: object({
|
||||
secure: bool().default(false),
|
||||
secret: string().min(8).required(),
|
||||
host: string().default('0.0.0.0'),
|
||||
port: number().default(3000),
|
||||
database_url: string().required(),
|
||||
logger: boolean().default(false),
|
||||
stats_interval: number().default(1800),
|
||||
}).required(),
|
||||
uploader: object({
|
||||
route: string().default('/u'),
|
||||
embed_route: string().default('/a'),
|
||||
length: number().default(6),
|
||||
directory: string().default('./uploads'),
|
||||
admin_limit: number().default(104900000),
|
||||
user_limit: number().default(104900000),
|
||||
disabled_extensions: array().default([]),
|
||||
}).required(),
|
||||
urls: object({
|
||||
route: string().default('/go'),
|
||||
length: number().default(6),
|
||||
}).required(),
|
||||
ratelimit: object({
|
||||
user: number().default(0),
|
||||
admin: number().default(0),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
module.exports = function validate(config) {
|
||||
try {
|
||||
return validator.validateSync(config, { abortEarly: false });
|
||||
} catch (e) {
|
||||
if (process.env.ZIPLINE_DOCKER_BUILD) return {};
|
||||
throw `${e.errors.length} errors occured\n${e.errors.map(x => '\t' + x).join('\n')}`;
|
||||
}
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
import { Anchor } from '@mantine/core';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function AnchorNext({ href, ...others }) {
|
||||
return <Anchor component={Link} href={href} {...others} />;
|
||||
}
|
||||
8
src/components/Backdrop.tsx
Normal file
8
src/components/Backdrop.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
import { LoadingOverlay } from '@mantine/core';
|
||||
|
||||
export default function Backdrop({ open }) {
|
||||
return (
|
||||
<LoadingOverlay visible={open} />
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
import { Card as MCard, Title } from '@mantine/core';
|
||||
import React from 'react';
|
||||
import {
|
||||
Card as MCard,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
|
||||
export default function Card(props) {
|
||||
const { name, children, ...other } = props;
|
||||
|
||||
export default function Card({ name, children, ...other }) {
|
||||
return (
|
||||
<MCard p='md' shadow='sm' {...other}>
|
||||
{name && <Title order={2}>{name}</Title>}
|
||||
<MCard padding='md' shadow='sm' {...other}>
|
||||
<Title order={2}>{name}</Title>
|
||||
{children}
|
||||
</MCard>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { createStyles, Textarea } from '@mantine/core';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const useStyles = createStyles(() => ({
|
||||
input: {
|
||||
fontFamily: 'monospace',
|
||||
height: '80vh',
|
||||
},
|
||||
}));
|
||||
|
||||
export default function CodeInput({ ...props }) {
|
||||
const { classes } = useStyles(null, { name: 'CodeInput' });
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Tab') {
|
||||
if (document.activeElement?.tagName !== 'TEXTAREA') return;
|
||||
|
||||
e.preventDefault();
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
const start = target.selectionStart;
|
||||
const end = target.selectionEnd;
|
||||
target.value = `${target.value.substring(0, start)} ${target.value.substring(end)}`;
|
||||
target.selectionStart = target.selectionEnd = start + 2;
|
||||
target.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <Textarea classNames={{ input: classes.input }} {...props} />;
|
||||
}
|
||||
@@ -1,354 +0,0 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Group,
|
||||
LoadingOverlay,
|
||||
Modal,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { useClipboard, useMediaQuery } from '@mantine/hooks';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import {
|
||||
IconAlarm,
|
||||
IconCalendarPlus,
|
||||
IconClipboardCopy,
|
||||
IconDeviceSdCard,
|
||||
IconExternalLink,
|
||||
IconEye,
|
||||
IconEyeglass,
|
||||
IconFile,
|
||||
IconFileDownload,
|
||||
IconFolderCancel,
|
||||
IconFolderMinus,
|
||||
IconFolderPlus,
|
||||
IconHash,
|
||||
IconInfoCircle,
|
||||
IconPhoto,
|
||||
IconPhotoCancel,
|
||||
IconPhotoMinus,
|
||||
IconPhotoStar,
|
||||
} 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 { useState } from 'react';
|
||||
import { FileMeta } from '.';
|
||||
import Type from '../Type';
|
||||
|
||||
export default function FileModal({
|
||||
open,
|
||||
setOpen,
|
||||
file,
|
||||
loading,
|
||||
refresh,
|
||||
reducedActions = false,
|
||||
exifEnabled,
|
||||
compress,
|
||||
otherUser = false,
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
file: UserFilesResponse;
|
||||
loading: boolean;
|
||||
refresh: () => void;
|
||||
reducedActions?: boolean;
|
||||
exifEnabled?: boolean;
|
||||
compress: boolean;
|
||||
otherUser: boolean;
|
||||
}) {
|
||||
const deleteFile = useFileDelete();
|
||||
const favoriteFile = useFileFavorite();
|
||||
const folders = useFolders();
|
||||
|
||||
const [overrideRender, setOverrideRender] = useState(false);
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const handleDelete = async () => {
|
||||
deleteFile.mutate(file.id, {
|
||||
onSuccess: () => {
|
||||
showNotification({
|
||||
title: 'File Deleted',
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <IconPhotoMinus size='1rem' />,
|
||||
});
|
||||
},
|
||||
|
||||
onError: (res: ApiError) => {
|
||||
showNotification({
|
||||
title: 'Failed to delete file',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <IconPhotoCancel size='1rem' />,
|
||||
});
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}${file.url}`);
|
||||
setOpen(false);
|
||||
|
||||
showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: '',
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
});
|
||||
};
|
||||
|
||||
const handleFavorite = async () => {
|
||||
favoriteFile.mutate(
|
||||
{ id: file.id, favorite: !file.favorite },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showNotification({
|
||||
title: 'The file is now ' + (!file.favorite ? 'favorited' : 'unfavorited'),
|
||||
message: '',
|
||||
icon: <IconPhotoStar size='1rem' />,
|
||||
});
|
||||
},
|
||||
|
||||
onError: (res: { error: string }) => {
|
||||
showNotification({
|
||||
title: 'Failed to favorite file',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <IconPhotoCancel size='1rem' />,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const inFolder = file.folderId;
|
||||
|
||||
const removeFromFolder = async () => {
|
||||
const res = await useFetch('/api/user/folders/' + file.folderId, 'DELETE', {
|
||||
file: Number(file.id),
|
||||
});
|
||||
|
||||
refresh();
|
||||
|
||||
if (!res.error) {
|
||||
showNotification({
|
||||
title: 'Removed from folder',
|
||||
message: res.name,
|
||||
color: 'green',
|
||||
icon: <IconFolderMinus size='1rem' />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Failed to remove from folder',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <IconFolderCancel size='1rem' />,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addToFolder = async (t) => {
|
||||
const res = await useFetch('/api/user/folders/' + t, 'POST', {
|
||||
file: Number(file.id),
|
||||
});
|
||||
|
||||
refresh();
|
||||
|
||||
if (!res.error) {
|
||||
showNotification({
|
||||
title: 'Added to folder',
|
||||
message: res.name,
|
||||
color: 'green',
|
||||
icon: <IconFolderPlus size='1rem' />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Failed to add to folder',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <IconFolderCancel size='1rem' />,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const createFolder = (t) => {
|
||||
useFetch('/api/user/folders', 'POST', {
|
||||
name: t,
|
||||
add: [Number(file.id)],
|
||||
}).then((res) => {
|
||||
refresh();
|
||||
|
||||
if (!res.error) {
|
||||
showNotification({
|
||||
title: 'Created & added to folder',
|
||||
message: res.name,
|
||||
color: 'green',
|
||||
icon: <IconFolderPlus size='1rem' />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Failed to create folder',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <IconFolderCancel size='1rem' />,
|
||||
});
|
||||
}
|
||||
});
|
||||
return { value: t, label: t };
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title={<Title>{file.name}</Title>}
|
||||
size='auto'
|
||||
fullScreen={useMediaQuery('(max-width: 600px)')}
|
||||
>
|
||||
<LoadingOverlay visible={loading} />
|
||||
<Stack>
|
||||
<Type
|
||||
file={file}
|
||||
src={`/r/${encodeURI(file.name)}?compress=${compress}`}
|
||||
alt={file.name}
|
||||
popup
|
||||
sx={{ minHeight: 200 }}
|
||||
style={{ minHeight: 200 }}
|
||||
disableMediaPreview={false}
|
||||
overrideRender={overrideRender}
|
||||
setOverrideRender={setOverrideRender}
|
||||
/>
|
||||
<SimpleGrid
|
||||
my='md'
|
||||
cols={3}
|
||||
breakpoints={[
|
||||
{ maxWidth: 600, cols: 1 },
|
||||
{ maxWidth: 900, cols: 2 },
|
||||
{ maxWidth: 1200, cols: 3 },
|
||||
]}
|
||||
>
|
||||
<FileMeta Icon={IconFile} title='Name' subtitle={file.name} />
|
||||
<FileMeta Icon={IconPhoto} title='Type' subtitle={file.mimetype} />
|
||||
<FileMeta Icon={IconDeviceSdCard} title='Size' subtitle={bytesToHuman(file.size || 0)} />
|
||||
<FileMeta Icon={IconEye} title='Views' subtitle={file?.views?.toLocaleString()} />
|
||||
{file.maxViews && (
|
||||
<FileMeta
|
||||
Icon={IconEyeglass}
|
||||
title='Max views'
|
||||
subtitle={file?.maxViews?.toLocaleString()}
|
||||
tooltip={`This file will be deleted after being viewed ${file?.maxViews?.toLocaleString()} times.`}
|
||||
/>
|
||||
)}
|
||||
<FileMeta
|
||||
Icon={IconCalendarPlus}
|
||||
title='Uploaded'
|
||||
subtitle={relativeTime(new Date(file.createdAt))}
|
||||
tooltip={new Date(file?.createdAt).toLocaleString()}
|
||||
/>
|
||||
{file.expiresAt && !reducedActions && (
|
||||
<FileMeta
|
||||
Icon={IconAlarm}
|
||||
title='Expires'
|
||||
subtitle={relativeTime(new Date(file.expiresAt))}
|
||||
tooltip={new Date(file.expiresAt).toLocaleString()}
|
||||
/>
|
||||
)}
|
||||
<FileMeta Icon={IconHash} title='ID' subtitle={file.id} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
<Group position='apart' my='md'>
|
||||
<Group position='left'>
|
||||
{exifEnabled && !reducedActions && (
|
||||
<Tooltip label='View Metadata'>
|
||||
<ActionIcon
|
||||
color='blue'
|
||||
variant='filled'
|
||||
onClick={() => window.open(`/dashboard/metadata/${file.id}`, '_blank')}
|
||||
>
|
||||
<IconInfoCircle size='1rem' />
|
||||
</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 : (
|
||||
<>
|
||||
<Tooltip label='Delete file'>
|
||||
<ActionIcon color='red' variant='filled' onClick={handleDelete}>
|
||||
<IconPhotoMinus size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label={file.favorite ? 'Unfavorite' : 'Favorite'}>
|
||||
<ActionIcon
|
||||
color={file.favorite ? 'yellow' : 'gray'}
|
||||
variant='filled'
|
||||
onClick={handleFavorite}
|
||||
>
|
||||
<IconPhotoStar size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Tooltip label='Open in new tab'>
|
||||
<ActionIcon color='blue' variant='filled' onClick={() => window.open(file.url, '_blank')}>
|
||||
<IconExternalLink size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label='Copy URL'>
|
||||
<ActionIcon color='blue' variant='filled' onClick={handleCopy}>
|
||||
<IconClipboardCopy size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label='Download'>
|
||||
<ActionIcon
|
||||
color='blue'
|
||||
variant='filled'
|
||||
onClick={() => window.open(`/r/${encodeURI(file.name)}?download=true`, '_blank')}
|
||||
>
|
||||
<IconFileDownload size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { Card, Group, LoadingOverlay, Stack, Text, Tooltip } from '@mantine/core';
|
||||
import { useFileDelete, useFileFavorite } from 'lib/queries/files';
|
||||
import { useFolders } from 'lib/queries/folders';
|
||||
import { useState } from 'react';
|
||||
import MutedText from '../MutedText';
|
||||
import Type from '../Type';
|
||||
import FileModal from './FileModal';
|
||||
|
||||
export function FileMeta({ Icon, title, subtitle, ...other }) {
|
||||
return other.tooltip ? (
|
||||
<Group>
|
||||
<Icon size={24} />
|
||||
<Tooltip label={other.tooltip}>
|
||||
<Stack spacing={1}>
|
||||
<Text>{title}</Text>
|
||||
<MutedText size='md'>{subtitle}</MutedText>
|
||||
</Stack>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
) : (
|
||||
<Group>
|
||||
<Icon size={24} />
|
||||
<Stack spacing={1}>
|
||||
<Text>{title}</Text>
|
||||
<MutedText size='md'>{subtitle}</MutedText>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default function File({
|
||||
image,
|
||||
disableMediaPreview,
|
||||
exifEnabled,
|
||||
refreshImages = undefined,
|
||||
reducedActions = false,
|
||||
onDash,
|
||||
otherUser = false,
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const deleteFile = useFileDelete();
|
||||
const favoriteFile = useFileFavorite();
|
||||
const loading = deleteFile.isLoading || favoriteFile.isLoading;
|
||||
|
||||
const folders = useFolders();
|
||||
|
||||
const refresh = () => {
|
||||
if (!otherUser) refreshImages();
|
||||
folders.refetch();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FileModal
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
file={image}
|
||||
loading={loading}
|
||||
refresh={refresh}
|
||||
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.Section>
|
||||
<LoadingOverlay visible={loading} />
|
||||
<Type
|
||||
file={image}
|
||||
sx={{
|
||||
minHeight: 200,
|
||||
maxHeight: 320,
|
||||
fontSize: 70,
|
||||
width: '100%',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
style={{
|
||||
minHeight: 200,
|
||||
maxHeight: 320,
|
||||
fontSize: 70,
|
||||
width: '100%',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
src={`/r/${encodeURI(image.name)}?compress=${onDash}`}
|
||||
alt={image.name}
|
||||
disableMediaPreview={disableMediaPreview}
|
||||
/>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
94
src/components/Image.tsx
Normal file
94
src/components/Image.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import React, { useState } from 'react';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { Button, Card, Group, Image as MImage, Modal, Title } from '@mantine/core';
|
||||
import { useNotifications } from '@mantine/notifications';
|
||||
import { CopyIcon, Cross1Icon, StarIcon, TrashIcon } from '@modulz/radix-icons';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
|
||||
export default function Image({ image, updateImages }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [t] = useState(image.mimetype.split('/')[0]);
|
||||
const notif = useNotifications();
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const handleDelete = async () => {
|
||||
const res = await useFetch('/api/user/files', 'DELETE', { id: image.id });
|
||||
if (!res.error) {
|
||||
updateImages(true);
|
||||
notif.showNotification({
|
||||
title: 'Image Deleted',
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <TrashIcon />,
|
||||
});
|
||||
} else {
|
||||
notif.showNotification({
|
||||
title: 'Failed to delete image',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <Cross1Icon />,
|
||||
});
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
clipboard.copy(`${window.location.protocol}//${window.location.host}${image.url}`);
|
||||
setOpen(false);
|
||||
notif.showNotification({
|
||||
title: 'Copied to clipboard',
|
||||
message: '',
|
||||
icon: <CopyIcon />,
|
||||
});
|
||||
};
|
||||
|
||||
const handleFavorite = async () => {
|
||||
const data = await useFetch('/api/user/files', 'PATCH', { id: image.id, favorite: !image.favorite });
|
||||
if (!data.error) updateImages(true);
|
||||
notif.showNotification({
|
||||
title: 'Image is now ' + (!image.favorite ? 'favorited' : 'unfavorited'),
|
||||
message: '',
|
||||
icon: <StarIcon />,
|
||||
});
|
||||
};
|
||||
|
||||
const Type = (props) => {
|
||||
return {
|
||||
'video': <video controls {...props} />,
|
||||
'image': <MImage {...props} />,
|
||||
'audio': <audio controls {...props} />,
|
||||
}[t];
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title={<Title>{image.file}</Title>}
|
||||
>
|
||||
<Type
|
||||
src={image.url}
|
||||
alt={image.file}
|
||||
/>
|
||||
<Group position='right' mt={22}>
|
||||
<Button onClick={handleCopy}>Copy</Button>
|
||||
<Button onClick={handleDelete}>Delete</Button>
|
||||
<Button onClick={handleFavorite}>{image.favorite ? 'Unfavorite' : 'Favorite'}</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
<Card sx={{ maxWidth: '100%', height: '100%' }} shadow='md'>
|
||||
<Card.Section>
|
||||
<Type
|
||||
sx={{ maxHeight: 320, fontSize: 70, width: '100%', cursor: 'pointer' }}
|
||||
style={{ maxHeight: 320, fontSize: 70, width: '100%', cursor: 'pointer' }}
|
||||
src={image.url}
|
||||
alt={image.file}
|
||||
onClick={() => setOpen(true)}
|
||||
/>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
159
src/components/ImagesTable.tsx
Normal file
159
src/components/ImagesTable.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
/* eslint-disable react/jsx-key */
|
||||
/* eslint-disable react/display-name */
|
||||
// Code taken from https://codesandbox.io/s/eojw8 and is modified a bit (the entire src/components/table directory)
|
||||
import React from 'react';
|
||||
import {
|
||||
usePagination,
|
||||
useTable,
|
||||
} from 'react-table';
|
||||
import {
|
||||
ActionIcon,
|
||||
Checkbox,
|
||||
createStyles,
|
||||
Divider,
|
||||
Group,
|
||||
Pagination,
|
||||
Select,
|
||||
Table,
|
||||
Text,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
CopyIcon,
|
||||
EnterIcon,
|
||||
TrashIcon,
|
||||
} from '@modulz/radix-icons';
|
||||
|
||||
const pageSizeOptions = ['10', '25', '50'];
|
||||
|
||||
const useStyles = createStyles((t) => ({
|
||||
root: { height: '100%', display: 'block', marginTop: 10 },
|
||||
tableContainer: {
|
||||
display: 'block',
|
||||
overflow: 'auto',
|
||||
'& > table': {
|
||||
'& > thead': { backgroundColor: t.colorScheme === 'dark' ? t.colors.dark[6] : t.colors.gray[0], zIndex: 1 },
|
||||
'& > thead > tr > th': { padding: t.spacing.md },
|
||||
'& > tbody > tr > td': { padding: t.spacing.md },
|
||||
},
|
||||
borderRadius: 6,
|
||||
},
|
||||
stickHeader: { top: 0, position: 'sticky' },
|
||||
disableSortIcon: { color: t.colors.gray[5] },
|
||||
sortDirectionIcon: { transition: 'transform 200ms ease' },
|
||||
}));
|
||||
|
||||
export default function ImagesTable({
|
||||
columns,
|
||||
data = [],
|
||||
serverSideDataSource = false,
|
||||
initialPageSize = 10,
|
||||
initialPageIndex = 0,
|
||||
pageCount = 0,
|
||||
total = 0,
|
||||
deleteImage, copyImage, viewImage,
|
||||
}) {
|
||||
const { classes } = useStyles();
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const tableOptions = useTable(
|
||||
{
|
||||
data,
|
||||
columns,
|
||||
pageCount,
|
||||
initialState: { pageSize: initialPageSize, pageIndex: initialPageIndex },
|
||||
},
|
||||
usePagination
|
||||
);
|
||||
|
||||
const {
|
||||
getTableProps, getTableBodyProps, headerGroups, rows, prepareRow, page, gotoPage, setPageSize, state: { pageIndex, pageSize },
|
||||
} = tableOptions;
|
||||
|
||||
const getPageRecordInfo = () => {
|
||||
const firstRowNum = pageIndex * pageSize + 1;
|
||||
const totalRows = serverSideDataSource ? total : rows.length;
|
||||
|
||||
const currLastRowNum = (pageIndex + 1) * pageSize;
|
||||
let lastRowNum = currLastRowNum < totalRows ? currLastRowNum : totalRows;
|
||||
return `${firstRowNum} - ${lastRowNum} of ${totalRows}`;
|
||||
};
|
||||
|
||||
const getPageCount = () => {
|
||||
const totalRows = serverSideDataSource ? total : rows.length;
|
||||
return Math.ceil(totalRows / pageSize);
|
||||
};
|
||||
|
||||
const handlePageChange = (pageNum) => gotoPage(pageNum - 1);
|
||||
|
||||
const renderHeader = () => headerGroups.map(hg => (
|
||||
<tr {...hg.getHeaderGroupProps()}>
|
||||
{hg.headers.map(column => (
|
||||
<th {...column.getHeaderProps()}>
|
||||
<Group noWrap position={column.align || 'apart'}>
|
||||
<div>{column.render('Header')}</div>
|
||||
</Group>
|
||||
</th>
|
||||
))}
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
));
|
||||
|
||||
const renderRow = rows => rows.map(row => {
|
||||
prepareRow(row);
|
||||
return (
|
||||
<tr {...row.getRowProps()}>
|
||||
{row.cells.map(cell => (
|
||||
<td align={cell.column.align || 'left'} {...cell.getCellProps()}>
|
||||
{cell.render('Cell')}
|
||||
</td>
|
||||
))}
|
||||
<td align='right'>
|
||||
<Group noWrap>
|
||||
<ActionIcon color='red' variant='outline' onClick={() => deleteImage(row)}><TrashIcon /></ActionIcon>
|
||||
<ActionIcon color='primary' variant='outline' onClick={() => copyImage(row)}><CopyIcon /></ActionIcon>
|
||||
<ActionIcon color='green' variant='outline' onClick={() => viewImage(row)}><EnterIcon /></ActionIcon>
|
||||
</Group>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<div
|
||||
className={classes.tableContainer}
|
||||
style={{ height: 'calc(100% - 44px)' }}
|
||||
>
|
||||
<Table {...getTableProps()}>
|
||||
<thead style={{ backgroundColor: theme.other.hover }}>
|
||||
{renderHeader()}
|
||||
</thead>
|
||||
|
||||
<tbody {...getTableBodyProps()}>
|
||||
{renderRow(page)}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
<Divider mb='md' variant='dotted' />
|
||||
<Group position='left'>
|
||||
<Text size='sm'>Rows per page: </Text>
|
||||
<Select
|
||||
style={{ width: '72px' }}
|
||||
variant='filled'
|
||||
data={pageSizeOptions}
|
||||
value={pageSize + ''}
|
||||
onChange={pageSize => setPageSize(Number(pageSize))} />
|
||||
<Divider orientation='vertical' />
|
||||
|
||||
<Text size='sm'>{getPageRecordInfo()}</Text>
|
||||
<Divider orientation='vertical' />
|
||||
|
||||
<Pagination
|
||||
page={pageIndex + 1}
|
||||
total={getPageCount()}
|
||||
onChange={handlePageChange} />
|
||||
</Group>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,330 +1,251 @@
|
||||
import {
|
||||
AppShell,
|
||||
Badge,
|
||||
Box,
|
||||
Burger,
|
||||
Button,
|
||||
Header,
|
||||
Image,
|
||||
MediaQuery,
|
||||
Menu,
|
||||
Navbar,
|
||||
NavLink,
|
||||
Paper,
|
||||
rem,
|
||||
ScrollArea,
|
||||
Select,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { useClipboard, useMediaQuery } from '@mantine/hooks';
|
||||
import { useModals } from '@mantine/modals';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import {
|
||||
IconBackspace,
|
||||
IconBrandDiscordFilled,
|
||||
IconBrandGithubFilled,
|
||||
IconBrandGoogle,
|
||||
IconBrush,
|
||||
IconClipboardCopy,
|
||||
IconExternalLink,
|
||||
IconFiles,
|
||||
IconFileText,
|
||||
IconFileUpload,
|
||||
IconFolders,
|
||||
IconGraph,
|
||||
IconHome,
|
||||
IconLink,
|
||||
IconLogout,
|
||||
IconReload,
|
||||
IconSettings,
|
||||
IconTag,
|
||||
IconUpload,
|
||||
IconUser,
|
||||
IconUserCog,
|
||||
IconUsers,
|
||||
} from '@tabler/icons-react';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useVersion } from 'lib/queries/version';
|
||||
import { userSelector } from 'lib/recoil/user';
|
||||
import { capitalize } from 'lib/utils/client';
|
||||
import { UserExtended } from 'middleware/withZipline';
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { useStoreDispatch } from 'lib/redux/store';
|
||||
import { updateUser } from 'lib/redux/reducers/user';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { CheckIcon, CopyIcon, Cross1Icon, FileIcon, GearIcon, HomeIcon, Link1Icon, ResetIcon, UploadIcon, PinRightIcon, PersonIcon, Pencil1Icon, MixerHorizontalIcon } from '@modulz/radix-icons';
|
||||
import { AppShell, Burger, Divider, Group, Header, MediaQuery, Navbar, Paper, Popover, ScrollArea, Select, Text, ThemeIcon, Title, UnstyledButton, useMantineTheme, Box } from '@mantine/core';
|
||||
import { useModals } from '@mantine/modals';
|
||||
import { useNotifications } from '@mantine/notifications';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { friendlyThemeName, themes } from './Theming';
|
||||
|
||||
export type NavbarItems = {
|
||||
icon: React.ReactNode;
|
||||
text: string;
|
||||
link?: string;
|
||||
children?: NavbarItems[];
|
||||
if?: (user: UserExtended, props: unknown) => boolean;
|
||||
};
|
||||
function MenuItemLink(props) {
|
||||
return (
|
||||
<Link href={props.href} passHref>
|
||||
<MenuItem {...props} />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const items: NavbarItems[] = [
|
||||
function MenuItem(props) {
|
||||
return (
|
||||
<UnstyledButton
|
||||
sx={theme => ({
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: 5,
|
||||
borderRadius: theme.radius.sm,
|
||||
color: props.color
|
||||
? theme.fn.themeColor(props.color, theme.colorScheme === 'dark' ? 5 : 7)
|
||||
: theme.colorScheme === 'dark'
|
||||
? theme.colors.dark[0]
|
||||
: theme.black,
|
||||
'&:hover': {
|
||||
backgroundColor: props.color
|
||||
? theme.fn.rgba(
|
||||
theme.fn.themeColor(props.color, theme.colorScheme === 'dark' ? 9 : 0),
|
||||
theme.colorScheme === 'dark' ? 0.2 : 1
|
||||
)
|
||||
: theme.colorScheme === 'dark'
|
||||
? theme.fn.rgba(theme.colors.dark[3], 0.35)
|
||||
: theme.colors.gray[0],
|
||||
},
|
||||
})}
|
||||
{...props}
|
||||
>
|
||||
<Group noWrap>
|
||||
<Box sx={theme => ({
|
||||
marginRight: theme.spacing.xs / 4,
|
||||
paddingLeft: theme.spacing.xs / 2,
|
||||
|
||||
'& *': {
|
||||
display: 'block',
|
||||
},
|
||||
})}>
|
||||
{props.icon}
|
||||
</Box>
|
||||
<Text size='sm'>{props.children}</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
|
||||
const items = [
|
||||
{
|
||||
icon: <IconHome size={18} />,
|
||||
icon: <HomeIcon />,
|
||||
text: 'Home',
|
||||
link: '/dashboard',
|
||||
},
|
||||
{
|
||||
icon: <IconFiles size={18} />,
|
||||
icon: <FileIcon />,
|
||||
text: 'Files',
|
||||
link: '/dashboard/files',
|
||||
},
|
||||
{
|
||||
icon: <IconFolders size={18} />,
|
||||
text: 'Folders',
|
||||
link: '/dashboard/folders',
|
||||
},
|
||||
{
|
||||
icon: <IconGraph size={18} />,
|
||||
icon: <MixerHorizontalIcon />,
|
||||
text: 'Stats',
|
||||
link: '/dashboard/stats',
|
||||
},
|
||||
{
|
||||
icon: <IconLink size={18} />,
|
||||
icon: <Link1Icon />,
|
||||
text: 'URLs',
|
||||
link: '/dashboard/urls',
|
||||
},
|
||||
{
|
||||
icon: <IconUpload size={18} />,
|
||||
icon: <UploadIcon />,
|
||||
text: 'Upload',
|
||||
children: [
|
||||
{
|
||||
icon: <IconFileUpload size={18} />,
|
||||
text: 'File',
|
||||
link: '/dashboard/upload/file',
|
||||
},
|
||||
{
|
||||
icon: <IconFileText size={18} />,
|
||||
text: 'Text',
|
||||
link: '/dashboard/upload/text',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <IconUser size={18} />,
|
||||
text: 'Administration',
|
||||
if: (user, _) => user.administrator as boolean,
|
||||
children: [
|
||||
{
|
||||
icon: <IconUsers size={18} />,
|
||||
text: 'Users',
|
||||
link: '/dashboard/users',
|
||||
if: () => true,
|
||||
},
|
||||
{
|
||||
icon: <IconTag size={18} />,
|
||||
text: 'Invites',
|
||||
link: '/dashboard/invites',
|
||||
if: (_, props: { invites: boolean }) => props.invites,
|
||||
},
|
||||
],
|
||||
link: '/dashboard/upload',
|
||||
},
|
||||
];
|
||||
|
||||
export default function Layout({ children, props }) {
|
||||
const [user, setUser] = useRecoilState(userSelector);
|
||||
|
||||
const { title, oauth_providers: unparsed } = props;
|
||||
const oauth_providers = JSON.parse(unparsed);
|
||||
const icons = {
|
||||
GitHub: IconBrandGithubFilled,
|
||||
Discord: IconBrandDiscordFilled,
|
||||
Google: IconBrandGoogle,
|
||||
};
|
||||
|
||||
for (const provider of oauth_providers) {
|
||||
provider.Icon = icons[provider.name];
|
||||
}
|
||||
|
||||
const external_links = JSON.parse(props.external_links ?? '[]');
|
||||
|
||||
export default function Layout({ children, user }) {
|
||||
const [token, setToken] = useState(user?.token);
|
||||
const [systemTheme, setSystemTheme] = useState(user.systemTheme ?? 'system');
|
||||
const version = useVersion();
|
||||
const [opened, setOpened] = useState(false); // navigation open
|
||||
|
||||
const avatar = user?.avatar ?? null;
|
||||
const [open, setOpen] = useState(false); // manage acc dropdown
|
||||
const router = useRouter();
|
||||
const dispatch = useStoreDispatch();
|
||||
const theme = useMantineTheme();
|
||||
const modals = useModals();
|
||||
const notif = useNotifications();
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const handleUpdateTheme = async (value) => {
|
||||
const handleUpdateTheme = async value => {
|
||||
const newUser = await useFetch('/api/user', 'PATCH', {
|
||||
systemTheme: value || 'dark_blue',
|
||||
});
|
||||
|
||||
setSystemTheme(newUser.systemTheme);
|
||||
setUser(newUser);
|
||||
dispatch(updateUser(newUser));
|
||||
router.replace(router.pathname);
|
||||
|
||||
showNotification({
|
||||
notif.showNotification({
|
||||
title: `Theme changed to ${friendlyThemeName[value]}`,
|
||||
message: '',
|
||||
color: 'green',
|
||||
icon: <IconBrush size='1rem' />,
|
||||
icon: <Pencil1Icon />,
|
||||
});
|
||||
};
|
||||
|
||||
const openResetToken = () =>
|
||||
modals.openConfirmModal({
|
||||
title: <Title>Reset Token?</Title>,
|
||||
children: (
|
||||
<Text size='sm'>
|
||||
Once you reset your token, you will have to update any uploaders to use this new token.
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: 'Reset', cancel: 'Cancel' },
|
||||
onConfirm: async () => {
|
||||
const a = await useFetch('/api/user/token', 'PATCH');
|
||||
if (!a.success) {
|
||||
setToken(a.success);
|
||||
showNotification({
|
||||
title: 'Token Reset Failed',
|
||||
message: a.error,
|
||||
color: 'red',
|
||||
icon: <IconReload size='1rem' />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Token Reset',
|
||||
message:
|
||||
'Your token has been reset. You will need to update any uploaders to use this new token.',
|
||||
color: 'green',
|
||||
icon: <IconReload size='1rem' />,
|
||||
});
|
||||
}
|
||||
const openResetToken = () => modals.openConfirmModal({
|
||||
title: 'Reset Token',
|
||||
children: (
|
||||
<Text size='sm'>
|
||||
Once you reset your token, you will have to update any uploaders to use this new token.
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: 'Reset', cancel: 'Cancel' },
|
||||
onConfirm: async () => {
|
||||
const a = await useFetch('/api/user/token', 'PATCH');
|
||||
if (!a.success) {
|
||||
setToken(a.success);
|
||||
notif.showNotification({
|
||||
title: 'Token Reset Failed',
|
||||
message: a.error,
|
||||
color: 'red',
|
||||
icon: <Cross1Icon />,
|
||||
});
|
||||
} else {
|
||||
notif.showNotification({
|
||||
title: 'Token Reset',
|
||||
message: 'Your token has been reset. You will need to update any uploaders to use this new token.',
|
||||
color: 'green',
|
||||
icon: <CheckIcon />,
|
||||
});
|
||||
}
|
||||
|
||||
modals.closeAll();
|
||||
},
|
||||
});
|
||||
modals.closeAll();
|
||||
},
|
||||
});
|
||||
|
||||
const openCopyToken = () =>
|
||||
modals.openConfirmModal({
|
||||
title: <Title>Copy Token</Title>,
|
||||
children: (
|
||||
<Text size='sm'>
|
||||
Make sure you don't share this token with anyone as they will be able to upload files on your
|
||||
behalf.
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: 'Copy', cancel: 'Cancel' },
|
||||
onConfirm: async () => {
|
||||
clipboard.copy(token);
|
||||
const openCopyToken = () => modals.openConfirmModal({
|
||||
title: 'Copy Token',
|
||||
children: (
|
||||
<Text size='sm'>
|
||||
Make sure you don't share this token with anyone as they will be able to upload files on your behalf.
|
||||
</Text>
|
||||
),
|
||||
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.",
|
||||
color: 'red',
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
});
|
||||
else
|
||||
showNotification({
|
||||
title: 'Token Copied',
|
||||
message: 'Your token has been copied to your clipboard.',
|
||||
color: 'green',
|
||||
icon: <IconClipboardCopy size='1rem' />,
|
||||
});
|
||||
notif.showNotification({
|
||||
title: 'Token Copied',
|
||||
message: 'Your token has been copied to your clipboard.',
|
||||
color: 'green',
|
||||
icon: <CheckIcon />,
|
||||
});
|
||||
|
||||
modals.closeAll();
|
||||
},
|
||||
});
|
||||
modals.closeAll();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
navbarOffsetBreakpoint='sm'
|
||||
fixed
|
||||
navbar={
|
||||
<Navbar pt='sm' hiddenBreakpoint='sm' hidden={!opened} width={{ sm: 200, lg: 230 }}>
|
||||
<Navbar.Section grow component={ScrollArea}>
|
||||
{items
|
||||
.filter((x) => (x.if ? x.if(user, props) : true))
|
||||
.map(({ icon, text, link, children }) =>
|
||||
children ? (
|
||||
<NavLink
|
||||
key={text}
|
||||
label={text}
|
||||
icon={icon}
|
||||
defaultOpened={children.map((x) => x.link).includes(router.pathname)}
|
||||
>
|
||||
{children
|
||||
.filter((x) => (x.if ? x.if(user, props) : true))
|
||||
.map(({ icon, text, link }) => (
|
||||
<NavLink
|
||||
key={text}
|
||||
label={text}
|
||||
icon={icon}
|
||||
active={router.pathname === link}
|
||||
variant='light'
|
||||
component={Link}
|
||||
href={link}
|
||||
/>
|
||||
))}
|
||||
</NavLink>
|
||||
) : (
|
||||
<NavLink
|
||||
key={text}
|
||||
label={text}
|
||||
icon={icon}
|
||||
active={router.pathname === link}
|
||||
variant='light'
|
||||
component={Link}
|
||||
href={link}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</Navbar.Section>
|
||||
<Navbar.Section>
|
||||
{external_links.length
|
||||
? external_links.map(({ label, link }, i: number) => (
|
||||
<NavLink
|
||||
key={i}
|
||||
label={label}
|
||||
target='_blank'
|
||||
variant='light'
|
||||
icon={<IconExternalLink size={18} />}
|
||||
component={Link}
|
||||
href={link}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</Navbar.Section>
|
||||
{version.isSuccess ? (
|
||||
<Navbar.Section>
|
||||
<Tooltip
|
||||
label={
|
||||
version.data.update
|
||||
? `There is a new ${version.data.updateToType} version: ${
|
||||
version.data.versions[version.data.updateToType]
|
||||
}`
|
||||
: `You are running the latest ${version.data.isUpstream ? 'upstream' : 'stable'} version`
|
||||
}
|
||||
>
|
||||
<Badge
|
||||
m='md'
|
||||
radius='md'
|
||||
size='lg'
|
||||
variant='dot'
|
||||
color={version.data.update ? 'red' : 'primary'}
|
||||
<Navbar
|
||||
padding='md'
|
||||
hiddenBreakpoint='sm'
|
||||
hidden={!opened}
|
||||
width={{ sm: 200, lg: 230 }}
|
||||
>
|
||||
<Navbar.Section
|
||||
grow
|
||||
component={ScrollArea}
|
||||
ml={-10}
|
||||
mr={-10}
|
||||
sx={{ paddingLeft: 10, paddingRight: 10 }}
|
||||
>
|
||||
{items.map(({ icon, text, link }) => (
|
||||
<Link href={link} key={text} passHref>
|
||||
<UnstyledButton
|
||||
sx={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: theme.spacing.xs,
|
||||
borderRadius: theme.radius.sm,
|
||||
color: theme.colorScheme === 'dark' ? theme.colors.dark[0] : theme.black,
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: theme.other.hover,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{version.data.versions.current}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</Navbar.Section>
|
||||
) : null}
|
||||
<Group>
|
||||
<ThemeIcon color='primary' variant='filled'>
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
|
||||
<Text size='lg'>{text}</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Link>
|
||||
))}
|
||||
{user.administrator && (
|
||||
<Link href='/dashboard/users' passHref>
|
||||
<UnstyledButton
|
||||
sx={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: theme.spacing.xs,
|
||||
borderRadius: theme.radius.sm,
|
||||
color: theme.colorScheme === 'dark' ? theme.colors.dark[0] : theme.black,
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: theme.other.hover,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Group>
|
||||
<ThemeIcon color='primary' variant='filled'>
|
||||
<PersonIcon />
|
||||
</ThemeIcon>
|
||||
|
||||
<Text size='lg'>Users</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Link>
|
||||
)}
|
||||
</Navbar.Section>
|
||||
</Navbar>
|
||||
}
|
||||
header={
|
||||
<Header height={70} p='md'>
|
||||
<Header height={70} padding='md'>
|
||||
<div style={{ display: 'flex', alignItems: 'center', height: '100%' }}>
|
||||
<MediaQuery largerThan='sm' styles={{ display: 'none' }}>
|
||||
<Burger
|
||||
@@ -334,157 +255,75 @@ export default function Layout({ children, props }) {
|
||||
color={theme.colors.gray[6]}
|
||||
/>
|
||||
</MediaQuery>
|
||||
<Title ml='sm'>{title}</Title>
|
||||
<Title sx={{ marginLeft: 12 }}>Zipline</Title>
|
||||
<Box sx={{ marginLeft: 'auto', marginRight: 0 }}>
|
||||
<Menu
|
||||
styles={{
|
||||
item: {
|
||||
'@media (max-width: 768px)': {
|
||||
padding: '1rem',
|
||||
width: '80vw',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
leftIcon={
|
||||
avatar ? (
|
||||
<Image src={avatar} height={32} width={32} fit='cover' radius='md' />
|
||||
) : (
|
||||
<IconUserCog size='1rem' />
|
||||
)
|
||||
}
|
||||
variant='subtle'
|
||||
color={theme.colorScheme === 'dark' ? 'dark' : 'gray'}
|
||||
compact
|
||||
size='xl'
|
||||
p='sm'
|
||||
styles={{
|
||||
label: {
|
||||
overflow: 'unset',
|
||||
<Popover
|
||||
position='top'
|
||||
placement='end'
|
||||
spacing={4}
|
||||
opened={open}
|
||||
onClose={() => setOpen(false)}
|
||||
target={
|
||||
<UnstyledButton
|
||||
onClick={() => setOpen(!open)}
|
||||
sx={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: theme.spacing.xs,
|
||||
borderRadius: theme.radius.sm,
|
||||
color: theme.other.color,
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: theme.other.hover,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{user.username}
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>
|
||||
{user.username} ({user.id}){' '}
|
||||
{user.administrator && user.username !== 'administrator' ? '(Administrator)' : ''}
|
||||
</Menu.Label>
|
||||
<Menu.Item component={Link} icon={<IconFiles size='1rem' />} href='/dashboard/files'>
|
||||
Files
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
icon={<IconFileUpload size='1rem' />}
|
||||
href='/dashboard/upload/file'
|
||||
>
|
||||
Upload File
|
||||
</Menu.Item>
|
||||
<Menu.Item component={Link} icon={<IconLink size='1rem' />} href='/dashboard/urls'>
|
||||
Shorten URL
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Label>Settings</Menu.Label>
|
||||
<Menu.Item component={Link} icon={<IconSettings size='1rem' />} href='/dashboard/manage'>
|
||||
Manage Account
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
icon={<IconClipboardCopy size='1rem' />}
|
||||
onClick={() => {
|
||||
openCopyToken();
|
||||
}}
|
||||
>
|
||||
Copy Token
|
||||
</Menu.Item>
|
||||
<Menu.Item icon={<IconLogout size='1rem' />} component={Link} href='/auth/logout'>
|
||||
Logout
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Label>Danger</Menu.Label>
|
||||
<Menu.Item
|
||||
icon={<IconBackspace size='1rem' />}
|
||||
onClick={() => {
|
||||
openResetToken();
|
||||
}}
|
||||
color='red'
|
||||
>
|
||||
Reset Token
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<>
|
||||
{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()),
|
||||
)
|
||||
.map(({ name, Icon }, i) => (
|
||||
<>
|
||||
<Menu.Item
|
||||
closeMenuOnClick={false}
|
||||
key={i}
|
||||
icon={<Icon size={18} colorScheme={theme.colorScheme} />}
|
||||
>
|
||||
Logged in with {capitalize(name)}
|
||||
</Menu.Item>
|
||||
</>
|
||||
))}
|
||||
{oauth_providers.filter(
|
||||
(x) =>
|
||||
user.oauth
|
||||
?.map(({ provider }) => provider.toLowerCase())
|
||||
.includes(x.name.toLowerCase()),
|
||||
).length ? (
|
||||
<Menu.Divider />
|
||||
) : null}
|
||||
</>
|
||||
<Menu.Item closeMenuOnClick={false} icon={<IconBrush size='1rem' />}>
|
||||
<Select
|
||||
size={useMediaQuery('(max-width: 768px)') ? 'md' : 'xs'}
|
||||
data={Object.keys(themes).map((t) => ({
|
||||
value: t,
|
||||
label: friendlyThemeName[t],
|
||||
}))}
|
||||
<Group>
|
||||
<ThemeIcon color='primary' variant='filled'>
|
||||
<GearIcon />
|
||||
</ThemeIcon>
|
||||
<Text>{user.username}</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
}
|
||||
>
|
||||
<Group direction='column' spacing={2}>
|
||||
<Text sx={{
|
||||
color: theme.colorScheme === 'dark' ? theme.colors.dark[2] : theme.colors.gray[6],
|
||||
fontWeight: 500,
|
||||
fontSize: theme.fontSizes.xs,
|
||||
padding: `${theme.spacing.xs / 2}px ${theme.spacing.sm}px`,
|
||||
cursor: 'default',
|
||||
}}>User: {user.username}</Text>
|
||||
<MenuItemLink icon={<GearIcon />} href='/dashboard/manage'>Manage Account</MenuItemLink>
|
||||
<MenuItem icon={<CopyIcon />} onClick={() => {setOpen(false);openCopyToken();}}>Copy Token</MenuItem>
|
||||
<MenuItem icon={<ResetIcon />} onClick={() => {setOpen(false);openResetToken();}} color='red'>Reset Token</MenuItem>
|
||||
<MenuItemLink icon={<PinRightIcon />} href='/auth/logout' color='red'>Logout</MenuItemLink>
|
||||
<Divider
|
||||
variant='solid'
|
||||
my={theme.spacing.xs / 2}
|
||||
sx={theme => ({
|
||||
width: '110%',
|
||||
borderTopColor: theme.colorScheme === 'dark' ? theme.colors.dark[7] : theme.colors.gray[2],
|
||||
margin: `${theme.spacing.xs / 2}px -4px`,
|
||||
})}
|
||||
/>
|
||||
<MenuItem icon={<Pencil1Icon />}>
|
||||
<Select
|
||||
size='xs'
|
||||
data={Object.keys(themes).map(t => ({ value: t, label: friendlyThemeName[t] }))}
|
||||
value={systemTheme}
|
||||
onChange={handleUpdateTheme}
|
||||
/>
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</MenuItem>
|
||||
</Group>
|
||||
</Popover>
|
||||
</Box>
|
||||
</div>
|
||||
</Header>
|
||||
}
|
||||
>
|
||||
<Paper
|
||||
withBorder
|
||||
p='md'
|
||||
mr='md'
|
||||
mb='md'
|
||||
shadow='xs'
|
||||
sx={(theme) => ({
|
||||
'&[data-with-border]': {
|
||||
border: `${rem(1)} solid ${
|
||||
theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[0]
|
||||
}`,
|
||||
},
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</Paper>
|
||||
<Paper withBorder padding='md' shadow='xs'>{children}</Paper>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
76
src/components/Link.tsx
Normal file
76
src/components/Link.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
/// https://github.com/mui-org/material-ui/blob/next/examples/nextjs/src/Link.js
|
||||
/* eslint-disable jsx-a11y/anchor-has-content */
|
||||
import React, { forwardRef } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useRouter } from 'next/router';
|
||||
import NextLink from 'next/link';
|
||||
import { Text } from '@mantine/core';
|
||||
|
||||
export const NextLinkComposed = forwardRef(function NextLinkComposed(props: any, ref) {
|
||||
const { to, linkAs, href, replace, scroll, passHref, shallow, prefetch, locale, ...other } =
|
||||
props;
|
||||
|
||||
return (
|
||||
<NextLink
|
||||
href={to}
|
||||
prefetch={prefetch}
|
||||
as={linkAs}
|
||||
replace={replace}
|
||||
scroll={scroll}
|
||||
shallow={shallow}
|
||||
passHref={passHref}
|
||||
locale={locale}
|
||||
>
|
||||
<a ref={ref} {...other} />
|
||||
</NextLink>
|
||||
);
|
||||
});
|
||||
|
||||
// A styled version of the Next.js Link component:
|
||||
// https://nextjs.org/docs/#with-link
|
||||
const Link = forwardRef(function Link(props: any, ref) {
|
||||
const {
|
||||
activeClassName = 'active',
|
||||
as: linkAs,
|
||||
className: classNameProps,
|
||||
href,
|
||||
noLinkStyle,
|
||||
role, // Link don't have roles.
|
||||
...other
|
||||
} = props;
|
||||
|
||||
const router = useRouter();
|
||||
const pathname = typeof href === 'string' ? href : href.pathname;
|
||||
const className = clsx(classNameProps, {
|
||||
[activeClassName]: router.pathname === pathname && activeClassName,
|
||||
});
|
||||
|
||||
const isExternal =
|
||||
typeof href === 'string' && (href.indexOf('http') === 0 || href.indexOf('mailto:') === 0);
|
||||
|
||||
if (isExternal) {
|
||||
if (noLinkStyle) {
|
||||
return <Text variant='link' component='a' className={className} href={href} ref={ref} {...other} />;
|
||||
}
|
||||
|
||||
return <Text component='a' variant='link' href={href} ref={ref} {...other} />;
|
||||
}
|
||||
|
||||
if (noLinkStyle) {
|
||||
return <NextLinkComposed className={className} ref={ref} to={href} {...other} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Text
|
||||
component={NextLinkComposed}
|
||||
variant='link'
|
||||
linkAs={linkAs}
|
||||
className={className}
|
||||
ref={ref}
|
||||
to={href}
|
||||
{...other}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default Link;
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Text } from '@mantine/core';
|
||||
|
||||
export default function MutedText({ children, ...props }) {
|
||||
return (
|
||||
<Text color='dimmed' size='xl' {...props}>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
// https://mantine.dev/core/password-input/
|
||||
|
||||
import { Box, PasswordInput, Popover, Progress, Text } from '@mantine/core';
|
||||
import { IconCheck, IconCross } from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
function PasswordRequirement({ meets, label }: { meets: boolean; label: string }) {
|
||||
return (
|
||||
<Text color={meets ? 'teal' : 'red'} sx={{ display: 'flex', alignItems: 'center' }} mt='sm' size='sm'>
|
||||
{meets ? <IconCheck size='1rem' /> : <IconCross size='1rem' />} <Box ml='md'>{label}</Box>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const requirements = [
|
||||
{ re: /[0-9]/, label: 'Includes number' },
|
||||
{ re: /[a-z]/, label: 'Includes lowercase letter' },
|
||||
{ re: /[A-Z]/, label: 'Includes uppercase letter' },
|
||||
{ re: /[$&+,:;=?@#|'<>.^*()%!-]/, label: 'Includes special symbol' },
|
||||
];
|
||||
|
||||
function getStrength(password: string) {
|
||||
let multiplier = password.length > 7 ? 0 : 1;
|
||||
|
||||
requirements.forEach((requirement) => {
|
||||
if (!requirement.re.test(password)) {
|
||||
multiplier += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return Math.max(100 - (100 / (requirements.length + 1)) * multiplier, 10);
|
||||
}
|
||||
|
||||
export default function PasswordStrength({ value, setValue, setStrength, ...props }) {
|
||||
const [popoverOpened, setPopoverOpened] = useState(false);
|
||||
const checks = requirements.map((requirement, index) => (
|
||||
<PasswordRequirement key={index} label={requirement.label} meets={requirement.re.test(value)} />
|
||||
));
|
||||
|
||||
const strength = getStrength(value);
|
||||
setStrength(strength);
|
||||
const color = strength === 100 ? 'teal' : strength > 50 ? 'yellow' : 'red';
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={popoverOpened}
|
||||
position='bottom'
|
||||
width='target'
|
||||
withArrow
|
||||
trapFocus={false}
|
||||
styles={{
|
||||
dropdown: {
|
||||
zIndex: 999999,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Popover.Target>
|
||||
<div onFocusCapture={() => setPopoverOpened(true)} onBlurCapture={() => setPopoverOpened(false)}>
|
||||
<PasswordInput
|
||||
label='Password'
|
||||
description='A strong password should include letters in lower and uppercase, at least 1 number, at least 1 special symbol'
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.currentTarget.value)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Progress color={color} value={strength} size={7} mb='md' />
|
||||
<PasswordRequirement label='Includes at least 8 characters' meets={value.length > 7} />
|
||||
{checks}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Box, Table } from '@mantine/core';
|
||||
import { randomId } from '@mantine/hooks';
|
||||
|
||||
export function SmallTable({ rows, columns }) {
|
||||
return (
|
||||
<Box sx={{ pt: 1 }}>
|
||||
<Table highlightOnHover>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th key={randomId()}>{col.name}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={randomId()}>
|
||||
{columns.map((col) => (
|
||||
<td key={randomId()}>{col.format ? col.format(row[col.id]) : row[col.id]}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { Card, createStyles, Group, Text } from '@mantine/core';
|
||||
import { IconArrowDownRight, IconArrowUpRight } from '@tabler/icons-react';
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
root: {
|
||||
padding: `calc(${theme.spacing.xl} * 1.5)`,
|
||||
},
|
||||
|
||||
value: {
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1,
|
||||
},
|
||||
|
||||
diff: {
|
||||
lineHeight: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
|
||||
icon: {
|
||||
color: theme.colorScheme === 'dark' ? theme.colors.dark[3] : theme.colors.gray[4],
|
||||
},
|
||||
|
||||
title: {
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
}));
|
||||
|
||||
interface StatsGridProps {
|
||||
stat: {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
value: string;
|
||||
desc: string;
|
||||
diff?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function StatCard({ stat }: StatsGridProps) {
|
||||
const { classes } = useStyles();
|
||||
if (stat.diff) stat.diff = Math.round(stat.diff);
|
||||
|
||||
return (
|
||||
<Card p='md' radius='md' key={stat.title}>
|
||||
<Group position='apart'>
|
||||
<Text size='xs' color='dimmed' className={classes.title}>
|
||||
{stat.title}
|
||||
</Text>
|
||||
{stat.icon}
|
||||
</Group>
|
||||
|
||||
<Group align='flex-end' spacing='xs' mt='md'>
|
||||
<Text className={classes.value}>{stat.value}</Text>
|
||||
{typeof stat.diff == 'number' && (
|
||||
<>
|
||||
<Text color={stat.diff >= 0 ? 'teal' : 'red'} size='sm' weight={500} className={classes.diff}>
|
||||
<span>{stat.diff === Infinity ? '∞' : stat.diff}%</span>
|
||||
{stat.diff >= 0 ? <IconArrowUpRight size={16} /> : <IconArrowDownRight size={16} />}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Text size='xs' color='dimmed' mt='sm'>
|
||||
{stat.desc}
|
||||
</Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user