mirror of
https://github.com/diced/zipline.git
synced 2025-12-07 13:20:43 -08:00
Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ab39fb94d | ||
|
|
e520f1e589 | ||
|
|
eff47404df | ||
|
|
844dd2d4ed | ||
|
|
2ab17aa297 | ||
|
|
776b0aa3c4 | ||
|
|
701e5ae2d0 | ||
|
|
1529eb3afd | ||
|
|
d3676c2662 | ||
|
|
cb93df347c | ||
|
|
c31a2172eb | ||
|
|
e3d0f5e47d | ||
|
|
975fc00fad | ||
|
|
03475bd7d7 | ||
|
|
6992b0eb67 | ||
|
|
c796927b35 | ||
|
|
00eb6aef41 | ||
|
|
39f2773703 | ||
|
|
59ce5e5cce | ||
|
|
a0360269b8 | ||
|
|
6270c725dc | ||
|
|
b82a50ae4e | ||
|
|
19d2c63ea9 | ||
|
|
70b5ff7562 | ||
|
|
15367f0495 | ||
|
|
306b8812e2 | ||
|
|
06c57851e4 | ||
|
|
0063c97e01 | ||
|
|
cdf7af0283 | ||
|
|
1b4c8373ea | ||
|
|
585b737c5f | ||
|
|
b1cfed834c | ||
|
|
ccde532880 | ||
|
|
bc7a7a8c24 | ||
|
|
27ec3de3ac | ||
|
|
96c0a72178 | ||
|
|
dafbe647af | ||
|
|
b679239109 | ||
|
|
5de1293949 | ||
|
|
134ee7f38e | ||
|
|
0c365d6364 | ||
|
|
12ad45387a | ||
|
|
f6d62388fa | ||
|
|
e3fe4e4254 | ||
|
|
6dee11b4dc | ||
|
|
7126fd67c6 | ||
|
|
1c22ccfa20 | ||
|
|
ef12842a5e | ||
|
|
1089dcfc73 | ||
|
|
bbae2776f6 | ||
|
|
faa51e794a | ||
|
|
23a21cf227 | ||
|
|
a900daa244 | ||
|
|
42dcd03428 | ||
|
|
b61a283059 | ||
|
|
801fe8276f | ||
|
|
48732be47c | ||
|
|
353122c169 | ||
|
|
cea5092fd6 | ||
|
|
3d4625f531 | ||
|
|
f5ab288bb3 | ||
|
|
41565b3a62 | ||
|
|
ef7fbaf1dc | ||
|
|
87cf861648 | ||
|
|
d414c85efd | ||
|
|
5b24a8e415 | ||
|
|
1d348db4dd | ||
|
|
ececc3ab0e | ||
|
|
a71bde9730 | ||
|
|
3417a84789 | ||
|
|
080c92a968 | ||
|
|
94b0220db8 | ||
|
|
dc29ad42f5 | ||
|
|
4cce4718a2 | ||
|
|
8fe1de013f | ||
|
|
7a6766e9cc | ||
|
|
5537d28849 | ||
|
|
a420d830e3 | ||
|
|
43657a8cb0 | ||
|
|
9f6e7717df | ||
|
|
da1bfe5567 | ||
|
|
a88fa14d5b | ||
|
|
58f934ba30 | ||
|
|
bfaca477a8 | ||
|
|
278aa66e00 | ||
|
|
2c179c8668 | ||
|
|
13406a2f67 | ||
|
|
cc99001697 |
@@ -29,6 +29,7 @@ module.exports = {
|
||||
'linebreak-style': ['error', 'unix'],
|
||||
quotes: ['error', 'single'],
|
||||
semi: ['error', 'always'],
|
||||
'comma-dangle': ['error', 'never']
|
||||
'comma-dangle': ['error', 'never'],
|
||||
'nonblock-statement-body-position': ['error', 'beside']
|
||||
}
|
||||
};
|
||||
|
||||
22
.github/workflows/docker.yml
vendored
Normal file
22
.github/workflows/docker.yml
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
name: Publish Zipline Docker Image
|
||||
on:
|
||||
push:
|
||||
branches: [next]
|
||||
pull_request:
|
||||
branches: [next]
|
||||
jobs:
|
||||
push_to_registry:
|
||||
name: Push Docker Image to Github Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v2
|
||||
- name: Push to GitHub Packages
|
||||
uses: docker/build-push-action@v1
|
||||
with:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
registry: docker.pkg.github.com
|
||||
repository: diced/zipline/zipline
|
||||
dockerfile: Dockerfile
|
||||
tag_with_ref: true
|
||||
5
.github/workflows/node.js.yml
vendored
5
.github/workflows/node.js.yml
vendored
@@ -23,5 +23,6 @@ jobs:
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- run: npm ci
|
||||
- run: npm run build --if-present
|
||||
- run: npm i -g yarn
|
||||
- run: yarn
|
||||
- run: yarn build
|
||||
|
||||
38
README.md
38
README.md
@@ -1,14 +1,38 @@
|
||||
## 
|
||||
<p align="center"><img src="https://raw.githubusercontent.com/ZiplineProject/zipline/next/public/zipline_small.png"/></p>
|
||||
|
||||
[](https://app.codacy.com/gh/ZiplineProject/zipline?utm_source=github.com&utm_medium=referral&utm_content=ZiplineProject/zipline&utm_campaign=Badge_Grade)
|
||||

|
||||

|
||||
[](https://discord.gg/AtTSecwqeV)
|
||||

|
||||

|
||||

|
||||
<br>
|
||||

|
||||

|
||||

|
||||
|
||||
# ZiplineNext
|
||||
Speed & reliable
|
||||
# Zipline
|
||||
The best and only **React + Next.js** ShareX / File Uploader you would ever want.
|
||||
|
||||
# Comparison
|
||||
Wondering how Zipline compares to other popular uploaders? We have done some benchmarking on other popular upload servers, see how Zipline compares.
|
||||
|
||||
| Uploader | Average ms (3 batches/1.5k files) |
|
||||
|-|-|
|
||||
| **[Zipline](https://github.com/diced/zipline)** | **61 ms** |
|
||||
| [ShareX-Upload-Server](https://github.com/TannerReynolds/ShareX-Upload-Server) | 86 ms |
|
||||
|
||||
*Note: there were 3 batches of 1.5k requests, the average ms of each was averaged again*<br>
|
||||
*Note 2: results will vary because its very dependent on the server, location, and your internet (these tests were run on the same machine with local dbs)*
|
||||
|
||||
# Features
|
||||
- Configurable
|
||||
- Fast (API)
|
||||
- Built with Next.js & React
|
||||
- Support for multible database types (mongo soon)
|
||||
- Support for **multible database types** (*literally the only one that supports multiple dbs*, mongo soon)
|
||||
- Token protected uploading
|
||||
- MFA with Authy/Google Authenticator
|
||||
- Easy setup instructions on [docs](https://zipline.diced.wtf/docs)
|
||||
|
||||
# Documentation
|
||||
You can view current documentation [here](https://zipline.diced.wtf/)
|
||||
# Installing
|
||||
[See how to install here](https://zipline.diced.wtf/docs/)
|
||||
|
||||
9
docker-compose.template.yml
Normal file
9
docker-compose.template.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
version: "3"
|
||||
services:
|
||||
zipline:
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- "./uploads:/opt/zipline/uploads"
|
||||
build: .
|
||||
tty: true
|
||||
9590
package-lock.json
generated
9590
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "zipline-next",
|
||||
"version": "2.1.0",
|
||||
"version": "2.8.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@dicedtomato/colors": "^1.0.3",
|
||||
@@ -26,18 +26,21 @@
|
||||
"material-ui-dropzone": "^3.5.0",
|
||||
"next": "^9.5.4",
|
||||
"pg": "^8.4.1",
|
||||
"qrcode": "^1.4.4",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-redux": "^7.2.1",
|
||||
"redux": "^4.0.5",
|
||||
"redux-persist": "^6.0.0",
|
||||
"speakeasy": "^2.0.0",
|
||||
"toml-patch": "^0.2.3",
|
||||
"typeorm": "^0.2.28"
|
||||
},
|
||||
"scripts": {
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"dev": "ts-node src",
|
||||
"dev:verbose": "VERBOSE=true ts-node src",
|
||||
"build": "next build && tsc -p .",
|
||||
@@ -49,15 +52,18 @@
|
||||
"@types/crypto-js": "^3.1.47",
|
||||
"@types/mongodb": "^3.5.27",
|
||||
"@types/node": "^14.11.2",
|
||||
"@types/qrcode": "^1.3.5",
|
||||
"@types/react": "^16.9.49",
|
||||
"@types/react-redux": "^7.1.9",
|
||||
"@types/semver": "^7.3.4",
|
||||
"@typescript-eslint/eslint-plugin": "^4.4.0",
|
||||
"@typescript-eslint/parser": "^4.4.0",
|
||||
"eslint": "^7.10.0",
|
||||
"eslint-plugin-react": "^7.21.3",
|
||||
"mongodb": "^3.6.2",
|
||||
"prettier": "2.1.2",
|
||||
"release": "^6.3.0",
|
||||
"ts-node": "^9.0.0",
|
||||
"typescript": "^4.0.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/zipline_small.png
Normal file
BIN
public/zipline_small.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
3
release.js
Normal file
3
release.js
Normal file
@@ -0,0 +1,3 @@
|
||||
module.exports = async (markdown) => {
|
||||
return markdown;
|
||||
};
|
||||
58
setup.js
58
setup.js
@@ -4,14 +4,14 @@ const { stringify } = require('toml-patch');
|
||||
const { writeFileSync } = require('fs');
|
||||
const { join } = require('path');
|
||||
|
||||
const createDockerCompose = (port) => {
|
||||
const createDockerCompose = (port, dir) => {
|
||||
return `version: "3"
|
||||
services:
|
||||
zipline:
|
||||
ports:
|
||||
- "${port}:${port}"
|
||||
volumes:
|
||||
- "${join(process.cwd(), 'uploads')}:/opt/zipline/uploads"
|
||||
- "${join(process.cwd(), dir)}:${join('/opt/zipline', dir)}"
|
||||
build: .
|
||||
tty: true`;
|
||||
};
|
||||
@@ -25,7 +25,14 @@ const base = {
|
||||
'https://github.githubassets.com/images/modules/open_graph/github-mark.png',
|
||||
color: '#128377'
|
||||
},
|
||||
core: { secret: 'my-secret', port: 3000 },
|
||||
core: {
|
||||
secret: 'my-secret',
|
||||
port: 3000,
|
||||
host: '127.0.0.1',
|
||||
theme: 'dark',
|
||||
secure: false,
|
||||
mfa: false
|
||||
},
|
||||
uploader: {
|
||||
directory: './uploads',
|
||||
route: '/u',
|
||||
@@ -33,7 +40,7 @@ const base = {
|
||||
original: false,
|
||||
blacklisted: []
|
||||
},
|
||||
urls: { route: '/s', length: 4, vanity: false }
|
||||
urls: { route: '/go', length: 4, vanity: true }
|
||||
};
|
||||
|
||||
(async () => {
|
||||
@@ -41,42 +48,40 @@ const base = {
|
||||
{
|
||||
type: 'list',
|
||||
name: 'type',
|
||||
message: 'What database type?',
|
||||
message: 'What database type? (you will have to install the drivers)',
|
||||
choices: [
|
||||
{ name: 'postgres', extra: 'This is what we recomend using.' },
|
||||
{ name: 'cockroachdb' },
|
||||
{ name: 'mysql' },
|
||||
{ name: 'mariadb' },
|
||||
{ name: 'mssql' },
|
||||
{ name: 'sqlite' },
|
||||
{ name: 'sqlite3' },
|
||||
{ name: 'mongodb', extra: 'No support yet' }
|
||||
{ name: 'sqlite' }
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'host',
|
||||
message: 'Database Host'
|
||||
message: 'Database Host (leave blank if sqlite3)'
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
name: 'port',
|
||||
message: 'Database Port'
|
||||
message: 'Database Port (leave blank if sqlite3)'
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'database',
|
||||
message: 'Database Name'
|
||||
message: 'Database Name (db path if sqlite3)'
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'username',
|
||||
message: 'Database User'
|
||||
message: 'Database User (leave blank if sqlite3)'
|
||||
},
|
||||
{
|
||||
type: 'password',
|
||||
name: 'password',
|
||||
message: 'Database Password'
|
||||
message: 'Database Password (leave blank if sqlite3)'
|
||||
}
|
||||
]);
|
||||
|
||||
@@ -92,6 +97,20 @@ const base = {
|
||||
type: 'number',
|
||||
name: 'port',
|
||||
message: 'Serve on Port'
|
||||
},
|
||||
{
|
||||
type: 'list',
|
||||
name: 'theme',
|
||||
message: 'Theme',
|
||||
choices: [
|
||||
{ name: 'dark' },
|
||||
{ name: 'light' }
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'mfa',
|
||||
message: 'Enable 2 Factor Authentication with Authy/Google Authenticator'
|
||||
}
|
||||
]);
|
||||
|
||||
@@ -106,7 +125,7 @@ const base = {
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'original',
|
||||
message: 'Keep Original?'
|
||||
message: 'Keep Original File names?'
|
||||
}
|
||||
]);
|
||||
|
||||
@@ -138,10 +157,15 @@ const base = {
|
||||
urls: { ...base.urls, ...urls }
|
||||
};
|
||||
|
||||
writeFileSync('Zipline.toml', stringify(config));
|
||||
|
||||
if (docker.useDocker) {
|
||||
config.core.host = '0.0.0.0';
|
||||
console.log('Generating docker-compose.yml...');
|
||||
writeFileSync('docker-compose.yml', createDockerCompose(config.core.port));
|
||||
writeFileSync('docker-compose.yml', createDockerCompose(config.core.port, config.uploader.directory));
|
||||
console.log(
|
||||
'Head to https://zipline.diced.wtf/docs/docker to learn how to run with docker.'
|
||||
);
|
||||
}
|
||||
if (config.database.type !== "postgres") console.log(`please head to https://zipline.diced.wtf/docs/config/getting-started#database to see what drivers you need to install for ${config.database.type}`);
|
||||
|
||||
writeFileSync('Zipline.toml', stringify(config));
|
||||
})();
|
||||
|
||||
@@ -6,6 +6,10 @@ import Button from '@material-ui/core/Button';
|
||||
import Card from '@material-ui/core/Card';
|
||||
import CardContent from '@material-ui/core/CardContent';
|
||||
import CardActions from '@material-ui/core/CardActions';
|
||||
import Dialog from '@material-ui/core/Dialog';
|
||||
import DialogActions from '@material-ui/core/DialogActions';
|
||||
import DialogContent from '@material-ui/core/DialogContent';
|
||||
import DialogTitle from '@material-ui/core/DialogTitle';
|
||||
import Snackbar from '@material-ui/core/Snackbar';
|
||||
import Alert from '@material-ui/lab/Alert';
|
||||
import makeStyles from '@material-ui/core/styles/makeStyles';
|
||||
@@ -26,8 +30,11 @@ export default function Login() {
|
||||
const classes = useStyles();
|
||||
const router = useRouter();
|
||||
const dispatch = useDispatch();
|
||||
const [error, setError] = useState(false);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [authOpen, setAuthOpen] = useState(false);
|
||||
const [token, setToken] = useState('');
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleClose = (event, reason) => {
|
||||
@@ -37,17 +44,48 @@ export default function Login() {
|
||||
|
||||
const handleLogin = async () => {
|
||||
const d = await (
|
||||
await fetch('/api/user/login', {
|
||||
await fetch('/api/user/verify-login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
})
|
||||
).json();
|
||||
if (!d.error) {
|
||||
dispatch({ type: UPDATE_USER, payload: d });
|
||||
if (d.mfa) {
|
||||
setAuthOpen(true);
|
||||
} else {
|
||||
const payload = await (
|
||||
await fetch('/api/user/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
})
|
||||
).json();
|
||||
if (!payload.error) {
|
||||
dispatch({ type: UPDATE_USER, payload });
|
||||
dispatch({ type: LOGIN });
|
||||
router.push('/dash');
|
||||
}
|
||||
}
|
||||
} else setOpen(true);
|
||||
};
|
||||
|
||||
const tryChecking = async () => {
|
||||
const verified = await (
|
||||
await fetch(`/api/mfa/verify?token=${token}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
})
|
||||
).json();
|
||||
if (!verified.passed) setError(true);
|
||||
else {
|
||||
setError(false);
|
||||
setAuthOpen(false);
|
||||
dispatch({ type: UPDATE_USER, payload: verified.user });
|
||||
dispatch({ type: LOGIN });
|
||||
router.push('/dash');
|
||||
} else setOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -65,30 +103,63 @@ export default function Login() {
|
||||
Could not login!
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant='h4'>Login</Typography>
|
||||
<Dialog
|
||||
open={authOpen}
|
||||
onClose={() => setAuthOpen(false)}
|
||||
aria-labelledby='enable-mfa'
|
||||
aria-describedby='mfa-desc'
|
||||
>
|
||||
<DialogTitle id='enable-mfa'>2FA</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
label='Username'
|
||||
label='Code'
|
||||
helperText={error ? 'Incorrect code' : ''}
|
||||
value={token}
|
||||
className={classes.field}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
onChange={e => setToken(e.target.value)}
|
||||
error={error}
|
||||
/>
|
||||
<TextField
|
||||
label='Password'
|
||||
type='password'
|
||||
className={classes.field}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardActions>
|
||||
<Button
|
||||
color='primary'
|
||||
className={classes.field}
|
||||
onClick={handleLogin}
|
||||
>
|
||||
Login
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button color='primary' autoFocus onClick={tryChecking}>
|
||||
Check
|
||||
</Button>
|
||||
</CardActions>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<Card>
|
||||
<form>
|
||||
<CardContent>
|
||||
<Typography variant='h4'>Login</Typography>
|
||||
<TextField
|
||||
label='Username'
|
||||
InputLabelProps={{
|
||||
htmlFor: 'username'
|
||||
}}
|
||||
id='username'
|
||||
className={classes.field}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label='Password'
|
||||
type='password'
|
||||
InputLabelProps={{
|
||||
htmlFor: 'password'
|
||||
}}
|
||||
id='password'
|
||||
className={classes.field}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardActions>
|
||||
<Button
|
||||
color='primary'
|
||||
className={classes.field}
|
||||
onClick={handleLogin}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</CardActions>
|
||||
</form>
|
||||
</Card>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
@@ -3,14 +3,21 @@ import Typography from '@material-ui/core/Typography';
|
||||
import Card from '@material-ui/core/Card';
|
||||
import CardContent from '@material-ui/core/CardContent';
|
||||
import CardActions from '@material-ui/core/CardActions';
|
||||
import Dialog from '@material-ui/core/Dialog';
|
||||
import DialogActions from '@material-ui/core/DialogActions';
|
||||
import DialogContent from '@material-ui/core/DialogContent';
|
||||
import DialogContentText from '@material-ui/core/DialogContentText';
|
||||
import DialogTitle from '@material-ui/core/DialogTitle';
|
||||
import Button from '@material-ui/core/Button';
|
||||
import TextField from '@material-ui/core/TextField';
|
||||
import Snackbar from '@material-ui/core/Snackbar';
|
||||
import Grid from '@material-ui/core/Grid';
|
||||
import Alert from '@material-ui/lab/Alert';
|
||||
import { makeStyles } from '@material-ui/core';
|
||||
import { UPDATE_USER } from '../reducer';
|
||||
import { store } from '../store';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Config } from '../lib/Config';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
margin: {
|
||||
@@ -27,27 +34,76 @@ const useStyles = makeStyles({
|
||||
}
|
||||
});
|
||||
|
||||
export default function ManageUser() {
|
||||
export default function ManageUser({ config }: { config: Config }) {
|
||||
const classes = useStyles();
|
||||
const dispatch = useDispatch();
|
||||
const state = store.getState();
|
||||
const [alertOpen, setAlertOpen] = useState(false);
|
||||
const [mfaDialogOpen, setMfaDialogOpen] = useState(false);
|
||||
const [qrcode, setQRCode] = useState(null);
|
||||
const [username, setUsername] = useState(state.user.username);
|
||||
const [password, setPassword] = useState('');
|
||||
const [mfaToken, setMfaToken] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const handleUpdateUser = async () => {
|
||||
const d = await (
|
||||
await fetch('/api/user', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password: password.trim() === '' ? null : password,
|
||||
email
|
||||
})
|
||||
})
|
||||
).json();
|
||||
|
||||
if (!d.error) {
|
||||
dispatch({ type: UPDATE_USER, payload: d });
|
||||
setAlertOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const disableMFA = async () => {
|
||||
await fetch('/api/mfa/disable');
|
||||
const d = await (await fetch('/api/user')).json();
|
||||
|
||||
if (!d.error) {
|
||||
dispatch({ type: UPDATE_USER, payload: d });
|
||||
setAlertOpen(true);
|
||||
}
|
||||
};
|
||||
const enableMFA = async () => {
|
||||
setMfaDialogOpen(true);
|
||||
const { dataURL } = await (await fetch('/api/mfa/qrcode')).json();
|
||||
setQRCode(dataURL);
|
||||
};
|
||||
const tryEnablingMfa = async () => {
|
||||
const verified = await (
|
||||
await fetch(`/api/mfa/verify?token=${mfaToken}`)
|
||||
).json();
|
||||
if (!verified) setError(true);
|
||||
else {
|
||||
setError(false);
|
||||
setMfaDialogOpen(false);
|
||||
setAlertOpen(true);
|
||||
const d = await (await fetch('/api/user')).json();
|
||||
|
||||
if (!d.error) {
|
||||
dispatch({ type: UPDATE_USER, payload: d });
|
||||
setAlertOpen(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const disableMfaAndClose = async () => {
|
||||
const d = await (await fetch('/api/mfa/disable')).json();
|
||||
|
||||
if (!d.error) setMfaDialogOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Snackbar
|
||||
@@ -63,6 +119,44 @@ export default function ManageUser() {
|
||||
Updated <b>{state.user.username}</b>
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
<Dialog
|
||||
open={mfaDialogOpen}
|
||||
onClose={disableMfaAndClose}
|
||||
aria-labelledby='enable-mfa'
|
||||
aria-describedby='mfa-desc'
|
||||
>
|
||||
<DialogTitle id='enable-mfa'>Enable 2FA</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id='mfa-desc'>
|
||||
When enabling 2FA/MFA you can use <b>Authy</b> or{' '}
|
||||
<b>Google Authenticator</b> to authenticate before logging into
|
||||
Zipline.
|
||||
</DialogContentText>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6}>
|
||||
<img src={qrcode} />
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<TextField
|
||||
label='Code'
|
||||
helperText='After scanning the QRCode, enter the authentication code here'
|
||||
value={mfaToken}
|
||||
className={classes.field}
|
||||
onChange={e => setMfaToken(e.target.value)}
|
||||
error={error}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button color='primary' autoFocus onClick={disableMfaAndClose}>
|
||||
Disable
|
||||
</Button>
|
||||
<Button color='primary' autoFocus onClick={tryEnablingMfa}>
|
||||
Enable
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography color='textSecondary' variant='h4' gutterBottom>
|
||||
@@ -82,6 +176,14 @@ export default function ManageUser() {
|
||||
className={classes.field}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label='Email'
|
||||
type='email'
|
||||
helperText='Used for a Gravatar Avatar'
|
||||
value={email}
|
||||
className={classes.field}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardActions>
|
||||
<Button
|
||||
@@ -91,6 +193,15 @@ export default function ManageUser() {
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
{config.core.mfa ? (
|
||||
<Button
|
||||
className={classes.button}
|
||||
color='primary'
|
||||
onClick={state.user.secretMfaKey ? disableMFA : enableMFA}
|
||||
>
|
||||
{state.user.secretMfaKey ? 'Disable MFA' : 'Enable MFA'}
|
||||
</Button>
|
||||
) : null}
|
||||
</CardActions>
|
||||
</Card>
|
||||
</React.Fragment>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import AppBar from '@material-ui/core/AppBar';
|
||||
import Box from '@material-ui/core/Box';
|
||||
import Menu from '@material-ui/core/Menu';
|
||||
import MenuItem from '@material-ui/core/MenuItem';
|
||||
import Drawer from '@material-ui/core/Drawer';
|
||||
import Avatar from '@material-ui/core/Avatar';
|
||||
import Hidden from '@material-ui/core/Hidden';
|
||||
import List from '@material-ui/core/List';
|
||||
import ListItem from '@material-ui/core/ListItem';
|
||||
@@ -31,11 +33,14 @@ import FileCopyIcon from '@material-ui/icons/FileCopy';
|
||||
import ExitToAppIcon from '@material-ui/icons/ExitToApp';
|
||||
import PublishIcon from '@material-ui/icons/Publish';
|
||||
import RotateLeftIcon from '@material-ui/icons/RotateLeft';
|
||||
import Divider from '@material-ui/core/Divider';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import { LOGOUT, UPDATE_USER } from '../reducer';
|
||||
import { makeStyles, useTheme } from '@material-ui/core/styles';
|
||||
import { makeStyles, useTheme, withStyles } from '@material-ui/core/styles';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { store } from '../store';
|
||||
import { MD5 } from 'crypto-js';
|
||||
|
||||
const drawerWidth = 240;
|
||||
|
||||
@@ -53,13 +58,13 @@ const useStyles = makeStyles(theme => ({
|
||||
},
|
||||
appBar: {
|
||||
display: 'flex',
|
||||
backgroundColor: '#000',
|
||||
color: '#fff',
|
||||
backgroundColor: theme.palette.type === 'dark' ? '#000' : '#fff',
|
||||
color: theme.palette.type !== 'dark' ? '#000' : '#fff',
|
||||
[theme.breakpoints.up('sm')]: {
|
||||
width: 'calc(100%)',
|
||||
marginLeft: drawerWidth
|
||||
},
|
||||
borderBottom: '1px solid #1f1f1f'
|
||||
borderBottom: theme.palette.type === 'dark' ? '1px solid #1f1f1f' : '1px solid #e0e0e0'
|
||||
},
|
||||
menuButton: {
|
||||
marginRight: theme.spacing(2),
|
||||
@@ -84,11 +89,21 @@ const useStyles = makeStyles(theme => ({
|
||||
}
|
||||
}));
|
||||
|
||||
const NoFocusMenuItem = withStyles(theme => ({
|
||||
root: {
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.type === 'dark' ? '#000' : '#f7f7f7'
|
||||
}
|
||||
}
|
||||
}))(MenuItem);
|
||||
|
||||
export default function UI({ children }) {
|
||||
const classes = useStyles();
|
||||
const theme = useTheme();
|
||||
const state = store.getState();
|
||||
const router = useRouter();
|
||||
const dispatch = useDispatch();
|
||||
const [emailHash, setEmailHash] = useState('');
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [admin, setAdmin] = useState(false);
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
@@ -107,6 +122,8 @@ export default function UI({ children }) {
|
||||
router.push('/user/login');
|
||||
} else setAdmin(d.administrator);
|
||||
})();
|
||||
|
||||
setEmailHash(MD5(state.user.email).toString());
|
||||
}, []);
|
||||
|
||||
const handleCopyTokenThenClose = async () => {
|
||||
@@ -161,16 +178,34 @@ export default function UI({ children }) {
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Typography variant='h6'>Zipline</Typography>
|
||||
<IconButton
|
||||
aria-label='account of current user'
|
||||
aria-controls='menu-appbar'
|
||||
aria-haspopup='true'
|
||||
onClick={event => setAnchorEl(event.currentTarget)}
|
||||
color='inherit'
|
||||
className={classes.rightButton}
|
||||
>
|
||||
<AccountCircleIcon className={classes.rightButton} />
|
||||
</IconButton>
|
||||
<Box className={classes.rightButton}>
|
||||
{state.user.email ? (
|
||||
<Button
|
||||
aria-label='account of current user'
|
||||
aria-controls='menu-appbar'
|
||||
aria-haspopup='true'
|
||||
onClick={event => setAnchorEl(event.currentTarget)}
|
||||
color='inherit'
|
||||
className={classes.rightButton}
|
||||
>
|
||||
<Avatar src={`https://www.gravatar.com/avatar/${emailHash}.jpg`}>
|
||||
{state.user.username[0].toUpperCase()}
|
||||
</Avatar>
|
||||
</Button>
|
||||
) : (
|
||||
<IconButton
|
||||
aria-label='account of current user'
|
||||
aria-controls='menu-appbar'
|
||||
aria-haspopup='true'
|
||||
onClick={event => setAnchorEl(event.currentTarget)}
|
||||
color='inherit'
|
||||
className={classes.rightButton}
|
||||
>
|
||||
<AccountCircleIcon className={classes.rightButton} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Menu
|
||||
id='menu-appbar'
|
||||
anchorEl={anchorEl}
|
||||
@@ -186,6 +221,12 @@ export default function UI({ children }) {
|
||||
open={open}
|
||||
onClose={() => setAnchorEl(null)}
|
||||
>
|
||||
<NoFocusMenuItem>
|
||||
<Typography variant='h6'>
|
||||
{state.user.username}
|
||||
</Typography>
|
||||
</NoFocusMenuItem>
|
||||
<Divider />
|
||||
<Link href='/user/manage'>
|
||||
<MenuItem onClick={() => setAnchorEl(null)}>
|
||||
<AccountCircleIcon className={classes.menuIcon} />
|
||||
|
||||
@@ -1,175 +1,21 @@
|
||||
import React from 'react';
|
||||
import AppBar from '@material-ui/core/AppBar';
|
||||
import Drawer from '@material-ui/core/Drawer';
|
||||
import Hidden from '@material-ui/core/Hidden';
|
||||
import IconButton from '@material-ui/core/IconButton';
|
||||
import List from '@material-ui/core/List';
|
||||
import ListItem from '@material-ui/core/ListItem';
|
||||
import Toolbar from '@material-ui/core/Toolbar';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import Backdrop from '@material-ui/core/Backdrop';
|
||||
import CircularProgress from '@material-ui/core/CircularProgress';
|
||||
import Grid from '@material-ui/core/Grid';
|
||||
import MenuIcon from '@material-ui/icons/Menu';
|
||||
import Skeleton from '@material-ui/lab/Skeleton';
|
||||
import { makeStyles, useTheme } from '@material-ui/core/styles';
|
||||
import { makeStyles } from '@material-ui/core';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
root: {
|
||||
display: 'flex'
|
||||
},
|
||||
drawer: {
|
||||
[theme.breakpoints.up('sm')]: {
|
||||
width: 240,
|
||||
flexShrink: 0
|
||||
}
|
||||
},
|
||||
appBar: {
|
||||
[theme.breakpoints.up('sm')]: {
|
||||
width: 'calc(100%)',
|
||||
marginLeft: 240
|
||||
}
|
||||
},
|
||||
menuButton: {
|
||||
marginRight: theme.spacing(2),
|
||||
[theme.breakpoints.up('sm')]: {
|
||||
display: 'none'
|
||||
}
|
||||
},
|
||||
rightButton: {
|
||||
marginLeft: 'auto'
|
||||
},
|
||||
// necessary for content to be below app bar
|
||||
toolbar: theme.mixins.toolbar,
|
||||
drawerPaper: {
|
||||
width: 240
|
||||
},
|
||||
content: {
|
||||
flexGrow: 1,
|
||||
padding: theme.spacing(3)
|
||||
},
|
||||
fullWidth: {
|
||||
width: '100%'
|
||||
backdrop: {
|
||||
zIndex: theme.zIndex.drawer + 1,
|
||||
color: '#000'
|
||||
}
|
||||
}));
|
||||
|
||||
export default function UIPlaceholder() {
|
||||
const classes = useStyles();
|
||||
const theme = useTheme();
|
||||
const [mobileOpen, setMobileOpen] = React.useState(false);
|
||||
|
||||
const handleDrawerToggle = () => {
|
||||
setMobileOpen(!mobileOpen);
|
||||
};
|
||||
|
||||
const drawer = (
|
||||
<div>
|
||||
<Toolbar>
|
||||
<AppBar position='fixed' className={classes.appBar} elevation={0}>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
color='inherit'
|
||||
aria-label='open drawer'
|
||||
edge='start'
|
||||
onClick={handleDrawerToggle}
|
||||
className={classes.menuButton}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Typography variant='h6' noWrap>
|
||||
Zipline
|
||||
</Typography>
|
||||
<div className={classes.rightButton}>
|
||||
<Skeleton animation='wave' className={classes.fullWidth} />
|
||||
</div>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
</Toolbar>
|
||||
|
||||
<List>
|
||||
<ListItem button key='Home'>
|
||||
<Skeleton animation='wave' className={classes.fullWidth} />
|
||||
</ListItem>
|
||||
<ListItem button key='Statistics'>
|
||||
<Skeleton animation='wave' className={classes.fullWidth} />
|
||||
</ListItem>
|
||||
<ListItem button key='Images'>
|
||||
<Skeleton animation='wave' className={classes.fullWidth} />
|
||||
</ListItem>
|
||||
<ListItem button key='URLs'>
|
||||
<Skeleton animation='wave' className={classes.fullWidth} />
|
||||
</ListItem>
|
||||
</List>
|
||||
</div>
|
||||
);
|
||||
|
||||
const container =
|
||||
typeof window !== 'undefined' ? () => window.document.body : undefined;
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<AppBar position='fixed' className={classes.appBar} elevation={0}>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
color='inherit'
|
||||
aria-label='open drawer'
|
||||
edge='start'
|
||||
onClick={handleDrawerToggle}
|
||||
className={classes.menuButton}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Typography variant='h6' noWrap>
|
||||
Zipline
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<nav className={classes.drawer} aria-label='mailbox folders'>
|
||||
<Hidden smUp implementation='css'>
|
||||
<Drawer
|
||||
container={container}
|
||||
variant='temporary'
|
||||
anchor={theme.direction === 'rtl' ? 'right' : 'left'}
|
||||
open={mobileOpen}
|
||||
onClose={handleDrawerToggle}
|
||||
classes={{
|
||||
paper: classes.drawerPaper
|
||||
}}
|
||||
ModalProps={{
|
||||
keepMounted: true // Better open performance on mobile.
|
||||
}}
|
||||
>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
</Hidden>
|
||||
<Hidden xsDown implementation='css'>
|
||||
<Drawer
|
||||
classes={{
|
||||
paper: classes.drawerPaper
|
||||
}}
|
||||
variant='permanent'
|
||||
open
|
||||
PaperProps={{ style: { border: 'none' } }}
|
||||
>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
</Hidden>
|
||||
</nav>
|
||||
|
||||
<main className={classes.content}>
|
||||
<div className={classes.toolbar} />
|
||||
<Grid
|
||||
container
|
||||
spacing={0}
|
||||
direction='column'
|
||||
alignItems='center'
|
||||
justify='center'
|
||||
style={{ minHeight: '80vh' }}
|
||||
>
|
||||
<Grid item xs={3}>
|
||||
<CircularProgress size={100} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</main>
|
||||
</div>
|
||||
<Backdrop className={classes.backdrop} open={true}>
|
||||
<CircularProgress color='inherit' />
|
||||
</Backdrop>
|
||||
);
|
||||
}
|
||||
|
||||
14
src/components/ZiplineTheming.tsx
Normal file
14
src/components/ZiplineTheming.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import CssBaseline from '@material-ui/core/CssBaseline';
|
||||
import { ThemeProvider } from '@material-ui/core/styles';
|
||||
import dark from '../lib/themes/dark';
|
||||
import light from '../lib/themes/light';
|
||||
|
||||
export default function ZiplineTheming({ Component, pageProps, theme }) {
|
||||
return (
|
||||
<ThemeProvider theme={theme == 'light' ? light : dark}>
|
||||
<CssBaseline />
|
||||
<Component {...pageProps} />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
198
src/index.ts
198
src/index.ts
@@ -1,37 +1,29 @@
|
||||
import next from 'next';
|
||||
import { textSync as text } from 'figlet';
|
||||
import fastify, { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import fastifyTypeorm from 'fastify-typeorm-plugin';
|
||||
import fastifyCookies from 'fastify-cookie';
|
||||
import fastifyMultipart from 'fastify-multipart';
|
||||
import fastifyRateLimit from 'fastify-rate-limit';
|
||||
import fastify from 'fastify';
|
||||
import fastifyStatic from 'fastify-static';
|
||||
import fastifyFavicon from 'fastify-favicon';
|
||||
import { bootstrap } from 'fastify-decorators';
|
||||
import fastifyTypeorm from 'fastify-typeorm-plugin';
|
||||
import { Console } from './lib/logger';
|
||||
import { AddressInfo } from 'net';
|
||||
import { magenta, bold, green, reset, blue, red } from '@dicedtomato/colors';
|
||||
import { bold, green, reset } from '@dicedtomato/colors';
|
||||
import { Configuration } from './lib/Config';
|
||||
import { UserController } from './controllers/UserController';
|
||||
import { RootController } from './controllers/RootController';
|
||||
import { join } from 'path';
|
||||
import { ImagesController } from './controllers/ImagesController';
|
||||
import { URLSController } from './controllers/URLSController';
|
||||
import { URL } from './entities/URL';
|
||||
import { checkVersion } from './lib/Util';
|
||||
import { PluginLoader } from './lib/plugin';
|
||||
import { readdirSync, statSync } from 'fs';
|
||||
|
||||
const dev = process.env.NODE_ENV !== 'production';
|
||||
const server = fastify({});
|
||||
const app = next({
|
||||
dev,
|
||||
quiet: dev
|
||||
});
|
||||
|
||||
console.log(`
|
||||
${magenta(text('Zipline'))}
|
||||
|
||||
GitHub : ${blue('https://github.com/ZiplineProject/zipline')}
|
||||
Issues : ${blue('https://github.com/ZiplineProject/zipline/issues')}
|
||||
Docs : ${blue('https://zipline.diced.wtf/')}
|
||||
Mode : ${bold(dev ? red('dev') : green('production'))}
|
||||
Verbose : ${bold(process.env.VERBOSE ? red('yes') : green('no'))}
|
||||
`);
|
||||
app.prepare();
|
||||
|
||||
const pluginLoader = new PluginLoader(server, process.cwd(), dev ? './src/plugins' : './dist/plugins');
|
||||
Console.logger(Configuration).verbose('searching for config...');
|
||||
const config = Configuration.readConfig();
|
||||
|
||||
if (!config) {
|
||||
Console.logger(Configuration).error(
|
||||
`could not find a Zipline.toml file in ${process.cwd()}`
|
||||
@@ -39,120 +31,72 @@ if (!config) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const server = fastify({});
|
||||
const app = next({
|
||||
dev,
|
||||
quiet: dev
|
||||
});
|
||||
const handle = app.getRequestHandler();
|
||||
(async () => {
|
||||
const builtInPlugins = await pluginLoader.loadPlugins(true);
|
||||
for (const plugin of builtInPlugins) {
|
||||
try {
|
||||
plugin.onLoad(server, null, app, config);
|
||||
} catch (e) {
|
||||
Console.logger(PluginLoader).error(`failed to load built-in plugin: ${plugin.name}, ${e.message}`);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
Console.logger(next).info('Preparing app...');
|
||||
app.prepare();
|
||||
Console.logger(next).verbose('Prepared app');
|
||||
const dir = config.uploader.directory ? config.uploader.directory : 'uploads';
|
||||
const path = dir.charAt(0) == '/' ? dir : join(process.cwd(), dir);
|
||||
const handle = app.getRequestHandler();
|
||||
|
||||
server.register(fastifyRateLimit, {
|
||||
timeWindow: 5000,
|
||||
max: 1,
|
||||
global: false
|
||||
});
|
||||
|
||||
if (dev)
|
||||
server.get('/_next/*', async (req, reply) => {
|
||||
await handle(req.raw, reply.raw);
|
||||
return (reply.sent = true);
|
||||
});
|
||||
|
||||
server.all('/*', async (req, reply) => {
|
||||
await handle(req.raw, reply.raw);
|
||||
return (reply.sent = true);
|
||||
});
|
||||
|
||||
server.setNotFoundHandler(async (req, reply) => {
|
||||
await app.render404(req.raw, reply.raw);
|
||||
return (reply.sent = true);
|
||||
});
|
||||
|
||||
server.get(`${config.urls.route}/:id`, async function (
|
||||
req: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const urls = this.orm.getRepository(URL);
|
||||
|
||||
const urlId = await urls.findOne({
|
||||
where: {
|
||||
id: req.params.id
|
||||
server.get('/*', async (req, reply) => {
|
||||
const routeRegex = /\/_next\/static|\/((dash|user)(\/)?(.+)?)?/gi;
|
||||
if (routeRegex.test(req.url)) {
|
||||
await handle(req.raw, reply.raw);
|
||||
return (reply.sent = true);
|
||||
} else {
|
||||
await app.render404(req.raw, reply.raw);
|
||||
return (reply.sent = true);
|
||||
}
|
||||
});
|
||||
|
||||
const urlVanity = await urls.findOne({
|
||||
where: {
|
||||
vanity: req.params.id
|
||||
}
|
||||
});
|
||||
|
||||
if (config.urls.vanity && urlVanity) return reply.redirect(urlVanity.url);
|
||||
if (!urlId) {
|
||||
server.setNotFoundHandler(async (req, reply) => {
|
||||
await app.render404(req.raw, reply.raw);
|
||||
return (reply.sent = true);
|
||||
}
|
||||
return reply.redirect(urlId.url);
|
||||
});
|
||||
});
|
||||
|
||||
server.register(fastifyMultipart);
|
||||
server.register(fastifyStatic, {
|
||||
root: path,
|
||||
prefix: config.uploader.route
|
||||
});
|
||||
|
||||
server.register(fastifyTypeorm, {
|
||||
...config.database,
|
||||
entities: [dev ? './src/entities/**/*.ts' : './dist/entities/**/*.js'],
|
||||
synchronize: true,
|
||||
logging: false
|
||||
});
|
||||
|
||||
server.register(bootstrap, {
|
||||
controllers: [
|
||||
UserController,
|
||||
RootController,
|
||||
ImagesController,
|
||||
URLSController
|
||||
]
|
||||
});
|
||||
// done after everything so plugins can overwrite routes, etc.
|
||||
server.register(async () => {
|
||||
const plugins = await pluginLoader.loadPlugins();
|
||||
for (const plugin of plugins) {
|
||||
try {
|
||||
plugin.onLoad(server, server.orm, app, config);
|
||||
Console.logger(PluginLoader).info(`loaded plugin: ${plugin.name}`);
|
||||
} catch (e) {
|
||||
Console.logger(PluginLoader).error(`failed to load plugin: ${plugin.name}, ${e.message}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
server.register(fastifyCookies, {
|
||||
secret: config.core.secret
|
||||
});
|
||||
server.listen(
|
||||
{
|
||||
port: config.core.port,
|
||||
host: config.core.host
|
||||
},
|
||||
async err => {
|
||||
if (err) throw err;
|
||||
const info = server.server.address() as AddressInfo;
|
||||
|
||||
server.register(fastifyStatic, {
|
||||
root: join(process.cwd(), config.uploader.directory),
|
||||
prefix: config.uploader.route
|
||||
});
|
||||
|
||||
server.register(fastifyStatic, {
|
||||
root: join(process.cwd(), 'public'),
|
||||
prefix: '/public',
|
||||
decorateReply: false
|
||||
});
|
||||
|
||||
server.register(fastifyFavicon);
|
||||
|
||||
server.listen({
|
||||
port: config.core.port,
|
||||
host: config.core.host
|
||||
}, err => {
|
||||
if (err) throw err;
|
||||
const info = server.server.address() as AddressInfo;
|
||||
|
||||
Console.logger('Server').info(
|
||||
`server listening on ${bold(
|
||||
`${green(info.address)}${reset(':')}${bold(green(info.port.toString()))}`
|
||||
)}`
|
||||
Console.logger('Server').info(
|
||||
`server listening on ${bold(
|
||||
`${green(info.address)}${reset(':')}${bold(
|
||||
green(info.port.toString())
|
||||
)}`
|
||||
)}`
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
server.addHook('preHandler', async (req, reply) => {
|
||||
if (
|
||||
config.core.blacklisted_ips &&
|
||||
config.core.blacklisted_ips.includes(req.ip)
|
||||
) {
|
||||
await app.render404(req.raw, reply.raw);
|
||||
return (reply.sent = true);
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -2,7 +2,7 @@ import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { parse } from 'toml-patch';
|
||||
import { ConnectionOptions } from 'typeorm';
|
||||
import { WebhookHelper, WebhookType } from './Webhooks';
|
||||
import { Webhooks, WebhookType } from './Webhooks';
|
||||
|
||||
export interface Config {
|
||||
database: ConnectionOptions;
|
||||
@@ -23,6 +23,7 @@ export interface ConfigMeta {
|
||||
export interface ConfigUploader {
|
||||
directory: string;
|
||||
route: string;
|
||||
rich_content_route?: string;
|
||||
length: number;
|
||||
blacklisted: string[];
|
||||
original: boolean;
|
||||
@@ -46,6 +47,9 @@ export interface ConfigCore {
|
||||
secure?: boolean;
|
||||
blacklisted_ips?: string[];
|
||||
ratelimiter?: ConfigCoreRateLimiter;
|
||||
theme?: 'dark' | 'light';
|
||||
mfa?: boolean;
|
||||
log?: boolean;
|
||||
}
|
||||
|
||||
export interface ConfigWebhooks {
|
||||
@@ -75,8 +79,7 @@ export class Configuration {
|
||||
try {
|
||||
const data = readFileSync(resolve(process.cwd(), 'Zipline.toml'), 'utf8');
|
||||
const parsed = parse(data);
|
||||
if (parsed.webhooks)
|
||||
parsed.webhooks.events = WebhookHelper.convert(parsed.webhooks.events);
|
||||
if (parsed.webhooks) parsed.webhooks.events = Webhooks.convert(parsed.webhooks.events);
|
||||
return parsed;
|
||||
} catch (e) {
|
||||
return null;
|
||||
|
||||
@@ -2,7 +2,11 @@ import aes from 'crypto-js/aes';
|
||||
import { compareSync, hashSync } from 'bcrypt';
|
||||
import { Configuration } from './Config';
|
||||
import { Connection } from 'typeorm';
|
||||
import { Zipline } from '../entities/Zipline';
|
||||
import { compare } from 'semver';
|
||||
import { Zipline } from './entities/Zipline';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { FastifyReply } from 'fastify';
|
||||
|
||||
const config = Configuration.readConfig();
|
||||
|
||||
@@ -11,8 +15,7 @@ export function createRandomId(
|
||||
charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
) {
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++)
|
||||
result += charset.charAt(Math.floor(Math.random() * charset.length));
|
||||
for (let i = 0; i < length; i++) result += charset.charAt(Math.floor(Math.random() * charset.length));
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -44,3 +47,22 @@ export async function getFirst(orm: Connection): Promise<boolean> {
|
||||
if (!d) d = await zipline.save(new Zipline());
|
||||
return d.first;
|
||||
}
|
||||
|
||||
export async function checkVersion(): Promise<boolean> {
|
||||
const res = await fetch('https://raw.githubusercontent.com/dicedtomatoreal/zipline/next/package.json');
|
||||
if (!res.ok) return true;
|
||||
|
||||
const latestVersion = (await res.json()).version;
|
||||
const currentVersion = process.env.npm_package_version || readFileSync(join(process.cwd(), 'package.json'), 'utf8');
|
||||
|
||||
const compared = compare(currentVersion, latestVersion);
|
||||
|
||||
return compared == 0 || compared == 1 ? false : true;
|
||||
}
|
||||
|
||||
export async function sendError(reply: FastifyReply, error: string) {
|
||||
return reply.code(400).send({
|
||||
code: 400,
|
||||
error
|
||||
});
|
||||
}
|
||||
5
src/lib/WebUtil.ts
Normal file
5
src/lib/WebUtil.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export function createURL(href: string, route: string, file: string): string {
|
||||
const t = new URL(href);
|
||||
t.pathname = `${route}/${file}`;
|
||||
return t.toString();
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Image } from '../entities/Image';
|
||||
import { URL } from '../entities/URL';
|
||||
import { User } from '../entities/User';
|
||||
import { Image } from './entities/Image';
|
||||
import { URL } from './entities/URL';
|
||||
import { User } from './entities/User';
|
||||
import { Config, Configuration, ConfigWebhooks } from './Config';
|
||||
import { Console } from './logger';
|
||||
|
||||
@@ -46,7 +46,7 @@ export type WebhookSendText =
|
||||
| 'user_delete'
|
||||
| 'user_edit';
|
||||
|
||||
export class WebhookHelper {
|
||||
export class Webhooks {
|
||||
public static convert(strings: WebhookSendText[]) {
|
||||
return strings.map(x => WebhookType[x.toUpperCase()]);
|
||||
}
|
||||
@@ -59,7 +59,7 @@ export class WebhookHelper {
|
||||
public static parseContent(content: string, data: WebhookData) {
|
||||
return content
|
||||
.replace(WebhookParseTokens.IMAGE_ID, data.image?.id)
|
||||
.replace(WebhookParseTokens.IMAGE_URL, `${data.host}${data.image?.file}`)
|
||||
.replace(WebhookParseTokens.IMAGE_URL, data.host)
|
||||
.replace(WebhookParseTokens.URL_ID, data.url?.id)
|
||||
.replace(WebhookParseTokens.URL_URL, data.host + data.url?.id)
|
||||
.replace(WebhookParseTokens.URL_VANITY, data.url?.vanity)
|
||||
@@ -81,11 +81,11 @@ export class WebhookHelper {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: config.webhooks.username,
|
||||
content: WebhookHelper.parseContent(content, data)
|
||||
content: Webhooks.parseContent(content, data)
|
||||
})
|
||||
});
|
||||
} catch (e) {
|
||||
Console.logger(WebhookHelper).error(e);
|
||||
Console.logger(Webhooks).error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export class MissingBodyData extends Error {}
|
||||
export class LoginError extends Error {}
|
||||
export class NotAdministratorError extends Error {}
|
||||
export class UserExistsError extends Error {}
|
||||
export class UserNotFoundError extends Error {}
|
||||
export class UserCredentialsError extends Error {}
|
||||
export class AuthError extends Error {}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { unlinkSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { FastifyReply, FastifyRequest, FastifyInstance } from 'fastify';
|
||||
import {
|
||||
Controller,
|
||||
@@ -8,11 +10,10 @@ import {
|
||||
} from 'fastify-decorators';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Image } from '../entities/Image';
|
||||
import { LoginError } from '../lib/api/APIErrors';
|
||||
import { Configuration, ConfigWebhooks } from '../lib/Config';
|
||||
import { Console } from '../lib/logger';
|
||||
import { readBaseCookie } from '../lib/Util';
|
||||
import { WebhookHelper, WebhookType } from '../lib/Webhooks';
|
||||
import { Configuration, ConfigWebhooks } from '../Config';
|
||||
import { Console } from '../logger';
|
||||
import { readBaseCookie, sendError } from '../Util';
|
||||
import { Webhooks, WebhookType } from '../Webhooks';
|
||||
|
||||
const config = Configuration.readConfig();
|
||||
|
||||
@@ -22,11 +23,11 @@ export class ImagesController {
|
||||
private instance!: FastifyInstance;
|
||||
|
||||
private images: Repository<Image> = this.instance.orm.getRepository(Image);
|
||||
private webhooks: ConfigWebhooks = WebhookHelper.conf(config);
|
||||
private webhooks: ConfigWebhooks = Webhooks.conf(config);
|
||||
|
||||
@GET('/')
|
||||
async allImages(req: FastifyRequest, reply: FastifyReply) {
|
||||
if (!req.cookies.zipline) throw new LoginError('Not logged in.');
|
||||
if (!req.cookies.zipline) return sendError(reply, 'Not logged in.');
|
||||
|
||||
const images = await this.images.find({
|
||||
where: {
|
||||
@@ -42,7 +43,7 @@ export class ImagesController {
|
||||
req: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
if (!req.cookies.zipline) throw new LoginError('Not logged in.');
|
||||
if (!req.cookies.zipline) return sendError(reply, 'Not logged in.');
|
||||
|
||||
const image = await this.images.findOne({
|
||||
where: {
|
||||
@@ -51,25 +52,41 @@ export class ImagesController {
|
||||
}
|
||||
});
|
||||
|
||||
if (!image) throw new Error('No image');
|
||||
if (!image) return sendError(reply, 'No image');
|
||||
|
||||
this.images.delete({
|
||||
id: req.params.id
|
||||
});
|
||||
|
||||
Console.logger(Image).info(`image ${image.id} was deleted`);
|
||||
if (this.webhooks.events.includes(WebhookType.DELETE_IMAGE))
|
||||
WebhookHelper.sendWebhook(this.webhooks.upload.content, {
|
||||
const dir = config.uploader.directory
|
||||
? config.uploader.directory
|
||||
: 'uploads';
|
||||
const path = join(
|
||||
dir.charAt(0) == '/' ? dir : join(process.cwd(), dir),
|
||||
image.file
|
||||
);
|
||||
|
||||
try {
|
||||
unlinkSync(path);
|
||||
|
||||
Console.logger(Image).info(`image ${image.id} was deleted`);
|
||||
if (this.webhooks.events.includes(WebhookType.DELETE_IMAGE)) Webhooks.sendWebhook(this.webhooks.upload.content, {
|
||||
image,
|
||||
host: `${config.core.secure ? 'https' : 'http'}://${req.hostname}${config.uploader.route}/`
|
||||
host: `${config.core.secure ? 'https' : 'http'}://${req.hostname}${
|
||||
config.uploader.route
|
||||
}/`
|
||||
});
|
||||
|
||||
return reply.send(image);
|
||||
return reply.send(image);
|
||||
} catch (e) {
|
||||
Console.logger(Image).error(`image ${image.id} could not be deleted...`);
|
||||
return reply.status(401).send({ error: 'Could not delete image.' });
|
||||
}
|
||||
}
|
||||
|
||||
@GET('/recent')
|
||||
async recentImages(req: FastifyRequest, reply: FastifyReply) {
|
||||
if (!req.cookies.zipline) throw new LoginError('Not logged in.');
|
||||
if (!req.cookies.zipline) return sendError(reply, 'Not logged in.');
|
||||
|
||||
const images = await this.images.find({
|
||||
where: {
|
||||
@@ -82,7 +99,7 @@ export class ImagesController {
|
||||
|
||||
@GET('/chunk')
|
||||
async pages(req: FastifyRequest, reply: FastifyReply) {
|
||||
if (!req.cookies.zipline) throw new LoginError('Not logged in.');
|
||||
if (!req.cookies.zipline) return sendError(reply, 'Not logged in.');
|
||||
|
||||
const images = await this.images.find({
|
||||
where: {
|
||||
156
src/lib/controllers/MultiFactorController.ts
Normal file
156
src/lib/controllers/MultiFactorController.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { FastifyReply, FastifyRequest, FastifyInstance } from 'fastify';
|
||||
import {
|
||||
Controller,
|
||||
GET,
|
||||
FastifyInstanceToken,
|
||||
Inject,
|
||||
POST
|
||||
} from 'fastify-decorators';
|
||||
import { Repository } from 'typeorm';
|
||||
import { User } from '../entities/User';
|
||||
import { totp, generateSecret, otpauthURL } from 'speakeasy';
|
||||
import { toDataURL } from 'qrcode';
|
||||
import {
|
||||
checkPassword,
|
||||
createBaseCookie,
|
||||
readBaseCookie,
|
||||
sendError
|
||||
} from '../Util';
|
||||
import { Console } from '../logger';
|
||||
import { WebhookType, Webhooks } from '../Webhooks';
|
||||
import { Configuration, ConfigWebhooks } from '../Config';
|
||||
|
||||
const config = Configuration.readConfig();
|
||||
|
||||
@Controller('/api/mfa')
|
||||
export class MultiFactorController {
|
||||
@Inject(FastifyInstanceToken)
|
||||
private instance!: FastifyInstance;
|
||||
|
||||
private users: Repository<User> = this.instance.orm.getRepository(User);
|
||||
private logger: Console = Console.logger(User);
|
||||
private webhooks: ConfigWebhooks = Webhooks.conf(config);
|
||||
|
||||
@GET('/qrcode')
|
||||
async qrcode(req: FastifyRequest, reply: FastifyReply) {
|
||||
if (!req.cookies.zipline) return sendError(reply, 'Not logged in.');
|
||||
let user = await this.users.findOne({
|
||||
where: {
|
||||
id: readBaseCookie(req.cookies.zipline)
|
||||
}
|
||||
});
|
||||
|
||||
if (!user.secretMfaKey) {
|
||||
const secret = generateSecret({
|
||||
issuer: 'Zipline',
|
||||
length: 128,
|
||||
name: user.username
|
||||
});
|
||||
user.secretMfaKey = secret.base32;
|
||||
user = await this.users.save(user);
|
||||
}
|
||||
|
||||
const dataURL = await toDataURL(
|
||||
otpauthURL({
|
||||
secret: user.secretMfaKey,
|
||||
label: user.email || 'none',
|
||||
issuer: 'Zipline',
|
||||
encoding: 'base32'
|
||||
})
|
||||
);
|
||||
|
||||
return reply.send({
|
||||
dataURL
|
||||
});
|
||||
}
|
||||
|
||||
@GET('/disable')
|
||||
async disable(req: FastifyRequest, reply: FastifyReply) {
|
||||
if (!req.cookies.zipline) return sendError(reply, 'Not logged in.');
|
||||
const user = await this.users.findOne({
|
||||
where: {
|
||||
id: readBaseCookie(req.cookies.zipline)
|
||||
}
|
||||
});
|
||||
|
||||
user.secretMfaKey = null;
|
||||
|
||||
this.users.save(user);
|
||||
|
||||
this.logger.info(`disabled mfa ${user.username} (${user.id})`);
|
||||
reply.send({ disabled: true });
|
||||
}
|
||||
|
||||
@POST('/verify')
|
||||
async verify(
|
||||
req: FastifyRequest<{
|
||||
Querystring: { token: string };
|
||||
Body: { username: string; password: string };
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
if (req.cookies.zipline) return sendError(reply, 'Already logged in.');
|
||||
if (!req.body.username) return sendError(reply, 'Missing username.');
|
||||
if (!req.body.password) return sendError(reply, 'Missing uassword.');
|
||||
|
||||
const user = await this.users.findOne({
|
||||
where: {
|
||||
username: req.body.username
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) return sendError(reply, `User "${req.body.username}" was not found.`);
|
||||
if (!checkPassword(req.body.password, user.password)) {
|
||||
this.logger.error(
|
||||
`${user.username} (${user.id}) tried to login but failed with mfa`
|
||||
);
|
||||
return sendError(reply, 'Wrong credentials!');
|
||||
}
|
||||
delete user.password;
|
||||
|
||||
const passed = totp.verify({
|
||||
encoding: 'base32',
|
||||
token: req.query.token,
|
||||
secret: user.secretMfaKey
|
||||
});
|
||||
|
||||
this.logger.verbose(`set cookie for ${user.username} (${user.id})`);
|
||||
reply.setCookie('zipline', createBaseCookie(user.id), {
|
||||
path: '/',
|
||||
maxAge: 1036800000
|
||||
});
|
||||
|
||||
this.logger.info(`${user.username} (${user.id}) logged in with mfa`);
|
||||
if (this.webhooks.events.includes(WebhookType.LOGIN)) Webhooks.sendWebhook(this.webhooks.login.content, {
|
||||
user
|
||||
});
|
||||
|
||||
return reply.send({ user, passed });
|
||||
}
|
||||
|
||||
@GET('/verify')
|
||||
async verifyOn(
|
||||
req: FastifyRequest<{
|
||||
Querystring: { token: string };
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
if (!req.cookies.zipline) return sendError(reply, 'Not logged in.');
|
||||
|
||||
const user = await this.users.findOne({
|
||||
where: {
|
||||
id: readBaseCookie(req.cookies.zipline)
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) return sendError(reply, 'User that was signed in was not found, and guess what you should probably clear your cookies.');
|
||||
|
||||
const passed = totp.verify({
|
||||
encoding: 'base32',
|
||||
token: req.query.token,
|
||||
secret: user.secretMfaKey
|
||||
});
|
||||
|
||||
return reply.send(passed);
|
||||
}
|
||||
}
|
||||
@@ -14,16 +14,28 @@ import { pipeline } from 'stream';
|
||||
import { promisify } from 'util';
|
||||
import { Image } from '../entities/Image';
|
||||
import { User } from '../entities/User';
|
||||
import { AuthError } from '../lib/api/APIErrors';
|
||||
import { Configuration, ConfigWebhooks } from '../lib/Config';
|
||||
import { createRandomId, getFirst } from '../lib/Util';
|
||||
import { Console } from '../lib/logger';
|
||||
import { WebhookHelper, WebhookType } from '../lib/Webhooks';
|
||||
import { Configuration, ConfigWebhooks } from '../Config';
|
||||
import { createRandomId, getFirst, sendError } from '../Util';
|
||||
import { Console } from '../logger';
|
||||
import { Webhooks, WebhookType } from '../Webhooks';
|
||||
const pump = promisify(pipeline);
|
||||
|
||||
const config = Configuration.readConfig();
|
||||
if (!config.core || !config.database) {
|
||||
Console.logger('Zipline').error(
|
||||
'configuration seems to be invalid, did you generate a config? https://zipline.diced.wtf/docs/auto'
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
const rateLimiterConfig = config.core.ratelimiter
|
||||
? { config: { rateLimit: { max: config.core.ratelimiter.requests, timeWindow: config.core.ratelimiter.retry_after } } }
|
||||
? {
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: config.core.ratelimiter.requests,
|
||||
timeWindow: config.core.ratelimiter.retry_after
|
||||
}
|
||||
}
|
||||
}
|
||||
: {};
|
||||
|
||||
@Controller('/api')
|
||||
@@ -33,7 +45,7 @@ export class RootController {
|
||||
|
||||
private users: Repository<User> = this.instance.orm.getRepository(User);
|
||||
private images: Repository<Image> = this.instance.orm.getRepository(Image);
|
||||
private webhooks: ConfigWebhooks = WebhookHelper.conf(config);
|
||||
private webhooks: ConfigWebhooks = Webhooks.conf(config);
|
||||
private logger: Console = Console.logger(Image);
|
||||
|
||||
@GET('/first')
|
||||
@@ -42,9 +54,14 @@ export class RootController {
|
||||
return first;
|
||||
}
|
||||
|
||||
@GET('/theme')
|
||||
async getTheme() {
|
||||
return { theme: config.core.theme || 'dark' };
|
||||
}
|
||||
|
||||
@GET('/users')
|
||||
async allUsers(req: FastifyRequest, reply: FastifyReply) {
|
||||
if (!req.cookies.zipline) throw new Error('Not logged in.');
|
||||
if (!req.cookies.zipline) return sendError(reply, 'Not logged in.');
|
||||
const users = await this.users.find();
|
||||
const final = [];
|
||||
|
||||
@@ -59,7 +76,7 @@ export class RootController {
|
||||
|
||||
@GET('/statistics')
|
||||
async stats(req: FastifyRequest, reply: FastifyReply) {
|
||||
if (!req.cookies.zipline) throw new Error('Not logged in.');
|
||||
if (!req.cookies.zipline) return sendError(reply, 'Not logged in.');
|
||||
|
||||
const images = await this.images.find();
|
||||
const users = await this.users.find();
|
||||
@@ -92,34 +109,32 @@ export class RootController {
|
||||
|
||||
@POST('/upload', rateLimiterConfig)
|
||||
async loginStatus(req: FastifyRequest, reply: FastifyReply) {
|
||||
if (!req.headers.authorization)
|
||||
return new AuthError('No authorization header!');
|
||||
if (!req.headers.authorization) return sendError(reply, 'No authorization header!');
|
||||
|
||||
const user = await this.users.findOne({
|
||||
where: {
|
||||
token: req.headers.authorization
|
||||
}
|
||||
});
|
||||
if (!user) return new AuthError('Incorrect token!');
|
||||
if (!user) return sendError(reply, 'Incorrect token!');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore stupid multipart types smh
|
||||
const data: Multipart = await req.file();
|
||||
|
||||
if (!existsSync(config.uploader.directory))
|
||||
mkdirSync(config.uploader.directory);
|
||||
|
||||
const ext = data.filename.split('.')[1];
|
||||
if (config.uploader.blacklisted.includes(ext))
|
||||
throw new Error('Blacklisted file extension!');
|
||||
|
||||
if (!existsSync(config.uploader.directory)) mkdirSync(config.uploader.directory);
|
||||
|
||||
const og = data.filename;
|
||||
const ext = data.filename.split('.').pop();
|
||||
if (config.uploader.blacklisted.includes(ext)) return sendError(reply, 'Blacklisted file extension!');
|
||||
console.log(data.filename);
|
||||
const fileName = config.uploader.original
|
||||
? data.filename.split('.')[0]
|
||||
? og
|
||||
: createRandomId(config.uploader.length);
|
||||
const path = join(config.uploader.directory, `${fileName}.${ext}`);
|
||||
const path = join(config.uploader.directory, config.uploader.original ? fileName : `${fileName}.${ext}`);
|
||||
|
||||
this.logger.verbose(`attempting to save ${fileName} to db`);
|
||||
const image = await this.images.save(new Image(fileName, ext, user.id));
|
||||
const image = await this.images.save(new Image(config.uploader.original, fileName, ext, user.id));
|
||||
this.logger.verbose(`saved image ${image.id} to db`);
|
||||
|
||||
this.logger.verbose(`attempting to save file ${path}`);
|
||||
@@ -130,14 +145,18 @@ export class RootController {
|
||||
`image ${fileName}.${ext} was uploaded by ${user.username} (${user.id})`
|
||||
);
|
||||
|
||||
if (this.webhooks.events.includes(WebhookType.UPLOAD))
|
||||
WebhookHelper.sendWebhook(this.webhooks.upload.content, {
|
||||
image,
|
||||
host: `${config.core.secure ? 'https' : 'http'}://${req.hostname}${config.uploader.route}/`
|
||||
});
|
||||
const host = `${config.core.secure ? 'https' : 'http'}://${req.hostname}${
|
||||
config.uploader.rich_content_route
|
||||
? config.uploader.rich_content_route
|
||||
: config.uploader.route
|
||||
}/${config.uploader.original ? og : `${fileName}.${ext}`}`;
|
||||
|
||||
reply.send(
|
||||
`${config.core.secure ? 'https' : 'http'}://${req.hostname}${config.uploader.route}/${fileName}.${ext}`
|
||||
);
|
||||
if (this.webhooks.events.includes(WebhookType.UPLOAD)) Webhooks.sendWebhook(this.webhooks.upload.content, {
|
||||
image,
|
||||
user,
|
||||
host
|
||||
});
|
||||
|
||||
reply.send(host);
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,10 @@ import {
|
||||
import { Repository } from 'typeorm';
|
||||
import { URL } from '../entities/URL';
|
||||
import { User } from '../entities/User';
|
||||
import { LoginError } from '../lib/api/APIErrors';
|
||||
import { Configuration, ConfigWebhooks } from '../lib/Config';
|
||||
import { Console } from '../lib/logger';
|
||||
import { createRandomId, readBaseCookie } from '../lib/Util';
|
||||
import { WebhookType, WebhookHelper } from '../lib/Webhooks';
|
||||
import { Configuration, ConfigWebhooks } from '../Config';
|
||||
import { Console } from '../logger';
|
||||
import { createRandomId, readBaseCookie, sendError } from '../Util';
|
||||
import { WebhookType, Webhooks } from '../Webhooks';
|
||||
|
||||
const config = Configuration.readConfig();
|
||||
|
||||
@@ -26,11 +25,11 @@ export class URLSController {
|
||||
private urls: Repository<URL> = this.instance.orm.getRepository(URL);
|
||||
private users: Repository<User> = this.instance.orm.getRepository(User);
|
||||
private logger: Console = Console.logger(URL);
|
||||
private webhooks: ConfigWebhooks = WebhookHelper.conf(config);
|
||||
private webhooks: ConfigWebhooks = Webhooks.conf(config);
|
||||
|
||||
@GET('/')
|
||||
async allURLS(req: FastifyRequest, reply: FastifyReply) {
|
||||
if (!req.cookies.zipline) throw new LoginError('Not logged in.');
|
||||
if (!req.cookies.zipline) return sendError(reply, 'Not logged in.');
|
||||
|
||||
const all = await this.urls.find({
|
||||
where: {
|
||||
@@ -46,7 +45,7 @@ export class URLSController {
|
||||
req: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
if (!req.cookies.zipline) throw new LoginError('Not logged in.');
|
||||
if (!req.cookies.zipline) return sendError(reply, 'Not logged in.');
|
||||
|
||||
const url = await this.urls.findOne({
|
||||
where: {
|
||||
@@ -55,7 +54,7 @@ export class URLSController {
|
||||
}
|
||||
});
|
||||
|
||||
if (!url) throw new Error('No url');
|
||||
if (!url) return sendError(reply, 'No url');
|
||||
|
||||
this.logger.verbose(`attempting to delete url ${url.id}`);
|
||||
this.urls.delete({
|
||||
@@ -63,11 +62,12 @@ export class URLSController {
|
||||
});
|
||||
|
||||
this.logger.info(`url ${url.id} was deleted`);
|
||||
if (this.webhooks.events.includes(WebhookType.DELETE_URL))
|
||||
WebhookHelper.sendWebhook(this.webhooks.delete_url.content, {
|
||||
url,
|
||||
host: `${config.core.secure ? 'https' : 'http'}://${req.hostname}${config.urls.route}/`
|
||||
});
|
||||
if (this.webhooks.events.includes(WebhookType.DELETE_URL)) Webhooks.sendWebhook(this.webhooks.delete_url.content, {
|
||||
url,
|
||||
host: `${config.core.secure ? 'https' : 'http'}://${req.hostname}${
|
||||
config.urls.route
|
||||
}/`
|
||||
});
|
||||
|
||||
return reply.send(url);
|
||||
}
|
||||
@@ -77,7 +77,7 @@ export class URLSController {
|
||||
req: FastifyRequest<{ Body: { vanity: string; url: string } }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
if (!req.cookies.zipline) throw new LoginError('Not logged in.');
|
||||
if (!req.cookies.zipline) return sendError(reply, 'Not logged in.');
|
||||
|
||||
if (config.urls.vanity && req.body.vanity) {
|
||||
const existingVanity = await this.urls.findOne({
|
||||
@@ -85,7 +85,7 @@ export class URLSController {
|
||||
vanity: req.body.vanity
|
||||
}
|
||||
});
|
||||
if (existingVanity) throw new Error('There is an existing vanity!');
|
||||
if (existingVanity) return sendError(reply, 'There is an existing vanity!');
|
||||
}
|
||||
|
||||
const user = await this.users.findOne({
|
||||
@@ -94,7 +94,7 @@ export class URLSController {
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) throw new LoginError('No user');
|
||||
if (!user) return sendError(reply, 'No user');
|
||||
|
||||
const id = createRandomId(config.urls.length);
|
||||
|
||||
@@ -104,11 +104,12 @@ export class URLSController {
|
||||
);
|
||||
|
||||
this.logger.info(`saved url ${url.id}`);
|
||||
if (this.webhooks.events.includes(WebhookType.SHORTEN))
|
||||
WebhookHelper.sendWebhook(this.webhooks.shorten.content, {
|
||||
url,
|
||||
host: `${config.core.secure ? 'https' : 'http'}://${req.hostname}${config.urls.route}/`
|
||||
});
|
||||
if (this.webhooks.events.includes(WebhookType.SHORTEN)) Webhooks.sendWebhook(this.webhooks.shorten.content, {
|
||||
url,
|
||||
host: `${config.core.secure ? 'https' : 'http'}://${req.hostname}${
|
||||
config.urls.route
|
||||
}/`
|
||||
});
|
||||
|
||||
return reply.send(url);
|
||||
}
|
||||
@@ -10,24 +10,20 @@ import {
|
||||
} from 'fastify-decorators';
|
||||
import { Repository } from 'typeorm';
|
||||
import { User } from '../entities/User';
|
||||
import { Image } from '../entities/Image';
|
||||
import { Zipline } from '../entities/Zipline';
|
||||
import {
|
||||
UserNotFoundError,
|
||||
MissingBodyData,
|
||||
LoginError,
|
||||
UserExistsError
|
||||
} from '../lib/api/APIErrors';
|
||||
import { Configuration, ConfigWebhooks } from '../lib/Config';
|
||||
import { Console } from '../lib/logger';
|
||||
import { Configuration, ConfigWebhooks } from '../Config';
|
||||
import { Console } from '../logger';
|
||||
import {
|
||||
checkPassword,
|
||||
createBaseCookie,
|
||||
createToken,
|
||||
encryptPassword,
|
||||
getFirst,
|
||||
readBaseCookie
|
||||
} from '../lib/Util';
|
||||
import { WebhookType, WebhookHelper } from '../lib/Webhooks';
|
||||
readBaseCookie,
|
||||
sendError
|
||||
} from '../Util';
|
||||
import { WebhookType, Webhooks } from '../Webhooks';
|
||||
|
||||
const config = Configuration.readConfig();
|
||||
|
||||
@@ -37,8 +33,9 @@ export class UserController {
|
||||
private instance!: FastifyInstance;
|
||||
|
||||
private users: Repository<User> = this.instance.orm.getRepository(User);
|
||||
private images: Repository<Image> = this.instance.orm.getRepository(Image);
|
||||
private logger: Console = Console.logger(User);
|
||||
private webhooks: ConfigWebhooks = WebhookHelper.conf(config);
|
||||
private webhooks: ConfigWebhooks = Webhooks.conf(config);
|
||||
|
||||
@GET('/login-status')
|
||||
async loginStatus(req: FastifyRequest, reply: FastifyReply) {
|
||||
@@ -49,24 +46,50 @@ export class UserController {
|
||||
|
||||
@GET('/')
|
||||
async currentUser(req: FastifyRequest, reply: FastifyReply) {
|
||||
if (!req.cookies.zipline) throw new LoginError('Not logged in.');
|
||||
if (!req.cookies.zipline) return sendError(reply, 'Not logged in.');
|
||||
const user = await this.users.findOne({
|
||||
where: {
|
||||
id: readBaseCookie(req.cookies.zipline)
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line quotes
|
||||
if (!user) throw new UserExistsError("User doesn't exist");
|
||||
if (!user) return sendError(reply, "User doesn't exist");
|
||||
delete user.password;
|
||||
return reply.send(user);
|
||||
}
|
||||
|
||||
@GET('/stats')
|
||||
async stats(req: FastifyRequest, reply: FastifyReply) {
|
||||
if (!req.cookies.zipline) return sendError(reply, 'Not logged in.');
|
||||
const user = await this.users.findOne({
|
||||
where: {
|
||||
id: readBaseCookie(req.cookies.zipline)
|
||||
}
|
||||
});
|
||||
|
||||
const images = await this.images.find({
|
||||
where: {
|
||||
user: user.id
|
||||
}
|
||||
});
|
||||
|
||||
const totalViews = images.map(x => x.views).reduce((a, b) => Number(a) + Number(b), 0);
|
||||
|
||||
return reply.send({
|
||||
totalViews,
|
||||
images: images.length,
|
||||
averageViews: totalViews / images.length
|
||||
});
|
||||
}
|
||||
|
||||
@PATCH('/')
|
||||
async editUser(
|
||||
req: FastifyRequest<{ Body: { username: string; password: string } }>,
|
||||
req: FastifyRequest<{
|
||||
Body: { username: string; password: string; email: string };
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
if (!req.cookies.zipline) throw new LoginError('Not logged in.');
|
||||
if (!req.cookies.zipline) return sendError(reply, 'Not logged in.');
|
||||
|
||||
const user = await this.users.findOne({
|
||||
where: {
|
||||
@@ -74,31 +97,31 @@ export class UserController {
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line quotes
|
||||
if (!user) throw new UserExistsError("User doesn't exist");
|
||||
if (!user) return sendError(reply, "User doesn't exist");
|
||||
|
||||
this.logger.verbose(`attempting to save ${user.username} (${user.id})`);
|
||||
user.username = req.body.username;
|
||||
user.password = encryptPassword(req.body.password);
|
||||
if (req.body.password) user.password = encryptPassword(req.body.password);
|
||||
if (req.body.email) user.email = req.body.email;
|
||||
await this.users.save(user);
|
||||
|
||||
this.logger.info(`saved ${user.username} (${user.id})`);
|
||||
if (this.webhooks.events.includes(WebhookType.USER_EDIT))
|
||||
WebhookHelper.sendWebhook(this.webhooks.user_edit.content, {
|
||||
user
|
||||
});
|
||||
if (this.webhooks.events.includes(WebhookType.USER_EDIT)) Webhooks.sendWebhook(this.webhooks.user_edit.content, {
|
||||
user
|
||||
});
|
||||
|
||||
delete user.password;
|
||||
return reply.send(user);
|
||||
}
|
||||
|
||||
@POST('/login')
|
||||
async login(
|
||||
@POST('/verify-login')
|
||||
async verify(
|
||||
req: FastifyRequest<{ Body: { username: string; password: string } }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
if (req.cookies.zipline) throw new LoginError('Already logged in.');
|
||||
if (!req.body.username) throw new MissingBodyData('Missing username.');
|
||||
if (!req.body.password) throw new MissingBodyData('Missing uassword.');
|
||||
if (req.cookies.zipline) return sendError(reply, 'Already logged in.');
|
||||
if (!req.body.username) return sendError(reply, 'Missing username.');
|
||||
if (!req.body.password) return sendError(reply, 'Missing uassword.');
|
||||
|
||||
const user = await this.users.findOne({
|
||||
where: {
|
||||
@@ -106,13 +129,40 @@ export class UserController {
|
||||
}
|
||||
});
|
||||
|
||||
if (!user)
|
||||
throw new UserNotFoundError(`User "${req.body.username}" was not found.`);
|
||||
if (!user) return sendError(reply, `User "${req.body.username}" was not found.`);
|
||||
if (!checkPassword(req.body.password, user.password)) {
|
||||
this.logger.error(
|
||||
`${user.username} (${user.id}) tried to verify their credentials but failed`
|
||||
);
|
||||
return sendError(reply, 'Wrong credentials!');
|
||||
}
|
||||
|
||||
reply.send({
|
||||
mfa: !!user.secretMfaKey
|
||||
});
|
||||
}
|
||||
|
||||
@POST('/login')
|
||||
async login(
|
||||
req: FastifyRequest<{ Body: { username: string; password: string } }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
if (req.cookies.zipline) return sendError(reply, 'Already logged in.');
|
||||
if (!req.body.username) return sendError(reply, 'Missing username.');
|
||||
if (!req.body.password) return sendError(reply, 'Missing uassword.');
|
||||
|
||||
const user = await this.users.findOne({
|
||||
where: {
|
||||
username: req.body.username
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) return sendError(reply, `User "${req.body.username}" was not found.`);
|
||||
if (!checkPassword(req.body.password, user.password)) {
|
||||
this.logger.error(
|
||||
`${user.username} (${user.id}) tried to login but failed`
|
||||
);
|
||||
throw new LoginError('Wrong credentials!');
|
||||
return sendError(reply, 'Wrong credentials!');
|
||||
}
|
||||
delete user.password;
|
||||
|
||||
@@ -123,17 +173,16 @@ export class UserController {
|
||||
});
|
||||
|
||||
this.logger.info(`${user.username} (${user.id}) logged in`);
|
||||
if (this.webhooks.events.includes(WebhookType.LOGIN))
|
||||
WebhookHelper.sendWebhook(this.webhooks.login.content, {
|
||||
user
|
||||
});
|
||||
if (this.webhooks.events.includes(WebhookType.LOGIN)) Webhooks.sendWebhook(this.webhooks.login.content, {
|
||||
user
|
||||
});
|
||||
|
||||
return reply.send(user);
|
||||
}
|
||||
|
||||
@POST('/logout')
|
||||
async logout(req: FastifyRequest, reply: FastifyReply) {
|
||||
if (!req.cookies.zipline) throw new LoginError('Not logged in.');
|
||||
if (!req.cookies.zipline) return sendError(reply, 'Not logged in.');
|
||||
try {
|
||||
reply.clearCookie('zipline', { path: '/' });
|
||||
return reply.send({ clearStore: true });
|
||||
@@ -144,7 +193,7 @@ export class UserController {
|
||||
|
||||
@POST('/reset-token')
|
||||
async resetToken(req: FastifyRequest, reply: FastifyReply) {
|
||||
if (!req.cookies.zipline) throw new LoginError('Not logged in.');
|
||||
if (!req.cookies.zipline) return sendError(reply, 'Not logged in.');
|
||||
|
||||
const user = await this.users.findOne({
|
||||
where: {
|
||||
@@ -152,7 +201,7 @@ export class UserController {
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) throw new UserNotFoundError('User was not found.');
|
||||
if (!user) return sendError(reply, 'User was not found.');
|
||||
|
||||
this.logger.verbose(
|
||||
`attempting to reset token ${user.username} (${user.id})`
|
||||
@@ -161,10 +210,9 @@ export class UserController {
|
||||
await this.users.save(user);
|
||||
|
||||
this.logger.info(`reset token ${user.username} (${user.id})`);
|
||||
if (this.webhooks.events.includes(WebhookType.TOKEN_RESET))
|
||||
WebhookHelper.sendWebhook(this.webhooks.token_reset.content, {
|
||||
user
|
||||
});
|
||||
if (this.webhooks.events.includes(WebhookType.TOKEN_RESET)) Webhooks.sendWebhook(this.webhooks.token_reset.content, {
|
||||
user
|
||||
});
|
||||
|
||||
return reply.send({ updated: true });
|
||||
}
|
||||
@@ -176,13 +224,13 @@ export class UserController {
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
if (!req.body.username) throw new MissingBodyData('Missing username.');
|
||||
if (!req.body.password) throw new MissingBodyData('Missing uassword.');
|
||||
if (!req.body.username) return sendError(reply, 'Missing username.');
|
||||
if (!req.body.password) return sendError(reply, 'Missing uassword.');
|
||||
|
||||
const existing = await this.users.findOne({
|
||||
where: { username: req.body.username }
|
||||
});
|
||||
if (existing) throw new UserExistsError('User exists already');
|
||||
if (existing) return sendError(reply, 'User exists already');
|
||||
|
||||
try {
|
||||
this.logger.verbose(`attempting to create ${req.body.username}`);
|
||||
@@ -195,26 +243,24 @@ export class UserController {
|
||||
)
|
||||
);
|
||||
this.logger.info(`created user ${user.username} (${user.id})`);
|
||||
if (this.webhooks.events.includes(WebhookType.CREATE_USER))
|
||||
WebhookHelper.sendWebhook(this.webhooks.create_user.content, {
|
||||
user
|
||||
});
|
||||
if (this.webhooks.events.includes(WebhookType.CREATE_USER)) Webhooks.sendWebhook(this.webhooks.create_user.content, {
|
||||
user
|
||||
});
|
||||
|
||||
const firstSetup = await getFirst(this.instance.orm);
|
||||
if (firstSetup)
|
||||
await this.instance.orm.getRepository(Zipline).update(
|
||||
{
|
||||
id: 'zipline'
|
||||
},
|
||||
{
|
||||
first: false
|
||||
}
|
||||
);
|
||||
if (firstSetup) await this.instance.orm.getRepository(Zipline).update(
|
||||
{
|
||||
id: 'zipline'
|
||||
},
|
||||
{
|
||||
first: false
|
||||
}
|
||||
);
|
||||
|
||||
delete user.password;
|
||||
return reply.send(user);
|
||||
} catch (e) {
|
||||
throw new Error(`Could not create user: ${e.message}`);
|
||||
return sendError(reply, `Could not create user: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,28 +276,24 @@ export class UserController {
|
||||
const existing = await this.users.findOne({
|
||||
where: { id: req.params.id }
|
||||
});
|
||||
if (!existing) throw new UserExistsError('User doesnt exist');
|
||||
if (!existing) return sendError(reply, 'User doesnt exist');
|
||||
|
||||
try {
|
||||
this.logger.verbose(
|
||||
`attempting to delete ${existing.username} (${existing.id})`
|
||||
);
|
||||
await this.users.delete({
|
||||
id: existing.id
|
||||
});
|
||||
await this.users.remove(existing);
|
||||
|
||||
this.logger.info(`deleted ${existing.username} (${existing.id})`);
|
||||
if (this.webhooks.events.includes(WebhookType.USER_DELETE))
|
||||
WebhookHelper.sendWebhook(this.webhooks.user_delete.content, {
|
||||
user: existing
|
||||
});
|
||||
if (this.webhooks.events.includes(WebhookType.USER_DELETE)) Webhooks.sendWebhook(this.webhooks.user_delete.content, {
|
||||
user: existing
|
||||
});
|
||||
|
||||
return reply.send({ ok: true });
|
||||
} catch (e) {
|
||||
throw new Error(`Could not delete user: ${e.message}`);
|
||||
return sendError(reply, `Could not delete user: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// @Hook('preValidation')
|
||||
// public async preValidation(req: FastifyRequest, reply: FastifyReply) {
|
||||
// // const adminRoutes = ['/api/user/create'];
|
||||
@@ -14,9 +14,10 @@ export class Image {
|
||||
@Column('bigint', { default: '0' })
|
||||
public views: number;
|
||||
|
||||
public constructor(id: string, ext: string, user: number) {
|
||||
public constructor(original: boolean, id: string, ext: string, user: number) {
|
||||
this.id = id;
|
||||
this.file = `${id}.${ext}`;
|
||||
if (original) this.file = id;
|
||||
else this.file = `${id}.${ext}`;
|
||||
this.user = user;
|
||||
this.views = 0;
|
||||
}
|
||||
@@ -11,12 +11,18 @@ export class User {
|
||||
@Column('text')
|
||||
public password: string;
|
||||
|
||||
@Column('text', { default: null }) /* used for gravatar avatar! */
|
||||
public email: string;
|
||||
|
||||
@Column('boolean', { default: false })
|
||||
public administrator: boolean;
|
||||
|
||||
@Column('text')
|
||||
public token: string;
|
||||
|
||||
@Column('simple-json', { default: null })
|
||||
public secretMfaKey: string;
|
||||
|
||||
public constructor(
|
||||
username: string,
|
||||
password: string,
|
||||
@@ -25,7 +31,9 @@ export class User {
|
||||
) {
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
this.email = null;
|
||||
this.administrator = administrator;
|
||||
this.token = token;
|
||||
this.secretMfaKey = null;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!global.logr) global.logr = { formatter: null };
|
||||
|
||||
export enum ConsoleLevel {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ConsoleLevel } from '.';
|
||||
import { blue, red, reset, white, yellow } from '@dicedtomato/colors';
|
||||
import { blue, green, red, reset, white, yellow } from '@dicedtomato/colors';
|
||||
|
||||
export interface Formatter {
|
||||
format(
|
||||
@@ -27,7 +27,7 @@ export class DefaultFormatter implements Formatter {
|
||||
level: ConsoleLevel,
|
||||
time: Date
|
||||
): string {
|
||||
return `[${time.toLocaleString().replace(',', '')}] ${this.formatLevel(
|
||||
return `[${time.toLocaleString().replace(',', '')}] [${green(origin.toLowerCase())}] ${this.formatLevel(
|
||||
level
|
||||
)} ${reset(message)}`;
|
||||
}
|
||||
|
||||
11
src/lib/plugin/Plugin.ts
Normal file
11
src/lib/plugin/Plugin.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import Server from "next/dist/next-server/server/next-server";
|
||||
import { Connection } from "typeorm";
|
||||
import { Config } from "../Config";
|
||||
|
||||
export interface Plugin {
|
||||
name: string;
|
||||
priority?: number;
|
||||
|
||||
onLoad(server: FastifyInstance, orm: Connection, app: Server, config: Config): any;
|
||||
}
|
||||
45
src/lib/plugin/PluginLoader.ts
Normal file
45
src/lib/plugin/PluginLoader.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { readdirSync, statSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { Plugin } from './Plugin';
|
||||
|
||||
export class PluginLoader {
|
||||
public directory: string;
|
||||
public files: string[];
|
||||
public plugins: Plugin[] = [];
|
||||
public builtIns: Plugin[] = [];
|
||||
public fastify: FastifyInstance;
|
||||
|
||||
constructor(fastify: FastifyInstance, ...directory: string[]) {
|
||||
this.directory = join(...directory);
|
||||
this.fastify = fastify;
|
||||
}
|
||||
|
||||
public getAllFiles(builtIn = false): string[] {
|
||||
const result = [];
|
||||
|
||||
const r = (dir: string) => {
|
||||
for (const file of readdirSync(dir)) {
|
||||
const p = join(dir, file);
|
||||
const s = statSync(p);
|
||||
if (s.isDirectory()) r(p);
|
||||
else result.push(p);
|
||||
}
|
||||
};
|
||||
|
||||
r(builtIn ? join(process.cwd(), process.env.NODE_ENV !== 'development' ? 'dist' : 'src', 'lib', 'plugin', 'builtins') : this.directory);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async loadPlugins(builtIn = false): Promise<Plugin[]> {
|
||||
const files = this.getAllFiles(builtIn);
|
||||
|
||||
for (const pluginFile of files) {
|
||||
const im = await import(pluginFile);
|
||||
builtIn ? this.builtIns.push(new im.default()) : this.plugins.push(new im.default());
|
||||
}
|
||||
|
||||
return builtIn ? this.builtIns.sort((a, b) => a.priority - b.priority) : this.plugins.sort((a, b) => a.priority - b.priority);
|
||||
}
|
||||
}
|
||||
59
src/lib/plugin/builtins/FastifyPlugin.ts
Normal file
59
src/lib/plugin/builtins/FastifyPlugin.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import Server from 'next/dist/next-server/server/next-server';
|
||||
import { Connection } from 'typeorm';
|
||||
import { Config } from '../../Config';
|
||||
import { Plugin } from '../Plugin';
|
||||
import fastifyTypeorm from 'fastify-typeorm-plugin';
|
||||
import fastifyCookies from 'fastify-cookie';
|
||||
import fastifyMultipart from 'fastify-multipart';
|
||||
import fastifyRateLimit from 'fastify-rate-limit';
|
||||
import fastifyStatic from 'fastify-static';
|
||||
import fastifyFavicon from 'fastify-favicon';
|
||||
import { bootstrap } from 'fastify-decorators';
|
||||
import { User } from '../../entities/User';
|
||||
import { Zipline } from '../../entities/Zipline';
|
||||
import { Image } from '../../entities/Image';
|
||||
import { URL } from '../../entities/URL';
|
||||
import { UserController } from '../../controllers/UserController';
|
||||
import path, { join } from 'path';
|
||||
import { ImagesController } from '../../controllers/ImagesController';
|
||||
import { MultiFactorController } from '../../controllers/MultiFactorController';
|
||||
import { RootController } from '../../controllers/RootController';
|
||||
import { URLSController } from '../../controllers/URLSController';
|
||||
|
||||
export default class implements Plugin {
|
||||
public name: string = "assets";
|
||||
|
||||
public onLoad(server: FastifyInstance, orm: Connection, app: Server, config: Config) {
|
||||
server.register(fastifyMultipart);
|
||||
|
||||
server.register(fastifyTypeorm, {
|
||||
...config.database,
|
||||
entities: [Image, URL, User, Zipline],
|
||||
synchronize: true,
|
||||
logging: false
|
||||
});
|
||||
|
||||
server.register(bootstrap, {
|
||||
controllers: [
|
||||
UserController,
|
||||
RootController,
|
||||
ImagesController,
|
||||
URLSController,
|
||||
MultiFactorController
|
||||
]
|
||||
});
|
||||
|
||||
server.register(fastifyCookies, {
|
||||
secret: config.core.secret
|
||||
});
|
||||
|
||||
server.register(fastifyStatic, {
|
||||
root: join(process.cwd(), 'public'),
|
||||
prefix: '/public',
|
||||
decorateReply: false
|
||||
});
|
||||
|
||||
server.register(fastifyFavicon);
|
||||
}
|
||||
}
|
||||
29
src/lib/plugin/builtins/LogPlugin.ts
Normal file
29
src/lib/plugin/builtins/LogPlugin.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import Server from 'next/dist/next-server/server/next-server';
|
||||
import { Connection } from 'typeorm';
|
||||
import { Config } from '../../Config';
|
||||
import { Plugin } from '../Plugin';
|
||||
import { textSync } from 'figlet';
|
||||
import { magenta, blue, bold, red, green } from '@dicedtomato/colors';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
export default class implements Plugin {
|
||||
public name: string = "assets";
|
||||
|
||||
public onLoad(server: FastifyInstance, orm: Connection, app: Server, config: Config) {
|
||||
if (config.core.log) console.log(`
|
||||
${magenta(textSync('Zipline'))}
|
||||
Version : ${blue(
|
||||
process.env.npm_package_version ||
|
||||
JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8'))
|
||||
.version
|
||||
)}
|
||||
GitHub : ${blue('https://github.com/ZiplineProject/zipline')}
|
||||
Issues : ${blue('https://github.com/ZiplineProject/zipline/issues')}
|
||||
Docs : ${blue('https://zipline.diced.wtf/')}
|
||||
Mode : ${bold(process.env.NODE_ENV !== 'production' ? red('dev') : green('production'))}
|
||||
Verbose : ${bold(process.env.VERBOSE ? red('yes') : green('no'))}
|
||||
`);
|
||||
}
|
||||
}
|
||||
2
src/lib/plugin/index.ts
Normal file
2
src/lib/plugin/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './Plugin';
|
||||
export * from './PluginLoader';
|
||||
38
src/lib/themes/light.ts
Normal file
38
src/lib/themes/light.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import createMuiTheme from '@material-ui/core/styles/createMuiTheme';
|
||||
|
||||
const lightTheme = createMuiTheme({
|
||||
palette: {
|
||||
type: 'light',
|
||||
primary: {
|
||||
main: '#000000'
|
||||
},
|
||||
secondary: {
|
||||
main: '#4a5bb0'
|
||||
},
|
||||
background: {
|
||||
default: '#fff',
|
||||
paper: '#f7f7f7'
|
||||
}
|
||||
},
|
||||
overrides: {
|
||||
MuiListItem: {
|
||||
root: {
|
||||
'&$selected': {
|
||||
backgroundColor: '#e0e0e0'
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiCard: {
|
||||
root: {
|
||||
backgroundColor: '#fff'
|
||||
}
|
||||
},
|
||||
MuiButton: {
|
||||
root: {
|
||||
margin: '132'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default lightTheme;
|
||||
56
src/pages/404.tsx
Normal file
56
src/pages/404.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import CssBaseline from '@material-ui/core/CssBaseline';
|
||||
import Grid from '@material-ui/core/Grid';
|
||||
import { ThemeProvider } from '@material-ui/core/styles';
|
||||
import dark from '../lib/themes/dark';
|
||||
|
||||
export default function NotFound() {
|
||||
useEffect(() => {
|
||||
const jssStyles = document.querySelector('#jss-server-side');
|
||||
if (jssStyles) jssStyles.parentElement.removeChild(jssStyles);
|
||||
}, []);
|
||||
|
||||
const faces = ['◉_◉', 'ರ_ರ'];
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Head>
|
||||
<title>Not Found</title>
|
||||
<meta
|
||||
name='viewport'
|
||||
content='minimum-scale=1, initial-scale=1, width=device-width'
|
||||
/>
|
||||
<link
|
||||
rel='icon'
|
||||
type='image/png'
|
||||
href='https://twemoji.maxcdn.com/v/13.0.1/72x72/1f621.png'
|
||||
/>
|
||||
</Head>
|
||||
|
||||
<ThemeProvider theme={dark}>
|
||||
<CssBaseline />
|
||||
<Grid
|
||||
container
|
||||
spacing={0}
|
||||
direction='column'
|
||||
alignItems='center'
|
||||
justify='center'
|
||||
style={{ minHeight: '100vh' }}
|
||||
>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant='h2'>
|
||||
<b>{faces[Math.floor(Math.random() * faces.length)]}</b>
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant='h4'>
|
||||
<b>Looks like you've hit a dead end...</b>
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</ThemeProvider>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,25 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Head from 'next/head';
|
||||
import CssBaseline from '@material-ui/core/CssBaseline';
|
||||
import { ThemeProvider } from '@material-ui/core/styles';
|
||||
import { Provider } from 'react-redux';
|
||||
import { PersistGate } from 'redux-persist/integration/react';
|
||||
import { store, persistor } from '../store';
|
||||
import theme from '../lib/themes/dark';
|
||||
import ZiplineTheming from '../components/ZiplineTheming';
|
||||
import UIPlaceholder from '../components/UIPlaceholder';
|
||||
|
||||
function MyApp({ Component, pageProps }) {
|
||||
function App({ Component, pageProps }) {
|
||||
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
|
||||
useEffect(() => {
|
||||
const jssStyles = document.querySelector('#jss-server-side');
|
||||
if (jssStyles) jssStyles.parentElement.removeChild(jssStyles);
|
||||
}, []);
|
||||
|
||||
(async () => {
|
||||
const d = await (await fetch('/api/theme')).json();
|
||||
if (!d.error) setTheme(d.theme);
|
||||
})();
|
||||
}, []);
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<React.Fragment>
|
||||
<Head>
|
||||
<title>Zipline</title>
|
||||
<meta
|
||||
@@ -25,18 +29,21 @@ function MyApp({ Component, pageProps }) {
|
||||
</Head>
|
||||
|
||||
<Provider store={store}>
|
||||
<PersistGate loading={<div>loading</div>} persistor={persistor}>
|
||||
<CssBaseline />
|
||||
<Component {...pageProps} />
|
||||
<PersistGate loading={<UIPlaceholder />} persistor={persistor}>
|
||||
<ZiplineTheming
|
||||
Component={Component}
|
||||
pageProps={pageProps}
|
||||
theme={theme}
|
||||
/>
|
||||
</PersistGate>
|
||||
</Provider>
|
||||
</ThemeProvider>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
MyApp.propTypes = {
|
||||
App.propTypes = {
|
||||
Component: PropTypes.elementType.isRequired,
|
||||
pageProps: PropTypes.object.isRequired
|
||||
};
|
||||
|
||||
export default MyApp;
|
||||
export default App;
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
|
||||
import React from 'react';
|
||||
import Document, { Html, Head, Main, NextScript } from 'next/document';
|
||||
@@ -33,7 +34,7 @@ export default class MyDocument extends Document<DocumentProps> {
|
||||
</Head>
|
||||
) : null}
|
||||
<body>
|
||||
<Main />
|
||||
<Main/>
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
@@ -43,17 +44,18 @@ export default class MyDocument extends Document<DocumentProps> {
|
||||
|
||||
MyDocument.getInitialProps = async ctx => {
|
||||
const sheets = new ServerStyleSheets();
|
||||
const config = Configuration.readConfig();
|
||||
const originalRenderPage = ctx.renderPage;
|
||||
|
||||
ctx.renderPage = () =>
|
||||
originalRenderPage({
|
||||
enhanceApp: App => props => sheets.collect(<App {...props} />)
|
||||
enhanceApp: App => props => sheets.collect(<App {...props}/>)
|
||||
});
|
||||
|
||||
const initialProps = await Document.getInitialProps(ctx);
|
||||
return {
|
||||
...initialProps,
|
||||
config: Configuration.readConfig(),
|
||||
config,
|
||||
styles: [
|
||||
...React.Children.toArray(initialProps.styles),
|
||||
sheets.getStyleElement()
|
||||
|
||||
@@ -12,6 +12,7 @@ import UI from '../components/UI';
|
||||
import UIPlaceholder from '../components/UIPlaceholder';
|
||||
import { makeStyles } from '@material-ui/core';
|
||||
import { store } from '../store';
|
||||
import { createURL } from '../lib/WebUtil';
|
||||
import { Configuration } from '../lib/Config';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
@@ -19,7 +20,7 @@ const useStyles = makeStyles(theme => ({
|
||||
margin: '5px'
|
||||
},
|
||||
padding: {
|
||||
border: '1px solid #1f1f1f',
|
||||
border: theme.palette.type === 'dark' ? '1px solid #1f1f1f' : '1px solid #e0e0e0',
|
||||
padding: '10px'
|
||||
},
|
||||
backdrop: {
|
||||
@@ -33,8 +34,8 @@ export default function Dashboard({ config }) {
|
||||
const router = useRouter();
|
||||
const state = store.getState();
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [stats, setStats] = React.useState<{ totalViews: number, averageViews: number, images: number }>(null);
|
||||
const [recentImages, setRecentImages] = React.useState([]);
|
||||
const [images, setImages] = React.useState([]);
|
||||
|
||||
if (typeof window === 'undefined') return <UIPlaceholder />;
|
||||
if (!state.loggedIn) router.push('/user/login');
|
||||
@@ -44,9 +45,9 @@ export default function Dashboard({ config }) {
|
||||
const recentImages = await (await fetch('/api/images/recent')).json();
|
||||
if (!recentImages.error) setRecentImages(recentImages);
|
||||
|
||||
const allImages = await (await fetch('/api/images')).json();
|
||||
if (!allImages.error) {
|
||||
setImages(allImages);
|
||||
const stats = await (await fetch('/api/user/stats')).json();
|
||||
if (!stats.error) {
|
||||
setStats(stats);
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
@@ -59,17 +60,15 @@ export default function Dashboard({ config }) {
|
||||
</Backdrop>
|
||||
{!loading ? (
|
||||
<Paper elevation={3} className={classes.padding}>
|
||||
<Typography variant='h5'>
|
||||
<Typography variant='h4'>
|
||||
Welcome back, {state.user.username}
|
||||
</Typography>
|
||||
<Typography color='textSecondary'>
|
||||
You have <b>{images.length}</b> images
|
||||
You have <b>{stats.images}</b> images, with <b>{stats.totalViews}</b> ({Math.round(stats.averageViews)}) collectively.
|
||||
</Typography>
|
||||
<Typography variant='h5'>Recent Images</Typography>
|
||||
<Grid container spacing={2}>
|
||||
{recentImages.map(d => {
|
||||
const t = new URL(window.location.href);
|
||||
t.pathname = `${config ? config.uploader.route : '/u'}/${d.file}`;
|
||||
return (
|
||||
<Grid item key={d.id} xs={12} sm={4}>
|
||||
<Card>
|
||||
@@ -77,7 +76,7 @@ export default function Dashboard({ config }) {
|
||||
<CardMedia
|
||||
component='img'
|
||||
height='140'
|
||||
image={t.toString()}
|
||||
image={createURL(window.location.href, config ? config.uploader.route : '/u', d.file)}
|
||||
/>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
|
||||
@@ -16,15 +16,16 @@ import UI from '../../components/UI';
|
||||
import UIPlaceholder from '../../components/UIPlaceholder';
|
||||
import { makeStyles } from '@material-ui/core';
|
||||
import { store } from '../../store';
|
||||
import { Image } from '../../entities/Image';
|
||||
import { Image } from '../../lib/entities/Image';
|
||||
import { Configuration } from '../../lib/Config';
|
||||
import { createURL } from '../../lib/WebUtil';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
margin: {
|
||||
margin: '5px'
|
||||
},
|
||||
padding: {
|
||||
border: '1px solid #1f1f1f',
|
||||
border: theme.palette.type === 'dark' ? '1px solid #1f1f1f' : '1px solid #e0e0e0',
|
||||
padding: '10px'
|
||||
},
|
||||
backdrop: {
|
||||
@@ -109,45 +110,41 @@ export default function Images({ config }) {
|
||||
{showPagination ? (
|
||||
<>
|
||||
<Grid container spacing={2}>
|
||||
{images.map(d => {
|
||||
const t = new URL(window.location.href);
|
||||
t.pathname = `${config ? config.uploader.route : '/u'}/${d.file}`;
|
||||
return (
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
sm={6}
|
||||
key={d.id}
|
||||
onClick={e => setImageOpenPopover(e, d)}
|
||||
>
|
||||
<Card>
|
||||
<CardActionArea>
|
||||
<CardMedia
|
||||
component='img'
|
||||
height='140'
|
||||
image={t.toString()}
|
||||
/>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
{images.map(d => ((
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
sm={4}
|
||||
key={d.id}
|
||||
onClick={e => setImageOpenPopover(e, d)}
|
||||
>
|
||||
<Card>
|
||||
<CardActionArea>
|
||||
<CardMedia
|
||||
component='img'
|
||||
height='140'
|
||||
image={createURL(window.location.href, config ? config.uploader.route : '/u', d.file)}
|
||||
/>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
</Grid>
|
||||
)))}
|
||||
</Grid>
|
||||
<Pagination count={chunks.length} onChange={changePage} />
|
||||
</>
|
||||
) : (
|
||||
<Grid
|
||||
container
|
||||
spacing={0}
|
||||
direction='column'
|
||||
alignItems='center'
|
||||
justify='center'
|
||||
>
|
||||
<Grid item xs={6} sm={12}>
|
||||
<AddToPhotosIcon style={{ fontSize: 100 }} />
|
||||
</Grid>
|
||||
<Grid
|
||||
container
|
||||
spacing={0}
|
||||
direction='column'
|
||||
alignItems='center'
|
||||
justify='center'
|
||||
>
|
||||
<Grid item xs={6} sm={12}>
|
||||
<AddToPhotosIcon style={{ fontSize: 100 }} />
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
</Paper>
|
||||
) : null}
|
||||
<Popover
|
||||
|
||||
@@ -20,7 +20,7 @@ const useStyles = makeStyles(theme => ({
|
||||
margin: '5px'
|
||||
},
|
||||
padding: {
|
||||
border: '1px solid #1f1f1f',
|
||||
border: theme.palette.type === 'dark' ? '1px solid #1f1f1f' : '1px solid #e0e0e0',
|
||||
padding: '10px'
|
||||
},
|
||||
backdrop: {
|
||||
@@ -28,7 +28,7 @@ const useStyles = makeStyles(theme => ({
|
||||
color: '#fff'
|
||||
},
|
||||
tableBorder: {
|
||||
borderColor: '#121212'
|
||||
borderColor: theme.palette.type === 'dark' ? '#1f1f1f' : '#e0e0e0'
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
// @ts-nocheck
|
||||
|
||||
import React from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
@@ -17,7 +20,7 @@ const useStyles = makeStyles(theme => ({
|
||||
margin: '5px'
|
||||
},
|
||||
padding: {
|
||||
border: '1px solid #1f1f1f',
|
||||
border: theme.palette.type === 'dark' ? '1px solid #1f1f1f' : '1px solid #e0e0e0',
|
||||
padding: '10px'
|
||||
},
|
||||
backdrop: {
|
||||
@@ -32,6 +35,8 @@ export default function Upload() {
|
||||
const state = store.getState();
|
||||
const [files, setFiles] = React.useState<File[]>([]);
|
||||
const [alertOpen, setAlertOpen] = React.useState<boolean>(false);
|
||||
const [alertSev, setAlertSev] = React.useState('success');
|
||||
const [alertMsg, setAlertMsg] = React.useState('Uploaded Image!');
|
||||
|
||||
const handleFileUpload = async () => {
|
||||
const file = files[0];
|
||||
@@ -46,41 +51,57 @@ export default function Upload() {
|
||||
body
|
||||
});
|
||||
|
||||
if (res.ok) setAlertOpen(true);
|
||||
if (res.ok) {
|
||||
setAlertOpen(true);
|
||||
setAlertMsg('Uploaded Image!');
|
||||
setAlertSev('success');
|
||||
} else {
|
||||
const d = await res.json();
|
||||
setAlertOpen(true);
|
||||
setAlertMsg(`Couldn't upload: ${d.error}`);
|
||||
setAlertSev('error');
|
||||
}
|
||||
};
|
||||
|
||||
const snack = (
|
||||
<Snackbar
|
||||
anchorOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'center'
|
||||
}}
|
||||
open={alertOpen}
|
||||
autoHideDuration={6000}
|
||||
onClose={() => setAlertOpen(false)}
|
||||
>
|
||||
<Alert severity={alertSev} variant='filled'>
|
||||
{alertMsg}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
);
|
||||
|
||||
if (typeof window === 'undefined') return <UIPlaceholder />;
|
||||
if (!state.loggedIn) router.push('/user/login');
|
||||
else {
|
||||
return (
|
||||
<UI>
|
||||
<Snackbar
|
||||
anchorOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'center'
|
||||
}}
|
||||
open={alertOpen}
|
||||
autoHideDuration={6000}
|
||||
onClose={() => setAlertOpen(false)}
|
||||
>
|
||||
<Alert severity='success' variant='filled'>
|
||||
Uploaded image!
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
<Paper elevation={3} className={classes.padding}>
|
||||
<Typography variant='h5'>
|
||||
Upload
|
||||
</Typography>
|
||||
<Box m={1}>
|
||||
<DropzoneArea
|
||||
acceptedFiles={['image/*']}
|
||||
dropzoneText={'Drag an image or click to upload an image.'}
|
||||
onChange={f => setFiles(f)}
|
||||
maxFileSize={1073741824} // 1gb in byte
|
||||
/>
|
||||
</Box>
|
||||
<Button onClick={handleFileUpload}>Submit</Button>
|
||||
</Paper>
|
||||
<>
|
||||
{snack}
|
||||
<Paper elevation={3} className={classes.padding}>
|
||||
<Typography variant='h5'>
|
||||
Upload
|
||||
</Typography>
|
||||
<Box m={1}>
|
||||
<DropzoneArea
|
||||
acceptedFiles={['image/*']}
|
||||
dropzoneText={'Drag an image or click to upload an image.'}
|
||||
onChange={f => setFiles(f)}
|
||||
filesLimit={1}
|
||||
maxFileSize={1073741824} // 1gb in byte
|
||||
/>
|
||||
</Box>
|
||||
<Button onClick={handleFileUpload}>Upload</Button>
|
||||
</Paper>
|
||||
</>
|
||||
</UI>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import Backdrop from '@material-ui/core/Backdrop';
|
||||
import CircularProgress from '@material-ui/core/CircularProgress';
|
||||
@@ -23,16 +24,18 @@ import AddIcon from '@material-ui/icons/Add';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import UI from '../../components/UI';
|
||||
import UIPlaceholder from '../../components/UIPlaceholder';
|
||||
import OpenInNewIcon from '@material-ui/icons/OpenInNew';
|
||||
import { makeStyles } from '@material-ui/core';
|
||||
import { URL as URLEntity } from '../../entities/URL';
|
||||
import { URL as URLEntity } from '../../lib/entities/URL';
|
||||
import { Configuration } from '../../lib/Config';
|
||||
import { createURL } from '../../lib/WebUtil';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
margin: {
|
||||
margin: '5px'
|
||||
},
|
||||
padding: {
|
||||
border: '1px solid #1f1f1f',
|
||||
border: theme.palette.type === 'dark' ? '1px solid #1f1f1f' : '1px solid #e0e0e0',
|
||||
padding: '10px'
|
||||
},
|
||||
backdrop: {
|
||||
@@ -157,41 +160,46 @@ export default function Urls({ config }) {
|
||||
<Typography variant='h5'>
|
||||
URLs
|
||||
<IconButton
|
||||
aria-label='Create User'
|
||||
aria-label='Create URL'
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
{urls.length > 0 ? urls.map(u => {
|
||||
const url = new URL(window.location.href);
|
||||
url.pathname = `${config ? config.urls.route : '/go'}/${u.id}`;
|
||||
return (
|
||||
<Grid item xs={12} sm={4} key={u.id}>
|
||||
<Card elevation={3}>
|
||||
<CardHeader
|
||||
action={
|
||||
<div>
|
||||
<IconButton aria-label='Copy URL'>
|
||||
<FileCopyIcon
|
||||
onClick={() => copy(url.toString())}
|
||||
/>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
aria-label='Delete Forever'
|
||||
onClick={() => deleteUrl(u)}
|
||||
>
|
||||
<DeleteForeverIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
}
|
||||
title={u.vanity ? u.vanity : u.id}
|
||||
/>
|
||||
</Card>
|
||||
</Grid>
|
||||
);
|
||||
}) : (
|
||||
{urls.length > 0 ? urls.map(u => ((
|
||||
<Grid item xs={12} sm={4} key={u.id}>
|
||||
<Card elevation={3}>
|
||||
<CardHeader
|
||||
action={
|
||||
<div>
|
||||
<IconButton aria-label='Copy URL'>
|
||||
<FileCopyIcon
|
||||
onClick={() => copy(createURL(window.location.href, config ? config.urls.route : '/go', u.vanity || u.id))}
|
||||
/>
|
||||
</IconButton>
|
||||
<Link href={u.url}>
|
||||
<a target='_blank'>
|
||||
<IconButton
|
||||
aria-label='Open URL in new Tab'
|
||||
>
|
||||
<OpenInNewIcon />
|
||||
</IconButton>
|
||||
</a>
|
||||
</Link>
|
||||
<IconButton
|
||||
aria-label='Delete Forever'
|
||||
onClick={() => deleteUrl(u)}
|
||||
>
|
||||
<DeleteForeverIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
}
|
||||
title={u.vanity ? u.vanity : u.id}
|
||||
/>
|
||||
</Card>
|
||||
</Grid>
|
||||
))) : (
|
||||
<Grid
|
||||
container
|
||||
spacing={0}
|
||||
|
||||
@@ -30,7 +30,7 @@ const useStyles = makeStyles(theme => ({
|
||||
margin: '5px'
|
||||
},
|
||||
padding: {
|
||||
border: '1px solid #1f1f1f',
|
||||
border: theme.palette.type === 'dark' ? '1px solid #1f1f1f' : '1px solid #e0e0e0',
|
||||
padding: '10px'
|
||||
},
|
||||
field: {
|
||||
|
||||
@@ -4,8 +4,9 @@ import UI from '../../components/UI';
|
||||
import UIPlaceholder from '../../components/UIPlaceholder';
|
||||
import ManageUser from '../../components/ManageUser';
|
||||
import { store } from '../../store';
|
||||
import { Configuration } from '../../lib/Config';
|
||||
|
||||
export default function Manage() {
|
||||
export default function Manage({ config }) {
|
||||
const router = useRouter();
|
||||
const state = store.getState();
|
||||
|
||||
@@ -13,9 +14,14 @@ export default function Manage() {
|
||||
else {
|
||||
return (
|
||||
<UI>
|
||||
<ManageUser />
|
||||
<ManageUser config={config} />
|
||||
</UI>
|
||||
);
|
||||
}
|
||||
return <UIPlaceholder />;
|
||||
}
|
||||
|
||||
export async function getStaticProps() {
|
||||
const config = Configuration.readConfig();
|
||||
return { props: { config } };
|
||||
}
|
||||
|
||||
67
src/plugins/AssetsPlugin.ts
Normal file
67
src/plugins/AssetsPlugin.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { Config } from '../lib/Config';
|
||||
import { Plugin } from '../lib/plugin';
|
||||
import { URL } from '../lib/entities/URL';
|
||||
import Server from 'next/dist/next-server/server/next-server';
|
||||
import { Connection } from 'typeorm';
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
export default class implements Plugin {
|
||||
public name: string = "assets";
|
||||
|
||||
public onLoad(server: FastifyInstance, orm: Connection, app: Server, config: Config) {
|
||||
server.get(`${config.urls.route}/:id`, async function (
|
||||
req: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const urls = orm.getRepository(URL);
|
||||
|
||||
const urlId = await urls.findOne({
|
||||
where: {
|
||||
id: req.params.id
|
||||
}
|
||||
});
|
||||
|
||||
const urlVanity = await urls.findOne({
|
||||
where: {
|
||||
vanity: req.params.id
|
||||
}
|
||||
});
|
||||
|
||||
if (config.urls.vanity && urlVanity) return reply.redirect(urlVanity.url);
|
||||
if (!urlId) {
|
||||
await app.render404(req.raw, reply.raw);
|
||||
return (reply.sent = true);
|
||||
}
|
||||
return reply.redirect(urlId.url);
|
||||
});
|
||||
|
||||
server.get(`${config.uploader.rich_content_route || '/a'}/:id`, async function (
|
||||
req: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
if (!existsSync(join(config.uploader.directory, req.params.id))) {
|
||||
await app.render404(req.raw, reply.raw);
|
||||
return (reply.sent = true);
|
||||
}
|
||||
|
||||
return reply.type('text/html').send(`
|
||||
<html>
|
||||
<head>
|
||||
<meta property="theme-color" content="${config.meta.color}">
|
||||
<meta property="og:title" content="${req.params.id}">
|
||||
<meta property="og:url" content="${config.uploader.route}/${req.params.id}">
|
||||
<meta property="og:image" content="${config.uploader.route}/${req.params.id}">
|
||||
<meta property="twitter:card" content="summary_large_image">
|
||||
</head>
|
||||
<body>
|
||||
<div style="text-align:center;vertical-align:middle;">
|
||||
<img src="${config.uploader.route}/${req.params.id}" >
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
});
|
||||
}
|
||||
}
|
||||
34
src/plugins/HooksPlugin.ts
Normal file
34
src/plugins/HooksPlugin.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { Config } from '../lib/Config';
|
||||
import { Plugin } from '../lib/plugin';
|
||||
import { Console } from '../lib/logger';
|
||||
import Server from 'next/dist/next-server/server/next-server';
|
||||
import { Connection } from 'typeorm';
|
||||
import { bold, green, red } from '@dicedtomato/colors';
|
||||
|
||||
export default class implements Plugin {
|
||||
public name: string = "fastify_hooks";
|
||||
|
||||
public onLoad(server: FastifyInstance, orm: Connection, app: Server, config: Config) {
|
||||
server.addHook('preHandler', async (req, reply) => {
|
||||
if (
|
||||
config.core.blacklisted_ips &&
|
||||
config.core.blacklisted_ips.includes(req.ip)
|
||||
) {
|
||||
await app.render404(req.raw, reply.raw);
|
||||
return (reply.sent = true);
|
||||
}
|
||||
});
|
||||
|
||||
server.addHook('onResponse', (req, res, done) => {
|
||||
if (!req.url.startsWith('/_next') && config.core.log) {
|
||||
const status =
|
||||
res.statusCode !== 200
|
||||
? bold(red(res.statusCode.toString()))
|
||||
: bold(green(res.statusCode.toString()));
|
||||
Console.logger('server').info(`${status} ${req.url} was accessed`);
|
||||
}
|
||||
done();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
/* eslint-disable indent */
|
||||
import { User } from './entities/User';
|
||||
import { User } from './lib/entities/User';
|
||||
|
||||
export const LOGIN = 'LOGIN';
|
||||
export const LOGOUT = 'LOGOUT';
|
||||
export const UPDATE_USER = 'UPDATE_USER';
|
||||
export const STOP_LOADING = 'STOP_LOADING';
|
||||
export const START_LOADING = 'START_LOADING';
|
||||
export const SET_THEME = 'SET_THEME';
|
||||
|
||||
export interface State {
|
||||
loggedIn: boolean;
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"outDir": "./dist",
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
@@ -16,6 +20,12 @@
|
||||
"experimentalDecorators": true,
|
||||
"noEmit": false
|
||||
},
|
||||
"include": ["next-env.d.ts", "src"],
|
||||
"exclude": ["node_modules", ".next"]
|
||||
}
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"src"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
".next"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user