1
Fork 0
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:
diced 2021-06-23 11:20:20 -07:00
commit fd400aa850
No known key found for this signature in database
GPG key ID: 85AB64C74535D76E
88 changed files with 9776 additions and 0 deletions

20
.babelrc Normal file
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1 @@
_

4
.husky/commit-msg Executable file
View file

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

30
Dockerfile Normal file
View 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
View file

@ -0,0 +1,3 @@
prisma/migrations
node_modules
.next

26
README.md Normal file
View file

@ -0,0 +1,26 @@
<p align="center"><img src="https://raw.githubusercontent.com/diced/zipline/trunk/public/zipline_small.png"/></p>
![Version](https://img.shields.io/github/package-json/v/diced/zipline)
![LICENCE](https://img.shields.io/github/license/diced/zipline)
[![Discord](https://img.shields.io/discord/729771078196527176)](https://discord.gg/AtTSecwqeV)
![Stars](https://img.shields.io/github/stars/diced/zipline)
![GitHub repo size](https://img.shields.io/github/repo-size/diced/zipline)
![GitHub last commit (branch)](https://img.shields.io/github/last-commit/diced/zipline/trunk)
<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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,4 @@
module.exports = {
reactStrictMode: true,
};

64
package.json Normal file
View 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"
}
}

View 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"

View 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;

View 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"

View 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;

View 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"

View 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");

View 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
View 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
}

View 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
}

View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

BIN
public/zipline.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
public/zipline_small.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

8
release.config.js Normal file
View file

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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>
);
}

View 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
View 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>
);
}

View 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
View 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
View 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&apos;t share this token with anyone as they will be able to upload images on your behalf.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)} color='inherit' autoFocus>Cancel</Button>
<Button onClick={handleCopyToken} color='inherit'>
Copy
</Button>
</DialogActions>
</Dialog>
</div>
);
}
function ResetTokenDialog({ open, setOpen, setToken }) {
const handleResetToken = async () => {
const a = await useFetch('/api/user/token', 'PATCH');
if (a.success) setToken(a.success);
setOpen(false);
};
return (
<div>
<Dialog
open={open}
onClose={() => setOpen(false)}
>
<DialogTitle id='reset-dialog-title'>
Reset Token
</DialogTitle>
<DialogContent>
<DialogContentText id='reset-dialog-description'>
Once you reset your token, you will have to update any uploaders to use this new token.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)} color='inherit' autoFocus>Cancel</Button>
<Button onClick={handleResetToken} color='inherit'>
Reset
</Button>
</DialogActions>
</Dialog>
</div>
);
}
export default function Layout({ children, user, loading, noPaper }) {
const [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
View 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;

View 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>
</>
);
}

View 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}
</>
);
}

View 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>
</>
);
}

View 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>
</>
);
}

View 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
View 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
View 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
View 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
View 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;

View 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
View 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
View 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;
}

View file

@ -0,0 +1,4 @@
import { combineReducers } from 'redux';
import user from './reducers/user';
export default combineReducers({ user });

View 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
View 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;

View 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
View 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
View 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
View 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
View 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
View 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
View 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;

View 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);

View 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);

View 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
View 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
View 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,
},
};

View 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);

View 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);

View 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);

View 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
View 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
View 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
View 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';

View 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';

View 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';

View 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';

View 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';

View 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
View 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
View 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"
]
}

5798
yarn.lock Normal file

File diff suppressed because it is too large Load diff