chore: split code to a few files
This commit is contained in:
parent
ce119ff84c
commit
65b42b33b0
71
src/auth.ts
Normal file
71
src/auth.ts
Normal 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.' });
|
||||
}
|
||||
};
|
110
src/index.ts
110
src/index.ts
@ -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
19
src/types.ts
Normal 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
18
src/utils.ts
Normal 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}`;
|
||||
};
|
Loading…
Reference in New Issue
Block a user