chore: split code to a few files

This commit is contained in:
florian 2024-03-24 00:28:19 +01:00
parent ce119ff84c
commit 65b42b33b0
4 changed files with 111 additions and 107 deletions

71
src/auth.ts Normal file
View File

@ -0,0 +1,71 @@
import { HTTPException } from 'hono/http-exception';
import type { AuthType } from './types';
import { nip19, validateEvent, verifyEvent } from 'nostr-tools';
import type { Event } from 'nostr-tools';
import dayjs from 'dayjs';
export const getAuth = (authHeader?: string) => {
if (!authHeader?.startsWith('Nostr ')) throw new HTTPException(401, { message: 'Missing Nostr: Auth Scheme.' });
const authEvent = authHeader ? (JSON.parse(atob(authHeader.replace(/^Nostr\s/i, ''))) as Event) : undefined;
if (!authEvent) {
throw new HTTPException(400, { message: 'Nostr-Auth Token is invalid.' });
}
if (!validateEvent(authEvent)) {
throw new HTTPException(400, { message: 'Auth event is not valid.' });
}
if (!verifyEvent(authEvent)) {
throw new HTTPException(400, {
message: 'Auth event signature is not valid.',
});
}
if (authEvent.kind !== 24242) {
throw new HTTPException(400, { message: 'Unexpected auth kind' });
}
const type = authEvent.tags.find((t) => t[0] === 't')?.[1] as AuthType;
if (!type) {
throw new HTTPException(400, { message: 'Auth missing type' });
}
const now = dayjs().unix();
const expiration = authEvent.tags.find((t) => t[0] === 'expiration')?.[1];
if (!expiration) {
throw new HTTPException(400, { message: 'Auth missing expiration' });
}
if (parseInt(expiration) < now) {
throw new HTTPException(400, { message: 'Auth expired' });
}
return {
event: authEvent,
type,
expiration,
};
};
export const checkAuth = async (
auth: { event: Event; type: AuthType },
expectedType: AuthType,
allowedNubs: string,
kv: KVNamespace
) => {
if (auth.type !== expectedType) {
throw new HTTPException(400, { message: 'Auth type not allowed.' });
}
const key = `auth:${auth.type}:${auth.event.id}`;
if ((await kv.get(key)) != null) {
throw new HTTPException(400, { message: 'Token was used before.' });
}
await kv.put(key, 'used', { expirationTtl: 60 * 60 * 24 }); // 24h TTL for expired tokens
const allowedPubKeys = allowedNubs.split(',').map((n) => nip19.decode(n).data as string);
if (allowedPubKeys.length > 0 && !allowedPubKeys.some((pk) => pk == auth.event.pubkey)) {
throw new HTTPException(403, { message: 'Public key not authorized.' });
}
};

View File

@ -5,102 +5,10 @@ import { html } from 'hono/html';
import { HTTPException } from 'hono/http-exception';
import { logger } from 'hono/logger';
import { stream } from 'hono/streaming';
import { nip19, validateEvent, verifyEvent } from 'nostr-tools';
import dayjs from 'dayjs';
import type { Event } from 'nostr-tools';
type Bindings = {
BLOSSOM_BUCKET: R2Bucket;
KV_BLOSSOM: KVNamespace;
PUBLIC_URL: string;
ALLOWED_NPUBS: string;
};
type BlobData = {
created: number;
type?: string;
size: number;
};
type BlobDescriptor = BlobData & {
sha256: string;
url: string;
};
type AuthType = 'upload' | 'delete';
const getAuth = (authHeader?: string) => {
if (!authHeader?.startsWith('Nostr ')) throw new HTTPException(401, { message: 'Missing Nostr: Auth Scheme.' });
const authEvent = authHeader ? (JSON.parse(atob(authHeader.replace(/^Nostr\s/i, ''))) as Event) : undefined;
if (!authEvent) {
throw new HTTPException(400, { message: 'Nostr-Auth Token is invalid.' });
}
if (!validateEvent(authEvent)) {
throw new HTTPException(400, { message: 'Auth event is not valid.' });
}
if (!verifyEvent(authEvent)) {
throw new HTTPException(400, {
message: 'Auth event signature is not valid.',
});
}
if (authEvent.kind !== 24242) {
throw new HTTPException(400, { message: 'Unexpected auth kind' });
}
const type = authEvent.tags.find((t) => t[0] === 't')?.[1] as AuthType;
if (!type) {
throw new HTTPException(400, { message: 'Auth missing type' });
}
const now = dayjs().unix();
const expiration = authEvent.tags.find((t) => t[0] === 'expiration')?.[1];
if (!expiration) {
throw new HTTPException(400, { message: 'Auth missing expiration' });
}
if (parseInt(expiration) < now) {
throw new HTTPException(400, { message: 'Auth expired' });
}
return {
event: authEvent,
type,
expiration,
};
};
const checkAuth = async (
auth: { event: Event; type: AuthType },
expectedType: AuthType,
allowedNubs: string,
kv: KVNamespace
) => {
if (auth.type !== expectedType) {
throw new HTTPException(400, { message: 'Auth type not allowed.' });
}
const key = `auth:${auth.type}:${auth.event.id}`;
if ((await kv.get(key)) != null) {
throw new HTTPException(400, { message: 'Token was used before.' });
}
await kv.put(key, 'used', { expirationTtl: 60 * 60 * 24 }); // 24h TTL for expired tokens
const allowedPubKeys = allowedNubs.split(',').map((n) => nip19.decode(n).data as string);
if (allowedPubKeys.length > 0 && !allowedPubKeys.some((pk) => pk == auth.event.pubkey)) {
throw new HTTPException(403, { message: 'Public key not authorized.' });
}
};
const parseHashFromPath = (path: string) => {
const match = path.toLowerCase().match(/\/([0-9a-f]{64})(\.[a-z]+)?/);
const hash = match && match[1];
const ext = match && match[2];
return { hash, ext };
};
import type { Bindings, BlobData, BlobDescriptor } from './types';
import { checkAuth, getAuth } from './auth';
import { computeContentRange, parseHashFromPath } from './utils';
const app = new Hono<{ Bindings: Bindings }>();
app.use(cors());
@ -143,18 +51,6 @@ app.get('/list/:pubkey', async (c) => {
app.get('/list', cache({ cacheName: 'blossom', cacheControl: 'max-age=60' }));
function computeContentRange(range: R2Range, size: number) {
const offset = 'offset' in range ? range.offset : undefined;
const length = 'length' in range ? range.length : undefined;
const suffix = 'suffix' in range ? range.suffix : undefined;
const startOffset = typeof suffix === 'number' ? size - suffix : typeof offset === 'number' ? offset : 0;
const endOffset = typeof suffix === 'number' ? size : typeof length === 'number' ? startOffset + length : size;
//console.log({ offset, length, suffix, startOffset, endOffset }, `bytes ${startOffset}-${endOffset - 1}/${size}`);
return `bytes ${startOffset}-${endOffset - 1}/${size}`;
}
app.put('/upload', async (c) => {
const auth = getAuth(c.req.header('authorization') as string);
await checkAuth(auth, 'upload', c.env.ALLOWED_NPUBS, c.env.KV_BLOSSOM);

19
src/types.ts Normal file
View File

@ -0,0 +1,19 @@
export type Bindings = {
BLOSSOM_BUCKET: R2Bucket;
KV_BLOSSOM: KVNamespace;
PUBLIC_URL: string;
ALLOWED_NPUBS: string;
};
export type BlobData = {
created: number;
type?: string;
size: number;
};
export type BlobDescriptor = BlobData & {
sha256: string;
url: string;
};
export type AuthType = 'upload' | 'delete';

18
src/utils.ts Normal file
View File

@ -0,0 +1,18 @@
export const parseHashFromPath = (path: string) => {
const match = path.toLowerCase().match(/\/([0-9a-f]{64})(\.[a-z]+)?/);
const hash = match && match[1];
const ext = match && match[2];
return { hash, ext };
};
export const computeContentRange = (range: R2Range, size: number) => {
const offset = 'offset' in range ? range.offset : undefined;
const length = 'length' in range ? range.length : undefined;
const suffix = 'suffix' in range ? range.suffix : undefined;
const startOffset = typeof suffix === 'number' ? size - suffix : typeof offset === 'number' ? offset : 0;
const endOffset = typeof suffix === 'number' ? size : typeof length === 'number' ? startOffset + length : size;
//console.log({ offset, length, suffix, startOffset, endOffset }, `bytes ${startOffset}-${endOffset - 1}/${size}`);
return `bytes ${startOffset}-${endOffset - 1}/${size}`;
};