1
Fork 0
mirror of https://github.com/diced/zipline.git synced 2025-04-11 23:31:17 -05:00

Merge branch 'trunk' into trunk

This commit is contained in:
nobody 2025-02-22 16:48:56 +01:00 committed by GitHub
commit 00efc1a7e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 2789 additions and 2961 deletions

View file

@ -33,7 +33,7 @@ jobs:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
@ -52,7 +52,6 @@ jobs:
${{ secrets.DOCKERHUB_USERNAME }}/zipline:${{ steps.version.outputs.zipline_version }}-${{ matrix.arch }}
${{ secrets.DOCKERHUB_USERNAME }}/zipline:${{ steps.version.outputs.zipline_version }}-${{ steps.sha.outputs.short_sha }}-${{ matrix.arch }}
amend-builds:
runs-on: ubuntu-24.04
needs: push
@ -103,7 +102,6 @@ jobs:
--amend ${{ secrets.DOCKERHUB_USERNAME }}/zipline:${{ steps.version.outputs.zipline_version }}-amd64 \
--amend ${{ secrets.DOCKERHUB_USERNAME }}/zipline:${{ steps.version.outputs.zipline_version }}-arm64
- name: push manifests
run: |
docker manifest push ghcr.io/diced/zipline:${{ steps.version.outputs.zipline_version }}

View file

@ -24,7 +24,6 @@ services:
- .env
environment:
- DATABASE_URL=postgres://postgres:postgres@postgres/postgres2
- CORE_SECRET=thissecretisverynotsecretbutshouldonlybeusedindevelopmentsoitsfineiguess
- CORE_HOSTNAME=0.0.0.0
depends_on:
- postgres

View file

@ -17,7 +17,8 @@ services:
retries: 5
zipline:
image: ghcr.io/diced/zipline:v4
image: ghcr.io/diced/zipline:latest
restart: unless-stopped
ports:
- '3000:3000'
env_file:

View file

@ -20,90 +20,90 @@
"db:migrate": "prisma migrate dev --create-only"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.717.0",
"@fastify/cookie": "^9.4.0",
"@fastify/cors": "^9.0.1",
"@fastify/multipart": "^8.3.0",
"@fastify/rate-limit": "^9.1.0",
"@fastify/sensible": "^5.6.0",
"@fastify/static": "^7.0.4",
"@aws-sdk/client-s3": "^3.750.0",
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^10.0.2",
"@fastify/multipart": "^9.0.3",
"@fastify/rate-limit": "^10.2.2",
"@fastify/sensible": "^6.0.3",
"@fastify/static": "^8.1.1",
"@github/webauthn-json": "^2.1.1",
"@mantine/charts": "^7.15.1",
"@mantine/code-highlight": "^7.15.1",
"@mantine/core": "^7.15.1",
"@mantine/dates": "^7.15.1",
"@mantine/dropzone": "^7.15.1",
"@mantine/form": "^7.15.1",
"@mantine/hooks": "^7.15.1",
"@mantine/modals": "^7.15.1",
"@mantine/notifications": "^7.15.1",
"@prisma/client": "^6.1.0",
"@prisma/internals": "^6.1.0",
"@prisma/migrate": "^6.1.0",
"@tabler/icons-react": "^3.26.0",
"@xoi/gps-metadata-remover": "^1.1.2",
"@mantine/charts": "^7.17.0",
"@mantine/code-highlight": "^7.17.0",
"@mantine/core": "^7.17.0",
"@mantine/dates": "^7.17.0",
"@mantine/dropzone": "^7.17.0",
"@mantine/form": "^7.17.0",
"@mantine/hooks": "^7.17.0",
"@mantine/modals": "^7.17.0",
"@mantine/notifications": "^7.17.0",
"@prisma/client": "^6.4.1",
"@prisma/internals": "^6.4.1",
"@prisma/migrate": "^6.4.1",
"@tabler/icons-react": "^3.30.0",
"@xoi/gps-metadata-remover": "^2.0.0",
"argon2": "^0.41.1",
"bytes": "^3.1.2",
"clsx": "^2.1.1",
"colorette": "^2.0.20",
"commander": "^12.1.0",
"commander": "^13.1.0",
"dayjs": "^1.11.13",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"fast-glob": "^3.3.2",
"fastify": "^4.29.0",
"fastify-plugin": "^4.5.1",
"fast-glob": "^3.3.3",
"fastify": "^5.2.1",
"fastify-plugin": "^5.0.1",
"fflate": "^0.8.2",
"fluent-ffmpeg": "^2.1.3",
"highlight.js": "^11.11.0",
"highlight.js": "^11.11.1",
"iron-session": "^8.0.4",
"isomorphic-dompurify": "^2.19.0",
"katex": "^0.16.18",
"isomorphic-dompurify": "^2.22.0",
"katex": "^0.16.21",
"mantine-datatable": "^7.15.1",
"ms": "^2.1.3",
"multer": "1.4.5-lts.1",
"next": "^15.1.2",
"next": "^15.1.7",
"otplib": "^12.0.1",
"prisma": "^6.1.0",
"prisma": "^6.4.1",
"qrcode": "^1.5.4",
"react": "^19.0.0-rc.1",
"react-dom": "^19.0.0-rc.1",
"react-markdown": "^9.0.1",
"remark-gfm": "^4.0.0",
"react-markdown": "^10.0.0",
"remark-gfm": "^4.0.1",
"sharp": "^0.33.5",
"swr": "^2.2.5",
"zod": "^3.24.1",
"zustand": "^5.0.2"
"swr": "^2.3.2",
"zod": "^3.24.2",
"zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/compat": "^1.2.4",
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.17.0",
"@eslint/compat": "^1.2.7",
"@eslint/eslintrc": "^3.3.0",
"@eslint/js": "^9.21.0",
"@types/bytes": "^3.1.5",
"@types/express": "^4.17.21",
"@types/express": "^5.0.0",
"@types/fluent-ffmpeg": "^2.1.27",
"@types/katex": "^0.16.7",
"@types/ms": "^0.7.34",
"@types/ms": "^2.1.0",
"@types/multer": "^1.4.12",
"@types/node": "^20.17.10",
"@types/node": "^22.13.5",
"@types/qrcode": "^1.5.5",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@typescript-eslint/eslint-plugin": "^8.18.1",
"@typescript-eslint/parser": "^8.18.1",
"eslint": "^9.17.0",
"eslint-config-next": "^15.1.2",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@typescript-eslint/eslint-plugin": "^8.24.1",
"@typescript-eslint/parser": "^8.24.1",
"eslint": "^9.21.0",
"eslint-config-next": "^15.1.7",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.3",
"eslint-plugin-unused-imports": "^4.1.4",
"postcss": "^8.4.49",
"postcss": "^8.5.3",
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.4.2",
"prettier": "^3.5.2",
"tsc-alias": "^1.8.10",
"tsup": "^8.3.5",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
"tsup": "^8.3.6",
"tsx": "^4.19.3",
"typescript": "^5.7.3"
},
"engines": {
"node": ">=22"

5070
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,5 @@
generator client {
provider = "prisma-client-js"
previewFeatures = ["omitApi"]
}
datasource db {

View file

@ -88,13 +88,7 @@ export default function DashboardSettings() {
</SimpleGrid>
<Stack mt='md' gap='md'>
{error ? (
<div>Error loading server settings</div>
) : (
<>
<ServerSettingsDiscord swr={{ data, isLoading }} />
</>
)}
{error ? null : <ServerSettingsDiscord swr={{ data, isLoading }} />}
</Stack>
</>
);

View file

@ -40,7 +40,6 @@ export function settingsOnSubmit(router: NextRouter, form: ReturnType<typeof use
});
await fetch('/reload');
await fetch('/api/reload');
mutate('/api/server/settings', data);
router.replace(router.asPath, undefined, { scroll: false });
}

View file

@ -144,7 +144,8 @@ export default function SettingsFileView() {
<Switch
label='Enable Embed'
description='Enable the following embed properties. These properties take advantage of OpenGraph tags.'
description='Enable the following embed properties. These properties take advantage of OpenGraph tags. View routes will need to be enabled for this to work.'
disabled={!form.values.enabled}
my='xs'
{...form.getInputProps('embed', { type: 'checkbox' })}
/>
@ -152,22 +153,22 @@ export default function SettingsFileView() {
<SimpleGrid cols={{ base: 1, md: 2 }} spacing='sm'>
<TextInput
label='Embed Title'
disabled={!form.values.embed}
disabled={!form.values.embed || !form.values.enabled}
{...form.getInputProps('embedTitle')}
/>
<TextInput
label='Embed Description'
disabled={!form.values.embed}
disabled={!form.values.embed || !form.values.enabled}
{...form.getInputProps('embedDescription')}
/>
<TextInput
label='Embed Site Name'
disabled={!form.values.embed}
disabled={!form.values.embed || !form.values.enabled}
{...form.getInputProps('embedSiteName')}
/>
<ColorInput
label='Embed Color'
disabled={!form.values.embed}
disabled={!form.values.embed || !form.values.enabled}
{...form.getInputProps('embedColor')}
/>
</SimpleGrid>

View file

@ -20,6 +20,7 @@ import useSWR from 'swr';
import { flameshot } from './generators/flameshot';
import { sharex } from './generators/sharex';
import { shell } from './generators/shell';
import { ishare } from './generators/ishare';
export type GeneratorOptions = {
deletesAt: string | null;
@ -84,6 +85,7 @@ const generators = {
Flameshot: flameshot,
ShareX: sharex,
'Shell Script': shell,
ishare,
};
export default function GeneratorButton({
@ -110,7 +112,7 @@ export default function GeneratorButton({
return (
<>
<Modal opened={opened} onClose={() => setOpen(false)} title={`Generate ${name}`}>
<Modal opened={opened} onClose={() => setOpen(false)} title={`Generate ${name} Uploader`}>
{desc && (
<Text size='sm' c='dimmed'>
{desc}
@ -121,7 +123,7 @@ export default function GeneratorButton({
<Select
data={[
{ label: 'Upload File', value: 'file' },
{ label: 'Shorten URL', value: 'url' },
{ label: 'Shorten URL', value: 'url', disabled: name === 'ishare' },
]}
description='Select which type of destination you want to generate'
label='Destination Type'

View file

@ -0,0 +1,63 @@
import { UploadHeaders } from '@/lib/uploader/parseHeaders';
import { GeneratorOptions, download } from '../GeneratorButton';
export function ishare(token: string, type: 'file' | 'url', options: GeneratorOptions) {
if (type === 'url') {
// unsupported in ishare
return;
}
const config = {
requesturl: `${window.location.origin}/api/upload`,
name: `Zipline - ${window.location.origin} - File`,
headers: {},
fileformname: 'file',
responseurl: '{{files[0].url}}',
};
const toAddHeaders: UploadHeaders = {
authorization: token,
};
if (options.deletesAt !== null && type === 'file') {
toAddHeaders['x-zipline-deletes-at'] = options.deletesAt;
} else {
delete toAddHeaders['x-zipline-deletes-at'];
}
if (options.format !== 'default' && type === 'file') {
toAddHeaders['x-zipline-format'] = options.format;
} else {
delete toAddHeaders['x-zipline-format'];
}
if (options.imageCompressionPercent !== null && type === 'file') {
toAddHeaders['x-zipline-image-compression-percent'] = options.imageCompressionPercent.toString();
} else {
delete toAddHeaders['x-zipline-image-compression-percent'];
}
if (options.maxViews !== null) {
toAddHeaders['x-zipline-max-views'] = options.maxViews.toString();
} else {
delete toAddHeaders['x-zipline-max-views'];
}
if (options.addOriginalName === true && type === 'file') {
toAddHeaders['x-zipline-original-name'] = 'true';
} else {
delete toAddHeaders['x-zipline-original-name'];
}
if (options.overrides_returnDomain !== null) {
toAddHeaders['x-zipline-domain'] = options.overrides_returnDomain;
} else {
delete toAddHeaders['x-zipline-domain'];
}
for (const [key, value] of Object.entries(toAddHeaders)) {
(config as any).headers[key] = value;
}
return download(`zipline-${type}.iscu`, JSON.stringify(config, null, 2));
}

View file

@ -3,7 +3,7 @@ import { GeneratorOptions, download } from '../GeneratorButton';
export function sharex(token: string, type: 'file' | 'url', options: GeneratorOptions) {
const config = {
Version: '14.1.0',
Version: '17.0.0',
Name: `Zipline - ${window.location.origin} - ${type === 'file' ? 'File' : 'URL'}`,
DestinationType: 'ImageUploader, TextUploader, FileUploader',
RequestMethod: 'POST',

View file

@ -1,4 +1,4 @@
import { Anchor, Code, Group, Paper, Text, Title } from '@mantine/core';
import { Anchor, Code, Group, Paper, Text, Title, Image as MantineImage } from '@mantine/core';
import { IconPrompt } from '@tabler/icons-react';
import Image from 'next/image';
import GeneratorButton from './GeneratorButton';
@ -52,6 +52,23 @@ export default function SettingsGenerators() {
</>
}
/>
<GeneratorButton
name='ishare'
icon={
<MantineImage
width={24}
height={24}
alt='ishare logo'
src='https://isharemac.app/ishare/Util/Assets.xcassets/AppIcon.appiconset/AppIcon-128.png'
/>
}
desc={
<>
This generator requires <Anchor href='https://isharemac.app/'>ishare</Anchor> to be installed on
macOS. This uploader is intended for use on macOS only.
</>
}
/>
<GeneratorButton
name='Shell Script'
icon={<IconPrompt size={24} />}

View file

@ -95,7 +95,6 @@ export default function ImportButton() {
});
await fetch('/reload');
await fetch('/api/reload');
}
};

View file

@ -30,7 +30,7 @@ import {
IconTrashFilled,
IconWriting,
} from '@tabler/icons-react';
import ms from 'ms';
import ms, { StringValue } from 'ms';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import useSWR from 'swr';
@ -133,8 +133,8 @@ export default function UploadOptionsButton({ numFiles }: { numFiles: number })
The file will automatically delete itself after this time.{' '}
{config.files.defaultExpiration ? (
<>
The default expiration time is <b>{ms(config.files.defaultExpiration)}</b> (you can
override this with the below option).
The default expiration time is <b>{ms(config.files.defaultExpiration as StringValue)}</b>{' '}
(you can override this with the below option).
</>
) : (
<>

View file

@ -8,9 +8,9 @@ export default function Markdown({ md }: { md: string }) {
<Paper withBorder p='md'>
<ReactMarkdown
components={{
code({ node: _, inline, className, children, ...props }) {
code({ node: _, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');
return !inline && match ? (
return match ? (
<HighlightCode language={match[1]} code={String(children).replace(/\n$/, '')} />
) : (
<Code className={className} {...props}>

View file

@ -1,9 +1,8 @@
import { ApiAuthOauthResponse } from '@/pages/api/auth/oauth';
import { ApiAuthInvitesResponse } from '@/server/routes/api/auth/invites';
import { ApiAuthInvitesIdResponse } from '@/server/routes/api/auth/invites/[id]';
import { ApiLoginResponse } from '@/server/routes/api/auth/login';
import { ApiLogoutResponse } from '@/server/routes/api/auth/logout';
import { ApiAuthOauthResponse } from '@/server/routes/api/auth/oauth';
import { ApiAuthRegisterResponse } from '@/server/routes/api/auth/register';
import { ApiAuthWebauthnResponse } from '@/server/routes/api/auth/webauthn';
import { ApiHealthcheckResponse } from '@/server/routes/api/healthcheck';

View file

@ -7,7 +7,9 @@ export async function readToDataURL(file: File): Promise<string> {
});
}
export async function fetchToDataURL(url: string) {
export async function fetchToDataURL(url?: string) {
if (!url) return null;
const res = await fetch(url);
if (!res.ok) return null;

View file

@ -1,4 +1,4 @@
import msFn from 'ms';
import msFn, { StringValue } from 'ms';
import { log } from '../logger';
import { bytes } from '../bytes';
import { prisma } from '../db';
@ -446,7 +446,7 @@ function parse(value: string, type: EnvType) {
case 'byte':
return bytes(value);
case 'ms':
return msFn(value);
return msFn(value as StringValue);
case 'json[]':
try {
return JSON.parse(value);

View file

@ -147,7 +147,7 @@ export function parseHeaders(headers: UploadHeaders, fileConfig: Config['files']
response.deletesAt = expiresAt;
} else {
if (fileConfig.defaultExpiration) {
const expiresAt = new Date(Date.now() + ms(fileConfig.defaultExpiration));
const expiresAt = new Date(Date.now() + ms(fileConfig.defaultExpiration as StringValue));
response.deletesAt = expiresAt;
}
}

View file

@ -84,7 +84,7 @@ async function generate(config: Config, datasource: Datasource, ids: string[]) {
stream.pipe(writeStream);
stream.on('error', reject);
writeStream.on('error', reject);
writeStream.on('finish', resolve);
writeStream.on('finish', resolve as any);
});
const thumbnailTmpFile = join(config.core.tempDirectory, `zthumbnail_${file.id}.jpg`);

View file

@ -1,59 +0,0 @@
import { prisma } from '@/lib/db';
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 { OAuthProvider, OAuthProviderType } from '@prisma/client';
export type ApiAuthOauthResponse = OAuthProvider[];
type Body = {
provider?: OAuthProviderType;
};
const logger = log('api').c('auth').c('oauth');
async function handler(req: NextApiReq<Body>, res: NextApiRes<ApiAuthOauthResponse>) {
if (req.method === 'DELETE') {
const { password } = (await prisma.user.findFirst({
where: {
id: req.user.id,
},
select: {
password: true,
},
}))!;
if (!req.user.oauthProviders.length) return res.badRequest('No providers to delete');
if (req.user.oauthProviders.length === 1 && !password)
return res.badRequest("You can't your last oauth provider without a password");
const { provider } = req.body;
if (!provider) return res.badRequest('Provider is required');
const providers = await prisma.user.update({
where: {
id: req.user.id,
},
data: {
oauthProviders: {
deleteMany: [{ provider }],
},
},
include: {
oauthProviders: true,
},
});
logger.info(`${req.user.username} unlinked an oauth provider`, {
provider,
});
return res.ok(providers.oauthProviders);
} else {
return res.ok(req.user.oauthProviders);
}
}
export default combine([method(['GET', 'DELETE']), ziplineAuth()], handler);

View file

@ -1,25 +0,0 @@
import { reloadSettings } from '@/lib/config';
import { prisma } from '@/lib/db';
import { isAdministrator } from '@/lib/role';
import { getSession } from '@/server/session';
import { NextApiRequest, NextApiResponse } from 'next/types';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession(req, res);
if (!session.id || !session.sessionId) return res.redirect(302, '/auth/login');
const user = await prisma.user.findFirst({
where: {
sessions: {
has: session.sessionId,
},
},
});
if (!user) return res.redirect(302, '/dashboard');
if (!isAdministrator(user.role)) return res.redirect(302, '/dashboard');
await reloadSettings();
return res.json({ success: true });
}

View file

@ -247,9 +247,16 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
backdropFilter: config.website.loginBackgroundBlur ? 'blur(35px)' : undefined,
}}
>
<Title order={1} size={50} ta='center'>
<b>{config.website.title ?? 'Zipline'}</b>
</Title>
<div style={{ width: '100%', overflowWrap: 'break-word' }}>
<Title
order={1}
size={(config.website.title ?? 'Zipline').length > 50 ? 20 : 50}
ta='center'
style={{ whiteSpace: 'normal' }}
>
<b>{config.website.title ?? 'Zipline'}</b>
</Title>
</div>
{showLocalLogin && (
<>

View file

@ -20,7 +20,7 @@ import { fastifySensible } from '@fastify/sensible';
import { fastifyStatic } from '@fastify/static';
import fastify from 'fastify';
import { mkdir, readFile } from 'fs/promises';
import ms from 'ms';
import ms, { StringValue } from 'ms';
import { parse } from 'url';
import { version } from '../../package.json';
import { checkRateLimit } from './plugins/checkRateLimit';
@ -28,6 +28,7 @@ import next, { ALL_METHODS } from './plugins/next';
import loadRoutes from './routes';
import { filesRoute } from './routes/files.dy';
import { urlsRoute } from './routes/urls.dy';
import oauthPlugin from './plugins/oauth';
const MODE = process.env.NODE_ENV || 'production';
const logger = log('server');
@ -95,6 +96,8 @@ async function main() {
root: config.core.tempDirectory,
});
await server.register(oauthPlugin);
if (config.ratelimit.enabled) {
try {
checkRateLimit(config);
@ -169,22 +172,22 @@ async function main() {
server.next('/reload', ALL_METHODS);
}
// TODO: no longer need this when all the api routes are handled by fastify :)
const routeKeys = Object.keys(routes); // holds "currently migrated routes" so we can parse json through fastify
server.addContentTypeParser('application/json', (req, body, done) => {
if (routeKeys.includes(req.routeOptions.config.url)) {
let bodyString = '';
body.on('data', (chunk) => {
bodyString += chunk;
});
// // TODO: no longer need this when all the api routes are handled by fastify :)
// const routeKeys = Object.keys(routes); // holds "currently migrated routes" so we can parse json through fastify
// server.addContentTypeParser('application/json', (req, body, done) => {
// if (routeKeys.includes(req.routeOptions.config.url)) {
// let bodyString = '';
// body.on('data', (chunk) => {
// bodyString += chunk;
// });
body.on('end', () => {
if (bodyString === '' || bodyString === null) return done(null, {});
// body.on('end', () => {
// if (bodyString === '' || bodyString === null) return done(null, {});
server.getDefaultJsonParser('error', 'ignore')(req, bodyString, done);
});
} else done(null, body);
});
// server.getDefaultJsonParser('error', 'ignore')(req, bodyString, done);
// });
// } else done(null, body);
// });
server.setErrorHandler((error, _, res) => {
if (error.statusCode) {
@ -209,10 +212,11 @@ async function main() {
logger.info('server started', { hostname: config.core.hostname, port: config.core.port });
// Tasks
tasks.interval('deletefiles', ms(config.tasks.deleteInterval), deleteFiles(prisma));
tasks.interval('maxviews', ms(config.tasks.maxViewsInterval), maxViews(prisma));
tasks.interval('deletefiles', ms(config.tasks.deleteInterval as StringValue), deleteFiles(prisma));
tasks.interval('maxviews', ms(config.tasks.maxViewsInterval as StringValue), maxViews(prisma));
if (config.features.metrics) tasks.interval('metrics', ms(config.tasks.metricsInterval), metrics(prisma));
if (config.features.metrics)
tasks.interval('metrics', ms(config.tasks.metricsInterval as StringValue), metrics(prisma));
if (config.features.thumbnails.enabled) {
for (let i = 0; i !== config.features.thumbnails.num_threads; ++i) {
@ -222,8 +226,12 @@ async function main() {
});
}
tasks.interval('thumbnails', ms(config.tasks.thumbnailsInterval), thumbnails(prisma));
tasks.interval('clearinvites', ms(config.tasks.clearInvitesInterval), clearInvites(prisma));
tasks.interval('thumbnails', ms(config.tasks.thumbnailsInterval as StringValue), thumbnails(prisma));
tasks.interval(
'clearinvites',
ms(config.tasks.clearInvitesInterval as StringValue),
clearInvites(prisma),
);
}
tasks.start();

View file

@ -3,18 +3,7 @@ import fastifyPlugin from 'fastify-plugin';
import next from 'next';
import { NextServerOptions, RequestHandler } from 'next/dist/server/next';
export const ALL_METHODS: HTTPMethods[] = [
'DELETE',
'GET',
'HEAD',
'PATCH',
'POST',
'PUT',
// 'OPTIONS',
'COPY',
'MOVE',
'TRACE',
];
export const ALL_METHODS: HTTPMethods[] = ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT'];
async function nextPlugin(fastify: FastifyInstance, options: NextServerOptions) {
const nextServer = next(options);
@ -48,7 +37,7 @@ async function nextPlugin(fastify: FastifyInstance, options: NextServerOptions)
export default fastifyPlugin(nextPlugin, {
name: 'next',
fastify: '4.x',
fastify: '5.x',
});
declare module 'fastify' {

View file

@ -1,12 +1,12 @@
import { NextApiReq, NextApiRes } from '@/lib/response';
import { OAuthProviderType } from '@prisma/client';
import { prisma } from '../db';
import { findProvider } from './providerUtil';
import { createToken, decrypt } from '../crypto';
import { config } from '../config';
import { User } from '../db/models/user';
import Logger, { log } from '../logger';
import { getSession, saveSession } from '@/server/session';
import { config } from '@/lib/config';
import { createToken, decrypt } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import Logger, { log } from '@/lib/logger';
import { findProvider } from '@/lib/oauth/providerUtil';
import { OAuthProviderType, User } from '@prisma/client';
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import fastifyPlugin from 'fastify-plugin';
import { getSession, saveSession } from '../session';
export interface OAuthQuery {
state?: string;
@ -26,31 +26,31 @@ export interface OAuthResponse {
redirect?: string;
}
export const withOAuth =
(
async function oauthPlugin(fastify: FastifyInstance) {
fastify.decorateRequest('oauthHandle', oauthHandle);
async function oauthHandle(
this: FastifyRequest,
reply: FastifyReply,
provider: OAuthProviderType,
oauthProfile: (query: OAuthQuery, logger: Logger) => Promise<OAuthResponse>,
) =>
async (req: NextApiReq, res: NextApiRes) => {
handler: (query: OAuthQuery, logger: Logger) => Promise<OAuthResponse>,
) {
const logger = log('api').c('auth').c('oauth').c(provider.toLowerCase());
req.query.host = req.headers.host ?? 'localhost:3000';
(this.query as any).host = this.headers.host ?? 'localhost:3000';
const response = await oauthProfile(req.query as OAuthQuery, logger);
const session = await getSession(req, res);
const response = await handler(this.query as OAuthQuery, logger);
const session = await getSession(this, reply);
if (response.error) {
logger.warn('invalid oauth request', {
error: response.error,
});
return res.serverError(response.error, {
oauth: response.error_code,
});
return reply.internalServerError(response.error_code + ' ' + response.error);
}
if (response.redirect) {
return res.redirect(response.redirect);
return reply.redirect(response.redirect);
}
logger.debug('oauth response', {
@ -76,7 +76,7 @@ export const withOAuth =
},
});
const { state } = req.query as OAuthQuery;
const { state } = this.query as OAuthQuery;
const user = await prisma.user.findFirst({
where: {
@ -99,10 +99,10 @@ export const withOAuth =
}
if (urlState === 'link') {
if (!user) return res.unauthorized('invalid session');
if (!user) return reply.unauthorized('invalid session');
if (findProvider(provider, user.oauthProviders))
return res.badRequest('This account is already linked to this provider');
return reply.badRequest('This account is already linked to this provider');
logger.debug('attempting to link oauth account', {
provider,
@ -134,7 +134,7 @@ export const withOAuth =
user: user.id,
});
return res.redirect('/dashboard/settings');
return reply.redirect('/dashboard/settings');
} catch (e) {
logger.error('failed to link oauth account', {
provider,
@ -142,7 +142,7 @@ export const withOAuth =
error: e,
});
return res.badRequest('Cant link account, already linked with this provider');
return reply.badRequest('Cant link account, already linked with this provider');
}
} else if (user && userOauth) {
await prisma.oAuthProvider.update({
@ -164,7 +164,7 @@ export const withOAuth =
user: user.id,
});
return res.redirect('/dashboard');
return reply.redirect('/dashboard');
} else if (existingOauth) {
const login = await prisma.oAuthProvider.update({
where: {
@ -188,15 +188,15 @@ export const withOAuth =
user: login.user!.id,
});
return res.redirect('/dashboard');
return reply.redirect('/dashboard');
} else if (config.oauth.loginOnly) {
logger.warn('user tried to create account with oauth, but login only is enabled', {
oauth: response.username || 'unknown',
ua: req.headers['user-agent'],
ua: this.headers['user-agent'],
});
return res.badRequest("Can't create users through oauth.");
return reply.badRequest("Can't create users through oauth.");
} else if (existingUser) {
return res.badRequest('This username is already taken');
return reply.badRequest('This username is already taken');
}
try {
@ -224,20 +224,36 @@ export const withOAuth =
user: nuser.id,
});
return res.redirect('/dashboard');
return reply.redirect('/dashboard');
} catch (e) {
if ((e as { code: string }).code === 'P2002') {
// already linked can't create, last failsafe lol
logger.warn('user tried to create account with oauth, but already linked', {
oauth: response.username || 'unknown',
ua: req.headers['user-agent'],
ua: this.headers['user-agent'],
});
logger.debug('oauth create error', {
error: e,
response,
});
return res.badRequest('Cant create user, already linked with this provider');
return reply.badRequest('Cant create user, already linked with this provider');
} else throw e;
}
};
}
}
export default fastifyPlugin(oauthPlugin, {
name: 'oauth',
fastify: '5.x',
});
declare module 'fastify' {
interface FastifyRequest {
oauthHandle: (
reply: FastifyReply,
provider: OAuthProviderType,
handler: (query: OAuthQuery, logger: Logger) => Promise<OAuthResponse>,
) => void;
}
}

View file

@ -1,14 +1,13 @@
import { fetchToDataURL } from '@/lib/base64';
import { config } from '@/lib/config';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import { OAuthQuery, OAuthResponse, withOAuth } from '@/lib/oauth/withOAuth';
import { encrypt } from '@/lib/crypto';
import Logger from '@/lib/logger';
import enabled from '@/lib/oauth/enabled';
import { discordAuth } from '@/lib/oauth/providerUtil';
import { fetchToDataURL } from '@/lib/base64';
import Logger from '@/lib/logger';
import { encrypt } from '@/lib/crypto';
import { OAuthQuery, OAuthResponse } from '@/server/plugins/oauth';
import fastifyPlugin from 'fastify-plugin';
async function handler({ code, host, state }: OAuthQuery, logger: Logger): Promise<OAuthResponse> {
async function discordOauth({ code, host, state }: OAuthQuery, logger: Logger): Promise<OAuthResponse> {
if (!config.features.oauthRegistration)
return {
error: 'OAuth registration is disabled.',
@ -72,6 +71,8 @@ async function handler({ code, host, state }: OAuthQuery, logger: Logger): Promi
const userJson = await discordAuth.user(json.access_token);
if (!userJson) return { error: 'Failed to fetch user' };
logger.debug('user', { '@me': userJson });
const avatar = userJson.avatar
? `https://cdn.discordapp.com/avatars/${userJson.id}/${userJson.avatar}.png`
: `https://cdn.discordapp.com/embed/avatars/${userJson.discriminator % 5}.png`;
@ -85,4 +86,14 @@ async function handler({ code, host, state }: OAuthQuery, logger: Logger): Promi
};
}
export default combine([method(['GET'])], withOAuth('DISCORD', handler));
export const PATH = '/api/auth/oauth/discord';
export default fastifyPlugin(
(server, _, done) => {
server.get(PATH, async (req, res) => {
return req.oauthHandle(res, 'DISCORD', discordOauth);
});
done();
},
{ name: PATH },
);

View file

@ -2,13 +2,12 @@ import { fetchToDataURL } from '@/lib/base64';
import { config } from '@/lib/config';
import { encrypt } from '@/lib/crypto';
import Logger from '@/lib/logger';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import enabled from '@/lib/oauth/enabled';
import { githubAuth } from '@/lib/oauth/providerUtil';
import { OAuthQuery, OAuthResponse, withOAuth } from '@/lib/oauth/withOAuth';
import { OAuthQuery, OAuthResponse } from '@/server/plugins/oauth';
import fastifyPlugin from 'fastify-plugin';
async function handler({ code, state }: OAuthQuery, logger: Logger): Promise<OAuthResponse> {
async function githubOauth({ code, state }: OAuthQuery, logger: Logger): Promise<OAuthResponse> {
if (!config.features.oauthRegistration)
return {
error: 'OAuth registration is disabled.',
@ -42,7 +41,7 @@ async function handler({ code, state }: OAuthQuery, logger: Logger): Promise<OAu
});
logger.debug('github oauth request', {
body,
body: body.toString(),
});
const res = await fetch('https://github.com/login/oauth/access_token', {
@ -54,7 +53,9 @@ async function handler({ code, state }: OAuthQuery, logger: Logger): Promise<OAu
},
});
if (!res.ok)
const isJson = res.headers.get('content-type')?.startsWith('application/json');
if (!isJson && !res.ok)
return {
error: 'Failed to fetch access token',
};
@ -75,6 +76,8 @@ async function handler({ code, state }: OAuthQuery, logger: Logger): Promise<OAu
const userJson = await githubAuth.user(json.access_token);
if (!userJson) return { error: 'Failed to fetch user' };
logger.debug('user', { user: userJson });
return {
access_token: json.access_token,
refresh_token: json.refresh_token,
@ -84,4 +87,14 @@ async function handler({ code, state }: OAuthQuery, logger: Logger): Promise<OAu
};
}
export default combine([method(['GET'])], withOAuth('GITHUB', handler));
export const PATH = '/api/auth/oauth/github';
export default fastifyPlugin(
(server, _, done) => {
server.get(PATH, async (req, res) => {
return req.oauthHandle(res, 'GITHUB', githubOauth);
});
done();
},
{ name: PATH },
);

View file

@ -2,13 +2,12 @@ import { fetchToDataURL } from '@/lib/base64';
import { config } from '@/lib/config';
import { encrypt } from '@/lib/crypto';
import Logger from '@/lib/logger';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import enabled from '@/lib/oauth/enabled';
import { googleAuth } from '@/lib/oauth/providerUtil';
import { OAuthQuery, OAuthResponse, withOAuth } from '@/lib/oauth/withOAuth';
import { OAuthQuery, OAuthResponse } from '@/server/plugins/oauth';
import fastifyPlugin from 'fastify-plugin';
async function handler({ code, host, state }: OAuthQuery, _logger: Logger): Promise<OAuthResponse> {
async function googleOauth({ code, host, state }: OAuthQuery, logger: Logger): Promise<OAuthResponse> {
if (!config.features.oauthRegistration)
return {
error: 'OAuth registration is disabled.',
@ -47,6 +46,8 @@ async function handler({ code, host, state }: OAuthQuery, _logger: Logger): Prom
access_type: 'offline',
});
logger.debug('google oauth request', { body: body.toString() });
const res = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
body,
@ -66,6 +67,8 @@ async function handler({ code, host, state }: OAuthQuery, _logger: Logger): Prom
const userJson = await googleAuth.user(json.access_token);
if (!userJson) return { error: 'Failed to fetch user' };
logger.debug('user', { userinfo: userJson });
return {
access_token: json.access_token,
refresh_token: json.refresh_token,
@ -75,4 +78,14 @@ async function handler({ code, host, state }: OAuthQuery, _logger: Logger): Prom
};
}
export default combine([method(['GET'])], withOAuth('GOOGLE', handler));
export const PATH = '/api/auth/oauth/google';
export default fastifyPlugin(
(server, _, done) => {
server.get(PATH, async (req, res) => {
return req.oauthHandle(res, 'GOOGLE', googleOauth);
});
done();
},
{ name: PATH },
);

View file

@ -0,0 +1,63 @@
import { log } from '@/lib/logger';
import fastifyPlugin from 'fastify-plugin';
import { OAuthProvider, OAuthProviderType } from '@prisma/client';
import { userMiddleware } from '@/server/middleware/user';
import { prisma } from '@/lib/db';
export type ApiAuthOauthResponse = OAuthProvider[];
type Body = {
provider?: OAuthProviderType;
};
const logger = log('api').c('auth').c('oauth');
export const PATH = '/api/auth/oauth/oidc';
export default fastifyPlugin(
(server, _, done) => {
server.get(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
return res.send(req.user.oauthProviders);
});
server.delete<{ Body: Body }>(PATH, { preHandler: [userMiddleware] }, async (req, res) => {
const { password } = (await prisma.user.findFirst({
where: {
id: req.user.id,
},
select: {
password: true,
},
}))!;
if (!req.user.oauthProviders.length) return res.badRequest('No providers to delete');
if (req.user.oauthProviders.length === 1 && !password)
return res.badRequest("You can't delete your last oauth provider without a password");
const { provider } = req.body;
if (!provider) return res.badRequest('Provider is required');
const providers = await prisma.user.update({
where: {
id: req.user.id,
},
data: {
oauthProviders: {
deleteMany: [{ provider }],
},
},
include: {
oauthProviders: true,
},
});
logger.info(`${req.user.username} unlinked an oauth provider`, {
provider,
});
return res.send(providers.oauthProviders);
});
done();
},
{ name: PATH },
);

View file

@ -1,14 +1,13 @@
import { fetchToDataURL } from '@/lib/base64';
import { config } from '@/lib/config';
import { encrypt } from '@/lib/crypto';
import Logger from '@/lib/logger';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import enabled from '@/lib/oauth/enabled';
import { oidcAuth } from '@/lib/oauth/providerUtil';
import { OAuthQuery, OAuthResponse, withOAuth } from '@/lib/oauth/withOAuth';
import { OAuthQuery, OAuthResponse } from '@/server/plugins/oauth';
import fastifyPlugin from 'fastify-plugin';
// thanks to @danejur for this https://github.com/diced/zipline/pull/372
async function handler({ code, host, state }: OAuthQuery, _logger: Logger): Promise<OAuthResponse> {
async function oidcOauth({ code, host, state }: OAuthQuery, logger: Logger): Promise<OAuthResponse> {
if (!config.features.oauthRegistration)
return {
error: 'OAuth registration is disabled.',
@ -48,6 +47,10 @@ async function handler({ code, host, state }: OAuthQuery, _logger: Logger): Prom
`${config.core.returnHttpsUrls ? 'https' : 'http'}://${host}/api/auth/oauth/oidc`,
});
logger.debug('oidc oauth request', {
body: body.toString(),
});
const res = await fetch(config.oauth.oidc.tokenUrl!, {
method: 'POST',
body,
@ -67,12 +70,27 @@ async function handler({ code, host, state }: OAuthQuery, _logger: Logger): Prom
const userJson = await oidcAuth.user(json.access_token, config.oauth.oidc.userinfoUrl!);
if (!userJson) return { error: 'Failed to fetch user' };
logger.debug('user', { userinfo: userJson });
return {
access_token: json.access_token,
refresh_token: json.refresh_token || null,
username: userJson.preferred_username,
// many different properties, so we are just gonna go down the list
username:
userJson.preferred_username ?? userJson.name ?? userJson.given_name ?? userJson.email ?? userJson.sub,
user_id: userJson.sub,
avatar: await fetchToDataURL(userJson.picture ?? null),
};
}
export default combine([method(['GET'])], withOAuth('OIDC', handler));
export const PATH = '/api/auth/oauth/oidc';
export default fastifyPlugin(
(server, _, done) => {
server.get(PATH, async (req, res) => {
return req.oauthHandle(res, 'OIDC', oidcOauth);
});
done();
},
{ name: PATH },
);

View file

@ -8,7 +8,7 @@ import { administratorMiddleware } from '@/server/middleware/administrator';
import { userMiddleware } from '@/server/middleware/user';
import fastifyPlugin from 'fastify-plugin';
import { statSync } from 'fs';
import ms from 'ms';
import ms, { StringValue } from 'ms';
import { cpus } from 'os';
import { resolve } from 'path';
import { z } from 'zod';
@ -20,7 +20,7 @@ type Body = Partial<Settings>;
const reservedRoutes = ['/dashboard', '/api', '/raw', '/robots.txt', '/manifest.json', '/favicon.ico'];
const zMs = z.string().refine((value) => ms(value) > 0, 'Value must be greater than 0');
const zMs = z.string().refine((value) => ms(value as StringValue) > 0, 'Value must be greater than 0');
const zBytes = z.string().refine((value) => bytes(value) > 0, 'Value must be greater than 0');
const discordEmbed = z

View file

@ -1,6 +1,5 @@
import { config } from '@/lib/config';
import { prisma } from '@/lib/db';
import { User } from '@/lib/db/models/user';
import { randomCharacters } from '@/lib/random';
import { FastifyReply, FastifyRequest } from 'fastify';
import { IncomingMessage, ServerResponse } from 'http';
@ -54,7 +53,7 @@ export async function getSession(
export async function saveSession(
session: Awaited<ReturnType<typeof getSession>>,
user: User,
user: { id: string } & Record<string, any>,
overwriteSessions = true,
) {
session.id = user.id;