Compare commits

...

19 Commits

Author SHA1 Message Date
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
diced
762d2927f7 Merge branch 'trunk' of github.com:diced/zipline into trunk 2022-02-19 20:17:53 -08:00
diced
d9561f3b12 feat(v3.3.1): bug fixes and new features 2022-02-19 20:17:02 -08:00
dicedtomato
dde24848d4 fix: domains in readme 2022-02-17 16:31:12 -08:00
diced
e786482902 fix: multiple issues & new features 2022-02-12 20:35:36 -08:00
106 changed files with 5200 additions and 4976 deletions

View File

@@ -1,25 +0,0 @@
module.exports = {
'extends': ['next', 'next/core-web-vitals'],
'rules': {
'indent': ['error', 2],
'linebreak-style': ['error', 'unix'],
'quotes': ['error', 'single'],
'semi': ['error', 'always'],
'comma-dangle': ['error', 'always-multiline'],
'jsx-quotes': ['error', 'prefer-single'],
'react/prop-types': 'off',
'react-hooks/rules-of-hooks': 'off',
'react-hooks/exhaustive-deps': 'off',
'react/jsx-uses-react': 'warn',
'react/jsx-uses-vars': 'warn',
'react/no-danger-with-children': 'warn',
'react/no-deprecated': 'warn',
'react/no-direct-mutation-state': 'warn',
'react/no-is-mounted': 'warn',
'react/no-typos': 'error',
'react/react-in-jsx-scope': 'error',
'react/require-render-return': 'error',
'react/style-prop-object': 'warn',
'@next/next/no-img-element': 'off',
},
};

25
.eslintrc.json Normal file
View File

@@ -0,0 +1,25 @@
{
"extends": ["next", "next/core-web-vitals"],
"rules": {
"indent": ["error", 2],
"linebreak-style": ["error", "unix"],
"quotes": ["error", "single"],
"semi": ["error", "always"],
"comma-dangle": ["error", "always-multiline"],
"jsx-quotes": ["error", "prefer-single"],
"react/prop-types": "off",
"react-hooks/rules-of-hooks": "off",
"react-hooks/exhaustive-deps": "off",
"react/jsx-uses-react": "warn",
"react/jsx-uses-vars": "warn",
"react/no-danger-with-children": "warn",
"react/no-deprecated": "warn",
"react/no-direct-mutation-state": "warn",
"react/no-is-mounted": "warn",
"react/no-typos": "error",
"react/react-in-jsx-scope": "error",
"react/require-render-return": "error",
"react/style-prop-object": "warn",
"@next/next/no-img-element": "off"
}
}

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/amd64,linux/arm64
push: true
tags: ghcr.io/diced/zipline/arm: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

4
.gitignore vendored
View File

@@ -36,4 +36,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

View File

@@ -1,33 +1,45 @@
FROM node:16-alpine3.11 AS builder
FROM node:16-alpine AS deps
WORKDIR /build
ENV NEXT_TELEMETRY_DISABLED=1
COPY package.json yarn.lock ./
RUN apk add --no-cache libc6-compat
RUN yarn install --frozen-lockfile
FROM node:16-alpine AS builder
WORKDIR /build
COPY --from=deps /build/node_modules ./node_modules
COPY src ./src
COPY server ./server
COPY scripts ./scripts
COPY prisma ./prisma
COPY package.json yarn.lock esbuild.config.js next.config.js next-env.d.ts zip-env.d.ts tsconfig.json ./
COPY package.json yarn.lock next.config.js next-env.d.ts zip-env.d.ts tsconfig.json ./
RUN yarn install
# create a mock config.toml to spoof next build!
RUN echo -e "[core]\nsecret = '12345678'\ndatabase_url = 'postgres://postgres:postgres@postgres/postgres'\n[uploader]\nroute = '/u'\ndirectory = './uploads'\n[urls]\nroute = '/go'" > config.toml
ENV ZIPLINE_DOCKER_BUILD 1
ENV NEXT_TELEMETRY_DISABLED 1
RUN yarn build
FROM node:16-alpine3.11 AS runner
FROM node:16-alpine AS runner
WORKDIR /zipline
COPY --from=builder /build/node_modules ./node_modules
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/server ./server
COPY --from=builder /build/scripts ./scripts
COPY --from=builder /build/prisma ./prisma
COPY --from=builder /build/.next ./.next
COPY --from=builder /build/tsconfig.json ./tsconfig.json
COPY --from=builder /build/package.json ./package.json
CMD ["node", "server"]
USER zipline
CMD ["node", "dist/server"]

44
Dockerfile-arm Normal file
View File

@@ -0,0 +1,44 @@
FROM node:16 AS deps
WORKDIR /build
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
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 package.json yarn.lock 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,19 @@
- Fast
- Built with Next.js & React
- Token protected uploading
- Easy setup instructions on [docs](https://zipline.diced.cf) (One command install `docker-compose up`)
- Image uploading
- 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://zipline.diced.tech/) (One command install `docker-compose up -d`)
## Installing
[See how to install here](https://zipline.diced.cf/docs/get-started)
[See how to install here](https://zipline.diced.tech/docs/get-started)
## Configuration
[See how to configure here](https://zipline.diced.cf/docs/config/overview)
[See how to configure here](https://zipline.diced.tech/docs/config/overview)
## Theming
[See how to theme here](https://zipline.diced.cf/docs/themes/reference)
[See how to theme here](https://zipline.diced.tech/docs/themes/reference)

View File

@@ -4,7 +4,8 @@
| Version | Supported |
| ------- | ------------------ |
| 3.x.x | :white_check_mark: |
| 3.2.x | :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'
],
],
},
};

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

@@ -0,0 +1,45 @@
version: '3'
services:
postgres:
image: postgres
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DATABASE=postgres
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres']
interval: 10s
timeout: 5s
retries: 5
zipline:
image: ghcr.io/diced/zipline/arm: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:

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

@@ -0,0 +1,47 @@
version: '3'
services:
postgres:
image: postgres
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DATABASE=postgres
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres']
interval: 10s
timeout: 5s
retries: 5
zipline:
build:
context: .
dockerfile: Dockerfile
ports:
- '3000:3000'
restart: unless-stopped
environment:
- SECURE=false
- SECRET=changethis
- HOST=0.0.0.0
- PORT=3000
- 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

@@ -24,14 +24,17 @@ services:
- 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=
- URLS_ROUTE=/go
- URLS_LENGTH=6
volumes:
- '$PWD/uploads:/zipline/uploads'
- '$PWD/public:/zipline/public'

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

View File

@@ -1,54 +1,62 @@
{
"name": "zip3",
"version": "3.3.0",
"version": "3.4.3",
"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",
"migrate:dev": "prisma migrate dev --create-only",
"start": "node 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.7.0",
"@mantine/core": "^3.6.9",
"@mantine/dropzone": "^3.6.9",
"@mantine/hooks": "^3.6.9",
"@mantine/modals": "^3.6.9",
"@mantine/next": "^3.6.9",
"@mantine/notifications": "^3.6.9",
"@mantine/prism": "^3.6.11",
"@modulz/radix-icons": "^4.0.0",
"@prisma/client": "^3.9.2",
"@prisma/migrate": "^3.9.2",
"@prisma/sdk": "^3.9.2",
"@reduxjs/toolkit": "^1.6.0",
"argon2": "^0.28.2",
"aws-sdk": "^2.1085.0",
"colorette": "^1.2.2",
"cookie": "^0.4.1",
"copy-to-clipboard": "^3.3.1",
"fecha": "^4.2.1",
"formik": "^2.2.9",
"multer": "^1.4.2",
"next": "^12.0.7",
"prisma": "^3.7.0",
"react": "17.0.2",
"react-color": "^2.19.3",
"react-dom": "17.0.2",
"react-dropzone": "^11.3.2",
"find-my-way": "^5.2.0",
"multer": "^1.4.4",
"next": "^12.1.0",
"prisma": "^3.9.2",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-redux": "^7.2.4",
"react-table": "^7.7.0",
"redux": "^4.1.0",
"redux-thunk": "^2.3.0",
"uuid": "^8.3.2",
"yup": "^0.32.9"
},
"devDependencies": {
"@commitlint/cli": "^12.1.4",
"@commitlint/config-conventional": "^12.1.4",
"@types/cookie": "^0.4.0",
"@types/multer": "^1.4.6",
"@types/node": "^15.12.2",
"babel-plugin-import": "^1.13.3",
"esbuild": "^0.14.23",
"eslint": "^7.32.0",
"eslint-config-next": "11.0.0",
"husky": "^6.0.0",
"npm-run-all": "^4.1.5",
"release": "^6.3.0",
"ts-node": "^10.0.0",
"typescript": "^4.3.2"
},

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

@@ -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 {
@@ -47,6 +39,7 @@ model Image {
favorite Boolean @default(false)
embed Boolean @default(false)
invisible InvisibleImage?
format ImageFormat @default(RANDOM)
user User @relation(fields: [userId], references: [id])
userId Int
}

View File

@@ -9,8 +9,8 @@ async function main() {
username: 'administrator',
password: await hashPassword('password'),
token: createToken(),
administrator: true
}
administrator: true,
},
});
console.log(`

View File

@@ -1,13 +0,0 @@
const Logger = require('../src/lib/logger');
const prismaRun = require('./prisma-run');
module.exports = async (config) => {
try {
await prismaRun(config.core.database_url, ['migrate', 'deploy']);
await prismaRun(config.core.database_url, ['generate'], true);
} catch (e) {
console.log(e);
Logger.get('db').error('there was an error.. exiting..');
process.exit(1);
}
};

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

@@ -20,7 +20,7 @@ const { PrismaClient } = require('@prisma/client');
return {
file: x,
mimetype: mime,
userId: 1
userId: 1,
};
});
@@ -28,7 +28,7 @@ const { PrismaClient } = require('@prisma/client');
Logger.get('migrator').info('starting migrations...');
await prisma.image.createMany({
data
data,
});
Logger.get('migrator').info('finished migrations! It is recomended to move your old uploads folder (' + process.argv[2] + ') to the current one which is ' + config.uploader.directory);
process.exit();

View File

@@ -74,5 +74,5 @@ module.exports = {
'.zip': 'application/zip',
'.3gp': 'video/3gpp',
'.3g2': 'video/3gpp2',
'.7z': 'application/x-7z-compressed'
'.7z': 'application/x-7z-compressed',
};

View File

@@ -1,26 +0,0 @@
const { spawn } = require('child_process');
const { join } = require('path');
module.exports = (url, args, nostdout = false) => {
return new Promise((res, rej) => {
const proc = spawn(join(process.cwd(), 'node_modules', '.bin', 'prisma'), args, {
env: {
DATABASE_URL: url,
...process.env
},
});
let a = '';
proc.stdout.on('data', d => {
if (!nostdout) console.log(d.toString());
a += d.toString();
});
proc.stderr.on('data', d => {
if (!nostdout) console.log(d.toString());
rej(d.toString());
});
proc.stdout.on('end', () => res(a));
proc.stdout.on('close', () => res(a));
});
};

View File

@@ -1,260 +0,0 @@
const next = require('next');
const { createServer } = require('http');
const { stat, mkdir, readdir } = require('fs/promises');
const { execSync } = require('child_process');
const { extname, join } = require('path');
const { PrismaClient } = require('@prisma/client');
const validateConfig = require('./validateConfig');
const Logger = require('../src/lib/logger');
const getFile = require('./static');
const prismaRun = require('../scripts/prisma-run');
const readConfig = require('../src/lib/readConfig');
const mimes = require('../scripts/mimes');
const deployDb = require('../scripts/deploy-db');
const { version } = require('../package.json');
Logger.get('server').info(`starting zipline@${version} server`);
const dev = process.env.NODE_ENV === 'development';
function log(url, status) {
if (url.startsWith('/_next') || url.startsWith('/__nextjs')) return;
return Logger.get('url').info(url);
}
function shouldUseYarn() {
try {
execSync('yarnpkg --version', { stdio: 'ignore' });
return true;
} catch (e) {
return false;
}
}
(async () => {
try {
const a = readConfig();
const config = await validateConfig(a);
const data = await prismaRun(config.core.database_url, ['migrate', 'status'], true);
if (data.match(/Following migrations? have not yet been applied/)) {
Logger.get('database').info('some migrations are not applied, applying them now...');
await deployDb(config);
Logger.get('database').info('finished applying migrations');
} else Logger.get('database').info('migrations up to date');
process.env.DATABASE_URL = config.core.database_url;
await mkdir(config.uploader.directory, { recursive: true });
const app = next({
dir: '.',
dev,
quiet: dev,
}, config.core.port, config.core.host);
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) => {
Logger.get('server').error(e);
process.exit(1);
});
srv.on('listening', () => {
Logger.get('server').info(`listening on ${config.core.host}:${config.core.port}`);
if (process.platform === 'linux' && dev) execSync(`xdg-open ${config.core.secure ? 'https' : 'http'}://${config.core.host === '0.0.0.0' ? 'localhost' : 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) Logger.get('server').info('stats updated');
}, config.core.stats_interval * 1000);
} catch (e) {
if (e.message && e.message.startsWith('Could not find a production')) {
Logger.get('web').error(`there is no production build - run \`${shouldUseYarn() ? 'yarn build' : 'npm build'}\``);
} else if (e.code && e.code === 'ENOENT') {
if (e.path === './.next') Logger.get('web').error(`there is no production build - run \`${shouldUseYarn() ? 'yarn build' : 'npm build'}\``);
} else {
Logger.get('server').error(e);
process.exit(1);
}
}
})();
async function sizeOfDir(directory) {
const files = await readdir(directory);
let size = 0;
for (let i = 0, L = files.length; i !== L; ++i) {
const sta = await stat(join(directory, files[i]));
size += sta.size;
}
return size;
}
function bytesToRead(bytes) {
const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB'];
let num = 0;
while (bytes > 1024) {
bytes /= 1024;
++num;
}
return `${bytes.toFixed(1)} ${units[num]}`;
}
async function getStats(prisma, config) {
const size = await sizeOfDir(join(process.cwd(), config.uploader.directory));
const byUser = await prisma.image.groupBy({
by: ['userId'],
_count: {
_all: true,
},
});
const count_users = await prisma.user.count();
const count_by_user = [];
for (let i = 0, L = byUser.length; i !== L; ++i) {
const user = await prisma.user.findFirst({
where: {
id: byUser[i].userId,
},
});
count_by_user.push({
username: user.username,
count: byUser[i]._count._all,
});
}
const count = await prisma.image.count();
const viewsCount = await prisma.image.groupBy({
by: ['views'],
_sum: {
views: true,
},
});
const typesCount = await prisma.image.groupBy({
by: ['mimetype'],
_count: {
mimetype: true,
},
});
const types_count = [];
for (let i = 0, L = typesCount.length; i !== L; ++i) types_count.push({ mimetype: typesCount[i].mimetype, count: typesCount[i]._count.mimetype });
return {
size: bytesToRead(size),
size_num: size,
count,
count_by_user: count_by_user.sort((a,b) => b.count-a.count),
count_users,
views_count: (viewsCount[0]?._sum?.views ?? 0),
types_count: types_count.sort((a,b) => b.count-a.count),
};
}

View File

@@ -1,11 +0,0 @@
const { readFile } = require('fs/promises');
const { join } = require('path');
module.exports = async (dir, file) => {
try {
const data = await readFile(join(process.cwd(), dir, file));
return data;
} catch (e) {
return null;
}
};

View File

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

View File

@@ -1,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,239 @@ 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',
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',
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,71 +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,
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,27 +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 Link from 'components/Link';
import Card from 'components/Card';
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';
@@ -39,83 +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 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(() => {
@@ -124,108 +86,74 @@ export default function Dashboard() {
return (
<>
<Typography variant='h4'>Welcome back {user?.username}</Typography>
<Typography color='GrayText' pb={2}>You have <b>{images.length ? images.length : '...'}</b> files</Typography>
<Typography variant='h4'>Recent Files</Typography>
<Grid container spacing={4} py={2}>
<Title>Welcome back {user?.username}</Title>
<Text color='gray' sx={{ paddingBottom: 4 }}>You have <b>{images.length ? images.length : '...'}</b> files</Text>
<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' : 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='outlined'>
<Button onClick={() => handleDelete(row)} color='error' size='small'>Delete</Button>
</ButtonGroup>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
<TablePagination
rowsPerPageOptions={[10, 25, 100]}
component='div'
count={images.length}
rowsPerPage={rowsPerPage}
page={page}
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage} />
</Card>
<Card name='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' },
@@ -233,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,73 +1,23 @@
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>
visit <Link href='https://zipline.diced.me/docs/variables'>the docs</Link> for more variables
<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
</>
}>
{children}
@@ -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 = {
@@ -97,7 +44,7 @@ export default function Manage() {
...(withEmbed && {Embed: 'true'}),
...(withZws && {ZWS: 'true'}),
},
URL: '$json:url$',
URL: '$json:files[0]$',
Body: 'MultipartFormData',
FileFormName: 'file',
};
@@ -111,150 +58,112 @@ 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,
});
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.me/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,65 @@
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,
});
const res = await fetch('/api/upload', {
method: 'POST',
headers: {
@@ -43,63 +69,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,152 +1,92 @@
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 } from '@mantine/core';
import { Cross1Icon, PlusIcon, TrashIcon } from '@modulz/radix-icons';
import { useNotifications } from '@mantine/notifications';
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 [users, setUsers] = useState([]);
const [open, setOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [severity, setSeverity] = useState('success');
const [message, setMessage] = useState('Saved');
const [loading, setLoading] = useState(true);
const updateUsers = async () => {
setLoading(true);
const us = await useFetch('/api/users');
if (!us.error) {
setUsers(us);
} else {
router.push('/dashboard');
};
setLoading(false);
};
const handleDelete = async (user) => {
@@ -154,15 +94,22 @@ export default function Users() {
id: user.id,
});
if (res.error) {
setMessage(`Could not delete ${user.username}`);
setSeverity('error');
setOpen(true);
notif.showNotification({
title: 'Failed to delete user',
message: res.error,
color: 'red',
icon: <Cross1Icon />,
});
} else {
setMessage(`Deleted user ${res.username}`);
setSeverity('success');
setOpen(true);
updateUsers();
notif.showNotification({
title: 'User deleted',
message: '',
color: 'green',
icon: <TrashIcon />,
});
}
updateUsers();
};
useEffect(() => {
@@ -171,17 +118,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, i) => (
<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={() => handleDelete(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,36 @@
import { createReadStream, ReadStream } from 'fs';
import { readdir, 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 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;
}
}

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

@@ -0,0 +1,59 @@
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 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,9 @@
import { Readable } from 'stream';
export abstract class Datasource {
public name: string;
public abstract save(file: string, data: Buffer): 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,40 +0,0 @@
const { format } = require('fecha');
const { blueBright, red, cyan } = require('colorette');
class Logger {
static get(clas) {
if (typeof clas !== 'function') if (typeof clas !== 'string') throw new Error('not string/function');
const name = clas.name ?? clas;
return new Logger(name);
}
constructor (name) {
this.name = name;
}
info(message) {
console.log(this.formatMessage('INFO', this.name, message));
}
error(error) {
console.log(this.formatMessage('ERROR', this.name, error.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');
}
}
}
module.exports = Logger;

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,5 +1,7 @@
import { PrismaClient } from '@prisma/client';
if (!global.prisma) global.prisma = new PrismaClient();
if (!global.prisma) {
if (!process.env.ZIPLINE_DOCKER_BUILD) global.prisma = new PrismaClient();
};
export default global.prisma;

View File

@@ -1,6 +1,8 @@
const { existsSync, readFileSync } = require('fs');
const { join } = require('path');
const Logger = require('./logger');
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 });
@@ -13,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 = []),
@@ -27,20 +34,22 @@ const envValues = [
e('RATELIMIT_ADMIN', 'number', (c, v) => c.ratelimit.user = v ?? 0),
];
module.exports = () => {
export default function readConfig(): Config {
if (!existsSync(join(process.cwd(), 'config.toml'))) {
Logger.get('config').info('reading environment');
if (!process.env.ZIPLINE_DOCKER_BUILD) Logger.get('config').info('reading environment');
return tryReadEnv();
} else {
if (process.env.ZIPLINE_DOCKER_BUILD) return;
Logger.get('config').info('reading config file');
const str = readFileSync(join(process.cwd(), 'config.toml'), 'utf8');
const parsed = require('@iarna/toml/parse-string')(str);
const parsed = parse(str);
return parsed;
}
};
function tryReadEnv() {
function tryReadEnv(): Config {
const config = {
core: {
secure: undefined,
@@ -51,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,
@@ -71,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

@@ -0,0 +1,36 @@
import createTheme from '.';
export default createTheme({
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

@@ -0,0 +1,37 @@
import createTheme from '.';
export default createTheme({
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,13 +1,13 @@
import React, { useEffect } from 'react';
import Head from 'next/head';
import { GetServerSideProps } from 'next';
import { Box } from '@mui/material';
import { Box } from '@mantine/core';
import config from 'lib/config';
import prisma from 'lib/prisma';
import getFile from '../../server/static';
import { parse } from 'lib/clientUtils';
import * as exts from '../../scripts/exts';
export default function EmbeddedImage({ image, user, normal }) {
export default function EmbeddedImage({ image, user }) {
const dataURL = (route: string) => `${route}/${image.file}`;
// reapply date from workaround
@@ -22,8 +22,7 @@ export default function EmbeddedImage({ image, user, normal }) {
if (original.width > innerWidth) imageEl.width = Math.floor(original.width * Math.min((innerHeight / original.height), (innerWidth / original.width)));
else imageEl.width = original.width;
};
if (typeof window !== 'undefined') window.onresize = () => updateImage();
useEffect(() => updateImage(), []);
return (
@@ -41,10 +40,12 @@ export default function EmbeddedImage({ image, user, normal }) {
<title>{image.file}</title>
</Head>
<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>
@@ -114,20 +115,29 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
//@ts-ignore workaround because next wont allow date
image.created_at = image.created_at.toString();
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 data = await getFile(config.uploader.directory, id);
const { default: datasource } = await import('lib/ds');
const data = datasource.get(image.file);
if (!data) return { notFound: true };
context.res.end(data);
data.pipe(context.res);
return { props: {} };
};
}
return {
props: {
image,
user,
normal: config.uploader.route,
},
};
}

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

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

@@ -3,13 +3,13 @@ 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 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');
@@ -22,37 +22,62 @@ 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 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,
},
});
if (req.headers.zws) invis = await createInvisImage(zconfig.uploader.length, image.id);
await writeFile(join(process.cwd(), zconfig.uploader.directory, image.file), file.buffer);
await datasource.save(image.file, file.buffer);
Logger.get('image').info(`User ${user.username} (${user.id}) uploaded an image ${image.file} (${image.id})`);
files.push(`${zconfig.core.secure ? 'https' : 'http'}://${req.headers.host}${zconfig.uploader.route}/${invis ? invis.invis : image.file}`);
if (user.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) {
@@ -92,9 +117,7 @@ function run(middleware: any) {
});
}
export default async function handlers(req, res) {
await run(uploader.array('file'))(req, res);
export default async function handlers(req, res) {
return withZipline(handler)(req, res);
};

View File

@@ -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,8 +1,5 @@
import { join } from 'path';
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
import prisma from 'lib/prisma';
import { bytesToRead, sizeOfDir } from 'lib/util';
import { tryGetPreviewData } from 'next/dist/server/api-utils';
async function handler(req: NextApiReq, res: NextApiRes) {
const user = await req.user();
@@ -36,7 +33,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,82 +1,96 @@
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 [versions, setVersions] = React.useState<{ upstream: string, local: string }>(null);
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');
else {
const v = await useFetch('/api/version');
setVersions(v);
if (v.local !== v.upstream) {
notif.showNotification({
title: 'Update available',
message: `A new version of Zipline is available. You are running ${v.local} and the latest version is ${v.upstream}.`,
icon: <DownloadIcon />,
});
}
}
})();
}, []);
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>
</div>
</Center>
<Box
sx={{
zIndex: 99,
position: 'fixed',
bottom: '10px',
right: '20px',
}}
>
{versions && (
<Tooltip
wrapLines
width={220}
transition='rotate-left'
transitionDuration={200}
label={versions.local !== versions.upstream ? 'Looks like you are running an outdated version of Zipline. Please update to the latest version.' : 'You are running the latest version of Zipline.'}
>
<Badge radius='md' size='lg' variant='dot' color={versions.local !== versions.upstream ? 'red' : 'primary'}>{versions.local}</Badge>
</Tooltip>
)}
</Box>
</>
);

View File

@@ -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 (context) => {
return {
props: {
route: 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;
}

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