diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 0ff6519..fb62190 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -197,7 +197,7 @@ export default async function RootLayout({
-

© {new Date().getFullYear()} Backerup — Open source under the MIT License.

+

© {new Date().getFullYear()} Backerup — Open source.

All systems operational diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 445d0f4..ebdb840 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -101,24 +101,6 @@ const features = [ title: "Scheduled backups", body: "Define cron-based backup jobs per container with configurable retention: keep latest N, keep every N-th, or purge older than N days.", }, - { - icon: , - gradient: "from-pink-500/15 to-rose-600/15", - border: "border-pink-500/20", - iconBg: "bg-pink-500/10", - iconColor: "text-pink-400", - title: "Community sharing", - body: "Publish your container configs so others can spin up the same setup in seconds. Browse configs shared by the community right here.", - }, - { - icon: , - gradient: "from-slate-400/10 to-slate-500/10", - border: "border-slate-600/30", - iconBg: "bg-slate-500/10", - iconColor: "text-slate-300", - title: "Self-hosted & private", - body: "Runs entirely inside your Docker environment. No cloud dependencies, no data leaves your server unless you choose to share.", - }, ]; const steps = [ @@ -153,16 +135,16 @@ const steps = [ ]; const pills = [ - { label: "MIT Licensed", color: "text-emerald-400 bg-emerald-500/10 border-emerald-500/20" }, + { label: "Open source", color: "text-emerald-400 bg-emerald-500/10 border-emerald-500/20" }, { label: "Zero cloud deps", color: "text-cyan-400 bg-cyan-500/10 border-cyan-500/20" }, { label: "Self-hosted", color: "text-violet-400 bg-violet-500/10 border-violet-500/20" }, { label: "Docker native", color: "text-blue-400 bg-blue-500/10 border-blue-500/20" }, ]; const stats = [ - { num: 100, suffix: "%", label: "Open source", sub: "MIT licensed" }, + { num: 100, suffix: "%", label: "Open source", sub: "source available" }, { num: 500, suffix: " MB", label: "Max file size", sub: "per upload" }, - { num: 6, suffix: "+", label: "Backup strategies", sub: "fully configurable" }, + { num: 4, suffix: "", label: "Backup strategies", sub: "fully configurable" }, { num: 0, suffix: "", label: "Cloud dependencies", sub: "runs on your infra" }, ]; diff --git a/frontend/bun.lock b/frontend/bun.lock index 76589fe..de09158 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -5,6 +5,7 @@ "": { "name": "backerup-website", "dependencies": { + "@libsql/client": "^0.17.3", "jose": "^6.2.3", "nanoid": "^5.1.11", "next": "16.2.6", @@ -156,8 +157,36 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@libsql/client": ["@libsql/client@0.17.3", "", { "dependencies": { "@libsql/core": "^0.17.3", "@libsql/hrana-client": "^0.10.0", "js-base64": "^3.7.5", "libsql": "^0.5.28", "promise-limit": "^2.7.0" } }, "sha512-HXk9wiAoJbKFbyBH4O+aEhN6ir5ERXuXvwE5OD2eR4/5RUa3Pw/8L9zrnVdU+iNJitRvisPWaIwmhkO3bH7giA=="], + + "@libsql/core": ["@libsql/core@0.17.3", "", { "dependencies": { "js-base64": "^3.7.5" } }, "sha512-2UjK1i7JBkMduJo4WdvvBxMMvVJ31pArBZNONyz/GCJJAH+1UHat2X6vn10S/WpY5fKzIT98WqYFl2vzWRLOfg=="], + + "@libsql/darwin-arm64": ["@libsql/darwin-arm64@0.5.29", "", { "os": "darwin", "cpu": "arm64" }, "sha512-K+2RIB1OGFPYQbfay48GakLhqf3ArcbHqPFu7EZiaUcRgFcdw8RoltsMyvbj5ix2fY0HV3Q3Ioa/ByvQdaSM0A=="], + + "@libsql/darwin-x64": ["@libsql/darwin-x64@0.5.29", "", { "os": "darwin", "cpu": "x64" }, "sha512-OtT+KFHsKFy1R5FVadr8FJ2Bb1mghtXTyJkxv0trocq7NuHntSki1eUbxpO5ezJesDvBlqFjnWaYYY516QNLhQ=="], + + "@libsql/hrana-client": ["@libsql/hrana-client@0.10.0", "", { "dependencies": { "@libsql/isomorphic-ws": "^0.1.5", "js-base64": "^3.7.5" } }, "sha512-OoA4EMqRAC7kn7V2P6EQqRcpZf2W+AjsNIyCizBg339Tq/aMC7sRnzs3SklderhmQWAqEzvv8A2vhxVmWpkVvw=="], + + "@libsql/isomorphic-ws": ["@libsql/isomorphic-ws@0.1.5", "", { "dependencies": { "@types/ws": "^8.5.4", "ws": "^8.13.0" } }, "sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg=="], + + "@libsql/linux-arm-gnueabihf": ["@libsql/linux-arm-gnueabihf@0.5.29", "", { "os": "linux", "cpu": "arm" }, "sha512-CD4n4zj7SJTHso4nf5cuMoWoMSS7asn5hHygsDuhRl8jjjCTT3yE+xdUvI4J7zsyb53VO5ISh4cwwOtf6k2UhQ=="], + + "@libsql/linux-arm-musleabihf": ["@libsql/linux-arm-musleabihf@0.5.29", "", { "os": "linux", "cpu": "arm" }, "sha512-2Z9qBVpEJV7OeflzIR3+l5yAd4uTOLxklScYTwpZnkm2vDSGlC1PRlueLaufc4EFITkLKXK2MWBpexuNJfMVcg=="], + + "@libsql/linux-arm64-gnu": ["@libsql/linux-arm64-gnu@0.5.29", "", { "os": "linux", "cpu": "arm64" }, "sha512-gURBqaiXIGGwFNEaUj8Ldk7Hps4STtG+31aEidCk5evMMdtsdfL3HPCpvys+ZF/tkOs2MWlRWoSq7SOuCE9k3w=="], + + "@libsql/linux-arm64-musl": ["@libsql/linux-arm64-musl@0.5.29", "", { "os": "linux", "cpu": "arm64" }, "sha512-fwgYZ0H8mUkyVqXZHF3mT/92iIh1N94Owi/f66cPVNsk9BdGKq5gVpoKO+7UxaNzuEH1roJp2QEwsCZMvBLpqg=="], + + "@libsql/linux-x64-gnu": ["@libsql/linux-x64-gnu@0.5.29", "", { "os": "linux", "cpu": "x64" }, "sha512-y14V0vY0nmMC6G0pHeJcEarcnGU2H6cm21ZceRkacWHvQAEhAG0latQkCtoS2njFOXiYIg+JYPfAoWKbi82rkg=="], + + "@libsql/linux-x64-musl": ["@libsql/linux-x64-musl@0.5.29", "", { "os": "linux", "cpu": "x64" }, "sha512-gquqwA/39tH4pFl+J9n3SOMSymjX+6kZ3kWgY3b94nXFTwac9bnFNMffIomgvlFaC4ArVqMnOZD3nuJ3H3VO1w=="], + + "@libsql/win32-x64-msvc": ["@libsql/win32-x64-msvc@0.5.29", "", { "os": "win32", "cpu": "x64" }, "sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], + "@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="], + "@next/env": ["@next/env@16.2.6", "", {}, "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw=="], "@next/eslint-plugin-next": ["@next/eslint-plugin-next@16.2.6", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-Z8l6o4JWKUl755x4R+wogD86KPeU+Ckw4K+SYG4kHeOJtRenDeK+OSbGcqZpDtbwn9DsJVdir2UxmwXuinUbUw=="], @@ -234,6 +263,8 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.3", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/type-utils": "8.59.3", "@typescript-eslint/utils": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.3", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.3", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg=="], @@ -382,7 +413,7 @@ "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], - "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="], "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], @@ -586,6 +617,8 @@ "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + "js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -610,6 +643,8 @@ "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + "libsql": ["libsql@0.5.29", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.29", "@libsql/darwin-x64": "0.5.29", "@libsql/linux-arm-gnueabihf": "0.5.29", "@libsql/linux-arm-musleabihf": "0.5.29", "@libsql/linux-arm64-gnu": "0.5.29", "@libsql/linux-arm64-musl": "0.5.29", "@libsql/linux-x64-gnu": "0.5.29", "@libsql/linux-x64-musl": "0.5.29", "@libsql/win32-x64-msvc": "0.5.29" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -710,6 +745,8 @@ "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + "promise-limit": ["promise-limit@2.7.0", "", {}, "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw=="], + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], @@ -844,6 +881,8 @@ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], @@ -888,12 +927,16 @@ "is-bun-module/semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], + "lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "postcss/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + "sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "sharp/semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], diff --git a/frontend/lib/db.ts b/frontend/lib/db.ts new file mode 100644 index 0000000..f0866d2 --- /dev/null +++ b/frontend/lib/db.ts @@ -0,0 +1,127 @@ +/** + * lib/db.ts + * Singleton libSQL (SQLite) client. Call getDb() anywhere on the server. + * + * WAL journal mode is set for better concurrency. + * On first boot, existing meta.json / users.json are imported and renamed + * to *.migrated so the one-time import never runs twice. + */ +import { createClient, type Client } from '@libsql/client' +import path from 'path' +import fs from 'fs' + +const DATA_DIR = path.join(process.cwd(), 'data') +const DB_URL = `file:${path.join(DATA_DIR, 'backerup.db')}` + +let _client: Client | null = null + +export function getDb(): Client { + if (_client) return _client + fs.mkdirSync(DATA_DIR, { recursive: true }) + _client = createClient({ url: DB_URL }) + // Fire-and-forget: migrations run before first real query via ensureReady() + return _client +} + +/** Call once at startup (or await before first query if needed). */ +let _ready: Promise | null = null + +export function ensureReady(): Promise { + if (_ready) return _ready + _ready = (async () => { + const db = getDb() + await applyMigrations(db) + await importLegacyJson(db) + })() + return _ready +} + +// ── Schema ──────────────────────────────────────────────────────────────────── + +async function applyMigrations(db: Client): Promise { + await db.executeMultiple(` + PRAGMA journal_mode = WAL; + PRAGMA foreign_keys = ON; + PRAGMA synchronous = NORMAL; + + CREATE TABLE IF NOT EXISTS files ( + token TEXT PRIMARY KEY, + original_name TEXT NOT NULL, + display_name TEXT, + size INTEGER NOT NULL, + type TEXT NOT NULL, + uploaded_at TEXT NOT NULL, + description TEXT, + group_name TEXT, + container_name TEXT, + image TEXT + ); + + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + password_hash TEXT NOT NULL, + salt TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + created_at TEXT NOT NULL + ); + `) +} + +// ── One-time JSON → SQLite import ───────────────────────────────────────────── + +async function importLegacyJson(db: Client): Promise { + const metaPath = path.join(DATA_DIR, 'meta.json') + if (fs.existsSync(metaPath)) { + try { + const { rows } = await db.execute('SELECT COUNT(*) as cnt FROM files') + if ((rows[0].cnt as number) === 0) { + type OldRecord = { + token: string; originalName: string; displayName?: string; size: number + type: string; uploadedAt: string; description?: string; group?: string + containerName?: string; image?: string + } + const records = JSON.parse(fs.readFileSync(metaPath, 'utf-8')) as OldRecord[] + for (const r of records) { + await db.execute({ + sql: `INSERT OR IGNORE INTO files + (token,original_name,display_name,size,type,uploaded_at,description,group_name,container_name,image) + VALUES (?,?,?,?,?,?,?,?,?,?)`, + args: [r.token, r.originalName, r.displayName ?? null, r.size, r.type, + r.uploadedAt, r.description ?? null, r.group ?? null, + r.containerName ?? null, r.image ?? null], + }) + } + fs.renameSync(metaPath, metaPath + '.migrated') + console.log(`[db] Imported ${records.length} file records from meta.json`) + } + } catch (e) { + console.error('[db] Failed to import meta.json:', e) + } + } + + const usersPath = path.join(DATA_DIR, 'users.json') + if (fs.existsSync(usersPath)) { + try { + const { rows } = await db.execute('SELECT COUNT(*) as cnt FROM users') + if ((rows[0].cnt as number) === 0) { + type OldUser = { + id: string; username: string; passwordHash: string; salt: string + role: string; createdAt: string + } + const users = JSON.parse(fs.readFileSync(usersPath, 'utf-8')) as OldUser[] + for (const u of users) { + await db.execute({ + sql: `INSERT OR IGNORE INTO users (id,username,password_hash,salt,role,created_at) + VALUES (?,?,?,?,?,?)`, + args: [u.id, u.username, u.passwordHash, u.salt, u.role, u.createdAt], + }) + } + fs.renameSync(usersPath, usersPath + '.migrated') + console.log(`[db] Imported ${users.length} users from users.json`) + } + } catch (e) { + console.error('[db] Failed to import users.json:', e) + } + } +} diff --git a/frontend/lib/store.ts b/frontend/lib/store.ts index 91dfaf4..53d2183 100644 --- a/frontend/lib/store.ts +++ b/frontend/lib/store.ts @@ -1,5 +1,6 @@ import path from 'path' import fs from 'fs/promises' +import { getDb, ensureReady } from './db' export type FileType = 'backerup' | 'config' @@ -17,51 +18,77 @@ export interface FileRecord { } const DATA_DIR = path.join(process.cwd(), 'data') -const META_FILE = path.join(DATA_DIR, 'meta.json') const UPLOADS_DIR = path.join(DATA_DIR, 'uploads') -async function ensureDirs() { - await fs.mkdir(UPLOADS_DIR, { recursive: true }) +type DbRow = Record + +function rowToRecord(row: DbRow): FileRecord { + return { + token: row.token as string, + originalName: row.original_name as string, + displayName: (row.display_name as string | null) ?? undefined, + size: row.size as number, + type: row.type as FileType, + uploadedAt: row.uploaded_at as string, + description: (row.description as string | null) ?? undefined, + group: (row.group_name as string | null) ?? undefined, + containerName: (row.container_name as string | null) ?? undefined, + image: (row.image as string | null) ?? undefined, + } } export async function readMeta(): Promise { - try { - const text = await fs.readFile(META_FILE, 'utf-8') - return JSON.parse(text) as FileRecord[] - } catch { - return [] - } + await ensureReady() + const { rows } = await getDb().execute( + 'SELECT * FROM files ORDER BY uploaded_at DESC', + ) + return rows.map(rowToRecord) } export async function listGroups(): Promise { - const records = await readMeta() - const groups = new Set() - for (const r of records) { - if (r.group) groups.add(r.group) - } - return Array.from(groups).sort() -} - -async function writeMeta(records: FileRecord[]): Promise { - await ensureDirs() - await fs.writeFile(META_FILE, JSON.stringify(records, null, 2), 'utf-8') + await ensureReady() + const { rows } = await getDb().execute( + 'SELECT DISTINCT group_name FROM files WHERE group_name IS NOT NULL ORDER BY group_name', + ) + return rows.map((r) => r.group_name as string) } export async function addFile(record: FileRecord, buffer: Buffer): Promise { - await ensureDirs() + await ensureReady() + await fs.mkdir(UPLOADS_DIR, { recursive: true }) const dir = path.join(UPLOADS_DIR, record.token) await fs.mkdir(dir, { recursive: true }) await fs.writeFile(path.join(dir, record.originalName), buffer) - const records = await readMeta() - records.unshift(record) - await writeMeta(records) + + await getDb().execute({ + sql: `INSERT INTO files + (token,original_name,display_name,size,type,uploaded_at,description,group_name,container_name,image) + VALUES (?,?,?,?,?,?,?,?,?,?)`, + args: [ + record.token, + record.originalName, + record.displayName ?? null, + record.size, + record.type, + record.uploadedAt, + record.description ?? null, + record.group ?? null, + record.containerName ?? null, + record.image ?? null, + ], + }) } export async function getFile(token: string): Promise { - const records = await readMeta() - return records.find((r) => r.token === token) + await ensureReady() + const { rows } = await getDb().execute({ + sql: 'SELECT * FROM files WHERE token = ?', + args: [token], + }) + return rows[0] ? rowToRecord(rows[0]) : undefined } export function getUploadPath(token: string, originalName: string): string { return path.join(UPLOADS_DIR, token, originalName) } + diff --git a/frontend/lib/users.ts b/frontend/lib/users.ts index 56434b0..ba31516 100644 --- a/frontend/lib/users.ts +++ b/frontend/lib/users.ts @@ -1,7 +1,6 @@ -import path from 'path' -import fs from 'fs/promises' import crypto from 'crypto' import { hashPassword, verifyPassword } from './auth' +import { getDb, ensureReady } from './db' export type Role = 'admin' | 'user' @@ -17,41 +16,48 @@ export interface User { /** User fields safe to expose to the client (no credentials). */ export type PublicUser = Omit -const DATA_DIR = path.join(process.cwd(), 'data') -const USERS_FILE = path.join(DATA_DIR, 'users.json') +type DbRow = Record -async function readUsers(): Promise { - try { - const text = await fs.readFile(USERS_FILE, 'utf-8') - return JSON.parse(text) as User[] - } catch { - return [] +function rowToUser(row: DbRow): User { + return { + id: row.id as string, + username: row.username as string, + passwordHash: row.password_hash as string, + salt: row.salt as string, + role: row.role as Role, + createdAt: row.created_at as string, } } -async function writeUsers(users: User[]): Promise { - await fs.mkdir(DATA_DIR, { recursive: true }) - await fs.writeFile(USERS_FILE, JSON.stringify(users, null, 2), 'utf-8') -} - function toPublic(user: User): PublicUser { const { passwordHash: _, salt: __, ...pub } = user return pub } export async function listUsers(): Promise { - const users = await readUsers() - return users.map(toPublic) + await ensureReady() + const { rows } = await getDb().execute( + 'SELECT * FROM users ORDER BY created_at ASC', + ) + return rows.map((r) => toPublic(rowToUser(r))) } export async function findUserByUsername(username: string): Promise { - const users = await readUsers() - return users.find((u) => u.username.toLowerCase() === username.toLowerCase()) + await ensureReady() + const { rows } = await getDb().execute({ + sql: 'SELECT * FROM users WHERE username = ? COLLATE NOCASE', + args: [username], + }) + return rows[0] ? rowToUser(rows[0]) : undefined } export async function findUserById(id: string): Promise { - const users = await readUsers() - return users.find((u) => u.id === id) + await ensureReady() + const { rows } = await getDb().execute({ + sql: 'SELECT * FROM users WHERE id = ?', + args: [id], + }) + return rows[0] ? rowToUser(rows[0]) : undefined } export async function createUser( @@ -59,14 +65,10 @@ export async function createUser( password: string, role: Role, ): Promise { + await ensureReady() if (!username.trim()) throw new Error('Username cannot be empty') if (password.length < 8) throw new Error('Password must be at least 8 characters') - const users = await readUsers() - if (users.some((u) => u.username.toLowerCase() === username.toLowerCase())) { - throw new Error(`Username "${username}" is already taken`) - } - const { hash, salt } = await hashPassword(password) const user: User = { id: crypto.randomUUID(), @@ -76,40 +78,67 @@ export async function createUser( role, createdAt: new Date().toISOString(), } - users.push(user) - await writeUsers(users) + + try { + await getDb().execute({ + sql: `INSERT INTO users (id,username,password_hash,salt,role,created_at) + VALUES (?,?,?,?,?,?)`, + args: [user.id, user.username, user.passwordHash, user.salt, user.role, user.createdAt], + }) + } catch (e: unknown) { + if (String(e).includes('UNIQUE')) { + throw new Error(`Username "${username}" is already taken`) + } + throw e + } + return toPublic(user) } export async function deleteUser(id: string): Promise { - const users = await readUsers() - const target = users.find((u) => u.id === id) - if (!target) throw new Error('User not found') + await ensureReady() + const db = getDb() - const remaining = users.filter((u) => u.id !== id) - const remainingAdmins = remaining.filter((u) => u.role === 'admin') - if (target.role === 'admin' && remainingAdmins.length === 0) { - throw new Error('Cannot delete the last admin account') + const { rows: userRows } = await db.execute({ + sql: 'SELECT * FROM users WHERE id = ?', + args: [id], + }) + if (!userRows[0]) throw new Error('User not found') + const user = rowToUser(userRows[0]) + + if (user.role === 'admin') { + const { rows: adminRows } = await db.execute( + "SELECT COUNT(*) as cnt FROM users WHERE role = 'admin'", + ) + if ((adminRows[0].cnt as number) <= 1) { + throw new Error('Cannot delete the last admin account') + } } - await writeUsers(remaining) + + await db.execute({ sql: 'DELETE FROM users WHERE id = ?', args: [id] }) } export async function updatePassword(id: string, newPassword: string): Promise { + await ensureReady() if (newPassword.length < 8) throw new Error('Password must be at least 8 characters') - const users = await readUsers() - const idx = users.findIndex((u) => u.id === id) - if (idx === -1) throw new Error('User not found') + + const { rows } = await getDb().execute({ + sql: 'SELECT id FROM users WHERE id = ?', + args: [id], + }) + if (!rows[0]) throw new Error('User not found') + const { hash, salt } = await hashPassword(newPassword) - users[idx].passwordHash = hash - users[idx].salt = salt - await writeUsers(users) + await getDb().execute({ + sql: 'UPDATE users SET password_hash = ?, salt = ? WHERE id = ?', + args: [hash, salt, id], + }) } export async function authenticateUser(username: string, password: string): Promise { const user = await findUserByUsername(username) if (!user) { - // Run a dummy derivation to keep constant time even when user not found - await hashPassword(password) + await hashPassword(password) // constant-time dummy to prevent enumeration return null } const valid = await verifyPassword(password, user.passwordHash, user.salt) @@ -117,8 +146,9 @@ export async function authenticateUser(username: string, password: string): Prom } export async function countUsers(): Promise { - const users = await readUsers() - return users.length + await ensureReady() + const { rows } = await getDb().execute('SELECT COUNT(*) as cnt FROM users') + return rows[0].cnt as number } /** @@ -138,3 +168,4 @@ export async function seedAdminIfEmpty(): Promise { console.error('[auth] Failed to seed admin user:', e) } } + diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 5ebc0de..f54e9ef 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -14,6 +14,7 @@ const securityHeaders = [ const nextConfig: NextConfig = { output: "standalone", + serverExternalPackages: ["@libsql/client"], experimental: { serverActions: { bodySizeLimit: "500mb", diff --git a/frontend/package.json b/frontend/package.json index ffb65ba..c7e3b9d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "lint": "eslint" }, "dependencies": { + "@libsql/client": "^0.17.3", "jose": "^6.2.3", "nanoid": "^5.1.11", "next": "16.2.6",