Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"eslint-plugin-svelte": "^3.0.0",
"globals": "^16.0.0",
"hono": "^4.10.1",
"jose": "^6.1.3",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
Expand All @@ -56,5 +57,9 @@
"@prisma/client": "^6.15.0",
"prisma": "^6.15.0",
"s3-sync-client": "^4.3.1"
},
"//cookie": "sveltekit depends on v0.6, which is insecure",
"overrides": {
"cookie": "^0.7.0"
}
}
1 change: 1 addition & 0 deletions src/app.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ declare global {
// interface Error {}
interface Locals {
clientId: number;
userEmail?: string;
}
// interface PageData {}
// interface PageState {}
Expand Down
39 changes: 21 additions & 18 deletions src/hooks.server.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,25 @@
import { type Handle, error } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks';
import { building } from '$app/environment';
import { tryVerifyAPIToken, tryVerifyCookie } from '$lib/server/auth';
import { QueueConnected, getQueues } from '$lib/server/bullmq';
import { bullboardHandle } from '$lib/server/bullmq/BullBoard';
import { allWorkers } from '$lib/server/bullmq/BullMQ';
import { DatabaseConnected, prisma } from '$lib/server/prisma';
import { ErrorResponse } from '$lib/utils';
import { DatabaseConnected } from '$lib/server/prisma';

const handleAPIRoute: Handle = async ({ event, resolve }) => {
if (event.route.id?.split('/')[1] === '(api)') {
if (event.request.headers.get('Content-Type') !== 'application/json') {
return ErrorResponse(400, 'Missing Header Content-Type: application/json');
}
const access_token = event.request.headers.get('Authorization')?.replace('Bearer ', '');
if (!access_token) {
return ErrorResponse(401, 'Missing Header Authorization: Bearer <token>');
}
const client = await prisma.client.findFirst({ where: { access_token } });
if (!client) {
return ErrorResponse(403, 'Invalid Access Token');
}
event.locals.clientId = client.id;
} else {
event.locals.clientId = 0;
const [success, res] = await tryVerifyAPIToken(event);
if (!success) {
return res;
}
event.locals.clientId = res.id;
return resolve(event);
};

const handleAuthRoute: Handle = async ({ event, resolve }) => {
event.locals.clientId = 0;
if (event.route.id?.split('/')?.[1] !== '(auth)') {
await tryVerifyCookie(event);
}
return resolve(event);
};
Expand Down Expand Up @@ -62,5 +59,11 @@ export const handle: Handle = async ({ event, resolve }) => {
return new Response('', { status: 404 });
}

return await sequence(heartbeat, handleAPIRoute, bullboardHandle)({ event, resolve });
return await sequence(
heartbeat,
(h) => {
return event.route.id?.split('/')?.[1] === '(api)' ? handleAPIRoute(h) : handleAuthRoute(h);
},
bullboardHandle
)({ event, resolve });
};
35 changes: 35 additions & 0 deletions src/lib/icons/ScriptoriaIcon.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<script lang="ts">
interface Props {
size?: string;
color?: string;
}

let { size = '24', color = 'black' }: Props = $props();
</script>

<svg
fill={color}
width={size}
height={size}
viewBox="0 0 216.95833 228.07083"
version="1.1"
id="svg5"
xml:space="preserve"
xmlns="http://www.w3.org/2000/svg"
>
<sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
showgrid="false"
></sodipodi:namedview>
<defs id="defs2" />
<g id="layer1" transform="translate(14.202624,-29.114538)">
<path
style="fill:#020202;stroke:none;stroke-width:0.264583"
d="m 17.621199,167.29475 c -3.60865,0.15504 -7.6616202,0.57282 -11.1125002,1.67851 -2.36146,0.75671 -4.56136,1.87484 -6.61457997,3.26152 -13.51442983,9.12707 -10.31874983,26.78562 -10.31874983,40.83288 0,5.84465 -0.75519,12.27349 0.5916298,17.99167 2.44407,10.37669 12.12139,18.04749 22.6917002,18.51051 22.30544,0.97658 44.87466,0.0103 67.204168,0.0103 10.523538,0 22.108583,1.63671 30.956253,-5.29563 10.13619,-7.94174 9.26042,-19.67283 9.26042,-31.21687 0,-14.0073 3.00011,-31.54204 -10.31875,-40.73922 -1.89363,-1.30757 -3.90261,-2.4122 -6.08542,-3.16071 -3.72348,-1.27688 -8.264263,-1.82668 -12.170836,-2.13757 1.154112,-1.43166 2.600589,-2.4593 3.313112,-4.23333 1.806575,-4.49818 -0.756708,-9.70545 -5.429779,-10.98285 -3.154892,-0.86228 -6.809316,-0.39423 -10.054166,-0.39423 H 61.277449 c 6.32566,-8.70718 11.600923,-18.1282 18.164706,-26.72292 11.182879,-14.64283 24.513385,-27.939994 39.249885,-38.992433 5.47846,-4.108979 11.2313,-8.895027 17.4625,-11.80756 -13.34426,13.307483 -26.19931,26.952843 -37.537238,42.068743 -4.859072,6.47806 -10.34997,13.26568 -13.791935,20.6375 6.32169,1.50495 14.461331,-0.34184 20.637503,-1.88569 17.5088,-4.37594 32.53026,-13.00083 46.83125,-23.77888 -1.97141,-1.18269 -5.15408,-1.13665 -7.40833,-1.5875 -5.61446,-1.12263 -11.3284,-2.45481 -16.66875,-4.55163 -2.28838,-0.89853 -4.91861,-1.79996 -6.61459,-3.65046 8.90509,1.65338 18.73938,0.79375 27.78125,0.79375 3.35889,0 8.25791,0.87181 11.37709,-0.56594 1.80525,-0.83211 3.30676,-2.577044 4.7625,-3.887262 3.59833,-3.2385 6.81778,-6.847417 9.8425,-10.628048 11.2641,-14.079802 21.54237,-31.462927 25.61166,-49.212499 -45.04954,0 -101.257893,7.602431 -127.875241,48.947915 -5.52344,8.579644 -9.31519,18.517924 -11.00958,28.574994 -0.56065,3.3274 -0.82629,6.69713 -1.05595,10.05417 -0.2241,3.2721 0.34846,5.08449 -1.61793,7.9375 -2.79982,4.06188 -5.49751,8.20367 -8.03672,12.43541 -0.93556,1.55946 -2.03649,4.62862 -3.64728,5.54884 -1.71688,0.98081 -5.41046,0.272 -7.35938,0.272 h -13.75833 c -2.5908,0 -5.44645,-0.30877 -7.9375,0.55747 -4.31139,1.4994 -6.36599,6.68629 -4.69424,10.81961 0.79216,1.95845 2.3667,2.9374 3.6359,4.49792 m 118.797921,-93.662497 -0.26458,0.264584 z"
id="path225"
/>
</g>
</svg>
92 changes: 92 additions & 0 deletions src/lib/server/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { Prisma } from '@prisma/client';
import { type RequestEvent, redirect } from '@sveltejs/kit';
import { jwtDecrypt } from 'jose';
import { createHash, randomUUID } from 'node:crypto';
import { getAuthConnection } from './bullmq/queues';
import { prisma } from './prisma';
import { env as secrets } from '$env/dynamic/private';
import { env } from '$env/dynamic/public';
import { ErrorResponse } from '$lib/utils';

export async function tryVerifyCookie(event: RequestEvent, gotoLoginPage = true) {
const cookie = event.cookies.get('scriptoria.session-token');

let token = null;
try {
if (cookie) {
token = await jwtDecrypt(cookie, new TextEncoder().encode(secrets.AUTH0_SECRET));

event.locals.userEmail = token.payload.email as string;
}
} catch {
/* empty */
}

if (!cookie || !token) {
if (gotoLoginPage) {
const returnTo = event.url.pathname + event.url.search;
throw redirect(302, `/login?returnTo=${encodeURIComponent(returnTo)}`);
} else {
throw await initiateScriptoriaLogin(event);
}
}
}

async function initiateScriptoriaLogin(event: RequestEvent) {
const verify = randomUUID();
const requestId = randomUUID();

await getAuthConnection().set(`${requestId}`, verify, 'EX', 300); // 5 minute (300 s) TTL

const hash = createHash('sha256');
hash.update(verify);
const challenge = hash.digest('base64url').replace(/=+$/, '');

const returnTo = event.url.searchParams.get('returnTo');

throw redirect(
302,
`${env.PUBLIC_SCRIPTORIA_URL}/api/auth/token?` +
`challenge=${challenge}&` +
`redirect_uri=${encodeURIComponent(
`${secrets.ORIGIN}/exchange?` +
(returnTo ? `returnTo=${returnTo}` : '') +
`&requestId=${requestId}`
)}&` +
`scope=admin`
);
}

export function returnTo(event: RequestEvent) {
let redirectUrl = decodeURIComponent(event.url.searchParams.get('returnTo') ?? '');
while (redirectUrl?.startsWith('/login')) {
redirectUrl = decodeURIComponent(new URL(redirectUrl).searchParams.get('returnTo') ?? '');
}
throw redirect(
302,
redirectUrl && redirectUrl.startsWith('/') && !redirectUrl.startsWith('//') ? redirectUrl : '/'
);
}

export function invalidateLogin(event: RequestEvent) {
event.cookies.set('scriptoria.session-token', '', { path: '/' });
throw redirect(302, '/login');
}

export async function tryVerifyAPIToken(
event: RequestEvent
): Promise<[true, Prisma.clientGetPayload<true>] | [false, Response]> {
if (event.request.headers.get('Content-Type') !== 'application/json') {
return [false, ErrorResponse(400, 'Missing Header Content-Type: application/json')];
}
const access_token = event.request.headers.get('Authorization')?.replace('Bearer ', '');
if (!access_token) {
return [false, ErrorResponse(401, 'Missing Header Authorization: Bearer <token>')];
}
const client = await prisma.client.findFirst({ where: { access_token } });
if (!client) {
return [false, ErrorResponse(403, 'Invalid Access Token')];
}

return [true, client];
}
16 changes: 12 additions & 4 deletions src/lib/server/bullmq/queues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@ import { Queue } from 'bullmq';
import { Redis } from 'ioredis';
import type { BuildJob, PollJob, ProjectJob, PublishJob, S3Job, SystemJob } from './types';
import { QueueName } from './types';
import { env } from '$env/dynamic/private';

class Connection {
private conn: Redis;
private connected: boolean;
constructor(isQueueConnection = false) {
constructor(isQueueConnection = false, keyPrefix?: string) {
this.conn = new Redis({
host: process.env.NODE_ENV === 'development' ? 'localhost' : process.env.VALKEY_HOST,
maxRetriesPerRequest: isQueueConnection ? undefined : null
maxRetriesPerRequest: isQueueConnection ? undefined : null,
keyPrefix
});
this.connected = false;
this.conn.on('close', () => {
Expand Down Expand Up @@ -55,22 +57,28 @@ class Connection {

let _workerConnection: Connection | undefined = undefined;
let _queueConnection: Connection | undefined = undefined;
let _authConnection: Connection | undefined = undefined;

export const QueueConnected = () => _queueConnection?.IsConnected() ?? false;

export const getAuthConnection = () => {
if (!_authConnection) _authConnection = new Connection(false, env.APP_ENV + '_be_auth');
return _authConnection.connection();
};

export const getWorkerConfig = () => {
if (!_workerConnection) _workerConnection = new Connection(false);
return {
connection: _workerConnection!.connection(),
prefix: 'build-engine'
prefix: env.APP_ENV + '_build-engine'
} as const;
};

export const getQueueConfig = () => {
if (!_queueConnection) _queues = createQueues();
return {
connection: _queueConnection!.connection(),
prefix: 'build-engine'
prefix: env.APP_ENV + '_build-engine'
} as const;
};
let _queues: ReturnType<typeof createQueues> | undefined = undefined;
Expand Down
2 changes: 1 addition & 1 deletion src/routes/(api)/system/check/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ import type { RequestHandler } from './$types';

// GET system/check
export const GET: RequestHandler = () => {
return new Response(JSON.stringify({}));
return new Response(JSON.stringify({ versions: {}, imageHash: '' }));
};
16 changes: 16 additions & 0 deletions src/routes/(auth)/+layout.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<script lang="ts">
import type { Snippet } from 'svelte';

interface Props {
children?: Snippet;
}

let { children }: Props = $props();
</script>

<div
data-theme="light"
class="grid w-full h-full place-items-center place-content-center [background-color:#0068a6]"
>
{@render children?.()}
</div>
60 changes: 60 additions & 0 deletions src/routes/(auth)/exchange/+server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { error } from 'console';
import { EncryptJWT, jwtVerify } from 'jose';
import type { RequestHandler } from './$types';
import { env as secrets } from '$env/dynamic/private';
import { env } from '$env/dynamic/public';
import { returnTo } from '$lib/server/auth';
import { QueueConnected } from '$lib/server/bullmq';
import { getAuthConnection } from '$lib/server/bullmq/queues';

// GET system/check
export const GET: RequestHandler = async (event) => {
if (QueueConnected()) {
const requestId = event.url.searchParams.get('requestId');
const code = event.url.searchParams.get('code');
if (!requestId || !code) {
throw error(400, 'Missing URL Search Params');
}

const verify = await getAuthConnection().get(requestId);
if (!verify) {
throw error(400, 'Invalid or expired code');
}

try {
//immediately invalidate
await getAuthConnection().del(requestId);
} catch {
/* empty */
}

const res: { id_token?: string } = await fetch(
`${env.PUBLIC_SCRIPTORIA_URL}/api/auth/exchange`,
{
method: 'POST',
body: JSON.stringify({
code,
verify
})
}
).then((r) => r.json());

if (!res.id_token) {
throw error(401, 'Authentication failed');
}

const key = new TextEncoder().encode(secrets.AUTH0_SECRET);

const token = await jwtVerify(res.id_token, key);

const encryptedToken = await new EncryptJWT(token.payload)
.setProtectedHeader({ alg: 'dir', enc: 'A256CBC-HS512' })
.encrypt(key);

event.cookies.set('scriptoria.session-token', encryptedToken, { path: '/' });

throw returnTo(event);
} else {
throw error(503, 'Service Unavailable');
}
};
15 changes: 15 additions & 0 deletions src/routes/(auth)/login/+page.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { Actions, PageServerLoad } from './$types';
import { returnTo, tryVerifyCookie } from '$lib/server/auth';
import { QueueConnected } from '$lib/server/bullmq';

export const load: PageServerLoad = async (event) => {
return {
serviceAvailable: QueueConnected()
};
};
export const actions: Actions = {
async login(event) {
await tryVerifyCookie(event, false);
throw returnTo(event);
}
};
Loading