feat: deploy IT Tools to Cloudflare Pages

Closes #1823
This commit is contained in:
shake 2026-06-22 15:57:15 +08:00
parent d505845f91
commit 59a70ebfa0
12 changed files with 341 additions and 13 deletions

View file

@ -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

10
functions/_lib/d1.ts Normal file
View file

@ -0,0 +1,10 @@
export interface D1PreparedStatement {
bind(...values: unknown[]): D1PreparedStatement;
all<T = Record<string, unknown>>(): Promise<{ results: T[] }>;
run(): Promise<unknown>;
}
export interface D1Database {
prepare(query: string): D1PreparedStatement;
batch(statements: D1PreparedStatement[]): Promise<unknown>;
}

View file

@ -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'));
}

View file

@ -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 });
}

11
functions/api/me.ts Normal file
View file

@ -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' } });
}

View file

@ -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);

1
public/_redirects Normal file
View file

@ -0,0 +1 @@
/* /index.html 200

17
shared/favorites.ts Normal file
View file

@ -0,0 +1,17 @@
export function normalizeFavoriteToolPaths(paths: readonly string[]): string[] {
const seen = new Set<string>();
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;
}

View file

@ -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',
]);
});
});

View file

@ -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)));
}

View file

@ -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<string[]>([]);
const favoriteSyncMode = ref<'loading' | 'remote' | 'local'>('loading');
let favoriteHydrationPromise: Promise<void> | 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<string[]>;
const { t } = useI18n();
void hydrateFavoriteToolPaths();
const tools = computed<ToolWithCategory[]>(() => 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<Tool> }) {
async addToolToFavorites({ tool }: { tool: MaybeRef<Tool> }) {
const toolPath = get(tool).path;
if (toolPath) {
favoriteToolsName.value.push(toolPath);
favoriteToolPaths.value = normalizeFavoriteToolPaths([...favoriteToolPaths.value, toolPath]);
await queueFavoritePersistence();
}
},
removeToolFromFavorites({ tool }: { tool: MaybeRef<Tool> }) {
favoriteToolsName.value = favoriteToolsName.value.filter(name => get(tool).name !== name && get(tool).path !== name);
async removeToolFromFavorites({ tool }: { tool: MaybeRef<Tool> }) {
favoriteToolPaths.value = favoriteToolPaths.value.filter(name => name !== get(tool).path);
await queueFavoritePersistence();
},
isToolFavorite({ tool }: { tool: MaybeRef<Tool> }) {
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();
},
};
});

8
wrangler.toml Normal file
View file

@ -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"