Files
markone/packages/server/src/bookmark/handlers.ts

276 lines
7.1 KiB
TypeScript
Raw Normal View History

2025-05-29 00:11:17 +01:00
import type { Bookmark, Tag, TagId, TaggedBookmark } from "@markone/core"
2025-05-07 15:47:08 +01:00
import { DEMO_USER } from "@markone/core/user"
2025-05-06 11:00:35 +01:00
import { type } from "arktype"
2025-05-07 23:09:14 +01:00
import { ulid } from "ulid"
2025-05-06 11:00:35 +01:00
import { db } from "~/database.ts"
import { HttpError } from "~/error.ts"
import type { User } from "~/user/user.ts"
2025-05-21 13:18:16 +01:00
import {
LinkUnreachable,
UnsupportedLink,
assignTagsToBookmark,
cacheContent,
2025-05-21 23:27:17 +01:00
deleteBookmark,
2025-05-21 13:18:16 +01:00
findBookmark,
findBookmarkCachedContent,
findBookmarkTags,
2025-05-29 00:11:17 +01:00
findBookmarks,
2025-05-21 13:18:16 +01:00
insertBookmark,
2025-05-25 15:40:16 +01:00
updateBookmarkTags,
updateBookmarkTitle,
2025-05-21 13:18:16 +01:00
} from "./bookmark.ts"
2025-05-25 15:40:16 +01:00
2025-05-29 00:11:17 +01:00
import { fuzzySearchBookmarks } from "~/bookmark/search.js"
2025-05-25 15:40:16 +01:00
import { insertTags } from "~/tag/tag.js"
2025-05-06 11:00:35 +01:00
const BOOKMARK_PAGINATION_LIMIT = 100
const ListUserBookmarksParams = type({
limit: ["number", "=", BOOKMARK_PAGINATION_LIMIT],
2025-05-08 15:51:17 +01:00
skip: ["number", "=", 0],
2025-05-21 23:27:17 +01:00
"tags?": "string",
2025-05-29 00:11:17 +01:00
"q?": "string",
2025-05-06 11:00:35 +01:00
})
2025-05-07 23:09:14 +01:00
const AddBookmarkRequestBody = type({
"title?": "string",
url: "string",
tags: "string[]",
"force?": "boolean",
})
2025-05-25 15:40:16 +01:00
const UpdateBookmarkRequestBody = type({
"title?": "string",
"tags?": "string[]",
})
2025-05-21 13:18:16 +01:00
const AddTagRequestBody = type({
name: "string",
})
2025-05-06 11:00:35 +01:00
async function listUserBookmarks(request: Bun.BunRequest<"/api/bookmarks">, user: User) {
2025-05-21 23:27:17 +01:00
const { searchParams } = new URL(request.url)
const queryParams = ListUserBookmarksParams(Object.fromEntries(searchParams))
2025-05-06 11:00:35 +01:00
if (queryParams instanceof type.errors) {
2025-05-07 23:09:14 +01:00
throw new HttpError(400, "", queryParams.summary)
2025-05-06 11:00:35 +01:00
}
2025-05-21 23:27:17 +01:00
let results: Bookmark[]
2025-05-29 00:11:17 +01:00
if (queryParams.q || queryParams.tags) {
let tagIds: TagId[] = []
if (queryParams.tags) {
const tagNames = queryParams.tags.split(",")
const tagIdsQuery = db.query<{ id: string }, string[]>(
`SELECT id FROM tags WHERE name IN (${Array(tagNames.length).fill("?").join(",")})`,
)
tagIds = tagIdsQuery.all(...tagNames).map(({ id }) => id)
}
if (queryParams.q) {
results = fuzzySearchBookmarks({ searchTerm: queryParams.q, tagIds }, user)
} else {
results = findBookmarks({ ids: [], tagIds }, user)
}
2025-05-21 23:27:17 +01:00
} else {
2025-05-29 00:11:17 +01:00
results = findBookmarks({ ids: [], tagIds: [], limit: queryParams.limit, skip: queryParams.skip }, user)
2025-05-21 23:27:17 +01:00
}
2025-05-06 11:00:35 +01:00
return Response.json(results, { status: 200 })
}
async function deleteUserBookmark(request: Bun.BunRequest<"/api/bookmarks/:id">, user: User) {
2025-05-07 15:47:08 +01:00
if (user.id !== DEMO_USER.id) {
2025-05-21 23:27:17 +01:00
deleteBookmark(request.params.id, user)
2025-05-07 15:47:08 +01:00
}
return Response.json(undefined, { status: 204 })
}
2025-05-07 23:09:14 +01:00
async function addBookmark(request: Bun.BunRequest<"/api/bookmarks">, user: User) {
if (user.id !== DEMO_USER.id) {
const body = AddBookmarkRequestBody(await request.json())
if (body instanceof type.errors) {
throw new HttpError(400)
}
2025-05-21 13:18:16 +01:00
const cachedContent = await cacheContent(body.url).catch((error) => {
2025-05-13 11:55:50 +01:00
if (error instanceof LinkUnreachable) {
if (body.force) {
return null
}
throw new HttpError(400, "LinkUnreachable")
2025-05-08 15:51:17 +01:00
}
2025-05-13 11:55:50 +01:00
if (error instanceof UnsupportedLink) {
throw new HttpError(400, "UnsupportedLink")
}
console.error(error)
throw new HttpError(500)
2025-05-07 23:09:14 +01:00
})
2025-05-21 13:18:16 +01:00
const tagNames = new Set(body.tags)
for (const tag of tagNames) {
2025-05-21 23:27:17 +01:00
if (tag.length === 0 || /[\s#]/g.test(tag)) {
2025-05-21 13:18:16 +01:00
throw new HttpError(400, "InvalidTag", "Tags cannot contain '#' or whitespaces")
}
}
2025-05-25 15:40:16 +01:00
const bookmark: TaggedBookmark = {
2025-05-07 23:09:14 +01:00
id: ulid(),
2025-05-08 15:51:17 +01:00
title: "",
2025-05-07 23:09:14 +01:00
url: body.url,
2025-05-21 13:18:16 +01:00
tags: [],
2025-05-07 23:09:14 +01:00
}
2025-05-08 15:51:17 +01:00
if (body.title) {
bookmark.title = body.title
2025-05-21 13:18:16 +01:00
} else if (cachedContent?.title) {
bookmark.title = cachedContent.title
2025-05-08 15:51:17 +01:00
}
2025-05-21 13:18:16 +01:00
insertBookmark(bookmark, cachedContent, user)
if (tagNames.size > 0) {
2025-05-25 15:40:16 +01:00
const tagQuery = db.query<Partial<Tag>, string[]>(`
2025-05-21 23:27:17 +01:00
SELECT id, name FROM tags
WHERE user_id = ? AND name IN (${Array(tagNames.size).fill("?").join(",")})
`)
2025-05-21 13:18:16 +01:00
const tags = tagQuery.all(user.id, ...tagNames)
2025-05-07 23:09:14 +01:00
for (const tag of tags) {
2025-05-21 13:18:16 +01:00
if (tag.id && tag.name) {
2025-05-25 15:40:16 +01:00
bookmark.tags.push(tag as Tag)
2025-05-21 13:18:16 +01:00
tagNames.delete(tag.name)
}
2025-05-07 23:09:14 +01:00
}
2025-05-21 23:27:17 +01:00
if (tagNames.size > 0) {
const createdTags = insertTags([...tagNames], user)
bookmark.tags.push(...createdTags)
}
assignTagsToBookmark(bookmark.tags, bookmark)
2025-05-21 13:18:16 +01:00
}
2025-05-07 23:09:14 +01:00
return Response.json(bookmark, { status: 200 })
}
return Response.json(undefined, { status: 204 })
}
async function fetchBookmark(request: Bun.BunRequest<"/api/bookmarks/:id">, user: User) {
2025-05-08 15:51:17 +01:00
switch (request.headers.get("Accept")) {
case "text/html": {
2025-05-21 13:18:16 +01:00
const html = findBookmarkCachedContent(request.params.id, user)
2025-05-08 15:51:17 +01:00
if (html === null) {
throw new HttpError(404)
}
return new Response(html, {
status: 200,
headers: {
"Content-Type": "text/html",
},
})
}
case "application/json": {
const bookmark = findBookmark(request.params.id, user)
if (bookmark === null) {
throw new HttpError(404)
}
return Response.json(bookmark, { status: 200 })
}
2025-05-08 15:51:17 +01:00
default:
throw new HttpError(400, "UnsupportedAcceptHeader")
}
}
2025-05-21 13:18:16 +01:00
async function listUserTags(request: Bun.BunRequest<"/api/tags">, user: User) {
2025-05-25 15:40:16 +01:00
const query = db.query<Tag, { id: string }>("SELECT id, name FROM tags WHERE user_id = $id")
2025-05-21 13:18:16 +01:00
const tags = query.all({ id: user.id })
return Response.json(tags, { status: 200 })
}
async function createUserTag(request: Bun.BunRequest<"/api/tags">, user: User) {
if (user.id !== DEMO_USER.id) {
const query = db.query<void, { id: string; name: string; userId: string }>(
"INSERT INTO tags (id, name, user_id) VALUES ($id, $name, $userId)",
)
const json = await request.json().catch(() => {
throw new HttpError(400)
})
const body = AddTagRequestBody(json)
if (body instanceof type.errors) {
throw new HttpError(400)
}
2025-05-25 15:40:16 +01:00
const tag: Tag = {
2025-05-21 13:18:16 +01:00
id: ulid(),
name: body.name,
}
query.run({ id: tag.id, name: tag.name, userId: user.id })
return Response.json(tag, { status: 200 })
}
return Response.json(undefined, { status: 204 })
}
async function listBookmarkTags(request: Bun.BunRequest<"/api/bookmarks/:id/tags">, user: User) {
const bookmark = findBookmark(request.params.id, user)
if (!bookmark) {
throw new HttpError(404)
}
const tags = findBookmarkTags(bookmark)
return Response.json(tags, { status: 200 })
}
2025-05-25 15:40:16 +01:00
async function updateBookmark(request: Bun.BunRequest<"/api/bookmarks/:id">, user: User) {
const bodyJson = await request.json().catch(() => {
throw new HttpError(400)
})
const body = UpdateBookmarkRequestBody(bodyJson)
if (body instanceof type.errors) {
throw new HttpError(400)
}
if (!body.title || !body.tags) {
return Response.json(undefined, { status: 204 })
}
const bookmark = findBookmark(request.params.id, user)
if (!bookmark) {
throw new HttpError(404)
}
if (body.title) {
updateBookmarkTitle(bookmark, body.title, user)
bookmark.title = body.title
}
if (body.tags) {
const taggedBookmark = bookmark as TaggedBookmark
for (const tag of body.tags) {
if (tag.length === 0 || /[\s#]/g.test(tag)) {
throw new HttpError(400, "InvalidTag", "Tags cannot contain '#' or whitespaces")
}
}
taggedBookmark.tags = updateBookmarkTags(bookmark, body.tags, user)
return Response.json(taggedBookmark, { status: 200 })
}
return Response.json(bookmark, { status: 200 })
}
2025-05-21 13:18:16 +01:00
export {
addBookmark,
fetchBookmark,
listUserBookmarks,
deleteUserBookmark,
listUserTags,
createUserTag,
listBookmarkTags,
2025-05-25 15:40:16 +01:00
updateBookmark,
2025-05-21 13:18:16 +01:00
}