mirror of
https://github.com/diced/zipline.git
synced 2025-12-24 12:04:05 -08:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e64922b70 | ||
|
|
15042b16d1 | ||
|
|
5e4c4fc6c9 | ||
|
|
7194c53891 | ||
|
|
7eff77ccc4 | ||
|
|
1b78ffaa91 | ||
|
|
8e8bfd68d1 | ||
|
|
b029505cdd | ||
|
|
c5c862bee3 | ||
|
|
3c38d008f1 | ||
|
|
dc52b00a00 | ||
|
|
b5d2e7040e | ||
|
|
5818440721 | ||
|
|
f1c46da47d | ||
|
|
212c69d303 | ||
|
|
9e4152e298 | ||
|
|
307f023e47 | ||
|
|
3451bd8762 | ||
|
|
a9d0be8aae | ||
|
|
d83f720631 | ||
|
|
1f3d396296 | ||
|
|
48f771f344 | ||
|
|
555bc6aa26 | ||
|
|
8bd0eaac1e | ||
|
|
3280c77002 | ||
|
|
b39743a53a | ||
|
|
9a73da56e9 | ||
|
|
c9b0d2664f | ||
|
|
6063c9efac | ||
|
|
dd6f192d4a | ||
|
|
d956f4ed3d |
20
.babelrc
20
.babelrc
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"presets": [
|
||||
"next/babel"
|
||||
],
|
||||
"plugins": [
|
||||
[
|
||||
"babel-plugin-transform-imports",
|
||||
{
|
||||
"@material-ui/core": {
|
||||
"transform": "@material-ui/core/${member}",
|
||||
"preventFullImport": true
|
||||
},
|
||||
"@material-ui/icons": {
|
||||
"transform": "@material-ui/icons/${member}",
|
||||
"preventFullImport": true
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.next/
|
||||
uploads/
|
||||
.git/
|
||||
@@ -5,6 +5,7 @@ module.exports = {
|
||||
'linebreak-style': ['error', 'unix'],
|
||||
'quotes': ['error', 'single'],
|
||||
'semi': ['error', 'always'],
|
||||
'comma-dangle': ['error', 'always-multiline'],
|
||||
'jsx-quotes': ['error', 'prefer-single'],
|
||||
'react/prop-types': 'off',
|
||||
'react-hooks/rules-of-hooks': 'off',
|
||||
@@ -19,6 +20,6 @@ module.exports = {
|
||||
'react/react-in-jsx-scope': 'error',
|
||||
'react/require-render-return': 'error',
|
||||
'react/style-prop-object': 'warn',
|
||||
'@next/next/no-img-element': 'off'
|
||||
}
|
||||
'@next/next/no-img-element': 'off',
|
||||
},
|
||||
};
|
||||
|
||||
9
.github/workflows/build.yml
vendored
9
.github/workflows/build.yml
vendored
@@ -23,12 +23,11 @@ jobs:
|
||||
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
|
||||
|
||||
run: echo -e "[core]\nsecret = '12345678'\ndatabase_url = 'postgres://postgres:postgres@postgres/postgres'\n[uploader]\nroute = '/u'\ndirectory = './uploads'\n[urls]\nroute = '/go'" > config.toml
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.cache-restore.outputs.cache-hit != 'true'
|
||||
run: yarn install
|
||||
|
||||
|
||||
- name: Build
|
||||
run: yarn build
|
||||
|
||||
run: yarn build
|
||||
1
.github/workflows/docker.yml
vendored
1
.github/workflows/docker.yml
vendored
@@ -7,6 +7,7 @@ on:
|
||||
- 'src/**'
|
||||
- 'server/**'
|
||||
- 'prisma/**'
|
||||
- '.github/**'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -18,6 +18,7 @@
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
.idea
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
@@ -35,5 +36,4 @@ yarn-error.log*
|
||||
|
||||
# zipline
|
||||
config.toml
|
||||
uploads/
|
||||
data.db*
|
||||
uploads/
|
||||
@@ -1,6 +1,8 @@
|
||||
FROM node:16-alpine3.11 AS builder
|
||||
WORKDIR /build
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
COPY src ./src
|
||||
COPY server ./server
|
||||
COPY scripts ./scripts
|
||||
@@ -11,7 +13,7 @@ COPY package.json yarn.lock next.config.js next-env.d.ts zip-env.d.ts tsconfig.j
|
||||
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 echo -e "[core]\nsecret = '12345678'\ndatabase_url = 'postgres://postgres:postgres@postgres/postgres'\n[uploader]\nroute = '/u'\ndirectory = './uploads'\n[urls]\nroute = '/go'" > config.toml
|
||||
|
||||
RUN yarn build
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
prisma
|
||||
node_modules
|
||||
.next
|
||||
uploads
|
||||
.git
|
||||
10
README.md
10
README.md
@@ -1,7 +1,7 @@
|
||||
<div align="center">
|
||||
<img src="https://raw.githubusercontent.com/diced/zipline/trunk/public/zipline_small.png"/>
|
||||
|
||||
Zipline is a file sharing, URL sharing, lightweight and easy to use!
|
||||
Zipline is a ShareX/file upload server that is easy to use, packed with features and can be setup in one command!
|
||||
|
||||

|
||||

|
||||
@@ -16,13 +16,13 @@
|
||||
- Fast
|
||||
- Built with Next.js & React
|
||||
- Token protected uploading
|
||||
- Easy setup instructions on [docs](https://zipline.diced.me) (One command install `docker-compose up`)
|
||||
- Easy setup instructions on [docs](https://zipline.diced.cf) (One command install `docker-compose up`)
|
||||
|
||||
## Installing
|
||||
[See how to install here](https://zipline.diced.me/get-started)
|
||||
[See how to install here](https://zipline.diced.cf/docs/get-started)
|
||||
|
||||
## Configuration
|
||||
[See how to configure here](https://zipline.diced.me/configuration/overview)
|
||||
[See how to configure here](https://zipline.diced.cf/docs/config/overview)
|
||||
|
||||
## Theming
|
||||
[See how to theme here](https://zipline.diced.me/themes)
|
||||
[See how to theme here](https://zipline.diced.cf/docs/themes/reference)
|
||||
|
||||
@@ -5,6 +5,10 @@ host = '0.0.0.0'
|
||||
port = 3000
|
||||
database_url = 'postgres://postgres:postgres@postgres/postgres'
|
||||
|
||||
[urls]
|
||||
route = '/go'
|
||||
length = 6
|
||||
|
||||
[uploader]
|
||||
route = '/u'
|
||||
embed_route = '/a'
|
||||
@@ -12,4 +16,4 @@ length = 6
|
||||
directory = './uploads'
|
||||
user_limit = 104900000 # 100mb
|
||||
admin_limit = 104900000 # 100mb
|
||||
disabled_extentions = ['jpg']
|
||||
disabled_extentions = ['jpg']
|
||||
|
||||
1
next-env.d.ts
vendored
1
next-env.d.ts
vendored
@@ -1,5 +1,4 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/types/global" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
|
||||
23
package.json
23
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "zip3",
|
||||
"version": "3.2.2",
|
||||
"version": "3.3.0",
|
||||
"scripts": {
|
||||
"prepare": "husky install",
|
||||
"dev": "NODE_ENV=development node server",
|
||||
@@ -9,17 +9,16 @@
|
||||
"build:schema": "prisma generate --schema=prisma/schema.prisma",
|
||||
"start": "node server",
|
||||
"lint": "next lint",
|
||||
"ts-node": "ts-node --compiler-options \"{\\\"module\\\":\\\"commonjs\\\"}\" --transpile-only",
|
||||
"semantic-release": "semantic-release"
|
||||
"seed": "ts-node --compiler-options \"{\\\"module\\\":\\\"commonjs\\\"}\" --transpile-only prisma/seed.ts"
|
||||
},
|
||||
"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/icons": "^5.0.0-alpha.37",
|
||||
"@material-ui/styles": "^5.0.0-alpha.35",
|
||||
"@prisma/client": "^3.0.2",
|
||||
"@mui/icons-material": "^5.0.0",
|
||||
"@mui/material": "^5.0.2",
|
||||
"@mui/styles": "^5.0.1",
|
||||
"@prisma/client": "^3.7.0",
|
||||
"@reduxjs/toolkit": "^1.6.0",
|
||||
"argon2": "^0.28.2",
|
||||
"colorette": "^1.2.2",
|
||||
@@ -28,9 +27,10 @@
|
||||
"fecha": "^4.2.1",
|
||||
"formik": "^2.2.9",
|
||||
"multer": "^1.4.2",
|
||||
"next": "11.1.1",
|
||||
"prisma": "^3.0.2",
|
||||
"next": "^12.0.7",
|
||||
"prisma": "^3.7.0",
|
||||
"react": "17.0.2",
|
||||
"react-color": "^2.19.3",
|
||||
"react-dom": "17.0.2",
|
||||
"react-dropzone": "^11.3.2",
|
||||
"react-redux": "^7.2.4",
|
||||
@@ -44,8 +44,7 @@
|
||||
"@types/cookie": "^0.4.0",
|
||||
"@types/multer": "^1.4.6",
|
||||
"@types/node": "^15.12.2",
|
||||
"babel-plugin-transform-imports": "^2.0.0",
|
||||
"eslint": "7.28.0",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-config-next": "11.0.0",
|
||||
"husky": "^6.0.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
@@ -55,6 +54,6 @@
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/diced/workflow-testing.git"
|
||||
"url": "https://github.com/diced/zipline.git"
|
||||
}
|
||||
}
|
||||
|
||||
39
prisma/migrations/20210924045900_delete_url/migration.sql
Normal file
39
prisma/migrations/20210924045900_delete_url/migration.sql
Normal file
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the `InvisibleUrl` table. If the table is not empty, all the data it contains will be lost.
|
||||
- You are about to drop the `Url` table. If the table is not empty, all the data it contains will be lost.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Image" DROP CONSTRAINT "Image_userId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "InvisibleImage" DROP CONSTRAINT "InvisibleImage_imageId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "InvisibleUrl" DROP CONSTRAINT "InvisibleUrl_id_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Theme" DROP CONSTRAINT "Theme_userId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Url" DROP CONSTRAINT "Url_userId_fkey";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "InvisibleUrl";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "Url";
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Theme" ADD CONSTRAINT "Theme_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Image" ADD CONSTRAINT "Image_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InvisibleImage" ADD CONSTRAINT "InvisibleImage_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "Image"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- RenameIndex
|
||||
ALTER INDEX "InvisibleImage.invis_unique" RENAME TO "InvisibleImage_invis_key";
|
||||
34
prisma/migrations/20210924050753_new_url/migration.sql
Normal file
34
prisma/migrations/20210924050753_new_url/migration.sql
Normal file
@@ -0,0 +1,34 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Url" (
|
||||
"id" TEXT NOT NULL,
|
||||
"destination" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"views" INTEGER NOT NULL DEFAULT 0,
|
||||
"userId" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "Url_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "InvisibleUrl" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"invis" TEXT NOT NULL,
|
||||
"urlId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "InvisibleUrl_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Url_id_key" ON "Url"("id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "InvisibleUrl_invis_key" ON "InvisibleUrl"("invis");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "InvisibleUrl_urlId_unique" ON "InvisibleUrl"("urlId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Url" ADD CONSTRAINT "Url_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InvisibleUrl" ADD CONSTRAINT "InvisibleUrl_urlId_fkey" FOREIGN KEY ("urlId") REFERENCES "Url"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Url" ADD COLUMN "vanity" TEXT;
|
||||
2
prisma/migrations/20211003022626_site_name/migration.sql
Normal file
2
prisma/migrations/20211003022626_site_name/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "embedSiteName" TEXT DEFAULT E'{image.file} • {user.name}';
|
||||
11
prisma/migrations/20211128031800_ratelimit/migration.sql
Normal file
11
prisma/migrations/20211128031800_ratelimit/migration.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "ratelimited" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- RenameIndex
|
||||
ALTER INDEX "InvisibleImage_imageId_unique" RENAME TO "InvisibleImage_imageId_key";
|
||||
|
||||
-- RenameIndex
|
||||
ALTER INDEX "InvisibleUrl_urlId_unique" RENAME TO "InvisibleUrl_urlId_key";
|
||||
|
||||
-- RenameIndex
|
||||
ALTER INDEX "Theme_userId_unique" RENAME TO "Theme_userId_key";
|
||||
8
prisma/migrations/20220103232702_stats/migration.sql
Normal file
8
prisma/migrations/20220103232702_stats/migration.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Stats" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"data" JSONB NOT NULL,
|
||||
|
||||
CONSTRAINT "Stats_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
@@ -17,6 +17,8 @@ model User {
|
||||
customTheme Theme?
|
||||
embedTitle String?
|
||||
embedColor String @default("#2f3136")
|
||||
embedSiteName String? @default("{image.file} • {user.name}")
|
||||
ratelimited Boolean @default(false)
|
||||
images Image[]
|
||||
urls Url[]
|
||||
}
|
||||
@@ -50,26 +52,32 @@ model Image {
|
||||
}
|
||||
|
||||
model InvisibleImage {
|
||||
id Int @id @default(autoincrement())
|
||||
invis String @unique
|
||||
|
||||
id Int @id @default(autoincrement())
|
||||
invis String @unique
|
||||
imageId Int
|
||||
image Image @relation(fields: [imageId], references: [id])
|
||||
image Image @relation(fields: [imageId], references: [id])
|
||||
}
|
||||
|
||||
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
|
||||
id String @id @unique
|
||||
destination String
|
||||
vanity 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])
|
||||
|
||||
id Int @id @default(autoincrement())
|
||||
invis String @unique
|
||||
urlId String
|
||||
url Url @relation(fields: [urlId], references: [id])
|
||||
}
|
||||
|
||||
model Stats {
|
||||
id Int @id @default(autoincrement())
|
||||
created_at DateTime @default(now())
|
||||
data Json
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
module.exports = {
|
||||
branches: ['trunk'],
|
||||
plugins: [
|
||||
'@semantic-release/commit-analyzer',
|
||||
'@semantic-release/github',
|
||||
'@semantic-release/changelog'
|
||||
]
|
||||
};
|
||||
186
server/index.js
186
server/index.js
@@ -1,9 +1,8 @@
|
||||
const next = require('next');
|
||||
const { createServer } = require('http');
|
||||
const { stat, mkdir } = require('fs/promises');
|
||||
const { stat, mkdir, readdir } = require('fs/promises');
|
||||
const { execSync } = require('child_process');
|
||||
const { extname } = require('path');
|
||||
const { red, green, bold } = require('colorette');
|
||||
const { extname, join } = require('path');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const validateConfig = require('./validateConfig');
|
||||
const Logger = require('../src/lib/logger');
|
||||
@@ -20,7 +19,7 @@ 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}`);
|
||||
return Logger.get('url').info(url);
|
||||
}
|
||||
|
||||
function shouldUseYarn() {
|
||||
@@ -34,17 +33,15 @@ function shouldUseYarn() {
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const config = readConfig();
|
||||
await validateConfig(config);
|
||||
const a = readConfig();
|
||||
const config = await validateConfig(a);
|
||||
|
||||
const data = await prismaRun(config.core.database_url, ['migrate', 'status'], true);
|
||||
if (data.includes('Following migration have not yet been applied:')) {
|
||||
if (data.match(/Following migrations? have not yet been applied/)) {
|
||||
Logger.get('database').info('some migrations are not applied, applying them now...');
|
||||
await deployDb(config);
|
||||
Logger.get('database').info('finished applying migrations');
|
||||
} else {
|
||||
Logger.get('database').info('migrations up to date');
|
||||
}
|
||||
} else Logger.get('database').info('migrations up to date');
|
||||
process.env.DATABASE_URL = config.core.database_url;
|
||||
|
||||
await mkdir(config.uploader.directory, { recursive: true });
|
||||
@@ -52,7 +49,7 @@ function shouldUseYarn() {
|
||||
const app = next({
|
||||
dir: '.',
|
||||
dev,
|
||||
quiet: dev
|
||||
quiet: dev,
|
||||
}, config.core.port, config.core.host);
|
||||
|
||||
await app.prepare();
|
||||
@@ -70,15 +67,15 @@ function shouldUseYarn() {
|
||||
where: {
|
||||
OR: [
|
||||
{ file: parts[2] },
|
||||
{ invisible:{ invis: decodeURI(parts[2]) } }
|
||||
]
|
||||
{ invisible:{ invis: decodeURI(parts[2]) } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
mimetype: true,
|
||||
id: true,
|
||||
file: true,
|
||||
invisible: true
|
||||
}
|
||||
invisible: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!image) {
|
||||
@@ -89,31 +86,61 @@ function shouldUseYarn() {
|
||||
res.setHeader('Content-Type', mimetype);
|
||||
res.end(data);
|
||||
} else {
|
||||
if (image) {
|
||||
const data = await getFile(config.uploader.directory, image.file);
|
||||
if (!data) return app.render404(req, res);
|
||||
const data = await getFile(config.uploader.directory, image.file);
|
||||
if (!data) return app.render404(req, res);
|
||||
|
||||
await prisma.image.update({
|
||||
where: { id: image.id },
|
||||
data: { views: { increment: 1 } }
|
||||
});
|
||||
res.setHeader('Content-Type', image.mimetype);
|
||||
res.end(data);
|
||||
} else {
|
||||
const data = await getFile(config.uploader.directory, parts[2]);
|
||||
if (!data) return app.render404(req, res);
|
||||
await prisma.image.update({
|
||||
where: { id: image.id },
|
||||
data: { views: { increment: 1 } },
|
||||
});
|
||||
res.setHeader('Content-Type', image.mimetype);
|
||||
res.end(data);
|
||||
}
|
||||
} else if (req.url.startsWith(config.uploader.route)) {
|
||||
const parts = req.url.split('/');
|
||||
if (!parts[2] || parts[2] === '') return;
|
||||
|
||||
let image = await prisma.image.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ file: parts[2] },
|
||||
{ invisible:{ invis: decodeURI(parts[2]) } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
mimetype: true,
|
||||
id: true,
|
||||
file: true,
|
||||
invisible: true,
|
||||
embed: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!image) {
|
||||
const data = await getFile(config.uploader.directory, parts[2]);
|
||||
if (!data) return app.render404(req, res);
|
||||
|
||||
const mimetype = mimes[extname(parts[2])] ?? 'application/octet-stream';
|
||||
res.setHeader('Content-Type', mimetype);
|
||||
res.end(data);
|
||||
}
|
||||
const mimetype = mimes[extname(parts[2])] ?? 'application/octet-stream';
|
||||
res.setHeader('Content-Type', mimetype);
|
||||
res.end(data);
|
||||
} else if (image.embed) {
|
||||
handle(req, res);
|
||||
} else {
|
||||
const data = await getFile(config.uploader.directory, image.file);
|
||||
if (!data) return app.render404(req, res);
|
||||
|
||||
await prisma.image.update({
|
||||
where: { id: image.id },
|
||||
data: { views: { increment: 1 } },
|
||||
});
|
||||
res.setHeader('Content-Type', image.mimetype);
|
||||
res.end(data);
|
||||
}
|
||||
} else {
|
||||
handle(req, res);
|
||||
}
|
||||
|
||||
log(req.url, res.statusCode);
|
||||
if (config.core.logger) log(req.url, res.statusCode);
|
||||
});
|
||||
|
||||
srv.on('error', (e) => {
|
||||
@@ -127,6 +154,22 @@ function shouldUseYarn() {
|
||||
});
|
||||
|
||||
srv.listen(config.core.port, config.core.host ?? '0.0.0.0');
|
||||
|
||||
const stats = await getStats(prisma, config);
|
||||
await prisma.stats.create({
|
||||
data: {
|
||||
data: stats,
|
||||
},
|
||||
});
|
||||
setInterval(async () => {
|
||||
const stats = await getStats(prisma, config);
|
||||
await prisma.stats.create({
|
||||
data: {
|
||||
data: stats,
|
||||
},
|
||||
});
|
||||
if (config.core.logger) Logger.get('server').info('stats updated');
|
||||
}, config.core.stats_interval * 1000);
|
||||
} 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'}\``);
|
||||
@@ -137,4 +180,81 @@ function shouldUseYarn() {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
})();
|
||||
})();
|
||||
|
||||
async function sizeOfDir(directory) {
|
||||
const files = await readdir(directory);
|
||||
|
||||
let size = 0;
|
||||
for (let i = 0, L = files.length; i !== L; ++i) {
|
||||
const sta = await stat(join(directory, files[i]));
|
||||
size += sta.size;
|
||||
}
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
function bytesToRead(bytes) {
|
||||
const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB'];
|
||||
let num = 0;
|
||||
|
||||
while (bytes > 1024) {
|
||||
bytes /= 1024;
|
||||
++num;
|
||||
}
|
||||
|
||||
return `${bytes.toFixed(1)} ${units[num]}`;
|
||||
}
|
||||
|
||||
|
||||
async function getStats(prisma, config) {
|
||||
const size = await sizeOfDir(join(process.cwd(), config.uploader.directory));
|
||||
const byUser = await prisma.image.groupBy({
|
||||
by: ['userId'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
});
|
||||
const count_users = await prisma.user.count();
|
||||
|
||||
const count_by_user = [];
|
||||
for (let i = 0, L = byUser.length; i !== L; ++i) {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: byUser[i].userId,
|
||||
},
|
||||
});
|
||||
|
||||
count_by_user.push({
|
||||
username: user.username,
|
||||
count: byUser[i]._count._all,
|
||||
});
|
||||
}
|
||||
|
||||
const count = await prisma.image.count();
|
||||
const viewsCount = await prisma.image.groupBy({
|
||||
by: ['views'],
|
||||
_sum: {
|
||||
views: true,
|
||||
},
|
||||
});
|
||||
|
||||
const typesCount = await prisma.image.groupBy({
|
||||
by: ['mimetype'],
|
||||
_count: {
|
||||
mimetype: true,
|
||||
},
|
||||
});
|
||||
const types_count = [];
|
||||
for (let i = 0, L = typesCount.length; i !== L; ++i) types_count.push({ mimetype: typesCount[i].mimetype, count: typesCount[i]._count.mimetype });
|
||||
|
||||
return {
|
||||
size: bytesToRead(size),
|
||||
size_num: size,
|
||||
count,
|
||||
count_by_user: count_by_user.sort((a,b) => b.count-a.count),
|
||||
count_users,
|
||||
views_count: (viewsCount[0]?._sum?.views ?? 0),
|
||||
types_count: types_count.sort((a,b) => b.count-a.count),
|
||||
};
|
||||
}
|
||||
@@ -1,45 +1,40 @@
|
||||
const Logger = require('../src/lib/logger');
|
||||
const yup = require('yup');
|
||||
|
||||
function dot(str, obj) {
|
||||
return str.split('.').reduce((a,b) => a[b], obj);
|
||||
}
|
||||
|
||||
const path = (path, type) => ({ path, type });
|
||||
const validator = yup.object({
|
||||
core: yup.object({
|
||||
secure: yup.bool().default(false),
|
||||
secret: yup.string().min(8).required(),
|
||||
host: yup.string().default('0.0.0.0'),
|
||||
port: yup.number().default(3000),
|
||||
database_url: yup.string().required(),
|
||||
logger: yup.boolean().default(true),
|
||||
stats_interval: yup.number().default(1800),
|
||||
}).required(),
|
||||
uploader: yup.object({
|
||||
route: yup.string().required(),
|
||||
length: yup.number().default(6),
|
||||
directory: yup.string().required(),
|
||||
admin_limit: yup.number().default(104900000),
|
||||
user_limit: yup.number().default(104900000),
|
||||
disabled_extensions: yup.array().default([]),
|
||||
}).required(),
|
||||
urls: yup.object({
|
||||
route: yup.string().required(),
|
||||
length: yup.number().default(6),
|
||||
}).required(),
|
||||
ratelimit: yup.object({
|
||||
user: yup.number().default(0),
|
||||
admin: yup.number().default(0),
|
||||
}),
|
||||
});
|
||||
|
||||
module.exports = async config => {
|
||||
const paths = [
|
||||
path('core.secure', 'boolean'),
|
||||
path('core.secret', 'string'),
|
||||
path('core.host', 'string'),
|
||||
path('core.port', 'number'),
|
||||
path('core.database_url', 'string'),
|
||||
path('uploader.route', 'string'),
|
||||
path('uploader.length', 'number'),
|
||||
path('uploader.directory', 'string'),
|
||||
path('uploader.admin_limit', 'number'),
|
||||
path('uploader.user_limit', 'number'),
|
||||
path('uploader.disabled_extentions', 'object'),
|
||||
];
|
||||
|
||||
let errors = 0;
|
||||
|
||||
for (let i = 0, L = paths.length; i !== L; ++i) {
|
||||
const path = paths[i];
|
||||
const value = dot(path.path, config);
|
||||
if (value === undefined) {
|
||||
Logger.get('config').error(`there was no ${path.path} in config which was required`);
|
||||
++errors;
|
||||
}
|
||||
|
||||
const type = typeof value;
|
||||
if (value !== undefined && type !== path.type) {
|
||||
Logger.get('config').error(`expected ${path.type} on ${path.path}, but got ${type}`);
|
||||
++errors;
|
||||
}
|
||||
}
|
||||
|
||||
if (errors !== 0) {
|
||||
Logger.get('config').error(`exiting due to ${errors} errors`);
|
||||
process.exit(1);
|
||||
}
|
||||
module.exports = config => {
|
||||
try {
|
||||
return validator.validateSync(config, { abortEarly: false });
|
||||
} catch (e) {
|
||||
throw `${e.errors.length} errors occured\n${e.errors.map(x => '\t' + x).join('\n')}`;
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Snackbar, Alert as MuiAlert } from '@material-ui/core';
|
||||
import { Snackbar, Alert as MuiAlert } from '@mui/material';
|
||||
|
||||
export default function Alert({ open, setOpen, severity, message }) {
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Backdrop as MuiBackdrop,
|
||||
CircularProgress
|
||||
} from '@material-ui/core';
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
|
||||
export default function Backdrop({ open }) {
|
||||
return (
|
||||
|
||||
@@ -2,8 +2,8 @@ import React from 'react';
|
||||
import {
|
||||
Card as MuiCard,
|
||||
CardContent,
|
||||
Typography
|
||||
} from '@material-ui/core';
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
|
||||
export default function Card(props) {
|
||||
const { name, children, ...other } = props;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@material-ui/core';
|
||||
import { Box } from '@mui/material';
|
||||
|
||||
export default function CenteredBox({ children, ...other }) {
|
||||
return (
|
||||
|
||||
@@ -8,15 +8,15 @@ import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogActions,
|
||||
DialogContent
|
||||
} from '@material-ui/core';
|
||||
import AudioIcon from '@material-ui/icons/Audiotrack';
|
||||
DialogContent,
|
||||
} from '@mui/material';
|
||||
import AudioIcon from '@mui/icons-material/Audiotrack';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
|
||||
export default function Image({ image, updateImages }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [t,] = useState(image.mimetype.split('/')[0]);
|
||||
const [t] = useState(image.mimetype.split('/')[0]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
const res = await useFetch('/api/user/files', 'DELETE', { id: image.id });
|
||||
@@ -40,7 +40,7 @@ export default function Image({ image, updateImages }) {
|
||||
'video': <video controls {...props} />,
|
||||
// eslint-disable-next-line jsx-a11y/alt-text
|
||||
'image': <img {...props} />,
|
||||
'audio': <audio controls {...props} />
|
||||
'audio': <audio controls {...props} />,
|
||||
}[t];
|
||||
};
|
||||
|
||||
|
||||
@@ -22,8 +22,7 @@ import {
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
Select,
|
||||
} from '@material-ui/core';
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Menu as MenuIcon,
|
||||
Home as HomeIcon,
|
||||
@@ -35,10 +34,12 @@ import {
|
||||
Logout as LogoutIcon,
|
||||
PeopleAlt as UsersIcon,
|
||||
Brush as BrushIcon,
|
||||
} from '@material-ui/icons';
|
||||
Link as URLIcon,
|
||||
} from '@mui/icons-material';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import Backdrop from './Backdrop';
|
||||
import { friendlyThemeName, themes } from './Theming';
|
||||
import { friendlyThemeName, themes } from 'components/Theming';
|
||||
import Select from 'components/input/Select';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useStoreDispatch } from 'lib/redux/store';
|
||||
import { updateUser } from 'lib/redux/reducers/user';
|
||||
@@ -48,18 +49,23 @@ const items = [
|
||||
{
|
||||
icon: <HomeIcon />,
|
||||
text: 'Home',
|
||||
link: '/dashboard'
|
||||
link: '/dashboard',
|
||||
},
|
||||
{
|
||||
icon: <FolderIcon />,
|
||||
text: 'Files',
|
||||
link: '/dashboard/files'
|
||||
link: '/dashboard/files',
|
||||
},
|
||||
{
|
||||
icon: <URLIcon />,
|
||||
text: 'URLs',
|
||||
link: '/dashboard/urls',
|
||||
},
|
||||
{
|
||||
icon: <UploadIcon />,
|
||||
text: 'Upload',
|
||||
link: '/dashboard/upload'
|
||||
}
|
||||
link: '/dashboard/upload',
|
||||
},
|
||||
];
|
||||
|
||||
const drawerWidth = 240;
|
||||
@@ -81,7 +87,7 @@ function CopyTokenDialog({ open, setOpen, token }) {
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id='copy-dialog-description'>
|
||||
Make sure you don't share this token with anyone as they will be able to upload images on your behalf.
|
||||
Make sure you don't share this token with anyone as they will be able to upload files on your behalf.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
@@ -151,9 +157,9 @@ export default function Layout({ children, user, loading, noPaper }) {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleUpdateTheme = async (event: React.ChangeEvent<{ value: string }>) => {
|
||||
const handleUpdateTheme = async event => {
|
||||
const newUser = await useFetch('/api/user', 'PATCH', {
|
||||
systemTheme: event.target.value || 'dark_blue'
|
||||
systemTheme: event.target.value || 'dark_blue',
|
||||
});
|
||||
|
||||
setSystemTheme(newUser.systemTheme);
|
||||
@@ -168,7 +174,7 @@ export default function Layout({ children, user, loading, noPaper }) {
|
||||
<ResetTokenDialog open={resetOpen} setOpen={setResetOpen} setToken={setToken} />
|
||||
<Toolbar
|
||||
sx={{
|
||||
width: { xs: drawerWidth }
|
||||
width: { xs: drawerWidth },
|
||||
}}
|
||||
>
|
||||
<AppBar
|
||||
@@ -177,7 +183,7 @@ export default function Layout({ children, user, loading, noPaper }) {
|
||||
sx={{
|
||||
borderBottom: 1,
|
||||
borderBottomColor: t => t.palette.divider,
|
||||
display: { xs: 'none', sm: 'block' }
|
||||
display: { xs: 'none', sm: 'block' },
|
||||
}}
|
||||
>
|
||||
<Toolbar>
|
||||
@@ -293,7 +299,7 @@ export default function Layout({ children, user, loading, noPaper }) {
|
||||
elevation={0}
|
||||
sx={{
|
||||
width: { sm: `calc(100% - ${drawerWidth}px)` },
|
||||
ml: { sm: `${drawerWidth}px` }
|
||||
ml: { sm: `${drawerWidth}px` },
|
||||
}}
|
||||
>
|
||||
<Toolbar>
|
||||
@@ -363,7 +369,7 @@ export default function Layout({ children, user, loading, noPaper }) {
|
||||
component='nav'
|
||||
sx={{
|
||||
width: { sm: drawerWidth },
|
||||
flexShrink: { sm: 0 }
|
||||
flexShrink: { sm: 0 },
|
||||
}}
|
||||
>
|
||||
<Drawer
|
||||
@@ -373,11 +379,11 @@ export default function Layout({ children, user, loading, noPaper }) {
|
||||
open={mobileOpen}
|
||||
elevation={0}
|
||||
ModalProps={{
|
||||
keepMounted: true
|
||||
keepMounted: true,
|
||||
}}
|
||||
sx={{
|
||||
display: { xs: 'block', sm: 'none' },
|
||||
'* .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth }
|
||||
'* .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
|
||||
}}
|
||||
>
|
||||
{drawer}
|
||||
@@ -386,7 +392,7 @@ export default function Layout({ children, user, loading, noPaper }) {
|
||||
variant='permanent'
|
||||
sx={{
|
||||
display: { xs: 'none', sm: 'block' },
|
||||
'* .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth }
|
||||
'* .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
|
||||
}}
|
||||
open
|
||||
>
|
||||
|
||||
@@ -4,7 +4,7 @@ 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';
|
||||
import MuiLink from '@mui/material/Link';
|
||||
|
||||
export const NextLinkComposed = forwardRef(function NextLinkComposed(props: any, ref) {
|
||||
const { to, linkAs, href, replace, scroll, passHref, shallow, prefetch, locale, ...other } =
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { ThemeProvider } from '@emotion/react';
|
||||
import { CssBaseline } from '@material-ui/core';
|
||||
import { CssBaseline } from '@mui/material';
|
||||
|
||||
// themes
|
||||
import dark_blue from 'lib/themes/dark_blue';
|
||||
@@ -23,7 +23,7 @@ export const themes = {
|
||||
'ayu_light': ayu_light,
|
||||
'nord': nord,
|
||||
'polar': polar,
|
||||
'dracula': dracula
|
||||
'dracula': dracula,
|
||||
};
|
||||
|
||||
export const friendlyThemeName = {
|
||||
@@ -34,7 +34,7 @@ export const friendlyThemeName = {
|
||||
'ayu_light': 'Ayu Light',
|
||||
'nord': 'Nord',
|
||||
'polar': 'Polar',
|
||||
'dracula': 'Dracula'
|
||||
'dracula': 'Dracula',
|
||||
};
|
||||
|
||||
export default function ZiplineTheming({ Component, pageProps }) {
|
||||
@@ -54,8 +54,8 @@ export default function ZiplineTheming({ Component, pageProps }) {
|
||||
border: user.customTheme.border,
|
||||
background: {
|
||||
main: user.customTheme.mainBackground,
|
||||
paper: user.customTheme.paperBackground
|
||||
}
|
||||
paper: user.customTheme.paperBackground,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
t = themes[user.systemTheme] ?? themes.dark_blue;
|
||||
|
||||
28
src/components/input/Select.tsx
Normal file
28
src/components/input/Select.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { styled, Select as MuiSelect, Input } from '@mui/material';
|
||||
import { makeStyles } from '@mui/styles';
|
||||
import { useTheme } from '@mui/system';
|
||||
|
||||
const CssInput = styled(Input)(({ theme }) => ({
|
||||
'& label.Mui-focused': {
|
||||
color: 'white',
|
||||
},
|
||||
'&': {
|
||||
color: 'white',
|
||||
},
|
||||
'&:before': {
|
||||
borderBottomColor: '#fff8',
|
||||
},
|
||||
'&&:hover:before': {
|
||||
borderBottomColor: theme.palette.primary.dark,
|
||||
},
|
||||
'&:after': {
|
||||
borderBottomColor: theme.palette.primary.main,
|
||||
},
|
||||
}));
|
||||
|
||||
export default function Select({ ...other }) {
|
||||
return (
|
||||
<MuiSelect input={<CssInput />} {...other}/>
|
||||
);
|
||||
}
|
||||
41
src/components/input/TextInput.tsx
Normal file
41
src/components/input/TextInput.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { styled, TextField, Box } from '@mui/material';
|
||||
|
||||
const CssTextField = styled(TextField)(({ theme }) => ({
|
||||
'& label.Mui-focused': {
|
||||
color: 'white',
|
||||
},
|
||||
'& input': {
|
||||
color: 'white',
|
||||
},
|
||||
'& .MuiInput-underline:before': {
|
||||
borderBottomColor: '#fff8',
|
||||
},
|
||||
'&& .MuiInput-underline:hover:before': {
|
||||
borderBottomColor: theme.palette.primary.dark,
|
||||
},
|
||||
'& .MuiInput-underline:after': {
|
||||
borderBottomColor: theme.palette.primary.main,
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
export default function TextInput({ id, label, formik, ...other }) {
|
||||
return (
|
||||
<Box>
|
||||
<CssTextField
|
||||
id={id}
|
||||
name={id}
|
||||
label={label}
|
||||
value={formik.values[id]}
|
||||
onChange={formik.handleChange}
|
||||
error={formik.touched[id] && Boolean(formik.errors[id])}
|
||||
helperText={formik.touched[id] && formik.errors[id]}
|
||||
// @ts-ignore
|
||||
variant='standard'
|
||||
sx={{ pb: 0.5 }}
|
||||
{...other}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
Skeleton,
|
||||
CardActionArea,
|
||||
CardMedia,
|
||||
Card as MuiCard
|
||||
} from '@material-ui/core';
|
||||
import AudioIcon from '@material-ui/icons/Audiotrack';
|
||||
Card as MuiCard,
|
||||
} from '@mui/material';
|
||||
import AudioIcon from '@mui/icons-material/Audiotrack';
|
||||
|
||||
import Link from 'components/Link';
|
||||
import Card from 'components/Card';
|
||||
@@ -48,7 +48,7 @@ const columns = [
|
||||
minWidth: 170,
|
||||
align: 'right' as Aligns,
|
||||
format: (value) => new Date(value).toLocaleString(),
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
function StatText({ children }) {
|
||||
@@ -125,9 +125,9 @@ export default function Dashboard() {
|
||||
return (
|
||||
<>
|
||||
<Typography variant='h4'>Welcome back {user?.username}</Typography>
|
||||
<Typography color='GrayText' pb={2}>You have <b>{images.length ? images.length : '...'}</b> images</Typography>
|
||||
<Typography color='GrayText' pb={2}>You have <b>{images.length ? images.length : '...'}</b> files</Typography>
|
||||
|
||||
<Typography variant='h4'>Recent Images</Typography>
|
||||
<Typography variant='h4'>Recent Files</Typography>
|
||||
<Grid container spacing={4} py={2}>
|
||||
{recent.length ? recent.map(image => (
|
||||
<Grid item xs={12} sm={3} key={image.id}>
|
||||
@@ -172,7 +172,7 @@ export default function Dashboard() {
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Card name='Images' sx={{ my: 2 }} elevation={0} variant='outlined'>
|
||||
<Link href='/dashboard/images' pb={2}>View Gallery</Link>
|
||||
<Link href='/dashboard/files' pb={2}>View Files</Link>
|
||||
<TableContainer sx={{ maxHeight: 440 }}>
|
||||
<Table size='small'>
|
||||
<TableHead>
|
||||
@@ -225,11 +225,11 @@ export default function Dashboard() {
|
||||
onPageChange={handleChangePage}
|
||||
onRowsPerPageChange={handleChangeRowsPerPage} />
|
||||
</Card>
|
||||
<Card name='Images per User' sx={{ height: '100%', my: 2 }} elevation={0} variant='outlined'>
|
||||
<Card name='Files per User' sx={{ height: '100%', my: 2 }} elevation={0} variant='outlined'>
|
||||
<StatTable
|
||||
columns={[
|
||||
{ id: 'username', name: 'Name' },
|
||||
{ id: 'count', name: 'Images' }
|
||||
{ id: 'count', name: 'Files' },
|
||||
]}
|
||||
rows={stats ? stats.count_by_user : []} />
|
||||
</Card>
|
||||
@@ -237,7 +237,7 @@ export default function Dashboard() {
|
||||
<StatTable
|
||||
columns={[
|
||||
{ id: 'mimetype', name: 'Type' },
|
||||
{ id: 'count', name: 'Count' }
|
||||
{ id: 'count', name: 'Count' },
|
||||
]}
|
||||
rows={stats ? stats.types_count : []} />
|
||||
</Card>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Grid, Pagination, Box, Typography, Accordion, AccordionSummary, AccordionDetails } from '@material-ui/core';
|
||||
import { ExpandMore } from '@material-ui/icons';
|
||||
import { Grid, Pagination, Box, Typography, Accordion, AccordionSummary, AccordionDetails } from '@mui/material';
|
||||
import { ExpandMore } from '@mui/icons-material';
|
||||
|
||||
import Backdrop from 'components/Backdrop';
|
||||
import ZiplineImage from 'components/Image';
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import React, { useState } from 'react';
|
||||
import { TextField, Button, Box, Typography, Select, MenuItem } from '@material-ui/core';
|
||||
import Download from '@material-ui/icons/Download';
|
||||
import { Button, Box, Typography, MenuItem, Tooltip } from '@mui/material';
|
||||
import Download from '@mui/icons-material/Download';
|
||||
|
||||
import { useFormik } from 'formik';
|
||||
import * as yup from 'yup';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import Backdrop from 'components/Backdrop';
|
||||
import Alert from 'components/Alert';
|
||||
import TextInput from 'components/input/TextInput';
|
||||
import Select from 'components/input/Select';
|
||||
import Link from 'components/Link';
|
||||
import { useStoreDispatch, useStoreSelector } from 'lib/redux/store';
|
||||
import { updateUser } from 'lib/redux/reducers/user';
|
||||
import { useRouter } from 'next/router';
|
||||
@@ -14,7 +17,7 @@ import { useRouter } from 'next/router';
|
||||
const validationSchema = yup.object({
|
||||
username: yup
|
||||
.string()
|
||||
.required('Username is required')
|
||||
.required('Username is required'),
|
||||
});
|
||||
|
||||
const themeValidationSchema = yup.object({
|
||||
@@ -56,21 +59,19 @@ const themeValidationSchema = yup.object({
|
||||
|
||||
});
|
||||
|
||||
function TextInput({ id, label, formik, ...other }) {
|
||||
function VarsTooltip({ children }) {
|
||||
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}
|
||||
/>
|
||||
<Tooltip title={
|
||||
<>
|
||||
<Typography><b>{'{image.file}'}</b> - file name</Typography>
|
||||
<Typography><b>{'{image.mimetype}'}</b> - mimetype</Typography>
|
||||
<Typography><b>{'{image.id}'}</b> - id of the image</Typography>
|
||||
<Typography><b>{'{user.name}'}</b> - your username</Typography>
|
||||
visit <Link href='https://zipline.diced.me/docs/variables'>the docs</Link> for more variables
|
||||
</>
|
||||
}>
|
||||
{children}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -94,11 +95,11 @@ export default function Manage() {
|
||||
Headers: {
|
||||
Authorization: user?.token,
|
||||
...(withEmbed && {Embed: 'true'}),
|
||||
...(withZws && {ZWS: 'true'})
|
||||
...(withZws && {ZWS: 'true'}),
|
||||
},
|
||||
URL: '$json:url$',
|
||||
Body: 'MultipartFormData',
|
||||
FileFormName: 'file'
|
||||
FileFormName: 'file',
|
||||
};
|
||||
|
||||
const pseudoElement = document.createElement('a');
|
||||
@@ -115,7 +116,8 @@ export default function Manage() {
|
||||
username: user.username,
|
||||
password: '',
|
||||
embedTitle: user.embedTitle ?? '',
|
||||
embedColor: user.embedColor
|
||||
embedColor: user.embedColor,
|
||||
embedSiteName: user.embedSiteName ?? '',
|
||||
},
|
||||
validationSchema,
|
||||
onSubmit: async values => {
|
||||
@@ -123,6 +125,7 @@ export default function Manage() {
|
||||
const cleanPassword = values.password.trim();
|
||||
const cleanEmbedTitle = values.embedTitle.trim();
|
||||
const cleanEmbedColor = values.embedColor.trim();
|
||||
const cleanEmbedSiteName = values.embedSiteName.trim();
|
||||
|
||||
if (cleanUsername === '') return formik.setFieldError('username', 'Username can\'t be nothing');
|
||||
|
||||
@@ -132,7 +135,8 @@ export default function Manage() {
|
||||
username: cleanUsername,
|
||||
password: cleanPassword === '' ? null : cleanPassword,
|
||||
embedTitle: cleanEmbedTitle === '' ? null : cleanEmbedTitle,
|
||||
embedColor: cleanEmbedColor === '' ? null : cleanEmbedColor
|
||||
embedColor: cleanEmbedColor === '' ? null : cleanEmbedColor,
|
||||
embedSiteName: cleanEmbedSiteName === '' ? null : cleanEmbedSiteName,
|
||||
};
|
||||
|
||||
const newUser = await useFetch('/api/user', 'PATCH', data);
|
||||
@@ -149,7 +153,7 @@ export default function Manage() {
|
||||
setSeverity('success');
|
||||
setOpen(true);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const customThemeFormik = useFormik({
|
||||
@@ -182,7 +186,7 @@ export default function Manage() {
|
||||
setSeverity('success');
|
||||
setOpen(true);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -190,12 +194,16 @@ export default function Manage() {
|
||||
<Backdrop open={loading}/>
|
||||
<Alert open={open} setOpen={setOpen} message={message} severity={severity} />
|
||||
|
||||
<Typography variant='h4' pb={2}>Manage User</Typography>
|
||||
<Typography variant='h4'>Manage User</Typography>
|
||||
<VarsTooltip>
|
||||
<Typography variant='caption' color='GrayText'>Want to use variables in embed text? Hover on this or visit <Link href='https://zipline.diced.me/docs/variables'>the docs</Link> for more variables</Typography>
|
||||
</VarsTooltip>
|
||||
<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} />
|
||||
<TextInput fullWidth id='username' label='Username' formik={formik} />
|
||||
<TextInput fullWidth id='password' label='Password' formik={formik} type='password' />
|
||||
<TextInput fullWidth id='embedTitle' label='Embed Title' formik={formik} />
|
||||
<TextInput fullWidth id='embedColor' label='Embed Color' formik={formik} />
|
||||
<TextInput fullWidth id='embedSiteName' label='Embed Site Name' formik={formik} />
|
||||
<Box
|
||||
display='flex'
|
||||
justifyContent='right'
|
||||
@@ -223,14 +231,14 @@ export default function Manage() {
|
||||
<MenuItem value='dark'>Dark Theme</MenuItem>
|
||||
<MenuItem value='light'>Light Theme</MenuItem>
|
||||
</Select>
|
||||
<TextInput id='primary' label='Primary Color' formik={customThemeFormik} />
|
||||
<TextInput id='secondary' label='Secondary Color' formik={customThemeFormik} />
|
||||
<TextInput id='error' label='Error Color' formik={customThemeFormik} />
|
||||
<TextInput id='warning' label='Warning Color' formik={customThemeFormik} />
|
||||
<TextInput id='info' label='Info Color' formik={customThemeFormik} />
|
||||
<TextInput id='border' label='Border Color' formik={customThemeFormik} />
|
||||
<TextInput id='mainBackground' label='Main Background' formik={customThemeFormik} />
|
||||
<TextInput id='paperBackground' label='Paper Background' formik={customThemeFormik} />
|
||||
<TextInput fullWidth id='primary' label='Primary Color' formik={customThemeFormik} />
|
||||
<TextInput fullWidth id='secondary' label='Secondary Color' formik={customThemeFormik} />
|
||||
<TextInput fullWidth id='error' label='Error Color' formik={customThemeFormik} />
|
||||
<TextInput fullWidth id='warning' label='Warning Color' formik={customThemeFormik} />
|
||||
<TextInput fullWidth id='info' label='Info Color' formik={customThemeFormik} />
|
||||
<TextInput fullWidth id='border' label='Border Color' formik={customThemeFormik} />
|
||||
<TextInput fullWidth id='mainBackground' label='Main Background' formik={customThemeFormik} />
|
||||
<TextInput fullWidth id='paperBackground' label='Paper Background' formik={customThemeFormik} />
|
||||
<Box
|
||||
display='flex'
|
||||
justifyContent='right'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Typography, Button, CardActionArea, Paper, Box } from '@material-ui/core';
|
||||
import { Upload as UploadIcon } from '@material-ui/icons';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Typography, Button, CardActionArea, Paper, Box } from '@mui/material';
|
||||
import { Upload as UploadIcon } from '@mui/icons-material';
|
||||
import Dropzone from 'react-dropzone';
|
||||
|
||||
import Backdrop from 'components/Backdrop';
|
||||
@@ -18,19 +18,28 @@ export default function Upload({ route }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [severity, setSeverity] = useState('success');
|
||||
const [message, setMessage] = useState('Saved');
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('paste', (e: ClipboardEvent) => {
|
||||
const item = Array.from(e.clipboardData.items).find(x => /^image/.test(x.type));
|
||||
const blob = item.getAsFile();
|
||||
setFiles([...files, new File([blob], blob.name, { type: blob.type })]);
|
||||
});
|
||||
});
|
||||
|
||||
const handleUpload = async () => {
|
||||
const body = new FormData();
|
||||
|
||||
|
||||
for (let i = 0; i !== files.length; ++i) body.append('file', files[i]);
|
||||
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': user.token
|
||||
'Authorization': user.token,
|
||||
},
|
||||
body
|
||||
body,
|
||||
});
|
||||
const json = await res.json();
|
||||
if (res.ok && json.error === undefined) {
|
||||
@@ -65,7 +74,7 @@ export default function Upload({ route }) {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
display: 'block',
|
||||
p: 5
|
||||
p: 5,
|
||||
}}
|
||||
{...getRootProps()}
|
||||
>
|
||||
|
||||
179
src/components/pages/Urls.tsx
Normal file
179
src/components/pages/Urls.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Grid, Card, CardHeader, Box, Typography, IconButton, Link, Dialog, DialogContent, DialogActions, Button, DialogTitle, TextField } from '@mui/material';
|
||||
import { ContentCopy as CopyIcon, DeleteForever as DeleteIcon, Add as AddIcon } from '@mui/icons-material';
|
||||
|
||||
import Backdrop from 'components/Backdrop';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import Alert from 'components/Alert';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import { useFormik } from 'formik';
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import * as yup from 'yup';
|
||||
|
||||
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 Urls() {
|
||||
const user = useStoreSelector(state => state.user);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [urls, setURLS] = useState([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [severity, setSeverity] = useState('success');
|
||||
const [message, setMessage] = useState('Deleted');
|
||||
|
||||
const updateURLs = async () => {
|
||||
setLoading(true);
|
||||
const urls = await useFetch('/api/user/urls');
|
||||
|
||||
setURLS(urls);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const deleteURL = async u => {
|
||||
const url = await useFetch('/api/user/urls', 'DELETE', { id: u.id });
|
||||
if (url.error) {
|
||||
setSeverity('error');
|
||||
setMessage('Error: ' + url.error);
|
||||
setOpen(true);
|
||||
} else {
|
||||
setSeverity('success');
|
||||
setMessage(`Deleted ${u.vanity ?? u.id}`);
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
updateURLs();
|
||||
};
|
||||
|
||||
const copyURL = u => {
|
||||
copy(`${window.location.protocol}//${window.location.host}${u.url}`);
|
||||
setSeverity('success');
|
||||
setMessage(`Copied URL: ${window.location.protocol}//${window.location.host}${u.url}`);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
url: '',
|
||||
vanity: '',
|
||||
},
|
||||
validationSchema: yup.object({
|
||||
|
||||
}),
|
||||
onSubmit: async (values) => {
|
||||
const cleanURL = values.url.trim();
|
||||
const cleanVanity = values.vanity.trim();
|
||||
|
||||
if (cleanURL === '') return formik.setFieldError('username', 'Username can\'t be nothing');
|
||||
|
||||
const data = {
|
||||
url: cleanURL,
|
||||
vanity: cleanVanity === '' ? null : cleanVanity,
|
||||
};
|
||||
|
||||
setCreateOpen(false);
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/shorten', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': user.token,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
if (json.error) {
|
||||
setSeverity('error');
|
||||
setMessage('Could\'nt create URL: ' + json.error);
|
||||
setOpen(true);
|
||||
} else {
|
||||
setSeverity('success');
|
||||
setMessage('Copied URL: ' + json.url);
|
||||
copy(json.url);
|
||||
setOpen(true);
|
||||
setCreateOpen(false);
|
||||
updateURLs();
|
||||
}
|
||||
setLoading(false);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
updateURLs();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Backdrop open={loading}/>
|
||||
<Alert open={open} setOpen={setOpen} message={message} severity={severity} />
|
||||
|
||||
<Dialog open={createOpen} onClose={() => setCreateOpen(false)}>
|
||||
<DialogTitle>Shorten URL</DialogTitle>
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<DialogContent>
|
||||
<TextInput id='url' label='URL' formik={formik} />
|
||||
<TextInput id='vanity' label='Vanity' formik={formik} />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setCreateOpen(false)} color='inherit' autoFocus>Cancel</Button>
|
||||
<Button type='submit' color='inherit'>
|
||||
Create
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
{!urls.length ? (
|
||||
<Box
|
||||
display='flex'
|
||||
justifyContent='center'
|
||||
alignItems='center'
|
||||
pt={2}
|
||||
pb={3}
|
||||
>
|
||||
<Typography variant='h4' sx={{ mb: 1 }}>No URLs <IconButton onClick={() => setCreateOpen(true)}><AddIcon/></IconButton></Typography>
|
||||
</Box>
|
||||
) : <Typography variant='h4' sx={{ mb: 1 }}>URLs <IconButton onClick={() => setCreateOpen(true)}><AddIcon/></IconButton></Typography>}
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{urls.length ? urls.map(url => (
|
||||
<Grid item xs={12} sm={3} key={url.id}>
|
||||
<Card sx={{ maxWidth: '100%' }}>
|
||||
<CardHeader
|
||||
action={
|
||||
<>
|
||||
<IconButton aria-label='copy' onClick={() => copyURL(url)}>
|
||||
<CopyIcon />
|
||||
</IconButton>
|
||||
<IconButton aria-label='delete' onClick={() => deleteURL(url)}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</>
|
||||
}
|
||||
title={url.vanity ?? url.id}
|
||||
subheader={<Link href={url.destination}>{url.destination}</Link>}
|
||||
/>
|
||||
</Card>
|
||||
</Grid>
|
||||
)) : null}
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -13,9 +13,9 @@ import {
|
||||
Button,
|
||||
TextField,
|
||||
Switch,
|
||||
FormControlLabel
|
||||
} from '@material-ui/core';
|
||||
import { Delete as DeleteIcon, Add as AddIcon } from '@material-ui/icons';
|
||||
FormControlLabel,
|
||||
} from '@mui/material';
|
||||
import { Delete as DeleteIcon, Add as AddIcon } from '@mui/icons-material';
|
||||
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import Backdrop from 'components/Backdrop';
|
||||
@@ -59,7 +59,7 @@ function CreateUserDialog({ open, setOpen, updateUsers, setSeverity, setMessage,
|
||||
initialValues: {
|
||||
username: '',
|
||||
password: '',
|
||||
administrator: false
|
||||
administrator: false,
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
const cleanUsername = values.username.trim();
|
||||
@@ -70,7 +70,7 @@ function CreateUserDialog({ open, setOpen, updateUsers, setSeverity, setMessage,
|
||||
const data = {
|
||||
username: cleanUsername,
|
||||
password: cleanPassword,
|
||||
administrator: values.administrator
|
||||
administrator: values.administrator,
|
||||
};
|
||||
|
||||
setOpen(false);
|
||||
@@ -87,7 +87,7 @@ function CreateUserDialog({ open, setOpen, updateUsers, setSeverity, setMessage,
|
||||
updateUsers();
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -96,7 +96,7 @@ function CreateUserDialog({ open, setOpen, updateUsers, setSeverity, setMessage,
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
PaperProps={{
|
||||
elevation: 1
|
||||
elevation: 1,
|
||||
}}
|
||||
>
|
||||
<DialogTitle>
|
||||
@@ -151,7 +151,7 @@ export default function Users() {
|
||||
|
||||
const handleDelete = async (user) => {
|
||||
const res = await useFetch('/api/users', 'DELETE', {
|
||||
id: user.id
|
||||
id: user.id,
|
||||
});
|
||||
if (res.error) {
|
||||
setMessage(`Could not delete ${user.username}`);
|
||||
|
||||
16
src/lib/clientUtils.ts
Normal file
16
src/lib/clientUtils.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Image, User } from '@prisma/client';
|
||||
|
||||
export function parse(str: string, image: Image, user: User) {
|
||||
if (!str) return null;
|
||||
|
||||
return str
|
||||
.replace(/{user.admin}/gi, user.administrator ? 'yes' : 'no')
|
||||
.replace(/{user.id}/gi, user.id.toString())
|
||||
.replace(/{user.name}/gi, user.username)
|
||||
.replace(/{image.id}/gi, image.id.toString())
|
||||
.replace(/{image.mime}/gi, image.mimetype)
|
||||
.replace(/{image.file}/gi, image.file)
|
||||
.replace(/{image.created_at.full_string}/gi, image.created_at.toLocaleString())
|
||||
.replace(/{image.created_at.time_string}/gi, image.created_at.toLocaleTimeString())
|
||||
.replace(/{image.created_at.date_string}/gi, image.created_at.toLocaleDateString());
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Config } from './types';
|
||||
import readConfig from './readConfig';
|
||||
import validateConfig from '../../server/validateConfig';
|
||||
|
||||
if (!global.config) global.config = readConfig() as Config;
|
||||
if (!global.config) global.config = validateConfig(readConfig()) as unknown as Config;
|
||||
|
||||
export default global.config;
|
||||
@@ -5,7 +5,7 @@ export default async function useFetch(url: string, method: 'GET' | 'POST' | 'PA
|
||||
const res = await global.fetch(url, {
|
||||
body: body ? JSON.stringify(body) : null,
|
||||
method,
|
||||
headers
|
||||
headers,
|
||||
});
|
||||
|
||||
return res.json();
|
||||
|
||||
@@ -14,21 +14,17 @@ export default function login() {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (!loading && user) return;
|
||||
load();
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const { format } = require('fecha');
|
||||
const { yellow, blueBright, magenta, red, cyan } = require('colorette');
|
||||
const { blueBright, red, cyan } = require('colorette');
|
||||
|
||||
class Logger {
|
||||
static get(clas) {
|
||||
@@ -19,7 +19,7 @@ class Logger {
|
||||
}
|
||||
|
||||
error(error) {
|
||||
console.log(this.formatMessage('ERROR', this.name, error.toString()));
|
||||
console.log(this.formatMessage('ERROR', this.name, error.stack ?? error));
|
||||
}
|
||||
|
||||
formatMessage(level, name, message) {
|
||||
@@ -31,10 +31,6 @@ class Logger {
|
||||
switch (level) {
|
||||
case 'INFO':
|
||||
return cyan('INFO ');
|
||||
case 'DEBUG':
|
||||
return yellow('DEBUG');
|
||||
case 'WARN':
|
||||
return magenta('WARN ');
|
||||
case 'ERROR':
|
||||
return red('ERROR');
|
||||
}
|
||||
|
||||
@@ -38,14 +38,19 @@ export type NextApiRes = NextApiResponse & {
|
||||
forbid: (message: string) => void;
|
||||
bad: (message: string) => void;
|
||||
json: (json: any) => void;
|
||||
ratelimited: () => void;
|
||||
setCookie: (name: string, value: unknown, options: CookieSerializeOptions) => void;
|
||||
}
|
||||
|
||||
export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse) => unknown) => (req: NextApiReq, res: NextApiRes) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Content-Allow-Methods', 'GET,HEAD,POST,OPTIONS');
|
||||
res.setHeader('Access-Control-Max-Age', '86400');
|
||||
|
||||
res.error = (message: string) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.json({
|
||||
error: message
|
||||
error: message,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -53,21 +58,26 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.status(403);
|
||||
res.json({
|
||||
error: '403: ' + message
|
||||
error: '403: ' + message,
|
||||
});
|
||||
};
|
||||
|
||||
res.bad = (message: string) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.status(401);
|
||||
res.json({
|
||||
error: '403: ' + message
|
||||
error: '403: ' + message,
|
||||
});
|
||||
};
|
||||
|
||||
res.ratelimited = () => {
|
||||
res.status(429);
|
||||
|
||||
res.json({
|
||||
error: '429: ratelimited',
|
||||
});
|
||||
};
|
||||
|
||||
res.json = (json: any) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
|
||||
res.end(JSON.stringify(json));
|
||||
};
|
||||
|
||||
@@ -82,7 +92,7 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
|
||||
res.setHeader('Set-Cookie', serialize(name, '', {
|
||||
path: '/',
|
||||
expires: new Date(1),
|
||||
maxAge: undefined
|
||||
maxAge: undefined,
|
||||
}));
|
||||
};
|
||||
req.user = async () => {
|
||||
@@ -92,7 +102,7 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: Number(userId)
|
||||
id: Number(userId),
|
||||
},
|
||||
select: {
|
||||
administrator: true,
|
||||
@@ -103,8 +113,8 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
|
||||
systemTheme: true,
|
||||
customTheme: true,
|
||||
token: true,
|
||||
username: true
|
||||
}
|
||||
username: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) return null;
|
||||
@@ -130,11 +140,11 @@ export const setCookie = (
|
||||
) => {
|
||||
|
||||
if ('maxAge' in options) {
|
||||
options.expires = new Date(Date.now() + options.maxAge);
|
||||
options.expires = new Date(Date.now() + options.maxAge * 1000);
|
||||
options.maxAge /= 1000;
|
||||
}
|
||||
|
||||
const signed = sign64(String(value), config.core.secret);
|
||||
|
||||
res.setHeader('Set-Cookie', serialize(name, signed, options));
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ const { existsSync, readFileSync } = require('fs');
|
||||
const { join } = require('path');
|
||||
const Logger = require('./logger');
|
||||
|
||||
const e = (val, type, fn, required = true) => ({ val, type, fn, required });
|
||||
const e = (val, type, fn) => ({ val, type, fn });
|
||||
|
||||
const envValues = [
|
||||
e('SECURE', 'boolean', (c, v) => c.core.secure = v),
|
||||
@@ -10,12 +10,21 @@ const envValues = [
|
||||
e('HOST', 'string', (c, v) => c.core.host = v),
|
||||
e('PORT', 'number', (c, v) => c.core.port = v),
|
||||
e('DATABASE_URL', 'string', (c, v) => c.core.database_url = v),
|
||||
e('LOGGER', 'boolean', (c, v) => c.core.logger = v ?? true),
|
||||
e('STATS_INTERVAL', 'number', (c, v) => c.core.stats_interval = v),
|
||||
|
||||
e('UPLOADER_ROUTE', 'string', (c, v) => c.uploader.route = v),
|
||||
e('UPLOADER_LENGTH', 'number', (c, v) => c.uploader.length = v),
|
||||
e('UPLOADER_DIRECTORY', 'string', (c, v) => c.uploader.directory = v),
|
||||
e('UPLOADER_ADMIN_LIMIT', 'number', (c, v) => c.uploader.admin_limit = v),
|
||||
e('UPLOADER_USER_LIMIT', 'number', (c, v) => c.uploader.user_limit = v),
|
||||
e('UPLOADER_DISABLED_EXTS', 'array', (c, v) => v ? c.uploader.disabled_extentions = v : c.uploader.disabled_extentions = [], false),
|
||||
e('UPLOADER_DISABLED_EXTS', 'array', (c, v) => v ? c.uploader.disabled_extentions = v : c.uploader.disabled_extentions = []),
|
||||
|
||||
e('URLS_ROUTE', 'string', (c, v) => c.urls.route = v),
|
||||
e('URLS_LENGTH', 'number', (c, v) => c.urls.length = v),
|
||||
|
||||
e('RATELIMIT_USER', 'number', (c, v) => c.ratelimit.user = v ?? 0),
|
||||
e('RATELIMIT_ADMIN', 'number', (c, v) => c.ratelimit.user = v ?? 0),
|
||||
];
|
||||
|
||||
module.exports = () => {
|
||||
@@ -39,6 +48,8 @@ function tryReadEnv() {
|
||||
host: undefined,
|
||||
port: undefined,
|
||||
database_url: undefined,
|
||||
logger: undefined,
|
||||
stats_interval: undefined,
|
||||
},
|
||||
uploader: {
|
||||
route: undefined,
|
||||
@@ -46,29 +57,32 @@ function tryReadEnv() {
|
||||
directory: undefined,
|
||||
admin_limit: undefined,
|
||||
user_limit: undefined,
|
||||
disabled_extentions: undefined
|
||||
}
|
||||
disabled_extentions: undefined,
|
||||
},
|
||||
urls: {
|
||||
route: undefined,
|
||||
length: undefined,
|
||||
},
|
||||
ratelimit: {
|
||||
user: undefined,
|
||||
admin: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
for (let i = 0, L = envValues.length; i !== L; ++i) {
|
||||
const envValue = envValues[i];
|
||||
let value = process.env[envValue.val];
|
||||
|
||||
if (envValue.required && !value) {
|
||||
Logger.get('config').error(`there is no config file or required environment variables (${envValue.val})... exiting...`);
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
envValues[i].fn(config, value);
|
||||
if (envValue.required) {
|
||||
if (!value) {
|
||||
envValues[i].fn(config, undefined);
|
||||
} else {
|
||||
envValues[i].fn(config, value);
|
||||
if (envValue.type === 'number') value = parseToNumber(value);
|
||||
else if (envValue.type === 'boolean') value = parseToBoolean(value);
|
||||
else if (envValue.type === 'array') value = parseToArray(value);
|
||||
envValues[i].fn(config, value);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface User {
|
||||
token: string;
|
||||
embedTitle: string;
|
||||
embedColor: string;
|
||||
embedSiteName: string;
|
||||
systemTheme: string;
|
||||
customTheme?: Theme;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,6 @@ export default createTheme({
|
||||
border: '#191e29',
|
||||
background: {
|
||||
main: '#0A0E14',
|
||||
paper: '#0D1016'
|
||||
}
|
||||
paper: '#0D1016',
|
||||
},
|
||||
});
|
||||
@@ -12,6 +12,6 @@ export default createTheme({
|
||||
border: '#e3e3e3',
|
||||
background: {
|
||||
main: '#FAFAFA',
|
||||
paper: '#FFFFFF'
|
||||
}
|
||||
paper: '#FFFFFF',
|
||||
},
|
||||
});
|
||||
@@ -12,6 +12,6 @@ export default createTheme({
|
||||
border: '#363c4d',
|
||||
background: {
|
||||
main: '#1F2430',
|
||||
paper: '#232834'
|
||||
}
|
||||
paper: '#232834',
|
||||
},
|
||||
});
|
||||
@@ -10,6 +10,6 @@ export default createTheme({
|
||||
border: '#2b2b2b',
|
||||
background: {
|
||||
main: '#000000',
|
||||
paper: '#060606'
|
||||
}
|
||||
paper: '#060606',
|
||||
},
|
||||
});
|
||||
@@ -10,6 +10,6 @@ export default createTheme({
|
||||
border: '#1b2541',
|
||||
background: {
|
||||
main: '#05070f',
|
||||
paper: '#0c101c'
|
||||
}
|
||||
paper: '#0c101c',
|
||||
},
|
||||
});
|
||||
@@ -12,6 +12,6 @@ export default createTheme({
|
||||
border: '#7D8096',
|
||||
background: {
|
||||
main: '#282A36',
|
||||
paper: '#44475A'
|
||||
}
|
||||
paper: '#44475A',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createTheme as muiCreateTheme } from '@material-ui/core/styles';
|
||||
import { createTheme as muiCreateTheme } from '@mui/material/styles';
|
||||
|
||||
export interface ThemeOptions {
|
||||
type: 'dark' | 'light';
|
||||
@@ -45,9 +45,9 @@ export default function createTheme(o: ThemeOptions) {
|
||||
MuiTableHead: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundColor: o.border
|
||||
}
|
||||
}
|
||||
backgroundColor: o.border,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -12,6 +12,6 @@ export default createTheme({
|
||||
border: '#565e70',
|
||||
background: {
|
||||
main: '#2E3440',
|
||||
paper: '#3B4252'
|
||||
}
|
||||
paper: '#3B4252',
|
||||
},
|
||||
});
|
||||
@@ -12,6 +12,6 @@ export default createTheme({
|
||||
border: '#989fab',
|
||||
background: {
|
||||
main: '#D8DEE9',
|
||||
paper: '#E5E9F0'
|
||||
}
|
||||
paper: '#E5E9F0',
|
||||
},
|
||||
});
|
||||
@@ -13,6 +13,12 @@ export interface ConfigCore {
|
||||
|
||||
// The PostgreSQL database url
|
||||
database_url: string
|
||||
|
||||
// Whether or not to log stuff
|
||||
logger: boolean;
|
||||
|
||||
// The interval to store stats
|
||||
stats_interval: number;
|
||||
}
|
||||
|
||||
export interface ConfigUploader {
|
||||
@@ -35,7 +41,26 @@ export interface ConfigUploader {
|
||||
disabled_extentions: string[];
|
||||
}
|
||||
|
||||
export interface ConfigUrls {
|
||||
// The route urls will be served on
|
||||
route: string;
|
||||
|
||||
// Length of random chars to generate for urls
|
||||
length: number;
|
||||
}
|
||||
|
||||
// Ratelimiting for users/admins, setting them to 0 disables ratelimiting
|
||||
export interface ConfigRatelimit {
|
||||
// Ratelimit for users
|
||||
user: number;
|
||||
|
||||
// Ratelimit for admins
|
||||
admin: number;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
core: ConfigCore;
|
||||
uploader: ConfigUploader;
|
||||
}
|
||||
urls: ConfigUrls;
|
||||
ratelimit: ConfigRatelimit;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { hash, verify } from 'argon2';
|
||||
import { readdir, stat } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import prisma from './prisma';
|
||||
import { InvisibleImage } from '@prisma/client';
|
||||
import { InvisibleImage, InvisibleUrl } from '@prisma/client';
|
||||
import config from './config';
|
||||
|
||||
export async function hashPassword(s: string): Promise<string> {
|
||||
return await hash(s);
|
||||
@@ -89,21 +90,21 @@ export function bytesToRead(bytes: number) {
|
||||
return `${bytes.toFixed(1)} ${units[num]}`;
|
||||
}
|
||||
|
||||
export function createInvisURL(length: number) {
|
||||
export function randomInvis(length: number) {
|
||||
// some parts from https://github.com/tycrek/ass/blob/master/generators/lengthGen.js
|
||||
const invisibleCharset = ['\u200B', '\u2060', '\u200C', '\u200D'];
|
||||
|
||||
return [...randomBytes(length)].map((byte) => invisibleCharset[Number(byte) % invisibleCharset.length]).join('').slice(1).concat(invisibleCharset[0]);
|
||||
}
|
||||
|
||||
export function createInvis(length: number, imageId: number) {
|
||||
export function createInvisImage(length: number, imageId: number) {
|
||||
const retry = async (): Promise<InvisibleImage> => {
|
||||
const invis = createInvisURL(length);
|
||||
const invis = randomInvis(length);
|
||||
|
||||
const existing = await prisma.invisibleImage.findUnique({
|
||||
where: {
|
||||
invis
|
||||
}
|
||||
invis,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) return retry();
|
||||
@@ -111,12 +112,37 @@ export function createInvis(length: number, imageId: number) {
|
||||
const inv = await prisma.invisibleImage.create({
|
||||
data: {
|
||||
invis,
|
||||
imageId
|
||||
}
|
||||
imageId,
|
||||
},
|
||||
});
|
||||
|
||||
return inv;
|
||||
};
|
||||
|
||||
return retry();
|
||||
}
|
||||
|
||||
export function createInvisURL(length: number, urlId: string) {
|
||||
const retry = async (): Promise<InvisibleUrl> => {
|
||||
const invis = randomInvis(length);
|
||||
|
||||
const existing = await prisma.invisibleUrl.findUnique({
|
||||
where: {
|
||||
invis,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) return retry();
|
||||
|
||||
const ur = await prisma.invisibleUrl.create({
|
||||
data: {
|
||||
invis,
|
||||
urlId,
|
||||
},
|
||||
});
|
||||
|
||||
return ur;
|
||||
};
|
||||
|
||||
return retry();
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography } from '@material-ui/core';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
|
||||
export default function FourOhFour() {
|
||||
return (
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import Head from 'next/head';
|
||||
import { GetServerSideProps } from 'next';
|
||||
import { Box } from '@material-ui/core';
|
||||
import { Box } from '@mui/material';
|
||||
import config from 'lib/config';
|
||||
import prisma from 'lib/prisma';
|
||||
import getFile from '../../server/static';
|
||||
import { parse } from 'lib/clientUtils';
|
||||
|
||||
export default function EmbeddedImage({ image, title, username, color, normal, embed }) {
|
||||
export default function EmbeddedImage({ image, user, normal }) {
|
||||
const dataURL = (route: string) => `${route}/${image.file}`;
|
||||
|
||||
// reapply date from workaround
|
||||
image.created_at = new Date(image.created_at);
|
||||
|
||||
const updateImage = () => {
|
||||
const imageEl = document.getElementById('image_content') as HTMLImageElement;
|
||||
|
||||
@@ -25,18 +29,11 @@ export default function EmbeddedImage({ image, title, username, color, normal, e
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
{embed && (
|
||||
{image.embed && (
|
||||
<>
|
||||
{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(normal)} />
|
||||
{user.embedSiteName && (<meta property='og:site_name' content={parse(user.embedSiteName, image, user)} />)}
|
||||
{user.embedTitle && (<meta property='og:title' content={parse(user.embedTitle, image, user)} />)}
|
||||
<meta property='theme-color' content={user.embedColor}/>
|
||||
</>
|
||||
)}
|
||||
<meta property='og:image' content={dataURL('/r')} />
|
||||
@@ -58,64 +55,80 @@ export default function EmbeddedImage({ image, title, username, color, normal, e
|
||||
export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||
const id = context.params.id[1];
|
||||
const route = context.params.id[0];
|
||||
if (route !== config.uploader.route.substring(1)) return {
|
||||
notFound: true
|
||||
};
|
||||
const routes = [config.uploader.route.substring(1), config.urls.route.substring(1)];
|
||||
if (!routes.includes(route)) return { notFound: true };
|
||||
if (route === routes[1]) {
|
||||
const url = await prisma.url.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ id },
|
||||
{ vanity: id },
|
||||
{ invisible: { invis: id } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
destination: true,
|
||||
},
|
||||
});
|
||||
if (!url) return { notFound: true };
|
||||
|
||||
const image = await prisma.image.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ file: id },
|
||||
{ invisible: { invis: id } }
|
||||
]
|
||||
},
|
||||
select: {
|
||||
mimetype: true,
|
||||
id: true,
|
||||
file: true,
|
||||
invisible: true,
|
||||
userId: true,
|
||||
embed: true
|
||||
}
|
||||
});
|
||||
return {
|
||||
props: {},
|
||||
redirect: {
|
||||
destination: url.destination,
|
||||
},
|
||||
};
|
||||
|
||||
if (!image) return { notFound: true };
|
||||
} else {
|
||||
const image = await prisma.image.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ file: id },
|
||||
{ invisible: { invis: id } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
mimetype: true,
|
||||
id: true,
|
||||
file: true,
|
||||
invisible: true,
|
||||
userId: true,
|
||||
embed: true,
|
||||
created_at: true,
|
||||
},
|
||||
});
|
||||
if (!image) return { notFound: true };
|
||||
|
||||
if (!image.embed) {
|
||||
const data = await getFile(config.uploader.directory, id);
|
||||
if (!data) return { notFound: true };
|
||||
const user = await prisma.user.findFirst({
|
||||
select: {
|
||||
embedTitle: true,
|
||||
embedColor: true,
|
||||
embedSiteName: true,
|
||||
username: true,
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
id: image.userId,
|
||||
},
|
||||
});
|
||||
|
||||
context.res.end(data);
|
||||
return { props: {} };
|
||||
};
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
select: {
|
||||
embedTitle: true,
|
||||
embedColor: true,
|
||||
username: true
|
||||
},
|
||||
where: {
|
||||
id: image.userId
|
||||
}
|
||||
});
|
||||
//@ts-ignore workaround because next wont allow date
|
||||
image.created_at = image.created_at.toString();
|
||||
|
||||
if (!image.mimetype.startsWith('image')) {
|
||||
const data = await getFile(config.uploader.directory, id);
|
||||
if (!data) return { notFound: true };
|
||||
if (!image.mimetype.startsWith('image')) {
|
||||
const data = await getFile(config.uploader.directory, id);
|
||||
if (!data) return { notFound: true };
|
||||
|
||||
context.res.end(data);
|
||||
return { props: {} };
|
||||
};
|
||||
context.res.end(data);
|
||||
return { props: {} };
|
||||
};
|
||||
|
||||
return {
|
||||
props: {
|
||||
image,
|
||||
title: user.embedTitle,
|
||||
color: user.embedColor,
|
||||
username: user.username,
|
||||
normal: config.uploader.route,
|
||||
embed: image.embed
|
||||
}
|
||||
};
|
||||
return {
|
||||
props: {
|
||||
image,
|
||||
user,
|
||||
normal: config.uploader.route,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -22,4 +22,4 @@ class MyDocument extends Document {
|
||||
}
|
||||
}
|
||||
|
||||
export default MyDocument;
|
||||
export default MyDocument;
|
||||
@@ -17,8 +17,8 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
|
||||
const existing = await prisma.user.findFirst({
|
||||
where: {
|
||||
username
|
||||
}
|
||||
username,
|
||||
},
|
||||
});
|
||||
if (existing) return res.forbid('user exists');
|
||||
|
||||
@@ -29,8 +29,8 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
password: hashed,
|
||||
username,
|
||||
token: createToken(),
|
||||
administrator
|
||||
}
|
||||
administrator,
|
||||
},
|
||||
});
|
||||
|
||||
delete newUser.password;
|
||||
|
||||
@@ -15,16 +15,16 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
username: 'administrator',
|
||||
password: await hashPassword('password'),
|
||||
token: createToken(),
|
||||
administrator: true
|
||||
}
|
||||
administrator: true,
|
||||
},
|
||||
});
|
||||
Logger.get('database').info('created default user:\nUsername: "administrator"\nPassword: "password"');
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
username
|
||||
}
|
||||
username,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) return res.status(404).end(JSON.stringify({ error: 'User not found' }));
|
||||
|
||||
39
src/pages/api/shorten.ts
Normal file
39
src/pages/api/shorten.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import prisma from 'lib/prisma';
|
||||
import zconfig from 'lib/config';
|
||||
import { NextApiReq, NextApiRes, withZipline } from 'lib/middleware/withZipline';
|
||||
import { createInvisURL, randomChars } from 'lib/util';
|
||||
import Logger from 'lib/logger';
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
if (req.method !== 'POST') return res.forbid('no allow');
|
||||
if (!req.headers.authorization) return res.forbid('no authorization');
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
token: req.headers.authorization,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) return res.forbid('authorization incorect');
|
||||
if (!req.body) return res.error('no body');
|
||||
if (!req.body.url) return res.error('no url');
|
||||
const rand = randomChars(zconfig.urls.length);
|
||||
|
||||
let invis;
|
||||
const url = await prisma.url.create({
|
||||
data: {
|
||||
id: rand,
|
||||
vanity: req.body.vanity ?? null,
|
||||
destination: req.body.url,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (req.headers.zws) invis = await createInvisURL(zconfig.urls.length, url.id);
|
||||
|
||||
Logger.get('url').info(`User ${user.username} (${user.id}) shortenned a url ${url.destination} (${url.id})`);
|
||||
|
||||
return res.json({ url: `${zconfig.core.secure ? 'https' : 'http'}://${req.headers.host}${zconfig.urls.route}/${req.body.vanity ? req.body.vanity : invis ? invis.invis : url.id}` });
|
||||
}
|
||||
|
||||
export default withZipline(handler);
|
||||
@@ -1,62 +1,18 @@
|
||||
import { join } from 'path';
|
||||
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
|
||||
import prisma from 'lib/prisma';
|
||||
import { bytesToRead, sizeOfDir } from 'lib/util';
|
||||
import config from 'lib/config';
|
||||
|
||||
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(), config.uploader.directory));
|
||||
const byUser = await prisma.image.groupBy({
|
||||
by: ['userId'],
|
||||
_count: {
|
||||
_all: true
|
||||
}
|
||||
});
|
||||
const count_users = await prisma.user.count();
|
||||
|
||||
const count_by_user = [];
|
||||
for (let i = 0, L = byUser.length; i !== L; ++i) {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: byUser[i].userId
|
||||
}
|
||||
});
|
||||
|
||||
count_by_user.push({
|
||||
username: user.username,
|
||||
count: byUser[i]._count._all
|
||||
});
|
||||
}
|
||||
|
||||
const count = await prisma.image.count();
|
||||
const viewsCount = await prisma.image.groupBy({
|
||||
by: ['views'],
|
||||
_sum: {
|
||||
views: true
|
||||
}
|
||||
});
|
||||
|
||||
const typesCount = await prisma.image.groupBy({
|
||||
by: ['mimetype'],
|
||||
_count: {
|
||||
mimetype: true
|
||||
const stats = await prisma.stats.findFirst({
|
||||
orderBy: {
|
||||
created_at: 'desc',
|
||||
},
|
||||
take: 1,
|
||||
});
|
||||
const types_count = [];
|
||||
for (let i = 0, L = typesCount.length; i !== L; ++i) types_count.push({ mimetype: typesCount[i].mimetype, count: typesCount[i]._count.mimetype });
|
||||
|
||||
return res.json({
|
||||
size: bytesToRead(size),
|
||||
size_num: size,
|
||||
count,
|
||||
count_by_user: count_by_user.sort((a,b) => b.count-a.count),
|
||||
count_users,
|
||||
views_count: (viewsCount[0]?._sum?.views ?? 0),
|
||||
types_count: types_count.sort((a,b) => b.count-a.count)
|
||||
});
|
||||
return res.json(stats.data);
|
||||
}
|
||||
|
||||
export default withZipline(handler);
|
||||
@@ -2,7 +2,7 @@ import multer from 'multer';
|
||||
import prisma from 'lib/prisma';
|
||||
import zconfig from 'lib/config';
|
||||
import { NextApiReq, NextApiRes, withZipline } from 'lib/middleware/withZipline';
|
||||
import { createInvis, randomChars } from 'lib/util';
|
||||
import { createInvisImage, randomChars } from 'lib/util';
|
||||
import { writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import Logger from 'lib/logger';
|
||||
@@ -12,17 +12,19 @@ const uploader = multer({
|
||||
});
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
if (req.method !== 'POST') return res.forbid('no allow');
|
||||
if (req.method !== 'POST') return res.forbid('invalid method');
|
||||
if (!req.headers.authorization) return res.forbid('no authorization');
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
token: req.headers.authorization
|
||||
}
|
||||
token: req.headers.authorization,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
if (!user) return res.forbid('authorization incorect');
|
||||
|
||||
if (user.ratelimited) return res.ratelimited();
|
||||
|
||||
if (!req.files) return res.error('no files');
|
||||
if (req.files && req.files.length === 0) return res.error('no files');
|
||||
|
||||
@@ -42,19 +44,42 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
file: `${rand}.${ext}`,
|
||||
mimetype: file.mimetype,
|
||||
userId: user.id,
|
||||
embed: !!req.headers.embed
|
||||
}
|
||||
embed: !!req.headers.embed,
|
||||
},
|
||||
});
|
||||
|
||||
if (req.headers.zws) invis = await createInvis(zconfig.uploader.length, image.id);
|
||||
if (req.headers.zws) invis = await createInvisImage(zconfig.uploader.length, image.id);
|
||||
|
||||
await writeFile(join(process.cwd(), zconfig.uploader.directory, image.file), file.buffer);
|
||||
Logger.get('image').info(`User ${user.username} (${user.id}) uploaded an image ${image.file} (${image.id})`);
|
||||
files.push(`${zconfig.core.secure ? 'https' : 'http'}://${req.headers.host}${zconfig.uploader.route}/${invis ? invis.invis : image.file}`);
|
||||
}
|
||||
|
||||
// url will be deprecated soon
|
||||
return res.json({ files, url: files[0] });
|
||||
if (user.administrator && zconfig.ratelimit.admin !== 0) {
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
ratelimited: true,
|
||||
},
|
||||
});
|
||||
setTimeout(async () => await prisma.user.update({ where: { id: user.id }, data: { ratelimited: false } }), zconfig.ratelimit.admin * 1000).unref();
|
||||
}
|
||||
|
||||
if (!user.administrator && zconfig.ratelimit.user !== 0) {
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
ratelimited: true,
|
||||
},
|
||||
});
|
||||
setTimeout(async () => await prisma.user.update({ where: { id: user.id }, data: { ratelimited: false } }), zconfig.ratelimit.user * 1000).unref();
|
||||
}
|
||||
|
||||
return res.json({ files });
|
||||
}
|
||||
|
||||
function run(middleware: any) {
|
||||
|
||||
@@ -15,8 +15,8 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
|
||||
const image = await prisma.image.delete({
|
||||
where: {
|
||||
id: req.body.id
|
||||
}
|
||||
id: req.body.id,
|
||||
},
|
||||
});
|
||||
|
||||
await rm(join(process.cwd(), config.uploader.directory, image.file));
|
||||
@@ -32,8 +32,8 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
if (req.body.favorite !== null) image = await prisma.image.update({
|
||||
where: { id: req.body.id },
|
||||
data: {
|
||||
favorite: req.body.favorite
|
||||
}
|
||||
favorite: req.body.favorite,
|
||||
},
|
||||
});
|
||||
|
||||
return res.json(image);
|
||||
@@ -41,15 +41,15 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
let images = await prisma.image.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
favorite: !!req.query.favorite
|
||||
favorite: !!req.query.favorite,
|
||||
},
|
||||
select: {
|
||||
created_at: true,
|
||||
file: true,
|
||||
mimetype: true,
|
||||
id: true,
|
||||
favorite: true
|
||||
}
|
||||
favorite: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -12,38 +12,43 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
const hashed = await hashPassword(req.body.password);
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { password: hashed }
|
||||
data: { password: hashed },
|
||||
});
|
||||
}
|
||||
|
||||
if (req.body.username) {
|
||||
const existing = await prisma.user.findFirst({
|
||||
where: {
|
||||
username: req.body.username
|
||||
}
|
||||
username: req.body.username,
|
||||
},
|
||||
});
|
||||
if (existing && user.username !== req.body.username) {
|
||||
return res.forbid('Username is already taken');
|
||||
}
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { username: req.body.username }
|
||||
data: { username: req.body.username },
|
||||
});
|
||||
}
|
||||
|
||||
if (req.body.embedTitle) await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { embedTitle: req.body.embedTitle }
|
||||
data: { embedTitle: req.body.embedTitle },
|
||||
});
|
||||
|
||||
if (req.body.embedColor) await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { embedColor: req.body.embedColor }
|
||||
data: { embedColor: req.body.embedColor },
|
||||
});
|
||||
|
||||
if (req.body.embedSiteName) await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { embedSiteName: req.body.embedSiteName },
|
||||
});
|
||||
|
||||
if (req.body.systemTheme) await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { systemTheme: req.body.systemTheme }
|
||||
data: { systemTheme: req.body.systemTheme },
|
||||
});
|
||||
|
||||
if (req.body.customTheme) {
|
||||
@@ -52,34 +57,35 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
data: {
|
||||
customTheme: {
|
||||
update: {
|
||||
...req.body.customTheme
|
||||
}
|
||||
}
|
||||
}
|
||||
...req.body.customTheme,
|
||||
},
|
||||
},
|
||||
},
|
||||
}); else await prisma.theme.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
...req.body.customTheme
|
||||
}
|
||||
...req.body.customTheme,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const newUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: Number(user.id)
|
||||
id: Number(user.id),
|
||||
},
|
||||
select: {
|
||||
administrator: true,
|
||||
embedColor: true,
|
||||
embedTitle: true,
|
||||
embedSiteName: true,
|
||||
id: true,
|
||||
images: false,
|
||||
password: false,
|
||||
systemTheme: true,
|
||||
customTheme: true,
|
||||
token: true,
|
||||
username: true
|
||||
}
|
||||
username: true,
|
||||
},
|
||||
});
|
||||
|
||||
Logger.get('user').info(`User ${user.username} (${newUser.username}) (${newUser.id}) was updated`);
|
||||
|
||||
@@ -11,14 +11,17 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
|
||||
let images = await prisma.image.findMany({
|
||||
take,
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
orderBy: {
|
||||
created_at: 'desc'
|
||||
created_at: 'desc',
|
||||
},
|
||||
select: {
|
||||
created_at: true,
|
||||
file: true,
|
||||
mimetype: true
|
||||
}
|
||||
mimetype: true,
|
||||
},
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
|
||||
@@ -10,11 +10,11 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
if (req.method === 'PATCH') {
|
||||
const updated = await prisma.user.update({
|
||||
where: {
|
||||
id: user.id
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
token: createToken()
|
||||
}
|
||||
token: createToken(),
|
||||
},
|
||||
});
|
||||
|
||||
Logger.get('user').info(`User ${user.username} (${user.id}) reset their token`);
|
||||
|
||||
41
src/pages/api/user/urls.ts
Normal file
41
src/pages/api/user/urls.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
|
||||
import prisma from 'lib/prisma';
|
||||
import config from 'lib/config';
|
||||
import Logger from 'lib/logger';
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
const user = await req.user();
|
||||
if (!user) return res.forbid('not logged in');
|
||||
|
||||
if (req.method === 'DELETE') {
|
||||
if (!req.body.id) return res.error('no url id');
|
||||
|
||||
const url = await prisma.url.delete({
|
||||
where: {
|
||||
id: req.body.id,
|
||||
},
|
||||
});
|
||||
|
||||
Logger.get('url').info(`User ${user.username} (${user.id}) deleted a url ${url.destination} (${url.id})`);
|
||||
|
||||
return res.json(url);
|
||||
} else {
|
||||
let urls = await prisma.url.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
created_at: true,
|
||||
id: true,
|
||||
destination: true,
|
||||
vanity: true,
|
||||
},
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
urls.map(url => url.url = `${config.urls.route}/${url.vanity ?? url.id}`);
|
||||
return res.json(urls);
|
||||
}
|
||||
}
|
||||
|
||||
export default withZipline(handler);
|
||||
@@ -7,28 +7,28 @@ import { tryGetPreviewData } from 'next/dist/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 (!user.administrator) return res.forbid('you aren\'t 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
|
||||
}
|
||||
id: req.body.id,
|
||||
},
|
||||
});
|
||||
if (!deleteUser) return res.forbid('user doesn\'t exist');
|
||||
|
||||
await prisma.user.delete({
|
||||
where: {
|
||||
id: deleteUser.id
|
||||
}
|
||||
id: deleteUser.id,
|
||||
},
|
||||
});
|
||||
|
||||
delete deleteUser.password;
|
||||
return res.json(deleteUser);
|
||||
} else {
|
||||
const all_users = await prisma.user.findMany({
|
||||
const users = await prisma.user.findMany({
|
||||
select: {
|
||||
username: true,
|
||||
id: true,
|
||||
@@ -37,10 +37,10 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
embedColor: true,
|
||||
embedTitle: true,
|
||||
customTheme: true,
|
||||
systemTheme: true
|
||||
}
|
||||
systemTheme: true,
|
||||
},
|
||||
});
|
||||
return res.json(all_users);
|
||||
return res.json(users);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,35 +1,15 @@
|
||||
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 { Typography, Box, TextField, Stack, Button, styled } from '@mui/material';
|
||||
import { useRouter } from 'next/router';
|
||||
import Alert from 'components/Alert';
|
||||
import Backdrop from 'components/Backdrop';
|
||||
import TextInput from 'components/input/TextInput';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { 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 [severity, setSeverity] = useState('success');
|
||||
const [message, setMessage] = useState('');
|
||||
const [loadingOpen, setLoadingOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
@@ -37,7 +17,7 @@ export default function Login() {
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
username: '',
|
||||
password: ''
|
||||
password: '',
|
||||
},
|
||||
onSubmit: async values => {
|
||||
const username = values.username.trim();
|
||||
@@ -47,7 +27,7 @@ export default function Login() {
|
||||
|
||||
setLoadingOpen(true);
|
||||
const res = await useFetch('/api/auth/login', 'POST', {
|
||||
username, password
|
||||
username, password,
|
||||
});
|
||||
|
||||
if (res.error) {
|
||||
@@ -61,7 +41,7 @@ export default function Login() {
|
||||
setMessage('Logged in');
|
||||
router.push('/dashboard');
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Backdrop, CircularProgress } from '@material-ui/core';
|
||||
import { Backdrop, CircularProgress } from '@mui/material';
|
||||
|
||||
export default function Logout() {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -3,7 +3,7 @@ import useLogin from 'hooks/useLogin';
|
||||
import Layout from 'components/Layout';
|
||||
import Files from 'components/pages/Files';
|
||||
|
||||
export default function ImagesPage() {
|
||||
export default function FilesPage() {
|
||||
const { user, loading } = useLogin();
|
||||
|
||||
if (loading) return null;
|
||||
@@ -19,4 +19,4 @@ export default function ImagesPage() {
|
||||
);
|
||||
}
|
||||
|
||||
ImagesPage.title = 'Zipline - Gallery';
|
||||
FilesPage.title = 'Zipline - Gallery';
|
||||
@@ -24,8 +24,8 @@ export default function UploadPage({ route }) {
|
||||
export const getStaticProps: GetStaticProps = async (context) => {
|
||||
return {
|
||||
props: {
|
||||
route: config.uploader.route
|
||||
}
|
||||
route: config.uploader.route,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
22
src/pages/dashboard/urls.tsx
Normal file
22
src/pages/dashboard/urls.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import useLogin from 'hooks/useLogin';
|
||||
import Layout from 'components/Layout';
|
||||
import Urls from 'components/pages/Urls';
|
||||
|
||||
export default function UrlsPage() {
|
||||
const { user, loading } = useLogin();
|
||||
|
||||
if (loading) return null;
|
||||
|
||||
return (
|
||||
<Layout
|
||||
user={user}
|
||||
loading={loading}
|
||||
noPaper={false}
|
||||
>
|
||||
<Urls />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
UrlsPage.title = 'Zipline - URLs';
|
||||
@@ -19,4 +19,4 @@ export default function UsersPage() {
|
||||
);
|
||||
}
|
||||
|
||||
UsersPage.title = 'Zipline - User';
|
||||
UsersPage.title = 'Zipline - Users';
|
||||
1
zip-env.d.ts
vendored
1
zip-env.d.ts
vendored
@@ -6,6 +6,7 @@ declare global {
|
||||
interface Global {
|
||||
prisma: PrismaClient;
|
||||
config: Config;
|
||||
ratelimit: Set<string>;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user