feat: a frontend

This commit is contained in:
diced
2023-07-01 00:23:05 -07:00
parent 14da882840
commit 75810ff90e
29 changed files with 953 additions and 212 deletions

View File

@@ -31,7 +31,7 @@
"@prisma/client": "4.16.1",
"@prisma/internals": "^4.16.1",
"@prisma/migrate": "^4.16.1",
"@tabler/icons-react": "^2.22.0",
"@tabler/icons-react": "^2.23.0",
"argon2": "^0.30.3",
"bytes": "^3.1.2",
"colorette": "^2.0.20",
@@ -42,8 +42,10 @@
"next": "^13.4.7",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"swr": "^2.2.0",
"znv": "^0.3.2",
"zod": "^3.21.4"
"zod": "^3.21.4",
"zustand": "^4.3.8"
},
"devDependencies": {
"@remix-run/dev": "^1.16.1",

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

@@ -0,0 +1,190 @@
import type { Response } from '@/lib/api/response';
import useLogin from '@/lib/hooks/useLogin';
import {
AppShell,
Burger,
Header,
MediaQuery,
NavLink,
Navbar,
Paper,
Text,
useMantineTheme,
} from '@mantine/core';
import {
IconChevronRight,
IconFileText,
IconFileUpload,
IconFiles,
IconHome,
IconLink,
IconShieldLockFilled,
IconUpload,
IconUsersGroup,
} from '@tabler/icons-react';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useState } from 'react';
type NavLinks = {
label: string;
icon: React.ReactNode;
active: (path: string) => boolean;
href?: string;
description?: string;
links?: NavLinks[];
if?: (user: Response['/api/user']['user']) => boolean;
};
const navLinks: NavLinks[] = [
{
label: 'Home',
icon: <IconHome size='1rem' />,
active: (path: string) => path === '/dashbaord',
href: '/dashboard',
description: 'View recent files and your statistics',
},
{
label: 'Files',
icon: <IconFiles size='1rem' />,
active: (path: string) => path === '/dashboard/files',
href: '/dashboard/files',
description: 'View your files',
},
{
label: 'Upload',
icon: <IconUpload size='1rem' />,
active: (path: string) => path.startsWith('/dashboard/upload'),
links: [
{
label: 'File',
icon: <IconFileUpload size='1rem' />,
active: (path: string) => path === '/dashboard/upload/file',
href: '/dashboard/upload/file',
description: 'Upload a file',
},
{
label: 'Text',
icon: <IconFileText size='1rem' />,
active: (path: string) => path === '/dashboard/upload/text',
href: '/dashboard/upload/text',
description: 'Upload text, code, etc.',
},
],
},
{
label: 'URLs',
icon: <IconLink size='1rem' />,
active: (path: string) => path === '/dashboard/urls',
href: '/dashboard/urls',
description: 'View your URLs',
},
{
label: 'Administrator',
icon: <IconShieldLockFilled size='1rem' />,
if: (user) => user?.administrator || false,
active: (path: string) => path.startsWith('/dashboard/admin'),
links: [
{
label: 'Users',
icon: <IconUsersGroup size='1rem' />,
active: (path: string) => path === '/dashboard/admin/users',
href: '/dashboard/admin/users',
description: 'View all users',
},
],
},
];
export default function Layout({ children }: { children: React.ReactNode }) {
const theme = useMantineTheme();
const [opened, setOpened] = useState(false);
const router = useRouter();
const { user, token } = useLogin();
return (
<AppShell
styles={{
main: {
background: theme.colorScheme === 'dark' ? theme.colors.dark[8] : theme.colors.gray[0],
},
}}
navbarOffsetBreakpoint='sm'
asideOffsetBreakpoint='sm'
navbar={
<Navbar hiddenBreakpoint='sm' hidden={!opened} width={{ sm: 200, lg: 300 }}>
{navLinks
.filter((link) => !link.if || link.if(user as Response['/api/user']['user']))
.map((link) => {
if (!link.links) {
return (
<NavLink
key={link.label}
label={link.label}
icon={link.icon}
variant='light'
rightSection={<IconChevronRight size='0.7rem' />}
active={router.pathname === link.href}
component={Link}
href={link.href || ''}
description={link.description}
/>
);
} else {
return (
<NavLink
key={link.label}
label={link.label}
icon={link.icon}
variant='light'
rightSection={<IconChevronRight size='0.7rem' />}
active={router.pathname === link.href}
description={link.description}
defaultOpened={link.active(router.pathname)}
>
{link.links.map((sublink) => (
<NavLink
key={sublink.label}
label={sublink.label}
icon={sublink.icon}
rightSection={<IconChevronRight size='0.7rem' />}
variant='light'
active={router.pathname === link.href}
component={Link}
href={sublink.href || ''}
description={sublink.description}
/>
))}
</NavLink>
);
}
})}
</Navbar>
}
header={
<Header height={{ base: 50, md: 70 }} p='md'>
<div style={{ display: 'flex', alignItems: 'center', height: '100%' }}>
<MediaQuery largerThan='sm' styles={{ display: 'none' }}>
<Burger
opened={opened}
onClick={() => setOpened((o) => !o)}
size='sm'
color={theme.colors.gray[6]}
mr='xl'
/>
</MediaQuery>
<Text size={30} weight={700}>
Zipline
</Text>
</div>
</Header>
}
>
<Paper m={2} withBorder p={'xs'}>
{children}
</Paper>
</AppShell>
);
}

25
src/lib/api/response.ts Normal file
View File

@@ -0,0 +1,25 @@
import type { ApiLoginResponse } from '@/pages/api/auth/login';
import type { ApiLogoutResponse } from '@/pages/api/auth/logout';
import type { ApiUserResponse } from '@/pages/api/user';
import type { ApiUserRecentResponse } from '@/pages/api/user/recent';
import type { ApiUserTokenResponse } from '@/pages/api/user/token';
import type { ApiUserFilesIdResponse } from '@/pages/api/user/files/[id]';
import type { ApiUserFilesResponse } from '@/pages/api/user/files';
import type { ApiHealthcheckResponse } from '@/pages/api/healthcheck';
import type { ApiSetupResponse } from '@/pages/api/setup';
import type { ApiUploadResponse } from '@/pages/api/upload';
export type Response = {
'/api/auth/login': ApiLoginResponse;
'/api/auth/logout': ApiLogoutResponse;
'/api/user/files/[id]': ApiUserFilesIdResponse;
'/api/user/files': ApiUserFilesResponse;
'/api/user': ApiUserResponse;
'/api/user/recent': ApiUserRecentResponse;
'/api/user/token': ApiUserTokenResponse;
'/api/healthcheck': ApiHealthcheckResponse;
'/api/setup': ApiSetupResponse;
'/api/upload': ApiUploadResponse;
};

56
src/lib/db/models/file.ts Normal file
View File

@@ -0,0 +1,56 @@
import { Prisma } from '@prisma/client';
import { prisma } from '..';
import { formatRootUrl } from '@/lib/url';
import { config } from '@/lib/config';
export type File = {
createdAt: Date;
updatedAt: Date;
deletesAt: Date | null;
favorite: boolean;
id: string;
originalName: string;
name: string;
path: string;
size: number;
type: string;
views: number;
zeroWidthSpace: string | null;
password?: string | boolean | null;
url?: string;
};
export const fileSelect = {
createdAt: true,
updatedAt: true,
deletesAt: true,
favorite: true,
id: true,
originalName: true,
name: true,
path: true,
size: true,
type: true,
views: true,
zeroWidthSpace: true,
};
export function cleanFile(file: File) {
file.password = !!file.password;
file.url = formatRootUrl(config.files.route, file.name);
return file;
}
export function cleanFiles(files: File[]) {
for (let i = 0; i !== files.length; ++i) {
const file = files[i];
file.password = !!file.password;
file.url = formatRootUrl(config.files.route, file.name);
}
return files;
}

20
src/lib/db/models/user.ts Normal file
View File

@@ -0,0 +1,20 @@
import { Prisma } from '@prisma/client';
export type User = {
id: string;
username: string;
createdAt: Date;
updatedAt: Date;
administrator: boolean;
avatar?: string | null;
password?: string | null;
token?: string | null;
};
export const userSelect = {
id: true,
username: true,
createdAt: true,
updatedAt: true,
administrator: true,
};

View File

@@ -1,65 +0,0 @@
import { Prisma } from '@prisma/client';
import { prisma } from '..';
export type File = {
createdAt: Date;
updatedAt: Date;
deletesAt: Date | null;
favorite: boolean;
id: string;
originalName: string;
name: string;
path: string;
size: number;
type: string;
views: number;
zeroWidthSpace: string | null;
password?: string | null;
};
export type FileSelectOptions = { password?: boolean };
export async function getFile(
where: Prisma.FileWhereInput | Prisma.FileWhereUniqueInput,
options?: FileSelectOptions
): Promise<File | null> {
return prisma.file.findFirst({
where,
select: {
createdAt: true,
updatedAt: true,
deletesAt: true,
favorite: true,
id: true,
originalName: true,
name: true,
path: true,
size: true,
type: true,
views: true,
zeroWidthSpace: true,
password: options?.password || false,
},
});
}
export async function createFile(data: Prisma.FileCreateInput, options?: FileSelectOptions): Promise<File> {
return prisma.file.create({
data,
select: {
createdAt: true,
updatedAt: true,
deletesAt: true,
favorite: true,
id: true,
originalName: true,
name: true,
path: true,
size: true,
type: true,
views: true,
zeroWidthSpace: true,
password: options?.password || false,
},
});
}

View File

@@ -1,55 +0,0 @@
import { Prisma } from '@prisma/client';
import { prisma } from '..';
export type User = {
id: string;
username: string;
createdAt: Date;
updatedAt: Date;
administrator: boolean;
avatar?: string | null;
password?: string | null;
token?: string | null;
};
export type UserSelectOptions = { password?: boolean; avatar?: boolean; token?: boolean };
export async function getUser(
where: Prisma.UserWhereInput | Prisma.UserWhereUniqueInput,
options?: UserSelectOptions
): Promise<User | null> {
return prisma.user.findFirst({
where,
select: {
administrator: true,
avatar: options?.avatar || false,
id: true,
createdAt: true,
updatedAt: true,
password: options?.password || false,
username: true,
token: options?.token || false,
},
});
}
export async function updateUser(
where: Prisma.UserWhereUniqueInput,
data: Prisma.UserUpdateInput,
options?: UserSelectOptions
): Promise<User> {
return prisma.user.update({
where,
data,
select: {
administrator: true,
avatar: options?.avatar || false,
id: true,
createdAt: true,
updatedAt: true,
password: options?.password || false,
username: true,
token: options?.token || false,
},
});
}

32
src/lib/fetchApi.ts Normal file
View File

@@ -0,0 +1,32 @@
import { useEffect, useState } from 'react';
import { ErrorBody } from './response';
export async function fetchApi<Response = any, Error = string>(
route: string,
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' = 'GET',
body: any = null
): Promise<{
data: Response | null;
error: ErrorBody | null;
}> {
let data: Response | null = null;
let error: ErrorBody | null = null;
const res = await fetch(route, {
method,
headers: {
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : null,
});
if (res.ok) {
data = await res.json();
} else {
if (res.headers.get('Content-Type')?.startsWith('application/json')) {
error = await res.json();
}
}
return { data, error };
}

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

@@ -0,0 +1,24 @@
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import useSWR from 'swr';
import type { Response } from '../api/response';
import { useUserStore } from '../store/user';
export default function useLogin() {
const router = useRouter();
const { data, error, isLoading, mutate } = useSWR<Response['/api/user']>('/api/user');
const [user, setUser] = useUserStore((state) => [state.user, state.setUser]);
const [token, setToken] = useUserStore((state) => [state.token, state.setToken]);
useEffect(() => {
if (data?.user && data?.token) {
setUser(data.user);
setToken(data.token);
} else if (error) {
router.push('/auth/login');
}
}, [data, error]);
return { user, token, loading: isLoading || !user, mutate };
}

View File

@@ -1,12 +1,14 @@
import { Prisma } from '@prisma/client';
import { config } from '../config';
import { decryptToken } from '../crypto';
import { User, UserSelectOptions, getUser } from '../db/queries/user';
import { prisma } from '../db';
import { User, userSelect } from '../db/models/user';
import { NextApiReq, NextApiRes } from '../response';
import { Handler } from './combine';
export type ZiplineAuthOptions = {
administratorOnly?: boolean;
getOptions?: UserSelectOptions;
select?: Prisma.UserSelect;
};
declare module 'next' {
@@ -31,7 +33,15 @@ export function ziplineAuth(options?: ZiplineAuthOptions) {
const [date, token] = decryptedToken;
if (isNaN(new Date(date).getTime())) return res.unauthorized('could not decrypt token date');
const user = await getUser({ token }, options?.getOptions || {});
const user = await prisma.user.findFirst({
where: {
token,
},
select: {
...userSelect,
...(options?.select && options.select),
},
});
if (!user) return res.unauthorized();
req.user = user;

View File

@@ -1,5 +1,5 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { User } from './db/queries/user';
import { User } from './db/models/user';
import { Readable } from 'stream';
export interface File {

17
src/lib/store/user.ts Normal file
View File

@@ -0,0 +1,17 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { User } from '../db/models/user';
type UserStore = {
user: User | null;
token: string | null;
setUser: (user?: User | null) => void;
setToken: (token?: string | null) => void;
};
export const useUserStore = create<UserStore>()((set) => ({
user: null,
token: null,
setUser: (user) => set({ user }),
setToken: (token) => set({ token }),
}));

3
src/lib/url.ts Normal file
View File

@@ -0,0 +1,3 @@
export function formatRootUrl(route: string, src: string) {
return `${route === '/' || route === '' ? '' : route}${encodeURI(src)}`
}

View File

@@ -1,6 +1,19 @@
import { AppProps } from 'next/app';
import Head from 'next/head';
import { MantineProvider } from '@mantine/core';
import { SWRConfig } from 'swr';
const fetcher = async (url: RequestInfo | URL) => {
const res = await fetch(url);
if (!res.ok) {
const json = await res.json();
throw new Error(json.message);
}
return res.json();
}
export default function App(props: AppProps) {
const { Component, pageProps } = props;
@@ -12,15 +25,21 @@ export default function App(props: AppProps) {
<meta name='viewport' content='minimum-scale=1, initial-scale=1, width=device-width' />
</Head>
<MantineProvider
withGlobalStyles
withNormalizeCSS
theme={{
colorScheme: 'dark',
<SWRConfig
value={{
fetcher
}}
>
<Component {...pageProps} />
</MantineProvider>
<MantineProvider
withGlobalStyles
withNormalizeCSS
theme={{
colorScheme: 'dark',
}}
>
<Component {...pageProps} />
</MantineProvider>
</SWRConfig>
</>
);
}

View File

@@ -1,13 +1,14 @@
import { config } from '@/lib/config';
import { serializeCookie } from '@/lib/cookie';
import { encryptToken, verifyPassword } from '@/lib/crypto';
import { User, getUser } from '@/lib/db/queries/user';
import { prisma } from '@/lib/db';
import { User, userSelect } from '@/lib/db/models/user';
import { combine } from '@/lib/middleware/combine';
import { cors } from '@/lib/middleware/cors';
import { method } from '@/lib/middleware/method';
import { NextApiReq, NextApiRes } from '@/lib/response';
type Data = {
export type ApiLoginResponse = {
user: User;
token: string;
};
@@ -17,18 +18,27 @@ type Body = {
password: string;
};
async function handler(req: NextApiReq<Body>, res: NextApiRes<Data>) {
async function handler(req: NextApiReq<Body>, res: NextApiRes<ApiLoginResponse>) {
const { username, password } = req.body;
if (!username) return res.badRequest('Username is required');
if (!password) return res.badRequest('Password is required');
const user = await getUser({ username }, { password: true, token: true });
if (!user) return res.badRequest('Invalid username');
const user = await prisma.user.findUnique({
where: {
username,
},
select: {
...userSelect,
password: true,
token: true,
},
});
if (!user) return res.badRequest('Invalid username', { username: true });
if (!user.password) return res.badRequest('User does not have a password, login through a provider');
const valid = await verifyPassword(password, user.password);
if (!valid) return res.badRequest('Invalid password');
if (!valid) return res.badRequest('Invalid password', { password: true });
const token = encryptToken(user.token!, config.core.secret);
@@ -42,8 +52,8 @@ async function handler(req: NextApiReq<Body>, res: NextApiRes<Data>) {
res.setHeader('Set-Cookie', cookie);
delete user.token;
delete user.password;
delete (user as any).token;
delete (user as any).password;
return res.ok({
token,

View File

@@ -1,17 +1,16 @@
import { combine } from '@/lib/middleware/combine';
import { cors } from '@/lib/middleware/cors';
import { method } from '@/lib/middleware/method';
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
import { NextApiReq, NextApiRes } from '@/lib/response';
type Data = {
export type ApiLogoutResponse = {
loggedOut?: boolean;
};
async function handler(req: NextApiReq, res: NextApiRes<Data>) {
async function handler(_: NextApiReq, res: NextApiRes<ApiLogoutResponse>) {
res.setHeader('Set-Cookie', `zipline_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT`);
return res.ok({ loggedOut: true });
}
export default combine([cors(), method(['POST']), ziplineAuth()], handler);
export default combine([method(['GET']), ziplineAuth()], handler);

View File

@@ -1,15 +1,14 @@
import { prisma } from '@/lib/db';
import { log } from '@/lib/logger';
import { combine } from '@/lib/middleware/combine';
import { cors } from '@/lib/middleware/cors';
import { method } from '@/lib/middleware/method';
import { NextApiReq, NextApiRes } from '@/lib/response';
type Data = {
export type ApiHealthcheckResponse = {
pass: boolean;
};
export async function handler(req: NextApiReq, res: NextApiRes<Data>) {
export async function handler(req: NextApiReq, res: NextApiRes<ApiHealthcheckResponse>) {
const logger = log('api').c('healthcheck');
try {
@@ -25,4 +24,4 @@ export async function handler(req: NextApiReq, res: NextApiRes<Data>) {
}
}
export default combine([cors(), method(['GET'])], handler);
export default combine([method(['GET'])], handler);

View File

@@ -1,14 +1,13 @@
import { createToken, hashPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { User } from '@/lib/db/queries/user';
import { getZipline } from '@/lib/db/queries/zipline';
import { User } from '@/lib/db/models/user';
import { getZipline } from '@/lib/db/models/zipline';
import { log } from '@/lib/logger';
import { combine } from '@/lib/middleware/combine';
import { cors } from '@/lib/middleware/cors';
import { method } from '@/lib/middleware/method';
import { NextApiReq, NextApiRes } from '@/lib/response';
type Response = {
export type ApiSetupResponse = {
firstSetup?: boolean;
user?: User;
};
@@ -18,7 +17,7 @@ type Body = {
password: string;
};
export async function handler(req: NextApiReq<Body>, res: NextApiRes<Response>) {
export async function handler(req: NextApiReq<Body>, res: NextApiRes<ApiSetupResponse>) {
const logger = log('api').c('setup');
const { firstSetup, id } = await getZipline();

View File

@@ -1,10 +1,9 @@
import { config as zconfig } from '@/lib/config';
import { hashPassword } from '@/lib/crypto';
import { datasource } from '@/lib/datasource';
import { createFile, getFile } from '@/lib/db/queries/file';
import { prisma } from '@/lib/db';
import { log } from '@/lib/logger';
import { combine } from '@/lib/middleware/combine';
import { cors } from '@/lib/middleware/cors';
import { file } from '@/lib/middleware/file';
import { method } from '@/lib/middleware/method';
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
@@ -15,7 +14,7 @@ import { UploadHeaders, parseHeaders } from '@/lib/uploader/parseHeaders';
import bytes from 'bytes';
import { extname, parse } from 'path';
type Data = {
export type ApiUploadResponse = {
files: {
id: string;
type: string;
@@ -28,14 +27,14 @@ type Data = {
const logger = log('api').c('upload');
export async function handler(req: NextApiReq<any, any, UploadHeaders>, res: NextApiRes<Data>) {
export async function handler(req: NextApiReq<any, any, UploadHeaders>, res: NextApiRes<ApiUploadResponse>) {
if (!req.files || !req.files.length) return res.badRequest('No files received');
const options = parseHeaders(req.headers, zconfig.files);
if (options.header) return res.badRequest('', options);
const response: Data = {
const response: ApiUploadResponse = {
files: [],
...(options.deletesAt && { deletesAt: options.deletesAt.toISOString() }),
...(zconfig.files.assumeMimetypes && { assumedMimetypes: Array(req.files.length) }),
@@ -64,9 +63,11 @@ export async function handler(req: NextApiReq<any, any, UploadHeaders>, res: Nex
if (options.overrides?.filename) {
fileName = options.overrides!.filename!;
const existing = await getFile({
name: {
startsWith: fileName,
const existing = await prisma.file.findFirst({
where: {
name: {
startsWith: fileName,
},
},
});
if (existing) return res.badRequest(`A file with the name "${fileName}*" already exists`);
@@ -84,16 +85,29 @@ export async function handler(req: NextApiReq<any, any, UploadHeaders>, res: Nex
}
}
const fileUpload = await createFile({
name: `${fileName}${extension}`,
path: `${fileName}${extension}`,
originalName: file.originalname,
size: file.size,
type: mimetype,
...(options.maxViews && { maxViews: options.maxViews }),
...(options.password && { password: await hashPassword(options.password) }),
...(options.deletesAt && { deletesAt: options.deletesAt }),
...(options.folder && { Folder: { connect: { id: options.folder } } }),
const fileUpload = await prisma.file.create({
data: {
name: `${fileName}${extension}`,
path: `${fileName}${extension}`,
originalName: file.originalname,
size: file.size,
type: mimetype,
User: {
connect: {
id: req.user.id,
},
},
...(options.maxViews && { maxViews: options.maxViews }),
...(options.password && { password: await hashPassword(options.password) }),
...(options.deletesAt && { deletesAt: options.deletesAt }),
...(options.folder && { Folder: { connect: { id: options.folder } } }),
},
select: {
name: true,
id: true,
type: true,
size: true,
},
});
// TODO: remove gps
@@ -115,12 +129,16 @@ export async function handler(req: NextApiReq<any, any, UploadHeaders>, res: Nex
});
}
if (options.noJson) return res.status(200).end(response.files.map((x) => x.url).join(','));
if (options.noJson)
return res
.status(200)
.setHeader('content-type', 'text/plain')
.end(response.files.map((x) => x.url).join(','));
return res.ok(response);
}
export default combine([cors(), method(['POST']), ziplineAuth(), file()], handler);
export default combine([method(['POST']), ziplineAuth(), file()], handler);
export const config = {
api: {

View File

@@ -0,0 +1,64 @@
import { datasource } from '@/lib/datasource';
import { prisma } from '@/lib/db';
import { File, fileSelect } from '@/lib/db/models/file';
import { log } from '@/lib/logger';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
import { NextApiReq, NextApiRes } from '@/lib/response';
import bytes from 'bytes';
export type ApiUserFilesIdResponse = File;
type Body = {
favorite?: boolean;
};
type Query = {
id: string;
};
const logger = log('api').c('user').c('files').c('id');
export async function handler(req: NextApiReq<Body, Query>, res: NextApiRes<ApiUserFilesIdResponse>) {
const file = await prisma.file.findFirst({
where: {
OR: [{ id: req.query.id }, { name: req.query.id }],
},
select: fileSelect,
});
if (!file) return res.notFound();
if (req.method === 'PATCH') {
const newFile = await prisma.file.update({
where: {
id: req.query.id,
},
data: {
...(req.body.favorite && { favorite: req.body.favorite }),
},
select: fileSelect,
});
logger.info(`${req.user.username} updated file ${newFile.name} (favorite=${newFile.favorite})`);
return res.ok(newFile);
} else if (req.method === 'DELETE') {
const deletedFile = await prisma.file.delete({
where: {
id: req.query.id,
},
select: fileSelect,
});
await datasource.delete(deletedFile.name);
logger.info(`${req.user.username} deleted file ${deletedFile.name} (size=${bytes(deletedFile.size)})`);
return res.ok(deletedFile);
}
return res.ok(file);
}
export default combine([method(['GET', 'PATCH', 'DELETE']), ziplineAuth()], handler);

View File

@@ -0,0 +1,86 @@
import { prisma } from '@/lib/db';
import { File, cleanFiles, fileSelect } from '@/lib/db/models/file';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
import { NextApiReq, NextApiRes } from '@/lib/response';
export type ApiUserFilesResponse =
| File[]
| {
count: number;
}
| {
totalCount: number;
};
type Query = {
page?: string;
pagecount?: string;
totalcount?: string;
filter?: 'dashboard' | 'none';
};
const PAGE_COUNT = 9;
export async function handler(req: NextApiReq<any, Query>, res: NextApiRes<ApiUserFilesResponse>) {
if (req.query.pagecount) {
const count = await prisma.file.count({
where: {
userId: req.user.id,
},
});
return res.ok({ count: Math.ceil(count / PAGE_COUNT) });
}
if (req.query.totalcount) {
const totalCount = await prisma.file.count({
where: {
userId: req.user.id,
},
});
return res.ok({ totalCount });
}
const { page, filter } = req.query;
if (!page) return res.badRequest('Page is required');
if (isNaN(Number(page))) return res.badRequest('Page must be a number');
const files = cleanFiles(
await prisma.file.findMany({
where: {
userId: req.user.id,
...(filter === 'dashboard' && {
OR: [
{
type: { startsWith: 'image/' },
},
{
type: { startsWith: 'video/' },
},
{
type: { startsWith: 'audio/' },
},
{
type: { startsWith: 'text/' },
},
],
}),
},
select: {
...fileSelect,
},
orderBy: {
createdAt: 'desc',
},
skip: (Number(page) - 1) * PAGE_COUNT,
take: PAGE_COUNT,
})
);
return res.ok(files);
}
export default combine([method(['GET', 'PATCH']), ziplineAuth()], handler);

View File

@@ -1,13 +1,12 @@
import { hashPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { User } from '@/lib/db/queries/user';
import { User, userSelect } from '@/lib/db/models/user';
import { combine } from '@/lib/middleware/combine';
import { cors } from '@/lib/middleware/cors';
import { method } from '@/lib/middleware/method';
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
import { NextApiReq, NextApiRes } from '@/lib/response';
type Response = {
export type ApiUserResponse = {
user?: User;
token?: string;
};
@@ -18,11 +17,11 @@ type EditBody = {
avatar?: string;
};
export async function handler(req: NextApiReq<EditBody>, res: NextApiRes<Response>) {
export async function handler(req: NextApiReq<EditBody>, res: NextApiRes<ApiUserResponse>) {
if (req.method === 'GET') {
return res.ok({ user: req.user, token: req.cookies.zipline_token });
} else if (req.method === 'PATCH') {
await prisma.user.update({
const user = await prisma.user.update({
where: {
id: req.user.id,
},
@@ -31,8 +30,13 @@ export async function handler(req: NextApiReq<EditBody>, res: NextApiRes<Respons
...(req.body.password && { password: await hashPassword(req.body.password) }),
...(req.body.avatar && { avatar: req.body.avatar }),
},
select: {
...userSelect,
},
});
return res.ok({ user, token: req.cookies.zipline_token });
}
}
export default combine([cors(), method(['GET', 'PATCH']), ziplineAuth()], handler);
export default combine([method(['GET', 'PATCH']), ziplineAuth()], handler);

View File

@@ -0,0 +1,39 @@
import { prisma } from '@/lib/db';
import { File, cleanFiles, fileSelect } from '@/lib/db/models/file';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
import { NextApiReq, NextApiRes } from '@/lib/response';
export type ApiUserRecentResponse =
| File[]
| {
count: number;
};
type Query = {
page?: string;
pagecount?: string;
};
export async function handler(req: NextApiReq<any, Query>, res: NextApiRes<ApiUserRecentResponse>) {
const files = cleanFiles(
await prisma.file.findMany({
where: {
userId: req.user.id,
},
select: {
...fileSelect,
password: true,
},
orderBy: {
createdAt: 'desc',
},
take: 4,
})
);
return res.ok(files);
}
export default combine([method(['GET', 'PATCH']), ziplineAuth()], handler);

View File

@@ -1,32 +1,50 @@
import { config } from '@/lib/config';
import { serializeCookie } from '@/lib/cookie';
import { createToken, encryptToken } from '@/lib/crypto';
import { User, updateUser } from '@/lib/db/queries/user';
import { prisma } from '@/lib/db';
import { User, userSelect } from '@/lib/db/models/user';
import { combine } from '@/lib/middleware/combine';
import { cors } from '@/lib/middleware/cors';
import { method } from '@/lib/middleware/method';
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
import { NextApiReq, NextApiRes } from '@/lib/response';
type Response = {
export type ApiUserTokenResponse = {
user?: User;
token?: string;
};
export async function handler(req: NextApiReq, res: NextApiRes<Response>) {
const user = await updateUser(
{
export async function handler(req: NextApiReq, res: NextApiRes<ApiUserTokenResponse>) {
if (req.method === 'GET') {
const user = await prisma.user.findUnique({
where: {
id: req.user.id,
},
select: {
token: true,
},
});
const token = encryptToken(user!.token, config.core.secret);
return res.ok({
token,
});
}
const user = await prisma.user.update({
where: {
id: req.user.id,
},
{
data: {
token: createToken(),
},
{
select: {
...userSelect,
token: true,
}
);
},
});
const token = encryptToken(user.token!, config.core.secret);
const token = encryptToken(user.token, config.core.secret);
const cookie = serializeCookie('zipline_token', token, {
// week
@@ -37,7 +55,7 @@ export async function handler(req: NextApiReq, res: NextApiRes<Response>) {
});
res.setHeader('Set-Cookie', cookie);
delete user.token;
delete (user as any).token;
return res.ok({
user,
@@ -45,4 +63,4 @@ export async function handler(req: NextApiReq, res: NextApiRes<Response>) {
});
}
export default combine([cors(), method(['PATCH']), ziplineAuth()], handler);
export default combine([method(['GET', 'PATCH']), ziplineAuth()], handler);

View File

@@ -1,13 +1,129 @@
import { Box, Center, Text, TextInput, Title } from '@mantine/core';
import {
Box,
Button,
Center,
Group,
LoadingOverlay,
PasswordInput,
Stack,
Text,
TextInput,
Title,
} from '@mantine/core';
import useSWR from 'swr';
import useSWRImmutable from 'swr/immutable';
import { Response } from '@/lib/api/response';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import {
IconBrandGithubFilled,
IconBrandGoogle,
IconBrandDiscordFilled,
IconCircleKeyFilled,
Icon as TIcon,
} from '@tabler/icons-react';
import { hasLength, useForm } from '@mantine/form';
import { fetchApi } from '@/lib/fetchApi';
export default function Login() {
function IconText({ Icon, text }: { Icon: TIcon; text: string }) {
return (
<Box py='xl'>
<Center>
<Title order={1}>
<b>Zipline</b>
</Title>
</Center>
</Box>
<Group spacing='xs' align='center'>
<Icon />
<Text>{text}</Text>
</Group>
);
}
export default function Login() {
const router = useRouter();
const { data, isLoading, mutate } = useSWR<Response['/api/user']>('/api/user');
useEffect(() => {
if (data?.user) {
router.push('/dashboard');
}
}, [data]);
const form = useForm({
initialValues: {
username: '',
password: '',
},
validate: {
username: hasLength({ min: 1 }, 'Username is required'),
password: hasLength({ min: 1 }, 'Password is required'),
},
});
const onSubmit = async (values: typeof form.values) => {
const { username, password } = values;
const { data, error } = await fetchApi<Response['/api/auth/login']>('/api/auth/login', 'POST', {
username,
password,
});
if (error) {
if (error.username) form.setFieldError('username', 'Invalid username');
else if (error.password) form.setFieldError('password', 'Invalid password');
} else {
mutate(data as Response['/api/user']);
}
};
return (
<>
<LoadingOverlay visible={isLoading} />
<Center h='100vh'>
<div>
<Title order={1} size={50} align='center'>
<b>Zipline</b>
</Title>
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack my='sm'>
<TextInput
size='lg'
placeholder='Enter your username...'
{...form.getInputProps('username', { withError: true })}
/>
<PasswordInput
size='lg'
placeholder='Enter your password...'
{...form.getInputProps('password')}
/>
<Button size='lg' fullWidth type='submit'>
Login
</Button>
</Stack>
</form>
<Text size='sm' align='center' color='dimmed'>
OR
</Text>
<Stack my='xs'>
<Button size='lg' fullWidth variant='outline'>
Sign up
</Button>
<Button size='lg' fullWidth variant='outline'>
<IconText Icon={IconBrandGithubFilled} text='Sign in with GitHub' />
</Button>
<Button size='lg' fullWidth variant='outline'>
<IconText Icon={IconBrandGoogle} text='Sign in with Google' />
</Button>
<Button size='lg' fullWidth variant='outline'>
<IconText Icon={IconBrandDiscordFilled} text='Sign in with Discord' />
</Button>
<Button size='lg' fullWidth variant='outline'>
<IconText Icon={IconCircleKeyFilled} text='Sign in with Authentik' />
</Button>
</Stack>
</div>
</Center>
</>
);
}

45
src/pages/auth/logout.tsx Normal file
View File

@@ -0,0 +1,45 @@
import { Response } from '@/lib/api/response';
import { fetchApi } from '@/lib/fetchApi';
import { useUserStore } from '@/lib/store/user';
import { Group, LoadingOverlay, Text } from '@mantine/core';
import { Icon as TIcon } from '@tabler/icons-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { mutate } from 'swr';
function IconText({ Icon, text }: { Icon: TIcon; text: string }) {
return (
<Group spacing='xs' align='center'>
<Icon />
<Text>{text}</Text>
</Group>
);
}
export default function Login() {
const router = useRouter();
const [setUser, setToken] = useUserStore((state) => [state.setUser, state.setToken]);
useEffect(() => {
(async () => {
const userRes = await fetch('/api/user');
if (userRes.ok) {
const res = await fetch('/api/auth/logout');
if (res.ok) {
setUser(null);
setToken(null);
mutate('/api/user', null);
}
} else {
await router.push('/auth/login');
}
})();
});
return (
<>
<LoadingOverlay visible />
</>
);
}

View File

@@ -0,0 +1,27 @@
import Layout from '@/components/Layout';
import { Response } from '@/lib/api/response';
import useLogin from '@/lib/hooks/useLogin';
import { LoadingOverlay, Text, Title } from '@mantine/core';
import useSWR from 'swr';
export default function DashboardIndex() {
const { user, loading } = useLogin();
const { data: recent, isLoading: recentLoading } = useSWR<Response['/api/user/recent']>('/api/user/recent');
const { data: total, isLoading: totalLoading } = useSWR<
Extract<Response['/api/user/files'], { totalCount: number }>
>('/api/user/files?totalcount=true');
if (loading) return <LoadingOverlay visible />;
return (
<Layout>
<Title order={1}>
Welcome back, <b>{user?.username}</b>
</Title>
<Text size='sm' color='dimmed'>
You have <b>{totalLoading ? '...' : total?.totalCount}</b> files uploaded.
</Text>
</Layout>
);
}

View File

@@ -4252,22 +4252,22 @@ __metadata:
languageName: node
linkType: hard
"@tabler/icons-react@npm:^2.22.0":
version: 2.22.0
resolution: "@tabler/icons-react@npm:2.22.0"
"@tabler/icons-react@npm:^2.23.0":
version: 2.23.0
resolution: "@tabler/icons-react@npm:2.23.0"
dependencies:
"@tabler/icons": 2.22.0
"@tabler/icons": 2.23.0
prop-types: ^15.7.2
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0
checksum: fe2a4c3e5483269ee178195746cdc4b8c1e150dec78aae4bbf3884f33dd819929a7ff1e53eb6ba1426b914ba710e13a30d0e70caf9e588b8df45e500f70941dd
checksum: 9e40f615e20cd2a4f7a9f079fb076c8b53af7420b59b9cd80abc470c2cc39440012c0ede1779b5bca67a2f587d475571fd0c9b228e7e0071e069e90ccddc1828
languageName: node
linkType: hard
"@tabler/icons@npm:2.22.0":
version: 2.22.0
resolution: "@tabler/icons@npm:2.22.0"
checksum: 3f0aaa801e8739d841ac5d335fbaee41399aaa9eeae03eb21c1dbe77877a29ba3664f16c225323a0635fad6548aca40212220edfb2919797454ea1029c5a5b78
"@tabler/icons@npm:2.23.0":
version: 2.23.0
resolution: "@tabler/icons@npm:2.23.0"
checksum: aceeebf5ea526e7cc00b128ae018f8501d78e4f5c91b4e0a1d4d5f2b6b9c67457f892b7f054e4f7f074b1189b4ea515455d130b8cf274265b5a12b67e1ee4d92
languageName: node
linkType: hard
@@ -13694,6 +13694,17 @@ __metadata:
languageName: node
linkType: hard
"swr@npm:^2.2.0":
version: 2.2.0
resolution: "swr@npm:2.2.0"
dependencies:
use-sync-external-store: ^1.2.0
peerDependencies:
react: ^16.11.0 || ^17.0.0 || ^18.0.0
checksum: 1f04795ff9dff54987cbf8a544afc9e42d1350a99e899be8a7cdc4885f561e3ee464f78245ee2ebc8ced262b04023d134f731e276227319dc2d6e1843389ddd8
languageName: node
linkType: hard
"synckit@npm:^0.8.5":
version: 0.8.5
resolution: "synckit@npm:0.8.5"
@@ -14491,6 +14502,15 @@ __metadata:
languageName: node
linkType: hard
"use-sync-external-store@npm:1.2.0, use-sync-external-store@npm:^1.2.0":
version: 1.2.0
resolution: "use-sync-external-store@npm:1.2.0"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
checksum: 5c639e0f8da3521d605f59ce5be9e094ca772bd44a4ce7322b055a6f58eeed8dda3c94cabd90c7a41fb6fa852210092008afe48f7038792fd47501f33299116a
languageName: node
linkType: hard
"util-deprecate@npm:^1.0.1, util-deprecate@npm:^1.0.2, util-deprecate@npm:~1.0.1":
version: 1.0.2
resolution: "util-deprecate@npm:1.0.2"
@@ -14977,7 +14997,7 @@ __metadata:
"@prisma/migrate": ^4.16.1
"@remix-run/dev": ^1.16.1
"@remix-run/eslint-config": ^1.16.1
"@tabler/icons-react": ^2.22.0
"@tabler/icons-react": ^2.23.0
"@types/bytes": ^3.1.1
"@types/express": ^4.17.17
"@types/multer": ^1.4.7
@@ -15000,10 +15020,12 @@ __metadata:
prisma: ^4.16.1
react: ^18.2.0
react-dom: ^18.2.0
swr: ^2.2.0
tsup: ^7.0.0
typescript: ^5.1.3
znv: ^0.3.2
zod: ^3.21.4
zustand: ^4.3.8
languageName: unknown
linkType: soft
@@ -15025,6 +15047,23 @@ __metadata:
languageName: node
linkType: hard
"zustand@npm:^4.3.8":
version: 4.3.8
resolution: "zustand@npm:4.3.8"
dependencies:
use-sync-external-store: 1.2.0
peerDependencies:
immer: ">=9.0"
react: ">=16.8"
peerDependenciesMeta:
immer:
optional: true
react:
optional: true
checksum: 24db6bf063ce1fc8b2ee238f13211a88f43236541a716e5f6f706f613c671a45332465f9ed06d694f8c353da3d24c53ea668e5712a86aceda9ad74f6c433e8c0
languageName: node
linkType: hard
"zwitch@npm:^2.0.0":
version: 2.0.4
resolution: "zwitch@npm:2.0.4"