Compare commits

..

2 Commits

Author SHA1 Message Date
diced
45f29c7b68 fix(prisma): add removal of custom theme migration 2022-02-26 17:26:13 -08:00
diced
114a7a05a9 feat(v3.4.0): switch from Material-UI to Mantine! 2022-02-26 17:14:46 -08:00
59 changed files with 5829 additions and 9881 deletions

View File

@@ -2,6 +2,3 @@ node_modules/
.next/
uploads/
.git/
.yarn/*
!.yarn/releases
!.yarn/plugins

View File

@@ -1,41 +0,0 @@
name: 'CD: Push ARM64 Docker Images'
on:
push:
branches: [ trunk ]
paths:
- 'src/**'
- 'server/**'
- 'prisma/**'
- '.github/**'
workflow_dispatch:
jobs:
push_to_ghcr:
name: Push Image to GitHub Packages
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v2
- name: Setup QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
- name: Login to Github Packages
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker Image
uses: docker/build-push-action@v2
with:
file: ./Dockerfile-arm
platforms: linux/arm64
push: true
tags: ghcr.io/diced/zipline/arm64:trunk

View File

@@ -18,31 +18,28 @@ jobs:
- name: Check out the repo
uses: actions/checkout@v2
- name: Setup QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
- name: Login to Github Packages
uses: docker/login-action@v1
- name: Push to GitHub Packages
uses: docker/build-push-action@v1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
registry: docker.pkg.github.com
repository: diced/zipline/zipline
dockerfile: Dockerfile
tag_with_ref: true
- name: Login to Docker Hub
uses: docker/login-action@v1
push_to_dockerhub:
name: Push Image to Docker Hub
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v2
- name: Push to Docker Hub
uses: docker/build-push-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build Docker Image
uses: docker/build-push-action@v2
with:
push: true
tags: |
ghcr.io/diced/zipline/zipline:trunk
ghcr.io/diced/zipline/amd64:trunk
diced/zipline:trunk
repository: diced/zipline
dockerfile: Dockerfile
tag_with_ref: true

9
.gitignore vendored
View File

@@ -5,11 +5,6 @@
/.pnp
.pnp.js
# yarn
.yarn/*
!.yarn/releases
!.yarn/plugins
# testing
/coverage
@@ -41,6 +36,4 @@ yarn-error.log*
# zipline
config.toml
uploads/
dist/
docker-compose.local.yml
uploads/

File diff suppressed because one or more lines are too long

View File

@@ -1,3 +0,0 @@
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-3.2.1.cjs

View File

@@ -1,21 +1,20 @@
FROM node:16-alpine AS deps
WORKDIR /build
COPY .yarn .yarn
COPY package.json yarn.lock .yarnrc.yml ./
COPY package.json yarn.lock ./
RUN apk add --no-cache libc6-compat
RUN yarn install --immutable
RUN yarn install --frozen-lockfile
FROM node:16-alpine AS builder
WORKDIR /build
COPY --from=deps /build/node_modules ./node_modules
COPY src ./src
COPY server ./server
COPY scripts ./scripts
COPY prisma ./prisma
COPY .yarn .yarn
COPY package.json yarn.lock .yarnrc.yml esbuild.config.js next.config.js next-env.d.ts zip-env.d.ts tsconfig.json ./
COPY package.json yarn.lock next.config.js next-env.d.ts zip-env.d.ts tsconfig.json ./
ENV ZIPLINE_DOCKER_BUILD 1
ENV NEXT_TELEMETRY_DISABLED 1
@@ -32,11 +31,11 @@ RUN addgroup --system --gid 1001 zipline
RUN adduser --system --uid 1001 zipline
COPY --from=builder --chown=zipline:zipline /build/.next ./.next
COPY --from=builder --chown=zipline:zipline /build/dist ./dist
COPY --from=builder --chown=zipline:zipline /build/node_modules ./node_modules
COPY --from=builder /build/next.config.js ./next.config.js
COPY --from=builder /build/src ./src
COPY --from=builder /build/server ./server
COPY --from=builder /build/scripts ./scripts
COPY --from=builder /build/prisma ./prisma
COPY --from=builder /build/tsconfig.json ./tsconfig.json
@@ -44,4 +43,4 @@ COPY --from=builder /build/package.json ./package.json
USER zipline
CMD ["node", "dist/server"]
CMD ["node", "server"]

View File

@@ -1,46 +0,0 @@
FROM node:16 AS deps
WORKDIR /build
COPY .yarn .yarn
COPY package.json yarn.lock .yarnrc.yml ./
RUN yarn install --immutable
FROM node:16 AS builder
WORKDIR /build
COPY --from=deps /build/node_modules ./node_modules
COPY src ./src
COPY scripts ./scripts
COPY prisma ./prisma
COPY .yarn .yarn
COPY package.json yarn.lock .yarnrc.yml esbuild.config.js next.config.js next-env.d.ts zip-env.d.ts tsconfig.json ./
ENV ZIPLINE_DOCKER_BUILD 1
ENV NEXT_TELEMETRY_DISABLED 1
RUN yarn build
FROM node:16 AS runner
WORKDIR /zipline
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 zipline
RUN adduser --system --uid 1001 zipline
COPY --from=builder --chown=zipline:zipline /build/.next ./.next
COPY --from=builder --chown=zipline:zipline /build/dist ./dist
COPY --from=builder --chown=zipline:zipline /build/node_modules ./node_modules
COPY --from=builder /build/next.config.js ./next.config.js
COPY --from=builder /build/src ./src
COPY --from=builder /build/scripts ./scripts
COPY --from=builder /build/prisma ./prisma
COPY --from=builder /build/tsconfig.json ./tsconfig.json
COPY --from=builder /build/package.json ./package.json
USER zipline
CMD ["node", "dist/server"]

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2022 dicedtomato
Copyright (c) 2021 dicedtomato
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -17,19 +17,18 @@
- Built with Next.js & React
- Token protected uploading
- Image uploading
- Password Protected Uploads
- URL shortening
- Text uploading
- URL Formats (uuid, dates, random alphanumeric, original name, zws)
- Discord embeds (OG metadata)
- Gallery viewer, and multiple file format support
- Easy setup instructions on [docs](https://zipl.vercel.app/) (One command install `docker-compose up -d`)
- Easy setup instructions on [docs](https://zipline.diced.tech/) (One command install `docker-compose up -d`)
## Installing
[See how to install here](https://zipl.vercel.app/docs/get-started)
[See how to install here](https://zipline.diced.tech/docs/get-started)
## Configuration
[See how to configure here](https://zipl.vercel.app/docs/config/overview)
[See how to configure here](https://zipline.diced.tech/docs/config/overview)
## Theming
[See how to theme here](https://zipl.vercel.app/docs/themes/reference)
[See how to theme here](https://zipline.diced.tech/docs/themes/reference)

View File

@@ -4,7 +4,7 @@
| Version | Supported |
| ------- | ------------------ |
| 3.4.4 | :white_check_mark: |
| 3.2.x | :white_check_mark: |
| < 3 | :x: |
| < 2 | :x: |

View File

@@ -1,6 +1,6 @@
[core]
secure = true
secret = 'changethis'
secret = 'some secret'
host = '0.0.0.0'
port = 3000
database_url = 'postgres://postgres:postgres@postgres/postgres'

View File

@@ -1,46 +0,0 @@
version: '3'
services:
postgres:
image: postgres
restart: always
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DATABASE=postgres
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres']
interval: 10s
timeout: 5s
retries: 5
zipline:
image: ghcr.io/diced/zipline/arm64:trunk
ports:
- '3000:3000'
restart: unless-stopped
environment:
- SECURE=false
- SECRET=changethis
- HOST=0.0.0.0
- PORT=3000
- DATASOURCE_TYPE=local
- DATASOURCE_DIRECTORY=./uploads
- DATABASE_URL=postgresql://postgres:postgres@postgres/postgres/
- UPLOADER_ROUTE=/u
- UPLOADER_EMBED_ROUTE=/a
- UPLOADER_LENGTH=6
- UPLOADER_ADMIN_LIMIT=104900000
- UPLOADER_USER_LIMIT=104900000
- UPLOADER_DISABLED_EXTS=
- URLS_ROUTE=/go
- URLS_LENGTH=6
volumes:
- '$PWD/uploads:/zipline/uploads'
- '$PWD/public:/zipline/public'
depends_on:
- 'postgres'
volumes:
pg_data:

View File

@@ -2,12 +2,11 @@ version: '3'
services:
postgres:
image: postgres
restart: always
environment:
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DATABASE=postgres
volumes:
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres']
@@ -22,17 +21,16 @@ services:
ports:
- '3000:3000'
restart: unless-stopped
environment:
environment:
- SECURE=false
- SECRET=changethis
- HOST=0.0.0.0
- PORT=3000
- DATASOURCE_TYPE=local
- DATASOURCE_DIRECTORY=./uploads
- DATABASE_URL=postgresql://postgres:postgres@postgres/postgres/
- UPLOADER_ROUTE=/u
- UPLOADER_EMBED_ROUTE=/a
- UPLOADER_LENGTH=6
- UPLOADER_DIRECTORY=./uploads
- UPLOADER_ADMIN_LIMIT=104900000
- UPLOADER_USER_LIMIT=104900000
- UPLOADER_DISABLED_EXTS=

View File

@@ -2,12 +2,11 @@ version: '3'
services:
postgres:
image: postgres
restart: always
environment:
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DATABASE=postgres
volumes:
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres']
@@ -19,18 +18,17 @@ services:
image: ghcr.io/diced/zipline/zipline:trunk
ports:
- '3000:3000'
restart: always
environment:
restart: unless-stopped
environment:
- SECURE=false
- SECRET=changethis
- HOST=0.0.0.0
- PORT=3000
- DATASOURCE_TYPE=local
- DATASOURCE_DIRECTORY=./uploads
- DATABASE_URL=postgresql://postgres:postgres@postgres/postgres/
- UPLOADER_ROUTE=/u
- UPLOADER_EMBED_ROUTE=/a
- UPLOADER_LENGTH=6
- UPLOADER_DIRECTORY=./uploads
- UPLOADER_ADMIN_LIMIT=104900000
- UPLOADER_USER_LIMIT=104900000
- UPLOADER_DISABLED_EXTS=

View File

@@ -1,40 +0,0 @@
const esbuild = require('esbuild');
const { existsSync } = require('fs');
const { rm } = require('fs/promises');
(async () => {
const watch = process.argv[2] === '--watch';
if (existsSync('./dist')) {
await rm('./dist', { recursive: true });
}
await esbuild.build({
tsconfig: 'tsconfig.json',
outdir: 'dist',
bundle: false,
platform: 'node',
treeShaking: true,
entryPoints: [
'src/server/index.ts',
'src/server/server.ts',
'src/server/util.ts',
'src/server/validateConfig.ts',
'src/lib/logger.ts',
'src/lib/readConfig.ts',
'src/lib/datasource/datasource.ts',
'src/lib/datasource/index.ts',
'src/lib/datasource/Local.ts',
'src/lib/datasource/S3.ts',
'src/lib/ds.ts',
'src/lib/config.ts',
],
format: 'cjs',
resolveExtensions: ['.ts', '.js'],
write: true,
watch,
incremental: watch,
sourcemap: false,
minify: process.env.NODE_ENV === 'production',
});
})();

View File

@@ -1,15 +1,14 @@
{
"name": "zipline",
"version": "3.4.4",
"name": "zip3",
"version": "3.4.0",
"license": "MIT",
"scripts": {
"dev": "node esbuild.config.js && REACT_EDITOR=code-insiders NODE_ENV=development node dist/server",
"build": "npm-run-all build:server build:schema build:next",
"build:server": "node esbuild.config.js",
"dev": "NODE_ENV=development node server",
"build": "npm-run-all build:schema build:next",
"build:next": "next build",
"build:schema": "prisma generate --schema=prisma/schema.prisma",
"migrate:dev": "prisma migrate dev --create-only",
"start": "node dist/server",
"start": "node server",
"lint": "next lint",
"seed": "ts-node --compiler-options \"{\\\"module\\\":\\\"commonjs\\\"}\" --transpile-only prisma/seed.ts",
"docker:run": "docker-compose up -d",
@@ -26,20 +25,17 @@
"@mantine/notifications": "^3.6.9",
"@mantine/prism": "^3.6.11",
"@modulz/radix-icons": "^4.0.0",
"@prisma/client": "^3.14.0",
"@prisma/migrate": "^3.14.0",
"@prisma/sdk": "^3.14.0",
"@prisma/client": "^3.9.2",
"@prisma/migrate": "^3.9.2",
"@prisma/sdk": "^3.9.2",
"@reduxjs/toolkit": "^1.6.0",
"argon2": "^0.28.2",
"aws-sdk": "^2.1085.0",
"colorette": "^1.2.2",
"cookie": "^0.4.1",
"fecha": "^4.2.1",
"fflate": "^0.7.3",
"find-my-way": "^5.2.0",
"multer": "^1.4.4",
"multer": "^1.4.2",
"next": "^12.1.0",
"prisma": "^3.14.0",
"prisma": "^3.9.2",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-redux": "^7.2.4",
@@ -54,7 +50,6 @@
"@types/multer": "^1.4.6",
"@types/node": "^15.12.2",
"babel-plugin-import": "^1.13.3",
"esbuild": "^0.14.23",
"eslint": "^7.32.0",
"eslint-config-next": "11.0.0",
"npm-run-all": "^4.1.5",
@@ -64,6 +59,5 @@
"repository": {
"type": "git",
"url": "https://github.com/diced/zipline.git"
},
"packageManager": "yarn@3.2.1"
}
}

View File

@@ -1,6 +1,8 @@
/*
Warnings:
- You are about to drop the `Theme` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "Theme" DROP CONSTRAINT "Theme_userId_fkey";
@@ -9,4 +11,4 @@ ALTER TABLE "Theme" DROP CONSTRAINT "Theme_userId_fkey";
ALTER TABLE "User" ALTER COLUMN "systemTheme" SET DEFAULT E'system';
-- DropTable
DROP TABLE "Theme";
DROP TABLE "Theme";

View File

@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "domains" TEXT[];

View File

@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "Image" ADD COLUMN "password" TEXT;

View File

@@ -8,17 +8,16 @@ generator client {
}
model User {
id Int @id @default(autoincrement())
id Int @id @default(autoincrement())
username String
password String
token String
administrator Boolean @default(false)
systemTheme String @default("system")
administrator Boolean @default(false)
systemTheme String @default("system")
embedTitle String?
embedColor String @default("#2f3136")
embedSiteName String? @default("{image.file} • {user.name}")
ratelimited Boolean @default(false)
domains String[]
embedColor String @default("#2f3136")
embedSiteName String? @default("{image.file} • {user.name}")
ratelimited Boolean @default(false)
images Image[]
urls Url[]
}
@@ -38,7 +37,6 @@ model Image {
views Int @default(0)
favorite Boolean @default(false)
embed Boolean @default(false)
password String?
invisible InvisibleImage?
format ImageFormat @default(RANDOM)
user User @relation(fields: [userId], references: [id])
@@ -48,7 +46,7 @@ model Image {
model InvisibleImage {
id Int @id @default(autoincrement())
invis String @unique
imageId Int @unique
imageId Int
image Image @relation(fields: [imageId], references: [id])
}
@@ -66,12 +64,12 @@ model Url {
model InvisibleUrl {
id Int @id @default(autoincrement())
invis String @unique
urlId String @unique
urlId String
url Url @relation(fields: [urlId], references: [id])
}
model Stats {
id Int @id @default(autoincrement())
id Int @id @default(autoincrement())
created_at DateTime @default(now())
data Json
}
}

164
server/index.js Normal file
View File

@@ -0,0 +1,164 @@
const next = require('next').default;
const { createServer } = require('http');
const { mkdir } = require('fs/promises');
const { extname } = require('path');
const validateConfig = require('./validateConfig');
const Logger = require('../src/lib/logger');
const readConfig = require('../src/lib/readConfig');
const mimes = require('../scripts/mimes');
const { log, getStats, getFile, migrations } = require('./util');
const { PrismaClient } = require('@prisma/client');
const { version } = require('../package.json');
const exts = require('../scripts/exts');
const serverLog = Logger.get('server');
serverLog.info(`starting zipline@${version} server`);
const dev = process.env.NODE_ENV === 'development';
(async () => {
try {
await run();
} catch (e) {
serverLog.error(e);
process.exit(1);
}
})();
async function run() {
const a = readConfig();
const config = validateConfig(a);
process.env.DATABASE_URL = config.core.database_url;
await migrations();
await mkdir(config.uploader.directory, { recursive: true });
const app = next({
dir: '.',
dev,
quiet: !dev,
hostname: config.core.host,
port: config.core.port,
});
await app.prepare();
const handle = app.getRequestHandler();
const prisma = new PrismaClient();
const srv = createServer(async (req, res) => {
if (req.url.startsWith('/r')) {
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,
},
});
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);
} 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 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);
} else if (image.embed) {
handle(req, res);
} else {
const ext = image.file.split('.').pop();
if (Object.keys(exts).includes(ext)) return handle(req, res);
const data = await getFile(config.uploader.directory, image.file);
if (!data) return app.render404(req, res);
await prisma.image.update({
where: { id: image.id },
data: { views: { increment: 1 } },
});
res.setHeader('Content-Type', image.mimetype);
res.end(data);
}
} else {
handle(req, res);
}
if (config.core.logger) log(req.url, res.statusCode);
});
srv.on('error', (e) => {
serverLog.error(e);
process.exit(1);
});
srv.on('listening', () => {
serverLog.info(`listening on ${config.core.host}:${config.core.port}`);
});
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) serverLog.info('stats updated');
}, config.core.stats_interval * 1000);
}

View File

@@ -1,34 +1,60 @@
import { Migrate } from '@prisma/migrate/dist/Migrate';
import { ensureDatabaseExists } from '@prisma/migrate/dist/utils/ensureDatabaseExists';
import Logger from '../lib/logger';
import { Datasource } from 'lib/datasource';
import { PrismaClient } from '@prisma/client';
const { readFile, readdir, stat } = require('fs/promises');
const { join } = require('path');
const { Migrate } = require('@prisma/migrate/dist/Migrate.js');
const Logger = require('../src/lib/logger.js');
export async function migrations() {
async function migrations() {
const migrate = new Migrate('./prisma/schema.prisma');
await ensureDatabaseExists('apply', true, './prisma/schema.prisma');
const diagnose = await migrate.diagnoseMigrationHistory({
optInToShadowDatabase: false,
});
if (diagnose.history?.diagnostic === 'databaseIsBehind') {
try {
Logger.get('database').info('migrating database');
await migrate.applyMigrations();
} finally {
migrate.stop();
Logger.get('database').info('finished migrating database');
}
Logger.get('database').info('migrating database');
await migrate.applyMigrations();
Logger.get('database').info('finished migrating database');
}
migrate.stop();
}
export function log(url: string) {
function log(url) {
if (url.startsWith('/_next') || url.startsWith('/__nextjs')) return;
return Logger.get('url').info(url);
}
export function bytesToRead(bytes: number) {
function shouldUseYarn() {
try {
execSync('yarnpkg --version', { stdio: 'ignore' });
return true;
} catch (e) {
return false;
}
}
async function getFile(dir, file) {
try {
const data = await readFile(join(process.cwd(), dir, file));
return data;
} catch (e) {
return null;
}
}
async function sizeOfDir(directory) {
const files = await readdir(directory);
let size = 0;
for (let i = 0, L = files.length; i !== L; ++i) {
const sta = await stat(join(directory, files[i]));
size += sta.size;
}
return size;
}
function bytesToRead(bytes) {
const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB'];
let num = 0;
@@ -41,8 +67,8 @@ export function bytesToRead(bytes: number) {
}
export async function getStats(prisma: PrismaClient, datasource: Datasource) {
const size = await datasource.size();
async function getStats(prisma, config) {
const size = await sizeOfDir(join(process.cwd(), config.uploader.directory));
const byUser = await prisma.image.groupBy({
by: ['userId'],
_count: {
@@ -86,9 +112,19 @@ export async function getStats(prisma: PrismaClient, datasource: Datasource) {
size: bytesToRead(size),
size_num: size,
count,
count_by_user: count_by_user.sort((a, b) => b.count - a.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),
types_count: types_count.sort((a,b) => b.count-a.count),
};
}
}
module.exports = {
migrations,
bytesToRead,
getFile,
getStats,
log,
sizeOfDir,
shouldUseYarn,
};

40
server/validateConfig.js Normal file
View File

@@ -0,0 +1,40 @@
const { object, bool, string, number, boolean, array } = require('yup');
const validator = object({
core: object({
secure: bool().default(false),
secret: string().min(8).required(),
host: string().default('0.0.0.0'),
port: number().default(3000),
database_url: string().required(),
logger: boolean().default(false),
stats_interval: number().default(1800),
}).required(),
uploader: object({
route: string().default('/u'),
embed_route: string().default('/a'),
length: number().default(6),
directory: string().default('./uploads'),
admin_limit: number().default(104900000),
user_limit: number().default(104900000),
disabled_extensions: array().default([]),
}).required(),
urls: object({
route: string().default('/go'),
length: number().default(6),
}).required(),
ratelimit: object({
user: number().default(0),
admin: number().default(0),
}),
});
module.exports = function validate(config) {
try {
return validator.validateSync(config, { abortEarly: false });
} catch (e) {
if (process.env.ZIPLINE_DOCKER_BUILD) return {};
throw `${e.errors.length} errors occured\n${e.errors.map(x => '\t' + x).join('\n')}`;
}
};

View File

@@ -8,6 +8,7 @@ import {
} from 'react-table';
import {
ActionIcon,
Checkbox,
createStyles,
Divider,
Group,

View File

@@ -122,7 +122,6 @@ export default function Layout({ children, user }) {
const openResetToken = () => modals.openConfirmModal({
title: 'Reset Token',
centered: true,
children: (
<Text size='sm'>
Once you reset your token, you will have to update any uploaders to use this new token.
@@ -154,7 +153,6 @@ export default function Layout({ children, user }) {
const openCopyToken = () => modals.openConfirmModal({
title: 'Copy Token',
centered: true,
children: (
<Text size='sm'>
Make sure you don&apos;t share this token with anyone as they will be able to upload files on your behalf.
@@ -328,4 +326,4 @@ export default function Layout({ children, user }) {
<Paper withBorder padding='md' shadow='xs'>{children}</Paper>
</AppShell>
);
}
}

View File

@@ -1,6 +0,0 @@
import React from 'react';
import { Text } from '@mantine/core';
export default function StatText({ children }) {
return <Text color='gray' size='xl'>{children}</Text>;
}

View File

@@ -1,16 +1,15 @@
import React, { useEffect, useState } from 'react';
import Card from 'components/Card';
import ZiplineImage from 'components/Image';
import Image from 'components/Image';
import ImagesTable from 'components/ImagesTable';
import useFetch from 'lib/hooks/useFetch';
import { useStoreSelector } from 'lib/redux/store';
import { Text, Skeleton, Title, SimpleGrid } from '@mantine/core';
import { Box, Text, Table, Skeleton, Title, SimpleGrid } from '@mantine/core';
import { randomId, useClipboard } from '@mantine/hooks';
import Link from 'components/Link';
import { CopyIcon, Cross1Icon, TrashIcon } from '@modulz/radix-icons';
import { useNotifications } from '@mantine/notifications';
import StatText from 'components/StatText';
type Aligns = 'inherit' | 'right' | 'left' | 'center' | 'justify';
@@ -28,6 +27,37 @@ export function bytesToRead(bytes: number) {
return `${bytes.toFixed(1)} ${units[num]}`;
}
function StatText({ children }) {
return <Text color='gray' size='xl'>{children}</Text>;
}
function StatTable({ rows, columns }) {
return (
<Box sx={{ pt: 1 }}>
<Table highlightOnHover>
<thead>
<tr>
{columns.map(col => (
<th key={randomId()}>{col.name}</th>
))}
</tr>
</thead>
<tbody>
{rows.map(row => (
<tr key={randomId()}>
{columns.map(col => (
<td key={randomId()}>
{col.format ? col.format(row[col.id]) : row[col.id]}
</td>
))}
</tr>
))}
</tbody>
</Table>
</Box>
);
}
export default function Dashboard() {
const user = useStoreSelector(state => state.user);
@@ -98,7 +128,8 @@ export default function Dashboard() {
]}
>
{recent.length ? recent.map(image => (
<ZiplineImage key={randomId()} image={image} updateImages={updateImages} />
// eslint-disable-next-line jsx-a11y/alt-text
<Image key={randomId()} image={image} updateImages={updateImages} />
)) : [1,2,3,4].map(x => (
<div key={x}>
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }}/>

View File

@@ -1,13 +1,12 @@
import React, { useState } from 'react';
import React from 'react';
import useFetch from 'hooks/useFetch';
import Link from 'components/Link';
import { useStoreDispatch, useStoreSelector } from 'lib/redux/store';
import { updateUser } from 'lib/redux/reducers/user';
import { randomId, useForm } from '@mantine/hooks';
import { Tooltip, TextInput, Button, Text, Title, Group, ColorInput, MultiSelect, Space } from '@mantine/core';
import { DownloadIcon, Cross1Icon } from '@modulz/radix-icons';
import { useNotifications } from '@mantine/notifications';
import { useForm } from '@mantine/hooks';
import { Tooltip, TextInput, Button, Text, Title, Group, ColorInput } from '@mantine/core';
import { DownloadIcon } from '@modulz/radix-icons';
function VarsTooltip({ children }) {
return (
@@ -28,9 +27,6 @@ function VarsTooltip({ children }) {
export default function Manage() {
const user = useStoreSelector(state => state.user);
const dispatch = useStoreDispatch();
const notif = useNotifications();
const [domains, setDomains] = useState(user.domains ?? []);
const genShareX = (withEmbed: boolean = false, withZws: boolean = false) => {
const config = {
@@ -65,7 +61,6 @@ export default function Manage() {
embedTitle: user.embedTitle ?? '',
embedColor: user.embedColor,
embedSiteName: user.embedSiteName ?? '',
domains: user.domains ?? [],
},
});
@@ -78,52 +73,19 @@ export default function Manage() {
if (cleanUsername === '') return form.setFieldError('username', 'Username can\'t be nothing');
const id = notif.showNotification({
title: 'Updating user...',
message: '',
loading: true,
autoClose: false,
});
const data = {
username: cleanUsername,
password: cleanPassword === '' ? null : cleanPassword,
embedTitle: cleanEmbedTitle === '' ? null : cleanEmbedTitle,
embedColor: cleanEmbedColor === '' ? null : cleanEmbedColor,
embedSiteName: cleanEmbedSiteName === '' ? null : cleanEmbedSiteName,
domains,
};
const newUser = await useFetch('/api/user', 'PATCH', data);
if (newUser.error) {
if (newUser.invalidDomains) {
notif.updateNotification(id, {
message: <>
<Text mt='xs'>The following domains are invalid:</Text>
{newUser.invalidDomains.map(err => (
<>
<Text color='gray' key={randomId()}>{err.domain}: {err.reason}</Text>
<Space h='md' />
</>
))}
</>,
color: 'red',
icon: <Cross1Icon />,
});
}
notif.updateNotification(id, {
title: 'Couldn\'t save user',
message: newUser.error,
color: 'red',
icon: <Cross1Icon />,
});
} else {
dispatch(updateUser(newUser));
notif.updateNotification(id, {
title: 'Saved User',
message: '',
});
}
};
@@ -135,23 +97,10 @@ export default function Manage() {
</VarsTooltip>
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
<TextInput id='username' label='Username' {...form.getInputProps('username')} />
<TextInput id='password' label='Password' type='password' {...form.getInputProps('password')} />
<TextInput id='password' label='Password'type='password' {...form.getInputProps('password')} />
<TextInput id='embedTitle' label='Embed Title' {...form.getInputProps('embedTitle')} />
<ColorInput id='embedColor' label='Embed Color' {...form.getInputProps('embedColor')} />
<TextInput id='embedSiteName' label='Embed Site Name' {...form.getInputProps('embedSiteName')} />
<MultiSelect
id='domains'
label='Domains'
data={domains}
placeholder='Leave blank if you dont want random domain selection.'
creatable
searchable
clearable
getCreateLabel={query => `Add ${query}`}
onCreate={query => setDomains((current) => [...current, query])}
{...form.getInputProps('domains')}
/>
<Group position='right' sx={{ paddingTop: 12 }}>
<Button
type='submit'

View File

@@ -1,11 +1,17 @@
import React, { useEffect, useState } from 'react';
import Card from 'components/Card';
import StatText from 'components/StatText';
import Image from 'components/Image';
import ImagesTable from 'components/ImagesTable';
import useFetch from 'lib/hooks/useFetch';
import { useStoreSelector } from 'lib/redux/store';
import { Box, Text, Table, Skeleton, Title, SimpleGrid } from '@mantine/core';
import { randomId } from '@mantine/hooks';
import { randomId, useClipboard } from '@mantine/hooks';
import Link from 'components/Link';
import { CopyIcon, Cross1Icon, TrashIcon } from '@modulz/radix-icons';
import { useNotifications } from '@mantine/notifications';
type Aligns = 'inherit' | 'right' | 'left' | 'center' | 'justify';
export function bytesToRead(bytes: number) {
if (isNaN(bytes)) return '0.0 B';
@@ -21,6 +27,10 @@ export function bytesToRead(bytes: number) {
return `${bytes.toFixed(1)} ${units[num]}`;
}
function StatText({ children }) {
return <Text color='gray' size='xl'>{children}</Text>;
}
function StatTable({ rows, columns }) {
return (
<Box sx={{ pt: 1 }}>
@@ -48,7 +58,9 @@ function StatTable({ rows, columns }) {
);
}
export default function Stats() {
export default function Dashboard() {
const user = useStoreSelector(state => state.user);
const [stats, setStats] = useState(null);
const update = async () => {

View File

@@ -30,7 +30,7 @@ function getIconColor(status, theme) {
: theme.black;
}
export default function Upload() {
export default function Upload({ route }) {
const theme = useMantineTheme();
const notif = useNotifications();
const clipboard = useClipboard();
@@ -58,7 +58,6 @@ export default function Upload() {
title: 'Uploading Images...',
message: '',
loading: true,
autoClose: false,
});
const res = await fetch('/api/upload', {
@@ -90,8 +89,10 @@ export default function Upload() {
return (
<>
<Dropzone onDrop={(f) => setFiles([...files, ...f])}>
{status => (
<Dropzone
onDrop={(f) => setFiles([...files, ...f])}
>
{(status) => (
<>
<Group position='center' spacing='xl' style={{ minHeight: 220, pointerEvents: 'none' }}>
<ImageUploadIcon

View File

@@ -1,12 +1,12 @@
import React, { useState, useEffect } from 'react';
import { useStoreSelector } from 'lib/redux/store';
import useFetch from 'hooks/useFetch';
import { useRouter } from 'next/router';
import { useForm } from '@mantine/hooks';
import { Avatar, Modal, Title, TextInput, Group, Button, Card, ActionIcon, SimpleGrid, Switch, Skeleton, Checkbox } from '@mantine/core';
import { Avatar, Modal, Title, TextInput, Group, Button, Card, Grid, ActionIcon, SimpleGrid, Switch, Skeleton } from '@mantine/core';
import { Cross1Icon, PlusIcon, TrashIcon } from '@modulz/radix-icons';
import { useNotifications } from '@mantine/notifications';
import { useModals } from '@mantine/modals';
function CreateUserModal({ open, setOpen, updateUsers }) {
@@ -51,7 +51,7 @@ function CreateUserModal({ open, setOpen, updateUsers }) {
updateUsers();
};
return (
<Modal
opened={open}
@@ -76,15 +76,22 @@ export default function Users() {
const user = useStoreSelector(state => state.user);
const router = useRouter();
const notif = useNotifications();
const modals = useModals();
const [users, setUsers] = useState([]);
const [open, setOpen] = useState(false);
const handleDelete = async (user, delete_images) => {
const updateUsers = async () => {
const us = await useFetch('/api/users');
if (!us.error) {
setUsers(us);
} else {
router.push('/dashboard');
};
};
const handleDelete = async (user) => {
const res = await useFetch('/api/users', 'DELETE', {
id: user.id,
delete_images,
});
if (res.error) {
notif.showNotification({
@@ -100,39 +107,9 @@ export default function Users() {
color: 'green',
icon: <TrashIcon />,
});
updateUsers();
}
};
// 2-step modal for deleting user if they want to delete their images too.
const openDeleteModal = user => modals.openConfirmModal({
title: `Delete ${user.username}?`,
closeOnConfirm: false,
centered: true,
labels: { confirm: 'Yes', cancel: 'No' },
onConfirm: () => {
modals.openConfirmModal({
title: `Delete ${user.username}'s images?`,
labels: { confirm: 'Yes', cancel: 'No' },
onConfirm: () => {
handleDelete(user, true);
modals.closeAll();
},
onCancel: () => {
handleDelete(user, false);
modals.closeAll();
},
});
},
});
const updateUsers = async () => {
const us = await useFetch('/api/users');
if (!us.error) {
setUsers(us);
} else {
router.push('/dashboard');
};
updateUsers();
};
useEffect(() => {
@@ -144,7 +121,7 @@ export default function Users() {
<CreateUserModal open={open} setOpen={setOpen} updateUsers={updateUsers} />
<Group>
<Title sx={{ marginBottom: 12 }}>Users</Title>
<ActionIcon variant='filled' color='primary' onClick={() => setOpen(true)}><PlusIcon /></ActionIcon>
<ActionIcon variant='filled' color='primary' onClick={() => setOpen(true)}><PlusIcon/></ActionIcon>
</Group>
<SimpleGrid
cols={3}
@@ -153,23 +130,23 @@ export default function Users() {
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
]}
>
{users.length ? users.filter(x => x.username !== user.username).map(user => (
{users.length ? users.filter(x => x.username !== user.username).map((user, i) => (
<Card key={user.id} sx={{ maxWidth: '100%' }}>
<Group position='apart'>
<Group position='left'>
<Avatar color={user.administrator ? 'primary' : 'dark'}>{user.username[0]}</Avatar>
<Avatar color={user.administrator ? 'primary' : 'dark'}>{user.username[0]}</Avatar>
<Title>{user.username}</Title>
</Group>
<Group position='right'>
<ActionIcon aria-label='delete' onClick={() => openDeleteModal(user)}>
<ActionIcon aria-label='delete' onClick={() => handleDelete(user)}>
<TrashIcon />
</ActionIcon>
</Group>
</Group>
</Card>
)) : [1, 2, 3, 4].map(x => (
)): [1,2,3,4].map(x => (
<div key={x}>
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }} />
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }}/>
</div>
))}
</SimpleGrid>

View File

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

View File

@@ -1,40 +0,0 @@
import { createReadStream, ReadStream } from 'fs';
import { readdir, rm, stat, writeFile } from 'fs/promises';
import { join } from 'path';
import { Datasource } from './datasource';
export class Local extends Datasource {
public name: string = 'local';
public constructor(public path: string) {
super();
}
public async save(file: string, data: Buffer): Promise<void> {
await writeFile(join(process.cwd(), this.path, file), data);
}
public async delete(file: string): Promise<void> {
await rm(join(process.cwd(), this.path, file));
}
public get(file: string): ReadStream {
try {
return createReadStream(join(process.cwd(), this.path, file));
} catch (e) {
return null;
}
}
public async size(): Promise<number> {
const files = await readdir(this.path);
let size = 0;
for (let i = 0, L = files.length; i !== L; ++i) {
const sta = await stat(join(this.path, files[i]));
size += sta.size;
}
return size;
}
}

View File

@@ -1,74 +0,0 @@
import { Datasource } from './datasource';
import AWS from 'aws-sdk';
import { Readable } from 'stream';
export class S3 extends Datasource {
public name: string = 'S3';
public s3: AWS.S3;
public constructor(
public accessKey: string,
public secretKey: string,
public bucket: string,
) {
super();
this.s3 = new AWS.S3({
accessKeyId: accessKey,
secretAccessKey: secretKey,
});
}
public async save(file: string, data: Buffer): Promise<void> {
return new Promise((resolve, reject) => {
this.s3.upload({
Bucket: this.bucket,
Key: file,
Body: data,
}, err => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
public async delete(file: string): Promise<void> {
return new Promise((resolve, reject) => {
this.s3.deleteObject({
Bucket: this.bucket,
Key: file,
}, err => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
public get(file: string): Readable {
// Unfortunately, aws-sdk is bad and the stream still loads everything into memory.
return this.s3.getObject({
Bucket: this.bucket,
Key: file,
}).createReadStream();
}
public async size(): Promise<number> {
return new Promise((resolve, reject) => {
this.s3.listObjects({
Bucket: this.bucket,
}, (err, data) => {
if (err) {
reject(err);
} else {
const size = data.Contents.reduce((acc, cur) => acc + cur.Size, 0);
resolve(size);
}
});
});
}
}

View File

@@ -1,10 +0,0 @@
import { Readable } from 'stream';
export abstract class Datasource {
public name: string;
public abstract save(file: string, data: Buffer): Promise<void>;
public abstract delete(file: string): Promise<void>;
public abstract get(file: string): Readable;
public abstract size(): Promise<number>;
}

View File

@@ -1,4 +0,0 @@
export { Datasource } from './datasource';
export { Local } from './Local';
export { S3 } from './S3';

View File

@@ -1,20 +0,0 @@
import config from './config';
import { S3, Local } from './datasource';
import Logger from './logger';
if (!global.datasource) {
switch (config.datasource.type) {
case 's3':
Logger.get('datasource').info(`Using S3(${config.datasource.s3.bucket}) datasource`);
global.datasource = new S3(config.datasource.s3.access_key_id, config.datasource.s3.secret_access_key, config.datasource.s3.bucket);
break;
case 'local':
Logger.get('datasource').info(`Using local(${config.datasource.local.directory}) datasource`);
global.datasource = new Local(config.datasource.local.directory);
break;
default:
throw new Error('Invalid datasource type');
}
}
export default global.datasource;

38
src/lib/logger.js Normal file
View File

@@ -0,0 +1,38 @@
const { format } = require('fecha');
const { blueBright, red, cyan } = require('colorette');
module.exports = class Logger {
static get(clas) {
if (typeof clas !== 'function') if (typeof clas !== 'string') throw new Error('not string/function');
const name = clas.name ?? clas;
return new Logger(name);
}
constructor (name) {
this.name = name;
}
info(message) {
console.log(this.formatMessage('INFO', this.name, message));
}
error(error) {
console.log(this.formatMessage('ERROR', this.name, error.stack ?? error));
}
formatMessage(level, name, message) {
const time = format(new Date(), 'YYYY-MM-DD hh:mm:ss,SSS A');
return `${time} ${this.formatLevel(level)} [${blueBright(name)}] ${message}`;
}
formatLevel(level) {
switch (level) {
case 'INFO':
return cyan('INFO ');
case 'ERROR':
return red('ERROR');
}
}
};

View File

@@ -1,45 +0,0 @@
import { format } from 'fecha';
import { blueBright, red, cyan } from 'colorette';
export enum LoggerLevel {
ERROR,
INFO,
}
export default class Logger {
public name: string;
static get(clas: any) {
if (typeof clas !== 'function') if (typeof clas !== 'string') throw new Error('not string/function');
const name = clas.name ?? clas;
return new Logger(name);
}
constructor(name: string) {
this.name = name;
}
info(...args) {
console.log(this.formatMessage(LoggerLevel.INFO, this.name, args.join(' ')));
}
error(...args: any[]) {
console.log(this.formatMessage(LoggerLevel.ERROR, this.name, args.map(error => error.stack ?? error).join(' ')));
}
formatMessage(level: LoggerLevel, name, message) {
const time = format(new Date(), 'YYYY-MM-DD hh:mm:ss,SSS A');
return `${time} ${this.formatLevel(level)} [${blueBright(name)}] ${message}`;
}
formatLevel(level: LoggerLevel) {
switch (level) {
case LoggerLevel.INFO:
return cyan('INFO ');
case LoggerLevel.ERROR:
return red('ERROR');
}
}
};

View File

@@ -11,7 +11,7 @@ export interface NextApiFile {
originalname: string;
encoding: string;
mimetype: string;
buffer: Buffer;
buffer: string;
size: number;
}
@@ -25,7 +25,6 @@ export type NextApiReq = NextApiRequest & {
administrator: boolean;
id: number;
password: string;
domains: string[];
} | null | void>;
getCookie: (name: string) => string | null;
cleanCookie: (name: string) => void;
@@ -34,7 +33,7 @@ export type NextApiReq = NextApiRequest & {
export type NextApiRes = NextApiResponse & {
error: (message: string) => void;
forbid: (message: string, extra?: any) => void;
forbid: (message: string) => void;
bad: (message: string) => void;
json: (json: any) => void;
ratelimited: () => void;
@@ -53,12 +52,11 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
});
};
res.forbid = (message: string, extra: any = {}) => {
res.forbid = (message: string) => {
res.setHeader('Content-Type', 'application/json');
res.status(403);
res.json({
error: '403: ' + message,
...extra,
});
};
@@ -95,7 +93,6 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
maxAge: undefined,
}));
};
req.user = async () => {
try {
const userId = req.getCookie('user');
@@ -114,7 +111,6 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
systemTheme: true,
token: true,
username: true,
domains: true,
},
});

View File

@@ -1,8 +1,7 @@
import { existsSync, readFileSync } from 'fs';
import { join } from 'path';
import parse from '@iarna/toml/parse-string';
import Logger from './logger';
import { Config } from './types';
const { existsSync, readFileSync } = require('fs');
const { join } = require('path');
const parse = require('@iarna/toml/parse-string.js');
const Logger = require('./logger.js');
const e = (val, type, fn) => ({ val, type, fn });
@@ -15,14 +14,9 @@ const envValues = [
e('LOGGER', 'boolean', (c, v) => c.core.logger = v ?? true),
e('STATS_INTERVAL', 'number', (c, v) => c.core.stats_interval = v),
e('DATASOURCE_TYPE', 'string', (c, v) => c.datasource.type = v),
e('DATASOURCE_LOCAL_DIRECTORY', 'string', (c, v) => c.datasource.local.directory = v),
e('DATASOURCE_S3_ACCESS_KEY_ID', 'string', (c, v) => c.datasource.s3.access_key_id = v ),
e('DATASOURCE_S3_SECRET_ACCESS_KEY', 'string', (c, v) => c.datasource.s3.secret_access_key = v),
e('DATASOURCE_S3_BUCKET', 'string', (c, v) => c.datasource.s3.bucket = 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 = []),
@@ -34,7 +28,7 @@ const envValues = [
e('RATELIMIT_ADMIN', 'number', (c, v) => c.ratelimit.user = v ?? 0),
];
export default function readConfig(): Config {
module.exports = function readConfig() {
if (!existsSync(join(process.cwd(), 'config.toml'))) {
if (!process.env.ZIPLINE_DOCKER_BUILD) Logger.get('config').info('reading environment');
return tryReadEnv();
@@ -49,7 +43,7 @@ export default function readConfig(): Config {
}
};
function tryReadEnv(): Config {
function tryReadEnv() {
const config = {
core: {
secure: undefined,
@@ -60,20 +54,10 @@ function tryReadEnv(): Config {
logger: undefined,
stats_interval: undefined,
},
datasource: {
type: undefined,
local: {
directory: undefined,
},
s3: {
access_key_id: undefined,
secret_access_key: undefined,
bucket: undefined,
},
},
uploader: {
route: undefined,
length: undefined,
directory: undefined,
admin_limit: undefined,
user_limit: undefined,
disabled_extentions: undefined,
@@ -90,7 +74,7 @@ function tryReadEnv(): Config {
for (let i = 0, L = envValues.length; i !== L; ++i) {
const envValue = envValues[i];
let value: any = process.env[envValue.val];
let value = process.env[envValue.val];
if (!value) {
envValues[i].fn(config, undefined);

View File

@@ -7,7 +7,6 @@ export interface User {
embedColor: string;
embedSiteName: string;
systemTheme: string;
domains: string[];
}
const initialState: User = null;

View File

@@ -21,26 +21,6 @@ export interface ConfigCore {
stats_interval: number;
}
export interface ConfigDatasource {
// The type of datasource
type: 'local' | 's3';
// The local datasource
local: ConfigLocalDatasource;
s3?: ConfigS3Datasource;
}
export interface ConfigLocalDatasource {
// The directory to store files in
directory: string;
}
export interface ConfigS3Datasource {
access_key_id: string;
secret_access_key: string;
bucket: string;
}
export interface ConfigUploader {
// The route uploads will be served on
route: string;
@@ -48,6 +28,9 @@ export interface ConfigUploader {
// Length of random chars to generate for file names
length: number;
// Where uploads are stored
directory: string;
// Admin file upload limit
admin_limit: number;
@@ -80,5 +63,4 @@ export interface Config {
uploader: ConfigUploader;
urls: ConfigUrls;
ratelimit: ConfigRatelimit;
datasource: ConfigDatasource;
}

View File

@@ -1,85 +1,47 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect } from 'react';
import Head from 'next/head';
import { GetServerSideProps } from 'next';
import { Box, Button, Modal, PasswordInput } from '@mantine/core';
import { Box, useMantineTheme } from '@mantine/core';
import config from 'lib/config';
import prisma from 'lib/prisma';
import { getFile } from '../../server/util';
import { parse } from 'lib/clientUtils';
import * as exts from '../../scripts/exts';
import { Prism } from '@mantine/prism';
import ZiplineTheming from 'components/Theming';
export default function EmbeddedImage({ image, user, pass }) {
export default function EmbeddedImage({ image, user }) {
const dataURL = (route: string) => `${route}/${image.file}`;
const [opened, setOpened] = useState(pass);
const [password, setPassword] = useState('');
const [error, setError] = useState('');
// reapply date from workaround
image.created_at = new Date(image.created_at);
const check = async () => {
const res = await fetch(`/api/auth/image?id=${image.id}&password=${password}`);
if (res.ok) {
setError('');
updateImage(`/api/auth/image?id=${image.id}&password=${password}`);
setOpened(false);
} else {
setError('Invalid password');
}
};
const updateImage = async (url?: string) => {
const updateImage = () => {
const imageEl = document.getElementById('image_content') as HTMLImageElement;
const img = new Image();
img.addEventListener('load', function() {
if (this.naturalWidth > innerWidth) imageEl.width = Math.floor(this.naturalWidth * Math.min((innerHeight / this.naturalHeight), (innerWidth / this.naturalWidth)));
else imageEl.width = this.naturalWidth;
});
img.src = url || dataURL('/r');
if (url) {
imageEl.src = url;
};
};
const original = new Image;
original.src = dataURL('/r');
useEffect(() => {
if (pass) {
setOpened(true);
} else {
updateImage();
}
}, []);
if (original.width > innerWidth) imageEl.width = Math.floor(original.width * Math.min((innerHeight / original.height), (innerWidth / original.width)));
else imageEl.width = original.width;
};
useEffect(() => updateImage(), []);
return (
<>
<Head>
{image.embed && (
<>
{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} />
{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')} />
<meta property='twitter:card' content='summary_large_image' />
<title>{image.file}</title>
</Head>
<Modal
opened={opened}
onClose={() => setOpened(false)}
title='Password Protected'
centered={true}
hideCloseButton={true}
closeOnEscape={false}
closeOnClickOutside={false}
>
<PasswordInput label='Password' placeholder='Password' error={error} value={password} onChange={e => setPassword(e.target.value)} />
<Button fullWidth onClick={() => check()} mt='md'>
Submit
</Button>
</Modal>
<Box
sx={{
display: 'flex',
@@ -118,7 +80,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
props: {},
redirect: {
destination: url.destination,
},
},
};
} else {
@@ -137,7 +99,6 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
userId: true,
embed: true,
created_at: true,
password: true,
},
});
if (!image) return { notFound: true };
@@ -159,6 +120,8 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
image.created_at = image.created_at.toString();
const prismRender = Object.keys(exts).includes(image.file.split('.').pop());
// let prismRenderCode;/
// if (prismRender) prismRenderCode = (await getFile(config.uploader.directory, id)).toString();
if (prismRender) return {
redirect: {
destination: `/code/${image.file}`,
@@ -167,21 +130,17 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
};
if (!image.mimetype.startsWith('image')) {
const { default: datasource } = await import('lib/ds');
const data = datasource.get(image.file);
const data = await getFile(config.uploader.directory, id);
if (!data) return { notFound: true };
data.pipe(context.res);
context.res.end(data);
return { props: {} };
}
const pass = image.password ? true : false;
delete image.password;
return {
props: {
image,
user,
pass,
},
};
}

View File

@@ -1,33 +0,0 @@
import prisma from 'lib/prisma';
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
import { checkPassword } from 'lib/util';
import datasource from 'lib/ds';
import mimes from '../../../../scripts/mimes';
import { extname } from 'path';
async function handler(req: NextApiReq, res: NextApiRes) {
const { id, password } = req.query;
const image = await prisma.image.findFirst({
where: {
id: Number(id),
},
});
if (!image) return res.status(404).end(JSON.stringify({ error: 'Image not found' }));
if (!password) return res.forbid('No password provided');
const valid = await checkPassword(password as string, image.password);
if (!valid) return res.forbid('Wrong password');
const data = datasource.get(image.file);
if (!data) return res.error('Image not found');
const mimetype = mimes[extname(image.file)] ?? 'application/octet-stream';
res.setHeader('Content-Type', mimetype);
data.pipe(res);
data.on('error', () => res.error('Image not found'));
data.on('end', () => res.end());
}
export default withZipline(handler);

View File

@@ -2,19 +2,22 @@ import multer from 'multer';
import prisma from 'lib/prisma';
import zconfig from 'lib/config';
import { NextApiReq, NextApiRes, withZipline } from 'lib/middleware/withZipline';
import { createInvisImage, randomChars, hashPassword } from 'lib/util';
import { createInvisImage, randomChars } from 'lib/util';
import { writeFile } from 'fs/promises';
import { join } from 'path';
import Logger from 'lib/logger';
import { ImageFormat, InvisibleImage } from '@prisma/client';
import { format as formatDate } from 'fecha';
import { v4 } from 'uuid';
import datasource from 'lib/ds';
const uploader = multer();
const uploader = multer({
storage: multer.memoryStorage(),
});
async function handler(req: NextApiReq, res: NextApiRes) {
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,
@@ -23,16 +26,13 @@ async function handler(req: NextApiReq, res: NextApiRes) {
if (!user) return res.forbid('authorization incorect');
if (user.ratelimited) return res.ratelimited();
await run(uploader.array('file'))(req, res);
if (!req.files) return res.error('no files');
if (req.files && req.files.length === 0) return res.error('no files');
const rawFormat = ((req.headers.format || '') as string).toUpperCase() || 'RANDOM';
const format: ImageFormat = Object.keys(ImageFormat).includes(rawFormat) && ImageFormat[rawFormat];
const files = [];
for (let i = 0; i !== req.files.length; ++i) {
const file = req.files[i];
if (file.size > zconfig.uploader[user.administrator ? 'admin_limit' : 'user_limit']) return res.error(`file[${i}] size too big`);
@@ -56,10 +56,6 @@ async function handler(req: NextApiReq, res: NextApiRes) {
break;
}
let password = null;
if (req.headers.password) {
password = await hashPassword(req.headers.password as string);
}
let invis: InvisibleImage;
const image = await prisma.image.create({
@@ -69,20 +65,14 @@ async function handler(req: NextApiReq, res: NextApiRes) {
userId: user.id,
embed: !!req.headers.embed,
format,
password,
},
});
if (req.headers.zws) invis = await createInvisImage(zconfig.uploader.length, image.id);
await datasource.save(image.file, file.buffer);
Logger.get('image').info(`User ${user.username} (${user.id}) uploaded an image ${image.file} (${image.id})`);
if (user.domains.length) {
const domain = user.domains[Math.floor(Math.random() * user.domains.length)];
files.push(`${domain}${zconfig.uploader.route}/${invis ? invis.invis : image.file}`);
} else {
files.push(`${zconfig.core.secure ? 'https' : 'http'}://${req.headers.host}${zconfig.uploader.route}/${invis ? invis.invis : image.file}`);
}
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}`);
}
if (user.administrator && zconfig.ratelimit.admin !== 0) {
@@ -123,6 +113,8 @@ function run(middleware: any) {
}
export default async function handlers(req, res) {
await run(uploader.array('file'))(req, res);
return withZipline(handler)(req, res);
};

View File

@@ -1,8 +1,10 @@
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
import prisma from 'lib/prisma';
import config from 'lib/config';
import { chunk } from 'lib/util';
import { rm } from 'fs/promises';
import { join } from 'path';
import Logger from 'lib/logger';
import datasource from 'lib/ds';
async function handler(req: NextApiReq, res: NextApiRes) {
const user = await req.user();
@@ -17,11 +19,10 @@ async function handler(req: NextApiReq, res: NextApiRes) {
},
});
await datasource.delete(image.file);
await rm(join(process.cwd(), config.uploader.directory, image.file));
Logger.get('image').info(`User ${user.username} (${user.id}) deleted an image ${image.file} (${image.id})`);
delete image.password;
return res.json(image);
} else if (req.method === 'PATCH') {
if (!req.body.id) return res.error('no file id');
@@ -35,7 +36,6 @@ async function handler(req: NextApiReq, res: NextApiRes) {
},
});
delete image.password;
return res.json(image);
} else {
let images = await prisma.image.findMany({
@@ -43,9 +43,6 @@ async function handler(req: NextApiReq, res: NextApiRes) {
userId: user.id,
favorite: !!req.query.favorite,
},
orderBy: {
created_at: 'desc',
},
select: {
created_at: true,
file: true,

View File

@@ -2,7 +2,6 @@ import prisma from 'lib/prisma';
import { hashPassword } from 'lib/util';
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
import Logger from 'lib/logger';
import pkg from '../../../../package.json';
async function handler(req: NextApiReq, res: NextApiRes) {
const user = await req.user();
@@ -52,36 +51,6 @@ async function handler(req: NextApiReq, res: NextApiRes) {
data: { systemTheme: req.body.systemTheme },
});
if (req.body.domains) {
if (!req.body.domains) await prisma.user.update({
where: { id: user.id },
data: { domains: [] },
});
const invalidDomains = [];
for (const domain of req.body.domains) {
try {
const url = new URL(domain);
url.pathname = '/api/version';
const res = await fetch(url.toString());
if (!res.ok) invalidDomains.push({ domain, reason: 'Got a non OK response' });
else {
const body = await res.json();
if (body?.local !== pkg.version) invalidDomains.push({ domain, reason: 'Version mismatch' });
else await prisma.user.update({
where: { id: user.id },
data: { domains: { push: url.origin } },
});
}
} catch (e) {
invalidDomains.push({ domain, reason: e.message });
}
}
if (invalidDomains.length) return res.forbid('Invalid domains', { invalidDomains });
}
const newUser = await prisma.user.findFirst({
where: {
id: Number(user.id),
@@ -97,7 +66,6 @@ async function handler(req: NextApiReq, res: NextApiRes) {
systemTheme: true,
token: true,
username: true,
domains: true,
},
});

View File

@@ -1,6 +1,5 @@
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
import prisma from 'lib/prisma';
import Logger from 'lib/logger';
async function handler(req: NextApiReq, res: NextApiRes) {
const user = await req.user();
@@ -17,15 +16,6 @@ async function handler(req: NextApiReq, res: NextApiRes) {
});
if (!deleteUser) return res.forbid('user doesn\'t exist');
if (req.body.delete_images) {
const { count } = await prisma.image.deleteMany({
where: {
userId: deleteUser.id,
},
});
Logger.get('image').info(`User ${user.username} (${user.id}) deleted ${count} images of user ${deleteUser.username} (${deleteUser.id})`);
}
await prisma.user.delete({
where: {
id: deleteUser.id,

View File

@@ -9,6 +9,7 @@ import { Cross1Icon, DownloadIcon } from '@modulz/radix-icons';
export default function Login() {
const router = useRouter();
const notif = useNotifications();
const [versions, setVersions] = React.useState<{ upstream: string, local: string }>(null);
const form = useForm({
initialValues: {
@@ -35,14 +36,25 @@ export default function Login() {
icon: <Cross1Icon />,
});
} else {
await router.push(router.query.url as string || '/dashboard');
router.push(router.query.url as string || '/dashboard');
}
};
useEffect(() => {
(async () => {
const a = await fetch('/api/user');
if (a.ok) await router.push('/dashboard');
if (a.ok) router.push('/dashboard');
else {
const v = await useFetch('/api/version');
setVersions(v);
if (v.local !== v.upstream) {
notif.showNotification({
title: 'Update available',
message: `A new version of Zipline is available. You are running ${v.local} and the latest version is ${v.upstream}.`,
icon: <DownloadIcon />,
});
}
}
})();
}, []);
@@ -59,6 +71,27 @@ export default function Login() {
</form>
</div>
</Center>
<Box
sx={{
zIndex: 99,
position: 'fixed',
bottom: '10px',
right: '20px',
}}
>
{versions && (
<Tooltip
wrapLines
width={220}
transition='rotate-left'
transitionDuration={200}
label={versions.local !== versions.upstream ? 'Looks like you are running an outdated version of Zipline. Please update to the latest version.' : 'You are running the latest version of Zipline.'}
>
<Badge radius='md' size='lg' variant='dot' color={versions.local !== versions.upstream ? 'red' : 'primary'}>{versions.local}</Badge>
</Tooltip>
)}
</Box>
</>
);
}

View File

@@ -4,6 +4,8 @@ import { LoadingOverlay } from '@mantine/core';
export default function Logout() {
const router = useRouter();
const [visible, setVisible] = useState(true);
useEffect(() => {
(async () => {
@@ -18,7 +20,7 @@ export default function Logout() {
}, []);
return (
<LoadingOverlay visible={true} />
<LoadingOverlay visible={visible} />
);
}

View File

@@ -11,7 +11,7 @@ export default function Code() {
useEffect(() => {
(async () => {
const res = await fetch('/r/' + id);
if (id && !res.ok) await router.push('/404');
if (id && !res.ok) router.push('/404');
const data = await res.text();
if (id) setPrismRenderCode(data);
})();

View File

@@ -1,7 +1,9 @@
import React from 'react';
import { GetStaticProps } from 'next';
import useLogin from 'hooks/useLogin';
import Layout from 'components/Layout';
import Upload from 'components/pages/Upload';
import config from 'lib/config';
export default function UploadPage({ route }) {
const { user, loading } = useLogin();
@@ -12,9 +14,17 @@ export default function UploadPage({ route }) {
<Layout
user={user}
>
<Upload/>
<Upload route={route}/>
</Layout>
);
}
export const getStaticProps: GetStaticProps = async () => {
return {
props: {
route: process.env.ZIPLINE_DOCKER_BUILD === '1' ? '/u' : config.uploader.route,
},
};
};
UploadPage.title = 'Zipline - Upload';

View File

@@ -1,7 +0,0 @@
import { version } from '../../package.json';
import Logger from '../lib/logger';
Logger.get('server').info(`starting zipline@${version} server`);
import Server from './server';
new Server();

View File

@@ -1,182 +0,0 @@
import Router from 'find-my-way';
import { NextServer, RequestHandler } from 'next/dist/server/next';
import { Image, PrismaClient } from '@prisma/client';
import { createServer, IncomingMessage, OutgoingMessage, Server as HttpServer, ServerResponse } from 'http';
import next from 'next';
import config from '../lib/config';
import datasource from '../lib/ds';
import { getStats, log, migrations } from './util';
import { mkdir } from 'fs/promises';
import Logger from '../lib/logger';
import mimes from '../../scripts/mimes';
import { extname } from 'path';
import exts from '../../scripts/exts';
const serverLog = Logger.get('server');
export default class Server {
public router: Router.Instance<Router.HTTPVersion.V1>;
public nextServer: NextServer;
public handle: RequestHandler;
public prisma: PrismaClient;
private http: HttpServer;
public constructor() {
this.start();
}
private async start() {
// annoy user if they didnt change secret from default "changethis"
if (config.core.secret === 'changethis') {
serverLog.error('Secret is not set!');
serverLog.error('Running Zipline as is, without a randomized secret is not recommended and leaves your instance at risk!');
serverLog.error('Please change your secret in the config file or environment variables.');
serverLog.error('The config file is located at `config.toml`, or if using docker-compose you can change the variables in the `docker-compose.yml` file.');
serverLog.error('It is recomended to use a secret that is alphanumeric and randomized. A way you can generate this is through a password manager you may have.');
process.exit(1);
};
const dev = process.env.NODE_ENV === 'development';
process.env.DATABASE_URL = config.core.database_url;
await migrations();
this.prisma = new PrismaClient();
if (config.datasource.type === 'local') {
await mkdir(config.datasource.local.directory, { recursive: true });
}
this.nextServer = next({
dir: '.',
dev,
quiet: !dev,
hostname: config.core.host,
port: config.core.port,
});
this.handle = this.nextServer.getRequestHandler();
this.router = Router({
defaultRoute: (req, res) => {
this.handle(req, res);
},
});
this.router.on('GET', `${config.uploader.route}/:id`, async (req, res, params) => {
const image = await this.prisma.image.findFirst({
where: {
OR: [
{ file: params.id },
{ invisible: { invis: decodeURI(params.id) } },
],
},
});
if (!image) await this.rawFile(req, res, params.id);
if (image.password) await this.handle(req, res);
else if (image.embed) await this.handle(req, res);
else await this.fileDb(req, res, image);
});
this.router.on('GET', '/r/:id', async (req, res, params) => {
const image = await this.prisma.image.findFirst({
where: {
OR: [
{ file: params.id },
{ invisible: { invis: decodeURI(params.id) } },
],
},
});
if (!image) await this.rawFile(req, res, params.id);
if (image.password) await this.handle(req, res);
else await this.rawFileDb(req, res, image);
});
await this.nextServer.prepare();
this.http = createServer((req, res) => {
this.router.lookup(req, res);
if (config.core.logger) log(req.url);
});
this.http.on('error', (e) => {
serverLog.error(e);
process.exit(1);
});
this.http.on('listening', () => {
serverLog.info(`listening on ${config.core.host}:${config.core.port}`);
});
this.http.listen(config.core.port, config.core.host ?? '0.0.0.0');
this.stats();
}
private async rawFile(req: IncomingMessage, res: OutgoingMessage, id: string) {
const data = datasource.get(id);
if (!data) return this.nextServer.render404(req, res as ServerResponse);
const mimetype = mimes[extname(id)] ?? 'application/octet-stream';
res.setHeader('Content-Type', mimetype);
data.pipe(res);
data.on('error', () => this.nextServer.render404(req, res as ServerResponse));
data.on('end', () => res.end());
}
private async rawFileDb(req: IncomingMessage, res: OutgoingMessage, image: Image) {
const data = datasource.get(image.file);
if (!data) return this.nextServer.render404(req, res as ServerResponse);
res.setHeader('Content-Type', image.mimetype);
data.pipe(res);
data.on('error', () => this.nextServer.render404(req, res as ServerResponse));
data.on('end', () => res.end());
await this.prisma.image.update({
where: { id: image.id },
data: { views: { increment: 1 } },
});
}
private async fileDb(req: IncomingMessage, res: OutgoingMessage, image: Image) {
const ext = image.file.split('.').pop();
if (Object.keys(exts).includes(ext)) return this.handle(req, res as ServerResponse);
const data = datasource.get(image.file);
if (!data) return this.nextServer.render404(req, res as ServerResponse);
res.setHeader('Content-Type', image.mimetype);
data.pipe(res);
data.on('error', () => this.nextServer.render404(req, res as ServerResponse));
data.on('end', () => res.end());
await this.prisma.image.update({
where: { id: image.id },
data: { views: { increment: 1 } },
});
}
private async stats() {
const stats = await getStats(this.prisma, datasource);
await this.prisma.stats.create({
data: {
data: stats,
},
});
setInterval(async () => {
const stats = await getStats(this.prisma, datasource);
await this.prisma.stats.create({
data: {
data: stats,
},
});
if (config.core.logger) serverLog.info('stats updated');
}, config.core.stats_interval * 1000);
}
}

View File

@@ -1,60 +0,0 @@
import { Config } from 'lib/types';
import { object, bool, string, number, boolean, array } from 'yup';
const validator = object({
core: object({
secure: bool().default(false),
secret: string().min(8).required(),
host: string().default('0.0.0.0'),
port: number().default(3000),
database_url: string().required(),
logger: boolean().default(false),
stats_interval: number().default(1800),
}).required(),
datasource: object({
type: string().default('local'),
local: object({
directory: string().default('./uploads'),
}),
s3: object({
access_key_id: string(),
secret_access_key: string(),
bucket: string(),
}).notRequired(),
}).required(),
uploader: object({
route: string().default('/u'),
embed_route: string().default('/a'),
length: number().default(6),
admin_limit: number().default(104900000),
user_limit: number().default(104900000),
disabled_extensions: array().default([]),
}).required(),
urls: object({
route: string().default('/go'),
length: number().default(6),
}).required(),
ratelimit: object({
user: number().default(0),
admin: number().default(0),
}),
});
export default function validate(config): Config {
try {
const validated = validator.validateSync(config, { abortEarly: false });
if (validated.datasource.type === 's3') {
const errors = [];
if (!validated.datasource.s3.access_key_id) errors.push('datasource.s3.access_key_id is a required field');
if (!validated.datasource.s3.secret_access_key) errors.push('datasource.s3.secret_access_key is a required field');
if (!validated.datasource.s3.bucket) errors.push('datasource.s3.bucket is a required field');
if (errors.length) throw { errors };
}
return validated as unknown as Config;
} catch (e) {
if (process.env.ZIPLINE_DOCKER_BUILD) return null;
throw `${e.errors.length} errors occured\n${e.errors.map(x => '\t' + x).join('\n')}`;
}
};

13285
yarn.lock

File diff suppressed because it is too large Load Diff

4
zip-env.d.ts vendored
View File

@@ -1,13 +1,11 @@
import type { PrismaClient } from '@prisma/client';
import type { Datasource } from 'lib/datasource';
import type { Config } from '.lib/types';
import type { Config } from './src/lib/types';
declare global {
namespace NodeJS {
interface Global {
prisma: PrismaClient;
config: Config;
datasource: Datasource
}
interface ProcessEnv {