Compare commits

..

39 Commits

Author SHA1 Message Date
dicedtomato
12baadd563 fix: revert version 2022-07-15 21:29:44 +00:00
dicedtomato
f5ae36d4e7 feat: version 3.4.9 2022-07-15 21:28:27 +00:00
dicedtomato
04ca738fb1 feat: add more mimetypes! 2022-07-15 21:27:01 +00:00
dicedtomato
95e09e51e1 fix: add title to Layout 2022-07-15 17:34:32 +00:00
dicedtomato
2f0af385c7 feat: add content-length headers 2022-07-15 17:20:24 +00:00
dicedtomato
786e6d5799 feat: new configuration options 2022-07-14 03:05:16 +00:00
dicedtomato
61c5df750a feat: invitations to create accounts 2022-07-14 02:31:23 +00:00
dicedtomato
eb30afcb83 remove: meta config (will be added another day) 2022-07-13 16:32:56 +00:00
dicedtomato
cdf0f6e96c feat: versioned docker images 2022-07-13 04:32:52 +00:00
dicedtomato
54158c5dbe refactor: remove config file in favor for env variables 2022-07-13 02:50:25 +00:00
dicedtomato
56ff86db44 feat: revamp file gallery 2022-07-12 22:09:57 +00:00
dicedtomato
b7560c80aa fix: make dropzone larger 2022-07-10 05:43:19 +00:00
dicedtomato
03379943de fix: clean up upload components 2022-07-10 05:38:53 +00:00
dicedtomato
2376fd8968 feat: switch from radix-icons to feathericons 2022-07-10 00:46:15 +00:00
dicedtomato
2f90193d7e feat: add text uplading 2022-07-09 23:54:55 +00:00
dicedtomato
964199f8a9 fix(config): make s3/swift optional and not error out 2022-07-09 20:35:21 +00:00
dicedtomato
678ea20004 fix(docker): build 2022-07-08 02:57:31 +00:00
dicedtomato
ea27fd8a45 feat(3.4.8): fix bug where you can crash zipline 2022-07-08 02:52:19 +00:00
dicedtomato
38eef3f0ad feat(v3.4.7): version 2022-07-06 17:03:44 +00:00
dicedtomato
22615e9ce9 fix: build 2022-07-06 17:01:12 +00:00
dicedtomato
a999abfbf8 feat: a bunch of changes 2022-07-06 16:57:39 +00:00
dicedtomato
20c1d3ef08 Merge branch 'trunk' of https://github.com/diced/zipline into trunk 2022-06-26 17:58:14 +00:00
Adil Mohiuddin
b06c8e4918 fix: add link to xsel (#157) 2022-06-25 21:41:42 -07:00
dicedtomato
6edfdcefcc fix(s3): use smaller libraries 2022-06-25 17:07:19 +00:00
dicedtomato
10b145b006 fix(docker): use prebuilt binaries 2022-06-25 00:01:23 +00:00
dicedtomato
0ba9a9659d fix(docker): build prisma so it works on alpine arm64 2022-06-24 18:27:35 +00:00
cstef
2dfa1b6b14 feat: add Openstack Swift support (#154)
* Add Openstack support

* Fix datasource getting

* Restore example config + remove useless vscode dir

* Restore example config + remove useless vscode dir

* Add config.ts to the entryPoints list

* Fix indenting problems with switch-case

* Replace openstack-swift-client

* Add openstack to the config validator

* Rename Openstack to Swift

* Better error handling for swift

* More error handling

* Update Swift.ts

Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com>
2022-06-21 17:54:05 -07:00
dicedtomato
7a3f9f1fa4 fix: maybe fix action idk 2022-06-20 22:43:21 -07:00
diced
f276fdc6a0 feat(docker): remove arm docker stuff in favor of cross arch dockerfile 2022-06-20 22:29:33 -07:00
diced
7963bdd1e4 feat(v3.4.6): version & fixes 2022-06-20 17:40:36 -07:00
diced
195c57edc3 refactor(server): clean up server code 2022-06-20 17:09:52 -07:00
diced
4442c85dc1 fix(server): prisma migrations dont exit (high cpu) 2022-06-20 17:08:43 -07:00
diced
5bcac2a2b0 refactor(readme): more info 2022-06-20 15:07:15 -07:00
diced
5303b67d11 fix(pages): add blur to password modal 2022-06-20 14:35:26 -07:00
diced
af59e9abb8 fix(pages): videos supported on embeded uploads 2022-06-20 14:33:10 -07:00
diced
fb098c9147 feat(page): add preview on hover & progress bar 2022-06-20 14:12:57 -07:00
diced
739974bef4 refactor(config): move into directory 2022-06-20 10:55:43 -07:00
diced
d21e48a1a3 fix(api): improve ratelimits 2022-06-20 10:49:24 -07:00
diced
8fea0cbe77 refactor: clean up datasource stuff 2022-06-20 10:25:22 -07:00
128 changed files with 12863 additions and 2831 deletions

46
.env.local.example Normal file
View File

@@ -0,0 +1,46 @@
# every field in here is optional except, CORE_SECRET and CORE_DATABASE_URL.
# if CORE_SECRET is still "changethis" then zipline will exit and tell you to change it.
# if using s3/swift make sure to comment out the other datasources
CORE_HTTPS=true
CORE_SECRET="changethis"
CORE_HOST=0.0.0.0
CORE_PORT=3000
CORE_DATABASE_URL="postgres://postgres:postgres@localhost/zip10"
CORE_LOGGER=false
CORE_STATS_INTERVAL=1800
# default
DATASOURCE_TYPE=local
DATASOURCE_LOCAL_DIRECTORY=./uploads
# or you can choose to use s3
DATASOURCE_TYPE=s3
DATASOURCE_S3_ACCESS_KEY_ID=key
DATASOURCE_S3_SECRET_ACCESS_KEY=secret
DATASOURCE_S3_BUCKET=bucket
DATASOURCE_S3_ENDPOINT=s3.amazonaws.com
DATASOURCE_S3_REGION=us-west-2
DATASOURCE_S3_FORCE_S3_PATH=false
# or you can use swift
DATASOURCE_TYPE=swift
DATASOURCE_SWIFT_CONTAINER=container
DATASOURCE_SWIFT_AUTH_ENDPOINT="https://something/v3"
DATASOURCE_SWIFT_USERNAME=username
DATASOURCE_SWIFT_PASSWORD=password
DATASOURCE_SWIFT_PROJECT_ID=project_id
DATASOURCE_SWIFT_DOMAIN_ID=domain_id
UPLOADER_ROUTE=/u
UPLOADER_LENGTH=6
UPLOADER_ADMIN_LIMIT=104900000
UPLOADER_USER_LIMIT=104900000
UPLOADER_DISABLED_EXTENSIONS=someext
URLS_ROUTE=/go
URLS_LENGTH=6
RATELIMIT_USER = 5
RATELIMIT_ADMIN = 3

View File

@@ -1,12 +1,36 @@
{
"extends": ["next", "next/core-web-vitals"],
"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"],
"indent": [
"error",
2,
{
"SwitchCase": 1
}
],
"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",
@@ -17,9 +41,11 @@
"react/no-direct-mutation-state": "warn",
"react/no-is-mounted": "warn",
"react/no-typos": "error",
"react/react-in-jsx-scope": "error",
"react/react-in-jsx-scope": "off",
"react/require-render-return": "error",
"react/style-prop-object": "warn",
"@next/next/no-img-element": "off"
"@next/next/no-img-element": "off",
"jsx-a11y/alt-text": "off",
"react/display-name": "off"
}
}

View File

@@ -1,4 +1,4 @@
name: 'CI: Build'
name: 'Build'
on:
push:
@@ -22,12 +22,11 @@ jobs:
path: node_modules
key: ${{ runner.os }}-node${{ matrix.node }}-${{ hashFiles('**/yarn.lock') }}
- name: Create mock config
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
env:
ZIPLINE_DOCKER_BUILD: true

View File

@@ -1,23 +1,31 @@
name: 'CD: Push ARM64 Docker Images'
name: 'Push Release Docker Images'
on:
push:
branches: [ trunk ]
tags:
- 'v*.*.*'
paths:
- 'src/**'
- 'server/**'
- 'prisma/**'
- '.github/**'
- 'Dockerfile'
workflow_dispatch:
jobs:
push_to_ghcr:
name: Push Image to GitHub Packages
name: Push Release Image to GitHub Packages
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v2
- name: Get version
uses: sergeysova/jq-action@v2
id: version
with:
cmd: 'jq .version package.json -r'
- name: Setup QEMU
uses: docker/setup-qemu-action@v1
@@ -35,7 +43,8 @@ jobs:
- 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
platforms: linux/amd64,linux/arm64
tags: |
ghcr.io/diced/zipline:latest
ghcr.io/diced/zipline:${{ steps.version.outputs.value }}

View File

@@ -1,4 +1,4 @@
name: 'CD: Push Docker Images'
name: 'Push Docker Images'
on:
push:
@@ -8,6 +8,7 @@ on:
- 'server/**'
- 'prisma/**'
- '.github/**'
- 'Dockerfile'
workflow_dispatch:
jobs:
@@ -32,17 +33,10 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build Docker Image
uses: docker/build-push-action@v2
with:
push: true
platforms: linux/amd64,linux/arm64
tags: |
ghcr.io/diced/zipline/zipline:trunk
ghcr.io/diced/zipline/amd64:trunk
diced/zipline:trunk
ghcr.io/diced/zipline:trunk

3
.gitignore vendored
View File

@@ -42,5 +42,4 @@ yarn-error.log*
# zipline
config.toml
uploads/
dist/
docker-compose.local.yml
dist/

View File

@@ -1,47 +1,64 @@
FROM node:16-alpine AS deps
FROM ghcr.io/diced/prisma-binaries:3.15.x as prisma
FROM alpine:3.16 AS deps
RUN mkdir -p /prisma-engines
WORKDIR /build
COPY .yarn .yarn
COPY package.json yarn.lock .yarnrc.yml ./
RUN apk add --no-cache libc6-compat
RUN apk add --no-cache nodejs yarn
RUN yarn install --immutable
FROM node:16-alpine AS builder
FROM alpine:3.16 AS builder
WORKDIR /build
COPY --from=prisma /prisma-engines /prisma-engines
ENV PRISMA_QUERY_ENGINE_BINARY=/prisma-engines/query-engine \
PRISMA_MIGRATION_ENGINE_BINARY=/prisma-engines/migration-engine \
PRISMA_INTROSPECTION_ENGINE_BINARY=/prisma-engines/introspection-engine \
PRISMA_FMT_BINARY=/prisma-engines/prisma-fmt \
PRISMA_CLI_QUERY_ENGINE_TYPE=binary \
PRISMA_CLIENT_ENGINE_TYPE=binary
RUN apk add --no-cache nodejs yarn openssl openssl-dev
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 ./
COPY package.json yarn.lock .yarnrc.yml esbuild.config.js next.config.js next-env.d.ts zip-env.d.ts tsconfig.json mimes.json ./
ENV ZIPLINE_DOCKER_BUILD 1
ENV NEXT_TELEMETRY_DISABLED 1
RUN yarn build
FROM node:16-alpine AS runner
FROM alpine:3.16 AS runner
WORKDIR /zipline
COPY --from=prisma /prisma-engines /prisma-engines
ENV PRISMA_QUERY_ENGINE_BINARY=/prisma-engines/query-engine \
PRISMA_MIGRATION_ENGINE_BINARY=/prisma-engines/migration-engine \
PRISMA_INTROSPECTION_ENGINE_BINARY=/prisma-engines/introspection-engine \
PRISMA_FMT_BINARY=/prisma-engines/prisma-fmt \
PRISMA_CLI_QUERY_ENGINE_TYPE=binary \
PRISMA_CLIENT_ENGINE_TYPE=binary
RUN apk add --no-cache nodejs yarn openssl openssl-dev
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 ./.next
COPY --from=builder /build/dist ./dist
COPY --from=builder /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
COPY --from=builder /build/mimes.json ./mimes.json
CMD ["node", "dist/server"]

View File

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

View File

@@ -1,14 +1,14 @@
<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
@@ -23,13 +23,91 @@
- URL Formats (uuid, dates, random alphanumeric, original name, zws)
- Discord embeds (OG metadata)
- Gallery viewer, and multiple file format support
- Code highlighting
- Easy setup instructions on [docs](https://zipl.vercel.app/) (One command install `docker-compose up -d`)
## Installing
[See how to install here](https://zipl.vercel.app/docs/get-started)
# Usage
## Configuration
[See how to configure here](https://zipl.vercel.app/docs/config/overview)
## Install & run with Docker
This section requires [Docker](https://docs.docker.com/get-docker/) and [docker-compose](https://docs.docker.com/compose/install/).
## Theming
[See how to theme here](https://zipl.vercel.app/docs/themes/reference)
```shell
git clone https://github.com/diced/zipline
cd zipline
docker-compose up -d
```
### After installing
After installing, please edit the `docker-compose.yml` file and find the line that says `SECRET=changethis` and replace `changethis` with a random string.
Ways you could generate the string could be from a password managers generator, or you could just slam your keyboard and hope for the best.
## Building & running from source
This section requires [nodejs](https://nodejs.org), [yarn](https://yarnpkg.com/) or [npm](https://npmjs.com).
```shell
git clone https://github.com/diced/zipline
cd zipline
# npm install
yarn install
# npm run build
yarn build
# npm start
yarn start
```
# NGINX Proxy
This section requires [NGINX](https://nginx.org/).
```nginx
server {
listen 80 default_server;
client_max_body_size 100M;
server_name <your domain (optional)>;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
# Website
The default port is `3000`, once you have accessed it you can see a login screen. The default credentials are "administrator" and "password". Once you login please immediately change the details to something more secure. You can do this by clicking on the top right corner where it says "administrator" with a gear icon and clicking Manage Account.
# ShareX (Windows)
This section requires [ShareX](https://www.getsharex.com/).
After navigating to Zipline, click on the top right corner where it says your username and click Manage Account. Scroll down to see "ShareX Config", select the one you would prefer using. After this you can import the .sxcu into sharex. [More information here](https://zipl.vercel.app/docs/uploaders/sharex)
# Flameshot (Linux)
This section requires [Flameshot](https://www.flameshot.org/), [jq](https://stedolan.github.io/jq/), and [xsel](https://github.com/kfish/xsel).
To upload files using flameshot we will use a script. Replace $TOKEN and $HOST with your own values, you probably know how to do this if you use linux.
```shell
DATE=$(date '+%h_%Y_%d_%I_%m_%S.png');
flameshot gui -r > ~/Pictures/$DATE;
curl -H "Content-Type: multipart/form-data" -H "authorization: $TOKEN" -F file=@$1 $HOST/api/upload | jq -r 'files[0].url' | xsel -ib
```
# Contributing
## Bug reports
Create an issue on GitHub, please include the following (if one of them is not applicable to the issue then it's not needed):
* The steps to reproduce the bug
* Logs of Zipline
* The version of Zipline
* Your OS & Browser including server OS
* What you were expecting to see
## Feature requests
Create an issue on GitHub, please include the following:
* Breif explanation of the feature in the title (very breif please)
* How it would work (detailed, but optional)
## Pull Requests (contributions to the codebase)
Create a pull request on GitHub. If your PR does not pass the action checks, then please fix the errors. If your PR was submitted before a release, and I have pushed a new release, please make sure to update your PR to reflect any changes, usually this is handled by GitHub.

View File

@@ -1,19 +0,0 @@
[core]
secure = true
secret = 'changethis'
host = '0.0.0.0'
port = 3000
database_url = 'postgres://postgres:postgres@postgres/postgres'
[urls]
route = '/go'
length = 6
[uploader]
route = '/u'
embed_route = '/a'
length = 6
directory = './uploads'
user_limit = 104900000 # 100mb
admin_limit = 104900000 # 100mb
disabled_extensions = ['jpg']

View File

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

View File

@@ -23,21 +23,12 @@ services:
- '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
- CORE_HTTPS=false
- CORE_SECRET=changethislol
- CORE_HOST=0.0.0.0
- CORE_PORT=3000
- CORE_DATABASE_URL=postgres://postgres:postgres@postgres/postgres
- CORE_LOGGER=true
volumes:
- '$PWD/uploads:/zipline/uploads'
- '$PWD/public:/zipline/public'

View File

@@ -16,28 +16,19 @@ services:
retries: 5
zipline:
image: ghcr.io/diced/zipline/zipline:trunk
image: ghcr.io/diced/zipline
ports:
- '3000:3000'
restart: always
environment:
- SECURE=false
- SECRET=changethis
- HOST=0.0.0.0
- PORT=3000
- DATASOURCE_TYPE=local
- DATASOURCE_DIRECTORY=./uploads
- DATABASE_URL=postgresql://postgres:postgres@postgres/postgres/
- UPLOADER_ROUTE=/u
- UPLOADER_EMBED_ROUTE=/a
- UPLOADER_LENGTH=6
- UPLOADER_ADMIN_LIMIT=104900000
- UPLOADER_USER_LIMIT=104900000
- UPLOADER_DISABLED_EXTS=
- URLS_ROUTE=/go
- URLS_LENGTH=6
- CORE_HTTPS=false
- CORE_SECRET=changethis
- CORE_HOST=0.0.0.0
- CORE_PORT=3000
- CORE_DATABASE_URL=postgres://postgres:postgres@postgres/postgres
- CORE_LOGGER=true
volumes:
- '$PWD/uploads:/zipline/uploads'
- './uploads:/zipline/uploads'
- '$PWD/public:/zipline/public'
depends_on:
- 'postgres'

View File

@@ -17,17 +17,20 @@ const { rm } = require('fs/promises');
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',
'src/lib/mimes.ts',
'src/lib/exts.ts',
'src/lib/config/Config.ts',
'src/lib/config/readConfig.ts',
'src/lib/config/validateConfig.ts',
'src/lib/datasources/Datasource.ts',
'src/lib/datasources/index.ts',
'src/lib/datasources/Local.ts',
'src/lib/datasources/S3.ts',
'src/lib/datasources/Swift.ts',
'src/lib/datasource.ts',
],
format: 'cjs',
resolveExtensions: ['.ts', '.js'],
@@ -35,6 +38,6 @@ const { rm } = require('fs/promises');
watch,
incremental: watch,
sourcemap: false,
minify: process.env.NODE_ENV === 'production',
minify: false,
});
})();

8298
mimes.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -11,4 +11,6 @@ module.exports = {
api: {
responseLimit: false,
},
poweredByHeader: false,
reactStrictMode: true,
};

View File

@@ -1,9 +1,9 @@
{
"name": "zipline",
"version": "3.4.5",
"version": "3.4.8",
"license": "MIT",
"scripts": {
"dev": "node esbuild.config.js && REACT_EDITOR=code-insiders NODE_ENV=development node dist/server",
"dev": "node esbuild.config.js && REACT_EDITOR=code 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",
@@ -11,7 +11,6 @@
"migrate:dev": "prisma migrate dev --create-only",
"start": "node dist/server",
"lint": "next lint",
"seed": "ts-node --compiler-options \"{\\\"module\\\":\\\"commonjs\\\"}\" --transpile-only prisma/seed.ts",
"docker:run": "docker-compose up -d",
"docker:down": "docker-compose down",
"docker:build-dev": "docker-compose --file docker-compose.dev.yml up --build"
@@ -25,32 +24,33 @@
"@mantine/next": "^4.2.9",
"@mantine/notifications": "^4.2.9",
"@mantine/prism": "^4.2.9",
"@modulz/radix-icons": "^4.0.0",
"@prisma/client": "^3.15.2",
"@prisma/migrate": "^3.15.2",
"@prisma/sdk": "^3.15.2",
"@reduxjs/toolkit": "^1.8.2",
"argon2": "^0.28.5",
"aws-sdk": "^2.1156.0",
"colorette": "^2.0.19",
"cookie": "^0.5.0",
"dotenv": "^16.0.1",
"dotenv-expand": "^8.0.3",
"fecha": "^4.2.3",
"fflate": "^0.7.3",
"find-my-way": "^6.3.0",
"minio": "^7.0.28",
"multer": "^1.4.5-lts.1",
"next": "^12.1.6",
"prisma": "^3.15.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-feather": "^2.0.10",
"react-redux": "^8.0.2",
"react-table": "^7.8.0",
"redux": "^4.2.0",
"redux-thunk": "^2.4.1",
"uuid": "^8.3.2",
"yup": "^0.32.11"
},
"devDependencies": {
"@types/cookie": "^0.5.1",
"@types/minio": "^7.0.13",
"@types/multer": "^1.4.7",
"@types/node": "^15.12.2",
"babel-plugin-import": "^1.13.5",

View File

@@ -0,0 +1,9 @@
/*
Warnings:
- You are about to drop the column `ratelimited` on the `User` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "User" DROP COLUMN "ratelimited",
ADD COLUMN "ratelimit" TIMESTAMP(3);

View File

@@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "Invite" (
"id" SERIAL NOT NULL,
"code" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expires_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"used" BOOLEAN NOT NULL DEFAULT false,
"createdById" INTEGER NOT NULL,
CONSTRAINT "Invite_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Invite_code_key" ON "Invite"("code");
-- AddForeignKey
ALTER TABLE "Invite" ADD CONSTRAINT "Invite_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Invite" ALTER COLUMN "expires_at" DROP NOT NULL,
ALTER COLUMN "expires_at" DROP DEFAULT;

View File

@@ -17,10 +17,11 @@ model User {
embedTitle String?
embedColor String @default("#2f3136")
embedSiteName String? @default("{image.file} • {user.name}")
ratelimited Boolean @default(false)
ratelimit DateTime?
domains String[]
images Image[]
urls Url[]
Invite Invite[]
}
enum ImageFormat {
@@ -75,3 +76,14 @@ model Stats {
created_at DateTime @default(now())
data Json
}
model Invite {
id Int @id @default(autoincrement())
code String @unique
created_at DateTime @default(now())
expires_at DateTime?
used Boolean @default(false)
createdBy User @relation(fields: [createdById], references: [id])
createdById Int
}

View File

@@ -1,38 +0,0 @@
// 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,35 +0,0 @@
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 config = readConfig();
await validateConfig(config);
process.env.DATABASE_URL = config.core.database_url;
const files = await readdir(process.argv[2]);
const data = files.map(x => {
const mime = mimes[extname(x)] ?? 'application/octet-stream';
return {
file: x,
mimetype: mime,
userId: 1,
};
});
const prisma = new PrismaClient();
Logger.get('migrator').info('starting migrations...');
await prisma.image.createMany({
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

@@ -1,78 +0,0 @@
module.exports = {
'.aac': 'audio/aac',
'.abw': 'application/x-abiword',
'.arc': 'application/x-freearc',
'.avi': 'video/x-msvideo',
'.azw': 'application/vnd.amazon.ebook',
'.bin': 'application/octet-stream',
'.bmp': 'image/bmp',
'.bz': 'application/x-bzip',
'.bz2': 'application/x-bzip2',
'.cda': 'application/x-cdf',
'.csh': 'application/x-csh',
'.css': 'text/css',
'.csv': 'text/csv',
'.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.eot': 'application/vnd.ms-fontobject',
'.epub': 'application/epub+zip',
'.gz': 'application/gzip',
'.gif': 'image/gif',
'.htm': 'text/html',
'.html': 'text/html',
'.ico': 'image/vnd.microsoft.icon',
'.ics': 'text/calendar',
'.jar': 'application/java-archive',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.js': 'text/javascript',
'.json': 'application/json',
'.jsonld': 'application/ld+json',
'.mid': 'audio/midi',
'.midi': 'audio/midi',
'.mjs': 'text/javascript',
'.mp3': 'audio/mpeg',
'.mp4': 'video/mp4',
'.mpeg': 'video/mpeg',
'.mpkg': 'application/vnd.apple.installer+xml',
'.odp': 'application/vnd.oasis.opendocument.presentation',
'.ods': 'application/vnd.oasis.opendocument.spreadsheet',
'.odt': 'application/vnd.oasis.opendocument.text',
'.oga': 'audio/ogg',
'.ogv': 'video/ogg',
'.ogx': 'application/ogg',
'.opus': 'audio/opus',
'.otf': 'font/otf',
'.png': 'image/png',
'.pdf': 'application/pdf',
'.php': 'application/x-httpd-php',
'.ppt': 'application/vnd.ms-powerpoint',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'.rar': 'application/vnd.rar',
'.rtf': 'application/rtf',
'.sh': 'application/x-sh',
'.svg': 'image/svg+xml',
'.swf': 'application/x-shockwave-flash',
'.tar': 'application/x-tar',
'.tif': 'image/tiff',
'.tiff': 'image/tiff',
'.ts': 'video/mp2t',
'.ttf': 'font/ttf',
'.txt': 'text/plain',
'.vsd': 'application/vnd.visio',
'.wav': 'audio/wav',
'.weba': 'audio/webm',
'.webm': 'video/webm',
'.webp': 'image/webp',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.xhtml': 'application/xhtml+xml',
'.xls': 'application/vnd.ms-excel',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.xml': 'application/xml',
'.xul': 'application/vnd.mozilla.xul+xml',
'.zip': 'application/zip',
'.3gp': 'video/3gpp',
'.3g2': 'video/3gpp2',
'.7z': 'application/x-7z-compressed',
};

View File

@@ -1,8 +0,0 @@
import React from 'react';
import { LoadingOverlay } from '@mantine/core';
export default function Backdrop({ open }) {
return (
<LoadingOverlay visible={open} />
);
}

View File

@@ -1,14 +1,9 @@
import React from 'react';
import {
Card as MCard,
Title,
} from '@mantine/core';
import { Card as MCard, Title } from '@mantine/core';
export default function Card(props) {
const { name, children, ...other } = props;
export default function Card({ name, children, ...other }) {
return (
<MCard padding='md' shadow='sm' {...other}>
<MCard p='md' shadow='sm' {...other}>
<Title order={2}>{name}</Title>
{children}
</MCard>

View File

@@ -0,0 +1,21 @@
import { createStyles, MantineSize, Textarea } from '@mantine/core';
const useStyles = createStyles((theme, { size }: { size: MantineSize }) => ({
input: {
fontFamily: 'monospace',
fontSize: theme.fn.size({ size, sizes: theme.fontSizes }) - 2,
height: '100vh',
},
}));
export default function CodeInput({ ...props }) {
const { classes } = useStyles({ size: 'md' }, { name: 'CodeInput' });
return (
<Textarea
classNames={{ input: classes.input }}
autoComplete='nope'
{...props}
/>
);
}

View File

@@ -1,35 +1,49 @@
import React, { useState } from 'react';
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 { Button, Card, Grid, Group, Image as MImage, Modal, Stack, Text, Title, useMantineTheme } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { useNotifications } from '@mantine/notifications';
import useFetch from 'hooks/useFetch';
import { useState } from 'react';
import Type from './Type';
import { CalendarIcon, CopyIcon, CrossIcon, DeleteIcon, FileIcon, HashIcon, ImageIcon, StarIcon } from './icons';
import MutedText from './MutedText';
export default function Image({ image, updateImages }) {
export function FileMeta({ Icon, title, subtitle }) {
return (
<Group>
<Icon size={24} />
<Stack spacing={1}>
<Text>{title}</Text>
<MutedText size='md'>{subtitle}</MutedText>
</Stack>
</Group>
);
}
export default function File({ image, updateImages }) {
const [open, setOpen] = useState(false);
const [t] = useState(image.mimetype.split('/')[0]);
const notif = useNotifications();
const clipboard = useClipboard();
const theme = useMantineTheme();
const handleDelete = async () => {
const res = await useFetch('/api/user/files', 'DELETE', { id: image.id });
if (!res.error) {
updateImages(true);
notif.showNotification({
title: 'Image Deleted',
title: 'File Deleted',
message: '',
color: 'green',
icon: <TrashIcon />,
icon: <DeleteIcon />,
});
} else {
notif.showNotification({
title: 'Failed to delete image',
title: 'Failed to delete file',
message: res.error,
color: 'red',
icon: <Cross1Icon />,
icon: <CrossIcon />,
});
}
setOpen(false);
};
@@ -53,13 +67,6 @@ export default function Image({ image, updateImages }) {
});
};
const Type = (props) => {
return {
'video': <video controls {...props} />,
'image': <MImage {...props} />,
'audio': <audio controls {...props} />,
}[t];
};
return (
<>
@@ -67,11 +74,27 @@ export default function Image({ image, updateImages }) {
opened={open}
onClose={() => setOpen(false)}
title={<Title>{image.file}</Title>}
size='xl'
overlayBlur={3}
overlayColor={theme.colorScheme === 'dark' ? theme.colors.dark[6] : 'white'}
>
<Type
src={image.url}
alt={image.file}
/>
<Stack>
<Type
file={image}
src={image.url}
alt={image.file}
popup
sx={{ minHeight: 200 }}
style={{ minHeight: 200 }}
/>
<Stack>
<FileMeta Icon={FileIcon} title='Name' subtitle={image.file} />
<FileMeta Icon={ImageIcon} title='Type' subtitle={image.mimetype} />
<FileMeta Icon={CalendarIcon} title='Uploaded at' subtitle={new Date(image.created_at).toLocaleString()} />
<FileMeta Icon={HashIcon} title='ID' subtitle={image.id} />
</Stack>
</Stack>
<Group position='right' mt={22}>
<Button onClick={handleCopy}>Copy</Button>
<Button onClick={handleDelete}>Delete</Button>
@@ -81,8 +104,9 @@ export default function Image({ image, updateImages }) {
<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' }}
file={image}
sx={{ minHeight: 200, maxHeight: 320, fontSize: 70, width: '100%', cursor: 'pointer' }}
style={{ minHeight: 200, maxHeight: 320, fontSize: 70, width: '100%', cursor: 'pointer' }}
src={image.url}
alt={image.file}
onClick={() => setOpen(true)}

View File

@@ -1,27 +1,20 @@
/* 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,
Group, Image, Pagination,
Select,
Table,
Text,
useMantineTheme,
} from '@mantine/core';
import {
CopyIcon,
EnterIcon,
TrashIcon,
} from '@modulz/radix-icons';
usePagination,
useTable,
} from 'react-table';
import { CopyIcon, DeleteIcon, EnterIcon } from './icons';
const pageSizeOptions = ['10', '25', '50'];
@@ -42,6 +35,26 @@ const useStyles = createStyles((t) => ({
sortDirectionIcon: { transition: 'transform 200ms ease' },
}));
export function FilePreview({ url, type }) {
const Type = props => {
return {
'video': <video autoPlay controls {...props} />,
'image': <Image {...props} />,
'audio': <audio autoPlay controls {...props} />,
}[type.split('/')[0]];
};
return (
<Type
sx={{ maxWidth: '10vw', maxHeight: '100vh' }}
style={{ maxWidth: '10vw', maxHeight: '100vh' }}
mr='sm'
src={url}
alt={'Unable to preview file'}
/>
);
}
export default function ImagesTable({
columns,
data = [],
@@ -71,17 +84,14 @@ export default function ImagesTable({
const getPageRecordInfo = () => {
const firstRowNum = pageIndex * pageSize + 1;
const totalRows = serverSideDataSource ? total : rows.length;
const totalRows = 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 getPageCount = () => Math.ceil(rows.length / pageSize);
const handlePageChange = (pageNum) => gotoPage(pageNum - 1);
@@ -109,7 +119,7 @@ export default function ImagesTable({
))}
<td align='right'>
<Group noWrap>
<ActionIcon color='red' variant='outline' onClick={() => deleteImage(row)}><TrashIcon /></ActionIcon>
<ActionIcon color='red' variant='outline' onClick={() => deleteImage(row)}><DeleteIcon /></ActionIcon>
<ActionIcon color='primary' variant='outline' onClick={() => copyImage(row)}><CopyIcon /></ActionIcon>
<ActionIcon color='green' variant='outline' onClick={() => viewImage(row)}><EnterIcon /></ActionIcon>
</Group>

View File

@@ -1,15 +1,14 @@
import React, { useState } from 'react';
import Link from 'next/link';
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 { AppShell, Box, Burger, Divider, Group, Header, MediaQuery, Navbar, Paper, Popover, ScrollArea, Select, Text, ThemeIcon, Title, UnstyledButton, useMantineTheme } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { useModals } from '@mantine/modals';
import { useNotifications } from '@mantine/notifications';
import { useClipboard } from '@mantine/hooks';
import useFetch from 'hooks/useFetch';
import { updateUser } from 'lib/redux/reducers/user';
import { useStoreDispatch } from 'lib/redux/store';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { ActivityIcon, CheckIcon, CopyIcon, CrossIcon, DeleteIcon, FileIcon, HomeIcon, LinkIcon, LogoutIcon, PencilIcon, SettingsIcon, TagIcon, TypeIcon, UploadIcon, UserIcon } from './icons';
import { friendlyThemeName, themes } from './Theming';
function MenuItemLink(props) {
@@ -75,12 +74,12 @@ const items = [
link: '/dashboard/files',
},
{
icon: <MixerHorizontalIcon />,
icon: <ActivityIcon />,
text: 'Stats',
link: '/dashboard/stats',
},
{
icon: <Link1Icon />,
icon: <LinkIcon />,
text: 'URLs',
link: '/dashboard/urls',
},
@@ -89,9 +88,14 @@ const items = [
text: 'Upload',
link: '/dashboard/upload',
},
{
icon: <TypeIcon />,
text: 'Upload Text',
link: '/dashboard/text',
},
];
export default function Layout({ children, user }) {
export default function Layout({ children, user, title }) {
const [token, setToken] = useState(user?.token);
const [systemTheme, setSystemTheme] = useState(user.systemTheme ?? 'system');
const [opened, setOpened] = useState(false); // navigation open
@@ -116,7 +120,7 @@ export default function Layout({ children, user }) {
title: `Theme changed to ${friendlyThemeName[value]}`,
message: '',
color: 'green',
icon: <Pencil1Icon />,
icon: <PencilIcon />,
});
};
@@ -138,7 +142,7 @@ export default function Layout({ children, user }) {
title: 'Token Reset Failed',
message: a.error,
color: 'red',
icon: <Cross1Icon />,
icon: <CrossIcon />,
});
} else {
notif.showNotification({
@@ -198,7 +202,7 @@ export default function Layout({ children, user }) {
{items.map(({ icon, text, link }) => (
<Link href={link} key={text} passHref>
<UnstyledButton
sx={{
sx={{
display: 'block',
width: '100%',
padding: theme.spacing.xs,
@@ -221,29 +225,54 @@ export default function Layout({ children, user }) {
</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>
<>
<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'>
<UserIcon />
</ThemeIcon>
<Text size='lg'>Users</Text>
</Group>
</UnstyledButton>
</Link>
<Text size='lg'>Users</Text>
</Group>
</UnstyledButton>
</Link>
<Link href='/dashboard/invites' 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'>
<TagIcon />
</ThemeIcon>
<Text size='lg'>Invites</Text>
</Group>
</UnstyledButton>
</Link>
</>
)}
</Navbar.Section>
</Navbar>
@@ -259,7 +288,7 @@ export default function Layout({ children, user }) {
color={theme.colors.gray[6]}
/>
</MediaQuery>
<Title sx={{ marginLeft: 12 }}>Zipline</Title>
<Title ml='md'>{title}</Title>
<Box sx={{ marginLeft: 'auto', marginRight: 0 }}>
<Popover
position='top'
@@ -284,7 +313,7 @@ export default function Layout({ children, user }) {
>
<Group>
<ThemeIcon color='primary' variant='filled'>
<GearIcon />
<SettingsIcon />
</ThemeIcon>
<Text>{user.username}</Text>
</Group>
@@ -295,14 +324,17 @@ export default function Layout({ children, user }) {
<Text sx={{
color: theme.colorScheme === 'dark' ? theme.colors.dark[2] : theme.colors.gray[6],
fontWeight: 500,
fontSize: theme.fontSizes.xs,
fontSize: theme.fontSizes.sm,
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>
}}
>
{user.username}
</Text>
<MenuItemLink icon={<SettingsIcon />} 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>
<MenuItem icon={<DeleteIcon />} onClick={() => {setOpen(false);openResetToken();}} color='red'>Reset Token</MenuItem>
<MenuItemLink icon={<LogoutIcon />} href='/auth/logout' color='red'>Logout</MenuItemLink>
<Divider
variant='solid'
my={theme.spacing.xs / 2}
@@ -312,7 +344,7 @@ export default function Layout({ children, user }) {
margin: `${theme.spacing.xs / 2}px -4px`,
})}
/>
<MenuItem icon={<Pencil1Icon />}>
<MenuItem icon={<PencilIcon />}>
<Select
size='xs'
data={Object.keys(themes).map(t => ({ value: t, label: friendlyThemeName[t] }))}

View File

@@ -1,76 +1,3 @@
/// https://github.com/mui-org/material-ui/blob/next/examples/nextjs/src/Link.js
/* eslint-disable jsx-a11y/anchor-has-content */
import React, { forwardRef } from 'react';
import clsx from 'clsx';
import { useRouter } from 'next/router';
import NextLink from 'next/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 } =
props;
return (
<NextLink
href={to}
prefetch={prefetch}
as={linkAs}
replace={replace}
scroll={scroll}
shallow={shallow}
passHref={passHref}
locale={locale}
>
<a ref={ref} {...other} />
</NextLink>
);
});
// A styled version of the Next.js Link component:
// https://nextjs.org/docs/#with-link
const Link = forwardRef(function Link(props: any, ref) {
const {
activeClassName = 'active',
as: linkAs,
className: classNameProps,
href,
noLinkStyle,
role, // Link don't have roles.
...other
} = props;
const router = useRouter();
const pathname = typeof href === 'string' ? href : href.pathname;
const className = clsx(classNameProps, {
[activeClassName]: router.pathname === pathname && activeClassName,
});
const isExternal =
typeof href === 'string' && (href.indexOf('http') === 0 || href.indexOf('mailto:') === 0);
if (isExternal) {
if (noLinkStyle) {
return <Text variant='link' component='a' className={className} href={href} ref={ref} {...other} />;
}
return <Text component='a' variant='link' href={href} ref={ref} {...other} />;
}
if (noLinkStyle) {
return <NextLinkComposed className={className} ref={ref} to={href} {...other} />;
}
return (
<Text
component={NextLinkComposed}
variant='link'
linkAs={linkAs}
className={className}
ref={ref}
to={href}
{...other}
/>
);
});
import { NextLink as Link } from '@mantine/next';
export default Link;

View File

@@ -0,0 +1,5 @@
import { Text } from '@mantine/core';
export default function MutedText({ children, ...props }) {
return <Text color='gray' size='xl' {...props}>{children}</Text>;
}

View File

@@ -0,0 +1,75 @@
// https://mantine.dev/core/password-input/
import { useState } from 'react';
import { PasswordInput, Progress, Text, Popover, Box } from '@mantine/core';
import { CheckIcon, CrossIcon } from './icons';
function PasswordRequirement({ meets, label }: { meets: boolean; label: string }) {
return (
<Text
color={meets ? 'teal' : 'red'}
sx={{ display: 'flex', alignItems: 'center' }}
mt='sm'
size='sm'
>
{meets ? <CheckIcon /> : <CrossIcon />} <Box ml='md'>{label}</Box>
</Text>
);
}
const requirements = [
{ re: /[0-9]/, label: 'Includes number' },
{ re: /[a-z]/, label: 'Includes lowercase letter' },
{ re: /[A-Z]/, label: 'Includes uppercase letter' },
{ re: /[$&+,:;=?@#|'<>.^*()%!-]/, label: 'Includes special symbol' },
];
function getStrength(password: string) {
let multiplier = password.length > 7 ? 0 : 1;
requirements.forEach((requirement) => {
if (!requirement.re.test(password)) {
multiplier += 1;
}
});
return Math.max(100 - (100 / (requirements.length + 1)) * multiplier, 10);
}
export default function PasswordStrength({ value, setValue, setStrength, ...props }) {
const [popoverOpened, setPopoverOpened] = useState(false);
const checks = requirements.map((requirement, index) => (
<PasswordRequirement key={index} label={requirement.label} meets={requirement.re.test(value)} />
));
const strength = getStrength(value);
setStrength(strength);
const color = strength === 100 ? 'teal' : strength > 50 ? 'yellow' : 'red';
return (
<Popover
opened={popoverOpened}
position='bottom'
placement='start'
withArrow
trapFocus={false}
transition='pop-top-left'
onFocusCapture={() => setPopoverOpened(true)}
onBlurCapture={() => setPopoverOpened(false)}
styles={{ root: { width: '100%' } }}
target={
<PasswordInput
label='Password'
description='Strong password should include letters in lower and uppercase, at least 1 number, at least 1 special symbol'
value={value}
onChange={(event) => setValue(event.currentTarget.value)}
{...props}
/>
}
>
<Progress color={color} value={strength} size={7} mb='md' />
<PasswordRequirement label='Includes at least 8 characters' meets={value.length > 7} />
{checks}
</Popover>
);
}

View File

@@ -0,0 +1,29 @@
import { Box, Table } from '@mantine/core';
import { randomId } from '@mantine/hooks';
export function SmallTable({ 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>
);
}

View File

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

View File

@@ -1,22 +1,22 @@
import React, { useEffect } from 'react';
import { 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 ayu_mirage from 'lib/themes/ayu_mirage';
import dark from 'lib/themes/dark';
import dark_blue from 'lib/themes/dark_blue';
import dracula from 'lib/themes/dracula';
import light_blue from 'lib/themes/light_blue';
import matcha_dark_azul from 'lib/themes/matcha_dark_azul';
import nord from 'lib/themes/nord';
import qogir_dark from 'lib/themes/qogir_dark';
import { useStoreSelector } from 'lib/redux/store';
import { MantineProvider, MantineThemeOverride } from '@mantine/core';
import { useColorScheme } from '@mantine/hooks';
import { ModalsProvider } from '@mantine/modals';
import { NotificationsProvider } from '@mantine/notifications';
import { useColorScheme } from '@mantine/hooks';
import { useStoreSelector } from 'lib/redux/store';
export const themes = {
system: (colorScheme: 'dark' | 'light') => colorScheme === 'dark' ? dark_blue : light_blue,

46
src/components/Type.tsx Normal file
View File

@@ -0,0 +1,46 @@
import { Group, Image, Stack, Text } from '@mantine/core';
import { Prism } from '@mantine/prism';
import { useEffect, useState } from 'react';
import { AudioIcon, FileIcon, PlayIcon, TypeIcon, VideoIcon } from './icons';
import MutedText from './MutedText';
function Placeholder({ text, Icon, ...props }) {
return (
<Image height={200} withPlaceholder placeholder={
<Group>
<Icon size={48} />
<Text>{text}</Text>
</Group>
} {...props} />
);
}
export default function Type({ file, popup = false, ...props }){
const type = (file.type || file.mimetype).split('/')[0];
const name = (file.name || file.file);
const [text, setText] = useState('');
if (type === 'text') {
useEffect(() => {
(async () => {
const res = await fetch('/r/' + name);
const text = await res.text();
setText(text);
})();
}, []);
}
return popup ? {
'video': <video width='100%' autoPlay controls {...props} />,
'image': <Image {...props} />,
'audio': <audio autoPlay controls {...props} style={{ width: '100%' }}/>,
'text': <Prism withLineNumbers language={name.split('.').pop()} {...props} style={{}} sx={{}}>{text}</Prism>,
}[type] : {
'video': <Placeholder Icon={PlayIcon} text={`Click to view video (${name})`} {...props} />,
'image': <Image {...props} />,
'audio': <Placeholder Icon={AudioIcon} text={`Click to view audio (${name})`} {...props}/>,
'text': <Placeholder Icon={FileIcon} text={`Click to view text file (${name})`} {...props}/>,
}[type];
};

View File

@@ -0,0 +1,52 @@
import React from 'react';
import { Dropzone as MantineDropzone } from '@mantine/dropzone';
import { Group, Text, useMantineTheme } from '@mantine/core';
import { CrossIcon, UploadIcon, ImageIcon } from 'components/icons';
function ImageUploadIcon({ status, ...props }) {
if (status.accepted) {
return <UploadIcon {...props} />;
}
if (status.rejected) {
return <CrossIcon {...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 Dropzone({ loading, onDrop, children }) {
const theme = useMantineTheme();
return (
<MantineDropzone loading={loading} onDrop={onDrop}>
{status => (
<>
<Group position='center' spacing='xl' style={{ minHeight: 440, pointerEvents: 'none' }}>
<ImageUploadIcon
status={status}
style={{ width: 80, height: 80, color: getIconColor(status, theme) }}
/>
<Text size='xl' inline>
Drag images here or click to select files
</Text>
</Group>
{children}
</>
)}
</MantineDropzone>
);
}

View File

@@ -0,0 +1,54 @@
import React from 'react';
import { Table, Tooltip, Badge, useMantineTheme } from '@mantine/core';
import Type from 'components/Type';
export function FilePreview({ file }: { file: File }) {
return (
<Type
file={file}
autoPlay
sx={{ maxWidth: '10vw', maxHeight: '100vh' }}
style={{ maxWidth: '10vw', maxHeight: '100vh' }}
src={URL.createObjectURL(file)}
alt={file.name}
/>
);
}
export default function FileDropzone({ file }: { file: File }) {
const theme = useMantineTheme();
return (
<Tooltip
position='top'
placement='center'
allowPointerEvents
label={
<div style={{ display: 'flex', alignItems: 'center' }}>
<FilePreview file={file} />
<Table sx={{ color: theme.colorScheme === 'dark' ? 'black' : 'white' }} ml='md'>
<tbody>
<tr>
<td>Name</td>
<td>{file.name}</td>
</tr>
<tr>
<td>Type</td>
<td>{file.type}</td>
</tr>
<tr>
<td>Last Modified</td>
<td>{new Date(file.lastModified).toLocaleString()}</td>
</tr>
</tbody>
</Table>
</div>
}
>
<Badge size='lg'>
{file.name}
</Badge>
</Tooltip>
);
}

View File

@@ -0,0 +1,5 @@
import { Activity } from 'react-feather';
export default function ActivityIcon({ ...props }) {
return <Activity size={15} {...props} />;
}

View File

@@ -0,0 +1,5 @@
import { Disc } from 'react-feather';
export default function AudioIcon({ ...props }) {
return <Disc size={15} {...props} />;
}

View File

@@ -0,0 +1,5 @@
import { Calendar } from 'react-feather';
export default function CalendarIcon({ ...props }) {
return <Calendar size={15} {...props} />;
}

View File

@@ -0,0 +1,5 @@
import { Check } from 'react-feather';
export default function CheckIcon({ ...props }) {
return <Check size={15} {...props} />;
}

View File

@@ -0,0 +1,5 @@
import { Copy } from 'react-feather';
export default function CopyIcon({ ...props }) {
return <Copy size={15} {...props} />;
}

View File

@@ -0,0 +1,5 @@
import { X } from 'react-feather';
export default function CrossIcon({ ...props }) {
return <X size={15} {...props} />;
}

View File

@@ -0,0 +1,5 @@
import { Delete } from 'react-feather';
export default function DeleteIcon({ ...props }) {
return <Delete size={15} {...props} />;
}

View File

@@ -0,0 +1,5 @@
import { Download } from 'react-feather';
export default function DownloadIcon({ ...props }) {
return <Download size={15} {...props} />;
}

View File

@@ -0,0 +1,5 @@
import { LogIn } from 'react-feather';
export default function EnterIcon({ ...props }) {
return <LogIn size={15} {...props} />;
}

View File

@@ -0,0 +1,5 @@
import { File } from 'react-feather';
export default function FileIcon({ ...props }) {
return <File size={15} {...props} />;
}

View File

@@ -0,0 +1,5 @@
import { Hash } from 'react-feather';
export default function HashIcon({ ...props }) {
return <Hash size={15} {...props} />;
}

View File

@@ -0,0 +1,5 @@
import { Home } from 'react-feather';
export default function HomeIcon({ ...props }) {
return <Home size={15} {...props} />;
}

View File

@@ -0,0 +1,5 @@
import { Image as FeatherImage } from 'react-feather';
export default function ImageIcon({ ...props }) {
return <FeatherImage size={15} {...props} />;
}

View File

@@ -0,0 +1,5 @@
import { Link } from 'react-feather';
export default function LinkIcon({ ...props }) {
return <Link size={15} {...props} />;
}

View File

@@ -0,0 +1,5 @@
import { LogOut } from 'react-feather';
export default function LogoutIcon({ ...props }) {
return <LogOut size={15} {...props} />;
}

View File

@@ -0,0 +1,5 @@
import { Edit2 } from 'react-feather';
export default function PencilIcon({ ...props }) {
return <Edit2 size={15} {...props} />;
}

View File

@@ -0,0 +1,5 @@
import { Play } from 'react-feather';
export default function PlayIcon({ ...props }) {
return <Play size={15} {...props} />;
}

View File

@@ -0,0 +1,5 @@
import { Plus } from 'react-feather';
export default function PlusIcon({ ...props }) {
return <Plus size={15} {...props} />;
}

View File

@@ -0,0 +1,5 @@
import { Settings } from 'react-feather';
export default function SettingsIcon({ ...props }) {
return <Settings size={15} {...props} />;
}

View File

@@ -0,0 +1,5 @@
import { Star } from 'react-feather';
export default function StarIcon({ ...props }) {
return <Star size={15} {...props} />;
}

View File

@@ -0,0 +1,5 @@
import { Tag } from 'react-feather';
export default function TagIcon({ ...props }) {
return <Tag size={15} {...props} />;
}

View File

@@ -0,0 +1,5 @@
import { Type } from 'react-feather';
export default function TypeIcon({ ...props }) {
return <Type size={15} {...props} />;
}

View File

@@ -0,0 +1,5 @@
import { Upload } from 'react-feather';
export default function UploadIcon({ ...props }) {
return <Upload size={15} {...props} />;
}

View File

@@ -0,0 +1,5 @@
import { User } from 'react-feather';
export default function UserIcon({ ...props }) {
return <User size={15} {...props} />;
}

View File

@@ -0,0 +1,5 @@
import { Video } from 'react-feather';
export default function VideoIcon({ ...props }) {
return <Video size={15} {...props} />;
}

View File

@@ -0,0 +1,51 @@
import ActivityIcon from './ActivityIcon';
import CheckIcon from './CheckIcon';
import CopyIcon from './CopyIcon';
import CrossIcon from './CrossIcon';
import DeleteIcon from './DeleteIcon';
import FileIcon from './FileIcon';
import HomeIcon from './HomeIcon';
import LinkIcon from './LinkIcon';
import LogoutIcon from './LogoutIcon';
import PencilIcon from './PencilIcon';
import SettingsIcon from './SettingsIcon';
import TypeIcon from './TypeIcon';
import UploadIcon from './UploadIcon';
import UserIcon from './UserIcon';
import EnterIcon from './EnterIcon';
import PlusIcon from './PlusIcon';
import ImageIcon from './ImageIcon';
import StarIcon from './StarIcon';
import AudioIcon from './AudioIcon';
import VideoIcon from './VideoIcon';
import PlayIcon from './PlayIcon';
import CalendarIcon from './CalendarIcon';
import HashIcon from './HashIcon';
import TagIcon from './TagIcon';
export {
ActivityIcon,
CheckIcon,
CopyIcon,
CrossIcon,
DeleteIcon,
FileIcon,
HomeIcon,
LinkIcon,
LogoutIcon,
PencilIcon,
SettingsIcon,
TypeIcon,
UploadIcon,
UserIcon,
EnterIcon,
PlusIcon,
ImageIcon,
StarIcon,
AudioIcon,
VideoIcon,
PlayIcon,
CalendarIcon,
HashIcon,
TagIcon,
};

View File

@@ -1,33 +1,19 @@
import React, { useEffect, useState } from 'react';
import { SimpleGrid, Skeleton, Text, Title } from '@mantine/core';
import { randomId, useClipboard } from '@mantine/hooks';
import { useNotifications } from '@mantine/notifications';
import Card from 'components/Card';
import ZiplineImage from 'components/Image';
import File from 'components/File';
import { CopyIcon, CrossIcon, DeleteIcon } from 'components/icons';
import ImagesTable from 'components/ImagesTable';
import Link from 'components/Link';
import MutedText from 'components/MutedText';
import { bytesToRead } from 'lib/clientUtils';
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';
import { useEffect, useState } from 'react';
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;
while (bytes > 1024) {
bytes /= 1024;
++num;
}
return `${bytes.toFixed(1)} ${units[num]}`;
}
export default function Dashboard() {
const user = useStoreSelector(state => state.user);
@@ -54,14 +40,14 @@ export default function Dashboard() {
title: 'Image Deleted',
message: '',
color: 'green',
icon: <TrashIcon />,
icon: <DeleteIcon />,
});
} else {
notif.showNotification({
title: 'Failed to delete image',
message: res.error,
color: 'red',
icon: <Cross1Icon />,
icon: <CrossIcon />,
});
}
@@ -86,8 +72,8 @@ export default function Dashboard() {
return (
<>
<Title>Welcome back {user?.username}</Title>
<Text color='gray' sx={{ paddingBottom: 4 }}>You have <b>{images.length ? images.length : '...'}</b> files</Text>
<Title>Welcome back, {user?.username}</Title>
<Text color='gray' mb='sm'>You have <b>{images.length ? images.length : '...'}</b> files</Text>
<Title>Recent Files</Title>
<SimpleGrid
@@ -98,7 +84,7 @@ export default function Dashboard() {
]}
>
{recent.length ? recent.map(image => (
<ZiplineImage key={randomId()} image={image} updateImages={updateImages} />
<File key={randomId()} image={image} updateImages={updateImages} />
)) : [1,2,3,4].map(x => (
<div key={x}>
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }}/>
@@ -116,20 +102,22 @@ export default function Dashboard() {
]}
>
<Card name='Size' sx={{ height: '100%' }}>
<StatText>{stats ? stats.size : <Skeleton height={8} />}</StatText>
<MutedText>{stats ? stats.size : <Skeleton height={8} />}</MutedText>
<Title order={2}>Average Size</Title>
<StatText>{stats ? bytesToRead(stats.size_num / stats.count) : <Skeleton height={8} />}</StatText>
<MutedText>{stats ? bytesToRead(stats.size_num / stats.count) : <Skeleton height={8} />}</MutedText>
</Card>
<Card name='Images' sx={{ height: '100%' }}>
<StatText>{stats ? stats.count : <Skeleton height={8} />}</StatText>
<MutedText>{stats ? stats.count : <Skeleton height={8} />}</MutedText>
<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>
<MutedText>{stats ? `${stats.views_count} (${isNaN(stats.views_count / stats.count) ? 0 : Math.round(stats.views_count / stats.count)})` : <Skeleton height={8} />}</MutedText>
</Card>
<Card name='Users' sx={{ height: '100%' }}>
<StatText>{stats ? stats.count_users : <Skeleton height={8} />}</StatText>
<MutedText>{stats ? stats.count_users : <Skeleton height={8} />}</MutedText>
</Card>
</SimpleGrid>
<Title mt='md'>Files</Title>
<Text>View your gallery <Link href='/dashboard/files'>here</Link>.</Text>
<ImagesTable
columns={[
{ accessor: 'file', Header: 'Name', minWidth: 170, align: 'inherit' as Aligns },
@@ -141,34 +129,6 @@ export default function Dashboard() {
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: '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,10 +1,9 @@
import React, { useEffect, useState } from 'react';
import ZiplineImage from 'components/Image';
import { Accordion, ActionIcon, Box, Group, Pagination, SimpleGrid, Skeleton, Title } from '@mantine/core';
import File from 'components/File';
import { PlusIcon } from 'components/icons';
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';
import { useEffect, useState } from 'react';
export default function Files() {
const [pages, setPages] = useState([]);
@@ -27,48 +26,50 @@ export default function Files() {
return (
<>
<Group>
<Title sx={{ marginBottom: 12 }}>Files</Title>
<Group mb='md'>
<Title>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>
{favoritePages.length ? (
<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}>
<File 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>
) : null}
<SimpleGrid
cols={3}
spacing='lg'
@@ -78,7 +79,7 @@ export default function Files() {
>
{pages.length ? pages[(page - 1) ?? 0].map(image => (
<div key={image.id}>
<ZiplineImage image={image} updateImages={() => updatePages(true)} />
<File image={image} updateImages={() => updatePages(true)} />
</div>
)) : [1,2,3,4].map(x => (
<div key={x}>

View File

@@ -0,0 +1,204 @@
import { ActionIcon, Avatar, Button, Card, Group, Modal, Select, SimpleGrid, Skeleton, Stack, Switch, TextInput, Title } from '@mantine/core';
import { useClipboard, useForm } from '@mantine/hooks';
import { useModals } from '@mantine/modals';
import { useNotifications } from '@mantine/notifications';
import { CopyIcon, CrossIcon, DeleteIcon, PlusIcon, TagIcon } from 'components/icons';
import MutedText from 'components/MutedText';
import useFetch from 'hooks/useFetch';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
const expires = [
'30m',
'1h',
'6h',
'12h',
'1d',
'3d',
'5d',
'7d',
'never',
];
function CreateInviteModal({ open, setOpen, updateInvites }) {
const form = useForm({
initialValues: {
expires: '30m',
},
});
const notif = useNotifications();
const onSubmit = async values => {
if (!expires.includes(values.expires)) return form.setFieldError('expires', 'Invalid expiration');
const expires_at = values.expires === 'never' ? null : new Date({
'30m': Date.now() + 30 * 60 * 1000,
'1h': Date.now() + 60 * 60 * 1000,
'6h': Date.now() + 6 * 60 * 60 * 1000,
'12h': Date.now() + 12 * 60 * 60 * 1000,
'1d': Date.now() + 24 * 60 * 60 * 1000,
'5d': Date.now() + 5 * 24 * 60 * 60 * 1000,
'7d': Date.now() + 7 * 24 * 60 * 60 * 1000,
}[values.expires]);
setOpen(false);
const res = await useFetch('/api/auth/invite', 'POST', {
expires_at,
});
if (res.error) {
notif.showNotification({
title: 'Failed to create invite',
message: res.error,
icon: <CrossIcon />,
color: 'red',
});
} else {
notif.showNotification({
title: 'Created invite',
message: '',
icon: <TagIcon />,
color: 'green',
});
}
updateInvites();
};
return (
<Modal
opened={open}
onClose={() => setOpen(false)}
title={<Title>Create Invite</Title>}
overlayBlur={3}
centered={true}
>
<form onSubmit={form.onSubmit(v => onSubmit(v))}>
<Select
label='Expires'
id='expires'
{...form.getInputProps('expires')}
data={[
{ value: '30m', label: '30 minutes' },
{ value: '1h', label: '1 hour' },
{ value: '6h', label: '6 hours' },
{ value: '12h', label: '12 hours' },
{ value: '1d', label: '1 day' },
{ value: '3d', label: '3 days' },
{ value: '5d', label: '5 days' },
{ value: '7d', label: '7 days' },
{ value: 'never', label: 'Never' },
]}
/>
<Group position='right' mt={22}>
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button type='submit'>Create</Button>
</Group>
</form>
</Modal>
);
}
export default function Users() {
const router = useRouter();
const notif = useNotifications();
const modals = useModals();
const clipboard = useClipboard();
const [invites, setInvites] = useState([]);
const [open, setOpen] = useState(false);
const openDeleteModal = invite => modals.openConfirmModal({
title: `Delete ${invite.code}?`,
centered: true,
overlayBlur: 3,
labels: { confirm: 'Yes', cancel: 'No' },
onConfirm: async () => {
const res = await useFetch(`/api/auth/invite?code=${invite.code}`, 'DELETE');
if (res.error) {
notif.showNotification({
title: 'Failed to delete invite ${invite.code}',
message: res.error,
icon: <CrossIcon />,
color: 'red',
});
} else {
notif.showNotification({
title: `Deleted invite ${invite.code}`,
message: '',
icon: <DeleteIcon />,
color: 'green',
});
}
updateInvites();
},
});
const handleCopy = async invite => {
clipboard.copy(`${window.location.protocol}//${window.location.host}/invite/${invite.code}`);
notif.showNotification({
title: 'Copied to clipboard',
message: '',
icon: <CopyIcon />,
});
};
const updateInvites = async () => {
const us = await useFetch('/api/auth/invite');
if (!us.error) {
setInvites(us);
} else {
router.push('/dashboard');
};
};
useEffect(() => {
console.log(invites);
updateInvites();
}, []);
return (
<>
<CreateInviteModal open={open} setOpen={setOpen} updateInvites={updateInvites} />
<Group mb='md'>
<Title>Invites</Title>
<ActionIcon variant='filled' color='primary' onClick={() => setOpen(true)}><PlusIcon /></ActionIcon>
</Group>
<SimpleGrid
cols={3}
spacing='lg'
breakpoints={[
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
]}
>
{invites.length ? invites.map(invite => (
<Card key={invite.id} sx={{ maxWidth: '100%' }}>
<Group position='apart'>
<Group position='left'>
<Avatar size='lg' color={invite.used ? 'dark' : 'primary'}>{invite.id}</Avatar>
<Stack spacing={0}>
<Title>{invite.code}{invite.used && <> (Used)</>}</Title>
<MutedText size='sm'>Created: {new Date(invite.created_at).toLocaleString()}</MutedText>
<MutedText size='sm'>Expires: {invite.expires_at ? new Date(invite.expires_at).toLocaleString() : 'Never'}</MutedText>
</Stack>
</Group>
<Group position='right'>
<ActionIcon aria-label='copy' onClick={() => handleCopy(invite)}>
<CopyIcon />
</ActionIcon>
<ActionIcon aria-label='delete' onClick={() => openDeleteModal(invite)}>
<DeleteIcon />
</ActionIcon>
</Group>
</Group>
</Card>
)) : [1, 2, 3].map(x => (
<Skeleton key={x} width='100%' height={100} radius='sm' />
))}
</SimpleGrid>
</>
);
}

View File

@@ -1,14 +1,16 @@
import React, { useEffect, useState } from 'react';
import useFetch from 'hooks/useFetch';
import Link from 'components/Link';
import { useStoreDispatch, useStoreSelector } from 'lib/redux/store';
import { updateUser } from 'lib/redux/reducers/user';
import { Box, Button, Card, ColorInput, Group, MultiSelect, Space, Text, TextInput, PasswordInput, Title, Tooltip } from '@mantine/core';
import { randomId, useForm, useInterval } from '@mantine/hooks';
import { Card, Tooltip, TextInput, Button, Text, Title, Group, ColorInput, MultiSelect, Space, Box, Table } from '@mantine/core';
import { DownloadIcon, Cross1Icon, TrashIcon } from '@modulz/radix-icons';
import { useNotifications } from '@mantine/notifications';
import { useModals } from '@mantine/modals';
import { useNotifications } from '@mantine/notifications';
import { CrossIcon, DeleteIcon } from 'components/icons';
import DownloadIcon from 'components/icons/DownloadIcon';
import Link from 'components/Link';
import { SmallTable } from 'components/SmallTable';
import useFetch from 'hooks/useFetch';
import { bytesToRead } from 'lib/clientUtils';
import { updateUser } from 'lib/redux/reducers/user';
import { useStoreDispatch, useStoreSelector } from 'lib/redux/store';
import { useEffect, useState } from 'react';
function VarsTooltip({ children }) {
return (
@@ -18,7 +20,7 @@ function VarsTooltip({ children }) {
<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
visit <Link href='https://zipl.vercel.app/docs/variables'>the docs</Link> for more variables
</>
}>
{children}
@@ -30,33 +32,6 @@ function ExportDataTooltip({ children }) {
return <Tooltip position='top' placement='center' color='' label='After clicking, if you have a lot of files the export can take a while to complete. A list of previous exports will be below to download.'>{children}</Tooltip>;
}
function ExportTable({ 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 Manage() {
const user = useStoreSelector(state => state.user);
const dispatch = useStoreDispatch();
@@ -143,14 +118,14 @@ export default function Manage() {
))}
</>,
color: 'red',
icon: <Cross1Icon />,
icon: <CrossIcon />,
});
}
notif.updateNotification(id, {
title: 'Couldn\'t save user',
message: newUser.error,
color: 'red',
icon: <Cross1Icon />,
icon: <CrossIcon />,
});
} else {
dispatch(updateUser(newUser));
@@ -176,8 +151,9 @@ export default function Manage() {
const res = await useFetch('/api/user/export');
setExports(res.exports.map(s => ({
date: new Date(Number(s.split('_')[3].slice(0, -4))),
full: s,
date: new Date(Number(s.name.split('_')[3].slice(0, -4))),
size: s.size,
full: s.name,
})).sort((a, b) => a.date.getTime() - b.date.getTime()));
};
@@ -191,14 +167,14 @@ export default function Manage() {
title: 'Couldn\'t delete files',
message: res.error,
color: 'red',
icon: <Cross1Icon />,
icon: <CrossIcon />,
});
} else {
notif.showNotification({
title: 'Deleted files',
message: `${res.count} files deleted`,
color: 'green',
icon: <TrashIcon />,
icon: <DeleteIcon />,
});
}
};
@@ -226,7 +202,6 @@ export default function Manage() {
},
});
const interval = useInterval(() => getExports(), 30000);
useEffect(() => {
getExports();
@@ -241,7 +216,7 @@ export default function Manage() {
</VarsTooltip>
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
<TextInput id='username' label='Username' {...form.getInputProps('username')} />
<TextInput id='password' label='Password' type='password' {...form.getInputProps('password')} />
<PasswordInput id='password' label='Password' description='Leave blank to keep your old 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')} />
@@ -258,32 +233,37 @@ export default function Manage() {
{...form.getInputProps('domains')}
/>
<Group position='right' sx={{ paddingTop: 12 }}>
<Group position='right' mt='md'>
<Button
type='submit'
>Save User</Button>
</Group>
</form>
<Title sx={{ paddingTop: 12 }}>Manage Data</Title>
<Text color='gray' sx={{ paddingBottom: 12 }}>Delete, or export your data into a zip file.</Text>
<Box mb='md'>
<Title>Manage Data</Title>
<Text color='gray'>Delete, or export your data into a zip file.</Text>
</Box>
<Group>
<Button onClick={openDeleteModal} rightIcon={<TrashIcon />}>Delete All Data</Button>
<Button onClick={openDeleteModal} rightIcon={<DeleteIcon />}>Delete All Data</Button>
<ExportDataTooltip><Button onClick={exportData} rightIcon={<DownloadIcon />}>Export Data</Button></ExportDataTooltip>
</Group>
<Card mt={22}>
<ExportTable
<SmallTable
columns={[
{ id: 'name', name: 'Name' },
{ id: 'date', name: 'Date' },
{ id: 'size', name: 'Size' },
]}
rows={exports ? exports.map((x, i) => ({
name: <Link href={'/api/user/export?name=' + x.full}>Export {i + 1}</Link>,
date: x.date.toLocaleString(),
size: bytesToRead(x.size),
})) : []} />
</Card>
<Title sx={{ paddingTop: 12, paddingBottom: 12 }}>ShareX Config</Title>
<Title my='md'>ShareX Config</Title>
<Group>
<Button onClick={() => genShareX(false)} rightIcon={<DownloadIcon />}>ShareX Config</Button>
<Button onClick={() => genShareX(true)} rightIcon={<DownloadIcon />}>ShareX Config with Embed</Button>

View File

@@ -1,52 +1,10 @@
import React, { useEffect, useState } from 'react';
import { SimpleGrid, Skeleton, Title } from '@mantine/core';
import Card from 'components/Card';
import StatText from 'components/StatText';
import MutedText from 'components/MutedText';
import { SmallTable } from 'components/SmallTable';
import { bytesToRead } from 'lib/clientUtils';
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>
);
}
import { useEffect, useState } from 'react';
export default function Stats() {
const [stats, setStats] = useState(null);
@@ -62,7 +20,7 @@ export default function Stats() {
return (
<>
<Title>Stats</Title>
<Title mb='md'>Stats</Title>
<SimpleGrid
cols={3}
spacing='lg'
@@ -71,30 +29,32 @@ export default function Stats() {
]}
>
<Card name='Size' sx={{ height: '100%' }}>
<StatText>{stats ? stats.size : <Skeleton height={8} />}</StatText>
<MutedText>{stats ? stats.size : <Skeleton height={8} />}</MutedText>
<Title order={2}>Average Size</Title>
<StatText>{stats ? bytesToRead(stats.size_num / stats.count) : <Skeleton height={8} />}</StatText>
<MutedText>{stats ? bytesToRead(stats.size_num / stats.count) : <Skeleton height={8} />}</MutedText>
</Card>
<Card name='Images' sx={{ height: '100%' }}>
<StatText>{stats ? stats.count : <Skeleton height={8} />}</StatText>
<MutedText>{stats ? stats.count : <Skeleton height={8} />}</MutedText>
<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>
<MutedText>{stats ? `${stats.views_count} (${isNaN(stats.views_count / stats.count) ? 0 : Math.round(stats.views_count / stats.count)})` : <Skeleton height={8} />}</MutedText>
</Card>
<Card name='Users' sx={{ height: '100%' }}>
<StatText>{stats ? stats.count_users : <Skeleton height={8} />}</StatText>
<MutedText>{stats ? stats.count_users : <Skeleton height={8} />}</MutedText>
</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>
{stats.count_by_user.length ? (
<Card name='Files per User' mt={22}>
<SmallTable
columns={[
{ id: 'username', name: 'Name' },
{ id: 'count', name: 'Files' },
]}
rows={stats ? stats.count_by_user : []} />
</Card>
) : null}
<Card name='Types' mt={22}>
<StatTable
<SmallTable
columns={[
{ id: 'mimetype', name: 'Type' },
{ id: 'count', name: 'Count' },

View File

@@ -1,56 +1,37 @@
import React, { useEffect, useState } from 'react';
import { useStoreSelector } from 'lib/redux/store';
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 { Button, Collapse, Group, Progress, Title } from '@mantine/core';
import { randomId, useClipboard } from '@mantine/hooks';
import { useNotifications } from '@mantine/notifications';
import { useClipboard } from '@mantine/hooks';
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;
}
import Dropzone from 'components/dropzone/Dropzone';
import FileDropzone from 'components/dropzone/DropzoneFile';
import { CrossIcon, UploadIcon } from 'components/icons';
import Link from 'components/Link';
import { useStoreSelector } from 'lib/redux/store';
import { useEffect, useState } from 'react';
export default function Upload() {
const theme = useMantineTheme();
const notif = useNotifications();
const clipboard = useClipboard();
const user = useStoreSelector(state => state.user);
const [files, setFiles] = useState([]);
const [progress, setProgress] = useState(0);
const [loading, setLoading] = useState(false);
useEffect(() => {
window.addEventListener('paste', (e: ClipboardEvent) => {
const item = Array.from(e.clipboardData.items).find(x => /^image/.test(x.type));
const blob = item.getAsFile();
setFiles([...files, new File([blob], blob.name, { type: blob.type })]);
const file = item.getAsFile();
setFiles([...files, file]);
notif.showNotification({
title: 'Image Imported',
title: 'Image imported from clipboard',
message: '',
});
});
});
const handleUpload = async () => {
setProgress(0);
setLoading(true);
const body = new FormData();
for (let i = 0; i !== files.length; ++i) body.append('file', files[i]);
@@ -61,58 +42,59 @@ export default function Upload() {
autoClose: false,
});
const res = await fetch('/api/upload', {
method: 'POST',
headers: {
'Authorization': user.token,
},
body,
const req = new XMLHttpRequest();
req.upload.addEventListener('progress', e => {
if (e.lengthComputable) {
setProgress(Math.round(e.loaded / e.total * 100));
}
});
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 {
notif.updateNotification(id, {
title: 'Upload Failed',
message: json.error,
color: 'red',
icon: <CrossCircledIcon />,
});
}
req.addEventListener('load', e => {
// @ts-ignore not sure why it thinks response doesnt exist, but it does.
const json = JSON.parse(e.target.response);
setLoading(false);
if (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.files[0]);
setFiles([]);
} else {
notif.updateNotification(id, {
title: 'Upload Failed',
message: json.error,
color: 'red',
icon: <CrossIcon />,
});
}
setProgress(0);
}, false);
req.open('POST', '/api/upload');
req.setRequestHeader('Authorization', user.token);
req.send(body);
};
return (
<>
<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) }}
/>
<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>
</>
)}
<Title mb='md'>Upload Files</Title>
<Dropzone loading={loading} onDrop={(f) => setFiles([...files, ...f])}>
<Group position='center' spacing='md'>
{files.map(file => (<FileDropzone key={randomId()} file={file} />))}
</Group>
</Dropzone>
<Collapse in={progress !== 0}>
{progress !== 0 && <Progress mt='md' value={progress} animate />}
</Collapse>
<Group position='right'>
<Button leftIcon={<UploadIcon />} mt={12} onClick={handleUpload}>Upload</Button>
<Button leftIcon={<UploadIcon />} mt={12} onClick={handleUpload} disabled={files.length === 0 ? true : false}>Upload</Button>
</Group>
</>
);

View File

@@ -0,0 +1,71 @@
import { Button, Group, LoadingOverlay, Select, Title } from '@mantine/core';
import { useNotifications } from '@mantine/notifications';
import CodeInput from 'components/CodeInput';
import { TypeIcon, UploadIcon } from 'components/icons';
import Link from 'components/Link';
import exts from 'lib/exts';
import { useStoreSelector } from 'lib/redux/store';
import { useState } from 'react';
export default function Upload() {
const notif = useNotifications();
const user = useStoreSelector(state => state.user);
const [value, setValue] = useState('');
const [lang, setLang] = useState('txt');
const handleUpload = async () => {
const file = new File([value], 'text.' + lang);
const id = notif.showNotification({
title: 'Uploading...',
message: '',
loading: true,
autoClose: false,
});
const req = new XMLHttpRequest();
req.addEventListener('load', e => {
// @ts-ignore not sure why it thinks response doesnt exist, but it does.
const json = JSON.parse(e.target.response);
if (!json.error) {
notif.updateNotification(id, {
title: 'Upload Successful',
message: <>Copied first file to clipboard! <br />{json.files.map(x => (<Link key={x} href={x}>{x}<br /></Link>))}</>,
});
}
});
const body = new FormData();
body.append('file', file);
req.open('POST', '/api/upload');
req.setRequestHeader('Authorization', user.token);
req.setRequestHeader('UploadText', 'true');
req.send(body);
};
return (
<>
<Title mb='md'>Upload Text</Title>
<CodeInput
value={value}
onChange={e => setValue(e.target.value)}
/>
<Group position='right' mt='md'>
<Select
value={lang}
onChange={setLang}
dropdownPosition='top'
data={Object.keys(exts).map(x => ({ value: x, label: exts[x] }))}
icon={<TypeIcon />}
/>
<Button leftIcon={<UploadIcon />} onClick={handleUpload} disabled={value.trim().length === 0 ? true : false}>Upload</Button>
</Group>
</>
);
}

View File

@@ -1,11 +1,10 @@
import React, { useEffect, useState } from 'react';
import { ActionIcon, Button, Card, Group, Modal, SimpleGrid, Skeleton, TextInput, Title } from '@mantine/core';
import { useClipboard, useForm } from '@mantine/hooks';
import { useNotifications } from '@mantine/notifications';
import { CopyIcon, CrossIcon, DeleteIcon, LinkIcon, PlusIcon } from 'components/icons';
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';
import { useEffect, useState } from 'react';
export default function Urls() {
const user = useStoreSelector(state => state.user);
@@ -27,14 +26,14 @@ export default function Urls() {
notif.showNotification({
title: 'Failed to delete URL',
message: url.error,
icon: <TrashIcon />,
icon: <DeleteIcon />,
color: 'red',
});
} else {
notif.showNotification({
title: 'Deleted URL',
message: '',
icon: <Cross1Icon />,
icon: <CrossIcon />,
color: 'green',
});
}
@@ -58,12 +57,18 @@ export default function Urls() {
},
});
const onSubmit = async (values) => {
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');
try {
new URL(cleanURL);
} catch (e) {
return form.setFieldError('url', 'Invalid URL');
}
const data = {
url: cleanURL,
vanity: cleanVanity === '' ? null : cleanVanity,
@@ -85,14 +90,14 @@ export default function Urls() {
title: 'Failed to create URL',
message: json.error,
color: 'red',
icon: <Cross1Icon />,
icon: <CrossIcon />,
});
} else {
notif.showNotification({
title: 'URL shortened',
message: json.url,
color: 'green',
icon: <Link1Icon />,
icon: <LinkIcon />,
});
}
@@ -121,8 +126,8 @@ export default function Urls() {
</form>
</Modal>
<Group>
<Title sx={{ marginBottom: 12 }}>URLs</Title>
<Group mb='md'>
<Title>URLs</Title>
<ActionIcon variant='filled' color='primary' onClick={() => setCreateOpen(true)}><PlusIcon/></ActionIcon>
</Group>
@@ -140,20 +145,18 @@ export default function Urls() {
<Title>{url.vanity ?? url.id}</Title>
</Group>
<Group position='right'>
<ActionIcon href={url.url} component='a' target='_blank'><Link1Icon/></ActionIcon>
<ActionIcon href={url.url} component='a' target='_blank'><LinkIcon/></ActionIcon>
<ActionIcon aria-label='copy' onClick={() => copyURL(url)}>
<CopyIcon />
</ActionIcon>
<ActionIcon aria-label='delete' onClick={() => deleteURL(url)}>
<TrashIcon />
<DeleteIcon />
</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>
)) : [1, 2, 3, 4].map(x => (
<Skeleton key={x} width='100%' height={80} radius='sm' />
))}
</SimpleGrid>
</>

View File

@@ -1,12 +1,12 @@
import React, { useState, useEffect } from 'react';
import { useStoreSelector } from 'lib/redux/store';
import useFetch from 'hooks/useFetch';
import { useRouter } from 'next/router';
import { ActionIcon, Avatar, Button, Card, Group, Modal, SimpleGrid, Skeleton, Switch, TextInput, Title } from '@mantine/core';
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';
import { useNotifications } from '@mantine/notifications';
import { CrossIcon, DeleteIcon, PlusIcon } from 'components/icons';
import useFetch from 'hooks/useFetch';
import { useStoreSelector } from 'lib/redux/store';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
function CreateUserModal({ open, setOpen, updateUsers }) {
@@ -19,7 +19,7 @@ function CreateUserModal({ open, setOpen, updateUsers }) {
});
const notif = useNotifications();
const onSubmit = async (values) => {
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');
@@ -37,7 +37,7 @@ function CreateUserModal({ open, setOpen, updateUsers }) {
notif.showNotification({
title: 'Failed to create user',
message: res.error,
icon: <TrashIcon />,
icon: <DeleteIcon />,
color: 'red',
});
} else {
@@ -58,7 +58,7 @@ function CreateUserModal({ open, setOpen, updateUsers }) {
onClose={() => setOpen(false)}
title={<Title>Create User</Title>}
>
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
<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')} />
@@ -91,14 +91,14 @@ export default function Users() {
title: 'Failed to delete user',
message: res.error,
color: 'red',
icon: <Cross1Icon />,
icon: <CrossIcon />,
});
} else {
notif.showNotification({
title: 'User deleted',
message: '',
color: 'green',
icon: <TrashIcon />,
icon: <DeleteIcon />,
});
updateUsers();
}
@@ -145,8 +145,8 @@ export default function Users() {
return (
<>
<CreateUserModal open={open} setOpen={setOpen} updateUsers={updateUsers} />
<Group>
<Title sx={{ marginBottom: 12 }}>Users</Title>
<Group mb='md'>
<Title>Users</Title>
<ActionIcon variant='filled' color='primary' onClick={() => setOpen(true)}><PlusIcon /></ActionIcon>
</Group>
<SimpleGrid
@@ -165,15 +165,13 @@ export default function Users() {
</Group>
<Group position='right'>
<ActionIcon aria-label='delete' onClick={() => openDeleteModal(user)}>
<TrashIcon />
<DeleteIcon />
</ActionIcon>
</Group>
</Group>
</Card>
)) : [1, 2, 3, 4].map(x => (
<div key={x}>
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }} />
</div>
)) : [1, 2, 3].map(x => (
<Skeleton key={x} width='100%' height={100} radius='sm' />
))}
</SimpleGrid>
</>

View File

@@ -1,4 +1,4 @@
import { Image, User } from '@prisma/client';
import type { Image, User } from '@prisma/client';
export function parse(str: string, image: Image, user: User) {
if (!str) return null;
@@ -13,4 +13,18 @@ export function parse(str: string, image: Image, user: User) {
.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());
}
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]}`;
}

View File

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

View File

@@ -1,6 +1,6 @@
export interface ConfigCore {
// Whether to return http or https links
secure: boolean;
https: boolean;
// Used for signing of cookies and other stuff
secret: string;
@@ -12,7 +12,7 @@ export interface ConfigCore {
port: number;
// The PostgreSQL database url
database_url: string
database_url: string;
// Whether or not to log stuff
logger: boolean;
@@ -23,11 +23,15 @@ export interface ConfigCore {
export interface ConfigDatasource {
// The type of datasource
type: 'local' | 's3';
type: 'local' | 's3' | 'swift';
// The local datasource
// The local datasource, the default
local: ConfigLocalDatasource;
// The s3 datasource
s3?: ConfigS3Datasource;
// The Swift datasource
swift?: ConfigSwiftDatasource;
}
export interface ConfigLocalDatasource {
@@ -36,11 +40,35 @@ export interface ConfigLocalDatasource {
}
export interface ConfigS3Datasource {
// The access key id for the s3 bucket
access_key_id: string;
// The secret access key for the s3 bucket
secret_access_key: string;
// Not required, but if using a non-aws S3 service you can specify the endpoint
endpoint?: string;
// The S3 bucket to store files in
bucket: string;
// If true Zipline will attempt to connect to the bucket via the url "https://s3.amazonaws.com/{bucket}/stuff"
// If false Zipline will attempt to connect to the bucket via the url "http://{bucket}.s3.amazonaws.com/stuff"
force_s3_path: boolean;
// Region
// aws region, default will be us-east-1 (if using a non-aws S3 service this might work for you)
region?: string;
}
export interface ConfigSwiftDatasource {
container: string;
auth_endpoint: string;
username: string;
password: string;
project_id: string;
domain_id?: string;
region_id?: string;
}
export interface ConfigUploader {
@@ -77,10 +105,18 @@ export interface ConfigRatelimit {
admin: number;
}
export interface ConfigWebsite {
// Change the title from Zipline to something else
title: string;
// If zipline should show files per user in the stats page
show_files_per_user: boolean;
}
export interface Config {
core: ConfigCore;
uploader: ConfigUploader;
urls: ConfigUrls;
ratelimit: ConfigRatelimit;
datasource: ConfigDatasource;
}
website: ConfigWebsite;
}

View File

@@ -0,0 +1,116 @@
import { parse } from 'dotenv';
import { expand } from 'dotenv-expand';
import { existsSync, readFileSync } from 'fs';
function isObject(value: any): value is Record<string, any> {
return typeof value === 'object' && value !== null;
}
function set(object: Record<string, any>, property: string, value: any) {
const parts = property.split('.');
for (let i = 0; i < parts.length; ++i) {
const key = parts[i];
if (i === parts.length - 1) {
object[key] = value;
} else if (!isObject(object[key])) {
object[key] = typeof parts[i + 1] === 'number' ? [] : {};
}
object = object[key];
}
return object;
}
function map(env: string, type: 'string' | 'number' | 'boolean' | 'array', path: string) {
return {
env,
type,
path,
};
}
export default function readConfig() {
if (existsSync('.env.local')) {
const contents = readFileSync('.env.local');
expand({
parsed: parse(contents),
});
}
const maps = [
map('CORE_HTTPS', 'boolean', 'core.secure'),
map('CORE_SECRET', 'string', 'core.secret'),
map('CORE_HOST', 'string', 'core.host'),
map('CORE_PORT', 'number', 'core.port'),
map('CORE_DATABASE_URL', 'string', 'core.database_url'),
map('CORE_LOGGER', 'boolean', 'core.logger'),
map('CORE_STATS_INTERVAL', 'number', 'core.stats_interval'),
map('DATASOURCE_TYPE', 'string', 'datasource.type'),
map('DATASOURCE_LOCAL_DIRECTORY', 'string', 'datasource.local.directory'),
map('DATASOURCE_S3_ACCESS_KEY_ID', 'string', 'datasource.s3.access_key_id'),
map('DATASOURCE_S3_SECRET_ACCESS_KEY', 'string', 'datasource.s3.secret_access_key'),
map('DATASOURCE_S3_ENDPOINT', 'string', 'datasource.s3.endpoint'),
map('DATASOURCE_S3_BUCKET', 'string', 'datasource.s3.bucket'),
map('DATASOURCE_S3_FORCE_S3_PATH', 'boolean', 'datasource.s3.force_s3_path'),
map('DATASOURCE_S3_REGION', 'string', 'datasource.s3.region'),
map('DATASOURCE_SWIFT_USERNAME', 'string', 'datasource.swift.username'),
map('DATASOURCE_SWIFT_PASSWORD', 'string', 'datasource.swift.password'),
map('DATASOURCE_SWIFT_AUTH_ENDPOINT', 'string', 'datasource.swift.auth_endpoint'),
map('DATASOURCE_SWIFT_CONTAINER', 'string', 'datasource.swift.container'),
map('DATASOURCE_SWIFT_PROJECT_ID', 'string', 'datasource.swift.project_id'),
map('DATASOURCE_SWIFT_DOMAIN_ID', 'string', 'datasource.swift.domain_id'),
map('DATASOURCE_SWIFT_REGION_ID', 'string', 'datasource.swift.region_id'),
map('UPLOADER_ROUTE', 'string', 'uploader.route'),
map('UPLOADER_LENGTH', 'number', 'uploader.length'),
map('UPLOADER_ADMIN_LIMIT', 'number', 'uploader.admin_limit'),
map('UPLOADER_USER_LIMIT', 'number', 'uploader.user_limit'),
map('UPLOADER_DISABLED_EXTENSIONS', 'array', 'uploader.disabled_extensions'),
map('URLS_ROUTE', 'string', 'urls.route'),
map('URLS_LENGTH', 'number', 'urls.length'),
map('RATELIMIT_USER', 'number', 'ratelimit.user'),
map('RATELIMIT_ADMIN', 'number', 'ratelimit.admin'),
map('WEBSITE_TITLE', 'string', 'website.title'),
map('WEBSITE_SHOW_FILES_PER_USER', 'boolean', 'website.show_files_per_user'),
];
const config = {};
for (let i = 0; i !== maps.length; ++i) {
const map = maps[i];
const value = process.env[map.env];
if (value) {
let parsed: any;
switch (map.type) {
case 'array':
parsed = value.split(',');
break;
case 'number':
parsed = Number(value);
break;
case 'boolean':
parsed = value === 'true';
break;
default:
parsed = value;
};
set(config, map.path, parsed);
}
}
return config;
}

View File

@@ -0,0 +1,98 @@
import { Config } from 'lib/config/Config';
import { object, bool, string, number, boolean, array } from 'yup';
const validator = object({
core: object({
https: bool().default(false),
secret: string().min(8).required(),
host: string().default('0.0.0.0'),
port: number().default(3000),
database_url: string().required(),
logger: boolean().default(false),
stats_interval: number().default(1800),
}).required(),
datasource: object({
type: string().oneOf(['local', 's3', 'swift']).default('local'),
local: object({
directory: string().default('./uploads'),
}),
s3: object({
access_key_id: string(),
secret_access_key: string(),
endpoint: string(),
bucket: string(),
force_s3_path: boolean().default(false),
region: string().default('us-east-1'),
}).nullable().notRequired(),
swift: object({
username: string(),
password: string(),
auth_endpoint: string(),
container: string(),
project_id: string(),
domain_id: string().default('default'),
region_id: string().nullable(),
}).nullable().notRequired(),
}).required(),
uploader: object({
route: string().default('/u'),
embed_route: string().default('/a'),
length: number().default(6),
admin_limit: number().default(104900000),
user_limit: number().default(104900000),
disabled_extensions: array().default([]),
}).required(),
urls: object({
route: string().default('/go'),
length: number().default(6),
}).required(),
ratelimit: object({
user: number().default(0),
admin: number().default(0),
}),
website: object({
title: string().default('Zipline'),
show_files_per_user: boolean().default(true),
}),
});
export default function validate(config): Config {
try {
const validated = validator.validateSync(config, { abortEarly: false });
switch (validated.datasource.type) {
case 's3': {
const errors = [];
if (!validated.datasource.s3.access_key_id)
errors.push('datasource.s3.access_key_id is a required field');
if (!validated.datasource.s3.secret_access_key)
errors.push('datasource.s3.secret_access_key is a required field');
if (!validated.datasource.s3.bucket)
errors.push('datasource.s3.bucket is a required field');
if (!validated.datasource.s3.endpoint)
errors.push('datasource.s3.endpoint is a required field');
if (errors.length) throw { errors };
break;
}
case 'swift': {
const errors = [];
if (!validated.datasource.swift.container)
errors.push('datasource.swift.container is a required field');
if (!validated.datasource.swift.project_id)
errors.push('datasource.swift.project_id is a required field');
if (!validated.datasource.swift.auth_endpoint)
errors.push('datasource.swift.auth_endpoint is a required field');
if (!validated.datasource.swift.password)
errors.push('datasource.swift.password is a required field');
if (!validated.datasource.swift.username)
errors.push('datasource.swift.username is a required field');
if (errors.length) throw { errors };
break;
}
}
return validated as unknown as Config;
} catch (e) {
if (process.env.ZIPLINE_DOCKER_BUILD) return null;
throw `${e.errors.length} errors occured\n${e.errors.map((x) => '\t' + x).join('\n')}`;
}
}

24
src/lib/datasource.ts Normal file
View File

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

View File

@@ -1,75 +0,0 @@
import { Datasource } from './datasource';
import AWS from 'aws-sdk';
import { Readable } from 'stream';
import { ConfigS3Datasource } from 'lib/types';
export class S3 extends Datasource {
public name: string = 'S3';
public s3: AWS.S3;
public constructor(
public config: ConfigS3Datasource,
) {
super();
this.s3 = new AWS.S3({
accessKeyId: config.access_key_id,
endpoint: config.endpoint || null,
s3ForcePathStyle: config.force_s3_path,
secretAccessKey: config.secret_access_key,
});
}
public async save(file: string, data: Buffer): Promise<void> {
return new Promise((resolve, reject) => {
this.s3.upload({
Bucket: this.config.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.config.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.config.bucket,
Key: file,
}).createReadStream();
}
public async size(): Promise<number> {
return new Promise((resolve, reject) => {
this.s3.listObjects({
Bucket: this.config.bucket,
}, (err, data) => {
if (err) {
reject(err);
} else {
const size = data.Contents.reduce((acc, cur) => acc + cur.Size, 0);
resolve(size);
}
});
});
}
}

View File

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

View File

@@ -2,9 +2,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>;
public abstract size(file: string): Promise<number>;
public abstract get(file: string): Readable | Promise<Readable>;
public abstract fullSize(): Promise<number>;
}

View File

@@ -1,7 +1,7 @@
import { createReadStream, existsSync, ReadStream } from 'fs';
import { readdir, rm, stat, writeFile } from 'fs/promises';
import { join } from 'path';
import { Datasource } from './datasource';
import { Datasource } from '.';
export class Local extends Datasource {
public name: string = 'local';
@@ -29,7 +29,13 @@ export class Local extends Datasource {
}
}
public async size(): Promise<number> {
public async size(file: string): Promise<number> {
const stats = await stat(join(process.cwd(), this.path, file));
return stats.size;
}
public async fullSize(): Promise<number> {
const files = await readdir(this.path);
let size = 0;

61
src/lib/datasources/S3.ts Normal file
View File

@@ -0,0 +1,61 @@
import { Datasource } from '.';
import { Readable } from 'stream';
import { ConfigS3Datasource } from 'lib/config/Config';
import { Client } from 'minio';
export class S3 extends Datasource {
public name: string = 'S3';
public s3: Client;
public constructor(
public config: ConfigS3Datasource,
) {
super();
this.s3 = new Client({
endPoint: config.endpoint,
accessKey: config.access_key_id,
secretKey: config.secret_access_key,
pathStyle: config.force_s3_path,
region: config.region,
});
}
public async save(file: string, data: Buffer): Promise<void> {
await this.s3.putObject(this.config.bucket, file, data);
}
public async delete(file: string): Promise<void> {
await this.s3.removeObject(this.config.bucket, file);
}
public get(file: string): Promise<Readable> {
return new Promise((res, rej) => {
this.s3.getObject(this.config.bucket, file, (err, stream) => {
if (err) res(null);
else res(stream);
});
});
}
public size(file: string): Promise<number> {
return new Promise((res, rej) => {
this.s3.statObject(this.config.bucket, file, (err, stat) => {
if (err) rej(err);
else res(stat.size);
});
});
}
public async fullSize(): Promise<number> {
return new Promise((res, rej) => {
const objects = this.s3.listObjectsV2(this.config.bucket, '', true);
let size = 0;
objects.on('data', item => size += item.size);
objects.on('end', err => {
if (err) rej(err);
else res(size);
});
});
}
}

View File

@@ -0,0 +1,233 @@
import { Datasource } from '.';
import { Readable } from 'stream';
import { ConfigSwiftDatasource } from 'lib/config/Config';
interface SwiftContainerOptions {
auth_endpoint_url: string;
credentials: {
username: string;
password: string;
project_id: string;
domain_id: string;
container: string;
interface?: string;
region_id: string;
};
refreshMargin?: number;
}
interface SwiftAuth {
token: string;
expires: Date;
swiftURL: string;
}
interface SwiftObject {
bytes: number;
content_type: string;
hash: string;
name: string;
last_modified: string;
}
class SwiftContainer {
auth: SwiftAuth | null;
constructor(private options: SwiftContainerOptions) {
this.auth = null;
}
private findEndpointURL(catalog: any[], service: string): string | null {
const catalogEntry = catalog.find((x) => x.name === service);
if (!catalogEntry) return null;
const endpoint = catalogEntry.endpoints.find(
(x: any) =>
x.interface === (this.options.credentials.interface || 'public') &&
(this.options.credentials.region_id
? x.region_id == this.options.credentials.region_id
: true)
);
return endpoint ? endpoint.url : null;
}
private async getCredentials(): Promise<SwiftAuth> {
const payload = {
auth: {
identity: {
methods: ['password'],
password: {
user: {
name: this.options.credentials.username,
password: this.options.credentials.password,
domain: {
id: this.options.credentials.domain_id || 'default',
},
},
},
},
scope: {
project: {
id: this.options.credentials.project_id,
domain: {
id: this.options.credentials.domain_id || 'default',
},
},
},
},
};
const { json, headers, error } = await fetch(`${this.options.auth_endpoint_url}/auth/tokens`, {
body: JSON.stringify(payload),
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
}).then(async (e) => {
try {
const json = await e.json();
return { json, headers: e.headers, error: null };
} catch (e) {
return { json: null, headers: null, error: e };
}
});
if (error || !json || !headers || json.error) throw new Error('Could not retrieve credentials from OpenStack, check your config file');
const catalog = json.token.catalog;
// many Swift clouds use ceph radosgw to provide swift
const swiftURL = this.findEndpointURL(catalog, 'swift') || this.findEndpointURL(catalog, 'radosgw-swift');
if (!swiftURL) throw new Error('Couldn\'t find any "swift" or "radosgw-swift" service in the catalog');
return {
token: headers.get('x-subject-token'),
expires: new Date(json.token.expires_at),
swiftURL,
};
}
private async authenticate() {
if (!this.auth) this.auth = await this.getCredentials();
const authExpiry = new Date(Date.now() + this.options.refreshMargin || 10_000);
if (authExpiry > this.auth.expires) this.auth = await this.getCredentials();
const validAuth = this.auth;
return { swiftURL: validAuth.swiftURL, token: validAuth.token };
}
private generateHeaders(token: string, extra?: any) {
return { accept: 'application/json', 'x-auth-token': token, ...extra };
}
public async listObjects(query?: string): Promise<SwiftObject[]> {
const auth = await this.authenticate();
return await fetch(`${auth.swiftURL}/${this.options.credentials.container}${query ? `${query.startsWith('?') ? '' : '?'}${query}` : ''}`, {
method: 'GET',
headers: this.generateHeaders(auth.token),
}).then((e) => e.json());
}
public async uploadObject(name: string, data: Buffer): Promise<any> {
const auth = await this.authenticate();
return fetch(`${auth.swiftURL}/${this.options.credentials.container}/${name}`, {
method: 'PUT',
headers: this.generateHeaders(auth.token),
body: data,
});
}
public async deleteObject(name: string): Promise<any> {
const auth = await this.authenticate();
return fetch(`${auth.swiftURL}/${this.options.credentials.container}/${name}`, {
method: 'DELETE',
headers: this.generateHeaders(auth.token),
});
}
public async getObject(name: string): Promise<Readable> {
const auth = await this.authenticate();
const arrayBuffer = await fetch(`${auth.swiftURL}/${this.options.credentials.container}/${name}`, {
method: 'GET',
headers: this.generateHeaders(auth.token, { Accept: '*/*' }),
}).then((e) => e.arrayBuffer());
return Readable.from(Buffer.from(arrayBuffer));
}
public async headObject(name: string): Promise<any> {
const auth = await this.authenticate();
return fetch(`${auth.swiftURL}/${this.options.credentials.container}/${name}`, {
method: 'HEAD',
headers: this.generateHeaders(auth.token),
});
}
}
export class Swift extends Datasource {
public name: string = 'Swift';
container: SwiftContainer;
public constructor(public config: ConfigSwiftDatasource) {
super();
console.log(config);
this.container = new SwiftContainer({
auth_endpoint_url: config.auth_endpoint,
credentials: {
username: config.username,
password: config.password,
project_id: config.project_id,
domain_id: config.domain_id || 'default',
container: config.container,
region_id: config.region_id,
},
});
}
public async save(file: string, data: Buffer): Promise<void> {
try {
return this.container.uploadObject(file, data);
} catch {
return null;
}
}
public async delete(file: string): Promise<void> {
try {
return this.container.deleteObject(file);
} catch {
return null;
}
}
public get(file: string): Promise<Readable> | Readable {
try {
return this.container.getObject(file);
} catch {
return null;
}
}
public async size(file: string): Promise<number> {
try {
const head = await this.container.headObject(file);
return head.headers.get('content-length') || 0;
} catch {
return 0;
}
}
public async fullSize(): Promise<number> {
return this.container
.listObjects()
.then((objects) => objects.reduce((acc, object) => acc + object.bytes, 0))
.catch(() => 0);
}
}

View File

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

View File

@@ -1,20 +0,0 @@
import config from './config';
import { S3, Local } from './datasource';
import Logger from './logger';
if (!global.datasource) {
switch (config.datasource.type) {
case 's3':
Logger.get('datasource').info(`Using S3(${config.datasource.s3.bucket}) datasource`);
global.datasource = new S3(config.datasource.s3);
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;

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

@@ -0,0 +1,45 @@
// https://github.com/toptal/haste-server/blob/master/static/application.js#L167-L174
// Popular extension map
const exts = {
'md': 'Markdown',
'css': 'CSS',
'js': 'JavaScript',
'json': 'JSON',
'html': 'HTML',
'ts': 'TypeScript',
'java': 'Java',
'py': 'Python',
'rb': 'Ruby',
'sh': 'Shell',
'php': 'PHP',
'pl': 'Perl',
'sql': 'SQL',
'xml': 'XML',
'yml': 'YAML',
'yaml': 'YAML',
'c': 'C',
'cpp': 'C++',
'cs': 'C#',
'go': 'Go',
'h': 'C/C++ Header',
'txt': 'Text',
'dockerfile': 'Dockerfile',
'toml': 'TOML',
'ini': 'INI',
'bat': 'Batch File',
'tex': 'TeX',
'r': 'R',
'lua': 'Lua',
'ps1': 'PowerShell',
'rst': 'reStructuredText',
'rs': 'Rust',
'swift': 'Swift',
'scss': 'SCSS',
'less': 'LESS',
'scala': 'Scala',
'kotlin': 'Kotlin',
'vb': 'Visual Basic',
'vim': 'Vim Script',
};
export default exts;

View File

@@ -10,7 +10,8 @@ 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');
if (typeof clas !== 'function')
if (typeof clas !== 'string') throw new Error('not string/function');
const name = clas.name ?? clas;
@@ -21,25 +22,31 @@ export default class Logger {
this.name = name;
}
info(...args) {
info(...args: any[]) {
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(' ')));
console.log(
this.formatMessage(
LoggerLevel.ERROR,
this.name,
args.map((error) => error.stack ?? error).join(' ')
)
);
}
formatMessage(level: LoggerLevel, name, message) {
formatMessage(level: LoggerLevel, name: string, message: string) {
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');
case LoggerLevel.INFO:
return cyan('INFO ');
case LoggerLevel.ERROR:
return red('ERROR');
}
}
};
}

View File

@@ -2,7 +2,7 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import type { CookieSerializeOptions } from 'cookie';
import { serialize } from 'cookie';
import { sign64, unsign64 } from '../util';
import { sign64, unsign64 } from 'lib/util';
import config from 'lib/config';
import prisma from 'lib/prisma';
@@ -36,8 +36,8 @@ export type NextApiRes = NextApiResponse & {
error: (message: string) => void;
forbid: (message: string, extra?: any) => void;
bad: (message: string) => void;
json: (json: any) => void;
ratelimited: () => void;
json: (json: Record<string, any>, status?: number) => void;
ratelimited: (remaining: number) => void;
setCookie: (name: string, value: unknown, options: CookieSerializeOptions) => void;
}
@@ -50,34 +50,33 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
res.error = (message: string) => {
res.json({
error: message,
});
}, 500);
};
res.forbid = (message: string, extra: any = {}) => {
res.setHeader('Content-Type', 'application/json');
res.status(403);
res.json({
error: '403: ' + message,
...extra,
});
}, 403);
};
res.bad = (message: string) => {
res.status(401);
res.json({
error: '403: ' + message,
});
error: '401: ' + message,
}, 401);
};
res.ratelimited = () => {
res.ratelimited = (remaining: number) => {
res.status(429);
res.setHeader('X-Ratelimit-Remaining', Math.floor(remaining / 1000));
res.json({
error: '429: ratelimited',
});
};
res.json = (json: any) => {
res.json = (json: any, status: number = 200) => {
res.setHeader('Content-Type', 'application/json');
res.status(status);
res.end(JSON.stringify(json));
};

12
src/lib/mimes.ts Normal file
View File

@@ -0,0 +1,12 @@
import { readFile } from 'fs/promises';
export type Mimes = [string, string[]][]
export async function guess(extension: string): Promise<string> {
const mimes: Mimes = JSON.parse(await readFile('./mimes.json', 'utf8'));
const mime = mimes.find(x => x[0] === extension);
if (!mime) return 'application/octet-stream';
return mime[1][0];
}

View File

@@ -1,127 +0,0 @@
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_ENDPOINT', 'string', (c, v) => c.datasource.s3.endpoint = v ?? null),
e('DATASOURCE_S3_FORCE_S3_PATH', 'string', (c, v) => c.datasource.s3.force_s3_path = v ?? false),
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_extensions = v : c.uploader.disabled_extensions = []),
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,
endpoint: undefined,
bucket: undefined,
force_s3_path: undefined,
},
},
uploader: {
route: undefined,
length: undefined,
admin_limit: undefined,
user_limit: undefined,
disabled_extensions: 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(',');
}

View File

@@ -2,9 +2,8 @@ import { createHmac, randomBytes, timingSafeEqual } from 'crypto';
import { hash, verify } from 'argon2';
import { readdir, stat } from 'fs/promises';
import { join } from 'path';
import prisma from './prisma';
import prisma from 'lib/prisma';
import { InvisibleImage, InvisibleUrl } from '@prisma/client';
import config from './config';
export async function hashPassword(s: string): Promise<string> {
return await hash(s);

View File

@@ -1,19 +1,25 @@
import React from 'react';
import { Box, Text } from '@mantine/core';
import { Button, Stack, Title } from '@mantine/core';
import Link from 'components/Link';
import MutedText from 'components/MutedText';
export default function FourOhFour() {
return (
<>
<Box
sx={{
display: 'flex',
alignItems: 'center',
minHeight: '100vh',
justifyContent: 'center',
}}
>
<Text size='xl'>404 - Not Found</Text>
</Box>
</>
<Stack
sx={{
display: 'flex',
alignItems: 'center',
minHeight: '100vh',
justifyContent: 'center',
position: 'relative',
}}
spacing='sm'
>
<Title sx={{ fontSize: 220, fontWeight: 900, lineHeight: .8 }}>404</Title>
<MutedText sx={{ fontSize: 40, fontWeight: 500 }}>This page does not exist!</MutedText>
<Button component={Link} href='/dashboard'>Head to the Dashboard</Button>
</Stack>
);
}
}
FourOhFour.title = 'Zipline - 404';

25
src/pages/500.tsx Normal file
View File

@@ -0,0 +1,25 @@
import React from 'react';
import { Button, Stack, Title } from '@mantine/core';
import Link from 'components/Link';
import MutedText from 'components/MutedText';
export default function FiveHundred() {
return (
<Stack
sx={{
display: 'flex',
alignItems: 'center',
minHeight: '100vh',
justifyContent: 'center',
position: 'relative',
}}
spacing='sm'
>
<Title sx={{ fontSize: 220, fontWeight: 900, lineHeight: .8 }}>500</Title>
<MutedText sx={{ fontSize: 40, fontWeight: 500 }}>Internal Server Error</MutedText>
<Button component={Link} href='/dashboard'>Head to the Dashboard</Button>
</Stack>
);
}
FiveHundred.title = 'Zipline - 500';

View File

@@ -5,7 +5,7 @@ import { Box, Button, Modal, PasswordInput } from '@mantine/core';
import config from 'lib/config';
import prisma from 'lib/prisma';
import { parse } from 'lib/clientUtils';
import * as exts from '../../scripts/exts';
import exts from 'lib/exts';
export default function EmbeddedImage({ image, user, pass }) {
const dataURL = (route: string) => `${route}/${image.file}`;
@@ -30,6 +30,7 @@ export default function EmbeddedImage({ image, user, pass }) {
};
const updateImage = async (url?: string) => {
const imageEl = document.getElementById('image_content') as HTMLImageElement;
const img = new Image();
@@ -62,8 +63,19 @@ export default function EmbeddedImage({ image, user, pass }) {
<meta property='theme-color' content={user.embedColor} />
</>
)}
<meta property='og:image' content={dataURL('/r')} />
<meta property='twitter:card' content='summary_large_image' />
{image.mimetype.startsWith('image') && (
<>
<meta property='og:image' content={dataURL('/r')} />
<meta property='twitter:card' content='summary_large_image' />
</>
)}
{image.mimetype.startsWith('video') && (
<>
<meta property='og:video' content={dataURL('/r')} />
<meta property='og:video:url' content={dataURL('/r')} />
<meta property='og:video:type' content={image.mimetype} />
</>
)}
<title>{image.file}</title>
</Head>
<Modal
@@ -74,6 +86,7 @@ export default function EmbeddedImage({ image, user, pass }) {
withCloseButton={true}
closeOnEscape={false}
closeOnClickOutside={false}
overlayBlur={3}
>
<PasswordInput label='Password' placeholder='Password' error={error} value={password} onChange={e => setPassword(e.target.value)} />
<Button fullWidth onClick={() => check()} mt='md'>
@@ -88,7 +101,18 @@ export default function EmbeddedImage({ image, user, pass }) {
justifyContent: 'center',
}}
>
<img src={dataURL('/r')} alt={dataURL('/r')} id='image_content' />
{image.mimetype.startsWith('image') && (
<img src={dataURL('/r')} alt={dataURL('/r')} id='image_content' />
)}
{image.mimetype.startsWith('video') && (
<video
src={dataURL('/r')}
controls={true}
autoPlay={true}
id='image_content'
/>
)}
</Box>
</>
);
@@ -168,10 +192,10 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
},
};
if (!image.mimetype.startsWith('image')) {
const { default: datasource } = await import('lib/ds');
if (!image.mimetype.startsWith('image') && !image.mimetype.startsWith('video')) {
const { default: datasource } = await import('lib/datasource');
const data = datasource.get(image.file);
const data = await datasource.get(image.file);
if (!data) return { notFound: true };
data.pipe(context.res);
@@ -190,3 +214,4 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
return { notFound: true };
}
};

View File

@@ -8,7 +8,6 @@ export default function MyApp({ Component, pageProps }) {
return (
<Provider store={store}>
<Head>
<title>{Component.title}</title>
<meta name='viewport' content='minimum-scale=1, initial-scale=1, width=device-width' />
</Head>
<ZiplineTheming Component={Component} pageProps={pageProps} />

30
src/pages/_error.tsx Normal file
View File

@@ -0,0 +1,30 @@
import React from 'react';
import { Button, Stack, Title } from '@mantine/core';
import Link from 'components/Link';
import MutedText from 'components/MutedText';
export default function Error({ statusCode }) {
return (
<Stack
sx={{
display: 'flex',
alignItems: 'center',
minHeight: '100vh',
justifyContent: 'center',
position: 'relative',
}}
spacing='sm'
>
<Title sx={{ fontSize: 220, fontWeight: 900, lineHeight: .8 }}>{statusCode}</Title>
<MutedText sx={{ fontSize: 40, fontWeight: 500 }}>Something went wrong...</MutedText>
<Button component={Link} href='/dashboard'>Head to the Dashboard</Button>
</Stack>
);
}
export function getInitialProps({ res, err }) {
const statusCode = res ? res.statusCode : err ? err.statusCode : 404;
return { pageProps: { statusCode } };
}
Error.title = 'Zipline - Something went wrong...';

View File

@@ -4,6 +4,42 @@ import { createToken, hashPassword } from 'lib/util';
import Logger from 'lib/logger';
async function handler(req: NextApiReq, res: NextApiRes) {
if (req.method === 'POST' && req.body && req.body.code) {
const { code, username, password } = req.body as { code: string; username: string, password: string };
const invite = await prisma.invite.findUnique({
where: { code },
});
if (!invite) return res.bad('invalid invite code');
const user = await prisma.user.findFirst({
where: { username },
});
if (user) return res.bad('username already exists');
const hashed = await hashPassword(password);
const newUser = await prisma.user.create({
data: {
password: hashed,
username,
token: createToken(),
administrator: false,
},
});
await prisma.invite.update({
where: {
code,
},
data: {
used: true,
},
});
Logger.get('user').info(`Created user ${newUser.username} (${newUser.id}) from invite code ${code}`);
return res.json({ success: true });
}
const user = await req.user();
if (!user) return res.forbid('not logged in');
if (!user.administrator) return res.forbid('you arent an administrator');

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