mirror of https://github.com/AlphaNecron/Void.git
feat: initial
This commit is contained in:
commit
5926ecff4a
|
@ -0,0 +1,24 @@
|
|||
module.exports = {
|
||||
'extends': ['next', 'next/core-web-vitals'],
|
||||
'rules': {
|
||||
'indent': ['error', 2],
|
||||
'linebreak-style': ['error', 'unix'],
|
||||
'quotes': ['error', 'single'],
|
||||
'semi': ['error', 'always'],
|
||||
'jsx-quotes': ['error', 'prefer-single'],
|
||||
'react/prop-types': 'off',
|
||||
'react-hooks/rules-of-hooks': 'off',
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
'react/jsx-uses-react': 'warn',
|
||||
'react/jsx-uses-vars': 'warn',
|
||||
'react/no-danger-with-children': 'warn',
|
||||
'react/no-deprecated': 'warn',
|
||||
'react/no-direct-mutation-state': 'warn',
|
||||
'react/no-is-mounted': 'warn',
|
||||
'react/no-typos': 'error',
|
||||
'react/react-in-jsx-scope': 'error',
|
||||
'react/require-render-return': 'error',
|
||||
'react/style-prop-object': 'warn',
|
||||
'@next/next/no-img-element': 'off'
|
||||
}
|
||||
};
|
|
@ -0,0 +1,42 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# lock files
|
||||
yarn.lock
|
||||
package.lock.json
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# axtral
|
||||
config.toml
|
||||
/uploads
|
|
@ -0,0 +1,4 @@
|
|||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
yarn commitlint --edit $1
|
|
@ -0,0 +1,34 @@
|
|||
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file.
|
||||
|
||||
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`.
|
||||
|
||||
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
|
@ -0,0 +1,11 @@
|
|||
import type { PrismaClient } from '@prisma/client';
|
||||
import type { Config } from './src/lib/types';
|
||||
|
||||
declare global {
|
||||
namespace NodeJS {
|
||||
interface Global {
|
||||
prisma: PrismaClient;
|
||||
config: Config;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/types/global" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
|
@ -0,0 +1,3 @@
|
|||
module.exports = {
|
||||
reactStrictMode: true
|
||||
};
|
|
@ -0,0 +1,149 @@
|
|||
{
|
||||
"name": "axtral",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "NODE_ENV=development node server",
|
||||
"build": "npm-run-all build:schema build:next",
|
||||
"build:next": "next build",
|
||||
"build:schema": "prisma generate --schema=prisma/schema.prisma",
|
||||
"start": "node server",
|
||||
"prepare": "husky install",
|
||||
"lint": "next lint --fix"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "ts-node --compiler-options {\"module\":\"commonjs\"} --transpile-only prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chakra-ui/react": "^1.6.7",
|
||||
"@emotion/react": "^11",
|
||||
"@emotion/styled": "^11",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@prisma/client": "^3.0.2",
|
||||
"@reduxjs/toolkit": "^1.6.1",
|
||||
"argon2": "^0.28.2",
|
||||
"cookie": "^0.4.1",
|
||||
"copy-to-clipboard": "^3.3.1",
|
||||
"formik": "^2.2.9",
|
||||
"framer-motion": "^4",
|
||||
"multer": "^1.4.3",
|
||||
"next": "^11.1.2",
|
||||
"prisma": "^3.0.2",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-dropzone": "^11.3.4",
|
||||
"react-feather": "^2.0.9",
|
||||
"react-redux": "^7.2.5",
|
||||
"react-responsive": "^9.0.0-beta.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^13.1.0",
|
||||
"@commitlint/config-conventional": "^13.1.0",
|
||||
"@types/node": "^16.7.13",
|
||||
"@types/react": "^17.0.20",
|
||||
"@typescript-eslint/parser": "^4.31.0",
|
||||
"eslint": "7.32.0",
|
||||
"eslint-config-next": "11.1.2",
|
||||
"husky": "^7.0.2",
|
||||
"ts-node": "^10.2.1",
|
||||
"typescript": "^4.3.2",
|
||||
"yarn-run-all": "^3.1.1"
|
||||
},
|
||||
"commitlint": {
|
||||
"extends": [
|
||||
"@commitlint/config-conventional"
|
||||
],
|
||||
"parserPreset": "conventional-changelog-conventionalcommits",
|
||||
"rules": {
|
||||
"body-leading-blank": [
|
||||
1,
|
||||
"always"
|
||||
],
|
||||
"body-max-line-length": [
|
||||
2,
|
||||
"always",
|
||||
100
|
||||
],
|
||||
"footer-leading-blank": [
|
||||
1,
|
||||
"always"
|
||||
],
|
||||
"footer-max-line-length": [
|
||||
2,
|
||||
"always",
|
||||
100
|
||||
],
|
||||
"header-max-length": [
|
||||
2,
|
||||
"always",
|
||||
100
|
||||
],
|
||||
"subject-case": [
|
||||
2,
|
||||
"never",
|
||||
[
|
||||
"sentence-case",
|
||||
"start-case",
|
||||
"pascal-case",
|
||||
"upper-case"
|
||||
]
|
||||
],
|
||||
"subject-empty": [
|
||||
2,
|
||||
"never"
|
||||
],
|
||||
"subject-full-stop": [
|
||||
2,
|
||||
"never",
|
||||
"."
|
||||
],
|
||||
"type-case": [
|
||||
2,
|
||||
"always",
|
||||
"lower-case"
|
||||
],
|
||||
"type-empty": [
|
||||
2,
|
||||
"never"
|
||||
],
|
||||
"type-enum": [
|
||||
2,
|
||||
"always",
|
||||
[
|
||||
"build",
|
||||
"chore",
|
||||
"ci",
|
||||
"docs",
|
||||
"feat",
|
||||
"fix",
|
||||
"perf",
|
||||
"refactor",
|
||||
"revert",
|
||||
"style",
|
||||
"test"
|
||||
]
|
||||
],
|
||||
"scope-enum": [
|
||||
1,
|
||||
"always",
|
||||
[
|
||||
"prisma",
|
||||
"scripts",
|
||||
"server",
|
||||
"pages",
|
||||
"config",
|
||||
"api",
|
||||
"hooks",
|
||||
"components",
|
||||
"middleware",
|
||||
"redux",
|
||||
"lib",
|
||||
"assets"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"username" TEXT NOT NULL,
|
||||
"password" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
|
||||
"embedTitle" TEXT,
|
||||
"embedColor" TEXT NOT NULL DEFAULT E'#4fd1c5',
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "File" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"fileName" TEXT NOT NULL,
|
||||
"mimetype" TEXT NOT NULL DEFAULT E'image/png',
|
||||
"slug" TEXT NOT NULL,
|
||||
"uploadedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"deletionToken" TEXT NOT NULL,
|
||||
"views" INTEGER NOT NULL DEFAULT 0,
|
||||
"userId" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "File_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_token_key" ON "User"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "File_slug_key" ON "File"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "File_deletionToken_key" ON "File"("deletionToken");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "File" ADD CONSTRAINT "File_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
@ -0,0 +1,3 @@
|
|||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
|
@ -0,0 +1,31 @@
|
|||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
username String
|
||||
password String
|
||||
token String @unique
|
||||
isAdmin Boolean @default(false)
|
||||
embedTitle String?
|
||||
embedColor String @default("#B794F4")
|
||||
files File[]
|
||||
}
|
||||
|
||||
model File {
|
||||
id Int @id @default(autoincrement())
|
||||
fileName String
|
||||
mimetype String @default("image/png")
|
||||
slug String @unique
|
||||
uploadedAt DateTime @default(now())
|
||||
deletionToken String @unique
|
||||
views Int @default(0)
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
import { PrismaClient } from '@prisma/client';
|
||||
import { hashPassword, createToken } from '../src/lib/utils';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: 'axtral',
|
||||
password: await hashPassword('axtraluser'),
|
||||
token: createToken(),
|
||||
isAdmin: true
|
||||
}
|
||||
});
|
||||
console.log(`
|
||||
Use these credentials when logging in Axtral for the first time:
|
||||
Username: ${user.username}
|
||||
Password: axtraluser
|
||||
`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(e => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
|
@ -0,0 +1,4 @@
|
|||
<svg width="283" height="64" viewBox="0 0 283 64" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
|
@ -0,0 +1,13 @@
|
|||
const { error } = require('../src/lib/logger');
|
||||
const prismaRun = require('./prismaRun');
|
||||
|
||||
module.exports = async (config) => {
|
||||
try {
|
||||
await prismaRun(config.core.database_url, ['migrate', 'deploy']);
|
||||
await prismaRun(config.core.database_url, ['generate'], true);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
error('DB', 'there was an error.. exiting..');
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
|
@ -0,0 +1,26 @@
|
|||
const { spawn } = require('child_process');
|
||||
const { join } = require('path');
|
||||
|
||||
module.exports = (url, args, nostdout = false) => {
|
||||
return new Promise((res, rej) => {
|
||||
const proc = spawn(join(process.cwd(), 'node_modules', '.bin', 'prisma'), args, {
|
||||
env: {
|
||||
DATABASE_URL: url,
|
||||
...process.env
|
||||
},
|
||||
});
|
||||
|
||||
let a = '';
|
||||
|
||||
proc.stdout.on('data', d => {
|
||||
if (!nostdout) console.log(d.toString());
|
||||
a += d.toString();
|
||||
});
|
||||
proc.stderr.on('data', d => {
|
||||
if (!nostdout) console.log(d.toString());
|
||||
rej(d.toString());
|
||||
});
|
||||
proc.stdout.on('end', () => res(a));
|
||||
proc.stdout.on('close', () => res(a));
|
||||
});
|
||||
};
|
|
@ -0,0 +1,98 @@
|
|||
const next = require('next');
|
||||
const { createServer } = require('http');
|
||||
const { stat, mkdir } = require('fs/promises');
|
||||
const { extname } = require('path');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const validateConfig = require('./validateConfig');
|
||||
const { info, error } = require('../src/lib/logger');
|
||||
const getFile = require('./static');
|
||||
const prismaRun = require('../scripts/prismaRun');
|
||||
const configReader = require('../src/lib/configReader');
|
||||
const mimes = require('../src/lib/mimetype');
|
||||
const deployDb = require('../scripts/deployDb');
|
||||
|
||||
info('SERVER', 'Starting Axtral server');
|
||||
|
||||
const dev = process.env.NODE_ENV === 'development';
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const config = configReader();
|
||||
await validateConfig(config);
|
||||
const data = await prismaRun(config.core.database_url, ['migrate', 'status'], true);
|
||||
if (data.includes('Following migration have not yet been applied:')) {
|
||||
info('DB', 'Some migrations are not applied, applying them now...');
|
||||
await deployDb(config);
|
||||
info('DB', 'Finished applying migrations');
|
||||
}
|
||||
process.env.DATABASE_URL = config.core.database_url;
|
||||
await stat('./.next');
|
||||
await mkdir(config.uploader.directory, { recursive: true });
|
||||
const app = next({
|
||||
dir: '.',
|
||||
dev,
|
||||
quiet: dev
|
||||
}, config.core.port, config.core.host);
|
||||
await app.prepare();
|
||||
const handle = app.getRequestHandler();
|
||||
const prisma = new PrismaClient();
|
||||
const srv = createServer(async (req, res) => {
|
||||
if (req.url.startsWith('/raw')) {
|
||||
const parts = req.url.split('/');
|
||||
if (!parts[2] || parts[2] === '') return;
|
||||
const data = await getFile(config.uploader.directory, parts[2]);
|
||||
if (!data) {
|
||||
app.render404(req, res);
|
||||
} else {
|
||||
let file = await prisma.file.findFirst({
|
||||
where: {
|
||||
fileName: parts[2],
|
||||
}
|
||||
});
|
||||
if (file) {
|
||||
await prisma.file.update({
|
||||
where: {
|
||||
id: file.id,
|
||||
},
|
||||
data: {
|
||||
views: {
|
||||
increment: 1
|
||||
}
|
||||
}
|
||||
});
|
||||
res.setHeader('Content-Type', file.mimetype);
|
||||
} else {
|
||||
const mimetype = mimes[extname(parts[2])] ?? 'application/octet-stream';
|
||||
res.setHeader('Content-Type', mimetype);
|
||||
}
|
||||
res.end(data);
|
||||
}
|
||||
} else {
|
||||
handle(req, res);
|
||||
}
|
||||
res.statusCode === 200 ? info('URL', `${res.statusCode} ${req.url}`) : error('URL', `${res.statusCode} ${req.url}`);
|
||||
});
|
||||
srv.on('error', (e) => {
|
||||
error('SERVER', e);
|
||||
process.exit(1);
|
||||
});
|
||||
srv.on('listening', async () => {
|
||||
info('SERVER', `Listening on ${config.core.host}:${config.core.port}`);
|
||||
if (process.platform === 'linux' && dev) execSync(`xdg-open ${config.core.secure ? 'https' : 'http'}://${config.core.host === '0.0.0.0' ? 'localhost' : config.core.host}:${config.core.port}`);
|
||||
const users = await prisma.user.findMany();
|
||||
if (users.length === 0) {
|
||||
await prismaRun(config.core.database_url, ['db', 'seed']);
|
||||
}
|
||||
});
|
||||
srv.listen(config.core.port, config.core.host ?? '0.0.0.0');
|
||||
} catch (e) {
|
||||
if (e.message && e.message.startsWith('Could not find a production')) {
|
||||
error('WEB', 'There is no production build - run yarn build');
|
||||
} else if (e.code && e.code === 'ENOENT') {
|
||||
if (e.path === './.next') error('WEB', 'There is no production build - run yarn build');
|
||||
} else {
|
||||
error('SERVER', e);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
})();
|
|
@ -0,0 +1,11 @@
|
|||
const { readFile } = require('fs/promises');
|
||||
const { join } = require('path');
|
||||
|
||||
module.exports = async (dir, file) => {
|
||||
try {
|
||||
const data = await readFile(join(process.cwd(), dir, file));
|
||||
return data;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
|
@ -0,0 +1,39 @@
|
|||
const { error } = require('../src/lib/logger');
|
||||
|
||||
function dot(str, obj) {
|
||||
return str.split('.').reduce((a,b) => a[b], obj);
|
||||
}
|
||||
|
||||
const path = (path, type) => ({ path, type });
|
||||
|
||||
module.exports = async config => {
|
||||
const paths = [
|
||||
path('core.secure', 'boolean'),
|
||||
path('core.secret', 'string'),
|
||||
path('core.host', 'string'),
|
||||
path('core.port', 'number'),
|
||||
path('core.database_url', 'string'),
|
||||
path('uploader.length', 'number'),
|
||||
path('uploader.directory', 'string'),
|
||||
path('uploader.blacklisted', 'object'),
|
||||
];
|
||||
|
||||
let errors = 0;
|
||||
for (let i = 0, L = paths.length; i !== L; ++i) {
|
||||
const path = paths[i];
|
||||
const value = dot(path.path, config);
|
||||
if (value === undefined) {
|
||||
error('CONFIG', `There was no ${path.path} in config which was required`);
|
||||
++errors;
|
||||
}
|
||||
const type = typeof value;
|
||||
if (value !== undefined && type !== path.type) {
|
||||
error('CONFIG', `Expected ${path.type} on ${path.path}, but got ${type}`);
|
||||
++errors;
|
||||
}
|
||||
}
|
||||
if (errors !== 0) {
|
||||
error('CONFIG', `Exiting due to ${errors} errors`);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
|
@ -0,0 +1,19 @@
|
|||
import React from 'react';
|
||||
import { Heading } from '@chakra-ui/react';
|
||||
|
||||
export default function FileViewer({ src, type }) {
|
||||
switch (type) {
|
||||
case 'image': {
|
||||
return <img src={src} alt={src}/>;
|
||||
}
|
||||
case 'video': {
|
||||
return <video src={src} autoPlay={true} controls={true}/>;
|
||||
}
|
||||
case 'audio': {
|
||||
return <audio src={src} autoPlay={true} controls={true}/>;
|
||||
}
|
||||
default: {
|
||||
return <Heading fontSize='lg' m={6}>This file can't be previewed.</Heading>;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
import { InputGroup, Input, InputLeftElement, Icon } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
export default function IconTextbox({ icon, ...other }) {
|
||||
return (
|
||||
<InputGroup>
|
||||
<InputLeftElement pointerEvents='none' width='4.5rem'>
|
||||
<Icon as={icon} mr={8} mb={2}/>
|
||||
</InputLeftElement>
|
||||
<Input size='sm' {...other} />
|
||||
</InputGroup>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,100 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Button, MenuItem, IconButton, Icon, HStack, useColorMode, Spacer, Menu, MenuList, MenuButton, useDisclosure, Skeleton } from '@chakra-ui/react';
|
||||
import { Sun, Moon, Upload, Home, File, Users, User, Edit, LogOut, Tool } from 'react-feather';
|
||||
import Navigation from './Navigation';
|
||||
import ShareXDialog from './ShareXDialog';
|
||||
import Link from 'next/link';
|
||||
import ManageAccountDialog from './ManageAccountDialog';
|
||||
import { useRouter } from 'next/router';
|
||||
import MediaQuery from 'react-responsive';
|
||||
|
||||
export default function Layout({ children, id, user }) {
|
||||
const { colorMode, toggleColorMode } = useColorMode();
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const { onClose: onShareXClose, isOpen: shareXOpen, onOpen: onShareXOpen } = useDisclosure();
|
||||
const { onClose: onManageClose, isOpen: manageOpen, onOpen: onManageOpen } = useDisclosure();
|
||||
const logout = async () => {
|
||||
setBusy(true);
|
||||
const userRes = await fetch('/api/user');
|
||||
if (userRes.ok) {
|
||||
const res = await fetch('/api/auth/logout');
|
||||
if (res.ok) router.push('/auth/login');
|
||||
} else {
|
||||
router.push('/auth/login');
|
||||
}
|
||||
};
|
||||
const pages = [
|
||||
{
|
||||
icon: Home,
|
||||
label: 'Dashboard',
|
||||
route: '/dash'
|
||||
},
|
||||
{
|
||||
icon: File,
|
||||
label: 'Files',
|
||||
route: '/dash/files'
|
||||
},
|
||||
{
|
||||
icon: Upload,
|
||||
label: 'Upload',
|
||||
route: '/dash/upload'
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
label: 'Users',
|
||||
route: '/dash/users'
|
||||
}
|
||||
];
|
||||
return (
|
||||
<>
|
||||
{busy ? (
|
||||
<Skeleton r={4} l={4} t={4} b={4} height='96%' width='98%' m={4} pos='fixed'/>
|
||||
) : (
|
||||
<>
|
||||
<Navigation>
|
||||
<HStack align='left'>
|
||||
{pages.map((page, i) => (
|
||||
<>
|
||||
<MediaQuery minWidth={640}>
|
||||
<Link key={i} href={page.route} passHref>
|
||||
<Button justifyContent='flex-start' colorScheme='purple' isActive={i === id} variant='ghost' leftIcon={<Icon as={page.icon}/>}>{page.label}</Button>
|
||||
</Link>
|
||||
</MediaQuery>
|
||||
<MediaQuery maxWidth={640}>
|
||||
<Link key={i} href={page.route} passHref>
|
||||
<IconButton colorScheme='purple' aria-label={page.label} isActive={i === id} variant='ghost' icon={<Icon as={page.icon}/>}>{page.label}</IconButton>
|
||||
</Link>
|
||||
</MediaQuery>
|
||||
</>
|
||||
))}
|
||||
<Spacer/>
|
||||
<IconButton aria-label='Theme toggle' onClick={toggleColorMode} variant='solid' colorScheme='purple' icon={colorMode === 'light' ? <Moon size={20}/> : <Sun size={20}/>}/>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
aria-label='Options'
|
||||
leftIcon={<User size={16}/>}
|
||||
>{user.username}</MenuButton>
|
||||
<MenuList>
|
||||
<MenuItem icon={<Edit size={16}/>} onClick={onManageOpen}>
|
||||
Manage account
|
||||
</MenuItem>
|
||||
<MenuItem icon={<Tool size={16}/>} onClick={onShareXOpen}>
|
||||
ShareX config
|
||||
</MenuItem>
|
||||
<MenuItem icon={<LogOut size={16}/>} onClick={logout}>
|
||||
Logout
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</HStack>
|
||||
</Navigation>
|
||||
<ManageAccountDialog onClose={onManageClose} open={manageOpen} user={user}/>
|
||||
<ShareXDialog onClose={onShareXClose} open={shareXOpen} token={user.token}/>
|
||||
{children}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
import React from 'react';
|
||||
import { Table, TableCaption, Thead, Th, Tr, Tbody, Td, Button, Text } from '@chakra-ui/react';
|
||||
export default function List({ types, users }) {
|
||||
return (
|
||||
<>
|
||||
<Table variant='simple' size='sm'>
|
||||
<TableCaption placement='top'>
|
||||
<Text align='left' fontSize='xl'>By type</Text>
|
||||
</TableCaption>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Index</Th>
|
||||
<Th>File type</Th>
|
||||
<Th>Count</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{types.map((type, i) => (
|
||||
<Tr key={i}>
|
||||
<Td>{i}</Td>
|
||||
<Td>{type.mimetype}</Td>
|
||||
<Td>{type.count}</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
<Table variant='simple' size='sm'>
|
||||
<TableCaption placement='top'>
|
||||
<Text align='left' fontSize='xl'>By user</Text>
|
||||
</TableCaption>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Index</Th>
|
||||
<Th>Username</Th>
|
||||
<Th>File count</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{users.map((usr, i) => (
|
||||
<Tr key={i}>
|
||||
<Td>{i}</Td>
|
||||
<Td>{usr.username}</Td>
|
||||
<Td>{usr.count}</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,125 @@
|
|||
import React, { useRef, useState } from 'react';
|
||||
import { Modal, ModalOverlay, ModalContent, ModalHeader, ModalCloseButton, ModalBody, ModalFooter, Button, FormControl, FormErrorMessage, FormLabel, ButtonGroup, useToast, Icon } from '@chakra-ui/react';
|
||||
import PasswordBox from './PasswordBox';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import { updateUser } from 'lib/redux/reducers/user';
|
||||
import useFetch from 'lib/hooks/useFetch';
|
||||
import { useStoreDispatch } from 'lib/redux/store';
|
||||
import { useRouter } from 'next/router';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import { X, Check, RefreshCw, Clipboard, User, Key, Hexagon, Feather } from 'react-feather';
|
||||
import IconTextbox from './IconTextbox';
|
||||
|
||||
export default function ManageAccountDialog({ onClose, open, user }) {
|
||||
const ref = useRef();
|
||||
const [token, setToken] = useState(user.token);
|
||||
const dispatch = useStoreDispatch();
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const validateUsername = username => {
|
||||
if (username.trim() === '') {
|
||||
return 'Username is required';
|
||||
}
|
||||
};
|
||||
const regenToken = async () => {
|
||||
setBusy(true);
|
||||
const res = await useFetch('/api/user/token', 'PATCH');
|
||||
if (res.success) setToken(res.token);
|
||||
setBusy(false);
|
||||
};
|
||||
const handleSubmit = async (values, actions) => {
|
||||
const data = {
|
||||
username: values.username.trim(),
|
||||
...(values.password.trim() === '' || { password: values.password.trim() } as {}),
|
||||
embedColor: values.embedColor.trim(),
|
||||
embedTitle: values.embedTitle
|
||||
};
|
||||
const res = await useFetch('/api/user', 'PATCH', data);
|
||||
if (res.error) {
|
||||
toast({
|
||||
title: 'Couldn't update user info',
|
||||
description: res.error,
|
||||
duration: 4000,
|
||||
status: 'error',
|
||||
isClosable: true
|
||||
});
|
||||
} else {
|
||||
dispatch(updateUser(res));
|
||||
router.reload();
|
||||
}
|
||||
setBusy(false);
|
||||
actions.setSubmitting(false);
|
||||
onClose();
|
||||
};
|
||||
return (
|
||||
<Modal
|
||||
onClose={onClose}
|
||||
initialFocusRef={ref}
|
||||
isOpen={open}
|
||||
scrollBehavior='inside'
|
||||
>
|
||||
<ModalOverlay />
|
||||
<Formik
|
||||
initialValues={{ username: user.username, password: '', embedTitle: user.embedTitle, embedColor: user.embedColor }}
|
||||
onSubmit={(values, actions) => { handleSubmit(values, actions); }}
|
||||
>
|
||||
{(props) => (
|
||||
<Form>
|
||||
<ModalContent>
|
||||
<ModalHeader>Manage your account</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<Field name='username' validate={validateUsername}>
|
||||
{({ field, form }) => (
|
||||
<FormControl isInvalid={form.errors.username}>
|
||||
<FormLabel htmlFor='username'>Username</FormLabel>
|
||||
<IconTextbox icon={User} {...field} isInvalid={form.errors.username} id='username' placeholder='Username' />
|
||||
<FormErrorMessage>{form.errors.username}</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Field name='password'>
|
||||
{({ field }) => (
|
||||
<FormControl>
|
||||
<FormLabel mt={2} htmlFor='password'>Password</FormLabel>
|
||||
<PasswordBox {...field} id='password' placeholder='Blank = unchanged' />
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Field name='embedTitle'>
|
||||
{({ field }) => (
|
||||
<FormControl>
|
||||
<FormLabel mt={2} htmlFor='embedTitle'>Embed title</FormLabel>
|
||||
<IconTextbox icon={Feather} {...field} id='embedTitle' placeholder='Embed title' />
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Field name='embedColor'>
|
||||
{({ field }) => (
|
||||
<FormControl>
|
||||
<FormLabel mt={2} htmlFor='embedColor'>Embed color</FormLabel>
|
||||
<IconTextbox icon={Hexagon} {...field} type='color' id='embedColor' placeholder='Embed color' />
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<FormLabel mt={2}>Token</FormLabel>
|
||||
<IconTextbox icon={Key} isReadOnly placeholder='Token' value={token} />
|
||||
<ButtonGroup size='sm' mt={2}>
|
||||
<Button size='sm' leftIcon={<Icon as={Clipboard}/>} onClick={() => copy(token)} colorScheme='yellow'>Copy token</Button>
|
||||
<Button size='sm' leftIcon={<Icon as={RefreshCw}/>} colorScheme='red' isLoading={busy} loadingText='Regenerating token' onClick={regenToken}>Regenerate token</Button>
|
||||
</ButtonGroup>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<ButtonGroup size='sm'>
|
||||
<Button leftIcon={<Icon as={X}/>} onClick={onClose}>Cancel</Button>
|
||||
<Button leftIcon={<Icon as={Check}/>} isLoading={props.isSubmitting} loadingText='Saving' type='submit' colorScheme='purple' ref={ref}>Save</Button>
|
||||
</ButtonGroup>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</Modal>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
import React from 'react';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
|
||||
export default function Navigation({children}) {
|
||||
return (
|
||||
<Box h='48px' sx={{ zIndex: 100, position: 'sticky' }} top={0} right={0} left={0} p={1} boxShadow='base'>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
import React from 'react';
|
||||
import { InputGroup, Input, InputRightElement, IconButton, InputLeftElement, Icon } from '@chakra-ui/react';
|
||||
import { Eye, EyeOff, Lock } from 'react-feather';
|
||||
|
||||
export default function PasswordBox(props) {
|
||||
const [show, setShow] = React.useState(false);
|
||||
const handleClick = () => setShow(!show);
|
||||
return (
|
||||
<InputGroup size='md'>
|
||||
<InputLeftElement pointerEvents='none' width='4.5rem'>
|
||||
<Icon as={Lock} mr={8} mb={2}/>
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
pr='4.5rem' size='sm'
|
||||
type={show ? 'text' : 'password'} {...props}
|
||||
/>
|
||||
<InputRightElement width='4.5rem'>
|
||||
<IconButton aria-label='Reveal' colorScheme='purple' icon={<Icon as={show ? EyeOff : Eye}/>} h='1.5rem' mt={-2} mr={-8} size='sm' onClick={handleClick}/>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Select, Button, Modal, ModalOverlay, ModalContent, ModalHeader, ModalCloseButton, ModalBody, ModalFooter, Text, Input } from '@chakra-ui/react';
|
||||
|
||||
export default function ShareXDialog({ open, onClose, token }) {
|
||||
const ref = React.useRef();
|
||||
const [name, setName] = useState('Astralize CDN');
|
||||
const [generator, setGenerator] = useState('random');
|
||||
const generateConfig = () => {
|
||||
const config = {
|
||||
Version: '13.2.1',
|
||||
Name: name,
|
||||
DestinationType: 'ImageUploader, FileUploader, TextUploader',
|
||||
RequestMethod: 'POST',
|
||||
RequestURL: `${window.location.protocol + '//' + window.location.hostname + (window.location.port ? ':' + window.location.port : '')}/api/upload`,
|
||||
Headers: {
|
||||
Token: token,
|
||||
Generator: generator
|
||||
},
|
||||
URL: '$json:url$',
|
||||
ThumbnailURL: '$json:thumbUrl$',
|
||||
DeletionURL: '$json:deletionUrl$',
|
||||
ErrorMessage: '$json:error$',
|
||||
Body: 'MultipartFormData',
|
||||
FileFormName: 'file'
|
||||
};
|
||||
const a = document.createElement('a');
|
||||
a.setAttribute('href', 'data:application/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(config, null, '\t')));
|
||||
a.setAttribute('download', `${name.replaceAll(' ', '_')}.sxcu`);
|
||||
a.click();
|
||||
};
|
||||
return (
|
||||
<Modal
|
||||
onClose={onClose}
|
||||
initialFocusRef={ref}
|
||||
isOpen={open}
|
||||
scrollBehavior='inside'
|
||||
>
|
||||
<ModalOverlay/>
|
||||
<ModalContent>
|
||||
<ModalHeader>ShareX config generator</ModalHeader>
|
||||
<ModalCloseButton/>
|
||||
<ModalBody>
|
||||
<Text mb={2}>Config name</Text>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={n => setName(n.target.value)}
|
||||
placeholder='Axtral'
|
||||
size='sm'
|
||||
/>
|
||||
<Text my={2}>URL generator</Text>
|
||||
<Select
|
||||
value={generator}
|
||||
onChange={g => setGenerator(g.target.value)}
|
||||
size='sm'
|
||||
>
|
||||
<option value='random'>Random</option>
|
||||
<option value='zws'>Invisible</option>
|
||||
<option value='emoji'>Emoji</option>
|
||||
</Select>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button size='sm' onClick={onClose} mx={2}>Cancel</Button>
|
||||
<Button size='sm' colorScheme='purple' onClick={generateConfig} ref={ref}>Download</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
import React from 'react';
|
||||
import { Stat, StatLabel, StatNumber, useColorModeValue } from '@chakra-ui/react';
|
||||
|
||||
export default function StatCard({name, value}) {
|
||||
const bg = useColorModeValue('purple.500', 'purple.200');
|
||||
const fg = useColorModeValue('white', 'gray.800');
|
||||
return (
|
||||
<Stat bg={bg} p={2} borderRadius={4} color={fg} shadow='xl'>
|
||||
<StatLabel>{name}</StatLabel>
|
||||
<StatNumber>{value}</StatNumber>
|
||||
</Stat>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import useFetch from 'lib/hooks/useFetch';
|
||||
import { SimpleGrid, Skeleton } from '@chakra-ui/react';
|
||||
import StatCard from 'components/StatCard';
|
||||
import List from 'components/List';
|
||||
|
||||
export default function Dashboard() {
|
||||
const [stats, setStats] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const loadStats = async () => {
|
||||
setBusy(true);
|
||||
const stts = await useFetch('/api/stats');
|
||||
setStats(stts);
|
||||
setBusy(false);
|
||||
};
|
||||
useEffect(() => { loadStats(); }, []);
|
||||
return (
|
||||
<>
|
||||
<Skeleton m={2} isLoaded={!busy && stats} minHeight={16}>
|
||||
{stats && (
|
||||
<SimpleGrid mx={2} gap={4} minChildWidth='150px' columns={[2, 3, 3]}>
|
||||
<StatCard name='Size' value={stats.size}/>
|
||||
<StatCard name='Average size' value={stats.avgSize}/>
|
||||
<StatCard name='Files' value={stats.count}/>
|
||||
<StatCard name='Views' value={stats.viewCount}/>
|
||||
<StatCard name='Users' value={stats.userCount}/>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Skeleton>
|
||||
<Skeleton isLoaded={!busy && stats} m={2} minHeight={20}>
|
||||
{(stats && stats.countByType && stats.countByUser) && (
|
||||
<List types={stats.countByType} users={stats.countByUser}/>
|
||||
)}
|
||||
</Skeleton>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
import React from 'react';
|
||||
|
||||
export default function Files({files}) {
|
||||
|
||||
return (
|
||||
<>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,102 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useToast, useColorModeValue, Box, Button, Flex, Heading, HStack, Icon, Select, Spacer, VStack, Text } from '@chakra-ui/react';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import { useStoreSelector } from 'lib/redux/store';
|
||||
import Dropzone from 'react-dropzone';
|
||||
import { Upload as UploadIcon } from 'react-feather';
|
||||
|
||||
export default function Upload() {
|
||||
const { token } = useStoreSelector(state => state.user);
|
||||
const toast = useToast();
|
||||
const [generator, setGenerator] = useState('random');
|
||||
const [file, setFile] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const showToast = (srv, title, content) => {
|
||||
toast({
|
||||
title: title,
|
||||
description: content,
|
||||
status: srv,
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
};
|
||||
const handleFileUpload = async () => {
|
||||
try {
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Token': token,
|
||||
'Generator': generator
|
||||
},
|
||||
body
|
||||
});
|
||||
const json = await res.json();
|
||||
if (res.ok && !json.error) {
|
||||
showToast('success', 'File uploaded', json.url);
|
||||
let hasCopied = copy(json.url);
|
||||
if (hasCopied) {
|
||||
showToast('info', 'Copied the URL to your clipboard', null);
|
||||
}
|
||||
} else {
|
||||
showToast('error', 'Couldn\'t upload the file', json.error);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
showToast('error', 'Error while uploading the file', error.message);
|
||||
}
|
||||
finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const fg = useColorModeValue('gray.800', 'white');
|
||||
const bg = useColorModeValue('gray.100', 'gray.700');
|
||||
const shadow = useColorModeValue('outline', 'dark-lg');
|
||||
return (
|
||||
<Flex minHeight='92vh' width='full' align='center' justifyContent='center'>
|
||||
<Box
|
||||
px={2}
|
||||
boxShadow='xl'
|
||||
bg={bg}
|
||||
fg={fg}
|
||||
justify='center'
|
||||
align='center'
|
||||
p={2}
|
||||
borderRadius={4}
|
||||
|
||||
textAlign='left'
|
||||
shadow={shadow}
|
||||
>
|
||||
<VStack>
|
||||
<Heading fontSize='lg' m={1} align='left'>Upload a file</Heading>
|
||||
<Button m={2} variant='ghost' minWidth='300' maxWidth='350' minHeight='200'>
|
||||
<Dropzone disabled={loading} onDrop={acceptedFiles => setFile(acceptedFiles[0])}>
|
||||
{({ getRootProps, getInputProps, isDragActive }) => (
|
||||
<VStack {...getRootProps()}>
|
||||
<input {...getInputProps()}/>
|
||||
<UploadIcon size={56}/>
|
||||
{isDragActive ? (
|
||||
<Text fontSize='xl'>Drop the file here</Text>
|
||||
) : (
|
||||
<Text fontSize='xl'>Drag a file here or click to upload one</Text>
|
||||
)}
|
||||
<Text fontSize='lg' colorScheme='yellow' isTruncated maxWidth='325'>{file && file.name}</Text>
|
||||
</VStack>
|
||||
)}
|
||||
</Dropzone>
|
||||
</Button>
|
||||
<HStack justify='stretch' minWidth='300' maxWidth='350'>
|
||||
<Select size='sm' variant='filled' maxWidth='150' value={generator} onChange={selection => setGenerator(selection.target.value)}>
|
||||
<option value='random'>Random</option>
|
||||
<option value='zws'>Invisible</option>
|
||||
<option value='emoji'>Emoji</option>
|
||||
</Select>
|
||||
<Button size='sm' width='full' isDisabled={loading || !file} isLoading={loading} loadingText='Uploading' onClick={handleFileUpload} colorScheme='purple' leftIcon={<UploadIcon size={16}/>}>Upload</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,126 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { Table, Thead, Tr, Th, Tbody, Td, Button, Skeleton, Text, Popover, PopoverTrigger, PopoverContent, PopoverHeader, PopoverArrow, PopoverCloseButton, PopoverBody, PopoverFooter, ButtonGroup, useDisclosure, useToast } from '@chakra-ui/react';
|
||||
import useFetch from 'lib/hooks/useFetch';
|
||||
import router from 'next/router';
|
||||
import { Plus } from 'react-feather';
|
||||
import { Formik, Form } from 'formik';
|
||||
|
||||
export default function Users() {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const toast = useToast();
|
||||
const updateUsers = async () => {
|
||||
setBusy(true);
|
||||
const res = await useFetch('/api/users');
|
||||
if (!res.error) {
|
||||
setUsers(res);
|
||||
} else {
|
||||
router.push('/dash');
|
||||
};
|
||||
setBusy(false);
|
||||
};
|
||||
const validateField = (value, name) => {
|
||||
if (value.trim() === '') {
|
||||
return `${name} is required`;
|
||||
}
|
||||
};
|
||||
const showToast = (srv, title) => {
|
||||
toast({
|
||||
title,
|
||||
status: srv,
|
||||
duration: 3000,
|
||||
isClosable: true
|
||||
});
|
||||
};
|
||||
const handleSubmit = async (values, actions) => {
|
||||
const data = {
|
||||
username: values.username.trim(),
|
||||
password: values.password.trim(),
|
||||
isAdmin: values.isAdmin
|
||||
};
|
||||
setBusy(true);
|
||||
const res = await useFetch('/api/auth/create', 'POST', data);
|
||||
if (res.error) {
|
||||
showToast('error', res.error);
|
||||
} else {
|
||||
showToast('success', 'Created the user');
|
||||
}
|
||||
actions.setSubmitting(false);
|
||||
};
|
||||
useEffect(() => { updateUsers(); }, []);
|
||||
return (
|
||||
<Skeleton m={2} isLoaded={!busy}>
|
||||
<Popover
|
||||
isOpen={isOpen}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
placement='right-start'
|
||||
>
|
||||
<PopoverTrigger>
|
||||
<Button colorScheme='purple' leftIcon={<Plus size={20} />}>New user</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<PopoverHeader fontWeight='bold' border='0'>
|
||||
Create a new user
|
||||
</PopoverHeader>
|
||||
<PopoverArrow />
|
||||
<PopoverCloseButton />
|
||||
<Formik
|
||||
initialValues={{ username: '', password: '' }}
|
||||
onSubmit={(values, actions) => { handleSubmit(values, actions); }}
|
||||
>
|
||||
{(props) => (
|
||||
<Form>
|
||||
<PopoverBody>
|
||||
|
||||
|
||||
</PopoverBody>
|
||||
<PopoverFooter
|
||||
border='0'
|
||||
d='flex'
|
||||
alignItems='center'
|
||||
justifyContent='space-between'
|
||||
pb={4}
|
||||
>
|
||||
<ButtonGroup alignSelf='flex-end' size='sm'>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button colorScheme='purple' leftIcon={<Plus size={16} />}>Create</Button>
|
||||
</ButtonGroup>
|
||||
</PopoverFooter>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>ID</Th>
|
||||
<Th>Username</Th>
|
||||
<Th>Role</Th>
|
||||
<Th>Embed color</Th>
|
||||
<Th>Embed title</Th>
|
||||
<Th>Action</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{users.map((usr, i) => (
|
||||
<Tr key={i}>
|
||||
<Td>{usr.id}</Td>
|
||||
<Td>{usr.username}</Td>
|
||||
<Td>{usr.isAdmin ? 'Administrator' : 'User'}</Td>
|
||||
<Td>
|
||||
<Text sx={{ color: usr.embedColor }}>{usr.embedColor}</Text>
|
||||
</Td>
|
||||
<Td>{usr.embedTitle}</Td>
|
||||
<Td>
|
||||
<Button size='sm' colorScheme='red'>Delete</Button>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Skeleton >
|
||||
);
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
import type { Config } from './types';
|
||||
import configReader from './configReader';
|
||||
if (!global.config) global.config = configReader() as Config;
|
||||
export default global.config;
|
|
@ -0,0 +1,75 @@
|
|||
const { join } = require('path');
|
||||
const { info, error } = require('./logger');
|
||||
const { existsSync, readFileSync } = require('fs');
|
||||
|
||||
const e = (val, type, fn, required = true) => ({ val, type, fn, required });
|
||||
|
||||
const envValues = [
|
||||
e('SECURE', 'boolean', (c, v) => c.core.secure = v),
|
||||
e('SECRET', 'string', (c, v) => c.core.secret = v),
|
||||
e('HOST', 'string', (c, v) => c.core.host = v),
|
||||
e('PORT', 'number', (c, v) => c.core.port = v),
|
||||
e('DATABASE_URL', 'string', (c, v) => c.core.database_url = v),
|
||||
e('UPLOADER_LENGTH', 'number', (c, v) => c.uploader.length = v),
|
||||
e('UPLOADER_DIRECTORY', 'string', (c, v) => c.uploader.directory = v),
|
||||
e('UPLOADER_BLACKLISTED', 'array', (c, v) => c.uploader.blacklisted = v),
|
||||
];
|
||||
|
||||
module.exports = () => {
|
||||
if (!existsSync(join(process.cwd(), 'config.toml'))) {
|
||||
info('CONFIG', 'Reading environment');
|
||||
return tryReadEnv();
|
||||
} else {
|
||||
info('CONFIG', 'Reading config file');
|
||||
const str = readFileSync(join(process.cwd(), 'config.toml'), 'utf8');
|
||||
const parsed = require('@iarna/toml/parse-string')(str);
|
||||
return parsed;
|
||||
}
|
||||
};
|
||||
|
||||
function tryReadEnv() {
|
||||
const config = {
|
||||
core: {
|
||||
secure: undefined,
|
||||
secret: undefined,
|
||||
host: undefined,
|
||||
port: undefined,
|
||||
database_url: undefined,
|
||||
},
|
||||
uploader: {
|
||||
length: undefined,
|
||||
directory: undefined,
|
||||
blacklisted: undefined
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0, L = envValues.length; i !== L; ++i) {
|
||||
const envValue = envValues[i];
|
||||
let value = process.env[envValue.val];
|
||||
if (envValue.required && !value) {
|
||||
error('CONFIG', `There is no config file or required environment variables (${envValue.val})... exiting...`);
|
||||
process.exit(1);
|
||||
}
|
||||
envValues[i].fn(config, value);
|
||||
if (envValue.type === 'number') value = parseToNumber(value);
|
||||
else if (envValue.type === 'boolean') value = parseToBoolean(value);
|
||||
else if (envValue.type === 'array') value = parseToArray(value);
|
||||
envValues[i].fn(config, value);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function parseToNumber(value) {
|
||||
const number = Number(value);
|
||||
if (isNaN(number)) return undefined;
|
||||
return number;
|
||||
}
|
||||
|
||||
function parseToBoolean(value) {
|
||||
if (!value || value === 'false') return false;
|
||||
else return true;
|
||||
}
|
||||
|
||||
function parseToArray(value) {
|
||||
return value.split(',');
|
||||
}
|
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,12 @@
|
|||
export default async function useFetch(url: string, method: 'GET' | 'POST' | 'PATCH' | 'DELETE' = 'GET', body: Record<string, any> = null) {
|
||||
const headers = {};
|
||||
if (body) headers['content-type'] = 'application/json';
|
||||
|
||||
const res = await global.fetch(url, {
|
||||
body: body ? JSON.stringify(body) : null,
|
||||
method,
|
||||
headers
|
||||
});
|
||||
|
||||
return res.json();
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { updateUser, User } from 'lib/redux/reducers/user';
|
||||
import { useStoreDispatch, useStoreSelector } from 'lib/redux/store';
|
||||
import useFetch from './useFetch';
|
||||
|
||||
export default function login() {
|
||||
const router = useRouter();
|
||||
const dispatch = useStoreDispatch();
|
||||
const userState = useStoreSelector(s => s.user);
|
||||
const [user, setUser] = useState<User>(userState);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(!userState);
|
||||
|
||||
async function load() {
|
||||
setIsLoading(true);
|
||||
const res = await useFetch('/api/user');
|
||||
if (res.error) return router.push('/auth/login');
|
||||
dispatch(updateUser(res));
|
||||
setUser(res);
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && user) {
|
||||
return;
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
return { isLoading, user };
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
const colors = {
|
||||
red: '\x1b[41m',
|
||||
green: '\x1b[42m',
|
||||
yellow: '\x1b[43m',
|
||||
blue: '\x1b[44m',
|
||||
magenta: '\x1b[45m',
|
||||
cyan: '\x1b[46m',
|
||||
reset: '\x1b[0m',
|
||||
black: '\x1b[30m'
|
||||
};
|
||||
|
||||
function log(color, stype, msg, srv) {
|
||||
console.log(`${colors.blue}${colors.black} ${(new Date()).toLocaleTimeString()} ${color} ${srv}/${stype} ${colors.reset} ${msg}`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
debug: function(stype, msg) {
|
||||
log(colors.magenta, stype, msg, 'DBUG');
|
||||
},
|
||||
warn: function(stype, msg) {
|
||||
log(colors.yellow, stype, msg, 'WARN');
|
||||
},
|
||||
error: function(stype, msg) {
|
||||
log(colors.red, stype, msg, 'ERR');
|
||||
},
|
||||
info: function(stype, msg) {
|
||||
log(colors.cyan, stype, msg, 'INFO');
|
||||
},
|
||||
};
|
|
@ -0,0 +1,132 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import type { CookieSerializeOptions } from 'cookie';
|
||||
import type { File, User } from '@prisma/client';
|
||||
|
||||
import { serialize } from 'cookie';
|
||||
import { sign64, unsign64 } from '../utils';
|
||||
import config from '../config';
|
||||
import prisma from '../prisma';
|
||||
|
||||
export interface NextApiFile {
|
||||
fieldname: string;
|
||||
originalname: string;
|
||||
encoding: string;
|
||||
mimetype: string;
|
||||
buffer: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export type NextApiReq = NextApiRequest & {
|
||||
user: () => Promise<{
|
||||
username: string;
|
||||
token: string;
|
||||
embedTitle: string;
|
||||
embedColor: string;
|
||||
isAdmin: boolean;
|
||||
id: number;
|
||||
password: string;
|
||||
} | null | void>;
|
||||
getCookie: (name: string) => string | null;
|
||||
cleanCookie: (name: string) => void;
|
||||
file?: NextApiFile;
|
||||
}
|
||||
|
||||
export type NextApiRes = NextApiResponse & {
|
||||
error: (message: string) => void;
|
||||
forbid: (message: string) => void;
|
||||
bad: (message: string) => void;
|
||||
json: (json: any) => void;
|
||||
setCookie: (name: string, value: unknown, options: CookieSerializeOptions) => void;
|
||||
}
|
||||
|
||||
export const withAxtral = (handler: (req: NextApiRequest, res: NextApiResponse) => unknown) => (req: NextApiReq, res: NextApiRes) => {
|
||||
res.error = (message: string) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.json({
|
||||
error: message
|
||||
});
|
||||
};
|
||||
|
||||
res.forbid = (message: string) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.status(403);
|
||||
res.json({
|
||||
error: '403: ' + message
|
||||
});
|
||||
};
|
||||
|
||||
res.bad = (message: string) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.status(401);
|
||||
res.json({
|
||||
error: '403: ' + message
|
||||
});
|
||||
};
|
||||
|
||||
res.json = (json: any) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(json));
|
||||
};
|
||||
|
||||
req.getCookie = (name: string) => {
|
||||
const cookie = req.cookies[name];
|
||||
if (!cookie) return null;
|
||||
|
||||
const unsigned = unsign64(cookie, config.core.secret);
|
||||
return unsigned ? unsigned : null;
|
||||
};
|
||||
req.cleanCookie = (name: string) => {
|
||||
res.setHeader('Set-Cookie', serialize(name, '', {
|
||||
path: '/',
|
||||
expires: new Date(1),
|
||||
maxAge: undefined
|
||||
}));
|
||||
};
|
||||
req.user = async () => {
|
||||
try {
|
||||
const userId = req.getCookie('user');
|
||||
if (!userId) return null;
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: Number(userId)
|
||||
},
|
||||
select: {
|
||||
isAdmin: true,
|
||||
embedColor: true,
|
||||
embedTitle: true,
|
||||
id: true,
|
||||
password: true,
|
||||
token: true,
|
||||
username: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) return null;
|
||||
return user;
|
||||
} catch (e) {
|
||||
if (e.code && e.code === 'ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH') {
|
||||
req.cleanCookie('user');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
res.setCookie = (name: string, value: unknown, options?: CookieSerializeOptions) => setCookie(res, name, value, options || {});
|
||||
|
||||
return handler(req, res);
|
||||
};
|
||||
|
||||
export const setCookie = (
|
||||
res: NextApiResponse,
|
||||
name: string,
|
||||
value: unknown,
|
||||
options: CookieSerializeOptions = {}
|
||||
) => {
|
||||
|
||||
if ('maxAge' in options) {
|
||||
options.expires = new Date(Date.now() + options.maxAge);
|
||||
options.maxAge /= 1000;
|
||||
}
|
||||
const signed = sign64(String(value), config.core.secret);
|
||||
res.setHeader('Set-Cookie', serialize(name, signed, options));
|
||||
};
|
|
@ -0,0 +1,79 @@
|
|||
|
||||
module.exports = {
|
||||
'.aac': 'audio/aac',
|
||||
'.abw': 'application/x-abiword',
|
||||
'.arc': 'application/x-freearc',
|
||||
'.avi': 'video/x-msvideo',
|
||||
'.azw': 'application/vnd.amazon.ebook',
|
||||
'.bin': 'application/octet-stream',
|
||||
'.bmp': 'image/bmp',
|
||||
'.bz': 'application/x-bzip',
|
||||
'.bz2': 'application/x-bzip2',
|
||||
'.cda': 'application/x-cdf',
|
||||
'.csh': 'application/x-csh',
|
||||
'.css': 'text/css',
|
||||
'.csv': 'text/csv',
|
||||
'.doc': 'application/msword',
|
||||
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'.eot': 'application/vnd.ms-fontobject',
|
||||
'.epub': 'application/epub+zip',
|
||||
'.gz': 'application/gzip',
|
||||
'.gif': 'image/gif',
|
||||
'.htm': 'text/html',
|
||||
'.html': 'text/html',
|
||||
'.ico': 'image/vnd.microsoft.icon',
|
||||
'.ics': 'text/calendar',
|
||||
'.jar': 'application/java-archive',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.js': 'text/javascript',
|
||||
'.json': 'application/json',
|
||||
'.jsonld': 'application/ld+json',
|
||||
'.mid': 'audio/midi',
|
||||
'.midi': 'audio/midi',
|
||||
'.mjs': 'text/javascript',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.mp4': 'video/mp4',
|
||||
'.mpeg': 'video/mpeg',
|
||||
'.mpkg': 'application/vnd.apple.installer+xml',
|
||||
'.odp': 'application/vnd.oasis.opendocument.presentation',
|
||||
'.ods': 'application/vnd.oasis.opendocument.spreadsheet',
|
||||
'.odt': 'application/vnd.oasis.opendocument.text',
|
||||
'.oga': 'audio/ogg',
|
||||
'.ogv': 'video/ogg',
|
||||
'.ogx': 'application/ogg',
|
||||
'.opus': 'audio/opus',
|
||||
'.otf': 'font/otf',
|
||||
'.png': 'image/png',
|
||||
'.pdf': 'application/pdf',
|
||||
'.php': 'application/x-httpd-php',
|
||||
'.ppt': 'application/vnd.ms-powerpoint',
|
||||
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'.rar': 'application/vnd.rar',
|
||||
'.rtf': 'application/rtf',
|
||||
'.sh': 'application/x-sh',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.swf': 'application/x-shockwave-flash',
|
||||
'.tar': 'application/x-tar',
|
||||
'.tif': 'image/tiff',
|
||||
'.tiff': 'image/tiff',
|
||||
'.ts': 'video/mp2t',
|
||||
'.ttf': 'font/ttf',
|
||||
'.txt': 'text/plain',
|
||||
'.vsd': 'application/vnd.visio',
|
||||
'.wav': 'audio/wav',
|
||||
'.weba': 'audio/webm',
|
||||
'.webm': 'video/webm',
|
||||
'.webp': 'image/webp',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.xhtml': 'application/xhtml+xml',
|
||||
'.xls': 'application/vnd.ms-excel',
|
||||
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'.xml': 'application/xml',
|
||||
'.xul': 'application/vnd.mozilla.xul+xml',
|
||||
'.zip': 'application/zip',
|
||||
'.3gp': 'video/3gpp',
|
||||
'.3g2': 'video/3gpp2',
|
||||
'.7z': 'application/x-7z-compressed'
|
||||
};
|
|
@ -0,0 +1,5 @@
|
|||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
if (!global.prisma) global.prisma = new PrismaClient();
|
||||
|
||||
export default global.prisma;
|
|
@ -0,0 +1,4 @@
|
|||
import { combineReducers } from 'redux';
|
||||
import user from './reducers/user';
|
||||
|
||||
export default combineReducers({ user });
|
|
@ -0,0 +1,25 @@
|
|||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
export interface User {
|
||||
username: string;
|
||||
token: string;
|
||||
embedTitle: string;
|
||||
embedColor: string;
|
||||
}
|
||||
|
||||
const initialState: User = null;
|
||||
|
||||
const user = createSlice({
|
||||
name: 'user',
|
||||
initialState,
|
||||
reducers: {
|
||||
updateUser(state, action: PayloadAction<User>) {
|
||||
state = action.payload;
|
||||
return state;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { updateUser } = user.actions;
|
||||
|
||||
export default user.reducer;
|
|
@ -0,0 +1,54 @@
|
|||
// https://github.com/mikecao/umami/blob/master/redux/store.js
|
||||
import { useMemo } from 'react';
|
||||
import { Action, CombinedState, configureStore, EnhancedStore } from '@reduxjs/toolkit';
|
||||
import thunk, { ThunkAction } from 'redux-thunk';
|
||||
import rootReducer from './reducers';
|
||||
import { User } from './reducers/user';
|
||||
import { useDispatch, TypedUseSelectorHook, useSelector } from 'react-redux';
|
||||
|
||||
let store: EnhancedStore<CombinedState<{
|
||||
user: User;
|
||||
}>>;
|
||||
|
||||
export function getStore(preloadedState) {
|
||||
return configureStore({
|
||||
reducer: rootReducer,
|
||||
middleware: [thunk],
|
||||
preloadedState,
|
||||
});
|
||||
}
|
||||
|
||||
export const initializeStore = preloadedState => {
|
||||
let _store = store ?? getStore(preloadedState);
|
||||
|
||||
if (preloadedState && store) {
|
||||
_store = getStore({
|
||||
...store.getState(),
|
||||
...preloadedState,
|
||||
});
|
||||
store = undefined;
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') return _store;
|
||||
if (!store) store = _store;
|
||||
|
||||
return _store;
|
||||
};
|
||||
|
||||
export function useStore(initialState?: User) {
|
||||
return useMemo(() => initializeStore(initialState), [initialState]);
|
||||
}
|
||||
|
||||
export type AppState = ReturnType<typeof store.getState>
|
||||
|
||||
export type AppDispatch = typeof store.dispatch
|
||||
|
||||
export type AppThunk<ReturnType = void> = ThunkAction<
|
||||
ReturnType,
|
||||
AppState,
|
||||
unknown,
|
||||
Action<User>
|
||||
>
|
||||
|
||||
export const useStoreDispatch = () => useDispatch<AppDispatch>();
|
||||
export const useStoreSelector: TypedUseSelectorHook<AppState> = useSelector;
|
|
@ -0,0 +1,18 @@
|
|||
export interface Core {
|
||||
secure: boolean;
|
||||
secret: string;
|
||||
host: string;
|
||||
port: number;
|
||||
database_url: string
|
||||
}
|
||||
|
||||
export interface Uploader {
|
||||
length: number;
|
||||
directory: string;
|
||||
blacklisted: string[];
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
core: Core;
|
||||
uploader: Uploader;
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import { hash, verify } from 'argon2';
|
||||
import { join } from 'path';
|
||||
import { readdir, stat } from 'fs/promises';
|
||||
import generate from './generators';
|
||||
|
||||
export async function hashPassword(s: string): Promise<string> {
|
||||
return await hash(s);
|
||||
}
|
||||
|
||||
export function checkPassword(s: string, hash: string): Promise<boolean> {
|
||||
return verify(hash, s);
|
||||
}
|
||||
|
||||
export function createToken() {
|
||||
return generate(24) + '.' + Buffer.from(Date.now().toString()).toString('base64url');
|
||||
}
|
||||
|
||||
export function sign(value: string, secret: string): string {
|
||||
const signed = value + ':' + createHmac('sha256', secret)
|
||||
.update(value)
|
||||
.digest('base64')
|
||||
.replace(/=+$/, '');
|
||||
return signed;
|
||||
}
|
||||
|
||||
export function unsign(value: string, secret: string): string {
|
||||
const str = value.slice(0, value.lastIndexOf(':'));
|
||||
const mac = sign(str, secret);
|
||||
const macBuffer = Buffer.from(mac);
|
||||
const valBuffer = Buffer.from(value);
|
||||
return timingSafeEqual(macBuffer, valBuffer) ? str : null;
|
||||
}
|
||||
|
||||
export function sign64(value: string, secret: string): string {
|
||||
return Buffer.from(sign(value, secret)).toString('base64');
|
||||
}
|
||||
|
||||
export function unsign64(value: string, secret: string): string {
|
||||
return unsign(Buffer.from(value, 'base64').toString(), secret);
|
||||
}
|
||||
|
||||
export async function sizeOfDir(directory: string): Promise<number> {
|
||||
const files = await readdir(directory);
|
||||
let size = 0;
|
||||
for (let i = 0, L = files.length; i !== L; ++i) {
|
||||
const sta = await stat(join(directory, files[i]));
|
||||
size += sta.size;
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
export function bytesToHr(bytes: number) {
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
let num = 0;
|
||||
while (bytes > 1024) {
|
||||
bytes /= 1024;
|
||||
++num;
|
||||
}
|
||||
return `${bytes.toFixed(1)} ${units[num]}`;
|
||||
}
|
|
@ -0,0 +1,92 @@
|
|||
import React from 'react';
|
||||
import Head from 'next/head';
|
||||
import { GetServerSideProps } from 'next';
|
||||
import prisma from 'lib/prisma';
|
||||
import { Box, Flex, useColorModeValue, Heading, Button } from '@chakra-ui/react';
|
||||
import FileViewer from 'components/FileViewer';
|
||||
|
||||
export default function Embed({ file, title, color, username }) {
|
||||
const fileUrl = `/raw/${file.fileName}`;
|
||||
const bg = useColorModeValue('gray.100', 'gray.700');
|
||||
const fg = useColorModeValue('gray.800', 'white');
|
||||
const shadow = useColorModeValue('outline', 'dark-lg');
|
||||
const handleDownload = () => {
|
||||
const a = document.createElement('a');
|
||||
a.download = file.fileName;
|
||||
a.href = fileUrl;
|
||||
a.click();
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
{title ? (
|
||||
<>
|
||||
<meta property='og:site_name' content='Axtral'/>
|
||||
<meta property='og:title' content={title}/>
|
||||
</>
|
||||
) : (
|
||||
<meta property='og:title' content='Axtral'/>
|
||||
)}
|
||||
<meta property='theme-color' content={color}/>
|
||||
<meta property='og:url' content={file.slug}/>
|
||||
<meta property='twitter:card' content='summary_large_file'/>
|
||||
<title>{'Uploaded by ' + username}</title>
|
||||
</Head>
|
||||
<Flex minHeight='92%' width='full' align='center' justifyContent='center'>
|
||||
<Box
|
||||
m={4}
|
||||
boxShadow='xl'
|
||||
bg={bg}
|
||||
fg={fg}
|
||||
justify='center'
|
||||
align='center'
|
||||
p={1}
|
||||
maxWidth='72%' maxHeight='67%'
|
||||
borderRadius={5}
|
||||
textAlign='center'
|
||||
shadow={shadow}
|
||||
>
|
||||
<Heading mb={1} fontSize='md'>{file.fileName}</Heading>
|
||||
<FileViewer type={file.mimetype.split('/')[0]} src={fileUrl}/>
|
||||
<Button mt={1} colorScheme='purple' size='sm' onClick={handleDownload}>Download</Button>
|
||||
</Box>
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||
const slug = context.params.id[0];
|
||||
const file = await prisma.file.findFirst({
|
||||
where: {
|
||||
slug
|
||||
},
|
||||
select: {
|
||||
fileName: true,
|
||||
mimetype: true,
|
||||
userId: true
|
||||
}
|
||||
});
|
||||
if (!file) return {
|
||||
notFound: true
|
||||
};
|
||||
const user = await prisma.user.findFirst({
|
||||
select: {
|
||||
embedTitle: true,
|
||||
embedColor: true,
|
||||
username: true
|
||||
},
|
||||
where: {
|
||||
id: file.userId
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
props: {
|
||||
file,
|
||||
title: user.embedTitle,
|
||||
color: user.embedColor,
|
||||
username: user.username
|
||||
}
|
||||
};
|
||||
};
|
|
@ -0,0 +1,20 @@
|
|||
import React from 'react';
|
||||
import { ChakraProvider } from '@chakra-ui/react';
|
||||
import { useStore } from 'lib/redux/store';
|
||||
import { Provider } from 'react-redux';
|
||||
import Head from 'next/head';
|
||||
|
||||
export default function Axtral({ Component, pageProps }) {
|
||||
const store = useStore();
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<Head>
|
||||
<title>{Component.title}</title>
|
||||
<meta name='viewport' content='minimum-scale=1, initial-scale=1, width=device-width'/>
|
||||
</Head>
|
||||
<ChakraProvider>
|
||||
<Component {...pageProps}/>
|
||||
</ChakraProvider>
|
||||
</Provider>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
import prisma from 'lib/prisma';
|
||||
import { NextApiReq, NextApiRes, withAxtral } from 'middleware/withAxtral';
|
||||
import { checkPassword } from 'lib/utils';
|
||||
import { info } from 'lib/logger';
|
||||
import config from 'lib/config';
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
if (req.method !== 'POST') return res.status(405).end();
|
||||
const { username, password } = req.body as { username: string, password: string };
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
username
|
||||
}
|
||||
});
|
||||
if (!user) return res.status(404).end(JSON.stringify({ error: 'User not found' }));
|
||||
const valid = await checkPassword(password, user.password);
|
||||
if (!valid) return res.forbid('Wrong password');
|
||||
res.setCookie('user', user.id, { sameSite: true, maxAge: 10000000, path: '/' });
|
||||
info('AUTH', `User ${user.username} (${user.id}) logged in`);
|
||||
return res.json({ success: true });
|
||||
}
|
||||
|
||||
export default withAxtral(handler);
|
|
@ -0,0 +1,12 @@
|
|||
import { NextApiReq, NextApiRes, withAxtral } from 'middleware/withAxtral';
|
||||
import { info } from 'lib/logger';
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
const user = await req.user();
|
||||
if (!user) return res.forbid('Unauthorized');
|
||||
req.cleanCookie('user');
|
||||
info('USER', `User ${user.username} (${user.id}) logged out`);
|
||||
return res.json({ success: true });
|
||||
}
|
||||
|
||||
export default withAxtral(handler);
|
|
@ -0,0 +1,50 @@
|
|||
import prisma from '../../lib/prisma';
|
||||
import cfg from '../../lib/config';
|
||||
import { NextApiReq, NextApiRes, withAxtral } from '../../lib/middleware/withAxtral';
|
||||
import { rm } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { info } from '../../lib/logger';
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
if (!req.query.token) return res.forbid('No deletion token provided');
|
||||
try {
|
||||
const file = await prisma.file.delete({
|
||||
where: {
|
||||
deletionToken: req.query.token
|
||||
}
|
||||
});
|
||||
if (!file) {
|
||||
return res.json({
|
||||
success: 'false'
|
||||
});
|
||||
}
|
||||
await rm(join(process.cwd(), cfg.uploader.directory, file.fileName));
|
||||
info('USER', `Deleted ${file.fileName} (${file.id})`);
|
||||
return res.json({
|
||||
success: true
|
||||
});
|
||||
}
|
||||
catch {
|
||||
return res.json({
|
||||
success: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function run(middleware: any) {
|
||||
return (req, res) =>
|
||||
new Promise((resolve, reject) => {
|
||||
middleware(req, res, (result) => {
|
||||
if (result instanceof Error) reject(result);
|
||||
resolve(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default withAxtral(handler);
|
|
@ -0,0 +1,70 @@
|
|||
import { join } from 'path';
|
||||
import { NextApiReq, NextApiRes, withAxtral } from 'middleware/withAxtral';
|
||||
import prisma from 'lib/prisma';
|
||||
import { bytesToHr, sizeOfDir } from 'lib/utils';
|
||||
import config from 'lib/config';
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
const user = await req.user();
|
||||
if (!user) return res.forbid('Unauthorized');
|
||||
const size = await sizeOfDir(join(process.cwd(), config.uploader.directory));
|
||||
const userCount = await prisma.user.count();
|
||||
const count = await prisma.file.count();
|
||||
if (count === 0) {
|
||||
return res.json({
|
||||
size: bytesToHr(0),
|
||||
sizeRaw: 0,
|
||||
avgSize: bytesToHr(0),
|
||||
count,
|
||||
viewCount: 0,
|
||||
userCount
|
||||
});
|
||||
}
|
||||
const byUser = await prisma.file.groupBy({
|
||||
by: ['userId'],
|
||||
_count: {
|
||||
_all: true
|
||||
}
|
||||
});
|
||||
const countByUser = [];
|
||||
for (let i = 0, L = byUser.length; i !== L; ++i) {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: byUser[i].userId
|
||||
}
|
||||
});
|
||||
countByUser.push({
|
||||
username: user.username,
|
||||
count: byUser[i]._count._all
|
||||
});
|
||||
}
|
||||
const viewsCount = await prisma.file.groupBy({
|
||||
by: ['views'],
|
||||
_sum: {
|
||||
views: true
|
||||
}
|
||||
});
|
||||
|
||||
const typesCount = await prisma.file.groupBy({
|
||||
by: ['mimetype'],
|
||||
_count: {
|
||||
mimetype: true
|
||||
}
|
||||
});
|
||||
const countByType = [];
|
||||
for (let i = 0, L = typesCount.length; i !== L; ++i) {
|
||||
countByType.push({ mimetype: typesCount[i].mimetype, count: typesCount[i]._count.mimetype });
|
||||
}
|
||||
return res.json({
|
||||
size: bytesToHr(size),
|
||||
sizeRaw: size,
|
||||
avgSize: bytesToHr(isNaN(size / count) ? 0 : size / count),
|
||||
count,
|
||||
countByUser,
|
||||
userCount,
|
||||
viewCount: (viewsCount[0]?._sum?.views ?? 0),
|
||||
countByType
|
||||
});
|
||||
}
|
||||
|
||||
export default withAxtral(handler);
|
|
@ -0,0 +1,81 @@
|
|||
import multer from 'multer';
|
||||
import prisma from '../../lib/prisma';
|
||||
import cfg from '../../lib/config';
|
||||
import { NextApiReq, NextApiRes, withAxtral } from '../../lib/middleware/withAxtral';
|
||||
import generate, { zws, emoji } from '../../lib/generators';
|
||||
import { writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { info } from '../../lib/logger';
|
||||
|
||||
const uploader = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
});
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
if (req.method !== 'POST') return res.forbid('Invalid method');
|
||||
if (!req.headers.token) return res.forbid('Unauthorized');
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
token: req.headers.token
|
||||
}
|
||||
});
|
||||
if (!user) return res.forbid('Unauthorized');
|
||||
if (!req.file) return res.error('No file specified');
|
||||
const ext = req.file.originalname.split('.').pop();
|
||||
if (cfg.uploader.blacklisted.includes(ext)) return res.error('Blacklisted extension received: ' + ext);
|
||||
const rand = generate(cfg.uploader.length);
|
||||
let slug = '';
|
||||
switch (req.headers.generator) {
|
||||
case 'zws': {
|
||||
slug = zws(cfg.uploader.length);
|
||||
break;
|
||||
}
|
||||
case 'emoji': {
|
||||
slug = emoji(cfg.uploader.length);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
slug = rand;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const deletionToken = generate(15);
|
||||
const file = await prisma.file.create({
|
||||
data: {
|
||||
slug,
|
||||
fileName: `${rand}.${ext}`,
|
||||
mimetype: req.file.mimetype,
|
||||
userId: user.id,
|
||||
deletionToken
|
||||
}
|
||||
});
|
||||
await writeFile(join(process.cwd(), cfg.uploader.directory, file.fileName), req.file.buffer);
|
||||
info('FILE', `User ${user.username} (${user.id}) uploaded an file ${file.fileName} (${file.id})`);
|
||||
const baseUrl = `${cfg.core.secure ? 'https' : 'http'}://${req.headers.host}`;
|
||||
return res.json({
|
||||
url: `${baseUrl}/${file.slug}`,
|
||||
deletionUrl: `${baseUrl}/api/delete?token=${deletionToken}`,
|
||||
thumbUrl: `${baseUrl}/raw/${file.fileName}`
|
||||
});
|
||||
}
|
||||
|
||||
function run(middleware: any) {
|
||||
return (req, res) =>
|
||||
new Promise((resolve, reject) => {
|
||||
middleware(req, res, (result) => {
|
||||
if (result instanceof Error) reject(result);
|
||||
resolve(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default async function handlers(req, res) {
|
||||
await run(uploader.single('file'))(req, res);
|
||||
return withAxtral(handler)(req, res);
|
||||
};
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
|
@ -0,0 +1,28 @@
|
|||
import { NextApiReq, NextApiRes, withAxtral } from 'middleware/withAxtral';
|
||||
import prisma from 'lib/prisma';
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
const user = await req.user();
|
||||
if (!user) return res.forbid('Unauthorized');
|
||||
if (req.method === 'GET') {
|
||||
let files = await prisma.file.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
uploadedAt: true,
|
||||
fileName: true,
|
||||
mimetype: true,
|
||||
id: true,
|
||||
slug: true,
|
||||
deletionToken: true
|
||||
}
|
||||
});
|
||||
if (req.query.filter) files = files.filter(file => file.mimetype.startsWith(req.query.filter));
|
||||
return res.json(files);
|
||||
} else {
|
||||
return res.forbid('Invalid method');
|
||||
}
|
||||
}
|
||||
|
||||
export default withAxtral(handler);
|
|
@ -0,0 +1,62 @@
|
|||
import prisma from 'lib/prisma';
|
||||
import { hashPassword } from 'lib/utils';
|
||||
import { NextApiReq, NextApiRes, withAxtral } from 'middleware/withAxtral';
|
||||
import { info } from 'lib/logger';
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
const user = await req.user();
|
||||
if (!user) return res.forbid('Unauthorized');
|
||||
if (req.method === 'PATCH') {
|
||||
if (req.body.password) {
|
||||
const hashed = await hashPassword(req.body.password);
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { password: hashed }
|
||||
});
|
||||
}
|
||||
if (req.body.username) {
|
||||
const existing = await prisma.user.findFirst({
|
||||
where: {
|
||||
username: req.body.username
|
||||
}
|
||||
});
|
||||
if (existing && user.username !== req.body.username) {
|
||||
return res.forbid('Username is already taken');
|
||||
}
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { username: req.body.username }
|
||||
});
|
||||
}
|
||||
if (req.body.embedTitle) await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { embedTitle: req.body.embedTitle }
|
||||
});
|
||||
if (req.body.embedColor) await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { embedColor: req.body.embedColor }
|
||||
});
|
||||
const newUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: Number(user.id)
|
||||
},
|
||||
select: {
|
||||
isAdmin: true,
|
||||
embedColor: true,
|
||||
embedTitle: true,
|
||||
id: true,
|
||||
files: false,
|
||||
password: false,
|
||||
token: true,
|
||||
username: true
|
||||
}
|
||||
});
|
||||
info('USER', `User ${user.username} (${newUser.username}) (${newUser.id}) was updated`);
|
||||
return res.json(newUser);
|
||||
} else {
|
||||
delete user.password;
|
||||
return res.json(user);
|
||||
}
|
||||
}
|
||||
|
||||
export default withAxtral(handler);
|
|
@ -0,0 +1,27 @@
|
|||
import { NextApiReq, NextApiRes, withAxtral } from 'middleware/withAxtral';
|
||||
import prisma from 'lib/prisma';
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
const user = await req.user();
|
||||
if (!user) return res.forbid('Unauthorized');
|
||||
const take = Number(req.query.take ?? 3);
|
||||
if (take > 50) return res.error('Take query can\'t be more than 50');
|
||||
const files = await prisma.file.findMany({
|
||||
take,
|
||||
orderBy: {
|
||||
uploadedAt: 'desc'
|
||||
},
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
uploadedAt: true,
|
||||
slug: true,
|
||||
fileName: true,
|
||||
mimetype: true
|
||||
}
|
||||
});
|
||||
return res.json(files);
|
||||
}
|
||||
|
||||
export default withAxtral(handler);
|
|
@ -0,0 +1,27 @@
|
|||
import { NextApiReq, NextApiRes, withAxtral } from 'middleware/withAxtral';
|
||||
import prisma from 'lib/prisma';
|
||||
import { createToken } from 'lib/utils';
|
||||
import { info } from 'lib/logger';
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
const user = await req.user();
|
||||
if (!user) return res.forbid('Unauthorized');
|
||||
|
||||
if (req.method === 'PATCH') {
|
||||
const updated = await prisma.user.update({
|
||||
where: {
|
||||
id: user.id
|
||||
},
|
||||
data: {
|
||||
token: createToken()
|
||||
}
|
||||
});
|
||||
info('USER', `User ${user.username} (${user.id}) reset their token`);
|
||||
return res.json({ success: true, token: updated.token });
|
||||
}
|
||||
else {
|
||||
return res.forbid('Invalid method');
|
||||
}
|
||||
}
|
||||
|
||||
export default withAxtral(handler);
|
|
@ -0,0 +1,62 @@
|
|||
import prisma from 'lib/prisma';
|
||||
import { createToken, hashPassword } from 'lib/utils';
|
||||
import { NextApiReq, NextApiRes, withAxtral } from 'middleware/withAxtral';
|
||||
import { info } from 'lib/logger';
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||
const user = await req.user();
|
||||
if (!user) return res.forbid('Unauthorized');
|
||||
if (!user.isAdmin) return res.forbid('You aren\'t an administrator');
|
||||
if (req.method === 'DELETE') {
|
||||
if (req.body.id === user.id) return res.forbid('You can\'t delete your own account');
|
||||
const userToDelete = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: req.body.id
|
||||
}
|
||||
});
|
||||
if (!userToDelete) return res.status(404).end(JSON.stringify({ error: 'User not found' }));
|
||||
await prisma.user.delete({
|
||||
where: {
|
||||
id: userToDelete.id
|
||||
}
|
||||
});
|
||||
delete userToDelete.password;
|
||||
return res.json(userToDelete);
|
||||
} else if (req.method === 'POST') {
|
||||
const { username, password, isAdmin } = req.body as { username: string, password: string, isAdmin: boolean };
|
||||
if (!username) return res.bad('No username provided');
|
||||
if (!password) return res.bad('No password provided');
|
||||
const existing = await prisma.user.findFirst({
|
||||
where: {
|
||||
username
|
||||
}
|
||||
});
|
||||
if (existing) return res.forbid('User already exists');
|
||||
const hashed = await hashPassword(password);
|
||||
const newUser = await prisma.user.create({
|
||||
data: {
|
||||
password: hashed,
|
||||
username,
|
||||
token: createToken(),
|
||||
isAdmin
|
||||
}
|
||||
});
|
||||
delete newUser.password;
|
||||
info('USER', `Created user ${newUser.username} (${newUser.id})`);
|
||||
return res.json(newUser);
|
||||
} else {
|
||||
const all = await prisma.user.findMany({
|
||||
select: {
|
||||
username: true,
|
||||
id: true,
|
||||
isAdmin: true,
|
||||
token: true,
|
||||
embedColor: true,
|
||||
embedTitle: true,
|
||||
}
|
||||
});
|
||||
return res.json(all);
|
||||
}
|
||||
}
|
||||
|
||||
export default withAxtral(handler);
|
|
@ -0,0 +1,113 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { FormControl, Button, Input, FormLabel, Text, FormErrorMessage, Box, Flex, Checkbox, Heading, useColorModeValue, useToast, VStack } from '@chakra-ui/react';
|
||||
import { Formik, Form, Field } from 'formik';
|
||||
import { LogIn, User } from 'react-feather';
|
||||
import { useRouter } from 'next/router';
|
||||
import useFetch from 'lib/hooks/useFetch';
|
||||
import PasswordBox from 'components/PasswordBox';
|
||||
import IconTextbox from 'components/IconTextbox';
|
||||
|
||||
export default function Login() {
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const validateUsername = username => {
|
||||
let error;
|
||||
if (username.trim() === '') {
|
||||
error = 'Username is required';
|
||||
}
|
||||
return error;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const a = await fetch('/api/user');
|
||||
if (a.ok) router.push('/dash');
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const onSubmit = async (actions, values) => {
|
||||
const username = values.username.trim();
|
||||
const password = values.password.trim();
|
||||
const res = await useFetch('/api/auth/login', 'POST', {
|
||||
username, password
|
||||
});
|
||||
if (res.error) {
|
||||
showToast('error', res.error);
|
||||
} else {
|
||||
showToast('success', 'Logged in');
|
||||
router.push('/dash');
|
||||
}
|
||||
actions.setSubmitting(false);
|
||||
};
|
||||
|
||||
const showToast = (srv, content) => {
|
||||
toast({
|
||||
title: content,
|
||||
status: srv,
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
};
|
||||
|
||||
const validatePassword = password => {
|
||||
let error;
|
||||
if (password.trim() === '') {
|
||||
error = 'Password is required';
|
||||
}
|
||||
return error;
|
||||
};
|
||||
const bg = useColorModeValue('gray.100', 'gray.700');
|
||||
const shadow = useColorModeValue('outline', 'dark-lg');
|
||||
return (
|
||||
<Flex minHeight='100vh' width='full' align='center' justifyContent='center'>
|
||||
<Box
|
||||
p={4}
|
||||
bg={bg}
|
||||
width={250}
|
||||
justify='flex-end'
|
||||
align='center'
|
||||
borderRadius={6}
|
||||
boxShadow={shadow}
|
||||
>
|
||||
<Formik initialValues={{ username: '', password: '' }}
|
||||
onSubmit={(values, actions) => onSubmit(actions, values)}
|
||||
>
|
||||
{(props) => (
|
||||
<Form>
|
||||
<VStack>
|
||||
<Heading fontSize='xl' mb={2} align='center'>Axtral</Heading>
|
||||
<Field name='username' validate={validateUsername}>
|
||||
{({ field, form }) => (
|
||||
<FormControl isInvalid={form.errors.username && form.touched.username} isRequired mb={4}>
|
||||
<FormLabel htmlFor='username'>Username</FormLabel>
|
||||
<IconTextbox icon={User} {...field} id='username' placeholder='Username' />
|
||||
<FormErrorMessage>{form.errors.username}</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Field name='password' validate={validatePassword}>
|
||||
{({ field, form }) => (
|
||||
<FormControl isInvalid={form.errors.password && form.touched.password} isRequired>
|
||||
<FormLabel htmlFor='password'>Password</FormLabel>
|
||||
<PasswordBox {...field} id='password' mb={4} placeholder='Password' />
|
||||
</FormControl>
|
||||
)}
|
||||
</Field>
|
||||
<Button
|
||||
colorScheme='purple'
|
||||
isLoading={props.isSubmitting}
|
||||
loadingText='Logging in'
|
||||
type='submit'
|
||||
width='full'
|
||||
>
|
||||
<LogIn size={16} />
|
||||
<Text ml={2}>Login</Text>
|
||||
</Button>
|
||||
</VStack>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
import React from 'react';
|
||||
import Layout from 'components/Layout';
|
||||
import useLogin from 'lib/hooks/useLogin';
|
||||
|
||||
export default function Files() {
|
||||
const { user, isLoading } = useLogin();
|
||||
if (isLoading) return null;
|
||||
return (
|
||||
<Layout user={user} id={1}>
|
||||
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
Files.title = 'Files';
|
|
@ -0,0 +1,16 @@
|
|||
import React from 'react';
|
||||
import Dash from 'components/pages/Dashboard';
|
||||
import Layout from 'components/Layout';
|
||||
import useLogin from 'lib/hooks/useLogin';
|
||||
|
||||
export default function Dashboard() {
|
||||
const { user, isLoading } = useLogin();
|
||||
if (isLoading) return null;
|
||||
return (
|
||||
<Layout user={user} id={0}>
|
||||
<Dash/>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
Dashboard.title = 'Dashboard';
|
|
@ -0,0 +1,16 @@
|
|||
import React from 'react';
|
||||
import Layout from '../../components/Layout';
|
||||
import { default as UploadPage } from '../../components/pages/Upload';
|
||||
import useLogin from 'lib/hooks/useLogin';
|
||||
|
||||
export default function Upload() {
|
||||
const { user, isLoading } = useLogin();
|
||||
if (isLoading) return null;
|
||||
return (
|
||||
<Layout user={user} id={2}>
|
||||
<UploadPage/>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
Upload.title = 'Upload';
|
|
@ -0,0 +1,16 @@
|
|||
import React from 'react';
|
||||
import Layout from 'components/Layout';
|
||||
import useLogin from 'lib/hooks/useLogin';
|
||||
import UserPage from 'components/pages/Users';
|
||||
|
||||
export default function Users() {
|
||||
const { user, isLoading } = useLogin();
|
||||
if (isLoading) return null;
|
||||
return (
|
||||
<Layout user={user} id={3}>
|
||||
<UserPage/>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
Users.title = 'Users';
|
|
@ -0,0 +1,10 @@
|
|||
import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function Index() {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
router.push('/dash');
|
||||
}, [router]);
|
||||
return null;
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": false,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "./dist",
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"noEmit": true,
|
||||
"baseUrl": "src",
|
||||
"paths": {
|
||||
"components/*": [
|
||||
"components/*"
|
||||
],
|
||||
"middleware/*": [
|
||||
"lib/middleware/*"
|
||||
],
|
||||
"lib/*": [
|
||||
"lib/*"
|
||||
],
|
||||
"hooks/*": [
|
||||
"hooks/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"axtral-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"prisma/seed.ts",
|
||||
"src/lib/configReader.js"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
Loading…
Reference in New Issue