chore: Inital commit
This commit is contained in:
commit
8649e6dbdf
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
dist
|
||||
.wrangler
|
||||
.dev.vars
|
||||
|
||||
# Change them to your taste:
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
bun.lockb
|
8
README.md
Normal file
8
README.md
Normal file
@ -0,0 +1,8 @@
|
||||
```
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
```
|
||||
npm run deploy
|
||||
```
|
16
package.json
Normal file
16
package.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"scripts": {
|
||||
"dev": "wrangler dev src/index.ts",
|
||||
"deploy": "wrangler deploy --minify src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"dayjs": "^1.11.10",
|
||||
"hono": "^4.1.3",
|
||||
"nostr-tools": "^2.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20240208.0",
|
||||
"@types/node": "^20.11.30",
|
||||
"wrangler": "^3.32.0"
|
||||
}
|
||||
}
|
250
src/index.ts
Normal file
250
src/index.ts
Normal file
@ -0,0 +1,250 @@
|
||||
import type {
|
||||
KVNamespace,
|
||||
R2Bucket,
|
||||
ReadableStream,
|
||||
} from "@cloudflare/workers-types";
|
||||
import dayjs from "dayjs";
|
||||
import { Hono } from "hono";
|
||||
import { cache } from "hono/cache";
|
||||
import { cors } from "hono/cors";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import { stream } from "hono/streaming";
|
||||
import { validateEvent, verifyEvent } from "nostr-tools";
|
||||
import type { Event } from "nostr-tools";
|
||||
|
||||
type Bindings = {
|
||||
BLOSSOM_BUCKET: R2Bucket;
|
||||
KV_BLOSSOM: KVNamespace;
|
||||
PUBLIC_URL: string;
|
||||
};
|
||||
|
||||
type BlobData = {
|
||||
created: number;
|
||||
type?: string;
|
||||
size: number;
|
||||
};
|
||||
|
||||
type BlobDescriptor = BlobData & {
|
||||
sha256: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type AuthType = "upload" | "delete";
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
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 };
|
||||
};
|
||||
|
||||
app.use("*", cors());
|
||||
|
||||
app.get("/list/:pubkey", async (c) => {
|
||||
const pubkey = c.req.param("pubkey");
|
||||
|
||||
const keyList = await c.env.KV_BLOSSOM.list({ prefix: pubkey + ":" });
|
||||
|
||||
// TODO paging is needed for >1000 blobs
|
||||
const finalList: any[] = [];
|
||||
await Promise.all(
|
||||
keyList.keys.map(async (key) => {
|
||||
const descriptor = await c.env.KV_BLOSSOM.get(key.name);
|
||||
const [_, blobKey] = key.name.split(":");
|
||||
|
||||
if (descriptor) {
|
||||
const blobDescriptor: BlobDescriptor = {
|
||||
...(JSON.parse(descriptor) as BlobData),
|
||||
sha256: blobKey,
|
||||
url: c.env.PUBLIC_URL + "/" + blobKey,
|
||||
};
|
||||
blobKey;
|
||||
finalList.push(blobDescriptor);
|
||||
}
|
||||
})
|
||||
);
|
||||
return await c.json(finalList);
|
||||
});
|
||||
|
||||
app.get(
|
||||
"*",
|
||||
cache({
|
||||
cacheName: "my-app",
|
||||
cacheControl: "max-age=3600",
|
||||
})
|
||||
);
|
||||
|
||||
app.get("*", async (c) => {
|
||||
const { hash } = parseHashFromPath(c.req.path);
|
||||
if (!hash) {
|
||||
throw new HTTPException(400, { message: "Invalid path, hash missing" });
|
||||
}
|
||||
|
||||
if (c.req.method == "HEAD") {
|
||||
const blob = await c.env.BLOSSOM_BUCKET.head(hash);
|
||||
|
||||
if (!blob) {
|
||||
throw new HTTPException(404, { message: "Blob hash not found." });
|
||||
}
|
||||
|
||||
c.status(200);
|
||||
return c.body("");
|
||||
} else {
|
||||
const blob = await c.env.BLOSSOM_BUCKET.get(hash);
|
||||
if (!blob) {
|
||||
throw new HTTPException(404, { message: "Blob hash not found." });
|
||||
}
|
||||
|
||||
return stream(c, async (stream) => {
|
||||
// Write a process to be executed when aborted.
|
||||
stream.onAbort(() => {
|
||||
console.warn("Aborted!");
|
||||
});
|
||||
c.header(
|
||||
"Content-Type",
|
||||
blob.httpMetadata?.contentType || "application/octet-stream"
|
||||
);
|
||||
|
||||
// TODO maybe redirect if file > xx MB
|
||||
await stream.pipe(blob.body as ReadableStream<any>);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const getAuth = (authHeader?: string) => {
|
||||
if (!authHeader?.startsWith("Nostr "))
|
||||
throw new HTTPException(400, { 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 authTokenWasUsedBefore = async (
|
||||
auth: { event: Event; type: AuthType },
|
||||
kv: KVNamespace
|
||||
) => {
|
||||
const key = `auth:${auth.type}:${auth.event.id}`;
|
||||
|
||||
const usedBefore = (await kv.get(key)) != null;
|
||||
|
||||
await kv.put(key, "used", { expirationTtl: 60 * 60 * 24 * 10 }); // 10 days
|
||||
|
||||
return usedBefore;
|
||||
};
|
||||
|
||||
app.put("/upload", async (c) => {
|
||||
const auth = getAuth(c.req.header("authorization") as string);
|
||||
|
||||
if (auth.type !== "upload") {
|
||||
throw new HTTPException(400, { message: "Auth type not allowed." });
|
||||
}
|
||||
|
||||
if (await authTokenWasUsedBefore(auth, c.env.KV_BLOSSOM)) {
|
||||
throw new HTTPException(400, { message: "Token was used before." });
|
||||
}
|
||||
|
||||
const uploadBuffer = await c.req.raw.arrayBuffer();
|
||||
if (uploadBuffer) {
|
||||
const keyBuffer = await crypto.subtle.digest("SHA-256", uploadBuffer);
|
||||
const key = Array.from(new Uint8Array(keyBuffer))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
|
||||
const contentType = c.req.header("content-type");
|
||||
const pubkey = auth.event.pubkey;
|
||||
|
||||
const storedObject = await c.env.BLOSSOM_BUCKET.put(key, uploadBuffer, {
|
||||
sha256: key,
|
||||
httpMetadata: { contentType },
|
||||
customMetadata: {
|
||||
pubkey,
|
||||
},
|
||||
});
|
||||
|
||||
const blobData: BlobData = {
|
||||
size: storedObject.size,
|
||||
type: storedObject.httpMetadata?.contentType,
|
||||
created: new Date(storedObject.uploaded).getTime(),
|
||||
};
|
||||
|
||||
console.log(`writing to ${pubkey + ":" + key}`, JSON.stringify(blobData));
|
||||
await c.env.KV_BLOSSOM.put(pubkey + ":" + key, JSON.stringify(blobData));
|
||||
|
||||
c.status(200);
|
||||
return c.json({
|
||||
...blobData,
|
||||
url: c.env.PUBLIC_URL + "/" + key,
|
||||
sha256: key,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("*", async (c) => {
|
||||
const auth = getAuth(c.req.header("authorization") as string);
|
||||
|
||||
if (auth.type !== "delete") {
|
||||
throw new HTTPException(400, { message: "Auth type not allowed." });
|
||||
}
|
||||
|
||||
if (await authTokenWasUsedBefore(auth, c.env.KV_BLOSSOM)) {
|
||||
throw new HTTPException(400, { message: "Token was used before." });
|
||||
}
|
||||
|
||||
const pubkey = auth.event.pubkey;
|
||||
const { hash } = parseHashFromPath(c.req.path);
|
||||
|
||||
if (!hash) {
|
||||
throw new HTTPException(400, { message: "Invalid path, hash missing" });
|
||||
}
|
||||
|
||||
try {
|
||||
await c.env.BLOSSOM_BUCKET.delete(hash);
|
||||
await c.env.KV_BLOSSOM.delete(pubkey + ":" + hash);
|
||||
return c.body("");
|
||||
} catch (e) {
|
||||
throw new HTTPException(404, { message: "Blob not found." });
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
27
tsconfig.json
Normal file
27
tsconfig.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Enable latest features
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
// Some stricter flags
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noPropertyAccessFromIndexSignature": true
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user