Compare commits

...

24 Commits

Author SHA1 Message Date
renovate[bot]
b81b1f979e chore(deps): add renovate.json 2022-06-17 09:43:38 +00:00
diced
bfa6c70bf3 chore(deps): update stuff 2022-06-16 14:22:26 -07:00
Jonathan
73eff05180 feat: use yarn v3 (#136)
* feat: use yarn v3

* chore: bump yarn to 3.2.1
2022-06-06 16:38:15 -07:00
relaxtakenotes
74f3b3f13d fix: image width not being set properly (#143)
* Fix image width not being set properly

Sometimes it got set to 0 because the original image wasn't loaded yet.

* fix: eslint

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2022-06-04 22:18:07 -07:00
diced
181833d768 Merge branch 'trunk' of github.com:diced/zipline into trunk 2022-06-04 22:05:26 -07:00
diced
be9523304a feat(v3.4.4): fix many bugs and password protected uploads 2022-06-04 22:05:08 -07:00
dicedtomato
b26fef3ad4 fix(docker): add restart policy for postgres 2022-03-26 20:37:02 +00:00
diced
9f86674bbe fix: update security policy 2022-03-14 20:31:18 -07:00
diced
095e57a037 fix(actions): arm -> arm64 2022-03-14 20:22:39 -07:00
diced
66a8e3bb79 feat: arm docker-compose file 2022-03-13 20:05:41 -07:00
diced
473137abdf fix(actions): fix arm action path 2022-03-13 19:36:28 -07:00
diced
740f1605e7 fix(actions): maybe fix actions 2022-03-13 19:30:37 -07:00
diced
0922ec020e fix: revert to node 16 on actions 2022-03-13 19:27:32 -07:00
diced
dbe8291f55 fix(actions): maybe fix arm action 2022-03-13 19:26:33 -07:00
diced
9dcc16277e refactor(actions): update to v2 of build-push-action & push arm image 2022-03-13 19:25:11 -07:00
diced
aa611fa6ba feat(v3.4.3): cleanup, fix memory leak, arm support 2022-03-13 19:13:18 -07:00
diced
083040e300 feat(v3.4.2): random domain selection #129 2022-03-03 17:52:34 -08:00
diced
99e92e4594 feat(v3.4.1): datasource api, for S3 functionality 2022-03-02 22:04:56 -08:00
diced
870f6e88b1 fix(prisma): add removal of custom theme migration 2022-02-26 17:27:37 -08:00
dicedtomato
16d2014bfb feat(v3.4.0): switch from Material-UI to Mantine! (#127) 2022-02-26 17:19:02 -08:00
diced
4d9a22e82c fix(api): data not defined 2022-02-21 09:28:32 -08:00
diced
42d77e445b feat(v3.3.2): image formats 2022-02-21 09:27:10 -08:00
diced
6506846207 fix: cleanup 2022-02-21 09:26:26 -08:00
diced
2b9af0e0de feat(api): formats for uploaded images 2022-02-20 22:01:31 -08:00
105 changed files with 12217 additions and 8756 deletions

View File

@@ -2,3 +2,6 @@ node_modules/
.next/ .next/
uploads/ uploads/
.git/ .git/
.yarn/*
!.yarn/releases
!.yarn/plugins

41
.github/workflows/docker-arm.yml vendored Normal file
View File

@@ -0,0 +1,41 @@
name: 'CD: Push ARM64 Docker Images'
on:
push:
branches: [ trunk ]
paths:
- 'src/**'
- 'server/**'
- 'prisma/**'
- '.github/**'
workflow_dispatch:
jobs:
push_to_ghcr:
name: Push Image to GitHub Packages
runs-on: ubuntu-latest
steps:
- 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
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker Image
uses: docker/build-push-action@v2
with:
file: ./Dockerfile-arm
platforms: linux/arm64
push: true
tags: ghcr.io/diced/zipline/arm64:trunk

View File

@@ -18,28 +18,31 @@ jobs:
- name: Check out the repo - name: Check out the repo
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: Push to GitHub Packages - name: Setup QEMU
uses: docker/build-push-action@v1 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
with: with:
registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
registry: docker.pkg.github.com
repository: diced/zipline/zipline
dockerfile: Dockerfile
tag_with_ref: true
push_to_dockerhub: - name: Login to Docker Hub
name: Push Image to Docker Hub uses: docker/login-action@v1
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: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
repository: diced/zipline
dockerfile: Dockerfile - name: Build Docker Image
tag_with_ref: true uses: docker/build-push-action@v2
with:
push: true
tags: |
ghcr.io/diced/zipline/zipline:trunk
ghcr.io/diced/zipline/amd64:trunk
diced/zipline:trunk

9
.gitignore vendored
View File

@@ -5,6 +5,11 @@
/.pnp /.pnp
.pnp.js .pnp.js
# yarn
.yarn/*
!.yarn/releases
!.yarn/plugins
# testing # testing
/coverage /coverage
@@ -36,4 +41,6 @@ yarn-error.log*
# zipline # zipline
config.toml config.toml
uploads/ uploads/
dist/
docker-compose.local.yml

1
.husky/.gitignore vendored
View File

@@ -1 +0,0 @@
_

View File

@@ -1,4 +0,0 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
yarn commitlint --edit $1

File diff suppressed because one or more lines are too long

786
.yarn/releases/yarn-3.2.1.cjs vendored Normal file

File diff suppressed because one or more lines are too long

7
.yarnrc.yml Normal file
View File

@@ -0,0 +1,7 @@
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

View File

@@ -1,20 +1,21 @@
FROM node:16-alpine AS deps FROM node:16-alpine AS deps
WORKDIR /build WORKDIR /build
COPY package.json yarn.lock ./ COPY .yarn .yarn
COPY package.json yarn.lock .yarnrc.yml ./
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
RUN yarn install --frozen-lockfile RUN yarn install --immutable
FROM node:16-alpine AS builder FROM node:16-alpine AS builder
WORKDIR /build WORKDIR /build
COPY --from=deps /build/node_modules ./node_modules COPY --from=deps /build/node_modules ./node_modules
COPY src ./src COPY src ./src
COPY server ./server
COPY scripts ./scripts COPY scripts ./scripts
COPY prisma ./prisma COPY prisma ./prisma
COPY package.json yarn.lock next.config.js next-env.d.ts zip-env.d.ts tsconfig.json ./ 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 ./
ENV ZIPLINE_DOCKER_BUILD 1 ENV ZIPLINE_DOCKER_BUILD 1
ENV NEXT_TELEMETRY_DISABLED 1 ENV NEXT_TELEMETRY_DISABLED 1
@@ -31,11 +32,11 @@ RUN addgroup --system --gid 1001 zipline
RUN adduser --system --uid 1001 zipline RUN adduser --system --uid 1001 zipline
COPY --from=builder --chown=zipline:zipline /build/.next ./.next COPY --from=builder --chown=zipline:zipline /build/.next ./.next
COPY --from=builder --chown=zipline:zipline /build/dist ./dist
COPY --from=builder --chown=zipline:zipline /build/node_modules ./node_modules 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/next.config.js ./next.config.js
COPY --from=builder /build/src ./src COPY --from=builder /build/src ./src
COPY --from=builder /build/server ./server
COPY --from=builder /build/scripts ./scripts COPY --from=builder /build/scripts ./scripts
COPY --from=builder /build/prisma ./prisma COPY --from=builder /build/prisma ./prisma
COPY --from=builder /build/tsconfig.json ./tsconfig.json COPY --from=builder /build/tsconfig.json ./tsconfig.json
@@ -43,4 +44,4 @@ COPY --from=builder /build/package.json ./package.json
USER zipline USER zipline
CMD ["node", "server"] CMD ["node", "dist/server"]

46
Dockerfile-arm Normal file
View File

@@ -0,0 +1,46 @@
FROM node:16 AS deps
WORKDIR /build
COPY .yarn .yarn
COPY package.json yarn.lock .yarnrc.yml ./
RUN yarn install --immutable
FROM node:16 AS builder
WORKDIR /build
COPY --from=deps /build/node_modules ./node_modules
COPY src ./src
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 ./
ENV ZIPLINE_DOCKER_BUILD 1
ENV NEXT_TELEMETRY_DISABLED 1
RUN yarn build
FROM node:16 AS runner
WORKDIR /zipline
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 zipline
RUN adduser --system --uid 1001 zipline
COPY --from=builder --chown=zipline:zipline /build/.next ./.next
COPY --from=builder --chown=zipline:zipline /build/dist ./dist
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/scripts ./scripts
COPY --from=builder /build/prisma ./prisma
COPY --from=builder /build/tsconfig.json ./tsconfig.json
COPY --from=builder /build/package.json ./package.json
USER zipline
CMD ["node", "dist/server"]

View File

@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2021 dicedtomato Copyright (c) 2022 dicedtomato
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@@ -16,13 +16,20 @@
- Fast - Fast
- Built with Next.js & React - Built with Next.js & React
- Token protected uploading - Token protected uploading
- Easy setup instructions on [docs](https://zipline.diced.tech/) (One command install `docker-compose up`) - 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
- Easy setup instructions on [docs](https://zipl.vercel.app/) (One command install `docker-compose up -d`)
## Installing ## Installing
[See how to install here](https://zipline.diced.tech/docs/get-started) [See how to install here](https://zipl.vercel.app/docs/get-started)
## Configuration ## Configuration
[See how to configure here](https://zipline.diced.tech/docs/config/overview) [See how to configure here](https://zipl.vercel.app/docs/config/overview)
## Theming ## Theming
[See how to theme here](https://zipline.diced.tech/docs/themes/reference) [See how to theme here](https://zipl.vercel.app/docs/themes/reference)

View File

@@ -4,7 +4,8 @@
| Version | Supported | | Version | Supported |
| ------- | ------------------ | | ------- | ------------------ |
| 3.x.x | :white_check_mark: | | 3.4.4 | :white_check_mark: |
| < 3 | :x: |
| < 2 | :x: | | < 2 | :x: |
## Reporting a Vulnerability ## Reporting a Vulnerability

View File

@@ -1,55 +0,0 @@
module.exports = {
parserPreset: 'conventional-changelog-conventionalcommits',
rules: {
'body-leading-blank': [1, 'always'],
'body-max-line-length': [2, 'always', 100],
'footer-leading-blank': [1, 'always'],
'footer-max-line-length': [2, 'always', 100],
'header-max-length': [2, 'always', 100],
'subject-case': [
2,
'never',
['sentence-case', 'start-case', 'pascal-case', 'upper-case'],
],
'subject-empty': [2, 'never'],
'subject-full-stop': [2, 'never', '.'],
'type-case': [2, 'always', 'lower-case'],
'type-empty': [2, 'never'],
'type-enum': [
2,
'always',
[
'build',
'chore',
'ci',
'docs',
'feat',
'fix',
'perf',
'refactor',
'revert',
'style',
'test',
],
],
'scope-enum': [
1,
'always',
[
'prisma',
'scripts',
'server',
'pages',
'config',
'api',
'hooks',
'components',
'middleware',
'redux',
'themes',
'lib',
'assets'
],
],
},
};

View File

@@ -1,6 +1,6 @@
[core] [core]
secure = true secure = true
secret = 'some secret' secret = 'changethis'
host = '0.0.0.0' host = '0.0.0.0'
port = 3000 port = 3000
database_url = 'postgres://postgres:postgres@postgres/postgres' database_url = 'postgres://postgres:postgres@postgres/postgres'

46
docker-compose.arm.yml Normal file
View File

@@ -0,0 +1,46 @@
version: '3'
services:
postgres:
image: postgres
restart: always
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DATABASE=postgres
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres']
interval: 10s
timeout: 5s
retries: 5
zipline:
image: ghcr.io/diced/zipline/arm64:trunk
ports:
- '3000:3000'
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_ADMIN_LIMIT=104900000
- UPLOADER_USER_LIMIT=104900000
- UPLOADER_DISABLED_EXTS=
- URLS_ROUTE=/go
- URLS_LENGTH=6
volumes:
- '$PWD/uploads:/zipline/uploads'
- '$PWD/public:/zipline/public'
depends_on:
- 'postgres'
volumes:
pg_data:

48
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,48 @@
version: '3'
services:
postgres:
image: postgres
restart: always
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DATABASE=postgres
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres']
interval: 10s
timeout: 5s
retries: 5
zipline:
build:
context: .
dockerfile: Dockerfile
ports:
- '3000:3000'
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_ADMIN_LIMIT=104900000
- UPLOADER_USER_LIMIT=104900000
- UPLOADER_DISABLED_EXTS=
- URLS_ROUTE=/go
- URLS_LENGTH=6
volumes:
- '$PWD/uploads:/zipline/uploads'
- '$PWD/public:/zipline/public'
depends_on:
- 'postgres'
volumes:
pg_data:

View File

@@ -2,11 +2,12 @@ version: '3'
services: services:
postgres: postgres:
image: postgres image: postgres
environment: restart: always
environment:
- POSTGRES_USER=postgres - POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres - POSTGRES_PASSWORD=postgres
- POSTGRES_DATABASE=postgres - POSTGRES_DATABASE=postgres
volumes: volumes:
- pg_data:/var/lib/postgresql/data - pg_data:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres'] test: ['CMD-SHELL', 'pg_isready -U postgres']
@@ -18,17 +19,18 @@ services:
image: ghcr.io/diced/zipline/zipline:trunk image: ghcr.io/diced/zipline/zipline:trunk
ports: ports:
- '3000:3000' - '3000:3000'
restart: unless-stopped restart: always
environment: environment:
- SECURE=false - SECURE=false
- SECRET=changethis - SECRET=changethis
- HOST=0.0.0.0 - HOST=0.0.0.0
- PORT=3000 - PORT=3000
- DATASOURCE_TYPE=local
- DATASOURCE_DIRECTORY=./uploads
- DATABASE_URL=postgresql://postgres:postgres@postgres/postgres/ - DATABASE_URL=postgresql://postgres:postgres@postgres/postgres/
- UPLOADER_ROUTE=/u - UPLOADER_ROUTE=/u
- UPLOADER_EMBED_ROUTE=/a - UPLOADER_EMBED_ROUTE=/a
- UPLOADER_LENGTH=6 - UPLOADER_LENGTH=6
- UPLOADER_DIRECTORY=./uploads
- UPLOADER_ADMIN_LIMIT=104900000 - UPLOADER_ADMIN_LIMIT=104900000
- UPLOADER_USER_LIMIT=104900000 - UPLOADER_USER_LIMIT=104900000
- UPLOADER_DISABLED_EXTS= - UPLOADER_DISABLED_EXTS=

40
esbuild.config.js Normal file
View File

@@ -0,0 +1,40 @@
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/server.ts',
'src/server/util.ts',
'src/server/validateConfig.ts',
'src/lib/logger.ts',
'src/lib/readConfig.ts',
'src/lib/datasource/datasource.ts',
'src/lib/datasource/index.ts',
'src/lib/datasource/Local.ts',
'src/lib/datasource/S3.ts',
'src/lib/ds.ts',
'src/lib/config.ts',
],
format: 'cjs',
resolveExtensions: ['.ts', '.js'],
write: true,
watch,
incremental: watch,
sourcemap: false,
minify: process.env.NODE_ENV === 'production',
});
})();

View File

@@ -1,3 +1,11 @@
module.exports = { module.exports = {
reactStrictMode: true, async redirects() {
return [
{
source: '/',
destination: '/dashboard',
permanent: true,
},
];
},
}; };

View File

@@ -1,62 +1,69 @@
{ {
"name": "zip3", "name": "zipline",
"version": "3.3.1", "version": "3.4.4",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"prepare": "husky install", "dev": "node esbuild.config.js && REACT_EDITOR=code-insiders NODE_ENV=development node dist/server",
"dev": "NODE_ENV=development node server", "build": "npm-run-all build:server build:schema build:next",
"build": "npm-run-all build:schema build:next", "build:server": "node esbuild.config.js",
"build:next": "next build", "build:next": "next build",
"build:schema": "prisma generate --schema=prisma/schema.prisma", "build:schema": "prisma generate --schema=prisma/schema.prisma",
"start": "node server", "migrate:dev": "prisma migrate dev --create-only",
"start": "node dist/server",
"lint": "next lint", "lint": "next lint",
"seed": "ts-node --compiler-options \"{\\\"module\\\":\\\"commonjs\\\"}\" --transpile-only prisma/seed.ts" "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": { "dependencies": {
"@emotion/react": "^11.4.0",
"@emotion/styled": "^11.3.0",
"@iarna/toml": "2.2.5", "@iarna/toml": "2.2.5",
"@mui/icons-material": "^5.0.0", "@mantine/core": "^4.2.9",
"@mui/material": "^5.0.2", "@mantine/dropzone": "^4.2.9",
"@mui/styles": "^5.0.1", "@mantine/hooks": "^4.2.9",
"@prisma/client": "^3.9.2", "@mantine/modals": "^4.2.9",
"@prisma/migrate": "^3.9.2", "@mantine/next": "^4.2.9",
"@prisma/sdk": "^3.9.2", "@mantine/notifications": "^4.2.9",
"@reduxjs/toolkit": "^1.6.0", "@mantine/prism": "^4.2.9",
"argon2": "^0.28.2", "@modulz/radix-icons": "^4.0.0",
"colorette": "^1.2.2", "@prisma/client": "^3.15.2",
"cookie": "^0.4.1", "@prisma/migrate": "^3.15.2",
"copy-to-clipboard": "^3.3.1", "@prisma/sdk": "^3.15.2",
"fecha": "^4.2.1", "@reduxjs/toolkit": "^1.8.2",
"formik": "^2.2.9", "argon2": "^0.28.5",
"multer": "^1.4.2", "aws-sdk": "^2.1156.0",
"next": "^12.1.0", "colorette": "^2.0.19",
"prisma": "^3.9.2", "cookie": "^0.5.0",
"react": "17.0.2", "fecha": "^4.2.3",
"react-color": "^2.19.3", "fflate": "^0.7.3",
"react-dom": "17.0.2", "find-my-way": "^6.3.0",
"react-dropzone": "^11.3.2", "multer": "^1.4.5-lts.1",
"react-redux": "^7.2.4", "next": "^12.1.6",
"redux": "^4.1.0", "prisma": "^3.15.2",
"redux-thunk": "^2.3.0", "react": "^18.2.0",
"yup": "^0.32.9" "react-dom": "^18.2.0",
"react-redux": "^8.0.2",
"react-table": "^7.8.0",
"redux": "^4.2.0",
"redux-thunk": "^2.4.1",
"uuid": "^8.3.2",
"yup": "^0.32.11"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^12.1.4", "@types/cookie": "^0.5.1",
"@commitlint/config-conventional": "^12.1.4", "@types/multer": "^1.4.7",
"@types/cookie": "^0.4.0",
"@types/multer": "^1.4.6",
"@types/node": "^15.12.2", "@types/node": "^15.12.2",
"babel-plugin-import": "^1.13.5",
"esbuild": "^0.14.44",
"eslint": "^7.32.0", "eslint": "^7.32.0",
"eslint-config-next": "11.0.0", "eslint-config-next": "12.1.6",
"husky": "^6.0.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"release": "^6.3.0", "ts-node": "^10.8.1",
"ts-node": "^10.0.0", "typescript": "^4.7.3"
"typescript": "^4.3.2"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/diced/zipline.git" "url": "https://github.com/diced/zipline.git"
} },
"packageManager": "yarn@3.2.1"
} }

View File

@@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "ImageFormat" AS ENUM ('UUID', 'DATE', 'RANDOM');
-- AlterTable
ALTER TABLE "Image" ADD COLUMN "format" "ImageFormat" NOT NULL DEFAULT E'RANDOM';

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "ImageFormat" ADD VALUE 'NAME';

View File

@@ -0,0 +1,12 @@
/*
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";
-- AlterTable
ALTER TABLE "User" ALTER COLUMN "systemTheme" SET DEFAULT E'system';
-- DropTable
DROP TABLE "Theme";

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "domains" TEXT[];

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Image" ADD COLUMN "password" TEXT;

View File

@@ -8,34 +8,26 @@ generator client {
} }
model User { model User {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
username String username String
password String password String
token String token String
administrator Boolean @default(false) administrator Boolean @default(false)
systemTheme String @default("dark_blue") systemTheme String @default("system")
customTheme Theme?
embedTitle String? embedTitle String?
embedColor String @default("#2f3136") embedColor String @default("#2f3136")
embedSiteName String? @default("{image.file} • {user.name}") embedSiteName String? @default("{image.file} • {user.name}")
ratelimited Boolean @default(false) ratelimited Boolean @default(false)
domains String[]
images Image[] images Image[]
urls Url[] urls Url[]
} }
model Theme { enum ImageFormat {
id Int @id @default(autoincrement()) UUID
type String DATE
primary String RANDOM
secondary String NAME
error String
warning String
info String
border String
mainBackground String
paperBackground String
user User @relation(fields: [userId], references: [id])
userId Int
} }
model Image { model Image {
@@ -46,7 +38,9 @@ model Image {
views Int @default(0) views Int @default(0)
favorite Boolean @default(false) favorite Boolean @default(false)
embed Boolean @default(false) embed Boolean @default(false)
password String?
invisible InvisibleImage? invisible InvisibleImage?
format ImageFormat @default(RANDOM)
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
userId Int userId Int
} }
@@ -54,7 +48,7 @@ model Image {
model InvisibleImage { model InvisibleImage {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
invis String @unique invis String @unique
imageId Int imageId Int @unique
image Image @relation(fields: [imageId], references: [id]) image Image @relation(fields: [imageId], references: [id])
} }
@@ -72,12 +66,12 @@ model Url {
model InvisibleUrl { model InvisibleUrl {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
invis String @unique invis String @unique
urlId String urlId String @unique
url Url @relation(fields: [urlId], references: [id]) url Url @relation(fields: [urlId], references: [id])
} }
model Stats { model Stats {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
created_at DateTime @default(now()) created_at DateTime @default(now())
data Json data Json
} }

5
renovate.json Normal file
View File

@@ -0,0 +1,5 @@
{
"extends": [
"config:base"
]
}

38
scripts/exts.js Normal file
View File

@@ -0,0 +1,38 @@
// https://github.com/toptal/haste-server/blob/master/static/application.js#L167-L174
// Popular extension map
module.exports = {
rb: 'ruby',
py: 'python',
pl: 'perl',
php: 'php',
scala: 'scala',
go: 'go',
xml: 'xml',
html: 'xml',
htm: 'xml',
css: 'css',
js: 'javascript',
json: 'json',
vbs: 'vbscript',
lua: 'lua',
pas: 'delphi',
java: 'java',
cpp: 'cpp',
cc: 'cpp',
m: 'objectivec',
vala: 'vala',
sql: 'sql',
sm: 'smalltalk',
lisp: 'lisp',
ini: 'ini',
diff: 'diff',
bash: 'bash',
sh: 'bash',
tex: 'tex',
erl: 'erlang',
hs: 'haskell',
md: 'markdown',
txt: '',
coffee: 'coffee',
swift: 'swift',
};

View File

@@ -1,171 +0,0 @@
const NextServer = require('next/dist/server/next-server').default;
const defaultConfig = require('next/dist/server/config-shared').defaultConfig;
const { createServer } = require('http');
const { stat, 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, shouldUseYarn, getFile, migrations } = require('./util');
const { PrismaClient } = require('@prisma/client');
const { version } = require('../package.json');
const nextConfig = require('../next.config');
const serverLog = Logger.get('server');
const webLog = Logger.get('web');
serverLog.info(`starting zipline@${version} server`);
const dev = process.env.NODE_ENV === 'development';
(async () => {
try {
await run();
} catch (e) {
if (e.message && e.message.startsWith('Could not find a production')) {
webLog.error(`there is no production build - run \`${shouldUseYarn() ? 'yarn build' : 'npm build'}\``);
} else if (e.code && e.code === 'ENOENT') {
if (e.path === './.next') webLog.error(`there is no production build - run \`${shouldUseYarn() ? 'yarn build' : 'npm build'}\``);
} else {
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 = new NextServer({
dir: '.',
dev,
quiet: dev,
customServer: false,
host: config.core.host,
port: config.core.port,
conf: Object.assign(defaultConfig, nextConfig),
});
await app.prepare();
await stat('./.next');
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 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);
}

View File

@@ -1,40 +0,0 @@
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(true),
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')}`;
}
};

View File

@@ -1,12 +0,0 @@
import React from 'react';
import { Snackbar, Alert as MuiAlert } from '@mui/material';
export default function Alert({ open, setOpen, severity, message }) {
return (
<Snackbar open={open} autoHideDuration={6000} anchorOrigin={{ vertical: 'top', horizontal: 'center' }} onClose={() => setOpen(false)}>
<MuiAlert severity={severity} sx={{ width: '100%' }}>
{message}
</MuiAlert>
</Snackbar>
);
}

View File

@@ -1,16 +1,8 @@
import React from 'react'; import React from 'react';
import { import { LoadingOverlay } from '@mantine/core';
Backdrop as MuiBackdrop,
CircularProgress,
} from '@mui/material';
export default function Backdrop({ open }) { export default function Backdrop({ open }) {
return ( return (
<MuiBackdrop <LoadingOverlay visible={open} />
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
open={open}
>
<CircularProgress color='inherit' />
</MuiBackdrop>
); );
} }

View File

@@ -1,19 +1,16 @@
import React from 'react'; import React from 'react';
import { import {
Card as MuiCard, Card as MCard,
CardContent, Title,
Typography, } from '@mantine/core';
} from '@mui/material';
export default function Card(props) { export default function Card(props) {
const { name, children, ...other } = props; const { name, children, ...other } = props;
return ( return (
<MuiCard sx={{ minWidth: '100%' }} {...other}> <MCard padding='md' shadow='sm' {...other}>
<CardContent> <Title order={2}>{name}</Title>
<Typography variant='h3'>{name}</Typography> {children}
{children} </MCard>
</CardContent>
</MuiCard>
); );
} }

View File

@@ -1,15 +0,0 @@
import React from 'react';
import { Box } from '@mui/material';
export default function CenteredBox({ children, ...other }) {
return (
<Box
justifyContent='center'
display='flex'
alignItems='center'
{...other}
>
{children}
</Box>
);
}

View File

@@ -1,83 +1,94 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import {
Card,
CardMedia,
CardActionArea,
Button,
Dialog,
DialogTitle,
DialogActions,
DialogContent,
} from '@mui/material';
import AudioIcon from '@mui/icons-material/Audiotrack';
import copy from 'copy-to-clipboard';
import useFetch from 'hooks/useFetch'; import useFetch from 'hooks/useFetch';
import { Button, Card, Group, Image as MImage, Modal, Title } from '@mantine/core';
import { useNotifications } from '@mantine/notifications';
import { CopyIcon, Cross1Icon, StarIcon, TrashIcon } from '@modulz/radix-icons';
import { useClipboard } from '@mantine/hooks';
export default function Image({ image, updateImages }) { export default function Image({ image, updateImages }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [t] = useState(image.mimetype.split('/')[0]); const [t] = useState(image.mimetype.split('/')[0]);
const notif = useNotifications();
const clipboard = useClipboard();
const handleDelete = async () => { const handleDelete = async () => {
const res = await useFetch('/api/user/files', 'DELETE', { id: image.id }); const res = await useFetch('/api/user/files', 'DELETE', { id: image.id });
if (!res.error) updateImages(true); if (!res.error) {
updateImages(true);
notif.showNotification({
title: 'Image Deleted',
message: '',
color: 'green',
icon: <TrashIcon />,
});
} else {
notif.showNotification({
title: 'Failed to delete image',
message: res.error,
color: 'red',
icon: <Cross1Icon />,
});
}
setOpen(false); setOpen(false);
}; };
const handleCopy = () => { const handleCopy = () => {
copy(`${window.location.protocol}//${window.location.host}${image.url}`); clipboard.copy(`${window.location.protocol}//${window.location.host}${image.url}`);
setOpen(false); setOpen(false);
notif.showNotification({
title: 'Copied to clipboard',
message: '',
icon: <CopyIcon />,
});
}; };
const handleFavorite = async () => { const handleFavorite = async () => {
const data = await useFetch('/api/user/files', 'PATCH', { id: image.id, favorite: !image.favorite }); const data = await useFetch('/api/user/files', 'PATCH', { id: image.id, favorite: !image.favorite });
if (!data.error) updateImages(true); if (!data.error) updateImages(true);
notif.showNotification({
title: 'Image is now ' + (!image.favorite ? 'favorited' : 'unfavorited'),
message: '',
icon: <StarIcon />,
});
}; };
const Type = (props) => { const Type = (props) => {
return { return {
'video': <video controls {...props} />, 'video': <video controls {...props} />,
// eslint-disable-next-line jsx-a11y/alt-text 'image': <MImage {...props} />,
'image': <img {...props} />,
'audio': <audio controls {...props} />, 'audio': <audio controls {...props} />,
}[t]; }[t];
}; };
return ( return (
<> <>
<Card sx={{ maxWidth: '100%' }}> <Modal
<CardActionArea sx={t === 'audio' ? { justifyContent: 'center', display: 'flex', alignItems: 'center' } : {}}> opened={open}
<CardMedia onClose={() => setOpen(false)}
sx={{ height: 320, fontSize: 70, width: '100%' }} title={<Title>{image.file}</Title>}
image={image.url} >
title={image.file} <Type
component={t === 'audio' ? AudioIcon : t} // this is done because audio without controls is hidden src={image.url}
alt={image.file}
/>
<Group position='right' mt={22}>
<Button onClick={handleCopy}>Copy</Button>
<Button onClick={handleDelete}>Delete</Button>
<Button onClick={handleFavorite}>{image.favorite ? 'Unfavorite' : 'Favorite'}</Button>
</Group>
</Modal>
<Card sx={{ maxWidth: '100%', height: '100%' }} shadow='md'>
<Card.Section>
<Type
sx={{ maxHeight: 320, fontSize: 70, width: '100%', cursor: 'pointer' }}
style={{ maxHeight: 320, fontSize: 70, width: '100%', cursor: 'pointer' }}
src={image.url}
alt={image.file}
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
/> />
</CardActionArea> </Card.Section>
</Card> </Card>
<Dialog
open={open}
onClose={() => setOpen(false)}
>
<DialogTitle id='alert-dialog-title'>
{image.file}
</DialogTitle>
<DialogContent>
<Type
style={{ width: '100%' }}
src={image.url}
alt={image.url}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleDelete} color='inherit'>Delete</Button>
<Button onClick={handleCopy} color='inherit'>Copy URL</Button>
<Button onClick={handleFavorite} color='inherit'>{image.favorite ? 'Unfavorite' : 'Favorite'}</Button>
</DialogActions>
</Dialog>
</> </>
); );
} }

View File

@@ -0,0 +1,158 @@
/* 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,
createStyles,
Divider,
Group,
Pagination,
Select,
Table,
Text,
useMantineTheme,
} from '@mantine/core';
import {
CopyIcon,
EnterIcon,
TrashIcon,
} from '@modulz/radix-icons';
const pageSizeOptions = ['10', '25', '50'];
const useStyles = createStyles((t) => ({
root: { height: '100%', display: 'block', marginTop: 10 },
tableContainer: {
display: 'block',
overflow: 'auto',
'& > table': {
'& > thead': { backgroundColor: t.colorScheme === 'dark' ? t.colors.dark[6] : t.colors.gray[0], zIndex: 1 },
'& > thead > tr > th': { padding: t.spacing.md },
'& > tbody > tr > td': { padding: t.spacing.md },
},
borderRadius: 6,
},
stickHeader: { top: 0, position: 'sticky' },
disableSortIcon: { color: t.colors.gray[5] },
sortDirectionIcon: { transition: 'transform 200ms ease' },
}));
export default function ImagesTable({
columns,
data = [],
serverSideDataSource = false,
initialPageSize = 10,
initialPageIndex = 0,
pageCount = 0,
total = 0,
deleteImage, copyImage, viewImage,
}) {
const { classes } = useStyles();
const theme = useMantineTheme();
const tableOptions = useTable(
{
data,
columns,
pageCount,
initialState: { pageSize: initialPageSize, pageIndex: initialPageIndex },
},
usePagination
);
const {
getTableProps, getTableBodyProps, headerGroups, rows, prepareRow, page, gotoPage, setPageSize, state: { pageIndex, pageSize },
} = tableOptions;
const getPageRecordInfo = () => {
const firstRowNum = pageIndex * pageSize + 1;
const totalRows = serverSideDataSource ? total : rows.length;
const currLastRowNum = (pageIndex + 1) * pageSize;
let lastRowNum = currLastRowNum < totalRows ? currLastRowNum : totalRows;
return `${firstRowNum} - ${lastRowNum} of ${totalRows}`;
};
const getPageCount = () => {
const totalRows = serverSideDataSource ? total : rows.length;
return Math.ceil(totalRows / pageSize);
};
const handlePageChange = (pageNum) => gotoPage(pageNum - 1);
const renderHeader = () => headerGroups.map(hg => (
<tr {...hg.getHeaderGroupProps()}>
{hg.headers.map(column => (
<th {...column.getHeaderProps()}>
<Group noWrap position={column.align || 'apart'}>
<div>{column.render('Header')}</div>
</Group>
</th>
))}
<th>Actions</th>
</tr>
));
const renderRow = rows => rows.map(row => {
prepareRow(row);
return (
<tr {...row.getRowProps()}>
{row.cells.map(cell => (
<td align={cell.column.align || 'left'} {...cell.getCellProps()}>
{cell.render('Cell')}
</td>
))}
<td align='right'>
<Group noWrap>
<ActionIcon color='red' variant='outline' onClick={() => deleteImage(row)}><TrashIcon /></ActionIcon>
<ActionIcon color='primary' variant='outline' onClick={() => copyImage(row)}><CopyIcon /></ActionIcon>
<ActionIcon color='green' variant='outline' onClick={() => viewImage(row)}><EnterIcon /></ActionIcon>
</Group>
</td>
</tr>
);
});
return (
<div className={classes.root}>
<div
className={classes.tableContainer}
style={{ height: 'calc(100% - 44px)' }}
>
<Table {...getTableProps()}>
<thead style={{ backgroundColor: theme.other.hover }}>
{renderHeader()}
</thead>
<tbody {...getTableBodyProps()}>
{renderRow(page)}
</tbody>
</Table>
</div>
<Divider mb='md' variant='dotted' />
<Group position='left'>
<Text size='sm'>Rows per page: </Text>
<Select
style={{ width: '72px' }}
variant='filled'
data={pageSizeOptions}
value={pageSize + ''}
onChange={pageSize => setPageSize(Number(pageSize))} />
<Divider orientation='vertical' />
<Text size='sm'>{getPageRecordInfo()}</Text>
<Divider orientation='vertical' />
<Pagination
page={pageIndex + 1}
total={getPageCount()}
onChange={handlePageChange} />
</Group>
</div>
);
}

View File

@@ -1,49 +1,67 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import {
AppBar,
Box,
Divider,
Drawer,
IconButton,
List,
ListItem,
ListItemIcon,
ListItemText,
Toolbar,
Typography,
Button,
Menu,
MenuItem,
Paper,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
} from '@mui/material';
import {
Menu as MenuIcon,
Home as HomeIcon,
AccountCircle as AccountIcon,
Folder as FolderIcon,
Upload as UploadIcon,
ContentCopy as CopyIcon,
Autorenew as ResetIcon,
Logout as LogoutIcon,
PeopleAlt as UsersIcon,
Brush as BrushIcon,
Link as URLIcon,
} from '@mui/icons-material';
import copy from 'copy-to-clipboard';
import Backdrop from './Backdrop';
import { friendlyThemeName, themes } from 'components/Theming';
import Select from 'components/input/Select';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useStoreDispatch } from 'lib/redux/store'; import { useStoreDispatch } from 'lib/redux/store';
import { updateUser } from 'lib/redux/reducers/user'; import { updateUser } from 'lib/redux/reducers/user';
import useFetch from 'hooks/useFetch'; import useFetch from 'hooks/useFetch';
import { CheckIcon, CopyIcon, Cross1Icon, FileIcon, GearIcon, HomeIcon, Link1Icon, ResetIcon, UploadIcon, PinRightIcon, PersonIcon, Pencil1Icon, MixerHorizontalIcon } from '@modulz/radix-icons';
import { AppShell, Burger, Divider, Group, Header, MediaQuery, Navbar, Paper, Popover, ScrollArea, Select, Text, ThemeIcon, Title, UnstyledButton, useMantineTheme, Box } from '@mantine/core';
import { useModals } from '@mantine/modals';
import { useNotifications } from '@mantine/notifications';
import { useClipboard } from '@mantine/hooks';
import { friendlyThemeName, themes } from './Theming';
function MenuItemLink(props) {
return (
<Link href={props.href} passHref>
<MenuItem {...props} />
</Link>
);
}
function MenuItem(props) {
return (
<UnstyledButton
sx={theme => ({
display: 'block',
width: '100%',
padding: 5,
borderRadius: theme.radius.sm,
color: props.color
? theme.fn.themeColor(props.color, theme.colorScheme === 'dark' ? 5 : 7)
: theme.colorScheme === 'dark'
? theme.colors.dark[0]
: theme.black,
'&:hover': {
backgroundColor: props.color
? theme.fn.rgba(
theme.fn.themeColor(props.color, theme.colorScheme === 'dark' ? 9 : 0),
theme.colorScheme === 'dark' ? 0.2 : 1
)
: theme.colorScheme === 'dark'
? theme.fn.rgba(theme.colors.dark[3], 0.35)
: theme.colors.gray[0],
},
})}
{...props}
>
<Group noWrap>
<Box sx={theme => ({
marginRight: theme.spacing.xs / 4,
paddingLeft: theme.spacing.xs / 2,
'& *': {
display: 'block',
},
})}>
{props.icon}
</Box>
<Text size='sm'>{props.children}</Text>
</Group>
</UnstyledButton>
);
}
const items = [ const items = [
{ {
@@ -52,12 +70,17 @@ const items = [
link: '/dashboard', link: '/dashboard',
}, },
{ {
icon: <FolderIcon />, icon: <FileIcon />,
text: 'Files', text: 'Files',
link: '/dashboard/files', link: '/dashboard/files',
}, },
{ {
icon: <URLIcon />, icon: <MixerHorizontalIcon />,
text: 'Stats',
link: '/dashboard/stats',
},
{
icon: <Link1Icon />,
text: 'URLs', text: 'URLs',
link: '/dashboard/urls', link: '/dashboard/urls',
}, },
@@ -68,344 +91,241 @@ const items = [
}, },
]; ];
const drawerWidth = 240; export default function Layout({ children, user }) {
function CopyTokenDialog({ open, setOpen, token }) {
const handleCopyToken = () => {
copy(token);
setOpen(false);
};
return (
<div>
<Dialog
open={open}
onClose={() => setOpen(false)}
>
<DialogTitle id='copy-dialog-title'>
Copy Token
</DialogTitle>
<DialogContent>
<DialogContentText id='copy-dialog-description'>
Make sure you don&apos;t share this token with anyone as they will be able to upload files on your behalf.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)} color='inherit' autoFocus>Cancel</Button>
<Button onClick={handleCopyToken} color='inherit'>
Copy
</Button>
</DialogActions>
</Dialog>
</div>
);
}
function ResetTokenDialog({ open, setOpen, setToken }) {
const handleResetToken = async () => {
const a = await useFetch('/api/user/token', 'PATCH');
if (a.success) setToken(a.success);
setOpen(false);
};
return (
<div>
<Dialog
open={open}
onClose={() => setOpen(false)}
>
<DialogTitle id='reset-dialog-title'>
Reset Token
</DialogTitle>
<DialogContent>
<DialogContentText id='reset-dialog-description'>
Once you reset your token, you will have to update any uploaders to use this new token.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)} color='inherit' autoFocus>Cancel</Button>
<Button onClick={handleResetToken} color='inherit'>
Reset
</Button>
</DialogActions>
</Dialog>
</div>
);
}
export default function Layout({ children, user, loading, noPaper }) {
const [systemTheme, setSystemTheme] = useState(user.systemTheme || 'dark_blue');
const [mobileOpen, setMobileOpen] = useState(false);
const [anchorEl, setAnchorEl] = useState(null);
const [copyOpen, setCopyOpen] = useState(false);
const [resetOpen, setResetOpen] = useState(false);
const [token, setToken] = useState(user?.token); const [token, setToken] = useState(user?.token);
const [systemTheme, setSystemTheme] = useState(user.systemTheme ?? 'system');
const [opened, setOpened] = useState(false); // navigation open
const [open, setOpen] = useState(false); // manage acc dropdown
const router = useRouter(); const router = useRouter();
const dispatch = useStoreDispatch(); const dispatch = useStoreDispatch();
const theme = useMantineTheme();
const modals = useModals();
const notif = useNotifications();
const clipboard = useClipboard();
const open = Boolean(anchorEl); const handleUpdateTheme = async value => {
const handleClick = e => setAnchorEl(e.currentTarget);
const handleClose = (cmd: 'copy' | 'reset') => () => {
switch (cmd) {
case 'copy':
setCopyOpen(true);
break;
case 'reset':
setResetOpen(true);
break;
}
setAnchorEl(null);
};
const handleUpdateTheme = async event => {
const newUser = await useFetch('/api/user', 'PATCH', { const newUser = await useFetch('/api/user', 'PATCH', {
systemTheme: event.target.value || 'dark_blue', systemTheme: value || 'dark_blue',
}); });
setSystemTheme(newUser.systemTheme); setSystemTheme(newUser.systemTheme);
dispatch(updateUser(newUser)); dispatch(updateUser(newUser));
router.replace(router.pathname); router.replace(router.pathname);
notif.showNotification({
title: `Theme changed to ${friendlyThemeName[value]}`,
message: '',
color: 'green',
icon: <Pencil1Icon />,
});
}; };
const drawer = ( const openResetToken = () => modals.openConfirmModal({
<div> title: 'Reset Token',
<CopyTokenDialog open={copyOpen} setOpen={setCopyOpen} token={token} /> centered: true,
<ResetTokenDialog open={resetOpen} setOpen={setResetOpen} setToken={setToken} /> children: (
<Toolbar <Text size='sm'>
sx={{ Once you reset your token, you will have to update any uploaders to use this new token.
width: { xs: drawerWidth }, </Text>
}} ),
> labels: { confirm: 'Reset', cancel: 'Cancel' },
<AppBar onConfirm: async () => {
position='fixed' const a = await useFetch('/api/user/token', 'PATCH');
elevation={0} if (!a.success) {
sx={{ setToken(a.success);
borderBottom: 1, notif.showNotification({
borderBottomColor: t => t.palette.divider, title: 'Token Reset Failed',
display: { xs: 'none', sm: 'block' }, message: a.error,
}} color: 'red',
> icon: <Cross1Icon />,
<Toolbar> });
<IconButton } else {
color='inherit' notif.showNotification({
aria-label='open drawer' title: 'Token Reset',
edge='start' message: 'Your token has been reset. You will need to update any uploaders to use this new token.',
onClick={() => setMobileOpen(true)} color: 'green',
sx={{ mr: 2, display: { sm: 'none' } }} icon: <CheckIcon />,
> });
<MenuIcon /> }
</IconButton>
<Typography
variant='h5'
noWrap
component='div'
>
Zipline
</Typography>
{user && (
<Box sx={{ marginLeft: 'auto' }}>
<Button
color='inherit'
aria-expanded={open ? 'true' : undefined}
onClick={handleClick}
>
<AccountIcon />
</Button>
<Menu
id='zipline-user-menu'
anchorEl={anchorEl}
open={open}
onClose={handleClose(null)}
MenuListProps={{
'aria-labelledby': 'basic-button',
}}
>
<MenuItem disableRipple>
<Typography variant='h5'>
<b>{user.username}</b>
</Typography>
</MenuItem>
<Divider />
<Link href='/dashboard/manage' passHref>
<MenuItem onClick={handleClose(null)}>
<AccountIcon sx={{ mr: 2 }} /> Manage Account
</MenuItem>
</Link>
<MenuItem onClick={handleClose('copy')}>
<CopyIcon sx={{ mr: 2 }} /> Copy Token
</MenuItem>
<MenuItem onClick={handleClose('reset')}>
<ResetIcon sx={{ mr: 2 }} /> Reset Token
</MenuItem>
<Link href='/auth/logout' passHref>
<MenuItem onClick={handleClose(null)}>
<LogoutIcon sx={{ mr: 2 }} /> Logout
</MenuItem>
</Link>
<MenuItem>
<BrushIcon sx={{ mr: 2 }} />
<Select
variant='standard'
label='Theme'
value={systemTheme}
onChange={handleUpdateTheme}
fullWidth
>
{Object.keys(themes).map(t => (
<MenuItem value={t} key={t}>
{friendlyThemeName[t]}
</MenuItem>
))}
</Select>
</MenuItem>
</Menu>
</Box>
)}
</Toolbar>
</AppBar>
</Toolbar>
<Divider />
<List>
{items.map((item, i) => (
<Link key={i} href={item.link} passHref>
<ListItem button>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.text} />
</ListItem>
</Link>
))}
{user && user.administrator && (
<Link href='/dashboard/users' passHref>
<ListItem button>
<ListItemIcon><UsersIcon /></ListItemIcon>
<ListItemText primary='Users' />
</ListItem>
</Link>
)}
</List>
</div>
);
const container = typeof window !== 'undefined' ? window.document.body : undefined; modals.closeAll();
},
});
const openCopyToken = () => modals.openConfirmModal({
title: 'Copy Token',
centered: true,
children: (
<Text size='sm'>
Make sure you don&apos;t share this token with anyone as they will be able to upload files on your behalf.
</Text>
),
labels: { confirm: 'Copy', cancel: 'Cancel' },
onConfirm: async () => {
clipboard.copy(token);
notif.showNotification({
title: 'Token Copied',
message: 'Your token has been copied to your clipboard.',
color: 'green',
icon: <CheckIcon />,
});
modals.closeAll();
},
});
return ( return (
<Box sx={{ display: 'flex' }}> <AppShell
<Backdrop open={loading} /> navbarOffsetBreakpoint='sm'
fixed
navbar={
<Navbar
padding='md'
hiddenBreakpoint='sm'
hidden={!opened}
width={{ sm: 200, lg: 230 }}
>
<Navbar.Section
grow
component={ScrollArea}
ml={-10}
mr={-10}
sx={{ paddingLeft: 10, paddingRight: 10 }}
>
{items.map(({ icon, text, link }) => (
<Link href={link} key={text} passHref>
<UnstyledButton
sx={{
display: 'block',
width: '100%',
padding: theme.spacing.xs,
borderRadius: theme.radius.sm,
color: theme.colorScheme === 'dark' ? theme.colors.dark[0] : theme.black,
'&:hover': {
backgroundColor: theme.other.hover,
},
}}
>
<Group>
<ThemeIcon color='primary' variant='filled'>
{icon}
</ThemeIcon>
<AppBar <Text size='lg'>{text}</Text>
position='fixed' </Group>
elevation={0} </UnstyledButton>
sx={{ </Link>
width: { sm: `calc(100% - ${drawerWidth}px)` }, ))}
ml: { sm: `${drawerWidth}px` }, {user.administrator && (
}} <Link href='/dashboard/users' passHref>
> <UnstyledButton
<Toolbar> sx={{
<IconButton display: 'block',
color='inherit' width: '100%',
aria-label='open drawer' padding: theme.spacing.xs,
edge='start' borderRadius: theme.radius.sm,
onClick={() => setMobileOpen(true)} color: theme.colorScheme === 'dark' ? theme.colors.dark[0] : theme.black,
sx={{ mr: 2, display: { sm: 'none' } }}
> '&:hover': {
<MenuIcon /> backgroundColor: theme.other.hover,
</IconButton> },
<Typography }}
variant='h5' >
noWrap <Group>
component='div' <ThemeIcon color='primary' variant='filled'>
sx={{ display: { sm: 'none' } }} <PersonIcon />
> </ThemeIcon>
Zipline
</Typography> <Text size='lg'>Users</Text>
{user && ( </Group>
<Box sx={{ marginLeft: 'auto' }}> </UnstyledButton>
<Button </Link>
color='inherit' )}
aria-expanded={open ? 'true' : undefined} </Navbar.Section>
onClick={handleClick} </Navbar>
}
header={
<Header height={70} padding='md'>
<div style={{ display: 'flex', alignItems: 'center', height: '100%' }}>
<MediaQuery largerThan='sm' styles={{ display: 'none' }}>
<Burger
opened={opened}
onClick={() => setOpened((o) => !o)}
size='sm'
color={theme.colors.gray[6]}
/>
</MediaQuery>
<Title sx={{ marginLeft: 12 }}>Zipline</Title>
<Box sx={{ marginLeft: 'auto', marginRight: 0 }}>
<Popover
position='top'
placement='end'
spacing={4}
opened={open}
onClose={() => setOpen(false)}
target={
<UnstyledButton
onClick={() => setOpen(!open)}
sx={{
display: 'block',
width: '100%',
padding: theme.spacing.xs,
borderRadius: theme.radius.sm,
color: theme.other.color,
'&:hover': {
backgroundColor: theme.other.hover,
},
}}
>
<Group>
<ThemeIcon color='primary' variant='filled'>
<GearIcon />
</ThemeIcon>
<Text>{user.username}</Text>
</Group>
</UnstyledButton>
}
> >
<AccountIcon /> <Group direction='column' spacing={2}>
</Button> <Text sx={{
<Menu color: theme.colorScheme === 'dark' ? theme.colors.dark[2] : theme.colors.gray[6],
id='zipline-user-menu' fontWeight: 500,
anchorEl={anchorEl} fontSize: theme.fontSizes.xs,
open={open} padding: `${theme.spacing.xs / 2}px ${theme.spacing.sm}px`,
onClose={handleClose(null)} cursor: 'default',
MenuListProps={{ }}>User: {user.username}</Text>
'aria-labelledby': 'basic-button', <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>
<MenuItem disableRipple> <MenuItemLink icon={<PinRightIcon />} href='/auth/logout' color='red'>Logout</MenuItemLink>
<Typography variant='h5'> <Divider
<b>{user.username}</b> variant='solid'
</Typography> my={theme.spacing.xs / 2}
</MenuItem> sx={theme => ({
<Divider /> width: '110%',
<Link href='/dash/manage' passHref> borderTopColor: theme.colorScheme === 'dark' ? theme.colors.dark[7] : theme.colors.gray[2],
<MenuItem onClick={handleClose(null)}> margin: `${theme.spacing.xs / 2}px -4px`,
<AccountIcon sx={{ mr: 2 }} /> Manage Account })}
/>
<MenuItem icon={<Pencil1Icon />}>
<Select
size='xs'
data={Object.keys(themes).map(t => ({ value: t, label: friendlyThemeName[t] }))}
value={systemTheme}
onChange={handleUpdateTheme}
/>
</MenuItem> </MenuItem>
</Link> </Group>
<MenuItem onClick={handleClose('copy')}> </Popover>
<CopyIcon sx={{ mr: 2 }} /> Copy Token
</MenuItem>
<MenuItem onClick={handleClose('reset')}>
<ResetIcon sx={{ mr: 2 }} /> Reset Token
</MenuItem>
<Link href='/auth/logout' passHref>
<MenuItem onClick={handleClose(null)}>
<LogoutIcon sx={{ mr: 2 }} /> Logout
</MenuItem>
</Link>
</Menu>
</Box> </Box>
)} </div>
</Toolbar> </Header>
</AppBar> }
<Box >
component='nav' <Paper withBorder padding='md' shadow='xs'>{children}</Paper>
sx={{ </AppShell>
width: { sm: drawerWidth },
flexShrink: { sm: 0 },
}}
>
<Drawer
container={container}
variant='temporary'
onClose={() => setMobileOpen(false)}
open={mobileOpen}
elevation={0}
ModalProps={{
keepMounted: true,
}}
sx={{
display: { xs: 'block', sm: 'none' },
'* .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
}}
>
{drawer}
</Drawer>
<Drawer
variant='permanent'
sx={{
display: { xs: 'none', sm: 'block' },
'* .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
}}
open
>
{drawer}
</Drawer>
</Box>
<Box component='main' sx={{ flexGrow: 1, p: 3, mt: 8 }}>
{user && noPaper ? children : (
<Paper elevation={0} sx={{ p: 2 }} variant='outlined'>
{children}
</Paper>
)}
</Box>
</Box>
); );
} }

View File

@@ -4,7 +4,7 @@ import React, { forwardRef } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import NextLink from 'next/link'; import NextLink from 'next/link';
import MuiLink from '@mui/material/Link'; import { Text } from '@mantine/core';
export const NextLinkComposed = forwardRef(function NextLinkComposed(props: any, ref) { export const NextLinkComposed = forwardRef(function NextLinkComposed(props: any, ref) {
const { to, linkAs, href, replace, scroll, passHref, shallow, prefetch, locale, ...other } = const { to, linkAs, href, replace, scroll, passHref, shallow, prefetch, locale, ...other } =
@@ -50,10 +50,10 @@ const Link = forwardRef(function Link(props: any, ref) {
if (isExternal) { if (isExternal) {
if (noLinkStyle) { if (noLinkStyle) {
return <a className={className} href={href} ref={ref} {...other} />; return <Text variant='link' component='a' className={className} href={href} ref={ref} {...other} />;
} }
return <MuiLink className={className} href={href} ref={ref} {...other} />; return <Text component='a' variant='link' href={href} ref={ref} {...other} />;
} }
if (noLinkStyle) { if (noLinkStyle) {
@@ -61,8 +61,9 @@ const Link = forwardRef(function Link(props: any, ref) {
} }
return ( return (
<MuiLink <Text
component={NextLinkComposed} component={NextLinkComposed}
variant='link'
linkAs={linkAs} linkAs={linkAs}
className={className} className={className}
ref={ref} ref={ref}

View File

@@ -0,0 +1,6 @@
import React from 'react';
import { Text } from '@mantine/core';
export default function StatText({ children }) {
return <Text color='gray' size='xl'>{children}</Text>;
}

View File

@@ -1,77 +1,96 @@
import React from 'react'; import React, { useEffect } from 'react';
import { ThemeProvider } from '@emotion/react';
import { CssBaseline } from '@mui/material';
// themes // themes
import dark_blue from 'lib/themes/dark_blue'; import dark_blue from 'lib/themes/dark_blue';
import light_blue from 'lib/themes/light_blue';
import dark from 'lib/themes/dark'; import dark from 'lib/themes/dark';
import ayu_dark from 'lib/themes/ayu_dark'; import ayu_dark from 'lib/themes/ayu_dark';
import ayu_mirage from 'lib/themes/ayu_mirage'; import ayu_mirage from 'lib/themes/ayu_mirage';
import ayu_light from 'lib/themes/ayu_light'; import ayu_light from 'lib/themes/ayu_light';
import nord from 'lib/themes/nord'; import nord from 'lib/themes/nord';
import polar from 'lib/themes/polar';
import dracula from 'lib/themes/dracula'; import dracula from 'lib/themes/dracula';
import matcha_dark_azul from 'lib/themes/matcha_dark_azul'; import matcha_dark_azul from 'lib/themes/matcha_dark_azul';
import qogir_dark from 'lib/themes/qogir_dark'; import qogir_dark from 'lib/themes/qogir_dark';
import { useStoreSelector } from 'lib/redux/store'; import { useStoreSelector } from 'lib/redux/store';
import createTheme from 'lib/themes'; import { MantineProvider, MantineThemeOverride } from '@mantine/core';
import { ModalsProvider } from '@mantine/modals';
import { NotificationsProvider } from '@mantine/notifications';
import { useColorScheme } from '@mantine/hooks';
export const themes = { export const themes = {
'dark_blue': dark_blue, system: (colorScheme: 'dark' | 'light') => colorScheme === 'dark' ? dark_blue : light_blue,
'dark': dark, dark_blue,
'ayu_dark': ayu_dark, light_blue,
'ayu_mirage': ayu_mirage, dark,
'ayu_light': ayu_light, ayu_dark,
'nord': nord, ayu_mirage,
'polar': polar, ayu_light,
'dracula': dracula, nord,
'matcha_dark_azul': matcha_dark_azul, dracula,
'qogir_dark': qogir_dark, matcha_dark_azul,
qogir_dark,
}; };
export const friendlyThemeName = { export const friendlyThemeName = {
'system': 'System Theme',
'dark_blue': 'Dark Blue', 'dark_blue': 'Dark Blue',
'light_blue': 'Light Blue',
'dark': 'Very Dark', 'dark': 'Very Dark',
'ayu_dark': 'Ayu Dark', 'ayu_dark': 'Ayu Dark',
'ayu_mirage': 'Ayu Mirage', 'ayu_mirage': 'Ayu Mirage',
'ayu_light': 'Ayu Light', 'ayu_light': 'Ayu Light',
'nord': 'Nord', 'nord': 'Nord',
'polar': 'Polar',
'dracula': 'Dracula', 'dracula': 'Dracula',
'matcha_dark_azul': 'Matcha Dark Azul', 'matcha_dark_azul': 'Matcha Dark Azul',
'qogir_dark': 'Qogir Dark', 'qogir_dark': 'Qogir Dark',
}; };
export default function ZiplineTheming({ Component, pageProps }) { export default function ZiplineTheming({ Component, pageProps, ...props }) {
let t;
const user = useStoreSelector(state => state.user); const user = useStoreSelector(state => state.user);
if (!user) t = themes.dark_blue; const colorScheme = useColorScheme();
else {
if (user.customTheme) { let theme: MantineThemeOverride;
t = createTheme({
type: 'dark', if (!user) theme = themes.system(colorScheme);
primary: user.customTheme.primary, else if (user.systemTheme === 'system') theme = themes.system(colorScheme);
secondary: user.customTheme.secondary, else theme = themes[user.systemTheme] ?? themes.system(colorScheme);
error: user.customTheme.error,
warning: user.customTheme.warning, useEffect(() => {
info: user.customTheme.info, document.documentElement.style.setProperty('color-scheme', theme.colorScheme);
border: user.customTheme.border, }, [user, theme]);
background: {
main: user.customTheme.mainBackground,
paper: user.customTheme.paperBackground,
},
});
} else {
t = themes[user.systemTheme] ?? themes.dark_blue;
}
}
return ( return (
<ThemeProvider theme={t}> <MantineProvider
<CssBaseline /> withGlobalStyles
<Component {...pageProps} /> withNormalizeCSS
</ThemeProvider> theme={theme}
styles={{
AppShell: t => ({
root: {
backgroundColor: t.other.AppShell_backgroundColor,
},
}),
Popover: {
inner: {
width: 200,
},
},
Accordion: {
itemTitle: {
border: 0,
},
itemOpened: {
border: 0,
},
},
}}
>
<ModalsProvider>
<NotificationsProvider>
{props.children ? props.children : <Component {...pageProps} />}
</NotificationsProvider>
</ModalsProvider>
</MantineProvider>
); );
} }

View File

@@ -1,28 +0,0 @@
import React from 'react';
import { styled, Select as MuiSelect, Input } from '@mui/material';
import { makeStyles } from '@mui/styles';
import { useTheme } from '@mui/system';
const CssInput = styled(Input)(({ theme }) => ({
'& label.Mui-focused': {
color: 'white',
},
'&': {
color: 'white',
},
'&:before': {
borderBottomColor: '#fff8',
},
'&&:hover:before': {
borderBottomColor: theme.palette.primary.dark,
},
'&:after': {
borderBottomColor: theme.palette.primary.main,
},
}));
export default function Select({ ...other }) {
return (
<MuiSelect input={<CssInput />} {...other}/>
);
}

View File

@@ -1,41 +0,0 @@
import React from 'react';
import { styled, TextField, Box } from '@mui/material';
const CssTextField = styled(TextField)(({ theme }) => ({
'& label.Mui-focused': {
color: 'white',
},
'& input': {
color: 'white',
},
'& .MuiInput-underline:before': {
borderBottomColor: '#fff8',
},
'&& .MuiInput-underline:hover:before': {
borderBottomColor: theme.palette.primary.dark,
},
'& .MuiInput-underline:after': {
borderBottomColor: theme.palette.primary.main,
},
}));
export default function TextInput({ id, label, formik, ...other }) {
return (
<Box>
<CssTextField
id={id}
name={id}
label={label}
value={formik.values[id]}
onChange={formik.handleChange}
error={formik.touched[id] && Boolean(formik.errors[id])}
helperText={formik.touched[id] && formik.errors[id]}
// @ts-ignore
variant='standard'
sx={{ pb: 0.5 }}
{...other}
/>
</Box>
);
}

View File

@@ -1,32 +1,16 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TablePagination,
TableRow,
Button,
ButtonGroup,
Typography,
Grid,
Skeleton,
CardActionArea,
CardMedia,
Card as MuiCard,
} from '@mui/material';
import AudioIcon from '@mui/icons-material/Audiotrack';
import DeleteIcon from '@mui/icons-material/Delete';
import CopyIcon from '@mui/icons-material/FileCopy';
import OpenIcon from '@mui/icons-material/OpenInNew';
import Link from 'components/Link';
import Card from 'components/Card'; import Card from 'components/Card';
import Alert from 'components/Alert'; import ZiplineImage from 'components/Image';
import copy from 'copy-to-clipboard'; import ImagesTable from 'components/ImagesTable';
import useFetch from 'lib/hooks/useFetch'; import useFetch from 'lib/hooks/useFetch';
import { useStoreSelector } from 'lib/redux/store'; import { useStoreSelector } from 'lib/redux/store';
import { Text, 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';
import StatText from 'components/StatText';
type Aligns = 'inherit' | 'right' | 'left' | 'center' | 'justify'; type Aligns = 'inherit' | 'right' | 'left' | 'center' | 'justify';
@@ -44,87 +28,56 @@ export function bytesToRead(bytes: number) {
return `${bytes.toFixed(1)} ${units[num]}`; return `${bytes.toFixed(1)} ${units[num]}`;
} }
const columns = [
{ id: 'file', label: 'Name', minWidth: 170, align: 'inherit' as Aligns },
{ id: 'mimetype', label: 'Type', minWidth: 100, align: 'inherit' as Aligns },
{
id: 'created_at',
label: 'Date',
minWidth: 170,
align: 'right' as Aligns,
format: (value) => new Date(value).toLocaleString(),
},
];
function StatText({ children }) {
return <Typography variant='h5' color='GrayText'>{children}</Typography>;
}
function StatTable({ rows, columns }) {
return (
<TableContainer sx={{ pt: 1 }}>
<Table sx={{ minWidth: 100 }} size='small'>
<TableHead>
<TableRow>
{columns.map(col => (
<TableCell key={col.name} sx={{ borderColor: t => t.palette.divider }}>{col.name}</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{rows.map(row => (
<TableRow
hover
key={row.username}
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
>
{columns.map(col => (
<TableCell key={col.id} sx={{ borderColor: t => t.palette.divider }}>
{col.format ? col.format(row[col.id]) : row[col.id]}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
}
export default function Dashboard() { export default function Dashboard() {
const user = useStoreSelector(state => state.user); const user = useStoreSelector(state => state.user);
const [images, setImages] = useState([]); const [images, setImages] = useState([]);
const [recent, setRecent] = useState([]); const [recent, setRecent] = useState([]);
const [page, setPage] = useState(0);
const [stats, setStats] = useState(null); const [stats, setStats] = useState(null);
const [rowsPerPage, setRowsPerPage] = useState(10); const clipboard = useClipboard();
const notif = useNotifications();
const [open, setOpen] = useState(false);
const [severity, setSeverity] = useState('success');
const [message, setMessage] = useState('Saved');
const updateImages = async () => { const updateImages = async () => {
const imgs = await useFetch('/api/user/files'); const imgs = await useFetch('/api/user/files');
const recent = await useFetch('/api/user/recent?filter=media'); const recent = await useFetch('/api/user/recent?filter=media');
const stts = await useFetch('/api/stats'); const stts = await useFetch('/api/stats');
setImages(imgs); setImages(imgs.map(x => ({ ...x, created_at: new Date(x.created_at).toLocaleString() })));
setStats(stts); setStats(stts);
setRecent(recent); setRecent(recent);
}; };
const handleChangePage = (event, newPage) => { const deleteImage = async ({ original }) => {
setPage(newPage); const res = await useFetch('/api/user/files', 'DELETE', { id: original.id });
if (!res.error) {
updateImages();
notif.showNotification({
title: 'Image Deleted',
message: '',
color: 'green',
icon: <TrashIcon />,
});
} else {
notif.showNotification({
title: 'Failed to delete image',
message: res.error,
color: 'red',
icon: <Cross1Icon />,
});
}
}; };
const handleChangeRowsPerPage = event => { const copyImage = async ({ original }) => {
setRowsPerPage(+event.target.value); clipboard.copy(`${window.location.protocol}//${window.location.host}${original.url}`);
setPage(0); notif.showNotification({
title: 'Copied to clipboard',
message: '',
icon: <CopyIcon />,
});
}; };
const handleDelete = async image => { const viewImage = async ({ original }) => {
const res = await useFetch('/api/user/files', 'DELETE', { id: image.id }); window.open(`${window.location.protocol}//${window.location.host}${original.url}`);
if (!res.error) updateImages();
}; };
useEffect(() => { useEffect(() => {
@@ -133,117 +86,74 @@ export default function Dashboard() {
return ( return (
<> <>
<Alert open={open} setOpen={setOpen} message={message} severity={severity} /> <Title>Welcome back {user?.username}</Title>
<Text color='gray' sx={{ paddingBottom: 4 }}>You have <b>{images.length ? images.length : '...'}</b> files</Text>
<Typography variant='h4'>Welcome back {user?.username}</Typography> <Title>Recent Files</Title>
<Typography color='GrayText' pb={2}>You have <b>{images.length ? images.length : '...'}</b> files</Typography> <SimpleGrid
cols={4}
<Typography variant='h4'>Recent Files</Typography> spacing='lg'
<Grid container spacing={4} py={2}> breakpoints={[
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
]}
>
{recent.length ? recent.map(image => ( {recent.length ? recent.map(image => (
<Grid item xs={12} sm={3} key={image.id}> <ZiplineImage key={randomId()} image={image} updateImages={updateImages} />
<MuiCard sx={{ minWidth: '100%' }}>
<CardActionArea>
<CardMedia
sx={{ height: 220 }}
image={image.url}
title={image.file}
controls
component={image.mimetype.split('/')[0] === 'audio' ? AudioIcon : image.mimetype.split('/')[0]} // this is done because audio without controls is hidden
/>
</CardActionArea>
</MuiCard>
</Grid>
)) : [1,2,3,4].map(x => ( )) : [1,2,3,4].map(x => (
<Grid item xs={12} sm={3} key={x}> <div key={x}>
<Skeleton variant='rectangular' width='100%' height={220} sx={{ borderRadius: 1 }}/> <Skeleton width='100%' height={220} sx={{ borderRadius: 1 }}/>
</Grid> </div>
))} ))}
</Grid> </SimpleGrid>
<Typography variant='h4'>Stats</Typography>
<Grid container spacing={4} py={2}> <Title mt='md'>Stats</Title>
<Grid item xs={12} sm={4}> <Text>View more stats here <Link href='/dashboard/stats'>here</Link>.</Text>
<Card name='Size' sx={{ height: '100%' }}> <SimpleGrid
<StatText>{stats ? stats.size : <Skeleton variant='text' />}</StatText> cols={3}
<Typography variant='h3'>Average Size</Typography> spacing='lg'
<StatText>{stats ? bytesToRead(stats.size_num / stats.count) : <Skeleton variant='text' />}</StatText> breakpoints={[
</Card> { maxWidth: 'sm', cols: 1, spacing: 'sm' },
</Grid> ]}
<Grid item xs={12} sm={4}> >
<Card name='Images' sx={{ height: '100%' }}> <Card name='Size' sx={{ height: '100%' }}>
<StatText>{stats ? stats.count : <Skeleton variant='text' />}</StatText> <StatText>{stats ? stats.size : <Skeleton height={8} />}</StatText>
<Typography variant='h3'>Views</Typography> <Title order={2}>Average Size</Title>
<StatText>{stats ? `${stats.views_count} (${isNaN(stats.views_count / stats.count) ? 0 : Math.round(stats.views_count / stats.count)})` : <Skeleton variant='text' />}</StatText> <StatText>{stats ? bytesToRead(stats.size_num / stats.count) : <Skeleton height={8} />}</StatText>
</Card> </Card>
</Grid> <Card name='Images' sx={{ height: '100%' }}>
<Grid item xs={12} sm={4}> <StatText>{stats ? stats.count : <Skeleton height={8} />}</StatText>
<Card name='Users' sx={{ height: '100%' }}> <Title order={2}>Views</Title>
<StatText>{stats ? stats.count_users : <Skeleton variant='text' />}</StatText> <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>
</Grid> <Card name='Users' sx={{ height: '100%' }}>
</Grid> <StatText>{stats ? stats.count_users : <Skeleton height={8} />}</StatText>
<Card name='Images' sx={{ my: 2 }} elevation={0} variant='outlined'> </Card>
<Link href='/dashboard/files' pb={2}>View Files</Link> </SimpleGrid>
<TableContainer sx={{ maxHeight: 440 }}>
<Table size='small'> <ImagesTable
<TableHead> columns={[
<TableRow> { accessor: 'file', Header: 'Name', minWidth: 170, align: 'inherit' as Aligns },
{columns.map(column => ( { accessor: 'mimetype', Header: 'Type', minWidth: 100, align: 'inherit' as Aligns },
<TableCell { accessor: 'created_at', Header: 'Date' },
key={column.id} ]}
align={column.align} data={images}
sx={{ minWidth: column.minWidth, borderColor: t => t.palette.divider }} deleteImage={deleteImage}
> copyImage={copyImage}
{column.label} viewImage={viewImage}
</TableCell> />
))}
<TableCell sx={{ minWidth: 200, borderColor: t => t.palette.divider }} align='right'> {/* <Title mt='md'>Files</Title>
Actions <Text>View previews of your files in the <Link href='/dashboard/files'>browser</Link>.</Text>
</TableCell> <ReactTable
</TableRow> columns={[
</TableHead> { accessor: 'file', Header: 'Name', minWidth: 170, align: 'inherit' as Aligns },
<TableBody> { accessor: 'mimetype', Header: 'Type', minWidth: 100, align: 'inherit' as Aligns },
{images { accessor: 'created_at', Header: 'Date' },
.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) ]}
.map(row => { data={images}
return ( pagination
<TableRow hover role='checkbox' tabIndex={-1} key={row.id}> />
{columns.map(column => { <Card name='Files per User' mt={22}>
const value = row[column.id];
return (
<TableCell key={column.id} align={column.align} sx={{ borderColor: t => t.palette.divider }}>
{column.format ? column.format(value) : value}
</TableCell>
);
})}
<TableCell align='right' sx={{ borderColor: t => t.palette.divider }}>
<ButtonGroup variant='text'>
<Button onClick={() => handleDelete(row)} color='error' size='small'><DeleteIcon /></Button>
<Button onClick={() => window.open(row.url, '_blank')} color='warning' size='small'><OpenIcon /></Button>
<Button onClick={() => {
copy(window.location.origin + row.url);
setOpen(true);
setSeverity('success');
setMessage('Copied to clipboard');
}} color='info' size='small'><CopyIcon /></Button>
</ButtonGroup>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
<TablePagination
rowsPerPageOptions={[10, 25, 100]}
component='div'
count={images.length}
rowsPerPage={rowsPerPage}
page={page}
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage} />
</Card>
<Card name='Files per User' sx={{ height: '100%', my: 2 }} elevation={0} variant='outlined'>
<StatTable <StatTable
columns={[ columns={[
{ id: 'username', name: 'Name' }, { id: 'username', name: 'Name' },
@@ -251,14 +161,14 @@ export default function Dashboard() {
]} ]}
rows={stats ? stats.count_by_user : []} /> rows={stats ? stats.count_by_user : []} />
</Card> </Card>
<Card name='Types' sx={{ height: '100%', my: 2 }} elevation={0} variant='outlined'> <Card name='Types' mt={22}>
<StatTable <StatTable
columns={[ columns={[
{ id: 'mimetype', name: 'Type' }, { id: 'mimetype', name: 'Type' },
{ id: 'count', name: 'Count' }, { id: 'count', name: 'Count' },
]} ]}
rows={stats ? stats.types_count : []} /> rows={stats ? stats.types_count : []} />
</Card> </Card> */}
</> </>
); );
} }

View File

@@ -1,27 +1,24 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Grid, Pagination, Box, Typography, Accordion, AccordionSummary, AccordionDetails } from '@mui/material';
import { ExpandMore } from '@mui/icons-material';
import Backdrop from 'components/Backdrop';
import ZiplineImage from 'components/Image'; import ZiplineImage from 'components/Image';
import useFetch from 'hooks/useFetch'; 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';
export default function Files() { export default function Files() {
const [pages, setPages] = useState([]); const [pages, setPages] = useState([]);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [favoritePages, setFavoritePages] = useState([]); const [favoritePages, setFavoritePages] = useState([]);
const [favoritePage, setFavoritePage] = useState(1); const [favoritePage, setFavoritePage] = useState(1);
const [loading, setLoading] = useState(true);
const updatePages = async favorite => { const updatePages = async favorite => {
setLoading(true);
const pages = await useFetch('/api/user/files?paged=true&filter=media'); const pages = await useFetch('/api/user/files?paged=true&filter=media');
if (favorite) { if (favorite) {
const fPages = await useFetch('/api/user/files?paged=true&favorite=media'); const fPages = await useFetch('/api/user/files?paged=true&favorite=media');
setFavoritePages(fPages); setFavoritePages(fPages);
} }
setPages(pages); setPages(pages);
setLoading(false);
}; };
useEffect(() => { useEffect(() => {
@@ -30,59 +27,76 @@ export default function Files() {
return ( return (
<> <>
<Backdrop open={loading}/> <Group>
{!pages.length ? ( <Title sx={{ marginBottom: 12 }}>Files</Title>
<Box <Link href='/dashboard/upload' passHref>
display='flex' <ActionIcon component='a' variant='filled' color='primary'><PlusIcon/></ActionIcon>
justifyContent='center' </Link>
alignItems='center' </Group>
pt={2} <Accordion
pb={3} offsetIcon={false}
> sx={t => ({
<Typography variant='h4'>No Files</Typography> marginTop: 2,
</Box> border: '1px solid',
) : <Typography variant='h4'>Files</Typography>} marginBottom: 12,
{favoritePages.length ? ( borderColor: t.colorScheme === 'dark' ? t.colors.dark[6] : t.colors.gray[0] ,
<Accordion sx={{ my: 2, border: 1, borderColor: t => t.palette.divider }} elevation={0}> })}
<AccordionSummary expandIcon={<ExpandMore />}> >
<Typography variant='h4'>Favorite Files</Typography> <Accordion.Item label={<Title>Favorite Files</Title>}>
</AccordionSummary> <SimpleGrid
<AccordionDetails> cols={3}
<Grid container spacing={2}> spacing='lg'
{favoritePages.length ? favoritePages[(favoritePage - 1) ?? 0].map(image => ( breakpoints={[
<Grid item xs={12} sm={3} key={image.id}> { maxWidth: 'sm', cols: 1, spacing: 'sm' },
<ZiplineImage image={image} updateImages={() => updatePages(true)} /> ]}
</Grid> >
)) : null} {favoritePages.length ? favoritePages[(favoritePage - 1) ?? 0].map(image => (
</Grid> <div key={image.id}>
{favoritePages.length ? ( <ZiplineImage image={image} updateImages={() => updatePages(true)} />
<Box </div>
display='flex' )) : null}
justifyContent='center' </SimpleGrid>
alignItems='center' <Box
pt={2} sx={{
> display: 'flex',
<Pagination count={favoritePages.length} page={favoritePage} onChange={(_, v) => setFavoritePage(v)}/> justifyContent: 'center',
</Box> alignItems: 'center',
) : null} paddingTop: 12,
</AccordionDetails> paddingBottom: 3,
</Accordion> }}
) : null} >
<Grid container spacing={2}> <Pagination total={favoritePages.length} page={favoritePage} onChange={setFavoritePage}/>
</Box>
</Accordion.Item>
</Accordion>
<SimpleGrid
cols={3}
spacing='lg'
breakpoints={[
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
]}
>
{pages.length ? pages[(page - 1) ?? 0].map(image => ( {pages.length ? pages[(page - 1) ?? 0].map(image => (
<Grid item xs={12} sm={3} key={image.id}> <div key={image.id}>
<ZiplineImage image={image} updateImages={updatePages} /> <ZiplineImage image={image} updateImages={() => updatePages(true)} />
</Grid> </div>
)) : null} )) : [1,2,3,4].map(x => (
</Grid> <div key={x}>
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }}/>
</div>
))}
</SimpleGrid>
{pages.length ? ( {pages.length ? (
<Box <Box
display='flex' sx={{
justifyContent='center' display: 'flex',
alignItems='center' justifyContent: 'center',
pt={2} alignItems: 'center',
paddingTop: 12,
paddingBottom: 3,
}}
> >
<Pagination count={pages.length} page={page} onChange={(_, v) => setPage(v)}/> <Pagination total={pages.length} page={page} onChange={setPage}/>
</Box> </Box>
) : null} ) : null}
</> </>

View File

@@ -1,72 +1,22 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Button, Box, Typography, MenuItem, Tooltip } from '@mui/material';
import Download from '@mui/icons-material/Download';
import { useFormik } from 'formik';
import * as yup from 'yup';
import useFetch from 'hooks/useFetch'; import useFetch from 'hooks/useFetch';
import Backdrop from 'components/Backdrop';
import Alert from 'components/Alert';
import TextInput from 'components/input/TextInput';
import Select from 'components/input/Select';
import Link from 'components/Link'; import Link from 'components/Link';
import { useStoreDispatch, useStoreSelector } from 'lib/redux/store'; import { useStoreDispatch, useStoreSelector } from 'lib/redux/store';
import { updateUser } from 'lib/redux/reducers/user'; import { updateUser } from 'lib/redux/reducers/user';
import { useRouter } from 'next/router'; import { randomId, useForm } from '@mantine/hooks';
import { Tooltip, TextInput, Button, Text, Title, Group, ColorInput, MultiSelect, Space } from '@mantine/core';
const validationSchema = yup.object({ import { DownloadIcon, Cross1Icon } from '@modulz/radix-icons';
username: yup import { useNotifications } from '@mantine/notifications';
.string()
.required('Username is required'),
});
const themeValidationSchema = yup.object({
type: yup
.string()
.required('Type (dark, light) is required is required'),
primary: yup
.string()
.required('Primary color is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
secondary: yup
.string()
.required('Secondary color is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
error: yup
.string()
.required('Error color is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
warning: yup
.string()
.required('Warning color is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
info: yup
.string()
.required('Info color is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
border: yup
.string()
.required('Border color is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
mainBackground: yup
.string()
.required('Main Background is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
paperBackground: yup
.string()
.required('Paper Background is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
});
function VarsTooltip({ children }) { function VarsTooltip({ children }) {
return ( return (
<Tooltip title={ <Tooltip position='top' placement='center' color='' label={
<> <>
<Typography><b>{'{image.file}'}</b> - file name</Typography> <Text><b>{'{image.file}'}</b> - file name</Text>
<Typography><b>{'{image.mimetype}'}</b> - mimetype</Typography> <Text><b>{'{image.mimetype}'}</b> - mimetype</Text>
<Typography><b>{'{image.id}'}</b> - id of the image</Typography> <Text><b>{'{image.id}'}</b> - id of the image</Text>
<Typography><b>{'{user.name}'}</b> - your username</Typography> <Text><b>{'{user.name}'}</b> - your username</Text>
visit <Link href='https://zipline.diced.cf/docs/variables'>the docs</Link> for more variables visit <Link href='https://zipline.diced.cf/docs/variables'>the docs</Link> for more variables
</> </>
}> }>
@@ -78,12 +28,9 @@ function VarsTooltip({ children }) {
export default function Manage() { export default function Manage() {
const user = useStoreSelector(state => state.user); const user = useStoreSelector(state => state.user);
const dispatch = useStoreDispatch(); const dispatch = useStoreDispatch();
const router = useRouter(); const notif = useNotifications();
const [loading, setLoading] = useState(false); const [domains, setDomains] = useState(user.domains ?? []);
const [open, setOpen] = useState(false);
const [severity, setSeverity] = useState('success');
const [message, setMessage] = useState('Saved');
const genShareX = (withEmbed: boolean = false, withZws: boolean = false) => { const genShareX = (withEmbed: boolean = false, withZws: boolean = false) => {
const config = { const config = {
@@ -111,150 +58,113 @@ export default function Manage() {
pseudoElement.parentNode.removeChild(pseudoElement); pseudoElement.parentNode.removeChild(pseudoElement);
}; };
const formik = useFormik({ const form = useForm({
initialValues: { initialValues: {
username: user.username, username: user.username,
password: '', password: '',
embedTitle: user.embedTitle ?? '', embedTitle: user.embedTitle ?? '',
embedColor: user.embedColor, embedColor: user.embedColor,
embedSiteName: user.embedSiteName ?? '', embedSiteName: user.embedSiteName ?? '',
}, domains: user.domains ?? [],
validationSchema,
onSubmit: async values => {
const cleanUsername = values.username.trim();
const cleanPassword = values.password.trim();
const cleanEmbedTitle = values.embedTitle.trim();
const cleanEmbedColor = values.embedColor.trim();
const cleanEmbedSiteName = values.embedSiteName.trim();
if (cleanUsername === '') return formik.setFieldError('username', 'Username can\'t be nothing');
setLoading(true);
const data = {
username: cleanUsername,
password: cleanPassword === '' ? null : cleanPassword,
embedTitle: cleanEmbedTitle === '' ? null : cleanEmbedTitle,
embedColor: cleanEmbedColor === '' ? null : cleanEmbedColor,
embedSiteName: cleanEmbedSiteName === '' ? null : cleanEmbedSiteName,
};
const newUser = await useFetch('/api/user', 'PATCH', data);
if (newUser.error) {
setLoading(false);
setMessage('An error occured');
setSeverity('error');
setOpen(true);
} else {
dispatch(updateUser(newUser));
setLoading(false);
setMessage('Saved user');
setSeverity('success');
setOpen(true);
}
}, },
}); });
const customThemeFormik = useFormik({ const onSubmit = async values => {
initialValues: { const cleanUsername = values.username.trim();
type: user.customTheme?.type || 'dark', const cleanPassword = values.password.trim();
primary: user.customTheme?.primary || '', const cleanEmbedTitle = values.embedTitle.trim();
secondary: user.customTheme?.secondary || '', const cleanEmbedColor = values.embedColor.trim();
error: user.customTheme?.error || '', const cleanEmbedSiteName = values.embedSiteName.trim();
warning: user.customTheme?.warning || '',
info: user.customTheme?.info || '',
border: user.customTheme?.border || '',
mainBackground: user.customTheme?.mainBackground || '',
paperBackground: user.customTheme?.paperBackground || '',
},
validationSchema: themeValidationSchema,
onSubmit: async values => {
setLoading(true);
const newUser = await useFetch('/api/user', 'PATCH', { customTheme: values });
if (newUser.error) { if (cleanUsername === '') return form.setFieldError('username', 'Username can\'t be nothing');
setLoading(false);
setMessage('An error occured'); const id = notif.showNotification({
setSeverity('error'); title: 'Updating user...',
setOpen(true); message: '',
} else { loading: true,
dispatch(updateUser(newUser)); autoClose: false,
router.replace(router.pathname); });
setLoading(false);
setMessage('Saved theme'); const data = {
setSeverity('success'); username: cleanUsername,
setOpen(true); 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: '',
});
}
};
return ( return (
<> <>
<Backdrop open={loading}/> <Title>Manage User</Title>
<Alert open={open} setOpen={setOpen} message={message} severity={severity} />
<Typography variant='h4'>Manage User</Typography>
<VarsTooltip> <VarsTooltip>
<Typography variant='caption' color='GrayText'>Want to use variables in embed text? Hover on this or visit <Link href='https://zipline.diced.cf/docs/variables'>the docs</Link> for more variables</Typography> <Text color='gray'>Want to use variables in embed text? Hover on this or visit <Link href='https://zipline.diced.cf/docs/variables'>the docs</Link> for more variables</Text>
</VarsTooltip> </VarsTooltip>
<form onSubmit={formik.handleSubmit}> <form onSubmit={form.onSubmit((v) => onSubmit(v))}>
<TextInput fullWidth id='username' label='Username' formik={formik} /> <TextInput id='username' label='Username' {...form.getInputProps('username')} />
<TextInput fullWidth id='password' label='Password' formik={formik} type='password' /> <TextInput id='password' label='Password' type='password' {...form.getInputProps('password')} />
<TextInput fullWidth id='embedTitle' label='Embed Title' formik={formik} /> <TextInput id='embedTitle' label='Embed Title' {...form.getInputProps('embedTitle')} />
<TextInput fullWidth id='embedColor' label='Embed Color' formik={formik} /> <ColorInput id='embedColor' label='Embed Color' {...form.getInputProps('embedColor')} />
<TextInput fullWidth id='embedSiteName' label='Embed Site Name' formik={formik} /> <TextInput id='embedSiteName' label='Embed Site Name' {...form.getInputProps('embedSiteName')} />
<Box <MultiSelect
display='flex' id='domains'
justifyContent='right' label='Domains'
alignItems='right' data={domains}
pt={2} 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' sx={{ paddingTop: 12 }}>
<Button <Button
variant='contained'
type='submit' type='submit'
>Save User</Button> >Save User</Button>
</Box> </Group>
</form> </form>
<Typography variant='h4' py={2}>Manage Theme</Typography>
<form onSubmit={customThemeFormik.handleSubmit}> <Title sx={{ paddingTop: 12, paddingBottom: 12 }}>ShareX Config</Title>
<Select <Group>
id='type' <Button onClick={() => genShareX(false)} rightIcon={<DownloadIcon />}>ShareX Config</Button>
name='type' <Button onClick={() => genShareX(true)} rightIcon={<DownloadIcon />}>ShareX Config with Embed</Button>
label='Type' <Button onClick={() => genShareX(false, true)} rightIcon={<DownloadIcon />}>ShareX Config with ZWS</Button>
value={customThemeFormik.values['type']} </Group>
onChange={customThemeFormik.handleChange}
error={customThemeFormik.touched['type'] && Boolean(customThemeFormik.errors['type'])}
variant='standard'
fullWidth
>
<MenuItem value='dark'>Dark Theme</MenuItem>
<MenuItem value='light'>Light Theme</MenuItem>
</Select>
<TextInput fullWidth id='primary' label='Primary Color' formik={customThemeFormik} />
<TextInput fullWidth id='secondary' label='Secondary Color' formik={customThemeFormik} />
<TextInput fullWidth id='error' label='Error Color' formik={customThemeFormik} />
<TextInput fullWidth id='warning' label='Warning Color' formik={customThemeFormik} />
<TextInput fullWidth id='info' label='Info Color' formik={customThemeFormik} />
<TextInput fullWidth id='border' label='Border Color' formik={customThemeFormik} />
<TextInput fullWidth id='mainBackground' label='Main Background' formik={customThemeFormik} />
<TextInput fullWidth id='paperBackground' label='Paper Background' formik={customThemeFormik} />
<Box
display='flex'
justifyContent='right'
alignItems='right'
pt={2}
>
<Button
variant='contained'
type='submit'
>Save Theme</Button>
</Box>
</form>
<Typography variant='h4' py={2}>ShareX Config</Typography>
<Button variant='contained' onClick={() => genShareX(false)} startIcon={<Download />}>ShareX Config</Button>
<Button variant='contained' sx={{ marginLeft: 1 }} onClick={() => genShareX(true)} startIcon={<Download />}>ShareX Config with Embed</Button>
<Button variant='contained' sx={{ marginLeft: 1 }} onClick={() => genShareX(false, true)} startIcon={<Download />}>ShareX Config with ZWS</Button>
</> </>
); );
} }

View File

@@ -0,0 +1,106 @@
import React, { useEffect, useState } from 'react';
import Card from 'components/Card';
import StatText from 'components/StatText';
import useFetch from 'lib/hooks/useFetch';
import { useStoreSelector } from 'lib/redux/store';
import { Box, Text, Table, Skeleton, Title, SimpleGrid } from '@mantine/core';
import { randomId } from '@mantine/hooks';
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 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 Stats() {
const [stats, setStats] = useState(null);
const update = async () => {
const stts = await useFetch('/api/stats');
setStats(stts);
};
useEffect(() => {
update();
}, []);
return (
<>
<Title>Stats</Title>
<SimpleGrid
cols={3}
spacing='lg'
breakpoints={[
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
]}
>
<Card name='Size' sx={{ height: '100%' }}>
<StatText>{stats ? stats.size : <Skeleton height={8} />}</StatText>
<Title order={2}>Average Size</Title>
<StatText>{stats ? bytesToRead(stats.size_num / stats.count) : <Skeleton height={8} />}</StatText>
</Card>
<Card name='Images' sx={{ height: '100%' }}>
<StatText>{stats ? stats.count : <Skeleton height={8} />}</StatText>
<Title order={2}>Views</Title>
<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%' }}>
<StatText>{stats ? stats.count_users : <Skeleton height={8} />}</StatText>
</Card>
</SimpleGrid>
<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>
</>
);
}

View File

@@ -1,39 +1,66 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Typography, Button, CardActionArea, Paper, Box } from '@mui/material';
import { Upload as UploadIcon } from '@mui/icons-material';
import Dropzone from 'react-dropzone';
import Backdrop from 'components/Backdrop';
import Alert from 'components/Alert';
import { useStoreSelector } from 'lib/redux/store'; import { useStoreSelector } from 'lib/redux/store';
import CenteredBox from 'components/CenteredBox';
import copy from 'copy-to-clipboard';
import Link from 'components/Link'; 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';
export default function Upload({ route }) { 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() {
const theme = useMantineTheme();
const notif = useNotifications();
const clipboard = useClipboard();
const user = useStoreSelector(state => state.user); const user = useStoreSelector(state => state.user);
const [files, setFiles] = useState([]); const [files, setFiles] = useState([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [severity, setSeverity] = useState('success');
const [message, setMessage] = useState('Saved');
useEffect(() => { useEffect(() => {
window.addEventListener('paste', (e: ClipboardEvent) => { window.addEventListener('paste', (e: ClipboardEvent) => {
const item = Array.from(e.clipboardData.items).find(x => /^image/.test(x.type)); const item = Array.from(e.clipboardData.items).find(x => /^image/.test(x.type));
const blob = item.getAsFile(); const blob = item.getAsFile();
setFiles([...files, new File([blob], blob.name, { type: blob.type })]); setFiles([...files, new File([blob], blob.name, { type: blob.type })]);
notif.showNotification({
title: 'Image Imported',
message: '',
});
}); });
}); });
const handleUpload = async () => { const handleUpload = async () => {
const body = new FormData(); const body = new FormData();
for (let i = 0; i !== files.length; ++i) body.append('file', files[i]); for (let i = 0; i !== files.length; ++i) body.append('file', files[i]);
setLoading(true); const id = notif.showNotification({
title: 'Uploading Images...',
message: '',
loading: true,
autoClose: false,
});
const res = await fetch('/api/upload', { const res = await fetch('/api/upload', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -43,63 +70,50 @@ export default function Upload({ route }) {
}); });
const json = await res.json(); const json = await res.json();
if (res.ok && json.error === undefined) { if (res.ok && json.error === undefined) {
setOpen(true); notif.updateNotification(id, {
setSeverity('success'); title: 'Upload Successful',
message: <>Copied first image to clipboard! <br/>{json.files.map(x => (<Link key={x} href={x}>{x}<br/></Link>))}</>,
//@ts-ignore color: 'green',
setMessage(<>Copied first image to clipboard! <br/>{json.files.map(x => (<Link key={x} href={x}>{x}<br/></Link>))}</>); icon: <UploadIcon />,
copy(json.url); });
clipboard.copy(json.url);
setFiles([]); setFiles([]);
} else { } else {
setOpen(true); notif.updateNotification(id, {
setSeverity('error'); title: 'Upload Failed',
setMessage('Could not upload file: ' + json.error); message: json.error,
color: 'red',
icon: <CrossCircledIcon />,
});
} }
setLoading(false);
}; };
return ( return (
<> <>
<Backdrop open={loading}/> <Dropzone onDrop={(f) => setFiles([...files, ...f])}>
<Alert open={open} setOpen={setOpen} message={message} severity={severity} /> {status => (
<>
<Group position='center' spacing='xl' style={{ minHeight: 220, pointerEvents: 'none' }}>
<ImageUploadIcon
status={status}
style={{ width: 80, height: 80, color: getIconColor(status, theme) }}
/>
<Typography variant='h4' pb={2}>Upload file</Typography> <div>
<Dropzone onDrop={acceptedFiles => setFiles([...files, ...acceptedFiles])}> <Text size='xl' inline>
{({getRootProps, getInputProps}) => ( Drag images here or click to select files
<CardActionArea> </Text>
<Paper </div>
elevation={0} </Group>
variant='outlined' <Group position='center' spacing='xl' style={{ pointerEvents: 'none' }}>
sx={{ {files.map(file => (<Text key={file.name} weight='bold'>{file.name}</Text>))}
justifyContent: 'center', </Group>
alignItems: 'center', </>
display: 'block',
p: 5,
}}
{...getRootProps()}
>
<input {...getInputProps()} />
<CenteredBox><UploadIcon sx={{ fontSize: 100 }} /></CenteredBox>
<CenteredBox><Typography variant='h5'>Drag an image or click to upload an image.</Typography></CenteredBox>
{files.map(file => (
<CenteredBox key={file.name}><Typography variant='h6'>{file.name}</Typography></CenteredBox>
))}
</Paper>
</CardActionArea>
)} )}
</Dropzone> </Dropzone>
<Group position='right'>
<Box <Button leftIcon={<UploadIcon />} mt={12} onClick={handleUpload}>Upload</Button>
display='flex' </Group>
justifyContent='right'
alignItems='right'
pt={2}
>
<Button
variant='contained'
onClick={handleUpload}
>Upload</Button>
</Box>
</> </>
); );
} }

View File

@@ -1,179 +1,161 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Grid, Card, CardHeader, Box, Typography, IconButton, Link, Dialog, DialogContent, DialogActions, Button, DialogTitle, TextField } from '@mui/material';
import { ContentCopy as CopyIcon, DeleteForever as DeleteIcon, Add as AddIcon } from '@mui/icons-material';
import Backdrop from 'components/Backdrop';
import useFetch from 'hooks/useFetch'; import useFetch from 'hooks/useFetch';
import Alert from 'components/Alert';
import copy from 'copy-to-clipboard';
import { useFormik } from 'formik';
import { useStoreSelector } from 'lib/redux/store'; import { useStoreSelector } from 'lib/redux/store';
import * as yup from 'yup'; import { useClipboard, useForm } from '@mantine/hooks';
import { CopyIcon, Cross1Icon, Link1Icon, PlusIcon, TrashIcon } from '@modulz/radix-icons';
function TextInput({ id, label, formik, ...other }) { import { useNotifications } from '@mantine/notifications';
return ( import { Modal, Title, Group, Button, Box, Card, TextInput, ActionIcon, SimpleGrid, Skeleton } from '@mantine/core';
<TextField
id={id}
name={id}
label={label}
value={formik.values[id]}
onChange={formik.handleChange}
error={formik.touched[id] && Boolean(formik.errors[id])}
helperText={formik.touched[id] && formik.errors[id]}
variant='standard'
fullWidth
sx={{ pb: 0.5 }}
{...other}
/>
);
}
export default function Urls() { export default function Urls() {
const user = useStoreSelector(state => state.user); const user = useStoreSelector(state => state.user);
const notif = useNotifications();
const clipboard = useClipboard();
const [loading, setLoading] = useState(true);
const [urls, setURLS] = useState([]); const [urls, setURLS] = useState([]);
const [open, setOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [severity, setSeverity] = useState('success');
const [message, setMessage] = useState('Deleted');
const updateURLs = async () => { const updateURLs = async () => {
setLoading(true);
const urls = await useFetch('/api/user/urls'); const urls = await useFetch('/api/user/urls');
setURLS(urls); setURLS(urls);
setLoading(false);
}; };
const deleteURL = async u => { const deleteURL = async u => {
const url = await useFetch('/api/user/urls', 'DELETE', { id: u.id }); const url = await useFetch('/api/user/urls', 'DELETE', { id: u.id });
if (url.error) { if (url.error) {
setSeverity('error'); notif.showNotification({
setMessage('Error: ' + url.error); title: 'Failed to delete URL',
setOpen(true); message: url.error,
icon: <TrashIcon />,
color: 'red',
});
} else { } else {
setSeverity('success'); notif.showNotification({
setMessage(`Deleted ${u.vanity ?? u.id}`); title: 'Deleted URL',
setOpen(true); message: '',
icon: <Cross1Icon />,
color: 'green',
});
} }
updateURLs(); updateURLs();
}; };
const copyURL = u => { const copyURL = u => {
copy(`${window.location.protocol}//${window.location.host}${u.url}`); clipboard.copy(`${window.location.protocol}//${window.location.host}${u.url}`);
setSeverity('success'); notif.showNotification({
setMessage(`Copied URL: ${window.location.protocol}//${window.location.host}${u.url}`); title: 'Copied to clipboard',
setOpen(true); message: '',
icon: <CopyIcon />,
});
}; };
const formik = useFormik({ const form = useForm({
initialValues: { initialValues: {
url: '', url: '',
vanity: '', vanity: '',
}, },
validationSchema: yup.object({
}),
onSubmit: async (values) => {
const cleanURL = values.url.trim();
const cleanVanity = values.vanity.trim();
if (cleanURL === '') return formik.setFieldError('username', 'Username can\'t be nothing');
const data = {
url: cleanURL,
vanity: cleanVanity === '' ? null : cleanVanity,
};
setCreateOpen(false);
setLoading(true);
const res = await fetch('/api/shorten', {
method: 'POST',
headers: {
'Authorization': user.token,
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
const json = await res.json();
if (json.error) {
setSeverity('error');
setMessage('Could\'nt create URL: ' + json.error);
setOpen(true);
} else {
setSeverity('success');
setMessage('Copied URL: ' + json.url);
copy(json.url);
setOpen(true);
setCreateOpen(false);
updateURLs();
}
setLoading(false);
},
}); });
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');
const data = {
url: cleanURL,
vanity: cleanVanity === '' ? null : cleanVanity,
};
setCreateOpen(false);
const res = await fetch('/api/shorten', {
method: 'POST',
headers: {
'Authorization': user.token,
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
const json = await res.json();
if (json.error) {
notif.showNotification({
title: 'Failed to create URL',
message: json.error,
color: 'red',
icon: <Cross1Icon />,
});
} else {
notif.showNotification({
title: 'URL shortened',
message: json.url,
color: 'green',
icon: <Link1Icon />,
});
}
updateURLs();
};
useEffect(() => { useEffect(() => {
updateURLs(); updateURLs();
}, []); }, []);
return ( return (
<> <>
<Backdrop open={loading}/> <Modal
<Alert open={open} setOpen={setOpen} message={message} severity={severity} /> opened={createOpen}
onClose={() => setCreateOpen(false)}
title={<Title>Shorten URL</Title>}
>
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
<TextInput id='url' label='URL' {...form.getInputProps('url')} />
<TextInput id='vanity' label='Vanity' {...form.getInputProps('vanity')} />
<Dialog open={createOpen} onClose={() => setCreateOpen(false)}> <Group position='right' mt={22}>
<DialogTitle>Shorten URL</DialogTitle> <Button onClick={() => setCreateOpen(false)}>Cancel</Button>
<form onSubmit={formik.handleSubmit}> <Button type='submit'>Submit</Button>
<DialogContent> </Group>
<TextInput id='url' label='URL' formik={formik} />
<TextInput id='vanity' label='Vanity' formik={formik} />
</DialogContent>
<DialogActions>
<Button onClick={() => setCreateOpen(false)} color='inherit' autoFocus>Cancel</Button>
<Button type='submit' color='inherit'>
Create
</Button>
</DialogActions>
</form> </form>
</Dialog> </Modal>
{!urls.length ? ( <Group>
<Box <Title sx={{ marginBottom: 12 }}>URLs</Title>
display='flex' <ActionIcon variant='filled' color='primary' onClick={() => setCreateOpen(true)}><PlusIcon/></ActionIcon>
justifyContent='center' </Group>
alignItems='center'
pt={2}
pb={3}
>
<Typography variant='h4' sx={{ mb: 1 }}>No URLs <IconButton onClick={() => setCreateOpen(true)}><AddIcon/></IconButton></Typography>
</Box>
) : <Typography variant='h4' sx={{ mb: 1 }}>URLs <IconButton onClick={() => setCreateOpen(true)}><AddIcon/></IconButton></Typography>}
<Grid container spacing={2}> <SimpleGrid
cols={4}
spacing='lg'
breakpoints={[
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
]}
>
{urls.length ? urls.map(url => ( {urls.length ? urls.map(url => (
<Grid item xs={12} sm={3} key={url.id}> <Card key={url.id} sx={{ maxWidth: '100%' }} shadow='sm'>
<Card sx={{ maxWidth: '100%' }}> <Group position='apart'>
<CardHeader <Group position='left'>
action={ <Title>{url.vanity ?? url.id}</Title>
<> </Group>
<IconButton aria-label='copy' onClick={() => copyURL(url)}> <Group position='right'>
<CopyIcon /> <ActionIcon href={url.url} component='a' target='_blank'><Link1Icon/></ActionIcon>
</IconButton> <ActionIcon aria-label='copy' onClick={() => copyURL(url)}>
<IconButton aria-label='delete' onClick={() => deleteURL(url)}> <CopyIcon />
<DeleteIcon /> </ActionIcon>
</IconButton> <ActionIcon aria-label='delete' onClick={() => deleteURL(url)}>
</> <TrashIcon />
} </ActionIcon>
title={url.vanity ?? url.id} </Group>
subheader={<Link href={url.destination}>{url.destination}</Link>} </Group>
/> </Card>
</Card> )) : [1,2,3,4,5,6,7].map(x => (
</Grid> <div key={x}>
)) : null} <Skeleton width='100%' height={60} sx={{ borderRadius: 1 }}/>
</Grid> </div>
))}
</SimpleGrid>
</> </>
); );
} }

View File

@@ -1,168 +1,138 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import {
Typography,
Card as MuiCard,
CardHeader,
Avatar,
IconButton,
Grid,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
Switch,
FormControlLabel,
} from '@mui/material';
import { Delete as DeleteIcon, Add as AddIcon } from '@mui/icons-material';
import { useStoreSelector } from 'lib/redux/store'; import { useStoreSelector } from 'lib/redux/store';
import Backdrop from 'components/Backdrop';
import Alert from 'components/Alert';
import useFetch from 'hooks/useFetch'; import useFetch from 'hooks/useFetch';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useFormik } from 'formik'; import { useForm } from '@mantine/hooks';
import { Avatar, Modal, Title, TextInput, Group, Button, Card, ActionIcon, SimpleGrid, Switch, Skeleton, Checkbox } from '@mantine/core';
import { Cross1Icon, PlusIcon, TrashIcon } from '@modulz/radix-icons';
import { useNotifications } from '@mantine/notifications';
import { useModals } from '@mantine/modals';
function Card({ user, handleDelete }) {
return (
<MuiCard sx={{ minWidth: 270 }}>
<CardHeader
avatar={<Avatar>{user.username[0]}</Avatar>}
action={<IconButton onClick={() => handleDelete(user)}><DeleteIcon /></IconButton>}
title={<Typography variant='h6'>{user.username}</Typography>}
/>
</MuiCard>
);
}
function TextInput({ id, label, formik, ...other }) { function CreateUserModal({ open, setOpen, updateUsers }) {
return ( const form = useForm({
<TextField
id={id}
name={id}
label={label}
value={formik.values[id]}
onChange={formik.handleChange}
error={formik.touched[id] && Boolean(formik.errors[id])}
helperText={formik.touched[id] && formik.errors[id]}
variant='standard'
fullWidth
sx={{ pb: 0.5 }}
{...other}
/>
);
}
function CreateUserDialog({ open, setOpen, updateUsers, setSeverity, setMessage, setLoading, setAlertOpen }) {
const formik = useFormik({
initialValues: { initialValues: {
username: '', username: '',
password: '', password: '',
administrator: false, administrator: false,
}, },
onSubmit: async (values) => {
const cleanUsername = values.username.trim();
const cleanPassword = values.password.trim();
if (cleanUsername === '') return formik.setFieldError('username', 'Username can\'t be nothing');
if (cleanPassword === '') return formik.setFieldError('password', 'Password can\'t be nothing');
const data = {
username: cleanUsername,
password: cleanPassword,
administrator: values.administrator,
};
setOpen(false);
setLoading(true);
const res = await useFetch('/api/auth/create', 'POST', data);
if (res.error) {
setSeverity('error');
setMessage('Could\'nt create user: ' + res.error);
setAlertOpen(true);
} else {
setSeverity('success');
setMessage('Created user ' + res.username);
setAlertOpen(true);
updateUsers();
}
setLoading(false);
},
}); });
const notif = useNotifications();
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');
if (cleanPassword === '') return form.setFieldError('password', 'Password can\'t be nothing');
const data = {
username: cleanUsername,
password: cleanPassword,
administrator: values.administrator,
};
setOpen(false);
const res = await useFetch('/api/auth/create', 'POST', data);
if (res.error) {
notif.showNotification({
title: 'Failed to create user',
message: res.error,
icon: <TrashIcon />,
color: 'red',
});
} else {
notif.showNotification({
title: 'Created user: ' + cleanUsername,
message: '',
icon: <PlusIcon />,
color: 'green',
});
}
updateUsers();
};
return ( return (
<div> <Modal
<Dialog opened={open}
open={open} onClose={() => setOpen(false)}
onClose={() => setOpen(false)} title={<Title>Create User</Title>}
PaperProps={{ >
elevation: 1, <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')} />
<DialogTitle> <Switch mt={12} id='administrator' label='Administrator' {...form.getInputProps('administrator')} />
Create User
</DialogTitle> <Group position='right' mt={22}>
<form onSubmit={formik.handleSubmit}> <Button onClick={() => setOpen(false)}>Cancel</Button>
<DialogContent> <Button type='submit'>Create</Button>
<TextInput id='username' label='Username' formik={formik} /> </Group>
<TextInput id='password' label='Password' formik={formik} type='password' /> </form>
<FormControlLabel </Modal>
id='administrator'
name='administrator'
value={formik.values.administrator}
onChange={formik.handleChange}
control={<Switch />}
label='Administrator?'
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)} color='inherit' autoFocus>Cancel</Button>
<Button type='submit' color='inherit'>
Create
</Button>
</DialogActions>
</form>
</Dialog>
</div>
); );
} }
export default function Users() { export default function Users() {
const user = useStoreSelector(state => state.user); const user = useStoreSelector(state => state.user);
const router = useRouter(); const router = useRouter();
const notif = useNotifications();
const modals = useModals();
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [severity, setSeverity] = useState('success'); const handleDelete = async (user, delete_images) => {
const [message, setMessage] = useState('Saved'); const res = await useFetch('/api/users', 'DELETE', {
const [loading, setLoading] = useState(true); id: user.id,
delete_images,
});
if (res.error) {
notif.showNotification({
title: 'Failed to delete user',
message: res.error,
color: 'red',
icon: <Cross1Icon />,
});
} else {
notif.showNotification({
title: 'User deleted',
message: '',
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,
labels: { confirm: 'Yes', cancel: 'No' },
onConfirm: () => {
modals.openConfirmModal({
title: `Delete ${user.username}'s images?`,
labels: { confirm: 'Yes', cancel: 'No' },
onConfirm: () => {
handleDelete(user, true);
modals.closeAll();
},
onCancel: () => {
handleDelete(user, false);
modals.closeAll();
},
});
},
});
const updateUsers = async () => { const updateUsers = async () => {
setLoading(true);
const us = await useFetch('/api/users'); const us = await useFetch('/api/users');
if (!us.error) { if (!us.error) {
setUsers(us); setUsers(us);
} else { } else {
router.push('/dashboard'); router.push('/dashboard');
}; };
setLoading(false);
};
const handleDelete = async (user) => {
const res = await useFetch('/api/users', 'DELETE', {
id: user.id,
});
if (res.error) {
setMessage(`Could not delete ${user.username}`);
setSeverity('error');
setOpen(true);
} else {
setMessage(`Deleted user ${res.username}`);
setSeverity('success');
setOpen(true);
updateUsers();
}
}; };
useEffect(() => { useEffect(() => {
@@ -171,17 +141,38 @@ export default function Users() {
return ( return (
<> <>
<Backdrop open={loading}/> <CreateUserModal open={open} setOpen={setOpen} updateUsers={updateUsers} />
<Alert open={open} setOpen={setOpen} message={message} severity={severity} /> <Group>
<CreateUserDialog open={createOpen} setOpen={setCreateOpen} setSeverity={setSeverity} setMessage={setMessage} setLoading={setLoading} updateUsers={updateUsers} setAlertOpen={setOpen} /> <Title sx={{ marginBottom: 12 }}>Users</Title>
<Typography variant='h4' pb={2}>Users <IconButton onClick={() => setCreateOpen(true)}><AddIcon /></IconButton></Typography> <ActionIcon variant='filled' color='primary' onClick={() => setOpen(true)}><PlusIcon /></ActionIcon>
<Grid container spacing={2}> </Group>
{users.filter(x => x.username !== user.username).map((user, i) => ( <SimpleGrid
<Grid item xs={12} sm={3} key={i}> cols={3}
<Card user={user} handleDelete={handleDelete}/> spacing='lg'
</Grid> breakpoints={[
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
]}
>
{users.length ? users.filter(x => x.username !== user.username).map(user => (
<Card key={user.id} sx={{ maxWidth: '100%' }}>
<Group position='apart'>
<Group position='left'>
<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)}>
<TrashIcon />
</ActionIcon>
</Group>
</Group>
</Card>
)) : [1, 2, 3, 4].map(x => (
<div key={x}>
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }} />
</div>
))} ))}
</Grid> </SimpleGrid>
</> </>
); );
} }

View File

@@ -1,6 +1,6 @@
import type { Config } from './types'; import type { Config } from './types';
import readConfig from './readConfig'; import readConfig from './readConfig';
import validateConfig from '../../server/validateConfig'; import validateConfig from '../server/validateConfig';
if (!global.config) global.config = validateConfig(readConfig()) as unknown as Config; if (!global.config) global.config = validateConfig(readConfig()) as unknown as Config;

View File

@@ -0,0 +1,40 @@
import { createReadStream, ReadStream } from 'fs';
import { readdir, rm, stat, writeFile } from 'fs/promises';
import { join } from 'path';
import { Datasource } from './datasource';
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 {
try {
return createReadStream(join(process.cwd(), this.path, file));
} 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;
}
}

74
src/lib/datasource/S3.ts Normal file
View File

@@ -0,0 +1,74 @@
import { Datasource } from './datasource';
import AWS from 'aws-sdk';
import { Readable } from 'stream';
export class S3 extends Datasource {
public name: string = 'S3';
public s3: AWS.S3;
public constructor(
public accessKey: string,
public secretKey: string,
public bucket: string,
) {
super();
this.s3 = new AWS.S3({
accessKeyId: accessKey,
secretAccessKey: secretKey,
});
}
public async save(file: string, data: Buffer): Promise<void> {
return new Promise((resolve, reject) => {
this.s3.upload({
Bucket: this.bucket,
Key: file,
Body: data,
}, err => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
public async delete(file: string): Promise<void> {
return new Promise((resolve, reject) => {
this.s3.deleteObject({
Bucket: this.bucket,
Key: file,
}, err => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
public get(file: string): Readable {
// Unfortunately, aws-sdk is bad and the stream still loads everything into memory.
return this.s3.getObject({
Bucket: this.bucket,
Key: file,
}).createReadStream();
}
public async size(): Promise<number> {
return new Promise((resolve, reject) => {
this.s3.listObjects({
Bucket: this.bucket,
}, (err, data) => {
if (err) {
reject(err);
} else {
const size = data.Contents.reduce((acc, cur) => acc + cur.Size, 0);
resolve(size);
}
});
});
}
}

View File

@@ -0,0 +1,10 @@
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;
public abstract size(): Promise<number>;
}

View File

@@ -0,0 +1,4 @@
export { Datasource } from './datasource';
export { Local } from './Local';
export { S3 } from './S3';

20
src/lib/ds.ts Normal file
View File

@@ -0,0 +1,20 @@
import config from './config';
import { S3, Local } from './datasource';
import Logger from './logger';
if (!global.datasource) {
switch (config.datasource.type) {
case 's3':
Logger.get('datasource').info(`Using S3(${config.datasource.s3.bucket}) datasource`);
global.datasource = new S3(config.datasource.s3.access_key_id, config.datasource.s3.secret_access_key, config.datasource.s3.bucket);
break;
case 'local':
Logger.get('datasource').info(`Using local(${config.datasource.local.directory}) datasource`);
global.datasource = new Local(config.datasource.local.directory);
break;
default:
throw new Error('Invalid datasource type');
}
}
export default global.datasource;

View File

@@ -16,7 +16,7 @@ export default function login() {
setLoading(true); setLoading(true);
const res = await useFetch('/api/user'); const res = await useFetch('/api/user');
if (res.error) return router.push('/auth/login'); if (res.error) return router.push('/auth/login?url=' + router.route);
dispatch(updateUser(res)); dispatch(updateUser(res));
setUser(res); setUser(res);

View File

@@ -1,38 +0,0 @@
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');
}
}
};

45
src/lib/logger.ts Normal file
View File

@@ -0,0 +1,45 @@
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) {
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, message) {
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');
}
}
};

View File

@@ -1,6 +1,5 @@
import type { NextApiRequest, NextApiResponse } from 'next'; import type { NextApiRequest, NextApiResponse } from 'next';
import type { CookieSerializeOptions } from 'cookie'; import type { CookieSerializeOptions } from 'cookie';
import type { Image, Theme, User } from '@prisma/client';
import { serialize } from 'cookie'; import { serialize } from 'cookie';
import { sign64, unsign64 } from '../util'; import { sign64, unsign64 } from '../util';
@@ -12,7 +11,7 @@ export interface NextApiFile {
originalname: string; originalname: string;
encoding: string; encoding: string;
mimetype: string; mimetype: string;
buffer: string; buffer: Buffer;
size: number; size: number;
} }
@@ -23,10 +22,10 @@ export type NextApiReq = NextApiRequest & {
embedTitle: string; embedTitle: string;
embedColor: string; embedColor: string;
systemTheme: string; systemTheme: string;
customTheme?: Theme;
administrator: boolean; administrator: boolean;
id: number; id: number;
password: string; password: string;
domains: string[];
} | null | void>; } | null | void>;
getCookie: (name: string) => string | null; getCookie: (name: string) => string | null;
cleanCookie: (name: string) => void; cleanCookie: (name: string) => void;
@@ -35,7 +34,7 @@ export type NextApiReq = NextApiRequest & {
export type NextApiRes = NextApiResponse & { export type NextApiRes = NextApiResponse & {
error: (message: string) => void; error: (message: string) => void;
forbid: (message: string) => void; forbid: (message: string, extra?: any) => void;
bad: (message: string) => void; bad: (message: string) => void;
json: (json: any) => void; json: (json: any) => void;
ratelimited: () => void; ratelimited: () => void;
@@ -54,11 +53,12 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
}); });
}; };
res.forbid = (message: string) => { res.forbid = (message: string, extra: any = {}) => {
res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Type', 'application/json');
res.status(403); res.status(403);
res.json({ res.json({
error: '403: ' + message, error: '403: ' + message,
...extra,
}); });
}; };
@@ -95,6 +95,7 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
maxAge: undefined, maxAge: undefined,
})); }));
}; };
req.user = async () => { req.user = async () => {
try { try {
const userId = req.getCookie('user'); const userId = req.getCookie('user');
@@ -111,9 +112,9 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
id: true, id: true,
password: true, password: true,
systemTheme: true, systemTheme: true,
customTheme: true,
token: true, token: true,
username: true, username: true,
domains: true,
}, },
}); });

View File

@@ -1,7 +1,8 @@
const { existsSync, readFileSync } = require('fs'); import { existsSync, readFileSync } from 'fs';
const { join } = require('path'); import { join } from 'path';
const parse = require('@iarna/toml/parse-string.js'); import parse from '@iarna/toml/parse-string';
const Logger = require('./logger.js'); import Logger from './logger';
import { Config } from './types';
const e = (val, type, fn) => ({ val, type, fn }); const e = (val, type, fn) => ({ val, type, fn });
@@ -14,9 +15,14 @@ const envValues = [
e('LOGGER', 'boolean', (c, v) => c.core.logger = v ?? true), e('LOGGER', 'boolean', (c, v) => c.core.logger = v ?? true),
e('STATS_INTERVAL', 'number', (c, v) => c.core.stats_interval = v), 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_BUCKET', 'string', (c, v) => c.datasource.s3.bucket = v),
e('UPLOADER_ROUTE', 'string', (c, v) => c.uploader.route = v), e('UPLOADER_ROUTE', 'string', (c, v) => c.uploader.route = v),
e('UPLOADER_LENGTH', 'number', (c, v) => c.uploader.length = 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_ADMIN_LIMIT', 'number', (c, v) => c.uploader.admin_limit = v),
e('UPLOADER_USER_LIMIT', 'number', (c, v) => c.uploader.user_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_extentions = v : c.uploader.disabled_extentions = []), e('UPLOADER_DISABLED_EXTS', 'array', (c, v) => v ? c.uploader.disabled_extentions = v : c.uploader.disabled_extentions = []),
@@ -28,7 +34,7 @@ const envValues = [
e('RATELIMIT_ADMIN', 'number', (c, v) => c.ratelimit.user = v ?? 0), e('RATELIMIT_ADMIN', 'number', (c, v) => c.ratelimit.user = v ?? 0),
]; ];
module.exports = function readConfig() { export default function readConfig(): Config {
if (!existsSync(join(process.cwd(), 'config.toml'))) { if (!existsSync(join(process.cwd(), 'config.toml'))) {
if (!process.env.ZIPLINE_DOCKER_BUILD) Logger.get('config').info('reading environment'); if (!process.env.ZIPLINE_DOCKER_BUILD) Logger.get('config').info('reading environment');
return tryReadEnv(); return tryReadEnv();
@@ -43,7 +49,7 @@ module.exports = function readConfig() {
} }
}; };
function tryReadEnv() { function tryReadEnv(): Config {
const config = { const config = {
core: { core: {
secure: undefined, secure: undefined,
@@ -54,10 +60,20 @@ function tryReadEnv() {
logger: undefined, logger: undefined,
stats_interval: undefined, stats_interval: undefined,
}, },
datasource: {
type: undefined,
local: {
directory: undefined,
},
s3: {
access_key_id: undefined,
secret_access_key: undefined,
bucket: undefined,
},
},
uploader: { uploader: {
route: undefined, route: undefined,
length: undefined, length: undefined,
directory: undefined,
admin_limit: undefined, admin_limit: undefined,
user_limit: undefined, user_limit: undefined,
disabled_extentions: undefined, disabled_extentions: undefined,
@@ -74,7 +90,7 @@ function tryReadEnv() {
for (let i = 0, L = envValues.length; i !== L; ++i) { for (let i = 0, L = envValues.length; i !== L; ++i) {
const envValue = envValues[i]; const envValue = envValues[i];
let value = process.env[envValue.val]; let value: any = process.env[envValue.val];
if (!value) { if (!value) {
envValues[i].fn(config, undefined); envValues[i].fn(config, undefined);

View File

@@ -1,4 +1,3 @@
import { Theme } from '@prisma/client';
import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createSlice, PayloadAction } from '@reduxjs/toolkit';
export interface User { export interface User {
@@ -8,7 +7,7 @@ export interface User {
embedColor: string; embedColor: string;
embedSiteName: string; embedSiteName: string;
systemTheme: string; systemTheme: string;
customTheme?: Theme; domains: string[];
} }
const initialState: User = null; const initialState: User = null;

View File

@@ -3,15 +3,36 @@
import createTheme from '.'; import createTheme from '.';
export default createTheme({ export default createTheme({
type: 'dark', colorScheme: 'dark',
primary: '#E6B450', primaryColor: 'orange',
secondary: '#FFEE99', other: {
error: '#F07178', AppShell_backgroundColor: '#0a0e14',
warning: '#F29668', hover: '#191e29',
info: '#95E6CB', },
border: '#191e29', colors: {
background: { dark: [
main: '#0A0E14', '#ffffff',
paper: '#0D1016', '#47494E',
'#6c707a',
'#33353B',
'#303238',
'#2C2E34',
'#25272D',
'#0d1016',
'#11141A',
'#0D1016',
],
orange: [
'#FFFFFF',
'#FCF6EA',
'#F9EDD4',
'#F3DAA8',
'#F2D69D',
'#F0D192',
'#EFCC87',
'#EDC77C',
'#EABE66',
'#E6B450',
],
}, },
}); });

View File

@@ -3,15 +3,24 @@
import createTheme from '.'; import createTheme from '.';
export default createTheme({ export default createTheme({
type: 'light', colorScheme: 'light',
primary: '#FF9940', primaryColor: 'orange',
secondary: '#E6BA7E', other: {
error: '#F07171', AppShell_backgroundColor: '#FAFAFA',
warning: '#ED9366', hover: '#FAFAFA',
info: '#95E6CB', },
border: '#e3e3e3', colors: {
background: { orange: [
main: '#FAFAFA', '#FFFFFF',
paper: '#FFFFFF', '#FCF6EA',
'#F9EDD4',
'#F3DAA8',
'#F2D69D',
'#F0D192',
'#EFCC87',
'#EDC77C',
'#EABE66',
'#E6B450',
],
}, },
}); });

View File

@@ -3,15 +3,36 @@
import createTheme from '.'; import createTheme from '.';
export default createTheme({ export default createTheme({
type: 'dark', colorScheme: 'dark',
primary: '#FFCC66', primaryColor: 'orange',
secondary: '#FFD580', other: {
error: '#F28779', AppShell_backgroundColor: '#1F2430',
warning: '#F29E74', hover: '#2a2f3b',
info: '#95E6CB', },
border: '#363c4d', colors: {
background: { dark: [
main: '#1F2430', '#ffffff',
paper: '#232834', '#91949A',
'#6c707a',
'#3F434E',
'#313641',
'#2A2F3B',
'#2e333e',
'#232834',
'#11141A',
'#0D1016',
],
orange: [
'#FFFFFF',
'#FCF6EA',
'#F9EDD4',
'#F3DAA8',
'#F2D69D',
'#F0D192',
'#EFCC87',
'#EDC77C',
'#EABE66',
'#E6B450',
],
}, },
}); });

View File

@@ -1,15 +1,36 @@
import createTheme from '.'; import createTheme from '.';
export default createTheme({ export default createTheme({
type: 'dark', colorScheme: 'dark',
primary: '#2c39a6', primaryColor: 'blue',
secondary: '#7344e2', other: {
error: '#ff4141', AppShell_backgroundColor: '#000000',
warning: '#ff9800', hover: '#2b2b2b',
info: '#2f6fb9', },
border: '#2b2b2b', colors: {
background: { dark: [
main: '#000000', '#ffffff',
paper: '#060606', '#A7A9AD',
'#7B7E84',
'#61646A',
'#54575D',
'#46494F',
'#3C3F44',
'#060606',
'#141517',
'#000000',
],
blue: [
'#FFFFFF',
'#7C7DC2',
'#7778C0',
'#6C6FBC',
'#575DB5',
'#4D54B2',
'#424BAE',
'#3742AA',
'#323EA8',
'#2C39A6',
],
}, },
}); });

View File

@@ -1,15 +1,36 @@
import createTheme from '.'; import createTheme from '.';
export default createTheme({ export default createTheme({
type: 'dark', colorScheme: 'dark',
primary: '#2c39a6', primaryColor: 'blue',
secondary: '#7344e2', other: {
error: '#ff4141', AppShell_backgroundColor: '#05070f',
warning: '#ff9800', hover: '#181c28',
info: '#2f6fb9', },
border: '#1b2541', colors: {
background: { dark: [
main: '#05070f', '#FFFFFF',
paper: '#0c101c', '#293747',
'#6C7A8D',
'#232F41',
'#41566e',
'#171F35',
'#181c28',
'#0c101c',
'#060824',
'#00001E',
],
blue: [
'#FFFFFF',
'#7C7DC2',
'#7778C0',
'#6C6FBC',
'#575DB5',
'#4D54B2',
'#424BAE',
'#3742AA',
'#323EA8',
'#2C39A6',
],
}, },
}); });

View File

@@ -3,15 +3,36 @@
import createTheme from '.'; import createTheme from '.';
export default createTheme({ export default createTheme({
type: 'dark', colorScheme: 'dark',
primary: '#BD93F9', primaryColor: 'violet',
secondary: '#6272A4', other: {
error: '#FF5555', AppShell_backgroundColor: '#282A36',
warning: '#FFB86C', hover: '#4e5062',
info: '#8BE9FD',
border: '#7D8096',
background: {
main: '#282A36',
paper: '#44475A',
}, },
}); colors: {
dark: [
'#FFFFFF',
'#CED0D4',
'#E8E8EB',
'#D1D1D6',
'#BABAC2',
'#A2A3AD',
'#4e5062',
'#44475A',
'#5C5E6F',
'#44475A',
],
violet: [
'#FFFFFF',
'#F7F2FF',
'#EFE4FE',
'#EBDEFE',
'#E7D7FD',
'#DEC9FC',
'#D6BCFC',
'#CEAEFB',
'#C6A1FA',
'#BD93F9',
],
},
});

View File

@@ -1,54 +1,5 @@
import { createTheme as muiCreateTheme } from '@mui/material/styles'; import { MantineThemeOverride } from '@mantine/core';
export interface ThemeOptions { export default function createTheme(o: MantineThemeOverride) {
type: 'dark' | 'light'; return o;
primary: string;
secondary: string;
error: string;
warning: string;
info: string;
border: string;
background: ThemeOptionsBackground;
}
export interface ThemeOptionsBackground {
main: string;
paper: string;
}
export default function createTheme(o: ThemeOptions) {
return muiCreateTheme({
palette: {
mode: o.type,
primary: {
main: o.primary,
},
secondary: {
main: o.secondary,
},
background: {
default: o.background.main,
paper: o.background.paper,
},
error: {
main: o.error,
},
warning: {
main: o.warning,
},
info: {
main: o.info,
},
divider: o.border,
},
components: {
MuiTableHead: {
styleOverrides: {
root: {
backgroundColor: o.border,
},
},
},
},
});
} }

View File

@@ -0,0 +1,24 @@
import createTheme from '.';
export default createTheme({
colorScheme: 'light',
primaryColor: 'blue',
other: {
AppShell_backgroundColor: '#FAFAFA',
hover: '#FAFAFA',
},
colors: {
blue: [
'#FFFFFF',
'#7C7DC2',
'#7778C0',
'#6C6FBC',
'#575DB5',
'#4D54B2',
'#424BAE',
'#3742AA',
'#323EA8',
'#2C39A6',
],
},
});

View File

@@ -1,15 +1,36 @@
import createTheme from '.'; import createTheme from '.';
export default createTheme({ export default createTheme({
type: 'dark', colorScheme: 'dark',
primary: '#3498db', primaryColor: 'blue',
secondary: '#7344e2', other: {
error: '#db5b5b', AppShell_backgroundColor: '#1b1d24',
warning: '#ff9800', hover: '#3c3f44',
info: '#2f6fb9', },
border: '#14161b', colors: {
background: { dark: [
main: '#1b1d24', '#FFFFFF',
paper: '#202329', '#C8C8CA',
'#F5F5F5',
'#909194',
'#585A5F',
'#4A4D52',
'#3C3F44',
'#202329',
'#272A30',
'#202329',
],
blue: [
'#FFFFFF',
'#E6F3FB',
'#CDE6F6',
'#B4D9F2',
'#9ACCED',
'#8EC6EB',
'#81BFE9',
'#67B2E4',
'#4EA5E0',
'#3498DB',
],
}, },
}); });

View File

@@ -3,15 +3,36 @@
import createTheme from '.'; import createTheme from '.';
export default createTheme({ export default createTheme({
type: 'dark', colorScheme: 'dark',
primary: '#81A1C1', primaryColor: 'blue',
secondary: '#88C0D0', other: {
error: '#BF616A', AppShell_backgroundColor: '#2E3440',
warning: '#EBCB8B', hover: '#6c727e',
info: '#5E81AC', },
border: '#565e70', colors: {
background: { dark: [
main: '#2E3440', '#FFFFFF',
paper: '#3B4252', '#CED0D4',
'#B6B9BF',
'#9DA1A9',
'#858A94',
'#6C727E',
'#606673',
'#3B4252',
'#484E5D',
'#3B4252',
],
blue: [
'#FFFFFF',
'#E0E8F0',
'#C0D0E0',
'#B9CBDD',
'#B1C5D9',
'#A1B9D1',
'#99B3CD',
'#91ADC9',
'#89A7C5',
'#81A1C1',
],
}, },
}); });

View File

@@ -1,17 +0,0 @@
// https://github.com/AlphaNecron/
// https://github.com/arcticicestudio/nord
import createTheme from '.';
export default createTheme({
type: 'light',
primary: '#81A1C1',
secondary: '#88C0D0',
error: '#BF616A',
warning: '#EBCB8B',
info: '#5E81AC',
border: '#989fab',
background: {
main: '#D8DEE9',
paper: '#E5E9F0',
},
});

View File

@@ -1,15 +1,37 @@
import createTheme from '.'; import createTheme from '.';
export default createTheme({ export default createTheme({
type: 'dark', colorScheme: 'dark',
primary: '#5294e2', primaryColor: 'blue',
secondary: '#7344e2', other: {
error: '##f04a50', AppShell_backgroundColor: '#32343d',
warning: '#ff9800', hover: '#34363d',
info: '#2f6fb9', },
border: '#4a4c54', colors: {
background: { dark: [
main: '#32343d', '#FFFFFF',
paper: '#262830', '#C9CACC',
'#F5F5F5',
'#78797E',
'#5D5E64',
'#42434A',
'#34363D',
'#262830',
'#2A2C34',
'#262830',
],
blue: [
'#FFFFFF',
'#E6F3FB',
'#CDE6F6',
'#B4D9F2',
'#9ACCED',
'#8EC6EB',
'#81BFE9',
'#67B2E4',
'#4EA5E0',
'#3498DB',
],
}, },
}); });

View File

@@ -21,6 +21,26 @@ export interface ConfigCore {
stats_interval: number; stats_interval: number;
} }
export interface ConfigDatasource {
// The type of datasource
type: 'local' | 's3';
// The local datasource
local: ConfigLocalDatasource;
s3?: ConfigS3Datasource;
}
export interface ConfigLocalDatasource {
// The directory to store files in
directory: string;
}
export interface ConfigS3Datasource {
access_key_id: string;
secret_access_key: string;
bucket: string;
}
export interface ConfigUploader { export interface ConfigUploader {
// The route uploads will be served on // The route uploads will be served on
route: string; route: string;
@@ -28,9 +48,6 @@ export interface ConfigUploader {
// Length of random chars to generate for file names // Length of random chars to generate for file names
length: number; length: number;
// Where uploads are stored
directory: string;
// Admin file upload limit // Admin file upload limit
admin_limit: number; admin_limit: number;
@@ -63,4 +80,5 @@ export interface Config {
uploader: ConfigUploader; uploader: ConfigUploader;
urls: ConfigUrls; urls: ConfigUrls;
ratelimit: ConfigRatelimit; ratelimit: ConfigRatelimit;
datasource: ConfigDatasource;
} }

View File

@@ -1,16 +1,18 @@
import React from 'react'; import React from 'react';
import { Box, Typography } from '@mui/material'; import { Box, Text } from '@mantine/core';
export default function FourOhFour() { export default function FourOhFour() {
return ( return (
<> <>
<Box <Box
display='flex' sx={{
justifyContent='center' display: 'flex',
alignItems='center' alignItems: 'center',
minHeight='100vh' minHeight: '100vh',
justifyContent: 'center',
}}
> >
<Typography variant='h2'>404 - Not Found</Typography> <Text size='xl'>404 - Not Found</Text>
</Box> </Box>
</> </>
); );

View File

@@ -1,49 +1,92 @@
import React, { useEffect } from 'react'; import React, { useEffect, useState } from 'react';
import Head from 'next/head'; import Head from 'next/head';
import { GetServerSideProps } from 'next'; import { GetServerSideProps } from 'next';
import { Box } from '@mui/material'; import { Box, Button, Modal, PasswordInput } from '@mantine/core';
import config from 'lib/config'; import config from 'lib/config';
import prisma from 'lib/prisma'; import prisma from 'lib/prisma';
import { getFile } from '../../server/util';
import { parse } from 'lib/clientUtils'; import { parse } from 'lib/clientUtils';
import * as exts from '../../scripts/exts';
export default function EmbeddedImage({ image, user, normal }) { export default function EmbeddedImage({ image, user, pass }) {
const dataURL = (route: string) => `${route}/${image.file}`; const dataURL = (route: string) => `${route}/${image.file}`;
const [opened, setOpened] = useState(pass);
const [password, setPassword] = useState('');
const [error, setError] = useState('');
// reapply date from workaround // reapply date from workaround
image.created_at = new Date(image.created_at); image.created_at = new Date(image.created_at);
const updateImage = () => { 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 imageEl = document.getElementById('image_content') as HTMLImageElement; const imageEl = document.getElementById('image_content') as HTMLImageElement;
const original = new Image; const img = new Image();
original.src = dataURL('/r'); img.addEventListener('load', function() {
if (this.naturalWidth > innerWidth) imageEl.width = Math.floor(this.naturalWidth * Math.min((innerHeight / this.naturalHeight), (innerWidth / this.naturalWidth)));
if (original.width > innerWidth) imageEl.width = Math.floor(original.width * Math.min((innerHeight / original.height), (innerWidth / original.width))); else imageEl.width = this.naturalWidth;
else imageEl.width = original.width; });
img.src = url || dataURL('/r');
if (url) {
imageEl.src = url;
};
}; };
useEffect(() => updateImage(), []); useEffect(() => {
if (pass) {
setOpened(true);
} else {
updateImage();
}
}, []);
return ( return (
<> <>
<Head> <Head>
{image.embed && ( {image.embed && (
<> <>
{user.embedSiteName && (<meta property='og:site_name' content={parse(user.embedSiteName, image, user)} />)} {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)} />)} {user.embedTitle && <meta property='og:title' content={parse(user.embedTitle, image, user)} />}
<meta property='theme-color' content={user.embedColor}/> <meta property='theme-color' content={user.embedColor} />
</> </>
)} )}
<meta property='og:image' content={dataURL('/r')} /> <meta property='og:image' content={dataURL('/r')} />
<meta property='twitter:card' content='summary_large_image' /> <meta property='twitter:card' content='summary_large_image' />
<title>{image.file}</title> <title>{image.file}</title>
</Head> </Head>
<Modal
opened={opened}
onClose={() => setOpened(false)}
title='Password Protected'
centered={true}
hideCloseButton={true}
closeOnEscape={false}
closeOnClickOutside={false}
>
<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 <Box
display='flex' sx={{
justifyContent='center' display: 'flex',
alignItems='center' alignItems: 'center',
minHeight='100vh' minHeight: '100vh',
justifyContent: 'center',
}}
> >
<img src={dataURL('/r')} alt={dataURL('/r')} id='image_content' /> <img src={dataURL('/r')} alt={dataURL('/r')} id='image_content' />
</Box> </Box>
@@ -75,7 +118,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
props: {}, props: {},
redirect: { redirect: {
destination: url.destination, destination: url.destination,
}, },
}; };
} else { } else {
@@ -94,6 +137,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
userId: true, userId: true,
embed: true, embed: true,
created_at: true, created_at: true,
password: true,
}, },
}); });
if (!image) return { notFound: true }; if (!image) return { notFound: true };
@@ -113,20 +157,31 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
//@ts-ignore workaround because next wont allow date //@ts-ignore workaround because next wont allow date
image.created_at = image.created_at.toString(); image.created_at = image.created_at.toString();
if (!image.mimetype.startsWith('image')) {
const data = await getFile(config.uploader.directory, id);
if (!data) return { notFound: true };
context.res.end(data); const prismRender = Object.keys(exts).includes(image.file.split('.').pop());
return { props: {} }; if (prismRender) return {
redirect: {
destination: `/code/${image.file}`,
permanent: true,
},
}; };
if (!image.mimetype.startsWith('image')) {
const { default: datasource } = await import('lib/ds');
const data = datasource.get(image.file);
if (!data) return { notFound: true };
data.pipe(context.res);
return { props: {} };
}
const pass = image.password ? true : false;
delete image.password;
return { return {
props: { props: {
image, image,
user, user,
normal: config.uploader.route, pass,
}, },
}; };
} }

View File

@@ -1,33 +1,19 @@
import React from 'react'; import React from 'react';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import PropTypes from 'prop-types';
import Head from 'next/head'; import Head from 'next/head';
import Theming from 'components/Theming';
import { useStore } from 'lib/redux/store'; import { useStore } from 'lib/redux/store';
import ZiplineTheming from 'components/Theming';
export default function MyApp({ Component, pageProps }) { export default function MyApp({ Component, pageProps }) {
const store = useStore(); const store = useStore();
React.useEffect(() => {
const jssStyles = document.querySelector('#jss-server-side');
if (jssStyles) jssStyles.parentElement.removeChild(jssStyles);
}, []);
return ( return (
<Provider store={store}> <Provider store={store}>
<Head> <Head>
<title>{Component.title}</title> <title>{Component.title}</title>
<meta name='viewport' content='minimum-scale=1, initial-scale=1, width=device-width' /> <meta name='viewport' content='minimum-scale=1, initial-scale=1, width=device-width' />
</Head> </Head>
<Theming <ZiplineTheming Component={Component} pageProps={pageProps} />
Component={Component}
pageProps={pageProps}
/>
</Provider> </Provider>
); );
} }
MyApp.propTypes = {
Component: PropTypes.elementType.isRequired,
pageProps: PropTypes.object.isRequired,
};

View File

@@ -1,11 +1,11 @@
import React from 'react'; import React from 'react';
import Document, { Html, Head, Main, NextScript } from 'next/document'; import Document, { Html, Head, Main, NextScript } from 'next/document';
import { createGetInitialProps } from '@mantine/next';
const getInitialProps = createGetInitialProps();
class MyDocument extends Document { class MyDocument extends Document {
static async getInitialProps(ctx) { static getInitialProps = getInitialProps;
const initialProps = await Document.getInitialProps(ctx);
return { ...initialProps };
}
render() { render() {
return ( return (

View File

@@ -0,0 +1,33 @@
import prisma from 'lib/prisma';
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
import { checkPassword } from 'lib/util';
import datasource from 'lib/ds';
import mimes from '../../../../scripts/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 = 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);

View File

@@ -32,8 +32,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
const valid = await checkPassword(password, user.password); const valid = await checkPassword(password, user.password);
if (!valid) return res.forbid('Wrong password'); if (!valid) return res.forbid('Wrong password');
// 604800 seconds is 1 week res.setCookie('user', user.id, { sameSite: true, expires: new Date(Date.now() + (6.048e+8 * 2)), path: '/' });
res.setCookie('user', user.id, { sameSite: true, maxAge: 604800, path: '/' });
Logger.get('user').info(`User ${user.username} (${user.id}) logged in`); Logger.get('user').info(`User ${user.username} (${user.id}) logged in`);

View File

@@ -2,19 +2,19 @@ import multer from 'multer';
import prisma from 'lib/prisma'; import prisma from 'lib/prisma';
import zconfig from 'lib/config'; import zconfig from 'lib/config';
import { NextApiReq, NextApiRes, withZipline } from 'lib/middleware/withZipline'; import { NextApiReq, NextApiRes, withZipline } from 'lib/middleware/withZipline';
import { createInvisImage, randomChars } from 'lib/util'; import { createInvisImage, randomChars, hashPassword } from 'lib/util';
import { writeFile } from 'fs/promises';
import { join } from 'path';
import Logger from 'lib/logger'; import Logger from 'lib/logger';
import { ImageFormat, InvisibleImage } from '@prisma/client';
import { format as formatDate } from 'fecha';
import { v4 } from 'uuid';
import datasource from 'lib/ds';
const uploader = multer({ const uploader = multer();
storage: multer.memoryStorage(),
});
async function handler(req: NextApiReq, res: NextApiRes) { async function handler(req: NextApiReq, res: NextApiRes) {
if (req.method !== 'POST') return res.forbid('invalid method'); if (req.method !== 'POST') return res.forbid('invalid method');
if (!req.headers.authorization) return res.forbid('no authorization'); if (!req.headers.authorization) return res.forbid('no authorization');
const user = await prisma.user.findFirst({ const user = await prisma.user.findFirst({
where: { where: {
token: req.headers.authorization, token: req.headers.authorization,
@@ -22,37 +22,67 @@ async function handler(req: NextApiReq, res: NextApiRes) {
}); });
if (!user) return res.forbid('authorization incorect'); if (!user) return res.forbid('authorization incorect');
if (user.ratelimited) return res.ratelimited(); if (user.ratelimited) return res.ratelimited();
await run(uploader.array('file'))(req, res);
if (!req.files) return res.error('no files'); if (!req.files) return res.error('no files');
if (req.files && req.files.length === 0) 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 = []; const files = [];
for (let i = 0; i !== req.files.length; ++i) { for (let i = 0; i !== req.files.length; ++i) {
const file = req.files[i]; const file = req.files[i];
if (file.size > zconfig.uploader[user.administrator ? 'admin_limit' : 'user_limit']) return res.error('file size too big'); 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(); const ext = file.originalname.split('.').pop();
if (zconfig.uploader.disabled_extentions.includes(ext)) return res.error('disabled extension recieved: ' + ext); if (zconfig.uploader.disabled_extentions.includes(ext)) return res.error('disabled extension recieved: ' + ext);
const rand = randomChars(zconfig.uploader.length); let fileName: string;
let invis; 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 = 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({ const image = await prisma.image.create({
data: { data: {
file: `${rand}.${ext}`, file: `${fileName}.${ext}`,
mimetype: file.mimetype, mimetype: file.mimetype,
userId: user.id, userId: user.id,
embed: !!req.headers.embed, embed: !!req.headers.embed,
format,
password,
}, },
}); });
if (req.headers.zws) invis = await createInvisImage(zconfig.uploader.length, image.id); if (req.headers.zws) invis = await createInvisImage(zconfig.uploader.length, image.id);
await writeFile(join(process.cwd(), zconfig.uploader.directory, image.file), file.buffer); await datasource.save(image.file, file.buffer);
Logger.get('image').info(`User ${user.username} (${user.id}) uploaded an image ${image.file} (${image.id})`); 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.domains.length) {
const domain = user.domains[Math.floor(Math.random() * user.domains.length)];
files.push(`${domain}${zconfig.uploader.route}/${invis ? invis.invis : image.file}`);
} else {
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) {
@@ -93,8 +123,6 @@ function run(middleware: any) {
} }
export default async function handlers(req, res) { export default async function handlers(req, res) {
await run(uploader.array('file'))(req, res);
return withZipline(handler)(req, res); return withZipline(handler)(req, res);
}; };

View File

@@ -1,10 +1,8 @@
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline'; import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
import prisma from 'lib/prisma'; import prisma from 'lib/prisma';
import config from 'lib/config';
import { chunk } from 'lib/util'; import { chunk } from 'lib/util';
import { rm } from 'fs/promises';
import { join } from 'path';
import Logger from 'lib/logger'; import Logger from 'lib/logger';
import datasource from 'lib/ds';
async function handler(req: NextApiReq, res: NextApiRes) { async function handler(req: NextApiReq, res: NextApiRes) {
const user = await req.user(); const user = await req.user();
@@ -19,10 +17,11 @@ async function handler(req: NextApiReq, res: NextApiRes) {
}, },
}); });
await rm(join(process.cwd(), config.uploader.directory, image.file)); await datasource.delete(image.file);
Logger.get('image').info(`User ${user.username} (${user.id}) deleted an image ${image.file} (${image.id})`); 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') { } else if (req.method === 'PATCH') {
if (!req.body.id) return res.error('no file id'); if (!req.body.id) return res.error('no file id');
@@ -36,6 +35,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
}, },
}); });
delete image.password;
return res.json(image); return res.json(image);
} else { } else {
let images = await prisma.image.findMany({ let images = await prisma.image.findMany({
@@ -43,6 +43,9 @@ async function handler(req: NextApiReq, res: NextApiRes) {
userId: user.id, userId: user.id,
favorite: !!req.query.favorite, favorite: !!req.query.favorite,
}, },
orderBy: {
created_at: 'desc',
},
select: { select: {
created_at: true, created_at: true,
file: true, file: true,

View File

@@ -2,6 +2,7 @@ import prisma from 'lib/prisma';
import { hashPassword } from 'lib/util'; import { hashPassword } from 'lib/util';
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline'; import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
import Logger from 'lib/logger'; import Logger from 'lib/logger';
import pkg from '../../../../package.json';
async function handler(req: NextApiReq, res: NextApiRes) { async function handler(req: NextApiReq, res: NextApiRes) {
const user = await req.user(); const user = await req.user();
@@ -51,22 +52,34 @@ async function handler(req: NextApiReq, res: NextApiRes) {
data: { systemTheme: req.body.systemTheme }, data: { systemTheme: req.body.systemTheme },
}); });
if (req.body.customTheme) { if (req.body.domains) {
if (user.customTheme) await prisma.user.update({ if (!req.body.domains) await prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: { data: { domains: [] },
customTheme: {
update: {
...req.body.customTheme,
},
},
},
}); else await prisma.theme.create({
data: {
userId: user.id,
...req.body.customTheme,
},
}); });
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({ const newUser = await prisma.user.findFirst({
@@ -82,9 +95,9 @@ async function handler(req: NextApiReq, res: NextApiRes) {
images: false, images: false,
password: false, password: false,
systemTheme: true, systemTheme: true,
customTheme: true,
token: true, token: true,
username: true, username: true,
domains: true,
}, },
}); });

View File

@@ -21,6 +21,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
created_at: true, created_at: true,
file: true, file: true,
mimetype: true, mimetype: true,
id: true,
}, },
}); });

View File

@@ -1,5 +1,6 @@
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline'; import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
import prisma from 'lib/prisma'; import prisma from 'lib/prisma';
import Logger from 'lib/logger';
async function handler(req: NextApiReq, res: NextApiRes) { async function handler(req: NextApiReq, res: NextApiRes) {
const user = await req.user(); const user = await req.user();
@@ -16,6 +17,15 @@ async function handler(req: NextApiReq, res: NextApiRes) {
}); });
if (!deleteUser) return res.forbid('user doesn\'t exist'); if (!deleteUser) return res.forbid('user doesn\'t exist');
if (req.body.delete_images) {
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({ await prisma.user.delete({
where: { where: {
id: deleteUser.id, id: deleteUser.id,
@@ -33,7 +43,6 @@ async function handler(req: NextApiReq, res: NextApiRes) {
token: true, token: true,
embedColor: true, embedColor: true,
embedTitle: true, embedTitle: true,
customTheme: true,
systemTheme: true, systemTheme: true,
}, },
}); });

17
src/pages/api/version.ts Normal file
View File

@@ -0,0 +1,17 @@
import { readFile } from 'fs/promises';
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
async function handler(req: NextApiReq, res: NextApiRes) {
const pkg = JSON.parse(await readFile('package.json', 'utf8'));
const re = await fetch('https://raw.githubusercontent.com/diced/zipline/trunk/package.json');
const upstreamPkg = await re.json();
return res.json({
local: pkg.version,
upstream: upstreamPkg.version,
});
}
export default withZipline(handler);

View File

@@ -1,83 +1,64 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect } from 'react';
import { Typography, Box, TextField, Stack, Button, styled } from '@mui/material';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import Alert from 'components/Alert';
import Backdrop from 'components/Backdrop';
import TextInput from 'components/input/TextInput';
import useFetch from 'hooks/useFetch'; import useFetch from 'hooks/useFetch';
import { useFormik } from 'formik'; 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() { export default function Login() {
const [open, setOpen] = useState(false);
const [severity, setSeverity] = useState('success');
const [message, setMessage] = useState('');
const [loadingOpen, setLoadingOpen] = useState(false);
const router = useRouter(); const router = useRouter();
const notif = useNotifications();
const formik = useFormik({ const form = useForm({
initialValues: { initialValues: {
username: '', username: '',
password: '', password: '',
}, },
onSubmit: async values => {
const username = values.username.trim();
const password = values.password.trim();
if (username === '') return formik.setFieldError('username', 'Username can\'t be nothing');
setLoadingOpen(true);
const res = await useFetch('/api/auth/login', 'POST', {
username, password,
});
if (res.error) {
setOpen(true);
setSeverity('error');
setMessage(res.error);
setLoadingOpen(false);
} else {
setOpen(true);
setSeverity('success');
setMessage('Logged in');
router.push('/dashboard');
}
},
}); });
const onSubmit = async values => {
const username = values.username.trim();
const password = values.password.trim();
if (username === '') return form.setFieldError('username', 'Username can\'t be nothing');
const res = await useFetch('/api/auth/login', 'POST', {
username, password,
});
if (res.error) {
notif.showNotification({
title: 'Login Failed',
message: res.error,
color: 'red',
icon: <Cross1Icon />,
});
} else {
await router.push(router.query.url as string || '/dashboard');
}
};
useEffect(() => { useEffect(() => {
(async () => { (async () => {
const a = await fetch('/api/user'); const a = await fetch('/api/user');
if (a.ok) router.push('/dashboard'); if (a.ok) await router.push('/dashboard');
})(); })();
}, []); }, []);
return ( return (
<> <>
<Alert open={open} setOpen={setOpen} severity={severity} message={message} /> <Center sx={{ height: '100vh' }}>
<Backdrop open={loadingOpen} /> <div>
<Box <Title align='center'>Zipline</Title>
display='flex' <form onSubmit={form.onSubmit((v) => onSubmit(v))}>
height='screen' <TextInput size='lg' id='username' label='Username' {...form.getInputProps('username')} />
alignItems='center' <TextInput size='lg' id='password' label='Password' type='password' {...form.getInputProps('password')} />
justifyContent='center'
sx={{ height: '24rem' }}
>
<Stack>
<Typography variant='h3' textAlign='center'>
Zipline
</Typography>
<form onSubmit={formik.handleSubmit}> <Button size='lg' type='submit' fullWidth mt={12}>Login</Button>
<TextInput formik={formik} id='username' label='Username' />
<TextInput formik={formik} id='password' label='Password' type='password' />
<Box my={2}>
<Button variant='contained' fullWidth type='submit'>
Login
</Button>
</Box>
</form> </form>
</Stack> </div>
</Box> </Center>
</> </>
); );
} }

View File

@@ -1,6 +1,6 @@
import React, { useEffect } from 'react'; import React, { useEffect, useState } from 'react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { Backdrop, CircularProgress } from '@mui/material'; import { LoadingOverlay } from '@mantine/core';
export default function Logout() { export default function Logout() {
const router = useRouter(); const router = useRouter();
@@ -18,12 +18,7 @@ export default function Logout() {
}, []); }, []);
return ( return (
<Backdrop <LoadingOverlay visible={true} />
sx={{ color: '#fff', zIndex: t => t.zIndex.drawer + 1 }}
open
>
<CircularProgress color='inherit' />
</Backdrop>
); );
} }

23
src/pages/code/[id].tsx Normal file
View File

@@ -0,0 +1,23 @@
import React, { useEffect } from 'react';
import { useRouter } from 'next/router';
import exts from '../../../scripts/exts';
import { Prism } from '@mantine/prism';
export default function Code() {
const [prismRenderCode, setPrismRenderCode] = React.useState('');
const router = useRouter();
const { id } = router.query as { id: string };
useEffect(() => {
(async () => {
const res = await fetch('/r/' + id);
if (id && !res.ok) await router.push('/404');
const data = await res.text();
if (id) setPrismRenderCode(data);
})();
}, [id]);
return id && prismRenderCode ? (
<Prism sx={t => ({ height: '100vh', backgroundColor: t.colors.dark[8] })} withLineNumbers language={exts[id.split('.').pop()]}>{prismRenderCode}</Prism>
) : null;
}

View File

@@ -11,8 +11,6 @@ export default function FilesPage() {
return ( return (
<Layout <Layout
user={user} user={user}
loading={loading}
noPaper={false}
> >
<Files /> <Files />
</Layout> </Layout>

View File

@@ -5,14 +5,11 @@ import Dashboard from 'components/pages/Dashboard';
export default function DashboardPage() { export default function DashboardPage() {
const { user, loading } = useLogin(); const { user, loading } = useLogin();
if (loading) return null; if (loading) return null;
return ( return (
<Layout <Layout
user={user} user={user}
loading={loading}
noPaper={false}
> >
<Dashboard /> <Dashboard />
</Layout> </Layout>

View File

@@ -11,8 +11,6 @@ export default function ManagePage() {
return ( return (
<Layout <Layout
user={user} user={user}
loading={loading}
noPaper={false}
> >
<Manage /> <Manage />
</Layout> </Layout>

View File

@@ -0,0 +1,19 @@
import React from 'react';
import useLogin from 'hooks/useLogin';
import Layout from 'components/Layout';
import Stats from 'components/pages/Stats';
export default function StatsPage() {
const { user, loading } = useLogin();
if (loading) return null;
return (
<Layout
user={user}
>
<Stats />
</Layout>
);
}
StatsPage.title = 'Zipline - Stats';

View File

@@ -1,9 +1,7 @@
import React from 'react'; import React from 'react';
import { GetStaticProps } from 'next';
import useLogin from 'hooks/useLogin'; import useLogin from 'hooks/useLogin';
import Layout from 'components/Layout'; import Layout from 'components/Layout';
import Upload from 'components/pages/Upload'; import Upload from 'components/pages/Upload';
import config from 'lib/config';
export default function UploadPage({ route }) { export default function UploadPage({ route }) {
const { user, loading } = useLogin(); const { user, loading } = useLogin();
@@ -13,20 +11,10 @@ export default function UploadPage({ route }) {
return ( return (
<Layout <Layout
user={user} user={user}
loading={loading}
noPaper={false}
> >
<Upload route={route}/> <Upload/>
</Layout> </Layout>
); );
} }
export const getStaticProps: GetStaticProps = async () => {
return {
props: {
route: process.env.ZIPLINE_DOCKER_BUILD === '1' ? '/u' : config.uploader.route,
},
};
};
UploadPage.title = 'Zipline - Upload'; UploadPage.title = 'Zipline - Upload';

View File

@@ -11,8 +11,6 @@ export default function UrlsPage() {
return ( return (
<Layout <Layout
user={user} user={user}
loading={loading}
noPaper={false}
> >
<Urls /> <Urls />
</Layout> </Layout>

View File

@@ -11,8 +11,6 @@ export default function UsersPage() {
return ( return (
<Layout <Layout
user={user} user={user}
loading={loading}
noPaper={false}
> >
<Users /> <Users />
</Layout> </Layout>

View File

@@ -1,12 +0,0 @@
import { useRouter } from 'next/router';
import { useEffect } from 'react';
export default function Index() {
const router = useRouter();
useEffect(() => {
router.push('/dashboard');
}, [router]);
return null;
}

7
src/server/index.ts Normal file
View File

@@ -0,0 +1,7 @@
import { version } from '../../package.json';
import Logger from '../lib/logger';
Logger.get('server').info(`starting zipline@${version} server`);
import Server from './server';
new Server();

Some files were not shown because too many files have changed in this diff Show More