Compare commits

...

119 Commits

Author SHA1 Message Date
Jonathan
73eff05180 feat: use yarn v3 (#136)
* feat: use yarn v3

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

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

* fix: eslint

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

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

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

* Update index.ts
2021-09-08 20:56:10 -07:00
diced
636de18642 feat(pages): add recent images to dashboard 2021-09-06 15:58:01 -07:00
diced
ee48456291 fix(api): change cookie max-age from 10m seconds to 1 week 2021-09-06 15:32:39 -07:00
diced
a06d5ffaed fix(api): sort images count and types count 2021-09-06 15:22:36 -07:00
diced
606821a2c0 fix(components): add skeleton placeholders to dashboard 2021-09-06 14:54:25 -07:00
diced
5c980c21e5 feat(api): allow bulk uploading (#97) 2021-09-05 11:23:52 -07:00
diced
771cc380df fix(api): maybe fix default user not being created 2021-09-05 09:14:19 -07:00
diced
38217870fe fix(api): maybe fix default user not being created 2021-09-05 09:13:13 -07:00
dicedtomato
5b82c96a43 Update README.md 2021-09-04 15:01:08 -07:00
diced
6f5f9869ad refactor(assets): update assets to v3 2021-09-04 14:42:38 -07:00
diced
b29bfeb8b1 fix(api): new way to handle non-embedded images to overwrite nextjs & supports discord now 2021-09-03 17:20:43 -07:00
diced
cb40559e49 fix(server): some changes 2021-09-03 17:03:47 -07:00
diced
90c72f7ffe fix(api): small images height strech fix 2021-09-03 16:50:14 -07:00
diced
002bd2e6f7 refactor(api): redirect non-embedded images to /r 2021-09-03 16:40:50 -07:00
diced
7b44f17a64 refactor(api): /raw -> /r 2021-09-03 16:26:04 -07:00
diced
b5c83f92e3 fix(api): fix images not showing 2021-09-02 21:46:10 -07:00
diced
51b4d64a93 fix(build): fix some docker stuff 2021-09-02 21:27:12 -07:00
diced
62c9e0a22f feat(api): add support for invisible images 2021-09-02 21:00:16 -07:00
diced
3daac34d3e feat(api): add support for invisible images 2021-09-02 20:59:30 -07:00
dependabot[bot]
d80d5d1632 build(deps): bump next from 11.1.0 to 11.1.1 (#95)
Bumps [next](https://github.com/vercel/next.js) from 11.1.0 to 11.1.1.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v11.1.0...v11.1.1)

---
updated-dependencies:
- dependency-name: next
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-09-02 06:51:13 -07:00
diced
912f716362 feat(api): add support for zws images 2021-08-30 20:56:34 -07:00
diced
16ecdf41af feat(components): added sharex config generator (#93) 2021-08-30 13:58:06 -07:00
Nguyen Thanh Quang
f0bb6b08fa feat(components): added sharex config generator (#93) 2021-08-30 07:59:08 -07:00
Nguyen Thanh Quang
efb4e2ce9a feat(sharex): added sharex config generator 2021-08-30 20:19:12 +07:00
diced
03238d10bf fix(config): notify which vars are missing 2021-08-28 21:41:04 -07:00
diced
e71590b9fb fix(config): new opts: admin_limit, user_limit, disabled_extensions (#68) 2021-08-28 21:02:04 -07:00
diced
4728f1cc46 fix(api): accidently sending images as a part of the user object 2021-08-28 19:37:38 -07:00
diced
794778dee2 feat(config): database section removed 2021-08-28 11:32:09 -07:00
Nguyen Thanh Quang
b5e882f07e feat(themes): added dracula theme (#92)
* added dracula theme

* change border color from white

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2021-08-27 15:33:32 -07:00
dicedtomato
e7c58a4847 licence 2021-08-27 15:24:37 -07:00
dicedtomato
bdb44db25e security policy 2021-08-27 15:23:28 -07:00
diced
e8b82ffe62 feat(api): image favoriting (#67) 2021-08-27 13:48:22 -07:00
diced
53c53c009e fix(components): white text on links when light theme 2021-08-26 21:41:30 -07:00
Nguyen Thanh Quang
7e8cda4605 feat(components): ayu_mirage, ayu_light, nord, polar themes (#90) 2021-08-26 19:44:04 -07:00
diced
dfa0419a0a fix(api): fix many bugs 2021-08-26 15:18:14 -07:00
diced
aeb2638d1e feat: v3.2.0 - custom themes & curated themes 2021-08-26 12:33:18 -07:00
diced
c5cef56e2a feat: v3.2.0 - custom themes & curated themes 2021-08-26 12:32:51 -07:00
Nguyen Thanh Quang
b9c9d98252 removed redundant code (#86) 2021-08-25 19:41:49 -07:00
Nguyen Thanh Quang
30083b6705 fixed the menu doesn't close when clicking outside (#87) 2021-08-25 19:41:32 -07:00
diced
47db6cf1bd fix(build): prisma not being copied over 2021-08-25 15:16:39 -07:00
diced
f929f6ad7d fix(build): schema.shared.prisma -> schema.prisma 2021-08-25 10:10:23 -07:00
diced
7e16e0f30c fix(build): add migrations to gitignore 2021-08-25 10:05:35 -07:00
diced
b2be4e51cc fix(build): remove schema from gitignore 2021-08-25 10:01:57 -07:00
diced
2c871be8c5 feat(prisma): remove multi-db support in favor of psql \w easier setup 2021-08-25 09:58:48 -07:00
diced
8c03e74979 fix(build): fix 2021-08-24 10:27:20 -07:00
diced
d5c0355fd4 fix(build): maybe fix 2021-08-24 10:20:47 -07:00
diced
386cad0474 feat(components): copy url to clipboard when uploading image 2021-08-24 10:17:04 -07:00
Nguyen Thanh Quang
474024ea55 Fixed error/url not showing properly (#85)
* Fixed error not showing.

* Update Upload.tsx
2021-08-23 08:39:31 -07:00
diced
dacf13e46d fix(docker): docker image 2021-08-21 14:57:54 -07:00
dependabot[bot]
f37b4bb2ee build(deps): bump next from 11.0.0 to 11.1.0 (#83)
* build(deps): bump next from 11.0.0 to 11.1.0

Bumps [next](https://github.com/vercel/next.js) from 11.0.0 to 11.1.0.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v11.0.0...v11.1.0)

---
updated-dependencies:
- dependency-name: next
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix(deps): nextjs 11 -> 11.1

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: diced <pranaco2@gmail.com>
2021-08-13 12:57:05 -07:00
diced
034398e9fb fix(api): no admin user creat 2021-08-07 20:38:45 -07:00
diced
2c605cb176 fix(api): no admin user creat 2021-08-07 20:38:15 -07:00
dicedtomato
9a6673fe6d Update README.md 2021-07-22 16:23:42 -07:00
diced
6733c9adba fix(pages): fix embed route not loading images 2021-06-23 21:44:01 -07:00
diced
9d3443ceac fix(server): add mimetype for non db entries 2021-06-23 21:34:38 -07:00
diced
d628424b35 fix(pages): fix average size of uploads 2021-06-23 14:24:42 -07:00
diced
dab444040e fix(api): fix broken uploads 2021-06-23 14:21:34 -07:00
diced
ecef854d23 fix(pages): stats being null 2021-06-23 12:18:10 -07:00
diced
166087e33c fix: make migrations use readConfig 2021-06-23 11:47:05 -07:00
diced
e9e30c4c46 fix: change compose to use dockerhub image 2021-06-23 11:40:52 -07:00
150 changed files with 13035 additions and 8274 deletions

View File

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

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
.next/
uploads/
.git/
.yarn/*
!.yarn/releases
!.yarn/plugins

View File

@@ -1,24 +0,0 @@
module.exports = {
'extends': ['next', 'next/core-web-vitals'],
'rules': {
'indent': ['error', 2],
'linebreak-style': ['error', 'unix'],
'quotes': ['error', 'single'],
'semi': ['error', 'always'],
'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"
}
}

View File

@@ -23,12 +23,11 @@ jobs:
key: ${{ runner.os }}-node${{ matrix.node }}-${{ hashFiles('**/yarn.lock') }}
- name: Create mock config
run: echo -e "[uploader]\nroute = '/u'\nembed_route = '/a'\nlength = 6\ndirectory = './uploads'" > config.toml
run: echo -e "[core]\nsecret = '12345678'\ndatabase_url = 'postgres://postgres:postgres@postgres/postgres'\n[uploader]\nroute = '/u'\ndirectory = './uploads'\n[urls]\nroute = '/go'" > config.toml
- name: Install dependencies
if: steps.cache-restore.outputs.cache-hit != 'true'
run: yarn install
- name: Build
run: yarn build
run: yarn build

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

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

View File

@@ -3,6 +3,11 @@ name: 'CD: Push Docker Images'
on:
push:
branches: [ trunk ]
paths:
- 'src/**'
- 'server/**'
- 'prisma/**'
- '.github/**'
workflow_dispatch:
jobs:
@@ -13,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

11
.gitignore vendored
View File

@@ -5,6 +5,11 @@
/.pnp
.pnp.js
# yarn
.yarn/*
!.yarn/releases
!.yarn/plugins
# testing
/coverage
@@ -18,6 +23,7 @@
# misc
.DS_Store
*.pem
.idea
# debug
npm-debug.log*
@@ -36,6 +42,5 @@ yarn-error.log*
# zipline
config.toml
uploads/
prisma/schema.prisma
data.db*
migrations/
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

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

File diff suppressed because one or more lines are too long

3
.yarnrc.yml Normal file
View File

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

View File

@@ -1,30 +1,47 @@
FROM node:16-alpine3.11 AS builder
FROM node:16-alpine AS deps
WORKDIR /build
COPY .yarn .yarn
COPY package.json yarn.lock .yarnrc.yml ./
RUN apk add --no-cache libc6-compat
RUN yarn install --immutable
FROM node:16-alpine AS builder
WORKDIR /build
COPY --from=deps /build/node_modules ./node_modules
COPY src ./src
COPY server ./server
COPY scripts ./scripts
COPY prisma/schema.shared.prisma ./prisma/schema.shared.prisma
COPY prisma ./prisma
COPY .yarn .yarn
COPY package.json yarn.lock .yarnrc.yml esbuild.config.js next.config.js next-env.d.ts zip-env.d.ts tsconfig.json ./
COPY package.json yarn.lock next.config.js next-env.d.ts tsconfig.json ./
RUN yarn install
# create a mock config.toml to spoof next build!
RUN echo -e "[uploader]\nroute = '/u'\nembed_route = '/a'\nlength = 6\ndirectory = './uploads'" > config.toml
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/.next ./.next
COPY --from=builder /build/prisma ./prisma
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"]

46
Dockerfile-arm Normal file
View File

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

View File

@@ -1,3 +0,0 @@
prisma/migrations
node_modules
.next

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
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
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,26 +1,35 @@
<p align="center"><img src="https://raw.githubusercontent.com/diced/zipline/trunk/public/zipline_small.png"/></p>
![Version](https://img.shields.io/github/package-json/v/diced/zipline)
![LICENCE](https://img.shields.io/github/license/diced/zipline)
[![Discord](https://img.shields.io/discord/729771078196527176)](https://discord.gg/AtTSecwqeV)
![Stars](https://img.shields.io/github/stars/diced/zipline)
![GitHub repo size](https://img.shields.io/github/repo-size/diced/zipline)
![GitHub last commit (branch)](https://img.shields.io/github/last-commit/diced/zipline/trunk)
<br>
# Zipline
Fast & lightweight file uploading.
# Features
<div align="center">
<img src="https://raw.githubusercontent.com/diced/zipline/trunk/public/zipline_small.png"/>
Zipline is a ShareX/file upload server that is easy to use, packed with features and can be setup in one command!
![Build](https://img.shields.io/github/workflow/status/diced/zipline/CD:%20Push%20Docker%20Images?logo=github&style=flat-square)
![Stars](https://img.shields.io/github/stars/diced/zipline?logo=github&style=flat-square)
![Version](https://img.shields.io/github/package-json/v/diced/zipline?logo=git&logoColor=white&style=flat-square)
![GitHub last commit (branch)](https://img.shields.io/github/last-commit/diced/zipline/trunk?logo=git&logoColor=white&style=flat-square)
[![Discord](https://img.shields.io/discord/729771078196527176?color=%23777ed3&label=discord&logo=discord&logoColor=white&style=flat-square)](https://discord.gg/EAhCRfGxCF)
</div>
## Features
- Configurable
- Fast
- Built with Next.js & React
- Support for **multible database types**
- Token protected uploading
- Easy setup instructions on [docs](https://zipline.diced.me)
- Image uploading
- Password Protected Uploads
- URL shortening
- Text uploading
- URL Formats (uuid, dates, random alphanumeric, original name, zws)
- Discord embeds (OG metadata)
- Gallery viewer, and multiple file format support
- Easy setup instructions on [docs](https://zipl.vercel.app/) (One command install `docker-compose up -d`)
# Installing
## Installing
[See how to install here](https://zipl.vercel.app/docs/get-started)
[See how to install here](https://zipline.diced.me/docs/getting-started)
## Configuration
[See how to configure here](https://zipl.vercel.app/docs/config/overview)
## Theming
[See how to theme here](https://zipl.vercel.app/docs/themes/reference)

12
SECURITY.md Normal file
View File

@@ -0,0 +1,12 @@
# Security Policy
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| 3.4.4 | :white_check_mark: |
| < 3 | :x: |
| < 2 | :x: |
## Reporting a Vulnerability
Report a Vulnerability by issuing a bug report, with exact details with how the vulnerability happened, what "exploits" can happen, and possible fixes (optional). Vulnerability reports are treated with high priority and will be resolved most of the time quickly.

View File

@@ -1,53 +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',
'api',
'hooks',
'components',
'middleware',
'redux',
'themes',
'lib'
],
],
},
};

View File

@@ -1,24 +1,19 @@
[core]
secure = true
secret = 'some secret'
secret = 'changethis'
host = '0.0.0.0'
port = 3000
database_url = 'postgres://postgres:postgres@postgres/postgres'
[database]
# postgresql
type = 'psql'
url = 'postgres://postgres:postgres@postgres/postgres'
# mysql
# type = 'mysql'
# url = 'mysql://postgres:postgres@mysql/mysql'
# sqlite
# type = 'sqlite'
# url = 'file:sqlite.db'
[urls]
route = '/go'
length = 6
[uploader]
route = '/u'
embed_route = '/a'
length = 6
directory = './uploads'
directory = './uploads'
user_limit = 104900000 # 100mb
admin_limit = 104900000 # 100mb
disabled_extentions = ['jpg']

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

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

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

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

View File

@@ -2,11 +2,12 @@ version: '3'
services:
postgres:
image: postgres
environment:
restart: always
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DATABASE=postgres
volumes:
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres']
@@ -15,26 +16,28 @@ services:
retries: 5
zipline:
build:
context: .
dockerfile: Dockerfile
image: ghcr.io/diced/zipline/zipline:trunk
ports:
- '3000:3000'
restart: unless-stopped
environment:
restart: always
environment:
- SECURE=false
- SECRET=changethis
- HOST=0.0.0.0
- PORT=3000
- DATABASE_TYPE=psql
- 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/prisma:/zipline/prisma'
- '$PWD/public:/zipline/public'
depends_on:
- 'postgres'

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

15
next-env.d.ts vendored
View File

@@ -1,14 +1,5 @@
/// <reference types="next" />
/// <reference types="next/types/global" />
/// <reference types="next/image-types/global" />
import type { PrismaClient } from '@prisma/client';
import type { Config } from './src/lib/types';
declare global {
namespace NodeJS {
interface Global {
prisma: PrismaClient;
config: Config
}
}
}
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View File

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

View File

@@ -1,64 +1,69 @@
{
"name": "zip3",
"version": "3.0.0",
"name": "zipline",
"version": "3.4.4",
"license": "MIT",
"scripts": {
"prepare": "husky install",
"dev": "NODE_ENV=development node server",
"build": "npm-run-all build:schema build:next",
"dev": "node esbuild.config.js && REACT_EDITOR=code-insiders NODE_ENV=development node dist/server",
"build": "npm-run-all build:server build:schema build:next",
"build:server": "node esbuild.config.js",
"build:next": "next build",
"build:schema": "prisma generate --schema=prisma/schema.shared.prisma",
"start": "node server",
"build:schema": "prisma generate --schema=prisma/schema.prisma",
"migrate:dev": "prisma migrate dev --create-only",
"start": "node dist/server",
"lint": "next lint",
"ts-node": "./node_modules/.bin/ts-node --compiler-options \"{\\\"module\\\":\\\"commonjs\\\"}\" --transpile-only",
"create-all-migrations": "node scripts/create-migrations",
"semantic-release": "semantic-release"
"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",
"@material-ui/core": "^5.0.0-alpha.37",
"@material-ui/data-grid": "^4.0.0-alpha.32",
"@material-ui/icons": "^5.0.0-alpha.37",
"@material-ui/styles": "^5.0.0-alpha.35",
"@prisma/client": "2.25.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.14.0",
"@prisma/migrate": "^3.14.0",
"@prisma/sdk": "^3.14.0",
"@reduxjs/toolkit": "^1.6.0",
"argon2": "^0.28.2",
"busboy": "^0.3.1",
"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",
"next": "11.0.0",
"prisma": "2.25.0",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-dropzone": "^11.3.2",
"fflate": "^0.7.3",
"find-my-way": "^5.2.0",
"multer": "^1.4.4",
"next": "^12.1.0",
"prisma": "^3.14.0",
"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",
"rimraf": "^3.0.2",
"uuid": "^8.3.2",
"yup": "^0.32.9"
},
"devDependencies": {
"@commitlint/cli": "^12.1.4",
"@commitlint/config-conventional": "^12.1.4",
"@types/bcryptjs": "^2.4.2",
"@types/busboy": "^0.2.3",
"@types/cookie": "^0.4.0",
"@types/multer": "^1.4.6",
"@types/node": "^15.12.2",
"babel-plugin-transform-imports": "^2.0.0",
"eslint": "7.28.0",
"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"
},
"repository": {
"type": "git",
"url": "https://github.com/diced/workflow-testing.git"
}
"url": "https://github.com/diced/zipline.git"
},
"packageManager": "yarn@3.2.1"
}

View File

@@ -40,12 +40,24 @@ CREATE TABLE "Url" (
PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "InvisibleUrl" (
"id" INTEGER NOT NULL,
"invis" TEXT NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "InvisibleImage.invis_unique" ON "InvisibleImage"("invis");
-- CreateIndex
CREATE UNIQUE INDEX "InvisibleImage_id_unique" ON "InvisibleImage"("id");
-- CreateIndex
CREATE UNIQUE INDEX "InvisibleUrl.invis_unique" ON "InvisibleUrl"("invis");
-- CreateIndex
CREATE UNIQUE INDEX "InvisibleUrl_id_unique" ON "InvisibleUrl"("id");
-- AddForeignKey
ALTER TABLE "Image" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -54,3 +66,6 @@ ALTER TABLE "InvisibleImage" ADD FOREIGN KEY ("id") REFERENCES "Image"("id") ON
-- AddForeignKey
ALTER TABLE "Url" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "InvisibleUrl" ADD FOREIGN KEY ("id") REFERENCES "Url"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,25 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "systemTheme" TEXT NOT NULL DEFAULT E'dark_blue';
-- CreateTable
CREATE TABLE "Theme" (
"id" SERIAL NOT NULL,
"type" TEXT NOT NULL,
"primary" TEXT NOT NULL,
"secondary" TEXT NOT NULL,
"error" TEXT NOT NULL,
"warning" TEXT NOT NULL,
"info" TEXT NOT NULL,
"border" TEXT NOT NULL,
"mainBackground" TEXT NOT NULL,
"paperBackground" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Theme_userId_unique" ON "Theme"("userId");
-- AddForeignKey
ALTER TABLE "Theme" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Image" ADD COLUMN "favorite" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,25 @@
/*
Warnings:
- A unique constraint covering the columns `[imageId]` on the table `InvisibleImage` will be added. If there are existing duplicate values, this will fail.
- Added the required column `imageId` to the `InvisibleImage` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE "InvisibleImage" DROP CONSTRAINT "InvisibleImage_id_fkey";
-- DropIndex
DROP INDEX "InvisibleImage_id_unique";
-- AlterTable
CREATE SEQUENCE "invisibleimage_id_seq";
ALTER TABLE "InvisibleImage" ADD COLUMN "imageId" INTEGER NOT NULL,
ALTER COLUMN "id" SET DEFAULT nextval('invisibleimage_id_seq'),
ADD PRIMARY KEY ("id");
ALTER SEQUENCE "invisibleimage_id_seq" OWNED BY "InvisibleImage"."id";
-- CreateIndex
CREATE UNIQUE INDEX "InvisibleImage_imageId_unique" ON "InvisibleImage"("imageId");
-- AddForeignKey
ALTER TABLE "InvisibleImage" ADD FOREIGN KEY ("imageId") REFERENCES "Image"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Image" ADD COLUMN "embed" BOOLEAN NOT NULL DEFAULT false;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "mysql"

View File

@@ -1,53 +0,0 @@
-- CreateTable
CREATE TABLE `User` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`username` VARCHAR(191) NOT NULL,
`password` VARCHAR(191) NOT NULL,
`token` VARCHAR(191) NOT NULL,
`administrator` BOOLEAN NOT NULL DEFAULT false,
`embedTitle` VARCHAR(191),
`embedColor` VARCHAR(191) NOT NULL DEFAULT '#2f3136',
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Image` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`file` VARCHAR(191) NOT NULL,
`mimetype` VARCHAR(191) NOT NULL DEFAULT 'image/png',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`views` INTEGER NOT NULL DEFAULT 0,
`userId` INTEGER NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `InvisibleImage` (
`id` INTEGER NOT NULL,
`invis` VARCHAR(191) NOT NULL,
UNIQUE INDEX `InvisibleImage.invis_unique`(`invis`),
UNIQUE INDEX `InvisibleImage_id_unique`(`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Url` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`to` VARCHAR(191) NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`views` INTEGER NOT NULL DEFAULT 0,
`userId` INTEGER NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `Image` ADD FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `InvisibleImage` ADD FOREIGN KEY (`id`) REFERENCES `Image`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Url` ADD FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "sqlite"

View File

@@ -1,44 +0,0 @@
-- CreateTable
CREATE TABLE "User" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"username" TEXT NOT NULL,
"password" TEXT NOT NULL,
"token" TEXT NOT NULL,
"administrator" BOOLEAN NOT NULL DEFAULT false,
"embedTitle" TEXT,
"embedColor" TEXT NOT NULL DEFAULT '#2f3136'
);
-- CreateTable
CREATE TABLE "Image" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"file" TEXT NOT NULL,
"mimetype" TEXT NOT NULL DEFAULT 'image/png',
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"views" INTEGER NOT NULL DEFAULT 0,
"userId" INTEGER NOT NULL,
FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "InvisibleImage" (
"id" INTEGER NOT NULL,
"invis" TEXT NOT NULL,
FOREIGN KEY ("id") REFERENCES "Image" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Url" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"to" TEXT NOT NULL,
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"views" INTEGER NOT NULL DEFAULT 0,
"userId" INTEGER NOT NULL,
FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "InvisibleImage.invis_unique" ON "InvisibleImage"("invis");
-- CreateIndex
CREATE UNIQUE INDEX "InvisibleImage_id_unique" ON "InvisibleImage"("id");

View File

@@ -1,55 +0,0 @@
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id Int @id @default(autoincrement())
username String
password String
token String
administrator Boolean @default(false)
embedTitle String?
embedColor String @default("#2f3136")
images Image[]
urls Url[]
}
model Image {
id Int @id @default(autoincrement())
file String
mimetype String @default("image/png")
created_at DateTime @default(now())
views Int @default(0)
invisible InvisibleImage?
user User @relation(fields: [userId], references: [id])
userId Int
}
model InvisibleImage {
id Int
image Image @relation(fields: [id], references: [id])
invis String @unique
}
model Url {
id Int @id @default(autoincrement())
to String
created_at DateTime @default(now())
views Int @default(0)
invisible InvisibleUrl?
user User @relation(fields: [userId], references: [id])
userId Int
}
model InvisibleUrl {
id Int
url Url @relation(fields: [id], references: [id])
invis String @unique
}

77
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,77 @@
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id Int @id @default(autoincrement())
username String
password String
token String
administrator Boolean @default(false)
systemTheme String @default("system")
embedTitle String?
embedColor String @default("#2f3136")
embedSiteName String? @default("{image.file} • {user.name}")
ratelimited Boolean @default(false)
domains String[]
images Image[]
urls Url[]
}
enum ImageFormat {
UUID
DATE
RANDOM
NAME
}
model Image {
id Int @id @default(autoincrement())
file String
mimetype String @default("image/png")
created_at DateTime @default(now())
views Int @default(0)
favorite Boolean @default(false)
embed Boolean @default(false)
password String?
invisible InvisibleImage?
format ImageFormat @default(RANDOM)
user User @relation(fields: [userId], references: [id])
userId Int
}
model InvisibleImage {
id Int @id @default(autoincrement())
invis String @unique
imageId Int @unique
image Image @relation(fields: [imageId], references: [id])
}
model Url {
id String @id @unique
destination String
vanity String?
created_at DateTime @default(now())
views Int @default(0)
invisible InvisibleUrl?
user User @relation(fields: [userId], references: [id])
userId Int
}
model InvisibleUrl {
id Int @id @default(autoincrement())
invis String @unique
urlId String @unique
url Url @relation(fields: [urlId], references: [id])
}
model Stats {
id Int @id @default(autoincrement())
created_at DateTime @default(now())
data Json
}

View File

@@ -1,55 +0,0 @@
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id Int @id @default(autoincrement())
username String
password String
token String
administrator Boolean @default(false)
embedTitle String?
embedColor String @default("#2f3136")
images Image[]
urls Url[]
}
model Image {
id Int @id @default(autoincrement())
file String
mimetype String @default("image/png")
created_at DateTime @default(now())
views Int @default(0)
invisible InvisibleImage?
user User @relation(fields: [userId], references: [id])
userId Int
}
model InvisibleImage {
id Int
image Image @relation(fields: [id], references: [id])
invis String @unique
}
model Url {
id Int @id @default(autoincrement())
to String
created_at DateTime @default(now())
views Int @default(0)
invisible InvisibleUrl?
user User @relation(fields: [userId], references: [id])
userId Int
}
model InvisibleUrl {
id Int
url Url @relation(fields: [id], references: [id])
invis String @unique
}

View File

@@ -1,55 +0,0 @@
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id Int @id @default(autoincrement())
username String
password String
token String
administrator Boolean @default(false)
embedTitle String?
embedColor String @default("#2f3136")
images Image[]
urls Url[]
}
model Image {
id Int @id @default(autoincrement())
file String
mimetype String @default("image/png")
created_at DateTime @default(now())
views Int @default(0)
invisible InvisibleImage?
user User @relation(fields: [userId], references: [id])
userId Int
}
model InvisibleImage {
id Int
image Image @relation(fields: [id], references: [id])
invis String @unique
}
model Url {
id Int @id @default(autoincrement())
to String
created_at DateTime @default(now())
views Int @default(0)
invisible InvisibleUrl?
user User @relation(fields: [userId], references: [id])
userId Int
}
model InvisibleUrl {
id Int
url Url @relation(fields: [id], references: [id])
invis String @unique
}

View File

@@ -1,55 +0,0 @@
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id Int @id @default(autoincrement())
username String
password String
token String
administrator Boolean @default(false)
embedTitle String?
embedColor String @default("#2f3136")
images Image[]
urls Url[]
}
model Image {
id Int @id @default(autoincrement())
file String
mimetype String @default("image/png")
created_at DateTime @default(now())
views Int @default(0)
invisible InvisibleImage?
user User @relation(fields: [userId], references: [id])
userId Int
}
model InvisibleImage {
id Int
image Image @relation(fields: [id], references: [id])
invis String @unique
}
model Url {
id Int @id @default(autoincrement())
to String
created_at DateTime @default(now())
views Int @default(0)
invisible InvisibleUrl?
user User @relation(fields: [userId], references: [id])
userId Int
}
model InvisibleUrl {
id Int
url Url @relation(fields: [id], references: [id])
invis String @unique
}

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

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

View File

@@ -1,42 +0,0 @@
const prismaRun = require('./prisma-run');
const remove = require('rimraf').sync;
const { readFileSync, readdirSync, statSync, renameSync } = require('fs');
const { join } = require('path');
const str = readFileSync('./config.toml');
const config = require('@iarna/toml/parse-string')(str);
remove('prisma/migrations*');
function getFirstDir(dir) {
const files = readdirSync(dir);
for (const file of files) {
if (statSync(join(dir, file)).isDirectory()) return join(dir, file);
}
}
function createPSQLMigrations() {
prismaRun(config.database.psql_url, 'psql', ['migrate', 'dev', '--skip-seed', '--name=psql', '--schema=prisma/schema.psql.prisma']);
const dir = getFirstDir('./prisma/migrations');
renameSync(dir, './prisma/migrations/psql');
renameSync('./prisma/migrations', './prisma/migrations_psql');
}
function createMYSQLMigrations() {
prismaRun(config.database.mysql_url, 'mysql', ['migrate', 'dev', '--skip-seed', '--name=mysql', '--schema=prisma/schema.mysql.prisma']);
const dir = getFirstDir('./prisma/migrations');
renameSync(dir, './prisma/migrations/mysql');
renameSync('./prisma/migrations', './prisma/migrations_mysql');
}
function createSqliteMigrations() {
prismaRun(config.database.sqlite_url, 'sqlite', ['migrate', 'dev', '--skip-seed', '--name=sqlite', '--schema=prisma/schema.sqlite.prisma']);
const dir = getFirstDir('./prisma/migrations');
renameSync(dir, './prisma/migrations/sqlite');
renameSync('./prisma/migrations', './prisma/migrations_sqlite');
}
createPSQLMigrations();
createMYSQLMigrations();
createSqliteMigrations();

View File

@@ -1,43 +0,0 @@
const { copyFileSync, readdirSync, statSync, existsSync, mkdirSync } = require('fs');
const { join, sep } = require('path');
const rimraf = require('rimraf');
const Logger = require('../src/lib/logger');
const prismaRun = require('./prisma-run');
function recursive(dir) {
let res = [];
const files = readdirSync(dir);
for (let i = 0, L = files.length; i !== L; ++i) {
const file = join(dir, files[i]);
if (statSync(file).isDirectory()) res = [...res, ...recursive(file)];
else res.push(file);
}
return res;
}
module.exports = async (config) => {
try {
const prisma = join(process.cwd(), 'prisma');
const migrationsDir = join(prisma, 'migrations_' + config.database.type);
const destMigrations = join(prisma, 'migrations');
const migrationFiles = recursive(migrationsDir);
const destFiles = migrationFiles.map(x => x.replace(migrationsDir + sep, destMigrations + sep));
if (existsSync(destMigrations)) rimraf.sync(destMigrations);
mkdirSync(destMigrations);
mkdirSync(join(destMigrations, config.database.type));
for (let i = 0, L = migrationFiles.length; i !== L; ++i) {
copyFileSync(migrationFiles[i], destFiles[i]);
}
await prismaRun(config.database.url, ['migrate', 'deploy', `--schema=prisma/schema.${config.database.type}.prisma`]);
await prismaRun(config.database.url, ['generate', `--schema=prisma/schema.${config.database.type}.prisma`]);
await prismaRun(config.database.url, ['db', 'seed', '--preview-feature', `--schema=prisma/schema.${config.database.type}.prisma`]);
} catch (e) {
Logger.get('db').error('there was an error.. exiting..');
rimraf.sync(join(process.cwd(), 'prisma', 'migrations'));
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

@@ -1,22 +1,17 @@
const { readFile, readdir } = require('fs/promises');
const { existsSync } = require('fs');
const { join, extname } = require('path');
const { readdir } = require('fs/promises');
const { extname } = require('path');
const validateConfig = require('../server/validateConfig');
const Logger = require('../src/lib/logger');
const readConfig = require('../src/lib/readConfig');
const mimes = require('./mimes');
const { PrismaClient } = require('@prisma/client');
(async () => {
const str = await readFile('./config.toml');
const config = require('@iarna/toml/parse-string')(str);
if (!existsSync(join(process.cwd(), 'prisma', 'migrations'))) {
Logger.get('server').info('detected an uncreated database - creating...');
await require('../scripts/deploy-db')(config);
}
const config = readConfig();
await validateConfig(config);
process.env.DATABASE_URL = config.database.url;
process.env.DATABASE_URL = config.core.database_url;
const files = await readdir(process.argv[2]);
const data = files.map(x => {
@@ -25,7 +20,7 @@ const { PrismaClient } = require('@prisma/client');
return {
file: x,
mimetype: mime,
userId: 1
userId: 1,
};
});
@@ -33,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,23 +0,0 @@
const { spawn } = require('child_process');
const { join } = require('path');
module.exports = (url, args) => {
return new Promise((res, rej) => {
const proc = spawn(join(process.cwd(), 'node_modules', '.bin', 'prisma'), args, {
env: {
DATABASE_URL: url,
...process.env
},
});
proc.stdout.on('data', d => console.log(d.toString()));
proc.stderr.on('data', d => {
console.log(d.toString());
rej(d.toString());
});
proc.stdout.on('close', () => res());
});
};

View File

@@ -1,124 +0,0 @@
const next = require('next');
const { createServer } = require('http');
const { readFile, stat, mkdir } = require('fs/promises');
const { existsSync } = require('fs');
const { execSync } = require('child_process');
const { join } = require('path');
const { red, green, bold } = require('colorette');
const { PrismaClient } = require('@prisma/client');
const validateConfig = require('./validateConfig');
const Logger = require('../src/lib/logger');
const getFile = require('./static');
const readConfig = require('../src/lib/readConfig');
Logger.get('server').info('starting zipline 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(`${status === 200 ? bold(green(status)) : bold(red(status))}: ${url}`);
}
function shouldUseYarn() {
try {
execSync('yarnpkg --version', { stdio: 'ignore' });
return true;
} catch (e) {
return false;
}
}
(async () => {
try {
const config = readConfig();
if (!existsSync(join(process.cwd(), 'prisma', 'migrations'))) {
Logger.get('server').info('detected an uncreated database - creating...');
require('../scripts/deploy-db')(config);
}
await validateConfig(config);
process.env.DATABASE_URL = config.database.url;
await stat('./.next');
await mkdir(config.uploader.directory, { recursive: true });
const app = next({
dir: '.',
dev,
quiet: dev
}, config.core.port, config.core.host);
await app.prepare();
const handle = app.getRequestHandler();
const prisma = new PrismaClient();
const srv = createServer(async (req, res) => {
if (req.url.startsWith(config.uploader.route)) {
const parts = req.url.split('/');
if (!parts[2] || parts[2] === '') return;
const data = await getFile(config.uploader.directory, parts[2]);
if (!data) {
app.render404(req, res);
} else {
let image = await prisma.image.findFirst({
where: {
OR: {
file: parts[2],
},
OR: {
invisible: {
invis: decodeURI(parts[2])
}
}
}
});
if (image) {
await prisma.image.update({
where: {
id: image.id,
},
data: {
views: {
increment: 1
}
}
});
res.setHeader('Content-Type', image.mimetype);
}
res.end(data);
}
} else {
handle(req, res);
}
log(req.url, res.statusCode);
});
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}`);
});
srv.listen(config.core.port, config.core.host ?? '0.0.0.0');
} 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);
}
}
})();

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,44 +0,0 @@
const Logger = require('../src/lib/logger');
function dot(str, obj) {
return str.split('.').reduce((a,b) => a[b], obj);
}
const path = (path, type) => ({ path, type });
module.exports = async config => {
const paths = [
path('core.secure', 'boolean'),
path('core.secret', 'string'),
path('core.host', 'string'),
path('core.port', 'number'),
path('database.type', 'string'),
path('database.url', 'string'),
path('uploader.route', 'string'),
path('uploader.embed_route', 'string'),
path('uploader.length', 'number'),
path('uploader.directory', 'string')
];
let errors = 0;
for (let i = 0, L = paths.length; i !== L; ++i) {
const path = paths[i];
const value = dot(path.path, config);
if (value === undefined) {
Logger.get('config').error(`there was no ${path.path} in config`);
++errors;
}
const type = typeof value;
if (value !== undefined && type !== path.type) {
Logger.get('config').error(`expected ${path.type} on ${path.path}, but got ${type}`);
++errors;
}
}
if (errors !== 0) {
Logger.get('config').error(`exiting due to ${errors} errors`);
process.exit(1);
}
};

View File

@@ -1,12 +0,0 @@
import React from 'react';
import { Snackbar, Alert as MuiAlert } from '@material-ui/core';
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 '@material-ui/core';
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 '@material-ui/core';
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 '@material-ui/core';
export default function CenteredBox({ children, ...other }) {
return (
<Box
justifyContent='center'
display='flex'
alignItems='center'
{...other}
>
{children}
</Box>
);
}

View File

@@ -1,61 +1,94 @@
import React, { useState } from 'react';
import {
Card,
CardMedia,
CardActionArea,
Popover,
Button,
ButtonGroup
} from '@material-ui/core';
import copy from 'copy-to-clipboard';
import useFetch from '../lib/hooks/useFetch';
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 [anchorEl, setAnchorEl] = useState(null);
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/images', 'DELETE', { id: image.id });
if (!res.error) updateImages();
const res = await useFetch('/api/user/files', 'DELETE', { id: image.id });
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 />,
});
}
setAnchorEl(null);
setOpen(false);
};
const handleCopy = () => {
copy(`${window.location.protocol}//${window.location.host}${image.url}`);
setAnchorEl(null);
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} />,
'image': <MImage {...props} />,
'audio': <audio controls {...props} />,
}[t];
};
return (
<>
<Card sx={{ maxWidth: '100%' }}>
<CardActionArea>
<CardMedia
sx={{ height: 320 }}
image={image.url}
title={image.file}
onClick={e => setAnchorEl(e.currentTarget)}
/>
</CardActionArea>
</Card>
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
anchorOrigin={{
vertical: 'center',
horizontal: 'center',
}}
transformOrigin={{
vertical: 'center',
horizontal: 'center',
}}
<Modal
opened={open}
onClose={() => setOpen(false)}
title={<Title>{image.file}</Title>}
>
<ButtonGroup variant='contained'>
<Button onClick={handleDelete} color='primary'>Delete</Button>
<Button onClick={handleCopy} color='primary'>Copy URL</Button>
</ButtonGroup>
</Popover>
<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)}
/>
</Card.Section>
</Card>
</>
);
}

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,381 +1,331 @@
import React, { useState } from 'react';
import Link from 'next/link';
import useFetch from '../lib/hooks/useFetch';
import {
AppBar,
Box,
Divider,
Drawer,
IconButton,
List,
ListItem,
ListItemIcon,
ListItemText,
Toolbar,
Typography,
Button,
Menu,
MenuItem,
Paper,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
} from '@material-ui/core';
import {
Menu as MenuIcon,
Home as HomeIcon,
AccountCircle as AccountIcon,
Image as ImageIcon,
Upload as UploadIcon,
ContentCopy as CopyIcon,
Autorenew as ResetIcon,
Logout as LogoutIcon,
PeopleAlt as UsersIcon
} from '@material-ui/icons';
import copy from 'copy-to-clipboard';
import Backdrop from './Backdrop';
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 = [
{
icon: <HomeIcon />,
text: 'Home',
link: '/dashboard'
link: '/dashboard',
},
{
icon: <ImageIcon />,
text: 'Images',
link: '/dashboard/images'
icon: <FileIcon />,
text: 'Files',
link: '/dashboard/files',
},
{
icon: <MixerHorizontalIcon />,
text: 'Stats',
link: '/dashboard/stats',
},
{
icon: <Link1Icon />,
text: 'URLs',
link: '/dashboard/urls',
},
{
icon: <UploadIcon />,
text: 'Upload',
link: '/dashboard/upload'
}
link: '/dashboard/upload',
},
];
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 images 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 [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 value => {
const newUser = await useFetch('/api/user', 'PATCH', {
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' }
}}
const openResetToken = () => modals.openConfirmModal({
title: 'Reset Token',
centered: true,
children: (
<Text size='sm'>
Once you reset your token, you will have to update any uploaders to use this new token.
</Text>
),
labels: { confirm: 'Reset', cancel: 'Cancel' },
onConfirm: async () => {
const a = await useFetch('/api/user/token', 'PATCH');
if (!a.success) {
setToken(a.success);
notif.showNotification({
title: 'Token Reset Failed',
message: a.error,
color: 'red',
icon: <Cross1Icon />,
});
} else {
notif.showNotification({
title: 'Token Reset',
message: 'Your token has been reset. You will need to update any uploaders to use this new token.',
color: 'green',
icon: <CheckIcon />,
});
}
modals.closeAll();
},
});
const openCopyToken = () => modals.openConfirmModal({
title: 'Copy Token',
centered: true,
children: (
<Text size='sm'>
Make sure you don&apos;t share this token with anyone as they will be able to upload files on your behalf.
</Text>
),
labels: { confirm: 'Copy', cancel: 'Cancel' },
onConfirm: async () => {
clipboard.copy(token);
notif.showNotification({
title: 'Token Copied',
message: 'Your token has been copied to your clipboard.',
color: 'green',
icon: <CheckIcon />,
});
modals.closeAll();
},
});
return (
<AppShell
navbarOffsetBreakpoint='sm'
fixed
navbar={
<Navbar
padding='md'
hiddenBreakpoint='sm'
hidden={!opened}
width={{ sm: 200, lg: 230 }}
>
<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}
MenuListProps={{
'aria-labelledby': 'basic-button',
<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,
},
}}
>
<MenuItem>
<Typography variant='h5'>
<b>{user.username}</b>
</Typography>
</MenuItem>
<Divider />
<Link href='/dashboard/manage'>
<a style={{ color: 'white', textDecoration: 'none' }}>
<MenuItem onClick={handleClose(null)}>
<AccountIcon sx={{ mr: 2 }} /> Manage Account
</MenuItem>
</a>
</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'>
<a style={{ color: 'white', textDecoration: 'none' }}>
<MenuItem onClick={handleClose(null)}>
<LogoutIcon sx={{ mr: 2 }} /> Logout
</MenuItem>
</a>
</Link>
</Menu>
</Box>
<Group>
<ThemeIcon color='primary' variant='filled'>
{icon}
</ThemeIcon>
<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>
)}
</Toolbar>
</AppBar>
</Toolbar>
<Divider />
<List>
{items.map((item, i) => (
<Link key={i} href={item.link}>
<a href={item.link} style={{ color: 'white', textDecoration: 'none' }}>
<ListItem button>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.text} />
</ListItem>
</a>
</Link>
))}
{user && user.administrator && (
<Link href='/dashboard/users' passHref>
<a style={{ color: 'white', textDecoration: 'none' }}>
<ListItem button>
<ListItemIcon><UsersIcon /></ListItemIcon>
<ListItemText primary='Users' />
</ListItem>
</a>
</Link>
)}
</List>
</div>
);
const container = typeof window !== 'undefined' ? window.document.body : undefined;
return (
<Box sx={{ display: 'flex' }}>
<Backdrop open={loading} />
<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}
</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}
MenuListProps={{
'aria-labelledby': 'basic-button',
}}
>
<MenuItem>
<Typography variant='h5'>
<b>{user.username}</b>
</Typography>
</MenuItem>
<Divider />
<Link href='/dash/manage'>
<a style={{ color: 'white', textDecoration: 'none' }}>
<MenuItem onClick={handleClose(null)}>
<AccountIcon sx={{ mr: 2 }} /> Manage Account
</MenuItem>
</a>
</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'>
<a style={{ color: 'white', textDecoration: 'none' }}>
<MenuItem onClick={handleClose(null)}>
<LogoutIcon sx={{ mr: 2 }} /> Logout
</MenuItem>
</a>
</Link>
</Menu>
<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>
</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 '@material-ui/core/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

@@ -0,0 +1,96 @@
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 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 { MantineProvider, MantineThemeOverride } from '@mantine/core';
import { ModalsProvider } from '@mantine/modals';
import { NotificationsProvider } from '@mantine/notifications';
import { useColorScheme } from '@mantine/hooks';
export const themes = {
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',
'dracula': 'Dracula',
'matcha_dark_azul': 'Matcha Dark Azul',
'qogir_dark': 'Qogir Dark',
};
export default function ZiplineTheming({ Component, pageProps, ...props }) {
const user = useStoreSelector(state => state.user);
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 (
<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,29 +1,22 @@
import React, { useEffect, useState } from 'react';
import {
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TablePagination,
TableRow,
Button,
ButtonGroup,
Typography,
Grid
} from '@material-ui/core';
import Link from 'components/Link';
import Card from 'components/Card';
import Backdrop from 'components/Backdrop';
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';
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;
@@ -35,84 +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}>{col.name}</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{rows.map((row, i) => (
<TableRow
key={row.username}
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
>
{columns.map(col => (
<TableCell key={col.id}>
{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 [page, setPage] = useState(0);
const [recent, setRecent] = useState([]);
const [stats, setStats] = useState(null);
const [apiLoading, setApiLoading] = useState(true);
const [rowsPerPage, setRowsPerPage] = useState(10);
const clipboard = useClipboard();
const notif = useNotifications();
const updateImages = async () => {
setApiLoading(true);
const imgs = await useFetch('/api/user/images');
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);
setApiLoading(false);
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/images', 'DELETE', { id: image.id });
if (!res.error) updateImages();
const viewImage = async ({ original }) => {
window.open(`${window.location.protocol}//${window.location.host}${original.url}`);
};
useEffect(() => {
@@ -121,110 +86,89 @@ export default function Dashboard() {
return (
<>
<Backdrop open={apiLoading} />
<Typography variant='h4'>Welcome back {user?.username}</Typography>
<Typography color='GrayText' pb={2}>You have <b>{images.length}</b> images</Typography>
<Title>Welcome back {user?.username}</Title>
<Text color='gray' sx={{ paddingBottom: 4 }}>You have <b>{images.length ? images.length : '...'}</b> files</Text>
<Typography variant='h4'>Stats</Typography>
{stats && (
<Grid container spacing={4} py={2}>
<Grid item xs={12} sm={4}>
<Card name='Size' sx={{ height: '100%' }}>
<StatText>{stats.size}</StatText>
<Typography variant='h3'>Average Size</Typography>
<StatText>{bytesToRead(stats.size / stats.count)}</StatText>
</Card>
</Grid>
<Grid item xs={12} sm={4}>
<Card name='Images' sx={{ height: '100%' }}>
<StatText>{stats.count}</StatText>
<Typography variant='h3'>Views</Typography>
<StatText>{stats.views_count} ({isNaN(stats.views_count / stats.count) ? '0' : stats.views_count / stats.count})</StatText>
</Card>
</Grid>
<Grid item xs={12} sm={4}>
<Card name='Users' sx={{ height: '100%' }}>
<StatText>{stats.count_users}</StatText>
</Card>
</Grid>
</Grid>
)}
<Title>Recent Files</Title>
<SimpleGrid
cols={4}
spacing='lg'
breakpoints={[
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
]}
>
{recent.length ? recent.map(image => (
<ZiplineImage key={randomId()} image={image} updateImages={updateImages} />
)) : [1,2,3,4].map(x => (
<div key={x}>
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }}/>
</div>
))}
</SimpleGrid>
<Card name='Images' sx={{ my: 2 }} elevation={0} variant='outlined'>
<Link href='/dashboard/images' pb={2}>View Gallery</Link>
<TableContainer sx={{ maxHeight: 440 }}>
<Table size='small'>
<TableHead>
<TableRow>
{columns.map((column) => (
<TableCell
key={column.id}
align={column.align}
sx={{ minWidth: column.minWidth }}
>
{column.label}
</TableCell>
))}
<TableCell sx={{ minWidth: 200 }} 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}>
{column.format ? column.format(value) : value}
</TableCell>
);
})}
<TableCell align='right'>
<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>
<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>
<Card name='Images per User' sx={{ height: '100%', my: 2 }} elevation={0} variant='outlined'>
<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' },
{ id: 'count', name: 'Images' }
{ id: 'count', name: 'Files' },
]}
rows={stats.count_by_user}
/>
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' }
{ id: 'count', name: 'Count' },
]}
rows={stats.types_count}
/>
</Card>
rows={stats ? stats.types_count : []} />
</Card> */}
</>
);
}

View File

@@ -0,0 +1,104 @@
import React, { useEffect, useState } from 'react';
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 updatePages = async favorite => {
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);
};
useEffect(() => {
updatePages(true);
}, []);
return (
<>
<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 => (
<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
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingTop: 12,
paddingBottom: 3,
}}
>
<Pagination total={pages.length} page={page} onChange={setPage}/>
</Box>
) : null}
</>
);
}

View File

@@ -1,61 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Grid, Pagination, Box, Typography } from '@material-ui/core';
import Backdrop from 'components/Backdrop';
import ZiplineImage from 'components/Image';
import useFetch from 'hooks/useFetch';
import { useStoreSelector } from 'lib/redux/store';
export default function Upload() {
const user = useStoreSelector(state => state.user);
const [pages, setPages] = useState([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const updatePages = async () => {
setLoading(true);
const pages = await useFetch('/api/user/images?paged=true');
setPages(pages);
setLoading(false);
};
useEffect(() => {
updatePages();
}, []);
return (
<>
<Backdrop open={loading}/>
{!pages.length && (
<Box
display='flex'
justifyContent='center'
alignItems='center'
pt={2}
pb={3}
>
<Typography variant='h4'>No Images</Typography>
</Box>
)}
<Grid container spacing={2}>
{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>
{pages.length ? (
<Box
display='flex'
justifyContent='center'
alignItems='center'
pt={2}
>
<Pagination count={pages.length} page={page} onChange={(_, v) => setPage(v)}/>
</Box>
) : null}
</>
);
}

View File

@@ -1,112 +1,170 @@
import React, { useState } from 'react';
import { TextField, Button, Box, Typography } from '@material-ui/core';
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 Link from 'components/Link';
import { useStoreDispatch, useStoreSelector } from 'lib/redux/store';
import { updateUser } from 'lib/redux/reducers/user';
import { randomId, useForm } from '@mantine/hooks';
import { Tooltip, TextInput, Button, Text, Title, Group, ColorInput, MultiSelect, Space } from '@mantine/core';
import { DownloadIcon, Cross1Icon } from '@modulz/radix-icons';
import { useNotifications } from '@mantine/notifications';
const validationSchema = yup.object({
username: yup
.string()
.required('Username is required')
});
function TextInput({ id, label, formik, ...other }) {
function VarsTooltip({ children }) {
return (
<TextField
id={id}
name={id}
label={label}
value={formik.values[id]}
onChange={formik.handleChange}
error={formik.touched[id] && Boolean(formik.errors[id])}
helperText={formik.touched[id] && formik.errors[id]}
variant='standard'
fullWidth
sx={{ pb: 0.5 }}
{...other}
/>
<Tooltip position='top' placement='center' color='' label={
<>
<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}
</Tooltip>
);
}
export default function Manage() {
const user = useStoreSelector(state => state.user);
const dispatch = useStoreDispatch();
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 formik = useFormik({
const genShareX = (withEmbed: boolean = false, withZws: boolean = false) => {
const config = {
Version: '13.2.1',
Name: 'Zipline',
DestinationType: 'ImageUploader, TextUploader',
RequestMethod: 'POST',
RequestURL: `${window.location.protocol + '//' + window.location.hostname + (window.location.port ? ':' + window.location.port : '')}/api/upload`,
Headers: {
Authorization: user?.token,
...(withEmbed && {Embed: 'true'}),
...(withZws && {ZWS: 'true'}),
},
URL: '$json:files[0]$',
Body: 'MultipartFormData',
FileFormName: 'file',
};
const pseudoElement = document.createElement('a');
pseudoElement.setAttribute('href', 'data:application/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(config, null, '\t')));
pseudoElement.setAttribute('download', `zipline${withEmbed ? '_embed' : ''}${withZws ? '_zws' : ''}.sxcu`);
pseudoElement.style.display = 'none';
document.body.appendChild(pseudoElement);
pseudoElement.click();
pseudoElement.parentNode.removeChild(pseudoElement);
};
const form = useForm({
initialValues: {
username: user.username,
password: '',
embedTitle: user.embedTitle ?? '',
embedColor: user.embedColor
embedColor: user.embedColor,
embedSiteName: user.embedSiteName ?? '',
domains: user.domains ?? [],
},
validationSchema,
onSubmit: async values => {
const cleanUsername = values.username.trim();
const cleanPassword = values.password.trim();
const cleanEmbedTitle = values.embedTitle.trim();
const cleanEmbedColor = values.embedColor.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
};
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);
}
}
});
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 (cleanUsername === '') return form.setFieldError('username', 'Username can\'t be nothing');
const id = notif.showNotification({
title: 'Updating user...',
message: '',
loading: true,
autoClose: false,
});
const data = {
username: cleanUsername,
password: cleanPassword === '' ? null : cleanPassword,
embedTitle: cleanEmbedTitle === '' ? null : cleanEmbedTitle,
embedColor: cleanEmbedColor === '' ? null : cleanEmbedColor,
embedSiteName: cleanEmbedSiteName === '' ? null : cleanEmbedSiteName,
domains,
};
const newUser = await useFetch('/api/user', 'PATCH', data);
if (newUser.error) {
if (newUser.invalidDomains) {
notif.updateNotification(id, {
message: <>
<Text mt='xs'>The following domains are invalid:</Text>
{newUser.invalidDomains.map(err => (
<>
<Text color='gray' key={randomId()}>{err.domain}: {err.reason}</Text>
<Space h='md' />
</>
))}
</>,
color: 'red',
icon: <Cross1Icon />,
});
}
notif.updateNotification(id, {
title: 'Couldn\'t save user',
message: newUser.error,
color: 'red',
icon: <Cross1Icon />,
});
} else {
dispatch(updateUser(newUser));
notif.updateNotification(id, {
title: 'Saved User',
message: '',
});
}
};
return (
<>
<Backdrop open={loading}/>
<Alert open={open} setOpen={setOpen} message={message} severity={severity} />
<Title>Manage User</Title>
<VarsTooltip>
<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={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')}
/>
<Typography variant='h4' pb={2}>Manage User</Typography>
<form onSubmit={formik.handleSubmit}>
<TextInput id='username' label='Username' formik={formik} />
<TextInput id='password' label='Password' formik={formik} type='password' />
<TextInput id='embedTitle' label='Embed Title' formik={formik} />
<TextInput id='embedColor' label='Embed Color' formik={formik} />
<Box
display='flex'
justifyContent='right'
alignItems='right'
pt={2}
>
<Group position='right' sx={{ paddingTop: 12 }}>
<Button
variant='contained'
type='submit'
>Save</Button>
</Box>
>Save User</Button>
</Group>
</form>
<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,89 +1,119 @@
import React, { useState } from 'react';
import { Typography, Button, CardActionArea, Paper, Box } from '@material-ui/core';
import { Upload as UploadIcon } from '@material-ui/icons';
import Dropzone from 'react-dropzone';
import React, { useEffect, useState } from 'react';
import useFetch from 'hooks/useFetch';
import Backdrop from 'components/Backdrop';
import Alert from 'components/Alert';
import { useStoreSelector } from 'lib/redux/store';
import CenteredBox from 'components/CenteredBox';
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 Manage({ 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 [file, setFile] = useState(null);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [severity, setSeverity] = useState('success');
const [message, setMessage] = useState('Saved');
const [files, setFiles] = useState([]);
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();
body.append('file', file);
for (let i = 0; i !== files.length; ++i) body.append('file', files[i]);
const id = notif.showNotification({
title: 'Uploading Images...',
message: '',
loading: true,
autoClose: false,
});
setLoading(true);
const res = await fetch('/api/upload', {
method: 'POST',
headers: {
'Authorization': user.token
'Authorization': user.token,
},
body
body,
});
if (res.ok) {
setOpen(true);
setSeverity('success');
setMessage(`File uploaded! ${window.location.protocol}//${window.location.host}${route}/${await res.text()}`);
const json = await res.json();
if (res.ok && json.error === undefined) {
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 {
const json = await res.json();
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 => setFile(acceptedFiles[0])}>
{({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>
<CenteredBox><Typography variant='h6'>{file && 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

@@ -0,0 +1,161 @@
import React, { useEffect, useState } from 'react';
import useFetch from 'hooks/useFetch';
import { useStoreSelector } from 'lib/redux/store';
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 [urls, setURLS] = useState([]);
const [createOpen, setCreateOpen] = useState(false);
const updateURLs = async () => {
const urls = await useFetch('/api/user/urls');
setURLS(urls);
};
const deleteURL = async u => {
const url = await useFetch('/api/user/urls', 'DELETE', { id: u.id });
if (url.error) {
notif.showNotification({
title: 'Failed to delete URL',
message: url.error,
icon: <TrashIcon />,
color: 'red',
});
} else {
notif.showNotification({
title: 'Deleted URL',
message: '',
icon: <Cross1Icon />,
color: 'green',
});
}
updateURLs();
};
const copyURL = u => {
clipboard.copy(`${window.location.protocol}//${window.location.host}${u.url}`);
notif.showNotification({
title: 'Copied to clipboard',
message: '',
icon: <CopyIcon />,
});
};
const form = useForm({
initialValues: {
url: '',
vanity: '',
},
});
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 (
<>
<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')} />
<Group position='right' mt={22}>
<Button onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button type='submit'>Submit</Button>
</Group>
</form>
</Modal>
<Group>
<Title sx={{ marginBottom: 12 }}>URLs</Title>
<ActionIcon variant='filled' color='primary' onClick={() => setCreateOpen(true)}><PlusIcon/></ActionIcon>
</Group>
<SimpleGrid
cols={4}
spacing='lg'
breakpoints={[
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
]}
>
{urls.length ? urls.map(url => (
<Card key={url.id} sx={{ maxWidth: '100%' }} shadow='sm'>
<Group position='apart'>
<Group position='left'>
<Title>{url.vanity ?? url.id}</Title>
</Group>
<Group position='right'>
<ActionIcon href={url.url} component='a' target='_blank'><Link1Icon/></ActionIcon>
<ActionIcon aria-label='copy' onClick={() => copyURL(url)}>
<CopyIcon />
</ActionIcon>
<ActionIcon aria-label='delete' onClick={() => deleteURL(url)}>
<TrashIcon />
</ActionIcon>
</Group>
</Group>
</Card>
)) : [1,2,3,4,5,6,7].map(x => (
<div key={x}>
<Skeleton width='100%' height={60} sx={{ borderRadius: 1 }}/>
</div>
))}
</SimpleGrid>
</>
);
}

View File

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

16
src/lib/clientUtils.ts Normal file
View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

View File

@@ -5,7 +5,7 @@ export default async function useFetch(url: string, method: 'GET' | 'POST' | 'PA
const res = await global.fetch(url, {
body: body ? JSON.stringify(body) : null,
method,
headers
headers,
});
return res.json();

View File

@@ -14,21 +14,17 @@ export default function login() {
async function load() {
setLoading(true);
const res = await useFetch('/api/user');
if (res.error) return router.push('/auth/login');
if (res.error) return router.push('/auth/login?url=' + router.route);
dispatch(updateUser(res));
setUser(res);
setLoading(false);
}
useEffect(() => {
if (!loading && user) {
return;
}
if (!loading && user) return;
load();
}, []);

View File

@@ -1,44 +0,0 @@
const { format } = require('fecha');
const { yellow, blueBright, magenta, 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.toString()));
}
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 'DEBUG':
return yellow('DEBUG');
case 'WARN':
return magenta('WARN ');
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,53 +1,83 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import type { CookieSerializeOptions } from 'cookie';
import type { User } from '@prisma/client';
import { serialize } from 'cookie';
import { sign64, unsign64 } from '../util';
import config from 'lib/config';
import prisma from 'lib/prisma';
export interface NextApiFile {
fieldname: string;
originalname: string;
encoding: string;
mimetype: string;
buffer: Buffer;
size: number;
}
export type NextApiReq = NextApiRequest & {
user: () => Promise<User | null | void>;
user: () => Promise<{
username: string;
token: string;
embedTitle: string;
embedColor: string;
systemTheme: string;
administrator: boolean;
id: number;
password: string;
domains: string[];
} | null | void>;
getCookie: (name: string) => string | null;
cleanCookie: (name: string) => void;
files?: NextApiFile[];
}
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;
setCookie: (name: string, value: unknown, options: CookieSerializeOptions) => void;
}
export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse) => unknown) => (req: NextApiReq, res: NextApiRes) => {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Content-Allow-Methods', 'GET,HEAD,POST,OPTIONS');
res.setHeader('Access-Control-Max-Age', '86400');
res.error = (message: string) => {
res.setHeader('Content-Type', 'application/json');
res.json({
error: message
error: message,
});
};
res.forbid = (message: string) => {
res.forbid = (message: string, extra: any = {}) => {
res.setHeader('Content-Type', 'application/json');
res.status(403);
res.json({
error: '403: ' + message
error: '403: ' + message,
...extra,
});
};
res.bad = (message: string) => {
res.setHeader('Content-Type', 'application/json');
res.status(401);
res.json({
error: '403: ' + message
error: '403: ' + message,
});
};
res.ratelimited = () => {
res.status(429);
res.json({
error: '429: ratelimited',
});
};
res.json = (json: any) => {
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(json));
};
@@ -62,9 +92,10 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
res.setHeader('Set-Cookie', serialize(name, '', {
path: '/',
expires: new Date(1),
maxAge: undefined
maxAge: undefined,
}));
};
req.user = async () => {
try {
const userId = req.getCookie('user');
@@ -72,8 +103,19 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
const user = await prisma.user.findFirst({
where: {
id: Number(userId)
}
id: Number(userId),
},
select: {
administrator: true,
embedColor: true,
embedTitle: true,
id: true,
password: true,
systemTheme: true,
token: true,
username: true,
domains: true,
},
});
if (!user) return null;
@@ -99,11 +141,11 @@ export const setCookie = (
) => {
if ('maxAge' in options) {
options.expires = new Date(Date.now() + options.maxAge);
options.expires = new Date(Date.now() + options.maxAge * 1000);
options.maxAge /= 1000;
}
const signed = sign64(String(value), config.core.secret);
res.setHeader('Set-Cookie', serialize(name, signed, options));
};
};

View File

@@ -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,83 +0,0 @@
const { existsSync, readFileSync } = require('fs');
const { join } = require('path');
const Logger = require('./logger');
const e = (val, type, fn) => ({ val, type, fn });
const envValues = [
e('SECURE', 'boolean', (c, v) => c.core.secure = v),
e('SECRET', 'string', (c, v) => c.core.secret = v),
e('HOST', 'string', (c, v) => c.core.host = v),
e('PORT', 'number', (c, v) => c.core.port = v),
e('DATABASE_TYPE', 'string', (c, v) => c.database.type = v),
e('DATABASE_URL', 'string', (c, v) => c.database.url = v),
e('UPLOADER_ROUTE', 'string', (c, v) => c.uploader.route = v),
e('UPLOADER_EMBED_ROUTE', 'string', (c, v) => c.uploader.embed_route = v),
e('UPLOADER_LENGTH', 'number', (c, v) => c.uploader.length = v),
e('UPLOADER_DIRECTORY', 'string', (c, v) => c.uploader.directory = v)
];
module.exports = () => {
if (!existsSync(join(process.cwd(), 'config.toml'))) {
Logger.get('config').info('reading environment');
return tryReadEnv();
} else {
Logger.get('config').info('reading config file');
const str = readFileSync(join(process.cwd(), 'config.toml'), 'utf8');
const parsed = require('@iarna/toml/parse-string')(str);
return parsed;
}
};
function tryReadEnv() {
const config = {
core: {
secure: undefined,
secret: undefined,
host: undefined,
port: undefined
},
database: {
type: undefined,
url: undefined
},
uploader: {
route: undefined,
embed_route: undefined,
length: undefined,
directory: undefined
}
};
for (let i = 0, L = envValues.length; i !== L; ++i) {
const envValue = envValues[i];
let value = process.env[envValue.val];
if (!value) {
Logger.get('config').error('there is no config file or required environment variables... exiting...');
process.exit(1);
}
envValues[i].fn(config, value);
if (envValue.type === 'number') value = parseToNumber(value);
else if (envValue.type === 'boolean') value = parseToBoolean(value);
envValues[i].fn(config, value);
}
return config;
}
function parseToNumber(value) {
// infer that it is a string since env values are only strings
const number = Number(value);
if (isNaN(number)) return undefined;
return number;
}
function parseToBoolean(value) {
// infer that it is a string since env values are only strings
if (!value || value === 'false') return false;
else return true;
}

123
src/lib/readConfig.ts Normal file
View File

@@ -0,0 +1,123 @@
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 });
const envValues = [
e('SECURE', 'boolean', (c, v) => c.core.secure = v),
e('SECRET', 'string', (c, v) => c.core.secret = v),
e('HOST', 'string', (c, v) => c.core.host = v),
e('PORT', 'number', (c, v) => c.core.port = v),
e('DATABASE_URL', 'string', (c, v) => c.core.database_url = v),
e('LOGGER', 'boolean', (c, v) => c.core.logger = v ?? true),
e('STATS_INTERVAL', 'number', (c, v) => c.core.stats_interval = v),
e('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_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 = []),
e('URLS_ROUTE', 'string', (c, v) => c.urls.route = v),
e('URLS_LENGTH', 'number', (c, v) => c.urls.length = v),
e('RATELIMIT_USER', 'number', (c, v) => c.ratelimit.user = v ?? 0),
e('RATELIMIT_ADMIN', 'number', (c, v) => c.ratelimit.user = v ?? 0),
];
export default function readConfig(): Config {
if (!existsSync(join(process.cwd(), 'config.toml'))) {
if (!process.env.ZIPLINE_DOCKER_BUILD) Logger.get('config').info('reading environment');
return tryReadEnv();
} 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 = parse(str);
return parsed;
}
};
function tryReadEnv(): Config {
const config = {
core: {
secure: undefined,
secret: undefined,
host: undefined,
port: undefined,
database_url: undefined,
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,
admin_limit: undefined,
user_limit: undefined,
disabled_extentions: undefined,
},
urls: {
route: undefined,
length: undefined,
},
ratelimit: {
user: undefined,
admin: undefined,
},
};
for (let i = 0, L = envValues.length; i !== L; ++i) {
const envValue = envValues[i];
let value: any = process.env[envValue.val];
if (!value) {
envValues[i].fn(config, undefined);
} else {
envValues[i].fn(config, value);
if (envValue.type === 'number') value = parseToNumber(value);
else if (envValue.type === 'boolean') value = parseToBoolean(value);
else if (envValue.type === 'array') value = parseToArray(value);
envValues[i].fn(config, value);
}
}
return config;
}
function parseToNumber(value) {
// infer that it is a string since env values are only strings
const number = Number(value);
if (isNaN(number)) return undefined;
return number;
}
function parseToBoolean(value) {
// infer that it is a string since env values are only strings
if (!value || value === 'false') return false;
else return true;
}
function parseToArray(value) {
return value.split(',');
}

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