diff --git a/README.md b/README.md index 72cbbd8d..fcec329b 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,17 @@ You have an idea of a tool? Submit a [feature request](https://github.com/Corent Self host solutions for your homelab +**Cloudflare Pages + Access** + +This repo also works as a Cloudflare-hosted static app. Build with `pnpm build`, deploy the `dist/` output to Cloudflare Pages, and attach a D1 database for favorites persistence. + +Recommended setup: + +- Protect the Pages hostname with Cloudflare Access. +- Allow only the IdP groups or email domains you want to use the app. +- Bind a D1 database named `it-tools-favorites` to the Pages project. +- Keep the SPA fallback enabled so direct tool URLs refresh correctly. + **From docker hub:** ```sh diff --git a/functions/_lib/d1.ts b/functions/_lib/d1.ts new file mode 100644 index 00000000..e88c4fe0 --- /dev/null +++ b/functions/_lib/d1.ts @@ -0,0 +1,10 @@ +export interface D1PreparedStatement { + bind(...values: unknown[]): D1PreparedStatement; + all>(): Promise<{ results: T[] }>; + run(): Promise; +} + +export interface D1Database { + prepare(query: string): D1PreparedStatement; + batch(statements: D1PreparedStatement[]): Promise; +} diff --git a/functions/_lib/favorites.ts b/functions/_lib/favorites.ts new file mode 100644 index 00000000..e056e094 --- /dev/null +++ b/functions/_lib/favorites.ts @@ -0,0 +1,15 @@ +import { normalizeFavoriteToolPaths } from '../../shared/favorites'; + +export function getAuthenticatedUserId(request: Request) { + const email = request.headers.get('cf-access-authenticated-user-email')?.trim().toLowerCase(); + + return email || null; +} + +export function normalizeStoredFavoriteToolPaths(paths: unknown) { + if (!Array.isArray(paths)) { + return []; + } + + return normalizeFavoriteToolPaths(paths.filter((value): value is string => typeof value === 'string')); +} diff --git a/functions/api/favorites.ts b/functions/api/favorites.ts new file mode 100644 index 00000000..b13dd237 --- /dev/null +++ b/functions/api/favorites.ts @@ -0,0 +1,70 @@ +import { getAuthenticatedUserId, normalizeStoredFavoriteToolPaths } from '../_lib/favorites'; +import type { D1Database } from '../_lib/d1'; + +type Env = { + DB: D1Database; +}; + +function json(data: unknown, init?: ResponseInit) { + return Response.json(data, { + headers: { + 'cache-control': 'no-store', + }, + ...init, + }); +} + +async function readFavoriteToolPaths(env: Env, userId: string) { + const result = await env.DB.prepare( + 'SELECT tool_path FROM favorites WHERE user_id = ? ORDER BY sort_order ASC', + ) + .bind(userId) + .all<{ tool_path: string }>(); + + return result.results.map(row => row.tool_path); +} + +async function saveFavoriteToolPaths(env: Env, userId: string, favoriteToolPaths: string[]) { + const normalizedFavoriteToolPaths = normalizeStoredFavoriteToolPaths(favoriteToolPaths); + const statements = [ + env.DB.prepare('DELETE FROM favorites WHERE user_id = ?').bind(userId), + ...normalizedFavoriteToolPaths.map((toolPath, index) => env.DB.prepare( + `INSERT INTO favorites (user_id, tool_path, sort_order) + VALUES (?, ?, ?) + ON CONFLICT(user_id, tool_path) DO UPDATE SET + sort_order = excluded.sort_order, + updated_at = CURRENT_TIMESTAMP`, + ).bind(userId, toolPath, index)), + ]; + + await env.DB.batch(statements); + + return normalizedFavoriteToolPaths; +} + +export async function onRequestGet({ request, env }: { request: Request; env: Env }) { + const userId = getAuthenticatedUserId(request); + + if (!userId) { + return json({ error: 'Unauthorized' }, { status: 401 }); + } + + const favoriteToolPaths = await readFavoriteToolPaths(env, userId); + + return json({ favoriteToolPaths }); +} + +export async function onRequestPut({ request, env }: { request: Request; env: Env }) { + const userId = getAuthenticatedUserId(request); + + if (!userId) { + return json({ error: 'Unauthorized' }, { status: 401 }); + } + + const payload = await request.json().catch(() => null) as { favoriteToolPaths?: unknown } | null; + const favoriteToolPaths = normalizeStoredFavoriteToolPaths(payload?.favoriteToolPaths); + + await saveFavoriteToolPaths(env, userId, favoriteToolPaths); + + return json({ favoriteToolPaths }); +} diff --git a/functions/api/me.ts b/functions/api/me.ts new file mode 100644 index 00000000..ad14128f --- /dev/null +++ b/functions/api/me.ts @@ -0,0 +1,11 @@ +import { getAuthenticatedUserId } from '../_lib/favorites'; + +export async function onRequestGet({ request }: { request: Request }) { + const userId = getAuthenticatedUserId(request); + + if (!userId) { + return Response.json({ authenticated: false }, { status: 401, headers: { 'cache-control': 'no-store' } }); + } + + return Response.json({ authenticated: true, userId }, { headers: { 'cache-control': 'no-store' } }); +} diff --git a/migrations/0001_favorites.sql b/migrations/0001_favorites.sql new file mode 100644 index 00000000..cda6b777 --- /dev/null +++ b/migrations/0001_favorites.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS favorites ( + user_id TEXT NOT NULL, + tool_path TEXT NOT NULL, + sort_order INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, tool_path) +); + +CREATE INDEX IF NOT EXISTS idx_favorites_user_sort_order + ON favorites (user_id, sort_order); diff --git a/public/_redirects b/public/_redirects new file mode 100644 index 00000000..7797f7c6 --- /dev/null +++ b/public/_redirects @@ -0,0 +1 @@ +/* /index.html 200 diff --git a/shared/favorites.ts b/shared/favorites.ts new file mode 100644 index 00000000..2649074d --- /dev/null +++ b/shared/favorites.ts @@ -0,0 +1,17 @@ +export function normalizeFavoriteToolPaths(paths: readonly string[]): string[] { + const seen = new Set(); + const normalized: string[] = []; + + for (const path of paths) { + const value = path.trim(); + + if (!value || seen.has(value)) { + continue; + } + + seen.add(value); + normalized.push(value); + } + + return normalized; +} diff --git a/src/tools/favorites.storage.test.ts b/src/tools/favorites.storage.test.ts new file mode 100644 index 00000000..947c46c6 --- /dev/null +++ b/src/tools/favorites.storage.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeFavoriteToolPaths } from '../../shared/favorites'; + +describe('normalizeFavoriteToolPaths', () => { + it('removes blanks and duplicates while preserving order', () => { + expect(normalizeFavoriteToolPaths([' /alpha ', '', '/beta', '/alpha', ' ', '/gamma'])).toEqual([ + '/alpha', + '/beta', + '/gamma', + ]); + }); +}); diff --git a/src/tools/favorites.storage.ts b/src/tools/favorites.storage.ts new file mode 100644 index 00000000..4a873a78 --- /dev/null +++ b/src/tools/favorites.storage.ts @@ -0,0 +1,40 @@ +import { normalizeFavoriteToolPaths } from '../../shared/favorites'; + +const FAVORITES_STORAGE_KEY = 'favoriteToolsName'; + +function hasLocalStorage() { + return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'; +} + +export function readCachedFavoriteToolPaths() { + if (!hasLocalStorage()) { + return []; + } + + const rawValue = window.localStorage.getItem(FAVORITES_STORAGE_KEY); + + if (!rawValue) { + return []; + } + + try { + const parsedValue = JSON.parse(rawValue); + + if (!Array.isArray(parsedValue)) { + return []; + } + + return normalizeFavoriteToolPaths(parsedValue.filter((value): value is string => typeof value === 'string')); + } + catch { + return []; + } +} + +export function writeCachedFavoriteToolPaths(paths: readonly string[]) { + if (!hasLocalStorage()) { + return; + } + + window.localStorage.setItem(FAVORITES_STORAGE_KEY, JSON.stringify(normalizeFavoriteToolPaths(paths))); +} diff --git a/src/tools/tools.store.ts b/src/tools/tools.store.ts index fb12450d..a767af7f 100644 --- a/src/tools/tools.store.ts +++ b/src/tools/tools.store.ts @@ -1,14 +1,133 @@ -import { type MaybeRef, get, useStorage } from '@vueuse/core'; +import { type MaybeRef, get } from '@vueuse/core'; import { defineStore } from 'pinia'; -import type { Ref } from 'vue'; import _ from 'lodash'; +import { normalizeFavoriteToolPaths } from '../../shared/favorites'; import type { Tool, ToolCategory, ToolWithCategory } from './tools.types'; +import { readCachedFavoriteToolPaths, writeCachedFavoriteToolPaths } from './favorites.storage'; import { toolsWithCategory } from './index'; +const FAVORITES_API_PATH = '/api/favorites'; + +const favoriteToolPaths = ref([]); +const favoriteSyncMode = ref<'loading' | 'remote' | 'local'>('loading'); + +let favoriteHydrationPromise: Promise | null = null; +let favoritePersistenceQueue = Promise.resolve(); + +async function fetchFavoriteToolPaths() { + const response = await fetch(FAVORITES_API_PATH, { + headers: { + Accept: 'application/json', + }, + }); + + if (response.status === 401 || response.status === 403) { + return null; + } + + if (!response.ok) { + throw new Error(`Failed to load favorites (${response.status})`); + } + + const payload = await response.json() as { favoriteToolPaths?: unknown }; + + if (!Array.isArray(payload.favoriteToolPaths)) { + return []; + } + + return normalizeFavoriteToolPaths(payload.favoriteToolPaths.filter((value): value is string => typeof value === 'string')); +} + +async function saveFavoriteToolPaths(paths: string[]) { + const response = await fetch(FAVORITES_API_PATH, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify({ favoriteToolPaths: normalizeFavoriteToolPaths(paths) }), + }); + + if (response.status === 401 || response.status === 403) { + return false; + } + + if (!response.ok) { + throw new Error(`Failed to save favorites (${response.status})`); + } + + return true; +} + +function queueFavoritePersistence() { + favoritePersistenceQueue = favoritePersistenceQueue.then(async () => { + const currentFavoriteToolPaths = normalizeFavoriteToolPaths(favoriteToolPaths.value); + + if (favoriteSyncMode.value !== 'remote') { + writeCachedFavoriteToolPaths(currentFavoriteToolPaths); + return; + } + + try { + const saved = await saveFavoriteToolPaths(currentFavoriteToolPaths); + + if (!saved) { + favoriteSyncMode.value = 'local'; + } + } + catch { + favoriteSyncMode.value = 'local'; + } + + writeCachedFavoriteToolPaths(currentFavoriteToolPaths); + }); + + return favoritePersistenceQueue; +} + +async function hydrateFavoriteToolPaths() { + if (favoriteHydrationPromise) { + return favoriteHydrationPromise; + } + + favoriteHydrationPromise = (async () => { + const cachedFavoriteToolPaths = readCachedFavoriteToolPaths(); + + try { + const remoteFavoriteToolPaths = await fetchFavoriteToolPaths(); + + if (remoteFavoriteToolPaths === null) { + favoriteToolPaths.value = cachedFavoriteToolPaths; + favoriteSyncMode.value = 'local'; + return; + } + + if (remoteFavoriteToolPaths.length === 0 && cachedFavoriteToolPaths.length > 0) { + favoriteToolPaths.value = cachedFavoriteToolPaths; + favoriteSyncMode.value = 'remote'; + await saveFavoriteToolPaths(cachedFavoriteToolPaths); + writeCachedFavoriteToolPaths(cachedFavoriteToolPaths); + return; + } + + favoriteToolPaths.value = remoteFavoriteToolPaths; + favoriteSyncMode.value = 'remote'; + writeCachedFavoriteToolPaths(remoteFavoriteToolPaths); + } + catch { + favoriteToolPaths.value = cachedFavoriteToolPaths; + favoriteSyncMode.value = 'local'; + } + })(); + + return favoriteHydrationPromise; +} + export const useToolStore = defineStore('tools', () => { - const favoriteToolsName = useStorage('favoriteToolsName', []) as Ref; const { t } = useI18n(); + void hydrateFavoriteToolPaths(); + const tools = computed(() => toolsWithCategory.map((tool) => { const toolI18nKey = tool.path.replace(/\//g, ''); @@ -33,8 +152,8 @@ export const useToolStore = defineStore('tools', () => { }); const favoriteTools = computed(() => { - return favoriteToolsName.value - .map(favoriteName => tools.value.find(({ name, path }) => name === favoriteName || path === favoriteName)) + return favoriteToolPaths.value + .map(favoriteName => tools.value.find(({ path }) => path === favoriteName)) .filter(Boolean) as ToolWithCategory[]; // cast because .filter(Boolean) does not remove undefined from type }); @@ -44,24 +163,27 @@ export const useToolStore = defineStore('tools', () => { toolsByCategory, newTools: computed(() => tools.value.filter(({ isNew }) => isNew)), - addToolToFavorites({ tool }: { tool: MaybeRef }) { + async addToolToFavorites({ tool }: { tool: MaybeRef }) { const toolPath = get(tool).path; + if (toolPath) { - favoriteToolsName.value.push(toolPath); + favoriteToolPaths.value = normalizeFavoriteToolPaths([...favoriteToolPaths.value, toolPath]); + await queueFavoritePersistence(); } }, - removeToolFromFavorites({ tool }: { tool: MaybeRef }) { - favoriteToolsName.value = favoriteToolsName.value.filter(name => get(tool).name !== name && get(tool).path !== name); + async removeToolFromFavorites({ tool }: { tool: MaybeRef }) { + favoriteToolPaths.value = favoriteToolPaths.value.filter(name => name !== get(tool).path); + await queueFavoritePersistence(); }, isToolFavorite({ tool }: { tool: MaybeRef }) { - return favoriteToolsName.value.includes(get(tool).name) - || favoriteToolsName.value.includes(get(tool).path); + return favoriteToolPaths.value.includes(get(tool).path); }, - updateFavoriteTools(newOrder: ToolWithCategory[]) { - favoriteToolsName.value = newOrder.map(tool => tool.path); + async updateFavoriteTools(newOrder: ToolWithCategory[]) { + favoriteToolPaths.value = normalizeFavoriteToolPaths(newOrder.map(tool => tool.path)); + await queueFavoritePersistence(); }, }; }); diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 00000000..1971b87f --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,8 @@ +name = "it-tools" +compatibility_date = "2026-06-22" +pages_build_output_dir = "dist" + +[[d1_databases]] +binding = "DB" +database_name = "it-tools-favorites" +database_id = "771ebf21-67b4-481a-a542-437c7ed64a87"