Compare commits

..

31 Commits

Author SHA1 Message Date
diced
4e64922b70 feat(v3.3): release 3.3 2022-01-03 19:00:51 -08:00
diced
15042b16d1 feat(v3.3): ctrl+v to upload image 2022-01-03 19:00:20 -08:00
diced
5e4c4fc6c9 feat(v3.3): faster stats 2022-01-03 15:56:33 -08:00
diced
7194c53891 feat(v3.3): ratelimit 2022-01-03 15:17:47 -08:00
Nguyen Thanh Quang
7eff77ccc4 refactor(api): cors duplication (#109)
* refactor(api): cors duplication

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

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

View File

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

4
.dockerignore Normal file
View File

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

View File

@@ -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',
},
};

View File

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

View File

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

4
.gitignore vendored
View File

@@ -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/

View File

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

View File

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

View File

@@ -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!
![Build](https://img.shields.io/github/workflow/status/diced/zipline/CD:%20Push%20Docker%20Images?logo=github&style=flat-square)
![Stars](https://img.shields.io/github/stars/diced/zipline?logo=github&style=flat-square)
@@ -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)

View File

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

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

View File

@@ -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"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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),
};
}

View File

@@ -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')}`;
}
};

View File

@@ -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 (

View File

@@ -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 (

View File

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

View File

@@ -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 (

View File

@@ -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];
};

View File

@@ -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&apos;t share this token with anyone as they will be able to upload images on your behalf.
Make sure you don&apos;t share this token with anyone as they will be able to upload files on your behalf.
</DialogContentText>
</DialogContent>
<DialogActions>
@@ -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
>

View File

@@ -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 } =

View File

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

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

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

View File

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

View File

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

View File

@@ -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'

View File

@@ -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()}
>

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

View File

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

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

View File

@@ -1,6 +1,7 @@
import type { Config } from './types';
import 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;

View File

@@ -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();

View File

@@ -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();
}, []);

View File

@@ -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');
}

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ export interface User {
token: string;
embedTitle: string;
embedColor: string;
embedSiteName: string;
systemTheme: string;
customTheme?: Theme;
}

View File

@@ -12,6 +12,6 @@ export default createTheme({
border: '#191e29',
background: {
main: '#0A0E14',
paper: '#0D1016'
}
paper: '#0D1016',
},
});

View File

@@ -12,6 +12,6 @@ export default createTheme({
border: '#e3e3e3',
background: {
main: '#FAFAFA',
paper: '#FFFFFF'
}
paper: '#FFFFFF',
},
});

View File

@@ -12,6 +12,6 @@ export default createTheme({
border: '#363c4d',
background: {
main: '#1F2430',
paper: '#232834'
}
paper: '#232834',
},
});

View File

@@ -10,6 +10,6 @@ export default createTheme({
border: '#2b2b2b',
background: {
main: '#000000',
paper: '#060606'
}
paper: '#060606',
},
});

View File

@@ -10,6 +10,6 @@ export default createTheme({
border: '#1b2541',
background: {
main: '#05070f',
paper: '#0c101c'
}
paper: '#0c101c',
},
});

View File

@@ -12,6 +12,6 @@ export default createTheme({
border: '#7D8096',
background: {
main: '#282A36',
paper: '#44475A'
}
paper: '#44475A',
},
});

View File

@@ -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,
},
},
},
},
});

View File

@@ -12,6 +12,6 @@ export default createTheme({
border: '#565e70',
background: {
main: '#2E3440',
paper: '#3B4252'
}
paper: '#3B4252',
},
});

View File

@@ -12,6 +12,6 @@ export default createTheme({
border: '#989fab',
background: {
main: '#D8DEE9',
paper: '#E5E9F0'
}
paper: '#E5E9F0',
},
});

View File

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

View File

@@ -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();
}

View File

@@ -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 (

View File

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

View File

@@ -22,4 +22,4 @@ class MyDocument extends Document {
}
}
export default MyDocument;
export default MyDocument;

View File

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

View File

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

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

View File

@@ -1,62 +1,18 @@
import { join } from 'path';
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
import 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);

View File

@@ -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) {

View File

@@ -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,
},
});

View File

@@ -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`);

View File

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

View File

@@ -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`);

View File

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

View File

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

View File

@@ -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(() => {

View File

@@ -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();

View File

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

View File

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

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

View File

@@ -19,4 +19,4 @@ export default function UsersPage() {
);
}
UsersPage.title = 'Zipline - User';
UsersPage.title = 'Zipline - Users';

2382
yarn.lock

File diff suppressed because it is too large Load Diff

1
zip-env.d.ts vendored
View File

@@ -6,6 +6,7 @@ declare global {
interface Global {
prisma: PrismaClient;
config: Config;
ratelimit: Set<string>;
}
}
}