Compare commits

..

41 Commits

Author SHA1 Message Date
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
diced
762d2927f7 Merge branch 'trunk' of github.com:diced/zipline into trunk 2022-02-19 20:17:53 -08:00
diced
d9561f3b12 feat(v3.3.1): bug fixes and new features 2022-02-19 20:17:02 -08:00
dicedtomato
dde24848d4 fix: domains in readme 2022-02-17 16:31:12 -08:00
diced
e786482902 fix: multiple issues & new features 2022-02-12 20:35:36 -08:00
diced
4e64922b70 feat(v3.3): release 3.3 2022-01-03 19:00:51 -08:00
diced
15042b16d1 feat(v3.3): ctrl+v to upload image 2022-01-03 19:00:20 -08:00
diced
5e4c4fc6c9 feat(v3.3): faster stats 2022-01-03 15:56:33 -08:00
diced
7194c53891 feat(v3.3): ratelimit 2022-01-03 15:17:47 -08:00
Nguyen Thanh Quang
7eff77ccc4 refactor(api): cors duplication (#109)
* refactor(api): cors duplication

* refactor(middleware): moved content-type setter to top
2021-11-27 15:00:18 -08:00
Nguyen Thanh Quang
1b78ffaa91 fix(prisma): make sure migrations are migrated in the first run (#105)
* fix(prisma): make sure migrations are migrated in the first run

* chore: removed redundant parentheses
2021-11-27 14:39:57 -08:00
dicedtomato
8e8bfd68d1 Update README.md 2021-11-23 18:31:26 -08:00
diced
b029505cdd feat(api): add cors 2021-11-04 17:09:18 -07:00
Kyle
c5c862bee3 fix: readme links (#104) 2021-10-03 11:42:27 -07:00
Nguyen Thanh Quang
3c38d008f1 fix(config): updated example config file (#103) 2021-10-03 11:41:50 -07:00
diced
dc52b00a00 feat(v3.2.5): update mui & embed vars 2021-10-02 20:16:23 -07:00
diced
b5d2e7040e fix: multi 1000 to expires 2021-09-25 18:03:06 -07:00
diced
5818440721 feat(pages): add create url 2021-09-25 18:00:00 -07:00
diced
f1c46da47d feat(pages): add urls page 2021-09-25 17:30:23 -07:00
diced
212c69d303 fix: add comma dangles 2021-09-25 09:39:51 -07:00
diced
9e4152e298 fix: github actions build 2021-09-24 20:41:26 -07:00
diced
307f023e47 fix: github actions build 2021-09-24 20:39:43 -07:00
diced
3451bd8762 feat(v3.2.4): url shortenning 2021-09-24 20:31:45 -07:00
diced
a9d0be8aae fix: revert arm stuff 2021-09-18 21:34:02 -07:00
diced
d83f720631 fix(actions): add custom prisma engines 2021-09-18 21:28:24 -07:00
diced
1f3d396296 fix(actions): make action use v2 2021-09-18 20:59:03 -07:00
diced
48f771f344 fix(actions): make action use v2 2021-09-18 20:42:47 -07:00
diced
555bc6aa26 fix(docker): make action target linux/arm64 2021-09-18 20:33:25 -07:00
diced
8bd0eaac1e fix(docker): make action target linux/arm64 2021-09-18 20:19:50 -07:00
diced
3280c77002 fix(docker): make action target linux/arm64 2021-09-18 20:18:04 -07:00
diced
b39743a53a fix(docker): make action target linux/arm64 2021-09-18 20:16:33 -07:00
diced
9a73da56e9 feat(docker): add arm64 compatible images 2021-09-18 20:10:22 -07:00
diced
c9b0d2664f feat(v3.2.3): new config validation 2021-09-17 21:38:24 -07:00
dicedtomato
6063c9efac Update README.md 2021-09-17 20:40:38 -07:00
diced
dd6f192d4a fix: many things 2021-09-17 20:39:20 -07:00
diced
d956f4ed3d fix(api): fix recent images showing other users images 2021-09-12 21:31:43 -07:00
109 changed files with 5533 additions and 5385 deletions

View File

@@ -1,20 +0,0 @@
{
"presets": [
"next/babel"
],
"plugins": [
[
"babel-plugin-transform-imports",
{
"@material-ui/core": {
"transform": "@material-ui/core/${member}",
"preventFullImport": true
},
"@material-ui/icons": {
"transform": "@material-ui/icons/${member}",
"preventFullImport": true
}
}
]
]
}

4
.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
.next/
uploads/
.git/

View File

@@ -1,24 +0,0 @@
module.exports = {
'extends': ['next', 'next/core-web-vitals'],
'rules': {
'indent': ['error', 2],
'linebreak-style': ['error', 'unix'],
'quotes': ['error', 'single'],
'semi': ['error', 'always'],
'jsx-quotes': ['error', 'prefer-single'],
'react/prop-types': 'off',
'react-hooks/rules-of-hooks': 'off',
'react-hooks/exhaustive-deps': 'off',
'react/jsx-uses-react': 'warn',
'react/jsx-uses-vars': 'warn',
'react/no-danger-with-children': 'warn',
'react/no-deprecated': 'warn',
'react/no-direct-mutation-state': 'warn',
'react/no-is-mounted': 'warn',
'react/no-typos': 'error',
'react/react-in-jsx-scope': 'error',
'react/require-render-return': 'error',
'react/style-prop-object': 'warn',
'@next/next/no-img-element': 'off'
}
};

25
.eslintrc.json Normal file
View File

@@ -0,0 +1,25 @@
{
"extends": ["next", "next/core-web-vitals"],
"rules": {
"indent": ["error", 2],
"linebreak-style": ["error", "unix"],
"quotes": ["error", "single"],
"semi": ["error", "always"],
"comma-dangle": ["error", "always-multiline"],
"jsx-quotes": ["error", "prefer-single"],
"react/prop-types": "off",
"react-hooks/rules-of-hooks": "off",
"react-hooks/exhaustive-deps": "off",
"react/jsx-uses-react": "warn",
"react/jsx-uses-vars": "warn",
"react/no-danger-with-children": "warn",
"react/no-deprecated": "warn",
"react/no-direct-mutation-state": "warn",
"react/no-is-mounted": "warn",
"react/no-typos": "error",
"react/react-in-jsx-scope": "error",
"react/require-render-return": "error",
"react/style-prop-object": "warn",
"@next/next/no-img-element": "off"
}
}

View File

@@ -23,7 +23,7 @@ jobs:
key: ${{ runner.os }}-node${{ matrix.node }}-${{ hashFiles('**/yarn.lock') }} key: ${{ runner.os }}-node${{ matrix.node }}-${{ hashFiles('**/yarn.lock') }}
- name: Create mock config - name: Create mock config
run: echo -e "[uploader]\nroute = '/u'\nembed_route = '/a'\nlength = 6\ndirectory = './uploads'" > config.toml run: echo -e "[core]\nsecret = '12345678'\ndatabase_url = 'postgres://postgres:postgres@postgres/postgres'\n[uploader]\nroute = '/u'\ndirectory = './uploads'\n[urls]\nroute = '/go'" > config.toml
- name: Install dependencies - name: Install dependencies
if: steps.cache-restore.outputs.cache-hit != 'true' if: steps.cache-restore.outputs.cache-hit != 'true'
@@ -31,4 +31,3 @@ jobs:
- name: Build - name: Build
run: yarn build run: yarn build

View File

@@ -7,6 +7,7 @@ on:
- 'src/**' - 'src/**'
- 'server/**' - 'server/**'
- 'prisma/**' - 'prisma/**'
- '.github/**'
workflow_dispatch: workflow_dispatch:
jobs: jobs:

2
.gitignore vendored
View File

@@ -18,6 +18,7 @@
# misc # misc
.DS_Store .DS_Store
*.pem *.pem
.idea
# debug # debug
npm-debug.log* npm-debug.log*
@@ -36,4 +37,3 @@ yarn-error.log*
# zipline # zipline
config.toml config.toml
uploads/ uploads/
data.db*

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

View File

@@ -1,31 +1,46 @@
FROM node:16-alpine3.11 AS builder FROM node:16-alpine AS deps
WORKDIR /build WORKDIR /build
COPY package.json yarn.lock ./
RUN apk add --no-cache libc6-compat
RUN yarn install --frozen-lockfile
FROM node:16-alpine AS builder
WORKDIR /build
COPY --from=deps /build/node_modules ./node_modules
COPY src ./src COPY src ./src
COPY server ./server 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 package.json yarn.lock next.config.js next-env.d.ts zip-env.d.ts tsconfig.json ./
RUN yarn install ENV ZIPLINE_DOCKER_BUILD 1
ENV NEXT_TELEMETRY_DISABLED 1
# create a mock config.toml to spoof next build!
RUN echo -e "[uploader]\nroute = '/u'\nembed_route = '/a'\nlength = 6\ndirectory = './uploads'" > config.toml
RUN yarn build RUN yarn build
FROM node:16-alpine3.11 AS runner FROM node:16-alpine AS runner
WORKDIR /zipline WORKDIR /zipline
COPY --from=builder /build/node_modules ./node_modules 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/node_modules ./node_modules
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/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/.next ./.next
COPY --from=builder /build/tsconfig.json ./tsconfig.json COPY --from=builder /build/tsconfig.json ./tsconfig.json
COPY --from=builder /build/package.json ./package.json COPY --from=builder /build/package.json ./package.json
USER zipline
CMD ["node", "server"] CMD ["node", "server"]

View File

@@ -1,5 +0,0 @@
prisma
node_modules
.next
uploads
.git

View File

@@ -1,7 +1,7 @@
<div align="center"> <div align="center">
<img src="https://raw.githubusercontent.com/diced/zipline/trunk/public/zipline_small.png"/> <img src="https://raw.githubusercontent.com/diced/zipline/trunk/public/zipline_small.png"/>
Zipline is a file sharing, URL sharing, lightweight and easy to use! Zipline is a ShareX/file upload server that is easy to use, packed with features and can be setup in one command!
![Build](https://img.shields.io/github/workflow/status/diced/zipline/CD:%20Push%20Docker%20Images?logo=github&style=flat-square) ![Build](https://img.shields.io/github/workflow/status/diced/zipline/CD:%20Push%20Docker%20Images?logo=github&style=flat-square)
![Stars](https://img.shields.io/github/stars/diced/zipline?logo=github&style=flat-square) ![Stars](https://img.shields.io/github/stars/diced/zipline?logo=github&style=flat-square)
@@ -16,13 +16,19 @@
- 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.me) (One command install `docker-compose up`) - Image uploading
- 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://zipline.diced.tech/) (One command install `docker-compose up -d`)
## Installing ## Installing
[See how to install here](https://zipline.diced.me/get-started) [See how to install here](https://zipline.diced.tech/docs/get-started)
## Configuration ## Configuration
[See how to configure here](https://zipline.diced.me/configuration/overview) [See how to configure here](https://zipline.diced.tech/docs/config/overview)
## Theming ## Theming
[See how to theme here](https://zipline.diced.me/themes) [See how to theme here](https://zipline.diced.tech/docs/themes/reference)

View File

@@ -4,7 +4,8 @@
| Version | Supported | | Version | Supported |
| ------- | ------------------ | | ------- | ------------------ |
| 3.x.x | :white_check_mark: | | 3.2.x | :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

@@ -5,6 +5,10 @@ host = '0.0.0.0'
port = 3000 port = 3000
database_url = 'postgres://postgres:postgres@postgres/postgres' database_url = 'postgres://postgres:postgres@postgres/postgres'
[urls]
route = '/go'
length = 6
[uploader] [uploader]
route = '/u' route = '/u'
embed_route = '/a' embed_route = '/a'

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

@@ -0,0 +1,46 @@
version: '3'
services:
postgres:
image: postgres
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
- DATABASE_URL=postgresql://postgres:postgres@postgres/postgres/
- UPLOADER_ROUTE=/u
- UPLOADER_EMBED_ROUTE=/a
- UPLOADER_LENGTH=6
- UPLOADER_DIRECTORY=./uploads
- UPLOADER_ADMIN_LIMIT=104900000
- UPLOADER_USER_LIMIT=104900000
- UPLOADER_DISABLED_EXTS=
- URLS_ROUTE=/go
- URLS_LENGTH=6
volumes:
- '$PWD/uploads:/zipline/uploads'
- '$PWD/public:/zipline/public'
depends_on:
- 'postgres'
volumes:
pg_data:

View File

@@ -32,6 +32,8 @@ services:
- UPLOADER_ADMIN_LIMIT=104900000 - UPLOADER_ADMIN_LIMIT=104900000
- UPLOADER_USER_LIMIT=104900000 - UPLOADER_USER_LIMIT=104900000
- UPLOADER_DISABLED_EXTS= - UPLOADER_DISABLED_EXTS=
- URLS_ROUTE=/go
- URLS_LENGTH=6
volumes: volumes:
- '$PWD/uploads:/zipline/uploads' - '$PWD/uploads:/zipline/uploads'
- '$PWD/public:/zipline/public' - '$PWD/public:/zipline/public'

1
next-env.d.ts vendored
View File

@@ -1,5 +1,4 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/types/global" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
// NOTE: This file should not be edited // NOTE: This file should not be edited

View File

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

View File

@@ -1,60 +1,63 @@
{ {
"name": "zip3", "name": "zip3",
"version": "3.2.2", "version": "3.4.0",
"license": "MIT",
"scripts": { "scripts": {
"prepare": "husky install",
"dev": "NODE_ENV=development node server", "dev": "NODE_ENV=development node server",
"build": "npm-run-all build:schema build:next", "build": "npm-run-all build:schema build:next",
"build:next": "next build", "build:next": "next build",
"build:schema": "prisma generate --schema=prisma/schema.prisma", "build:schema": "prisma generate --schema=prisma/schema.prisma",
"migrate:dev": "prisma migrate dev --create-only",
"start": "node server", "start": "node server",
"lint": "next lint", "lint": "next lint",
"ts-node": "ts-node --compiler-options \"{\\\"module\\\":\\\"commonjs\\\"}\" --transpile-only", "seed": "ts-node --compiler-options \"{\\\"module\\\":\\\"commonjs\\\"}\" --transpile-only prisma/seed.ts",
"semantic-release": "semantic-release" "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",
"@material-ui/core": "^5.0.0-alpha.37", "@mantine/core": "^3.6.9",
"@material-ui/icons": "^5.0.0-alpha.37", "@mantine/dropzone": "^3.6.9",
"@material-ui/styles": "^5.0.0-alpha.35", "@mantine/hooks": "^3.6.9",
"@prisma/client": "^3.0.2", "@mantine/modals": "^3.6.9",
"@mantine/next": "^3.6.9",
"@mantine/notifications": "^3.6.9",
"@mantine/prism": "^3.6.11",
"@modulz/radix-icons": "^4.0.0",
"@prisma/client": "^3.9.2",
"@prisma/migrate": "^3.9.2",
"@prisma/sdk": "^3.9.2",
"@reduxjs/toolkit": "^1.6.0", "@reduxjs/toolkit": "^1.6.0",
"argon2": "^0.28.2", "argon2": "^0.28.2",
"colorette": "^1.2.2", "colorette": "^1.2.2",
"cookie": "^0.4.1", "cookie": "^0.4.1",
"copy-to-clipboard": "^3.3.1",
"fecha": "^4.2.1", "fecha": "^4.2.1",
"formik": "^2.2.9",
"multer": "^1.4.2", "multer": "^1.4.2",
"next": "11.1.1", "next": "^12.1.0",
"prisma": "^3.0.2", "prisma": "^3.9.2",
"react": "17.0.2", "react": "^17.0.2",
"react-dom": "17.0.2", "react-dom": "^17.0.2",
"react-dropzone": "^11.3.2",
"react-redux": "^7.2.4", "react-redux": "^7.2.4",
"react-table": "^7.7.0",
"redux": "^4.1.0", "redux": "^4.1.0",
"redux-thunk": "^2.3.0", "redux-thunk": "^2.3.0",
"uuid": "^8.3.2",
"yup": "^0.32.9" "yup": "^0.32.9"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^12.1.4",
"@commitlint/config-conventional": "^12.1.4",
"@types/cookie": "^0.4.0", "@types/cookie": "^0.4.0",
"@types/multer": "^1.4.6", "@types/multer": "^1.4.6",
"@types/node": "^15.12.2", "@types/node": "^15.12.2",
"babel-plugin-transform-imports": "^2.0.0", "babel-plugin-import": "^1.13.3",
"eslint": "7.28.0", "eslint": "^7.32.0",
"eslint-config-next": "11.0.0", "eslint-config-next": "11.0.0",
"husky": "^6.0.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"release": "^6.3.0",
"ts-node": "^10.0.0", "ts-node": "^10.0.0",
"typescript": "^4.3.2" "typescript": "^4.3.2"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/diced/workflow-testing.git" "url": "https://github.com/diced/zipline.git"
} }
} }

View File

@@ -0,0 +1,39 @@
/*
Warnings:
- You are about to drop the `InvisibleUrl` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `Url` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "Image" DROP CONSTRAINT "Image_userId_fkey";
-- DropForeignKey
ALTER TABLE "InvisibleImage" DROP CONSTRAINT "InvisibleImage_imageId_fkey";
-- DropForeignKey
ALTER TABLE "InvisibleUrl" DROP CONSTRAINT "InvisibleUrl_id_fkey";
-- DropForeignKey
ALTER TABLE "Theme" DROP CONSTRAINT "Theme_userId_fkey";
-- DropForeignKey
ALTER TABLE "Url" DROP CONSTRAINT "Url_userId_fkey";
-- DropTable
DROP TABLE "InvisibleUrl";
-- DropTable
DROP TABLE "Url";
-- AddForeignKey
ALTER TABLE "Theme" ADD CONSTRAINT "Theme_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Image" ADD CONSTRAINT "Image_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "InvisibleImage" ADD CONSTRAINT "InvisibleImage_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "Image"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- RenameIndex
ALTER INDEX "InvisibleImage.invis_unique" RENAME TO "InvisibleImage_invis_key";

View File

@@ -0,0 +1,34 @@
-- CreateTable
CREATE TABLE "Url" (
"id" TEXT NOT NULL,
"destination" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"views" INTEGER NOT NULL DEFAULT 0,
"userId" INTEGER NOT NULL,
CONSTRAINT "Url_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "InvisibleUrl" (
"id" SERIAL NOT NULL,
"invis" TEXT NOT NULL,
"urlId" TEXT NOT NULL,
CONSTRAINT "InvisibleUrl_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Url_id_key" ON "Url"("id");
-- CreateIndex
CREATE UNIQUE INDEX "InvisibleUrl_invis_key" ON "InvisibleUrl"("invis");
-- CreateIndex
CREATE UNIQUE INDEX "InvisibleUrl_urlId_unique" ON "InvisibleUrl"("urlId");
-- AddForeignKey
ALTER TABLE "Url" ADD CONSTRAINT "Url_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "InvisibleUrl" ADD CONSTRAINT "InvisibleUrl_urlId_fkey" FOREIGN KEY ("urlId") REFERENCES "Url"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Url" ADD COLUMN "vanity" TEXT;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "embedSiteName" TEXT DEFAULT E'{image.file} • {user.name}';

View File

@@ -0,0 +1,11 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "ratelimited" BOOLEAN NOT NULL DEFAULT false;
-- RenameIndex
ALTER INDEX "InvisibleImage_imageId_unique" RENAME TO "InvisibleImage_imageId_key";
-- RenameIndex
ALTER INDEX "InvisibleUrl_urlId_unique" RENAME TO "InvisibleUrl_urlId_key";
-- RenameIndex
ALTER INDEX "Theme_userId_unique" RENAME TO "Theme_userId_key";

View File

@@ -0,0 +1,8 @@
-- CreateTable
CREATE TABLE "Stats" (
"id" SERIAL NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"data" JSONB NOT NULL,
CONSTRAINT "Stats_pkey" PRIMARY KEY ("id")
);

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

@@ -13,27 +13,20 @@ model User {
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}")
ratelimited Boolean @default(false)
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 {
@@ -45,6 +38,7 @@ model Image {
favorite Boolean @default(false) favorite Boolean @default(false)
embed Boolean @default(false) embed Boolean @default(false)
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
} }
@@ -52,14 +46,14 @@ 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
image Image @relation(fields: [imageId], references: [id]) image Image @relation(fields: [imageId], references: [id])
} }
model Url { model Url {
id Int @id @default(autoincrement()) id String @id @unique
to String destination String
vanity String?
created_at DateTime @default(now()) created_at DateTime @default(now())
views Int @default(0) views Int @default(0)
invisible InvisibleUrl? invisible InvisibleUrl?
@@ -68,8 +62,14 @@ model Url {
} }
model InvisibleUrl { model InvisibleUrl {
id Int id Int @id @default(autoincrement())
url Url @relation(fields: [id], references: [id])
invis String @unique invis String @unique
urlId String
url Url @relation(fields: [urlId], references: [id])
}
model Stats {
id Int @id @default(autoincrement())
created_at DateTime @default(now())
data Json
} }

View File

@@ -9,8 +9,8 @@ async function main() {
username: 'administrator', username: 'administrator',
password: await hashPassword('password'), password: await hashPassword('password'),
token: createToken(), token: createToken(),
administrator: true administrator: true,
} },
}); });
console.log(` console.log(`

View File

@@ -1,8 +0,0 @@
module.exports = {
branches: ['trunk'],
plugins: [
'@semantic-release/commit-analyzer',
'@semantic-release/github',
'@semantic-release/changelog'
]
};

View File

@@ -1,13 +0,0 @@
const Logger = require('../src/lib/logger');
const prismaRun = require('./prisma-run');
module.exports = async (config) => {
try {
await prismaRun(config.core.database_url, ['migrate', 'deploy']);
await prismaRun(config.core.database_url, ['generate'], true);
} catch (e) {
console.log(e);
Logger.get('db').error('there was an error.. exiting..');
process.exit(1);
}
};

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

@@ -20,7 +20,7 @@ const { PrismaClient } = require('@prisma/client');
return { return {
file: x, file: x,
mimetype: mime, mimetype: mime,
userId: 1 userId: 1,
}; };
}); });
@@ -28,7 +28,7 @@ const { PrismaClient } = require('@prisma/client');
Logger.get('migrator').info('starting migrations...'); Logger.get('migrator').info('starting migrations...');
await prisma.image.createMany({ await prisma.image.createMany({
data data,
}); });
Logger.get('migrator').info('finished migrations! It is recomended to move your old uploads folder (' + process.argv[2] + ') to the current one which is ' + config.uploader.directory); Logger.get('migrator').info('finished migrations! It is recomended to move your old uploads folder (' + process.argv[2] + ') to the current one which is ' + config.uploader.directory);
process.exit(); process.exit();

View File

@@ -74,5 +74,5 @@ module.exports = {
'.zip': 'application/zip', '.zip': 'application/zip',
'.3gp': 'video/3gpp', '.3gp': 'video/3gpp',
'.3g2': 'video/3gpp2', '.3g2': 'video/3gpp2',
'.7z': 'application/x-7z-compressed' '.7z': 'application/x-7z-compressed',
}; };

View File

@@ -1,26 +0,0 @@
const { spawn } = require('child_process');
const { join } = require('path');
module.exports = (url, args, nostdout = false) => {
return new Promise((res, rej) => {
const proc = spawn(join(process.cwd(), 'node_modules', '.bin', 'prisma'), args, {
env: {
DATABASE_URL: url,
...process.env
},
});
let a = '';
proc.stdout.on('data', d => {
if (!nostdout) console.log(d.toString());
a += d.toString();
});
proc.stderr.on('data', d => {
if (!nostdout) console.log(d.toString());
rej(d.toString());
});
proc.stdout.on('end', () => res(a));
proc.stdout.on('close', () => res(a));
});
};

View File

@@ -1,62 +1,48 @@
const next = require('next'); const next = require('next').default;
const { createServer } = require('http'); const { createServer } = require('http');
const { stat, mkdir } = require('fs/promises'); const { mkdir } = require('fs/promises');
const { execSync } = require('child_process');
const { extname } = require('path'); const { extname } = require('path');
const { red, green, bold } = require('colorette');
const { PrismaClient } = require('@prisma/client');
const validateConfig = require('./validateConfig'); const validateConfig = require('./validateConfig');
const Logger = require('../src/lib/logger'); const Logger = require('../src/lib/logger');
const getFile = require('./static');
const prismaRun = require('../scripts/prisma-run');
const readConfig = require('../src/lib/readConfig'); const readConfig = require('../src/lib/readConfig');
const mimes = require('../scripts/mimes'); const mimes = require('../scripts/mimes');
const deployDb = require('../scripts/deploy-db'); const { log, getStats, getFile, migrations } = require('./util');
const { PrismaClient } = require('@prisma/client');
const { version } = require('../package.json'); const { version } = require('../package.json');
const exts = require('../scripts/exts');
const serverLog = Logger.get('server');
Logger.get('server').info(`starting zipline@${version} server`); serverLog.info(`starting zipline@${version} server`);
const dev = process.env.NODE_ENV === 'development'; const dev = process.env.NODE_ENV === 'development';
function log(url, status) {
if (url.startsWith('/_next') || url.startsWith('/__nextjs')) return;
return Logger.get('url').info(`${status === 200 ? bold(green(status)) : bold(red(status))}: ${url}`);
}
function shouldUseYarn() {
try {
execSync('yarnpkg --version', { stdio: 'ignore' });
return true;
} catch (e) {
return false;
}
}
(async () => { (async () => {
try { try {
const config = readConfig(); await run();
await validateConfig(config); } catch (e) {
serverLog.error(e);
const data = await prismaRun(config.core.database_url, ['migrate', 'status'], true); process.exit(1);
if (data.includes('Following migration have not yet been applied:')) {
Logger.get('database').info('some migrations are not applied, applying them now...');
await deployDb(config);
Logger.get('database').info('finished applying migrations');
} else {
Logger.get('database').info('migrations up to date');
} }
})();
async function run() {
const a = readConfig();
const config = validateConfig(a);
process.env.DATABASE_URL = config.core.database_url; process.env.DATABASE_URL = config.core.database_url;
await migrations();
await mkdir(config.uploader.directory, { recursive: true }); await mkdir(config.uploader.directory, { recursive: true });
const app = next({ const app = next({
dir: '.', dir: '.',
dev, dev,
quiet: dev quiet: !dev,
}, config.core.port, config.core.host); hostname: config.core.host,
port: config.core.port,
});
await app.prepare(); await app.prepare();
await stat('./.next');
const handle = app.getRequestHandler(); const handle = app.getRequestHandler();
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@@ -70,15 +56,15 @@ function shouldUseYarn() {
where: { where: {
OR: [ OR: [
{ file: parts[2] }, { file: parts[2] },
{ invisible:{ invis: decodeURI(parts[2]) } } { invisible:{ invis: decodeURI(parts[2]) } },
] ],
}, },
select: { select: {
mimetype: true, mimetype: true,
id: true, id: true,
file: true, file: true,
invisible: true invisible: true,
} },
}); });
if (!image) { if (!image) {
@@ -89,52 +75,90 @@ function shouldUseYarn() {
res.setHeader('Content-Type', mimetype); res.setHeader('Content-Type', mimetype);
res.end(data); res.end(data);
} else { } else {
if (image) {
const data = await getFile(config.uploader.directory, image.file); const data = await getFile(config.uploader.directory, image.file);
if (!data) return app.render404(req, res); if (!data) return app.render404(req, res);
await prisma.image.update({ await prisma.image.update({
where: { id: image.id }, where: { id: image.id },
data: { views: { increment: 1 } } data: { views: { increment: 1 } },
}); });
res.setHeader('Content-Type', image.mimetype); res.setHeader('Content-Type', image.mimetype);
res.end(data); res.end(data);
} else { }
} 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]); const data = await getFile(config.uploader.directory, parts[2]);
if (!data) return app.render404(req, res); if (!data) return app.render404(req, res);
const mimetype = mimes[extname(parts[2])] ?? 'application/octet-stream'; const mimetype = mimes[extname(parts[2])] ?? 'application/octet-stream';
res.setHeader('Content-Type', mimetype); res.setHeader('Content-Type', mimetype);
res.end(data); res.end(data);
} } else if (image.embed) {
handle(req, res);
} else {
const ext = image.file.split('.').pop();
if (Object.keys(exts).includes(ext)) return handle(req, res);
const data = await getFile(config.uploader.directory, image.file);
if (!data) return app.render404(req, res);
await prisma.image.update({
where: { id: image.id },
data: { views: { increment: 1 } },
});
res.setHeader('Content-Type', image.mimetype);
res.end(data);
} }
} else { } else {
handle(req, res); handle(req, res);
} }
log(req.url, res.statusCode); if (config.core.logger) log(req.url, res.statusCode);
}); });
srv.on('error', (e) => { srv.on('error', (e) => {
Logger.get('server').error(e); serverLog.error(e);
process.exit(1); process.exit(1);
}); });
srv.on('listening', () => { srv.on('listening', () => {
Logger.get('server').info(`listening on ${config.core.host}:${config.core.port}`); serverLog.info(`listening on ${config.core.host}:${config.core.port}`);
if (process.platform === 'linux' && dev) execSync(`xdg-open ${config.core.secure ? 'https' : 'http'}://${config.core.host === '0.0.0.0' ? 'localhost' : config.core.host}:${config.core.port}`);
}); });
srv.listen(config.core.port, config.core.host ?? '0.0.0.0'); srv.listen(config.core.port, config.core.host ?? '0.0.0.0');
} catch (e) {
if (e.message && e.message.startsWith('Could not find a production')) { const stats = await getStats(prisma, config);
Logger.get('web').error(`there is no production build - run \`${shouldUseYarn() ? 'yarn build' : 'npm build'}\``); await prisma.stats.create({
} else if (e.code && e.code === 'ENOENT') { data: {
if (e.path === './.next') Logger.get('web').error(`there is no production build - run \`${shouldUseYarn() ? 'yarn build' : 'npm build'}\``); data: stats,
} else { },
Logger.get('server').error(e); });
process.exit(1); 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,11 +0,0 @@
const { readFile } = require('fs/promises');
const { join } = require('path');
module.exports = async (dir, file) => {
try {
const data = await readFile(join(process.cwd(), dir, file));
return data;
} catch (e) {
return null;
}
};

130
server/util.js Normal file
View File

@@ -0,0 +1,130 @@
const { readFile, readdir, stat } = require('fs/promises');
const { join } = require('path');
const { Migrate } = require('@prisma/migrate/dist/Migrate.js');
const Logger = require('../src/lib/logger.js');
async function migrations() {
const migrate = new Migrate('./prisma/schema.prisma');
const diagnose = await migrate.diagnoseMigrationHistory({
optInToShadowDatabase: false,
});
if (diagnose.history?.diagnostic === 'databaseIsBehind') {
Logger.get('database').info('migrating database');
await migrate.applyMigrations();
Logger.get('database').info('finished migrating database');
}
migrate.stop();
}
function log(url) {
if (url.startsWith('/_next') || url.startsWith('/__nextjs')) return;
return Logger.get('url').info(url);
}
function shouldUseYarn() {
try {
execSync('yarnpkg --version', { stdio: 'ignore' });
return true;
} catch (e) {
return false;
}
}
async function getFile(dir, file) {
try {
const data = await readFile(join(process.cwd(), dir, file));
return data;
} catch (e) {
return null;
}
}
async function sizeOfDir(directory) {
const files = await readdir(directory);
let size = 0;
for (let i = 0, L = files.length; i !== L; ++i) {
const sta = await stat(join(directory, files[i]));
size += sta.size;
}
return size;
}
function bytesToRead(bytes) {
const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB'];
let num = 0;
while (bytes > 1024) {
bytes /= 1024;
++num;
}
return `${bytes.toFixed(1)} ${units[num]}`;
}
async function getStats(prisma, config) {
const size = await sizeOfDir(join(process.cwd(), config.uploader.directory));
const byUser = await prisma.image.groupBy({
by: ['userId'],
_count: {
_all: true,
},
});
const count_users = await prisma.user.count();
const count_by_user = [];
for (let i = 0, L = byUser.length; i !== L; ++i) {
const user = await prisma.user.findFirst({
where: {
id: byUser[i].userId,
},
});
count_by_user.push({
username: user.username,
count: byUser[i]._count._all,
});
}
const count = await prisma.image.count();
const viewsCount = await prisma.image.groupBy({
by: ['views'],
_sum: {
views: true,
},
});
const typesCount = await prisma.image.groupBy({
by: ['mimetype'],
_count: {
mimetype: true,
},
});
const types_count = [];
for (let i = 0, L = typesCount.length; i !== L; ++i) types_count.push({ mimetype: typesCount[i].mimetype, count: typesCount[i]._count.mimetype });
return {
size: bytesToRead(size),
size_num: size,
count,
count_by_user: count_by_user.sort((a,b) => b.count-a.count),
count_users,
views_count: (viewsCount[0]?._sum?.views ?? 0),
types_count: types_count.sort((a,b) => b.count-a.count),
};
}
module.exports = {
migrations,
bytesToRead,
getFile,
getStats,
log,
sizeOfDir,
shouldUseYarn,
};

View File

@@ -1,45 +1,40 @@
const Logger = require('../src/lib/logger'); const { object, bool, string, number, boolean, array } = require('yup');
function dot(str, obj) { const validator = object({
return str.split('.').reduce((a,b) => a[b], obj); core: object({
} secure: bool().default(false),
secret: string().min(8).required(),
host: string().default('0.0.0.0'),
port: number().default(3000),
database_url: string().required(),
logger: boolean().default(false),
stats_interval: number().default(1800),
}).required(),
uploader: object({
route: string().default('/u'),
embed_route: string().default('/a'),
length: number().default(6),
directory: string().default('./uploads'),
admin_limit: number().default(104900000),
user_limit: number().default(104900000),
disabled_extensions: array().default([]),
}).required(),
urls: object({
route: string().default('/go'),
length: number().default(6),
}).required(),
ratelimit: object({
user: number().default(0),
admin: number().default(0),
}),
});
const path = (path, type) => ({ path, type });
module.exports = async config => { module.exports = function validate(config) {
const paths = [ try {
path('core.secure', 'boolean'), return validator.validateSync(config, { abortEarly: false });
path('core.secret', 'string'), } catch (e) {
path('core.host', 'string'), if (process.env.ZIPLINE_DOCKER_BUILD) return {};
path('core.port', 'number'), throw `${e.errors.length} errors occured\n${e.errors.map(x => '\t' + x).join('\n')}`;
path('core.database_url', 'string'),
path('uploader.route', 'string'),
path('uploader.length', 'number'),
path('uploader.directory', 'string'),
path('uploader.admin_limit', 'number'),
path('uploader.user_limit', 'number'),
path('uploader.disabled_extentions', 'object'),
];
let errors = 0;
for (let i = 0, L = paths.length; i !== L; ++i) {
const path = paths[i];
const value = dot(path.path, config);
if (value === undefined) {
Logger.get('config').error(`there was no ${path.path} in config which was required`);
++errors;
}
const type = typeof value;
if (value !== undefined && type !== path.type) {
Logger.get('config').error(`expected ${path.type} on ${path.path}, but got ${type}`);
++errors;
}
}
if (errors !== 0) {
Logger.get('config').error(`exiting due to ${errors} errors`);
process.exit(1);
} }
}; };

View File

@@ -1,12 +0,0 @@
import React from 'react';
import { Snackbar, Alert as MuiAlert } from '@material-ui/core';
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 '@material-ui/core';
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 '@material-ui/core';
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}
</CardContent> </MCard>
</MuiCard>
); );
} }

View File

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

View File

@@ -1,405 +1,329 @@
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,
Select,
} from '@material-ui/core';
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,
} from '@material-ui/icons';
import copy from 'copy-to-clipboard';
import Backdrop from './Backdrop';
import { friendlyThemeName, themes } from './Theming';
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 = [
{ {
icon: <HomeIcon />, icon: <HomeIcon />,
text: 'Home', text: 'Home',
link: '/dashboard' link: '/dashboard',
}, },
{ {
icon: <FolderIcon />, icon: <FileIcon />,
text: 'Files', text: 'Files',
link: '/dashboard/files' link: '/dashboard/files',
},
{
icon: <MixerHorizontalIcon />,
text: 'Stats',
link: '/dashboard/stats',
},
{
icon: <Link1Icon />,
text: 'URLs',
link: '/dashboard/urls',
}, },
{ {
icon: <UploadIcon />, icon: <UploadIcon />,
text: 'Upload', text: 'Upload',
link: '/dashboard/upload' link: '/dashboard/upload',
} },
]; ];
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 images 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: React.ChangeEvent<{ value: string }>) => {
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} /> children: (
<ResetTokenDialog open={resetOpen} setOpen={setResetOpen} setToken={setToken} /> <Text size='sm'>
<Toolbar Once you reset your token, you will have to update any uploaders to use this new token.
sx={{ </Text>
width: { xs: drawerWidth } ),
}} labels: { confirm: 'Reset', cancel: 'Cancel' },
> onConfirm: async () => {
<AppBar const a = await useFetch('/api/user/token', 'PATCH');
position='fixed' if (!a.success) {
elevation={0} setToken(a.success);
sx={{ notif.showNotification({
borderBottom: 1, title: 'Token Reset Failed',
borderBottomColor: t => t.palette.divider, message: a.error,
display: { xs: 'none', sm: 'block' } color: 'red',
}} icon: <Cross1Icon />,
> });
<Toolbar> } else {
<IconButton notif.showNotification({
color='inherit' title: 'Token Reset',
aria-label='open drawer' message: 'Your token has been reset. You will need to update any uploaders to use this new token.',
edge='start' color: 'green',
onClick={() => setMobileOpen(true)} icon: <CheckIcon />,
sx={{ mr: 2, display: { sm: 'none' } }} });
> }
<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> modals.closeAll();
); },
});
const container = typeof window !== 'undefined' ? window.document.body : undefined; const openCopyToken = () => modals.openConfirmModal({
title: 'Copy Token',
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,
<AppBar '&:hover': {
position='fixed' backgroundColor: theme.other.hover,
elevation={0} },
sx={{
width: { sm: `calc(100% - ${drawerWidth}px)` },
ml: { sm: `${drawerWidth}px` }
}} }}
> >
<Toolbar> <Group>
<IconButton <ThemeIcon color='primary' variant='filled'>
color='inherit' {icon}
aria-label='open drawer' </ThemeIcon>
edge='start'
onClick={() => setMobileOpen(true)} <Text size='lg'>{text}</Text>
sx={{ mr: 2, display: { sm: 'none' } }} </Group>
> </UnstyledButton>
<MenuIcon />
</IconButton>
<Typography
variant='h5'
noWrap
component='div'
sx={{ display: { sm: 'none' } }}
>
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='/dash/manage' passHref>
<MenuItem onClick={handleClose(null)}>
<AccountIcon sx={{ mr: 2 }} /> Manage Account
</MenuItem>
</Link> </Link>
<MenuItem onClick={handleClose('copy')}> ))}
<CopyIcon sx={{ mr: 2 }} /> Copy Token {user.administrator && (
</MenuItem> <Link href='/dashboard/users' passHref>
<MenuItem onClick={handleClose('reset')}> <UnstyledButton
<ResetIcon sx={{ mr: 2 }} /> Reset Token sx={{
</MenuItem> display: 'block',
<Link href='/auth/logout' passHref> width: '100%',
<MenuItem onClick={handleClose(null)}> padding: theme.spacing.xs,
<LogoutIcon sx={{ mr: 2 }} /> Logout borderRadius: theme.radius.sm,
</MenuItem> color: theme.colorScheme === 'dark' ? theme.colors.dark[0] : theme.black,
'&:hover': {
backgroundColor: theme.other.hover,
},
}}
>
<Group>
<ThemeIcon color='primary' variant='filled'>
<PersonIcon />
</ThemeIcon>
<Text size='lg'>Users</Text>
</Group>
</UnstyledButton>
</Link> </Link>
</Menu>
</Box>
)} )}
</Toolbar> </Navbar.Section>
</AppBar> </Navbar>
<Box }
component='nav' 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={{ sx={{
width: { sm: drawerWidth }, display: 'block',
flexShrink: { sm: 0 } width: '100%',
padding: theme.spacing.xs,
borderRadius: theme.radius.sm,
color: theme.other.color,
'&:hover': {
backgroundColor: theme.other.hover,
},
}} }}
> >
<Drawer <Group>
container={container} <ThemeIcon color='primary' variant='filled'>
variant='temporary' <GearIcon />
onClose={() => setMobileOpen(false)} </ThemeIcon>
open={mobileOpen} <Text>{user.username}</Text>
elevation={0} </Group>
ModalProps={{ </UnstyledButton>
keepMounted: true }
}}
sx={{
display: { xs: 'block', sm: 'none' },
'* .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth }
}}
> >
{drawer} <Group direction='column' spacing={2}>
</Drawer> <Text sx={{
<Drawer color: theme.colorScheme === 'dark' ? theme.colors.dark[2] : theme.colors.gray[6],
variant='permanent' fontWeight: 500,
sx={{ fontSize: theme.fontSizes.xs,
display: { xs: 'none', sm: 'block' }, padding: `${theme.spacing.xs / 2}px ${theme.spacing.sm}px`,
'* .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth } cursor: 'default',
}} }}>User: {user.username}</Text>
open <MenuItemLink icon={<GearIcon />} href='/dashboard/manage'>Manage Account</MenuItemLink>
<MenuItem icon={<CopyIcon />} onClick={() => {setOpen(false);openCopyToken();}}>Copy Token</MenuItem>
<MenuItem icon={<ResetIcon />} onClick={() => {setOpen(false);openResetToken();}} color='red'>Reset Token</MenuItem>
<MenuItemLink icon={<PinRightIcon />} href='/auth/logout' color='red'>Logout</MenuItemLink>
<Divider
variant='solid'
my={theme.spacing.xs / 2}
sx={theme => ({
width: '110%',
borderTopColor: theme.colorScheme === 'dark' ? theme.colors.dark[7] : theme.colors.gray[2],
margin: `${theme.spacing.xs / 2}px -4px`,
})}
/>
<MenuItem icon={<Pencil1Icon />}>
<Select
size='xs'
data={Object.keys(themes).map(t => ({ value: t, label: friendlyThemeName[t] }))}
value={systemTheme}
onChange={handleUpdateTheme}
/>
</MenuItem>
</Group>
</Popover>
</Box>
</div>
</Header>
}
> >
{drawer} <Paper withBorder padding='md' shadow='xs'>{children}</Paper>
</Drawer> </AppShell>
</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 '@material-ui/core/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

@@ -1,71 +1,96 @@
import React from 'react'; import React, { useEffect } from 'react';
import { ThemeProvider } from '@emotion/react';
import { CssBaseline } from '@material-ui/core';
// 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 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,
dracula,
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',
'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,27 +1,15 @@
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 '@material-ui/core';
import AudioIcon from '@material-ui/icons/Audiotrack';
import Link from 'components/Link';
import Card from 'components/Card'; import Card from 'components/Card';
import Image from 'components/Image';
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 { Box, Text, Table, Skeleton, Title, SimpleGrid } from '@mantine/core';
import { randomId, useClipboard } from '@mantine/hooks';
import Link from 'components/Link';
import { CopyIcon, Cross1Icon, TrashIcon } from '@modulz/radix-icons';
import { useNotifications } from '@mantine/notifications';
type Aligns = 'inherit' | 'right' | 'left' | 'center' | 'justify'; type Aligns = 'inherit' | 'right' | 'left' | 'center' | 'justify';
@@ -39,50 +27,34 @@ 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 }) { function StatText({ children }) {
return <Typography variant='h5' color='GrayText'>{children}</Typography>; return <Text color='gray' size='xl'>{children}</Text>;
} }
function StatTable({ rows, columns }) { function StatTable({ rows, columns }) {
return ( return (
<TableContainer sx={{ pt: 1 }}> <Box sx={{ pt: 1 }}>
<Table sx={{ minWidth: 100 }} size='small'> <Table highlightOnHover>
<TableHead> <thead>
<TableRow> <tr>
{columns.map(col => ( {columns.map(col => (
<TableCell key={col.name} sx={{ borderColor: t => t.palette.divider }}>{col.name}</TableCell> <th key={randomId()}>{col.name}</th>
))} ))}
</TableRow> </tr>
</TableHead> </thead>
<TableBody> <tbody>
{rows.map(row => ( {rows.map(row => (
<TableRow <tr key={randomId()}>
hover
key={row.username}
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
>
{columns.map(col => ( {columns.map(col => (
<TableCell key={col.id} sx={{ borderColor: t => t.palette.divider }}> <td key={randomId()}>
{col.format ? col.format(row[col.id]) : row[col.id]} {col.format ? col.format(row[col.id]) : row[col.id]}
</TableCell> </td>
))} ))}
</TableRow> </tr>
))} ))}
</TableBody> </tbody>
</Table> </Table>
</TableContainer> </Box>
); );
} }
@@ -91,31 +63,51 @@ export default function Dashboard() {
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 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(() => {
@@ -124,123 +116,90 @@ export default function Dashboard() {
return ( return (
<> <>
<Typography variant='h4'>Welcome back {user?.username}</Typography> <Title>Welcome back {user?.username}</Title>
<Typography color='GrayText' pb={2}>You have <b>{images.length ? images.length : '...'}</b> images</Typography> <Text color='gray' sx={{ paddingBottom: 4 }}>You have <b>{images.length ? images.length : '...'}</b> files</Text>
<Typography variant='h4'>Recent Images</Typography> <Title>Recent Files</Title>
<Grid container spacing={4} py={2}> <SimpleGrid
{recent.length ? recent.map(image => ( cols={4}
<Grid item xs={12} sm={3} key={image.id}> spacing='lg'
<MuiCard sx={{ minWidth: '100%' }}> breakpoints={[
<CardActionArea> { maxWidth: 'sm', cols: 1, spacing: 'sm' },
<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 => (
<Grid item xs={12} sm={3} key={x}>
<Skeleton variant='rectangular' width='100%' height={220} sx={{ borderRadius: 1 }}/>
</Grid>
))}
</Grid>
<Typography variant='h4'>Stats</Typography>
<Grid container spacing={4} py={2}>
<Grid item xs={12} sm={4}>
<Card name='Size' sx={{ height: '100%' }}>
<StatText>{stats ? stats.size : <Skeleton variant='text' />}</StatText>
<Typography variant='h3'>Average Size</Typography>
<StatText>{stats ? bytesToRead(stats.size_num / stats.count) : <Skeleton variant='text' />}</StatText>
</Card>
</Grid>
<Grid item xs={12} sm={4}>
<Card name='Images' sx={{ height: '100%' }}>
<StatText>{stats ? stats.count : <Skeleton variant='text' />}</StatText>
<Typography variant='h3'>Views</Typography>
<StatText>{stats ? `${stats.views_count} (${isNaN(stats.views_count / stats.count) ? '0' : stats.views_count / stats.count})` : <Skeleton variant='text' />}</StatText>
</Card>
</Grid>
<Grid item xs={12} sm={4}>
<Card name='Users' sx={{ height: '100%' }}>
<StatText>{stats ? stats.count_users : <Skeleton variant='text' />}</StatText>
</Card>
</Grid>
</Grid>
<Card name='Images' sx={{ my: 2 }} elevation={0} variant='outlined'>
<Link href='/dashboard/images' pb={2}>View Gallery</Link>
<TableContainer sx={{ maxHeight: 440 }}>
<Table size='small'>
<TableHead>
<TableRow>
{columns.map(column => (
<TableCell
key={column.id}
align={column.align}
sx={{ minWidth: column.minWidth, borderColor: t => t.palette.divider }}
> >
{column.label} {recent.length ? recent.map(image => (
</TableCell> // eslint-disable-next-line jsx-a11y/alt-text
<Image key={randomId()} image={image} updateImages={updateImages} />
)) : [1,2,3,4].map(x => (
<div key={x}>
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }}/>
</div>
))} ))}
<TableCell sx={{ minWidth: 200, borderColor: t => t.palette.divider }} align='right'> </SimpleGrid>
Actions
</TableCell> <Title mt='md'>Stats</Title>
</TableRow> <Text>View more stats here <Link href='/dashboard/stats'>here</Link>.</Text>
</TableHead> <SimpleGrid
<TableBody> cols={3}
{images spacing='lg'
.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) breakpoints={[
.map(row => { { maxWidth: 'sm', cols: 1, spacing: 'sm' },
return ( ]}
<TableRow hover role='checkbox' tabIndex={-1} key={row.id}> >
{columns.map(column => { <Card name='Size' sx={{ height: '100%' }}>
const value = row[column.id]; <StatText>{stats ? stats.size : <Skeleton height={8} />}</StatText>
return ( <Title order={2}>Average Size</Title>
<TableCell key={column.id} align={column.align} sx={{ borderColor: t => t.palette.divider }}> <StatText>{stats ? bytesToRead(stats.size_num / stats.count) : <Skeleton height={8} />}</StatText>
{column.format ? column.format(value) : value}
</TableCell>
);
})}
<TableCell align='right' sx={{ borderColor: t => t.palette.divider }}>
<ButtonGroup variant='outlined'>
<Button onClick={() => handleDelete(row)} color='error' size='small'>Delete</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>
<Card name='Images per User' sx={{ height: '100%', my: 2 }} elevation={0} variant='outlined'> <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>
<ImagesTable
columns={[
{ accessor: 'file', Header: 'Name', minWidth: 170, align: 'inherit' as Aligns },
{ accessor: 'mimetype', Header: 'Type', minWidth: 100, align: 'inherit' as Aligns },
{ accessor: 'created_at', Header: 'Date' },
]}
data={images}
deleteImage={deleteImage}
copyImage={copyImage}
viewImage={viewImage}
/>
{/* <Title mt='md'>Files</Title>
<Text>View previews of your files in the <Link href='/dashboard/files'>browser</Link>.</Text>
<ReactTable
columns={[
{ accessor: 'file', Header: 'Name', minWidth: 170, align: 'inherit' as Aligns },
{ accessor: 'mimetype', Header: 'Type', minWidth: 100, align: 'inherit' as Aligns },
{ accessor: 'created_at', Header: 'Date' },
]}
data={images}
pagination
/>
<Card name='Files per User' mt={22}>
<StatTable <StatTable
columns={[ columns={[
{ id: 'username', name: 'Name' }, { id: 'username', name: 'Name' },
{ id: 'count', name: 'Images' } { id: 'count', name: 'Files' },
]} ]}
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 '@material-ui/core';
import { ExpandMore } from '@material-ui/icons';
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 => ({
marginTop: 2,
border: '1px solid',
marginBottom: 12,
borderColor: t.colorScheme === 'dark' ? t.colors.dark[6] : t.colors.gray[0] ,
})}
>
<Accordion.Item label={<Title>Favorite Files</Title>}>
<SimpleGrid
cols={3}
spacing='lg'
breakpoints={[
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
]}
> >
<Typography variant='h4'>No Files</Typography>
</Box>
) : <Typography variant='h4'>Files</Typography>}
{favoritePages.length ? (
<Accordion sx={{ my: 2, border: 1, borderColor: t => t.palette.divider }} elevation={0}>
<AccordionSummary expandIcon={<ExpandMore />}>
<Typography variant='h4'>Favorite Files</Typography>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={2}>
{favoritePages.length ? favoritePages[(favoritePage - 1) ?? 0].map(image => ( {favoritePages.length ? favoritePages[(favoritePage - 1) ?? 0].map(image => (
<Grid item xs={12} sm={3} key={image.id}> <div key={image.id}>
<ZiplineImage image={image} updateImages={() => updatePages(true)} /> <ZiplineImage image={image} updateImages={() => updatePages(true)} />
</Grid> </div>
)) : null} )) : null}
</Grid> </SimpleGrid>
{favoritePages.length ? (
<Box <Box
display='flex' sx={{
justifyContent='center' display: 'flex',
alignItems='center' justifyContent: 'center',
pt={2} alignItems: 'center',
paddingTop: 12,
paddingBottom: 3,
}}
> >
<Pagination count={favoritePages.length} page={favoritePage} onChange={(_, v) => setFavoritePage(v)}/> <Pagination total={favoritePages.length} page={favoritePage} onChange={setFavoritePage}/>
</Box> </Box>
) : null} </Accordion.Item>
</AccordionDetails>
</Accordion> </Accordion>
) : null} <SimpleGrid
<Grid container spacing={2}> 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,88 +1,32 @@
import React, { useState } from 'react'; import React from 'react';
import { TextField, Button, Box, Typography, Select, MenuItem } from '@material-ui/core';
import Download from '@material-ui/icons/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 Link from 'components/Link';
import Alert from 'components/Alert';
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 { useForm } from '@mantine/hooks';
import { Tooltip, TextInput, Button, Text, Title, Group, ColorInput } from '@mantine/core';
import { DownloadIcon } from '@modulz/radix-icons';
const validationSchema = yup.object({ function VarsTooltip({ children }) {
username: yup
.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 TextInput({ id, label, formik, ...other }) {
return ( return (
<TextField <Tooltip position='top' placement='center' color='' label={
id={id} <>
name={id} <Text><b>{'{image.file}'}</b> - file name</Text>
label={label} <Text><b>{'{image.mimetype}'}</b> - mimetype</Text>
value={formik.values[id]} <Text><b>{'{image.id}'}</b> - id of the image</Text>
onChange={formik.handleChange} <Text><b>{'{user.name}'}</b> - your username</Text>
error={formik.touched[id] && Boolean(formik.errors[id])} visit <Link href='https://zipline.diced.cf/docs/variables'>the docs</Link> for more variables
helperText={formik.touched[id] && formik.errors[id]} </>
variant='standard' }>
fullWidth {children}
sx={{ pb: 0.5 }} </Tooltip>
{...other}
/>
); );
} }
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 [loading, setLoading] = useState(false);
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 = {
@@ -94,11 +38,11 @@ export default function Manage() {
Headers: { Headers: {
Authorization: user?.token, Authorization: user?.token,
...(withEmbed && {Embed: 'true'}), ...(withEmbed && {Embed: 'true'}),
...(withZws && {ZWS: 'true'}) ...(withZws && {ZWS: 'true'}),
}, },
URL: '$json:url$', URL: '$json:files[0]$',
Body: 'MultipartFormData', Body: 'MultipartFormData',
FileFormName: 'file' FileFormName: 'file',
}; };
const pseudoElement = document.createElement('a'); const pseudoElement = document.createElement('a');
@@ -110,143 +54,66 @@ 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 ?? '',
}, },
validationSchema, });
onSubmit: async values => {
const onSubmit = async values => {
const cleanUsername = values.username.trim(); const cleanUsername = values.username.trim();
const cleanPassword = values.password.trim(); const cleanPassword = values.password.trim();
const cleanEmbedTitle = values.embedTitle.trim(); const cleanEmbedTitle = values.embedTitle.trim();
const cleanEmbedColor = values.embedColor.trim(); const cleanEmbedColor = values.embedColor.trim();
const cleanEmbedSiteName = values.embedSiteName.trim();
if (cleanUsername === '') return formik.setFieldError('username', 'Username can\'t be nothing'); if (cleanUsername === '') return form.setFieldError('username', 'Username can\'t be nothing');
setLoading(true);
const data = { const data = {
username: cleanUsername, username: cleanUsername,
password: cleanPassword === '' ? null : cleanPassword, password: cleanPassword === '' ? null : cleanPassword,
embedTitle: cleanEmbedTitle === '' ? null : cleanEmbedTitle, embedTitle: cleanEmbedTitle === '' ? null : cleanEmbedTitle,
embedColor: cleanEmbedColor === '' ? null : cleanEmbedColor embedColor: cleanEmbedColor === '' ? null : cleanEmbedColor,
embedSiteName: cleanEmbedSiteName === '' ? null : cleanEmbedSiteName,
}; };
const newUser = await useFetch('/api/user', 'PATCH', data); const newUser = await useFetch('/api/user', 'PATCH', data);
if (newUser.error) { if (newUser.error) {
setLoading(false);
setMessage('An error occured');
setSeverity('error');
setOpen(true);
} else { } else {
dispatch(updateUser(newUser)); dispatch(updateUser(newUser));
setLoading(false);
setMessage('Saved user');
setSeverity('success');
setOpen(true);
} }
} };
});
const customThemeFormik = useFormik({
initialValues: {
type: user.customTheme?.type || 'dark',
primary: user.customTheme?.primary || '',
secondary: user.customTheme?.secondary || '',
error: user.customTheme?.error || '',
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) {
setLoading(false);
setMessage('An error occured');
setSeverity('error');
setOpen(true);
} else {
dispatch(updateUser(newUser));
router.replace(router.pathname);
setLoading(false);
setMessage('Saved theme');
setSeverity('success');
setOpen(true);
}
}
});
return ( return (
<> <>
<Backdrop open={loading}/> <Title>Manage User</Title>
<Alert open={open} setOpen={setOpen} message={message} severity={severity} /> <VarsTooltip>
<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>
<Typography variant='h4' pb={2}>Manage User</Typography> </VarsTooltip>
<form onSubmit={formik.handleSubmit}> <form onSubmit={form.onSubmit((v) => onSubmit(v))}>
<TextInput id='username' label='Username' formik={formik} /> <TextInput id='username' label='Username' {...form.getInputProps('username')} />
<TextInput id='password' label='Password' formik={formik} type='password' /> <TextInput id='password' label='Password'type='password' {...form.getInputProps('password')} />
<TextInput id='embedTitle' label='Embed Title' formik={formik} /> <TextInput id='embedTitle' label='Embed Title' {...form.getInputProps('embedTitle')} />
<TextInput id='embedColor' label='Embed Color' formik={formik} /> <ColorInput id='embedColor' label='Embed Color' {...form.getInputProps('embedColor')} />
<Box <TextInput id='embedSiteName' label='Embed Site Name' {...form.getInputProps('embedSiteName')} />
display='flex' <Group position='right' sx={{ paddingTop: 12 }}>
justifyContent='right'
alignItems='right'
pt={2}
>
<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 id='primary' label='Primary Color' formik={customThemeFormik} />
<TextInput id='secondary' label='Secondary Color' formik={customThemeFormik} />
<TextInput id='error' label='Error Color' formik={customThemeFormik} />
<TextInput id='warning' label='Warning Color' formik={customThemeFormik} />
<TextInput id='info' label='Info Color' formik={customThemeFormik} />
<TextInput id='border' label='Border Color' formik={customThemeFormik} />
<TextInput id='mainBackground' label='Main Background' formik={customThemeFormik} />
<TextInput 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,118 @@
import React, { useEffect, useState } from 'react';
import Card from 'components/Card';
import Image from 'components/Image';
import ImagesTable from 'components/ImagesTable';
import useFetch from 'lib/hooks/useFetch';
import { useStoreSelector } from 'lib/redux/store';
import { Box, Text, Table, Skeleton, Title, SimpleGrid } from '@mantine/core';
import { randomId, useClipboard } from '@mantine/hooks';
import Link from 'components/Link';
import { CopyIcon, Cross1Icon, TrashIcon } from '@modulz/radix-icons';
import { useNotifications } from '@mantine/notifications';
type Aligns = 'inherit' | 'right' | 'left' | 'center' | 'justify';
export function bytesToRead(bytes: number) {
if (isNaN(bytes)) return '0.0 B';
if (bytes === Infinity) return '0.0 B';
const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB'];
let num = 0;
while (bytes > 1024) {
bytes /= 1024;
++num;
}
return `${bytes.toFixed(1)} ${units[num]}`;
}
function StatText({ children }) {
return <Text color='gray' size='xl'>{children}</Text>;
}
function StatTable({ rows, columns }) {
return (
<Box sx={{ pt: 1 }}>
<Table highlightOnHover>
<thead>
<tr>
{columns.map(col => (
<th key={randomId()}>{col.name}</th>
))}
</tr>
</thead>
<tbody>
{rows.map(row => (
<tr key={randomId()}>
{columns.map(col => (
<td key={randomId()}>
{col.format ? col.format(row[col.id]) : row[col.id]}
</td>
))}
</tr>
))}
</tbody>
</Table>
</Box>
);
}
export default function Dashboard() {
const user = useStoreSelector(state => state.user);
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,96 +1,120 @@
import React, { useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Typography, Button, CardActionArea, Paper, Box } from '@material-ui/core';
import { Upload as UploadIcon } from '@material-ui/icons';
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';
function ImageUploadIcon({ status, ...props }) {
if (status.accepted) {
return <UploadIcon {...props} />;
}
if (status.rejected) {
return <CrossCircledIcon {...props} />;
}
return <ImageIcon {...props} />;
}
function getIconColor(status, theme) {
return status.accepted
? theme.colors[theme.primaryColor][6]
: status.rejected
? theme.colors.red[6]
: theme.colorScheme === 'dark'
? theme.colors.dark[0]
: theme.black;
}
export default function Upload({ route }) { export default function Upload({ route }) {
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); useEffect(() => {
const [severity, setSeverity] = useState('success'); window.addEventListener('paste', (e: ClipboardEvent) => {
const [message, setMessage] = useState('Saved'); const item = Array.from(e.clipboardData.items).find(x => /^image/.test(x.type));
const blob = item.getAsFile();
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,
});
const res = await fetch('/api/upload', { const res = await fetch('/api/upload', {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': user.token 'Authorization': user.token,
}, },
body body,
}); });
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
<Alert open={open} setOpen={setOpen} message={message} severity={severity} /> onDrop={(f) => setFiles([...files, ...f])}
<Typography variant='h4' pb={2}>Upload file</Typography>
<Dropzone onDrop={acceptedFiles => setFiles([...files, ...acceptedFiles])}>
{({getRootProps, getInputProps}) => (
<CardActionArea>
<Paper
elevation={0}
variant='outlined'
sx={{
justifyContent: 'center',
alignItems: 'center',
display: 'block',
p: 5
}}
{...getRootProps()}
> >
<input {...getInputProps()} /> {(status) => (
<CenteredBox><UploadIcon sx={{ fontSize: 100 }} /></CenteredBox> <>
<CenteredBox><Typography variant='h5'>Drag an image or click to upload an image.</Typography></CenteredBox> <Group position='center' spacing='xl' style={{ minHeight: 220, pointerEvents: 'none' }}>
{files.map(file => ( <ImageUploadIcon
<CenteredBox key={file.name}><Typography variant='h6'>{file.name}</Typography></CenteredBox> status={status}
))} style={{ width: 80, height: 80, color: getIconColor(status, theme) }}
</Paper> />
</CardActionArea>
<div>
<Text size='xl' inline>
Drag images here or click to select files
</Text>
</div>
</Group>
<Group position='center' spacing='xl' style={{ pointerEvents: 'none' }}>
{files.map(file => (<Text key={file.name} weight='bold'>{file.name}</Text>))}
</Group>
</>
)} )}
</Dropzone> </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

@@ -0,0 +1,161 @@
import React, { useEffect, useState } from 'react';
import useFetch from 'hooks/useFetch';
import { useStoreSelector } from 'lib/redux/store';
import { useClipboard, useForm } from '@mantine/hooks';
import { CopyIcon, Cross1Icon, Link1Icon, PlusIcon, TrashIcon } from '@modulz/radix-icons';
import { useNotifications } from '@mantine/notifications';
import { Modal, Title, Group, Button, Box, Card, TextInput, ActionIcon, SimpleGrid, Skeleton } from '@mantine/core';
export default function Urls() {
const user = useStoreSelector(state => state.user);
const notif = useNotifications();
const clipboard = useClipboard();
const [urls, setURLS] = useState([]);
const [createOpen, setCreateOpen] = useState(false);
const updateURLs = async () => {
const urls = await useFetch('/api/user/urls');
setURLS(urls);
};
const deleteURL = async u => {
const url = await useFetch('/api/user/urls', 'DELETE', { id: u.id });
if (url.error) {
notif.showNotification({
title: 'Failed to delete URL',
message: url.error,
icon: <TrashIcon />,
color: 'red',
});
} else {
notif.showNotification({
title: 'Deleted URL',
message: '',
icon: <Cross1Icon />,
color: 'green',
});
}
updateURLs();
};
const copyURL = u => {
clipboard.copy(`${window.location.protocol}//${window.location.host}${u.url}`);
notif.showNotification({
title: 'Copied to clipboard',
message: '',
icon: <CopyIcon />,
});
};
const form = useForm({
initialValues: {
url: '',
vanity: '',
},
});
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(() => {
updateURLs();
}, []);
return (
<>
<Modal
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')} />
<Group position='right' mt={22}>
<Button onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button type='submit'>Submit</Button>
</Group>
</form>
</Modal>
<Group>
<Title sx={{ marginBottom: 12 }}>URLs</Title>
<ActionIcon variant='filled' color='primary' onClick={() => setCreateOpen(true)}><PlusIcon/></ActionIcon>
</Group>
<SimpleGrid
cols={4}
spacing='lg'
breakpoints={[
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
]}
>
{urls.length ? urls.map(url => (
<Card key={url.id} sx={{ maxWidth: '100%' }} shadow='sm'>
<Group position='apart'>
<Group position='left'>
<Title>{url.vanity ?? url.id}</Title>
</Group>
<Group position='right'>
<ActionIcon href={url.url} component='a' target='_blank'><Link1Icon/></ActionIcon>
<ActionIcon aria-label='copy' onClick={() => copyURL(url)}>
<CopyIcon />
</ActionIcon>
<ActionIcon aria-label='delete' onClick={() => deleteURL(url)}>
<TrashIcon />
</ActionIcon>
</Group>
</Group>
</Card>
)) : [1,2,3,4,5,6,7].map(x => (
<div key={x}>
<Skeleton width='100%' height={60} sx={{ borderRadius: 1 }}/>
</div>
))}
</SimpleGrid>
</>
);
}

View File

@@ -1,168 +1,115 @@
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 '@material-ui/core';
import { Delete as DeleteIcon, Add as AddIcon } from '@material-ui/icons';
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, Grid, ActionIcon, SimpleGrid, Switch, Skeleton } from '@mantine/core';
import { Cross1Icon, PlusIcon, TrashIcon } from '@modulz/radix-icons';
import { useNotifications } from '@mantine/notifications';
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 notif = useNotifications();
const onSubmit = async (values) => {
const cleanUsername = values.username.trim(); const cleanUsername = values.username.trim();
const cleanPassword = values.password.trim(); const cleanPassword = values.password.trim();
if (cleanUsername === '') return formik.setFieldError('username', 'Username can\'t be nothing'); if (cleanUsername === '') return form.setFieldError('username', 'Username can\'t be nothing');
if (cleanPassword === '') return formik.setFieldError('password', 'Password can\'t be nothing'); if (cleanPassword === '') return form.setFieldError('password', 'Password can\'t be nothing');
const data = { const data = {
username: cleanUsername, username: cleanUsername,
password: cleanPassword, password: cleanPassword,
administrator: values.administrator administrator: values.administrator,
}; };
setOpen(false); setOpen(false);
setLoading(true);
const res = await useFetch('/api/auth/create', 'POST', data); const res = await useFetch('/api/auth/create', 'POST', data);
if (res.error) { if (res.error) {
setSeverity('error'); notif.showNotification({
setMessage('Could\'nt create user: ' + res.error); title: 'Failed to create user',
setAlertOpen(true); message: res.error,
} else { icon: <TrashIcon />,
setSeverity('success'); color: 'red',
setMessage('Created user ' + res.username);
setAlertOpen(true);
updateUsers();
}
setLoading(false);
}
}); });
} 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)}
PaperProps={{ title={<Title>Create User</Title>}
elevation: 1
}}
> >
<DialogTitle> <form onSubmit={form.onSubmit((v) => onSubmit(v))}>
Create User <TextInput id='username' label='Username' {...form.getInputProps('username')} />
</DialogTitle> <TextInput id='password' label='Password' type='password' {...form.getInputProps('password')} />
<form onSubmit={formik.handleSubmit}> <Switch mt={12} id='administrator' label='Administrator' {...form.getInputProps('administrator')} />
<DialogContent>
<TextInput id='username' label='Username' formik={formik} /> <Group position='right' mt={22}>
<TextInput id='password' label='Password' formik={formik} type='password' /> <Button onClick={() => setOpen(false)}>Cancel</Button>
<FormControlLabel <Button type='submit'>Create</Button>
id='administrator' </Group>
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> </form>
</Dialog> </Modal>
</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 [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 [message, setMessage] = useState('Saved');
const [loading, setLoading] = useState(true);
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 handleDelete = async (user) => {
const res = await useFetch('/api/users', 'DELETE', { const res = await useFetch('/api/users', 'DELETE', {
id: user.id id: user.id,
}); });
if (res.error) { if (res.error) {
setMessage(`Could not delete ${user.username}`); notif.showNotification({
setSeverity('error'); title: 'Failed to delete user',
setOpen(true); message: res.error,
color: 'red',
icon: <Cross1Icon />,
});
} else { } else {
setMessage(`Deleted user ${res.username}`); notif.showNotification({
setSeverity('success'); title: 'User deleted',
setOpen(true); message: '',
updateUsers(); color: 'green',
icon: <TrashIcon />,
});
} }
updateUsers();
}; };
useEffect(() => { useEffect(() => {
@@ -171,17 +118,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, i) => (
<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={() => handleDelete(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>
</> </>
); );
} }

16
src/lib/clientUtils.ts Normal file
View File

@@ -0,0 +1,16 @@
import { Image, User } from '@prisma/client';
export function parse(str: string, image: Image, user: User) {
if (!str) return null;
return str
.replace(/{user.admin}/gi, user.administrator ? 'yes' : 'no')
.replace(/{user.id}/gi, user.id.toString())
.replace(/{user.name}/gi, user.username)
.replace(/{image.id}/gi, image.id.toString())
.replace(/{image.mime}/gi, image.mimetype)
.replace(/{image.file}/gi, image.file)
.replace(/{image.created_at.full_string}/gi, image.created_at.toLocaleString())
.replace(/{image.created_at.time_string}/gi, image.created_at.toLocaleTimeString())
.replace(/{image.created_at.date_string}/gi, image.created_at.toLocaleDateString());
}

View File

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

View File

@@ -5,7 +5,7 @@ export default async function useFetch(url: string, method: 'GET' | 'POST' | 'PA
const res = await global.fetch(url, { const res = await global.fetch(url, {
body: body ? JSON.stringify(body) : null, body: body ? JSON.stringify(body) : null,
method, method,
headers headers,
}); });
return res.json(); return res.json();

View File

@@ -14,21 +14,17 @@ export default function login() {
async function load() { async function load() {
setLoading(true); setLoading(true);
const res = await useFetch('/api/user');
if (res.error) return router.push('/auth/login'); const res = await useFetch('/api/user');
if (res.error) return router.push('/auth/login?url=' + router.route);
dispatch(updateUser(res)); dispatch(updateUser(res));
setUser(res); setUser(res);
setLoading(false); setLoading(false);
} }
useEffect(() => { useEffect(() => {
if (!loading && user) { if (!loading && user) return;
return;
}
load(); load();
}, []); }, []);

View File

@@ -1,7 +1,7 @@
const { format } = require('fecha'); const { format } = require('fecha');
const { yellow, blueBright, magenta, red, cyan } = require('colorette'); const { blueBright, red, cyan } = require('colorette');
class Logger { module.exports = class Logger {
static get(clas) { static get(clas) {
if (typeof clas !== 'function') if (typeof clas !== 'string') throw new Error('not string/function'); if (typeof clas !== 'function') if (typeof clas !== 'string') throw new Error('not string/function');
@@ -19,7 +19,7 @@ class Logger {
} }
error(error) { error(error) {
console.log(this.formatMessage('ERROR', this.name, error.toString())); console.log(this.formatMessage('ERROR', this.name, error.stack ?? error));
} }
formatMessage(level, name, message) { formatMessage(level, name, message) {
@@ -31,14 +31,8 @@ class Logger {
switch (level) { switch (level) {
case 'INFO': case 'INFO':
return cyan('INFO '); return cyan('INFO ');
case 'DEBUG':
return yellow('DEBUG');
case 'WARN':
return magenta('WARN ');
case 'ERROR': case 'ERROR':
return red('ERROR'); return red('ERROR');
} }
} }
} };
module.exports = Logger;

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';
@@ -23,7 +22,6 @@ 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;
@@ -38,14 +36,19 @@ export type NextApiRes = NextApiResponse & {
forbid: (message: string) => void; forbid: (message: string) => void;
bad: (message: string) => void; bad: (message: string) => void;
json: (json: any) => void; json: (json: any) => void;
ratelimited: () => void;
setCookie: (name: string, value: unknown, options: CookieSerializeOptions) => void; setCookie: (name: string, value: unknown, options: CookieSerializeOptions) => void;
} }
export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse) => unknown) => (req: NextApiReq, res: NextApiRes) => { export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse) => unknown) => (req: NextApiReq, res: NextApiRes) => {
res.error = (message: string) => {
res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Content-Allow-Methods', 'GET,HEAD,POST,OPTIONS');
res.setHeader('Access-Control-Max-Age', '86400');
res.error = (message: string) => {
res.json({ res.json({
error: message error: message,
}); });
}; };
@@ -53,21 +56,26 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
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,
}); });
}; };
res.bad = (message: string) => { res.bad = (message: string) => {
res.setHeader('Content-Type', 'application/json');
res.status(401); res.status(401);
res.json({ res.json({
error: '403: ' + message error: '403: ' + message,
});
};
res.ratelimited = () => {
res.status(429);
res.json({
error: '429: ratelimited',
}); });
}; };
res.json = (json: any) => { res.json = (json: any) => {
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(json)); res.end(JSON.stringify(json));
}; };
@@ -82,7 +90,7 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
res.setHeader('Set-Cookie', serialize(name, '', { res.setHeader('Set-Cookie', serialize(name, '', {
path: '/', path: '/',
expires: new Date(1), expires: new Date(1),
maxAge: undefined maxAge: undefined,
})); }));
}; };
req.user = async () => { req.user = async () => {
@@ -92,7 +100,7 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
const user = await prisma.user.findFirst({ const user = await prisma.user.findFirst({
where: { where: {
id: Number(userId) id: Number(userId),
}, },
select: { select: {
administrator: true, administrator: true,
@@ -101,10 +109,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,
} },
}); });
if (!user) return null; if (!user) return null;
@@ -130,7 +137,7 @@ export const setCookie = (
) => { ) => {
if ('maxAge' in options) { if ('maxAge' in options) {
options.expires = new Date(Date.now() + options.maxAge); options.expires = new Date(Date.now() + options.maxAge * 1000);
options.maxAge /= 1000; options.maxAge /= 1000;
} }

View File

@@ -1,5 +1,7 @@
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
if (!global.prisma) global.prisma = new PrismaClient(); if (!global.prisma) {
if (!process.env.ZIPLINE_DOCKER_BUILD) global.prisma = new PrismaClient();
};
export default global.prisma; export default global.prisma;

View File

@@ -1,8 +1,9 @@
const { existsSync, readFileSync } = require('fs'); const { existsSync, readFileSync } = require('fs');
const { join } = require('path'); const { join } = require('path');
const Logger = require('./logger'); const parse = require('@iarna/toml/parse-string.js');
const Logger = require('./logger.js');
const e = (val, type, fn, required = true) => ({ val, type, fn, required }); const e = (val, type, fn) => ({ val, type, fn });
const envValues = [ const envValues = [
e('SECURE', 'boolean', (c, v) => c.core.secure = v), e('SECURE', 'boolean', (c, v) => c.core.secure = v),
@@ -10,22 +11,33 @@ const envValues = [
e('HOST', 'string', (c, v) => c.core.host = v), e('HOST', 'string', (c, v) => c.core.host = v),
e('PORT', 'number', (c, v) => c.core.port = v), e('PORT', 'number', (c, v) => c.core.port = v),
e('DATABASE_URL', 'string', (c, v) => c.core.database_url = v), e('DATABASE_URL', 'string', (c, v) => c.core.database_url = v),
e('LOGGER', 'boolean', (c, v) => c.core.logger = v ?? true),
e('STATS_INTERVAL', 'number', (c, v) => c.core.stats_interval = 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_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 = [], false), e('UPLOADER_DISABLED_EXTS', 'array', (c, v) => v ? c.uploader.disabled_extentions = v : c.uploader.disabled_extentions = []),
e('URLS_ROUTE', 'string', (c, v) => c.urls.route = v),
e('URLS_LENGTH', 'number', (c, v) => c.urls.length = v),
e('RATELIMIT_USER', 'number', (c, v) => c.ratelimit.user = v ?? 0),
e('RATELIMIT_ADMIN', 'number', (c, v) => c.ratelimit.user = v ?? 0),
]; ];
module.exports = () => { module.exports = function readConfig() {
if (!existsSync(join(process.cwd(), 'config.toml'))) { if (!existsSync(join(process.cwd(), 'config.toml'))) {
Logger.get('config').info('reading environment'); if (!process.env.ZIPLINE_DOCKER_BUILD) Logger.get('config').info('reading environment');
return tryReadEnv(); return tryReadEnv();
} else { } else {
if (process.env.ZIPLINE_DOCKER_BUILD) return;
Logger.get('config').info('reading config file'); Logger.get('config').info('reading config file');
const str = readFileSync(join(process.cwd(), 'config.toml'), 'utf8'); const str = readFileSync(join(process.cwd(), 'config.toml'), 'utf8');
const parsed = require('@iarna/toml/parse-string')(str); const parsed = parse(str);
return parsed; return parsed;
} }
@@ -39,6 +51,8 @@ function tryReadEnv() {
host: undefined, host: undefined,
port: undefined, port: undefined,
database_url: undefined, database_url: undefined,
logger: undefined,
stats_interval: undefined,
}, },
uploader: { uploader: {
route: undefined, route: undefined,
@@ -46,29 +60,32 @@ function tryReadEnv() {
directory: undefined, directory: undefined,
admin_limit: undefined, admin_limit: undefined,
user_limit: undefined, user_limit: undefined,
disabled_extentions: undefined disabled_extentions: undefined,
} },
urls: {
route: undefined,
length: undefined,
},
ratelimit: {
user: undefined,
admin: undefined,
},
}; };
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 = process.env[envValue.val];
if (envValue.required && !value) { if (!value) {
Logger.get('config').error(`there is no config file or required environment variables (${envValue.val})... exiting...`); envValues[i].fn(config, undefined);
} else {
process.exit(1);
}
envValues[i].fn(config, value); envValues[i].fn(config, value);
if (envValue.required) {
if (envValue.type === 'number') value = parseToNumber(value); if (envValue.type === 'number') value = parseToNumber(value);
else if (envValue.type === 'boolean') value = parseToBoolean(value); else if (envValue.type === 'boolean') value = parseToBoolean(value);
else if (envValue.type === 'array') value = parseToArray(value); else if (envValue.type === 'array') value = parseToArray(value);
envValues[i].fn(config, value); envValues[i].fn(config, value);
} }
} }
return config; return config;
} }

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 {
@@ -6,8 +5,8 @@ export interface User {
token: string; token: string;
embedTitle: string; embedTitle: string;
embedColor: string; embedColor: string;
embedSiteName: string;
systemTheme: string; systemTheme: string;
customTheme?: Theme;
} }
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', colors: {
background: { dark: [
main: '#282A36', '#FFFFFF',
paper: '#44475A' '#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 '@material-ui/core/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

@@ -0,0 +1,36 @@
import createTheme from '.';
export default createTheme({
colorScheme: 'dark',
primaryColor: 'blue',
other: {
AppShell_backgroundColor: '#1b1d24',
hover: '#3c3f44',
},
colors: {
dark: [
'#FFFFFF',
'#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

@@ -0,0 +1,37 @@
import createTheme from '.';
export default createTheme({
colorScheme: 'dark',
primaryColor: 'blue',
other: {
AppShell_backgroundColor: '#32343d',
hover: '#34363d',
},
colors: {
dark: [
'#FFFFFF',
'#C9CACC',
'#F5F5F5',
'#78797E',
'#5D5E64',
'#42434A',
'#34363D',
'#262830',
'#2A2C34',
'#262830',
],
blue: [
'#FFFFFF',
'#E6F3FB',
'#CDE6F6',
'#B4D9F2',
'#9ACCED',
'#8EC6EB',
'#81BFE9',
'#67B2E4',
'#4EA5E0',
'#3498DB',
],
},
});

View File

@@ -13,6 +13,12 @@ export interface ConfigCore {
// The PostgreSQL database url // The PostgreSQL database url
database_url: string database_url: string
// Whether or not to log stuff
logger: boolean;
// The interval to store stats
stats_interval: number;
} }
export interface ConfigUploader { export interface ConfigUploader {
@@ -35,7 +41,26 @@ export interface ConfigUploader {
disabled_extentions: string[]; disabled_extentions: string[];
} }
export interface ConfigUrls {
// The route urls will be served on
route: string;
// Length of random chars to generate for urls
length: number;
}
// Ratelimiting for users/admins, setting them to 0 disables ratelimiting
export interface ConfigRatelimit {
// Ratelimit for users
user: number;
// Ratelimit for admins
admin: number;
}
export interface Config { export interface Config {
core: ConfigCore; core: ConfigCore;
uploader: ConfigUploader; uploader: ConfigUploader;
urls: ConfigUrls;
ratelimit: ConfigRatelimit;
} }

View File

@@ -3,7 +3,8 @@ import { hash, verify } from 'argon2';
import { readdir, stat } from 'fs/promises'; import { readdir, stat } from 'fs/promises';
import { join } from 'path'; import { join } from 'path';
import prisma from './prisma'; import prisma from './prisma';
import { InvisibleImage } from '@prisma/client'; import { InvisibleImage, InvisibleUrl } from '@prisma/client';
import config from './config';
export async function hashPassword(s: string): Promise<string> { export async function hashPassword(s: string): Promise<string> {
return await hash(s); return await hash(s);
@@ -89,21 +90,21 @@ export function bytesToRead(bytes: number) {
return `${bytes.toFixed(1)} ${units[num]}`; return `${bytes.toFixed(1)} ${units[num]}`;
} }
export function createInvisURL(length: number) { export function randomInvis(length: number) {
// some parts from https://github.com/tycrek/ass/blob/master/generators/lengthGen.js // some parts from https://github.com/tycrek/ass/blob/master/generators/lengthGen.js
const invisibleCharset = ['\u200B', '\u2060', '\u200C', '\u200D']; const invisibleCharset = ['\u200B', '\u2060', '\u200C', '\u200D'];
return [...randomBytes(length)].map((byte) => invisibleCharset[Number(byte) % invisibleCharset.length]).join('').slice(1).concat(invisibleCharset[0]); return [...randomBytes(length)].map((byte) => invisibleCharset[Number(byte) % invisibleCharset.length]).join('').slice(1).concat(invisibleCharset[0]);
} }
export function createInvis(length: number, imageId: number) { export function createInvisImage(length: number, imageId: number) {
const retry = async (): Promise<InvisibleImage> => { const retry = async (): Promise<InvisibleImage> => {
const invis = createInvisURL(length); const invis = randomInvis(length);
const existing = await prisma.invisibleImage.findUnique({ const existing = await prisma.invisibleImage.findUnique({
where: { where: {
invis invis,
} },
}); });
if (existing) return retry(); if (existing) return retry();
@@ -111,8 +112,8 @@ export function createInvis(length: number, imageId: number) {
const inv = await prisma.invisibleImage.create({ const inv = await prisma.invisibleImage.create({
data: { data: {
invis, invis,
imageId imageId,
} },
}); });
return inv; return inv;
@@ -120,3 +121,28 @@ export function createInvis(length: number, imageId: number) {
return retry(); return retry();
} }
export function createInvisURL(length: number, urlId: string) {
const retry = async (): Promise<InvisibleUrl> => {
const invis = randomInvis(length);
const existing = await prisma.invisibleUrl.findUnique({
where: {
invis,
},
});
if (existing) return retry();
const ur = await prisma.invisibleUrl.create({
data: {
invis,
urlId,
},
});
return ur;
};
return retry();
}

View File

@@ -1,16 +1,18 @@
import React from 'react'; import React from 'react';
import { Box, Typography } from '@material-ui/core'; 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,14 +1,21 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect } from 'react';
import Head from 'next/head'; import Head from 'next/head';
import { GetServerSideProps } from 'next'; import { GetServerSideProps } from 'next';
import { Box } from '@material-ui/core'; import { Box, useMantineTheme } 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/static'; import { getFile } from '../../server/util';
import { parse } from 'lib/clientUtils';
import * as exts from '../../scripts/exts';
import { Prism } from '@mantine/prism';
import ZiplineTheming from 'components/Theming';
export default function EmbeddedImage({ image, title, username, color, normal, embed }) { export default function EmbeddedImage({ image, user }) {
const dataURL = (route: string) => `${route}/${image.file}`; const dataURL = (route: string) => `${route}/${image.file}`;
// reapply date from workaround
image.created_at = new Date(image.created_at);
const updateImage = () => { const updateImage = () => {
const imageEl = document.getElementById('image_content') as HTMLImageElement; const imageEl = document.getElementById('image_content') as HTMLImageElement;
@@ -19,24 +26,16 @@ export default function EmbeddedImage({ image, title, username, color, normal, e
else imageEl.width = original.width; else imageEl.width = original.width;
}; };
if (typeof window !== 'undefined') window.onresize = () => updateImage();
useEffect(() => updateImage(), []); useEffect(() => updateImage(), []);
return ( return (
<> <>
<Head> <Head>
{embed && ( {image.embed && (
<> <>
{title ? ( {user.embedSiteName && (<meta property='og:site_name' content={parse(user.embedSiteName, image, user)} />)}
<> {user.embedTitle && (<meta property='og:title' content={parse(user.embedTitle, image, user)} />)}
<meta property='og:site_name' content={`${image.file}${username}`} /> <meta property='theme-color' content={user.embedColor}/>
<meta property='og:title' content={title} />
</>
) : (
<meta property='og:title' content={`${image.file}${username}`} />
)}
<meta property='theme-color' content={color}/>
<meta property='og:url' content={dataURL(normal)} />
</> </>
)} )}
<meta property='og:image' content={dataURL('/r')} /> <meta property='og:image' content={dataURL('/r')} />
@@ -44,10 +43,12 @@ export default function EmbeddedImage({ image, title, username, color, normal, e
<title>{image.file}</title> <title>{image.file}</title>
</Head> </Head>
<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>
@@ -58,16 +59,37 @@ export default function EmbeddedImage({ image, title, username, color, normal, e
export const getServerSideProps: GetServerSideProps = async (context) => { export const getServerSideProps: GetServerSideProps = async (context) => {
const id = context.params.id[1]; const id = context.params.id[1];
const route = context.params.id[0]; const route = context.params.id[0];
if (route !== config.uploader.route.substring(1)) return { const routes = [config.uploader.route.substring(1), config.urls.route.substring(1)];
notFound: true if (!routes.includes(route)) return { notFound: true };
if (route === routes[1]) {
const url = await prisma.url.findFirst({
where: {
OR: [
{ id },
{ vanity: id },
{ invisible: { invis: id } },
],
},
select: {
destination: true,
},
});
if (!url) return { notFound: true };
return {
props: {},
redirect: {
destination: url.destination,
},
}; };
} else {
const image = await prisma.image.findFirst({ const image = await prisma.image.findFirst({
where: { where: {
OR: [ OR: [
{ file: id }, { file: id },
{ invisible: { invis: id } } { invisible: { invis: id } },
] ],
}, },
select: { select: {
mimetype: true, mimetype: true,
@@ -75,47 +97,51 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
file: true, file: true,
invisible: true, invisible: true,
userId: true, userId: true,
embed: true embed: true,
} created_at: true,
},
}); });
if (!image) return { notFound: true }; if (!image) return { notFound: true };
if (!image.embed) {
const data = await getFile(config.uploader.directory, id);
if (!data) return { notFound: true };
context.res.end(data);
return { props: {} };
};
const user = await prisma.user.findFirst({ const user = await prisma.user.findFirst({
select: { select: {
embedTitle: true, embedTitle: true,
embedColor: true, embedColor: true,
username: true embedSiteName: true,
username: true,
id: true,
}, },
where: { where: {
id: image.userId id: image.userId,
} },
}); });
//@ts-ignore workaround because next wont allow date
image.created_at = image.created_at.toString();
const prismRender = Object.keys(exts).includes(image.file.split('.').pop());
// let prismRenderCode;/
// if (prismRender) prismRenderCode = (await getFile(config.uploader.directory, id)).toString();
if (prismRender) return {
redirect: {
destination: `/code/${image.file}`,
permanent: true,
},
};
if (!image.mimetype.startsWith('image')) { if (!image.mimetype.startsWith('image')) {
const data = await getFile(config.uploader.directory, id); const data = await getFile(config.uploader.directory, id);
if (!data) return { notFound: true }; if (!data) return { notFound: true };
context.res.end(data); context.res.end(data);
return { props: {} }; return { props: {} };
}; }
return { return {
props: { props: {
image, image,
title: user.embedTitle, user,
color: user.embedColor, },
username: user.username,
normal: config.uploader.route,
embed: image.embed
}
}; };
}
}; };

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

@@ -17,8 +17,8 @@ async function handler(req: NextApiReq, res: NextApiRes) {
const existing = await prisma.user.findFirst({ const existing = await prisma.user.findFirst({
where: { where: {
username username,
} },
}); });
if (existing) return res.forbid('user exists'); if (existing) return res.forbid('user exists');
@@ -29,8 +29,8 @@ async function handler(req: NextApiReq, res: NextApiRes) {
password: hashed, password: hashed,
username, username,
token: createToken(), token: createToken(),
administrator administrator,
} },
}); });
delete newUser.password; delete newUser.password;

View File

@@ -15,16 +15,16 @@ async function handler(req: NextApiReq, res: NextApiRes) {
username: 'administrator', username: 'administrator',
password: await hashPassword('password'), password: await hashPassword('password'),
token: createToken(), token: createToken(),
administrator: true administrator: true,
} },
}); });
Logger.get('database').info('created default user:\nUsername: "administrator"\nPassword: "password"'); Logger.get('database').info('created default user:\nUsername: "administrator"\nPassword: "password"');
} }
const user = await prisma.user.findFirst({ const user = await prisma.user.findFirst({
where: { where: {
username username,
} },
}); });
if (!user) return res.status(404).end(JSON.stringify({ error: 'User not found' })); if (!user) return res.status(404).end(JSON.stringify({ error: 'User not found' }));
@@ -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`);

39
src/pages/api/shorten.ts Normal file
View File

@@ -0,0 +1,39 @@
import prisma from 'lib/prisma';
import zconfig from 'lib/config';
import { NextApiReq, NextApiRes, withZipline } from 'lib/middleware/withZipline';
import { createInvisURL, randomChars } from 'lib/util';
import Logger from 'lib/logger';
async function handler(req: NextApiReq, res: NextApiRes) {
if (req.method !== 'POST') return res.forbid('no allow');
if (!req.headers.authorization) return res.forbid('no authorization');
const user = await prisma.user.findFirst({
where: {
token: req.headers.authorization,
},
});
if (!user) return res.forbid('authorization incorect');
if (!req.body) return res.error('no body');
if (!req.body.url) return res.error('no url');
const rand = randomChars(zconfig.urls.length);
let invis;
const url = await prisma.url.create({
data: {
id: rand,
vanity: req.body.vanity ?? null,
destination: req.body.url,
userId: user.id,
},
});
if (req.headers.zws) invis = await createInvisURL(zconfig.urls.length, url.id);
Logger.get('url').info(`User ${user.username} (${user.id}) shortenned a url ${url.destination} (${url.id})`);
return res.json({ url: `${zconfig.core.secure ? 'https' : 'http'}://${req.headers.host}${zconfig.urls.route}/${req.body.vanity ? req.body.vanity : invis ? invis.invis : url.id}` });
}
export default withZipline(handler);

View File

@@ -1,62 +1,18 @@
import { join } from 'path';
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 { bytesToRead, sizeOfDir } from 'lib/util';
import config from 'lib/config';
async function handler(req: NextApiReq, res: NextApiRes) { async function handler(req: NextApiReq, res: NextApiRes) {
const user = await req.user(); const user = await req.user();
if (!user) return res.forbid('not logged in'); if (!user) return res.forbid('not logged in');
const size = await sizeOfDir(join(process.cwd(), config.uploader.directory)); const stats = await prisma.stats.findFirst({
const byUser = await prisma.image.groupBy({ orderBy: {
by: ['userId'], created_at: 'desc',
_count: {
_all: true
}
});
const count_users = await prisma.user.count();
const count_by_user = [];
for (let i = 0, L = byUser.length; i !== L; ++i) {
const user = await prisma.user.findFirst({
where: {
id: byUser[i].userId
}
});
count_by_user.push({
username: user.username,
count: byUser[i]._count._all
});
}
const count = await prisma.image.count();
const viewsCount = await prisma.image.groupBy({
by: ['views'],
_sum: {
views: true
}
});
const typesCount = await prisma.image.groupBy({
by: ['mimetype'],
_count: {
mimetype: true
}, },
take: 1,
}); });
const types_count = [];
for (let i = 0, L = typesCount.length; i !== L; ++i) types_count.push({ mimetype: typesCount[i].mimetype, count: typesCount[i]._count.mimetype });
return res.json({ return res.json(stats.data);
size: bytesToRead(size),
size_num: size,
count,
count_by_user: count_by_user.sort((a,b) => b.count-a.count),
count_users,
views_count: (viewsCount[0]?._sum?.views ?? 0),
types_count: types_count.sort((a,b) => b.count-a.count)
});
} }
export default withZipline(handler); export default withZipline(handler);

View File

@@ -2,59 +2,104 @@ 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 { createInvis, randomChars } from 'lib/util'; import { createInvisImage, randomChars } from 'lib/util';
import { writeFile } from 'fs/promises'; import { writeFile } from 'fs/promises';
import { join } from 'path'; 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';
const uploader = multer({ const uploader = multer({
storage: multer.memoryStorage(), storage: multer.memoryStorage(),
}); });
async function handler(req: NextApiReq, res: NextApiRes) { async function handler(req: NextApiReq, res: NextApiRes) {
if (req.method !== 'POST') return res.forbid('no allow'); 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,
} },
}); });
if (!user) return res.forbid('authorization incorect'); if (!user) return res.forbid('authorization incorect');
if (user.ratelimited) return res.ratelimited();
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 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,
},
}); });
if (req.headers.zws) invis = await createInvis(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 writeFile(join(process.cwd(), zconfig.uploader.directory, image.file), file.buffer);
Logger.get('image').info(`User ${user.username} (${user.id}) uploaded an image ${image.file} (${image.id})`); 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}`); files.push(`${zconfig.core.secure ? 'https' : 'http'}://${req.headers.host}${zconfig.uploader.route}/${invis ? invis.invis : image.file}`);
} }
// url will be deprecated soon if (user.administrator && zconfig.ratelimit.admin !== 0) {
return res.json({ files, url: files[0] }); await prisma.user.update({
where: {
id: user.id,
},
data: {
ratelimited: true,
},
});
setTimeout(async () => await prisma.user.update({ where: { id: user.id }, data: { ratelimited: false } }), zconfig.ratelimit.admin * 1000).unref();
}
if (!user.administrator && zconfig.ratelimit.user !== 0) {
await prisma.user.update({
where: {
id: user.id,
},
data: {
ratelimited: true,
},
});
setTimeout(async () => await prisma.user.update({ where: { id: user.id }, data: { ratelimited: false } }), zconfig.ratelimit.user * 1000).unref();
}
return res.json({ files });
} }
function run(middleware: any) { function run(middleware: any) {

View File

@@ -15,8 +15,8 @@ async function handler(req: NextApiReq, res: NextApiRes) {
const image = await prisma.image.delete({ const image = await prisma.image.delete({
where: { where: {
id: req.body.id id: req.body.id,
} },
}); });
await rm(join(process.cwd(), config.uploader.directory, image.file)); await rm(join(process.cwd(), config.uploader.directory, image.file));
@@ -32,8 +32,8 @@ async function handler(req: NextApiReq, res: NextApiRes) {
if (req.body.favorite !== null) image = await prisma.image.update({ if (req.body.favorite !== null) image = await prisma.image.update({
where: { id: req.body.id }, where: { id: req.body.id },
data: { data: {
favorite: req.body.favorite favorite: req.body.favorite,
} },
}); });
return res.json(image); return res.json(image);
@@ -41,15 +41,15 @@ async function handler(req: NextApiReq, res: NextApiRes) {
let images = await prisma.image.findMany({ let images = await prisma.image.findMany({
where: { where: {
userId: user.id, userId: user.id,
favorite: !!req.query.favorite favorite: !!req.query.favorite,
}, },
select: { select: {
created_at: true, created_at: true,
file: true, file: true,
mimetype: true, mimetype: true,
id: true, id: true,
favorite: true favorite: true,
} },
}); });

View File

@@ -12,74 +12,61 @@ async function handler(req: NextApiReq, res: NextApiRes) {
const hashed = await hashPassword(req.body.password); const hashed = await hashPassword(req.body.password);
await prisma.user.update({ await prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: { password: hashed } data: { password: hashed },
}); });
} }
if (req.body.username) { if (req.body.username) {
const existing = await prisma.user.findFirst({ const existing = await prisma.user.findFirst({
where: { where: {
username: req.body.username username: req.body.username,
} },
}); });
if (existing && user.username !== req.body.username) { if (existing && user.username !== req.body.username) {
return res.forbid('Username is already taken'); return res.forbid('Username is already taken');
} }
await prisma.user.update({ await prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: { username: req.body.username } data: { username: req.body.username },
}); });
} }
if (req.body.embedTitle) await prisma.user.update({ if (req.body.embedTitle) await prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: { embedTitle: req.body.embedTitle } data: { embedTitle: req.body.embedTitle },
}); });
if (req.body.embedColor) await prisma.user.update({ if (req.body.embedColor) await prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: { embedColor: req.body.embedColor } data: { embedColor: req.body.embedColor },
});
if (req.body.embedSiteName) await prisma.user.update({
where: { id: user.id },
data: { embedSiteName: req.body.embedSiteName },
}); });
if (req.body.systemTheme) await prisma.user.update({ if (req.body.systemTheme) await prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: { systemTheme: req.body.systemTheme } data: { systemTheme: req.body.systemTheme },
}); });
if (req.body.customTheme) {
if (user.customTheme) await prisma.user.update({
where: { id: user.id },
data: {
customTheme: {
update: {
...req.body.customTheme
}
}
}
}); else await prisma.theme.create({
data: {
userId: user.id,
...req.body.customTheme
}
});
}
const newUser = await prisma.user.findFirst({ const newUser = await prisma.user.findFirst({
where: { where: {
id: Number(user.id) id: Number(user.id),
}, },
select: { select: {
administrator: true, administrator: true,
embedColor: true, embedColor: true,
embedTitle: true, embedTitle: true,
embedSiteName: true,
id: true, id: true,
images: false, images: false,
password: false, password: false,
systemTheme: true, systemTheme: true,
customTheme: true,
token: true, token: true,
username: true username: true,
} },
}); });
Logger.get('user').info(`User ${user.username} (${newUser.username}) (${newUser.id}) was updated`); Logger.get('user').info(`User ${user.username} (${newUser.username}) (${newUser.id}) was updated`);

View File

@@ -11,14 +11,18 @@ async function handler(req: NextApiReq, res: NextApiRes) {
let images = await prisma.image.findMany({ let images = await prisma.image.findMany({
take, take,
where: {
userId: user.id,
},
orderBy: { orderBy: {
created_at: 'desc' created_at: 'desc',
}, },
select: { select: {
created_at: true, created_at: true,
file: true, file: true,
mimetype: true mimetype: true,
} id: true,
},
}); });
// @ts-ignore // @ts-ignore

View File

@@ -10,11 +10,11 @@ async function handler(req: NextApiReq, res: NextApiRes) {
if (req.method === 'PATCH') { if (req.method === 'PATCH') {
const updated = await prisma.user.update({ const updated = await prisma.user.update({
where: { where: {
id: user.id id: user.id,
}, },
data: { data: {
token: createToken() token: createToken(),
} },
}); });
Logger.get('user').info(`User ${user.username} (${user.id}) reset their token`); Logger.get('user').info(`User ${user.username} (${user.id}) reset their token`);

View File

@@ -0,0 +1,41 @@
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
import prisma from 'lib/prisma';
import config from 'lib/config';
import Logger from 'lib/logger';
async function handler(req: NextApiReq, res: NextApiRes) {
const user = await req.user();
if (!user) return res.forbid('not logged in');
if (req.method === 'DELETE') {
if (!req.body.id) return res.error('no url id');
const url = await prisma.url.delete({
where: {
id: req.body.id,
},
});
Logger.get('url').info(`User ${user.username} (${user.id}) deleted a url ${url.destination} (${url.id})`);
return res.json(url);
} else {
let urls = await prisma.url.findMany({
where: {
userId: user.id,
},
select: {
created_at: true,
id: true,
destination: true,
vanity: true,
},
});
// @ts-ignore
urls.map(url => url.url = `${config.urls.route}/${url.vanity ?? url.id}`);
return res.json(urls);
}
}
export default withZipline(handler);

View File

@@ -1,34 +1,31 @@
import { join } from 'path';
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 { bytesToRead, sizeOfDir } from 'lib/util';
import { tryGetPreviewData } from 'next/dist/server/api-utils';
async function handler(req: NextApiReq, res: NextApiRes) { async function handler(req: NextApiReq, res: NextApiRes) {
const user = await req.user(); const user = await req.user();
if (!user) return res.forbid('not logged in'); if (!user) return res.forbid('not logged in');
if (!user.administrator) return res.forbid('you arent an administrator'); if (!user.administrator) return res.forbid('you aren\'t an administrator');
if (req.method === 'DELETE') { if (req.method === 'DELETE') {
if (req.body.id === user.id) return res.forbid('you can\'t delete your own account'); if (req.body.id === user.id) return res.forbid('you can\'t delete your own account');
const deleteUser = await prisma.user.findFirst({ const deleteUser = await prisma.user.findFirst({
where: { where: {
id: req.body.id id: req.body.id,
} },
}); });
if (!deleteUser) return res.forbid('user doesn\'t exist'); if (!deleteUser) return res.forbid('user doesn\'t exist');
await prisma.user.delete({ await prisma.user.delete({
where: { where: {
id: deleteUser.id id: deleteUser.id,
} },
}); });
delete deleteUser.password; delete deleteUser.password;
return res.json(deleteUser); return res.json(deleteUser);
} else { } else {
const all_users = await prisma.user.findMany({ const users = await prisma.user.findMany({
select: { select: {
username: true, username: true,
id: true, id: true,
@@ -36,11 +33,10 @@ 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 },
}
}); });
return res.json(all_users); return res.json(users);
} }
} }

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,102 +1,96 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect } from 'react';
import { Typography, Box, TextField, Stack, Button } from '@material-ui/core';
import { Color } from '@material-ui/core/Alert/Alert';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import Alert from 'components/Alert';
import Backdrop from 'components/Backdrop';
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';
function TextInput({ id, label, formik, ...other }) { import { Cross1Icon, DownloadIcon } from '@modulz/radix-icons';
return (
<Box>
<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'
sx={{ pb: 0.5 }}
{...other}
/>
</Box>
);
}
export default function Login() { export default function Login() {
const [open, setOpen] = useState(false);
const [severity, setSeverity] = useState<Color>('success');
const [message, setMessage] = useState('');
const [loadingOpen, setLoadingOpen] = useState(false);
const router = useRouter(); const router = useRouter();
const notif = useNotifications();
const [versions, setVersions] = React.useState<{ upstream: string, local: string }>(null);
const formik = useFormik({ const form = useForm({
initialValues: { initialValues: {
username: '', username: '',
password: '' password: '',
}, },
onSubmit: async values => { });
const onSubmit = async values => {
const username = values.username.trim(); const username = values.username.trim();
const password = values.password.trim(); const password = values.password.trim();
if (username === '') return formik.setFieldError('username', 'Username can\'t be nothing'); if (username === '') return form.setFieldError('username', 'Username can\'t be nothing');
setLoadingOpen(true);
const res = await useFetch('/api/auth/login', 'POST', { const res = await useFetch('/api/auth/login', 'POST', {
username, password username, password,
}); });
if (res.error) { if (res.error) {
setOpen(true); notif.showNotification({
setSeverity('error'); title: 'Login Failed',
setMessage(res.error); message: res.error,
setLoadingOpen(false); color: 'red',
} else { icon: <Cross1Icon />,
setOpen(true);
setSeverity('success');
setMessage('Logged in');
router.push('/dashboard');
}
}
}); });
} else {
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) router.push('/dashboard');
else {
const v = await useFetch('/api/version');
setVersions(v);
if (v.local !== v.upstream) {
notif.showNotification({
title: 'Update available',
message: `A new version of Zipline is available. You are running ${v.local} and the latest version is ${v.upstream}.`,
icon: <DownloadIcon />,
});
}
}
})(); })();
}, []); }, []);
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>
</Center>
<Box
sx={{
zIndex: 99,
position: 'fixed',
bottom: '10px',
right: '20px',
}}
>
{versions && (
<Tooltip
wrapLines
width={220}
transition='rotate-left'
transitionDuration={200}
label={versions.local !== versions.upstream ? 'Looks like you are running an outdated version of Zipline. Please update to the latest version.' : 'You are running the latest version of Zipline.'}
>
<Badge radius='md' size='lg' variant='dot' color={versions.local !== versions.upstream ? 'red' : 'primary'}>{versions.local}</Badge>
</Tooltip>
)}
</Box> </Box>
</> </>
); );

View File

@@ -1,9 +1,11 @@
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 '@material-ui/core'; import { LoadingOverlay } from '@mantine/core';
export default function Logout() { export default function Logout() {
const router = useRouter(); const router = useRouter();
const [visible, setVisible] = useState(true);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@@ -18,12 +20,7 @@ export default function Logout() {
}, []); }, []);
return ( return (
<Backdrop <LoadingOverlay visible={visible} />
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) 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

@@ -3,7 +3,7 @@ import useLogin from 'hooks/useLogin';
import Layout from 'components/Layout'; import Layout from 'components/Layout';
import Files from 'components/pages/Files'; import Files from 'components/pages/Files';
export default function ImagesPage() { export default function FilesPage() {
const { user, loading } = useLogin(); const { user, loading } = useLogin();
if (loading) return null; if (loading) return null;
@@ -11,12 +11,10 @@ export default function ImagesPage() {
return ( return (
<Layout <Layout
user={user} user={user}
loading={loading}
noPaper={false}
> >
<Files /> <Files />
</Layout> </Layout>
); );
} }
ImagesPage.title = 'Zipline - Gallery'; FilesPage.title = 'Zipline - Gallery';

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