mirror of
https://github.com/diced/zipline.git
synced 2025-12-26 12:51:27 -08:00
Compare commits
2 Commits
v3.4.7
...
feature/ma
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45f29c7b68 | ||
|
|
114a7a05a9 |
@@ -2,6 +2,3 @@ node_modules/
|
||||
.next/
|
||||
uploads/
|
||||
.git/
|
||||
.yarn/*
|
||||
!.yarn/releases
|
||||
!.yarn/plugins
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"extends": ["next", "next/core-web-vitals"],
|
||||
"rules": {
|
||||
"indent": ["error", 2, { "SwitchCase": 1 }],
|
||||
"indent": ["error", 2],
|
||||
"linebreak-style": ["error", "unix"],
|
||||
"quotes": ["error", "single"],
|
||||
"semi": ["error", "always"],
|
||||
@@ -17,7 +17,7 @@
|
||||
"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"
|
||||
|
||||
40
.github/workflows/docker.yml
vendored
40
.github/workflows/docker.yml
vendored
@@ -8,7 +8,6 @@ on:
|
||||
- 'server/**'
|
||||
- 'prisma/**'
|
||||
- '.github/**'
|
||||
- 'Dockerfile'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -19,31 +18,28 @@ jobs:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
- name: Login to Github Packages
|
||||
uses: docker/login-action@v1
|
||||
- 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@v1
|
||||
push_to_dockerhub:
|
||||
name: Push Image to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Push to Docker Hub
|
||||
uses: docker/build-push-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build Docker Image
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
ghcr.io/diced/zipline/zipline:trunk
|
||||
diced/zipline:trunk
|
||||
repository: diced/zipline
|
||||
dockerfile: Dockerfile
|
||||
tag_with_ref: true
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -5,11 +5,6 @@
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# yarn
|
||||
.yarn/*
|
||||
!.yarn/releases
|
||||
!.yarn/plugins
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
@@ -41,6 +36,4 @@ yarn-error.log*
|
||||
|
||||
# zipline
|
||||
config.toml
|
||||
uploads/
|
||||
dist/
|
||||
docker-compose.local.yml
|
||||
uploads/
|
||||
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
786
.yarn/releases/yarn-3.2.1.cjs
vendored
786
.yarn/releases/yarn-3.2.1.cjs
vendored
File diff suppressed because one or more lines are too long
@@ -1,7 +0,0 @@
|
||||
nodeLinker: node-modules
|
||||
|
||||
plugins:
|
||||
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
|
||||
spec: "@yarnpkg/plugin-interactive-tools"
|
||||
|
||||
yarnPath: .yarn/releases/yarn-3.2.1.cjs
|
||||
53
Dockerfile
53
Dockerfile
@@ -1,65 +1,46 @@
|
||||
FROM ghcr.io/diced/prisma-binaries:3.15.x as prisma
|
||||
|
||||
FROM alpine:3.16 AS deps
|
||||
RUN mkdir -p /prisma-engines
|
||||
FROM node:16-alpine AS deps
|
||||
WORKDIR /build
|
||||
|
||||
COPY .yarn .yarn
|
||||
COPY package.json yarn.lock .yarnrc.yml ./
|
||||
COPY package.json yarn.lock ./
|
||||
|
||||
RUN apk add --no-cache nodejs yarn
|
||||
RUN yarn install --immutable
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
FROM alpine:3.16 AS builder
|
||||
FROM node:16-alpine AS builder
|
||||
WORKDIR /build
|
||||
|
||||
COPY --from=prisma /prisma-engines /prisma-engines
|
||||
ENV PRISMA_QUERY_ENGINE_BINARY=/prisma-engines/query-engine \
|
||||
PRISMA_MIGRATION_ENGINE_BINARY=/prisma-engines/migration-engine \
|
||||
PRISMA_INTROSPECTION_ENGINE_BINARY=/prisma-engines/introspection-engine \
|
||||
PRISMA_FMT_BINARY=/prisma-engines/prisma-fmt \
|
||||
PRISMA_CLI_QUERY_ENGINE_TYPE=binary \
|
||||
PRISMA_CLIENT_ENGINE_TYPE=binary
|
||||
|
||||
RUN apk add --no-cache nodejs yarn openssl openssl-dev
|
||||
|
||||
COPY --from=deps /build/node_modules ./node_modules
|
||||
COPY src ./src
|
||||
COPY server ./server
|
||||
COPY scripts ./scripts
|
||||
COPY prisma ./prisma
|
||||
COPY .yarn .yarn
|
||||
COPY package.json yarn.lock .yarnrc.yml esbuild.config.js next.config.js next-env.d.ts zip-env.d.ts tsconfig.json ./
|
||||
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 yarn build
|
||||
|
||||
FROM alpine:3.16 AS runner
|
||||
FROM node:16-alpine AS runner
|
||||
WORKDIR /zipline
|
||||
|
||||
COPY --from=prisma /prisma-engines /prisma-engines
|
||||
ENV PRISMA_QUERY_ENGINE_BINARY=/prisma-engines/query-engine \
|
||||
PRISMA_MIGRATION_ENGINE_BINARY=/prisma-engines/migration-engine \
|
||||
PRISMA_INTROSPECTION_ENGINE_BINARY=/prisma-engines/introspection-engine \
|
||||
PRISMA_FMT_BINARY=/prisma-engines/prisma-fmt \
|
||||
PRISMA_CLI_QUERY_ENGINE_TYPE=binary \
|
||||
PRISMA_CLIENT_ENGINE_TYPE=binary
|
||||
|
||||
RUN apk add --no-cache nodejs yarn openssl openssl-dev
|
||||
|
||||
ENV NODE_ENV production
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
COPY --from=builder /build/.next ./.next
|
||||
COPY --from=builder /build/dist ./dist
|
||||
COPY --from=builder /build/node_modules ./node_modules
|
||||
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 --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
|
||||
|
||||
CMD ["node", "dist/server"]
|
||||
USER zipline
|
||||
|
||||
CMD ["node", "server"]
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 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
|
||||
|
||||
99
README.md
99
README.md
@@ -1,14 +1,14 @@
|
||||
<div align="center">
|
||||
<img src="https://raw.githubusercontent.com/diced/zipline/trunk/public/zipline_small.png"/>
|
||||
|
||||
|
||||
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
|
||||
@@ -17,97 +17,18 @@
|
||||
- Built with Next.js & React
|
||||
- Token protected uploading
|
||||
- Image uploading
|
||||
- Password Protected Uploads
|
||||
- URL shortening
|
||||
- Text uploading
|
||||
- URL Formats (uuid, dates, random alphanumeric, original name, zws)
|
||||
- Discord embeds (OG metadata)
|
||||
- Gallery viewer, and multiple file format support
|
||||
- Code highlighting
|
||||
- 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`)
|
||||
|
||||
# Usage
|
||||
## Installing
|
||||
[See how to install here](https://zipline.diced.tech/docs/get-started)
|
||||
|
||||
## Install & run with Docker
|
||||
This section requires [Docker](https://docs.docker.com/get-docker/) and [docker-compose](https://docs.docker.com/compose/install/).
|
||||
## Configuration
|
||||
[See how to configure here](https://zipline.diced.tech/docs/config/overview)
|
||||
|
||||
```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/) or [npm](https://npmjs.com).
|
||||
```shell
|
||||
git clone https://github.com/diced/zipline
|
||||
cd zipline
|
||||
|
||||
# npm install
|
||||
yarn install
|
||||
# npm run build
|
||||
yarn build
|
||||
# npm start
|
||||
yarn start
|
||||
```
|
||||
|
||||
# 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/uploaders/sharex)
|
||||
|
||||
# Flameshot (Linux)
|
||||
This section requires [Flameshot](https://www.flameshot.org/), [jq](https://stedolan.github.io/jq/), and [xsel](https://github.com/kfish/xsel).
|
||||
|
||||
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].url' | xsel -ib
|
||||
```
|
||||
|
||||
# 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 issue on GitHub, please include the following:
|
||||
* Breif explanation of the feature in the title (very breif 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.
|
||||
## Theming
|
||||
[See how to theme here](https://zipline.diced.tech/docs/themes/reference)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 3.4.4 | :white_check_mark: |
|
||||
| 3.2.x | :white_check_mark: |
|
||||
| < 3 | :x: |
|
||||
| < 2 | :x: |
|
||||
|
||||
|
||||
@@ -1,32 +1,10 @@
|
||||
[core]
|
||||
secure = true # whether to return https or http in links
|
||||
secret = 'changethis' # change this or zipline will not work
|
||||
secure = true
|
||||
secret = 'some secret'
|
||||
host = '0.0.0.0'
|
||||
port = 3000
|
||||
database_url = 'postgres://postgres:postgres@postgres/postgres'
|
||||
|
||||
[datasource]
|
||||
type = 'local' # s3, local, or swift
|
||||
|
||||
[datasource.local]
|
||||
directory = './uploads' # directory to store uploads in
|
||||
|
||||
[datasource.s3]
|
||||
access_key_id = 'AKIAEXAMPLEKEY'
|
||||
secret_access_key = 'somethingsomethingsomething'
|
||||
bucket = 'zipline-storage'
|
||||
endpoint = 's3.amazonaws.com'
|
||||
region = 'us-west-2' # not required, defaults to us-east-1 if not specified
|
||||
force_s3_path = false
|
||||
|
||||
[datasource.swift]
|
||||
container = 'default'
|
||||
auth_endpoint = 'http://127.0.0.1:49155/v3' # only supports v3 swift endpoints at the moment.
|
||||
username = 'swift'
|
||||
password = 'fingertips'
|
||||
project_id = 'Default'
|
||||
domain_id = 'Default'
|
||||
|
||||
[urls]
|
||||
route = '/go'
|
||||
length = 6
|
||||
@@ -35,10 +13,7 @@ length = 6
|
||||
route = '/u'
|
||||
embed_route = '/a'
|
||||
length = 6
|
||||
directory = './uploads'
|
||||
user_limit = 104900000 # 100mb
|
||||
admin_limit = 104900000 # 100mb
|
||||
disabled_extensions = ['jpg']
|
||||
|
||||
[ratelimit]
|
||||
user = 5 # 5 seconds
|
||||
admin = 0 # 0 seconds, disabled
|
||||
disabled_extentions = ['jpg']
|
||||
|
||||
@@ -2,12 +2,11 @@ version: '3'
|
||||
services:
|
||||
postgres:
|
||||
image: postgres
|
||||
restart: always
|
||||
environment:
|
||||
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']
|
||||
@@ -22,17 +21,16 @@ services:
|
||||
ports:
|
||||
- '3000:3000'
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
environment:
|
||||
- SECURE=false
|
||||
- SECRET=changethis
|
||||
- HOST=0.0.0.0
|
||||
- PORT=3000
|
||||
- DATASOURCE_TYPE=local
|
||||
- DATASOURCE_LOCAL_DIRECTORY=./uploads
|
||||
- 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=
|
||||
|
||||
@@ -2,12 +2,11 @@ version: '3'
|
||||
services:
|
||||
postgres:
|
||||
image: postgres
|
||||
restart: always
|
||||
environment:
|
||||
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']
|
||||
@@ -19,18 +18,17 @@ services:
|
||||
image: ghcr.io/diced/zipline/zipline:trunk
|
||||
ports:
|
||||
- '3000:3000'
|
||||
restart: always
|
||||
environment:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- SECURE=false
|
||||
- SECRET=changethis
|
||||
- HOST=0.0.0.0
|
||||
- PORT=3000
|
||||
- DATASOURCE_TYPE=local
|
||||
- DATASOURCE_DIRECTORY=./uploads
|
||||
- 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=
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
const esbuild = require('esbuild');
|
||||
const { existsSync } = require('fs');
|
||||
const { rm } = require('fs/promises');
|
||||
|
||||
(async () => {
|
||||
const watch = process.argv[2] === '--watch';
|
||||
|
||||
if (existsSync('./dist')) {
|
||||
await rm('./dist', { recursive: true });
|
||||
}
|
||||
|
||||
await esbuild.build({
|
||||
tsconfig: 'tsconfig.json',
|
||||
outdir: 'dist',
|
||||
bundle: false,
|
||||
platform: 'node',
|
||||
treeShaking: true,
|
||||
entryPoints: [
|
||||
'src/server/index.ts',
|
||||
'src/server/util.ts',
|
||||
'src/lib/logger.ts',
|
||||
'src/lib/config.ts',
|
||||
'src/lib/mimes.ts',
|
||||
'src/lib/exts.ts',
|
||||
'src/lib/config/Config.ts',
|
||||
'src/lib/config/readConfig.ts',
|
||||
'src/lib/config/validateConfig.ts',
|
||||
'src/lib/datasources/Datasource.ts',
|
||||
'src/lib/datasources/index.ts',
|
||||
'src/lib/datasources/Local.ts',
|
||||
'src/lib/datasources/S3.ts',
|
||||
'src/lib/datasources/Swift.ts',
|
||||
'src/lib/datasource.ts',
|
||||
],
|
||||
format: 'cjs',
|
||||
resolveExtensions: ['.ts', '.js'],
|
||||
write: true,
|
||||
watch,
|
||||
incremental: watch,
|
||||
sourcemap: false,
|
||||
minify: false,
|
||||
});
|
||||
})();
|
||||
@@ -8,9 +8,4 @@ module.exports = {
|
||||
},
|
||||
];
|
||||
},
|
||||
api: {
|
||||
responseLimit: false,
|
||||
},
|
||||
poweredByHeader: false,
|
||||
reactStrictMode: true,
|
||||
};
|
||||
82
package.json
82
package.json
@@ -1,67 +1,63 @@
|
||||
{
|
||||
"name": "zipline",
|
||||
"version": "3.4.7",
|
||||
"name": "zip3",
|
||||
"version": "3.4.0",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.js && REACT_EDITOR=code NODE_ENV=development node dist/server",
|
||||
"build": "npm-run-all build:server build:schema build:next",
|
||||
"build:server": "node esbuild.config.js",
|
||||
"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",
|
||||
"migrate:dev": "prisma migrate dev --create-only",
|
||||
"start": "node dist/server",
|
||||
"start": "node server",
|
||||
"lint": "next lint",
|
||||
"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": {
|
||||
"@iarna/toml": "2.2.5",
|
||||
"@mantine/core": "^4.2.9",
|
||||
"@mantine/dropzone": "^4.2.9",
|
||||
"@mantine/hooks": "^4.2.9",
|
||||
"@mantine/modals": "^4.2.9",
|
||||
"@mantine/next": "^4.2.9",
|
||||
"@mantine/notifications": "^4.2.9",
|
||||
"@mantine/prism": "^4.2.9",
|
||||
"@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.15.2",
|
||||
"@prisma/migrate": "^3.15.2",
|
||||
"@prisma/sdk": "^3.15.2",
|
||||
"@reduxjs/toolkit": "^1.8.2",
|
||||
"argon2": "^0.28.5",
|
||||
"colorette": "^2.0.19",
|
||||
"cookie": "^0.5.0",
|
||||
"fecha": "^4.2.3",
|
||||
"fflate": "^0.7.3",
|
||||
"find-my-way": "^6.3.0",
|
||||
"minio": "^7.0.28",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"next": "^12.1.6",
|
||||
"prisma": "^3.15.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-redux": "^8.0.2",
|
||||
"react-table": "^7.8.0",
|
||||
"redux": "^4.2.0",
|
||||
"yup": "^0.32.11"
|
||||
"@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.1",
|
||||
"@types/minio": "^7.0.13",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/cookie": "^0.4.0",
|
||||
"@types/multer": "^1.4.6",
|
||||
"@types/node": "^15.12.2",
|
||||
"babel-plugin-import": "^1.13.5",
|
||||
"esbuild": "^0.14.44",
|
||||
"babel-plugin-import": "^1.13.3",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-config-next": "12.1.6",
|
||||
"eslint-config-next": "11.0.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"ts-node": "^10.8.1",
|
||||
"typescript": "^4.7.3"
|
||||
"ts-node": "^10.0.0",
|
||||
"typescript": "^4.3.2"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/diced/zipline.git"
|
||||
},
|
||||
"packageManager": "yarn@3.2.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);
|
||||
@@ -8,17 +8,16 @@ generator client {
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
id Int @id @default(autoincrement())
|
||||
username String
|
||||
password String
|
||||
token String
|
||||
administrator Boolean @default(false)
|
||||
systemTheme String @default("system")
|
||||
administrator Boolean @default(false)
|
||||
systemTheme String @default("system")
|
||||
embedTitle String?
|
||||
embedColor String @default("#2f3136")
|
||||
embedSiteName String? @default("{image.file} • {user.name}")
|
||||
ratelimit DateTime?
|
||||
domains String[]
|
||||
embedColor String @default("#2f3136")
|
||||
embedSiteName String? @default("{image.file} • {user.name}")
|
||||
ratelimited Boolean @default(false)
|
||||
images Image[]
|
||||
urls Url[]
|
||||
}
|
||||
@@ -38,7 +37,6 @@ model Image {
|
||||
views Int @default(0)
|
||||
favorite Boolean @default(false)
|
||||
embed Boolean @default(false)
|
||||
password String?
|
||||
invisible InvisibleImage?
|
||||
format ImageFormat @default(RANDOM)
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
@@ -48,7 +46,7 @@ model Image {
|
||||
model InvisibleImage {
|
||||
id Int @id @default(autoincrement())
|
||||
invis String @unique
|
||||
imageId Int @unique
|
||||
imageId Int
|
||||
image Image @relation(fields: [imageId], references: [id])
|
||||
}
|
||||
|
||||
@@ -66,12 +64,12 @@ model Url {
|
||||
model InvisibleUrl {
|
||||
id Int @id @default(autoincrement())
|
||||
invis String @unique
|
||||
urlId String @unique
|
||||
urlId String
|
||||
url Url @relation(fields: [urlId], references: [id])
|
||||
}
|
||||
|
||||
model Stats {
|
||||
id Int @id @default(autoincrement())
|
||||
id Int @id @default(autoincrement())
|
||||
created_at DateTime @default(now())
|
||||
data Json
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// https://github.com/toptal/haste-server/blob/master/static/application.js#L167-L174
|
||||
// Popular extension map
|
||||
const exts = {
|
||||
module.exports = {
|
||||
rb: 'ruby',
|
||||
py: 'python',
|
||||
pl: 'perl',
|
||||
@@ -35,6 +35,4 @@ const exts = {
|
||||
txt: '',
|
||||
coffee: 'coffee',
|
||||
swift: 'swift',
|
||||
};
|
||||
|
||||
export default exts;
|
||||
};
|
||||
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();
|
||||
})();
|
||||
@@ -1,4 +1,4 @@
|
||||
const mimes = {
|
||||
module.exports = {
|
||||
'.aac': 'audio/aac',
|
||||
'.abw': 'application/x-abiword',
|
||||
'.arc': 'application/x-freearc',
|
||||
@@ -75,6 +75,4 @@ const mimes = {
|
||||
'.3gp': 'video/3gpp',
|
||||
'.3g2': 'video/3gpp2',
|
||||
'.7z': 'application/x-7z-compressed',
|
||||
};
|
||||
|
||||
export default mimes;
|
||||
};
|
||||
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);
|
||||
}
|
||||
@@ -1,36 +1,60 @@
|
||||
import { Migrate } from '@prisma/migrate/dist/Migrate';
|
||||
import { ensureDatabaseExists } from '@prisma/migrate/dist/utils/ensureDatabaseExists';
|
||||
import Logger from '../lib/logger';
|
||||
import { Datasource } from 'lib/datasources';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
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');
|
||||
|
||||
export async function migrations() {
|
||||
async function migrations() {
|
||||
const migrate = new Migrate('./prisma/schema.prisma');
|
||||
await ensureDatabaseExists('apply', true, './prisma/schema.prisma');
|
||||
|
||||
const diagnose = await migrate.diagnoseMigrationHistory({
|
||||
optInToShadowDatabase: false,
|
||||
});
|
||||
|
||||
if (diagnose.history?.diagnostic === 'databaseIsBehind') {
|
||||
try {
|
||||
Logger.get('database').info('migrating database');
|
||||
await migrate.applyMigrations();
|
||||
} finally {
|
||||
migrate.stop();
|
||||
Logger.get('database').info('finished migrating database');
|
||||
}
|
||||
} else {
|
||||
migrate.stop();
|
||||
Logger.get('database').info('migrating database');
|
||||
await migrate.applyMigrations();
|
||||
Logger.get('database').info('finished migrating database');
|
||||
}
|
||||
|
||||
migrate.stop();
|
||||
}
|
||||
|
||||
export function log(url: string) {
|
||||
function log(url) {
|
||||
if (url.startsWith('/_next') || url.startsWith('/__nextjs')) return;
|
||||
return Logger.get('url').info(url);
|
||||
}
|
||||
|
||||
export function bytesToRead(bytes: number) {
|
||||
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;
|
||||
|
||||
@@ -43,8 +67,8 @@ export function bytesToRead(bytes: number) {
|
||||
}
|
||||
|
||||
|
||||
export async function getStats(prisma: PrismaClient, datasource: Datasource) {
|
||||
const size = await datasource.size();
|
||||
async function getStats(prisma, config) {
|
||||
const size = await sizeOfDir(join(process.cwd(), config.uploader.directory));
|
||||
const byUser = await prisma.image.groupBy({
|
||||
by: ['userId'],
|
||||
_count: {
|
||||
@@ -88,9 +112,19 @@ export async function getStats(prisma: PrismaClient, datasource: Datasource) {
|
||||
size: bytesToRead(size),
|
||||
size_num: size,
|
||||
count,
|
||||
count_by_user: count_by_user.sort((a, b) => b.count - a.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),
|
||||
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')}`;
|
||||
}
|
||||
};
|
||||
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,9 +1,14 @@
|
||||
import { Card as MCard, Title } from '@mantine/core';
|
||||
import React from 'react';
|
||||
import {
|
||||
Card as MCard,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
|
||||
export default function Card({ name, children, ...other }) {
|
||||
export default function Card(props) {
|
||||
const { name, children, ...other } = props;
|
||||
|
||||
return (
|
||||
<MCard p='md' shadow='sm' {...other}>
|
||||
<MCard padding='md' shadow='sm' {...other}>
|
||||
<Title order={2}>{name}</Title>
|
||||
{children}
|
||||
</MCard>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { useState } from 'react';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { Button, Card, Group, Image as MImage, Modal, Title } from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { useNotifications } from '@mantine/notifications';
|
||||
import { CopyIcon, Cross1Icon, StarIcon, TrashIcon } from '@modulz/radix-icons';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useState } from 'react';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
|
||||
export default function File({ image, updateImages }) {
|
||||
export default function Image({ image, updateImages }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [t] = useState(image.mimetype.split('/')[0]);
|
||||
const notif = useNotifications();
|
||||
@@ -56,7 +56,7 @@ export default function File({ image, updateImages }) {
|
||||
const Type = (props) => {
|
||||
return {
|
||||
'video': <video controls {...props} />,
|
||||
'image': <MImage withPlaceholder {...props} />,
|
||||
'image': <MImage {...props} />,
|
||||
'audio': <audio controls {...props} />,
|
||||
}[t];
|
||||
};
|
||||
@@ -1,12 +1,18 @@
|
||||
/* eslint-disable jsx-a11y/alt-text */
|
||||
/* 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, Image, Pagination,
|
||||
Group,
|
||||
Pagination,
|
||||
Select,
|
||||
Table,
|
||||
Text,
|
||||
@@ -17,10 +23,6 @@ import {
|
||||
EnterIcon,
|
||||
TrashIcon,
|
||||
} from '@modulz/radix-icons';
|
||||
import {
|
||||
usePagination,
|
||||
useTable,
|
||||
} from 'react-table';
|
||||
|
||||
const pageSizeOptions = ['10', '25', '50'];
|
||||
|
||||
@@ -41,26 +43,6 @@ const useStyles = createStyles((t) => ({
|
||||
sortDirectionIcon: { transition: 'transform 200ms ease' },
|
||||
}));
|
||||
|
||||
export function FilePreview({ url, type }) {
|
||||
const Type = props => {
|
||||
return {
|
||||
'video': <video autoPlay controls {...props} />,
|
||||
'image': <Image {...props} />,
|
||||
'audio': <audio autoPlay controls {...props} />,
|
||||
}[type.split('/')[0]];
|
||||
};
|
||||
|
||||
return (
|
||||
<Type
|
||||
sx={{ maxWidth: '10vw', maxHeight: '100vh' }}
|
||||
style={{ maxWidth: '10vw', maxHeight: '100vh' }}
|
||||
mr='sm'
|
||||
src={url}
|
||||
alt={'Unable to preview file'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ImagesTable({
|
||||
columns,
|
||||
data = [],
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { AppShell, Box, Burger, Divider, Group, Header, MediaQuery, Navbar, Paper, Popover, ScrollArea, Select, Text, ThemeIcon, Title, UnstyledButton, useMantineTheme } from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { useRouter } from 'next/router';
|
||||
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 { CheckIcon, CopyIcon, Cross1Icon, FileIcon, GearIcon, HomeIcon, Link1Icon, MixerHorizontalIcon, Pencil1Icon, PersonIcon, PinRightIcon, ResetIcon, UploadIcon } from '@modulz/radix-icons';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { updateUser } from 'lib/redux/reducers/user';
|
||||
import { useStoreDispatch } from 'lib/redux/store';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { friendlyThemeName, themes } from './Theming';
|
||||
|
||||
function MenuItemLink(props) {
|
||||
@@ -121,8 +122,6 @@ export default function Layout({ children, user }) {
|
||||
|
||||
const openResetToken = () => modals.openConfirmModal({
|
||||
title: 'Reset Token',
|
||||
centered: true,
|
||||
overlayBlur: 3,
|
||||
children: (
|
||||
<Text size='sm'>
|
||||
Once you reset your token, you will have to update any uploaders to use this new token.
|
||||
@@ -154,8 +153,6 @@ export default function Layout({ children, user }) {
|
||||
|
||||
const openCopyToken = () => modals.openConfirmModal({
|
||||
title: 'Copy Token',
|
||||
centered: true,
|
||||
overlayBlur: 3,
|
||||
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.
|
||||
@@ -182,7 +179,7 @@ export default function Layout({ children, user }) {
|
||||
fixed
|
||||
navbar={
|
||||
<Navbar
|
||||
p='md'
|
||||
padding='md'
|
||||
hiddenBreakpoint='sm'
|
||||
hidden={!opened}
|
||||
width={{ sm: 200, lg: 230 }}
|
||||
@@ -197,7 +194,7 @@ export default function Layout({ children, user }) {
|
||||
{items.map(({ icon, text, link }) => (
|
||||
<Link href={link} key={text} passHref>
|
||||
<UnstyledButton
|
||||
sx={{
|
||||
sx={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: theme.spacing.xs,
|
||||
@@ -248,7 +245,7 @@ export default function Layout({ children, user }) {
|
||||
</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
|
||||
@@ -294,13 +291,10 @@ export default function Layout({ children, user }) {
|
||||
<Text sx={{
|
||||
color: theme.colorScheme === 'dark' ? theme.colors.dark[2] : theme.colors.gray[6],
|
||||
fontWeight: 500,
|
||||
fontSize: theme.fontSizes.sm,
|
||||
fontSize: theme.fontSizes.xs,
|
||||
padding: `${theme.spacing.xs / 2}px ${theme.spacing.sm}px`,
|
||||
cursor: 'default',
|
||||
}}
|
||||
>
|
||||
{user.username}
|
||||
</Text>
|
||||
}}>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>
|
||||
@@ -329,7 +323,7 @@ export default function Layout({ children, user }) {
|
||||
</Header>
|
||||
}
|
||||
>
|
||||
<Paper withBorder p='md' shadow='xs'>{children}</Paper>
|
||||
<Paper withBorder padding='md' shadow='xs'>{children}</Paper>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/// https://github.com/mui-org/material-ui/blob/next/examples/nextjs/src/Link.js
|
||||
/* eslint-disable jsx-a11y/anchor-has-content */
|
||||
import { Text } from '@mantine/core';
|
||||
import React, { forwardRef } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import NextLink from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { forwardRef } from 'react';
|
||||
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 } =
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Text } from '@mantine/core';
|
||||
|
||||
export default function MutedText({ children, ...props }) {
|
||||
return <Text color='gray' size='xl' {...props}>{children}</Text>;
|
||||
}
|
||||
@@ -1,29 +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,22 +1,22 @@
|
||||
import { useEffect } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
// themes
|
||||
import ayu_dark from 'lib/themes/ayu_dark';
|
||||
import ayu_light from 'lib/themes/ayu_light';
|
||||
import ayu_mirage from 'lib/themes/ayu_mirage';
|
||||
import dark from 'lib/themes/dark';
|
||||
import dark_blue from 'lib/themes/dark_blue';
|
||||
import dracula from 'lib/themes/dracula';
|
||||
import light_blue from 'lib/themes/light_blue';
|
||||
import matcha_dark_azul from 'lib/themes/matcha_dark_azul';
|
||||
import dark from 'lib/themes/dark';
|
||||
import ayu_dark from 'lib/themes/ayu_dark';
|
||||
import ayu_mirage from 'lib/themes/ayu_mirage';
|
||||
import ayu_light from 'lib/themes/ayu_light';
|
||||
import nord from 'lib/themes/nord';
|
||||
import dracula from 'lib/themes/dracula';
|
||||
import matcha_dark_azul from 'lib/themes/matcha_dark_azul';
|
||||
import qogir_dark from 'lib/themes/qogir_dark';
|
||||
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import { MantineProvider, MantineThemeOverride } from '@mantine/core';
|
||||
import { useColorScheme } from '@mantine/hooks';
|
||||
import { ModalsProvider } from '@mantine/modals';
|
||||
import { NotificationsProvider } from '@mantine/notifications';
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import { useColorScheme } from '@mantine/hooks';
|
||||
|
||||
export const themes = {
|
||||
system: (colorScheme: 'dark' | 'light') => colorScheme === 'dark' ? dark_blue : light_blue,
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Dropzone as MantineDropzone } from '@mantine/dropzone';
|
||||
import { Group, Text, useMantineTheme } from '@mantine/core';
|
||||
import { UploadIcon, CrossCircledIcon, ImageIcon } from '@modulz/radix-icons';
|
||||
|
||||
function ImageUploadIcon({ status, ...props }) {
|
||||
if (status.accepted) {
|
||||
return <UploadIcon {...props} />;
|
||||
}
|
||||
|
||||
if (status.rejected) {
|
||||
return <CrossCircledIcon {...props} />;
|
||||
}
|
||||
|
||||
return <ImageIcon {...props} />;
|
||||
}
|
||||
|
||||
function getIconColor(status, theme) {
|
||||
return status.accepted
|
||||
? theme.colors[theme.primaryColor][6]
|
||||
: status.rejected
|
||||
? theme.colors.red[6]
|
||||
: theme.colorScheme === 'dark'
|
||||
? theme.colors.dark[0]
|
||||
: theme.black;
|
||||
}
|
||||
|
||||
|
||||
export default function Dropzone({ loading, onDrop, children }) {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
return (
|
||||
<MantineDropzone loading={loading} onDrop={onDrop}>
|
||||
{status => (
|
||||
<>
|
||||
<Group position='center' spacing='xl' style={{ minHeight: 220, pointerEvents: 'none' }}>
|
||||
<ImageUploadIcon
|
||||
status={status}
|
||||
style={{ width: 80, height: 80, color: getIconColor(status, theme) }}
|
||||
/>
|
||||
|
||||
<Text size='xl' inline>
|
||||
Drag images here or click to select files
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{children}
|
||||
</>
|
||||
)}
|
||||
</MantineDropzone>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
/* eslint-disable jsx-a11y/alt-text */
|
||||
import React from 'react';
|
||||
import { Image, Table, Tooltip, Badge, useMantineTheme } from '@mantine/core';
|
||||
|
||||
export function FilePreview({ file }: { file: File }) {
|
||||
const Type = props => {
|
||||
return {
|
||||
'video': <video autoPlay controls {...props} />,
|
||||
'image': <Image withPlaceholder {...props} />,
|
||||
'audio': <audio autoPlay controls {...props} />,
|
||||
}[file.type.split('/')[0]];
|
||||
};
|
||||
|
||||
return (
|
||||
<Type
|
||||
sx={{ maxWidth: '10vw', maxHeight: '100vh' }}
|
||||
style={{ maxWidth: '10vw', maxHeight: '100vh' }}
|
||||
src={URL.createObjectURL(file)}
|
||||
alt={file.name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FileDropzone({ file }: { file: File }) {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
position='top'
|
||||
placement='center'
|
||||
allowPointerEvents
|
||||
label={
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<FilePreview file={file} />
|
||||
|
||||
<Table sx={{ color: theme.colorScheme === 'dark' ? 'black' : 'white' }} ml='md'>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Name</td>
|
||||
<td>{file.name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Type</td>
|
||||
<td>{file.type}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Last Modified</td>
|
||||
<td>{new Date(file.lastModified).toLocaleString()}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Badge size='lg'>
|
||||
{file.name}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,63 @@
|
||||
import { SimpleGrid, Skeleton, Text, Title } from '@mantine/core';
|
||||
import { randomId, useClipboard } from '@mantine/hooks';
|
||||
import { useNotifications } from '@mantine/notifications';
|
||||
import { CopyIcon, Cross1Icon, TrashIcon } from '@modulz/radix-icons';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import Card from 'components/Card';
|
||||
import File from 'components/File';
|
||||
import Image from 'components/Image';
|
||||
import ImagesTable from 'components/ImagesTable';
|
||||
import Link from 'components/Link';
|
||||
import MutedText from 'components/MutedText';
|
||||
import { bytesToRead } from 'lib/clientUtils';
|
||||
import useFetch from 'lib/hooks/useFetch';
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Box, Text, Table, Skeleton, Title, SimpleGrid } from '@mantine/core';
|
||||
import { randomId, useClipboard } from '@mantine/hooks';
|
||||
import Link from 'components/Link';
|
||||
import { CopyIcon, Cross1Icon, TrashIcon } from '@modulz/radix-icons';
|
||||
import { useNotifications } from '@mantine/notifications';
|
||||
|
||||
type Aligns = 'inherit' | 'right' | 'left' | 'center' | 'justify';
|
||||
|
||||
export function bytesToRead(bytes: number) {
|
||||
if (isNaN(bytes)) return '0.0 B';
|
||||
if (bytes === Infinity) return '0.0 B';
|
||||
const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB'];
|
||||
let num = 0;
|
||||
|
||||
while (bytes > 1024) {
|
||||
bytes /= 1024;
|
||||
++num;
|
||||
}
|
||||
|
||||
return `${bytes.toFixed(1)} ${units[num]}`;
|
||||
}
|
||||
|
||||
function StatText({ children }) {
|
||||
return <Text color='gray' size='xl'>{children}</Text>;
|
||||
}
|
||||
|
||||
function StatTable({ 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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const user = useStoreSelector(state => state.user);
|
||||
|
||||
@@ -72,8 +116,8 @@ export default function Dashboard() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title>Welcome back, {user?.username}</Title>
|
||||
<Text color='gray' mb='sm'>You have <b>{images.length ? images.length : '...'}</b> files</Text>
|
||||
<Title>Welcome back {user?.username}</Title>
|
||||
<Text color='gray' sx={{ paddingBottom: 4 }}>You have <b>{images.length ? images.length : '...'}</b> files</Text>
|
||||
|
||||
<Title>Recent Files</Title>
|
||||
<SimpleGrid
|
||||
@@ -84,7 +128,8 @@ export default function Dashboard() {
|
||||
]}
|
||||
>
|
||||
{recent.length ? recent.map(image => (
|
||||
<File key={randomId()} image={image} updateImages={updateImages} />
|
||||
// eslint-disable-next-line jsx-a11y/alt-text
|
||||
<Image key={randomId()} image={image} updateImages={updateImages} />
|
||||
)) : [1,2,3,4].map(x => (
|
||||
<div key={x}>
|
||||
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }}/>
|
||||
@@ -102,22 +147,20 @@ export default function Dashboard() {
|
||||
]}
|
||||
>
|
||||
<Card name='Size' sx={{ height: '100%' }}>
|
||||
<MutedText>{stats ? stats.size : <Skeleton height={8} />}</MutedText>
|
||||
<StatText>{stats ? stats.size : <Skeleton height={8} />}</StatText>
|
||||
<Title order={2}>Average Size</Title>
|
||||
<MutedText>{stats ? bytesToRead(stats.size_num / stats.count) : <Skeleton height={8} />}</MutedText>
|
||||
<StatText>{stats ? bytesToRead(stats.size_num / stats.count) : <Skeleton height={8} />}</StatText>
|
||||
</Card>
|
||||
<Card name='Images' sx={{ height: '100%' }}>
|
||||
<MutedText>{stats ? stats.count : <Skeleton height={8} />}</MutedText>
|
||||
<StatText>{stats ? stats.count : <Skeleton height={8} />}</StatText>
|
||||
<Title order={2}>Views</Title>
|
||||
<MutedText>{stats ? `${stats.views_count} (${isNaN(stats.views_count / stats.count) ? 0 : Math.round(stats.views_count / stats.count)})` : <Skeleton height={8} />}</MutedText>
|
||||
<StatText>{stats ? `${stats.views_count} (${isNaN(stats.views_count / stats.count) ? 0 : Math.round(stats.views_count / stats.count)})` : <Skeleton height={8} />}</StatText>
|
||||
</Card>
|
||||
<Card name='Users' sx={{ height: '100%' }}>
|
||||
<MutedText>{stats ? stats.count_users : <Skeleton height={8} />}</MutedText>
|
||||
<StatText>{stats ? stats.count_users : <Skeleton height={8} />}</StatText>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
<Title mt='md'>Files</Title>
|
||||
<Text>View your gallery <Link href='/dashboard/files'>here</Link>.</Text>
|
||||
<ImagesTable
|
||||
columns={[
|
||||
{ accessor: 'file', Header: 'Name', minWidth: 170, align: 'inherit' as Aligns },
|
||||
@@ -129,6 +172,34 @@ export default function Dashboard() {
|
||||
copyImage={copyImage}
|
||||
viewImage={viewImage}
|
||||
/>
|
||||
|
||||
{/* <Title mt='md'>Files</Title>
|
||||
<Text>View previews of your files in the <Link href='/dashboard/files'>browser</Link>.</Text>
|
||||
<ReactTable
|
||||
columns={[
|
||||
{ accessor: 'file', Header: 'Name', minWidth: 170, align: 'inherit' as Aligns },
|
||||
{ accessor: 'mimetype', Header: 'Type', minWidth: 100, align: 'inherit' as Aligns },
|
||||
{ accessor: 'created_at', Header: 'Date' },
|
||||
]}
|
||||
data={images}
|
||||
pagination
|
||||
/>
|
||||
<Card name='Files per User' mt={22}>
|
||||
<StatTable
|
||||
columns={[
|
||||
{ id: 'username', name: 'Name' },
|
||||
{ id: 'count', name: 'Files' },
|
||||
]}
|
||||
rows={stats ? stats.count_by_user : []} />
|
||||
</Card>
|
||||
<Card name='Types' mt={22}>
|
||||
<StatTable
|
||||
columns={[
|
||||
{ id: 'mimetype', name: 'Type' },
|
||||
{ id: 'count', name: 'Count' },
|
||||
]}
|
||||
rows={stats ? stats.types_count : []} />
|
||||
</Card> */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Accordion, ActionIcon, Box, Group, Pagination, SimpleGrid, Skeleton, Title } from '@mantine/core';
|
||||
import { PlusIcon } from '@modulz/radix-icons';
|
||||
import File from 'components/File';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import ZiplineImage from 'components/Image';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { Box, Accordion, Pagination, Title, SimpleGrid, Skeleton, Group, ActionIcon } from '@mantine/core';
|
||||
import { PlusIcon } from '@modulz/radix-icons';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function Files() {
|
||||
const [pages, setPages] = useState([]);
|
||||
@@ -26,50 +27,48 @@ export default function Files() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group mb='md'>
|
||||
<Title>Files</Title>
|
||||
<Group>
|
||||
<Title sx={{ marginBottom: 12 }}>Files</Title>
|
||||
<Link href='/dashboard/upload' passHref>
|
||||
<ActionIcon component='a' variant='filled' color='primary'><PlusIcon/></ActionIcon>
|
||||
</Link>
|
||||
</Group>
|
||||
{favoritePages.length ? (
|
||||
<Accordion
|
||||
offsetIcon={false}
|
||||
sx={t => ({
|
||||
marginTop: 2,
|
||||
border: '1px solid',
|
||||
marginBottom: 12,
|
||||
borderColor: t.colorScheme === 'dark' ? t.colors.dark[6] : t.colors.gray[0] ,
|
||||
})}
|
||||
>
|
||||
<Accordion.Item label={<Title>Favorite Files</Title>}>
|
||||
<SimpleGrid
|
||||
cols={3}
|
||||
spacing='lg'
|
||||
breakpoints={[
|
||||
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||
]}
|
||||
>
|
||||
{favoritePages.length ? favoritePages[(favoritePage - 1) ?? 0].map(image => (
|
||||
<div key={image.id}>
|
||||
<File image={image} updateImages={() => updatePages(true)} />
|
||||
</div>
|
||||
)) : null}
|
||||
</SimpleGrid>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingTop: 12,
|
||||
paddingBottom: 3,
|
||||
}}
|
||||
>
|
||||
<Pagination total={favoritePages.length} page={favoritePage} onChange={setFavoritePage}/>
|
||||
</Box>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
) : null}
|
||||
<Accordion
|
||||
offsetIcon={false}
|
||||
sx={t => ({
|
||||
marginTop: 2,
|
||||
border: '1px solid',
|
||||
marginBottom: 12,
|
||||
borderColor: t.colorScheme === 'dark' ? t.colors.dark[6] : t.colors.gray[0] ,
|
||||
})}
|
||||
>
|
||||
<Accordion.Item label={<Title>Favorite Files</Title>}>
|
||||
<SimpleGrid
|
||||
cols={3}
|
||||
spacing='lg'
|
||||
breakpoints={[
|
||||
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||
]}
|
||||
>
|
||||
{favoritePages.length ? favoritePages[(favoritePage - 1) ?? 0].map(image => (
|
||||
<div key={image.id}>
|
||||
<ZiplineImage image={image} updateImages={() => updatePages(true)} />
|
||||
</div>
|
||||
)) : null}
|
||||
</SimpleGrid>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingTop: 12,
|
||||
paddingBottom: 3,
|
||||
}}
|
||||
>
|
||||
<Pagination total={favoritePages.length} page={favoritePage} onChange={setFavoritePage}/>
|
||||
</Box>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
<SimpleGrid
|
||||
cols={3}
|
||||
spacing='lg'
|
||||
@@ -79,7 +78,7 @@ export default function Files() {
|
||||
>
|
||||
{pages.length ? pages[(page - 1) ?? 0].map(image => (
|
||||
<div key={image.id}>
|
||||
<File image={image} updateImages={() => updatePages(true)} />
|
||||
<ZiplineImage image={image} updateImages={() => updatePages(true)} />
|
||||
</div>
|
||||
)) : [1,2,3,4].map(x => (
|
||||
<div key={x}>
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { Box, Button, Card, ColorInput, Group, MultiSelect, Space, Text, TextInput, PasswordInput, Title, Tooltip } from '@mantine/core';
|
||||
import { randomId, useForm, useInterval } from '@mantine/hooks';
|
||||
import { useModals } from '@mantine/modals';
|
||||
import { useNotifications } from '@mantine/notifications';
|
||||
import { Cross1Icon, DownloadIcon, TrashIcon } from '@modulz/radix-icons';
|
||||
import Link from 'components/Link';
|
||||
import { SmallTable } from 'components/SmallTable';
|
||||
import React from 'react';
|
||||
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { bytesToRead } from 'lib/clientUtils';
|
||||
import { updateUser } from 'lib/redux/reducers/user';
|
||||
import Link from 'components/Link';
|
||||
import { useStoreDispatch, useStoreSelector } from 'lib/redux/store';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { updateUser } from 'lib/redux/reducers/user';
|
||||
import { useForm } from '@mantine/hooks';
|
||||
import { Tooltip, TextInput, Button, Text, Title, Group, ColorInput } from '@mantine/core';
|
||||
import { DownloadIcon } from '@modulz/radix-icons';
|
||||
|
||||
function VarsTooltip({ children }) {
|
||||
return (
|
||||
@@ -19,7 +16,7 @@ function VarsTooltip({ children }) {
|
||||
<Text><b>{'{image.mimetype}'}</b> - mimetype</Text>
|
||||
<Text><b>{'{image.id}'}</b> - id of the image</Text>
|
||||
<Text><b>{'{user.name}'}</b> - your username</Text>
|
||||
visit <Link href='https://zipl.vercel.app/docs/variables'>the docs</Link> for more variables
|
||||
visit <Link href='https://zipline.diced.cf/docs/variables'>the docs</Link> for more variables
|
||||
</>
|
||||
}>
|
||||
{children}
|
||||
@@ -27,18 +24,9 @@ function VarsTooltip({ children }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ExportDataTooltip({ children }) {
|
||||
return <Tooltip position='top' placement='center' color='' label='After clicking, if you have a lot of files the export can take a while to complete. A list of previous exports will be below to download.'>{children}</Tooltip>;
|
||||
}
|
||||
|
||||
export default function Manage() {
|
||||
const user = useStoreSelector(state => state.user);
|
||||
const dispatch = useStoreDispatch();
|
||||
const notif = useNotifications();
|
||||
const modals = useModals();
|
||||
|
||||
const [exports, setExports] = useState([]);
|
||||
const [domains, setDomains] = useState(user.domains ?? []);
|
||||
|
||||
const genShareX = (withEmbed: boolean = false, withZws: boolean = false) => {
|
||||
const config = {
|
||||
@@ -49,8 +37,8 @@ export default function Manage() {
|
||||
RequestURL: `${window.location.protocol + '//' + window.location.hostname + (window.location.port ? ':' + window.location.port : '')}/api/upload`,
|
||||
Headers: {
|
||||
Authorization: user?.token,
|
||||
...(withEmbed && { Embed: 'true' }),
|
||||
...(withZws && { ZWS: 'true' }),
|
||||
...(withEmbed && {Embed: 'true'}),
|
||||
...(withZws && {ZWS: 'true'}),
|
||||
},
|
||||
URL: '$json:files[0]$',
|
||||
Body: 'MultipartFormData',
|
||||
@@ -73,7 +61,6 @@ export default function Manage() {
|
||||
embedTitle: user.embedTitle ?? '',
|
||||
embedColor: user.embedColor,
|
||||
embedSiteName: user.embedSiteName ?? '',
|
||||
domains: user.domains ?? [],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -86,127 +73,22 @@ export default function Manage() {
|
||||
|
||||
if (cleanUsername === '') return form.setFieldError('username', 'Username can\'t be nothing');
|
||||
|
||||
const id = notif.showNotification({
|
||||
title: 'Updating user...',
|
||||
message: '',
|
||||
loading: true,
|
||||
autoClose: false,
|
||||
});
|
||||
|
||||
const data = {
|
||||
username: cleanUsername,
|
||||
password: cleanPassword === '' ? null : cleanPassword,
|
||||
embedTitle: cleanEmbedTitle === '' ? null : cleanEmbedTitle,
|
||||
embedColor: cleanEmbedColor === '' ? null : cleanEmbedColor,
|
||||
embedSiteName: cleanEmbedSiteName === '' ? null : cleanEmbedSiteName,
|
||||
domains,
|
||||
};
|
||||
|
||||
const newUser = await useFetch('/api/user', 'PATCH', data);
|
||||
|
||||
if (newUser.error) {
|
||||
if (newUser.invalidDomains) {
|
||||
notif.updateNotification(id, {
|
||||
message: <>
|
||||
<Text mt='xs'>The following domains are invalid:</Text>
|
||||
{newUser.invalidDomains.map(err => (
|
||||
<>
|
||||
<Text color='gray' key={randomId()}>{err.domain}: {err.reason}</Text>
|
||||
<Space h='md' />
|
||||
</>
|
||||
))}
|
||||
</>,
|
||||
color: 'red',
|
||||
icon: <Cross1Icon />,
|
||||
});
|
||||
}
|
||||
notif.updateNotification(id, {
|
||||
title: 'Couldn\'t save user',
|
||||
message: newUser.error,
|
||||
color: 'red',
|
||||
icon: <Cross1Icon />,
|
||||
});
|
||||
} else {
|
||||
dispatch(updateUser(newUser));
|
||||
notif.updateNotification(id, {
|
||||
title: 'Saved User',
|
||||
message: '',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const exportData = async () => {
|
||||
const res = await useFetch('/api/user/export', 'POST');
|
||||
if (res.url) {
|
||||
notif.showNotification({
|
||||
title: 'Export started...',
|
||||
loading: true,
|
||||
message: 'If you have a lot of files, the export may take a while. The list of exports will be updated every 30s.',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getExports = async () => {
|
||||
const res = await useFetch('/api/user/export');
|
||||
|
||||
setExports(res.exports.map(s => ({
|
||||
date: new Date(Number(s.name.split('_')[3].slice(0, -4))),
|
||||
size: s.size,
|
||||
full: s.name,
|
||||
})).sort((a, b) => a.date.getTime() - b.date.getTime()));
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
const res = await useFetch('/api/user/files', 'DELETE', {
|
||||
all: true,
|
||||
});
|
||||
|
||||
if (!res.count) {
|
||||
notif.showNotification({
|
||||
title: 'Couldn\'t delete files',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <Cross1Icon />,
|
||||
});
|
||||
} else {
|
||||
notif.showNotification({
|
||||
title: 'Deleted files',
|
||||
message: `${res.count} files deleted`,
|
||||
color: 'green',
|
||||
icon: <TrashIcon />,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const openDeleteModal = () => modals.openConfirmModal({
|
||||
title: 'Are you sure you want to delete all of your images?',
|
||||
closeOnConfirm: false,
|
||||
centered: true,
|
||||
overlayBlur: 3,
|
||||
labels: { confirm: 'Yes', cancel: 'No' },
|
||||
onConfirm: () => {
|
||||
modals.openConfirmModal({
|
||||
title: 'Are you really sure?',
|
||||
centered: true,
|
||||
overlayBlur: 3,
|
||||
labels: { confirm: 'Yes', cancel: 'No' },
|
||||
onConfirm: () => {
|
||||
handleDelete();
|
||||
modals.closeAll();
|
||||
},
|
||||
onCancel: () => {
|
||||
modals.closeAll();
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const interval = useInterval(() => getExports(), 30000);
|
||||
useEffect(() => {
|
||||
getExports();
|
||||
interval.start();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title>Manage User</Title>
|
||||
@@ -215,54 +97,18 @@ export default function Manage() {
|
||||
</VarsTooltip>
|
||||
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
|
||||
<TextInput id='username' label='Username' {...form.getInputProps('username')} />
|
||||
<PasswordInput id='password' label='Password' description='Leave blank to keep your old password' {...form.getInputProps('password')} />
|
||||
<TextInput id='password' label='Password'type='password' {...form.getInputProps('password')} />
|
||||
<TextInput id='embedTitle' label='Embed Title' {...form.getInputProps('embedTitle')} />
|
||||
<ColorInput id='embedColor' label='Embed Color' {...form.getInputProps('embedColor')} />
|
||||
<TextInput id='embedSiteName' label='Embed Site Name' {...form.getInputProps('embedSiteName')} />
|
||||
<MultiSelect
|
||||
id='domains'
|
||||
label='Domains'
|
||||
data={domains}
|
||||
placeholder='Leave blank if you dont want random domain selection.'
|
||||
creatable
|
||||
searchable
|
||||
clearable
|
||||
getCreateLabel={query => `Add ${query}`}
|
||||
onCreate={query => setDomains((current) => [...current, query])}
|
||||
{...form.getInputProps('domains')}
|
||||
/>
|
||||
|
||||
<Group position='right' mt='md'>
|
||||
<Group position='right' sx={{ paddingTop: 12 }}>
|
||||
<Button
|
||||
type='submit'
|
||||
>Save User</Button>
|
||||
</Group>
|
||||
</form>
|
||||
|
||||
<Box mb='md'>
|
||||
<Title>Manage Data</Title>
|
||||
<Text color='gray'>Delete, or export your data into a zip file.</Text>
|
||||
</Box>
|
||||
|
||||
<Group>
|
||||
<Button onClick={openDeleteModal} rightIcon={<TrashIcon />}>Delete All Data</Button>
|
||||
<ExportDataTooltip><Button onClick={exportData} rightIcon={<DownloadIcon />}>Export Data</Button></ExportDataTooltip>
|
||||
</Group>
|
||||
<Card mt={22}>
|
||||
<SmallTable
|
||||
columns={[
|
||||
{ id: 'name', name: 'Name' },
|
||||
{ id: 'date', name: 'Date' },
|
||||
{ id: 'size', name: 'Size' },
|
||||
]}
|
||||
rows={exports ? exports.map((x, i) => ({
|
||||
name: <Link href={'/api/user/export?name=' + x.full}>Export {i + 1}</Link>,
|
||||
date: x.date.toLocaleString(),
|
||||
size: bytesToRead(x.size),
|
||||
})) : []} />
|
||||
</Card>
|
||||
|
||||
<Title my='md'>ShareX Config</Title>
|
||||
<Title sx={{ paddingTop: 12, paddingBottom: 12 }}>ShareX Config</Title>
|
||||
<Group>
|
||||
<Button onClick={() => genShareX(false)} rightIcon={<DownloadIcon />}>ShareX Config</Button>
|
||||
<Button onClick={() => genShareX(true)} rightIcon={<DownloadIcon />}>ShareX Config with Embed</Button>
|
||||
@@ -270,4 +116,4 @@ export default function Manage() {
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,66 @@
|
||||
import { SimpleGrid, Skeleton, Title } from '@mantine/core';
|
||||
import Card from 'components/Card';
|
||||
import MutedText from 'components/MutedText';
|
||||
import { SmallTable } from 'components/SmallTable';
|
||||
import { bytesToRead } from 'lib/clientUtils';
|
||||
import useFetch from 'lib/hooks/useFetch';
|
||||
import { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import Card from 'components/Card';
|
||||
import Image from 'components/Image';
|
||||
import ImagesTable from 'components/ImagesTable';
|
||||
import useFetch from 'lib/hooks/useFetch';
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import { Box, Text, Table, Skeleton, Title, SimpleGrid } from '@mantine/core';
|
||||
import { randomId, useClipboard } from '@mantine/hooks';
|
||||
import Link from 'components/Link';
|
||||
import { CopyIcon, Cross1Icon, TrashIcon } from '@modulz/radix-icons';
|
||||
import { useNotifications } from '@mantine/notifications';
|
||||
|
||||
type Aligns = 'inherit' | 'right' | 'left' | 'center' | 'justify';
|
||||
|
||||
export function bytesToRead(bytes: number) {
|
||||
if (isNaN(bytes)) return '0.0 B';
|
||||
if (bytes === Infinity) return '0.0 B';
|
||||
const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB'];
|
||||
let num = 0;
|
||||
|
||||
while (bytes > 1024) {
|
||||
bytes /= 1024;
|
||||
++num;
|
||||
}
|
||||
|
||||
return `${bytes.toFixed(1)} ${units[num]}`;
|
||||
}
|
||||
|
||||
function StatText({ children }) {
|
||||
return <Text color='gray' size='xl'>{children}</Text>;
|
||||
}
|
||||
|
||||
function StatTable({ 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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const user = useStoreSelector(state => state.user);
|
||||
|
||||
export default function Stats() {
|
||||
const [stats, setStats] = useState(null);
|
||||
|
||||
const update = async () => {
|
||||
@@ -20,7 +74,7 @@ export default function Stats() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title mb='md'>Stats</Title>
|
||||
<Title>Stats</Title>
|
||||
<SimpleGrid
|
||||
cols={3}
|
||||
spacing='lg'
|
||||
@@ -29,22 +83,22 @@ export default function Stats() {
|
||||
]}
|
||||
>
|
||||
<Card name='Size' sx={{ height: '100%' }}>
|
||||
<MutedText>{stats ? stats.size : <Skeleton height={8} />}</MutedText>
|
||||
<StatText>{stats ? stats.size : <Skeleton height={8} />}</StatText>
|
||||
<Title order={2}>Average Size</Title>
|
||||
<MutedText>{stats ? bytesToRead(stats.size_num / stats.count) : <Skeleton height={8} />}</MutedText>
|
||||
<StatText>{stats ? bytesToRead(stats.size_num / stats.count) : <Skeleton height={8} />}</StatText>
|
||||
</Card>
|
||||
<Card name='Images' sx={{ height: '100%' }}>
|
||||
<MutedText>{stats ? stats.count : <Skeleton height={8} />}</MutedText>
|
||||
<StatText>{stats ? stats.count : <Skeleton height={8} />}</StatText>
|
||||
<Title order={2}>Views</Title>
|
||||
<MutedText>{stats ? `${stats.views_count} (${isNaN(stats.views_count / stats.count) ? 0 : Math.round(stats.views_count / stats.count)})` : <Skeleton height={8} />}</MutedText>
|
||||
<StatText>{stats ? `${stats.views_count} (${isNaN(stats.views_count / stats.count) ? 0 : Math.round(stats.views_count / stats.count)})` : <Skeleton height={8} />}</StatText>
|
||||
</Card>
|
||||
<Card name='Users' sx={{ height: '100%' }}>
|
||||
<MutedText>{stats ? stats.count_users : <Skeleton height={8} />}</MutedText>
|
||||
<StatText>{stats ? stats.count_users : <Skeleton height={8} />}</StatText>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
<Card name='Files per User' mt={22}>
|
||||
<SmallTable
|
||||
<StatTable
|
||||
columns={[
|
||||
{ id: 'username', name: 'Name' },
|
||||
{ id: 'count', name: 'Files' },
|
||||
@@ -52,7 +106,7 @@ export default function Stats() {
|
||||
rows={stats ? stats.count_by_user : []} />
|
||||
</Card>
|
||||
<Card name='Types' mt={22}>
|
||||
<SmallTable
|
||||
<StatTable
|
||||
columns={[
|
||||
{ id: 'mimetype', name: 'Type' },
|
||||
{ id: 'count', name: 'Count' },
|
||||
|
||||
@@ -1,21 +1,42 @@
|
||||
import { Button, Collapse, Group, Progress, Title, useMantineTheme } from '@mantine/core';
|
||||
import { randomId, useClipboard } from '@mantine/hooks';
|
||||
import { useNotifications } from '@mantine/notifications';
|
||||
import { CrossCircledIcon, UploadIcon } from '@modulz/radix-icons';
|
||||
import Dropzone from 'components/dropzone/Dropzone';
|
||||
import FileDropzone from 'components/dropzone/DropzoneFile';
|
||||
import Link from 'components/Link';
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
export default function Upload() {
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import Link from 'components/Link';
|
||||
import { Button, Group, Text, useMantineTheme } from '@mantine/core';
|
||||
import { ImageIcon, UploadIcon, CrossCircledIcon } from '@modulz/radix-icons';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { useNotifications } from '@mantine/notifications';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
|
||||
function ImageUploadIcon({ status, ...props }) {
|
||||
if (status.accepted) {
|
||||
return <UploadIcon {...props} />;
|
||||
}
|
||||
|
||||
if (status.rejected) {
|
||||
return <CrossCircledIcon {...props} />;
|
||||
}
|
||||
|
||||
return <ImageIcon {...props} />;
|
||||
}
|
||||
|
||||
function getIconColor(status, theme) {
|
||||
return status.accepted
|
||||
? theme.colors[theme.primaryColor][6]
|
||||
: status.rejected
|
||||
? theme.colors.red[6]
|
||||
: theme.colorScheme === 'dark'
|
||||
? theme.colors.dark[0]
|
||||
: theme.black;
|
||||
}
|
||||
|
||||
export default function Upload({ route }) {
|
||||
const theme = useMantineTheme();
|
||||
const notif = useNotifications();
|
||||
const clipboard = useClipboard();
|
||||
const user = useStoreSelector(state => state.user);
|
||||
|
||||
const [files, setFiles] = useState([]);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('paste', (e: ClipboardEvent) => {
|
||||
@@ -23,15 +44,13 @@ export default function Upload() {
|
||||
const blob = item.getAsFile();
|
||||
setFiles([...files, new File([blob], blob.name, { type: blob.type })]);
|
||||
notif.showNotification({
|
||||
title: 'Image imported from clipboard',
|
||||
title: 'Image Imported',
|
||||
message: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
const handleUpload = async () => {
|
||||
setProgress(0);
|
||||
setLoading(true);
|
||||
const body = new FormData();
|
||||
for (let i = 0; i !== files.length; ++i) body.append('file', files[i]);
|
||||
|
||||
@@ -39,60 +58,60 @@ export default function Upload() {
|
||||
title: 'Uploading Images...',
|
||||
message: '',
|
||||
loading: true,
|
||||
autoClose: false,
|
||||
});
|
||||
|
||||
const req = new XMLHttpRequest();
|
||||
req.upload.addEventListener('progress', e => {
|
||||
if (e.lengthComputable) {
|
||||
setProgress(Math.round(e.loaded / e.total * 100));
|
||||
}
|
||||
const res = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': user.token,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
req.addEventListener('load', e => {
|
||||
// @ts-ignore not sure why it thinks response doesnt exist, but it does.
|
||||
const json = JSON.parse(e.target.response);
|
||||
setLoading(false);
|
||||
|
||||
if (json.error === undefined) {
|
||||
notif.updateNotification(id, {
|
||||
title: 'Upload Successful',
|
||||
message: <>Copied first image to clipboard! <br />{json.files.map(x => (<Link key={x} href={x}>{x}<br /></Link>))}</>,
|
||||
color: 'green',
|
||||
icon: <UploadIcon />,
|
||||
});
|
||||
clipboard.copy(json.files[0]);
|
||||
setFiles([]);
|
||||
} else {
|
||||
notif.updateNotification(id, {
|
||||
title: 'Upload Failed',
|
||||
message: json.error,
|
||||
color: 'red',
|
||||
icon: <CrossCircledIcon />,
|
||||
});
|
||||
}
|
||||
setProgress(0);
|
||||
}, false);
|
||||
|
||||
req.open('POST', '/api/upload');
|
||||
req.setRequestHeader('Authorization', user.token);
|
||||
req.send(body);
|
||||
const json = await res.json();
|
||||
if (res.ok && json.error === undefined) {
|
||||
notif.updateNotification(id, {
|
||||
title: 'Upload Successful',
|
||||
message: <>Copied first image to clipboard! <br/>{json.files.map(x => (<Link key={x} href={x}>{x}<br/></Link>))}</>,
|
||||
color: 'green',
|
||||
icon: <UploadIcon />,
|
||||
});
|
||||
clipboard.copy(json.url);
|
||||
setFiles([]);
|
||||
} else {
|
||||
notif.updateNotification(id, {
|
||||
title: 'Upload Failed',
|
||||
message: json.error,
|
||||
color: 'red',
|
||||
icon: <CrossCircledIcon />,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title mb='md'>Upload Files</Title>
|
||||
|
||||
<Dropzone loading={loading} onDrop={(f) => setFiles([...files, ...f])}>
|
||||
<Group position='center' spacing='md'>
|
||||
{files.map(file => (<FileDropzone key={randomId()} file={file} />))}
|
||||
</Group>
|
||||
<Dropzone
|
||||
onDrop={(f) => setFiles([...files, ...f])}
|
||||
>
|
||||
{(status) => (
|
||||
<>
|
||||
<Group position='center' spacing='xl' style={{ minHeight: 220, pointerEvents: 'none' }}>
|
||||
<ImageUploadIcon
|
||||
status={status}
|
||||
style={{ width: 80, height: 80, color: getIconColor(status, theme) }}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Text size='xl' inline>
|
||||
Drag images here or click to select files
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group position='center' spacing='xl' style={{ pointerEvents: 'none' }}>
|
||||
{files.map(file => (<Text key={file.name} weight='bold'>{file.name}</Text>))}
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Dropzone>
|
||||
|
||||
<Collapse in={progress !== 0}>
|
||||
{progress !== 0 && <Progress mt='md' value={progress} animate />}
|
||||
</Collapse>
|
||||
|
||||
<Group position='right'>
|
||||
<Button leftIcon={<UploadIcon />} mt={12} onClick={handleUpload}>Upload</Button>
|
||||
</Group>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { ActionIcon, Button, Card, Group, Modal, SimpleGrid, Skeleton, TextInput, Title } from '@mantine/core';
|
||||
import { useClipboard, useForm } from '@mantine/hooks';
|
||||
import { useNotifications } from '@mantine/notifications';
|
||||
import { CopyIcon, Cross1Icon, Link1Icon, PlusIcon, TrashIcon } from '@modulz/radix-icons';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useClipboard, useForm } from '@mantine/hooks';
|
||||
import { CopyIcon, Cross1Icon, Link1Icon, PlusIcon, TrashIcon } from '@modulz/radix-icons';
|
||||
import { useNotifications } from '@mantine/notifications';
|
||||
import { Modal, Title, Group, Button, Box, Card, TextInput, ActionIcon, SimpleGrid, Skeleton } from '@mantine/core';
|
||||
|
||||
export default function Urls() {
|
||||
const user = useStoreSelector(state => state.user);
|
||||
@@ -57,18 +58,12 @@ export default function Urls() {
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async values => {
|
||||
const onSubmit = async (values) => {
|
||||
const cleanURL = values.url.trim();
|
||||
const cleanVanity = values.vanity.trim();
|
||||
|
||||
if (cleanURL === '') return form.setFieldError('url', 'URL can\'t be nothing');
|
||||
|
||||
try {
|
||||
new URL(cleanURL);
|
||||
} catch (e) {
|
||||
return form.setFieldError('url', 'Invalid URL');
|
||||
}
|
||||
|
||||
const data = {
|
||||
url: cleanURL,
|
||||
vanity: cleanVanity === '' ? null : cleanVanity,
|
||||
@@ -126,8 +121,8 @@ export default function Urls() {
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Group mb='md'>
|
||||
<Title>URLs</Title>
|
||||
<Group>
|
||||
<Title sx={{ marginBottom: 12 }}>URLs</Title>
|
||||
<ActionIcon variant='filled' color='primary' onClick={() => setCreateOpen(true)}><PlusIcon/></ActionIcon>
|
||||
</Group>
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { ActionIcon, Avatar, Button, Card, Group, Modal, SimpleGrid, Skeleton, Switch, TextInput, Title } from '@mantine/core';
|
||||
import { useForm } from '@mantine/hooks';
|
||||
import { useModals } from '@mantine/modals';
|
||||
import { useNotifications } from '@mantine/notifications';
|
||||
import { Cross1Icon, PlusIcon, TrashIcon } from '@modulz/radix-icons';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from '@mantine/hooks';
|
||||
import { Avatar, Modal, Title, TextInput, Group, Button, Card, Grid, ActionIcon, SimpleGrid, Switch, Skeleton } from '@mantine/core';
|
||||
import { Cross1Icon, PlusIcon, TrashIcon } from '@modulz/radix-icons';
|
||||
import { useNotifications } from '@mantine/notifications';
|
||||
|
||||
|
||||
function CreateUserModal({ open, setOpen, updateUsers }) {
|
||||
@@ -19,7 +19,7 @@ function CreateUserModal({ open, setOpen, updateUsers }) {
|
||||
});
|
||||
const notif = useNotifications();
|
||||
|
||||
const onSubmit = async values => {
|
||||
const onSubmit = async (values) => {
|
||||
const cleanUsername = values.username.trim();
|
||||
const cleanPassword = values.password.trim();
|
||||
if (cleanUsername === '') return form.setFieldError('username', 'Username can\'t be nothing');
|
||||
@@ -51,14 +51,14 @@ function CreateUserModal({ open, setOpen, updateUsers }) {
|
||||
|
||||
updateUsers();
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title={<Title>Create User</Title>}
|
||||
>
|
||||
<form onSubmit={form.onSubmit(v => onSubmit(v))}>
|
||||
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
|
||||
<TextInput id='username' label='Username' {...form.getInputProps('username')} />
|
||||
<TextInput id='password' label='Password' type='password' {...form.getInputProps('password')} />
|
||||
<Switch mt={12} id='administrator' label='Administrator' {...form.getInputProps('administrator')} />
|
||||
@@ -76,15 +76,22 @@ export default function Users() {
|
||||
const user = useStoreSelector(state => state.user);
|
||||
const router = useRouter();
|
||||
const notif = useNotifications();
|
||||
const modals = useModals();
|
||||
|
||||
const [users, setUsers] = useState([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleDelete = async (user, delete_images) => {
|
||||
const updateUsers = async () => {
|
||||
const us = await useFetch('/api/users');
|
||||
if (!us.error) {
|
||||
setUsers(us);
|
||||
} else {
|
||||
router.push('/dashboard');
|
||||
};
|
||||
};
|
||||
|
||||
const handleDelete = async (user) => {
|
||||
const res = await useFetch('/api/users', 'DELETE', {
|
||||
id: user.id,
|
||||
delete_images,
|
||||
});
|
||||
if (res.error) {
|
||||
notif.showNotification({
|
||||
@@ -100,42 +107,9 @@ export default function Users() {
|
||||
color: 'green',
|
||||
icon: <TrashIcon />,
|
||||
});
|
||||
updateUsers();
|
||||
}
|
||||
};
|
||||
|
||||
// 2-step modal for deleting user if they want to delete their images too.
|
||||
const openDeleteModal = user => modals.openConfirmModal({
|
||||
title: `Delete ${user.username}?`,
|
||||
closeOnConfirm: false,
|
||||
centered: true,
|
||||
overlayBlur: 3,
|
||||
labels: { confirm: 'Yes', cancel: 'No' },
|
||||
onConfirm: () => {
|
||||
modals.openConfirmModal({
|
||||
title: `Delete ${user.username}'s images?`,
|
||||
labels: { confirm: 'Yes', cancel: 'No' },
|
||||
centered: true,
|
||||
overlayBlur: 3,
|
||||
onConfirm: () => {
|
||||
handleDelete(user, true);
|
||||
modals.closeAll();
|
||||
},
|
||||
onCancel: () => {
|
||||
handleDelete(user, false);
|
||||
modals.closeAll();
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const updateUsers = async () => {
|
||||
const us = await useFetch('/api/users');
|
||||
if (!us.error) {
|
||||
setUsers(us);
|
||||
} else {
|
||||
router.push('/dashboard');
|
||||
};
|
||||
updateUsers();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -147,7 +121,7 @@ export default function Users() {
|
||||
<CreateUserModal open={open} setOpen={setOpen} updateUsers={updateUsers} />
|
||||
<Group>
|
||||
<Title sx={{ marginBottom: 12 }}>Users</Title>
|
||||
<ActionIcon variant='filled' color='primary' onClick={() => setOpen(true)}><PlusIcon /></ActionIcon>
|
||||
<ActionIcon variant='filled' color='primary' onClick={() => setOpen(true)}><PlusIcon/></ActionIcon>
|
||||
</Group>
|
||||
<SimpleGrid
|
||||
cols={3}
|
||||
@@ -156,23 +130,23 @@ export default function Users() {
|
||||
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||
]}
|
||||
>
|
||||
{users.length ? users.filter(x => x.username !== user.username).map(user => (
|
||||
{users.length ? users.filter(x => x.username !== user.username).map((user, i) => (
|
||||
<Card key={user.id} sx={{ maxWidth: '100%' }}>
|
||||
<Group position='apart'>
|
||||
<Group position='left'>
|
||||
<Avatar color={user.administrator ? 'primary' : 'dark'}>{user.username[0]}</Avatar>
|
||||
<Avatar color={user.administrator ? 'primary' : 'dark'}>{user.username[0]}</Avatar>
|
||||
<Title>{user.username}</Title>
|
||||
</Group>
|
||||
<Group position='right'>
|
||||
<ActionIcon aria-label='delete' onClick={() => openDeleteModal(user)}>
|
||||
<ActionIcon aria-label='delete' onClick={() => handleDelete(user)}>
|
||||
<TrashIcon />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
)) : [1, 2, 3, 4].map(x => (
|
||||
)): [1,2,3,4].map(x => (
|
||||
<div key={x}>
|
||||
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }} />
|
||||
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }}/>
|
||||
</div>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Image, User } from '@prisma/client';
|
||||
import { Image, User } from '@prisma/client';
|
||||
|
||||
export function parse(str: string, image: Image, user: User) {
|
||||
if (!str) return null;
|
||||
@@ -13,18 +13,4 @@ export function parse(str: string, image: Image, user: User) {
|
||||
.replace(/{image.created_at.full_string}/gi, image.created_at.toLocaleString())
|
||||
.replace(/{image.created_at.time_string}/gi, image.created_at.toLocaleTimeString())
|
||||
.replace(/{image.created_at.date_string}/gi, image.created_at.toLocaleDateString());
|
||||
}
|
||||
|
||||
export function bytesToRead(bytes: number) {
|
||||
if (isNaN(bytes)) return '0.0 B';
|
||||
if (bytes === Infinity) return '0.0 B';
|
||||
const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB'];
|
||||
let num = 0;
|
||||
|
||||
while (bytes > 1024) {
|
||||
bytes /= 1024;
|
||||
++num;
|
||||
}
|
||||
|
||||
return `${bytes.toFixed(1)} ${units[num]}`;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import readConfig from './config/readConfig';
|
||||
import validateConfig from './config/validateConfig';
|
||||
import type { Config } from './types';
|
||||
import readConfig from './readConfig';
|
||||
import validateConfig from '../../server/validateConfig';
|
||||
|
||||
if (!global.config) global.config = validateConfig(readConfig());
|
||||
if (!global.config) global.config = validateConfig(readConfig()) as unknown as Config;
|
||||
|
||||
export default global.config;
|
||||
@@ -1,114 +0,0 @@
|
||||
export interface ConfigCore {
|
||||
// Whether to return http or https links
|
||||
secure: boolean;
|
||||
|
||||
// Used for signing of cookies and other stuff
|
||||
secret: string;
|
||||
|
||||
// The host Zipline will run on
|
||||
host: string;
|
||||
|
||||
// The port Zipline will run on
|
||||
port: number;
|
||||
|
||||
// The PostgreSQL database url
|
||||
database_url: string;
|
||||
|
||||
// Whether or not to log stuff
|
||||
logger: boolean;
|
||||
|
||||
// The interval to store stats
|
||||
stats_interval: number;
|
||||
}
|
||||
|
||||
export interface ConfigDatasource {
|
||||
// The type of datasource
|
||||
type: 'local' | 's3' | 'swift';
|
||||
|
||||
// The local datasource, the default
|
||||
local: ConfigLocalDatasource;
|
||||
|
||||
// The s3 datasource
|
||||
s3?: ConfigS3Datasource;
|
||||
// The Swift datasource
|
||||
swift?: ConfigSwiftDatasource;
|
||||
}
|
||||
|
||||
export interface ConfigLocalDatasource {
|
||||
// The directory to store files in
|
||||
directory: string;
|
||||
}
|
||||
|
||||
export interface ConfigS3Datasource {
|
||||
// The access key id for the s3 bucket
|
||||
access_key_id: string;
|
||||
|
||||
// The secret access key for the s3 bucket
|
||||
secret_access_key: string;
|
||||
|
||||
// Not required, but if using a non-aws S3 service you can specify the endpoint
|
||||
endpoint?: string;
|
||||
|
||||
// The S3 bucket to store files in
|
||||
bucket: string;
|
||||
|
||||
// If true Zipline will attempt to connect to the bucket via the url "https://s3.amazonaws.com/{bucket}/stuff"
|
||||
// If false Zipline will attempt to connect to the bucket via the url "http://{bucket}.s3.amazonaws.com/stuff"
|
||||
force_s3_path: boolean;
|
||||
|
||||
// Region
|
||||
// aws region, default will be us-east-1 (if using a non-aws S3 service this might work for you)
|
||||
region?: string;
|
||||
}
|
||||
|
||||
export interface ConfigSwiftDatasource {
|
||||
container: string;
|
||||
auth_endpoint: string;
|
||||
username: string;
|
||||
password: string;
|
||||
project_id: string;
|
||||
domain_id?: string;
|
||||
region_id?: string;
|
||||
}
|
||||
|
||||
export interface ConfigUploader {
|
||||
// The route uploads will be served on
|
||||
route: string;
|
||||
|
||||
// Length of random chars to generate for file names
|
||||
length: number;
|
||||
|
||||
// Admin file upload limit
|
||||
admin_limit: number;
|
||||
|
||||
// User file upload limit
|
||||
user_limit: number;
|
||||
|
||||
// Disabled extensions to block from uploading
|
||||
disabled_extensions: string[];
|
||||
}
|
||||
|
||||
export interface ConfigUrls {
|
||||
// The route urls will be served on
|
||||
route: string;
|
||||
|
||||
// Length of random chars to generate for urls
|
||||
length: number;
|
||||
}
|
||||
|
||||
// Ratelimiting for users/admins, setting them to 0 disables ratelimiting
|
||||
export interface ConfigRatelimit {
|
||||
// Ratelimit for users
|
||||
user: number;
|
||||
|
||||
// Ratelimit for admins
|
||||
admin: number;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
core: ConfigCore;
|
||||
uploader: ConfigUploader;
|
||||
urls: ConfigUrls;
|
||||
ratelimit: ConfigRatelimit;
|
||||
datasource: ConfigDatasource;
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { Config } from 'lib/config/Config';
|
||||
import { object, bool, string, number, boolean, array } from '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(),
|
||||
datasource: object({
|
||||
type: string().oneOf(['local', 's3', 'swift']).default('local'),
|
||||
local: object({
|
||||
directory: string().default('./uploads'),
|
||||
}),
|
||||
s3: object({
|
||||
access_key_id: string(),
|
||||
secret_access_key: string(),
|
||||
endpoint: string(),
|
||||
bucket: string(),
|
||||
force_s3_path: boolean().default(false),
|
||||
region: string().default('us-east-1'),
|
||||
}).notRequired(),
|
||||
swift: object({
|
||||
username: string(),
|
||||
password: string(),
|
||||
auth_endpoint: string(),
|
||||
container: string(),
|
||||
project_id: string(),
|
||||
domain_id: string().default('default'),
|
||||
region_id: string().nullable(),
|
||||
}),
|
||||
}).required(),
|
||||
uploader: object({
|
||||
route: string().default('/u'),
|
||||
embed_route: string().default('/a'),
|
||||
length: number().default(6),
|
||||
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),
|
||||
}),
|
||||
});
|
||||
|
||||
export default function validate(config): Config {
|
||||
try {
|
||||
const validated = validator.validateSync(config, { abortEarly: false });
|
||||
switch (validated.datasource.type) {
|
||||
case 's3': {
|
||||
const errors = [];
|
||||
if (!validated.datasource.s3.access_key_id)
|
||||
errors.push('datasource.s3.access_key_id is a required field');
|
||||
if (!validated.datasource.s3.secret_access_key)
|
||||
errors.push('datasource.s3.secret_access_key is a required field');
|
||||
if (!validated.datasource.s3.bucket)
|
||||
errors.push('datasource.s3.bucket is a required field');
|
||||
if (!validated.datasource.s3.endpoint)
|
||||
errors.push('datasource.s3.endpoint is a required field');
|
||||
if (errors.length) throw { errors };
|
||||
break;
|
||||
}
|
||||
case 'swift': {
|
||||
const errors = [];
|
||||
if (!validated.datasource.swift.container)
|
||||
errors.push('datasource.swift.container is a required field');
|
||||
if (!validated.datasource.swift.project_id)
|
||||
errors.push('datasource.swift.project_id is a required field');
|
||||
if (!validated.datasource.swift.auth_endpoint)
|
||||
errors.push('datasource.swift.auth_endpoint is a required field');
|
||||
if (!validated.datasource.swift.password)
|
||||
errors.push('datasource.swift.password is a required field');
|
||||
if (!validated.datasource.swift.username)
|
||||
errors.push('datasource.swift.username is a required field');
|
||||
if (errors.length) throw { errors };
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return validated as unknown as Config;
|
||||
} catch (e) {
|
||||
if (process.env.ZIPLINE_DOCKER_BUILD) return null;
|
||||
throw `${e.errors.length} errors occured\n${e.errors.map((x) => '\t' + x).join('\n')}`;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import config from './config';
|
||||
import { Swift, Local, S3 } from './datasources';
|
||||
import Logger from './logger';
|
||||
|
||||
if (!global.datasource) {
|
||||
switch (config.datasource.type) {
|
||||
case 's3':
|
||||
global.datasource = new S3(config.datasource.s3);
|
||||
Logger.get('datasource').info(`using S3(${config.datasource.s3.bucket}) datasource`);
|
||||
break;
|
||||
case 'local':
|
||||
global.datasource = new Local(config.datasource.local.directory);
|
||||
Logger.get('datasource').info(`using Local(${config.datasource.local.directory}) datasource`);
|
||||
break;
|
||||
case 'swift':
|
||||
global.datasource = new Swift(config.datasource.swift);
|
||||
Logger.get('datasource').info(`using Swift(${config.datasource.swift.container}) datasource`);
|
||||
break;
|
||||
default:
|
||||
throw new Error('Invalid datasource type');
|
||||
}
|
||||
}
|
||||
|
||||
export default global.datasource;
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Readable } from 'stream';
|
||||
|
||||
export abstract class Datasource {
|
||||
public name: string;
|
||||
|
||||
public abstract save(file: string, data: Buffer): Promise<void>;
|
||||
public abstract delete(file: string): Promise<void>;
|
||||
public abstract get(file: string): Readable | Promise<Readable>;
|
||||
public abstract size(): Promise<number>;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { createReadStream, existsSync, ReadStream } from 'fs';
|
||||
import { readdir, rm, stat, writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { Datasource } from '.';
|
||||
|
||||
export class Local extends Datasource {
|
||||
public name: string = 'local';
|
||||
|
||||
public constructor(public path: string) {
|
||||
super();
|
||||
}
|
||||
|
||||
public async save(file: string, data: Buffer): Promise<void> {
|
||||
await writeFile(join(process.cwd(), this.path, file), data);
|
||||
}
|
||||
|
||||
public async delete(file: string): Promise<void> {
|
||||
await rm(join(process.cwd(), this.path, file));
|
||||
}
|
||||
|
||||
public get(file: string): ReadStream {
|
||||
const full = join(process.cwd(), this.path, file);
|
||||
if (!existsSync(full)) return null;
|
||||
|
||||
try {
|
||||
return createReadStream(full);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async size(): Promise<number> {
|
||||
const files = await readdir(this.path);
|
||||
|
||||
let size = 0;
|
||||
for (let i = 0, L = files.length; i !== L; ++i) {
|
||||
const sta = await stat(join(this.path, files[i]));
|
||||
size += sta.size;
|
||||
}
|
||||
|
||||
return size;
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Datasource } from '.';
|
||||
import { Readable } from 'stream';
|
||||
import { ConfigS3Datasource } from 'lib/config/Config';
|
||||
import { Client } from 'minio';
|
||||
|
||||
export class S3 extends Datasource {
|
||||
public name: string = 'S3';
|
||||
public s3: Client;
|
||||
|
||||
public constructor(
|
||||
public config: ConfigS3Datasource,
|
||||
) {
|
||||
super();
|
||||
this.s3 = new Client({
|
||||
endPoint: config.endpoint,
|
||||
accessKey: config.access_key_id,
|
||||
secretKey: config.secret_access_key,
|
||||
pathStyle: config.force_s3_path,
|
||||
region: config.region,
|
||||
});
|
||||
}
|
||||
|
||||
public async save(file: string, data: Buffer): Promise<void> {
|
||||
await this.s3.putObject(this.config.bucket, file, data);
|
||||
}
|
||||
|
||||
public async delete(file: string): Promise<void> {
|
||||
await this.s3.removeObject(this.config.bucket, file);
|
||||
}
|
||||
|
||||
public get(file: string): Promise<Readable> {
|
||||
return new Promise((res, rej) => {
|
||||
this.s3.getObject(this.config.bucket, file, (err, stream) => {
|
||||
if (err) res(null);
|
||||
else res(stream);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async size(): Promise<number> {
|
||||
return new Promise((res, rej) => {
|
||||
const objects = this.s3.listObjectsV2(this.config.bucket, '', true);
|
||||
let size = 0;
|
||||
|
||||
objects.on('data', item => size += item.size);
|
||||
objects.on('end', err => {
|
||||
if (err) rej(err);
|
||||
else res(size);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
import { Datasource } from '.';
|
||||
import { Readable } from 'stream';
|
||||
import { ConfigSwiftDatasource } from 'lib/config/Config';
|
||||
|
||||
interface SwiftContainerOptions {
|
||||
auth_endpoint_url: string;
|
||||
credentials: {
|
||||
username: string;
|
||||
password: string;
|
||||
project_id: string;
|
||||
domain_id: string;
|
||||
container: string;
|
||||
interface?: string;
|
||||
region_id: string;
|
||||
};
|
||||
refreshMargin?: number;
|
||||
}
|
||||
|
||||
interface SwiftAuth {
|
||||
token: string;
|
||||
expires: Date;
|
||||
swiftURL: string;
|
||||
}
|
||||
|
||||
interface SwiftObject {
|
||||
bytes: number;
|
||||
content_type: string;
|
||||
hash: string;
|
||||
name: string;
|
||||
last_modified: string;
|
||||
}
|
||||
|
||||
class SwiftContainer {
|
||||
auth: SwiftAuth | null;
|
||||
|
||||
constructor(private options: SwiftContainerOptions) {
|
||||
this.auth = null;
|
||||
}
|
||||
|
||||
private findEndpointURL(catalog: any[], service: string): string | null {
|
||||
const catalogEntry = catalog.find((x) => x.name === service);
|
||||
if (!catalogEntry) return null;
|
||||
|
||||
const endpoint = catalogEntry.endpoints.find(
|
||||
(x: any) =>
|
||||
x.interface === (this.options.credentials.interface || 'public') &&
|
||||
(this.options.credentials.region_id
|
||||
? x.region_id == this.options.credentials.region_id
|
||||
: true)
|
||||
);
|
||||
|
||||
return endpoint ? endpoint.url : null;
|
||||
}
|
||||
|
||||
private async getCredentials(): Promise<SwiftAuth> {
|
||||
const payload = {
|
||||
auth: {
|
||||
identity: {
|
||||
methods: ['password'],
|
||||
password: {
|
||||
user: {
|
||||
name: this.options.credentials.username,
|
||||
password: this.options.credentials.password,
|
||||
domain: {
|
||||
id: this.options.credentials.domain_id || 'default',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scope: {
|
||||
project: {
|
||||
id: this.options.credentials.project_id,
|
||||
domain: {
|
||||
id: this.options.credentials.domain_id || 'default',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { json, headers, error } = await fetch(`${this.options.auth_endpoint_url}/auth/tokens`, {
|
||||
body: JSON.stringify(payload),
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}).then(async (e) => {
|
||||
try {
|
||||
const json = await e.json();
|
||||
return { json, headers: e.headers, error: null };
|
||||
} catch (e) {
|
||||
return { json: null, headers: null, error: e };
|
||||
}
|
||||
});
|
||||
|
||||
if (error || !json || !headers || json.error) throw new Error('Could not retrieve credentials from OpenStack, check your config file');
|
||||
|
||||
const catalog = json.token.catalog;
|
||||
// many Swift clouds use ceph radosgw to provide swift
|
||||
const swiftURL = this.findEndpointURL(catalog, 'swift') || this.findEndpointURL(catalog, 'radosgw-swift');
|
||||
if (!swiftURL) throw new Error('Couldn\'t find any "swift" or "radosgw-swift" service in the catalog');
|
||||
|
||||
return {
|
||||
token: headers.get('x-subject-token'),
|
||||
expires: new Date(json.token.expires_at),
|
||||
swiftURL,
|
||||
};
|
||||
}
|
||||
|
||||
private async authenticate() {
|
||||
if (!this.auth) this.auth = await this.getCredentials();
|
||||
const authExpiry = new Date(Date.now() + this.options.refreshMargin || 10_000);
|
||||
|
||||
if (authExpiry > this.auth.expires) this.auth = await this.getCredentials();
|
||||
const validAuth = this.auth;
|
||||
|
||||
return { swiftURL: validAuth.swiftURL, token: validAuth.token };
|
||||
}
|
||||
|
||||
private generateHeaders(token: string, extra?: any) {
|
||||
return { accept: 'application/json', 'x-auth-token': token, ...extra };
|
||||
}
|
||||
|
||||
public async listObjects(query?: string): Promise<SwiftObject[]> {
|
||||
const auth = await this.authenticate();
|
||||
return await fetch(`${auth.swiftURL}/${this.options.credentials.container}${query ? `${query.startsWith('?') ? '' : '?'}${query}` : ''}`, {
|
||||
method: 'GET',
|
||||
headers: this.generateHeaders(auth.token),
|
||||
}).then((e) => e.json());
|
||||
}
|
||||
|
||||
public async uploadObject(name: string, data: Buffer): Promise<any> {
|
||||
const auth = await this.authenticate();
|
||||
|
||||
return fetch(`${auth.swiftURL}/${this.options.credentials.container}/${name}`, {
|
||||
method: 'PUT',
|
||||
headers: this.generateHeaders(auth.token),
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
public async deleteObject(name: string): Promise<any> {
|
||||
const auth = await this.authenticate();
|
||||
|
||||
return fetch(`${auth.swiftURL}/${this.options.credentials.container}/${name}`, {
|
||||
method: 'DELETE',
|
||||
headers: this.generateHeaders(auth.token),
|
||||
});
|
||||
}
|
||||
public async getObject(name: string): Promise<Readable> {
|
||||
const auth = await this.authenticate();
|
||||
const arrayBuffer = await fetch(`${auth.swiftURL}/${this.options.credentials.container}/${name}`, {
|
||||
method: 'GET',
|
||||
headers: this.generateHeaders(auth.token, { Accept: '*/*' }),
|
||||
}).then((e) => e.arrayBuffer());
|
||||
|
||||
return Readable.from(Buffer.from(arrayBuffer));
|
||||
}
|
||||
}
|
||||
|
||||
export class Swift extends Datasource {
|
||||
public name: string = 'Swift';
|
||||
container: SwiftContainer;
|
||||
|
||||
public constructor(public config: ConfigSwiftDatasource) {
|
||||
super();
|
||||
this.container = new SwiftContainer({
|
||||
auth_endpoint_url: config.auth_endpoint,
|
||||
credentials: {
|
||||
username: config.username,
|
||||
password: config.password,
|
||||
project_id: config.project_id,
|
||||
domain_id: config.domain_id || 'default',
|
||||
container: config.container,
|
||||
region_id: config.region_id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public async save(file: string, data: Buffer): Promise<void> {
|
||||
try {
|
||||
return this.container.uploadObject(file, data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async delete(file: string): Promise<void> {
|
||||
try {
|
||||
return this.container.deleteObject(file);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public get(file: string): Promise<Readable> | Readable {
|
||||
try {
|
||||
return this.container.getObject(file);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async size(): Promise<number> {
|
||||
return this.container
|
||||
.listObjects()
|
||||
.then((objects) => objects.reduce((acc, object) => acc + object.bytes, 0))
|
||||
.catch(() => 0);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export { Datasource } from './Datasource';
|
||||
export { Local } from './Local';
|
||||
export { S3 } from './S3';
|
||||
export { Swift } from './Swift';
|
||||
38
src/lib/logger.js
Normal file
38
src/lib/logger.js
Normal file
@@ -0,0 +1,38 @@
|
||||
const { format } = require('fecha');
|
||||
const { blueBright, red, cyan } = require('colorette');
|
||||
|
||||
module.exports = class Logger {
|
||||
static get(clas) {
|
||||
if (typeof clas !== 'function') if (typeof clas !== 'string') throw new Error('not string/function');
|
||||
|
||||
const name = clas.name ?? clas;
|
||||
|
||||
return new Logger(name);
|
||||
}
|
||||
|
||||
constructor (name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
info(message) {
|
||||
console.log(this.formatMessage('INFO', this.name, message));
|
||||
}
|
||||
|
||||
error(error) {
|
||||
console.log(this.formatMessage('ERROR', this.name, error.stack ?? error));
|
||||
}
|
||||
|
||||
formatMessage(level, name, message) {
|
||||
const time = format(new Date(), 'YYYY-MM-DD hh:mm:ss,SSS A');
|
||||
return `${time} ${this.formatLevel(level)} [${blueBright(name)}] ${message}`;
|
||||
}
|
||||
|
||||
formatLevel(level) {
|
||||
switch (level) {
|
||||
case 'INFO':
|
||||
return cyan('INFO ');
|
||||
case 'ERROR':
|
||||
return red('ERROR');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,52 +0,0 @@
|
||||
import { format } from 'fecha';
|
||||
import { blueBright, red, cyan } from 'colorette';
|
||||
|
||||
export enum LoggerLevel {
|
||||
ERROR,
|
||||
INFO,
|
||||
}
|
||||
|
||||
export default class Logger {
|
||||
public name: string;
|
||||
|
||||
static get(clas: any) {
|
||||
if (typeof clas !== 'function')
|
||||
if (typeof clas !== 'string') throw new Error('not string/function');
|
||||
|
||||
const name = clas.name ?? clas;
|
||||
|
||||
return new Logger(name);
|
||||
}
|
||||
|
||||
constructor(name: string) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
info(...args: any[]) {
|
||||
console.log(this.formatMessage(LoggerLevel.INFO, this.name, args.join(' ')));
|
||||
}
|
||||
|
||||
error(...args: any[]) {
|
||||
console.log(
|
||||
this.formatMessage(
|
||||
LoggerLevel.ERROR,
|
||||
this.name,
|
||||
args.map((error) => error.stack ?? error).join(' ')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
formatMessage(level: LoggerLevel, name: string, message: string) {
|
||||
const time = format(new Date(), 'YYYY-MM-DD hh:mm:ss,SSS A');
|
||||
return `${time} ${this.formatLevel(level)} [${blueBright(name)}] ${message}`;
|
||||
}
|
||||
|
||||
formatLevel(level: LoggerLevel) {
|
||||
switch (level) {
|
||||
case LoggerLevel.INFO:
|
||||
return cyan('INFO ');
|
||||
case LoggerLevel.ERROR:
|
||||
return red('ERROR');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import type { CookieSerializeOptions } from 'cookie';
|
||||
|
||||
import { serialize } from 'cookie';
|
||||
import { sign64, unsign64 } from 'lib/util';
|
||||
import { sign64, unsign64 } from '../util';
|
||||
import config from 'lib/config';
|
||||
import prisma from 'lib/prisma';
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface NextApiFile {
|
||||
originalname: string;
|
||||
encoding: string;
|
||||
mimetype: string;
|
||||
buffer: Buffer;
|
||||
buffer: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ export type NextApiReq = NextApiRequest & {
|
||||
administrator: boolean;
|
||||
id: number;
|
||||
password: string;
|
||||
domains: string[];
|
||||
} | null | void>;
|
||||
getCookie: (name: string) => string | null;
|
||||
cleanCookie: (name: string) => void;
|
||||
@@ -34,10 +33,10 @@ export type NextApiReq = NextApiRequest & {
|
||||
|
||||
export type NextApiRes = NextApiResponse & {
|
||||
error: (message: string) => void;
|
||||
forbid: (message: string, extra?: any) => void;
|
||||
forbid: (message: string) => void;
|
||||
bad: (message: string) => void;
|
||||
json: (json: Record<string, any>) => void;
|
||||
ratelimited: (remaining: number) => void;
|
||||
json: (json: any) => void;
|
||||
ratelimited: () => void;
|
||||
setCookie: (name: string, value: unknown, options: CookieSerializeOptions) => void;
|
||||
}
|
||||
|
||||
@@ -53,12 +52,11 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
|
||||
});
|
||||
};
|
||||
|
||||
res.forbid = (message: string, extra: any = {}) => {
|
||||
res.forbid = (message: string) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.status(403);
|
||||
res.json({
|
||||
error: '403: ' + message,
|
||||
...extra,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -69,9 +67,9 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
|
||||
});
|
||||
};
|
||||
|
||||
res.ratelimited = (remaining: number) => {
|
||||
res.ratelimited = () => {
|
||||
res.status(429);
|
||||
res.setHeader('X-Ratelimit-Remaining', Math.floor(remaining / 1000));
|
||||
|
||||
res.json({
|
||||
error: '429: ratelimited',
|
||||
});
|
||||
@@ -95,7 +93,6 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
|
||||
maxAge: undefined,
|
||||
}));
|
||||
};
|
||||
|
||||
req.user = async () => {
|
||||
try {
|
||||
const userId = req.getCookie('user');
|
||||
@@ -114,7 +111,6 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
|
||||
systemTheme: true,
|
||||
token: true,
|
||||
username: true,
|
||||
domains: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import parse from '@iarna/toml/parse-string';
|
||||
import Logger from '../logger';
|
||||
import { Config } from './Config';
|
||||
const { existsSync, readFileSync } = require('fs');
|
||||
const { join } = require('path');
|
||||
const parse = require('@iarna/toml/parse-string.js');
|
||||
const Logger = require('./logger.js');
|
||||
|
||||
const e = (val, type, fn: (c: Config, v: any) => void) => ({ val, type, fn });
|
||||
const e = (val, type, fn) => ({ val, type, fn });
|
||||
|
||||
const envValues = [
|
||||
e('SECURE', 'boolean', (c, v) => c.core.secure = v),
|
||||
@@ -15,20 +14,12 @@ const envValues = [
|
||||
e('LOGGER', 'boolean', (c, v) => c.core.logger = v ?? true),
|
||||
e('STATS_INTERVAL', 'number', (c, v) => c.core.stats_interval = v),
|
||||
|
||||
e('DATASOURCE_TYPE', 'string', (c, v) => c.datasource.type = v),
|
||||
e('DATASOURCE_LOCAL_DIRECTORY', 'string', (c, v) => c.datasource.local.directory = v),
|
||||
e('DATASOURCE_S3_ACCESS_KEY_ID', 'string', (c, v) => c.datasource.s3.access_key_id = v),
|
||||
e('DATASOURCE_S3_SECRET_ACCESS_KEY', 'string', (c, v) => c.datasource.s3.secret_access_key = v),
|
||||
e('DATASOURCE_S3_ENDPOINT', 'string', (c, v) => c.datasource.s3.endpoint = v ?? null),
|
||||
e('DATASOURCE_S3_FORCE_S3_PATH', 'boolean', (c, v) => c.datasource.s3.force_s3_path = v ?? false),
|
||||
e('DATASOURCE_S3_BUCKET', 'string', (c, v) => c.datasource.s3.bucket = v),
|
||||
e('DATASOURCE_S3_REGION', 'string', (c, v) => c.datasource.s3.region = v ?? 'us-east-1'),
|
||||
|
||||
e('UPLOADER_ROUTE', 'string', (c, v) => c.uploader.route = v),
|
||||
e('UPLOADER_LENGTH', 'number', (c, v) => c.uploader.length = v),
|
||||
e('UPLOADER_DIRECTORY', 'string', (c, v) => c.uploader.directory = v),
|
||||
e('UPLOADER_ADMIN_LIMIT', 'number', (c, v) => c.uploader.admin_limit = v),
|
||||
e('UPLOADER_USER_LIMIT', 'number', (c, v) => c.uploader.user_limit = v),
|
||||
e('UPLOADER_DISABLED_EXTS', 'array', (c, v) => v ? c.uploader.disabled_extensions = v : c.uploader.disabled_extensions = []),
|
||||
e('UPLOADER_DISABLED_EXTS', 'array', (c, v) => v ? c.uploader.disabled_extentions = v : c.uploader.disabled_extentions = []),
|
||||
|
||||
e('URLS_ROUTE', 'string', (c, v) => c.urls.route = v),
|
||||
e('URLS_LENGTH', 'number', (c, v) => c.urls.length = v),
|
||||
@@ -37,13 +28,13 @@ const envValues = [
|
||||
e('RATELIMIT_ADMIN', 'number', (c, v) => c.ratelimit.user = v ?? 0),
|
||||
];
|
||||
|
||||
export default function readConfig(): Config {
|
||||
module.exports = function readConfig() {
|
||||
if (!existsSync(join(process.cwd(), 'config.toml'))) {
|
||||
if (!process.env.ZIPLINE_DOCKER_BUILD) Logger.get('config').info('reading environment');
|
||||
return tryReadEnv();
|
||||
} else {
|
||||
if (process.env.ZIPLINE_DOCKER_BUILD) return;
|
||||
|
||||
|
||||
Logger.get('config').info('reading config file');
|
||||
const str = readFileSync(join(process.cwd(), 'config.toml'), 'utf8');
|
||||
const parsed = parse(str);
|
||||
@@ -52,7 +43,7 @@ export default function readConfig(): Config {
|
||||
}
|
||||
};
|
||||
|
||||
function tryReadEnv(): Config {
|
||||
function tryReadEnv() {
|
||||
const config = {
|
||||
core: {
|
||||
secure: undefined,
|
||||
@@ -63,26 +54,13 @@ function tryReadEnv(): Config {
|
||||
logger: undefined,
|
||||
stats_interval: undefined,
|
||||
},
|
||||
datasource: {
|
||||
type: undefined,
|
||||
local: {
|
||||
directory: undefined,
|
||||
},
|
||||
s3: {
|
||||
access_key_id: undefined,
|
||||
secret_access_key: undefined,
|
||||
endpoint: undefined,
|
||||
bucket: undefined,
|
||||
force_s3_path: undefined,
|
||||
region: undefined,
|
||||
},
|
||||
},
|
||||
uploader: {
|
||||
route: undefined,
|
||||
length: undefined,
|
||||
directory: undefined,
|
||||
admin_limit: undefined,
|
||||
user_limit: undefined,
|
||||
disabled_extensions: undefined,
|
||||
disabled_extentions: undefined,
|
||||
},
|
||||
urls: {
|
||||
route: undefined,
|
||||
@@ -96,7 +74,7 @@ function tryReadEnv(): Config {
|
||||
|
||||
for (let i = 0, L = envValues.length; i !== L; ++i) {
|
||||
const envValue = envValues[i];
|
||||
let value: any = process.env[envValue.val];
|
||||
let value = process.env[envValue.val];
|
||||
|
||||
if (!value) {
|
||||
envValues[i].fn(config, undefined);
|
||||
@@ -126,4 +104,4 @@ function parseToBoolean(value) {
|
||||
|
||||
function parseToArray(value) {
|
||||
return value.split(',');
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ export interface User {
|
||||
embedColor: string;
|
||||
embedSiteName: string;
|
||||
systemTheme: string;
|
||||
domains: string[];
|
||||
}
|
||||
|
||||
const initialState: User = null;
|
||||
|
||||
@@ -1,13 +1,54 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
// https://github.com/mikecao/umami/blob/master/redux/store.js
|
||||
import { useMemo } from 'react';
|
||||
import { Action, CombinedState, configureStore, EnhancedStore } from '@reduxjs/toolkit';
|
||||
import thunk, { ThunkAction } from 'redux-thunk';
|
||||
import rootReducer from './reducers';
|
||||
import { User } from './reducers/user';
|
||||
import { useDispatch, TypedUseSelectorHook, useSelector } from 'react-redux';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: rootReducer,
|
||||
});
|
||||
let store: EnhancedStore<CombinedState<{
|
||||
user: User;
|
||||
}>>;
|
||||
|
||||
export function getStore(preloadedState) {
|
||||
return configureStore({
|
||||
reducer: rootReducer,
|
||||
middleware: [thunk],
|
||||
preloadedState,
|
||||
});
|
||||
}
|
||||
|
||||
export const initializeStore = preloadedState => {
|
||||
let _store = store ?? getStore(preloadedState);
|
||||
|
||||
if (preloadedState && store) {
|
||||
_store = getStore({
|
||||
...store.getState(),
|
||||
...preloadedState,
|
||||
});
|
||||
store = undefined;
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') return _store;
|
||||
if (!store) store = _store;
|
||||
|
||||
return _store;
|
||||
};
|
||||
|
||||
export function useStore(initialState?: User) {
|
||||
return useMemo(() => initializeStore(initialState), [initialState]);
|
||||
}
|
||||
|
||||
export type AppState = ReturnType<typeof store.getState>
|
||||
|
||||
export type AppDispatch = typeof store.dispatch
|
||||
|
||||
export type AppThunk<ReturnType = void> = ThunkAction<
|
||||
ReturnType,
|
||||
AppState,
|
||||
unknown,
|
||||
Action<User>
|
||||
>
|
||||
|
||||
export const useStoreDispatch = () => useDispatch<AppDispatch>();
|
||||
export const useStoreSelector: TypedUseSelectorHook<AppState> = useSelector;
|
||||
66
src/lib/types.ts
Normal file
66
src/lib/types.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
export interface ConfigCore {
|
||||
// Whether to return http or https links
|
||||
secure: boolean;
|
||||
|
||||
// Used for signing of cookies and other stuff
|
||||
secret: string;
|
||||
|
||||
// The host Zipline will run on
|
||||
host: string;
|
||||
|
||||
// The port Zipline will run on
|
||||
port: number;
|
||||
|
||||
// The PostgreSQL database url
|
||||
database_url: string
|
||||
|
||||
// Whether or not to log stuff
|
||||
logger: boolean;
|
||||
|
||||
// The interval to store stats
|
||||
stats_interval: number;
|
||||
}
|
||||
|
||||
export interface ConfigUploader {
|
||||
// The route uploads will be served on
|
||||
route: string;
|
||||
|
||||
// Length of random chars to generate for file names
|
||||
length: number;
|
||||
|
||||
// Where uploads are stored
|
||||
directory: string;
|
||||
|
||||
// Admin file upload limit
|
||||
admin_limit: number;
|
||||
|
||||
// User file upload limit
|
||||
user_limit: number;
|
||||
|
||||
// Disabled extensions to block from uploading
|
||||
disabled_extentions: string[];
|
||||
}
|
||||
|
||||
export interface ConfigUrls {
|
||||
// The route urls will be served on
|
||||
route: string;
|
||||
|
||||
// Length of random chars to generate for urls
|
||||
length: number;
|
||||
}
|
||||
|
||||
// Ratelimiting for users/admins, setting them to 0 disables ratelimiting
|
||||
export interface ConfigRatelimit {
|
||||
// Ratelimit for users
|
||||
user: number;
|
||||
|
||||
// Ratelimit for admins
|
||||
admin: number;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
core: ConfigCore;
|
||||
uploader: ConfigUploader;
|
||||
urls: ConfigUrls;
|
||||
ratelimit: ConfigRatelimit;
|
||||
}
|
||||
@@ -2,8 +2,9 @@ import { createHmac, randomBytes, timingSafeEqual } from 'crypto';
|
||||
import { hash, verify } from 'argon2';
|
||||
import { readdir, stat } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import prisma from 'lib/prisma';
|
||||
import prisma from './prisma';
|
||||
import { InvisibleImage, InvisibleUrl } from '@prisma/client';
|
||||
import config from './config';
|
||||
|
||||
export async function hashPassword(s: string): Promise<string> {
|
||||
return await hash(s);
|
||||
|
||||
@@ -1,25 +1,19 @@
|
||||
import React from 'react';
|
||||
import { Box, Button, Stack, Text, Title } from '@mantine/core';
|
||||
import Link from 'components/Link';
|
||||
import MutedText from 'components/MutedText';
|
||||
import { Box, Text } from '@mantine/core';
|
||||
|
||||
export default function FourOhFour() {
|
||||
return (
|
||||
<>
|
||||
<Stack
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
}}
|
||||
spacing='sm'
|
||||
>
|
||||
<Title sx={{ fontSize: 220, fontWeight: 900, lineHeight: .8 }}>404</Title>
|
||||
<MutedText sx={{ fontSize: 40, fontWeight: 500 }}>This page does not exist!</MutedText>
|
||||
<Button component={Link} href='/dashboard'>Head to the Dashboard</Button>
|
||||
</Stack>
|
||||
<Text size='xl'>404 - Not Found</Text>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Button, Stack, Title } from '@mantine/core';
|
||||
import Link from 'components/Link';
|
||||
import MutedText from 'components/MutedText';
|
||||
|
||||
export default function FiveHundred() {
|
||||
return (
|
||||
<>
|
||||
<Stack
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
}}
|
||||
spacing='sm'
|
||||
>
|
||||
<Title sx={{ fontSize: 220, fontWeight: 900, lineHeight: .8 }}>500</Title>
|
||||
<MutedText sx={{ fontSize: 40, fontWeight: 500 }}>Internal Server Error</MutedText>
|
||||
<Button component={Link} href='/dashboard'>Head to the Dashboard</Button>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,98 +1,47 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import Head from 'next/head';
|
||||
import { GetServerSideProps } from 'next';
|
||||
import { Box, Button, Modal, PasswordInput } from '@mantine/core';
|
||||
import { Box, useMantineTheme } from '@mantine/core';
|
||||
import config from 'lib/config';
|
||||
import prisma from 'lib/prisma';
|
||||
import { getFile } from '../../server/util';
|
||||
import { parse } from 'lib/clientUtils';
|
||||
import exts from 'lib/exts';
|
||||
import * as exts from '../../scripts/exts';
|
||||
import { Prism } from '@mantine/prism';
|
||||
import ZiplineTheming from 'components/Theming';
|
||||
|
||||
export default function EmbeddedImage({ image, user, pass }) {
|
||||
export default function EmbeddedImage({ image, user }) {
|
||||
const dataURL = (route: string) => `${route}/${image.file}`;
|
||||
|
||||
const [opened, setOpened] = useState(pass);
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// reapply date from workaround
|
||||
image.created_at = new Date(image.created_at);
|
||||
|
||||
const check = async () => {
|
||||
const res = await fetch(`/api/auth/image?id=${image.id}&password=${password}`);
|
||||
|
||||
if (res.ok) {
|
||||
setError('');
|
||||
updateImage(`/api/auth/image?id=${image.id}&password=${password}`);
|
||||
setOpened(false);
|
||||
} else {
|
||||
setError('Invalid password');
|
||||
}
|
||||
};
|
||||
|
||||
const updateImage = async (url?: string) => {
|
||||
|
||||
const updateImage = () => {
|
||||
const imageEl = document.getElementById('image_content') as HTMLImageElement;
|
||||
|
||||
const img = new Image();
|
||||
img.addEventListener('load', function () {
|
||||
if (this.naturalWidth > innerWidth) imageEl.width = Math.floor(this.naturalWidth * Math.min((innerHeight / this.naturalHeight), (innerWidth / this.naturalWidth)));
|
||||
else imageEl.width = this.naturalWidth;
|
||||
});
|
||||
const original = new Image;
|
||||
original.src = dataURL('/r');
|
||||
|
||||
img.src = url || dataURL('/r');
|
||||
if (url) {
|
||||
imageEl.src = url;
|
||||
};
|
||||
if (original.width > innerWidth) imageEl.width = Math.floor(original.width * Math.min((innerHeight / original.height), (innerWidth / original.width)));
|
||||
else imageEl.width = original.width;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (pass) {
|
||||
setOpened(true);
|
||||
} else {
|
||||
updateImage();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => updateImage(), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
{image.embed && (
|
||||
<>
|
||||
{user.embedSiteName && <meta property='og:site_name' content={parse(user.embedSiteName, image, user)} />}
|
||||
{user.embedTitle && <meta property='og:title' content={parse(user.embedTitle, image, user)} />}
|
||||
<meta property='theme-color' content={user.embedColor} />
|
||||
</>
|
||||
)}
|
||||
{image.mimetype.startsWith('image') && (
|
||||
<>
|
||||
<meta property='og:image' content={dataURL('/r')} />
|
||||
<meta property='twitter:card' content='summary_large_image' />
|
||||
</>
|
||||
)}
|
||||
{image.mimetype.startsWith('video') && (
|
||||
<>
|
||||
<meta property='og:video' content={dataURL('/r')} />
|
||||
<meta property='og:video:url' content={dataURL('/r')} />
|
||||
<meta property='og:video:type' content={image.mimetype} />
|
||||
{user.embedSiteName && (<meta property='og:site_name' content={parse(user.embedSiteName, image, user)} />)}
|
||||
{user.embedTitle && (<meta property='og:title' content={parse(user.embedTitle, image, user)} />)}
|
||||
<meta property='theme-color' content={user.embedColor}/>
|
||||
</>
|
||||
)}
|
||||
<meta property='og:image' content={dataURL('/r')} />
|
||||
<meta property='twitter:card' content='summary_large_image' />
|
||||
<title>{image.file}</title>
|
||||
</Head>
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => setOpened(false)}
|
||||
title='Password Protected'
|
||||
centered={true}
|
||||
withCloseButton={true}
|
||||
closeOnEscape={false}
|
||||
closeOnClickOutside={false}
|
||||
overlayBlur={3}
|
||||
>
|
||||
<PasswordInput label='Password' placeholder='Password' error={error} value={password} onChange={e => setPassword(e.target.value)} />
|
||||
<Button fullWidth onClick={() => check()} mt='md'>
|
||||
Submit
|
||||
</Button>
|
||||
</Modal>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
@@ -101,31 +50,18 @@ export default function EmbeddedImage({ image, user, pass }) {
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{image.mimetype.startsWith('image') && (
|
||||
<img src={dataURL('/r')} alt={dataURL('/r')} id='image_content' />
|
||||
)}
|
||||
|
||||
{image.mimetype.startsWith('video') && (
|
||||
<video
|
||||
src={dataURL('/r')}
|
||||
controls={true}
|
||||
autoPlay={true}
|
||||
id='image_content'
|
||||
/>
|
||||
)}
|
||||
<img src={dataURL('/r')} alt={dataURL('/r')} id='image_content' />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||
const id = context.params.id[1];
|
||||
const route = context.params.id[0];
|
||||
const serve_on_root = /(^[^\\.]+\.[^\\.]+)/.test(route);
|
||||
|
||||
const id = serve_on_root ? route : context.params.id[1];
|
||||
const uploader_route = config.uploader.route.substring(1);
|
||||
|
||||
if (route === config.urls.route.substring(1)) {
|
||||
const routes = [config.uploader.route.substring(1), config.urls.route.substring(1)];
|
||||
if (!routes.includes(route)) return { notFound: true };
|
||||
if (route === routes[1]) {
|
||||
const url = await prisma.url.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
@@ -144,10 +80,10 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||
props: {},
|
||||
redirect: {
|
||||
destination: url.destination,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
} else if (uploader_route === '' ? /(^[^\\.]+\.[^\\.]+)/.test(route) : route === uploader_route) {
|
||||
} else {
|
||||
const image = await prisma.image.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
@@ -163,7 +99,6 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||
userId: true,
|
||||
embed: true,
|
||||
created_at: true,
|
||||
password: true,
|
||||
},
|
||||
});
|
||||
if (!image) return { notFound: true };
|
||||
@@ -185,6 +120,8 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||
image.created_at = image.created_at.toString();
|
||||
|
||||
const prismRender = Object.keys(exts).includes(image.file.split('.').pop());
|
||||
// let prismRenderCode;/
|
||||
// if (prismRender) prismRenderCode = (await getFile(config.uploader.directory, id)).toString();
|
||||
if (prismRender) return {
|
||||
redirect: {
|
||||
destination: `/code/${image.file}`,
|
||||
@@ -192,25 +129,19 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||
},
|
||||
};
|
||||
|
||||
if (!image.mimetype.startsWith('image') && !image.mimetype.startsWith('video')) {
|
||||
const { default: datasource } = await import('lib/datasource');
|
||||
|
||||
const data = await datasource.get(image.file);
|
||||
if (!image.mimetype.startsWith('image')) {
|
||||
const data = await getFile(config.uploader.directory, id);
|
||||
if (!data) return { notFound: true };
|
||||
|
||||
data.pipe(context.res);
|
||||
context.res.end(data);
|
||||
return { props: {} };
|
||||
}
|
||||
const pass = image.password ? true : false;
|
||||
delete image.password;
|
||||
|
||||
return {
|
||||
props: {
|
||||
image,
|
||||
user,
|
||||
pass,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return { notFound: true };
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import Head from 'next/head';
|
||||
import { store } from 'lib/redux/store';
|
||||
import { useStore } from 'lib/redux/store';
|
||||
import ZiplineTheming from 'components/Theming';
|
||||
|
||||
export default function MyApp({ Component, pageProps }) {
|
||||
const store = useStore();
|
||||
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<Head>
|
||||
<title>{Component.title}</title>
|
||||
<meta name='viewport' content='minimum-scale=1, initial-scale=1, width=device-width' />
|
||||
</Head>
|
||||
<ZiplineTheming Component={Component} pageProps={pageProps} />
|
||||
</Provider>
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Button, Stack, Title } from '@mantine/core';
|
||||
import Link from 'components/Link';
|
||||
import MutedText from 'components/MutedText';
|
||||
|
||||
export default function Error({ statusCode }) {
|
||||
return (
|
||||
<>
|
||||
<Stack
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
}}
|
||||
spacing='sm'
|
||||
>
|
||||
<Title sx={{ fontSize: 220, fontWeight: 900, lineHeight: .8 }}>{statusCode}</Title>
|
||||
<MutedText sx={{ fontSize: 40, fontWeight: 500 }}>Something went wrong...</MutedText>
|
||||
<Button component={Link} href='/dashboard'>Head to the Dashboard</Button>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function getInitialProps({ res, err }) {
|
||||
const statusCode = res ? res.statusCode : err ? err.statusCode : 404;
|
||||
return { pageProps: { statusCode } };
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import prisma from 'lib/prisma';
|
||||
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
|
||||
import { checkPassword } from 'lib/util';
|
||||
import datasource from 'lib/datasource';
|
||||
import mimes from 'lib/mimes';
|
||||
import { extname } from 'path';
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
const { id, password } = req.query;
|
||||
|
||||
const image = await prisma.image.findFirst({
|
||||
where: {
|
||||
id: Number(id),
|
||||
},
|
||||
});
|
||||
|
||||
if (!image) return res.status(404).end(JSON.stringify({ error: 'Image not found' }));
|
||||
if (!password) return res.forbid('No password provided');
|
||||
|
||||
const valid = await checkPassword(password as string, image.password);
|
||||
if (!valid) return res.forbid('Wrong password');
|
||||
|
||||
const data = await datasource.get(image.file);
|
||||
if (!data) return res.error('Image not found');
|
||||
const mimetype = mimes[extname(image.file)] ?? 'application/octet-stream';
|
||||
res.setHeader('Content-Type', mimetype);
|
||||
|
||||
data.pipe(res);
|
||||
data.on('error', () => res.error('Image not found'));
|
||||
data.on('end', () => res.end());
|
||||
}
|
||||
|
||||
export default withZipline(handler);
|
||||
@@ -2,19 +2,22 @@ import multer from 'multer';
|
||||
import prisma from 'lib/prisma';
|
||||
import zconfig from 'lib/config';
|
||||
import { NextApiReq, NextApiRes, withZipline } from 'lib/middleware/withZipline';
|
||||
import { createInvisImage, randomChars, hashPassword } from 'lib/util';
|
||||
import { createInvisImage, randomChars } from 'lib/util';
|
||||
import { writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import Logger from 'lib/logger';
|
||||
import { ImageFormat, InvisibleImage } from '@prisma/client';
|
||||
import { format as formatDate } from 'fecha';
|
||||
import datasource from 'lib/datasource';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
const uploader = multer();
|
||||
const uploader = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
});
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
if (req.method !== 'POST') return res.forbid('invalid method');
|
||||
if (!req.headers.authorization) return res.forbid('no authorization');
|
||||
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
token: req.headers.authorization,
|
||||
@@ -22,58 +25,37 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
});
|
||||
|
||||
if (!user) return res.forbid('authorization incorect');
|
||||
if (user.ratelimit) {
|
||||
const remaining = user.ratelimit.getTime() - Date.now();
|
||||
if (remaining <= 0) {
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
ratelimit: null,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
return res.ratelimited(remaining);
|
||||
}
|
||||
}
|
||||
|
||||
await run(uploader.array('file'))(req, res);
|
||||
|
||||
if (user.ratelimited) return res.ratelimited();
|
||||
if (!req.files) return res.error('no files');
|
||||
|
||||
if (req.files && req.files.length === 0) return res.error('no files');
|
||||
|
||||
const rawFormat = ((req.headers.format || '') as string).toUpperCase() || 'RANDOM';
|
||||
const format: ImageFormat = Object.keys(ImageFormat).includes(rawFormat) && ImageFormat[rawFormat];
|
||||
const files = [];
|
||||
|
||||
for (let i = 0; i !== req.files.length; ++i) {
|
||||
const file = req.files[i];
|
||||
if (file.size > zconfig.uploader[user.administrator ? 'admin_limit' : 'user_limit']) return res.error(`file[${i}] size too big`);
|
||||
|
||||
const ext = file.originalname.split('.').pop();
|
||||
if (zconfig.uploader.disabled_extensions.includes(ext)) return res.error('disabled extension recieved: ' + ext);
|
||||
if (zconfig.uploader.disabled_extentions.includes(ext)) return res.error('disabled extension recieved: ' + ext);
|
||||
let fileName: string;
|
||||
|
||||
switch (format) {
|
||||
case ImageFormat.RANDOM:
|
||||
fileName = randomChars(zconfig.uploader.length);
|
||||
break;
|
||||
case ImageFormat.DATE:
|
||||
fileName = formatDate(new Date(), 'YYYY-MM-DD_HH:mm:ss');
|
||||
break;
|
||||
case ImageFormat.UUID:
|
||||
fileName = randomUUID({ disableEntropyCache: true });
|
||||
break;
|
||||
case ImageFormat.NAME:
|
||||
fileName = file.originalname.split('.')[0];
|
||||
break;
|
||||
case ImageFormat.RANDOM:
|
||||
fileName = randomChars(zconfig.uploader.length);
|
||||
break;
|
||||
case ImageFormat.DATE:
|
||||
fileName = formatDate(new Date(), 'YYYY-MM-DD_HH:mm:ss');
|
||||
break;
|
||||
case ImageFormat.UUID:
|
||||
fileName = v4();
|
||||
break;
|
||||
case ImageFormat.NAME:
|
||||
fileName = file.originalname.split('.')[0];
|
||||
break;
|
||||
}
|
||||
|
||||
let password = null;
|
||||
if (req.headers.password) {
|
||||
password = await hashPassword(req.headers.password as string);
|
||||
}
|
||||
|
||||
let invis: InvisibleImage;
|
||||
const image = await prisma.image.create({
|
||||
@@ -83,42 +65,38 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
userId: user.id,
|
||||
embed: !!req.headers.embed,
|
||||
format,
|
||||
password,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
if (req.headers.zws) invis = await createInvisImage(zconfig.uploader.length, image.id);
|
||||
|
||||
await datasource.save(image.file, file.buffer);
|
||||
Logger.get('image').info(`User ${user.username} (${user.id}) uploaded an image ${image.file} (${image.id})`);
|
||||
if (user.domains.length) {
|
||||
const domain = user.domains[Math.floor(Math.random() * user.domains.length)];
|
||||
files.push(`${domain}${zconfig.uploader.route === '/' ? '' : zconfig.uploader.route}/${invis ? invis.invis : image.file}`);
|
||||
} else {
|
||||
files.push(`${zconfig.core.secure ? 'https' : 'http'}://${req.headers.host}${zconfig.uploader.route === '/' ? '' : zconfig.uploader.route}/${invis ? invis.invis : image.file}`);
|
||||
}
|
||||
await writeFile(join(process.cwd(), zconfig.uploader.directory, image.file), file.buffer);
|
||||
Logger.get('image').info(`User ${user.username} (${user.id}) uploaded an image ${image.file} (${image.id})`);
|
||||
files.push(`${zconfig.core.secure ? 'https' : 'http'}://${req.headers.host}${zconfig.uploader.route}/${invis ? invis.invis : image.file}`);
|
||||
}
|
||||
|
||||
if (user.administrator && zconfig.ratelimit.admin > 0) {
|
||||
if (user.administrator && zconfig.ratelimit.admin !== 0) {
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
ratelimit: new Date(Date.now() + (zconfig.ratelimit.admin * 1000)),
|
||||
ratelimited: true,
|
||||
},
|
||||
});
|
||||
} else if (!user.administrator && zconfig.ratelimit.user > 0) {
|
||||
if (user.administrator && zconfig.ratelimit.user > 0) {
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
ratelimit: new Date(Date.now() + (zconfig.ratelimit.user * 1000)),
|
||||
},
|
||||
});
|
||||
}
|
||||
setTimeout(async () => await prisma.user.update({ where: { id: user.id }, data: { ratelimited: false } }), zconfig.ratelimit.admin * 1000).unref();
|
||||
}
|
||||
|
||||
if (!user.administrator && zconfig.ratelimit.user !== 0) {
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
ratelimited: true,
|
||||
},
|
||||
});
|
||||
setTimeout(async () => await prisma.user.update({ where: { id: user.id }, data: { ratelimited: false } }), zconfig.ratelimit.user * 1000).unref();
|
||||
}
|
||||
|
||||
return res.json({ files });
|
||||
@@ -135,6 +113,8 @@ function run(middleware: any) {
|
||||
}
|
||||
|
||||
export default async function handlers(req, res) {
|
||||
await run(uploader.array('file'))(req, res);
|
||||
|
||||
return withZipline(handler)(req, res);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
|
||||
import prisma from 'lib/prisma';
|
||||
import Logger from 'lib/logger';
|
||||
import { Zip, ZipPassThrough } from 'fflate';
|
||||
import datasource from 'lib/datasource';
|
||||
import { readdir, stat } from 'fs/promises';
|
||||
import { createReadStream, createWriteStream } from 'fs';
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
const user = await req.user();
|
||||
if (!user) return res.forbid('not logged in');
|
||||
|
||||
if (req.method === 'POST') {
|
||||
const files = await prisma.image.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
const zip = new Zip();
|
||||
const export_name = `zipline_export_${user.id}_${Date.now()}.zip`;
|
||||
const write_stream = createWriteStream(`/tmp/${export_name}`);
|
||||
|
||||
// i found this on some stack overflow thing, forgot the url
|
||||
const onBackpressure = (stream, outputStream, cb) => {
|
||||
const runCb = () => {
|
||||
// Pause if either output or internal backpressure should be applied
|
||||
cb(applyOutputBackpressure || backpressureBytes > backpressureThreshold);
|
||||
};
|
||||
|
||||
// Internal backpressure (for when AsyncZipDeflate is slow)
|
||||
const backpressureThreshold = 65536;
|
||||
let backpressure = [];
|
||||
let backpressureBytes = 0;
|
||||
const push = stream.push;
|
||||
stream.push = (dat, final) => {
|
||||
backpressure.push(dat.length);
|
||||
backpressureBytes += dat.length;
|
||||
runCb();
|
||||
push.call(stream, dat, final);
|
||||
};
|
||||
let ondata = stream.ondata;
|
||||
const ondataPatched = (err, dat, final) => {
|
||||
ondata.call(stream, err, dat, final);
|
||||
backpressureBytes -= backpressure.shift();
|
||||
runCb();
|
||||
};
|
||||
if (ondata) {
|
||||
stream.ondata = ondataPatched;
|
||||
} else {
|
||||
// You can remove this condition if you make sure to
|
||||
// call zip.add(file) before calling onBackpressure
|
||||
Object.defineProperty(stream, 'ondata', {
|
||||
get: () => ondataPatched,
|
||||
set: cb => ondata = cb,
|
||||
});
|
||||
}
|
||||
|
||||
// Output backpressure (for when outputStream is slow)
|
||||
let applyOutputBackpressure = false;
|
||||
const write = outputStream.write;
|
||||
outputStream.write = (data) => {
|
||||
const outputNotFull = write.call(outputStream, data);
|
||||
applyOutputBackpressure = !outputNotFull;
|
||||
runCb();
|
||||
};
|
||||
outputStream.on('drain', () => {
|
||||
applyOutputBackpressure = false;
|
||||
runCb();
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
|
||||
zip.ondata = async (err, data, final) => {
|
||||
if (!err) {
|
||||
write_stream.write(data);
|
||||
if (final) {
|
||||
write_stream.close();
|
||||
Logger.get('user').info(`Export for ${user.username} (${user.id}) has completed and is available at ${export_name}`);
|
||||
}
|
||||
} else {
|
||||
write_stream.close();
|
||||
|
||||
Logger.get('user').error(`Export for ${user.username} (${user.id}) has failed\n${err}`);
|
||||
}
|
||||
};
|
||||
|
||||
Logger.get('user').info(`Export for ${user.username} (${user.id}) has started`);
|
||||
for (let i = 0; i !== files.length; ++i) {
|
||||
const file = files[i];
|
||||
const stream = await datasource.get(file.file);
|
||||
if (stream) {
|
||||
const def = new ZipPassThrough(file.file);
|
||||
zip.add(def);
|
||||
onBackpressure(def, stream, shouldApplyBackpressure => {
|
||||
if (shouldApplyBackpressure) {
|
||||
stream.pause();
|
||||
} else if (stream.isPaused()) {
|
||||
stream.resume();
|
||||
}
|
||||
});
|
||||
stream.on('data', c => def.push(c));
|
||||
stream.on('end', () => def.push(new Uint8Array(0), true));
|
||||
}
|
||||
}
|
||||
|
||||
zip.end();
|
||||
|
||||
res.json({
|
||||
url: '/api/user/export?name=' + export_name,
|
||||
});
|
||||
} else {
|
||||
const export_name = req.query.name as string;
|
||||
if (export_name) {
|
||||
const parts = export_name.split('_');
|
||||
if (Number(parts[2]) !== user.id) return res.forbid('cannot access export');
|
||||
|
||||
const stream = createReadStream(`/tmp/${export_name}`);
|
||||
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${export_name}"`);
|
||||
stream.pipe(res);
|
||||
} else {
|
||||
const files = await readdir('/tmp');
|
||||
const exp = files.filter(f => f.startsWith('zipline_export_'));
|
||||
const exports = [];
|
||||
for (let i = 0; i !== exp.length; ++i) {
|
||||
const name = exp[i];
|
||||
const stats = await stat(`/tmp/${name}`);
|
||||
|
||||
exports.push({ name, size: stats.size });
|
||||
}
|
||||
|
||||
res.json({
|
||||
exports,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default withZipline(handler);
|
||||
@@ -1,49 +1,29 @@
|
||||
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
|
||||
import prisma from 'lib/prisma';
|
||||
import config from 'lib/config';
|
||||
import { chunk } from 'lib/util';
|
||||
import { rm } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import Logger from 'lib/logger';
|
||||
import datasource from 'lib/datasource';
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
const user = await req.user();
|
||||
if (!user) return res.forbid('not logged in');
|
||||
|
||||
if (req.method === 'DELETE') {
|
||||
if (req.body.all) {
|
||||
const files = await prisma.image.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
if (!req.body.id) return res.error('no file id');
|
||||
|
||||
for (let i = 0; i !== files.length; ++i) {
|
||||
await datasource.delete(files[i].file);
|
||||
}
|
||||
const image = await prisma.image.delete({
|
||||
where: {
|
||||
id: req.body.id,
|
||||
},
|
||||
});
|
||||
|
||||
const { count } = await prisma.image.deleteMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
Logger.get('image').info(`User ${user.username} (${user.id}) deleted ${count} images.`);
|
||||
await rm(join(process.cwd(), config.uploader.directory, image.file));
|
||||
|
||||
return res.json({ count });
|
||||
} else {
|
||||
if (!req.body.id) return res.error('no file id');
|
||||
Logger.get('image').info(`User ${user.username} (${user.id}) deleted an image ${image.file} (${image.id})`);
|
||||
|
||||
const image = await prisma.image.delete({
|
||||
where: {
|
||||
id: req.body.id,
|
||||
},
|
||||
});
|
||||
|
||||
await datasource.delete(image.file);
|
||||
|
||||
Logger.get('image').info(`User ${user.username} (${user.id}) deleted an image ${image.file} (${image.id})`);
|
||||
|
||||
delete image.password;
|
||||
return res.json(image);
|
||||
}
|
||||
return res.json(image);
|
||||
} else if (req.method === 'PATCH') {
|
||||
if (!req.body.id) return res.error('no file id');
|
||||
|
||||
@@ -56,7 +36,6 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
},
|
||||
});
|
||||
|
||||
delete image.password;
|
||||
return res.json(image);
|
||||
} else {
|
||||
let images = await prisma.image.findMany({
|
||||
@@ -64,9 +43,6 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
userId: user.id,
|
||||
favorite: !!req.query.favorite,
|
||||
},
|
||||
orderBy: {
|
||||
created_at: 'desc',
|
||||
},
|
||||
select: {
|
||||
created_at: true,
|
||||
file: true,
|
||||
|
||||
@@ -2,7 +2,6 @@ import prisma from 'lib/prisma';
|
||||
import { hashPassword } from 'lib/util';
|
||||
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
|
||||
import Logger from 'lib/logger';
|
||||
import pkg from '../../../../package.json';
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
const user = await req.user();
|
||||
@@ -52,36 +51,6 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
data: { systemTheme: req.body.systemTheme },
|
||||
});
|
||||
|
||||
if (req.body.domains) {
|
||||
if (!req.body.domains) await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { domains: [] },
|
||||
});
|
||||
|
||||
const invalidDomains = [];
|
||||
|
||||
for (const domain of req.body.domains) {
|
||||
try {
|
||||
const url = new URL(domain);
|
||||
url.pathname = '/api/version';
|
||||
const res = await fetch(url.toString());
|
||||
if (!res.ok) invalidDomains.push({ domain, reason: 'Got a non OK response' });
|
||||
else {
|
||||
const body = await res.json();
|
||||
if (body?.local !== pkg.version) invalidDomains.push({ domain, reason: 'Version mismatch' });
|
||||
else await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { domains: { push: url.origin } },
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
invalidDomains.push({ domain, reason: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
if (invalidDomains.length) return res.forbid('Invalid domains', { invalidDomains });
|
||||
}
|
||||
|
||||
const newUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: Number(user.id),
|
||||
@@ -97,7 +66,6 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
systemTheme: true,
|
||||
token: true,
|
||||
username: true,
|
||||
domains: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
|
||||
import prisma from 'lib/prisma';
|
||||
import Logger from 'lib/logger';
|
||||
import datasource from 'lib/datasource';
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
const user = await req.user();
|
||||
@@ -18,25 +16,6 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
});
|
||||
if (!deleteUser) return res.forbid('user doesn\'t exist');
|
||||
|
||||
if (req.body.delete_images) {
|
||||
const files = await prisma.image.findMany({
|
||||
where: {
|
||||
userId: deleteUser.id,
|
||||
},
|
||||
});
|
||||
|
||||
for (let i = 0; i !== files.length; ++i) {
|
||||
await datasource.delete(files[i].file);
|
||||
}
|
||||
|
||||
const { count } = await prisma.image.deleteMany({
|
||||
where: {
|
||||
userId: deleteUser.id,
|
||||
},
|
||||
});
|
||||
Logger.get('image').info(`User ${user.username} (${user.id}) deleted ${count} images of user ${deleteUser.username} (${deleteUser.id})`);
|
||||
}
|
||||
|
||||
await prisma.user.delete({
|
||||
where: {
|
||||
id: deleteUser.id,
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Button, Center, TextInput, Title, PasswordInput } from '@mantine/core';
|
||||
import { useForm } from '@mantine/hooks';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useForm } from '@mantine/hooks';
|
||||
import { TextInput, Button, Center, Title, Box, Badge, Tooltip } from '@mantine/core';
|
||||
import { useNotifications } from '@mantine/notifications';
|
||||
import { Cross1Icon, DownloadIcon } from '@modulz/radix-icons';
|
||||
|
||||
export default function Login() {
|
||||
const router = useRouter();
|
||||
const notif = useNotifications();
|
||||
const [versions, setVersions] = React.useState<{ upstream: string, local: string }>(null);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
@@ -25,21 +29,32 @@ export default function Login() {
|
||||
});
|
||||
|
||||
if (res.error) {
|
||||
if (res.error.startsWith('403')) {
|
||||
form.setFieldError('password', 'Invalid password');
|
||||
} else {
|
||||
form.setFieldError('username', 'Invalid username');
|
||||
form.setFieldError('password', 'Invalid password');
|
||||
}
|
||||
notif.showNotification({
|
||||
title: 'Login Failed',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <Cross1Icon />,
|
||||
});
|
||||
} else {
|
||||
await router.push(router.query.url as string || '/dashboard');
|
||||
router.push(router.query.url as string || '/dashboard');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const a = await fetch('/api/user');
|
||||
if (a.ok) await router.push('/dashboard');
|
||||
if (a.ok) router.push('/dashboard');
|
||||
else {
|
||||
const v = await useFetch('/api/version');
|
||||
setVersions(v);
|
||||
if (v.local !== v.upstream) {
|
||||
notif.showNotification({
|
||||
title: 'Update available',
|
||||
message: `A new version of Zipline is available. You are running ${v.local} and the latest version is ${v.upstream}.`,
|
||||
icon: <DownloadIcon />,
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
@@ -50,12 +65,33 @@ export default function Login() {
|
||||
<Title align='center'>Zipline</Title>
|
||||
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
|
||||
<TextInput size='lg' id='username' label='Username' {...form.getInputProps('username')} />
|
||||
<PasswordInput size='lg' id='password' label='Password' {...form.getInputProps('password')} />
|
||||
<TextInput size='lg' id='password' label='Password' type='password' {...form.getInputProps('password')} />
|
||||
|
||||
<Button size='lg' type='submit' fullWidth mt={12}>Login</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Center>
|
||||
<Box
|
||||
sx={{
|
||||
zIndex: 99,
|
||||
position: 'fixed',
|
||||
bottom: '10px',
|
||||
right: '20px',
|
||||
|
||||
}}
|
||||
>
|
||||
{versions && (
|
||||
<Tooltip
|
||||
wrapLines
|
||||
width={220}
|
||||
transition='rotate-left'
|
||||
transitionDuration={200}
|
||||
label={versions.local !== versions.upstream ? 'Looks like you are running an outdated version of Zipline. Please update to the latest version.' : 'You are running the latest version of Zipline.'}
|
||||
>
|
||||
<Badge radius='md' size='lg' variant='dot' color={versions.local !== versions.upstream ? 'red' : 'primary'}>{versions.local}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { LoadingOverlay } from '@mantine/core';
|
||||
import { useStoreDispatch } from 'lib/redux/store';
|
||||
import { updateUser } from 'lib/redux/reducers/user';
|
||||
|
||||
export default function Logout() {
|
||||
const dispatch = useStoreDispatch();
|
||||
const router = useRouter();
|
||||
const [visible, setVisible] = useState(true);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const userRes = await fetch('/api/user');
|
||||
if (userRes.ok) {
|
||||
const res = await fetch('/api/auth/logout');
|
||||
if (res.ok) {
|
||||
dispatch(updateUser(null));
|
||||
router.push('/auth/login');
|
||||
}
|
||||
if (res.ok) router.push('/auth/login');
|
||||
} else {
|
||||
router.push('/auth/login');
|
||||
}
|
||||
@@ -24,7 +20,7 @@ export default function Logout() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<LoadingOverlay visible={true} />
|
||||
<LoadingOverlay visible={visible} />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import exts from 'lib/exts';
|
||||
import exts from '../../../scripts/exts';
|
||||
import { Prism } from '@mantine/prism';
|
||||
|
||||
export default function Code() {
|
||||
@@ -11,7 +11,7 @@ export default function Code() {
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const res = await fetch('/r/' + id);
|
||||
if (id && !res.ok) await router.push('/404');
|
||||
if (id && !res.ok) router.push('/404');
|
||||
const data = await res.text();
|
||||
if (id) setPrismRenderCode(data);
|
||||
})();
|
||||
|
||||
@@ -2,12 +2,11 @@ import React from 'react';
|
||||
import useLogin from 'hooks/useLogin';
|
||||
import Layout from 'components/Layout';
|
||||
import Files from 'components/pages/Files';
|
||||
import { LoadingOverlay } from '@mantine/core';
|
||||
|
||||
export default function FilesPage() {
|
||||
const { user, loading } = useLogin();
|
||||
|
||||
if (loading) return <LoadingOverlay visible={loading} />;
|
||||
if (loading) return null;
|
||||
|
||||
return (
|
||||
<Layout
|
||||
@@ -18,4 +17,4 @@ export default function FilesPage() {
|
||||
);
|
||||
}
|
||||
|
||||
FilesPage.title = 'Zipline - Files';
|
||||
FilesPage.title = 'Zipline - Gallery';
|
||||
@@ -2,12 +2,10 @@ import React from 'react';
|
||||
import useLogin from 'hooks/useLogin';
|
||||
import Layout from 'components/Layout';
|
||||
import Dashboard from 'components/pages/Dashboard';
|
||||
import { LoadingOverlay } from '@mantine/core';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user, loading } = useLogin();
|
||||
|
||||
if (loading) return <LoadingOverlay visible={loading} />;
|
||||
if (loading) return null;
|
||||
|
||||
return (
|
||||
<Layout
|
||||
|
||||
@@ -2,12 +2,11 @@ import React from 'react';
|
||||
import useLogin from 'hooks/useLogin';
|
||||
import Layout from 'components/Layout';
|
||||
import Manage from 'components/pages/Manage';
|
||||
import { LoadingOverlay } from '@mantine/core';
|
||||
|
||||
export default function ManagePage() {
|
||||
const { user, loading } = useLogin();
|
||||
|
||||
if (loading) return <LoadingOverlay visible={loading} />;
|
||||
if (loading) return null;
|
||||
|
||||
return (
|
||||
<Layout
|
||||
|
||||
@@ -2,12 +2,10 @@ import React from 'react';
|
||||
import useLogin from 'hooks/useLogin';
|
||||
import Layout from 'components/Layout';
|
||||
import Stats from 'components/pages/Stats';
|
||||
import { LoadingOverlay } from '@mantine/core';
|
||||
|
||||
export default function StatsPage() {
|
||||
const { user, loading } = useLogin();
|
||||
|
||||
if (loading) return <LoadingOverlay visible={loading} />;
|
||||
if (loading) return null;
|
||||
|
||||
return (
|
||||
<Layout
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
import React from 'react';
|
||||
import { GetStaticProps } from 'next';
|
||||
import useLogin from 'hooks/useLogin';
|
||||
import Layout from 'components/Layout';
|
||||
import Upload from 'components/pages/Upload';
|
||||
import { LoadingOverlay } from '@mantine/core';
|
||||
import config from 'lib/config';
|
||||
|
||||
export default function UploadPage() {
|
||||
export default function UploadPage({ route }) {
|
||||
const { user, loading } = useLogin();
|
||||
|
||||
if (loading) return <LoadingOverlay visible={loading} />;
|
||||
if (loading) return null;
|
||||
|
||||
return (
|
||||
<Layout
|
||||
user={user}
|
||||
>
|
||||
<Upload/>
|
||||
<Upload route={route}/>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export const getStaticProps: GetStaticProps = async () => {
|
||||
return {
|
||||
props: {
|
||||
route: process.env.ZIPLINE_DOCKER_BUILD === '1' ? '/u' : config.uploader.route,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
UploadPage.title = 'Zipline - Upload';
|
||||
@@ -2,12 +2,11 @@ import React from 'react';
|
||||
import useLogin from 'hooks/useLogin';
|
||||
import Layout from 'components/Layout';
|
||||
import Urls from 'components/pages/Urls';
|
||||
import { LoadingOverlay } from '@mantine/core';
|
||||
|
||||
export default function UrlsPage() {
|
||||
const { user, loading } = useLogin();
|
||||
|
||||
if (loading) return <LoadingOverlay visible={loading} />;
|
||||
if (loading) return null;
|
||||
|
||||
return (
|
||||
<Layout
|
||||
|
||||
@@ -2,12 +2,11 @@ import React from 'react';
|
||||
import useLogin from 'hooks/useLogin';
|
||||
import Layout from 'components/Layout';
|
||||
import Users from 'components/pages/Users';
|
||||
import { LoadingOverlay } from '@mantine/core';
|
||||
|
||||
export default function UsersPage() {
|
||||
const { user, loading } = useLogin();
|
||||
|
||||
if (loading) return <LoadingOverlay visible={loading} />;
|
||||
if (loading) return null;
|
||||
|
||||
return (
|
||||
<Layout
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
import Router from 'find-my-way';
|
||||
import next from 'next';
|
||||
import { NextServer, RequestHandler } from 'next/dist/server/next';
|
||||
import { Image, PrismaClient } from '@prisma/client';
|
||||
import { createServer, IncomingMessage, OutgoingMessage, ServerResponse } from 'http';
|
||||
import { extname } from 'path';
|
||||
import { mkdir } from 'fs/promises';
|
||||
import { getStats, log, migrations } from './util';
|
||||
import Logger from '../lib/logger';
|
||||
import mimes from '../lib/mimes';
|
||||
import exts from '../lib/exts';
|
||||
import { version } from '../../package.json';
|
||||
import type { Config } from 'lib/config/Config';
|
||||
import type { Datasource } from 'lib/datasources';
|
||||
|
||||
let config: Config, datasource: Datasource;
|
||||
|
||||
const logger = Logger.get('server');
|
||||
logger.info(`starting zipline@${version} server`);
|
||||
|
||||
start();
|
||||
|
||||
async function start() {
|
||||
const c = await import('../lib/config.js');
|
||||
config = c.default.default;
|
||||
|
||||
const d = await import('../lib/datasource.js');
|
||||
// @ts-ignore
|
||||
datasource = d.default.default;
|
||||
|
||||
// annoy user if they didnt change secret from default "changethis"
|
||||
if (config.core.secret === 'changethis') {
|
||||
logger.error('Secret is not set!');
|
||||
logger.error('Running Zipline as is, without a randomized secret is not recommended and leaves your instance at risk!');
|
||||
logger.error('Please change your secret in the config file or environment variables.');
|
||||
logger.error('The config file is located at `config.toml`, or if using docker-compose you can change the variables in the `docker-compose.yml` file.');
|
||||
logger.error('It is recomended to use a secret that is alphanumeric and randomized. A way you can generate this is through a password manager you may have.');
|
||||
process.exit(1);
|
||||
};
|
||||
|
||||
const dev = process.env.NODE_ENV === 'development';
|
||||
|
||||
process.env.DATABASE_URL = config.core.database_url;
|
||||
await migrations();
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
if (config.datasource.type === 'local') {
|
||||
await mkdir(config.datasource.local.directory, { recursive: true });
|
||||
}
|
||||
|
||||
const nextServer = next({
|
||||
dir: '.',
|
||||
dev,
|
||||
quiet: !dev,
|
||||
hostname: config.core.host,
|
||||
port: config.core.port,
|
||||
});
|
||||
|
||||
const handle = nextServer.getRequestHandler();
|
||||
const router = Router({
|
||||
defaultRoute: (req, res) => {
|
||||
handle(req, res);
|
||||
},
|
||||
});
|
||||
|
||||
router.on('GET', config.uploader.route === '/' ? '/:id(^[^\\.]+\\.[^\\.]+)' : `${config.uploader.route}/:id`, async (req, res, params) => {
|
||||
const image = await prisma.image.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ file: params.id },
|
||||
{ invisible: { invis: decodeURI(params.id) } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (!image) await rawFile(req, res, nextServer, params.id);
|
||||
|
||||
if (image.password) await handle(req, res);
|
||||
else if (image.embed) await handle(req, res);
|
||||
else await fileDb(req, res, nextServer, prisma, handle, image);
|
||||
});
|
||||
|
||||
router.on('GET', '/r/:id', async (req, res, params) => {
|
||||
const image = await prisma.image.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ file: params.id },
|
||||
{ invisible: { invis: decodeURI(params.id) } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (!image) await rawFile(req, res, nextServer, params.id);
|
||||
|
||||
if (image.password) await handle(req, res);
|
||||
else await rawFileDb(req, res, nextServer, prisma, image);
|
||||
});
|
||||
|
||||
await nextServer.prepare();
|
||||
|
||||
const http = createServer((req, res) => {
|
||||
router.lookup(req, res);
|
||||
if (config.core.logger) log(req.url);
|
||||
});
|
||||
|
||||
http.on('error', (e) => {
|
||||
logger.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
http.on('listening', () => {
|
||||
logger.info(`listening on ${config.core.host}:${config.core.port}`);
|
||||
});
|
||||
|
||||
http.listen(config.core.port, config.core.host ?? '0.0.0.0');
|
||||
|
||||
stats(prisma);
|
||||
}
|
||||
|
||||
async function rawFile(
|
||||
req: IncomingMessage,
|
||||
res: OutgoingMessage,
|
||||
nextServer: NextServer,
|
||||
id: string,
|
||||
) {
|
||||
const data = await datasource.get(id);
|
||||
if (!data) return nextServer.render404(req, res as ServerResponse);
|
||||
const mimetype = mimes[extname(id)] ?? 'application/octet-stream';
|
||||
res.setHeader('Content-Type', mimetype);
|
||||
|
||||
data.pipe(res);
|
||||
data.on('error', () => nextServer.render404(req, res as ServerResponse));
|
||||
data.on('end', () => res.end());
|
||||
}
|
||||
|
||||
async function rawFileDb(
|
||||
req: IncomingMessage,
|
||||
res: OutgoingMessage,
|
||||
nextServer: NextServer,
|
||||
prisma: PrismaClient,
|
||||
image: Image,
|
||||
) {
|
||||
const data = await datasource.get(image.file);
|
||||
if (!data) return nextServer.render404(req, res as ServerResponse);
|
||||
|
||||
res.setHeader('Content-Type', image.mimetype);
|
||||
data.pipe(res);
|
||||
data.on('error', () => nextServer.render404(req, res as ServerResponse));
|
||||
data.on('end', () => res.end());
|
||||
|
||||
await prisma.image.update({
|
||||
where: { id: image.id },
|
||||
data: { views: { increment: 1 } },
|
||||
});
|
||||
}
|
||||
|
||||
async function fileDb(
|
||||
req: IncomingMessage,
|
||||
res: OutgoingMessage,
|
||||
nextServer: NextServer,
|
||||
prisma: PrismaClient,
|
||||
handle: RequestHandler,
|
||||
image: Image,
|
||||
) {
|
||||
const ext = image.file.split('.').pop();
|
||||
if (Object.keys(exts).includes(ext)) return handle(req, res as ServerResponse);
|
||||
|
||||
const data = await datasource.get(image.file);
|
||||
if (!data) return this.nextServer.render404(req, res as ServerResponse);
|
||||
|
||||
res.setHeader('Content-Type', image.mimetype);
|
||||
data.pipe(res);
|
||||
data.on('error', () => nextServer.render404(req, res as ServerResponse));
|
||||
data.on('end', () => res.end());
|
||||
|
||||
await prisma.image.update({
|
||||
where: { id: image.id },
|
||||
data: { views: { increment: 1 } },
|
||||
});
|
||||
}
|
||||
|
||||
async function stats(prisma: PrismaClient) {
|
||||
const stats = await getStats(prisma, datasource);
|
||||
await prisma.stats.create({
|
||||
data: {
|
||||
data: stats,
|
||||
},
|
||||
});
|
||||
|
||||
setInterval(async () => {
|
||||
const stats = await getStats(prisma, datasource);
|
||||
await prisma.stats.create({
|
||||
data: {
|
||||
data: stats,
|
||||
},
|
||||
});
|
||||
if (config.core.logger) logger.info('stats updated');
|
||||
}, config.core.stats_interval * 1000);
|
||||
}
|
||||
@@ -20,20 +20,11 @@
|
||||
"noEmit": true,
|
||||
"baseUrl": "src",
|
||||
"paths": {
|
||||
"components/*": [
|
||||
"components/*"
|
||||
],
|
||||
"hooks/*": [
|
||||
"lib/hooks/*"
|
||||
],
|
||||
"middleware/*": [
|
||||
"lib/middleware/*"
|
||||
],
|
||||
"lib/*": [
|
||||
"lib/*"
|
||||
]
|
||||
},
|
||||
"incremental": true
|
||||
"components/*": ["components/*"],
|
||||
"hooks/*": ["lib/hooks/*"],
|
||||
"middleware/*": ["lib/middleware/*"],
|
||||
"lib/*": ["lib/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
@@ -43,10 +34,6 @@
|
||||
"prisma/seed.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
".yarn",
|
||||
".next",
|
||||
"scripts"
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
4
zip-env.d.ts
vendored
4
zip-env.d.ts
vendored
@@ -1,13 +1,11 @@
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
import type { Datasource } from 'lib/datasources';
|
||||
import type { Config } from '.lib/types';
|
||||
import type { Config } from './src/lib/types';
|
||||
|
||||
declare global {
|
||||
namespace NodeJS {
|
||||
interface Global {
|
||||
prisma: PrismaClient;
|
||||
config: Config;
|
||||
datasource: Datasource
|
||||
}
|
||||
|
||||
interface ProcessEnv {
|
||||
|
||||
Reference in New Issue
Block a user