feat: initial

This commit is contained in:
AlphaNecron 2021-09-16 12:40:46 +07:00
commit 5926ecff4a
65 changed files with 2595 additions and 0 deletions

24
.eslintrc.js Normal file
View File

@ -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'
}
};

42
.gitignore vendored Normal file
View File

@ -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

4
.husky/commit-msg Normal file
View File

@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
yarn commitlint --edit $1

34
README.md Normal file
View File

@ -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.

11
axtral-env.d.ts vendored Normal file
View File

@ -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;
}
}
}

6
next-env.d.ts vendored Normal file
View File

@ -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.

3
next.config.js Normal file
View File

@ -0,0 +1,3 @@
module.exports = {
reactStrictMode: true
};

149
package.json Normal file
View File

@ -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"
]
]
}
}
}

View File

@ -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;

View File

@ -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"

31
prisma/schema.prisma Normal file
View File

@ -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
}

29
prisma/seed.ts Normal file
View File

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

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

4
public/vercel.svg Normal file
View File

@ -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

13
scripts/deployDb.js Normal file
View File

@ -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);
}
};

26
scripts/prismaRun.js Normal file
View File

@ -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));
});
};

98
server/index.js Normal file
View File

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

11
server/static.js Normal file
View File

@ -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;
}
};

39
server/validateConfig.js Normal file
View File

@ -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);
}
};

View File

@ -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&#39;t be previewed.</Heading>;
}
}
}

View File

@ -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>
);
}

100
src/components/Layout.tsx Normal file
View File

@ -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}
</>
)}
</>
);
}

50
src/components/List.tsx Normal file
View File

@ -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>
</>
);
}

View File

@ -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&#39;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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
</>
);
}

View File

@ -0,0 +1,9 @@
import React from 'react';
export default function Files({files}) {
return (
<>
</>
);
}

View File

@ -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>
);
}

View File

@ -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 >
);
}

4
src/lib/config.ts Normal file
View File

@ -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;

75
src/lib/configReader.js Normal file
View File

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

19
src/lib/generators.ts Normal file

File diff suppressed because one or more lines are too long

12
src/lib/hooks/useFetch.ts Normal file
View File

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

31
src/lib/hooks/useLogin.ts Normal file
View File

@ -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 };
}

29
src/lib/logger.js Normal file
View File

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

View File

@ -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));
};

79
src/lib/mimetype.js Normal file
View File

@ -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'
};

5
src/lib/prisma.ts Normal file
View File

@ -0,0 +1,5 @@
import { PrismaClient } from '@prisma/client';
if (!global.prisma) global.prisma = new PrismaClient();
export default global.prisma;

View File

@ -0,0 +1,4 @@
import { combineReducers } from 'redux';
import user from './reducers/user';
export default combineReducers({ user });

View File

@ -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;

54
src/lib/redux/store.ts Normal file
View File

@ -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;

18
src/lib/types.ts Normal file
View File

@ -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;
}

61
src/lib/utils.ts Normal file
View File

@ -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]}`;
}

92
src/pages/[...id].tsx Normal file
View File

@ -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
}
};
};

20
src/pages/_app.tsx Normal file
View File

@ -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>
);
}

View File

@ -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);

View File

@ -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);

50
src/pages/api/delete.ts Normal file
View File

@ -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);

70
src/pages/api/stats.ts Normal file
View File

@ -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);

81
src/pages/api/upload.ts Normal file
View File

@ -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,
},
};

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

62
src/pages/api/users.ts Normal file
View File

@ -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);

113
src/pages/auth/login.tsx Normal file
View File

@ -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>
);
}

15
src/pages/dash/files.tsx Normal file
View File

@ -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';

16
src/pages/dash/index.tsx Normal file
View File

@ -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';

16
src/pages/dash/upload.tsx Normal file
View File

@ -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';

16
src/pages/dash/users.tsx Normal file
View File

@ -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';

10
src/pages/index.tsx Normal file
View File

@ -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;
}

48
tsconfig.json Normal file
View File

@ -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"
]
}