mirror of
https://github.com/diced/zipline.git
synced 2025-04-04 23:21:17 -05:00
Release 3.0.0
This commit is contained in:
commit
fd400aa850
88 changed files with 9776 additions and 0 deletions
20
.babelrc
Normal file
20
.babelrc
Normal file
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
24
.eslintrc.js
Normal file
24
.eslintrc.js
Normal file
|
@ -0,0 +1,24 @@
|
|||
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'
|
||||
}
|
||||
};
|
34
.github/workflows/build.yml
vendored
Normal file
34
.github/workflows/build.yml
vendored
Normal file
|
@ -0,0 +1,34 @@
|
|||
name: 'CI: Build'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ trunk ]
|
||||
pull_request:
|
||||
branches: [ trunk ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: '16.x'
|
||||
- name: 'Restore dependency cache'
|
||||
id: cache-restore
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node${{ matrix.node }}-${{ hashFiles('**/yarn.lock') }}
|
||||
|
||||
- name: Create mock config
|
||||
run: echo -e "[uploader]\nroute = '/u'\nembed_route = '/a'\nlength = 6\ndirectory = './uploads'" > config.toml
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.cache-restore.outputs.cache-hit != 'true'
|
||||
run: yarn install
|
||||
|
||||
- name: Build
|
||||
run: yarn build
|
||||
|
40
.github/workflows/docker.yml
vendored
Normal file
40
.github/workflows/docker.yml
vendored
Normal file
|
@ -0,0 +1,40 @@
|
|||
name: 'CD: Push Docker Images'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ trunk ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
push_to_ghcr:
|
||||
name: Push Image to GitHub Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Push to GitHub Packages
|
||||
uses: docker/build-push-action@v1
|
||||
with:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
registry: docker.pkg.github.com
|
||||
repository: diced/zipline/zipline
|
||||
dockerfile: Dockerfile
|
||||
tag_with_ref: true
|
||||
|
||||
push_to_dockerhub:
|
||||
name: Push Image to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Push to Docker Hub
|
||||
uses: docker/build-push-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
repository: diced/zipline
|
||||
dockerfile: Dockerfile
|
||||
tag_with_ref: true
|
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
|
@ -0,0 +1,41 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# zipline
|
||||
config.toml
|
||||
uploads/
|
||||
prisma/schema.prisma
|
||||
data.db*
|
||||
migrations/
|
1
.husky/.gitignore
vendored
Normal file
1
.husky/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
_
|
4
.husky/commit-msg
Executable file
4
.husky/commit-msg
Executable file
|
@ -0,0 +1,4 @@
|
|||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
yarn commitlint --edit $1
|
30
Dockerfile
Normal file
30
Dockerfile
Normal file
|
@ -0,0 +1,30 @@
|
|||
FROM node:16-alpine3.11 AS builder
|
||||
WORKDIR /build
|
||||
|
||||
COPY src ./src
|
||||
COPY server ./server
|
||||
COPY scripts ./scripts
|
||||
COPY prisma/schema.shared.prisma ./prisma/schema.shared.prisma
|
||||
|
||||
COPY package.json yarn.lock next.config.js next-env.d.ts tsconfig.json ./
|
||||
|
||||
RUN yarn install
|
||||
|
||||
# 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
|
||||
|
||||
FROM node:16-alpine3.11 AS runner
|
||||
WORKDIR /zipline
|
||||
|
||||
COPY --from=builder /build/node_modules ./node_modules
|
||||
|
||||
COPY --from=builder /build/src ./src
|
||||
COPY --from=builder /build/server ./server
|
||||
COPY --from=builder /build/scripts ./scripts
|
||||
COPY --from=builder /build/.next ./.next
|
||||
COPY --from=builder /build/tsconfig.json ./tsconfig.json
|
||||
COPY --from=builder /build/package.json ./package.json
|
||||
|
||||
CMD ["node", "server"]
|
3
Dockerignore
Normal file
3
Dockerignore
Normal file
|
@ -0,0 +1,3 @@
|
|||
prisma/migrations
|
||||
node_modules
|
||||
.next
|
26
README.md
Normal file
26
README.md
Normal file
|
@ -0,0 +1,26 @@
|
|||
<p align="center"><img src="https://raw.githubusercontent.com/diced/zipline/trunk/public/zipline_small.png"/></p>
|
||||
|
||||

|
||||

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

|
||||

|
||||

|
||||
<br>
|
||||
|
||||
# Zipline
|
||||
|
||||
Fast & lightweight file uploading.
|
||||
|
||||
# Features
|
||||
|
||||
- Configurable
|
||||
- Fast
|
||||
- Built with Next.js & React
|
||||
- Support for **multible database types**
|
||||
- Token protected uploading
|
||||
- Easy setup instructions on [docs](https://zipline.diced.me)
|
||||
|
||||
# Installing
|
||||
|
||||
[See how to install here](https://zipline.diced.me/docs/getting-started)
|
53
commitlint.config.js
Normal file
53
commitlint.config.js
Normal file
|
@ -0,0 +1,53 @@
|
|||
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',
|
||||
'api',
|
||||
'hooks',
|
||||
'components',
|
||||
'middleware',
|
||||
'redux',
|
||||
'themes',
|
||||
'lib'
|
||||
],
|
||||
],
|
||||
},
|
||||
};
|
24
config.example.toml
Normal file
24
config.example.toml
Normal file
|
@ -0,0 +1,24 @@
|
|||
[core]
|
||||
secure = true
|
||||
secret = 'some secret'
|
||||
host = '0.0.0.0'
|
||||
port = 3000
|
||||
|
||||
[database]
|
||||
# postgresql
|
||||
type = 'psql'
|
||||
url = 'postgres://postgres:postgres@postgres/postgres'
|
||||
|
||||
# mysql
|
||||
# type = 'mysql'
|
||||
# url = 'mysql://postgres:postgres@mysql/mysql'
|
||||
|
||||
# sqlite
|
||||
# type = 'sqlite'
|
||||
# url = 'file:sqlite.db'
|
||||
|
||||
[uploader]
|
||||
route = '/u'
|
||||
embed_route = '/a'
|
||||
length = 6
|
||||
directory = './uploads'
|
43
docker-compose.yml
Normal file
43
docker-compose.yml
Normal file
|
@ -0,0 +1,43 @@
|
|||
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_TYPE=psql
|
||||
- DATABASE_URL=postgresql://postgres:postgres@postgres/postgres/
|
||||
- UPLOADER_ROUTE=/u
|
||||
- UPLOADER_EMBED_ROUTE=/a
|
||||
- UPLOADER_LENGTH=6
|
||||
- UPLOADER_DIRECTORY=./uploads
|
||||
volumes:
|
||||
- '$PWD/uploads:/zipline/uploads'
|
||||
- '$PWD/prisma:/zipline/prisma'
|
||||
- '$PWD/public:/zipline/public'
|
||||
depends_on:
|
||||
- 'postgres'
|
||||
|
||||
volumes:
|
||||
pg_data:
|
14
next-env.d.ts
vendored
Normal file
14
next-env.d.ts
vendored
Normal file
|
@ -0,0 +1,14 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/types/global" />
|
||||
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
import type { Config } from './src/lib/types';
|
||||
|
||||
declare global {
|
||||
namespace NodeJS {
|
||||
interface Global {
|
||||
prisma: PrismaClient;
|
||||
config: Config
|
||||
}
|
||||
}
|
||||
}
|
4
next.config.js
Normal file
4
next.config.js
Normal file
|
@ -0,0 +1,4 @@
|
|||
module.exports = {
|
||||
reactStrictMode: true,
|
||||
};
|
||||
|
64
package.json
Normal file
64
package.json
Normal file
|
@ -0,0 +1,64 @@
|
|||
{
|
||||
"name": "zip3",
|
||||
"version": "3.0.0",
|
||||
"scripts": {
|
||||
"prepare": "husky install",
|
||||
"dev": "NODE_ENV=development node server",
|
||||
"build": "npm-run-all build:schema build:next",
|
||||
"build:next": "next build",
|
||||
"build:schema": "prisma generate --schema=prisma/schema.shared.prisma",
|
||||
"start": "node server",
|
||||
"lint": "next lint",
|
||||
"ts-node": "./node_modules/.bin/ts-node --compiler-options \"{\\\"module\\\":\\\"commonjs\\\"}\" --transpile-only",
|
||||
"create-all-migrations": "node scripts/create-migrations",
|
||||
"semantic-release": "semantic-release"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.4.0",
|
||||
"@emotion/styled": "^11.3.0",
|
||||
"@iarna/toml": "2.2.5",
|
||||
"@material-ui/core": "^5.0.0-alpha.37",
|
||||
"@material-ui/data-grid": "^4.0.0-alpha.32",
|
||||
"@material-ui/icons": "^5.0.0-alpha.37",
|
||||
"@material-ui/styles": "^5.0.0-alpha.35",
|
||||
"@prisma/client": "2.25.0",
|
||||
"@reduxjs/toolkit": "^1.6.0",
|
||||
"argon2": "^0.28.2",
|
||||
"busboy": "^0.3.1",
|
||||
"colorette": "^1.2.2",
|
||||
"cookie": "^0.4.1",
|
||||
"copy-to-clipboard": "^3.3.1",
|
||||
"fecha": "^4.2.1",
|
||||
"formik": "^2.2.9",
|
||||
"next": "11.0.0",
|
||||
"prisma": "2.25.0",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"react-dropzone": "^11.3.2",
|
||||
"react-redux": "^7.2.4",
|
||||
"redux": "^4.1.0",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"rimraf": "^3.0.2",
|
||||
"yup": "^0.32.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^12.1.4",
|
||||
"@commitlint/config-conventional": "^12.1.4",
|
||||
"@types/bcryptjs": "^2.4.2",
|
||||
"@types/busboy": "^0.2.3",
|
||||
"@types/cookie": "^0.4.0",
|
||||
"@types/node": "^15.12.2",
|
||||
"babel-plugin-transform-imports": "^2.0.0",
|
||||
"eslint": "7.28.0",
|
||||
"eslint-config-next": "11.0.0",
|
||||
"husky": "^6.0.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"release": "^6.3.0",
|
||||
"ts-node": "^10.0.0",
|
||||
"typescript": "^4.3.2"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/diced/workflow-testing.git"
|
||||
}
|
||||
}
|
3
prisma/migrations_mysql/migration_lock.toml
Normal file
3
prisma/migrations_mysql/migration_lock.toml
Normal file
|
@ -0,0 +1,3 @@
|
|||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "mysql"
|
53
prisma/migrations_mysql/mysql/migration.sql
Normal file
53
prisma/migrations_mysql/mysql/migration.sql
Normal file
|
@ -0,0 +1,53 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE `User` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`username` VARCHAR(191) NOT NULL,
|
||||
`password` VARCHAR(191) NOT NULL,
|
||||
`token` VARCHAR(191) NOT NULL,
|
||||
`administrator` BOOLEAN NOT NULL DEFAULT false,
|
||||
`embedTitle` VARCHAR(191),
|
||||
`embedColor` VARCHAR(191) NOT NULL DEFAULT '#2f3136',
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `Image` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`file` VARCHAR(191) NOT NULL,
|
||||
`mimetype` VARCHAR(191) NOT NULL DEFAULT 'image/png',
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`views` INTEGER NOT NULL DEFAULT 0,
|
||||
`userId` INTEGER NOT NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `InvisibleImage` (
|
||||
`id` INTEGER NOT NULL,
|
||||
`invis` VARCHAR(191) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `InvisibleImage.invis_unique`(`invis`),
|
||||
UNIQUE INDEX `InvisibleImage_id_unique`(`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `Url` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`to` VARCHAR(191) NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`views` INTEGER NOT NULL DEFAULT 0,
|
||||
`userId` INTEGER NOT NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `Image` ADD FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `InvisibleImage` ADD FOREIGN KEY (`id`) REFERENCES `Image`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `Url` ADD FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
3
prisma/migrations_psql/migration_lock.toml
Normal file
3
prisma/migrations_psql/migration_lock.toml
Normal file
|
@ -0,0 +1,3 @@
|
|||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
56
prisma/migrations_psql/psql/migration.sql
Normal file
56
prisma/migrations_psql/psql/migration.sql
Normal file
|
@ -0,0 +1,56 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"username" TEXT NOT NULL,
|
||||
"password" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"administrator" BOOLEAN NOT NULL DEFAULT false,
|
||||
"embedTitle" TEXT,
|
||||
"embedColor" TEXT NOT NULL DEFAULT E'#2f3136',
|
||||
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Image" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"file" TEXT NOT NULL,
|
||||
"mimetype" TEXT NOT NULL DEFAULT E'image/png',
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"views" INTEGER NOT NULL DEFAULT 0,
|
||||
"userId" INTEGER NOT NULL,
|
||||
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "InvisibleImage" (
|
||||
"id" INTEGER NOT NULL,
|
||||
"invis" TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Url" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"to" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"views" INTEGER NOT NULL DEFAULT 0,
|
||||
"userId" INTEGER NOT NULL,
|
||||
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "InvisibleImage.invis_unique" ON "InvisibleImage"("invis");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "InvisibleImage_id_unique" ON "InvisibleImage"("id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Image" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InvisibleImage" ADD FOREIGN KEY ("id") REFERENCES "Image"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Url" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
3
prisma/migrations_sqlite/migration_lock.toml
Normal file
3
prisma/migrations_sqlite/migration_lock.toml
Normal file
|
@ -0,0 +1,3 @@
|
|||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "sqlite"
|
44
prisma/migrations_sqlite/sqlite/migration.sql
Normal file
44
prisma/migrations_sqlite/sqlite/migration.sql
Normal file
|
@ -0,0 +1,44 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"username" TEXT NOT NULL,
|
||||
"password" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"administrator" BOOLEAN NOT NULL DEFAULT false,
|
||||
"embedTitle" TEXT,
|
||||
"embedColor" TEXT NOT NULL DEFAULT '#2f3136'
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Image" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"file" TEXT NOT NULL,
|
||||
"mimetype" TEXT NOT NULL DEFAULT 'image/png',
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"views" INTEGER NOT NULL DEFAULT 0,
|
||||
"userId" INTEGER NOT NULL,
|
||||
FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "InvisibleImage" (
|
||||
"id" INTEGER NOT NULL,
|
||||
"invis" TEXT NOT NULL,
|
||||
FOREIGN KEY ("id") REFERENCES "Image" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Url" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"to" TEXT NOT NULL,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"views" INTEGER NOT NULL DEFAULT 0,
|
||||
"userId" INTEGER NOT NULL,
|
||||
FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "InvisibleImage.invis_unique" ON "InvisibleImage"("invis");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "InvisibleImage_id_unique" ON "InvisibleImage"("id");
|
55
prisma/schema.mysql.prisma
Normal file
55
prisma/schema.mysql.prisma
Normal file
|
@ -0,0 +1,55 @@
|
|||
datasource db {
|
||||
provider = "mysql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
username String
|
||||
password String
|
||||
token String
|
||||
administrator Boolean @default(false)
|
||||
embedTitle String?
|
||||
embedColor String @default("#2f3136")
|
||||
images Image[]
|
||||
urls Url[]
|
||||
}
|
||||
|
||||
model Image {
|
||||
id Int @id @default(autoincrement())
|
||||
file String
|
||||
mimetype String @default("image/png")
|
||||
created_at DateTime @default(now())
|
||||
views Int @default(0)
|
||||
invisible InvisibleImage?
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
}
|
||||
|
||||
model InvisibleImage {
|
||||
id Int
|
||||
image Image @relation(fields: [id], references: [id])
|
||||
|
||||
invis String @unique
|
||||
}
|
||||
|
||||
model Url {
|
||||
id Int @id @default(autoincrement())
|
||||
to String
|
||||
created_at DateTime @default(now())
|
||||
views Int @default(0)
|
||||
invisible InvisibleUrl?
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
}
|
||||
|
||||
model InvisibleUrl {
|
||||
id Int
|
||||
url Url @relation(fields: [id], references: [id])
|
||||
|
||||
invis String @unique
|
||||
}
|
55
prisma/schema.psql.prisma
Normal file
55
prisma/schema.psql.prisma
Normal file
|
@ -0,0 +1,55 @@
|
|||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
username String
|
||||
password String
|
||||
token String
|
||||
administrator Boolean @default(false)
|
||||
embedTitle String?
|
||||
embedColor String @default("#2f3136")
|
||||
images Image[]
|
||||
urls Url[]
|
||||
}
|
||||
|
||||
model Image {
|
||||
id Int @id @default(autoincrement())
|
||||
file String
|
||||
mimetype String @default("image/png")
|
||||
created_at DateTime @default(now())
|
||||
views Int @default(0)
|
||||
invisible InvisibleImage?
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
}
|
||||
|
||||
model InvisibleImage {
|
||||
id Int
|
||||
image Image @relation(fields: [id], references: [id])
|
||||
|
||||
invis String @unique
|
||||
}
|
||||
|
||||
model Url {
|
||||
id Int @id @default(autoincrement())
|
||||
to String
|
||||
created_at DateTime @default(now())
|
||||
views Int @default(0)
|
||||
invisible InvisibleUrl?
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
}
|
||||
|
||||
model InvisibleUrl {
|
||||
id Int
|
||||
url Url @relation(fields: [id], references: [id])
|
||||
|
||||
invis String @unique
|
||||
}
|
55
prisma/schema.shared.prisma
Normal file
55
prisma/schema.shared.prisma
Normal file
|
@ -0,0 +1,55 @@
|
|||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
username String
|
||||
password String
|
||||
token String
|
||||
administrator Boolean @default(false)
|
||||
embedTitle String?
|
||||
embedColor String @default("#2f3136")
|
||||
images Image[]
|
||||
urls Url[]
|
||||
}
|
||||
|
||||
model Image {
|
||||
id Int @id @default(autoincrement())
|
||||
file String
|
||||
mimetype String @default("image/png")
|
||||
created_at DateTime @default(now())
|
||||
views Int @default(0)
|
||||
invisible InvisibleImage?
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
}
|
||||
|
||||
model InvisibleImage {
|
||||
id Int
|
||||
image Image @relation(fields: [id], references: [id])
|
||||
|
||||
invis String @unique
|
||||
}
|
||||
|
||||
model Url {
|
||||
id Int @id @default(autoincrement())
|
||||
to String
|
||||
created_at DateTime @default(now())
|
||||
views Int @default(0)
|
||||
invisible InvisibleUrl?
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
}
|
||||
|
||||
model InvisibleUrl {
|
||||
id Int
|
||||
url Url @relation(fields: [id], references: [id])
|
||||
|
||||
invis String @unique
|
||||
}
|
55
prisma/schema.sqlite.prisma
Normal file
55
prisma/schema.sqlite.prisma
Normal file
|
@ -0,0 +1,55 @@
|
|||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
username String
|
||||
password String
|
||||
token String
|
||||
administrator Boolean @default(false)
|
||||
embedTitle String?
|
||||
embedColor String @default("#2f3136")
|
||||
images Image[]
|
||||
urls Url[]
|
||||
}
|
||||
|
||||
model Image {
|
||||
id Int @id @default(autoincrement())
|
||||
file String
|
||||
mimetype String @default("image/png")
|
||||
created_at DateTime @default(now())
|
||||
views Int @default(0)
|
||||
invisible InvisibleImage?
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
}
|
||||
|
||||
model InvisibleImage {
|
||||
id Int
|
||||
image Image @relation(fields: [id], references: [id])
|
||||
|
||||
invis String @unique
|
||||
}
|
||||
|
||||
model Url {
|
||||
id Int @id @default(autoincrement())
|
||||
to String
|
||||
created_at DateTime @default(now())
|
||||
views Int @default(0)
|
||||
invisible InvisibleUrl?
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
}
|
||||
|
||||
model InvisibleUrl {
|
||||
id Int
|
||||
url Url @relation(fields: [id], references: [id])
|
||||
|
||||
invis String @unique
|
||||
}
|
31
prisma/seed.ts
Normal file
31
prisma/seed.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
import { PrismaClient } from '@prisma/client';
|
||||
import { hashPassword, createToken } from '../src/lib/util';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: 'administrator',
|
||||
password: await hashPassword('password'),
|
||||
token: createToken(),
|
||||
administrator: true
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`
|
||||
When logging into Zipline for the first time, use these credentials:
|
||||
|
||||
Username: "${user.username}"
|
||||
Password: "password"
|
||||
`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(e => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 279 KiB |
BIN
public/zipline.png
Normal file
BIN
public/zipline.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
BIN
public/zipline_small.png
Normal file
BIN
public/zipline_small.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 28 KiB |
8
release.config.js
Normal file
8
release.config.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
module.exports = {
|
||||
branches: ['trunk'],
|
||||
plugins: [
|
||||
'@semantic-release/commit-analyzer',
|
||||
'@semantic-release/github',
|
||||
'@semantic-release/changelog'
|
||||
]
|
||||
};
|
42
scripts/create-migrations.js
Normal file
42
scripts/create-migrations.js
Normal file
|
@ -0,0 +1,42 @@
|
|||
const prismaRun = require('./prisma-run');
|
||||
const remove = require('rimraf').sync;
|
||||
const { readFileSync, readdirSync, statSync, renameSync } = require('fs');
|
||||
const { join } = require('path');
|
||||
|
||||
|
||||
const str = readFileSync('./config.toml');
|
||||
const config = require('@iarna/toml/parse-string')(str);
|
||||
|
||||
remove('prisma/migrations*');
|
||||
|
||||
function getFirstDir(dir) {
|
||||
const files = readdirSync(dir);
|
||||
for (const file of files) {
|
||||
if (statSync(join(dir, file)).isDirectory()) return join(dir, file);
|
||||
}
|
||||
}
|
||||
|
||||
function createPSQLMigrations() {
|
||||
prismaRun(config.database.psql_url, 'psql', ['migrate', 'dev', '--skip-seed', '--name=psql', '--schema=prisma/schema.psql.prisma']);
|
||||
const dir = getFirstDir('./prisma/migrations');
|
||||
renameSync(dir, './prisma/migrations/psql');
|
||||
renameSync('./prisma/migrations', './prisma/migrations_psql');
|
||||
}
|
||||
|
||||
function createMYSQLMigrations() {
|
||||
prismaRun(config.database.mysql_url, 'mysql', ['migrate', 'dev', '--skip-seed', '--name=mysql', '--schema=prisma/schema.mysql.prisma']);
|
||||
const dir = getFirstDir('./prisma/migrations');
|
||||
renameSync(dir, './prisma/migrations/mysql');
|
||||
renameSync('./prisma/migrations', './prisma/migrations_mysql');
|
||||
}
|
||||
|
||||
function createSqliteMigrations() {
|
||||
prismaRun(config.database.sqlite_url, 'sqlite', ['migrate', 'dev', '--skip-seed', '--name=sqlite', '--schema=prisma/schema.sqlite.prisma']);
|
||||
const dir = getFirstDir('./prisma/migrations');
|
||||
renameSync(dir, './prisma/migrations/sqlite');
|
||||
renameSync('./prisma/migrations', './prisma/migrations_sqlite');
|
||||
}
|
||||
|
||||
createPSQLMigrations();
|
||||
createMYSQLMigrations();
|
||||
createSqliteMigrations();
|
43
scripts/deploy-db.js
Normal file
43
scripts/deploy-db.js
Normal file
|
@ -0,0 +1,43 @@
|
|||
const { copyFileSync, readdirSync, statSync, existsSync, mkdirSync } = require('fs');
|
||||
const { join, sep } = require('path');
|
||||
const rimraf = require('rimraf');
|
||||
const Logger = require('../src/lib/logger');
|
||||
const prismaRun = require('./prisma-run');
|
||||
|
||||
function recursive(dir) {
|
||||
let res = [];
|
||||
const files = readdirSync(dir);
|
||||
for (let i = 0, L = files.length; i !== L; ++i) {
|
||||
const file = join(dir, files[i]);
|
||||
if (statSync(file).isDirectory()) res = [...res, ...recursive(file)];
|
||||
else res.push(file);
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
module.exports = async (config) => {
|
||||
try {
|
||||
const prisma = join(process.cwd(), 'prisma');
|
||||
const migrationsDir = join(prisma, 'migrations_' + config.database.type);
|
||||
const destMigrations = join(prisma, 'migrations');
|
||||
const migrationFiles = recursive(migrationsDir);
|
||||
const destFiles = migrationFiles.map(x => x.replace(migrationsDir + sep, destMigrations + sep));
|
||||
|
||||
if (existsSync(destMigrations)) rimraf.sync(destMigrations);
|
||||
mkdirSync(destMigrations);
|
||||
mkdirSync(join(destMigrations, config.database.type));
|
||||
|
||||
for (let i = 0, L = migrationFiles.length; i !== L; ++i) {
|
||||
copyFileSync(migrationFiles[i], destFiles[i]);
|
||||
}
|
||||
|
||||
await prismaRun(config.database.url, ['migrate', 'deploy', `--schema=prisma/schema.${config.database.type}.prisma`]);
|
||||
await prismaRun(config.database.url, ['generate', `--schema=prisma/schema.${config.database.type}.prisma`]);
|
||||
await prismaRun(config.database.url, ['db', 'seed', '--preview-feature', `--schema=prisma/schema.${config.database.type}.prisma`]);
|
||||
} catch (e) {
|
||||
Logger.get('db').error('there was an error.. exiting..');
|
||||
rimraf.sync(join(process.cwd(), 'prisma', 'migrations'));
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
40
scripts/migrate-v2-v3.js
Normal file
40
scripts/migrate-v2-v3.js
Normal file
|
@ -0,0 +1,40 @@
|
|||
const { readFile, readdir } = require('fs/promises');
|
||||
const { existsSync } = require('fs');
|
||||
const { join, extname } = require('path');
|
||||
const validateConfig = require('../server/validateConfig');
|
||||
const Logger = require('../src/lib/logger');
|
||||
const mimes = require('./mimes');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
|
||||
(async () => {
|
||||
const str = await readFile('./config.toml');
|
||||
const config = require('@iarna/toml/parse-string')(str);
|
||||
if (!existsSync(join(process.cwd(), 'prisma', 'migrations'))) {
|
||||
Logger.get('server').info('detected an uncreated database - creating...');
|
||||
await require('../scripts/deploy-db')(config);
|
||||
}
|
||||
|
||||
await validateConfig(config);
|
||||
|
||||
process.env.DATABASE_URL = config.database.url;
|
||||
|
||||
const files = await readdir(process.argv[2]);
|
||||
const data = files.map(x => {
|
||||
const mime = mimes[extname(x)] ?? 'application/octet-stream';
|
||||
|
||||
return {
|
||||
file: x,
|
||||
mimetype: mime,
|
||||
userId: 1
|
||||
};
|
||||
});
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
Logger.get('migrator').info('starting migrations...');
|
||||
await prisma.image.createMany({
|
||||
data
|
||||
});
|
||||
Logger.get('migrator').info('finished migrations! It is recomended to move your old uploads folder (' + process.argv[2] + ') to the current one which is ' + config.uploader.directory);
|
||||
process.exit();
|
||||
})();
|
78
scripts/mimes.js
Normal file
78
scripts/mimes.js
Normal file
|
@ -0,0 +1,78 @@
|
|||
module.exports = {
|
||||
'.aac': 'audio/aac',
|
||||
'.abw': 'application/x-abiword',
|
||||
'.arc': 'application/x-freearc',
|
||||
'.avi': 'video/x-msvideo',
|
||||
'.azw': 'application/vnd.amazon.ebook',
|
||||
'.bin': 'application/octet-stream',
|
||||
'.bmp': 'image/bmp',
|
||||
'.bz': 'application/x-bzip',
|
||||
'.bz2': 'application/x-bzip2',
|
||||
'.cda': 'application/x-cdf',
|
||||
'.csh': 'application/x-csh',
|
||||
'.css': 'text/css',
|
||||
'.csv': 'text/csv',
|
||||
'.doc': 'application/msword',
|
||||
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'.eot': 'application/vnd.ms-fontobject',
|
||||
'.epub': 'application/epub+zip',
|
||||
'.gz': 'application/gzip',
|
||||
'.gif': 'image/gif',
|
||||
'.htm': 'text/html',
|
||||
'.html': 'text/html',
|
||||
'.ico': 'image/vnd.microsoft.icon',
|
||||
'.ics': 'text/calendar',
|
||||
'.jar': 'application/java-archive',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.js': 'text/javascript',
|
||||
'.json': 'application/json',
|
||||
'.jsonld': 'application/ld+json',
|
||||
'.mid': 'audio/midi',
|
||||
'.midi': 'audio/midi',
|
||||
'.mjs': 'text/javascript',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.mp4': 'video/mp4',
|
||||
'.mpeg': 'video/mpeg',
|
||||
'.mpkg': 'application/vnd.apple.installer+xml',
|
||||
'.odp': 'application/vnd.oasis.opendocument.presentation',
|
||||
'.ods': 'application/vnd.oasis.opendocument.spreadsheet',
|
||||
'.odt': 'application/vnd.oasis.opendocument.text',
|
||||
'.oga': 'audio/ogg',
|
||||
'.ogv': 'video/ogg',
|
||||
'.ogx': 'application/ogg',
|
||||
'.opus': 'audio/opus',
|
||||
'.otf': 'font/otf',
|
||||
'.png': 'image/png',
|
||||
'.pdf': 'application/pdf',
|
||||
'.php': 'application/x-httpd-php',
|
||||
'.ppt': 'application/vnd.ms-powerpoint',
|
||||
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'.rar': 'application/vnd.rar',
|
||||
'.rtf': 'application/rtf',
|
||||
'.sh': 'application/x-sh',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.swf': 'application/x-shockwave-flash',
|
||||
'.tar': 'application/x-tar',
|
||||
'.tif': 'image/tiff',
|
||||
'.tiff': 'image/tiff',
|
||||
'.ts': 'video/mp2t',
|
||||
'.ttf': 'font/ttf',
|
||||
'.txt': 'text/plain',
|
||||
'.vsd': 'application/vnd.visio',
|
||||
'.wav': 'audio/wav',
|
||||
'.weba': 'audio/webm',
|
||||
'.webm': 'video/webm',
|
||||
'.webp': 'image/webp',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.xhtml': 'application/xhtml+xml',
|
||||
'.xls': 'application/vnd.ms-excel',
|
||||
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'.xml': 'application/xml',
|
||||
'.xul': 'application/vnd.mozilla.xul+xml',
|
||||
'.zip': 'application/zip',
|
||||
'.3gp': 'video/3gpp',
|
||||
'.3g2': 'video/3gpp2',
|
||||
'.7z': 'application/x-7z-compressed'
|
||||
};
|
23
scripts/prisma-run.js
Normal file
23
scripts/prisma-run.js
Normal file
|
@ -0,0 +1,23 @@
|
|||
const { spawn } = require('child_process');
|
||||
const { join } = require('path');
|
||||
|
||||
module.exports = (url, args) => {
|
||||
return new Promise((res, rej) => {
|
||||
const proc = spawn(join(process.cwd(), 'node_modules', '.bin', 'prisma'), args, {
|
||||
env: {
|
||||
DATABASE_URL: url,
|
||||
...process.env
|
||||
},
|
||||
});
|
||||
|
||||
proc.stdout.on('data', d => console.log(d.toString()));
|
||||
proc.stderr.on('data', d => {
|
||||
console.log(d.toString());
|
||||
|
||||
rej(d.toString());
|
||||
});
|
||||
|
||||
proc.stdout.on('close', () => res());
|
||||
|
||||
});
|
||||
};
|
124
server/index.js
Normal file
124
server/index.js
Normal file
|
@ -0,0 +1,124 @@
|
|||
const next = require('next');
|
||||
const { createServer } = require('http');
|
||||
const { readFile, stat, mkdir } = require('fs/promises');
|
||||
const { existsSync } = require('fs');
|
||||
const { execSync } = require('child_process');
|
||||
const { join } = require('path');
|
||||
const { red, green, bold } = require('colorette');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const validateConfig = require('./validateConfig');
|
||||
const Logger = require('../src/lib/logger');
|
||||
const getFile = require('./static');
|
||||
const readConfig = require('../src/lib/readConfig');
|
||||
|
||||
Logger.get('server').info('starting zipline server');
|
||||
|
||||
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 () => {
|
||||
try {
|
||||
const config = readConfig();
|
||||
|
||||
if (!existsSync(join(process.cwd(), 'prisma', 'migrations'))) {
|
||||
Logger.get('server').info('detected an uncreated database - creating...');
|
||||
require('../scripts/deploy-db')(config);
|
||||
}
|
||||
|
||||
await validateConfig(config);
|
||||
|
||||
process.env.DATABASE_URL = config.database.url;
|
||||
|
||||
await stat('./.next');
|
||||
await mkdir(config.uploader.directory, { recursive: true });
|
||||
|
||||
const app = next({
|
||||
dir: '.',
|
||||
dev,
|
||||
quiet: dev
|
||||
}, config.core.port, config.core.host);
|
||||
|
||||
await app.prepare();
|
||||
|
||||
const handle = app.getRequestHandler();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const srv = createServer(async (req, res) => {
|
||||
if (req.url.startsWith(config.uploader.route)) {
|
||||
const parts = req.url.split('/');
|
||||
if (!parts[2] || parts[2] === '') return;
|
||||
|
||||
const data = await getFile(config.uploader.directory, parts[2]);
|
||||
if (!data) {
|
||||
app.render404(req, res);
|
||||
} else {
|
||||
let image = await prisma.image.findFirst({
|
||||
where: {
|
||||
OR: {
|
||||
file: parts[2],
|
||||
},
|
||||
OR: {
|
||||
invisible: {
|
||||
invis: decodeURI(parts[2])
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if (image) {
|
||||
await prisma.image.update({
|
||||
where: {
|
||||
id: image.id,
|
||||
},
|
||||
data: {
|
||||
views: {
|
||||
increment: 1
|
||||
}
|
||||
}
|
||||
});
|
||||
res.setHeader('Content-Type', image.mimetype);
|
||||
}
|
||||
|
||||
res.end(data);
|
||||
}
|
||||
} else {
|
||||
handle(req, res);
|
||||
}
|
||||
|
||||
log(req.url, res.statusCode);
|
||||
});
|
||||
|
||||
srv.on('error', (e) => {
|
||||
Logger.get('server').error(e);
|
||||
|
||||
process.exit(1);
|
||||
});
|
||||
srv.on('listening', () => {
|
||||
Logger.get('server').info(`listening on ${config.core.host}:${config.core.port}`);
|
||||
});
|
||||
|
||||
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')) {
|
||||
Logger.get('web').error(`there is no production build - run \`${shouldUseYarn() ? 'yarn build' : 'npm build'}\``);
|
||||
} else if (e.code && e.code === 'ENOENT') {
|
||||
if (e.path === './.next') Logger.get('web').error(`there is no production build - run \`${shouldUseYarn() ? 'yarn build' : 'npm build'}\``);
|
||||
} else {
|
||||
Logger.get('server').error(e);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
})();
|
11
server/static.js
Normal file
11
server/static.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
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;
|
||||
}
|
||||
};
|
44
server/validateConfig.js
Normal file
44
server/validateConfig.js
Normal file
|
@ -0,0 +1,44 @@
|
|||
const Logger = require('../src/lib/logger');
|
||||
|
||||
function dot(str, obj) {
|
||||
return str.split('.').reduce((a,b) => a[b], obj);
|
||||
}
|
||||
|
||||
const path = (path, type) => ({ path, type });
|
||||
|
||||
module.exports = async config => {
|
||||
const paths = [
|
||||
path('core.secure', 'boolean'),
|
||||
path('core.secret', 'string'),
|
||||
path('core.host', 'string'),
|
||||
path('core.port', 'number'),
|
||||
path('database.type', 'string'),
|
||||
path('database.url', 'string'),
|
||||
path('uploader.route', 'string'),
|
||||
path('uploader.embed_route', 'string'),
|
||||
path('uploader.length', 'number'),
|
||||
path('uploader.directory', 'string')
|
||||
];
|
||||
|
||||
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`);
|
||||
++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);
|
||||
}
|
||||
};
|
12
src/components/Alert.tsx
Normal file
12
src/components/Alert.tsx
Normal file
|
@ -0,0 +1,12 @@
|
|||
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>
|
||||
);
|
||||
}
|
16
src/components/Backdrop.tsx
Normal file
16
src/components/Backdrop.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Backdrop as MuiBackdrop,
|
||||
CircularProgress
|
||||
} from '@material-ui/core';
|
||||
|
||||
export default function Backdrop({ open }) {
|
||||
return (
|
||||
<MuiBackdrop
|
||||
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
|
||||
open={open}
|
||||
>
|
||||
<CircularProgress color='inherit' />
|
||||
</MuiBackdrop>
|
||||
);
|
||||
}
|
19
src/components/Card.tsx
Normal file
19
src/components/Card.tsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Card as MuiCard,
|
||||
CardContent,
|
||||
Typography
|
||||
} from '@material-ui/core';
|
||||
|
||||
export default function Card(props) {
|
||||
const { name, children, ...other } = props;
|
||||
|
||||
return (
|
||||
<MuiCard sx={{ minWidth: 100 }} {...other}>
|
||||
<CardContent>
|
||||
<Typography variant='h3'>{name}</Typography>
|
||||
{children}
|
||||
</CardContent>
|
||||
</MuiCard>
|
||||
);
|
||||
}
|
15
src/components/CenteredBox.tsx
Normal file
15
src/components/CenteredBox.tsx
Normal file
|
@ -0,0 +1,15 @@
|
|||
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>
|
||||
);
|
||||
}
|
61
src/components/Image.tsx
Normal file
61
src/components/Image.tsx
Normal file
|
@ -0,0 +1,61 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardMedia,
|
||||
CardActionArea,
|
||||
Popover,
|
||||
Button,
|
||||
ButtonGroup
|
||||
} from '@material-ui/core';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import useFetch from '../lib/hooks/useFetch';
|
||||
|
||||
export default function Image({ image, updateImages }) {
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
|
||||
const handleDelete = async () => {
|
||||
const res = await useFetch('/api/user/images', 'DELETE', { id: image.id });
|
||||
if (!res.error) updateImages();
|
||||
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
copy(`${window.location.protocol}//${window.location.host}${image.url}`);
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card sx={{ maxWidth: '100%' }}>
|
||||
<CardActionArea>
|
||||
<CardMedia
|
||||
sx={{ height: 320 }}
|
||||
image={image.url}
|
||||
title={image.file}
|
||||
onClick={e => setAnchorEl(e.currentTarget)}
|
||||
/>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
<Popover
|
||||
open={Boolean(anchorEl)}
|
||||
anchorEl={anchorEl}
|
||||
onClose={() => setAnchorEl(null)}
|
||||
anchorOrigin={{
|
||||
vertical: 'center',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'center',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
>
|
||||
<ButtonGroup variant='contained'>
|
||||
<Button onClick={handleDelete} color='primary'>Delete</Button>
|
||||
<Button onClick={handleCopy} color='primary'>Copy URL</Button>
|
||||
</ButtonGroup>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
381
src/components/Layout.tsx
Normal file
381
src/components/Layout.tsx
Normal file
|
@ -0,0 +1,381 @@
|
|||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import useFetch from '../lib/hooks/useFetch';
|
||||
|
||||
import {
|
||||
AppBar,
|
||||
Box,
|
||||
Divider,
|
||||
Drawer,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Toolbar,
|
||||
Typography,
|
||||
Button,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
} from '@material-ui/core';
|
||||
import {
|
||||
Menu as MenuIcon,
|
||||
Home as HomeIcon,
|
||||
AccountCircle as AccountIcon,
|
||||
Image as ImageIcon,
|
||||
Upload as UploadIcon,
|
||||
ContentCopy as CopyIcon,
|
||||
Autorenew as ResetIcon,
|
||||
Logout as LogoutIcon,
|
||||
PeopleAlt as UsersIcon
|
||||
} from '@material-ui/icons';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import Backdrop from './Backdrop';
|
||||
|
||||
const items = [
|
||||
{
|
||||
icon: <HomeIcon />,
|
||||
text: 'Home',
|
||||
link: '/dashboard'
|
||||
},
|
||||
{
|
||||
icon: <ImageIcon />,
|
||||
text: 'Images',
|
||||
link: '/dashboard/images'
|
||||
},
|
||||
{
|
||||
icon: <UploadIcon />,
|
||||
text: 'Upload',
|
||||
link: '/dashboard/upload'
|
||||
}
|
||||
];
|
||||
|
||||
const drawerWidth = 240;
|
||||
|
||||
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'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 [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 open = Boolean(anchorEl);
|
||||
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 drawer = (
|
||||
<div>
|
||||
<CopyTokenDialog open={copyOpen} setOpen={setCopyOpen} token={token} />
|
||||
<ResetTokenDialog open={resetOpen} setOpen={setResetOpen} setToken={setToken} />
|
||||
<Toolbar
|
||||
sx={{
|
||||
width: { xs: drawerWidth }
|
||||
}}
|
||||
>
|
||||
<AppBar
|
||||
position='fixed'
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderBottom: 1,
|
||||
borderBottomColor: t => t.palette.divider,
|
||||
display: { xs: 'none', sm: 'block' }
|
||||
}}
|
||||
>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
color='inherit'
|
||||
aria-label='open drawer'
|
||||
edge='start'
|
||||
onClick={() => setMobileOpen(true)}
|
||||
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}
|
||||
MenuListProps={{
|
||||
'aria-labelledby': 'basic-button',
|
||||
}}
|
||||
>
|
||||
<MenuItem>
|
||||
<Typography variant='h5'>
|
||||
<b>{user.username}</b>
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<Link href='/dashboard/manage'>
|
||||
<a style={{ color: 'white', textDecoration: 'none' }}>
|
||||
<MenuItem onClick={handleClose(null)}>
|
||||
<AccountIcon sx={{ mr: 2 }} /> Manage Account
|
||||
</MenuItem>
|
||||
</a>
|
||||
</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'>
|
||||
<a style={{ color: 'white', textDecoration: 'none' }}>
|
||||
<MenuItem onClick={handleClose(null)}>
|
||||
<LogoutIcon sx={{ mr: 2 }} /> Logout
|
||||
</MenuItem>
|
||||
</a>
|
||||
</Link>
|
||||
</Menu>
|
||||
</Box>
|
||||
)}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
</Toolbar>
|
||||
<Divider />
|
||||
<List>
|
||||
{items.map((item, i) => (
|
||||
<Link key={i} href={item.link}>
|
||||
<a href={item.link} style={{ color: 'white', textDecoration: 'none' }}>
|
||||
<ListItem button>
|
||||
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||
<ListItemText primary={item.text} />
|
||||
</ListItem>
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
{user && user.administrator && (
|
||||
<Link href='/dashboard/users' passHref>
|
||||
<a style={{ color: 'white', textDecoration: 'none' }}>
|
||||
<ListItem button>
|
||||
<ListItemIcon><UsersIcon /></ListItemIcon>
|
||||
<ListItemText primary='Users' />
|
||||
</ListItem>
|
||||
</a>
|
||||
</Link>
|
||||
)}
|
||||
</List>
|
||||
|
||||
</div>
|
||||
);
|
||||
|
||||
const container = typeof window !== 'undefined' ? window.document.body : undefined;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<Backdrop open={loading} />
|
||||
|
||||
<AppBar
|
||||
position='fixed'
|
||||
elevation={0}
|
||||
sx={{
|
||||
width: { sm: `calc(100% - ${drawerWidth}px)` },
|
||||
ml: { sm: `${drawerWidth}px` }
|
||||
}}
|
||||
>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
color='inherit'
|
||||
aria-label='open drawer'
|
||||
edge='start'
|
||||
onClick={() => setMobileOpen(true)}
|
||||
sx={{ mr: 2, display: { sm: 'none' } }}
|
||||
>
|
||||
<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}
|
||||
MenuListProps={{
|
||||
'aria-labelledby': 'basic-button',
|
||||
}}
|
||||
>
|
||||
<MenuItem>
|
||||
<Typography variant='h5'>
|
||||
<b>{user.username}</b>
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<Link href='/dash/manage'>
|
||||
<a style={{ color: 'white', textDecoration: 'none' }}>
|
||||
<MenuItem onClick={handleClose(null)}>
|
||||
<AccountIcon sx={{ mr: 2 }} /> Manage Account
|
||||
</MenuItem>
|
||||
</a>
|
||||
</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'>
|
||||
<a style={{ color: 'white', textDecoration: 'none' }}>
|
||||
<MenuItem onClick={handleClose(null)}>
|
||||
<LogoutIcon sx={{ mr: 2 }} /> Logout
|
||||
</MenuItem>
|
||||
</a>
|
||||
</Link>
|
||||
</Menu>
|
||||
</Box>
|
||||
)}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Box
|
||||
component='nav'
|
||||
sx={{
|
||||
width: { sm: drawerWidth },
|
||||
flexShrink: { sm: 0 }
|
||||
}}
|
||||
>
|
||||
<Drawer
|
||||
container={container}
|
||||
variant='temporary'
|
||||
onClose={() => setMobileOpen(false)}
|
||||
open={mobileOpen}
|
||||
elevation={0}
|
||||
ModalProps={{
|
||||
keepMounted: true
|
||||
}}
|
||||
sx={{
|
||||
display: { xs: 'block', sm: 'none' },
|
||||
'* .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth }
|
||||
}}
|
||||
>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
<Drawer
|
||||
variant='permanent'
|
||||
sx={{
|
||||
display: { xs: 'none', sm: 'block' },
|
||||
'* .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth }
|
||||
}}
|
||||
open
|
||||
>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
</Box>
|
||||
<Box component='main' sx={{ flexGrow: 1, p: 3, mt: 8 }}>
|
||||
{user && noPaper ? children : (
|
||||
<Paper elevation={0} sx={{ p: 2 }} variant='outlined'>
|
||||
{children}
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
75
src/components/Link.tsx
Normal file
75
src/components/Link.tsx
Normal file
|
@ -0,0 +1,75 @@
|
|||
/// https://github.com/mui-org/material-ui/blob/next/examples/nextjs/src/Link.js
|
||||
/* eslint-disable jsx-a11y/anchor-has-content */
|
||||
import React, { forwardRef } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useRouter } from 'next/router';
|
||||
import NextLink from 'next/link';
|
||||
import MuiLink from '@material-ui/core/Link';
|
||||
|
||||
export const NextLinkComposed = forwardRef(function NextLinkComposed(props: any, ref) {
|
||||
const { to, linkAs, href, replace, scroll, passHref, shallow, prefetch, locale, ...other } =
|
||||
props;
|
||||
|
||||
return (
|
||||
<NextLink
|
||||
href={to}
|
||||
prefetch={prefetch}
|
||||
as={linkAs}
|
||||
replace={replace}
|
||||
scroll={scroll}
|
||||
shallow={shallow}
|
||||
passHref={passHref}
|
||||
locale={locale}
|
||||
>
|
||||
<a ref={ref} {...other} />
|
||||
</NextLink>
|
||||
);
|
||||
});
|
||||
|
||||
// A styled version of the Next.js Link component:
|
||||
// https://nextjs.org/docs/#with-link
|
||||
const Link = forwardRef(function Link(props: any, ref) {
|
||||
const {
|
||||
activeClassName = 'active',
|
||||
as: linkAs,
|
||||
className: classNameProps,
|
||||
href,
|
||||
noLinkStyle,
|
||||
role, // Link don't have roles.
|
||||
...other
|
||||
} = props;
|
||||
|
||||
const router = useRouter();
|
||||
const pathname = typeof href === 'string' ? href : href.pathname;
|
||||
const className = clsx(classNameProps, {
|
||||
[activeClassName]: router.pathname === pathname && activeClassName,
|
||||
});
|
||||
|
||||
const isExternal =
|
||||
typeof href === 'string' && (href.indexOf('http') === 0 || href.indexOf('mailto:') === 0);
|
||||
|
||||
if (isExternal) {
|
||||
if (noLinkStyle) {
|
||||
return <a className={className} href={href} ref={ref} {...other} />;
|
||||
}
|
||||
|
||||
return <MuiLink className={className} href={href} ref={ref} {...other} />;
|
||||
}
|
||||
|
||||
if (noLinkStyle) {
|
||||
return <NextLinkComposed className={className} ref={ref} to={href} {...other} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<MuiLink
|
||||
component={NextLinkComposed}
|
||||
linkAs={linkAs}
|
||||
className={className}
|
||||
ref={ref}
|
||||
to={href}
|
||||
{...other}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default Link;
|
230
src/components/pages/Dashboard.tsx
Normal file
230
src/components/pages/Dashboard.tsx
Normal file
|
@ -0,0 +1,230 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TablePagination,
|
||||
TableRow,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Typography,
|
||||
Grid
|
||||
} from '@material-ui/core';
|
||||
|
||||
import Link from 'components/Link';
|
||||
import Card from 'components/Card';
|
||||
import Backdrop from 'components/Backdrop';
|
||||
import useFetch from 'lib/hooks/useFetch';
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
|
||||
type Aligns = 'inherit' | 'right' | 'left' | 'center' | 'justify';
|
||||
|
||||
export function bytesToRead(bytes: number) {
|
||||
if (isNaN(bytes)) 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]}`;
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ id: 'file', label: 'Name', minWidth: 170, align: 'inherit' as Aligns },
|
||||
{ id: 'mimetype', label: 'Type', minWidth: 100, align: 'inherit' as Aligns },
|
||||
{
|
||||
id: 'created_at',
|
||||
label: 'Date',
|
||||
minWidth: 170,
|
||||
align: 'right' as Aligns,
|
||||
format: (value) => new Date(value).toLocaleString(),
|
||||
}
|
||||
];
|
||||
|
||||
function StatText({ children }) {
|
||||
return <Typography variant='h5' color='GrayText'>{children}</Typography>;
|
||||
}
|
||||
|
||||
function StatTable({ rows, columns }) {
|
||||
return (
|
||||
<TableContainer sx={{ pt: 1 }}>
|
||||
<Table sx={{ minWidth: 100 }} size='small'>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{columns.map(col => (
|
||||
<TableCell key={col.name}>{col.name}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{rows.map((row, i) => (
|
||||
<TableRow
|
||||
key={row.username}
|
||||
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
|
||||
>
|
||||
{columns.map(col => (
|
||||
<TableCell key={col.id}>
|
||||
{col.format ? col.format(row[col.id]) : row[col.id]}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const user = useStoreSelector(state => state.user);
|
||||
|
||||
const [images, setImages] = useState([]);
|
||||
const [page, setPage] = useState(0);
|
||||
const [stats, setStats] = useState(null);
|
||||
const [apiLoading, setApiLoading] = useState(true);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(10);
|
||||
|
||||
const updateImages = async () => {
|
||||
setApiLoading(true);
|
||||
|
||||
const imgs = await useFetch('/api/user/images');
|
||||
const stts = await useFetch('/api/stats');
|
||||
setImages(imgs);
|
||||
setStats(stts);
|
||||
|
||||
setApiLoading(false);
|
||||
};
|
||||
|
||||
const handleChangePage = (event, newPage) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
const handleChangeRowsPerPage = (event) => {
|
||||
setRowsPerPage(+event.target.value);
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const handleDelete = async image => {
|
||||
const res = await useFetch('/api/user/images', 'DELETE', { id: image.id });
|
||||
if (!res.error) updateImages();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateImages();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Backdrop open={apiLoading} />
|
||||
<Typography variant='h4'>Welcome back {user?.username}</Typography>
|
||||
<Typography color='GrayText' pb={2}>You have <b>{images.length}</b> images</Typography>
|
||||
|
||||
<Typography variant='h4'>Stats</Typography>
|
||||
{stats && (
|
||||
<Grid container spacing={4} py={2}>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Card name='Size' sx={{ height: '100%' }}>
|
||||
<StatText>{stats.size}</StatText>
|
||||
<Typography variant='h3'>Average Size</Typography>
|
||||
<StatText>{bytesToRead(stats.size / stats.count)}</StatText>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Card name='Images' sx={{ height: '100%' }}>
|
||||
<StatText>{stats.count}</StatText>
|
||||
<Typography variant='h3'>Views</Typography>
|
||||
<StatText>{stats.views_count} ({isNaN(stats.views_count / stats.count) ? '0' : stats.views_count / stats.count})</StatText>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Card name='Users' sx={{ height: '100%' }}>
|
||||
<StatText>{stats.count_users}</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 }}
|
||||
>
|
||||
{column.label}
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell sx={{ minWidth: 200 }} align='right'>
|
||||
Actions
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{images
|
||||
.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
|
||||
.map((row) => {
|
||||
return (
|
||||
<TableRow hover role='checkbox' tabIndex={-1} key={row.id}>
|
||||
{columns.map((column) => {
|
||||
const value = row[column.id];
|
||||
return (
|
||||
<TableCell key={column.id} align={column.align}>
|
||||
{column.format ? column.format(value) : value}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
<TableCell align='right'>
|
||||
<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 name='Images per User' sx={{ height: '100%', my: 2 }} elevation={0} variant='outlined'>
|
||||
<StatTable
|
||||
columns={[
|
||||
{ id: 'username', name: 'Name' },
|
||||
{ id: 'count', name: 'Images' }
|
||||
]}
|
||||
rows={stats.count_by_user}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card name='Types' sx={{ height: '100%', my: 2 }} elevation={0} variant='outlined'>
|
||||
<StatTable
|
||||
columns={[
|
||||
{ id: 'mimetype', name: 'Type' },
|
||||
{ id: 'count', name: 'Count' }
|
||||
]}
|
||||
rows={stats.types_count}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
61
src/components/pages/Images.tsx
Normal file
61
src/components/pages/Images.tsx
Normal file
|
@ -0,0 +1,61 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { Grid, Pagination, Box, Typography } from '@material-ui/core';
|
||||
|
||||
import Backdrop from 'components/Backdrop';
|
||||
import ZiplineImage from 'components/Image';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
|
||||
export default function Upload() {
|
||||
const user = useStoreSelector(state => state.user);
|
||||
|
||||
const [pages, setPages] = useState([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const updatePages = async () => {
|
||||
setLoading(true);
|
||||
const pages = await useFetch('/api/user/images?paged=true');
|
||||
setPages(pages);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updatePages();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Backdrop open={loading}/>
|
||||
{!pages.length && (
|
||||
<Box
|
||||
display='flex'
|
||||
justifyContent='center'
|
||||
alignItems='center'
|
||||
pt={2}
|
||||
pb={3}
|
||||
>
|
||||
<Typography variant='h4'>No Images</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Grid container spacing={2}>
|
||||
{pages.length ? pages[(page - 1) ?? 0].map(image => (
|
||||
<Grid item xs={12} sm={3} key={image.id}>
|
||||
<ZiplineImage image={image} updateImages={updatePages} />
|
||||
</Grid>
|
||||
)) : null}
|
||||
</Grid>
|
||||
|
||||
{pages.length ? (
|
||||
<Box
|
||||
display='flex'
|
||||
justifyContent='center'
|
||||
alignItems='center'
|
||||
pt={2}
|
||||
>
|
||||
<Pagination count={pages.length} page={page} onChange={(_, v) => setPage(v)}/>
|
||||
</Box>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
112
src/components/pages/Manage.tsx
Normal file
112
src/components/pages/Manage.tsx
Normal file
|
@ -0,0 +1,112 @@
|
|||
import React, { useState } from 'react';
|
||||
import { TextField, Button, Box, Typography } from '@material-ui/core';
|
||||
|
||||
import { useFormik } from 'formik';
|
||||
import * as yup from 'yup';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import Backdrop from 'components/Backdrop';
|
||||
import Alert from 'components/Alert';
|
||||
import { useStoreDispatch, useStoreSelector } from 'lib/redux/store';
|
||||
import { updateUser } from 'lib/redux/reducers/user';
|
||||
|
||||
const validationSchema = yup.object({
|
||||
username: yup
|
||||
.string()
|
||||
.required('Username is required')
|
||||
});
|
||||
|
||||
function TextInput({ id, label, formik, ...other }) {
|
||||
return (
|
||||
<TextField
|
||||
id={id}
|
||||
name={id}
|
||||
label={label}
|
||||
value={formik.values[id]}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.touched[id] && Boolean(formik.errors[id])}
|
||||
helperText={formik.touched[id] && formik.errors[id]}
|
||||
variant='standard'
|
||||
fullWidth
|
||||
sx={{ pb: 0.5 }}
|
||||
{...other}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Manage() {
|
||||
const user = useStoreSelector(state => state.user);
|
||||
const dispatch = useStoreDispatch();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [severity, setSeverity] = useState('success');
|
||||
const [message, setMessage] = useState('Saved');
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
username: user.username,
|
||||
password: '',
|
||||
embedTitle: user.embedTitle ?? '',
|
||||
embedColor: user.embedColor
|
||||
},
|
||||
validationSchema,
|
||||
onSubmit: async values => {
|
||||
const cleanUsername = values.username.trim();
|
||||
const cleanPassword = values.password.trim();
|
||||
const cleanEmbedTitle = values.embedTitle.trim();
|
||||
const cleanEmbedColor = values.embedColor.trim();
|
||||
|
||||
if (cleanUsername === '') return formik.setFieldError('username', 'Username can\'t be nothing');
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const data = {
|
||||
username: cleanUsername,
|
||||
password: cleanPassword === '' ? null : cleanPassword,
|
||||
embedTitle: cleanEmbedTitle === '' ? null : cleanEmbedTitle,
|
||||
embedColor: cleanEmbedColor === '' ? null : cleanEmbedColor
|
||||
};
|
||||
|
||||
const newUser = await useFetch('/api/user', 'PATCH', data);
|
||||
|
||||
if (newUser.error) {
|
||||
setLoading(false);
|
||||
setMessage('An error occured');
|
||||
setSeverity('error');
|
||||
setOpen(true);
|
||||
} else {
|
||||
dispatch(updateUser(newUser));
|
||||
setLoading(false);
|
||||
setMessage('Saved user');
|
||||
setSeverity('success');
|
||||
setOpen(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Backdrop open={loading}/>
|
||||
<Alert open={open} setOpen={setOpen} message={message} severity={severity} />
|
||||
|
||||
<Typography variant='h4' pb={2}>Manage User</Typography>
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<TextInput id='username' label='Username' formik={formik} />
|
||||
<TextInput id='password' label='Password' formik={formik} type='password' />
|
||||
<TextInput id='embedTitle' label='Embed Title' formik={formik} />
|
||||
<TextInput id='embedColor' label='Embed Color' formik={formik} />
|
||||
<Box
|
||||
display='flex'
|
||||
justifyContent='right'
|
||||
alignItems='right'
|
||||
pt={2}
|
||||
>
|
||||
<Button
|
||||
variant='contained'
|
||||
type='submit'
|
||||
>Save</Button>
|
||||
</Box>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
89
src/components/pages/Upload.tsx
Normal file
89
src/components/pages/Upload.tsx
Normal file
|
@ -0,0 +1,89 @@
|
|||
import React, { 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 useFetch from 'hooks/useFetch';
|
||||
import Backdrop from 'components/Backdrop';
|
||||
import Alert from 'components/Alert';
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import CenteredBox from 'components/CenteredBox';
|
||||
|
||||
export default function Manage({ route }) {
|
||||
const user = useStoreSelector(state => state.user);
|
||||
|
||||
const [file, setFile] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [severity, setSeverity] = useState('success');
|
||||
const [message, setMessage] = useState('Saved');
|
||||
|
||||
const handleUpload = async () => {
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': user.token
|
||||
},
|
||||
body
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setOpen(true);
|
||||
setSeverity('success');
|
||||
setMessage(`File uploaded! ${window.location.protocol}//${window.location.host}${route}/${await res.text()}`);
|
||||
} else {
|
||||
const json = await res.json();
|
||||
setOpen(true);
|
||||
setSeverity('error');
|
||||
setMessage('Could not upload file: ' + json.error);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Backdrop open={loading}/>
|
||||
<Alert open={open} setOpen={setOpen} message={message} severity={severity} />
|
||||
|
||||
<Typography variant='h4' pb={2}>Upload file</Typography>
|
||||
<Dropzone onDrop={acceptedFiles => setFile(acceptedFiles[0])}>
|
||||
{({getRootProps, getInputProps}) => (
|
||||
<CardActionArea>
|
||||
<Paper
|
||||
elevation={0}
|
||||
variant='outlined'
|
||||
sx={{
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
display: 'block',
|
||||
p: 5
|
||||
}}
|
||||
{...getRootProps()}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<CenteredBox><UploadIcon sx={{ fontSize: 100 }} /></CenteredBox>
|
||||
<CenteredBox><Typography variant='h5'>Drag an image or click to upload an image.</Typography></CenteredBox>
|
||||
<CenteredBox><Typography variant='h6'>{file && file.name}</Typography></CenteredBox>
|
||||
</Paper>
|
||||
</CardActionArea>
|
||||
)}
|
||||
</Dropzone>
|
||||
|
||||
<Box
|
||||
display='flex'
|
||||
justifyContent='right'
|
||||
alignItems='right'
|
||||
pt={2}
|
||||
>
|
||||
<Button
|
||||
variant='contained'
|
||||
onClick={handleUpload}
|
||||
>Upload</Button>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
187
src/components/pages/Users.tsx
Normal file
187
src/components/pages/Users.tsx
Normal file
|
@ -0,0 +1,187 @@
|
|||
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 Backdrop from 'components/Backdrop';
|
||||
import Alert from 'components/Alert';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useFormik } from 'formik';
|
||||
|
||||
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 }) {
|
||||
return (
|
||||
<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: {
|
||||
username: '',
|
||||
password: '',
|
||||
administrator: false
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
const cleanUsername = values.username.trim();
|
||||
const cleanPassword = values.password.trim();
|
||||
if (cleanUsername === '') return formik.setFieldError('username', 'Username can\'t be nothing');
|
||||
if (cleanPassword === '') return formik.setFieldError('password', 'Password can\'t be nothing');
|
||||
|
||||
const data = {
|
||||
username: cleanUsername,
|
||||
password: cleanPassword,
|
||||
administrator: values.administrator
|
||||
};
|
||||
|
||||
setOpen(false);
|
||||
setLoading(true);
|
||||
const res = await useFetch('/api/auth/create', 'POST', data);
|
||||
if (res.error) {
|
||||
setSeverity('error');
|
||||
setMessage('Could\'nt create user: ' + res.error);
|
||||
setAlertOpen(true);
|
||||
} else {
|
||||
setSeverity('success');
|
||||
setMessage('Created user ' + res.username);
|
||||
setAlertOpen(true);
|
||||
updateUsers();
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
PaperProps={{
|
||||
elevation: 1
|
||||
}}
|
||||
>
|
||||
<DialogTitle>
|
||||
Create User
|
||||
</DialogTitle>
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<DialogContent>
|
||||
<TextInput id='username' label='Username' formik={formik} />
|
||||
<TextInput id='password' label='Password' formik={formik} type='password' />
|
||||
<FormControlLabel
|
||||
id='administrator'
|
||||
name='administrator'
|
||||
value={formik.values.administrator}
|
||||
onChange={formik.handleChange}
|
||||
control={<Switch />}
|
||||
label='Administrator?'
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOpen(false)} color='inherit' autoFocus>Cancel</Button>
|
||||
<Button type='submit' color='inherit'>
|
||||
Create
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Users() {
|
||||
const user = useStoreSelector(state => state.user);
|
||||
const router = useRouter();
|
||||
|
||||
const [users, setUsers] = useState([]);
|
||||
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 () => {
|
||||
setLoading(true);
|
||||
const us = await useFetch('/api/users');
|
||||
if (!us.error) {
|
||||
setUsers(us);
|
||||
} else {
|
||||
router.push('/dashboard');
|
||||
};
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleDelete = async (user) => {
|
||||
const res = await useFetch('/api/users', 'DELETE', {
|
||||
id: user.id
|
||||
});
|
||||
if (res.error) {
|
||||
setMessage(`Could not delete ${user.username}`);
|
||||
setSeverity('error');
|
||||
setOpen(true);
|
||||
} else {
|
||||
setMessage(`Deleted user ${res.username}`);
|
||||
setSeverity('success');
|
||||
setOpen(true);
|
||||
updateUsers();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateUsers();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Backdrop open={loading}/>
|
||||
<Alert open={open} setOpen={setOpen} message={message} severity={severity} />
|
||||
<CreateUserDialog open={createOpen} setOpen={setCreateOpen} setSeverity={setSeverity} setMessage={setMessage} setLoading={setLoading} updateUsers={updateUsers} setAlertOpen={setOpen} />
|
||||
<Typography variant='h4' pb={2}>Users <IconButton onClick={() => setCreateOpen(true)}><AddIcon /></IconButton></Typography>
|
||||
<Grid container spacing={2}>
|
||||
{users.filter(x => x.username !== user.username).map((user, i) => (
|
||||
<Grid item xs={12} sm={3} key={i}>
|
||||
<Card user={user} handleDelete={handleDelete}/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
}
|
6
src/lib/config.ts
Normal file
6
src/lib/config.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import type { Config } from './types';
|
||||
import readConfig from './readConfig';
|
||||
|
||||
if (!global.config) global.config = readConfig() as Config;
|
||||
|
||||
export default global.config;
|
12
src/lib/hooks/useFetch.ts
Normal file
12
src/lib/hooks/useFetch.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
export default async function useFetch(url: string, method: 'GET' | 'POST' | 'PATCH' | 'DELETE' = 'GET', body: Record<string, any> = null) {
|
||||
const headers = {};
|
||||
if (body) headers['content-type'] = 'application/json';
|
||||
|
||||
const res = await global.fetch(url, {
|
||||
body: body ? JSON.stringify(body) : null,
|
||||
method,
|
||||
headers
|
||||
});
|
||||
|
||||
return res.json();
|
||||
}
|
36
src/lib/hooks/useLogin.ts
Normal file
36
src/lib/hooks/useLogin.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { updateUser, User } from 'lib/redux/reducers/user';
|
||||
import { useStoreDispatch, useStoreSelector } from 'lib/redux/store';
|
||||
import useFetch from './useFetch';
|
||||
|
||||
export default function login() {
|
||||
const router = useRouter();
|
||||
const dispatch = useStoreDispatch();
|
||||
const userState = useStoreSelector(s => s.user);
|
||||
|
||||
const [user, setUser] = useState<User>(userState);
|
||||
const [loading, setLoading] = useState(!userState);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
const res = await useFetch('/api/user');
|
||||
|
||||
if (res.error) return router.push('/auth/login');
|
||||
|
||||
dispatch(updateUser(res));
|
||||
|
||||
setUser(res);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && user) {
|
||||
return;
|
||||
}
|
||||
|
||||
load();
|
||||
}, []);
|
||||
|
||||
return { loading, user };
|
||||
}
|
44
src/lib/logger.js
Normal file
44
src/lib/logger.js
Normal file
|
@ -0,0 +1,44 @@
|
|||
const { format } = require('fecha');
|
||||
const { yellow, blueBright, magenta, red, cyan } = require('colorette');
|
||||
|
||||
class Logger {
|
||||
static get(clas) {
|
||||
if (typeof clas !== 'function') if (typeof clas !== 'string') throw new Error('not string/function');
|
||||
|
||||
const name = clas.name ?? clas;
|
||||
|
||||
return new Logger(name);
|
||||
}
|
||||
|
||||
constructor (name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
info(message) {
|
||||
console.log(this.formatMessage('INFO', this.name, message));
|
||||
}
|
||||
|
||||
error(error) {
|
||||
console.log(this.formatMessage('ERROR', this.name, error.toString()));
|
||||
}
|
||||
|
||||
formatMessage(level, name, message) {
|
||||
const time = format(new Date(), 'YYYY-MM-DD hh:mm:ss,SSS A');
|
||||
return `${time} ${this.formatLevel(level)} [${blueBright(name)}] ${message}`;
|
||||
}
|
||||
|
||||
formatLevel(level) {
|
||||
switch (level) {
|
||||
case 'INFO':
|
||||
return cyan('INFO ');
|
||||
case 'DEBUG':
|
||||
return yellow('DEBUG');
|
||||
case 'WARN':
|
||||
return magenta('WARN ');
|
||||
case 'ERROR':
|
||||
return red('ERROR');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Logger;
|
109
src/lib/middleware/withZipline.ts
Normal file
109
src/lib/middleware/withZipline.ts
Normal file
|
@ -0,0 +1,109 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import type { CookieSerializeOptions } from 'cookie';
|
||||
import type { User } from '@prisma/client';
|
||||
|
||||
import { serialize } from 'cookie';
|
||||
import { sign64, unsign64 } from '../util';
|
||||
import config from 'lib/config';
|
||||
import prisma from 'lib/prisma';
|
||||
|
||||
export type NextApiReq = NextApiRequest & {
|
||||
user: () => Promise<User | null | void>;
|
||||
getCookie: (name: string) => string | null;
|
||||
cleanCookie: (name: string) => void;
|
||||
}
|
||||
|
||||
export type NextApiRes = NextApiResponse & {
|
||||
error: (message: string) => void;
|
||||
forbid: (message: string) => void;
|
||||
bad: (message: string) => void;
|
||||
json: (json: any) => void;
|
||||
setCookie: (name: string, value: unknown, options: CookieSerializeOptions) => void;
|
||||
}
|
||||
|
||||
export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse) => unknown) => (req: NextApiReq, res: NextApiRes) => {
|
||||
res.error = (message: string) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.json({
|
||||
error: message
|
||||
});
|
||||
};
|
||||
|
||||
res.forbid = (message: string) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.status(403);
|
||||
res.json({
|
||||
error: '403: ' + message
|
||||
});
|
||||
};
|
||||
|
||||
res.bad = (message: string) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.status(401);
|
||||
res.json({
|
||||
error: '403: ' + message
|
||||
});
|
||||
};
|
||||
|
||||
res.json = (json: any) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
|
||||
res.end(JSON.stringify(json));
|
||||
};
|
||||
|
||||
req.getCookie = (name: string) => {
|
||||
const cookie = req.cookies[name];
|
||||
if (!cookie) return null;
|
||||
|
||||
const unsigned = unsign64(cookie, config.core.secret);
|
||||
return unsigned ? unsigned : null;
|
||||
};
|
||||
req.cleanCookie = (name: string) => {
|
||||
res.setHeader('Set-Cookie', serialize(name, '', {
|
||||
path: '/',
|
||||
expires: new Date(1),
|
||||
maxAge: undefined
|
||||
}));
|
||||
};
|
||||
req.user = async () => {
|
||||
try {
|
||||
const userId = req.getCookie('user');
|
||||
if (!userId) return null;
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: Number(userId)
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) return null;
|
||||
return user;
|
||||
} catch (e) {
|
||||
if (e.code && e.code === 'ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH') {
|
||||
req.cleanCookie('user');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
res.setCookie = (name: string, value: unknown, options?: CookieSerializeOptions) => setCookie(res, name, value, options || {});
|
||||
|
||||
return handler(req, res);
|
||||
};
|
||||
|
||||
export const setCookie = (
|
||||
res: NextApiResponse,
|
||||
name: string,
|
||||
value: unknown,
|
||||
options: CookieSerializeOptions = {}
|
||||
) => {
|
||||
|
||||
if ('maxAge' in options) {
|
||||
options.expires = new Date(Date.now() + options.maxAge);
|
||||
options.maxAge /= 1000;
|
||||
}
|
||||
|
||||
const signed = sign64(String(value), config.core.secret);
|
||||
|
||||
res.setHeader('Set-Cookie', serialize(name, signed, options));
|
||||
};
|
5
src/lib/prisma.ts
Normal file
5
src/lib/prisma.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
if (!global.prisma) global.prisma = new PrismaClient();
|
||||
|
||||
export default global.prisma;
|
83
src/lib/readConfig.js
Normal file
83
src/lib/readConfig.js
Normal file
|
@ -0,0 +1,83 @@
|
|||
const { existsSync, readFileSync } = require('fs');
|
||||
const { join } = require('path');
|
||||
const Logger = require('./logger');
|
||||
|
||||
const e = (val, type, fn) => ({ val, type, fn });
|
||||
|
||||
const envValues = [
|
||||
e('SECURE', 'boolean', (c, v) => c.core.secure = v),
|
||||
e('SECRET', 'string', (c, v) => c.core.secret = v),
|
||||
e('HOST', 'string', (c, v) => c.core.host = v),
|
||||
e('PORT', 'number', (c, v) => c.core.port = v),
|
||||
e('DATABASE_TYPE', 'string', (c, v) => c.database.type = v),
|
||||
e('DATABASE_URL', 'string', (c, v) => c.database.url = v),
|
||||
e('UPLOADER_ROUTE', 'string', (c, v) => c.uploader.route = v),
|
||||
e('UPLOADER_EMBED_ROUTE', 'string', (c, v) => c.uploader.embed_route = v),
|
||||
e('UPLOADER_LENGTH', 'number', (c, v) => c.uploader.length = v),
|
||||
e('UPLOADER_DIRECTORY', 'string', (c, v) => c.uploader.directory = v)
|
||||
];
|
||||
|
||||
module.exports = () => {
|
||||
if (!existsSync(join(process.cwd(), 'config.toml'))) {
|
||||
Logger.get('config').info('reading environment');
|
||||
return tryReadEnv();
|
||||
} else {
|
||||
Logger.get('config').info('reading config file');
|
||||
const str = readFileSync(join(process.cwd(), 'config.toml'), 'utf8');
|
||||
const parsed = require('@iarna/toml/parse-string')(str);
|
||||
|
||||
return parsed;
|
||||
}
|
||||
};
|
||||
|
||||
function tryReadEnv() {
|
||||
const config = {
|
||||
core: {
|
||||
secure: undefined,
|
||||
secret: undefined,
|
||||
host: undefined,
|
||||
port: undefined
|
||||
},
|
||||
database: {
|
||||
type: undefined,
|
||||
url: undefined
|
||||
},
|
||||
uploader: {
|
||||
route: undefined,
|
||||
embed_route: undefined,
|
||||
length: undefined,
|
||||
directory: undefined
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0, L = envValues.length; i !== L; ++i) {
|
||||
const envValue = envValues[i];
|
||||
let value = process.env[envValue.val];
|
||||
|
||||
if (!value) {
|
||||
Logger.get('config').error('there is no config file or required environment variables... exiting...');
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
envValues[i].fn(config, value);
|
||||
if (envValue.type === 'number') value = parseToNumber(value);
|
||||
else if (envValue.type === 'boolean') value = parseToBoolean(value);
|
||||
envValues[i].fn(config, value);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
function parseToNumber(value) {
|
||||
// infer that it is a string since env values are only strings
|
||||
const number = Number(value);
|
||||
if (isNaN(number)) return undefined;
|
||||
return number;
|
||||
}
|
||||
|
||||
function parseToBoolean(value) {
|
||||
// infer that it is a string since env values are only strings
|
||||
if (!value || value === 'false') return false;
|
||||
else return true;
|
||||
}
|
4
src/lib/redux/reducers.ts
Normal file
4
src/lib/redux/reducers.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
import { combineReducers } from 'redux';
|
||||
import user from './reducers/user';
|
||||
|
||||
export default combineReducers({ user });
|
25
src/lib/redux/reducers/user.ts
Normal file
25
src/lib/redux/reducers/user.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
export interface User {
|
||||
username: string;
|
||||
token: string;
|
||||
embedTitle: string;
|
||||
embedColor: string;
|
||||
}
|
||||
|
||||
const initialState: User = null;
|
||||
|
||||
const user = createSlice({
|
||||
name: 'user',
|
||||
initialState,
|
||||
reducers: {
|
||||
updateUser(state, action: PayloadAction<User>) {
|
||||
state = action.payload;
|
||||
return state;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { updateUser } = user.actions;
|
||||
|
||||
export default user.reducer;
|
54
src/lib/redux/store.ts
Normal file
54
src/lib/redux/store.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
// https://github.com/mikecao/umami/blob/master/redux/store.js
|
||||
import { useMemo } from 'react';
|
||||
import { Action, CombinedState, configureStore, EnhancedStore } from '@reduxjs/toolkit';
|
||||
import thunk, { ThunkAction } from 'redux-thunk';
|
||||
import rootReducer from './reducers';
|
||||
import { User } from './reducers/user';
|
||||
import { useDispatch, TypedUseSelectorHook, useSelector } from 'react-redux';
|
||||
|
||||
let store: EnhancedStore<CombinedState<{
|
||||
user: User;
|
||||
}>>;
|
||||
|
||||
export function getStore(preloadedState) {
|
||||
return configureStore({
|
||||
reducer: rootReducer,
|
||||
middleware: [thunk],
|
||||
preloadedState,
|
||||
});
|
||||
}
|
||||
|
||||
export const initializeStore = preloadedState => {
|
||||
let _store = store ?? getStore(preloadedState);
|
||||
|
||||
if (preloadedState && store) {
|
||||
_store = getStore({
|
||||
...store.getState(),
|
||||
...preloadedState,
|
||||
});
|
||||
store = undefined;
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') return _store;
|
||||
if (!store) store = _store;
|
||||
|
||||
return _store;
|
||||
};
|
||||
|
||||
export function useStore(initialState?: User) {
|
||||
return useMemo(() => initializeStore(initialState), [initialState]);
|
||||
}
|
||||
|
||||
export type AppState = ReturnType<typeof store.getState>
|
||||
|
||||
export type AppDispatch = typeof store.dispatch
|
||||
|
||||
export type AppThunk<ReturnType = void> = ThunkAction<
|
||||
ReturnType,
|
||||
AppState,
|
||||
unknown,
|
||||
Action<User>
|
||||
>
|
||||
|
||||
export const useStoreDispatch = () => useDispatch<AppDispatch>();
|
||||
export const useStoreSelector: TypedUseSelectorHook<AppState> = useSelector;
|
15
src/lib/themes/dark_blue.ts
Normal file
15
src/lib/themes/dark_blue.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import createTheme from '.';
|
||||
|
||||
export default createTheme({
|
||||
type: 'dark',
|
||||
primary: '#2c39a6',
|
||||
secondary: '#7344e2',
|
||||
error: '#ff4141',
|
||||
warning: '#ff9800',
|
||||
info: '#2f6fb9',
|
||||
border: '#1b2541',
|
||||
background: {
|
||||
main: '#05070f',
|
||||
paper: '#0c101c'
|
||||
}
|
||||
});
|
54
src/lib/themes/index.ts
Normal file
54
src/lib/themes/index.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
import { createTheme as muiCreateTheme } from '@material-ui/core/styles';
|
||||
|
||||
export interface ThemeOptions {
|
||||
type: 'dark' | 'light';
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
38
src/lib/types.ts
Normal file
38
src/lib/types.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
export interface ConfigCore {
|
||||
// Whether to return http or https links
|
||||
secure: boolean;
|
||||
|
||||
// Used for signing of cookies and other stuff
|
||||
secret: string;
|
||||
|
||||
// The host Zipline will run on
|
||||
host: string;
|
||||
|
||||
// The port Zipline will run on
|
||||
port: number;
|
||||
}
|
||||
|
||||
export interface ConfigDatabase {
|
||||
type: 'psql' | 'mysql' | 'sqlite';
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ConfigUploader {
|
||||
// The route uploads will be served on
|
||||
route: string;
|
||||
|
||||
// The route embedded routes will be served on
|
||||
embed_route: string;
|
||||
|
||||
// Length of random chars to generate for file names
|
||||
length: number;
|
||||
|
||||
// Where uploads are stored
|
||||
directory: string;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
core: ConfigCore;
|
||||
database: ConfigDatabase;
|
||||
uploader: ConfigUploader;
|
||||
}
|
120
src/lib/util.ts
Normal file
120
src/lib/util.ts
Normal file
|
@ -0,0 +1,120 @@
|
|||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import { hash, verify } from 'argon2';
|
||||
import { readdir, stat } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import prisma from './prisma';
|
||||
|
||||
export async function hashPassword(s: string): Promise<string> {
|
||||
return await hash(s);
|
||||
}
|
||||
|
||||
export function checkPassword(s: string, hash: string): Promise<boolean> {
|
||||
return verify(hash, s);
|
||||
}
|
||||
|
||||
export function randomChars(length: number) {
|
||||
const charset = 'QWERTYUIOPASDFGHJKLZXCVBNMqwertyuiopasdfghjklzxcvbnm1234567890';
|
||||
|
||||
let res = '';
|
||||
for (let i = 0; i !== length; ++i) res += charset[Math.floor(Math.random() * charset.length)];
|
||||
return res;
|
||||
}
|
||||
|
||||
export function createToken() {
|
||||
return randomChars(24) + '.' + Buffer.from(Date.now().toString()).toString('base64url');
|
||||
}
|
||||
|
||||
export function sign(value: string, secret: string): string {
|
||||
const signed = value + ':' + createHmac('sha256', secret)
|
||||
.update(value)
|
||||
.digest('base64')
|
||||
.replace(/=+$/, '');
|
||||
|
||||
return signed;
|
||||
}
|
||||
|
||||
export function unsign(value: string, secret: string): string {
|
||||
const str = value.slice(0, value.lastIndexOf(':'));
|
||||
|
||||
const mac = sign(str, secret);
|
||||
|
||||
const macBuffer = Buffer.from(mac);
|
||||
const valBuffer = Buffer.from(value);
|
||||
|
||||
return timingSafeEqual(macBuffer, valBuffer) ? str : null;
|
||||
}
|
||||
|
||||
export function sign64(value: string, secret: string): string {
|
||||
return Buffer.from(sign(value, secret)).toString('base64');
|
||||
}
|
||||
|
||||
export function unsign64(value: string, secret: string): string {
|
||||
return unsign(Buffer.from(value, 'base64').toString(), secret);
|
||||
}
|
||||
|
||||
export function chunk<T>(arr: T[], size: number): Array<T[]> {
|
||||
const result = [];
|
||||
const L = arr.length;
|
||||
let i = 0;
|
||||
|
||||
while (i < L) {
|
||||
result.push(arr.slice(i, i += size));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function sizeOfDir(directory: string): Promise<number> {
|
||||
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;
|
||||
}
|
||||
|
||||
export function bytesToRead(bytes: number) {
|
||||
const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB'];
|
||||
let num = 0;
|
||||
|
||||
while (bytes > 1024) {
|
||||
bytes /= 1024;
|
||||
++num;
|
||||
}
|
||||
|
||||
return `${bytes.toFixed(1)} ${units[num]}`;
|
||||
}
|
||||
|
||||
export function createInvisURL(length: number) {
|
||||
const invisibleCharset = ['\u200B', '\u2060', '\u200C', '\u200D'];
|
||||
for (var i = 0, output = ''; i <= length; ++i) output += invisibleCharset[Math.floor(Math.random() * 4)];
|
||||
return output;
|
||||
}
|
||||
|
||||
export function createInvis(length: number, imageId: number) {
|
||||
const retry = async () => {
|
||||
const invis = createInvisURL(length);
|
||||
|
||||
const existing = await prisma.invisibleImage.findUnique({
|
||||
where: {
|
||||
invis
|
||||
}
|
||||
});
|
||||
|
||||
if (existing) return retry();
|
||||
|
||||
const inv = await prisma.invisibleImage.create({
|
||||
data: {
|
||||
invis,
|
||||
id: imageId
|
||||
}
|
||||
});
|
||||
|
||||
return inv;
|
||||
};
|
||||
|
||||
return retry();
|
||||
}
|
84
src/pages/[...id].tsx
Normal file
84
src/pages/[...id].tsx
Normal file
|
@ -0,0 +1,84 @@
|
|||
import React from 'react';
|
||||
import Head from 'next/head';
|
||||
import { GetServerSideProps } from 'next';
|
||||
import { Box } from '@material-ui/core';
|
||||
import config from 'lib/config';
|
||||
import prisma from 'lib/prisma';
|
||||
|
||||
export default function EmbeddedImage({ image, title, username, color, normal, embed }) {
|
||||
const dataURL = (route: string) => `/${route}/${image.file}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
{title ? (
|
||||
<>
|
||||
<meta property='og:site_name' content={`${image.file} • ${username}`} />
|
||||
<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(embed)} />
|
||||
<meta property='og:image' content={dataURL(normal)} />
|
||||
<meta property='twitter:card' content='summary_large_image' />
|
||||
<title>{image.file}</title>
|
||||
</Head>
|
||||
|
||||
<Box
|
||||
display='flex'
|
||||
justifyContent='center'
|
||||
alignItems='center'
|
||||
minHeight='100vh'
|
||||
>
|
||||
<img src={dataURL(normal)} alt={dataURL(normal)}/>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||
const id = context.params.id[1];
|
||||
const route = context.params.id[0];
|
||||
if (route !== config.uploader.embed_route.substr(1)) return {
|
||||
notFound: true
|
||||
};
|
||||
|
||||
const image = await prisma.image.findFirst({
|
||||
where: {
|
||||
file: id
|
||||
},
|
||||
select: {
|
||||
file: true,
|
||||
mimetype: true,
|
||||
userId: true
|
||||
}
|
||||
});
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
select: {
|
||||
embedTitle: true,
|
||||
embedColor: true,
|
||||
username: true
|
||||
},
|
||||
where: {
|
||||
id: image.userId
|
||||
}
|
||||
});
|
||||
|
||||
if (!image) return {
|
||||
notFound: true
|
||||
};
|
||||
|
||||
return {
|
||||
props: {
|
||||
image,
|
||||
title: user.embedTitle,
|
||||
color: user.embedColor,
|
||||
username: user.username,
|
||||
normal: config.uploader.route,
|
||||
embed: config.uploader.embed_route
|
||||
}
|
||||
};
|
||||
};
|
36
src/pages/_app.tsx
Normal file
36
src/pages/_app.tsx
Normal file
|
@ -0,0 +1,36 @@
|
|||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import Head from 'next/head';
|
||||
import { ThemeProvider } from '@material-ui/core/styles';
|
||||
import CssBaseline from '@material-ui/core/CssBaseline';
|
||||
import theme from 'lib/themes/dark_blue';
|
||||
import { useStore } from 'lib/redux/store';
|
||||
|
||||
export default function MyApp({ Component, pageProps }) {
|
||||
const store = useStore();
|
||||
|
||||
React.useEffect(() => {
|
||||
const jssStyles = document.querySelector('#jss-server-side');
|
||||
if (jssStyles) jssStyles.parentElement.removeChild(jssStyles);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<Head>
|
||||
<title>{Component.title}</title>
|
||||
<meta name='description' content='Zipline' />
|
||||
<meta name='viewport' content='minimum-scale=1, initial-scale=1, width=device-width' />
|
||||
</Head>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<Component {...pageProps} />
|
||||
</ThemeProvider>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
MyApp.propTypes = {
|
||||
Component: PropTypes.elementType.isRequired,
|
||||
pageProps: PropTypes.object.isRequired,
|
||||
};
|
25
src/pages/_document.tsx
Normal file
25
src/pages/_document.tsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
import React from 'react';
|
||||
import Document, { Html, Head, Main, NextScript } from 'next/document';
|
||||
|
||||
class MyDocument extends Document {
|
||||
static async getInitialProps(ctx) {
|
||||
const initialProps = await Document.getInitialProps(ctx);
|
||||
return { ...initialProps };
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Html lang='en'>
|
||||
<Head>
|
||||
<link rel='stylesheet' href='https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap' />
|
||||
</Head>
|
||||
<body>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default MyDocument;
|
42
src/pages/api/auth/create.ts
Normal file
42
src/pages/api/auth/create.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
import prisma from 'lib/prisma';
|
||||
import { NextApiReq, NextApiRes, withZipline } from 'lib/middleware/withZipline';
|
||||
import { createToken, hashPassword } from 'lib/util';
|
||||
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 (!user.administrator) return res.forbid('you arent an administrator');
|
||||
|
||||
if (req.method !== 'POST') return res.status(405).end();
|
||||
|
||||
const { username, password } = req.body as { username: string, password: string };
|
||||
|
||||
if (!username) return res.bad('no username');
|
||||
if (!password) return res.bad('no auth');
|
||||
|
||||
const existing = await prisma.user.findFirst({
|
||||
where: {
|
||||
username
|
||||
}
|
||||
});
|
||||
if (existing) return res.forbid('user exists');
|
||||
|
||||
const hashed = await hashPassword(password);
|
||||
|
||||
const newUser = await prisma.user.create({
|
||||
data: {
|
||||
password: hashed,
|
||||
username,
|
||||
token: createToken()
|
||||
}
|
||||
});
|
||||
|
||||
delete newUser.password;
|
||||
|
||||
Logger.get('user').info(`Created user ${newUser.username} (${newUser.id})`);
|
||||
|
||||
return res.json(newUser);
|
||||
}
|
||||
|
||||
export default withZipline(handler);
|
28
src/pages/api/auth/login.ts
Normal file
28
src/pages/api/auth/login.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
import prisma from 'lib/prisma';
|
||||
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
|
||||
import { checkPassword } from 'lib/util';
|
||||
import Logger from 'lib/logger';
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
if (req.method !== 'POST') return res.status(405).end();
|
||||
const { username, password } = req.body as { username: string, password: string };
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
username
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) return res.status(404).end(JSON.stringify({ message: 'not found' }));
|
||||
|
||||
const valid = await checkPassword(password, user.password);
|
||||
if (!valid) return res.forbid('wrong password');
|
||||
|
||||
res.setCookie('user', user.id, { sameSite: true, maxAge: 10000000, path: '/' });
|
||||
|
||||
Logger.get('user').info(`User ${user.username} (${user.id}) logged in`);
|
||||
|
||||
return res.json({ success: true });
|
||||
}
|
||||
|
||||
export default withZipline(handler);
|
15
src/pages/api/auth/logout.ts
Normal file
15
src/pages/api/auth/logout.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
|
||||
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');
|
||||
|
||||
req.cleanCookie('user');
|
||||
|
||||
Logger.get('user').info(`User ${user.username} (${user.id}) logged out`);
|
||||
|
||||
return res.json({ success: true });
|
||||
}
|
||||
|
||||
export default withZipline(handler);
|
62
src/pages/api/stats.ts
Normal file
62
src/pages/api/stats.ts
Normal file
|
@ -0,0 +1,62 @@
|
|||
import { join } from 'path';
|
||||
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
|
||||
import prisma from 'lib/prisma';
|
||||
import { bytesToRead, sizeOfDir } from 'lib/util';
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
const user = await req.user();
|
||||
if (!user) return res.forbid('not logged in');
|
||||
|
||||
const size = await sizeOfDir(join(process.cwd(), 'uploads'));
|
||||
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 res.json({
|
||||
size: bytesToRead(size),
|
||||
count,
|
||||
count_by_user,
|
||||
count_users,
|
||||
views_count: (viewsCount[0]?._sum?.views ?? 0),
|
||||
types_count
|
||||
});
|
||||
}
|
||||
|
||||
export default withZipline(handler);
|
71
src/pages/api/upload.ts
Normal file
71
src/pages/api/upload.ts
Normal file
|
@ -0,0 +1,71 @@
|
|||
import Busboy from 'busboy';
|
||||
import prisma from 'lib/prisma';
|
||||
import zconfig from 'lib/config';
|
||||
import { NextApiReq, NextApiRes, withZipline } from 'lib/middleware/withZipline';
|
||||
import { randomChars } from 'lib/util';
|
||||
import { writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import Logger from 'lib/logger';
|
||||
|
||||
interface FileData {
|
||||
data: Buffer;
|
||||
ext: string;
|
||||
mimetype: string;
|
||||
}
|
||||
|
||||
function file(req: NextApiReq): Promise<FileData> {
|
||||
return new Promise((res, rej) => {
|
||||
const busboy = new Busboy({ headers: req.headers });
|
||||
const files = [];
|
||||
|
||||
busboy.on('file', (_, file, name, __, mimetype) => {
|
||||
const ext = name.split('.').pop();
|
||||
file.on('data', data => files.push({ data, ext, mimetype }));
|
||||
});
|
||||
|
||||
busboy.on('finish', () => {
|
||||
res(files[0]);
|
||||
});
|
||||
|
||||
req.pipe(busboy);
|
||||
});
|
||||
}
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
if (req.method !== 'POST') return res.send(JSON.stringify({error:'no aloow'}));
|
||||
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');
|
||||
|
||||
const data = await file(req);
|
||||
const rand = randomChars(zconfig.uploader.length);
|
||||
|
||||
const image = await prisma.image.create({
|
||||
data: {
|
||||
file: `${rand}.${data.ext}`,
|
||||
mimetype: data.mimetype,
|
||||
userId: user.id
|
||||
}
|
||||
});
|
||||
|
||||
await writeFile(join(process.cwd(), zconfig.uploader.directory, image.file), data.data);
|
||||
|
||||
Logger.get('image').info(`User ${user.username} (${user.id}) uploaded an image ${image.file} (${image.id})`);
|
||||
|
||||
return res.json({
|
||||
url: `${zconfig.core.secure ? 'https' : 'http'}://${req.headers.host}${req.headers.embed ? zconfig.uploader.embed_route : zconfig.uploader.route}/${image.file}`
|
||||
});
|
||||
}
|
||||
|
||||
export default withZipline(handler);
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
48
src/pages/api/user/images.ts
Normal file
48
src/pages/api/user/images.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
|
||||
import prisma from 'lib/prisma';
|
||||
import config from 'lib/config';
|
||||
import { chunk } from 'lib/util';
|
||||
import { rm } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import Logger from 'lib/logger';
|
||||
|
||||
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 file id');
|
||||
|
||||
const image = await prisma.image.delete({
|
||||
where: {
|
||||
id: req.body.id
|
||||
}
|
||||
});
|
||||
|
||||
await rm(join(process.cwd(), config.uploader.directory, image.file));
|
||||
|
||||
Logger.get('image').info(`User ${user.username} (${user.id}) deleted an image ${image.file} (${image.id})`);
|
||||
|
||||
return res.json(image);
|
||||
} else {
|
||||
const images = await prisma.image.findMany({
|
||||
where: {
|
||||
user
|
||||
},
|
||||
select: {
|
||||
created_at: true,
|
||||
file: true,
|
||||
mimetype: true,
|
||||
id: true
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// @ts-ignore
|
||||
images.map(image => image.url = `${config.uploader.route}/${image.file}`);
|
||||
|
||||
return res.json(req.query.paged ? chunk(images, 16) : images);
|
||||
}
|
||||
}
|
||||
|
||||
export default withZipline(handler);
|
50
src/pages/api/user/index.ts
Normal file
50
src/pages/api/user/index.ts
Normal file
|
@ -0,0 +1,50 @@
|
|||
import prisma from 'lib/prisma';
|
||||
import { hashPassword } from 'lib/util';
|
||||
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
|
||||
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 === 'PATCH') {
|
||||
if (req.body.password) {
|
||||
const hashed = await hashPassword(req.body.password);
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { password: hashed }
|
||||
});
|
||||
}
|
||||
|
||||
if (req.body.username) await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { username: req.body.username }
|
||||
});
|
||||
|
||||
if (req.body.embedTitle) await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { embedTitle: req.body.embedTitle }
|
||||
});
|
||||
|
||||
if (req.body.embedColor) await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { embedColor: req.body.embedColor }
|
||||
});
|
||||
|
||||
const newUser = await prisma.user.findFirst({
|
||||
where: { id: user.id }
|
||||
});
|
||||
|
||||
Logger.get('user').info(`User ${user.username} (${newUser.username}) (${newUser.id}) was updated`);
|
||||
|
||||
delete newUser.password;
|
||||
|
||||
return res.json(newUser);
|
||||
} else {
|
||||
delete user.password;
|
||||
|
||||
return res.json(user);
|
||||
}
|
||||
}
|
||||
|
||||
export default withZipline(handler);
|
27
src/pages/api/user/recent.ts
Normal file
27
src/pages/api/user/recent.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
|
||||
import prisma from 'lib/prisma';
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
const user = await req.user();
|
||||
if (!user) return res.forbid('not logged in');
|
||||
|
||||
const take = Number(req.query.take ?? 3);
|
||||
|
||||
if (take > 50) return res.error('take can\'t be more than 50');
|
||||
|
||||
const images = await prisma.image.findMany({
|
||||
take,
|
||||
orderBy: {
|
||||
created_at: 'desc'
|
||||
},
|
||||
select: {
|
||||
created_at: true,
|
||||
file: true,
|
||||
mimetype: true
|
||||
}
|
||||
});
|
||||
|
||||
return res.json(images);
|
||||
}
|
||||
|
||||
export default withZipline(handler);
|
26
src/pages/api/user/token.ts
Normal file
26
src/pages/api/user/token.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
|
||||
import prisma from 'lib/prisma';
|
||||
import { createToken } from 'lib/util';
|
||||
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 === 'PATCH') {
|
||||
const updated = await prisma.user.update({
|
||||
where: {
|
||||
id: user.id
|
||||
},
|
||||
data: {
|
||||
token: createToken()
|
||||
}
|
||||
});
|
||||
|
||||
Logger.get('user').info(`User ${user.username} (${user.id}) reset their token`);
|
||||
|
||||
return res.json({ success: updated.token });
|
||||
}
|
||||
}
|
||||
|
||||
export default withZipline(handler);
|
45
src/pages/api/users.ts
Normal file
45
src/pages/api/users.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
import { join } from 'path';
|
||||
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
|
||||
import prisma from 'lib/prisma';
|
||||
import { bytesToRead, sizeOfDir } from 'lib/util';
|
||||
import { tryGetPreviewData } from 'next/dist/next-server/server/api-utils';
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
const user = await req.user();
|
||||
if (!user) return res.forbid('not logged in');
|
||||
if (!user.administrator) return res.forbid('you arent an administrator');
|
||||
|
||||
if (req.method === 'DELETE') {
|
||||
if (req.body.id === user.id) return res.forbid('you can\'t delete your own account');
|
||||
|
||||
const deleteUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: req.body.id
|
||||
}
|
||||
});
|
||||
if (!deleteUser) return res.forbid('user doesn\'t exist');
|
||||
|
||||
await prisma.user.delete({
|
||||
where: {
|
||||
id: deleteUser.id
|
||||
}
|
||||
});
|
||||
|
||||
delete deleteUser.password;
|
||||
return res.json(deleteUser);
|
||||
} else {
|
||||
const all_users = await prisma.user.findMany({
|
||||
select: {
|
||||
username: true,
|
||||
id: true,
|
||||
administrator: true,
|
||||
token: true,
|
||||
embedColor: true,
|
||||
embedTitle: true
|
||||
}
|
||||
});
|
||||
return res.json(all_users);
|
||||
}
|
||||
}
|
||||
|
||||
export default withZipline(handler);
|
105
src/pages/auth/login.tsx
Normal file
105
src/pages/auth/login.tsx
Normal file
|
@ -0,0 +1,105 @@
|
|||
import React, { useEffect, useState } 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 Alert from 'components/Alert';
|
||||
import Backdrop from 'components/Backdrop';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useFormik } from 'formik';
|
||||
|
||||
|
||||
function TextInput({ id, label, formik, ...other }) {
|
||||
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() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [severity, setSeverity] = useState<Color>('success');
|
||||
const [message, setMessage] = useState('');
|
||||
const [loadingOpen, setLoadingOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
username: '',
|
||||
password: ''
|
||||
},
|
||||
onSubmit: async values => {
|
||||
const username = values.username.trim();
|
||||
const password = values.password.trim();
|
||||
|
||||
if (username === '') return formik.setFieldError('username', 'Username can\'t be nothing');
|
||||
|
||||
setLoadingOpen(true);
|
||||
const res = await useFetch('/api/auth/login', 'POST', {
|
||||
username, password
|
||||
});
|
||||
|
||||
if (res.error) {
|
||||
setOpen(true);
|
||||
setSeverity('error');
|
||||
setMessage(res.error);
|
||||
setLoadingOpen(false);
|
||||
} else {
|
||||
setOpen(true);
|
||||
setSeverity('success');
|
||||
setMessage('Logged in');
|
||||
router.push('/dashboard');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const a = await fetch('/api/user');
|
||||
if (a.ok) router.push('/dashboard');
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Alert open={open} setOpen={setOpen} severity={severity} message={message} />
|
||||
<Backdrop open={loadingOpen} />
|
||||
<Box
|
||||
display='flex'
|
||||
height='screen'
|
||||
alignItems='center'
|
||||
justifyContent='center'
|
||||
sx={{ height: '24rem' }}
|
||||
>
|
||||
<Stack>
|
||||
<Typography variant='h3' textAlign='center'>
|
||||
Zipline
|
||||
</Typography>
|
||||
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<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>
|
||||
</Stack>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Login.title = 'Zipline - Login';
|
30
src/pages/auth/logout.tsx
Normal file
30
src/pages/auth/logout.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Backdrop, CircularProgress } from '@material-ui/core';
|
||||
|
||||
export default function Logout() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const userRes = await fetch('/api/user');
|
||||
if (userRes.ok) {
|
||||
const res = await fetch('/api/auth/logout');
|
||||
if (res.ok) router.push('/auth/login');
|
||||
} else {
|
||||
router.push('/auth/login');
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Backdrop
|
||||
sx={{ color: '#fff', zIndex: t => t.zIndex.drawer + 1 }}
|
||||
open
|
||||
>
|
||||
<CircularProgress color='inherit' />
|
||||
</Backdrop>
|
||||
);
|
||||
}
|
||||
|
||||
Logout.title = 'Zipline - Logout';
|
22
src/pages/dashboard/images.tsx
Normal file
22
src/pages/dashboard/images.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
import React from 'react';
|
||||
import useLogin from 'hooks/useLogin';
|
||||
import Layout from 'components/Layout';
|
||||
import Images from 'components/pages/Images';
|
||||
|
||||
export default function ImagesPage() {
|
||||
const { user, loading } = useLogin();
|
||||
|
||||
if (loading) return null;
|
||||
|
||||
return (
|
||||
<Layout
|
||||
user={user}
|
||||
loading={loading}
|
||||
noPaper={false}
|
||||
>
|
||||
<Images />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
ImagesPage.title = 'Zipline - Gallery';
|
22
src/pages/dashboard/index.tsx
Normal file
22
src/pages/dashboard/index.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
import React from 'react';
|
||||
import useLogin from 'hooks/useLogin';
|
||||
import Layout from 'components/Layout';
|
||||
import Dashboard from 'components/pages/Dashboard';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user, loading } = useLogin();
|
||||
|
||||
if (loading) return null;
|
||||
|
||||
return (
|
||||
<Layout
|
||||
user={user}
|
||||
loading={loading}
|
||||
noPaper={false}
|
||||
>
|
||||
<Dashboard />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
DashboardPage.title = 'Zipline';
|
22
src/pages/dashboard/manage.tsx
Normal file
22
src/pages/dashboard/manage.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
import React from 'react';
|
||||
import useLogin from 'hooks/useLogin';
|
||||
import Layout from 'components/Layout';
|
||||
import Manage from 'components/pages/Manage';
|
||||
|
||||
export default function ManagePage() {
|
||||
const { user, loading } = useLogin();
|
||||
|
||||
if (loading) return null;
|
||||
|
||||
return (
|
||||
<Layout
|
||||
user={user}
|
||||
loading={loading}
|
||||
noPaper={false}
|
||||
>
|
||||
<Manage />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
ManagePage.title = 'Zipline - Manage';
|
32
src/pages/dashboard/upload.tsx
Normal file
32
src/pages/dashboard/upload.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
import React from 'react';
|
||||
import { GetStaticProps } from 'next';
|
||||
import useLogin from 'hooks/useLogin';
|
||||
import Layout from 'components/Layout';
|
||||
import Upload from 'components/pages/Upload';
|
||||
import config from 'lib/config';
|
||||
|
||||
export default function UploadPage({ route }) {
|
||||
const { user, loading } = useLogin();
|
||||
|
||||
if (loading) return null;
|
||||
|
||||
return (
|
||||
<Layout
|
||||
user={user}
|
||||
loading={loading}
|
||||
noPaper={false}
|
||||
>
|
||||
<Upload route={route}/>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export const getStaticProps: GetStaticProps = async (context) => {
|
||||
return {
|
||||
props: {
|
||||
route: config.uploader.route
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
UploadPage.title = 'Zipline - Upload';
|
22
src/pages/dashboard/users.tsx
Normal file
22
src/pages/dashboard/users.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
import React from 'react';
|
||||
import useLogin from 'hooks/useLogin';
|
||||
import Layout from 'components/Layout';
|
||||
import Users from 'components/pages/Users';
|
||||
|
||||
export default function UsersPage() {
|
||||
const { user, loading } = useLogin();
|
||||
|
||||
if (loading) return null;
|
||||
|
||||
return (
|
||||
<Layout
|
||||
user={user}
|
||||
loading={loading}
|
||||
noPaper={false}
|
||||
>
|
||||
<Users />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
UsersPage.title = 'Zipline - User';
|
12
src/pages/index.tsx
Normal file
12
src/pages/index.tsx
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function Index() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
router.push('/dashboard');
|
||||
}, [router]);
|
||||
|
||||
return null;
|
||||
}
|
38
tsconfig.json
Normal file
38
tsconfig.json
Normal file
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": false,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "./dist",
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"noEmit": true,
|
||||
"baseUrl": "src",
|
||||
"paths": {
|
||||
"components/*": ["components/*"],
|
||||
"hooks/*": ["lib/hooks/*"],
|
||||
"middleware/*": ["lib/middleware/*"],
|
||||
"lib/*": ["lib/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"prisma/seed.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
Loading…
Add table
Reference in a new issue