Compare commits
10 Commits
62f981dd93
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cedb29949c | ||
|
|
4e54d51d14 | ||
|
|
8c9ab34565 | ||
|
|
06ac3142a8 | ||
|
|
425dd96872 | ||
|
|
8ff1e4acaa | ||
|
|
6374566316 | ||
|
|
2b54c8dd00 | ||
|
|
29e8a63cb3 | ||
|
|
f39f62f1c7 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
16
jetstream.ts
16
jetstream.ts
@@ -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") {
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
16
server.ts
16
server.ts
@@ -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;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,4 +4,6 @@ export type Note = {
|
|||||||
title: string;
|
title: string;
|
||||||
publishedAt: string;
|
publishedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
discoverable?: boolean;
|
||||||
|
language?: string
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user