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