Compare commits

...

10 Commits

Author SHA1 Message Date
Julien Calixte
cedb29949c chore: docker compose for coolify 2026-03-14 20:27:15 +01:00
Julien Calixte
4e54d51d14 fix: skip body and Content-Type for GET/HEAD webhooks 2026-03-14 18:32:01 +01:00
Julien Calixte
8c9ab34565 feat: add optional bearer token support for webhook subscriptions
Token is stored in the DB but never returned in API responses (write-only).
fireWebhooks() sends Authorization: Bearer <token> header when present.
2026-03-14 18:16:28 +01:00
Julien Calixte
06ac3142a8 feat: add POST /notes/feed endpoint for multi-DID filtering 2026-03-10 15:51:10 +01:00
Julien Calixte
425dd96872 feat: update content length 2026-03-08 09:00:03 +01:00
Julien Calixte
8ff1e4acaa feat: get language in server too 2026-03-01 18:49:43 +01:00
Julien Calixte
6374566316 feat: add language support 2026-03-01 18:34:17 +01:00
Julien Calixte
2b54c8dd00 feat: add language support for better filtering 2026-03-01 17:37:59 +01:00
Julien Calixte
29e8a63cb3 refactor: rename listed → discoverable on note
Pure rename — no behavioral change. "discoverable" more clearly
communicates that the field controls whether a note can be found
by others in public listings.
2026-02-25 23:20:37 +01:00
Julien Calixte
f39f62f1c7 feat: add listed field to note for public listing visibility
Notes with listed=false are filtered out from all GET /notes queries.
The field defaults to true so existing and new notes without it remain visible.
Migration handles existing databases with ALTER TABLE ADD COLUMN.
2026-02-25 23:03:00 +01:00
7 changed files with 157 additions and 27 deletions

View File

@@ -1,21 +1,17 @@
services: services:
jetstream: jetstream:
image: docker.li212.fr/litenote:latest build: .
restart: unless-stopped restart: unless-stopped
command: ["sh", "-c", "deno task migrate && deno task jetstream:prod"] command: ["sh", "-c", "deno task migrate && deno task jetstream:prod"]
env_file:
- .env
volumes: volumes:
- ${DATA_VOLUME:-data}:/data - ${DATA_VOLUME:-data}:/data
api: api:
image: docker.li212.fr/litenote:latest build: .
restart: unless-stopped restart: unless-stopped
command: ["deno", "task", "server:prod"] command: ["deno", "task", "server:prod"]
ports: expose:
- "${PORT}:8080" - "8080"
env_file:
- .env
volumes: volumes:
- ${DATA_VOLUME:-data}:/data - ${DATA_VOLUME:-data}:/data

View File

@@ -24,13 +24,17 @@ const fireWebhooks = async (
const webhooks = getWebhooksByDid(did); const webhooks = getWebhooksByDid(did);
if (webhooks.length === 0) return; if (webhooks.length === 0) return;
const results = await Promise.allSettled( const results = await Promise.allSettled(
webhooks.map(({ method, url }) => webhooks.map(({ method, url, token }) => {
fetch(url, { const hasBody = method !== "GET" && method !== "HEAD";
return fetch(url, {
method, method,
headers: { "Content-Type": "application/json" }, headers: {
body: JSON.stringify(payload), ...(hasBody ? { "Content-Type": "application/json" } : {}),
}) ...(token ? { Authorization: `Bearer ${token}` } : {}),
), },
body: hasBody ? JSON.stringify(payload) : undefined,
});
}),
); );
for (const result of results) { for (const result of results) {
if (result.status === "rejected") { if (result.status === "rejected") {

View File

@@ -17,7 +17,64 @@
"content": { "content": {
"type": "string", "type": "string",
"description": "Markdown content. Local image paths are replaced with blob CIDs at publish time.", "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": { "images": {
"type": "array", "type": "array",
@@ -46,6 +103,10 @@
"type": "string", "type": "string",
"description": "Display theme for the note.", "description": "Display theme for the note.",
"knownValues": ["light", "dark"] "knownValues": ["light", "dark"]
},
"discoverable": {
"type": "boolean",
"description": "Whether the note can be discovered by others in public listings. Defaults to true."
} }
} }
} }

View File

@@ -4,6 +4,7 @@ import {
deleteWebhooksByDid, deleteWebhooksByDid,
getNotes, getNotes,
getNotesByDid, getNotesByDid,
getNotesByDids,
} from "./src/data/db.ts"; } from "./src/data/db.ts";
import { log } from "./src/log.ts"; import { log } from "./src/log.ts";
@@ -28,16 +29,27 @@ router.get("/:did/notes", (ctx) => {
ctx.response.body = getNotesByDid(did, cursor, limit); 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) => { router.post("/:did/webhooks", async (ctx) => {
const { did } = ctx.params; const { did } = ctx.params;
const body = await ctx.request.body.json(); const body = await ctx.request.body.json();
const { method, url } = body ?? {}; const { method, url, token } = body ?? {};
if (!method || !url) { if (!method || !url) {
ctx.response.status = 400; ctx.response.status = 400;
ctx.response.body = { error: "method and url are required" }; ctx.response.body = { error: "method and url are required" };
return; return;
} }
const subscription = addWebhookSubscription({ did, method, url }); const subscription = addWebhookSubscription({ did, method, url, token });
ctx.response.status = 201; ctx.response.status = 201;
ctx.response.body = subscription; ctx.response.body = subscription;
}); });

View File

@@ -22,10 +22,10 @@ type NoteRow = {
export const getNotes = (cursor?: string, limit = 20) => { export const getNotes = (cursor?: string, limit = 20) => {
const notes = cursor const notes = cursor
? db.prepare( ? 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) ).all<NoteRow>(cursor, limit)
: db.prepare( : 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); ).all<NoteRow>(limit);
return { return {
@@ -37,10 +37,10 @@ export const getNotes = (cursor?: string, limit = 20) => {
export const getNotesByDid = (did: string, cursor?: string, limit = 20) => { export const getNotesByDid = (did: string, cursor?: string, limit = 20) => {
const notes = cursor const notes = cursor
? db.prepare( ? 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) ).all<NoteRow>(did, cursor, limit)
: db.prepare( : 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); ).all<NoteRow>(did, limit);
return { 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 }) => { export const deleteNote = ({ did, rkey }: { did: string; rkey: string }) => {
db.exec("DELETE FROM note WHERE did = ? AND rkey = ?", did, rkey); db.exec("DELETE FROM note WHERE did = ? AND rkey = ?", did, rkey);
}; };
@@ -72,20 +89,23 @@ type WebhookSubscriptionRow = {
did: string; did: string;
method: string; method: string;
url: string; url: string;
token?: string;
}; };
export const addWebhookSubscription = ( export const addWebhookSubscription = (
{ did, method, url }: Omit<WebhookSubscriptionRow, "id">, { did, method, url, token }: Omit<WebhookSubscriptionRow, "id">,
): WebhookSubscriptionRow => { ): WebhookSubscriptionRow => {
db.exec( db.exec(
"INSERT INTO webhook_subscription (did, method, url) VALUES (?, ?, ?)", "INSERT INTO webhook_subscription (did, method, url, token) VALUES (?, ?, ?, ?)",
did, did,
method, method,
url, url,
token ?? null,
); );
return db.prepare( return db.prepare(
"SELECT id, did, method, url FROM webhook_subscription WHERE id = last_insert_rowid()", "SELECT id, did, method, url FROM webhook_subscription WHERE id = last_insert_rowid()",
).get<WebhookSubscriptionRow>()!; ).get<WebhookSubscriptionRow>()!;
// Note: token is intentionally excluded from the SELECT (write-only)
}; };
export const deleteWebhooksByDid = (did: string): void => { export const deleteWebhooksByDid = (did: string): void => {
@@ -94,7 +114,7 @@ export const deleteWebhooksByDid = (did: string): void => {
export const getWebhooksByDid = (did: string): WebhookSubscriptionRow[] => { export const getWebhooksByDid = (did: string): WebhookSubscriptionRow[] => {
return db.prepare( 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); ).all<WebhookSubscriptionRow>(did);
}; };
@@ -107,18 +127,24 @@ export const upsertNote = (note: Note) => {
publishedAt, publishedAt,
createdAt, createdAt,
did, did,
rkey rkey,
discoverable,
language
) )
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(did, rkey) ON CONFLICT(did, rkey)
DO UPDATE SET DO UPDATE SET
title = excluded.title, title = excluded.title,
publishedAt = excluded.publishedAt publishedAt = excluded.publishedAt,
discoverable = excluded.discoverable,
language = excluded.language
`, `,
note.title, note.title,
note.publishedAt ? new Date(note.publishedAt).toISOString() : now, note.publishedAt ? new Date(note.publishedAt).toISOString() : now,
note.createdAt ? new Date(note.createdAt).toISOString() : now, note.createdAt ? new Date(note.createdAt).toISOString() : now,
note.did, note.did,
note.rkey, note.rkey,
note.discoverable !== false ? 1 : 0,
note.language,
); );
}; };

View File

@@ -4,4 +4,6 @@ export type Note = {
title: string; title: string;
publishedAt: string; publishedAt: string;
createdAt: string; createdAt: string;
discoverable?: boolean;
language?: string
}; };

View File

@@ -7,10 +7,33 @@ db.exec(`
createdAt DATETIME NOT NULL, createdAt DATETIME NOT NULL,
did TEXT NOT NULL, did TEXT NOT NULL,
rkey TEXT NOT NULL, rkey TEXT NOT NULL,
discoverable INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (did, rkey) 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(` db.exec(`
CREATE TABLE IF NOT EXISTS state ( CREATE TABLE IF NOT EXISTS state (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
@@ -32,4 +55,10 @@ db.exec(`
ON webhook_subscription(did); ON webhook_subscription(did);
`); `);
try {
db.exec(`ALTER TABLE webhook_subscription ADD COLUMN token TEXT;`);
} catch {
// Column already exists — no-op
}
db.close(); db.close();