Compare commits

...

24 Commits

Author SHA1 Message Date
renovate[bot]
b81b1f979e chore(deps): add renovate.json 2022-06-17 09:43:38 +00:00
diced
bfa6c70bf3 chore(deps): update stuff 2022-06-16 14:22:26 -07:00
Jonathan
73eff05180 feat: use yarn v3 (#136)
* feat: use yarn v3

* chore: bump yarn to 3.2.1
2022-06-06 16:38:15 -07:00
relaxtakenotes
74f3b3f13d fix: image width not being set properly (#143)
* Fix image width not being set properly

Sometimes it got set to 0 because the original image wasn't loaded yet.

* fix: eslint

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2022-06-04 22:18:07 -07:00
diced
181833d768 Merge branch 'trunk' of github.com:diced/zipline into trunk 2022-06-04 22:05:26 -07:00
diced
be9523304a feat(v3.4.4): fix many bugs and password protected uploads 2022-06-04 22:05:08 -07:00
dicedtomato
b26fef3ad4 fix(docker): add restart policy for postgres 2022-03-26 20:37:02 +00:00
diced
9f86674bbe fix: update security policy 2022-03-14 20:31:18 -07:00
diced
095e57a037 fix(actions): arm -> arm64 2022-03-14 20:22:39 -07:00
diced
66a8e3bb79 feat: arm docker-compose file 2022-03-13 20:05:41 -07:00
diced
473137abdf fix(actions): fix arm action path 2022-03-13 19:36:28 -07:00
diced
740f1605e7 fix(actions): maybe fix actions 2022-03-13 19:30:37 -07:00
diced
0922ec020e fix: revert to node 16 on actions 2022-03-13 19:27:32 -07:00
diced
dbe8291f55 fix(actions): maybe fix arm action 2022-03-13 19:26:33 -07:00
diced
9dcc16277e refactor(actions): update to v2 of build-push-action & push arm image 2022-03-13 19:25:11 -07:00
diced
aa611fa6ba feat(v3.4.3): cleanup, fix memory leak, arm support 2022-03-13 19:13:18 -07:00
diced
083040e300 feat(v3.4.2): random domain selection #129 2022-03-03 17:52:34 -08:00
diced
99e92e4594 feat(v3.4.1): datasource api, for S3 functionality 2022-03-02 22:04:56 -08:00
diced
870f6e88b1 fix(prisma): add removal of custom theme migration 2022-02-26 17:27:37 -08:00
dicedtomato
16d2014bfb feat(v3.4.0): switch from Material-UI to Mantine! (#127) 2022-02-26 17:19:02 -08:00
diced
4d9a22e82c fix(api): data not defined 2022-02-21 09:28:32 -08:00
diced
42d77e445b feat(v3.3.2): image formats 2022-02-21 09:27:10 -08:00
diced
6506846207 fix: cleanup 2022-02-21 09:26:26 -08:00
diced
2b9af0e0de feat(api): formats for uploaded images 2022-02-20 22:01:31 -08:00
105 changed files with 12217 additions and 8756 deletions

View File

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

41
.github/workflows/docker-arm.yml vendored Normal file
View File

@@ -0,0 +1,41 @@
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,28 +18,31 @@ jobs:
- name: Check out the repo
uses: actions/checkout@v2
- name: Push to GitHub Packages
uses: docker/build-push-action@v1
- 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 }}
registry: docker.pkg.github.com
repository: diced/zipline/zipline
dockerfile: Dockerfile
tag_with_ref: true
push_to_dockerhub:
name: Push Image to Docker Hub
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v2
- name: Push to Docker Hub
uses: docker/build-push-action@v1
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
repository: diced/zipline
dockerfile: Dockerfile
tag_with_ref: true
- 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

9
.gitignore vendored
View File

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

1
.husky/.gitignore vendored
View File

@@ -1 +0,0 @@
_

View File

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

File diff suppressed because one or more lines are too long

786
.yarn/releases/yarn-3.2.1.cjs vendored Normal file

File diff suppressed because one or more lines are too long

7
.yarnrc.yml Normal file
View File

@@ -0,0 +1,7 @@
nodeLinker: node-modules
plugins:
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
spec: "@yarnpkg/plugin-interactive-tools"
yarnPath: .yarn/releases/yarn-3.2.1.cjs

View File

@@ -1,20 +1,21 @@
FROM node:16-alpine AS deps
WORKDIR /build
COPY package.json yarn.lock ./
COPY .yarn .yarn
COPY package.json yarn.lock .yarnrc.yml ./
RUN apk add --no-cache libc6-compat
RUN yarn install --frozen-lockfile
RUN yarn install --immutable
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 package.json yarn.lock next.config.js next-env.d.ts zip-env.d.ts tsconfig.json ./
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
@@ -31,11 +32,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
@@ -43,4 +44,4 @@ COPY --from=builder /build/package.json ./package.json
USER zipline
CMD ["node", "server"]
CMD ["node", "dist/server"]

46
Dockerfile-arm Normal file
View File

@@ -0,0 +1,46 @@
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) 2021 dicedtomato
Copyright (c) 2022 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

@@ -16,13 +16,20 @@
- Fast
- Built with Next.js & React
- Token protected uploading
- Easy setup instructions on [docs](https://zipline.diced.tech/) (One command install `docker-compose up`)
- 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`)
## Installing
[See how to install here](https://zipline.diced.tech/docs/get-started)
[See how to install here](https://zipl.vercel.app/docs/get-started)
## Configuration
[See how to configure here](https://zipline.diced.tech/docs/config/overview)
[See how to configure here](https://zipl.vercel.app/docs/config/overview)
## Theming
[See how to theme here](https://zipline.diced.tech/docs/themes/reference)
[See how to theme here](https://zipl.vercel.app/docs/themes/reference)

View File

@@ -4,7 +4,8 @@
| Version | Supported |
| ------- | ------------------ |
| 3.x.x | :white_check_mark: |
| 3.4.4 | :white_check_mark: |
| < 3 | :x: |
| < 2 | :x: |
## Reporting a Vulnerability

View File

@@ -1,55 +0,0 @@
module.exports = {
parserPreset: 'conventional-changelog-conventionalcommits',
rules: {
'body-leading-blank': [1, 'always'],
'body-max-line-length': [2, 'always', 100],
'footer-leading-blank': [1, 'always'],
'footer-max-line-length': [2, 'always', 100],
'header-max-length': [2, 'always', 100],
'subject-case': [
2,
'never',
['sentence-case', 'start-case', 'pascal-case', 'upper-case'],
],
'subject-empty': [2, 'never'],
'subject-full-stop': [2, 'never', '.'],
'type-case': [2, 'always', 'lower-case'],
'type-empty': [2, 'never'],
'type-enum': [
2,
'always',
[
'build',
'chore',
'ci',
'docs',
'feat',
'fix',
'perf',
'refactor',
'revert',
'style',
'test',
],
],
'scope-enum': [
1,
'always',
[
'prisma',
'scripts',
'server',
'pages',
'config',
'api',
'hooks',
'components',
'middleware',
'redux',
'themes',
'lib',
'assets'
],
],
},
};

View File

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

46
docker-compose.arm.yml Normal file
View File

@@ -0,0 +1,46 @@
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:

48
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,48 @@
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:
build:
context: .
dockerfile: Dockerfile
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,11 +2,12 @@ version: '3'
services:
postgres:
image: postgres
environment:
restart: always
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']
@@ -18,17 +19,18 @@ services:
image: ghcr.io/diced/zipline/zipline:trunk
ports:
- '3000:3000'
restart: unless-stopped
environment:
restart: always
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=

40
esbuild.config.js Normal file
View File

@@ -0,0 +1,40 @@
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,3 +1,11 @@
module.exports = {
reactStrictMode: true,
async redirects() {
return [
{
source: '/',
destination: '/dashboard',
permanent: true,
},
];
},
};

View File

@@ -1,62 +1,69 @@
{
"name": "zip3",
"version": "3.3.1",
"name": "zipline",
"version": "3.4.4",
"license": "MIT",
"scripts": {
"prepare": "husky install",
"dev": "NODE_ENV=development node server",
"build": "npm-run-all build:schema build:next",
"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",
"build:next": "next build",
"build:schema": "prisma generate --schema=prisma/schema.prisma",
"start": "node server",
"migrate:dev": "prisma migrate dev --create-only",
"start": "node dist/server",
"lint": "next lint",
"seed": "ts-node --compiler-options \"{\\\"module\\\":\\\"commonjs\\\"}\" --transpile-only prisma/seed.ts"
"seed": "ts-node --compiler-options \"{\\\"module\\\":\\\"commonjs\\\"}\" --transpile-only prisma/seed.ts",
"docker:run": "docker-compose up -d",
"docker:down": "docker-compose down",
"docker:build-dev": "docker-compose --file docker-compose.dev.yml up --build"
},
"dependencies": {
"@emotion/react": "^11.4.0",
"@emotion/styled": "^11.3.0",
"@iarna/toml": "2.2.5",
"@mui/icons-material": "^5.0.0",
"@mui/material": "^5.0.2",
"@mui/styles": "^5.0.1",
"@prisma/client": "^3.9.2",
"@prisma/migrate": "^3.9.2",
"@prisma/sdk": "^3.9.2",
"@reduxjs/toolkit": "^1.6.0",
"argon2": "^0.28.2",
"colorette": "^1.2.2",
"cookie": "^0.4.1",
"copy-to-clipboard": "^3.3.1",
"fecha": "^4.2.1",
"formik": "^2.2.9",
"multer": "^1.4.2",
"next": "^12.1.0",
"prisma": "^3.9.2",
"react": "17.0.2",
"react-color": "^2.19.3",
"react-dom": "17.0.2",
"react-dropzone": "^11.3.2",
"react-redux": "^7.2.4",
"redux": "^4.1.0",
"redux-thunk": "^2.3.0",
"yup": "^0.32.9"
"@mantine/core": "^4.2.9",
"@mantine/dropzone": "^4.2.9",
"@mantine/hooks": "^4.2.9",
"@mantine/modals": "^4.2.9",
"@mantine/next": "^4.2.9",
"@mantine/notifications": "^4.2.9",
"@mantine/prism": "^4.2.9",
"@modulz/radix-icons": "^4.0.0",
"@prisma/client": "^3.15.2",
"@prisma/migrate": "^3.15.2",
"@prisma/sdk": "^3.15.2",
"@reduxjs/toolkit": "^1.8.2",
"argon2": "^0.28.5",
"aws-sdk": "^2.1156.0",
"colorette": "^2.0.19",
"cookie": "^0.5.0",
"fecha": "^4.2.3",
"fflate": "^0.7.3",
"find-my-way": "^6.3.0",
"multer": "^1.4.5-lts.1",
"next": "^12.1.6",
"prisma": "^3.15.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "^8.0.2",
"react-table": "^7.8.0",
"redux": "^4.2.0",
"redux-thunk": "^2.4.1",
"uuid": "^8.3.2",
"yup": "^0.32.11"
},
"devDependencies": {
"@commitlint/cli": "^12.1.4",
"@commitlint/config-conventional": "^12.1.4",
"@types/cookie": "^0.4.0",
"@types/multer": "^1.4.6",
"@types/cookie": "^0.5.1",
"@types/multer": "^1.4.7",
"@types/node": "^15.12.2",
"babel-plugin-import": "^1.13.5",
"esbuild": "^0.14.44",
"eslint": "^7.32.0",
"eslint-config-next": "11.0.0",
"husky": "^6.0.0",
"eslint-config-next": "12.1.6",
"npm-run-all": "^4.1.5",
"release": "^6.3.0",
"ts-node": "^10.0.0",
"typescript": "^4.3.2"
"ts-node": "^10.8.1",
"typescript": "^4.7.3"
},
"repository": {
"type": "git",
"url": "https://github.com/diced/zipline.git"
}
},
"packageManager": "yarn@3.2.1"
}

View File

@@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "ImageFormat" AS ENUM ('UUID', 'DATE', 'RANDOM');
-- AlterTable
ALTER TABLE "Image" ADD COLUMN "format" "ImageFormat" NOT NULL DEFAULT E'RANDOM';

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "ImageFormat" ADD VALUE 'NAME';

View File

@@ -0,0 +1,12 @@
/*
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";
-- AlterTable
ALTER TABLE "User" ALTER COLUMN "systemTheme" SET DEFAULT E'system';
-- DropTable
DROP TABLE "Theme";

View File

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

View File

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

View File

@@ -8,34 +8,26 @@ 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("dark_blue")
customTheme Theme?
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)
embedColor String @default("#2f3136")
embedSiteName String? @default("{image.file} • {user.name}")
ratelimited Boolean @default(false)
domains String[]
images Image[]
urls Url[]
}
model Theme {
id Int @id @default(autoincrement())
type String
primary String
secondary String
error String
warning String
info String
border String
mainBackground String
paperBackground String
user User @relation(fields: [userId], references: [id])
userId Int
enum ImageFormat {
UUID
DATE
RANDOM
NAME
}
model Image {
@@ -46,7 +38,9 @@ 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])
userId Int
}
@@ -54,7 +48,7 @@ model Image {
model InvisibleImage {
id Int @id @default(autoincrement())
invis String @unique
imageId Int
imageId Int @unique
image Image @relation(fields: [imageId], references: [id])
}
@@ -72,12 +66,12 @@ model Url {
model InvisibleUrl {
id Int @id @default(autoincrement())
invis String @unique
urlId String
urlId String @unique
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
}
}

5
renovate.json Normal file
View File

@@ -0,0 +1,5 @@
{
"extends": [
"config:base"
]
}

38
scripts/exts.js Normal file
View File

@@ -0,0 +1,38 @@
// https://github.com/toptal/haste-server/blob/master/static/application.js#L167-L174
// Popular extension map
module.exports = {
rb: 'ruby',
py: 'python',
pl: 'perl',
php: 'php',
scala: 'scala',
go: 'go',
xml: 'xml',
html: 'xml',
htm: 'xml',
css: 'css',
js: 'javascript',
json: 'json',
vbs: 'vbscript',
lua: 'lua',
pas: 'delphi',
java: 'java',
cpp: 'cpp',
cc: 'cpp',
m: 'objectivec',
vala: 'vala',
sql: 'sql',
sm: 'smalltalk',
lisp: 'lisp',
ini: 'ini',
diff: 'diff',
bash: 'bash',
sh: 'bash',
tex: 'tex',
erl: 'erlang',
hs: 'haskell',
md: 'markdown',
txt: '',
coffee: 'coffee',
swift: 'swift',
};

View File

@@ -1,171 +0,0 @@
const NextServer = require('next/dist/server/next-server').default;
const defaultConfig = require('next/dist/server/config-shared').defaultConfig;
const { createServer } = require('http');
const { stat, 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, shouldUseYarn, getFile, migrations } = require('./util');
const { PrismaClient } = require('@prisma/client');
const { version } = require('../package.json');
const nextConfig = require('../next.config');
const serverLog = Logger.get('server');
const webLog = Logger.get('web');
serverLog.info(`starting zipline@${version} server`);
const dev = process.env.NODE_ENV === 'development';
(async () => {
try {
await run();
} catch (e) {
if (e.message && e.message.startsWith('Could not find a production')) {
webLog.error(`there is no production build - run \`${shouldUseYarn() ? 'yarn build' : 'npm build'}\``);
} else if (e.code && e.code === 'ENOENT') {
if (e.path === './.next') webLog.error(`there is no production build - run \`${shouldUseYarn() ? 'yarn build' : 'npm build'}\``);
} else {
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 = new NextServer({
dir: '.',
dev,
quiet: dev,
customServer: false,
host: config.core.host,
port: config.core.port,
conf: Object.assign(defaultConfig, nextConfig),
});
await app.prepare();
await stat('./.next');
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 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,40 +0,0 @@
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(true),
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

@@ -1,12 +0,0 @@
import React from 'react';
import { Snackbar, Alert as MuiAlert } from '@mui/material';
export default function Alert({ open, setOpen, severity, message }) {
return (
<Snackbar open={open} autoHideDuration={6000} anchorOrigin={{ vertical: 'top', horizontal: 'center' }} onClose={() => setOpen(false)}>
<MuiAlert severity={severity} sx={{ width: '100%' }}>
{message}
</MuiAlert>
</Snackbar>
);
}

View File

@@ -1,16 +1,8 @@
import React from 'react';
import {
Backdrop as MuiBackdrop,
CircularProgress,
} from '@mui/material';
import { LoadingOverlay } from '@mantine/core';
export default function Backdrop({ open }) {
return (
<MuiBackdrop
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
open={open}
>
<CircularProgress color='inherit' />
</MuiBackdrop>
<LoadingOverlay visible={open} />
);
}

View File

@@ -1,19 +1,16 @@
import React from 'react';
import {
Card as MuiCard,
CardContent,
Typography,
} from '@mui/material';
Card as MCard,
Title,
} from '@mantine/core';
export default function Card(props) {
const { name, children, ...other } = props;
return (
<MuiCard sx={{ minWidth: '100%' }} {...other}>
<CardContent>
<Typography variant='h3'>{name}</Typography>
{children}
</CardContent>
</MuiCard>
<MCard padding='md' shadow='sm' {...other}>
<Title order={2}>{name}</Title>
{children}
</MCard>
);
}

View File

@@ -1,15 +0,0 @@
import React from 'react';
import { Box } from '@mui/material';
export default function CenteredBox({ children, ...other }) {
return (
<Box
justifyContent='center'
display='flex'
alignItems='center'
{...other}
>
{children}
</Box>
);
}

View File

@@ -1,83 +1,94 @@
import React, { useState } from 'react';
import {
Card,
CardMedia,
CardActionArea,
Button,
Dialog,
DialogTitle,
DialogActions,
DialogContent,
} from '@mui/material';
import AudioIcon from '@mui/icons-material/Audiotrack';
import copy from 'copy-to-clipboard';
import useFetch from 'hooks/useFetch';
import { Button, Card, Group, Image as MImage, Modal, Title } from '@mantine/core';
import { useNotifications } from '@mantine/notifications';
import { CopyIcon, Cross1Icon, StarIcon, TrashIcon } from '@modulz/radix-icons';
import { useClipboard } from '@mantine/hooks';
export default function Image({ image, updateImages }) {
const [open, setOpen] = useState(false);
const [t] = useState(image.mimetype.split('/')[0]);
const notif = useNotifications();
const clipboard = useClipboard();
const handleDelete = async () => {
const res = await useFetch('/api/user/files', 'DELETE', { id: image.id });
if (!res.error) updateImages(true);
if (!res.error) {
updateImages(true);
notif.showNotification({
title: 'Image Deleted',
message: '',
color: 'green',
icon: <TrashIcon />,
});
} else {
notif.showNotification({
title: 'Failed to delete image',
message: res.error,
color: 'red',
icon: <Cross1Icon />,
});
}
setOpen(false);
};
const handleCopy = () => {
copy(`${window.location.protocol}//${window.location.host}${image.url}`);
clipboard.copy(`${window.location.protocol}//${window.location.host}${image.url}`);
setOpen(false);
notif.showNotification({
title: 'Copied to clipboard',
message: '',
icon: <CopyIcon />,
});
};
const handleFavorite = async () => {
const data = await useFetch('/api/user/files', 'PATCH', { id: image.id, favorite: !image.favorite });
if (!data.error) updateImages(true);
notif.showNotification({
title: 'Image is now ' + (!image.favorite ? 'favorited' : 'unfavorited'),
message: '',
icon: <StarIcon />,
});
};
const Type = (props) => {
return {
'video': <video controls {...props} />,
// eslint-disable-next-line jsx-a11y/alt-text
'image': <img {...props} />,
'image': <MImage {...props} />,
'audio': <audio controls {...props} />,
}[t];
};
return (
<>
<Card sx={{ maxWidth: '100%' }}>
<CardActionArea sx={t === 'audio' ? { justifyContent: 'center', display: 'flex', alignItems: 'center' } : {}}>
<CardMedia
sx={{ height: 320, fontSize: 70, width: '100%' }}
image={image.url}
title={image.file}
component={t === 'audio' ? AudioIcon : t} // this is done because audio without controls is hidden
<Modal
opened={open}
onClose={() => setOpen(false)}
title={<Title>{image.file}</Title>}
>
<Type
src={image.url}
alt={image.file}
/>
<Group position='right' mt={22}>
<Button onClick={handleCopy}>Copy</Button>
<Button onClick={handleDelete}>Delete</Button>
<Button onClick={handleFavorite}>{image.favorite ? 'Unfavorite' : 'Favorite'}</Button>
</Group>
</Modal>
<Card sx={{ maxWidth: '100%', height: '100%' }} shadow='md'>
<Card.Section>
<Type
sx={{ maxHeight: 320, fontSize: 70, width: '100%', cursor: 'pointer' }}
style={{ maxHeight: 320, fontSize: 70, width: '100%', cursor: 'pointer' }}
src={image.url}
alt={image.file}
onClick={() => setOpen(true)}
/>
</CardActionArea>
</Card.Section>
</Card>
<Dialog
open={open}
onClose={() => setOpen(false)}
>
<DialogTitle id='alert-dialog-title'>
{image.file}
</DialogTitle>
<DialogContent>
<Type
style={{ width: '100%' }}
src={image.url}
alt={image.url}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleDelete} color='inherit'>Delete</Button>
<Button onClick={handleCopy} color='inherit'>Copy URL</Button>
<Button onClick={handleFavorite} color='inherit'>{image.favorite ? 'Unfavorite' : 'Favorite'}</Button>
</DialogActions>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,158 @@
/* eslint-disable react/jsx-key */
/* eslint-disable react/display-name */
// Code taken from https://codesandbox.io/s/eojw8 and is modified a bit (the entire src/components/table directory)
import React from 'react';
import {
usePagination,
useTable,
} from 'react-table';
import {
ActionIcon,
createStyles,
Divider,
Group,
Pagination,
Select,
Table,
Text,
useMantineTheme,
} from '@mantine/core';
import {
CopyIcon,
EnterIcon,
TrashIcon,
} from '@modulz/radix-icons';
const pageSizeOptions = ['10', '25', '50'];
const useStyles = createStyles((t) => ({
root: { height: '100%', display: 'block', marginTop: 10 },
tableContainer: {
display: 'block',
overflow: 'auto',
'& > table': {
'& > thead': { backgroundColor: t.colorScheme === 'dark' ? t.colors.dark[6] : t.colors.gray[0], zIndex: 1 },
'& > thead > tr > th': { padding: t.spacing.md },
'& > tbody > tr > td': { padding: t.spacing.md },
},
borderRadius: 6,
},
stickHeader: { top: 0, position: 'sticky' },
disableSortIcon: { color: t.colors.gray[5] },
sortDirectionIcon: { transition: 'transform 200ms ease' },
}));
export default function ImagesTable({
columns,
data = [],
serverSideDataSource = false,
initialPageSize = 10,
initialPageIndex = 0,
pageCount = 0,
total = 0,
deleteImage, copyImage, viewImage,
}) {
const { classes } = useStyles();
const theme = useMantineTheme();
const tableOptions = useTable(
{
data,
columns,
pageCount,
initialState: { pageSize: initialPageSize, pageIndex: initialPageIndex },
},
usePagination
);
const {
getTableProps, getTableBodyProps, headerGroups, rows, prepareRow, page, gotoPage, setPageSize, state: { pageIndex, pageSize },
} = tableOptions;
const getPageRecordInfo = () => {
const firstRowNum = pageIndex * pageSize + 1;
const totalRows = serverSideDataSource ? total : rows.length;
const currLastRowNum = (pageIndex + 1) * pageSize;
let lastRowNum = currLastRowNum < totalRows ? currLastRowNum : totalRows;
return `${firstRowNum} - ${lastRowNum} of ${totalRows}`;
};
const getPageCount = () => {
const totalRows = serverSideDataSource ? total : rows.length;
return Math.ceil(totalRows / pageSize);
};
const handlePageChange = (pageNum) => gotoPage(pageNum - 1);
const renderHeader = () => headerGroups.map(hg => (
<tr {...hg.getHeaderGroupProps()}>
{hg.headers.map(column => (
<th {...column.getHeaderProps()}>
<Group noWrap position={column.align || 'apart'}>
<div>{column.render('Header')}</div>
</Group>
</th>
))}
<th>Actions</th>
</tr>
));
const renderRow = rows => rows.map(row => {
prepareRow(row);
return (
<tr {...row.getRowProps()}>
{row.cells.map(cell => (
<td align={cell.column.align || 'left'} {...cell.getCellProps()}>
{cell.render('Cell')}
</td>
))}
<td align='right'>
<Group noWrap>
<ActionIcon color='red' variant='outline' onClick={() => deleteImage(row)}><TrashIcon /></ActionIcon>
<ActionIcon color='primary' variant='outline' onClick={() => copyImage(row)}><CopyIcon /></ActionIcon>
<ActionIcon color='green' variant='outline' onClick={() => viewImage(row)}><EnterIcon /></ActionIcon>
</Group>
</td>
</tr>
);
});
return (
<div className={classes.root}>
<div
className={classes.tableContainer}
style={{ height: 'calc(100% - 44px)' }}
>
<Table {...getTableProps()}>
<thead style={{ backgroundColor: theme.other.hover }}>
{renderHeader()}
</thead>
<tbody {...getTableBodyProps()}>
{renderRow(page)}
</tbody>
</Table>
</div>
<Divider mb='md' variant='dotted' />
<Group position='left'>
<Text size='sm'>Rows per page: </Text>
<Select
style={{ width: '72px' }}
variant='filled'
data={pageSizeOptions}
value={pageSize + ''}
onChange={pageSize => setPageSize(Number(pageSize))} />
<Divider orientation='vertical' />
<Text size='sm'>{getPageRecordInfo()}</Text>
<Divider orientation='vertical' />
<Pagination
page={pageIndex + 1}
total={getPageCount()}
onChange={handlePageChange} />
</Group>
</div>
);
}

View File

@@ -1,49 +1,67 @@
import React, { useState } from 'react';
import Link from 'next/link';
import {
AppBar,
Box,
Divider,
Drawer,
IconButton,
List,
ListItem,
ListItemIcon,
ListItemText,
Toolbar,
Typography,
Button,
Menu,
MenuItem,
Paper,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
} from '@mui/material';
import {
Menu as MenuIcon,
Home as HomeIcon,
AccountCircle as AccountIcon,
Folder as FolderIcon,
Upload as UploadIcon,
ContentCopy as CopyIcon,
Autorenew as ResetIcon,
Logout as LogoutIcon,
PeopleAlt as UsersIcon,
Brush as BrushIcon,
Link as URLIcon,
} from '@mui/icons-material';
import copy from 'copy-to-clipboard';
import Backdrop from './Backdrop';
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';
import useFetch from 'hooks/useFetch';
import { CheckIcon, CopyIcon, Cross1Icon, FileIcon, GearIcon, HomeIcon, Link1Icon, ResetIcon, UploadIcon, PinRightIcon, PersonIcon, Pencil1Icon, MixerHorizontalIcon } from '@modulz/radix-icons';
import { AppShell, Burger, Divider, Group, Header, MediaQuery, Navbar, Paper, Popover, ScrollArea, Select, Text, ThemeIcon, Title, UnstyledButton, useMantineTheme, Box } from '@mantine/core';
import { useModals } from '@mantine/modals';
import { useNotifications } from '@mantine/notifications';
import { useClipboard } from '@mantine/hooks';
import { friendlyThemeName, themes } from './Theming';
function MenuItemLink(props) {
return (
<Link href={props.href} passHref>
<MenuItem {...props} />
</Link>
);
}
function MenuItem(props) {
return (
<UnstyledButton
sx={theme => ({
display: 'block',
width: '100%',
padding: 5,
borderRadius: theme.radius.sm,
color: props.color
? theme.fn.themeColor(props.color, theme.colorScheme === 'dark' ? 5 : 7)
: theme.colorScheme === 'dark'
? theme.colors.dark[0]
: theme.black,
'&:hover': {
backgroundColor: props.color
? theme.fn.rgba(
theme.fn.themeColor(props.color, theme.colorScheme === 'dark' ? 9 : 0),
theme.colorScheme === 'dark' ? 0.2 : 1
)
: theme.colorScheme === 'dark'
? theme.fn.rgba(theme.colors.dark[3], 0.35)
: theme.colors.gray[0],
},
})}
{...props}
>
<Group noWrap>
<Box sx={theme => ({
marginRight: theme.spacing.xs / 4,
paddingLeft: theme.spacing.xs / 2,
'& *': {
display: 'block',
},
})}>
{props.icon}
</Box>
<Text size='sm'>{props.children}</Text>
</Group>
</UnstyledButton>
);
}
const items = [
{
@@ -52,12 +70,17 @@ const items = [
link: '/dashboard',
},
{
icon: <FolderIcon />,
icon: <FileIcon />,
text: 'Files',
link: '/dashboard/files',
},
{
icon: <URLIcon />,
icon: <MixerHorizontalIcon />,
text: 'Stats',
link: '/dashboard/stats',
},
{
icon: <Link1Icon />,
text: 'URLs',
link: '/dashboard/urls',
},
@@ -68,344 +91,241 @@ const items = [
},
];
const drawerWidth = 240;
function CopyTokenDialog({ open, setOpen, token }) {
const handleCopyToken = () => {
copy(token);
setOpen(false);
};
return (
<div>
<Dialog
open={open}
onClose={() => setOpen(false)}
>
<DialogTitle id='copy-dialog-title'>
Copy Token
</DialogTitle>
<DialogContent>
<DialogContentText id='copy-dialog-description'>
Make sure you don&apos;t share this token with anyone as they will be able to upload files on your behalf.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)} color='inherit' autoFocus>Cancel</Button>
<Button onClick={handleCopyToken} color='inherit'>
Copy
</Button>
</DialogActions>
</Dialog>
</div>
);
}
function ResetTokenDialog({ open, setOpen, setToken }) {
const handleResetToken = async () => {
const a = await useFetch('/api/user/token', 'PATCH');
if (a.success) setToken(a.success);
setOpen(false);
};
return (
<div>
<Dialog
open={open}
onClose={() => setOpen(false)}
>
<DialogTitle id='reset-dialog-title'>
Reset Token
</DialogTitle>
<DialogContent>
<DialogContentText id='reset-dialog-description'>
Once you reset your token, you will have to update any uploaders to use this new token.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)} color='inherit' autoFocus>Cancel</Button>
<Button onClick={handleResetToken} color='inherit'>
Reset
</Button>
</DialogActions>
</Dialog>
</div>
);
}
export default function Layout({ children, user, loading, noPaper }) {
const [systemTheme, setSystemTheme] = useState(user.systemTheme || 'dark_blue');
const [mobileOpen, setMobileOpen] = useState(false);
const [anchorEl, setAnchorEl] = useState(null);
const [copyOpen, setCopyOpen] = useState(false);
const [resetOpen, setResetOpen] = useState(false);
export default function Layout({ children, user }) {
const [token, setToken] = useState(user?.token);
const [systemTheme, setSystemTheme] = useState(user.systemTheme ?? 'system');
const [opened, setOpened] = useState(false); // navigation open
const [open, setOpen] = useState(false); // manage acc dropdown
const router = useRouter();
const dispatch = useStoreDispatch();
const theme = useMantineTheme();
const modals = useModals();
const notif = useNotifications();
const clipboard = useClipboard();
const open = Boolean(anchorEl);
const handleClick = e => setAnchorEl(e.currentTarget);
const handleClose = (cmd: 'copy' | 'reset') => () => {
switch (cmd) {
case 'copy':
setCopyOpen(true);
break;
case 'reset':
setResetOpen(true);
break;
}
setAnchorEl(null);
};
const handleUpdateTheme = async event => {
const handleUpdateTheme = async value => {
const newUser = await useFetch('/api/user', 'PATCH', {
systemTheme: event.target.value || 'dark_blue',
systemTheme: value || 'dark_blue',
});
setSystemTheme(newUser.systemTheme);
dispatch(updateUser(newUser));
router.replace(router.pathname);
notif.showNotification({
title: `Theme changed to ${friendlyThemeName[value]}`,
message: '',
color: 'green',
icon: <Pencil1Icon />,
});
};
const drawer = (
<div>
<CopyTokenDialog open={copyOpen} setOpen={setCopyOpen} token={token} />
<ResetTokenDialog open={resetOpen} setOpen={setResetOpen} setToken={setToken} />
<Toolbar
sx={{
width: { xs: drawerWidth },
}}
>
<AppBar
position='fixed'
elevation={0}
sx={{
borderBottom: 1,
borderBottomColor: t => t.palette.divider,
display: { xs: 'none', sm: 'block' },
}}
>
<Toolbar>
<IconButton
color='inherit'
aria-label='open drawer'
edge='start'
onClick={() => setMobileOpen(true)}
sx={{ mr: 2, display: { sm: 'none' } }}
>
<MenuIcon />
</IconButton>
<Typography
variant='h5'
noWrap
component='div'
>
Zipline
</Typography>
{user && (
<Box sx={{ marginLeft: 'auto' }}>
<Button
color='inherit'
aria-expanded={open ? 'true' : undefined}
onClick={handleClick}
>
<AccountIcon />
</Button>
<Menu
id='zipline-user-menu'
anchorEl={anchorEl}
open={open}
onClose={handleClose(null)}
MenuListProps={{
'aria-labelledby': 'basic-button',
}}
>
<MenuItem disableRipple>
<Typography variant='h5'>
<b>{user.username}</b>
</Typography>
</MenuItem>
<Divider />
<Link href='/dashboard/manage' passHref>
<MenuItem onClick={handleClose(null)}>
<AccountIcon sx={{ mr: 2 }} /> Manage Account
</MenuItem>
</Link>
<MenuItem onClick={handleClose('copy')}>
<CopyIcon sx={{ mr: 2 }} /> Copy Token
</MenuItem>
<MenuItem onClick={handleClose('reset')}>
<ResetIcon sx={{ mr: 2 }} /> Reset Token
</MenuItem>
<Link href='/auth/logout' passHref>
<MenuItem onClick={handleClose(null)}>
<LogoutIcon sx={{ mr: 2 }} /> Logout
</MenuItem>
</Link>
<MenuItem>
<BrushIcon sx={{ mr: 2 }} />
<Select
variant='standard'
label='Theme'
value={systemTheme}
onChange={handleUpdateTheme}
fullWidth
>
{Object.keys(themes).map(t => (
<MenuItem value={t} key={t}>
{friendlyThemeName[t]}
</MenuItem>
))}
</Select>
</MenuItem>
</Menu>
</Box>
)}
</Toolbar>
</AppBar>
</Toolbar>
<Divider />
<List>
{items.map((item, i) => (
<Link key={i} href={item.link} passHref>
<ListItem button>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.text} />
</ListItem>
</Link>
))}
{user && user.administrator && (
<Link href='/dashboard/users' passHref>
<ListItem button>
<ListItemIcon><UsersIcon /></ListItemIcon>
<ListItemText primary='Users' />
</ListItem>
</Link>
)}
</List>
</div>
);
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.
</Text>
),
labels: { confirm: 'Reset', cancel: 'Cancel' },
onConfirm: async () => {
const a = await useFetch('/api/user/token', 'PATCH');
if (!a.success) {
setToken(a.success);
notif.showNotification({
title: 'Token Reset Failed',
message: a.error,
color: 'red',
icon: <Cross1Icon />,
});
} else {
notif.showNotification({
title: 'Token Reset',
message: 'Your token has been reset. You will need to update any uploaders to use this new token.',
color: 'green',
icon: <CheckIcon />,
});
}
const container = typeof window !== 'undefined' ? window.document.body : undefined;
modals.closeAll();
},
});
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.
</Text>
),
labels: { confirm: 'Copy', cancel: 'Cancel' },
onConfirm: async () => {
clipboard.copy(token);
notif.showNotification({
title: 'Token Copied',
message: 'Your token has been copied to your clipboard.',
color: 'green',
icon: <CheckIcon />,
});
modals.closeAll();
},
});
return (
<Box sx={{ display: 'flex' }}>
<Backdrop open={loading} />
<AppShell
navbarOffsetBreakpoint='sm'
fixed
navbar={
<Navbar
padding='md'
hiddenBreakpoint='sm'
hidden={!opened}
width={{ sm: 200, lg: 230 }}
>
<Navbar.Section
grow
component={ScrollArea}
ml={-10}
mr={-10}
sx={{ paddingLeft: 10, paddingRight: 10 }}
>
{items.map(({ icon, text, link }) => (
<Link href={link} key={text} passHref>
<UnstyledButton
sx={{
display: 'block',
width: '100%',
padding: theme.spacing.xs,
borderRadius: theme.radius.sm,
color: theme.colorScheme === 'dark' ? theme.colors.dark[0] : theme.black,
'&:hover': {
backgroundColor: theme.other.hover,
},
}}
>
<Group>
<ThemeIcon color='primary' variant='filled'>
{icon}
</ThemeIcon>
<AppBar
position='fixed'
elevation={0}
sx={{
width: { sm: `calc(100% - ${drawerWidth}px)` },
ml: { sm: `${drawerWidth}px` },
}}
>
<Toolbar>
<IconButton
color='inherit'
aria-label='open drawer'
edge='start'
onClick={() => setMobileOpen(true)}
sx={{ mr: 2, display: { sm: 'none' } }}
>
<MenuIcon />
</IconButton>
<Typography
variant='h5'
noWrap
component='div'
sx={{ display: { sm: 'none' } }}
>
Zipline
</Typography>
{user && (
<Box sx={{ marginLeft: 'auto' }}>
<Button
color='inherit'
aria-expanded={open ? 'true' : undefined}
onClick={handleClick}
<Text size='lg'>{text}</Text>
</Group>
</UnstyledButton>
</Link>
))}
{user.administrator && (
<Link href='/dashboard/users' passHref>
<UnstyledButton
sx={{
display: 'block',
width: '100%',
padding: theme.spacing.xs,
borderRadius: theme.radius.sm,
color: theme.colorScheme === 'dark' ? theme.colors.dark[0] : theme.black,
'&:hover': {
backgroundColor: theme.other.hover,
},
}}
>
<Group>
<ThemeIcon color='primary' variant='filled'>
<PersonIcon />
</ThemeIcon>
<Text size='lg'>Users</Text>
</Group>
</UnstyledButton>
</Link>
)}
</Navbar.Section>
</Navbar>
}
header={
<Header height={70} padding='md'>
<div style={{ display: 'flex', alignItems: 'center', height: '100%' }}>
<MediaQuery largerThan='sm' styles={{ display: 'none' }}>
<Burger
opened={opened}
onClick={() => setOpened((o) => !o)}
size='sm'
color={theme.colors.gray[6]}
/>
</MediaQuery>
<Title sx={{ marginLeft: 12 }}>Zipline</Title>
<Box sx={{ marginLeft: 'auto', marginRight: 0 }}>
<Popover
position='top'
placement='end'
spacing={4}
opened={open}
onClose={() => setOpen(false)}
target={
<UnstyledButton
onClick={() => setOpen(!open)}
sx={{
display: 'block',
width: '100%',
padding: theme.spacing.xs,
borderRadius: theme.radius.sm,
color: theme.other.color,
'&:hover': {
backgroundColor: theme.other.hover,
},
}}
>
<Group>
<ThemeIcon color='primary' variant='filled'>
<GearIcon />
</ThemeIcon>
<Text>{user.username}</Text>
</Group>
</UnstyledButton>
}
>
<AccountIcon />
</Button>
<Menu
id='zipline-user-menu'
anchorEl={anchorEl}
open={open}
onClose={handleClose(null)}
MenuListProps={{
'aria-labelledby': 'basic-button',
}}
>
<MenuItem disableRipple>
<Typography variant='h5'>
<b>{user.username}</b>
</Typography>
</MenuItem>
<Divider />
<Link href='/dash/manage' passHref>
<MenuItem onClick={handleClose(null)}>
<AccountIcon sx={{ mr: 2 }} /> Manage Account
<Group direction='column' spacing={2}>
<Text sx={{
color: theme.colorScheme === 'dark' ? theme.colors.dark[2] : theme.colors.gray[6],
fontWeight: 500,
fontSize: theme.fontSizes.xs,
padding: `${theme.spacing.xs / 2}px ${theme.spacing.sm}px`,
cursor: 'default',
}}>User: {user.username}</Text>
<MenuItemLink icon={<GearIcon />} href='/dashboard/manage'>Manage Account</MenuItemLink>
<MenuItem icon={<CopyIcon />} onClick={() => {setOpen(false);openCopyToken();}}>Copy Token</MenuItem>
<MenuItem icon={<ResetIcon />} onClick={() => {setOpen(false);openResetToken();}} color='red'>Reset Token</MenuItem>
<MenuItemLink icon={<PinRightIcon />} href='/auth/logout' color='red'>Logout</MenuItemLink>
<Divider
variant='solid'
my={theme.spacing.xs / 2}
sx={theme => ({
width: '110%',
borderTopColor: theme.colorScheme === 'dark' ? theme.colors.dark[7] : theme.colors.gray[2],
margin: `${theme.spacing.xs / 2}px -4px`,
})}
/>
<MenuItem icon={<Pencil1Icon />}>
<Select
size='xs'
data={Object.keys(themes).map(t => ({ value: t, label: friendlyThemeName[t] }))}
value={systemTheme}
onChange={handleUpdateTheme}
/>
</MenuItem>
</Link>
<MenuItem onClick={handleClose('copy')}>
<CopyIcon sx={{ mr: 2 }} /> Copy Token
</MenuItem>
<MenuItem onClick={handleClose('reset')}>
<ResetIcon sx={{ mr: 2 }} /> Reset Token
</MenuItem>
<Link href='/auth/logout' passHref>
<MenuItem onClick={handleClose(null)}>
<LogoutIcon sx={{ mr: 2 }} /> Logout
</MenuItem>
</Link>
</Menu>
</Group>
</Popover>
</Box>
)}
</Toolbar>
</AppBar>
<Box
component='nav'
sx={{
width: { sm: drawerWidth },
flexShrink: { sm: 0 },
}}
>
<Drawer
container={container}
variant='temporary'
onClose={() => setMobileOpen(false)}
open={mobileOpen}
elevation={0}
ModalProps={{
keepMounted: true,
}}
sx={{
display: { xs: 'block', sm: 'none' },
'* .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
}}
>
{drawer}
</Drawer>
<Drawer
variant='permanent'
sx={{
display: { xs: 'none', sm: 'block' },
'* .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
}}
open
>
{drawer}
</Drawer>
</Box>
<Box component='main' sx={{ flexGrow: 1, p: 3, mt: 8 }}>
{user && noPaper ? children : (
<Paper elevation={0} sx={{ p: 2 }} variant='outlined'>
{children}
</Paper>
)}
</Box>
</Box>
</div>
</Header>
}
>
<Paper withBorder padding='md' shadow='xs'>{children}</Paper>
</AppShell>
);
}
}

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 '@mui/material/Link';
import { Text } from '@mantine/core';
export const NextLinkComposed = forwardRef(function NextLinkComposed(props: any, ref) {
const { to, linkAs, href, replace, scroll, passHref, shallow, prefetch, locale, ...other } =
@@ -50,10 +50,10 @@ const Link = forwardRef(function Link(props: any, ref) {
if (isExternal) {
if (noLinkStyle) {
return <a className={className} href={href} ref={ref} {...other} />;
return <Text variant='link' component='a' className={className} href={href} ref={ref} {...other} />;
}
return <MuiLink className={className} href={href} ref={ref} {...other} />;
return <Text component='a' variant='link' href={href} ref={ref} {...other} />;
}
if (noLinkStyle) {
@@ -61,8 +61,9 @@ const Link = forwardRef(function Link(props: any, ref) {
}
return (
<MuiLink
<Text
component={NextLinkComposed}
variant='link'
linkAs={linkAs}
className={className}
ref={ref}

View File

@@ -0,0 +1,6 @@
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,77 +1,96 @@
import React from 'react';
import { ThemeProvider } from '@emotion/react';
import { CssBaseline } from '@mui/material';
import React, { useEffect } from 'react';
// themes
import dark_blue from 'lib/themes/dark_blue';
import light_blue from 'lib/themes/light_blue';
import dark from 'lib/themes/dark';
import ayu_dark from 'lib/themes/ayu_dark';
import ayu_mirage from 'lib/themes/ayu_mirage';
import ayu_light from 'lib/themes/ayu_light';
import nord from 'lib/themes/nord';
import polar from 'lib/themes/polar';
import dracula from 'lib/themes/dracula';
import matcha_dark_azul from 'lib/themes/matcha_dark_azul';
import qogir_dark from 'lib/themes/qogir_dark';
import { useStoreSelector } from 'lib/redux/store';
import createTheme from 'lib/themes';
import { MantineProvider, MantineThemeOverride } from '@mantine/core';
import { ModalsProvider } from '@mantine/modals';
import { NotificationsProvider } from '@mantine/notifications';
import { useColorScheme } from '@mantine/hooks';
export const themes = {
'dark_blue': dark_blue,
'dark': dark,
'ayu_dark': ayu_dark,
'ayu_mirage': ayu_mirage,
'ayu_light': ayu_light,
'nord': nord,
'polar': polar,
'dracula': dracula,
'matcha_dark_azul': matcha_dark_azul,
'qogir_dark': qogir_dark,
system: (colorScheme: 'dark' | 'light') => colorScheme === 'dark' ? dark_blue : light_blue,
dark_blue,
light_blue,
dark,
ayu_dark,
ayu_mirage,
ayu_light,
nord,
dracula,
matcha_dark_azul,
qogir_dark,
};
export const friendlyThemeName = {
'system': 'System Theme',
'dark_blue': 'Dark Blue',
'light_blue': 'Light Blue',
'dark': 'Very Dark',
'ayu_dark': 'Ayu Dark',
'ayu_mirage': 'Ayu Mirage',
'ayu_light': 'Ayu Light',
'nord': 'Nord',
'polar': 'Polar',
'dracula': 'Dracula',
'matcha_dark_azul': 'Matcha Dark Azul',
'qogir_dark': 'Qogir Dark',
};
export default function ZiplineTheming({ Component, pageProps }) {
let t;
export default function ZiplineTheming({ Component, pageProps, ...props }) {
const user = useStoreSelector(state => state.user);
if (!user) t = themes.dark_blue;
else {
if (user.customTheme) {
t = createTheme({
type: 'dark',
primary: user.customTheme.primary,
secondary: user.customTheme.secondary,
error: user.customTheme.error,
warning: user.customTheme.warning,
info: user.customTheme.info,
border: user.customTheme.border,
background: {
main: user.customTheme.mainBackground,
paper: user.customTheme.paperBackground,
},
});
} else {
t = themes[user.systemTheme] ?? themes.dark_blue;
}
}
const colorScheme = useColorScheme();
let theme: MantineThemeOverride;
if (!user) theme = themes.system(colorScheme);
else if (user.systemTheme === 'system') theme = themes.system(colorScheme);
else theme = themes[user.systemTheme] ?? themes.system(colorScheme);
useEffect(() => {
document.documentElement.style.setProperty('color-scheme', theme.colorScheme);
}, [user, theme]);
return (
<ThemeProvider theme={t}>
<CssBaseline />
<Component {...pageProps} />
</ThemeProvider>
<MantineProvider
withGlobalStyles
withNormalizeCSS
theme={theme}
styles={{
AppShell: t => ({
root: {
backgroundColor: t.other.AppShell_backgroundColor,
},
}),
Popover: {
inner: {
width: 200,
},
},
Accordion: {
itemTitle: {
border: 0,
},
itemOpened: {
border: 0,
},
},
}}
>
<ModalsProvider>
<NotificationsProvider>
{props.children ? props.children : <Component {...pageProps} />}
</NotificationsProvider>
</ModalsProvider>
</MantineProvider>
);
}

View File

@@ -1,28 +0,0 @@
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

@@ -1,41 +0,0 @@
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

@@ -1,32 +1,16 @@
import React, { useEffect, useState } from 'react';
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TablePagination,
TableRow,
Button,
ButtonGroup,
Typography,
Grid,
Skeleton,
CardActionArea,
CardMedia,
Card as MuiCard,
} from '@mui/material';
import AudioIcon from '@mui/icons-material/Audiotrack';
import DeleteIcon from '@mui/icons-material/Delete';
import CopyIcon from '@mui/icons-material/FileCopy';
import OpenIcon from '@mui/icons-material/OpenInNew';
import Link from 'components/Link';
import Card from 'components/Card';
import Alert from 'components/Alert';
import copy from 'copy-to-clipboard';
import ZiplineImage 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 { 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';
@@ -44,87 +28,56 @@ export function bytesToRead(bytes: number) {
return `${bytes.toFixed(1)} ${units[num]}`;
}
const columns = [
{ id: 'file', label: 'Name', minWidth: 170, align: 'inherit' as Aligns },
{ id: 'mimetype', label: 'Type', minWidth: 100, align: 'inherit' as Aligns },
{
id: 'created_at',
label: 'Date',
minWidth: 170,
align: 'right' as Aligns,
format: (value) => new Date(value).toLocaleString(),
},
];
function StatText({ children }) {
return <Typography variant='h5' color='GrayText'>{children}</Typography>;
}
function StatTable({ rows, columns }) {
return (
<TableContainer sx={{ pt: 1 }}>
<Table sx={{ minWidth: 100 }} size='small'>
<TableHead>
<TableRow>
{columns.map(col => (
<TableCell key={col.name} sx={{ borderColor: t => t.palette.divider }}>{col.name}</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{rows.map(row => (
<TableRow
hover
key={row.username}
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
>
{columns.map(col => (
<TableCell key={col.id} sx={{ borderColor: t => t.palette.divider }}>
{col.format ? col.format(row[col.id]) : row[col.id]}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
}
export default function Dashboard() {
const user = useStoreSelector(state => state.user);
const [images, setImages] = useState([]);
const [recent, setRecent] = useState([]);
const [page, setPage] = useState(0);
const [stats, setStats] = useState(null);
const [rowsPerPage, setRowsPerPage] = useState(10);
const [open, setOpen] = useState(false);
const [severity, setSeverity] = useState('success');
const [message, setMessage] = useState('Saved');
const clipboard = useClipboard();
const notif = useNotifications();
const updateImages = async () => {
const imgs = await useFetch('/api/user/files');
const recent = await useFetch('/api/user/recent?filter=media');
const stts = await useFetch('/api/stats');
setImages(imgs);
setImages(imgs.map(x => ({ ...x, created_at: new Date(x.created_at).toLocaleString() })));
setStats(stts);
setRecent(recent);
};
const handleChangePage = (event, newPage) => {
setPage(newPage);
const deleteImage = async ({ original }) => {
const res = await useFetch('/api/user/files', 'DELETE', { id: original.id });
if (!res.error) {
updateImages();
notif.showNotification({
title: 'Image Deleted',
message: '',
color: 'green',
icon: <TrashIcon />,
});
} else {
notif.showNotification({
title: 'Failed to delete image',
message: res.error,
color: 'red',
icon: <Cross1Icon />,
});
}
};
const handleChangeRowsPerPage = event => {
setRowsPerPage(+event.target.value);
setPage(0);
const copyImage = async ({ original }) => {
clipboard.copy(`${window.location.protocol}//${window.location.host}${original.url}`);
notif.showNotification({
title: 'Copied to clipboard',
message: '',
icon: <CopyIcon />,
});
};
const handleDelete = async image => {
const res = await useFetch('/api/user/files', 'DELETE', { id: image.id });
if (!res.error) updateImages();
const viewImage = async ({ original }) => {
window.open(`${window.location.protocol}//${window.location.host}${original.url}`);
};
useEffect(() => {
@@ -133,117 +86,74 @@ export default function Dashboard() {
return (
<>
<Alert open={open} setOpen={setOpen} message={message} severity={severity} />
<Title>Welcome back {user?.username}</Title>
<Text color='gray' sx={{ paddingBottom: 4 }}>You have <b>{images.length ? images.length : '...'}</b> files</Text>
<Typography variant='h4'>Welcome back {user?.username}</Typography>
<Typography color='GrayText' pb={2}>You have <b>{images.length ? images.length : '...'}</b> files</Typography>
<Typography variant='h4'>Recent Files</Typography>
<Grid container spacing={4} py={2}>
<Title>Recent Files</Title>
<SimpleGrid
cols={4}
spacing='lg'
breakpoints={[
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
]}
>
{recent.length ? recent.map(image => (
<Grid item xs={12} sm={3} key={image.id}>
<MuiCard sx={{ minWidth: '100%' }}>
<CardActionArea>
<CardMedia
sx={{ height: 220 }}
image={image.url}
title={image.file}
controls
component={image.mimetype.split('/')[0] === 'audio' ? AudioIcon : image.mimetype.split('/')[0]} // this is done because audio without controls is hidden
/>
</CardActionArea>
</MuiCard>
</Grid>
<ZiplineImage key={randomId()} image={image} updateImages={updateImages} />
)) : [1,2,3,4].map(x => (
<Grid item xs={12} sm={3} key={x}>
<Skeleton variant='rectangular' width='100%' height={220} sx={{ borderRadius: 1 }}/>
</Grid>
<div key={x}>
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }}/>
</div>
))}
</Grid>
<Typography variant='h4'>Stats</Typography>
<Grid container spacing={4} py={2}>
<Grid item xs={12} sm={4}>
<Card name='Size' sx={{ height: '100%' }}>
<StatText>{stats ? stats.size : <Skeleton variant='text' />}</StatText>
<Typography variant='h3'>Average Size</Typography>
<StatText>{stats ? bytesToRead(stats.size_num / stats.count) : <Skeleton variant='text' />}</StatText>
</Card>
</Grid>
<Grid item xs={12} sm={4}>
<Card name='Images' sx={{ height: '100%' }}>
<StatText>{stats ? stats.count : <Skeleton variant='text' />}</StatText>
<Typography variant='h3'>Views</Typography>
<StatText>{stats ? `${stats.views_count} (${isNaN(stats.views_count / stats.count) ? 0 : Math.round(stats.views_count / stats.count)})` : <Skeleton variant='text' />}</StatText>
</Card>
</Grid>
<Grid item xs={12} sm={4}>
<Card name='Users' sx={{ height: '100%' }}>
<StatText>{stats ? stats.count_users : <Skeleton variant='text' />}</StatText>
</Card>
</Grid>
</Grid>
<Card name='Images' sx={{ my: 2 }} elevation={0} variant='outlined'>
<Link href='/dashboard/files' pb={2}>View Files</Link>
<TableContainer sx={{ maxHeight: 440 }}>
<Table size='small'>
<TableHead>
<TableRow>
{columns.map(column => (
<TableCell
key={column.id}
align={column.align}
sx={{ minWidth: column.minWidth, borderColor: t => t.palette.divider }}
>
{column.label}
</TableCell>
))}
<TableCell sx={{ minWidth: 200, borderColor: t => t.palette.divider }} align='right'>
Actions
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{images
.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
.map(row => {
return (
<TableRow hover role='checkbox' tabIndex={-1} key={row.id}>
{columns.map(column => {
const value = row[column.id];
return (
<TableCell key={column.id} align={column.align} sx={{ borderColor: t => t.palette.divider }}>
{column.format ? column.format(value) : value}
</TableCell>
);
})}
<TableCell align='right' sx={{ borderColor: t => t.palette.divider }}>
<ButtonGroup variant='text'>
<Button onClick={() => handleDelete(row)} color='error' size='small'><DeleteIcon /></Button>
<Button onClick={() => window.open(row.url, '_blank')} color='warning' size='small'><OpenIcon /></Button>
<Button onClick={() => {
copy(window.location.origin + row.url);
setOpen(true);
setSeverity('success');
setMessage('Copied to clipboard');
}} color='info' size='small'><CopyIcon /></Button>
</ButtonGroup>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
<TablePagination
rowsPerPageOptions={[10, 25, 100]}
component='div'
count={images.length}
rowsPerPage={rowsPerPage}
page={page}
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage} />
</Card>
<Card name='Files per User' sx={{ height: '100%', my: 2 }} elevation={0} variant='outlined'>
</SimpleGrid>
<Title mt='md'>Stats</Title>
<Text>View more stats here <Link href='/dashboard/stats'>here</Link>.</Text>
<SimpleGrid
cols={3}
spacing='lg'
breakpoints={[
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
]}
>
<Card name='Size' sx={{ height: '100%' }}>
<StatText>{stats ? stats.size : <Skeleton height={8} />}</StatText>
<Title order={2}>Average Size</Title>
<StatText>{stats ? bytesToRead(stats.size_num / stats.count) : <Skeleton height={8} />}</StatText>
</Card>
<Card name='Images' sx={{ height: '100%' }}>
<StatText>{stats ? stats.count : <Skeleton height={8} />}</StatText>
<Title order={2}>Views</Title>
<StatText>{stats ? `${stats.views_count} (${isNaN(stats.views_count / stats.count) ? 0 : Math.round(stats.views_count / stats.count)})` : <Skeleton height={8} />}</StatText>
</Card>
<Card name='Users' sx={{ height: '100%' }}>
<StatText>{stats ? stats.count_users : <Skeleton height={8} />}</StatText>
</Card>
</SimpleGrid>
<ImagesTable
columns={[
{ accessor: 'file', Header: 'Name', minWidth: 170, align: 'inherit' as Aligns },
{ accessor: 'mimetype', Header: 'Type', minWidth: 100, align: 'inherit' as Aligns },
{ accessor: 'created_at', Header: 'Date' },
]}
data={images}
deleteImage={deleteImage}
copyImage={copyImage}
viewImage={viewImage}
/>
{/* <Title mt='md'>Files</Title>
<Text>View previews of your files in the <Link href='/dashboard/files'>browser</Link>.</Text>
<ReactTable
columns={[
{ accessor: 'file', Header: 'Name', minWidth: 170, align: 'inherit' as Aligns },
{ accessor: 'mimetype', Header: 'Type', minWidth: 100, align: 'inherit' as Aligns },
{ accessor: 'created_at', Header: 'Date' },
]}
data={images}
pagination
/>
<Card name='Files per User' mt={22}>
<StatTable
columns={[
{ id: 'username', name: 'Name' },
@@ -251,14 +161,14 @@ export default function Dashboard() {
]}
rows={stats ? stats.count_by_user : []} />
</Card>
<Card name='Types' sx={{ height: '100%', my: 2 }} elevation={0} variant='outlined'>
<Card name='Types' mt={22}>
<StatTable
columns={[
{ id: 'mimetype', name: 'Type' },
{ id: 'count', name: 'Count' },
]}
rows={stats ? stats.types_count : []} />
</Card>
</Card> */}
</>
);
}

View File

@@ -1,27 +1,24 @@
import React, { useEffect, useState } from 'react';
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';
import useFetch from 'hooks/useFetch';
import { Box, Accordion, Pagination, Title, SimpleGrid, Skeleton, Group, ActionIcon } from '@mantine/core';
import { PlusIcon } from '@modulz/radix-icons';
import Link from 'next/link';
export default function Files() {
const [pages, setPages] = useState([]);
const [page, setPage] = useState(1);
const [favoritePages, setFavoritePages] = useState([]);
const [favoritePage, setFavoritePage] = useState(1);
const [loading, setLoading] = useState(true);
const updatePages = async favorite => {
setLoading(true);
const pages = await useFetch('/api/user/files?paged=true&filter=media');
if (favorite) {
const fPages = await useFetch('/api/user/files?paged=true&favorite=media');
setFavoritePages(fPages);
}
setPages(pages);
setLoading(false);
};
useEffect(() => {
@@ -30,59 +27,76 @@ export default function Files() {
return (
<>
<Backdrop open={loading}/>
{!pages.length ? (
<Box
display='flex'
justifyContent='center'
alignItems='center'
pt={2}
pb={3}
>
<Typography variant='h4'>No Files</Typography>
</Box>
) : <Typography variant='h4'>Files</Typography>}
{favoritePages.length ? (
<Accordion sx={{ my: 2, border: 1, borderColor: t => t.palette.divider }} elevation={0}>
<AccordionSummary expandIcon={<ExpandMore />}>
<Typography variant='h4'>Favorite Files</Typography>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={2}>
{favoritePages.length ? favoritePages[(favoritePage - 1) ?? 0].map(image => (
<Grid item xs={12} sm={3} key={image.id}>
<ZiplineImage image={image} updateImages={() => updatePages(true)} />
</Grid>
)) : null}
</Grid>
{favoritePages.length ? (
<Box
display='flex'
justifyContent='center'
alignItems='center'
pt={2}
>
<Pagination count={favoritePages.length} page={favoritePage} onChange={(_, v) => setFavoritePage(v)}/>
</Box>
) : null}
</AccordionDetails>
</Accordion>
) : null}
<Grid container spacing={2}>
<Group>
<Title sx={{ marginBottom: 12 }}>Files</Title>
<Link href='/dashboard/upload' passHref>
<ActionIcon component='a' variant='filled' color='primary'><PlusIcon/></ActionIcon>
</Link>
</Group>
<Accordion
offsetIcon={false}
sx={t => ({
marginTop: 2,
border: '1px solid',
marginBottom: 12,
borderColor: t.colorScheme === 'dark' ? t.colors.dark[6] : t.colors.gray[0] ,
})}
>
<Accordion.Item label={<Title>Favorite Files</Title>}>
<SimpleGrid
cols={3}
spacing='lg'
breakpoints={[
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
]}
>
{favoritePages.length ? favoritePages[(favoritePage - 1) ?? 0].map(image => (
<div key={image.id}>
<ZiplineImage image={image} updateImages={() => updatePages(true)} />
</div>
)) : null}
</SimpleGrid>
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingTop: 12,
paddingBottom: 3,
}}
>
<Pagination total={favoritePages.length} page={favoritePage} onChange={setFavoritePage}/>
</Box>
</Accordion.Item>
</Accordion>
<SimpleGrid
cols={3}
spacing='lg'
breakpoints={[
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
]}
>
{pages.length ? pages[(page - 1) ?? 0].map(image => (
<Grid item xs={12} sm={3} key={image.id}>
<ZiplineImage image={image} updateImages={updatePages} />
</Grid>
)) : null}
</Grid>
<div key={image.id}>
<ZiplineImage image={image} updateImages={() => updatePages(true)} />
</div>
)) : [1,2,3,4].map(x => (
<div key={x}>
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }}/>
</div>
))}
</SimpleGrid>
{pages.length ? (
<Box
display='flex'
justifyContent='center'
alignItems='center'
pt={2}
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingTop: 12,
paddingBottom: 3,
}}
>
<Pagination count={pages.length} page={page} onChange={(_, v) => setPage(v)}/>
<Pagination total={pages.length} page={page} onChange={setPage}/>
</Box>
) : null}
</>

View File

@@ -1,72 +1,22 @@
import React, { useState } from 'react';
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';
const validationSchema = yup.object({
username: yup
.string()
.required('Username is required'),
});
const themeValidationSchema = yup.object({
type: yup
.string()
.required('Type (dark, light) is required is required'),
primary: yup
.string()
.required('Primary color is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
secondary: yup
.string()
.required('Secondary color is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
error: yup
.string()
.required('Error color is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
warning: yup
.string()
.required('Warning color is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
info: yup
.string()
.required('Info color is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
border: yup
.string()
.required('Border color is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
mainBackground: yup
.string()
.required('Main Background is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
paperBackground: yup
.string()
.required('Paper Background is required')
.matches(/\#[0-9A-Fa-f]{6}/g, { message: 'Not a valid hex color' }),
});
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';
function VarsTooltip({ children }) {
return (
<Tooltip title={
<Tooltip position='top' placement='center' color='' label={
<>
<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>
<Text><b>{'{image.file}'}</b> - file name</Text>
<Text><b>{'{image.mimetype}'}</b> - mimetype</Text>
<Text><b>{'{image.id}'}</b> - id of the image</Text>
<Text><b>{'{user.name}'}</b> - your username</Text>
visit <Link href='https://zipline.diced.cf/docs/variables'>the docs</Link> for more variables
</>
}>
@@ -78,12 +28,9 @@ function VarsTooltip({ children }) {
export default function Manage() {
const user = useStoreSelector(state => state.user);
const dispatch = useStoreDispatch();
const router = useRouter();
const notif = useNotifications();
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [severity, setSeverity] = useState('success');
const [message, setMessage] = useState('Saved');
const [domains, setDomains] = useState(user.domains ?? []);
const genShareX = (withEmbed: boolean = false, withZws: boolean = false) => {
const config = {
@@ -111,150 +58,113 @@ export default function Manage() {
pseudoElement.parentNode.removeChild(pseudoElement);
};
const formik = useFormik({
const form = useForm({
initialValues: {
username: user.username,
password: '',
embedTitle: user.embedTitle ?? '',
embedColor: user.embedColor,
embedSiteName: user.embedSiteName ?? '',
},
validationSchema,
onSubmit: async values => {
const cleanUsername = values.username.trim();
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');
setLoading(true);
const data = {
username: cleanUsername,
password: cleanPassword === '' ? null : cleanPassword,
embedTitle: cleanEmbedTitle === '' ? null : cleanEmbedTitle,
embedColor: cleanEmbedColor === '' ? null : cleanEmbedColor,
embedSiteName: cleanEmbedSiteName === '' ? null : cleanEmbedSiteName,
};
const newUser = await useFetch('/api/user', 'PATCH', data);
if (newUser.error) {
setLoading(false);
setMessage('An error occured');
setSeverity('error');
setOpen(true);
} else {
dispatch(updateUser(newUser));
setLoading(false);
setMessage('Saved user');
setSeverity('success');
setOpen(true);
}
domains: user.domains ?? [],
},
});
const customThemeFormik = useFormik({
initialValues: {
type: user.customTheme?.type || 'dark',
primary: user.customTheme?.primary || '',
secondary: user.customTheme?.secondary || '',
error: user.customTheme?.error || '',
warning: user.customTheme?.warning || '',
info: user.customTheme?.info || '',
border: user.customTheme?.border || '',
mainBackground: user.customTheme?.mainBackground || '',
paperBackground: user.customTheme?.paperBackground || '',
},
validationSchema: themeValidationSchema,
onSubmit: async values => {
setLoading(true);
const newUser = await useFetch('/api/user', 'PATCH', { customTheme: values });
const onSubmit = async values => {
const cleanUsername = values.username.trim();
const cleanPassword = values.password.trim();
const cleanEmbedTitle = values.embedTitle.trim();
const cleanEmbedColor = values.embedColor.trim();
const cleanEmbedSiteName = values.embedSiteName.trim();
if (newUser.error) {
setLoading(false);
setMessage('An error occured');
setSeverity('error');
setOpen(true);
} else {
dispatch(updateUser(newUser));
router.replace(router.pathname);
setLoading(false);
setMessage('Saved theme');
setSeverity('success');
setOpen(true);
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: '',
});
}
};
return (
<>
<Backdrop open={loading}/>
<Alert open={open} setOpen={setOpen} message={message} severity={severity} />
<Typography variant='h4'>Manage User</Typography>
<Title>Manage User</Title>
<VarsTooltip>
<Typography variant='caption' color='GrayText'>Want to use variables in embed text? Hover on this or visit <Link href='https://zipline.diced.cf/docs/variables'>the docs</Link> for more variables</Typography>
<Text color='gray'>Want to use variables in embed text? Hover on this or visit <Link href='https://zipline.diced.cf/docs/variables'>the docs</Link> for more variables</Text>
</VarsTooltip>
<form onSubmit={formik.handleSubmit}>
<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'
alignItems='right'
pt={2}
>
<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='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
variant='contained'
type='submit'
>Save User</Button>
</Box>
</Group>
</form>
<Typography variant='h4' py={2}>Manage Theme</Typography>
<form onSubmit={customThemeFormik.handleSubmit}>
<Select
id='type'
name='type'
label='Type'
value={customThemeFormik.values['type']}
onChange={customThemeFormik.handleChange}
error={customThemeFormik.touched['type'] && Boolean(customThemeFormik.errors['type'])}
variant='standard'
fullWidth
>
<MenuItem value='dark'>Dark Theme</MenuItem>
<MenuItem value='light'>Light Theme</MenuItem>
</Select>
<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'
alignItems='right'
pt={2}
>
<Button
variant='contained'
type='submit'
>Save Theme</Button>
</Box>
</form>
<Typography variant='h4' py={2}>ShareX Config</Typography>
<Button variant='contained' onClick={() => genShareX(false)} startIcon={<Download />}>ShareX Config</Button>
<Button variant='contained' sx={{ marginLeft: 1 }} onClick={() => genShareX(true)} startIcon={<Download />}>ShareX Config with Embed</Button>
<Button variant='contained' sx={{ marginLeft: 1 }} onClick={() => genShareX(false, true)} startIcon={<Download />}>ShareX Config with ZWS</Button>
<Title sx={{ paddingTop: 12, paddingBottom: 12 }}>ShareX Config</Title>
<Group>
<Button onClick={() => genShareX(false)} rightIcon={<DownloadIcon />}>ShareX Config</Button>
<Button onClick={() => genShareX(true)} rightIcon={<DownloadIcon />}>ShareX Config with Embed</Button>
<Button onClick={() => genShareX(false, true)} rightIcon={<DownloadIcon />}>ShareX Config with ZWS</Button>
</Group>
</>
);
}

View File

@@ -0,0 +1,106 @@
import React, { useEffect, useState } from 'react';
import Card from 'components/Card';
import StatText from 'components/StatText';
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';
export function bytesToRead(bytes: number) {
if (isNaN(bytes)) return '0.0 B';
if (bytes === Infinity) return '0.0 B';
const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB'];
let num = 0;
while (bytes > 1024) {
bytes /= 1024;
++num;
}
return `${bytes.toFixed(1)} ${units[num]}`;
}
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 Stats() {
const [stats, setStats] = useState(null);
const update = async () => {
const stts = await useFetch('/api/stats');
setStats(stts);
};
useEffect(() => {
update();
}, []);
return (
<>
<Title>Stats</Title>
<SimpleGrid
cols={3}
spacing='lg'
breakpoints={[
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
]}
>
<Card name='Size' sx={{ height: '100%' }}>
<StatText>{stats ? stats.size : <Skeleton height={8} />}</StatText>
<Title order={2}>Average Size</Title>
<StatText>{stats ? bytesToRead(stats.size_num / stats.count) : <Skeleton height={8} />}</StatText>
</Card>
<Card name='Images' sx={{ height: '100%' }}>
<StatText>{stats ? stats.count : <Skeleton height={8} />}</StatText>
<Title order={2}>Views</Title>
<StatText>{stats ? `${stats.views_count} (${isNaN(stats.views_count / stats.count) ? 0 : Math.round(stats.views_count / stats.count)})` : <Skeleton height={8} />}</StatText>
</Card>
<Card name='Users' sx={{ height: '100%' }}>
<StatText>{stats ? stats.count_users : <Skeleton height={8} />}</StatText>
</Card>
</SimpleGrid>
<Card name='Files per User' mt={22}>
<StatTable
columns={[
{ id: 'username', name: 'Name' },
{ id: 'count', name: 'Files' },
]}
rows={stats ? stats.count_by_user : []} />
</Card>
<Card name='Types' mt={22}>
<StatTable
columns={[
{ id: 'mimetype', name: 'Type' },
{ id: 'count', name: 'Count' },
]}
rows={stats ? stats.types_count : []} />
</Card>
</>
);
}

View File

@@ -1,39 +1,66 @@
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';
import Alert from 'components/Alert';
import { useStoreSelector } from 'lib/redux/store';
import CenteredBox from 'components/CenteredBox';
import copy from 'copy-to-clipboard';
import Link from 'components/Link';
import { Button, Group, Text, useMantineTheme } from '@mantine/core';
import { ImageIcon, UploadIcon, CrossCircledIcon } from '@modulz/radix-icons';
import { Dropzone } from '@mantine/dropzone';
import { useNotifications } from '@mantine/notifications';
import { useClipboard } from '@mantine/hooks';
export default function Upload({ route }) {
function ImageUploadIcon({ status, ...props }) {
if (status.accepted) {
return <UploadIcon {...props} />;
}
if (status.rejected) {
return <CrossCircledIcon {...props} />;
}
return <ImageIcon {...props} />;
}
function getIconColor(status, theme) {
return status.accepted
? theme.colors[theme.primaryColor][6]
: status.rejected
? theme.colors.red[6]
: theme.colorScheme === 'dark'
? theme.colors.dark[0]
: theme.black;
}
export default function Upload() {
const theme = useMantineTheme();
const notif = useNotifications();
const clipboard = useClipboard();
const user = useStoreSelector(state => state.user);
const [files, setFiles] = useState([]);
const [loading, setLoading] = useState(false);
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 })]);
notif.showNotification({
title: 'Image Imported',
message: '',
});
});
});
const handleUpload = async () => {
const body = new FormData();
for (let i = 0; i !== files.length; ++i) body.append('file', files[i]);
setLoading(true);
const id = notif.showNotification({
title: 'Uploading Images...',
message: '',
loading: true,
autoClose: false,
});
const res = await fetch('/api/upload', {
method: 'POST',
headers: {
@@ -43,63 +70,50 @@ export default function Upload({ route }) {
});
const json = await res.json();
if (res.ok && json.error === undefined) {
setOpen(true);
setSeverity('success');
//@ts-ignore
setMessage(<>Copied first image to clipboard! <br/>{json.files.map(x => (<Link key={x} href={x}>{x}<br/></Link>))}</>);
copy(json.url);
notif.updateNotification(id, {
title: 'Upload Successful',
message: <>Copied first image to clipboard! <br/>{json.files.map(x => (<Link key={x} href={x}>{x}<br/></Link>))}</>,
color: 'green',
icon: <UploadIcon />,
});
clipboard.copy(json.url);
setFiles([]);
} else {
setOpen(true);
setSeverity('error');
setMessage('Could not upload file: ' + json.error);
notif.updateNotification(id, {
title: 'Upload Failed',
message: json.error,
color: 'red',
icon: <CrossCircledIcon />,
});
}
setLoading(false);
};
return (
<>
<Backdrop open={loading}/>
<Alert open={open} setOpen={setOpen} message={message} severity={severity} />
<Dropzone onDrop={(f) => setFiles([...files, ...f])}>
{status => (
<>
<Group position='center' spacing='xl' style={{ minHeight: 220, pointerEvents: 'none' }}>
<ImageUploadIcon
status={status}
style={{ width: 80, height: 80, color: getIconColor(status, theme) }}
/>
<Typography variant='h4' pb={2}>Upload file</Typography>
<Dropzone onDrop={acceptedFiles => setFiles([...files, ...acceptedFiles])}>
{({getRootProps, getInputProps}) => (
<CardActionArea>
<Paper
elevation={0}
variant='outlined'
sx={{
justifyContent: 'center',
alignItems: 'center',
display: 'block',
p: 5,
}}
{...getRootProps()}
>
<input {...getInputProps()} />
<CenteredBox><UploadIcon sx={{ fontSize: 100 }} /></CenteredBox>
<CenteredBox><Typography variant='h5'>Drag an image or click to upload an image.</Typography></CenteredBox>
{files.map(file => (
<CenteredBox key={file.name}><Typography variant='h6'>{file.name}</Typography></CenteredBox>
))}
</Paper>
</CardActionArea>
<div>
<Text size='xl' inline>
Drag images here or click to select files
</Text>
</div>
</Group>
<Group position='center' spacing='xl' style={{ pointerEvents: 'none' }}>
{files.map(file => (<Text key={file.name} weight='bold'>{file.name}</Text>))}
</Group>
</>
)}
</Dropzone>
<Box
display='flex'
justifyContent='right'
alignItems='right'
pt={2}
>
<Button
variant='contained'
onClick={handleUpload}
>Upload</Button>
</Box>
<Group position='right'>
<Button leftIcon={<UploadIcon />} mt={12} onClick={handleUpload}>Upload</Button>
</Group>
</>
);
}

View File

@@ -1,179 +1,161 @@
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}
/>
);
}
import { useClipboard, useForm } from '@mantine/hooks';
import { CopyIcon, Cross1Icon, Link1Icon, PlusIcon, TrashIcon } from '@modulz/radix-icons';
import { useNotifications } from '@mantine/notifications';
import { Modal, Title, Group, Button, Box, Card, TextInput, ActionIcon, SimpleGrid, Skeleton } from '@mantine/core';
export default function Urls() {
const user = useStoreSelector(state => state.user);
const notif = useNotifications();
const clipboard = useClipboard();
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);
notif.showNotification({
title: 'Failed to delete URL',
message: url.error,
icon: <TrashIcon />,
color: 'red',
});
} else {
setSeverity('success');
setMessage(`Deleted ${u.vanity ?? u.id}`);
setOpen(true);
notif.showNotification({
title: 'Deleted URL',
message: '',
icon: <Cross1Icon />,
color: 'green',
});
}
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);
clipboard.copy(`${window.location.protocol}//${window.location.host}${u.url}`);
notif.showNotification({
title: 'Copied to clipboard',
message: '',
icon: <CopyIcon />,
});
};
const formik = useFormik({
const form = useForm({
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);
},
});
const onSubmit = async (values) => {
const cleanURL = values.url.trim();
const cleanVanity = values.vanity.trim();
if (cleanURL === '') return form.setFieldError('url', 'URL can\'t be nothing');
const data = {
url: cleanURL,
vanity: cleanVanity === '' ? null : cleanVanity,
};
setCreateOpen(false);
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) {
notif.showNotification({
title: 'Failed to create URL',
message: json.error,
color: 'red',
icon: <Cross1Icon />,
});
} else {
notif.showNotification({
title: 'URL shortened',
message: json.url,
color: 'green',
icon: <Link1Icon />,
});
}
updateURLs();
};
useEffect(() => {
updateURLs();
}, []);
return (
<>
<Backdrop open={loading}/>
<Alert open={open} setOpen={setOpen} message={message} severity={severity} />
<Modal
opened={createOpen}
onClose={() => setCreateOpen(false)}
title={<Title>Shorten URL</Title>}
>
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
<TextInput id='url' label='URL' {...form.getInputProps('url')} />
<TextInput id='vanity' label='Vanity' {...form.getInputProps('vanity')} />
<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>
<Group position='right' mt={22}>
<Button onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button type='submit'>Submit</Button>
</Group>
</form>
</Dialog>
</Modal>
{!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>}
<Group>
<Title sx={{ marginBottom: 12 }}>URLs</Title>
<ActionIcon variant='filled' color='primary' onClick={() => setCreateOpen(true)}><PlusIcon/></ActionIcon>
</Group>
<Grid container spacing={2}>
<SimpleGrid
cols={4}
spacing='lg'
breakpoints={[
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
]}
>
{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>
<Card key={url.id} sx={{ maxWidth: '100%' }} shadow='sm'>
<Group position='apart'>
<Group position='left'>
<Title>{url.vanity ?? url.id}</Title>
</Group>
<Group position='right'>
<ActionIcon href={url.url} component='a' target='_blank'><Link1Icon/></ActionIcon>
<ActionIcon aria-label='copy' onClick={() => copyURL(url)}>
<CopyIcon />
</ActionIcon>
<ActionIcon aria-label='delete' onClick={() => deleteURL(url)}>
<TrashIcon />
</ActionIcon>
</Group>
</Group>
</Card>
)) : [1,2,3,4,5,6,7].map(x => (
<div key={x}>
<Skeleton width='100%' height={60} sx={{ borderRadius: 1 }}/>
</div>
))}
</SimpleGrid>
</>
);
}

View File

@@ -1,168 +1,138 @@
import React, { useState, useEffect } from 'react';
import {
Typography,
Card as MuiCard,
CardHeader,
Avatar,
IconButton,
Grid,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
Switch,
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';
import Alert from 'components/Alert';
import useFetch from 'hooks/useFetch';
import { useRouter } from 'next/router';
import { useFormik } from 'formik';
import { useForm } from '@mantine/hooks';
import { Avatar, Modal, Title, TextInput, Group, Button, Card, ActionIcon, SimpleGrid, Switch, Skeleton, Checkbox } from '@mantine/core';
import { Cross1Icon, PlusIcon, TrashIcon } from '@modulz/radix-icons';
import { useNotifications } from '@mantine/notifications';
import { useModals } from '@mantine/modals';
function Card({ user, handleDelete }) {
return (
<MuiCard sx={{ minWidth: 270 }}>
<CardHeader
avatar={<Avatar>{user.username[0]}</Avatar>}
action={<IconButton onClick={() => handleDelete(user)}><DeleteIcon /></IconButton>}
title={<Typography variant='h6'>{user.username}</Typography>}
/>
</MuiCard>
);
}
function TextInput({ id, label, formik, ...other }) {
return (
<TextField
id={id}
name={id}
label={label}
value={formik.values[id]}
onChange={formik.handleChange}
error={formik.touched[id] && Boolean(formik.errors[id])}
helperText={formik.touched[id] && formik.errors[id]}
variant='standard'
fullWidth
sx={{ pb: 0.5 }}
{...other}
/>
);
}
function CreateUserDialog({ open, setOpen, updateUsers, setSeverity, setMessage, setLoading, setAlertOpen }) {
const formik = useFormik({
function CreateUserModal({ open, setOpen, updateUsers }) {
const form = useForm({
initialValues: {
username: '',
password: '',
administrator: false,
},
onSubmit: async (values) => {
const cleanUsername = values.username.trim();
const cleanPassword = values.password.trim();
if (cleanUsername === '') return formik.setFieldError('username', 'Username can\'t be nothing');
if (cleanPassword === '') return formik.setFieldError('password', 'Password can\'t be nothing');
const data = {
username: cleanUsername,
password: cleanPassword,
administrator: values.administrator,
};
setOpen(false);
setLoading(true);
const res = await useFetch('/api/auth/create', 'POST', data);
if (res.error) {
setSeverity('error');
setMessage('Could\'nt create user: ' + res.error);
setAlertOpen(true);
} else {
setSeverity('success');
setMessage('Created user ' + res.username);
setAlertOpen(true);
updateUsers();
}
setLoading(false);
},
});
const notif = useNotifications();
const onSubmit = async (values) => {
const cleanUsername = values.username.trim();
const cleanPassword = values.password.trim();
if (cleanUsername === '') return form.setFieldError('username', 'Username can\'t be nothing');
if (cleanPassword === '') return form.setFieldError('password', 'Password can\'t be nothing');
const data = {
username: cleanUsername,
password: cleanPassword,
administrator: values.administrator,
};
setOpen(false);
const res = await useFetch('/api/auth/create', 'POST', data);
if (res.error) {
notif.showNotification({
title: 'Failed to create user',
message: res.error,
icon: <TrashIcon />,
color: 'red',
});
} else {
notif.showNotification({
title: 'Created user: ' + cleanUsername,
message: '',
icon: <PlusIcon />,
color: 'green',
});
}
updateUsers();
};
return (
<div>
<Dialog
open={open}
onClose={() => setOpen(false)}
PaperProps={{
elevation: 1,
}}
>
<DialogTitle>
Create User
</DialogTitle>
<form onSubmit={formik.handleSubmit}>
<DialogContent>
<TextInput id='username' label='Username' formik={formik} />
<TextInput id='password' label='Password' formik={formik} type='password' />
<FormControlLabel
id='administrator'
name='administrator'
value={formik.values.administrator}
onChange={formik.handleChange}
control={<Switch />}
label='Administrator?'
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)} color='inherit' autoFocus>Cancel</Button>
<Button type='submit' color='inherit'>
Create
</Button>
</DialogActions>
</form>
</Dialog>
</div>
<Modal
opened={open}
onClose={() => setOpen(false)}
title={<Title>Create User</Title>}
>
<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')} />
<Switch mt={12} id='administrator' label='Administrator' {...form.getInputProps('administrator')} />
<Group position='right' mt={22}>
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button type='submit'>Create</Button>
</Group>
</form>
</Modal>
);
}
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 [createOpen, setCreateOpen] = useState(false);
const [severity, setSeverity] = useState('success');
const [message, setMessage] = useState('Saved');
const [loading, setLoading] = useState(true);
const handleDelete = async (user, delete_images) => {
const res = await useFetch('/api/users', 'DELETE', {
id: user.id,
delete_images,
});
if (res.error) {
notif.showNotification({
title: 'Failed to delete user',
message: res.error,
color: 'red',
icon: <Cross1Icon />,
});
} else {
notif.showNotification({
title: 'User deleted',
message: '',
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 () => {
setLoading(true);
const us = await useFetch('/api/users');
if (!us.error) {
setUsers(us);
} else {
router.push('/dashboard');
};
setLoading(false);
};
const handleDelete = async (user) => {
const res = await useFetch('/api/users', 'DELETE', {
id: user.id,
});
if (res.error) {
setMessage(`Could not delete ${user.username}`);
setSeverity('error');
setOpen(true);
} else {
setMessage(`Deleted user ${res.username}`);
setSeverity('success');
setOpen(true);
updateUsers();
}
};
useEffect(() => {
@@ -171,17 +141,38 @@ export default function Users() {
return (
<>
<Backdrop open={loading}/>
<Alert open={open} setOpen={setOpen} message={message} severity={severity} />
<CreateUserDialog open={createOpen} setOpen={setCreateOpen} setSeverity={setSeverity} setMessage={setMessage} setLoading={setLoading} updateUsers={updateUsers} setAlertOpen={setOpen} />
<Typography variant='h4' pb={2}>Users <IconButton onClick={() => setCreateOpen(true)}><AddIcon /></IconButton></Typography>
<Grid container spacing={2}>
{users.filter(x => x.username !== user.username).map((user, i) => (
<Grid item xs={12} sm={3} key={i}>
<Card user={user} handleDelete={handleDelete}/>
</Grid>
<CreateUserModal open={open} setOpen={setOpen} updateUsers={updateUsers} />
<Group>
<Title sx={{ marginBottom: 12 }}>Users</Title>
<ActionIcon variant='filled' color='primary' onClick={() => setOpen(true)}><PlusIcon /></ActionIcon>
</Group>
<SimpleGrid
cols={3}
spacing='lg'
breakpoints={[
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
]}
>
{users.length ? users.filter(x => x.username !== user.username).map(user => (
<Card key={user.id} sx={{ maxWidth: '100%' }}>
<Group position='apart'>
<Group position='left'>
<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)}>
<TrashIcon />
</ActionIcon>
</Group>
</Group>
</Card>
)) : [1, 2, 3, 4].map(x => (
<div key={x}>
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }} />
</div>
))}
</Grid>
</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

@@ -0,0 +1,40 @@
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;
}
}

74
src/lib/datasource/S3.ts Normal file
View File

@@ -0,0 +1,74 @@
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

@@ -0,0 +1,10 @@
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

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

20
src/lib/ds.ts Normal file
View File

@@ -0,0 +1,20 @@
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;

View File

@@ -16,7 +16,7 @@ export default function login() {
setLoading(true);
const res = await useFetch('/api/user');
if (res.error) return router.push('/auth/login');
if (res.error) return router.push('/auth/login?url=' + router.route);
dispatch(updateUser(res));
setUser(res);

View File

@@ -1,38 +0,0 @@
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');
}
}
};

45
src/lib/logger.ts Normal file
View File

@@ -0,0 +1,45 @@
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

@@ -1,6 +1,5 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import type { CookieSerializeOptions } from 'cookie';
import type { Image, Theme, User } from '@prisma/client';
import { serialize } from 'cookie';
import { sign64, unsign64 } from '../util';
@@ -12,7 +11,7 @@ export interface NextApiFile {
originalname: string;
encoding: string;
mimetype: string;
buffer: string;
buffer: Buffer;
size: number;
}
@@ -23,10 +22,10 @@ export type NextApiReq = NextApiRequest & {
embedTitle: string;
embedColor: string;
systemTheme: string;
customTheme?: Theme;
administrator: boolean;
id: number;
password: string;
domains: string[];
} | null | void>;
getCookie: (name: string) => string | null;
cleanCookie: (name: string) => void;
@@ -35,7 +34,7 @@ export type NextApiReq = NextApiRequest & {
export type NextApiRes = NextApiResponse & {
error: (message: string) => void;
forbid: (message: string) => void;
forbid: (message: string, extra?: any) => void;
bad: (message: string) => void;
json: (json: any) => void;
ratelimited: () => void;
@@ -54,11 +53,12 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
});
};
res.forbid = (message: string) => {
res.forbid = (message: string, extra: any = {}) => {
res.setHeader('Content-Type', 'application/json');
res.status(403);
res.json({
error: '403: ' + message,
...extra,
});
};
@@ -95,6 +95,7 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
maxAge: undefined,
}));
};
req.user = async () => {
try {
const userId = req.getCookie('user');
@@ -111,9 +112,9 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
id: true,
password: true,
systemTheme: true,
customTheme: true,
token: true,
username: true,
domains: true,
},
});

View File

@@ -1,7 +1,8 @@
const { existsSync, readFileSync } = require('fs');
const { join } = require('path');
const parse = require('@iarna/toml/parse-string.js');
const Logger = require('./logger.js');
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 e = (val, type, fn) => ({ val, type, fn });
@@ -14,9 +15,14 @@ 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 = []),
@@ -28,7 +34,7 @@ const envValues = [
e('RATELIMIT_ADMIN', 'number', (c, v) => c.ratelimit.user = v ?? 0),
];
module.exports = function readConfig() {
export default function readConfig(): Config {
if (!existsSync(join(process.cwd(), 'config.toml'))) {
if (!process.env.ZIPLINE_DOCKER_BUILD) Logger.get('config').info('reading environment');
return tryReadEnv();
@@ -43,7 +49,7 @@ module.exports = function readConfig() {
}
};
function tryReadEnv() {
function tryReadEnv(): Config {
const config = {
core: {
secure: undefined,
@@ -54,10 +60,20 @@ function tryReadEnv() {
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,
@@ -74,7 +90,7 @@ function tryReadEnv() {
for (let i = 0, L = envValues.length; i !== L; ++i) {
const envValue = envValues[i];
let value = process.env[envValue.val];
let value: any = process.env[envValue.val];
if (!value) {
envValues[i].fn(config, undefined);

View File

@@ -1,4 +1,3 @@
import { Theme } from '@prisma/client';
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
export interface User {
@@ -8,7 +7,7 @@ export interface User {
embedColor: string;
embedSiteName: string;
systemTheme: string;
customTheme?: Theme;
domains: string[];
}
const initialState: User = null;

View File

@@ -3,15 +3,36 @@
import createTheme from '.';
export default createTheme({
type: 'dark',
primary: '#E6B450',
secondary: '#FFEE99',
error: '#F07178',
warning: '#F29668',
info: '#95E6CB',
border: '#191e29',
background: {
main: '#0A0E14',
paper: '#0D1016',
colorScheme: 'dark',
primaryColor: 'orange',
other: {
AppShell_backgroundColor: '#0a0e14',
hover: '#191e29',
},
colors: {
dark: [
'#ffffff',
'#47494E',
'#6c707a',
'#33353B',
'#303238',
'#2C2E34',
'#25272D',
'#0d1016',
'#11141A',
'#0D1016',
],
orange: [
'#FFFFFF',
'#FCF6EA',
'#F9EDD4',
'#F3DAA8',
'#F2D69D',
'#F0D192',
'#EFCC87',
'#EDC77C',
'#EABE66',
'#E6B450',
],
},
});

View File

@@ -3,15 +3,24 @@
import createTheme from '.';
export default createTheme({
type: 'light',
primary: '#FF9940',
secondary: '#E6BA7E',
error: '#F07171',
warning: '#ED9366',
info: '#95E6CB',
border: '#e3e3e3',
background: {
main: '#FAFAFA',
paper: '#FFFFFF',
colorScheme: 'light',
primaryColor: 'orange',
other: {
AppShell_backgroundColor: '#FAFAFA',
hover: '#FAFAFA',
},
colors: {
orange: [
'#FFFFFF',
'#FCF6EA',
'#F9EDD4',
'#F3DAA8',
'#F2D69D',
'#F0D192',
'#EFCC87',
'#EDC77C',
'#EABE66',
'#E6B450',
],
},
});

View File

@@ -3,15 +3,36 @@
import createTheme from '.';
export default createTheme({
type: 'dark',
primary: '#FFCC66',
secondary: '#FFD580',
error: '#F28779',
warning: '#F29E74',
info: '#95E6CB',
border: '#363c4d',
background: {
main: '#1F2430',
paper: '#232834',
colorScheme: 'dark',
primaryColor: 'orange',
other: {
AppShell_backgroundColor: '#1F2430',
hover: '#2a2f3b',
},
colors: {
dark: [
'#ffffff',
'#91949A',
'#6c707a',
'#3F434E',
'#313641',
'#2A2F3B',
'#2e333e',
'#232834',
'#11141A',
'#0D1016',
],
orange: [
'#FFFFFF',
'#FCF6EA',
'#F9EDD4',
'#F3DAA8',
'#F2D69D',
'#F0D192',
'#EFCC87',
'#EDC77C',
'#EABE66',
'#E6B450',
],
},
});

View File

@@ -1,15 +1,36 @@
import createTheme from '.';
export default createTheme({
type: 'dark',
primary: '#2c39a6',
secondary: '#7344e2',
error: '#ff4141',
warning: '#ff9800',
info: '#2f6fb9',
border: '#2b2b2b',
background: {
main: '#000000',
paper: '#060606',
colorScheme: 'dark',
primaryColor: 'blue',
other: {
AppShell_backgroundColor: '#000000',
hover: '#2b2b2b',
},
colors: {
dark: [
'#ffffff',
'#A7A9AD',
'#7B7E84',
'#61646A',
'#54575D',
'#46494F',
'#3C3F44',
'#060606',
'#141517',
'#000000',
],
blue: [
'#FFFFFF',
'#7C7DC2',
'#7778C0',
'#6C6FBC',
'#575DB5',
'#4D54B2',
'#424BAE',
'#3742AA',
'#323EA8',
'#2C39A6',
],
},
});

View File

@@ -1,15 +1,36 @@
import createTheme from '.';
export default createTheme({
type: 'dark',
primary: '#2c39a6',
secondary: '#7344e2',
error: '#ff4141',
warning: '#ff9800',
info: '#2f6fb9',
border: '#1b2541',
background: {
main: '#05070f',
paper: '#0c101c',
colorScheme: 'dark',
primaryColor: 'blue',
other: {
AppShell_backgroundColor: '#05070f',
hover: '#181c28',
},
colors: {
dark: [
'#FFFFFF',
'#293747',
'#6C7A8D',
'#232F41',
'#41566e',
'#171F35',
'#181c28',
'#0c101c',
'#060824',
'#00001E',
],
blue: [
'#FFFFFF',
'#7C7DC2',
'#7778C0',
'#6C6FBC',
'#575DB5',
'#4D54B2',
'#424BAE',
'#3742AA',
'#323EA8',
'#2C39A6',
],
},
});

View File

@@ -3,15 +3,36 @@
import createTheme from '.';
export default createTheme({
type: 'dark',
primary: '#BD93F9',
secondary: '#6272A4',
error: '#FF5555',
warning: '#FFB86C',
info: '#8BE9FD',
border: '#7D8096',
background: {
main: '#282A36',
paper: '#44475A',
colorScheme: 'dark',
primaryColor: 'violet',
other: {
AppShell_backgroundColor: '#282A36',
hover: '#4e5062',
},
});
colors: {
dark: [
'#FFFFFF',
'#CED0D4',
'#E8E8EB',
'#D1D1D6',
'#BABAC2',
'#A2A3AD',
'#4e5062',
'#44475A',
'#5C5E6F',
'#44475A',
],
violet: [
'#FFFFFF',
'#F7F2FF',
'#EFE4FE',
'#EBDEFE',
'#E7D7FD',
'#DEC9FC',
'#D6BCFC',
'#CEAEFB',
'#C6A1FA',
'#BD93F9',
],
},
});

View File

@@ -1,54 +1,5 @@
import { createTheme as muiCreateTheme } from '@mui/material/styles';
import { MantineThemeOverride } from '@mantine/core';
export interface ThemeOptions {
type: 'dark' | 'light';
primary: string;
secondary: string;
error: string;
warning: string;
info: string;
border: string;
background: ThemeOptionsBackground;
}
export interface ThemeOptionsBackground {
main: string;
paper: string;
}
export default function createTheme(o: ThemeOptions) {
return muiCreateTheme({
palette: {
mode: o.type,
primary: {
main: o.primary,
},
secondary: {
main: o.secondary,
},
background: {
default: o.background.main,
paper: o.background.paper,
},
error: {
main: o.error,
},
warning: {
main: o.warning,
},
info: {
main: o.info,
},
divider: o.border,
},
components: {
MuiTableHead: {
styleOverrides: {
root: {
backgroundColor: o.border,
},
},
},
},
});
export default function createTheme(o: MantineThemeOverride) {
return o;
}

View File

@@ -0,0 +1,24 @@
import createTheme from '.';
export default createTheme({
colorScheme: 'light',
primaryColor: 'blue',
other: {
AppShell_backgroundColor: '#FAFAFA',
hover: '#FAFAFA',
},
colors: {
blue: [
'#FFFFFF',
'#7C7DC2',
'#7778C0',
'#6C6FBC',
'#575DB5',
'#4D54B2',
'#424BAE',
'#3742AA',
'#323EA8',
'#2C39A6',
],
},
});

View File

@@ -1,15 +1,36 @@
import createTheme from '.';
export default createTheme({
type: 'dark',
primary: '#3498db',
secondary: '#7344e2',
error: '#db5b5b',
warning: '#ff9800',
info: '#2f6fb9',
border: '#14161b',
background: {
main: '#1b1d24',
paper: '#202329',
colorScheme: 'dark',
primaryColor: 'blue',
other: {
AppShell_backgroundColor: '#1b1d24',
hover: '#3c3f44',
},
colors: {
dark: [
'#FFFFFF',
'#C8C8CA',
'#F5F5F5',
'#909194',
'#585A5F',
'#4A4D52',
'#3C3F44',
'#202329',
'#272A30',
'#202329',
],
blue: [
'#FFFFFF',
'#E6F3FB',
'#CDE6F6',
'#B4D9F2',
'#9ACCED',
'#8EC6EB',
'#81BFE9',
'#67B2E4',
'#4EA5E0',
'#3498DB',
],
},
});

View File

@@ -3,15 +3,36 @@
import createTheme from '.';
export default createTheme({
type: 'dark',
primary: '#81A1C1',
secondary: '#88C0D0',
error: '#BF616A',
warning: '#EBCB8B',
info: '#5E81AC',
border: '#565e70',
background: {
main: '#2E3440',
paper: '#3B4252',
colorScheme: 'dark',
primaryColor: 'blue',
other: {
AppShell_backgroundColor: '#2E3440',
hover: '#6c727e',
},
colors: {
dark: [
'#FFFFFF',
'#CED0D4',
'#B6B9BF',
'#9DA1A9',
'#858A94',
'#6C727E',
'#606673',
'#3B4252',
'#484E5D',
'#3B4252',
],
blue: [
'#FFFFFF',
'#E0E8F0',
'#C0D0E0',
'#B9CBDD',
'#B1C5D9',
'#A1B9D1',
'#99B3CD',
'#91ADC9',
'#89A7C5',
'#81A1C1',
],
},
});

View File

@@ -1,17 +0,0 @@
// https://github.com/AlphaNecron/
// https://github.com/arcticicestudio/nord
import createTheme from '.';
export default createTheme({
type: 'light',
primary: '#81A1C1',
secondary: '#88C0D0',
error: '#BF616A',
warning: '#EBCB8B',
info: '#5E81AC',
border: '#989fab',
background: {
main: '#D8DEE9',
paper: '#E5E9F0',
},
});

View File

@@ -1,15 +1,37 @@
import createTheme from '.';
export default createTheme({
type: 'dark',
primary: '#5294e2',
secondary: '#7344e2',
error: '##f04a50',
warning: '#ff9800',
info: '#2f6fb9',
border: '#4a4c54',
background: {
main: '#32343d',
paper: '#262830',
colorScheme: 'dark',
primaryColor: 'blue',
other: {
AppShell_backgroundColor: '#32343d',
hover: '#34363d',
},
colors: {
dark: [
'#FFFFFF',
'#C9CACC',
'#F5F5F5',
'#78797E',
'#5D5E64',
'#42434A',
'#34363D',
'#262830',
'#2A2C34',
'#262830',
],
blue: [
'#FFFFFF',
'#E6F3FB',
'#CDE6F6',
'#B4D9F2',
'#9ACCED',
'#8EC6EB',
'#81BFE9',
'#67B2E4',
'#4EA5E0',
'#3498DB',
],
},
});

View File

@@ -21,6 +21,26 @@ 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;
@@ -28,9 +48,6 @@ 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;
@@ -63,4 +80,5 @@ export interface Config {
uploader: ConfigUploader;
urls: ConfigUrls;
ratelimit: ConfigRatelimit;
datasource: ConfigDatasource;
}

View File

@@ -1,16 +1,18 @@
import React from 'react';
import { Box, Typography } from '@mui/material';
import { Box, Text } from '@mantine/core';
export default function FourOhFour() {
return (
<>
<Box
display='flex'
justifyContent='center'
alignItems='center'
minHeight='100vh'
sx={{
display: 'flex',
alignItems: 'center',
minHeight: '100vh',
justifyContent: 'center',
}}
>
<Typography variant='h2'>404 - Not Found</Typography>
<Text size='xl'>404 - Not Found</Text>
</Box>
</>
);

View File

@@ -1,49 +1,92 @@
import React, { useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import Head from 'next/head';
import { GetServerSideProps } from 'next';
import { Box } from '@mui/material';
import { Box, Button, Modal, PasswordInput } 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';
export default function EmbeddedImage({ image, user, normal }) {
export default function EmbeddedImage({ image, user, pass }) {
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 updateImage = () => {
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 imageEl = document.getElementById('image_content') as HTMLImageElement;
const original = new Image;
original.src = dataURL('/r');
if (original.width > innerWidth) imageEl.width = Math.floor(original.width * Math.min((innerHeight / original.height), (innerWidth / original.width)));
else imageEl.width = original.width;
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;
};
};
useEffect(() => updateImage(), []);
useEffect(() => {
if (pass) {
setOpened(true);
} else {
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
display='flex'
justifyContent='center'
alignItems='center'
minHeight='100vh'
sx={{
display: 'flex',
alignItems: 'center',
minHeight: '100vh',
justifyContent: 'center',
}}
>
<img src={dataURL('/r')} alt={dataURL('/r')} id='image_content' />
</Box>
@@ -75,7 +118,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
props: {},
redirect: {
destination: url.destination,
},
},
};
} else {
@@ -94,6 +137,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
userId: true,
embed: true,
created_at: true,
password: true,
},
});
if (!image) return { notFound: true };
@@ -113,20 +157,31 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
//@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 };
context.res.end(data);
return { props: {} };
const prismRender = Object.keys(exts).includes(image.file.split('.').pop());
if (prismRender) return {
redirect: {
destination: `/code/${image.file}`,
permanent: true,
},
};
if (!image.mimetype.startsWith('image')) {
const { default: datasource } = await import('lib/ds');
const data = datasource.get(image.file);
if (!data) return { notFound: true };
data.pipe(context.res);
return { props: {} };
}
const pass = image.password ? true : false;
delete image.password;
return {
props: {
image,
user,
normal: config.uploader.route,
pass,
},
};
}

View File

@@ -1,33 +1,19 @@
import React from 'react';
import { Provider } from 'react-redux';
import PropTypes from 'prop-types';
import Head from 'next/head';
import Theming from 'components/Theming';
import { useStore } from 'lib/redux/store';
import ZiplineTheming from 'components/Theming';
export default function MyApp({ Component, pageProps }) {
const store = useStore();
React.useEffect(() => {
const jssStyles = document.querySelector('#jss-server-side');
if (jssStyles) jssStyles.parentElement.removeChild(jssStyles);
}, []);
return (
<Provider store={store}>
<Head>
<title>{Component.title}</title>
<meta name='viewport' content='minimum-scale=1, initial-scale=1, width=device-width' />
</Head>
<Theming
Component={Component}
pageProps={pageProps}
/>
<ZiplineTheming Component={Component} pageProps={pageProps} />
</Provider>
);
}
MyApp.propTypes = {
Component: PropTypes.elementType.isRequired,
pageProps: PropTypes.object.isRequired,
};
}

View File

@@ -1,11 +1,11 @@
import React from 'react';
import Document, { Html, Head, Main, NextScript } from 'next/document';
import { createGetInitialProps } from '@mantine/next';
const getInitialProps = createGetInitialProps();
class MyDocument extends Document {
static async getInitialProps(ctx) {
const initialProps = await Document.getInitialProps(ctx);
return { ...initialProps };
}
static getInitialProps = getInitialProps;
render() {
return (

View File

@@ -0,0 +1,33 @@
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

@@ -32,8 +32,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
const valid = await checkPassword(password, user.password);
if (!valid) return res.forbid('Wrong password');
// 604800 seconds is 1 week
res.setCookie('user', user.id, { sameSite: true, maxAge: 604800, path: '/' });
res.setCookie('user', user.id, { sameSite: true, expires: new Date(Date.now() + (6.048e+8 * 2)), path: '/' });
Logger.get('user').info(`User ${user.username} (${user.id}) logged in`);

View File

@@ -2,19 +2,19 @@ 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 } from 'lib/util';
import { writeFile } from 'fs/promises';
import { join } from 'path';
import { createInvisImage, randomChars, hashPassword } from 'lib/util';
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({
storage: multer.memoryStorage(),
});
const uploader = multer();
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,
@@ -22,37 +22,67 @@ 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 size too big');
if (file.size > zconfig.uploader[user.administrator ? 'admin_limit' : 'user_limit']) return res.error(`file[${i}] size too big`);
const ext = file.originalname.split('.').pop();
if (zconfig.uploader.disabled_extentions.includes(ext)) return res.error('disabled extension recieved: ' + ext);
const rand = randomChars(zconfig.uploader.length);
let fileName: string;
let invis;
switch (format) {
case ImageFormat.RANDOM:
fileName = randomChars(zconfig.uploader.length);
break;
case ImageFormat.DATE:
fileName = formatDate(new Date(), 'YYYY-MM-DD_HH:mm:ss');
break;
case ImageFormat.UUID:
fileName = v4();
break;
case ImageFormat.NAME:
fileName = file.originalname.split('.')[0];
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({
data: {
file: `${rand}.${ext}`,
file: `${fileName}.${ext}`,
mimetype: file.mimetype,
userId: user.id,
embed: !!req.headers.embed,
format,
password,
},
});
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}`);
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}`);
}
}
if (user.administrator && zconfig.ratelimit.admin !== 0) {
@@ -93,8 +123,6 @@ 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,10 +1,8 @@
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();
@@ -19,10 +17,11 @@ async function handler(req: NextApiReq, res: NextApiRes) {
},
});
await rm(join(process.cwd(), config.uploader.directory, image.file));
await datasource.delete(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');
@@ -36,6 +35,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
},
});
delete image.password;
return res.json(image);
} else {
let images = await prisma.image.findMany({
@@ -43,6 +43,9 @@ 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,6 +2,7 @@ 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();
@@ -51,22 +52,34 @@ async function handler(req: NextApiReq, res: NextApiRes) {
data: { systemTheme: req.body.systemTheme },
});
if (req.body.customTheme) {
if (user.customTheme) await prisma.user.update({
if (req.body.domains) {
if (!req.body.domains) await prisma.user.update({
where: { id: user.id },
data: {
customTheme: {
update: {
...req.body.customTheme,
},
},
},
}); else await prisma.theme.create({
data: {
userId: user.id,
...req.body.customTheme,
},
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({
@@ -82,9 +95,9 @@ async function handler(req: NextApiReq, res: NextApiRes) {
images: false,
password: false,
systemTheme: true,
customTheme: true,
token: true,
username: true,
domains: true,
},
});

View File

@@ -21,6 +21,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
created_at: true,
file: true,
mimetype: true,
id: true,
},
});

View File

@@ -1,5 +1,6 @@
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();
@@ -16,6 +17,15 @@ 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,
@@ -33,7 +43,6 @@ async function handler(req: NextApiReq, res: NextApiRes) {
token: true,
embedColor: true,
embedTitle: true,
customTheme: true,
systemTheme: true,
},
});

17
src/pages/api/version.ts Normal file
View File

@@ -0,0 +1,17 @@
import { readFile } from 'fs/promises';
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
async function handler(req: NextApiReq, res: NextApiRes) {
const pkg = JSON.parse(await readFile('package.json', 'utf8'));
const re = await fetch('https://raw.githubusercontent.com/diced/zipline/trunk/package.json');
const upstreamPkg = await re.json();
return res.json({
local: pkg.version,
upstream: upstreamPkg.version,
});
}
export default withZipline(handler);

View File

@@ -1,83 +1,64 @@
import React, { useEffect, useState } from 'react';
import { Typography, Box, TextField, Stack, Button, styled } from '@mui/material';
import React, { useEffect } from 'react';
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';
import { useForm } from '@mantine/hooks';
import { TextInput, Button, Center, Title, Box, Badge, Tooltip } from '@mantine/core';
import { useNotifications } from '@mantine/notifications';
import { Cross1Icon, DownloadIcon } from '@modulz/radix-icons';
export default function Login() {
const [open, setOpen] = useState(false);
const [severity, setSeverity] = useState('success');
const [message, setMessage] = useState('');
const [loadingOpen, setLoadingOpen] = useState(false);
const router = useRouter();
const notif = useNotifications();
const formik = useFormik({
const form = useForm({
initialValues: {
username: '',
password: '',
},
onSubmit: async values => {
const username = values.username.trim();
const password = values.password.trim();
if (username === '') return formik.setFieldError('username', 'Username can\'t be nothing');
setLoadingOpen(true);
const res = await useFetch('/api/auth/login', 'POST', {
username, password,
});
if (res.error) {
setOpen(true);
setSeverity('error');
setMessage(res.error);
setLoadingOpen(false);
} else {
setOpen(true);
setSeverity('success');
setMessage('Logged in');
router.push('/dashboard');
}
},
});
const onSubmit = async values => {
const username = values.username.trim();
const password = values.password.trim();
if (username === '') return form.setFieldError('username', 'Username can\'t be nothing');
const res = await useFetch('/api/auth/login', 'POST', {
username, password,
});
if (res.error) {
notif.showNotification({
title: 'Login Failed',
message: res.error,
color: 'red',
icon: <Cross1Icon />,
});
} else {
await router.push(router.query.url as string || '/dashboard');
}
};
useEffect(() => {
(async () => {
const a = await fetch('/api/user');
if (a.ok) router.push('/dashboard');
if (a.ok) await router.push('/dashboard');
})();
}, []);
return (
<>
<Alert open={open} setOpen={setOpen} severity={severity} message={message} />
<Backdrop open={loadingOpen} />
<Box
display='flex'
height='screen'
alignItems='center'
justifyContent='center'
sx={{ height: '24rem' }}
>
<Stack>
<Typography variant='h3' textAlign='center'>
Zipline
</Typography>
<Center sx={{ height: '100vh' }}>
<div>
<Title align='center'>Zipline</Title>
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
<TextInput size='lg' id='username' label='Username' {...form.getInputProps('username')} />
<TextInput size='lg' id='password' label='Password' type='password' {...form.getInputProps('password')} />
<form onSubmit={formik.handleSubmit}>
<TextInput formik={formik} id='username' label='Username' />
<TextInput formik={formik} id='password' label='Password' type='password' />
<Box my={2}>
<Button variant='contained' fullWidth type='submit'>
Login
</Button>
</Box>
<Button size='lg' type='submit' fullWidth mt={12}>Login</Button>
</form>
</Stack>
</Box>
</div>
</Center>
</>
);
}

View File

@@ -1,6 +1,6 @@
import React, { useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import { useRouter } from 'next/router';
import { Backdrop, CircularProgress } from '@mui/material';
import { LoadingOverlay } from '@mantine/core';
export default function Logout() {
const router = useRouter();
@@ -18,12 +18,7 @@ export default function Logout() {
}, []);
return (
<Backdrop
sx={{ color: '#fff', zIndex: t => t.zIndex.drawer + 1 }}
open
>
<CircularProgress color='inherit' />
</Backdrop>
<LoadingOverlay visible={true} />
);
}

23
src/pages/code/[id].tsx Normal file
View File

@@ -0,0 +1,23 @@
import React, { useEffect } from 'react';
import { useRouter } from 'next/router';
import exts from '../../../scripts/exts';
import { Prism } from '@mantine/prism';
export default function Code() {
const [prismRenderCode, setPrismRenderCode] = React.useState('');
const router = useRouter();
const { id } = router.query as { id: string };
useEffect(() => {
(async () => {
const res = await fetch('/r/' + id);
if (id && !res.ok) await router.push('/404');
const data = await res.text();
if (id) setPrismRenderCode(data);
})();
}, [id]);
return id && prismRenderCode ? (
<Prism sx={t => ({ height: '100vh', backgroundColor: t.colors.dark[8] })} withLineNumbers language={exts[id.split('.').pop()]}>{prismRenderCode}</Prism>
) : null;
}

View File

@@ -11,8 +11,6 @@ export default function FilesPage() {
return (
<Layout
user={user}
loading={loading}
noPaper={false}
>
<Files />
</Layout>

View File

@@ -5,14 +5,11 @@ import Dashboard from 'components/pages/Dashboard';
export default function DashboardPage() {
const { user, loading } = useLogin();
if (loading) return null;
return (
<Layout
user={user}
loading={loading}
noPaper={false}
>
<Dashboard />
</Layout>

View File

@@ -11,8 +11,6 @@ export default function ManagePage() {
return (
<Layout
user={user}
loading={loading}
noPaper={false}
>
<Manage />
</Layout>

View File

@@ -0,0 +1,19 @@
import React from 'react';
import useLogin from 'hooks/useLogin';
import Layout from 'components/Layout';
import Stats from 'components/pages/Stats';
export default function StatsPage() {
const { user, loading } = useLogin();
if (loading) return null;
return (
<Layout
user={user}
>
<Stats />
</Layout>
);
}
StatsPage.title = 'Zipline - Stats';

View File

@@ -1,9 +1,7 @@
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();
@@ -13,20 +11,10 @@ export default function UploadPage({ route }) {
return (
<Layout
user={user}
loading={loading}
noPaper={false}
>
<Upload route={route}/>
<Upload/>
</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

@@ -11,8 +11,6 @@ export default function UrlsPage() {
return (
<Layout
user={user}
loading={loading}
noPaper={false}
>
<Urls />
</Layout>

View File

@@ -11,8 +11,6 @@ export default function UsersPage() {
return (
<Layout
user={user}
loading={loading}
noPaper={false}
>
<Users />
</Layout>

View File

@@ -1,12 +0,0 @@
import { useRouter } from 'next/router';
import { useEffect } from 'react';
export default function Index() {
const router = useRouter();
useEffect(() => {
router.push('/dashboard');
}, [router]);
return null;
}

7
src/server/index.ts Normal file
View File

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

Some files were not shown because too many files have changed in this diff Show More