diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..076117736 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: andreasmolnardev diff --git a/Dockerfile b/Dockerfile index bb2264846..07214eab9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,21 @@ RUN case "${TARGETARCH}" in \ rm /tmp/pocketbase.zip && \ chmod +x /usr/local/bin/pocketbase +FROM alpine:latest AS shoutrrr +ARG SHOUTRRR_VERSION=0.8.0 +ARG TARGETARCH + +RUN case "${TARGETARCH}" in \ + "arm64") ARCH="arm64" ;; \ + "amd64") ARCH="amd64" ;; \ + *) echo "Unsupported architecture: ${TARGETARCH}" && exit 1 ;; \ + esac && \ + apk add --no-cache wget && \ + wget -O /tmp/shoutrrr.tar.gz "https://github.com/containrrr/shoutrrr/releases/download/v${SHOUTRRR_VERSION}/shoutrrr_linux_${ARCH}.tar.gz" && \ + tar -xzf /tmp/shoutrrr.tar.gz -C /usr/local/bin shoutrrr && \ + rm /tmp/shoutrrr.tar.gz && \ + chmod +x /usr/local/bin/shoutrrr + FROM oven/bun:1-alpine AS deps WORKDIR /app @@ -52,8 +67,10 @@ WORKDIR /app RUN apk add --no-cache valkey COPY --from=pocketbase /usr/local/bin/pocketbase /usr/local/bin/pocketbase +COPY --from=shoutrrr /usr/local/bin/shoutrrr /usr/local/bin/shoutrrr COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh ENV PB_BINARY_PATH=/usr/local/bin/pocketbase +ENV SHOUTRRR_BINARY_PATH=/usr/local/bin/shoutrrr ENV NODE_PATH=/app/apps/backend:/app/packages RUN mkdir -p /app && touch /app/.root.ind diff --git a/README.md b/README.md index 21e64b379..9c46cc8ef 100755 --- a/README.md +++ b/README.md @@ -6,8 +6,6 @@ I've been self hosting for a while but did not find a dashboard that suits my needs and that I like the look of. This is my attempt to solving that. -> **Disclaimer:** Since the last major release, this project has been partly rewritten. Proceed with caution. Measures include creating a backup of your pocketbase directory before upgrading. - > **Use of AI:** The development of this project is AI-Assisted by using it as a tool - not as a substitute for real programming skills. ## Screenshot @@ -15,7 +13,8 @@ This is my attempt to solving that. ## Features -- **Principles**: Configure dashwise using its UI. No need to touch config files. Dashwise also has Authentication (including SSO) built in. +- **GUI Editing**: Configure dashwise using its UI. No need to touch config files. +- **Secure by default**: Dashwise has Authentication (including SSO via OIDC) built in. - **Dashboards** show Glanceables and Widgets, for example Links (which can be grouped into Link groups and folders) - **Glanceables**: Customizable bits of one-line information next to the clock. - **Widgets**: Modular blocks on the dashboard that show key info or actions at a glance. They can be moved and customized individually. diff --git a/VERSION b/VERSION index b66d68da7..9f724c174 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.0-rc1 +v1.0-rc2 diff --git a/apps/backend/openapi.yaml b/apps/backend/openapi.yaml index e017bc7b2..db48e3d27 100644 --- a/apps/backend/openapi.yaml +++ b/apps/backend/openapi.yaml @@ -17,7 +17,8 @@ tags: - name: news - name: notifications - name: pageConfig - - name: search + - name: shortcuts + - name: sessions - name: test - name: wallpapers - name: widgets @@ -167,6 +168,29 @@ paths: $ref: "#/components/responses/JsonOk" "401": $ref: "#/components/responses/JsonUnauthorized" + /sessions/current: + get: + tags: + - sessions + summary: Get the current client session + responses: + "200": + $ref: "#/components/responses/JsonOk" + "401": + $ref: "#/components/responses/JsonUnauthorized" + patch: + tags: + - sessions + summary: Rename the current client session + requestBody: + $ref: "#/components/requestBodies/JsonBody" + responses: + "200": + $ref: "#/components/responses/JsonOk" + "400": + $ref: "#/components/responses/JsonBadRequest" + "401": + $ref: "#/components/responses/JsonUnauthorized" /integrations: get: tags: @@ -236,6 +260,36 @@ paths: responses: "200": $ref: "#/components/responses/JsonOk" + /links/metadata: + get: + tags: + - links + summary: Get link metadata + parameters: + - name: url + in: query + required: true + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + title: + type: string + description: + type: string + iconUrl: + type: string + required: + - title + - description + - iconUrl + additionalProperties: false /links/home/groups: get: tags: @@ -343,6 +397,14 @@ paths: responses: "200": $ref: "#/components/responses/JsonOk" + /links/dev/user-links: + delete: + tags: + - links + summary: Wipe development user links + responses: + "200": + $ref: "#/components/responses/JsonOk" /links/tags: get: tags: @@ -360,11 +422,11 @@ paths: responses: "200": $ref: "#/components/responses/JsonOk" - /jobs/searchItems: + /jobs/shortcuts: get: tags: - jobs - summary: Search items job + summary: Shortcuts indexing job responses: "200": $ref: "#/components/responses/JsonOk" @@ -695,6 +757,16 @@ paths: responses: "200": $ref: "#/components/responses/JsonOk" + /notifications/forwarders/test: + post: + tags: + - notifications + summary: Test forwarder + requestBody: + $ref: "#/components/requestBodies/JsonBody" + responses: + "200": + $ref: "#/components/responses/JsonOk" /notifications/markAsRead: post: tags: @@ -757,6 +829,15 @@ paths: responses: "200": $ref: "#/components/responses/JsonOk" + put: + tags: + - notifications + summary: Update topic token + requestBody: + $ref: "#/components/requestBodies/JsonBody" + responses: + "200": + $ref: "#/components/responses/JsonOk" get: tags: - notifications @@ -773,11 +854,11 @@ paths: responses: "200": $ref: "#/components/responses/JsonOk" - /searchItems: + /shortcuts: get: tags: - - search - summary: Search items + - shortcuts + summary: List shortcuts responses: "200": $ref: "#/components/responses/JsonOk" @@ -1234,6 +1315,19 @@ paths: responses: "200": $ref: "#/components/responses/JsonOk" + delete: + tags: + - news + summary: Delete news feed record + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + $ref: "#/components/responses/JsonOk" /news/feed-records: post: tags: @@ -1306,19 +1400,57 @@ paths: responses: "200": $ref: "#/components/responses/JsonOk" - /searchItems/frequentlyUsed: + /shortcuts/apps: + post: + tags: + - shortcuts + summary: Create an on-demand shortcut app + requestBody: + $ref: "#/components/requestBodies/JsonBody" + responses: + "200": + $ref: "#/components/responses/JsonOk" + "400": + $ref: "#/components/responses/JsonBadRequest" + "401": + $ref: "#/components/responses/JsonUnauthorized" + /shortcuts/on-demand/{appId}: + put: + tags: + - shortcuts + summary: Replace an on-demand app's shortcuts + parameters: + - name: appId + in: path + required: true + schema: + type: string + requestBody: + $ref: "#/components/requestBodies/JsonBody" + responses: + "200": + $ref: "#/components/responses/JsonOk" + "400": + $ref: "#/components/responses/JsonBadRequest" + "401": + $ref: "#/components/responses/JsonUnauthorized" + "404": + $ref: "#/components/responses/JsonNotFound" + "409": + $ref: "#/components/responses/JsonConflict" + /shortcuts/frequentlyUsed: get: tags: - - search - summary: List frequently used search items + - shortcuts + summary: List frequently used shortcuts responses: "200": $ref: "#/components/responses/JsonOk" - /searchItems/usageStats: + /shortcuts/usageStats: post: tags: - - search - summary: Log search item usage + - shortcuts + summary: Log shortcut usage requestBody: $ref: "#/components/requestBodies/JsonBody" responses: @@ -1365,6 +1497,18 @@ components: application/json: schema: $ref: "#/components/schemas/Error" + JsonNotFound: + description: Not Found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + JsonConflict: + description: Conflict + content: + application/json: + schema: + $ref: "#/components/schemas/Error" securitySchemes: bearerAuth: type: http diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index aa534b3e5..8d0b095f3 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -6,6 +6,12 @@ import { cors } from "hono/cors"; import { Client as SshClient } from "ssh2"; import { config } from "./lib/config"; +import { + handleActivityMessage, + registerSessionConnection, + subscribeActivity, +} from "./lib/activity"; +import { ensureSession } from "./lib/data/sessions"; import { jobsApi, registerJobsCron } from "./jobs/index"; import { startPocketbase } from "./pocketbase"; import { createLogger } from "./lib/logger"; @@ -14,8 +20,9 @@ import { getNotifications } from "./lib/data/notifications/items"; import { listIntegrations } from "./lib/data/integrations"; import { getUpcomingEvents } from "./lib/calendar"; import { systemAgentClient } from "./lib/systemAgent"; -import { requireAuth } from "./routes/shared"; +import { readAuth, readSessionMetadata, requireAuth } from "./routes/shared"; import authRoute from "./routes/auth.route"; +import sessionsRoute from "./routes/sessions.route"; import systemRoute from "./routes/system.route"; import dataRoute from "./routes/data.route"; @@ -63,7 +70,24 @@ app.use("*", async (c, next) => { app.use("*", cors({ origin: "*" })); +// Session identity is deliberately independent from the auth token. Touch the +// current device on every authenticated API request that carries its stable id. +app.use("/api/v1/*", async (c, next) => { + const auth = readAuth(c); + if (auth.token && auth.sessionId) { + try { + const { pb, userId } = await requireAuth(auth); + await ensureSession(pb, userId, auth.sessionId, readSessionMetadata(c)); + } catch { + // The route handler remains responsible for returning auth errors. This + // middleware should not turn a missing/expired session touch into one. + } + } + await next(); +}); + app.route("/", authRoute); +app.route("/", sessionsRoute); app.route("/", systemRoute); app.route("/", dataRoute); @@ -71,29 +95,56 @@ app.get("/health", (c) => c.json({ status: "ok" })); app.get("/api/v1/activity", upgradeWebSocket((c) => { let refreshTimer: ReturnType | undefined; + let unsubscribeActivity: (() => void) | undefined; + let unregisterSessionConnection: (() => void) | undefined; + let connectedUserId = ""; + let connectedSessionId = ""; return { async onOpen(_event, ws) { const token = c.req.query("token") || ""; + const sessionId = c.req.query("sessionId") || c.req.header("x-session-id") || null; try { - const { userId, pb } = await requireAuth({ token }); + const { userId, pb } = await requireAuth({ token, sessionId }); + const session = await ensureSession(pb, userId, sessionId, readSessionMetadata(c)); + if (!session) throw new Error("A valid session id is required"); + connectedUserId = userId; + connectedSessionId = session.sessionId; + unregisterSessionConnection = registerSessionConnection(userId, session.sessionId, ws); + let calendarEvents: Array> = []; + let calendarRefreshedAt = 0; + let calendarRefresh: Promise | null = null; + const refreshCalendarEvents = async () => { + if (Date.now() - calendarRefreshedAt < 5 * 60 * 1000) return; + if (calendarRefresh) return calendarRefresh; + + calendarRefresh = (async () => { + const integrationResult = await listIntegrations(userId); + calendarEvents = (await Promise.all( + integrationResult.integrations + .filter((integration) => integration.type === "caldav") + .map((integration) => getUpcomingEvents( + integration.environment, + integration.localData, + (localData) => pb.collection("integrations").update(integration.id, { localData }).then(() => undefined), + ).then((events) => events.map((event) => ({ ...event, id: `${integration.id}:${event.id}` }))).catch(() => [])), + )).flat().filter((event) => new Date(event.start).getTime() >= new Date().setHours(0, 0, 0, 0)); + calendarRefreshedAt = Date.now(); + })().finally(() => { + calendarRefresh = null; + }); + + return calendarRefresh; + }; const sendSnapshot = async () => { - const [notificationResult, integrationResult] = await Promise.all([ + const [notificationResult] = await Promise.all([ getNotifications(userId), - listIntegrations(userId), + refreshCalendarEvents(), ]); - const calendarEvents = (await Promise.all( - integrationResult.integrations - .filter((integration) => integration.type === "caldav") - .map((integration) => getUpcomingEvents( - integration.environment, - integration.localData, - (localData) => pb.collection("integrations").update(integration.id, { localData }).then(() => undefined), - ).then((events) => events.map((event) => ({ ...event, id: `${integration.id}:${event.id}` }))).catch(() => [])), - )).flat().filter((event) => new Date(event.start).getTime() >= new Date().setHours(0, 0, 0, 0)); ws.send(JSON.stringify({ type: "activity:snapshot", notifications: notificationResult.items, calendarEvents })); }; + unsubscribeActivity = subscribeActivity(userId, sendSnapshot); await sendSnapshot(); refreshTimer = setInterval(() => void sendSnapshot().catch(() => undefined), 30_000); (ws as typeof ws & { data: { sendSnapshot: () => Promise } }).data = { sendSnapshot }; @@ -104,6 +155,12 @@ app.get("/api/v1/activity", upgradeWebSocket((c) => { onMessage(event, ws) { try { const message = JSON.parse(String(event.data)); + if (connectedUserId && connectedSessionId && handleActivityMessage( + connectedUserId, + connectedSessionId, + ws, + message, + )) return; if (message.type === "activity:subscribe" || message.type === "activity:refresh") { void (ws as typeof ws & { data?: { sendSnapshot: () => Promise } }).data?.sendSnapshot(); } @@ -113,6 +170,8 @@ app.get("/api/v1/activity", upgradeWebSocket((c) => { }, onClose() { if (refreshTimer) clearInterval(refreshTimer); + unsubscribeActivity?.(); + unregisterSessionConnection?.(); }, }; })); @@ -139,10 +198,12 @@ app.get("/api/v1/monitoring/ssh-hosts/:id/console", upgradeWebSocket((c) => { return { async onOpen(_event, ws) { const token = c.req.query("token") || c.req.header("Authorization")?.replace(/^Bearer\s+/i, "") || ""; + const sessionId = c.req.query("sessionId") || c.req.header("x-session-id") || null; const hostId = c.req.param("id") || ""; try { - const { userId } = await requireAuth({ token }); + const { userId, pb } = await requireAuth({ token, sessionId }); + await ensureSession(pb, userId, sessionId, readSessionMetadata(c)); const host = await getMonitoringSshHostById(userId, hostId); if (!host) { ws.send(JSON.stringify({ type: "error", message: "SSH host not found" })); @@ -228,8 +289,10 @@ app.get("/api/v1/monitoring/hosts/:id/stats/live", upgradeWebSocket((c) => { return { async onOpen(_event, ws) { const token = c.req.query("token") || c.req.header("Authorization")?.replace(/^Bearer\s+/i, "") || ""; + const sessionId = c.req.query("sessionId") || c.req.header("x-session-id") || null; try { - const { userId } = await requireAuth({ token }); + const { userId, pb } = await requireAuth({ token, sessionId }); + await ensureSession(pb, userId, sessionId, readSessionMetadata(c)); const host = await getSystemAgentHostById(userId, c.req.param("id") || ""); if (!host) { ws.close(1008, "Monitoring host not found"); @@ -252,42 +315,6 @@ app.get("/api/v1/monitoring/hosts/:id/stats/live", upgradeWebSocket((c) => { }; })); -app.get("/webhook/statusMonitoringIndexer", async (c) => { - await jobsApi.runMonitoringIndexerJob("webhook"); - return c.json({ status: "success" }); -}); - -app.get("/webhook/statusMonitoringRunner", async (c) => { - const source = c.req.query("source"); - const linkId = c.req.query("linkId"); - await jobsApi.runMonitoringRunnerJob("webhook", { source, linkId }); - return c.json({ status: "success" }); -}); - -app.get("/webhook/newsFeedBuilder", async (c) => { - const url = new URL(c.req.url); - const feedIds = [ - ...url.searchParams.getAll("feedIds"), - ...url.searchParams.getAll("feedId"), - ] - .flatMap((entry) => String(entry || "").split(",")) - .map((feedId) => feedId.trim()) - .filter(Boolean); - - if (!feedIds.length) { - return c.json({ status: "success", message: "No feed IDs specified" }); - } - - await jobsApi.runNewsFeedBuilderJob("webhook", undefined, undefined, feedIds); - - return c.json({ status: "success" }); -}); - -app.post("/api/forward-notifications", async (c) => { - await jobsApi.runNotificationForwarderJob("api"); - return c.json({ status: "success" }); -}); - async function serveWorkspaceAsset(scope: keyof typeof assetRoots, requestPath: string) { const prefix = `/${scope}`; const relativePath = requestPath.slice(prefix.length).replace(/^\/+/, ""); diff --git a/apps/backend/src/jobs/index.ts b/apps/backend/src/jobs/index.ts index 99e7fa97f..6b10d7583 100644 --- a/apps/backend/src/jobs/index.ts +++ b/apps/backend/src/jobs/index.ts @@ -5,7 +5,7 @@ import { promisify } from "node:util"; import { runJob } from "./job-logger"; import { config } from "../lib/config"; import { _d } from "../lib/sdk"; -import { runSearchItemsIndexing } from "./search-indexer"; +import { runShortcutsIndexing } from "./shortcuts-indexer"; import indexStatusMonitoringJobs from "./monitoring/indexer"; import { runStatusMonitoringJobs, @@ -23,8 +23,8 @@ import { getSuperuserPB } from "../lib/pb/pocketbase"; const execFileAsync = promisify(execFile); const logger = createLogger("Jobs"); -async function runSearchIndexerScript() { - await runSearchItemsIndexing(); +async function runShortcutsIndexerScript() { + await runShortcutsIndexing(); } async function runPullIconsScript() { @@ -34,11 +34,11 @@ async function runPullIconsScript() { }); } -const runSearchItemsJob = (source: string) => - runJob("searchItemsIndexer", runSearchIndexerScript, { +const runShortcutsJob = (source: string) => + runJob("shortcutsIndexer", runShortcutsIndexerScript, { startMessage: `Triggered by ${source}`, - successMessage: "Search items indexing completed", - errorMessage: "Search items indexing failed", + successMessage: "Shortcuts indexing completed", + errorMessage: "Shortcuts indexing failed", }); const runPullIconsJob = (source: string) => @@ -157,9 +157,9 @@ export function registerJobsCron() { logger.debug("Dashwise SDK app config", _d.getAppConfig()); void runLegacyUserConfigsMigrationJob("server start"); - void runSearchItemsJob("server start"); - Bun.cron(config.SEARCHITEMS_SCHEDULE, async () => { - await runSearchItemsJob("cron schedule"); + void runShortcutsJob("server start"); + Bun.cron(config.SHORTCUTS_SCHEDULE, async () => { + await runShortcutsJob("cron schedule"); }); if (config.ENABLE_ICONS_REFRESH) { @@ -199,7 +199,7 @@ export function registerJobsCron() { } export const jobsApi = { - runSearchItemsJob, + runShortcutsJob, runPullIconsJob, runMonitoringIndexerJob, runMonitoringRunnerJob, diff --git a/apps/backend/src/jobs/monitoring/runner.ts b/apps/backend/src/jobs/monitoring/runner.ts index 33118cbbb..ba981e2e1 100644 --- a/apps/backend/src/jobs/monitoring/runner.ts +++ b/apps/backend/src/jobs/monitoring/runner.ts @@ -6,10 +6,7 @@ import { getUserConfigsByAssociatedUserId, updateMonitoringJob, } from "../../lib/data/superuser"; -import { - createNotificationByTopicId, - queueNotificationForForwarding, -} from "../../lib/data/notifications/publish"; +import { createNotificationByTopicId } from "../../lib/data/notifications/publish"; import { createLogger } from "../../lib/logger"; import { getLinkIdFromSource, getLinkSource, parseConfigObject, type StatusCheckMethod } from "./shared"; @@ -185,8 +182,7 @@ export async function runStatusMonitoringJobsWithOptions(options?: { if (job.notifyOnStatusChange && job.notifyTopicId) { try { const content = `Monitor "${job.title || job.endpoint || job.source || 'Unnamed'}" changed from ${currentStatus} to ${newStatus}`; - const { itemId } = await createNotificationByTopicId(job.notifyTopicId, content, 'monitoring'); - await queueNotificationForForwarding(itemId); + await createNotificationByTopicId(job.notifyTopicId, content, 'monitoring'); } catch (err: any) { logger.error("Failed to send notification for monitor status change", { jobId: job.id, error: err.message }); } @@ -241,8 +237,7 @@ export async function runStatusMonitoringJobsWithOptions(options?: { if (job.notifyOnStatusChange && job.notifyTopicId) { try { const content = `Monitor "${job.title || job.endpoint || job.source || 'Unnamed'}" changed from ${currentStatus} to unhealthy (fetch error)`; - const { itemId } = await createNotificationByTopicId(job.notifyTopicId, content, 'monitoring'); - await queueNotificationForForwarding(itemId); + await createNotificationByTopicId(job.notifyTopicId, content, 'monitoring'); } catch (nerr: any) { logger.error("Failed to send notification for monitor status change (error)", { jobId: job.id, error: nerr.message }); } diff --git a/apps/backend/src/jobs/news/feed-builder.test.ts b/apps/backend/src/jobs/news/feed-builder.test.ts index 8756b358b..6a46197ab 100644 --- a/apps/backend/src/jobs/news/feed-builder.test.ts +++ b/apps/backend/src/jobs/news/feed-builder.test.ts @@ -70,6 +70,25 @@ describe("materialized news selection", () => { expect(selectNewsFeedSubscriptions(subs, custom).map((entry) => entry.id)).toEqual(["two"]); }); + test("includes nested feed subscriptions and keeps child exclusions", () => { + const subs = [subscription("one"), subscription("two"), subscription("three")]; + const child: NewsFeedRecord = { + id: "child", + title: "Child", + subscriptionRefs: ["one", "two"], + excludedSubscriptionRefs: ["two"], + }; + const parent: NewsFeedRecord = { + id: "parent", + title: "Parent", + subscriptionRefs: ["three"], + includedFeedRefs: ["child"], + excludedSubscriptionRefs: [], + }; + + expect(selectNewsFeedSubscriptions(subs, parent, [parent, child]).map((entry) => entry.id)).toEqual(["one", "three"]); + }); + test("groups topics only after exact article deduplication", () => { const subs = [subscription("one"), subscription("two")]; const items = deduplicateUserArticles([ diff --git a/apps/backend/src/jobs/news/feed-builder.ts b/apps/backend/src/jobs/news/feed-builder.ts index 0bee88ba3..c17995d8f 100644 --- a/apps/backend/src/jobs/news/feed-builder.ts +++ b/apps/backend/src/jobs/news/feed-builder.ts @@ -20,6 +20,7 @@ import { writeMaterializedFeed, writeSubscriptionArticles, } from "../../lib/cache/feed-items"; +import { config } from "../../lib/config"; import { createLogger } from "../../lib/logger"; import { getFeedItems } from "./helper"; import type { NewsFeedItem } from "@dashwise/types/sdk"; @@ -31,6 +32,7 @@ export type NewsFeedRecord = { feedType?: "all" | "custom" | string; systemKey?: string; subscriptionRefs?: string[]; + includedFeedRefs?: string[]; excludedSubscriptionRefs?: string[]; maxFeedItems?: number; [key: string]: unknown; @@ -39,7 +41,6 @@ export type NewsFeedRecord = { type BuilderOptions = { userId?: string; feedIds?: string[] }; const logger = createLogger("NewsFeedBuilder"); -const SUBSCRIPTION_RETENTION = 500; function itemTime(item: Record) { const value = item.pubDate; @@ -96,7 +97,7 @@ async function fetchAndCacheSubscription(subscription: NewsSubscription, result: try { const raw = await getFeedItems({ feedUrl, - maxItems: SUBSCRIPTION_RETENTION, + maxItems: config.NEWS_SUBSCRIPTION_RETENTION, feedName: subscription.title || feedUrl, linkReplaceRule: subscription.linkReplaceRule, thumbnailOverwriteUrl: subscription.thumbnailOverwriteUrl, @@ -127,6 +128,7 @@ function normalizeFeed(record: Record): NewsFeedRecord | null { feedType: record.feedType ? String(record.feedType) : undefined, systemKey: record.systemKey ? String(record.systemKey) : undefined, subscriptionRefs: Array.isArray(record.subscriptionRefs) ? record.subscriptionRefs.map(String) : [], + includedFeedRefs: Array.isArray(record.includedFeedRefs) ? record.includedFeedRefs.map(String) : [], excludedSubscriptionRefs: Array.isArray(record.excludedSubscriptionRefs) ? record.excludedSubscriptionRefs.map(String) : [], }; } @@ -168,12 +170,29 @@ export function deduplicateUserArticles( export function selectNewsFeedSubscriptions( userSubscriptions: NewsSubscription[], record: NewsFeedRecord | null, + feeds: NewsFeedRecord[] = [], ) { const exclusions = new Set((record?.excludedSubscriptionRefs || []).map(String)); if (isAllNewsFeed(record)) { return userSubscriptions.filter((subscription) => subscription.id && !exclusions.has(String(subscription.id))); } - const refs = new Set((record?.subscriptionRefs || []).map(String)); + const feedsById = new Map(feeds.map((feed) => [String(feed.id), feed])); + const refs = new Set(); + const visited = new Set(); + const collectRefs = (current: NewsFeedRecord | null) => { + if (!current) return; + const currentId = String(current.id || ""); + if (currentId && visited.has(currentId)) return; + if (currentId) visited.add(currentId); + const currentExclusions = new Set((current.excludedSubscriptionRefs || []).map(String)); + for (const subscriptionId of current.subscriptionRefs || []) { + if (!currentExclusions.has(String(subscriptionId))) refs.add(String(subscriptionId)); + } + for (const childId of current.includedFeedRefs || []) { + collectRefs(feedsById.get(String(childId)) || null); + } + }; + collectRefs(record); return userSubscriptions.filter((subscription) => subscription.id && refs.has(String(subscription.id)) && !exclusions.has(String(subscription.id))); } @@ -205,6 +224,7 @@ async function buildUserFeed( feedId: string, record: NewsFeedRecord | null, allSubscriptions: NewsSubscription[], + userFeeds: NewsFeedRecord[], sourceRevision: string, result: { errors: number; updated: number; details: any[] }, ) { @@ -222,7 +242,7 @@ async function buildUserFeed( excludedSubscriptionRefs: [], } : record; - const selectedSubscriptions = selectNewsFeedSubscriptions(userSubscriptions, effectiveRecord); + const selectedSubscriptions = selectNewsFeedSubscriptions(userSubscriptions, effectiveRecord, userFeeds); const maxFeedItems = normalizeMaxFeedItems(effectiveRecord?.maxFeedItems); try { @@ -294,7 +314,14 @@ export async function newsFeedBuilder(feedId?: string, options: BuilderOptions = normalizeFeed(await getNewsFeedById(requestedId).catch(() => null) as Record || {}); if (targetFeed?.userId) targetUserIds.add(targetFeed.userId); if (targetFeed && !isAllNewsFeed(targetFeed)) { - for (const id of targetFeed.subscriptionRefs || []) targetSubscriptionIds.add(String(id)); + const targetSubscriptions = selectNewsFeedSubscriptions( + allSubscriptions.filter((entry) => !entry.userId || entry.userId === targetFeed.userId), + targetFeed, + feeds.filter((entry) => entry.userId === targetFeed.userId), + ); + for (const subscription of targetSubscriptions) { + if (subscription.id) targetSubscriptionIds.add(String(subscription.id)); + } } if (targetFeed && isAllNewsFeed(targetFeed) && targetFeed.userId) { for (const subscription of allSubscriptions.filter((entry) => !entry.userId || entry.userId === targetFeed.userId)) { @@ -315,8 +342,14 @@ export async function newsFeedBuilder(feedId?: string, options: BuilderOptions = } for (const feed of feeds) { if (!feed.userId) continue; - const refs = new Set(feed.subscriptionRefs || []); - if (isAllNewsFeed(feed) || Array.from(targetSubscriptionIds).some((id) => refs.has(id))) affectedUsers.add(feed.userId); + const selectedSubscriptions = selectNewsFeedSubscriptions( + allSubscriptions.filter((entry) => !entry.userId || entry.userId === feed.userId), + feed, + feeds.filter((entry) => entry.userId === feed.userId), + ); + if (isAllNewsFeed(feed) || selectedSubscriptions.some((subscription) => targetSubscriptionIds.has(String(subscription.id)))) { + affectedUsers.add(feed.userId); + } } if (!requestedIds.length && !options.userId) { for (const feed of feeds) if (feed.userId) affectedUsers.add(feed.userId); @@ -325,9 +358,9 @@ export async function newsFeedBuilder(feedId?: string, options: BuilderOptions = for (const userId of affectedUsers) { const userFeeds = feeds.filter((feed) => feed.userId === userId); const allFeed = userFeeds.find((feed) => isAllNewsFeed(feed)) || null; - await buildUserFeed(userId, "all", allFeed, allSubscriptions, sourceRevision, result); + await buildUserFeed(userId, "all", allFeed, allSubscriptions, userFeeds, sourceRevision, result); for (const feed of userFeeds.filter((entry) => !isAllNewsFeed(entry))) { - await buildUserFeed(userId, String(feed.id), feed, allSubscriptions, sourceRevision, result); + await buildUserFeed(userId, String(feed.id), feed, allSubscriptions, userFeeds, sourceRevision, result); } for (const subscription of allSubscriptions.filter((entry) => !entry.userId || entry.userId === userId)) { if (!subscription.id) continue; @@ -336,8 +369,9 @@ export async function newsFeedBuilder(feedId?: string, options: BuilderOptions = title: String(subscription.title || subscription.name || subscription.url || "Subscription"), feedType: "custom", subscriptionRefs: [String(subscription.id)], + includedFeedRefs: [], excludedSubscriptionRefs: [], - }, allSubscriptions, sourceRevision, result); + }, allSubscriptions, userFeeds, sourceRevision, result); } } diff --git a/apps/backend/src/jobs/news/helper.ts b/apps/backend/src/jobs/news/helper.ts index 5fa0f2322..da5953dbd 100644 --- a/apps/backend/src/jobs/news/helper.ts +++ b/apps/backend/src/jobs/news/helper.ts @@ -90,6 +90,7 @@ export async function getFeedItems({ } as FeedItem; }) .filter((item: FeedItem) => !isNaN(item.pubDate.getTime())) + .sort((left: FeedItem, right: FeedItem) => right.pubDate.getTime() - left.pubDate.getTime()) .slice(0, maxItems); } diff --git a/apps/backend/src/jobs/search-indexer.ts b/apps/backend/src/jobs/shortcuts-indexer.ts similarity index 79% rename from apps/backend/src/jobs/search-indexer.ts rename to apps/backend/src/jobs/shortcuts-indexer.ts index 70ff60b02..8132041d2 100644 --- a/apps/backend/src/jobs/search-indexer.ts +++ b/apps/backend/src/jobs/shortcuts-indexer.ts @@ -3,8 +3,9 @@ import type { HomeLink } from "@dashwise/types"; import { getHomeLinks } from "../lib/data/links"; import { config } from "../lib/config"; import { getSuperuserPB } from "../lib/pb/pocketbase"; +import { ensureShortcutsApp, escapeFilter, parseTags } from "../lib/data/shortcuts"; -type SearchItemRow = { +type ShortcutRow = { name: string; icon: string; secondary: string; @@ -32,8 +33,8 @@ type ShortcutDefaultsRow = { tags?: unknown; }; -export async function runSearchItemsIndexing() { - console.log("Starting search items indexing job..."); +export async function runShortcutsIndexing() { + console.log("Starting shortcuts indexing job..."); const pb = await getSuperuserPB(); const users = await pb.collection("users").getFullList<{ id: string }>(500, { fields: "id", @@ -43,7 +44,7 @@ export async function runSearchItemsIndexing() { const userId = user.id; if (!userId) continue; - const rows: SearchItemRow[] = buildDefaultShortcutSearchRows(); + const rows: ShortcutRow[] = buildDefaultShortcutRows(); const links = await getHomeLinks(userId).catch(() => [] as HomeLink[]); for (const link of links) { const name = String(link?.title ?? "").trim(); @@ -124,7 +125,7 @@ export async function runSearchItemsIndexing() { continue; } try { - const integrationRows = await buildIntegrationSearchRows(integration); + const integrationRows = await buildIntegrationShortcutRows(pb, userId, integration); rows.push(...integrationRows); } catch { // If one integration fails to resolve endpoints/search mappings, @@ -133,16 +134,16 @@ export async function runSearchItemsIndexing() { } } - await rebuildUserSearchItems(pb, userId, rows); + await rebuildUserShortcuts(pb, userId, rows); } } -function buildDefaultShortcutSearchRows(): SearchItemRow[] { +function buildDefaultShortcutRows(): ShortcutRow[] { const shortcuts = Array.isArray(defaultShortcutsManifest) ? (defaultShortcutsManifest as ShortcutDefaultsRow[]) : []; - const rows: SearchItemRow[] = []; + const rows: ShortcutRow[] = []; for (const shortcut of shortcuts) { const name = String(shortcut?.name ?? "").trim(); const action = String(shortcut?.action ?? "").trim(); @@ -162,23 +163,9 @@ function buildDefaultShortcutSearchRows(): SearchItemRow[] { }); } - rows.push({ - name: "Log out", - icon: "fa6-solid:right-from-bracket", - secondary: "Dashwise", - action: "logout:", - app: "", - tags: ["log out", "logout", "sign out", "signout"], - sourceId: "default-shortcut:logout", - }); - return rows; } -function escapeFilter(value: string) { - return value.replace(/"/g, '\\"'); -} - function normalizeObject(raw: unknown): Record { if (!raw) return {}; if (typeof raw === "object" && !Array.isArray(raw)) return raw as Record; @@ -207,18 +194,6 @@ function normalizeObject(raw: unknown): Record { return {}; } -function parseTags(value: unknown) { - if (!value) return [] as unknown[]; - if (Array.isArray(value)) return value; - if (typeof value !== "string") return [] as unknown[]; - try { - const parsed = JSON.parse(value); - return Array.isArray(parsed) ? parsed : []; - } catch { - return [] as unknown[]; - } -} - function normalizeKey(value: string) { return String(value || "") .trim() @@ -297,9 +272,11 @@ function isIntegrationEnabled( return candidates.some((candidate) => enabledMap[normalizeKey(candidate)] === true); } -async function buildIntegrationSearchRows( +async function buildIntegrationShortcutRows( + pb: any, + userId: string, integration: SearchIndexIntegrationRecord, -): Promise { +): Promise { const integrationConfig = normalizeObject(integration.config); const searchDefinitions = Array.isArray(integrationConfig?.configuration?.shortcuts) ? (integrationConfig.configuration.shortcuts as Array>) @@ -327,12 +304,16 @@ async function buildIntegrationSearchRows( })); const appId = `integration:${integration.id}`; + const shortcutApp = await ensureShortcutsApp(pb, userId, appId, { + name: integrationName, + icon: integrationIcon, + }); const rows = shortcutRows.map((item) => ({ name: item.name, icon: item.icon || integrationIcon, secondary: item.secondaryInfo || integrationName, action: serializeShortcutAction(item.action), - app: appId, + app: shortcutApp.id, tags: item.tags, sourceId: integration.id, sourceUpdated: (integration as any).updated as string, @@ -343,7 +324,7 @@ async function buildIntegrationSearchRows( name: integrationName, icon: integrationIcon, secondary: "Integration", - action: `app:${appId}`, + action: `app:${shortcutApp.id}`, app: "", tags: [integrationName, "integration"], sourceId: integration.id, @@ -353,33 +334,45 @@ async function buildIntegrationSearchRows( ]; } -async function rebuildUserSearchItems(pb: any, userId: string, rows: SearchItemRow[]) { - const existing = await pb.collection("searchItems").getFullList(1000, { +async function rebuildUserShortcuts(pb: any, userId: string, rows: ShortcutRow[]) { + const onDemandApps = await pb.collection("shortcutsApps").getFullList(1000, { + filter: `user="${escapeFilter(userId)}" && type="on-demand"`, + fields: "id", + }).catch(() => [] as Array<{ id: string }>); + const onDemandAppIds = new Set(onDemandApps.map((record: { id: string }) => record.id)); + const existing = await pb.collection("shortcuts").getFullList(1000, { filter: `user="${escapeFilter(userId)}"`, }); const existingBySource = new Map(); for (const record of existing) { - const sid = record.sourceId || "legacy"; + const sourceId = String(record.sourceId ?? ""); + const isOnDemandShortcut = typeof record.app === "string" && onDemandAppIds.has(record.app); + const isOnDemandAppShortcut = !record.app && sourceId.startsWith("shortcuts-app:") && + onDemandAppIds.has(sourceId.slice("shortcuts-app:".length)); + if (isOnDemandShortcut || isOnDemandAppShortcut) { + continue; + } + const sid = sourceId || "legacy"; if (!existingBySource.has(sid)) existingBySource.set(sid, []); existingBySource.get(sid)!.push(record); } - const newBySource = new Map(); + const newBySource = new Map(); for (const row of rows) { const sid = row.sourceId || "unknown"; if (!newBySource.has(sid)) newBySource.set(sid, []); newBySource.get(sid)!.push(row); } - // 1. Clean up search items whose sources no longer exist + // 1. Clean up just-in-time shortcuts whose sources no longer exist. for (const [sid, records] of existingBySource.entries()) { if (sid === "legacy") { - for (const r of records) await pb.collection("searchItems").delete(r.id).catch(() => {}); + for (const r of records) await pb.collection("shortcuts").delete(r.id).catch(() => {}); continue; } if (!newBySource.has(sid)) { - for (const r of records) await pb.collection("searchItems").delete(r.id).catch(() => {}); + for (const r of records) await pb.collection("shortcuts").delete(r.id).catch(() => {}); } } @@ -395,7 +388,7 @@ async function rebuildUserSearchItems(pb: any, userId: string, rows: SearchItemR const existingRecord = existingRecords[0]; if (existingRecord) { - // "check if the parent link has been updated since the search item has lastly been updated. if yes replace, else discard" + // Keep the existing shortcut when its source has not changed. const sourceUpdated = new Date(newRow.sourceUpdated || 0).getTime(); const itemUpdated = new Date(existingRecord.updated).getTime(); @@ -405,10 +398,10 @@ async function rebuildUserSearchItems(pb: any, userId: string, rows: SearchItemR } // Replace - await pb.collection("searchItems").delete(existingRecord.id).catch(() => {}); + await pb.collection("shortcuts").delete(existingRecord.id).catch(() => {}); } - await pb.collection("searchItems").create({ + await pb.collection("shortcuts").create({ user: userId, name: newRow.name, icon: newRow.icon, @@ -421,14 +414,25 @@ async function rebuildUserSearchItems(pb: any, userId: string, rows: SearchItemR }); } else { // Integration logic: "regenerate every time and check whether the output differs" - const existingData = existingRecords.map(r => ({ - name: r.name, - icon: r.icon, - secondary: r.secondary, - action: r.action, - app: r.app, - tags: parseTags(r.tags), - })).sort((a, b) => a.action.localeCompare(b.action)); + const appRelationId = newRows + .map((row) => row.app || (row.action.startsWith("app:") ? row.action.slice(4) : "")) + .find(Boolean) || ""; + const migratedParentRecords: Array<{ id: string; action: string }> = []; + const existingData = existingRecords.map(r => { + let action = r.action; + if (!r.app && appRelationId && typeof action === "string" && action.startsWith("app:integration:")) { + action = `app:${appRelationId}`; + migratedParentRecords.push({ id: r.id, action }); + } + return { + name: r.name, + icon: r.icon, + secondary: r.secondary, + action, + app: r.app, + tags: parseTags(r.tags), + }; + }).sort((a, b) => a.action.localeCompare(b.action)); const newData = newRows.map(r => ({ name: r.name, @@ -440,13 +444,16 @@ async function rebuildUserSearchItems(pb: any, userId: string, rows: SearchItemR })).sort((a, b) => a.action.localeCompare(b.action)); if (JSON.stringify(existingData) === JSON.stringify(newData)) { + for (const record of migratedParentRecords) { + await pb.collection("shortcuts").update(record.id, { action: record.action }); + } continue; } // Replace all for this source - for (const r of existingRecords) await pb.collection("searchItems").delete(r.id).catch(() => {}); + for (const r of existingRecords) await pb.collection("shortcuts").delete(r.id).catch(() => {}); for (const row of newRows) { - await pb.collection("searchItems").create({ + await pb.collection("shortcuts").create({ user: userId, name: row.name, icon: row.icon, diff --git a/apps/backend/src/lib/activity.test.ts b/apps/backend/src/lib/activity.test.ts new file mode 100644 index 000000000..f982ad26d --- /dev/null +++ b/apps/backend/src/lib/activity.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from "bun:test"; + +import { + handleActivityMessage, + isSessionConnected, + registerSessionConnection, + requestShortcutExecution, +} from "./activity"; + +type FakeConnection = { + sent: string[]; + send: (payload: string) => void; +}; + +function fakeConnection(): FakeConnection { + const connection: FakeConnection = { + sent: [], + send(payload) { + this.sent.push(payload); + }, + }; + return connection; +} + +describe("activity shortcut routing", () => { + test("routes a request and resolves the matching result", async () => { + const connection = fakeConnection(); + const unregister = registerSessionConnection("user-a", "session-a", connection); + + const request = requestShortcutExecution("user-a", "session-a", "open-terminal"); + expect(isSessionConnected("user-a", "session-a")).toBe(true); + expect(connection.sent).toHaveLength(1); + expect(JSON.parse(connection.sent[0])).toMatchObject({ + type: "shortcut:execute", + shortcutId: "open-terminal", + }); + + const requestId = JSON.parse(connection.sent[0]).requestId; + expect(handleActivityMessage("user-a", "session-a", connection, { + type: "shortcut:result", + requestId, + success: true, + })).toBe(true); + await expect(request).resolves.toMatchObject({ success: true, requestId }); + + unregister(); + expect(isSessionConnected("user-a", "session-a")).toBe(false); + }); + + test("rejects a result from another user or unregistered connection", async () => { + const connection = fakeConnection(); + const otherConnection = fakeConnection(); + const unregister = registerSessionConnection("user-b", "session-b", connection); + const unregisterOther = registerSessionConnection("user-a", "session-b", otherConnection); + + const request = requestShortcutExecution("user-b", "session-b", "open-terminal"); + const requestId = JSON.parse(connection.sent[0]).requestId; + expect(handleActivityMessage("user-a", "session-b", otherConnection, { + type: "shortcut:result", + requestId, + success: true, + })).toBe(false); + expect(handleActivityMessage("user-b", "session-b", otherConnection, { + type: "shortcut:result", + requestId, + success: true, + })).toBe(false); + expect(handleActivityMessage("user-b", "session-b", connection, { + type: "shortcut:result", + requestId, + success: false, + error: "Not exposed", + })).toBe(true); + await expect(request).resolves.toMatchObject({ + success: false, + error: "Not exposed", + }); + + unregister(); + unregisterOther(); + }); + + test("fails immediately when the target session has no connection", async () => { + await expect(requestShortcutExecution("user-a", "offline", "shortcut-id")).resolves.toMatchObject({ + success: false, + error: "Target session is offline", + }); + }); + + test("fails pending requests when the last connection closes", async () => { + const connection = fakeConnection(); + const unregister = registerSessionConnection("user-a", "session-c", connection); + const request = requestShortcutExecution("user-a", "session-c", "shortcut-id"); + + unregister(); + + await expect(request).resolves.toMatchObject({ + success: false, + error: "Target session disconnected before the shortcut completed", + }); + expect(isSessionConnected("user-a", "session-c")).toBe(false); + }); +}); diff --git a/apps/backend/src/lib/activity.ts b/apps/backend/src/lib/activity.ts new file mode 100644 index 000000000..92e6f95d6 --- /dev/null +++ b/apps/backend/src/lib/activity.ts @@ -0,0 +1,170 @@ +type ActivitySubscriber = () => Promise; + +export type ActivityConnection = { + send: (data: string) => void; +}; + +export type ShortcutExecutionResult = { + success: boolean; + requestId: string; + error?: string; +}; + +const subscribers = new Map>(); +const sessionConnections = new Map>>(); +const pendingShortcutRequests = new Map void; + timer: ReturnType; +}>(); + +const SHORTCUT_RESULT_TIMEOUT_MS = 10_000; + +export function subscribeActivity(userId: string, subscriber: ActivitySubscriber) { + const userSubscribers = subscribers.get(userId) ?? new Set(); + userSubscribers.add(subscriber); + subscribers.set(userId, userSubscribers); + + return () => { + userSubscribers.delete(subscriber); + if (userSubscribers.size === 0) subscribers.delete(userId); + }; +} + +export function broadcastActivity(userId: string) { + for (const subscriber of subscribers.get(userId) ?? []) { + void subscriber().catch(() => undefined); + } +} + +export function registerSessionConnection( + userId: string, + sessionId: string, + connection: ActivityConnection, +) { + const userSessions = sessionConnections.get(userId) ?? new Map(); + const connections = userSessions.get(sessionId) ?? new Set(); + connections.add(connection); + userSessions.set(sessionId, connections); + sessionConnections.set(userId, userSessions); + + return () => unregisterSessionConnection(userId, sessionId, connection); +} + +export function unregisterSessionConnection( + userId: string, + sessionId: string, + connection: ActivityConnection, +) { + const userSessions = sessionConnections.get(userId); + const connections = userSessions?.get(sessionId); + if (!connections) return; + + connections.delete(connection); + if (connections.size > 0) return; + + userSessions?.delete(sessionId); + if (userSessions && userSessions.size === 0) sessionConnections.delete(userId); + + for (const [requestId, request] of pendingShortcutRequests) { + if (request.userId !== userId || request.sessionId !== sessionId) continue; + finishShortcutRequest(requestId, { + success: false, + requestId, + error: "Target session disconnected before the shortcut completed", + }); + } +} + +export function isSessionConnected(userId: string, sessionId: string) { + return (sessionConnections.get(userId)?.get(sessionId)?.size ?? 0) > 0; +} + +export function sendToSession( + userId: string, + sessionId: string, + message: Record, +) { + const connections = sessionConnections.get(userId)?.get(sessionId); + if (!connections?.size) return false; + + const payload = JSON.stringify(message); + let sent = false; + for (const connection of connections) { + try { + connection.send(payload); + sent = true; + } catch { + unregisterSessionConnection(userId, sessionId, connection); + } + } + return sent; +} + +export function handleActivityMessage( + userId: string, + sessionId: string, + connection: ActivityConnection, + message: unknown, +) { + if (!message || typeof message !== "object") return false; + const payload = message as Record; + if (payload.type !== "shortcut:result" || typeof payload.requestId !== "string") return false; + + const request = pendingShortcutRequests.get(payload.requestId); + if (!request || request.userId !== userId || request.sessionId !== sessionId) return false; + if (!sessionConnections.get(userId)?.get(sessionId)?.has(connection)) return false; + + const error = typeof payload.error === "string" ? payload.error.trim().slice(0, 500) : ""; + finishShortcutRequest(payload.requestId, { + success: payload.success === true, + requestId: payload.requestId, + ...(payload.success === true ? {} : { error: error || "The client failed to execute the shortcut" }), + }); + return true; +} + +export function requestShortcutExecution( + userId: string, + sessionId: string, + shortcutId: string, +): Promise { + const requestId = crypto.randomUUID(); + return new Promise((resolve) => { + const timer = setTimeout(() => { + finishShortcutRequest(requestId, { + success: false, + requestId, + error: "Timed out waiting for the target session to execute the shortcut", + }); + }, SHORTCUT_RESULT_TIMEOUT_MS); + + pendingShortcutRequests.set(requestId, { + userId, + sessionId, + resolve, + timer, + }); + + if (!sendToSession(userId, sessionId, { + type: "shortcut:execute", + requestId, + shortcutId, + })) { + finishShortcutRequest(requestId, { + success: false, + requestId, + error: "Target session is offline", + }); + } + }); +} + +function finishShortcutRequest(requestId: string, result: ShortcutExecutionResult) { + const request = pendingShortcutRequests.get(requestId); + if (!request) return; + pendingShortcutRequests.delete(requestId); + clearTimeout(request.timer); + request.resolve(result); +} diff --git a/apps/backend/src/lib/api/tools/linkMetadata.ts b/apps/backend/src/lib/api/tools/linkMetadata.ts new file mode 100644 index 000000000..641eff347 --- /dev/null +++ b/apps/backend/src/lib/api/tools/linkMetadata.ts @@ -0,0 +1,90 @@ +export type LinkMetadata = { + title: string; + description: string; + iconUrl: string; +}; + +const LINK_METADATA_HEADERS = { + "User-Agent": "Mozilla/5.0 (compatible; Dashwise link metadata)", + Accept: "text/html,application/xhtml+xml", +}; + +function decodeHtmlEntities(value: string) { + return value + .replace(/&/gi, "&") + .replace(/"/gi, '"') + .replace(/'|'/gi, "'") + .replace(/</gi, "<") + .replace(/>/gi, ">") + .replace(/&#x([\da-f]+);/gi, (_, code: string) => String.fromCodePoint(Number.parseInt(code, 16))) + .replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number.parseInt(code, 10))); +} + +function getAttribute(tag: string, attribute: string) { + const match = tag.match(new RegExp(`\\b${attribute}\\s*=\\s*["']([^"']*)["']`, "i")); + return match?.[1] ? decodeHtmlEntities(match[1]).trim() : ""; +} + +function getMetaContent(tags: string[], names: string[]) { + for (const tag of tags) { + const name = getAttribute(tag, "property") || getAttribute(tag, "name"); + if (names.includes(name.toLowerCase())) { + const content = getAttribute(tag, "content"); + if (content) return content; + } + } + + return ""; +} + +function getTitle(html: string) { + const title = html.match(/]*>([\s\S]*?)<\/title>/i)?.[1] ?? ""; + return decodeHtmlEntities(title).replace(/\s+/g, " ").trim(); +} + +function getIconUrl(html: string, baseUrl: string) { + const linkTags = html.match(/]*>/gi) ?? []; + for (const tag of linkTags) { + const rel = getAttribute(tag, "rel").toLowerCase(); + const href = getAttribute(tag, "href"); + if (!href || !/(^|\s)(shortcut\s+icon|icon|apple-touch-icon)(\s|$)/i.test(rel)) continue; + + try { + return new URL(href, baseUrl).href; + } catch { + // Try the next icon declaration. + } + } + + return new URL("/favicon.ico", baseUrl).href; +} + +export async function getLinkMetadata(url: string): Promise { + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + } catch { + throw new Error("Enter a valid URL to fetch link details"); + } + + if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { + throw new Error("Only HTTP and HTTPS URLs are supported"); + } + + const response = await fetch(parsedUrl, { headers: LINK_METADATA_HEADERS }); + if (!response.ok) { + throw new Error(`Unable to fetch URL (${response.status})`); + } + + const html = (await response.text()).slice(0, 2_000_000); + const finalUrl = response.url || parsedUrl.href; + const metaTags = html.match(/]*>/gi) ?? []; + const title = getMetaContent(metaTags, ["og:title", "twitter:title"]) || getTitle(html); + const description = getMetaContent(metaTags, ["og:description", "twitter:description", "description"]); + + return { + title: title || new URL(finalUrl).hostname, + description, + iconUrl: getIconUrl(html, finalUrl), + }; +} diff --git a/apps/backend/src/lib/cache/feed-items.ts b/apps/backend/src/lib/cache/feed-items.ts index 5782bcaf8..dee6be0df 100644 --- a/apps/backend/src/lib/cache/feed-items.ts +++ b/apps/backend/src/lib/cache/feed-items.ts @@ -332,6 +332,20 @@ export async function writeMaterializedFeed( ]); } +export async function deleteMaterializedFeed(userId: string, feedId: string) { + const orderKey = materializedFeedOrderKey(userId, feedId); + const itemsKey = materializedFeedItemsKey(userId, feedId); + const metaKey = materializedFeedMetaKey(userId, feedId); + + if (config.USE_LOCAL_FEED_CACHE) { + localViews.delete(orderKey); + localMetadata.delete(metaKey); + return; + } + + await command("DEL", [orderKey, itemsKey, metaKey]); +} + // Compatibility for the subscription JSON endpoint and older callers. New feed // construction must use the article/index functions above. export async function readFeedItemsCache(feedId: string): Promise { diff --git a/apps/backend/src/lib/config.ts b/apps/backend/src/lib/config.ts index fc0fa4b6b..10dbb9146 100644 --- a/apps/backend/src/lib/config.ts +++ b/apps/backend/src/lib/config.ts @@ -16,6 +16,10 @@ const fallbackOutlierValue = normalizedOutlierType === "absolute" ? 500 : 50; const resolvedOutlierValue = Number.isFinite(normalizedOutlierValue) && normalizedOutlierValue > 0 ? normalizedOutlierValue : fallbackOutlierValue; +const normalizedNewsSubscriptionRetention = Number(env.NEWS_SUBSCRIPTION_RETENTION); +const resolvedNewsSubscriptionRetention = Number.isInteger(normalizedNewsSubscriptionRetention) && normalizedNewsSubscriptionRetention > 0 + ? normalizedNewsSubscriptionRetention + : 50; const truthyEnv = (value?: string | null): boolean => { if (!value) return false; @@ -48,18 +52,20 @@ export const config = { PORT: Number(env.PORT) || 3000, PB_URL: getEnv("PB_URL", "NEXT_PUBLIC_PB_URL") || "http://127.0.0.1:8090", PB_BINARY_PATH: env.PB_BINARY_PATH, + SHOUTRRR_BINARY_PATH: env.SHOUTRRR_BINARY_PATH, LOG_LEVEL: getLogLevel(), START_POCKETBASE: processStartPocketBase == null ? true : !(processStartPocketBase === "false" || processStartPocketBase === "0"), - SEARCHITEMS_SCHEDULE: env.SEARCHITEMS_SCHEDULE || "*/10 * * * *", + SHORTCUTS_SCHEDULE: env.SHORTCUTS_SCHEDULE || "*/10 * * * *", ENABLE_ICONS_REFRESH: env.ENABLE_ICONS_REFRESH === "true", PULL_ICONS_SCHEDULE: env.PULL_ICONS_SCHEDULE || "0 */18 * * *", MONITORING_INDEXER_SCHEDULE: env.MONITORING_INDEXER_SCHEDULE || "*/10 * * * *", MONITORING_RUNNER_SCHEDULE: env.MONITORING_RUNNER_SCHEDULE || "*/1 * * * *", UPDATE_CHECK_SCHEDULE: env.UPDATE_CHECK_SCHEDULE || "0 2 * * *", FEED_BUILDING_SCHEDULE: env.FEED_BUILDING_SCHEDULE || "*/30 * * * *", + NEWS_SUBSCRIPTION_RETENTION: resolvedNewsSubscriptionRetention, NOTIFICATION_FORWARDER_SCHEDULE: env.NOTIFICATION_FORWARDER_SCHEDULE || "* * * * *", DEFAULT_INTEGRATIONS_SCHEDULE: env.DEFAULT_INTEGRATIONS_SCHEDULE || "0 4 * * *", PAGECONFIG_CLEANUP_SCHEDULE: env.PAGECONFIG_CLEANUP_SCHEDULE || "0 5 * * *", @@ -76,9 +82,6 @@ export const config = { INSTANCE_NAME: getEnv("INSTANCE_NAME", "NEXT_PUBLIC_INSTANCE_NAME") || "Dashwise", DISABLE_USER_SIGNUP: truthyEnv(getEnv("DISABLE_USER_SIGNUP", "NEXT_PUBLIC_DISABLE_USER_SIGNUP")), ENABLE_SSO: truthyEnv(getEnv("ENABLE_SSO", "NEXT_PUBLIC_ENABLE_SSO")), - JOBS_URL: getEnv("JOBS_URL", "NEXT_PUBLIC_JOBS_URL") || "http://127.0.0.1:3001", - JOBS_WEBHOOK_URL: env.JOBS_WEBHOOK_URL || "http://jobs:3000/api/forward-notifications", - JOBS_WEBHOOK_ENABLED: truthyEnv(getEnv("JOBS_WEBHOOK_ENABLE", "NEXT_PUBLIC_JOBS_WEBHOOK_ENABLE")) || !!getEnv("JOBS_URL", "NEXT_PUBLIC_JOBS_URL"), DEFAULT_BG_URL: getEnv("DEFAULT_BG_URL", "NEXT_PUBLIC_DEFAULT_BG_URL") || "/dashboard-wallpaper.png", allowInsecureCertsForIntegrationUrls: truthyEnv(getEnv("ALLOW_INSECURE_CERTS_FOR_INTEGRATION_URLS", "NEXT_PUBLIC_INTEGRATIONS_ENABLE_SSL")) || truthyEnv(env.ALLOW_SSL), } as const; diff --git a/apps/backend/src/lib/data/auth.ts b/apps/backend/src/lib/data/auth.ts index e662e2b31..f4a5808ed 100644 --- a/apps/backend/src/lib/data/auth.ts +++ b/apps/backend/src/lib/data/auth.ts @@ -18,6 +18,7 @@ export class ApiActionError extends Error { export type ActionAuth = { token?: string | null; + sessionId?: string | null; }; export type JsonPrimitive = string | number | boolean | null; @@ -50,6 +51,7 @@ export type UserAppearancePreferences = { roundness?: number; }; frostedAppearance?: string; + linkTileStyle?: "default" | "compact"; themeMode?: string; wallpaperFilters?: { blur?: number; diff --git a/apps/backend/src/lib/data/links.ts b/apps/backend/src/lib/data/links.ts index 42220f1b1..e9502fe87 100644 --- a/apps/backend/src/lib/data/links.ts +++ b/apps/backend/src/lib/data/links.ts @@ -893,6 +893,59 @@ export async function deleteCollection( await pb.collection("linksLists").delete(listId); } +export async function wipeUserLinks( + userId: string, +): Promise<{ deletedCollections: number; deletedFolders: number; deletedItems: number }> { + const pb = getServerPB(); + const lists = await pb.collection("linksLists").getFullList({ + filter: `user = "${userId}"`, + }); + const userLists = lists.filter((list: any) => { + const type = String(list.type ?? "").trim().toLowerCase(); + const name = String(list.name ?? "").trim().toLowerCase(); + return type !== "home" && name !== "home"; + }); + + const records = await Promise.all( + userLists.map(async (list: any) => { + const [items, folders] = await Promise.all([ + pb.collection("linkItems").getFullList({ filter: `collection = "${list.id}"` }), + pb.collection("linksFolders").getFullList({ filter: `list = "${list.id}"` }), + ]); + return { items, folders, list }; + }), + ); + const items = records.flatMap(({ items: listItems }) => listItems); + const folders = records.flatMap(({ folders: listFolders }) => listFolders); + + try { + const monitorPB = await getSuperuserPB(); + const linkIds = new Set(items.map((item: any) => String(item.id || ""))); + const monitors = await monitorPB.collection("monitors").getFullList({ + filter: `userId = "${userId}"`, + }); + await Promise.all( + monitors + .filter((monitor: any) => linkIds.has(getMonitorLinkId(monitor))) + .map((monitor: any) => monitorPB.collection("monitors").delete(monitor.id)), + ); + } catch { + // Ignore monitoring cleanup failures; link wipe should continue. + } + + await Promise.all([ + ...items.map((item: any) => pb.collection("linkItems").delete(item.id)), + ...folders.map((folder: any) => pb.collection("linksFolders").delete(folder.id)), + ]); + await Promise.all(userLists.map((list: any) => pb.collection("linksLists").delete(list.id))); + + return { + deletedCollections: userLists.length, + deletedFolders: folders.length, + deletedItems: items.length, + }; +} + export async function createLinkItem(data: { url: string; title: string; diff --git a/apps/backend/src/lib/data/monitoring.ts b/apps/backend/src/lib/data/monitoring.ts index 9b20e1627..19962f7c7 100644 --- a/apps/backend/src/lib/data/monitoring.ts +++ b/apps/backend/src/lib/data/monitoring.ts @@ -1,4 +1,5 @@ import { config } from "../config"; +import { runStatusMonitoringJobsWithOptions } from "../../jobs/monitoring/runner"; import { decryptSecret, encryptSecret } from "../crypto"; import { getSuperuserPB } from "../pb/pocketbase"; import type { MonitorsResponse } from "@dashwise/types"; @@ -603,25 +604,12 @@ export async function runMonitoringStatus(userId: string, body: any) { return { _status: 404, error: "Monitoring job not found for this user" }; } - if (!config.JOBS_WEBHOOK_ENABLED) { - return { _status: 400, error: "Jobs webhook is disabled" }; - } - const targetMonitor = existingMonitors[0]; const sourceLinkId = targetMonitor.sourcelinkId || targetMonitor.linkId; - - const webhookUrl = `${config.JOBS_URL}/webhook/statusMonitoringRunner${ - sourceLinkId ? `?linkId=${encodeURIComponent(sourceLinkId)}` : "" - }`; - const webhookResponse = await fetch(webhookUrl, { - ...(webhookUrl.startsWith("https://") - ? { tls: { rejectUnauthorized: false } } - : {}), - } as any); - const webhookContentType = webhookResponse.headers.get("content-type") || ""; - const webhookData = webhookContentType.includes("application/json") - ? await webhookResponse.json() - : await webhookResponse.text(); + const runnerData = await runStatusMonitoringJobsWithOptions({ + source: targetMonitor.source, + linkId: sourceLinkId, + }); const refreshedMonitors = await pb .collection("monitors") @@ -629,9 +617,7 @@ export async function runMonitoringStatus(userId: string, body: any) { const refreshedMonitor = refreshedMonitors[0] || targetMonitor; const statusSummary = getLatestMonitorStatus(refreshedMonitor); - const runnerDetails = typeof webhookData === "object" && webhookData - ? (webhookData as any)?.result?.details - : undefined; + const runnerDetails = runnerData.details; const matchingRunnerDetail = Array.isArray(runnerDetails) ? runnerDetails.find((entry: any) => entry?.jobId === refreshedMonitor.id) || runnerDetails[0] : undefined; @@ -656,6 +642,6 @@ export async function runMonitoringStatus(userId: string, body: any) { httpStatus: matchingRunnerDetail?.httpStatus, method: matchingRunnerDetail?.method, result: matchingRunnerDetail, - webhookResult: webhookData, + runnerResult: runnerData, }; } diff --git a/apps/backend/src/lib/data/news.ts b/apps/backend/src/lib/data/news.ts index 7563e1c7f..4a1c1fc52 100644 --- a/apps/backend/src/lib/data/news.ts +++ b/apps/backend/src/lib/data/news.ts @@ -28,6 +28,7 @@ import { getNewsSubscriptionById, getNewsSubscriptionByUrl, createNewsFeedRecord, + deleteNewsFeedRecord, updateNewsFeedRecord, updateNewsSubscription, createNewsSubscription, @@ -35,6 +36,7 @@ import { import { getSuperuserPB } from "../pb/pocketbase"; import { deleteSubscriptionArticleIndex, + deleteMaterializedFeed, readFeedItemsCache, readMaterializedFeedItems, readMaterializedFeedPage, @@ -461,7 +463,7 @@ export function normalizeSubscription(entry: Record | null): Ne async function getUserFeeds(userId: string): Promise { const feeds = await getNewsFeedsByUserId(userId, 2000, { - fields: "id,userId,subscriptionRefs,title,icon,excludedSubscriptionRefs,maxFeedItems,feedType,systemKey", + fields: "id,userId,subscriptionRefs,includedFeedRefs,title,icon,excludedSubscriptionRefs,maxFeedItems,feedType,systemKey", }); return Array.isArray(feeds) ? feeds as NewsFeedRecord[] : []; } @@ -475,6 +477,9 @@ function buildFeedList(feeds: NewsFeedRecord[]) { id: String(feed.id), title: String(feed.title || "Untitled feed"), icon: String(feed.icon || "").trim(), + includedFeedRefs: Array.isArray(feed.includedFeedRefs) + ? feed.includedFeedRefs.map(String).filter(Boolean) + : [], })), ]; } @@ -570,6 +575,9 @@ function normalizeFeedRecord(entry: Record | null): NewsFeedRec excludedSubscriptionRefs: Array.isArray(entry.excludedSubscriptionRefs) ? entry.excludedSubscriptionRefs.map((value) => String(value).trim()).filter(Boolean) : [], + includedFeedRefs: Array.isArray(entry.includedFeedRefs) + ? entry.includedFeedRefs.map((value) => String(value).trim()).filter(Boolean) + : [], maxFeedItems: normalizeMaxFeedItems(entry.maxFeedItems), feedType: entry.feedType ? String(entry.feedType) : undefined, systemKey: entry.systemKey ? String(entry.systemKey) : undefined, @@ -604,6 +612,7 @@ export async function getNewsFeedRecord(userId: string, feedId: string): Promise feedType: "all", systemKey: "all", subscriptionRefs: [], + includedFeedRefs: [], excludedSubscriptionRefs: [], maxFeedItems: 200, }; @@ -649,6 +658,7 @@ export async function updateNewsFeedRecordForUser( systemKey: "all", ...(icon === undefined ? {} : { icon }), subscriptionRefs: [], + includedFeedRefs: [], excludedSubscriptionRefs, maxFeedItems, }); @@ -663,6 +673,7 @@ export async function updateNewsFeedRecordForUser( systemKey: "all", ...(icon === undefined ? {} : { icon }), subscriptionRefs: [], + includedFeedRefs: [], excludedSubscriptionRefs, maxFeedItems, }); @@ -676,11 +687,43 @@ export async function updateNewsFeedRecordForUser( const ownerId = String((feedRecord as Record).userId ?? "").trim(); if (ownerId && ownerId !== userId) return null; + const requestedIncludedFeedRefs = Array.from( + new Set((payload.includedFeedRefs ?? (feedRecord.includedFeedRefs ?? [])) + .map((value: string) => String(value).trim()).filter(Boolean)), + ); + + const userFeeds = await getUserFeeds(userId); + const availableFeedIds = new Set(userFeeds + .filter((feed) => !isAllNewsFeed(feed) && String(feed.id) !== normalizedFeedId) + .map((feed) => String(feed.id))); + const includedFeedRefs = requestedIncludedFeedRefs.filter((feedId) => availableFeedIds.has(feedId)); + const feedById = new Map(userFeeds.map((feed) => [String(feed.id), feed])); + const visiting = new Set(); + const visited = new Set(); + const hasCycle = (currentId: string): boolean => { + if (visiting.has(currentId)) return true; + if (visited.has(currentId)) return false; + visiting.add(currentId); + const current = currentId === normalizedFeedId + ? { includedFeedRefs } + : feedById.get(currentId); + for (const childId of current?.includedFeedRefs ?? []) { + if (childId === "all" || hasCycle(String(childId))) return true; + } + visiting.delete(currentId); + visited.add(currentId); + return false; + }; + if (hasCycle(normalizedFeedId)) { + throw new Error("Feed hierarchy cannot contain cycles"); + } + const updated = await updateNewsFeedRecord(normalizedFeedId, { title: title || String((feedRecord as Record).title ?? "").trim(), feedType: "custom", ...(icon === undefined ? {} : { icon }), subscriptionRefs, + includedFeedRefs, excludedSubscriptionRefs, maxFeedItems, }); @@ -688,6 +731,38 @@ export async function updateNewsFeedRecordForUser( return updated; } +export async function deleteNewsFeedRecordForUser(userId: string, feedId: string) { + const normalizedFeedId = String(feedId || "").trim(); + if (!normalizedFeedId || normalizedFeedId === "all") { + throw new Error("All feed cannot be deleted"); + } + + const feedRecord = (await getNewsFeedById(normalizedFeedId).catch(() => null)) as NewsFeedRecord | null; + if (!feedRecord) return null; + + const ownerId = String((feedRecord as Record).userId ?? "").trim(); + if (ownerId && ownerId !== userId) return null; + if (isAllNewsFeed(feedRecord)) { + throw new Error("All feed cannot be deleted"); + } + + const deleted = await deleteNewsFeedRecord(normalizedFeedId); + if (!deleted) { + throw new Error("Unable to delete feed"); + } + + const userFeeds = await getUserFeeds(userId); + await Promise.all(userFeeds + .filter((feed) => Array.isArray(feed.includedFeedRefs) && feed.includedFeedRefs.includes(normalizedFeedId)) + .map((feed) => updateNewsFeedRecord(String(feed.id), { + includedFeedRefs: feed.includedFeedRefs?.filter((childId) => String(childId) !== normalizedFeedId) ?? [], + }))); + + await deleteMaterializedFeed(userId, normalizedFeedId); + void rebuildNewsViews(userId).catch(() => undefined); + return { id: normalizedFeedId }; +} + export async function createNewsFeedRecordForUser( userId: string, payload: NewsFeedRecordCreateInput, @@ -703,6 +778,7 @@ export async function createNewsFeedRecordForUser( feedType: "all", systemKey: "all", subscriptionRefs: [], + includedFeedRefs: [], excludedSubscriptionRefs: [], maxFeedItems: 200, }) as Promise; @@ -719,6 +795,7 @@ export async function createNewsFeedRecordForUser( feedType: "custom", icon: String(payload.icon ?? "").trim(), subscriptionRefs: [], + includedFeedRefs: [], excludedSubscriptionRefs: [], maxFeedItems: 200, })) as NewsFeedRecord; @@ -752,6 +829,7 @@ async function syncSubscriptionFeedRefs( title, feedType: "custom", subscriptionRefs: [subscriptionId], + includedFeedRefs: [], excludedSubscriptionRefs: [], })) as NewsFeedRecord; selectedIds.add(String(createdFeed.id)); diff --git a/apps/backend/src/lib/data/notifications/forwarders.ts b/apps/backend/src/lib/data/notifications/forwarders.ts index 9b39c8832..8e538e886 100644 --- a/apps/backend/src/lib/data/notifications/forwarders.ts +++ b/apps/backend/src/lib/data/notifications/forwarders.ts @@ -1,5 +1,6 @@ import { getSuperuserPB } from "../../pb/pocketbase"; import type { NotificationForwardersResponse, NotificationTopicsResponse } from "@dashwise/types"; +import { sendViaShoutrrr } from "./forwarding"; export async function getForwarders(userId: string) { const pb = await getSuperuserPB(); @@ -72,3 +73,21 @@ export async function deleteForwarder(userId: string, forwarderId: string) { await pb.collection("notificationForwarders").delete(forwarderId); return { success: true }; } + +export async function testForwarder(userId: string, forwarderId: string) { + const pb = await getSuperuserPB(); + + const forwarderRecord = (await pb.collection("notificationForwarders").getOne(forwarderId)) as NotificationForwardersResponse; + const topicRecord = (await pb.collection("notificationTopics").getOne(forwarderRecord.topic)) as NotificationTopicsResponse; + if (!topicRecord || topicRecord.userId !== userId) { + throw new Error("Forwarder not found or not owned by user"); + } + + await sendViaShoutrrr(forwarderRecord.target, "This is a test notification from Dashwise."); + return { ok: true }; +} + +export async function testForwarderTarget(target: string) { + await sendViaShoutrrr(target, "This is a test notification from Dashwise."); + return { ok: true }; +} diff --git a/apps/backend/src/lib/data/notifications/forwarding.ts b/apps/backend/src/lib/data/notifications/forwarding.ts index b8b3cef91..613802283 100644 --- a/apps/backend/src/lib/data/notifications/forwarding.ts +++ b/apps/backend/src/lib/data/notifications/forwarding.ts @@ -1,7 +1,8 @@ -import { exec } from "child_process"; +import { execFile } from "child_process"; import { promisify } from "util"; +import { config } from "../../config"; -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); export function formatNotificationMessage(content: unknown): string { if (typeof content === "string") { @@ -38,16 +39,11 @@ export async function sendViaShoutrrr(target: string, message: string): Promise< throw new Error("Invalid target"); } - const safeTarget = target.replace(/'/g, "'\\''"); - const safeMessage = message.replace(/'/g, "'\\''"); - try { - const { stderr } = await execAsync( - `shoutrrr send --url '${safeTarget}' --message '${safeMessage}'`, - { - env: { ...process.env }, - timeout: 30000, - } + const { stderr } = await execFileAsync( + config.SHOUTRRR_BINARY_PATH || "shoutrrr", + ["send", "--url", target, "--message", message], + { env: { ...process.env }, timeout: 30000 }, ); if (stderr) { diff --git a/apps/backend/src/lib/data/notifications/items.ts b/apps/backend/src/lib/data/notifications/items.ts index a04a80d72..3c51bad05 100644 --- a/apps/backend/src/lib/data/notifications/items.ts +++ b/apps/backend/src/lib/data/notifications/items.ts @@ -1,5 +1,5 @@ import { getSuperuserPB } from "../../pb/pocketbase"; -import { config } from "../../config"; +import { queueNotificationForForwarding } from "./publish"; import type { NotificationForwardersResponse, NotificationItemsResponse, @@ -151,15 +151,7 @@ export async function sendTestNotification(userId: string, topicId: string) { forwardStatus: "queued", }); - try { - const jobsUrl = config.JOBS_WEBHOOK_URL; - await fetch(jobsUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ trigger: "notification-queued", itemId: item.id }), - }); - } catch { - } + await queueNotificationForForwarding(item.id); return { ok: true, itemId: item.id }; } diff --git a/apps/backend/src/lib/data/notifications/publish.ts b/apps/backend/src/lib/data/notifications/publish.ts index 547c240dd..26bbab78e 100644 --- a/apps/backend/src/lib/data/notifications/publish.ts +++ b/apps/backend/src/lib/data/notifications/publish.ts @@ -1,5 +1,6 @@ import { getServerPB, getSuperuserPB } from "../../pb/pocketbase"; -import { config } from "../../config"; +import { broadcastActivity } from "../../activity"; +import { processQueuedNotifications } from "../../../jobs/notifications/forwarder"; import { resolveTopicToken } from "./topicTokens"; import type { NotificationItemsResponse, NotificationTopicsResponse } from "@dashwise/types"; @@ -24,6 +25,8 @@ export async function createNotificationWithTopicToken(topicToken: string, conte source: "token", forwardStatus: "none", })) as NotificationItemsResponse; + broadcastActivity(resolved.userId); + await queueNotificationForForwarding(createdItem.id); return { topicId: resolved.topicId, @@ -62,6 +65,8 @@ export async function createNotificationForUserTopic(input: PublishToUserTopicIn source, forwardStatus: "none", })) as NotificationItemsResponse; + broadcastActivity(userId); + await queueNotificationForForwarding(notificationItem.id); return { topicId, @@ -75,6 +80,7 @@ export async function createNotificationByTopicId( source = "system", ) { const pb = await getSuperuserPB(); + const topicRecord = (await pb.collection("notificationTopics").getOne(topicId)) as NotificationTopicsResponse; const notificationItem = (await pb.collection("notificationItems").create({ topicId, content, @@ -82,6 +88,8 @@ export async function createNotificationByTopicId( source, forwardStatus: "none", })) as NotificationItemsResponse; + broadcastActivity(topicRecord.userId); + await queueNotificationForForwarding(notificationItem.id); return { topicId, @@ -94,14 +102,5 @@ export async function queueNotificationForForwarding(itemId: string) { await pb.collection("notificationItems").update(itemId, { forwardStatus: "queued", }); - - try { - const jobsUrl = config.JOBS_WEBHOOK_URL; - await fetch(jobsUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ trigger: "notification-queued", itemId }), - }); - } catch { - } + await processQueuedNotifications(); } diff --git a/apps/backend/src/lib/data/notifications/topicTokens.ts b/apps/backend/src/lib/data/notifications/topicTokens.ts index 6a17b2f53..b5c4ba369 100644 --- a/apps/backend/src/lib/data/notifications/topicTokens.ts +++ b/apps/backend/src/lib/data/notifications/topicTokens.ts @@ -103,6 +103,42 @@ export async function deleteTopicToken(userId: string, tokenId: string) { return { success: true }; } +export async function updateTopicToken(userId: string, body: any) { + const pb = await getSuperuserPB(); + const { tokenId, topicId, expires } = body; + + const tokenRecord = (await pb.collection("notificationTopicTokens").getOne(tokenId)) as NotificationTopicTokensResponse; + const currentTopic = (await pb.collection("notificationTopics").getOne(tokenRecord.topic)) as NotificationTopicsResponse; + if (!currentTopic || currentTopic.userId !== userId) { + throw new Error("Token not found or not owned by user"); + } + + const updatePayload: Record = {}; + let topicRecord = currentTopic; + if (topicId !== undefined && topicId !== tokenRecord.topic) { + topicRecord = (await pb.collection("notificationTopics").getOne(topicId)) as NotificationTopicsResponse; + if (!topicRecord || topicRecord.userId !== userId) { + throw new ApiActionError("Topic not found", 404, { error: "Topic not found" }); + } + updatePayload.topic = topicRecord.id; + } + + if (Object.prototype.hasOwnProperty.call(body, "expires")) { + if (!expires) { + updatePayload.expires = null; + } else { + const expiresDate = new Date(expires); + if (Number.isNaN(expiresDate.getTime())) { + throw new ApiActionError("Invalid expiry date", 400, { error: "Invalid expiry date" }); + } + updatePayload.expires = expiresDate.toISOString(); + } + } + + const updated = (await pb.collection("notificationTopicTokens").update(tokenId, updatePayload)) as NotificationTopicTokensResponse; + return { item: { ...updated, topic: topicRecord } }; +} + export async function resolveTopicToken(token: string) { const pb = await getSuperuserPB(); const records = (await pb.collection("notificationTopicTokens").getFullList({ diff --git a/apps/backend/src/lib/data/searchItems.ts b/apps/backend/src/lib/data/searchItems.ts deleted file mode 100644 index 7ec2366ca..000000000 --- a/apps/backend/src/lib/data/searchItems.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { getSuperuserPB } from "../pb/pocketbase"; -import type { SearchItemsResponse } from "@dashwise/types"; - -function parseTags(value: unknown): string[] { - if (Array.isArray(value)) { - return value - .map((entry) => String(entry ?? "").trim()) - .filter((entry): entry is string => entry.length > 0); - } - - if (typeof value === "string") { - const trimmed = value.trim(); - if (!trimmed) return []; - try { - const parsed = JSON.parse(trimmed); - if (Array.isArray(parsed)) { - return parsed - .map((entry) => String(entry ?? "").trim()) - .filter((entry): entry is string => entry.length > 0); - } - } catch { - return [trimmed]; - } - } - - return []; -} - -export async function getSearchItems(userId: string) { - const pb = await getSuperuserPB(); - const records = (await pb.collection("searchItems").getFullList(1000, { - filter: `user=\"${userId.replace(/"/g, '\\"')}\"`, - sort: "name", - })) as Array; - - return records.map((record) => { - const action = parseAction(record.action); - const actionString = typeof action === "string" ? action : ""; - return { - id: record.id, - parentId: - typeof record.app === "string" && record.app.trim().length > 0 - ? record.app.trim() - : undefined, - name: String(record.name ?? ""), - icon: String(record.icon ?? ""), - secondaryInfo: String(record.secondary ?? ""), - type: actionString.startsWith("app:") ? "app" : "link", - action, - tags: parseTags(record.tags), - isPinned: Boolean(record.isPinned), - usageStats: record.usageStats, - }; - }); -} - -type SearchItemAction = string | { - type: string; - url?: string; - proxy?: boolean; -}; - -function parseAction(raw: unknown): SearchItemAction { - if (typeof raw !== "string") return ""; - const trimmed = raw.trim(); - if (!trimmed) return ""; - - if (trimmed.toLowerCase().startsWith("post:")) { - const url = trimmed.slice(5).trim(); - return { type: "post", url, proxy: true }; - } - - if (trimmed.startsWith("{") || trimmed.startsWith("[")) { - try { - const parsed = JSON.parse(trimmed); - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - const type = String((parsed as any).type ?? "").trim().toLowerCase(); - if (type) { - const url = typeof (parsed as any).url === "string" ? (parsed as any).url : undefined; - if (type === "post") { - return { type: "post", url, proxy: true }; - } - return { type, url }; - } - } - } catch { - } - } - - return trimmed; -} diff --git a/apps/backend/src/lib/data/sessions.ts b/apps/backend/src/lib/data/sessions.ts new file mode 100644 index 000000000..074925499 --- /dev/null +++ b/apps/backend/src/lib/data/sessions.ts @@ -0,0 +1,146 @@ +import type { RecordModel } from "pocketbase"; + +import { ApiActionError } from "./auth"; + +export type SessionRecord = { + id: string; + user: string; + sessionId: string; + displayName: string; + clientType?: string; + platform?: string; + lastSeenAt: string; + created?: string; + updated?: string; +}; + +type SessionMetadata = { + clientType?: string; + platform?: string; +}; + +const SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; +const DEFAULT_DISPLAY_NAME = "Web browser"; + +export function normalizeSessionId(value: unknown) { + const sessionId = typeof value === "string" ? value.trim() : ""; + return SESSION_ID_PATTERN.test(sessionId) ? sessionId : null; +} + +export async function getSessionById( + pb: { collection: (name: "sessions") => any }, + userId: string, + rawSessionId: unknown, +) { + const sessionId = normalizeSessionId(rawSessionId); + if (!sessionId) return null; + + try { + return toSessionRecord(await pb.collection("sessions").getFirstListItem( + `user = "${escapeFilter(userId)}" && sessionId = "${escapeFilter(sessionId)}"`, + )); + } catch { + return null; + } +} + +function normalizeMetadata(metadata?: SessionMetadata) { + return { + ...(metadata?.clientType?.trim() ? { clientType: metadata.clientType.trim().slice(0, 100) } : {}), + ...(metadata?.platform?.trim() ? { platform: metadata.platform.trim().slice(0, 100) } : {}), + }; +} + +function toSessionRecord(record: RecordModel) { + return record as unknown as SessionRecord; +} + +export async function ensureSession( + pb: { collection: (name: "sessions") => any }, + userId: string, + rawSessionId: unknown, + metadata?: SessionMetadata, +) { + const sessionId = normalizeSessionId(rawSessionId); + if (!sessionId) return null; + + const now = new Date().toISOString(); + const collection = pb.collection("sessions"); + const filter = `user = "${escapeFilter(userId)}" && sessionId = "${escapeFilter(sessionId)}"`; + const normalizedMetadata = normalizeMetadata(metadata); + + let session: RecordModel | null = null; + try { + session = await collection.getFirstListItem(filter); + } catch { + // A missing record is created below. Other read errors are surfaced by create/update. + } + + if (session) { + return toSessionRecord(await collection.update(session.id, { + lastSeenAt: now, + ...normalizedMetadata, + })); + } + + try { + return toSessionRecord(await collection.create({ + user: userId, + sessionId, + displayName: DEFAULT_DISPLAY_NAME, + lastSeenAt: now, + ...normalizedMetadata, + })); + } catch (error) { + // Another request from the same client may have won the race to create the unique pair. + try { + const existing = await collection.getFirstListItem(filter); + return toSessionRecord(await collection.update(existing.id, { + lastSeenAt: now, + ...normalizedMetadata, + })); + } catch { + throw error; + } + } +} + +function escapeFilter(value: string) { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +export async function getCurrentSession( + pb: { collection: (name: "sessions") => any }, + userId: string, + rawSessionId: unknown, + metadata?: SessionMetadata, +) { + const session = await ensureSession(pb, userId, rawSessionId, metadata); + if (!session) { + throw new ApiActionError("A valid session id is required", 400, { + error: "A valid session id is required", + }); + } + return session; +} + +export async function renameCurrentSession( + pb: { collection: (name: "sessions") => any }, + userId: string, + rawSessionId: unknown, + displayName: unknown, + metadata?: SessionMetadata, +) { + const session = await getCurrentSession(pb, userId, rawSessionId, metadata); + const normalizedName = typeof displayName === "string" ? displayName.trim() : ""; + if (!normalizedName || normalizedName.length > 100) { + throw new ApiActionError("Display name must be between 1 and 100 characters", 400, { + error: "Display name must be between 1 and 100 characters", + }); + } + + return toSessionRecord(await pb.collection("sessions").update(session.id, { + displayName: normalizedName, + lastSeenAt: new Date().toISOString(), + })); +} diff --git a/apps/backend/src/lib/data/shortcuts.ts b/apps/backend/src/lib/data/shortcuts.ts new file mode 100644 index 000000000..fcf676164 --- /dev/null +++ b/apps/backend/src/lib/data/shortcuts.ts @@ -0,0 +1,409 @@ +import { randomUUID } from "node:crypto"; + +import { ApiActionError } from "./auth"; +import { + isSessionConnected, + requestShortcutExecution, + type ShortcutExecutionResult, +} from "../activity"; +import { getSessionById } from "./sessions"; +import { getSuperuserPB } from "../pb/pocketbase"; +import type { ShortcutsResponse } from "@dashwise/types"; + +export type ShortcutAppType = "just-in-time" | "on-demand"; + +export type ShortcutAppDetails = { + name: string; + icon?: string; +}; + +export type OnDemandShortcutInput = { + sourceId: string; + name: string; + icon?: string; + secondary?: string; + action: string; + tags?: string[]; +}; + +export type RoutedShortcutAction = { + sessionId: string; + shortcutId: string; +}; + +export function escapeFilter(value: string) { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +export function parseTags(value: unknown): string[] { + if (Array.isArray(value)) { + return value + .map((entry) => String(entry ?? "").trim()) + .filter((entry): entry is string => entry.length > 0); + } + + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) return []; + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) { + return parsed + .map((entry) => String(entry ?? "").trim()) + .filter((entry): entry is string => entry.length > 0); + } + } catch { + return [trimmed]; + } + } + + return []; +} + +export function parseRoutedShortcutAction(value: unknown): RoutedShortcutAction | null { + if (typeof value !== "string") return null; + const match = /^shortcut:([^\.]+)\.(.+)$/i.exec(value.trim()); + if (!match) return null; + + const sessionId = match[1].trim(); + const shortcutId = match[2].trim(); + if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(sessionId)) return null; + if (!shortcutId || shortcutId.length > 512 || hasControlCharacter(shortcutId)) return null; + return { sessionId, shortcutId }; +} + +function hasControlCharacter(value: string) { + return [...value].some((character) => { + const code = character.charCodeAt(0); + return code < 32 || code === 127; + }); +} + +export async function executeRoutedShortcut(userId: string, action: unknown) { + const target = parseRoutedShortcutAction(action); + if (!target) { + throw new ApiActionError("Invalid shortcut action", 400, { + error: "Invalid shortcut action", + }); + } + + const pb = await getSuperuserPB(); + const session = await getSessionById(pb, userId, target.sessionId); + if (!session) { + throw new ApiActionError("Target session is unavailable", 404, { + error: "Target session is unavailable", + }); + } + if (!isSessionConnected(userId, target.sessionId)) { + throw new ApiActionError("Target session is offline", 503, { + error: "Target session is offline", + }); + } + + const result: ShortcutExecutionResult = await requestShortcutExecution( + userId, + target.sessionId, + target.shortcutId, + ); + if (!result.success) { + throw new ApiActionError(result.error ?? "Shortcut execution failed", 502, { + error: result.error ?? "Shortcut execution failed", + requestId: result.requestId, + }); + } + + return result; +} + +export async function getShortcuts(userId: string) { + const pb = await getSuperuserPB(); + const records = (await pb.collection("shortcuts").getFullList(1000, { + filter: `user="${escapeFilter(userId)}"`, + sort: "name", + })) as Array; + + return records.map((record) => { + const action = parseAction(record.action); + const actionString = typeof action === "string" ? action : ""; + return { + id: record.id, + parentId: + typeof record.app === "string" && record.app.trim().length > 0 + ? record.app.trim() + : undefined, + name: String(record.name ?? ""), + icon: String(record.icon ?? ""), + secondaryInfo: String(record.secondary ?? ""), + type: actionString.startsWith("app:") ? "app" : "link", + action, + tags: parseTags(record.tags), + isPinned: Boolean(record.isPinned), + usageStats: record.usageStats, + }; + }); +} + +export async function ensureShortcutsApp( + pb: any, + userId: string, + sourceId: string, + details: ShortcutAppDetails, + type: ShortcutAppType = "just-in-time", +) { + const filter = `user="${escapeFilter(userId)}" && sourceId="${escapeFilter(sourceId)}"`; + const records = await pb.collection("shortcutsApps").getFullList(10, { + filter, + sort: "created", + }); + const existing = records[0]; + if (existing) { + const updates: Record = {}; + if (details.name && existing.name !== details.name) updates.name = details.name; + if (details.icon !== undefined && existing.icon !== details.icon) updates.icon = details.icon; + if (Object.keys(updates).length > 0) { + return pb.collection("shortcutsApps").update(existing.id, updates); + } + return existing; + } + + return pb.collection("shortcutsApps").create({ + user: userId, + sourceId, + name: details.name, + type, + icon: details.icon ?? "", + }); +} + +export async function getOnDemandShortcutApp(userId: string, appId: string) { + const pb = await getSuperuserPB(); + let appRecord: any; + try { + appRecord = await pb.collection("shortcutsApps").getOne(appId); + } catch { + throw new ApiActionError("Shortcut app not found", 404, { + error: "Shortcut app not found", + }); + } + + if (String(appRecord.user ?? "") !== userId) { + throw new ApiActionError("Shortcut app not found", 404, { + error: "Shortcut app not found", + }); + } + if (appRecord.type !== "on-demand") { + throw new ApiActionError("Shortcut app is not on-demand", 409, { + error: "Shortcut app is not on-demand", + }); + } + + return { pb, appRecord }; +} + +export async function createOnDemandShortcutApp( + userId: string, + input: { name?: unknown; type?: unknown; icon?: unknown }, +) { + const name = typeof input.name === "string" ? input.name.trim() : ""; + if (!name) { + throw new ApiActionError("A shortcut app name is required", 400, { + error: "A shortcut app name is required", + }); + } + if (input.type !== "on-demand") { + throw new ApiActionError("Only on-demand shortcut apps can be created here", 400, { + error: "type must be on-demand", + }); + } + + const icon = typeof input.icon === "string" ? input.icon.trim() : ""; + const sourceId = `on-demand:${randomUUID()}`; + const pb = await getSuperuserPB(); + const record = await pb.collection("shortcutsApps").create({ + user: userId, + sourceId, + name, + type: "on-demand", + icon, + }); + await pb.collection("shortcuts").create({ + user: userId, + name, + icon, + secondary: "Shortcut app", + action: `app:${record.id}`, + app: null, + sourceId: `shortcuts-app:${record.id}`, + tags: [name, "shortcut app"], + }); + + return { + appId: record.id, + id: record.id, + name: record.name, + type: record.type, + }; +} + +export async function syncOnDemandShortcuts( + userId: string, + appId: string, + rawShortcuts: unknown, +) { + if (!Array.isArray(rawShortcuts)) { + throw new ApiActionError("shortcuts must be an array", 400, { + error: "shortcuts must be an array", + }); + } + + const { pb } = await getOnDemandShortcutApp(userId, appId); + const shortcuts = rawShortcuts.map(normalizeOnDemandShortcut); + const sourceIds = new Set(); + for (const shortcut of shortcuts) { + if (sourceIds.has(shortcut.sourceId)) { + throw new ApiActionError("Shortcut sourceId values must be unique", 400, { + error: `Duplicate shortcut sourceId: ${shortcut.sourceId}`, + }); + } + sourceIds.add(shortcut.sourceId); + } + + const filter = `user="${escapeFilter(userId)}" && app="${escapeFilter(appId)}"`; + const existing = await pb.collection("shortcuts").getFullList(10000, { + filter, + }); + const existingBySourceId = new Map(); + for (const record of existing) { + const sourceId = String(record.sourceId ?? ""); + if (sourceId) { + existingBySourceId.set(sourceId, record); + } else { + await pb.collection("shortcuts").delete(record.id); + } + } + + let created = 0; + let updated = 0; + for (const shortcut of shortcuts) { + const current = existingBySourceId.get(shortcut.sourceId); + const data = { + user: userId, + app: appId, + sourceId: shortcut.sourceId, + name: shortcut.name, + icon: shortcut.icon, + secondary: shortcut.secondary, + action: shortcut.action, + tags: shortcut.tags, + }; + + if (!current) { + await pb.collection("shortcuts").create(data); + created += 1; + continue; + } + + if (!sameShortcutData(current, shortcut)) { + await pb.collection("shortcuts").update(current.id, { + name: data.name, + icon: data.icon, + secondary: data.secondary, + action: data.action, + tags: data.tags, + }); + updated += 1; + } + existingBySourceId.delete(shortcut.sourceId); + } + + let deleted = 0; + for (const record of existingBySourceId.values()) { + await pb.collection("shortcuts").delete(record.id); + deleted += 1; + } + + return { appId, total: shortcuts.length, created, updated, deleted }; +} + +function normalizeOnDemandShortcut(raw: unknown): Required { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new ApiActionError("Each shortcut must be an object", 400, { + error: "Each shortcut must be an object", + }); + } + + const input = raw as Record; + const sourceId = stringInput(input.sourceId); + const name = stringInput(input.name); + const action = stringInput(input.action); + if (!sourceId || !name || !action) { + throw new ApiActionError("Each shortcut requires sourceId, name, and action", 400, { + error: "Each shortcut requires sourceId, name, and action", + }); + } + + const tags = input.tags === undefined ? [] : parseTags(input.tags); + if (input.tags !== undefined && !Array.isArray(input.tags) && typeof input.tags !== "string") { + throw new ApiActionError("Shortcut tags must be an array", 400, { + error: "Shortcut tags must be an array", + }); + } + + return { + sourceId, + name, + icon: stringInput(input.icon), + secondary: stringInput(input.secondary), + action, + tags, + }; +} + +function stringInput(value: unknown) { + return typeof value === "string" ? value.trim() : ""; +} + +function sameShortcutData(record: any, shortcut: Required) { + return String(record.name ?? "") === shortcut.name && + String(record.icon ?? "") === shortcut.icon && + String(record.secondary ?? "") === shortcut.secondary && + String(record.action ?? "") === shortcut.action && + JSON.stringify(parseTags(record.tags)) === JSON.stringify(shortcut.tags); +} + +type ShortcutAction = string | { + type: string; + url?: string; + proxy?: boolean; +}; + +function parseAction(raw: unknown): ShortcutAction { + if (typeof raw !== "string") return ""; + const trimmed = raw.trim(); + if (!trimmed) return ""; + + if (trimmed.toLowerCase().startsWith("post:")) { + const url = trimmed.slice(5).trim(); + return { type: "post", url, proxy: true }; + } + + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + const parsed = JSON.parse(trimmed); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + const type = String((parsed as any).type ?? "").trim().toLowerCase(); + if (type) { + const url = typeof (parsed as any).url === "string" ? (parsed as any).url : undefined; + if (type === "post") { + return { type: "post", url, proxy: true }; + } + return { type, url }; + } + } + } catch { + // Keep malformed action JSON as a literal string. + } + } + + return trimmed; +} diff --git a/apps/backend/src/lib/data/superuser.ts b/apps/backend/src/lib/data/superuser.ts index 839f16ad8..333d8b5d9 100644 --- a/apps/backend/src/lib/data/superuser.ts +++ b/apps/backend/src/lib/data/superuser.ts @@ -143,6 +143,13 @@ export async function updateNewsFeedRecord( return safeNull((pb) => pb.collection("newsFeeds").update(feedId, payload)); } +export async function deleteNewsFeedRecord(feedId: string) { + return safeNull(async (pb) => { + await pb.collection("newsFeeds").delete(feedId); + return true; + }); +} + export async function getAllNewsSubscriptions(batchSize = 2000, options?: Record) { return safeNull((pb) => pb.collection("newsSubscriptions").getFullList(batchSize, options)); } diff --git a/apps/backend/src/lib/news/topic-suggestions.test.ts b/apps/backend/src/lib/news/topic-suggestions.test.ts new file mode 100644 index 000000000..427ac06ad --- /dev/null +++ b/apps/backend/src/lib/news/topic-suggestions.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test"; +import { suggestCommonBlacklistWords } from "./topic-suggestions"; + +describe("suggestCommonBlacklistWords", () => { + test("ranks words shared by multiple article contents and excludes stop words", () => { + const suggestions = suggestCommonBlacklistWords([ + { + title: "Acme security update", + description: "Acme reports a security release for cloud teams.", + content: "Read the full Acme security report.", + }, + { + title: "Acme cloud platform news", + description: "The Acme platform adds another security feature.", + content: "Subscribe for more Acme coverage.", + }, + { + title: "Acme hardware launch", + description: "Acme launches hardware for developers.", + }, + ]); + + expect(suggestions[0]).toBe("acme"); + expect(suggestions).toContain("security"); + expect(suggestions).not.toContain("the"); + expect(suggestions).not.toContain("read"); + }); + + test("does not suggest words found in only one article", () => { + expect(suggestCommonBlacklistWords([ + { title: "Unique alpha", description: "Shared briefing" }, + { title: "Unique beta", description: "Shared briefing" }, + ])).not.toContain("alpha"); + }); +}); diff --git a/apps/backend/src/lib/news/topic-suggestions.ts b/apps/backend/src/lib/news/topic-suggestions.ts new file mode 100644 index 000000000..5d77e04ab --- /dev/null +++ b/apps/backend/src/lib/news/topic-suggestions.ts @@ -0,0 +1,94 @@ +const suggestionStopWords = new Set([ + "about", "after", "again", "also", "amid", "because", "before", "being", "between", "both", "can", + "could", "does", "from", "have", "into", "just", "more", "news", "over", "said", "says", "that", "their", + "there", "this", "through", "update", "using", "what", "when", "where", "which", "while", "with", "will", + "would", "your", "the", "and", "for", "are", "but", "not", "you", "all", "any", "was", "one", "our", "out", + "day", "get", "has", "him", "his", "how", "its", "may", "new", "now", "old", "see", "two", "who", "why", "via", + "able", "a", "an", "as", "at", "be", "by", "do", "go", "he", "if", "in", "is", "it", "me", "my", "no", "of", "on", "or", + "so", "to", "up", "us", "we", "read", "more", "click", "continue", "share", "subscribe", "copyright", "comment", "comments", + "http", "https", "www", "com", "org", "net", "amp", "nbsp", "quot", +]); + +function stripHtml(value: string) { + return value + .replace(//gi, " ") + .replace(//gi, " ") + .replace(/<[^>]*>/g, " "); +} + +function textValue(value: unknown): string { + if (Array.isArray(value)) return value.map(textValue).join(" "); + if (value && typeof value === "object") { + const record = value as Record; + return textValue(record._ ?? record["#"] ?? record.value ?? ""); + } + return typeof value === "string" || typeof value === "number" ? String(value) : ""; +} + +function articleText(item: unknown) { + if (!item || typeof item !== "object") return ""; + + const record = item as Record; + return [ + record.title, + record.description, + record.summary, + record.content, + record["content:encoded"], + record["media:description"], + record.categories, + record.category, + record.tags, + ].map(textValue).join(" "); +} + +/** + * Finds feed-specific words that occur in several articles. These are useful + * blacklist candidates because they are likely to describe the publication + * rather than distinguish one story from another. + */ +export function suggestCommonBlacklistWords(items: unknown[], limit = 18): string[] { + const articles = items + .slice(0, 100) + .map(articleText) + .map((text) => stripHtml(text).toLowerCase()) + .filter(Boolean); + if (!articles.length) return []; + + const counts = new Map(); + + for (const text of articles) { + const words = text.split(/[^\p{L}\p{N}]+/gu); + const seen = new Set(); + + for (const rawWord of words) { + const word = rawWord.trim(); + if (word.length < 3 || suggestionStopWords.has(word) || /^\d+$/.test(word)) continue; + seen.add(word); + } + + for (const word of seen) { + const entry = counts.get(word) ?? { articles: 0, occurrences: 0 }; + entry.articles++; + counts.set(word, entry); + } + + for (const rawWord of words) { + const word = rawWord.trim(); + const entry = counts.get(word); + if (entry) entry.occurrences++; + } + } + + const minimumArticles = Math.max(2, Math.ceil(articles.length * 0.1)); + + return Array.from(counts.entries()) + .filter(([, countsForWord]) => countsForWord.articles >= minimumArticles) + .sort(([leftWord, left], [rightWord, right]) => + right.articles - left.articles || + right.occurrences - left.occurrences || + leftWord.localeCompare(rightWord), + ) + .slice(0, limit) + .map(([word]) => word); +} diff --git a/apps/backend/src/routes/auth.route.ts b/apps/backend/src/routes/auth.route.ts index dc71dc7fc..7dd61134d 100644 --- a/apps/backend/src/routes/auth.route.ts +++ b/apps/backend/src/routes/auth.route.ts @@ -122,7 +122,7 @@ authRoute.get( "/api/v1/auth/validate-auth", withJson(async (c) => { const body = await readJsonBody< - { token?: string; auth?: { token?: string | null } } + { token?: string; auth?: { token?: string | null; sessionId?: string | null } } >(c); return validateAuthToken(String(body?.token ?? body?.auth?.token ?? "")); }), diff --git a/apps/backend/src/routes/data.route.ts b/apps/backend/src/routes/data.route.ts index c0b123da5..bc40b420a 100644 --- a/apps/backend/src/routes/data.route.ts +++ b/apps/backend/src/routes/data.route.ts @@ -8,7 +8,7 @@ import notificationsRoute from "./notifications.route"; import pageConfigRoute from "./pageConfig.route"; import wallpapersRoute from "./wallpapers.route"; import widgetsRoute from "./widgets.route"; -import searchItemsRoute from "./searchItems.route"; +import shortcutsRoute from "./shortcuts.route"; const dataRoute = new Hono(); @@ -20,6 +20,6 @@ dataRoute.route("/", newsRoute); dataRoute.route("/", notificationsRoute); dataRoute.route("/", monitoringRoute); dataRoute.route("/", wallpapersRoute); -dataRoute.route("/", searchItemsRoute); +dataRoute.route("/", shortcutsRoute); export default dataRoute; diff --git a/apps/backend/src/routes/integrations.route.ts b/apps/backend/src/routes/integrations.route.ts index d3f0736eb..31b036d40 100644 --- a/apps/backend/src/routes/integrations.route.ts +++ b/apps/backend/src/routes/integrations.route.ts @@ -15,6 +15,7 @@ import { updateIntegration, } from "../lib/data/integrations"; import { ApiActionError } from "../lib/data/auth"; +import { executeRoutedShortcut } from "../lib/data/shortcuts"; import { getSuperuserPB } from "../lib/pb/pocketbase"; import { flattenToEnv, @@ -119,20 +120,24 @@ integrationsRoute withJson(async (c) => { const body = await readJsonBody(c); const { userId } = await requireAuth({ token: readAuthToken(c) }); - const searchItemId = String(body?.searchItemId ?? body?.id ?? "").trim(); + const shortcutId = String(body?.shortcutId ?? body?.id ?? "").trim(); - if (!searchItemId) { - throw new ApiActionError("Missing searchItemId", 400, { - error: "Missing searchItemId", + if (!shortcutId) { + throw new ApiActionError("Missing shortcutId", 400, { + error: "Missing shortcutId", }); } const pb = await getSuperuserPB(); - const record = await pb.collection("searchItems").getOne(searchItemId); + const record = await pb.collection("shortcuts").getOne(shortcutId); if (!record || record.user !== userId) { throw new ApiActionError("Unauthorized", 403, { error: "Unauthorized" }); } + if (typeof record.action === "string" && /^shortcut:/i.test(record.action.trim())) { + return executeRoutedShortcut(userId, record.action); + } + const action = parseProxyAction(record.action); if (!action?.url) { throw new ApiActionError("Unsupported proxy action", 400, { diff --git a/apps/backend/src/routes/links.route.ts b/apps/backend/src/routes/links.route.ts index ba6409243..797336ffe 100644 --- a/apps/backend/src/routes/links.route.ts +++ b/apps/backend/src/routes/links.route.ts @@ -1,12 +1,19 @@ import { Hono } from "hono"; -import { createCollection, createCollectionLinkItem, createHomeLinkGroup, createHomeLinkItem, createLinkTag, createLinksFolder, deleteLinkItem, getHomeLinkGroups, getHomeLinks, getLinksCollections, getLinksFolders, getLinksItems, getLinksTags, reorderLinks, updateCollection, updateHomeLinkFolderIcon, updateHomeLinkItem, updateLinkTag } from "../lib/data/links"; +import { createCollection, createCollectionLinkItem, createHomeLinkGroup, createHomeLinkItem, createLinkTag, createLinksFolder, deleteLinkItem, getHomeLinkGroups, getHomeLinks, getLinksCollections, getLinksFolders, getLinksItems, getLinksTags, reorderLinks, updateCollection, updateHomeLinkFolderIcon, updateHomeLinkItem, updateLinkTag, wipeUserLinks } from "../lib/data/links"; import { readAuthToken, readJsonBody, requireAuth, withJson } from "./shared"; +import { config } from "../lib/config"; +import { ApiActionError } from "../lib/data/auth"; +import { getLinkMetadata } from "../lib/api/tools/linkMetadata"; const linksRoute = new Hono(); linksRoute + .get("/api/v1/links/metadata", withJson(async (c) => { + await requireAuth({ token: readAuthToken(c) }); + return getLinkMetadata(String(c.req.query("url") ?? "").trim()); + })) .get("/api/v1/links/collections", withJson(async (c) => { const { userId } = await requireAuth({ token: readAuthToken(c) }); return getLinksCollections(userId); @@ -53,6 +60,7 @@ linksRoute list: String(body?.list ?? ""), name: String(body?.name ?? ""), parentFolder: typeof body?.parentFolder === "string" ? body.parentFolder : undefined, + icon: typeof body?.icon === "string" ? body.icon : undefined, }); })) .get("/api/v1/links/items", withJson(async (c) => { @@ -79,6 +87,13 @@ linksRoute const { userId } = await requireAuth({ token: readAuthToken(c) }); return deleteLinkItem(userId, String(c.req.param("linkId") ?? "")); })) + .delete("/api/v1/links/dev/user-links", withJson(async (c) => { + if (config.ENVIRONMENT !== "dev") { + throw new ApiActionError("Not found", 404, { error: "Not found" }); + } + const { userId } = await requireAuth({ token: readAuthToken(c) }); + return wipeUserLinks(userId); + })) .post("/api/v1/links/reorder", withJson(async (c) => { const body = await readJsonBody(c); const { userId } = await requireAuth({ token: readAuthToken(c) }); diff --git a/apps/backend/src/routes/news.route.ts b/apps/backend/src/routes/news.route.ts index 60e31f2a4..36705e579 100644 --- a/apps/backend/src/routes/news.route.ts +++ b/apps/backend/src/routes/news.route.ts @@ -2,12 +2,13 @@ import Parser from "rss-parser"; import { Hono } from "hono"; import type { Context } from "hono"; -import { createNewsFeedRecordForUser, deleteNewsSavedArticle, deleteNewsSavedArticleList, getNewsFeed, getNewsFeedRecord, getNewsFeeds, getNewsSavedArticles, getNewsSubscriptions, getNewsSubscriptionJson, renameNewsSavedArticleList, saveNewsArticle, subscribeNewsFeed, unsubscribeNewsFeed, updateNewsFeed, updateNewsFeedRecordForUser, getNewsFeedMetadata, updateNewsSubscription, updateNewsSavedArticleReadState } from "../lib/data/news"; +import { createNewsFeedRecordForUser, deleteNewsFeedRecordForUser, deleteNewsSavedArticle, deleteNewsSavedArticleList, getNewsFeed, getNewsFeedRecord, getNewsFeeds, getNewsSavedArticles, getNewsSubscriptions, getNewsSubscriptionJson, renameNewsSavedArticleList, saveNewsArticle, subscribeNewsFeed, unsubscribeNewsFeed, updateNewsFeed, updateNewsFeedRecordForUser, getNewsFeedMetadata, updateNewsSubscription, updateNewsSavedArticleReadState } from "../lib/data/news"; import type { NewsFeedItem, NewsFeedMetadata, NewsFeedRecordCreateInput, NewsFeedRecordUpdateInput, NewsSubscribeInput, NewsUpdateInput } from "@dashwise/types/sdk"; import { readAuthToken, readJsonBody, requireAuth, withJson } from "./shared"; import { createLogger } from "../lib/logger"; import { jobsApi } from "../jobs/index"; +import { suggestCommonBlacklistWords } from "../lib/news/topic-suggestions"; const logger = createLogger("API"); @@ -99,6 +100,12 @@ async function getFeedMetadata(feedUrl: string): Promise { headers: FEED_REQUEST_HEADERS, customFields: { feed: ["image", "icon"], + item: [ + ["content:encoded", "content:encoded"], + ["media:description", "media:description"], + ["description", "description", { keepArray: false }], + ["category", "category", { keepArray: true }], + ], }, }); @@ -112,6 +119,7 @@ async function getFeedMetadata(feedUrl: string): Promise { feedUrl: normalizedFeedUrl, title, icon, + suggestedBlacklistWords: suggestCommonBlacklistWords(Array.isArray(feed.items) ? feed.items : []), }; } catch (error) { logger.error(`Error fetching feed metadata for ${normalizedFeedUrl}`, error); @@ -122,9 +130,10 @@ async function getFeedMetadata(feedUrl: string): Promise { feedUrl: normalizedFeedUrl, title: "", icon: `${parsed.origin}/favicon.ico`, + suggestedBlacklistWords: [], }; } catch { - return { feedUrl: normalizedFeedUrl, title: "", icon: "" }; + return { feedUrl: normalizedFeedUrl, title: "", icon: "", suggestedBlacklistWords: [] }; } } } @@ -231,6 +240,10 @@ newsRoute const { userId } = await requireAuth({ token: readAuthToken(c) }); return getNewsFeedRecord(userId, String(c.req.param("id") ?? "")); })) + .delete("/api/v1/news/feed-records/:id", withJson(async (c) => { + const { userId } = await requireAuth({ token: readAuthToken(c) }); + return deleteNewsFeedRecordForUser(userId, String(c.req.param("id") ?? "")); + })) .post("/api/v1/news/feed-records", withJson(async (c) => { const body = await readJsonBody(c); const { userId } = await requireAuth({ token: readAuthToken(c) }); diff --git a/apps/backend/src/routes/notifications.route.ts b/apps/backend/src/routes/notifications.route.ts index dee002266..db839e737 100644 --- a/apps/backend/src/routes/notifications.route.ts +++ b/apps/backend/src/routes/notifications.route.ts @@ -2,10 +2,10 @@ import { Hono } from "hono"; import type { Context } from "hono"; import { ApiActionError } from "../lib/data/auth"; -import { createForwarder, deleteForwarder, getForwarders, updateForwarder } from "../lib/data/notifications/forwarders"; +import { createForwarder, deleteForwarder, getForwarders, testForwarder, testForwarderTarget, updateForwarder } from "../lib/data/notifications/forwarders"; import { createNotificationTopic, deleteNotificationTopic, getNotificationTopics, getNotifications, markNotificationsAsRead, sendTestNotification } from "../lib/data/notifications/items"; import { createNotificationByTopicId, createNotificationWithTopicToken } from "../lib/data/notifications/publish"; -import { createTopicToken, deleteTopicToken, listTopicTokens } from "../lib/data/notifications/topicTokens"; +import { createTopicToken, deleteTopicToken, listTopicTokens, updateTopicToken } from "../lib/data/notifications/topicTokens"; import { getServerPB, getSuperuserPB } from "../lib/pb/pocketbase"; import { readAuthToken, readJsonBody, readBool, requireAuth, withJson } from "./shared"; @@ -122,6 +122,11 @@ notificationsRoute const { userId } = await requireAuth({ token: readAuthToken(c) }); return deleteTopicToken(userId, String(body?.tokenId ?? "")); })) + .put("/api/v1/notifications/topicTokens", withJson(async (c) => { + const body = await readJsonBody(c); + const { userId } = await requireAuth({ token: readAuthToken(c) }); + return updateTopicToken(userId, body ?? {}); + })) .get("/api/v1/notifications/forwarders", withJson(async (c) => { const { userId } = await requireAuth({ token: readAuthToken(c) }); return getForwarders(userId); @@ -141,6 +146,13 @@ notificationsRoute const { userId } = await requireAuth({ token: readAuthToken(c) }); return deleteForwarder(userId, String(body?.forwarderId ?? "")); })) + .post("/api/v1/notifications/forwarders/test", withJson(async (c) => { + const body = await readJsonBody(c); + const { userId } = await requireAuth({ token: readAuthToken(c) }); + return body?.forwarderId + ? testForwarder(userId, String(body.forwarderId)) + : testForwarderTarget(String(body?.target ?? "")); + })) .post("/api/v1/notifications/:topic", withJson(async (c) => { const topic = String(c.req.param("topic") ?? "").trim(); const body = await readJsonBody(c); @@ -161,4 +173,4 @@ notificationsRoute return { ok: true, ...(await createNotificationByTopicId(topic, content, source)) }; })); -export default notificationsRoute; \ No newline at end of file +export default notificationsRoute; diff --git a/apps/backend/src/routes/searchItems.route.ts b/apps/backend/src/routes/searchItems.route.ts deleted file mode 100644 index 669a10af6..000000000 --- a/apps/backend/src/routes/searchItems.route.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Hono } from "hono"; -import { getSuperuserPB } from "../lib/pb/pocketbase"; -import { getSearchItems } from "../lib/data/searchItems"; - -import { readAuthToken, readJsonBody, requireAuth, withJson } from "./shared"; - -const searchItemsRoute = new Hono(); - -searchItemsRoute - .get( - "/api/v1/searchItems", - withJson(async (c) => { - const { userId } = await requireAuth({ token: readAuthToken(c) }); - return getSearchItems(userId); - }), - ) - .post("/api/v1/searchItems/usageStats", withJson(async (c) => { - const body = await readJsonBody<{ id: string; timestamp: string; auth?: any }>(c); - const { userId } = await requireAuth({ token: readAuthToken(c) }); - - if (!body.id || !body.timestamp) { - return { success: false, error: "Missing id or timestamp" }; - } - - const pb = await getSuperuserPB(); - const record = await pb.collection("searchItems").getOne(body.id); - - if (record.user !== userId) { - return { success: false, error: "Unauthorized" }; - } - - const usageStats = Array.isArray(record.usageStats) ? record.usageStats : []; - usageStats.push({ timestamp: body.timestamp }); - - // Keep only last 100 usages to prevent infinite growth - if (usageStats.length > 100) { - usageStats.shift(); - } - - await pb.collection("searchItems").update(body.id, { usageStats }); - - return { success: true }; - })) - .get("/api/v1/searchItems/frequentlyUsed", withJson(async (c) => { - const { userId } = await requireAuth({ token: readAuthToken(c) }); - - const pb = await getSuperuserPB(); - const records = await pb.collection("searchItems").getFullList(1000, { - filter: `user="${userId.replace(/"/g, '\\"')}"`, - }); - - const sorted = records - .map(record => ({ - id: record.id, - usageCount: Array.isArray(record.usageStats) ? record.usageStats.length : 0, - })) - .filter(record => record.usageCount > 0) - .sort((a, b) => b.usageCount - a.usageCount) - .slice(0, 5); - - return sorted.map(item => ({ id: item.id })); - })); - -export default searchItemsRoute; diff --git a/apps/backend/src/routes/sessions.route.ts b/apps/backend/src/routes/sessions.route.ts new file mode 100644 index 000000000..948c179d6 --- /dev/null +++ b/apps/backend/src/routes/sessions.route.ts @@ -0,0 +1,27 @@ +import { Hono } from "hono"; + +import { getCurrentSession, renameCurrentSession } from "../lib/data/sessions"; +import { readAuth, readJsonBody, readSessionMetadata, requireAuth, withJson } from "./shared"; + +const sessionsRoute = new Hono(); + +sessionsRoute + .get("/api/v1/sessions/current", withJson(async (c) => { + const requestAuth = readAuth(c); + const auth = await requireAuth(requestAuth); + return getCurrentSession(auth.pb, auth.userId, requestAuth.sessionId, readSessionMetadata(c)); + })) + .patch("/api/v1/sessions/current", withJson(async (c) => { + const body = await readJsonBody<{ displayName?: unknown }>(c); + const requestAuth = readAuth(c); + const auth = await requireAuth(requestAuth); + return renameCurrentSession( + auth.pb, + auth.userId, + requestAuth.sessionId, + body.displayName, + readSessionMetadata(c), + ); + })); + +export default sessionsRoute; diff --git a/apps/backend/src/routes/shared.ts b/apps/backend/src/routes/shared.ts index 662c4e06a..ab534d2c0 100644 --- a/apps/backend/src/routes/shared.ts +++ b/apps/backend/src/routes/shared.ts @@ -17,7 +17,10 @@ import { defaultHomeConfig } from "@dashwise/assets"; export type JsonHandler) = import("hono").Context> = (c: C) => Promise | unknown; -export const authInput = z.object({ token: z.string().nullable().optional() }); +export const authInput = z.object({ + token: z.string().nullable().optional(), + sessionId: z.string().nullable().optional(), +}); export function normalizePageName(pageName?: string | null) { const cleaned = String(pageName ?? "home").trim().toLowerCase(); @@ -106,6 +109,20 @@ export function readAuthToken(c: Context) { return c.req.query("token") ?? c.req.query("authToken") ?? null; } +export function readAuth(c: Context) { + return { + token: readAuthToken(c), + sessionId: c.req.header("x-session-id") ?? null, + }; +} + +export function readSessionMetadata(c: Context) { + return { + clientType: c.req.header("x-client-type") ?? undefined, + platform: c.req.header("x-platform") ?? undefined, + }; +} + export async function readJsonBody>(c: Context): Promise { try { return await c.req.json(); diff --git a/apps/backend/src/routes/shortcuts.route.ts b/apps/backend/src/routes/shortcuts.route.ts new file mode 100644 index 000000000..dbc511b9c --- /dev/null +++ b/apps/backend/src/routes/shortcuts.route.ts @@ -0,0 +1,87 @@ +import { Hono } from "hono"; + +import { + createOnDemandShortcutApp, + getShortcuts, + syncOnDemandShortcuts, +} from "../lib/data/shortcuts"; +import { getSuperuserPB } from "../lib/pb/pocketbase"; +import { ApiActionError } from "../lib/data/auth"; +import { readAuthToken, readJsonBody, requireAuth, withJson } from "./shared"; + +const shortcutsRoute = new Hono(); + +shortcutsRoute + .get( + "/api/v1/shortcuts", + withJson(async (c) => { + const { userId } = await requireAuth({ token: readAuthToken(c) }); + return getShortcuts(userId); + }), + ) + .post( + "/api/v1/shortcuts/apps", + withJson(async (c) => { + const body = await readJsonBody<{ name?: unknown; type?: unknown; icon?: unknown }>(c); + const { userId } = await requireAuth({ token: readAuthToken(c) }); + return createOnDemandShortcutApp(userId, body ?? {}); + }), + ) + .put( + "/api/v1/shortcuts/on-demand/:appId", + withJson(async (c) => { + const body = await readJsonBody<{ shortcuts?: unknown }>(c); + const { userId } = await requireAuth({ token: readAuthToken(c) }); + return syncOnDemandShortcuts(userId, String(c.req.param("appId") ?? ""), body?.shortcuts); + }), + ) + .post( + "/api/v1/shortcuts/usageStats", + withJson(async (c) => { + const body = await readJsonBody<{ id?: string; timestamp?: string }>(c); + const { userId } = await requireAuth({ token: readAuthToken(c) }); + const id = String(body?.id ?? "").trim(); + const timestamp = String(body?.timestamp ?? "").trim(); + + if (!id || !timestamp) { + throw new ApiActionError("Missing id or timestamp", 400, { + error: "Missing id or timestamp", + }); + } + + const pb = await getSuperuserPB(); + const record = await pb.collection("shortcuts").getOne(id); + if (record.user !== userId) { + throw new ApiActionError("Unauthorized", 403, { error: "Unauthorized" }); + } + + const usageStats = Array.isArray(record.usageStats) ? record.usageStats : []; + usageStats.push({ timestamp }); + if (usageStats.length > 100) usageStats.shift(); + + await pb.collection("shortcuts").update(id, { usageStats }); + return { success: true }; + }), + ) + .get( + "/api/v1/shortcuts/frequentlyUsed", + withJson(async (c) => { + const { userId } = await requireAuth({ token: readAuthToken(c) }); + const pb = await getSuperuserPB(); + const records = await pb.collection("shortcuts").getFullList(1000, { + filter: `user="${userId.replace(/"/g, '\\"')}"`, + }); + + return records + .map((record) => ({ + id: record.id, + usageCount: Array.isArray(record.usageStats) ? record.usageStats.length : 0, + })) + .filter((record) => record.usageCount > 0) + .sort((a, b) => b.usageCount - a.usageCount) + .slice(0, 5) + .map((item) => ({ id: item.id })); + }), + ); + +export default shortcutsRoute; diff --git a/apps/backend/src/routes/system.route.ts b/apps/backend/src/routes/system.route.ts index cb4ea1de5..74cd6f095 100644 --- a/apps/backend/src/routes/system.route.ts +++ b/apps/backend/src/routes/system.route.ts @@ -25,12 +25,12 @@ systemRoute.get( }), ); -systemRoute.get("/api/v1/jobs/searchItems", async (c) => { +systemRoute.get("/api/v1/jobs/shortcuts", async (c) => { if (!validateJobsBasicAuth(c.req.header("authorization"))) { return c.json({ status: "error", message: "Unauthorized" }, 401); } - await jobsApi.runSearchItemsJob("api"); + await jobsApi.runShortcutsJob("api"); return c.json({ status: "success" }); }); systemRoute.get("/api/v1/jobs/pullIcons", async (c) => { diff --git a/apps/web/package.json b/apps/web/package.json index f6618c7d8..acf159ac0 100755 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -49,6 +49,7 @@ "iconify-picker": "^0.7.2", "lucide-react": "^0.471.2", "qrcode": "^1.5.4", + "radix-ui": "^1.6.7", "react": "^19.2.1", "react-colorful": "^5.6.1", "react-day-picker": "^9.11.1", diff --git a/apps/web/public/link-tile-layouts/compact.png b/apps/web/public/link-tile-layouts/compact.png new file mode 100644 index 000000000..9719a15a9 Binary files /dev/null and b/apps/web/public/link-tile-layouts/compact.png differ diff --git a/apps/web/public/link-tile-layouts/default.png b/apps/web/public/link-tile-layouts/default.png new file mode 100644 index 000000000..43161508d Binary files /dev/null and b/apps/web/public/link-tile-layouts/default.png differ diff --git a/apps/web/public/openapi.json b/apps/web/public/openapi.json index b2e425626..ea4503371 100644 --- a/apps/web/public/openapi.json +++ b/apps/web/public/openapi.json @@ -45,7 +45,10 @@ "name": "pageConfig" }, { - "name": "search" + "name": "shortcuts" + }, + { + "name": "sessions" }, { "name": "test" @@ -284,6 +287,42 @@ } } }, + "/sessions/current": { + "get": { + "tags": [ + "sessions" + ], + "summary": "Get the current client session", + "responses": { + "200": { + "$ref": "#/components/responses/JsonOk" + }, + "401": { + "$ref": "#/components/responses/JsonUnauthorized" + } + } + }, + "patch": { + "tags": [ + "sessions" + ], + "summary": "Rename the current client session", + "requestBody": { + "$ref": "#/components/requestBodies/JsonBody" + }, + "responses": { + "200": { + "$ref": "#/components/responses/JsonOk" + }, + "400": { + "$ref": "#/components/responses/JsonBadRequest" + }, + "401": { + "$ref": "#/components/responses/JsonUnauthorized" + } + } + } + }, "/integrations": { "get": { "tags": [ @@ -395,6 +434,53 @@ } } }, + "/links/metadata": { + "get": { + "tags": [ + "links" + ], + "summary": "Get link metadata", + "parameters": [ + { + "name": "url", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "iconUrl": { + "type": "string" + } + }, + "required": [ + "title", + "description", + "iconUrl" + ], + "additionalProperties": false + } + } + } + } + } + } + }, "/links/home/groups": { "get": { "tags": [ @@ -567,6 +653,19 @@ } } }, + "/links/dev/user-links": { + "delete": { + "tags": [ + "links" + ], + "summary": "Wipe development user links", + "responses": { + "200": { + "$ref": "#/components/responses/JsonOk" + } + } + } + }, "/links/tags": { "get": { "tags": [ @@ -594,12 +693,12 @@ } } }, - "/jobs/searchItems": { + "/jobs/shortcuts": { "get": { "tags": [ "jobs" ], - "summary": "Search items job", + "summary": "Shortcuts indexing job", "responses": { "200": { "$ref": "#/components/responses/JsonOk" @@ -1145,6 +1244,22 @@ } } }, + "/notifications/forwarders/test": { + "post": { + "tags": [ + "notifications" + ], + "summary": "Test forwarder", + "requestBody": { + "$ref": "#/components/requestBodies/JsonBody" + }, + "responses": { + "200": { + "$ref": "#/components/responses/JsonOk" + } + } + } + }, "/notifications/markAsRead": { "post": { "tags": [ @@ -1243,6 +1358,20 @@ } } }, + "put": { + "tags": [ + "notifications" + ], + "summary": "Update topic token", + "requestBody": { + "$ref": "#/components/requestBodies/JsonBody" + }, + "responses": { + "200": { + "$ref": "#/components/responses/JsonOk" + } + } + }, "get": { "tags": [ "notifications" @@ -1269,12 +1398,12 @@ } } }, - "/searchItems": { + "/shortcuts": { "get": { "tags": [ - "search" + "shortcuts" ], - "summary": "Search items", + "summary": "List shortcuts", "responses": { "200": { "$ref": "#/components/responses/JsonOk" @@ -2009,6 +2138,27 @@ "$ref": "#/components/responses/JsonOk" } } + }, + "delete": { + "tags": [ + "news" + ], + "summary": "Delete news feed record", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/JsonOk" + } + } } }, "/news/feed-records": { @@ -2128,12 +2278,72 @@ } } }, - "/searchItems/frequentlyUsed": { + "/shortcuts/apps": { + "post": { + "tags": [ + "shortcuts" + ], + "summary": "Create an on-demand shortcut app", + "requestBody": { + "$ref": "#/components/requestBodies/JsonBody" + }, + "responses": { + "200": { + "$ref": "#/components/responses/JsonOk" + }, + "400": { + "$ref": "#/components/responses/JsonBadRequest" + }, + "401": { + "$ref": "#/components/responses/JsonUnauthorized" + } + } + } + }, + "/shortcuts/on-demand/{appId}": { + "put": { + "tags": [ + "shortcuts" + ], + "summary": "Replace an on-demand app's shortcuts", + "parameters": [ + { + "name": "appId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "$ref": "#/components/requestBodies/JsonBody" + }, + "responses": { + "200": { + "$ref": "#/components/responses/JsonOk" + }, + "400": { + "$ref": "#/components/responses/JsonBadRequest" + }, + "401": { + "$ref": "#/components/responses/JsonUnauthorized" + }, + "404": { + "$ref": "#/components/responses/JsonNotFound" + }, + "409": { + "$ref": "#/components/responses/JsonConflict" + } + } + } + }, + "/shortcuts/frequentlyUsed": { "get": { "tags": [ - "search" + "shortcuts" ], - "summary": "List frequently used search items", + "summary": "List frequently used shortcuts", "responses": { "200": { "$ref": "#/components/responses/JsonOk" @@ -2141,12 +2351,12 @@ } } }, - "/searchItems/usageStats": { + "/shortcuts/usageStats": { "post": { "tags": [ - "search" + "shortcuts" ], - "summary": "Log search item usage", + "summary": "Log shortcut usage", "requestBody": { "$ref": "#/components/requestBodies/JsonBody" }, @@ -2221,6 +2431,26 @@ } } } + }, + "JsonNotFound": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "JsonConflict": { + "description": "Conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } } }, "securitySchemes": { diff --git a/apps/web/src/app/(authenticated)/apps/links/lists/[listId]/page.tsx b/apps/web/src/app/(authenticated)/apps/links/lists/[listId]/page.tsx index 35388f73f..af43d5233 100644 --- a/apps/web/src/app/(authenticated)/apps/links/lists/[listId]/page.tsx +++ b/apps/web/src/app/(authenticated)/apps/links/lists/[listId]/page.tsx @@ -80,6 +80,11 @@ export default function LinksListDetailPage() { queryClient.setQueryData(["api", token, ...queryKeys.links.items(listId)], (current = []) => [link as LinkItemRecord, ...current.filter((item) => item.id !== link.id)]); }} + onFolderCreated={(folder) => { + if (folder.list !== listId) return; + + queryClient.setQueryData(["api", token, ...queryKeys.links.folders(listId)], (current = []) => [folder as LinkFolderRecord, ...current.filter((item) => item.id !== folder.id)]); + }} /> ); diff --git a/apps/web/src/app/(authenticated)/apps/links/tags/[tagId]/page.tsx b/apps/web/src/app/(authenticated)/apps/links/tags/[tagId]/page.tsx index 151efb5c5..737ab6365 100644 --- a/apps/web/src/app/(authenticated)/apps/links/tags/[tagId]/page.tsx +++ b/apps/web/src/app/(authenticated)/apps/links/tags/[tagId]/page.tsx @@ -147,6 +147,9 @@ export default function LinksTagDetailPage() { queryClient.setQueryData(["api", token, ...queryKeys.links.tagDetail(tagId)], (current: typeof detailQuery.data) => current ? { ...current, items: [link as LinkItemRecord, ...current.items.filter((item) => item.id !== link.id)] } : current); }} + onFolderCreated={(folder) => { + queryClient.setQueryData(["api", token, ...queryKeys.links.tagDetail(tagId)], (current: typeof detailQuery.data) => current ? { ...current, folders: [folder as LinkFolderRecord, ...current.folders.filter((item) => item.id !== folder.id)] } : current); + }} /> ); diff --git a/apps/web/src/app/(authenticated)/settings/account/page.tsx b/apps/web/src/app/(authenticated)/settings/account/page.tsx index b105dcdcb..610f827f2 100644 --- a/apps/web/src/app/(authenticated)/settings/account/page.tsx +++ b/apps/web/src/app/(authenticated)/settings/account/page.tsx @@ -1,5 +1,5 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { Dialog, DialogClose, @@ -17,12 +17,17 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { ChangePasswordRequest } from '@/lib/apiClient'; import { useNavigate } from "react-router-dom"; import { changePasswordAction, deleteAccountAction } from '@/lib/apiClient'; +import { getCurrentSessionAction, renameCurrentSessionAction } from '@/lib/apiClient'; import { DialogDescription } from "@radix-ui/react-dialog"; import useAuth from "@/context/useAuth"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { queryKeys } from "@/lib/queryClient"; + +const DEFAULT_SESSION_NAMES = new Set(["web browser"]); export default function AccountSettingsPage() { const navigate = useNavigate(); - const { user, token, setAuth, logout } = useAuth(); + const { user, token, setAuth, logout, withAuth } = useAuth(); const [oldPassword, setOldPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); @@ -33,6 +38,46 @@ export default function AccountSettingsPage() { const [deleteTotp, setDeleteTotp] = useState(""); const [deleteLoading, setDeleteLoading] = useState(false); const [deleteError, setDeleteError] = useState(null); + const [sessionName, setSessionName] = useState(""); + const [sessionError, setSessionError] = useState(null); + const [isDeviceNameDialogOpen, setIsDeviceNameDialogOpen] = useState(false); + const queryClient = useQueryClient(); + const sessionQuery = useQuery({ + queryKey: queryKeys.auth.session(token), + enabled: Boolean(token), + retry: false, + queryFn: () => withAuth(getCurrentSessionAction), + }); + const sessionMutation = useMutation({ + mutationFn: (displayName: string) => withAuth((auth) => renameCurrentSessionAction(auth, displayName)), + onSuccess: (session) => { + setSessionName(session.displayName); + setSessionError(null); + queryClient.setQueryData(queryKeys.auth.session(token), session); + setIsDeviceNameDialogOpen(false); + }, + }); + + useEffect(() => { + setSessionName(sessionQuery.data?.displayName ?? ""); + }, [sessionQuery.data?.displayName]); + + const handleSessionNameSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const normalizedName = sessionName.trim(); + if (!normalizedName || normalizedName.length > 100) { + setSessionError("device name must be between 1 and 100 characters"); + return; + } + setSessionError(null); + sessionMutation.mutate(normalizedName, { + onError: (cause) => setSessionError(cause instanceof Error ? cause.message : "failed to save device name"), + }); + }; + + const normalizedSessionDisplayName = sessionQuery.data?.displayName?.trim().toLowerCase(); + const needsDeviceName = !normalizedSessionDisplayName || + DEFAULT_SESSION_NAMES.has(normalizedSessionDisplayName); const handleChangePasswordSubmit = async ( e: React.FormEvent, @@ -144,6 +189,69 @@ export default function AccountSettingsPage() {

Authentication

+ { + setIsDeviceNameDialogOpen(open); + if (open) { + setSessionName(sessionQuery.data?.displayName ?? ""); + setSessionError(null); + } + }} + > + + +

+ Change Device Name + {sessionQuery.isFetched && needsDeviceName && ( +

+ +
+ + + + Change Device Name + + Set a name for this browser so you can recognize it elsewhere in Dashwise. + + + +
+
+ + setSessionName(event.target.value)} + placeholder="Web browser" + maxLength={100} + disabled={sessionQuery.isLoading || sessionMutation.isPending} + /> + {sessionError &&

{sessionError}

} +
+ + + + + + + +
+
+
+ @@ -229,12 +337,6 @@ export default function AccountSettingsPage() { -
- -

Multi-factor Authentication

- -
- diff --git a/apps/web/src/app/(authenticated)/settings/apps/page.tsx b/apps/web/src/app/(authenticated)/settings/apps/page.tsx new file mode 100644 index 000000000..d00152932 --- /dev/null +++ b/apps/web/src/app/(authenticated)/settings/apps/page.tsx @@ -0,0 +1,334 @@ +"use client"; + +import { useCallback, useEffect, useState, type ReactNode } from "react"; +import { useSearchParams } from "react-router-dom"; +import { Icon } from "@iconify-icon/react"; +import LinksHtmlTransfer from "@/components/settings/LinksHtmlTransfer"; +import useAuth from "@/context/useAuth"; +import { getNewsFeedsAction, subscribeNewsFeedAction } from "@/lib/apiClient"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; + +type EmptyAppSectionProps = { + title: string; + icon: string; + description?: string; + children?: ReactNode; + withTopOutline?: boolean; +}; + +function EmptyAppSection({ title, icon, description, children, withTopOutline = false }: EmptyAppSectionProps) { + return ( +
+

+ + + + {title} +

+ {description && ( +
+ {description} +
+ )} + {children} +
+ ); +} + +type NewsFeedOption = { + id: string; + title?: string; +}; + +function DefaultNewsFeedSetting({ feeds }: { feeds: NewsFeedOption[] }) { + const { user, updateUserProperty } = useAuth(); + + const newsPreferences = user?.newsPreferences; + const preferences = newsPreferences && typeof newsPreferences === "object" + ? newsPreferences as Record + : {}; + const configuredDefaultNewsPage = typeof preferences.defaultNewsPage === "string" + ? preferences.defaultNewsPage.replace(/\/+$/, "") + : ""; + const defaultNewsPage = configuredDefaultNewsPage && configuredDefaultNewsPage !== "/apps/news" + ? configuredDefaultNewsPage + : "/apps/news/all"; + + async function handleChange(value: string) { + await updateUserProperty("newsPreferences", { + ...preferences, + defaultNewsPage: value, + }); + } + + return ( +
+ +

Default News Feed

+ + + +
+ ); +} + +function BulkNewsImportSetting({ + feeds, + onFeedsChange, +}: { + feeds: NewsFeedOption[]; + onFeedsChange: (feeds: NewsFeedOption[]) => void; +}) { + const { token, withAuth } = useAuth(); + const [bulkImportOpen, setBulkImportOpen] = useState(false); + const [bulkImportUrls, setBulkImportUrls] = useState(""); + const [bulkImportFeedId, setBulkImportFeedId] = useState("unsorted"); + const [bulkImporting, setBulkImporting] = useState(false); + const [bulkImportError, setBulkImportError] = useState(null); + const [bulkImportStatus, setBulkImportStatus] = useState(null); + const [searchParams, setSearchParams] = useSearchParams(); + const bulkImportRequest = searchParams.get("openNewsBulkImportModal"); + + const openBulkImport = useCallback(() => { + setBulkImportUrls(""); + setBulkImportFeedId("unsorted"); + setBulkImportError(null); + setBulkImportStatus(null); + setBulkImportOpen(true); + }, []); + + useEffect(() => { + const value = bulkImportRequest?.trim().toLowerCase(); + if (!value || ["false", "0", "no", "off"].includes(value)) return; + + openBulkImport(); + setSearchParams((current) => { + const next = new URLSearchParams(current); + next.delete("openNewsBulkImportModal"); + return next; + }, { replace: true }); + }, [bulkImportRequest, openBulkImport, setSearchParams]); + + async function handleBulkImport() { + const urls = Array.from(new Set( + bulkImportUrls + .split("\n") + .map((url) => url.trim()) + .filter(Boolean), + )); + + if (!urls.length) { + setBulkImportError("Add at least one feed URL."); + return; + } + + if (!token) { + setBulkImportError("You must be signed in to import feeds."); + return; + } + + setBulkImporting(true); + setBulkImportError(null); + setBulkImportStatus(null); + + const failures: string[] = []; + let imported = 0; + + try { + for (const url of urls) { + try { + await withAuth((auth) => subscribeNewsFeedAction(auth, { + feedUrl: url, + feedIds: bulkImportFeedId === "unsorted" ? [] : [bulkImportFeedId], + newFeedTitles: bulkImportFeedId === "unsorted" ? ["Unsorted"] : [], + })); + imported += 1; + } catch (error) { + failures.push(`${url}: ${error instanceof Error ? error.message : String(error)}`); + } + } + + const response = await getNewsFeedsAction({ token }); + onFeedsChange(response.feeds ?? []); + + if (failures.length) { + setBulkImportError(`${imported} imported. Failed: ${failures.join("; ")}`); + } else { + setBulkImportStatus(`${imported} feed${imported === 1 ? "" : "s"} imported.`); + setBulkImportUrls(""); + window.setTimeout(() => setBulkImportOpen(false), 700); + } + } catch (error) { + setBulkImportError(error instanceof Error ? error.message : String(error)); + } finally { + setBulkImporting(false); + } + } + + return ( + <> + + + !bulkImporting && setBulkImportOpen(open)}> + + + Bulk import news feeds + + Add one RSS, Atom, YouTube, GitHub, or Reddit URL per line. + + + +
+
+ +