Refactor database interactions and update footer text

- Removed community sharing and self-hosted features from the features list in page.tsx.
- Updated footer text in layout.tsx to remove the MIT License mention.
- Added @libsql/client dependency to package.json and bun.lock for database management.
- Implemented a new db.ts file to handle SQLite database connections and migrations.
- Refactored user and file management functions in users.ts and store.ts to utilize the new database client.
- Ensured legacy JSON data is imported into the new SQLite database structure on first run.
This commit is contained in:
Anders Böttcher
2026-05-12 16:35:10 +02:00
parent 6e2951734e
commit 33e339bde6
8 changed files with 307 additions and 95 deletions
+1 -1
View File
@@ -197,7 +197,7 @@ export default async function RootLayout({
</div> </div>
<div className="mt-12 pt-8 border-t border-white/5 flex flex-wrap items-center justify-between gap-4 text-xs text-slate-600"> <div className="mt-12 pt-8 border-t border-white/5 flex flex-wrap items-center justify-between gap-4 text-xs text-slate-600">
<p>© {new Date().getFullYear()} Backerup Open source under the MIT License.</p> <p>© {new Date().getFullYear()} Backerup Open source.</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" /> <span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
<span>All systems operational</span> <span>All systems operational</span>
+3 -21
View File
@@ -101,24 +101,6 @@ const features = [
title: "Scheduled backups", 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.", 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: <ShareIcon />,
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: <ShieldIcon />,
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 = [ const steps = [
@@ -153,16 +135,16 @@ const steps = [
]; ];
const pills = [ 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: "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: "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" }, { label: "Docker native", color: "text-blue-400 bg-blue-500/10 border-blue-500/20" },
]; ];
const stats = [ 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: 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" }, { num: 0, suffix: "", label: "Cloud dependencies", sub: "runs on your infra" },
]; ];
+44 -1
View File
@@ -5,6 +5,7 @@
"": { "": {
"name": "backerup-website", "name": "backerup-website",
"dependencies": { "dependencies": {
"@libsql/client": "^0.17.3",
"jose": "^6.2.3", "jose": "^6.2.3",
"nanoid": "^5.1.11", "nanoid": "^5.1.11",
"next": "16.2.6", "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=="], "@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=="], "@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/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=="], "@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/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/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=="], "@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=="], "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=="], "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=="], "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-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=="], "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=="], "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": ["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=="], "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=="], "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=="], "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=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
@@ -844,6 +881,8 @@
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "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=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
+127
View File
@@ -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<void> | null = null
export function ensureReady(): Promise<void> {
if (_ready) return _ready
_ready = (async () => {
const db = getDb()
await applyMigrations(db)
await importLegacyJson(db)
})()
return _ready
}
// ── Schema ────────────────────────────────────────────────────────────────────
async function applyMigrations(db: Client): Promise<void> {
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<void> {
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)
}
}
}
+53 -26
View File
@@ -1,5 +1,6 @@
import path from 'path' import path from 'path'
import fs from 'fs/promises' import fs from 'fs/promises'
import { getDb, ensureReady } from './db'
export type FileType = 'backerup' | 'config' export type FileType = 'backerup' | 'config'
@@ -17,51 +18,77 @@ export interface FileRecord {
} }
const DATA_DIR = path.join(process.cwd(), 'data') 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') const UPLOADS_DIR = path.join(DATA_DIR, 'uploads')
async function ensureDirs() { type DbRow = Record<string, unknown>
await fs.mkdir(UPLOADS_DIR, { recursive: true })
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<FileRecord[]> { export async function readMeta(): Promise<FileRecord[]> {
try { await ensureReady()
const text = await fs.readFile(META_FILE, 'utf-8') const { rows } = await getDb().execute(
return JSON.parse(text) as FileRecord[] 'SELECT * FROM files ORDER BY uploaded_at DESC',
} catch { )
return [] return rows.map(rowToRecord)
}
} }
export async function listGroups(): Promise<string[]> { export async function listGroups(): Promise<string[]> {
const records = await readMeta() await ensureReady()
const groups = new Set<string>() const { rows } = await getDb().execute(
for (const r of records) { 'SELECT DISTINCT group_name FROM files WHERE group_name IS NOT NULL ORDER BY group_name',
if (r.group) groups.add(r.group) )
} return rows.map((r) => r.group_name as string)
return Array.from(groups).sort()
}
async function writeMeta(records: FileRecord[]): Promise<void> {
await ensureDirs()
await fs.writeFile(META_FILE, JSON.stringify(records, null, 2), 'utf-8')
} }
export async function addFile(record: FileRecord, buffer: Buffer): Promise<void> { export async function addFile(record: FileRecord, buffer: Buffer): Promise<void> {
await ensureDirs() await ensureReady()
await fs.mkdir(UPLOADS_DIR, { recursive: true })
const dir = path.join(UPLOADS_DIR, record.token) const dir = path.join(UPLOADS_DIR, record.token)
await fs.mkdir(dir, { recursive: true }) await fs.mkdir(dir, { recursive: true })
await fs.writeFile(path.join(dir, record.originalName), buffer) await fs.writeFile(path.join(dir, record.originalName), buffer)
const records = await readMeta()
records.unshift(record) await getDb().execute({
await writeMeta(records) 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<FileRecord | undefined> { export async function getFile(token: string): Promise<FileRecord | undefined> {
const records = await readMeta() await ensureReady()
return records.find((r) => r.token === token) 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 { export function getUploadPath(token: string, originalName: string): string {
return path.join(UPLOADS_DIR, token, originalName) return path.join(UPLOADS_DIR, token, originalName)
} }
+77 -46
View File
@@ -1,7 +1,6 @@
import path from 'path'
import fs from 'fs/promises'
import crypto from 'crypto' import crypto from 'crypto'
import { hashPassword, verifyPassword } from './auth' import { hashPassword, verifyPassword } from './auth'
import { getDb, ensureReady } from './db'
export type Role = 'admin' | 'user' export type Role = 'admin' | 'user'
@@ -17,41 +16,48 @@ export interface User {
/** User fields safe to expose to the client (no credentials). */ /** User fields safe to expose to the client (no credentials). */
export type PublicUser = Omit<User, 'passwordHash' | 'salt'> export type PublicUser = Omit<User, 'passwordHash' | 'salt'>
const DATA_DIR = path.join(process.cwd(), 'data') type DbRow = Record<string, unknown>
const USERS_FILE = path.join(DATA_DIR, 'users.json')
async function readUsers(): Promise<User[]> { function rowToUser(row: DbRow): User {
try { return {
const text = await fs.readFile(USERS_FILE, 'utf-8') id: row.id as string,
return JSON.parse(text) as User[] username: row.username as string,
} catch { passwordHash: row.password_hash as string,
return [] salt: row.salt as string,
role: row.role as Role,
createdAt: row.created_at as string,
} }
} }
async function writeUsers(users: User[]): Promise<void> {
await fs.mkdir(DATA_DIR, { recursive: true })
await fs.writeFile(USERS_FILE, JSON.stringify(users, null, 2), 'utf-8')
}
function toPublic(user: User): PublicUser { function toPublic(user: User): PublicUser {
const { passwordHash: _, salt: __, ...pub } = user const { passwordHash: _, salt: __, ...pub } = user
return pub return pub
} }
export async function listUsers(): Promise<PublicUser[]> { export async function listUsers(): Promise<PublicUser[]> {
const users = await readUsers() await ensureReady()
return users.map(toPublic) 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<User | undefined> { export async function findUserByUsername(username: string): Promise<User | undefined> {
const users = await readUsers() await ensureReady()
return users.find((u) => u.username.toLowerCase() === username.toLowerCase()) 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<User | undefined> { export async function findUserById(id: string): Promise<User | undefined> {
const users = await readUsers() await ensureReady()
return users.find((u) => u.id === id) const { rows } = await getDb().execute({
sql: 'SELECT * FROM users WHERE id = ?',
args: [id],
})
return rows[0] ? rowToUser(rows[0]) : undefined
} }
export async function createUser( export async function createUser(
@@ -59,14 +65,10 @@ export async function createUser(
password: string, password: string,
role: Role, role: Role,
): Promise<PublicUser> { ): Promise<PublicUser> {
await ensureReady()
if (!username.trim()) throw new Error('Username cannot be empty') if (!username.trim()) throw new Error('Username cannot be empty')
if (password.length < 8) throw new Error('Password must be at least 8 characters') 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 { hash, salt } = await hashPassword(password)
const user: User = { const user: User = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
@@ -76,40 +78,67 @@ export async function createUser(
role, role,
createdAt: new Date().toISOString(), 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) return toPublic(user)
} }
export async function deleteUser(id: string): Promise<void> { export async function deleteUser(id: string): Promise<void> {
const users = await readUsers() await ensureReady()
const target = users.find((u) => u.id === id) const db = getDb()
if (!target) throw new Error('User not found')
const remaining = users.filter((u) => u.id !== id) const { rows: userRows } = await db.execute({
const remainingAdmins = remaining.filter((u) => u.role === 'admin') sql: 'SELECT * FROM users WHERE id = ?',
if (target.role === 'admin' && remainingAdmins.length === 0) { args: [id],
throw new Error('Cannot delete the last admin account') })
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<void> { export async function updatePassword(id: string, newPassword: string): Promise<void> {
await ensureReady()
if (newPassword.length < 8) throw new Error('Password must be at least 8 characters') 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) const { rows } = await getDb().execute({
if (idx === -1) throw new Error('User not found') sql: 'SELECT id FROM users WHERE id = ?',
args: [id],
})
if (!rows[0]) throw new Error('User not found')
const { hash, salt } = await hashPassword(newPassword) const { hash, salt } = await hashPassword(newPassword)
users[idx].passwordHash = hash await getDb().execute({
users[idx].salt = salt sql: 'UPDATE users SET password_hash = ?, salt = ? WHERE id = ?',
await writeUsers(users) args: [hash, salt, id],
})
} }
export async function authenticateUser(username: string, password: string): Promise<User | null> { export async function authenticateUser(username: string, password: string): Promise<User | null> {
const user = await findUserByUsername(username) const user = await findUserByUsername(username)
if (!user) { if (!user) {
// Run a dummy derivation to keep constant time even when user not found await hashPassword(password) // constant-time dummy to prevent enumeration
await hashPassword(password)
return null return null
} }
const valid = await verifyPassword(password, user.passwordHash, user.salt) 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<number> { export async function countUsers(): Promise<number> {
const users = await readUsers() await ensureReady()
return users.length 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<void> {
console.error('[auth] Failed to seed admin user:', e) console.error('[auth] Failed to seed admin user:', e)
} }
} }
+1
View File
@@ -14,6 +14,7 @@ const securityHeaders = [
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
serverExternalPackages: ["@libsql/client"],
experimental: { experimental: {
serverActions: { serverActions: {
bodySizeLimit: "500mb", bodySizeLimit: "500mb",
+1
View File
@@ -9,6 +9,7 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@libsql/client": "^0.17.3",
"jose": "^6.2.3", "jose": "^6.2.3",
"nanoid": "^5.1.11", "nanoid": "^5.1.11",
"next": "16.2.6", "next": "16.2.6",