diff --git a/README.md b/README.md index 296455b..072a765 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,10 @@ A self-hosted homepage portal for your homelab services. ## Features - Drag-and-drop sections and widgets -- Visual icon picker (5000+ icons) -- Custom icon uploads -- Labels with color coding +- icon picker (5000+ icons) +- Custom icon uploads or download from known sources +- Labels +- Health check - Theme customization (colors, card styles) - Dark/light mode - Edit mode with right-click context menus @@ -31,6 +32,10 @@ services: - /tmp ``` +> [!WARNING] +> Labbit has no built-in authentication. Run it on your LAN, over a VPN, or behind an auth proxy (Authelia, Authentik, Tailscale). + + ## Development ```bash diff --git a/app/components/board/BoardSection.vue b/app/components/board/BoardSection.vue index b51856f..80581f4 100644 --- a/app/components/board/BoardSection.vue +++ b/app/components/board/BoardSection.vue @@ -43,7 +43,7 @@ function onDragChange() { } function toggleCollapse() { - if (props.section.collapsible === false) return + if (!props.section.collapsible) return store.updateSection(props.section.id, { collapsed: !props.section.collapsed }) markDirty() } @@ -63,17 +63,17 @@ function toggleCollapse() {
-
+
-

+ Drag widgets here or add from the widget picker -

+
diff --git a/app/components/editor/EditorSectionSettings.vue b/app/components/editor/EditorSectionSettings.vue index 0a44d4d..0b4cc54 100644 --- a/app/components/editor/EditorSectionSettings.vue +++ b/app/components/editor/EditorSectionSettings.vue @@ -23,7 +23,7 @@ watch(open, (val) => { localColumns.value = props.section.columns localLayout.value = props.section.layout localShowTitle.value = props.section.showTitle - localCollapsible.value = props.section.collapsible !== false + localCollapsible.value = props.section.collapsible localCardVariant.value = props.section.defaults?.cardVariant || 'outline' } }) @@ -57,8 +57,7 @@ const columnOptions = [ const variantOptions: { label: string, value: string, description: string }[] = [ { label: 'Outline', value: 'outline', description: 'Bordered card' }, { label: 'Accent', value: 'accent', description: 'Colored border' }, - { label: 'Soft', value: 'soft', description: 'Tinted background' }, - { label: 'Subtle', value: 'subtle', description: 'Light background' }, + { label: 'Tinted', value: 'subtle', description: 'Light tinted background' }, { label: 'Ghost', value: 'ghost', description: 'No decoration' } ] diff --git a/app/components/editor/EditorWidgetSettings.vue b/app/components/editor/EditorWidgetSettings.vue index 3d484ae..61c56a4 100644 --- a/app/components/editor/EditorWidgetSettings.vue +++ b/app/components/editor/EditorWidgetSettings.vue @@ -16,9 +16,33 @@ const definition = computed(() => getDefinition(props.widget.kind)) const localOptions = ref>({}) +const { getAllPlugins } = usePluginRegistry() + +const enabledPlugins = computed(() => { + return getAllPlugins().filter((plugin) => { + if (plugin.compatibleWith !== '*' && !plugin.compatibleWith.includes(props.widget.kind)) return false + if (!plugin.settingsComponent) return false + const widgetEnabled = props.widget.plugins?.[plugin.id]?.enabled + const sectionEnabled = props.section.defaults?.plugins?.[plugin.id]?.enabled + return widgetEnabled ?? sectionEnabled ?? false + }) +}) + +const pluginConfigs = ref>>({}) + watch(open, (val) => { if (val) { localOptions.value = JSON.parse(JSON.stringify(props.widget.options)) + + const configs: Record> = {} + for (const plugin of enabledPlugins.value) { + configs[plugin.id] = { + ...plugin.defaultConfig, + ...(props.section.defaults?.plugins?.[plugin.id]?.config || {}), + ...(props.widget.plugins?.[plugin.id]?.config || {}) + } + } + pluginConfigs.value = configs } }) @@ -62,6 +86,13 @@ function createAndAssignLabel() { function handleSave() { store.updateWidgetOptions(props.section.id, props.widget.id, localOptions.value) + + for (const [pluginId, config] of Object.entries(pluginConfigs.value)) { + store.updateWidgetPlugins(props.section.id, props.widget.id, { + [pluginId]: { enabled: true, config } + }) + } + markDirty() open.value = false } @@ -229,6 +260,28 @@ function handleSave() { /> + + + +
diff --git a/app/components/editor/IconPicker.vue b/app/components/editor/IconPicker.vue index ed23599..89da155 100644 --- a/app/components/editor/IconPicker.vue +++ b/app/components/editor/IconPicker.vue @@ -9,39 +9,106 @@ const emit = defineEmits<{ 'update:iconType': [value: 'iconify' | 'url' | 'custom'] }>() -const activeTab = computed({ - get: () => props.iconType, - set: (val: string) => emit('update:iconType', val as 'iconify' | 'url' | 'custom') -}) - +const toast = useToast() const showIconifyBrowser = ref(false) const showCustomBrowser = ref(false) +const downloading = ref(false) + +const iconifyValue = ref('') +const imageValue = ref('') +const imageType = ref<'url' | 'custom'>('url') + +function initFromProps() { + if (props.iconType === 'iconify') { + iconifyValue.value = props.icon + } else { + imageValue.value = props.icon + imageType.value = props.iconType === 'custom' ? 'custom' : 'url' + } +} +initFromProps() -const urlInput = ref(props.icon) +const activeTab = ref(props.iconType === 'iconify' ? 'iconify' : 'custom') -watch(() => props.icon, (val) => { - if (props.iconType === 'url') urlInput.value = val +watch(() => [props.icon, props.iconType] as const, ([nextIcon, nextType]) => { + if (nextType === 'iconify') { + if (iconifyValue.value !== nextIcon) iconifyValue.value = nextIcon + if (activeTab.value !== 'iconify') activeTab.value = 'iconify' + } else { + const nextImageType = nextType === 'custom' ? 'custom' : 'url' + if (imageValue.value !== nextIcon) imageValue.value = nextIcon + if (imageType.value !== nextImageType) imageType.value = nextImageType + if (activeTab.value !== 'custom') activeTab.value = 'custom' + } }) -function onUrlChange(val: string) { - urlInput.value = val - emit('update:icon', val) -} +watch(activeTab, (tab) => { + if (tab === 'iconify') { + emit('update:iconType', 'iconify') + emit('update:icon', iconifyValue.value) + } else { + emit('update:iconType', imageType.value) + emit('update:icon', imageValue.value) + } +}) function onIconifySelect(iconName: string) { + iconifyValue.value = iconName emit('update:icon', iconName) + emit('update:iconType', 'iconify') showIconifyBrowser.value = false } +function onUrlInput(val: string) { + imageValue.value = val + imageType.value = 'url' + emit('update:icon', val) + emit('update:iconType', 'url') +} + function onCustomSelect(filename: string) { + imageValue.value = filename + imageType.value = 'custom' emit('update:icon', filename) + emit('update:iconType', 'custom') showCustomBrowser.value = false } +function onUploaded() { + showCustomBrowser.value = true +} + +async function downloadIcon() { + if (!imageValue.value || imageType.value !== 'url') return + + downloading.value = true + try { + const result = await $fetch<{ filename: string }>('/api/icons/custom/download', { + method: 'POST', + body: { url: imageValue.value } + }) + imageValue.value = result.filename + imageType.value = 'custom' + emit('update:icon', result.filename) + emit('update:iconType', 'custom') + toast.add({ title: 'Icon saved locally', color: 'success' }) + } catch (err: unknown) { + const message = (err as { data?: { statusMessage?: string } })?.data?.statusMessage || 'Download failed' + toast.add({ title: 'Download failed', description: message, color: 'error' }) + } finally { + downloading.value = false + } +} + +const imagePreviewSrc = computed(() => { + if (!imageValue.value) return '' + if (imageType.value === 'custom') return `/api/icons/custom/file/${imageValue.value}` + return imageValue.value +}) + const tabs = [ { label: 'Iconify', value: 'iconify', icon: 'i-lucide-search' }, - { label: 'Custom', value: 'custom', icon: 'i-lucide-upload' }, - { label: 'URL', value: 'url', icon: 'i-lucide-link' } + { label: 'Custom', value: 'custom', icon: 'i-lucide-image' } ] @@ -53,21 +120,22 @@ const tabs = [ size="sm" /> +
- {{ icon || 'No icon selected' }} + {{ iconifyValue || 'No icon selected' }}
+
+ +
+ + + + + + + + +
+ +
Preview
- {{ icon || 'No icon selected' }} + + {{ imageValue || 'No image selected' }} + +
- -
-
- -
- Preview - Preview + +
+
+
diff --git a/app/components/plugins/HealthDot.vue b/app/components/plugins/HealthDot.vue new file mode 100644 index 0000000..a904171 --- /dev/null +++ b/app/components/plugins/HealthDot.vue @@ -0,0 +1,47 @@ + + + + diff --git a/app/components/plugins/HealthDotSettings.vue b/app/components/plugins/HealthDotSettings.vue new file mode 100644 index 0000000..13c86f6 --- /dev/null +++ b/app/components/plugins/HealthDotSettings.vue @@ -0,0 +1,37 @@ + + + + diff --git a/app/components/widgets/WidgetWrapper.vue b/app/components/widgets/WidgetWrapper.vue index 72518c5..6a03b09 100644 --- a/app/components/widgets/WidgetWrapper.vue +++ b/app/components/widgets/WidgetWrapper.vue @@ -9,18 +9,30 @@ const props = defineProps<{ const { isEditing } = useEditMode() const isAccent = computed(() => props.section.defaults?.cardVariant === 'accent') +const isGhost = computed(() => props.section.defaults?.cardVariant === 'ghost') const isLinkable = computed(() => props.widget.kind === 'service-link') -const cardVariant = computed(() => { - const variant = props.section.defaults?.cardVariant || 'outline' +type UCardVariant = 'outline' | 'subtle' | 'soft' | 'solid' + +const cardVariant = computed(() => { + const variant = (props.section.defaults?.cardVariant || 'outline') as string if (variant === 'accent' || variant === 'ghost') return 'outline' - return variant + if (variant === 'soft') return 'subtle' + if (variant === 'subtle' || variant === 'solid') return variant + return 'outline' }) const cardColor = computed(() => { return props.section.defaults?.cardColor || undefined }) +const cardUi = computed(() => { + if (isGhost.value) { + return { root: 'rounded-lg bg-transparent ring-0 divide-y-0', body: 'p-4' } + } + return { body: 'p-4' } +}) + const resolvedPlugins = computed(() => { return useResolvedPlugins(props.section, props.widget) }) @@ -41,11 +53,11 @@ function pluginsAt(position: string) { :color="cardColor" class="relative h-full transition-all duration-200" :class="{ - 'ring-2 ring-primary/30 hover:ring-primary/50 select-none': isEditing, + 'hover:ring-2 hover:ring-primary/40 select-none cursor-grab': isEditing, 'ring-2 ring-primary/30': isAccent && !isEditing, 'hover:ring-1 hover:ring-accented cursor-pointer': isLinkable && !isEditing }" - :ui="{ body: 'p-4' }" + :ui="cardUi" >
, intervalMs: Ref) { + const result = ref({ + online: null, + status: 0, + latency: 0, + loading: true + }) + + let timer: number | null = null + + async function check() { + if (!url.value) return + + try { + const data = await $fetch<{ online: boolean, status: number, latency: number }>( + '/api/health/check', + { params: { url: url.value } } + ) + result.value = { ...data, loading: false } + } catch { + result.value = { online: false, status: 0, latency: 0, loading: false } + } + } + + function start() { + if (!import.meta.client) return + stop() + check() + if (intervalMs.value > 0) { + timer = window.setInterval(check, intervalMs.value) + } + } + + function stop() { + if (timer) { + window.clearInterval(timer) + timer = null + } + } + + watch([url, intervalMs], () => { + if (url.value) { + start() + } else { + stop() + } + }, { immediate: true }) + + onBeforeUnmount(() => stop()) + + return { result: readonly(result) } +} diff --git a/app/plugins/health-check.ts b/app/plugins/health-check.ts new file mode 100644 index 0000000..1119dc2 --- /dev/null +++ b/app/plugins/health-check.ts @@ -0,0 +1,19 @@ +import HealthDot from '~/components/plugins/HealthDot.vue' +import HealthDotSettings from '~/components/plugins/HealthDotSettings.vue' + +export default defineNuxtPlugin(() => { + const { register } = usePluginRegistry() + + register({ + id: 'health-check', + label: 'Health Check', + icon: 'i-lucide-activity', + defaultPosition: 'top-left', + defaultConfig: { + intervalSeconds: 30 + }, + compatibleWith: ['service-link'], + component: HealthDot, + settingsComponent: HealthDotSettings + }) +}) diff --git a/nuxt.config.ts b/nuxt.config.ts index 985aba2..ddba5a3 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -22,15 +22,6 @@ export default defineNuxtConfig({ compatibilityDate: '2025-01-15', - nitro: { - storage: { - boards: { - driver: 'fs', - base: './data/boards' - } - } - }, - eslint: { config: { stylistic: { diff --git a/package.json b/package.json index 3fedb51..e6004f3 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@nuxt/ui": "^4.4.0", "@pinia/nuxt": "0.11.3", "@vueuse/nuxt": "14.2.1", + "ipaddr.js": "^2.4.0", "nanoid": "^5.1.6", "nuxt": "^4.3.1", "tailwindcss": "^4.1.18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb7478b..40d4e55 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@vueuse/nuxt': specifier: 14.2.1 version: 14.2.1(magicast@0.5.2)(nuxt@4.3.1(@parcel/watcher@2.5.6)(@types/node@25.6.0)(@vue/compiler-sfc@3.5.28)(cac@6.7.14)(db0@0.3.4)(eslint@10.0.0(jiti@2.6.1))(ioredis@5.9.3)(lightningcss@1.31.1)(magicast@0.5.2)(optionator@0.9.4)(rollup@4.57.1)(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))(vue-tsc@3.2.4(typescript@5.9.3))(yaml@2.8.2))(vue@3.5.28(typescript@5.9.3)) + ipaddr.js: + specifier: ^2.4.0 + version: 2.4.0 nanoid: specifier: ^5.1.6 version: 5.1.6 @@ -3353,6 +3356,10 @@ packages: resolution: {integrity: sha512-VI5tMCdeoxZWU5vjHWsiE/Su76JGhBvWF1MJnV9ZtGltHk9BmD48oDq8Tj8haZ85aceXZMxLNDQZRVo5QKNgXA==} engines: {node: '>=12.22.0'} + ipaddr.js@2.4.0: + resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} + engines: {node: '>= 10'} + iron-webcrypto@1.2.1: resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} @@ -8729,6 +8736,8 @@ snapshots: transitivePeerDependencies: - supports-color + ipaddr.js@2.4.0: {} + iron-webcrypto@1.2.1: {} is-builtin-module@5.0.0: diff --git a/server/api/boards/[id].get.ts b/server/api/boards/[id].get.ts index ac8dadb..13292eb 100644 --- a/server/api/boards/[id].get.ts +++ b/server/api/boards/[id].get.ts @@ -1,14 +1,10 @@ -import type { Board } from '~~/shared/types' - export default defineEventHandler(async (event) => { const id = getRouterParam(event, 'id') if (!id || !VALID_BOARD_ID.test(id)) { throw createError({ statusCode: 400, statusMessage: 'Invalid board ID' }) } - const storage = useStorage('boards') - const board = await storage.getItem(id) - + const board = await readBoard(id) if (!board) { throw createError({ statusCode: 404, statusMessage: 'Board not found' }) } diff --git a/server/api/boards/[id].put.ts b/server/api/boards/[id].put.ts index 4a11157..0df3134 100644 --- a/server/api/boards/[id].put.ts +++ b/server/api/boards/[id].put.ts @@ -7,9 +7,8 @@ export default defineEventHandler(async (event) => { } const body = await readBody(event) - const storage = useStorage('boards') - const existing = await storage.getItem(id) + const existing = await readBoard(id) if (!existing) { throw createError({ statusCode: 404, statusMessage: 'Board not found' }) } @@ -17,6 +16,6 @@ export default defineEventHandler(async (event) => { body.id = id body.createdAt = existing.createdAt body.updatedAt = new Date().toISOString() - await storage.setItem(id, body) + await writeBoard(body) return body }) diff --git a/server/api/boards/index.get.ts b/server/api/boards/index.get.ts index 87a2468..3428bac 100644 --- a/server/api/boards/index.get.ts +++ b/server/api/boards/index.get.ts @@ -1,14 +1,4 @@ -import type { Board } from '~~/shared/types' - export default defineEventHandler(async () => { - const storage = useStorage('boards') - const keys = await storage.getKeys() - const boards: Board[] = [] - - for (const key of keys) { - const board = await storage.getItem(key) - if (board) boards.push(board) - } - + const boards = await listBoards() return boards.sort((a, b) => a.title.localeCompare(b.title)) }) diff --git a/server/api/boards/index.post.ts b/server/api/boards/index.post.ts index a55996d..0f700b8 100644 --- a/server/api/boards/index.post.ts +++ b/server/api/boards/index.post.ts @@ -10,9 +10,7 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 400, statusMessage: 'Board must have a title' }) } - const storage = useStorage('boards') - - const existing = await storage.getItem(body.id) + const existing = await readBoard(body.id) if (existing) { throw createError({ statusCode: 409, statusMessage: 'Board already exists' }) } @@ -21,6 +19,6 @@ export default defineEventHandler(async (event) => { body.createdAt = now body.updatedAt = now - await storage.setItem(body.id, body) + await writeBoard(body) return body }) diff --git a/server/api/health/check.get.ts b/server/api/health/check.get.ts new file mode 100644 index 0000000..a6f8035 --- /dev/null +++ b/server/api/health/check.get.ts @@ -0,0 +1,42 @@ +export default defineEventHandler(async (event) => { + const query = getQuery(event) + const url = query.url as string + + if (!url) { + throw createError({ statusCode: 400, statusMessage: 'Missing url parameter' }) + } + + let parsedUrl: URL + try { + parsedUrl = new URL(url) + } catch { + throw createError({ statusCode: 400, statusMessage: 'Invalid URL' }) + } + + if (!['http:', 'https:'].includes(parsedUrl.protocol)) { + throw createError({ statusCode: 400, statusMessage: 'Only HTTP/HTTPS URLs are supported' }) + } + + const start = Date.now() + + try { + const response = await $fetch.raw(url, { + method: 'GET', + timeout: 10_000, + redirect: 'follow', + ignoreResponseError: true + }) + + return { + status: response.status, + latency: Date.now() - start, + online: response.status >= 200 && response.status < 400 + } + } catch { + return { + status: 0, + latency: Date.now() - start, + online: false + } + } +}) diff --git a/server/api/icons/custom/download.post.ts b/server/api/icons/custom/download.post.ts new file mode 100644 index 0000000..72e3a05 --- /dev/null +++ b/server/api/icons/custom/download.post.ts @@ -0,0 +1,194 @@ +import { writeFile, readdir, mkdir } from 'node:fs/promises' +import { join, extname } from 'node:path' +import { Buffer } from 'node:buffer' +import { isIP } from 'node:net' +import { lookup } from 'node:dns/promises' +import ipaddr from 'ipaddr.js' + +const MAX_REDIRECTS = 3 +const FETCH_TIMEOUT_MS = 10000 + +const MIME_TO_EXT: Record = { + 'image/svg+xml': '.svg', + 'image/png': '.png', + 'image/jpeg': '.jpg', + 'image/webp': '.webp' +} + +const BLOCKED_RANGES = new Set([ + 'private', + 'loopback', + 'linkLocal', + 'uniqueLocal', + 'multicast', + 'unspecified', + 'reserved', + 'carrierGradeNat', + 'broadcast' +]) + +function isPrivateIP(ip: string): boolean { + let parsed + try { + parsed = ipaddr.parse(ip) + } catch { + return true + } + + if (parsed.kind() === 'ipv6' && (parsed as ipaddr.IPv6).isIPv4MappedAddress()) { + parsed = (parsed as ipaddr.IPv6).toIPv4Address() + } + + const range = parsed.range() + return BLOCKED_RANGES.has(range) +} + +async function assertSafeHost(host: string): Promise { + let address = host + if (!isIP(host)) { + try { + const resolved = await lookup(host) + address = resolved.address + } catch { + throw createError({ statusCode: 400, statusMessage: 'Could not resolve hostname' }) + } + } + if (isPrivateIP(address)) { + throw createError({ statusCode: 400, statusMessage: 'Requests to private/internal addresses are not allowed' }) + } +} + +async function fetchWithSafeRedirects(initialUrl: string): Promise { + let currentUrl = initialUrl + + for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { + const parsed = new URL(currentUrl) + if (!['http:', 'https:'].includes(parsed.protocol)) { + throw createError({ statusCode: 400, statusMessage: 'Only HTTP/HTTPS URLs are supported' }) + } + await assertSafeHost(parsed.hostname) + + let response: Response + try { + response = await fetch(currentUrl, { + headers: { 'User-Agent': 'Labbit/1.0' }, + redirect: 'manual', + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) + }) + } catch { + throw createError({ statusCode: 502, statusMessage: 'Failed to fetch icon from URL' }) + } + + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get('location') + if (!location) { + throw createError({ statusCode: 502, statusMessage: 'Redirect with no Location header' }) + } + if (hop === MAX_REDIRECTS) { + throw createError({ statusCode: 502, statusMessage: 'Too many redirects' }) + } + currentUrl = new URL(location, currentUrl).toString() + continue + } + + return response + } + + throw createError({ statusCode: 502, statusMessage: 'Too many redirects' }) +} + +function filenameFromUrl(url: string, contentType: string): string { + const pathname = new URL(url).pathname + const basename = pathname.split('/').pop() || 'icon' + const ext = extname(basename).toLowerCase() + + if (ext && ALLOWED_ICON_EXT.includes(ext)) { + return sanitizeIconFilename(basename) + } + + const mappedExt = MIME_TO_EXT[contentType] + if (mappedExt) { + const nameWithoutExt = basename.replace(/\.[^.]+$/, '') || 'icon' + return sanitizeIconFilename(`${nameWithoutExt}${mappedExt}`) + } + + return '' +} + +export default defineEventHandler(async (event) => { + const body = await readBody<{ url: string }>(event) + + if (!body.url || typeof body.url !== 'string') { + throw createError({ statusCode: 400, statusMessage: 'URL is required' }) + } + + try { + new URL(body.url) + } catch { + throw createError({ statusCode: 400, statusMessage: 'Invalid URL' }) + } + + await mkdir(ICONS_DIR, { recursive: true }) + + const existing = await readdir(ICONS_DIR) + if (existing.length >= MAX_ICON_COUNT) { + throw createError({ statusCode: 400, statusMessage: `Maximum ${MAX_ICON_COUNT} custom icons allowed` }) + } + + const response = await fetchWithSafeRedirects(body.url) + + if (!response.ok) { + throw createError({ statusCode: 502, statusMessage: `Remote server returned ${response.status}` }) + } + + const contentLength = response.headers.get('content-length') + if (contentLength && parseInt(contentLength, 10) > MAX_ICON_SIZE) { + throw createError({ statusCode: 400, statusMessage: 'Remote icon exceeds 512KB limit' }) + } + + const contentType = (response.headers.get('content-type') || '').split(';')[0]?.trim() || '' + if (!Object.keys(MIME_TO_EXT).includes(contentType)) { + throw createError({ statusCode: 400, statusMessage: `Unsupported content type: ${contentType}` }) + } + + const arrayBuffer = await response.arrayBuffer() + let data = Buffer.from(arrayBuffer) + + if (data.length > MAX_ICON_SIZE) { + throw createError({ statusCode: 400, statusMessage: 'Downloaded icon exceeds 512KB limit' }) + } + + const safeName = filenameFromUrl(body.url, contentType) + if (!safeName) { + throw createError({ statusCode: 400, statusMessage: 'Could not determine a valid filename from URL' }) + } + + const ext = extname(safeName).toLowerCase() + if (ext === '.svg') { + const content = data.toString('utf-8') + if (!isValidSvg(content)) { + throw createError({ statusCode: 400, statusMessage: 'Invalid SVG file' }) + } + data = Buffer.from(sanitizeSvg(content), 'utf-8') + } + + let finalName = safeName + if (existing.includes(finalName)) { + const base = finalName.slice(0, -ext.length) + let counter = 1 + while (existing.includes(`${base}-${counter}${ext}`)) counter++ + finalName = `${base}-${counter}${ext}` + } + + try { + await writeFile(join(ICONS_DIR, finalName), data, { flag: 'wx' }) + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'EEXIST') { + throw createError({ statusCode: 409, statusMessage: 'Icon file already exists' }) + } + throw err + } + + setResponseStatus(event, 201) + return { filename: finalName, url: `/api/icons/custom/file/${finalName}` } +}) diff --git a/server/api/icons/custom/index.post.ts b/server/api/icons/custom/index.post.ts index 5535b1d..3de18e1 100644 --- a/server/api/icons/custom/index.post.ts +++ b/server/api/icons/custom/index.post.ts @@ -2,21 +2,8 @@ import { writeFile, readdir, mkdir } from 'node:fs/promises' import { join, extname } from 'node:path' import { Buffer } from 'node:buffer' -const MAX_FILE_SIZE = 512 * 1024 -const MAX_FILE_COUNT = 200 const ALLOWED_TYPES = ['image/svg+xml', 'image/png', 'image/jpeg', 'image/webp'] -function sanitizeFilename(raw: string): string { - const ext = extname(raw).toLowerCase() - const base = raw - .slice(0, -ext.length) - .toLowerCase() - .replace(/[^a-z0-9-]/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, '') - return base ? `${base}${ext}` : '' -} - export default defineEventHandler(async (event) => { const formData = await readMultipartFormData(event) if (!formData || formData.length === 0) { @@ -26,8 +13,8 @@ export default defineEventHandler(async (event) => { await mkdir(ICONS_DIR, { recursive: true }) const existing = await readdir(ICONS_DIR) - if (existing.length >= MAX_FILE_COUNT) { - throw createError({ statusCode: 400, statusMessage: `Maximum ${MAX_FILE_COUNT} custom icons allowed` }) + if (existing.length >= MAX_ICON_COUNT) { + throw createError({ statusCode: 400, statusMessage: `Maximum ${MAX_ICON_COUNT} custom icons allowed` }) } const file = formData[0] @@ -35,7 +22,7 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 400, statusMessage: 'Invalid file' }) } - if (file.data.length > MAX_FILE_SIZE) { + if (file.data.length > MAX_ICON_SIZE) { throw createError({ statusCode: 400, statusMessage: 'File too large (max 512KB)' }) } @@ -50,7 +37,7 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 400, statusMessage: 'Content type header required' }) } - const safeName = sanitizeFilename(file.filename) + const safeName = sanitizeIconFilename(file.filename) if (!safeName) { throw createError({ statusCode: 400, statusMessage: 'Invalid filename' }) } diff --git a/server/plugins/seed.ts b/server/plugins/seed.ts index d013537..64b8a65 100644 --- a/server/plugins/seed.ts +++ b/server/plugins/seed.ts @@ -40,7 +40,7 @@ const DEFAULT_BOARD: Board = { } ], defaults: { - cardVariant: 'soft' + cardVariant: 'subtle' } }, { @@ -194,12 +194,10 @@ const DEFAULT_BOARD: Board = { } export default defineNitroPlugin(async () => { - const storage = useStorage('boards') - const existing = await storage.getItem('default') + const boards = await listBoards() + if (boards.length > 0) return - if (!existing) { - console.log('[labbit] No boards found, creating default board...') - await storage.setItem('default', DEFAULT_BOARD) - console.log('[labbit] Default board created.') - } + console.log('[labbit] No boards found, creating default board...') + await writeBoard(DEFAULT_BOARD) + console.log('[labbit] Default board created.') }) diff --git a/server/utils/board-storage.ts b/server/utils/board-storage.ts new file mode 100644 index 0000000..6c063e6 --- /dev/null +++ b/server/utils/board-storage.ts @@ -0,0 +1,42 @@ +import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import type { Board } from '~~/shared/types' + +function boardPath(id: string): string { + return join(BOARDS_DIR, `${id}.json`) +} + +export async function readBoard(id: string): Promise { + try { + const raw = await readFile(boardPath(id), 'utf-8') + return JSON.parse(raw) + } catch { + return null + } +} + +export async function writeBoard(board: Board): Promise { + await mkdir(BOARDS_DIR, { recursive: true }) + await writeFile(boardPath(board.id), JSON.stringify(board, null, 2), 'utf-8') +} + +export async function listBoards(): Promise { + let files: string[] + try { + files = await readdir(BOARDS_DIR) + } catch { + return [] + } + + const boards: Board[] = [] + for (const file of files) { + if (!file.endsWith('.json')) continue + try { + const raw = await readFile(join(BOARDS_DIR, file), 'utf-8') + boards.push(JSON.parse(raw)) + } catch { + // skip malformed files + } + } + return boards +} diff --git a/server/utils/constants.ts b/server/utils/constants.ts index 99d620f..d3e741d 100644 --- a/server/utils/constants.ts +++ b/server/utils/constants.ts @@ -1,6 +1,20 @@ -import { join } from 'node:path' +import { join, extname } from 'node:path' export const VALID_BOARD_ID = /^[a-z0-9][a-z0-9_-]*$/ +export const BOARDS_DIR = join(process.cwd(), 'data', 'boards') export const ICONS_DIR = join(process.cwd(), 'data', 'icons') export const SAFE_ICON_FILENAME = /^[a-z0-9][a-z0-9_-]*\.[a-z]+$/ export const ALLOWED_ICON_EXT = ['.svg', '.png', '.jpg', '.jpeg', '.webp'] +export const MAX_ICON_SIZE = 512 * 1024 +export const MAX_ICON_COUNT = 200 + +export function sanitizeIconFilename(raw: string): string { + const ext = extname(raw).toLowerCase() + const base = raw + .slice(0, -ext.length) + .toLowerCase() + .replace(/[^a-z0-9-]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + return base ? `${base}${ext}` : '' +} diff --git a/server/utils/icon-index.ts b/server/utils/icon-index.ts index da801ef..ff63296 100644 --- a/server/utils/icon-index.ts +++ b/server/utils/icon-index.ts @@ -1,5 +1,5 @@ -import { readFileSync } from 'node:fs' -import { createRequire } from 'node:module' +import lucideData from '@iconify-json/lucide/icons.json' +import simpleIconsData from '@iconify-json/simple-icons/icons.json' interface IconCollection { id: string @@ -8,33 +8,22 @@ interface IconCollection { iconNames: string[] } -let collections: IconCollection[] | null = null - -function loadCollections(): IconCollection[] { - const require = createRequire(import.meta.url) - - const specs = [ - { id: 'lucide', name: 'Lucide', pkg: '@iconify-json/lucide/icons.json' }, - { id: 'simple-icons', name: 'Simple Icons', pkg: '@iconify-json/simple-icons/icons.json' } - ] - - return specs.map((spec) => { - const jsonPath = require.resolve(spec.pkg) - const data = JSON.parse(readFileSync(jsonPath, 'utf-8')) - const iconNames = Object.keys(data.icons) - return { - id: spec.id, - name: spec.name, - prefix: data.prefix, - iconNames - } - }) -} +const collections: IconCollection[] = [ + { + id: 'lucide', + name: 'Lucide', + prefix: lucideData.prefix, + iconNames: Object.keys(lucideData.icons) + }, + { + id: 'simple-icons', + name: 'Simple Icons', + prefix: simpleIconsData.prefix, + iconNames: Object.keys(simpleIconsData.icons) + } +] export function getCollections(): IconCollection[] { - if (!collections) { - collections = loadCollections() - } return collections } diff --git a/shared/types/index.ts b/shared/types/index.ts index 9dccc3d..1579353 100644 --- a/shared/types/index.ts +++ b/shared/types/index.ts @@ -51,7 +51,7 @@ export interface BoardSection { showTitle: boolean widgets: WidgetInstance[] defaults: { - cardVariant?: 'outline' | 'accent' | 'soft' | 'subtle' | 'ghost' + cardVariant?: 'outline' | 'accent' | 'subtle' | 'ghost' cardColor?: string plugins?: Record