Compare commits
10 Commits
62f981dd93
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cedb29949c | ||
|
|
4e54d51d14 | ||
|
|
8c9ab34565 | ||
|
|
06ac3142a8 | ||
|
|
425dd96872 | ||
|
|
8ff1e4acaa | ||
|
|
6374566316 | ||
|
|
2b54c8dd00 | ||
|
|
29e8a63cb3 | ||
|
|
f39f62f1c7 |
@@ -1,21 +1,17 @@
|
||||
services:
|
||||
jetstream:
|
||||
image: docker.li212.fr/litenote:latest
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
command: ["sh", "-c", "deno task migrate && deno task jetstream:prod"]
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ${DATA_VOLUME:-data}:/data
|
||||
|
||||
api:
|
||||
image: docker.li212.fr/litenote:latest
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
command: ["deno", "task", "server:prod"]
|
||||
ports:
|
||||
- "${PORT}:8080"
|
||||
env_file:
|
||||
- .env
|
||||
expose:
|
||||
- "8080"
|
||||
volumes:
|
||||
- ${DATA_VOLUME:-data}:/data
|
||||
|
||||
|
||||
16
jetstream.ts
16
jetstream.ts
@@ -24,13 +24,17 @@ const fireWebhooks = async (
|
||||
const webhooks = getWebhooksByDid(did);
|
||||
if (webhooks.length === 0) return;
|
||||
const results = await Promise.allSettled(
|
||||
webhooks.map(({ method, url }) =>
|
||||
fetch(url, {
|
||||
webhooks.map(({ method, url, token }) => {
|
||||
const hasBody = method !== "GET" && method !== "HEAD";
|
||||
return fetch(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
),
|
||||
headers: {
|
||||
...(hasBody ? { "Content-Type": "application/json" } : {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: hasBody ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
}),
|
||||
);
|
||||
for (const result of results) {
|
||||
if (result.status === "rejected") {
|
||||
|
||||
@@ -17,7 +17,64 @@
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Markdown content. Local image paths are replaced with blob CIDs at publish time.",
|
||||
"maxLength": 10000
|
||||
"maxLength": 30000
|
||||
},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"description": "Most used language in the note. In ISO 639-3 code.",
|
||||
"maxLength": 10,
|
||||
"knownValues": [
|
||||
"afr",
|
||||
"ara",
|
||||
"aze",
|
||||
"bel",
|
||||
"ben",
|
||||
"bul",
|
||||
"cat",
|
||||
"ces",
|
||||
"ckb",
|
||||
"cmn",
|
||||
"dan",
|
||||
"deu",
|
||||
"ell",
|
||||
"eng",
|
||||
"est",
|
||||
"eus",
|
||||
"fin",
|
||||
"fra",
|
||||
"hau",
|
||||
"heb",
|
||||
"hin",
|
||||
"hrv",
|
||||
"hun",
|
||||
"hye",
|
||||
"ind",
|
||||
"isl",
|
||||
"ita",
|
||||
"jpn",
|
||||
"kat",
|
||||
"kaz",
|
||||
"kor",
|
||||
"lit",
|
||||
"mar",
|
||||
"mkd",
|
||||
"nld",
|
||||
"nob",
|
||||
"pes",
|
||||
"pol",
|
||||
"por",
|
||||
"ron",
|
||||
"run",
|
||||
"rus",
|
||||
"slk",
|
||||
"spa",
|
||||
"srp",
|
||||
"swe",
|
||||
"tgl",
|
||||
"tur",
|
||||
"ukr",
|
||||
"vie"
|
||||
]
|
||||
},
|
||||
"images": {
|
||||
"type": "array",
|
||||
@@ -46,6 +103,10 @@
|
||||
"type": "string",
|
||||
"description": "Display theme for the note.",
|
||||
"knownValues": ["light", "dark"]
|
||||
},
|
||||
"discoverable": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the note can be discovered by others in public listings. Defaults to true."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
16
server.ts
16
server.ts
@@ -4,6 +4,7 @@ import {
|
||||
deleteWebhooksByDid,
|
||||
getNotes,
|
||||
getNotesByDid,
|
||||
getNotesByDids,
|
||||
} from "./src/data/db.ts";
|
||||
import { log } from "./src/log.ts";
|
||||
|
||||
@@ -28,16 +29,27 @@ router.get("/:did/notes", (ctx) => {
|
||||
ctx.response.body = getNotesByDid(did, cursor, limit);
|
||||
});
|
||||
|
||||
router.post("/notes/feed", async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const { dids, cursor, limit } = body ?? {};
|
||||
if (!Array.isArray(dids) || dids.length === 0) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "dids must be a non-empty array" };
|
||||
return;
|
||||
}
|
||||
ctx.response.body = getNotesByDids(dids, cursor, Number(limit) || PAGINATION);
|
||||
});
|
||||
|
||||
router.post("/:did/webhooks", async (ctx) => {
|
||||
const { did } = ctx.params;
|
||||
const body = await ctx.request.body.json();
|
||||
const { method, url } = body ?? {};
|
||||
const { method, url, token } = body ?? {};
|
||||
if (!method || !url) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "method and url are required" };
|
||||
return;
|
||||
}
|
||||
const subscription = addWebhookSubscription({ did, method, url });
|
||||
const subscription = addWebhookSubscription({ did, method, url, token });
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = subscription;
|
||||
});
|
||||
|
||||
@@ -22,10 +22,10 @@ type NoteRow = {
|
||||
export const getNotes = (cursor?: string, limit = 20) => {
|
||||
const notes = cursor
|
||||
? db.prepare(
|
||||
"SELECT did, rkey, title, publishedAt, createdAt FROM note WHERE rkey < ? ORDER BY rkey DESC LIMIT ?",
|
||||
"SELECT did, rkey, title, publishedAt, createdAt, language FROM note WHERE discoverable = 1 AND rkey < ? ORDER BY rkey DESC LIMIT ?",
|
||||
).all<NoteRow>(cursor, limit)
|
||||
: db.prepare(
|
||||
"SELECT did, rkey, title, publishedAt, createdAt FROM note ORDER BY rkey DESC LIMIT ?",
|
||||
"SELECT did, rkey, title, publishedAt, createdAt, language FROM note WHERE discoverable = 1 ORDER BY rkey DESC LIMIT ?",
|
||||
).all<NoteRow>(limit);
|
||||
|
||||
return {
|
||||
@@ -37,10 +37,10 @@ export const getNotes = (cursor?: string, limit = 20) => {
|
||||
export const getNotesByDid = (did: string, cursor?: string, limit = 20) => {
|
||||
const notes = cursor
|
||||
? db.prepare(
|
||||
"SELECT did, rkey, title, publishedAt, createdAt FROM note WHERE did = ? AND rkey < ? ORDER BY rkey DESC LIMIT ?",
|
||||
"SELECT did, rkey, title, publishedAt, createdAt, language FROM note WHERE discoverable = 1 AND did = ? AND rkey < ? ORDER BY rkey DESC LIMIT ?",
|
||||
).all<NoteRow>(did, cursor, limit)
|
||||
: db.prepare(
|
||||
"SELECT did, rkey, title, publishedAt, createdAt FROM note WHERE did = ? ORDER BY rkey DESC LIMIT ?",
|
||||
"SELECT did, rkey, title, publishedAt, createdAt, language FROM note WHERE discoverable = 1 AND did = ? ORDER BY rkey DESC LIMIT ?",
|
||||
).all<NoteRow>(did, limit);
|
||||
|
||||
return {
|
||||
@@ -49,6 +49,23 @@ export const getNotesByDid = (did: string, cursor?: string, limit = 20) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const getNotesByDids = (dids: string[], cursor?: string, limit = 20) => {
|
||||
if (dids.length === 0) return { notes: [] };
|
||||
const placeholders = dids.map(() => "?").join(", ");
|
||||
const notes = cursor
|
||||
? db.prepare(
|
||||
`SELECT did, rkey, title, publishedAt, createdAt, language FROM note WHERE discoverable = 1 AND did IN (${placeholders}) AND rkey < ? ORDER BY rkey DESC LIMIT ?`,
|
||||
).all<NoteRow>(...dids, cursor, limit)
|
||||
: db.prepare(
|
||||
`SELECT did, rkey, title, publishedAt, createdAt, language FROM note WHERE discoverable = 1 AND did IN (${placeholders}) ORDER BY rkey DESC LIMIT ?`,
|
||||
).all<NoteRow>(...dids, limit);
|
||||
|
||||
return {
|
||||
notes,
|
||||
cursor: notes.length === limit ? notes[notes.length - 1].rkey : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const deleteNote = ({ did, rkey }: { did: string; rkey: string }) => {
|
||||
db.exec("DELETE FROM note WHERE did = ? AND rkey = ?", did, rkey);
|
||||
};
|
||||
@@ -72,20 +89,23 @@ type WebhookSubscriptionRow = {
|
||||
did: string;
|
||||
method: string;
|
||||
url: string;
|
||||
token?: string;
|
||||
};
|
||||
|
||||
export const addWebhookSubscription = (
|
||||
{ did, method, url }: Omit<WebhookSubscriptionRow, "id">,
|
||||
{ did, method, url, token }: Omit<WebhookSubscriptionRow, "id">,
|
||||
): WebhookSubscriptionRow => {
|
||||
db.exec(
|
||||
"INSERT INTO webhook_subscription (did, method, url) VALUES (?, ?, ?)",
|
||||
"INSERT INTO webhook_subscription (did, method, url, token) VALUES (?, ?, ?, ?)",
|
||||
did,
|
||||
method,
|
||||
url,
|
||||
token ?? null,
|
||||
);
|
||||
return db.prepare(
|
||||
"SELECT id, did, method, url FROM webhook_subscription WHERE id = last_insert_rowid()",
|
||||
).get<WebhookSubscriptionRow>()!;
|
||||
// Note: token is intentionally excluded from the SELECT (write-only)
|
||||
};
|
||||
|
||||
export const deleteWebhooksByDid = (did: string): void => {
|
||||
@@ -94,7 +114,7 @@ export const deleteWebhooksByDid = (did: string): void => {
|
||||
|
||||
export const getWebhooksByDid = (did: string): WebhookSubscriptionRow[] => {
|
||||
return db.prepare(
|
||||
"SELECT id, did, method, url FROM webhook_subscription WHERE did = ? ORDER BY id DESC LIMIT 10",
|
||||
"SELECT id, did, method, url, token FROM webhook_subscription WHERE did = ? ORDER BY id DESC LIMIT 10",
|
||||
).all<WebhookSubscriptionRow>(did);
|
||||
};
|
||||
|
||||
@@ -107,18 +127,24 @@ export const upsertNote = (note: Note) => {
|
||||
publishedAt,
|
||||
createdAt,
|
||||
did,
|
||||
rkey
|
||||
rkey,
|
||||
discoverable,
|
||||
language
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(did, rkey)
|
||||
DO UPDATE SET
|
||||
title = excluded.title,
|
||||
publishedAt = excluded.publishedAt
|
||||
publishedAt = excluded.publishedAt,
|
||||
discoverable = excluded.discoverable,
|
||||
language = excluded.language
|
||||
`,
|
||||
note.title,
|
||||
note.publishedAt ? new Date(note.publishedAt).toISOString() : now,
|
||||
note.createdAt ? new Date(note.createdAt).toISOString() : now,
|
||||
note.did,
|
||||
note.rkey,
|
||||
note.discoverable !== false ? 1 : 0,
|
||||
note.language,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,4 +4,6 @@ export type Note = {
|
||||
title: string;
|
||||
publishedAt: string;
|
||||
createdAt: string;
|
||||
discoverable?: boolean;
|
||||
language?: string
|
||||
};
|
||||
|
||||
@@ -7,10 +7,33 @@ db.exec(`
|
||||
createdAt DATETIME NOT NULL,
|
||||
did TEXT NOT NULL,
|
||||
rkey TEXT NOT NULL,
|
||||
discoverable INTEGER NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (did, rkey)
|
||||
);
|
||||
`);
|
||||
|
||||
try {
|
||||
db.exec(
|
||||
`ALTER TABLE note ADD COLUMN listed INTEGER NOT NULL DEFAULT 1;`,
|
||||
);
|
||||
} catch {
|
||||
// Column already exists — no-op
|
||||
}
|
||||
|
||||
try {
|
||||
db.exec(
|
||||
`ALTER TABLE note ADD COLUMN language STRING;`,
|
||||
);
|
||||
} catch {
|
||||
// Column already exists — no-op
|
||||
}
|
||||
|
||||
try {
|
||||
db.exec(`ALTER TABLE note RENAME COLUMN listed TO discoverable;`);
|
||||
} catch {
|
||||
// Column already renamed or doesn't exist — no-op
|
||||
}
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS state (
|
||||
key TEXT PRIMARY KEY,
|
||||
@@ -32,4 +55,10 @@ db.exec(`
|
||||
ON webhook_subscription(did);
|
||||
`);
|
||||
|
||||
try {
|
||||
db.exec(`ALTER TABLE webhook_subscription ADD COLUMN token TEXT;`);
|
||||
} catch {
|
||||
// Column already exists — no-op
|
||||
}
|
||||
|
||||
db.close();
|
||||
|
||||
Reference in New Issue
Block a user