chore: remediate pygienium audit findings
Dead code: 77 verified-unused exports, files (BackArrow, MenuBars, cookies.ts, db/create.ts, schemas/comment.ts, security-headers.ts) and 12 unused dependencies removed Comments: ~370 RESTATE comments stripped across 53 files; 2 verbose blocks tightened; dead commented-out config removed Complexity: bulkUpsert extracted into 11 per-entity helpers (CCN 156->~10); login formHandler split into 3 submitters (CCN 63->~5); account page render split into 8 section components (CCN 40); updatePost SQL builder rebuilt; assert*Owned consolidated behind generic assertOwnedBy Defensive guards: 4 redundant rethrow/nullish guards removed
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -31,3 +31,5 @@ perf-results-*.json
|
|||||||
# System Files
|
# System Files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
# pygienium run-state and check artifacts
|
||||||
|
.pygienium/
|
||||||
|
|||||||
14
package.json
14
package.json
@@ -18,7 +18,6 @@
|
|||||||
"@aws-sdk/s3-request-presigner": "^3.953.0",
|
"@aws-sdk/s3-request-presigner": "^3.953.0",
|
||||||
"@clerk/backend": "^3.12.0",
|
"@clerk/backend": "^3.12.0",
|
||||||
"@libsql/client": "^0.15.15",
|
"@libsql/client": "^0.15.15",
|
||||||
"@motionone/solid": "^10.16.4",
|
|
||||||
"@sentry/solidstart": "^10.67.0",
|
"@sentry/solidstart": "^10.67.0",
|
||||||
"@solidjs/meta": "^0.29.4",
|
"@solidjs/meta": "^0.29.4",
|
||||||
"@solidjs/router": "^0.15.0",
|
"@solidjs/router": "^0.15.0",
|
||||||
@@ -26,13 +25,11 @@
|
|||||||
"@tailwindcss/vite": "^4.0.7",
|
"@tailwindcss/vite": "^4.0.7",
|
||||||
"@tiptap/core": "^3.14.0",
|
"@tiptap/core": "^3.14.0",
|
||||||
"@tiptap/extension-code-block-lowlight": "^3.14.0",
|
"@tiptap/extension-code-block-lowlight": "^3.14.0",
|
||||||
"@tiptap/extension-color": "^3.14.0",
|
|
||||||
"@tiptap/extension-details": "^3.14.0",
|
"@tiptap/extension-details": "^3.14.0",
|
||||||
"@tiptap/extension-details-content": "^2.26.2",
|
"@tiptap/extension-details-content": "^2.26.2",
|
||||||
"@tiptap/extension-details-summary": "^2.26.2",
|
"@tiptap/extension-details-summary": "^2.26.2",
|
||||||
"@tiptap/extension-image": "^3.14.0",
|
"@tiptap/extension-image": "^3.14.0",
|
||||||
"@tiptap/extension-link": "^3.14.0",
|
"@tiptap/extension-link": "^3.14.0",
|
||||||
"@tiptap/extension-list-item": "^3.14.0",
|
|
||||||
"@tiptap/extension-subscript": "^3.14.0",
|
"@tiptap/extension-subscript": "^3.14.0",
|
||||||
"@tiptap/extension-superscript": "^3.14.0",
|
"@tiptap/extension-superscript": "^3.14.0",
|
||||||
"@tiptap/extension-table": "^3.14.0",
|
"@tiptap/extension-table": "^3.14.0",
|
||||||
@@ -42,13 +39,11 @@
|
|||||||
"@tiptap/extension-task-item": "^3.14.0",
|
"@tiptap/extension-task-item": "^3.14.0",
|
||||||
"@tiptap/extension-task-list": "^3.14.0",
|
"@tiptap/extension-task-list": "^3.14.0",
|
||||||
"@tiptap/extension-text-align": "^3.14.0",
|
"@tiptap/extension-text-align": "^3.14.0",
|
||||||
"@tiptap/extension-text-style": "^3.14.0",
|
|
||||||
"@tiptap/pm": "^3.14.0",
|
"@tiptap/pm": "^3.14.0",
|
||||||
"@tiptap/starter-kit": "^3.14.0",
|
"@tiptap/starter-kit": "^3.14.0",
|
||||||
"@trpc/client": "^10.45.2",
|
"@trpc/client": "^10.45.2",
|
||||||
"@trpc/server": "^10.45.2",
|
"@trpc/server": "^10.45.2",
|
||||||
"@tursodatabase/api": "^1.9.2",
|
"@tursodatabase/api": "^1.9.2",
|
||||||
"@typeschema/valibot": "^0.13.4",
|
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"es-toolkit": "^1.43.0",
|
"es-toolkit": "^1.43.0",
|
||||||
"fast-diff": "^1.3.0",
|
"fast-diff": "^1.3.0",
|
||||||
@@ -69,18 +64,11 @@
|
|||||||
"node": "24.x"
|
"node": "24.x"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.57.0",
|
|
||||||
"@sentry/vite-plugin": "^5.4.0",
|
"@sentry/vite-plugin": "^5.4.0",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/fast-diff": "^1.2.2",
|
|
||||||
"chrome-launcher": "^1.2.1",
|
|
||||||
"lighthouse": "^13.0.1",
|
|
||||||
"playwright": "^1.57.0",
|
"playwright": "^1.57.0",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
"prettier-plugin-tailwindcss": "^0.7.2"
|
||||||
"rollup-plugin-visualizer": "^6.0.5",
|
|
||||||
"trpc-panel": "^1.3.4",
|
|
||||||
"vite-bundle-visualizer": "^1.2.1"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
|
|||||||
"───────────────────────────────────────────────────────────────────\n"
|
"───────────────────────────────────────────────────────────────────\n"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Compare each page
|
|
||||||
for (const baseResult of baseline.results) {
|
for (const baseResult of baseline.results) {
|
||||||
const optResult = optimized.results.find((r) => r.page === baseResult.page);
|
const optResult = optimized.results.find((r) => r.page === baseResult.page);
|
||||||
if (!optResult) continue;
|
if (!optResult) continue;
|
||||||
@@ -107,7 +106,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
|
|||||||
console.log(`\n📄 ${baseResult.page}`);
|
console.log(`\n📄 ${baseResult.page}`);
|
||||||
console.log("─".repeat(70));
|
console.log("─".repeat(70));
|
||||||
|
|
||||||
// Core Web Vitals
|
|
||||||
console.log("\n Core Web Vitals:");
|
console.log("\n Core Web Vitals:");
|
||||||
|
|
||||||
const fcpDiff = opt.fcp - base.fcp;
|
const fcpDiff = opt.fcp - base.fcp;
|
||||||
@@ -121,7 +119,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
|
|||||||
` CLS: ${base.cls.toFixed(3)} → ${opt.cls.toFixed(3)} (${formatDiff(clsDiff * 1000, "ms")})`
|
` CLS: ${base.cls.toFixed(3)} → ${opt.cls.toFixed(3)} (${formatDiff(clsDiff * 1000, "ms")})`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Loading Metrics
|
|
||||||
console.log("\n Loading Metrics:");
|
console.log("\n Loading Metrics:");
|
||||||
|
|
||||||
const ttfbDiff = opt.ttfb - base.ttfb;
|
const ttfbDiff = opt.ttfb - base.ttfb;
|
||||||
@@ -148,7 +145,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
|
|||||||
` Load: ${formatTime(base.loadComplete)} → ${formatTime(opt.loadComplete)} (${formatDiff(loadDiff, "ms")}, ${loadPercent.toFixed(1)}%)${getImpact(loadPercent)}`
|
` Load: ${formatTime(base.loadComplete)} → ${formatTime(opt.loadComplete)} (${formatDiff(loadDiff, "ms")}, ${loadPercent.toFixed(1)}%)${getImpact(loadPercent)}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Resource Loading
|
|
||||||
console.log("\n Resources:");
|
console.log("\n Resources:");
|
||||||
|
|
||||||
const reqDiff = opt.totalRequests - base.totalRequests;
|
const reqDiff = opt.totalRequests - base.totalRequests;
|
||||||
@@ -185,7 +181,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Overall Summary
|
|
||||||
console.log(
|
console.log(
|
||||||
"\n\n═══════════════════════════════════════════════════════════════════"
|
"\n\n═══════════════════════════════════════════════════════════════════"
|
||||||
);
|
);
|
||||||
@@ -338,7 +333,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Specific findings
|
|
||||||
const reqPercent = calculatePercentChange(baseAvg.requests, optAvg.requests);
|
const reqPercent = calculatePercentChange(baseAvg.requests, optAvg.requests);
|
||||||
if (reqPercent < -5) {
|
if (reqPercent < -5) {
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ const BASE_URL = process.env.TEST_URL || "http://localhost:3000";
|
|||||||
const RUNS_PER_PAGE = parseInt(process.env.RUNS || "5", 10);
|
const RUNS_PER_PAGE = parseInt(process.env.RUNS || "5", 10);
|
||||||
const WARMUP_RUNS = 1;
|
const WARMUP_RUNS = 1;
|
||||||
|
|
||||||
// Pages to test
|
|
||||||
const TEST_PAGES: PageTestConfig[] = [
|
const TEST_PAGES: PageTestConfig[] = [
|
||||||
{ name: "Home", path: "/" },
|
{ name: "Home", path: "/" },
|
||||||
{ name: "Blog Index", path: "/blog" },
|
{ name: "Blog Index", path: "/blog" },
|
||||||
@@ -78,7 +77,6 @@ const TEST_PAGES: PageTestConfig[] = [
|
|||||||
{ name: "404", path: "/404" }
|
{ name: "404", path: "/404" }
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add additional blog post path if provided
|
|
||||||
if (process.env.TEST_BLOG_POST) {
|
if (process.env.TEST_BLOG_POST) {
|
||||||
TEST_PAGES.push({
|
TEST_PAGES.push({
|
||||||
name: "Custom Blog Post",
|
name: "Custom Blog Post",
|
||||||
@@ -102,7 +100,6 @@ async function setupPerformanceObservers(page: Page) {
|
|||||||
interactions: [] as number[]
|
interactions: [] as number[]
|
||||||
};
|
};
|
||||||
|
|
||||||
// Observe LCP
|
|
||||||
if ("PerformanceObserver" in window) {
|
if ("PerformanceObserver" in window) {
|
||||||
try {
|
try {
|
||||||
const lcpObserver = new PerformanceObserver((entryList) => {
|
const lcpObserver = new PerformanceObserver((entryList) => {
|
||||||
@@ -118,10 +115,8 @@ async function setupPerformanceObservers(page: Page) {
|
|||||||
buffered: true
|
buffered: true
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// LCP not supported
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Observe CLS
|
|
||||||
try {
|
try {
|
||||||
const clsObserver = new PerformanceObserver((entryList) => {
|
const clsObserver = new PerformanceObserver((entryList) => {
|
||||||
for (const entry of entryList.getEntries()) {
|
for (const entry of entryList.getEntries()) {
|
||||||
@@ -138,10 +133,8 @@ async function setupPerformanceObservers(page: Page) {
|
|||||||
});
|
});
|
||||||
clsObserver.observe({ type: "layout-shift", buffered: true });
|
clsObserver.observe({ type: "layout-shift", buffered: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// CLS not supported
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Observe FID (first input)
|
|
||||||
try {
|
try {
|
||||||
const fidObserver = new PerformanceObserver((entryList) => {
|
const fidObserver = new PerformanceObserver((entryList) => {
|
||||||
const firstInput = entryList.getEntries()[0] as any;
|
const firstInput = entryList.getEntries()[0] as any;
|
||||||
@@ -154,10 +147,8 @@ async function setupPerformanceObservers(page: Page) {
|
|||||||
});
|
});
|
||||||
fidObserver.observe({ type: "first-input", buffered: true });
|
fidObserver.observe({ type: "first-input", buffered: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// FID not supported
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Observe long tasks
|
|
||||||
try {
|
try {
|
||||||
const longTaskObserver = new PerformanceObserver((entryList) => {
|
const longTaskObserver = new PerformanceObserver((entryList) => {
|
||||||
for (const entry of entryList.getEntries()) {
|
for (const entry of entryList.getEntries()) {
|
||||||
@@ -166,10 +157,8 @@ async function setupPerformanceObservers(page: Page) {
|
|||||||
});
|
});
|
||||||
longTaskObserver.observe({ type: "longtask", buffered: true });
|
longTaskObserver.observe({ type: "longtask", buffered: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Long tasks not supported
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Observe INP (event timing for interactions)
|
|
||||||
try {
|
try {
|
||||||
const inpObserver = new PerformanceObserver((entryList) => {
|
const inpObserver = new PerformanceObserver((entryList) => {
|
||||||
for (const entry of entryList.getEntries()) {
|
for (const entry of entryList.getEntries()) {
|
||||||
@@ -194,7 +183,6 @@ async function setupPerformanceObservers(page: Page) {
|
|||||||
});
|
});
|
||||||
inpObserver.observe({ type: "event", buffered: true });
|
inpObserver.observe({ type: "event", buffered: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Event timing not supported
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -203,18 +191,14 @@ async function setupPerformanceObservers(page: Page) {
|
|||||||
async function collectPerformanceMetrics(
|
async function collectPerformanceMetrics(
|
||||||
page: Page
|
page: Page
|
||||||
): Promise<PerformanceMetrics> {
|
): Promise<PerformanceMetrics> {
|
||||||
// Wait for page to be loaded
|
|
||||||
await page.waitForLoadState("load");
|
await page.waitForLoadState("load");
|
||||||
|
|
||||||
// Wait a bit longer for LCP to settle (it can change as content loads)
|
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
// Additional wait for any remaining network activity
|
|
||||||
await page.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => {
|
await page.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => {
|
||||||
// Ignore timeout - networkidle may never happen for some pages
|
// Ignore timeout - networkidle may never happen for some pages
|
||||||
});
|
});
|
||||||
|
|
||||||
// Collect comprehensive performance metrics
|
|
||||||
const metrics = await page.evaluate(() => {
|
const metrics = await page.evaluate(() => {
|
||||||
const perf = performance.getEntriesByType(
|
const perf = performance.getEntriesByType(
|
||||||
"navigation"
|
"navigation"
|
||||||
@@ -222,7 +206,6 @@ async function collectPerformanceMetrics(
|
|||||||
const paint = performance.getEntriesByType("paint");
|
const paint = performance.getEntriesByType("paint");
|
||||||
const fcp = paint.find((entry) => entry.name === "first-contentful-paint");
|
const fcp = paint.find((entry) => entry.name === "first-contentful-paint");
|
||||||
|
|
||||||
// Get metrics from our observers
|
|
||||||
const observedMetrics = (window as any).__perfMetrics || {
|
const observedMetrics = (window as any).__perfMetrics || {
|
||||||
lcp: 0,
|
lcp: 0,
|
||||||
cls: 0,
|
cls: 0,
|
||||||
@@ -232,7 +215,6 @@ async function collectPerformanceMetrics(
|
|||||||
interactions: []
|
interactions: []
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fallback to direct API if observers didn't capture anything
|
|
||||||
let lcp = observedMetrics.lcp;
|
let lcp = observedMetrics.lcp;
|
||||||
let cls = observedMetrics.cls;
|
let cls = observedMetrics.cls;
|
||||||
let fid = observedMetrics.fid;
|
let fid = observedMetrics.fid;
|
||||||
@@ -258,7 +240,6 @@ async function collectPerformanceMetrics(
|
|||||||
.reduce((sum: number, entry: any) => sum + entry.value, 0);
|
.reduce((sum: number, entry: any) => sum + entry.value, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate INP from event timing entries if not already captured
|
|
||||||
if (inp === 0) {
|
if (inp === 0) {
|
||||||
const eventEntries = performance.getEntriesByType("event") as any[];
|
const eventEntries = performance.getEntriesByType("event") as any[];
|
||||||
const interactionLatencies = eventEntries
|
const interactionLatencies = eventEntries
|
||||||
@@ -275,7 +256,6 @@ async function collectPerformanceMetrics(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get resource timing
|
|
||||||
const resources = performance.getEntriesByType(
|
const resources = performance.getEntriesByType(
|
||||||
"resource"
|
"resource"
|
||||||
) as PerformanceResourceTiming[];
|
) as PerformanceResourceTiming[];
|
||||||
@@ -318,7 +298,6 @@ async function collectPerformanceMetrics(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate long task duration
|
|
||||||
let taskDuration = 0;
|
let taskDuration = 0;
|
||||||
if (observedMetrics.longTasks && observedMetrics.longTasks.length > 0) {
|
if (observedMetrics.longTasks && observedMetrics.longTasks.length > 0) {
|
||||||
taskDuration = observedMetrics.longTasks.reduce(
|
taskDuration = observedMetrics.longTasks.reduce(
|
||||||
@@ -327,7 +306,6 @@ async function collectPerformanceMetrics(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get more granular performance entries
|
|
||||||
let jsExecutionTime = 0;
|
let jsExecutionTime = 0;
|
||||||
let layoutDuration = 0;
|
let layoutDuration = 0;
|
||||||
let paintDuration = 0;
|
let paintDuration = 0;
|
||||||
@@ -339,7 +317,6 @@ async function collectPerformanceMetrics(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check for script evaluation entries
|
|
||||||
const entries = performance.getEntries();
|
const entries = performance.getEntries();
|
||||||
entries.forEach((entry: any) => {
|
entries.forEach((entry: any) => {
|
||||||
if (entry.entryType === "measure") {
|
if (entry.entryType === "measure") {
|
||||||
@@ -399,7 +376,6 @@ async function testPagePerformance(
|
|||||||
` Running ${WARMUP_RUNS} warmup + ${RUNS_PER_PAGE} measured runs...\n`
|
` Running ${WARMUP_RUNS} warmup + ${RUNS_PER_PAGE} measured runs...\n`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Warmup runs (not counted)
|
|
||||||
for (let i = 0; i < WARMUP_RUNS; i++) {
|
for (let i = 0; i < WARMUP_RUNS; i++) {
|
||||||
const context = await browser.newContext();
|
const context = await browser.newContext();
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
@@ -410,20 +386,16 @@ async function testPagePerformance(
|
|||||||
console.log(` ✓ Warmup run ${i + 1}/${WARMUP_RUNS}`);
|
console.log(` ✓ Warmup run ${i + 1}/${WARMUP_RUNS}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Measured runs
|
|
||||||
for (let i = 0; i < RUNS_PER_PAGE; i++) {
|
for (let i = 0; i < RUNS_PER_PAGE; i++) {
|
||||||
console.log(` → Run ${i + 1}/${RUNS_PER_PAGE}...`);
|
console.log(` → Run ${i + 1}/${RUNS_PER_PAGE}...`);
|
||||||
|
|
||||||
// Create new context for each run to ensure clean state
|
|
||||||
const context = await browser.newContext({
|
const context = await browser.newContext({
|
||||||
viewport: { width: 1920, height: 1080 }
|
viewport: { width: 1920, height: 1080 }
|
||||||
});
|
});
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
|
|
||||||
// Setup performance observers before navigation
|
|
||||||
await setupPerformanceObservers(page);
|
await setupPerformanceObservers(page);
|
||||||
|
|
||||||
// Navigate and collect metrics
|
|
||||||
await page.goto(url, { waitUntil: "load" });
|
await page.goto(url, { waitUntil: "load" });
|
||||||
const metrics = await collectPerformanceMetrics(page);
|
const metrics = await collectPerformanceMetrics(page);
|
||||||
|
|
||||||
@@ -436,7 +408,6 @@ async function testPagePerformance(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate statistics
|
|
||||||
const average = calculateAverage(runs);
|
const average = calculateAverage(runs);
|
||||||
const median = calculateMedian(runs);
|
const median = calculateMedian(runs);
|
||||||
const p95 = calculatePercentile(runs, 95);
|
const p95 = calculatePercentile(runs, 95);
|
||||||
@@ -648,7 +619,6 @@ function printResults(results: TestResult[]) {
|
|||||||
"═══════════════════════════════════════════════════════════════════\n"
|
"═══════════════════════════════════════════════════════════════════\n"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Overall averages
|
|
||||||
const overallAverage = {
|
const overallAverage = {
|
||||||
lcp: results.reduce((sum, r) => sum + r.median.lcp, 0) / results.length,
|
lcp: results.reduce((sum, r) => sum + r.median.lcp, 0) / results.length,
|
||||||
fcp: results.reduce((sum, r) => sum + r.median.fcp, 0) / results.length,
|
fcp: results.reduce((sum, r) => sum + r.median.fcp, 0) / results.length,
|
||||||
@@ -696,7 +666,6 @@ function printResults(results: TestResult[]) {
|
|||||||
|
|
||||||
console.log("\n Optimization Opportunities:");
|
console.log("\n Optimization Opportunities:");
|
||||||
|
|
||||||
// Find pages with highest JS bytes
|
|
||||||
const highestJS = [...results].sort(
|
const highestJS = [...results].sort(
|
||||||
(a, b) => b.median.jsBytes - a.median.jsBytes
|
(a, b) => b.median.jsBytes - a.median.jsBytes
|
||||||
)[0];
|
)[0];
|
||||||
@@ -707,7 +676,6 @@ function printResults(results: TestResult[]) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find pages with slow LCP
|
|
||||||
const slowLCP = results.filter((r) => r.median.lcp > 2500);
|
const slowLCP = results.filter((r) => r.median.lcp > 2500);
|
||||||
if (slowLCP.length > 0) {
|
if (slowLCP.length > 0) {
|
||||||
console.log(
|
console.log(
|
||||||
@@ -715,7 +683,6 @@ function printResults(results: TestResult[]) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find pages with high CLS
|
|
||||||
const highCLS = results.filter((r) => r.median.cls > 0.1);
|
const highCLS = results.filter((r) => r.median.cls > 0.1);
|
||||||
if (highCLS.length > 0) {
|
if (highCLS.length > 0) {
|
||||||
console.log(
|
console.log(
|
||||||
@@ -723,7 +690,6 @@ function printResults(results: TestResult[]) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find pages with high INP
|
|
||||||
const highINP = results.filter((r) => r.median.inp > 200);
|
const highINP = results.filter((r) => r.median.inp > 200);
|
||||||
if (highINP.length > 0) {
|
if (highINP.length > 0) {
|
||||||
console.log(
|
console.log(
|
||||||
@@ -740,7 +706,6 @@ async function main() {
|
|||||||
console.log(`Pages to test: ${TEST_PAGES.length}`);
|
console.log(`Pages to test: ${TEST_PAGES.length}`);
|
||||||
console.log(`Runs per page: ${RUNS_PER_PAGE} (+ ${WARMUP_RUNS} warmup)\n`);
|
console.log(`Runs per page: ${RUNS_PER_PAGE} (+ ${WARMUP_RUNS} warmup)\n`);
|
||||||
|
|
||||||
// Check if server is running
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(BASE_URL);
|
const response = await fetch(BASE_URL);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -770,10 +735,8 @@ async function main() {
|
|||||||
|
|
||||||
await browser.close();
|
await browser.close();
|
||||||
|
|
||||||
// Print results
|
|
||||||
printResults(results);
|
printResults(results);
|
||||||
|
|
||||||
// Save results to JSON file
|
|
||||||
const timestamp = new Date()
|
const timestamp = new Date()
|
||||||
.toISOString()
|
.toISOString()
|
||||||
.replace(/[:.]/g, "-")
|
.replace(/[:.]/g, "-")
|
||||||
|
|||||||
@@ -35,10 +35,8 @@ function AppLayout(props: { children: any }) {
|
|||||||
let lastScrollY = 0;
|
let lastScrollY = 0;
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Initialize performance tracking
|
|
||||||
initPerformanceTracking();
|
initPerformanceTracking();
|
||||||
|
|
||||||
// Start monitoring for new deployments
|
|
||||||
startDeploymentMonitoring();
|
startDeploymentMonitoring();
|
||||||
|
|
||||||
const windowWidth = createWindowWidth();
|
const windowWidth = createWindowWidth();
|
||||||
|
|||||||
@@ -460,7 +460,7 @@ function MainRightBarContent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RightBarContent() {
|
function RightBarContent() {
|
||||||
const site = useSite();
|
const site = useSite();
|
||||||
return (
|
return (
|
||||||
<Show when={site().id === "main"} fallback={<SubdomainRightBarContent />}>
|
<Show when={site().id === "main"} fallback={<SubdomainRightBarContent />}>
|
||||||
|
|||||||
@@ -144,7 +144,6 @@ const sendContactEmail = action(async (formData: FormData) => {
|
|||||||
|
|
||||||
const { env } = await import("~/env/server");
|
const { env } = await import("~/env/server");
|
||||||
|
|
||||||
// Verify Cloudflare Turnstile token
|
|
||||||
const turnstileValid = await verifyTurnstileToken(
|
const turnstileValid = await verifyTurnstileToken(
|
||||||
turnstileToken,
|
turnstileToken,
|
||||||
env.TURNSTILE_SECRET_KEY,
|
env.TURNSTILE_SECRET_KEY,
|
||||||
@@ -252,7 +251,6 @@ export function ContactForm(props: ContactFormProps) {
|
|||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
// Load server data using createAsync
|
|
||||||
const contactData = createAsync(() => getContactData(), {
|
const contactData = createAsync(() => getContactData(), {
|
||||||
deferStream: true
|
deferStream: true
|
||||||
});
|
});
|
||||||
@@ -350,7 +348,6 @@ export function ContactForm(props: ContactFormProps) {
|
|||||||
const message = formData.get("message") as string;
|
const message = formData.get("message") as string;
|
||||||
|
|
||||||
if (name && email && message) {
|
if (name && email && message) {
|
||||||
// Get fresh Turnstile token
|
|
||||||
let currentToken = turnstileToken();
|
let currentToken = turnstileToken();
|
||||||
if (
|
if (
|
||||||
!currentToken &&
|
!currentToken &&
|
||||||
@@ -388,7 +385,6 @@ export function ContactForm(props: ContactFormProps) {
|
|||||||
setError("");
|
setError("");
|
||||||
form.reset();
|
form.reset();
|
||||||
|
|
||||||
// Reset Turnstile widget
|
|
||||||
if (typeof window !== "undefined" && (window as any).turnstile) {
|
if (typeof window !== "undefined" && (window as any).turnstile) {
|
||||||
const widgetEl = document.getElementById("turnstile-widget-1");
|
const widgetEl = document.getElementById("turnstile-widget-1");
|
||||||
if (widgetEl) {
|
if (widgetEl) {
|
||||||
|
|||||||
@@ -138,9 +138,6 @@ export function Typewriter(props: {
|
|||||||
entries.forEach((entry) => {
|
entries.forEach((entry) => {
|
||||||
// If component leaves viewport while animating, we could pause
|
// If component leaves viewport while animating, we could pause
|
||||||
// For now, we just ensure it starts when visible
|
// For now, we just ensure it starts when visible
|
||||||
if (!entry.isIntersecting && cleanupAnimation) {
|
|
||||||
// Component is off-screen - could add pause logic here if needed
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ export default function AddAttachmentSection(props: AddAttachmentSectionProps) {
|
|||||||
};
|
};
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
|
|
||||||
// Refresh the S3 file list
|
|
||||||
await loadAttachments();
|
await loadAttachments();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -81,7 +80,6 @@ export default function AddAttachmentSection(props: AddAttachmentSectionProps) {
|
|||||||
body: JSON.stringify({ key })
|
body: JSON.stringify({ key })
|
||||||
});
|
});
|
||||||
|
|
||||||
// Refresh the S3 file list
|
|
||||||
await loadAttachments();
|
await loadAttachments();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to delete file:", err);
|
console.error("Failed to delete file:", err);
|
||||||
|
|||||||
@@ -259,7 +259,6 @@ export default function CommentSectionWrapper(
|
|||||||
const newComment = async (commentBody: string, parentCommentID?: number) => {
|
const newComment = async (commentBody: string, parentCommentID?: number) => {
|
||||||
setCommentSubmitLoading(true);
|
setCommentSubmitLoading(true);
|
||||||
|
|
||||||
// Clear any existing timeout
|
|
||||||
if (commentSubmitTimeoutId) {
|
if (commentSubmitTimeoutId) {
|
||||||
clearTimeout(commentSubmitTimeoutId);
|
clearTimeout(commentSubmitTimeoutId);
|
||||||
}
|
}
|
||||||
@@ -428,7 +427,6 @@ export default function CommentSectionWrapper(
|
|||||||
const editComment = async (body: string, comment_id: number) => {
|
const editComment = async (body: string, comment_id: number) => {
|
||||||
setCommentEditLoading(true);
|
setCommentEditLoading(true);
|
||||||
|
|
||||||
// Clear any existing timeout
|
|
||||||
if (editCommentTimeoutId) {
|
if (editCommentTimeoutId) {
|
||||||
clearTimeout(editCommentTimeoutId);
|
clearTimeout(editCommentTimeoutId);
|
||||||
}
|
}
|
||||||
@@ -527,7 +525,6 @@ export default function CommentSectionWrapper(
|
|||||||
|
|
||||||
setCommentDeletionLoading(true);
|
setCommentDeletionLoading(true);
|
||||||
|
|
||||||
// Clear any existing timeout
|
|
||||||
if (deleteCommentTimeoutId) {
|
if (deleteCommentTimeoutId) {
|
||||||
clearTimeout(deleteCommentTimeoutId);
|
clearTimeout(deleteCommentTimeoutId);
|
||||||
}
|
}
|
||||||
@@ -623,7 +620,6 @@ export default function CommentSectionWrapper(
|
|||||||
setOperationError("");
|
setOperationError("");
|
||||||
|
|
||||||
if (data.commentBody) {
|
if (data.commentBody) {
|
||||||
// Soft delete (replace body with deletion message)
|
|
||||||
setAllComments((prev) =>
|
setAllComments((prev) =>
|
||||||
prev.map((comment) => {
|
prev.map((comment) => {
|
||||||
if (comment.id === data.commentID) {
|
if (comment.id === data.commentID) {
|
||||||
@@ -652,7 +648,6 @@ export default function CommentSectionWrapper(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Hard delete (remove from list)
|
|
||||||
setAllComments((prev) =>
|
setAllComments((prev) =>
|
||||||
prev.filter((comment) => comment.id !== data.commentID)
|
prev.filter((comment) => comment.id !== data.commentID)
|
||||||
);
|
);
|
||||||
@@ -667,7 +662,6 @@ export default function CommentSectionWrapper(
|
|||||||
}, 300);
|
}, 300);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Deletion/edit prompt toggle
|
|
||||||
const toggleModification = (
|
const toggleModification = (
|
||||||
commentID: number,
|
commentID: number,
|
||||||
commenterID: string,
|
commenterID: string,
|
||||||
@@ -708,7 +702,6 @@ export default function CommentSectionWrapper(
|
|||||||
setCommentBodyForModification("");
|
setCommentBodyForModification("");
|
||||||
};
|
};
|
||||||
|
|
||||||
// Reaction handling
|
|
||||||
const commentReaction = (reactionType: ReactionType, commentID: number) => {
|
const commentReaction = (reactionType: ReactionType, commentID: number) => {
|
||||||
if (!props.currentUserID) {
|
if (!props.currentUserID) {
|
||||||
console.warn("Cannot react to comment: user not authenticated");
|
console.warn("Cannot react to comment: user not authenticated");
|
||||||
@@ -800,7 +793,6 @@ export default function CommentSectionWrapper(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Click outside handlers (SolidJS version)
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const handleClickOutsideDelete = (e: MouseEvent) => {
|
const handleClickOutsideDelete = (e: MouseEvent) => {
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -8,12 +8,10 @@ function sanitizeMermaidSvg(svgString: string): string {
|
|||||||
const parser = new DOMParser();
|
const parser = new DOMParser();
|
||||||
const doc = parser.parseFromString(svgString, "text/html");
|
const doc = parser.parseFromString(svgString, "text/html");
|
||||||
|
|
||||||
// Remove dangerous elements
|
|
||||||
doc.querySelectorAll("script, iframe, object, embed, form, link, meta, base").forEach((el) => {
|
doc.querySelectorAll("script, iframe, object, embed, form, link, meta, base").forEach((el) => {
|
||||||
el.remove();
|
el.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove event handlers and dangerous attributes from all elements
|
|
||||||
doc.querySelectorAll("[on*], [href*='javascript:'], [style*='expression(']").forEach((el) => {
|
doc.querySelectorAll("[on*], [href*='javascript:'], [style*='expression(']").forEach((el) => {
|
||||||
const attrs = Array.from(el.attributes);
|
const attrs = Array.from(el.attributes);
|
||||||
attrs.forEach((attr) => {
|
attrs.forEach((attr) => {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ function sanitizeHtml(html: string): string {
|
|||||||
const parser = new DOMParser();
|
const parser = new DOMParser();
|
||||||
const doc = parser.parseFromString(html, "text/html");
|
const doc = parser.parseFromString(html, "text/html");
|
||||||
|
|
||||||
// Remove dangerous elements
|
|
||||||
doc
|
doc
|
||||||
.querySelectorAll(
|
.querySelectorAll(
|
||||||
"script, iframe, object, embed, form, link, meta, base, svg script"
|
"script, iframe, object, embed, form, link, meta, base, svg script"
|
||||||
@@ -131,7 +130,6 @@ export default function PostBodyClient(props: PostBodyClientProps) {
|
|||||||
const processVideos = () => {
|
const processVideos = () => {
|
||||||
if (!contentRef) return;
|
if (!contentRef) return;
|
||||||
|
|
||||||
// Handle direct video elements
|
|
||||||
const videoElements = contentRef.querySelectorAll("video");
|
const videoElements = contentRef.querySelectorAll("video");
|
||||||
|
|
||||||
videoElements.forEach((video) => {
|
videoElements.forEach((video) => {
|
||||||
@@ -139,18 +137,14 @@ export default function PostBodyClient(props: PostBodyClientProps) {
|
|||||||
video.setAttribute("playsinline", "");
|
video.setAttribute("playsinline", "");
|
||||||
video.setAttribute("controls", "");
|
video.setAttribute("controls", "");
|
||||||
|
|
||||||
// Remove download attribute if present
|
|
||||||
video.removeAttribute("download");
|
video.removeAttribute("download");
|
||||||
|
|
||||||
// Ensure proper MIME types on source elements
|
|
||||||
const sources = video.querySelectorAll("source");
|
const sources = video.querySelectorAll("source");
|
||||||
sources.forEach((source) => {
|
sources.forEach((source) => {
|
||||||
const src = source.getAttribute("src");
|
const src = source.getAttribute("src");
|
||||||
if (src) {
|
if (src) {
|
||||||
// Remove download attribute from sources
|
|
||||||
source.removeAttribute("download");
|
source.removeAttribute("download");
|
||||||
|
|
||||||
// Set correct type attribute if missing
|
|
||||||
if (!source.hasAttribute("type")) {
|
if (!source.hasAttribute("type")) {
|
||||||
if (src.endsWith(".mp4")) {
|
if (src.endsWith(".mp4")) {
|
||||||
source.setAttribute("type", "video/mp4");
|
source.setAttribute("type", "video/mp4");
|
||||||
@@ -163,7 +157,6 @@ export default function PostBodyClient(props: PostBodyClientProps) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// If video has direct src attribute, ensure type is set
|
|
||||||
const videoSrc = video.getAttribute("src");
|
const videoSrc = video.getAttribute("src");
|
||||||
if (videoSrc && !video.hasAttribute("type")) {
|
if (videoSrc && !video.hasAttribute("type")) {
|
||||||
if (videoSrc.endsWith(".mp4")) {
|
if (videoSrc.endsWith(".mp4")) {
|
||||||
@@ -176,7 +169,6 @@ export default function PostBodyClient(props: PostBodyClientProps) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle iframes with video sources - replace with proper video tags
|
|
||||||
const iframes = contentRef.querySelectorAll("iframe");
|
const iframes = contentRef.querySelectorAll("iframe");
|
||||||
iframes.forEach((iframe) => {
|
iframes.forEach((iframe) => {
|
||||||
const src = iframe.getAttribute("src");
|
const src = iframe.getAttribute("src");
|
||||||
@@ -187,7 +179,6 @@ export default function PostBodyClient(props: PostBodyClientProps) {
|
|||||||
src.endsWith(".webm") ||
|
src.endsWith(".webm") ||
|
||||||
src.endsWith(".ogg"))
|
src.endsWith(".ogg"))
|
||||||
) {
|
) {
|
||||||
// Create a proper video element
|
|
||||||
const video = document.createElement("video");
|
const video = document.createElement("video");
|
||||||
video.setAttribute("controls", "");
|
video.setAttribute("controls", "");
|
||||||
video.setAttribute("playsinline", "");
|
video.setAttribute("playsinline", "");
|
||||||
@@ -195,7 +186,6 @@ export default function PostBodyClient(props: PostBodyClientProps) {
|
|||||||
video.style.maxWidth = "100%";
|
video.style.maxWidth = "100%";
|
||||||
video.style.height = "auto";
|
video.style.height = "auto";
|
||||||
|
|
||||||
// Set appropriate type based on file extension
|
|
||||||
let videoType = "video/mp4";
|
let videoType = "video/mp4";
|
||||||
if (src.endsWith(".mov")) {
|
if (src.endsWith(".mov")) {
|
||||||
videoType = "video/mp4"; // MOV files are typically H.264 which plays as mp4
|
videoType = "video/mp4"; // MOV files are typically H.264 which plays as mp4
|
||||||
@@ -208,7 +198,6 @@ export default function PostBodyClient(props: PostBodyClientProps) {
|
|||||||
video.setAttribute("type", videoType);
|
video.setAttribute("type", videoType);
|
||||||
video.src = src;
|
video.src = src;
|
||||||
|
|
||||||
// Replace the iframe with the video element
|
|
||||||
const parent = iframe.parentElement;
|
const parent = iframe.parentElement;
|
||||||
if (parent) {
|
if (parent) {
|
||||||
parent.replaceChild(video, iframe);
|
parent.replaceChild(video, iframe);
|
||||||
@@ -216,7 +205,6 @@ export default function PostBodyClient(props: PostBodyClientProps) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Also check for any anchor tags wrapping videos that might have download attribute
|
|
||||||
const videoLinks = contentRef.querySelectorAll("a");
|
const videoLinks = contentRef.querySelectorAll("a");
|
||||||
videoLinks.forEach((link) => {
|
videoLinks.forEach((link) => {
|
||||||
const hasVideo = link.querySelector("video");
|
const hasVideo = link.querySelector("video");
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ export default function PostForm(props: PostFormProps) {
|
|||||||
props.postId
|
props.postId
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mark initial load as complete after data is loaded (for edit mode)
|
|
||||||
// Use setTimeout to ensure this runs after all signals are initialized
|
// Use setTimeout to ensure this runs after all signals are initialized
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (props.mode === "edit" && props.initialData) {
|
if (props.mode === "edit" && props.initialData) {
|
||||||
@@ -73,12 +72,10 @@ export default function PostForm(props: PostFormProps) {
|
|||||||
}, 5000);
|
}, 5000);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper to ensure post exists (create if needed)
|
|
||||||
const ensurePostExists = async (): Promise<number> => {
|
const ensurePostExists = async (): Promise<number> => {
|
||||||
const existingId = createdPostId() || props.postId;
|
const existingId = createdPostId() || props.postId;
|
||||||
if (existingId) return existingId;
|
if (existingId) return existingId;
|
||||||
|
|
||||||
// Create minimal post if it doesn't exist yet
|
|
||||||
const result = await api.database.createPost.mutate({
|
const result = await api.database.createPost.mutate({
|
||||||
category: "blog",
|
category: "blog",
|
||||||
title: title().replaceAll(" ", "_") || "Untitled",
|
title: title().replaceAll(" ", "_") || "Untitled",
|
||||||
@@ -95,7 +92,6 @@ export default function PostForm(props: PostFormProps) {
|
|||||||
return newId;
|
return newId;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Individual autosave functions for each field
|
|
||||||
const autoSaveTitle = async () => {
|
const autoSaveTitle = async () => {
|
||||||
const currentTitle = title();
|
const currentTitle = title();
|
||||||
if (!currentTitle || currentTitle === props.initialData?.title) return;
|
if (!currentTitle || currentTitle === props.initialData?.title) return;
|
||||||
@@ -248,7 +244,6 @@ export default function PostForm(props: PostFormProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Debounced versions
|
|
||||||
const debouncedAutoSaveTitle = debounce(autoSaveTitle, 2500);
|
const debouncedAutoSaveTitle = debounce(autoSaveTitle, 2500);
|
||||||
const debouncedAutoSaveSubtitle = debounce(autoSaveSubtitle, 2500);
|
const debouncedAutoSaveSubtitle = debounce(autoSaveSubtitle, 2500);
|
||||||
const debouncedAutoSaveBody = debounce(autoSaveBody, 2500);
|
const debouncedAutoSaveBody = debounce(autoSaveBody, 2500);
|
||||||
@@ -256,7 +251,6 @@ export default function PostForm(props: PostFormProps) {
|
|||||||
const debouncedAutoSavePublished = debounce(autoSavePublished, 1000);
|
const debouncedAutoSavePublished = debounce(autoSavePublished, 1000);
|
||||||
const debouncedAutoSaveBanner = debounce(autoSaveBanner, 2500);
|
const debouncedAutoSaveBanner = debounce(autoSaveBanner, 2500);
|
||||||
|
|
||||||
// Individual effects for each field
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const titleVal = title();
|
const titleVal = title();
|
||||||
if (isInitialLoad()) return;
|
if (isInitialLoad()) return;
|
||||||
@@ -405,7 +399,6 @@ export default function PostForm(props: PostFormProps) {
|
|||||||
author_id: props.userID
|
author_id: props.userID
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Create new post
|
|
||||||
const result = await api.database.createPost.mutate({
|
const result = await api.database.createPost.mutate({
|
||||||
category: "blog",
|
category: "blog",
|
||||||
title: title().replaceAll(" ", "_"),
|
title: title().replaceAll(" ", "_"),
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export default function PostSorting(props: PostSortingProps) {
|
|||||||
case "newest":
|
case "newest":
|
||||||
break; // Posts already come newest first from DB (DESC order)
|
break; // Posts already come newest first from DB (DESC order)
|
||||||
case "oldest":
|
case "oldest":
|
||||||
sorted.reverse(); // Reverse to get oldest first
|
sorted.reverse();
|
||||||
break;
|
break;
|
||||||
case "most_liked":
|
case "most_liked":
|
||||||
sorted.sort((a, b) => (b.total_likes || 0) - (a.total_likes || 0));
|
sorted.sort((a, b) => (b.total_likes || 0) - (a.total_likes || 0));
|
||||||
|
|||||||
@@ -1547,7 +1547,6 @@ export default function TextEditor(props: TextEditorProps) {
|
|||||||
},
|
},
|
||||||
handleDOMEvents: {
|
handleDOMEvents: {
|
||||||
touchstart: (view, event) => {
|
touchstart: (view, event) => {
|
||||||
// Only handle touch events on mobile in fullscreen with active suggestion
|
|
||||||
if (
|
if (
|
||||||
!hasSuggestion() ||
|
!hasSuggestion() ||
|
||||||
!isFullscreen() ||
|
!isFullscreen() ||
|
||||||
@@ -1562,7 +1561,6 @@ export default function TextEditor(props: TextEditorProps) {
|
|||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
touchend: (view, event) => {
|
touchend: (view, event) => {
|
||||||
// Only handle touch events on mobile in fullscreen with active suggestion
|
|
||||||
if (
|
if (
|
||||||
!hasSuggestion() ||
|
!hasSuggestion() ||
|
||||||
!isFullscreen() ||
|
!isFullscreen() ||
|
||||||
@@ -1860,7 +1858,6 @@ export default function TextEditor(props: TextEditorProps) {
|
|||||||
const node = allSuperscriptNodes[i];
|
const node = allSuperscriptNodes[i];
|
||||||
const text = node.text;
|
const text = node.text;
|
||||||
|
|
||||||
// Check if this is a complete reference (with optional whitespace)
|
|
||||||
const completeMatch = text.match(/^\s*\[(\d+)\]\s*$/);
|
const completeMatch = text.match(/^\s*\[(\d+)\]\s*$/);
|
||||||
if (completeMatch) {
|
if (completeMatch) {
|
||||||
const hasOtherMarks = node.marks.some(
|
const hasOtherMarks = node.marks.some(
|
||||||
@@ -1877,7 +1874,6 @@ export default function TextEditor(props: TextEditorProps) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this might be the start of a split reference
|
|
||||||
if (text === "[" && i + 2 < allSuperscriptNodes.length) {
|
if (text === "[" && i + 2 < allSuperscriptNodes.length) {
|
||||||
const nextNode = allSuperscriptNodes[i + 1];
|
const nextNode = allSuperscriptNodes[i + 1];
|
||||||
const afterNode = allSuperscriptNodes[i + 2];
|
const afterNode = allSuperscriptNodes[i + 2];
|
||||||
@@ -1958,7 +1954,6 @@ export default function TextEditor(props: TextEditorProps) {
|
|||||||
|
|
||||||
allRefs.sort((a, b) => a.pos - b.pos);
|
allRefs.sort((a, b) => a.pos - b.pos);
|
||||||
|
|
||||||
// Check if renumbering is needed (if any ref doesn't match its expected number)
|
|
||||||
let needsRenumbering = false;
|
let needsRenumbering = false;
|
||||||
for (let i = 0; i < allRefs.length; i++) {
|
for (let i = 0; i < allRefs.length; i++) {
|
||||||
if (allRefs[i].refNum !== i + 1) {
|
if (allRefs[i].refNum !== i + 1) {
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ export const Mermaid = Node.create({
|
|||||||
getAttrs: (element) => {
|
getAttrs: (element) => {
|
||||||
if (typeof element === "string") return false;
|
if (typeof element === "string") return false;
|
||||||
|
|
||||||
// Skip if already has data-type or data-mermaid-diagram attribute
|
|
||||||
if (
|
if (
|
||||||
element.hasAttribute("data-type") ||
|
element.hasAttribute("data-type") ||
|
||||||
element.hasAttribute("data-mermaid-diagram")
|
element.hasAttribute("data-mermaid-diagram")
|
||||||
@@ -83,7 +82,6 @@ export const Mermaid = Node.create({
|
|||||||
const content = code.textContent || "";
|
const content = code.textContent || "";
|
||||||
const trimmedContent = content.trim();
|
const trimmedContent = content.trim();
|
||||||
|
|
||||||
// Check if this looks like a mermaid diagram
|
|
||||||
const mermaidKeywords = [
|
const mermaidKeywords = [
|
||||||
"graph ",
|
"graph ",
|
||||||
"sequenceDiagram",
|
"sequenceDiagram",
|
||||||
@@ -174,12 +172,10 @@ export const Mermaid = Node.create({
|
|||||||
code.textContent = node.attrs.content || "";
|
code.textContent = node.attrs.content || "";
|
||||||
pre.appendChild(code);
|
pre.appendChild(code);
|
||||||
|
|
||||||
// Validation status indicator
|
|
||||||
const statusIndicator = document.createElement("div");
|
const statusIndicator = document.createElement("div");
|
||||||
statusIndicator.className =
|
statusIndicator.className =
|
||||||
"absolute top-2 left-2 w-3 h-3 rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-200";
|
"absolute top-2 left-2 w-3 h-3 rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-200";
|
||||||
|
|
||||||
// Validate syntax asynchronously
|
|
||||||
const validateSyntax = async () => {
|
const validateSyntax = async () => {
|
||||||
const content = node.attrs.content || "";
|
const content = node.attrs.content || "";
|
||||||
if (!content.trim()) {
|
if (!content.trim()) {
|
||||||
@@ -250,7 +246,6 @@ export const Mermaid = Node.create({
|
|||||||
(p: any) => p.spec?.key === "mermaidSelection"
|
(p: any) => p.spec?.key === "mermaidSelection"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Use intersection observer to trigger update when visible
|
|
||||||
let updateInterval: ReturnType<typeof setInterval> | null = null;
|
let updateInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
const BackArrow = (props: {
|
|
||||||
height: number;
|
|
||||||
width: number;
|
|
||||||
stroke: string;
|
|
||||||
strokeWidth: number;
|
|
||||||
class?: string;
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div class={props.class}>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
strokeWidth={props.strokeWidth}
|
|
||||||
stroke={props.stroke}
|
|
||||||
height={props.height}
|
|
||||||
width={props.width}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default BackArrow;
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
function MenuBars() {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
width="36"
|
|
||||||
height="30"
|
|
||||||
viewBox="0 0 120 100"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<g id="Mask group">
|
|
||||||
<g id="Frame 1">
|
|
||||||
<rect width="120" height="100" />
|
|
||||||
<line
|
|
||||||
id="LineA"
|
|
||||||
x1="11.5"
|
|
||||||
y1="31.5"
|
|
||||||
x2="108.5"
|
|
||||||
y2="31.5"
|
|
||||||
strokeWidth="6"
|
|
||||||
strokeLinecap="round"
|
|
||||||
class="stroke-black dark:stroke-white"
|
|
||||||
/>
|
|
||||||
<line
|
|
||||||
id="LineB"
|
|
||||||
x1="11.5"
|
|
||||||
y1="64.5"
|
|
||||||
x2="108.5"
|
|
||||||
y2="64.5"
|
|
||||||
strokeWidth="6"
|
|
||||||
strokeLinecap="round"
|
|
||||||
class="stroke-black dark:stroke-white"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default MenuBars;
|
|
||||||
@@ -128,7 +128,6 @@ export default function Button(props: ButtonProps) {
|
|||||||
height: number;
|
height: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
// Measure content dimensions when not loading
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!local.loading && contentRef) {
|
if (!local.loading && contentRef) {
|
||||||
const rect = contentRef.getBoundingClientRect();
|
const rect = contentRef.getBoundingClientRect();
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ export const AUTH_CONFIG = {
|
|||||||
ACCESS_TOKEN_EXPIRY_DEV: "2m" as const, // 2 minutes for faster testing
|
ACCESS_TOKEN_EXPIRY_DEV: "2m" as const, // 2 minutes for faster testing
|
||||||
ACCESS_TOKEN_EXPIRY_LONG: "30d" as const, // rememberMe cookie lifetime
|
ACCESS_TOKEN_EXPIRY_LONG: "30d" as const, // rememberMe cookie lifetime
|
||||||
|
|
||||||
// Other Auth Settings
|
|
||||||
CSRF_TOKEN_MAX_AGE: 60 * 60 * 24 * 14,
|
CSRF_TOKEN_MAX_AGE: 60 * 60 * 24 * 14,
|
||||||
EMAIL_LOGIN_LINK_EXPIRY: "15m" as const,
|
EMAIL_LOGIN_LINK_EXPIRY: "15m" as const,
|
||||||
EMAIL_VERIFICATION_LINK_EXPIRY: "15m" as const,
|
EMAIL_VERIFICATION_LINK_EXPIRY: "15m" as const,
|
||||||
@@ -74,9 +73,6 @@ export const RATE_LIMITS = {
|
|||||||
EMAIL_VERIFICATION_IP: { maxAttempts: 5, windowMs: 15 * 60 * 1000 }
|
EMAIL_VERIFICATION_IP: { maxAttempts: 5, windowMs: 15 * 60 * 1000 }
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Rate limit store cleanup interval (5 minutes) */
|
|
||||||
export const RATE_LIMIT_CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// ACCOUNT SECURITY
|
// ACCOUNT SECURITY
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -136,22 +132,6 @@ export const NETWORK_CONFIG = {
|
|||||||
RETRY_DELAY_MS: 1000
|
RETRY_DELAY_MS: 1000
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// UI/UX - TYPEWRITER COMPONENT
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export const TYPEWRITER_CONFIG = {
|
|
||||||
DEFAULT_SPEED: 30,
|
|
||||||
FAST_SPEED: 80,
|
|
||||||
SLOW_SPEED: 10,
|
|
||||||
VERY_SLOW_SPEED: 100,
|
|
||||||
EXTRA_SLOW_SPEED: 120,
|
|
||||||
DEFAULT_KEEP_ALIVE_MS: 2000,
|
|
||||||
LONG_KEEP_ALIVE_MS: 10000,
|
|
||||||
DEFAULT_DELAY_MS: 500,
|
|
||||||
CURSOR_FADE_DELAY_MS: 1000
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// UI/UX - COUNTDOWN TIMER COMPONENT
|
// UI/UX - COUNTDOWN TIMER COMPONENT
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -177,41 +157,6 @@ export const BREAKPOINTS = {
|
|||||||
DESKTOP_MIN_WIDTH: 1025
|
DESKTOP_MIN_WIDTH: 1025
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// UI/UX - ANIMATIONS & TRANSITIONS
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export const ANIMATION_CONFIG = {
|
|
||||||
TRANSITION_DURATION_MS: 300,
|
|
||||||
FAST_TRANSITION_MS: 200,
|
|
||||||
SLOW_TRANSITION_MS: 500,
|
|
||||||
EXTRA_SLOW_TRANSITION_MS: 600,
|
|
||||||
SIDEBAR_DURATION_MS: 500,
|
|
||||||
MENU_TYPING_DELAY_MS: 140,
|
|
||||||
MENU_INITIAL_DELAY_MS: 500,
|
|
||||||
SUCCESS_MESSAGE_DURATION_MS: 3000,
|
|
||||||
ERROR_MESSAGE_DURATION_MS: 5000,
|
|
||||||
REDIRECT_DELAY_MS: 500
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// UI/UX - PDF VIEWER
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export const PDF_CONFIG = {
|
|
||||||
RENDER_SCALE: 1.5
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// UI/UX - 401 ERROR PAGE
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export const ERROR_PAGE_CONFIG = {
|
|
||||||
GLITCH_INTERVAL_MS: 300,
|
|
||||||
GLITCH_DURATION_MS: 100,
|
|
||||||
PARTICLE_COUNT: 45
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// UI/UX - MOBILE CONFIG
|
// UI/UX - MOBILE CONFIG
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -284,22 +229,4 @@ export const LINEAGE_CONFIG = {
|
|||||||
JWT_AUDIENCE: "lineage-app" as const
|
JWT_AUDIENCE: "lineage-app" as const
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// AUDIT & LOGGING
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export const AUDIT_CONFIG = {
|
|
||||||
DEFAULT_QUERY_LIMIT: 100,
|
|
||||||
MAX_RETENTION_DAYS: 90
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// SESSION CLEANUP
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export const SESSION_CLEANUP_CONFIG = {
|
|
||||||
ENABLED: true,
|
|
||||||
INTERVAL_HOURS: 24,
|
|
||||||
RETENTION_DAYS: 90,
|
|
||||||
RUN_ON_STARTUP: true
|
|
||||||
} as const;
|
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve the client-side active site, preferring the SSR-injected id. */
|
/** Resolve the client-side active site, preferring the SSR-injected id. */
|
||||||
export function resolveClientSite(): Site {
|
function resolveClientSite(): Site {
|
||||||
if (typeof window === "undefined") return MAIN_SITE;
|
if (typeof window === "undefined") return MAIN_SITE;
|
||||||
const injected = window.__SITE__;
|
const injected = window.__SITE__;
|
||||||
if (injected && SITE_CONFIG[injected]) return SITE_CONFIG[injected];
|
if (injected && SITE_CONFIG[injected]) return SITE_CONFIG[injected];
|
||||||
|
|||||||
@@ -53,9 +53,7 @@ export const AuthProvider: ParentComponent = (props) => {
|
|||||||
// Get server state using createAsync which works with cache()
|
// Get server state using createAsync which works with cache()
|
||||||
const serverAuth = createAsync(() => getUserState(), { deferStream: true });
|
const serverAuth = createAsync(() => getUserState(), { deferStream: true });
|
||||||
|
|
||||||
// Refresh callback that forces re-fetch
|
|
||||||
const refreshAuth = () => {
|
const refreshAuth = () => {
|
||||||
// Manually trigger a re-fetch by calling the revalidate function
|
|
||||||
revalidate(["user-auth-state"]);
|
revalidate(["user-auth-state"]);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,7 +68,6 @@ export const AuthProvider: ParentComponent = (props) => {
|
|||||||
// Server handles all token refresh logic
|
// Server handles all token refresh logic
|
||||||
// Client just displays the current auth state from server
|
// Client just displays the current auth state from server
|
||||||
|
|
||||||
// Listen for auth refresh events from external sources (token refresh, etc.)
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
|
|||||||
160
src/db/create.ts
160
src/db/create.ts
@@ -1,160 +0,0 @@
|
|||||||
export const model: { [key: string]: string } = {
|
|
||||||
User: `
|
|
||||||
CREATE TABLE User
|
|
||||||
(
|
|
||||||
id TEXT NOT NULL PRIMARY KEY,
|
|
||||||
email TEXT UNIQUE,
|
|
||||||
email_verified INTEGER DEFAULT 0,
|
|
||||||
password_hash TEXT,
|
|
||||||
display_name TEXT,
|
|
||||||
provider TEXT,
|
|
||||||
image TEXT,
|
|
||||||
is_admin INTEGER DEFAULT 0,
|
|
||||||
registered_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
failed_attempts INTEGER DEFAULT 0,
|
|
||||||
locked_until TEXT
|
|
||||||
);
|
|
||||||
`,
|
|
||||||
UserProvider: `
|
|
||||||
CREATE TABLE UserProvider
|
|
||||||
(
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
provider TEXT NOT NULL CHECK(provider IN ('email', 'google', 'github', 'apple')),
|
|
||||||
provider_user_id TEXT,
|
|
||||||
email TEXT,
|
|
||||||
display_name TEXT,
|
|
||||||
image TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
last_used_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_provider_provider_user ON UserProvider (provider, provider_user_id);
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_provider_provider_email ON UserProvider (provider, email);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_provider_user_id ON UserProvider (user_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_provider_provider ON UserProvider (provider);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_provider_email ON UserProvider (email);
|
|
||||||
`,
|
|
||||||
PasswordResetToken: `
|
|
||||||
CREATE TABLE PasswordResetToken
|
|
||||||
(
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
token TEXT NOT NULL UNIQUE,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
expires_at TEXT NOT NULL,
|
|
||||||
used_at TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_password_reset_token ON PasswordResetToken (token);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_password_reset_user_id ON PasswordResetToken (user_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_password_reset_expires_at ON PasswordResetToken (expires_at);
|
|
||||||
`,
|
|
||||||
Post: `
|
|
||||||
CREATE TABLE Post
|
|
||||||
(
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
title TEXT NOT NULL UNIQUE,
|
|
||||||
subtitle TEXT,
|
|
||||||
body TEXT NOT NULL,
|
|
||||||
banner_photo TEXT,
|
|
||||||
date TEXT,
|
|
||||||
published INTEGER NOT NULL,
|
|
||||||
category TEXT,
|
|
||||||
author_id TEXT NOT NULL,
|
|
||||||
reads INTEGER NOT NULL DEFAULT 0,
|
|
||||||
attachments TEXT,
|
|
||||||
last_edited_date TEXT
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_posts_category ON Post (category);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_posts_published ON Post (published);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_posts_date ON Post (date);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_posts_published_date ON Post (published, date);
|
|
||||||
`,
|
|
||||||
PostLike: `
|
|
||||||
CREATE TABLE PostLike
|
|
||||||
(
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
post_id INTEGER NOT NULL
|
|
||||||
);
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_likes_user_post ON PostLike (user_id, post_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_likes_post_id ON PostLike (post_id);
|
|
||||||
`,
|
|
||||||
Comment: `
|
|
||||||
CREATE TABLE Comment
|
|
||||||
(
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
body TEXT NOT NULL,
|
|
||||||
post_id INTEGER,
|
|
||||||
parent_comment_id INTEGER,
|
|
||||||
date TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
edited INTEGER NOT NULL DEFAULT 0,
|
|
||||||
commenter_id TEXT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_comment_commenter_id ON Comment (commenter_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_comment_parent_comment_id ON Comment (parent_comment_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_comment_post_id ON Comment (post_id);
|
|
||||||
`,
|
|
||||||
CommentReaction: `
|
|
||||||
CREATE TABLE CommentReaction
|
|
||||||
(
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
type TEXT NOT NULL,
|
|
||||||
comment_id INTEGER NOT NULL,
|
|
||||||
user_id TEXT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_reaction_user_type_comment ON CommentReaction (user_id, type, comment_id);
|
|
||||||
`,
|
|
||||||
Connection: `
|
|
||||||
CREATE TABLE Connection
|
|
||||||
(
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
connection_id TEXT NOT NULL,
|
|
||||||
post_id INTEGER
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_connection_post_id ON Connection (post_id);
|
|
||||||
`,
|
|
||||||
Tag: `
|
|
||||||
CREATE TABLE Tag
|
|
||||||
(
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
value TEXT NOT NULL,
|
|
||||||
post_id INTEGER NOT NULL
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tag_post_id ON Tag (post_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tag_value ON Tag (value);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tag_post_value ON Tag (post_id, value);
|
|
||||||
`,
|
|
||||||
PostHistory: `
|
|
||||||
CREATE TABLE PostHistory
|
|
||||||
(
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
post_id INTEGER NOT NULL,
|
|
||||||
parent_id INTEGER,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
is_saved INTEGER DEFAULT 0,
|
|
||||||
FOREIGN KEY (post_id) REFERENCES Post(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_history_post_id ON PostHistory (post_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_history_parent_id ON PostHistory (parent_id);
|
|
||||||
`,
|
|
||||||
RateLimit: `
|
|
||||||
CREATE TABLE RateLimit
|
|
||||||
(
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
identifier TEXT NOT NULL,
|
|
||||||
count INTEGER NOT NULL DEFAULT 1,
|
|
||||||
reset_at TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
-- Unique constraint on identifier so ON CONFLICT(identifier) atomic upserts
|
|
||||||
-- (see src/server/security.ts checkRateLimit) are well-defined. This makes
|
|
||||||
-- the rate-limit state shared across all instances (p8-010).
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_ratelimit_identifier_unique ON RateLimit (identifier);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_ratelimit_reset_at ON RateLimit (reset_at);
|
|
||||||
`
|
|
||||||
};
|
|
||||||
@@ -34,14 +34,12 @@ function shouldAttemptReload(): boolean {
|
|||||||
10
|
10
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reset counter if outside the time window
|
|
||||||
if (now - lastReloadTime > RELOAD_WINDOW_MS) {
|
if (now - lastReloadTime > RELOAD_WINDOW_MS) {
|
||||||
sessionStorage.setItem(RELOAD_STORAGE_KEY, "0");
|
sessionStorage.setItem(RELOAD_STORAGE_KEY, "0");
|
||||||
sessionStorage.setItem(RELOAD_TIMESTAMP_KEY, now.toString());
|
sessionStorage.setItem(RELOAD_TIMESTAMP_KEY, now.toString());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we've exceeded max reloads
|
|
||||||
if (reloadCount >= MAX_RELOADS) {
|
if (reloadCount >= MAX_RELOADS) {
|
||||||
console.error(
|
console.error(
|
||||||
`Exceeded ${MAX_RELOADS} reload attempts in ${RELOAD_WINDOW_MS}ms. Stopping to prevent infinite loop.`
|
`Exceeded ${MAX_RELOADS} reload attempts in ${RELOAD_WINDOW_MS}ms. Stopping to prevent infinite loop.`
|
||||||
@@ -49,12 +47,10 @@ function shouldAttemptReload(): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Increment counter and allow reload
|
|
||||||
sessionStorage.setItem(RELOAD_STORAGE_KEY, (reloadCount + 1).toString());
|
sessionStorage.setItem(RELOAD_STORAGE_KEY, (reloadCount + 1).toString());
|
||||||
sessionStorage.setItem(RELOAD_TIMESTAMP_KEY, now.toString());
|
sessionStorage.setItem(RELOAD_TIMESTAMP_KEY, now.toString());
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// If sessionStorage fails, allow reload but log error
|
|
||||||
console.warn("Failed to access sessionStorage:", e);
|
console.warn("Failed to access sessionStorage:", e);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -102,7 +98,6 @@ function handleChunkError(source: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle runtime chunk loading errors
|
|
||||||
window.addEventListener("error", (event) => {
|
window.addEventListener("error", (event) => {
|
||||||
if (
|
if (
|
||||||
event.message?.includes("Importing a module script failed") ||
|
event.message?.includes("Importing a module script failed") ||
|
||||||
@@ -113,7 +108,6 @@ window.addEventListener("error", (event) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle promise-based chunk loading errors
|
|
||||||
window.addEventListener("unhandledrejection", (event) => {
|
window.addEventListener("unhandledrejection", (event) => {
|
||||||
if (
|
if (
|
||||||
event.reason?.message?.includes("Importing a module script failed") ||
|
event.reason?.message?.includes("Importing a module script failed") ||
|
||||||
@@ -126,9 +120,7 @@ window.addEventListener("unhandledrejection", (event) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear reload counter on successful page load
|
|
||||||
window.addEventListener("load", () => {
|
window.addEventListener("load", () => {
|
||||||
// Only clear if we successfully loaded (we're past the critical chunk loading phase)
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
sessionStorage.removeItem(RELOAD_STORAGE_KEY);
|
sessionStorage.removeItem(RELOAD_STORAGE_KEY);
|
||||||
sessionStorage.removeItem(RELOAD_TIMESTAMP_KEY);
|
sessionStorage.removeItem(RELOAD_TIMESTAMP_KEY);
|
||||||
|
|||||||
11
src/env/client.ts
vendored
11
src/env/client.ts
vendored
@@ -38,16 +38,7 @@ export const validateClientEnv = (
|
|||||||
return envVars as unknown as ClientEnv;
|
return envVars as unknown as ClientEnv;
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateAndExportEnv = (): ClientEnv => {
|
export const env = validateClientEnv(import.meta.env);
|
||||||
try {
|
|
||||||
const validated = validateClientEnv(import.meta.env);
|
|
||||||
return validated;
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const env = validateAndExportEnv();
|
|
||||||
|
|
||||||
export const isMissingEnvVar = (varName: string): boolean => {
|
export const isMissingEnvVar = (varName: string): boolean => {
|
||||||
return !import.meta.env[varName] || import.meta.env[varName]?.trim() === "";
|
return !import.meta.env[varName] || import.meta.env[varName]?.trim() === "";
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ export const getUserState = query(async (): Promise<UserState> => {
|
|||||||
* Call this after login, logout, token refresh, email verification
|
* Call this after login, logout, token refresh, email verification
|
||||||
*/
|
*/
|
||||||
export function revalidateAuth() {
|
export function revalidateAuth() {
|
||||||
// Revalidate the cache
|
|
||||||
revalidateKey("user-auth-state");
|
revalidateKey("user-auth-state");
|
||||||
|
|
||||||
// Dispatch event to trigger UI updates (client-side only)
|
// Dispatch event to trigger UI updates (client-side only)
|
||||||
|
|||||||
@@ -3,21 +3,6 @@
|
|||||||
* Note: These utilities should only run in the browser
|
* Note: These utilities should only run in the browser
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch wrapper for auth checks where 401s are expected and should not trigger console errors
|
|
||||||
*/
|
|
||||||
export async function safeFetch(
|
|
||||||
input: RequestInfo | URL,
|
|
||||||
init?: RequestInit
|
|
||||||
): Promise<Response> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(input, init);
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decode JWT payload without verification (client-side only)
|
* Decode JWT payload without verification (client-side only)
|
||||||
* @param token - JWT token string
|
* @param token - JWT token string
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
/**
|
|
||||||
* Cookie utilities for SolidStart
|
|
||||||
* Provides client and server-side cookie management
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { getCookie as getServerCookie, setCookie as setServerCookie } from "vinxi/http";
|
|
||||||
import type { H3Event } from "vinxi/http";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get cookie value on the server
|
|
||||||
*/
|
|
||||||
export function getCookie(event: H3Event, name: string): string | undefined {
|
|
||||||
return getServerCookie(event, name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set cookie on the server
|
|
||||||
*/
|
|
||||||
export function setCookie(
|
|
||||||
event: H3Event,
|
|
||||||
name: string,
|
|
||||||
value: string,
|
|
||||||
options?: {
|
|
||||||
maxAge?: number;
|
|
||||||
expires?: Date;
|
|
||||||
httpOnly?: boolean;
|
|
||||||
secure?: boolean;
|
|
||||||
sameSite?: "strict" | "lax" | "none";
|
|
||||||
path?: string;
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
setServerCookie(event, name, value, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete cookie on the server
|
|
||||||
*/
|
|
||||||
export function deleteCookie(event: H3Event, name: string) {
|
|
||||||
setServerCookie(event, name, "", {
|
|
||||||
maxAge: 0,
|
|
||||||
expires: new Date("2016-10-05"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get cookie value on the client (browser)
|
|
||||||
*/
|
|
||||||
export function getClientCookie(name: string): string | undefined {
|
|
||||||
if (typeof document === "undefined") return undefined;
|
|
||||||
|
|
||||||
const value = `; ${document.cookie}`;
|
|
||||||
const parts = value.split(`; ${name}=`);
|
|
||||||
|
|
||||||
if (parts.length === 2) {
|
|
||||||
return parts.pop()?.split(";").shift();
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set cookie on the client (browser)
|
|
||||||
*/
|
|
||||||
export function setClientCookie(
|
|
||||||
name: string,
|
|
||||||
value: string,
|
|
||||||
options?: {
|
|
||||||
maxAge?: number;
|
|
||||||
expires?: Date;
|
|
||||||
path?: string;
|
|
||||||
secure?: boolean;
|
|
||||||
sameSite?: "strict" | "lax" | "none";
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
if (typeof document === "undefined") return;
|
|
||||||
|
|
||||||
let cookieString = `${name}=${value}`;
|
|
||||||
|
|
||||||
if (options?.maxAge) {
|
|
||||||
cookieString += `; max-age=${options.maxAge}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options?.expires) {
|
|
||||||
cookieString += `; expires=${options.expires.toUTCString()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options?.path) {
|
|
||||||
cookieString += `; path=${options.path}`;
|
|
||||||
} else {
|
|
||||||
cookieString += "; path=/";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options?.secure) {
|
|
||||||
cookieString += "; secure";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options?.sameSite) {
|
|
||||||
cookieString += `; samesite=${options.sameSite}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.cookie = cookieString;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete cookie on the client (browser)
|
|
||||||
*/
|
|
||||||
export function deleteClientCookie(name: string) {
|
|
||||||
if (typeof document === "undefined") return;
|
|
||||||
|
|
||||||
document.cookie = `${name}=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
|
|
||||||
}
|
|
||||||
@@ -81,7 +81,6 @@ export function formatRelativeTime(
|
|||||||
return `${diffDay}d ago`;
|
return `${diffDay}d ago`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// style === "long"
|
|
||||||
if (includeSeconds && diffSec < 60) {
|
if (includeSeconds && diffSec < 60) {
|
||||||
return `${diffSec} second${diffSec === 1 ? "" : "s"} ago`;
|
return `${diffSec} second${diffSec === 1 ? "" : "s"} ago`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,14 +12,12 @@ const VERSION_STORAGE_KEY = "app-version-hash";
|
|||||||
*/
|
*/
|
||||||
function getCurrentVersionHash(): string {
|
function getCurrentVersionHash(): string {
|
||||||
try {
|
try {
|
||||||
// Use a combination of script tags to detect version
|
|
||||||
const scripts = Array.from(document.querySelectorAll("script[src]"))
|
const scripts = Array.from(document.querySelectorAll("script[src]"))
|
||||||
.map((s) => (s as HTMLScriptElement).src)
|
.map((s) => (s as HTMLScriptElement).src)
|
||||||
.filter((src) => src.includes("/_build/"))
|
.filter((src) => src.includes("/_build/"))
|
||||||
.sort()
|
.sort()
|
||||||
.join(",");
|
.join(",");
|
||||||
|
|
||||||
// Simple hash function
|
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
for (let i = 0; i < scripts.length; i++) {
|
for (let i = 0; i < scripts.length; i++) {
|
||||||
const char = scripts.charCodeAt(i);
|
const char = scripts.charCodeAt(i);
|
||||||
@@ -39,7 +37,6 @@ function getCurrentVersionHash(): string {
|
|||||||
*/
|
*/
|
||||||
async function checkForNewVersion(): Promise<boolean> {
|
async function checkForNewVersion(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
// Fetch current page HTML
|
|
||||||
const response = await fetch(window.location.pathname, {
|
const response = await fetch(window.location.pathname, {
|
||||||
method: "HEAD",
|
method: "HEAD",
|
||||||
cache: "no-cache"
|
cache: "no-cache"
|
||||||
@@ -64,7 +61,6 @@ async function checkForNewVersion(): Promise<boolean> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store current ETag for future checks
|
|
||||||
if (newEtag) {
|
if (newEtag) {
|
||||||
sessionStorage.setItem("app-etag", newEtag);
|
sessionStorage.setItem("app-etag", newEtag);
|
||||||
}
|
}
|
||||||
@@ -80,7 +76,6 @@ async function checkForNewVersion(): Promise<boolean> {
|
|||||||
* Show update notification to user
|
* Show update notification to user
|
||||||
*/
|
*/
|
||||||
function showUpdateNotification(): void {
|
function showUpdateNotification(): void {
|
||||||
// Only show once per session
|
|
||||||
if (sessionStorage.getItem("update-notification-shown")) {
|
if (sessionStorage.getItem("update-notification-shown")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -147,7 +142,6 @@ function showUpdateNotification(): void {
|
|||||||
|
|
||||||
document.body.appendChild(notification);
|
document.body.appendChild(notification);
|
||||||
|
|
||||||
// Auto-remove after 30 seconds
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (notification.parentElement) {
|
if (notification.parentElement) {
|
||||||
notification.style.animation = "slideIn 0.3s ease-out reverse";
|
notification.style.animation = "slideIn 0.3s ease-out reverse";
|
||||||
@@ -162,11 +156,9 @@ function showUpdateNotification(): void {
|
|||||||
export function startDeploymentMonitoring(): void {
|
export function startDeploymentMonitoring(): void {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
// Store initial version
|
|
||||||
const initialVersion = getCurrentVersionHash();
|
const initialVersion = getCurrentVersionHash();
|
||||||
sessionStorage.setItem(VERSION_STORAGE_KEY, initialVersion);
|
sessionStorage.setItem(VERSION_STORAGE_KEY, initialVersion);
|
||||||
|
|
||||||
// Periodic version check
|
|
||||||
const intervalId = setInterval(async () => {
|
const intervalId = setInterval(async () => {
|
||||||
const hasNewVersion = await checkForNewVersion();
|
const hasNewVersion = await checkForNewVersion();
|
||||||
if (hasNewVersion) {
|
if (hasNewVersion) {
|
||||||
@@ -174,7 +166,6 @@ export function startDeploymentMonitoring(): void {
|
|||||||
}
|
}
|
||||||
}, VERSION_CHECK_INTERVAL);
|
}, VERSION_CHECK_INTERVAL);
|
||||||
|
|
||||||
// Check on visibility change (user returns to tab)
|
|
||||||
const handleVisibilityChange = async () => {
|
const handleVisibilityChange = async () => {
|
||||||
if (document.visibilityState === "visible") {
|
if (document.visibilityState === "visible") {
|
||||||
const hasNewVersion = await checkForNewVersion();
|
const hasNewVersion = await checkForNewVersion();
|
||||||
@@ -186,7 +177,6 @@ export function startDeploymentMonitoring(): void {
|
|||||||
|
|
||||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
|
||||||
// Cleanup function
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
(window as any).__cleanupDeploymentMonitoring = () => {
|
(window as any).__cleanupDeploymentMonitoring = () => {
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ export function initPerformanceTracking() {
|
|||||||
|
|
||||||
const supported = new Set(PerformanceObserver.supportedEntryTypes ?? []);
|
const supported = new Set(PerformanceObserver.supportedEntryTypes ?? []);
|
||||||
|
|
||||||
// Observe LCP
|
|
||||||
if (supported.has("largest-contentful-paint")) {
|
if (supported.has("largest-contentful-paint")) {
|
||||||
try {
|
try {
|
||||||
const lcpObserver = new PerformanceObserver((entryList) => {
|
const lcpObserver = new PerformanceObserver((entryList) => {
|
||||||
@@ -40,7 +39,6 @@ export function initPerformanceTracking() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Observe CLS
|
|
||||||
if (supported.has("layout-shift")) {
|
if (supported.has("layout-shift")) {
|
||||||
try {
|
try {
|
||||||
const clsObserver = new PerformanceObserver((entryList) => {
|
const clsObserver = new PerformanceObserver((entryList) => {
|
||||||
@@ -59,7 +57,6 @@ export function initPerformanceTracking() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Observe FID
|
|
||||||
if (supported.has("first-input")) {
|
if (supported.has("first-input")) {
|
||||||
try {
|
try {
|
||||||
const fidObserver = new PerformanceObserver((entryList) => {
|
const fidObserver = new PerformanceObserver((entryList) => {
|
||||||
@@ -74,7 +71,6 @@ export function initPerformanceTracking() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Observe INP (event timing)
|
|
||||||
if (supported.has("event")) {
|
if (supported.has("event")) {
|
||||||
try {
|
try {
|
||||||
const interactions: number[] = [];
|
const interactions: number[] = [];
|
||||||
@@ -96,7 +92,6 @@ export function initPerformanceTracking() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get navigation timing metrics
|
|
||||||
window.addEventListener("load", () => {
|
window.addEventListener("load", () => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const navTiming = performance.getEntriesByType(
|
const navTiming = performance.getEntriesByType(
|
||||||
@@ -110,7 +105,6 @@ export function initPerformanceTracking() {
|
|||||||
metrics.loadComplete = navTiming.loadEventEnd - navTiming.fetchStart;
|
metrics.loadComplete = navTiming.loadEventEnd - navTiming.fetchStart;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get FCP
|
|
||||||
const paintEntries = performance.getEntriesByType("paint");
|
const paintEntries = performance.getEntriesByType("paint");
|
||||||
const fcpEntry = paintEntries.find(
|
const fcpEntry = paintEntries.find(
|
||||||
(entry) => entry.name === "first-contentful-paint"
|
(entry) => entry.name === "first-contentful-paint"
|
||||||
@@ -135,7 +129,6 @@ export function initPerformanceTracking() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sendMetrics() {
|
function sendMetrics() {
|
||||||
// Only send if we have at least one metric
|
|
||||||
if (Object.keys(metrics).length === 0) {
|
if (Object.keys(metrics).length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -157,7 +150,6 @@ function sendMetrics() {
|
|||||||
const blob = new Blob([payload], { type: "application/json" });
|
const blob = new Blob([payload], { type: "application/json" });
|
||||||
navigator.sendBeacon(apiUrl, blob);
|
navigator.sendBeacon(apiUrl, blob);
|
||||||
} else {
|
} else {
|
||||||
// Fallback to fetch with keepalive
|
|
||||||
fetch(apiUrl, {
|
fetch(apiUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -168,6 +160,5 @@ function sendMetrics() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear metrics after sending
|
|
||||||
metrics = {};
|
metrics = {};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ export default async function AddImageToS3(
|
|||||||
throw new Error("Failed to upload file to S3");
|
throw new Error("Failed to upload file to S3");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create thumbnails for images (blog posts only)
|
|
||||||
if (type === "blog" && isImage) {
|
if (type === "blog" && isImage) {
|
||||||
try {
|
try {
|
||||||
const thumbnail = await resizeImage(file, 200, 200, 0.8);
|
const thumbnail = await resizeImage(file, 200, 200, 0.8);
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ function xmlEscape(s: string): string {
|
|||||||
/**
|
/**
|
||||||
* Generate a single `<url>` element for a given entry on a site.
|
* Generate a single `<url>` element for a given entry on a site.
|
||||||
*/
|
*/
|
||||||
export function urlElement(site: Site, entry: SitemapEntry): string {
|
function urlElement(site: Site, entry: SitemapEntry): string {
|
||||||
const loc = `https://${site.domain}${entry.path}`;
|
const loc = `https://${site.domain}${entry.path}`;
|
||||||
return ` <url>
|
return ` <url>
|
||||||
<loc>${xmlEscape(loc)}</loc>
|
<loc>${xmlEscape(loc)}</loc>
|
||||||
|
|||||||
@@ -28,13 +28,11 @@ describe("generateSitemap", () => {
|
|||||||
it("generates valid XML for main site with all expected routes", () => {
|
it("generates valid XML for main site with all expected routes", () => {
|
||||||
const xml = generateSitemap(SITE_CONFIG.main, SITEMAP_ROUTES.main);
|
const xml = generateSitemap(SITE_CONFIG.main, SITEMAP_ROUTES.main);
|
||||||
|
|
||||||
// Basic structure
|
|
||||||
expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>');
|
expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>');
|
||||||
expect(xml).toContain(
|
expect(xml).toContain(
|
||||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
|
||||||
);
|
);
|
||||||
|
|
||||||
// All main site paths present with freno.me domain
|
|
||||||
const locs = extractLocs(xml);
|
const locs = extractLocs(xml);
|
||||||
expect(locs).toContain("https://freno.me/");
|
expect(locs).toContain("https://freno.me/");
|
||||||
expect(locs).toContain("https://freno.me/blog");
|
expect(locs).toContain("https://freno.me/blog");
|
||||||
@@ -43,10 +41,8 @@ describe("generateSitemap", () => {
|
|||||||
expect(locs).toContain("https://freno.me/resume");
|
expect(locs).toContain("https://freno.me/resume");
|
||||||
expect(locs).toContain("https://freno.me/downloads");
|
expect(locs).toContain("https://freno.me/downloads");
|
||||||
|
|
||||||
// Exactly 6 entries
|
|
||||||
expect(locs.length).toBe(6);
|
expect(locs.length).toBe(6);
|
||||||
|
|
||||||
// Verify well-formedness by checking balanced tags
|
|
||||||
expect(xml).toContain("</urlset>");
|
expect(xml).toContain("</urlset>");
|
||||||
const urlOpens = (xml.match(/<url>/g) || []).length;
|
const urlOpens = (xml.match(/<url>/g) || []).length;
|
||||||
const urlCloses = (xml.match(/<\/url>/g) || []).length;
|
const urlCloses = (xml.match(/<\/url>/g) || []).length;
|
||||||
@@ -56,7 +52,6 @@ describe("generateSitemap", () => {
|
|||||||
|
|
||||||
it("generates valid parseable XML for lineage site", () => {
|
it("generates valid parseable XML for lineage site", () => {
|
||||||
const xml = generateSitemap(SITE_CONFIG.lineage, SITEMAP_ROUTES.lineage);
|
const xml = generateSitemap(SITE_CONFIG.lineage, SITEMAP_ROUTES.lineage);
|
||||||
// Verify balanced tags
|
|
||||||
expect(xml).toContain("</urlset>");
|
expect(xml).toContain("</urlset>");
|
||||||
const urlOpens = (xml.match(/<url>/g) || []).length;
|
const urlOpens = (xml.match(/<url>/g) || []).length;
|
||||||
const urlCloses = (xml.match(/<\/url>/g) || []).length;
|
const urlCloses = (xml.match(/<\/url>/g) || []).length;
|
||||||
@@ -73,7 +68,6 @@ describe("generateSitemap", () => {
|
|||||||
expect(locs).toContain("https://nessa.freno.me/privacy");
|
expect(locs).toContain("https://nessa.freno.me/privacy");
|
||||||
expect(locs.length).toBe(3);
|
expect(locs.length).toBe(3);
|
||||||
|
|
||||||
// No leakage from main site
|
|
||||||
for (const loc of locs) {
|
for (const loc of locs) {
|
||||||
expect(loc).not.toContain("://freno.me/");
|
expect(loc).not.toContain("://freno.me/");
|
||||||
expect(loc).not.toContain("://freno.me/blog");
|
expect(loc).not.toContain("://freno.me/blog");
|
||||||
|
|||||||
@@ -45,15 +45,12 @@ export function useCountdown(options: UseCountdownOptions = {}) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const startCountdown = (expiresAt: string | Date) => {
|
const startCountdown = (expiresAt: string | Date) => {
|
||||||
// Clear any existing interval
|
|
||||||
if (intervalId !== null) {
|
if (intervalId !== null) {
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate immediately
|
|
||||||
calculateRemaining(expiresAt);
|
calculateRemaining(expiresAt);
|
||||||
|
|
||||||
// Then update every second
|
|
||||||
intervalId = setInterval(() => calculateRemaining(expiresAt), 1000);
|
intervalId = setInterval(() => calculateRemaining(expiresAt), 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -64,7 +61,6 @@ export function useCountdown(options: UseCountdownOptions = {}) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cleanup on unmount
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
stopCountdown();
|
stopCountdown();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -512,37 +512,142 @@ export default function AccountPage() {
|
|||||||
Account Settings
|
Account Settings
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Account Type Section */}
|
<AccountTypeSection
|
||||||
|
profile={userProfile}
|
||||||
|
getProviderColor={getProviderColor}
|
||||||
|
getProviderName={getProviderName}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<hr class="mx-auto mb-8 max-w-4xl" />
|
||||||
|
|
||||||
|
<ProfileImageSection
|
||||||
|
profile={userProfile}
|
||||||
|
handleImageDrop={handleImageDrop}
|
||||||
|
profileImageHolder={profileImageHolder}
|
||||||
|
preSetHolder={preSetHolder}
|
||||||
|
removeImage={removeImage}
|
||||||
|
setUserImage={setUserImage}
|
||||||
|
profileImageSetLoading={profileImageSetLoading}
|
||||||
|
profileImageStateChange={profileImageStateChange}
|
||||||
|
showImageSuccess={showImageSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<hr class="mx-auto mb-8 max-w-4xl" />
|
||||||
|
|
||||||
|
{/* Email Section */}
|
||||||
|
<div class="mx-auto flex max-w-4xl flex-col gap-6 md:grid md:grid-cols-2">
|
||||||
|
<EmailSection
|
||||||
|
profile={userProfile}
|
||||||
|
emailRef={(el) => (emailRef = el)}
|
||||||
|
emailButtonLoading={emailButtonLoading}
|
||||||
|
setEmailTrigger={setEmailTrigger}
|
||||||
|
showEmailSuccess={showEmailSuccess}
|
||||||
|
sendEmailVerification={sendEmailVerification}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Display Name Section */}
|
||||||
|
<DisplayNameSection
|
||||||
|
profile={userProfile}
|
||||||
|
displayNameRef={(el) => (displayNameRef = el)}
|
||||||
|
displayNameButtonLoading={displayNameButtonLoading}
|
||||||
|
setDisplayNameTrigger={setDisplayNameTrigger}
|
||||||
|
showDisplayNameSuccess={showDisplayNameSuccess}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PasswordSection
|
||||||
|
profile={userProfile}
|
||||||
|
handlePasswordSubmit={handlePasswordSubmit}
|
||||||
|
oldPasswordRef={(el) => (oldPasswordRef = el)}
|
||||||
|
newPasswordRef={(el) => (newPasswordRef = el)}
|
||||||
|
newPasswordConfRef={(el) =>
|
||||||
|
el !== undefined
|
||||||
|
? (newPasswordConfRef = el)
|
||||||
|
: newPasswordConfRef
|
||||||
|
}
|
||||||
|
handleNewPasswordChange={handleNewPasswordChange}
|
||||||
|
handlePasswordConfChange={handlePasswordConfChange}
|
||||||
|
handlePasswordBlur={handlePasswordBlur}
|
||||||
|
sendEmailVerification={sendEmailVerification}
|
||||||
|
getProviderName={getProviderName}
|
||||||
|
passwordChangeLoading={passwordChangeLoading}
|
||||||
|
newPassword={newPassword}
|
||||||
|
passwordsMatch={passwordsMatch}
|
||||||
|
passwordLengthSufficient={passwordLengthSufficient}
|
||||||
|
passwordError={passwordError}
|
||||||
|
showPasswordSuccess={showPasswordSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<hr class="mt-8 mb-8" />
|
||||||
|
|
||||||
|
<LinkedProvidersSection profile={userProfile} />
|
||||||
|
|
||||||
|
<hr class="mt-8 mb-8" />
|
||||||
|
|
||||||
|
<SignOutSection
|
||||||
|
handleSignOut={handleSignOut}
|
||||||
|
signOutLoading={signOutLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<hr class="mt-8 mb-8" />
|
||||||
|
|
||||||
|
<DeleteAccountSection
|
||||||
|
profile={userProfile}
|
||||||
|
getProviderName={getProviderName}
|
||||||
|
deleteAccountPasswordRef={(el) =>
|
||||||
|
(deleteAccountPasswordRef = el)
|
||||||
|
}
|
||||||
|
deleteAccountButtonLoading={deleteAccountButtonLoading}
|
||||||
|
deleteAccountTrigger={deleteAccountTrigger}
|
||||||
|
passwordDeletionError={passwordDeletionError}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccountTypeSection(props: {
|
||||||
|
profile: () => UserProfile;
|
||||||
|
getProviderColor: (provider: UserProfile["provider"]) => string;
|
||||||
|
getProviderName: (provider: UserProfile["provider"]) => string;
|
||||||
|
}) {
|
||||||
|
const { profile, getProviderColor, getProviderName } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
<div class="mx-auto mb-8 max-w-md">
|
<div class="mx-auto mb-8 max-w-md">
|
||||||
<div class="bg-surface0 border-surface1 rounded-lg border px-6 py-4 shadow-sm">
|
<div class="bg-surface0 border-surface1 rounded-lg border px-6 py-4 shadow-sm">
|
||||||
<div class="text-subtext0 mb-2 text-center text-sm font-semibold tracking-wide uppercase">
|
<div class="text-subtext0 mb-2 text-center text-sm font-semibold tracking-wide uppercase">
|
||||||
Account Type
|
Account Type
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-center gap-3">
|
<div class="flex items-center justify-center gap-3">
|
||||||
<span class={getProviderColor(userProfile().provider)}>
|
<span class={getProviderColor(profile().provider)}>
|
||||||
<Show when={userProfile().provider === "google"}>
|
<Show when={profile().provider === "google"}>
|
||||||
<GoogleLogo height={24} width={24} />
|
<GoogleLogo height={24} width={24} />
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={userProfile().provider === "github"}>
|
<Show when={profile().provider === "github"}>
|
||||||
<GitHub height={24} width={24} fill="currentColor" />
|
<GitHub height={24} width={24} fill="currentColor" />
|
||||||
</Show>
|
</Show>
|
||||||
<Show
|
<Show
|
||||||
when={
|
when={
|
||||||
userProfile().provider === "email" ||
|
profile().provider === "email" ||
|
||||||
!userProfile().provider
|
!profile().provider
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<EmailIcon height={24} width={24} />
|
<EmailIcon height={24} width={24} />
|
||||||
</Show>
|
</Show>
|
||||||
</span>
|
</span>
|
||||||
<span class="text-lg font-semibold">
|
<span class="text-lg font-semibold">
|
||||||
{getProviderName(userProfile().provider)} Account
|
{getProviderName(profile().provider)} Account
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Show
|
<Show
|
||||||
when={
|
when={
|
||||||
userProfile().provider !== "email" &&
|
profile().provider !== "email" &&
|
||||||
!userProfile().email
|
!profile().email
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="bg-yellow mt-3 rounded px-3 py-2 text-center text-base text-sm">
|
<div class="bg-yellow mt-3 rounded px-3 py-2 text-center text-base text-sm">
|
||||||
@@ -551,24 +656,47 @@ export default function AccountPage() {
|
|||||||
</Show>
|
</Show>
|
||||||
<Show
|
<Show
|
||||||
when={
|
when={
|
||||||
userProfile().provider !== "email" &&
|
profile().provider !== "email" &&
|
||||||
!userProfile().hasPassword
|
!profile().hasPassword
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="bg-blue mt-3 rounded px-3 py-2 text-center text-base text-sm">
|
<div class="bg-blue mt-3 rounded px-3 py-2 text-center text-base text-sm">
|
||||||
{!userProfile().email
|
{!profile().email
|
||||||
? "💡 Add and verify an email to enable email/password login"
|
? "💡 Add and verify an email to enable email/password login"
|
||||||
: !userProfile().emailVerified
|
: !profile().emailVerified
|
||||||
? "💡 Verify your email to enable password setup"
|
? "💡 Verify your email to enable password setup"
|
||||||
: "💡 Add a password to enable email/password login"}
|
: "💡 Add a password to enable email/password login"}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<hr class="mx-auto mb-8 max-w-4xl" />
|
function ProfileImageSection(props: {
|
||||||
|
profile: () => UserProfile;
|
||||||
|
handleImageDrop: (acceptedFiles: File[]) => void;
|
||||||
|
profileImageHolder: () => string | null;
|
||||||
|
preSetHolder: () => string | null;
|
||||||
|
removeImage: () => void;
|
||||||
|
setUserImage: (e: Event) => void;
|
||||||
|
profileImageSetLoading: () => boolean;
|
||||||
|
profileImageStateChange: () => boolean;
|
||||||
|
showImageSuccess: () => boolean;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
profile,
|
||||||
|
handleImageDrop,
|
||||||
|
profileImageHolder,
|
||||||
|
preSetHolder,
|
||||||
|
removeImage,
|
||||||
|
setUserImage,
|
||||||
|
profileImageSetLoading,
|
||||||
|
profileImageStateChange,
|
||||||
|
showImageSuccess
|
||||||
|
} = props;
|
||||||
|
|
||||||
{/* Profile Image Section */}
|
return (
|
||||||
<div class="mx-auto mb-8 flex max-w-md justify-center">
|
<div class="mx-auto mb-8 flex max-w-md justify-center">
|
||||||
<div class="flex flex-col py-4">
|
<div class="flex flex-col py-4">
|
||||||
<div class="mb-2 text-center text-lg font-semibold">
|
<div class="mb-2 text-center text-lg font-semibold">
|
||||||
@@ -584,7 +712,7 @@ export default function AccountPage() {
|
|||||||
onDrop={handleImageDrop}
|
onDrop={handleImageDrop}
|
||||||
acceptedFiles="image/jpg, image/jpeg, image/png"
|
acceptedFiles="image/jpg, image/jpeg, image/png"
|
||||||
fileHolder={profileImageHolder()}
|
fileHolder={profileImageHolder()}
|
||||||
preSet={preSetHolder() || userProfile().image || null}
|
preSet={preSetHolder() || profile().image || null}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -624,31 +752,46 @@ export default function AccountPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<hr class="mx-auto mb-8 max-w-4xl" />
|
function EmailSection(props: {
|
||||||
|
profile: () => UserProfile;
|
||||||
|
emailRef: (el: HTMLInputElement) => void;
|
||||||
|
emailButtonLoading: () => boolean;
|
||||||
|
setEmailTrigger: (e: Event) => void;
|
||||||
|
showEmailSuccess: () => boolean;
|
||||||
|
sendEmailVerification: () => void;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
profile,
|
||||||
|
emailRef,
|
||||||
|
emailButtonLoading,
|
||||||
|
setEmailTrigger,
|
||||||
|
showEmailSuccess,
|
||||||
|
sendEmailVerification
|
||||||
|
} = props;
|
||||||
|
|
||||||
{/* Email Section */}
|
return (
|
||||||
<div class="mx-auto flex max-w-4xl flex-col gap-6 md:grid md:grid-cols-2">
|
<>
|
||||||
<div class="flex items-center justify-center text-lg md:justify-normal">
|
<div class="flex items-center justify-center text-lg md:justify-normal">
|
||||||
<div class="flex flex-col lg:flex-row">
|
<div class="flex flex-col lg:flex-row">
|
||||||
<div class="pr-1 font-semibold whitespace-nowrap">
|
<div class="pr-1 font-semibold whitespace-nowrap">
|
||||||
{userProfile().provider === "email"
|
{profile().provider === "email"
|
||||||
? "Email:"
|
? "Email:"
|
||||||
: "Linked Email:"}
|
: "Linked Email:"}
|
||||||
</div>
|
</div>
|
||||||
{userProfile().email ? (
|
{profile().email ? (
|
||||||
<span>{userProfile().email}</span>
|
<span>{profile().email}</span>
|
||||||
) : (
|
) : (
|
||||||
<span class="font-light italic underline underline-offset-4">
|
<span class="font-light italic underline underline-offset-4">
|
||||||
{userProfile().provider === "email"
|
{profile().provider === "email"
|
||||||
? "None Set"
|
? "None Set"
|
||||||
: "Not Linked"}
|
: "Not Linked"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Show
|
<Show when={profile().email && !profile().emailVerified}>
|
||||||
when={userProfile().email && !userProfile().emailVerified}
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
onClick={sendEmailVerification}
|
onClick={sendEmailVerification}
|
||||||
class="text-red ml-2 text-sm underline transition-all hover:brightness-125"
|
class="text-red ml-2 text-sm underline transition-all hover:brightness-125"
|
||||||
@@ -670,12 +813,12 @@ export default function AccountPage() {
|
|||||||
required
|
required
|
||||||
disabled={emailButtonLoading()}
|
disabled={emailButtonLoading()}
|
||||||
title="Please enter a valid email address"
|
title="Please enter a valid email address"
|
||||||
label={userProfile().email ? "Update Email" : "Add Email"}
|
label={profile().email ? "Update Email" : "Add Email"}
|
||||||
/>
|
/>
|
||||||
<Show
|
<Show
|
||||||
when={
|
when={
|
||||||
userProfile().provider !== "email" &&
|
profile().provider !== "email" &&
|
||||||
!userProfile().email
|
!profile().email
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="text-subtext0 mt-1 px-4 text-xs">
|
<div class="text-subtext0 mt-1 px-4 text-xs">
|
||||||
@@ -686,8 +829,8 @@ export default function AccountPage() {
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={
|
disabled={
|
||||||
userProfile().email !== null &&
|
profile().email !== null &&
|
||||||
!userProfile().emailVerified
|
!profile().emailVerified
|
||||||
}
|
}
|
||||||
loading={emailButtonLoading()}
|
loading={emailButtonLoading()}
|
||||||
class="mt-2"
|
class="mt-2"
|
||||||
@@ -702,15 +845,34 @@ export default function AccountPage() {
|
|||||||
class="mt-2"
|
class="mt-2"
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{/* Display Name Section */}
|
function DisplayNameSection(props: {
|
||||||
|
profile: () => UserProfile;
|
||||||
|
displayNameRef: (el: HTMLInputElement) => void;
|
||||||
|
displayNameButtonLoading: () => boolean;
|
||||||
|
setDisplayNameTrigger: (e: Event) => void;
|
||||||
|
showDisplayNameSuccess: () => boolean;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
profile,
|
||||||
|
displayNameRef,
|
||||||
|
displayNameButtonLoading,
|
||||||
|
setDisplayNameTrigger,
|
||||||
|
showDisplayNameSuccess
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
<div class="flex items-center justify-center text-lg md:justify-normal">
|
<div class="flex items-center justify-center text-lg md:justify-normal">
|
||||||
<div class="flex flex-col lg:flex-row">
|
<div class="flex flex-col lg:flex-row">
|
||||||
<div class="pr-1 font-semibold whitespace-nowrap">
|
<div class="pr-1 font-semibold whitespace-nowrap">
|
||||||
Display Name:
|
Display Name:
|
||||||
</div>
|
</div>
|
||||||
{userProfile().displayName ? (
|
{profile().displayName ? (
|
||||||
<span>{userProfile().displayName}</span>
|
<span>{profile().displayName}</span>
|
||||||
) : (
|
) : (
|
||||||
<span class="font-light italic underline underline-offset-4">
|
<span class="font-light italic underline underline-offset-4">
|
||||||
None Set
|
None Set
|
||||||
@@ -731,7 +893,7 @@ export default function AccountPage() {
|
|||||||
required
|
required
|
||||||
disabled={displayNameButtonLoading()}
|
disabled={displayNameButtonLoading()}
|
||||||
title="Please enter your display name"
|
title="Please enter your display name"
|
||||||
label={`Set ${userProfile().displayName ? "New " : ""}Display Name`}
|
label={`Set ${profile().displayName ? "New " : ""}Display Name`}
|
||||||
containerClass="input-group mx-4"
|
containerClass="input-group mx-4"
|
||||||
/>
|
/>
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
@@ -750,30 +912,71 @@ export default function AccountPage() {
|
|||||||
class="mt-2"
|
class="mt-2"
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{/* Password Change/Set Section */}
|
function PasswordSection(props: {
|
||||||
|
profile: () => UserProfile;
|
||||||
|
handlePasswordSubmit: (e: Event) => void;
|
||||||
|
oldPasswordRef: (el: HTMLInputElement) => void;
|
||||||
|
newPasswordRef: (el: HTMLInputElement) => void;
|
||||||
|
newPasswordConfRef: (
|
||||||
|
el?: HTMLInputElement
|
||||||
|
) => HTMLInputElement | undefined;
|
||||||
|
handleNewPasswordChange: (e: Event) => void;
|
||||||
|
handlePasswordConfChange: (e: Event) => void;
|
||||||
|
handlePasswordBlur: () => void;
|
||||||
|
sendEmailVerification: () => void;
|
||||||
|
getProviderName: (provider: UserProfile["provider"]) => string;
|
||||||
|
passwordChangeLoading: () => boolean;
|
||||||
|
newPassword: () => string;
|
||||||
|
passwordsMatch: () => boolean;
|
||||||
|
passwordLengthSufficient: () => boolean;
|
||||||
|
passwordError: () => boolean;
|
||||||
|
showPasswordSuccess: () => boolean;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
profile,
|
||||||
|
handlePasswordSubmit,
|
||||||
|
oldPasswordRef,
|
||||||
|
newPasswordRef,
|
||||||
|
newPasswordConfRef,
|
||||||
|
handleNewPasswordChange,
|
||||||
|
handlePasswordConfChange,
|
||||||
|
handlePasswordBlur,
|
||||||
|
sendEmailVerification,
|
||||||
|
getProviderName,
|
||||||
|
passwordChangeLoading,
|
||||||
|
newPassword,
|
||||||
|
passwordsMatch,
|
||||||
|
passwordLengthSufficient,
|
||||||
|
passwordError,
|
||||||
|
showPasswordSuccess
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
<form
|
<form
|
||||||
onSubmit={handlePasswordSubmit}
|
onSubmit={handlePasswordSubmit}
|
||||||
class="mt-8 flex w-full justify-center"
|
class="mt-8 flex w-full justify-center"
|
||||||
>
|
>
|
||||||
<div class="flex w-full max-w-md flex-col justify-center">
|
<div class="flex w-full max-w-md flex-col justify-center">
|
||||||
<div class="mb-2 text-center text-xl font-semibold">
|
<div class="mb-2 text-center text-xl font-semibold">
|
||||||
{userProfile().hasPassword
|
{profile().hasPassword
|
||||||
? "Change Password"
|
? "Change Password"
|
||||||
: "Add Password"}
|
: "Add Password"}
|
||||||
</div>
|
</div>
|
||||||
<noscript>
|
<noscript>
|
||||||
<div class="text-subtext0 mb-4 text-center text-sm">
|
<div class="text-subtext0 mb-4 text-center text-sm">
|
||||||
JavaScript required to{" "}
|
JavaScript required to{" "}
|
||||||
{userProfile().hasPassword ? "change" : "add"} password
|
{profile().hasPassword ? "change" : "add"} password
|
||||||
</div>
|
</div>
|
||||||
</noscript>
|
</noscript>
|
||||||
<Show when={!userProfile().hasPassword}>
|
<Show when={!profile().hasPassword}>
|
||||||
<Show
|
<Show
|
||||||
when={
|
when={
|
||||||
userProfile().provider !== "email" &&
|
profile().provider !== "email" &&
|
||||||
(!userProfile().email || !userProfile().emailVerified)
|
(!profile().email || !profile().emailVerified)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="bg-yellow mb-4 rounded px-4 py-3 text-center text-base text-sm">
|
<div class="bg-yellow mb-4 rounded px-4 py-3 text-center text-base text-sm">
|
||||||
@@ -781,14 +984,14 @@ export default function AccountPage() {
|
|||||||
⚠️ Email Verification Required
|
⚠️ Email Verification Required
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{!userProfile().email
|
{!profile().email
|
||||||
? "Please add and verify an email address before setting a password."
|
? "Please add and verify an email address before setting a password."
|
||||||
: "Please verify your email address before setting a password."}
|
: "Please verify your email address before setting a password."}
|
||||||
</div>
|
</div>
|
||||||
<Show
|
<Show
|
||||||
when={
|
when={
|
||||||
userProfile().email &&
|
profile().email &&
|
||||||
!userProfile().emailVerified
|
!profile().emailVerified
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -802,21 +1005,21 @@ export default function AccountPage() {
|
|||||||
</Show>
|
</Show>
|
||||||
<Show
|
<Show
|
||||||
when={
|
when={
|
||||||
userProfile().provider === "email" ||
|
profile().provider === "email" ||
|
||||||
(userProfile().email && userProfile().emailVerified)
|
(profile().email && profile().emailVerified)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="text-subtext0 mb-4 text-center text-sm">
|
<div class="text-subtext0 mb-4 text-center text-sm">
|
||||||
{userProfile().provider === "email"
|
{profile().provider === "email"
|
||||||
? "Set a password to enable password login"
|
? "Set a password to enable password login"
|
||||||
: "Add a password to enable email/password login alongside your " +
|
: "Add a password to enable email/password login alongside your " +
|
||||||
getProviderName(userProfile().provider) +
|
getProviderName(profile().provider) +
|
||||||
" login"}
|
" login"}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={userProfile().hasPassword}>
|
<Show when={profile().hasPassword}>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
ref={oldPasswordRef}
|
ref={oldPasswordRef}
|
||||||
required
|
required
|
||||||
@@ -835,10 +1038,10 @@ export default function AccountPage() {
|
|||||||
onBlur={handlePasswordBlur}
|
onBlur={handlePasswordBlur}
|
||||||
disabled={
|
disabled={
|
||||||
passwordChangeLoading() ||
|
passwordChangeLoading() ||
|
||||||
(!userProfile().hasPassword &&
|
(!profile().hasPassword &&
|
||||||
userProfile().provider !== "email" &&
|
profile().provider !== "email" &&
|
||||||
(!userProfile().email ||
|
(!profile().email ||
|
||||||
!userProfile().emailVerified))
|
!profile().emailVerified))
|
||||||
}
|
}
|
||||||
title="Password must be at least 8 characters"
|
title="Password must be at least 8 characters"
|
||||||
label="New Password"
|
label="New Password"
|
||||||
@@ -852,10 +1055,10 @@ export default function AccountPage() {
|
|||||||
onInput={handlePasswordConfChange}
|
onInput={handlePasswordConfChange}
|
||||||
disabled={
|
disabled={
|
||||||
passwordChangeLoading() ||
|
passwordChangeLoading() ||
|
||||||
(!userProfile().hasPassword &&
|
(!profile().hasPassword &&
|
||||||
userProfile().provider !== "email" &&
|
profile().provider !== "email" &&
|
||||||
(!userProfile().email ||
|
(!profile().email ||
|
||||||
!userProfile().emailVerified))
|
!profile().emailVerified))
|
||||||
}
|
}
|
||||||
title="Password must be at least 8 characters"
|
title="Password must be at least 8 characters"
|
||||||
label="New Password Conf."
|
label="New Password Conf."
|
||||||
@@ -865,8 +1068,8 @@ export default function AccountPage() {
|
|||||||
when={
|
when={
|
||||||
!passwordsMatch() &&
|
!passwordsMatch() &&
|
||||||
passwordLengthSufficient() &&
|
passwordLengthSufficient() &&
|
||||||
newPasswordConfRef &&
|
newPasswordConfRef() &&
|
||||||
newPasswordConfRef.value.length >= 6
|
newPasswordConfRef()!.value.length >= 6
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<FormFeedback
|
<FormFeedback
|
||||||
@@ -880,10 +1083,10 @@ export default function AccountPage() {
|
|||||||
type="submit"
|
type="submit"
|
||||||
disabled={
|
disabled={
|
||||||
!passwordsMatch() ||
|
!passwordsMatch() ||
|
||||||
(!userProfile().hasPassword &&
|
(!profile().hasPassword &&
|
||||||
userProfile().provider !== "email" &&
|
profile().provider !== "email" &&
|
||||||
(!userProfile().email ||
|
(!profile().email ||
|
||||||
!userProfile().emailVerified))
|
!profile().emailVerified))
|
||||||
}
|
}
|
||||||
loading={passwordChangeLoading()}
|
loading={passwordChangeLoading()}
|
||||||
class="my-6"
|
class="my-6"
|
||||||
@@ -894,7 +1097,7 @@ export default function AccountPage() {
|
|||||||
<FormFeedback
|
<FormFeedback
|
||||||
type="error"
|
type="error"
|
||||||
message={
|
message={
|
||||||
userProfile().hasPassword
|
profile().hasPassword
|
||||||
? "Password did not match record"
|
? "Password did not match record"
|
||||||
: "Must have email & password provider linked or set password first"
|
: "Must have email & password provider linked or set password first"
|
||||||
}
|
}
|
||||||
@@ -903,27 +1106,36 @@ export default function AccountPage() {
|
|||||||
|
|
||||||
<FormFeedback
|
<FormFeedback
|
||||||
type="success"
|
type="success"
|
||||||
message={`Password ${userProfile().hasPassword ? "changed" : "set"} successfully!`}
|
message={`Password ${profile().hasPassword ? "changed" : "set"} successfully!`}
|
||||||
show={showPasswordSuccess()}
|
show={showPasswordSuccess()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<hr class="mt-8 mb-8" />
|
function LinkedProvidersSection(props: { profile: () => UserProfile }) {
|
||||||
|
const { profile } = props;
|
||||||
|
|
||||||
{/* Linked Providers Section */}
|
return (
|
||||||
<div class="mx-auto max-w-2xl py-8">
|
<div class="mx-auto max-w-2xl py-8">
|
||||||
<div class="mb-6 text-center text-2xl font-semibold">
|
<div class="mb-6 text-center text-2xl font-semibold">
|
||||||
Linked Authentication Methods
|
Linked Authentication Methods
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-surface0 border-surface1 rounded-lg border px-6 py-4 shadow-sm">
|
<div class="bg-surface0 border-surface1 rounded-lg border px-6 py-4 shadow-sm">
|
||||||
<LinkedProviders userId={userProfile().id} />
|
<LinkedProviders userId={profile().id} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<hr class="mt-8 mb-8" />
|
function SignOutSection(props: {
|
||||||
|
handleSignOut: () => void;
|
||||||
|
signOutLoading: () => boolean;
|
||||||
|
}) {
|
||||||
|
const { handleSignOut, signOutLoading } = props;
|
||||||
|
|
||||||
{/* Sign Out Section */}
|
return (
|
||||||
<div class="mx-auto max-w-md py-4">
|
<div class="mx-auto max-w-md py-4">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -935,10 +1147,27 @@ export default function AccountPage() {
|
|||||||
Sign Out
|
Sign Out
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<hr class="mt-8 mb-8" />
|
function DeleteAccountSection(props: {
|
||||||
|
profile: () => UserProfile;
|
||||||
|
getProviderName: (provider: UserProfile["provider"]) => string;
|
||||||
|
deleteAccountPasswordRef: (el: HTMLInputElement) => void;
|
||||||
|
deleteAccountButtonLoading: () => boolean;
|
||||||
|
deleteAccountTrigger: (e: Event) => void;
|
||||||
|
passwordDeletionError: () => boolean;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
profile,
|
||||||
|
getProviderName,
|
||||||
|
deleteAccountPasswordRef,
|
||||||
|
deleteAccountButtonLoading,
|
||||||
|
deleteAccountTrigger,
|
||||||
|
passwordDeletionError
|
||||||
|
} = props;
|
||||||
|
|
||||||
{/* Delete Account Section */}
|
return (
|
||||||
<div class="mx-auto max-w-2xl py-8">
|
<div class="mx-auto max-w-2xl py-8">
|
||||||
<div class="bg-red w-full rounded-md px-6 pt-8 pb-4 shadow-md brightness-75">
|
<div class="bg-red w-full rounded-md px-6 pt-8 pb-4 shadow-md brightness-75">
|
||||||
<div class="pb-4 text-center text-xl font-semibold">
|
<div class="pb-4 text-center text-xl font-semibold">
|
||||||
@@ -956,11 +1185,11 @@ export default function AccountPage() {
|
|||||||
</noscript>
|
</noscript>
|
||||||
|
|
||||||
<Show
|
<Show
|
||||||
when={userProfile().hasPassword}
|
when={profile().hasPassword}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="flex flex-col items-center">
|
<div class="flex flex-col items-center">
|
||||||
<div class="text-crust mb-4 text-center text-sm">
|
<div class="text-crust mb-4 text-center text-sm">
|
||||||
Your {getProviderName(userProfile().provider)}{" "}
|
Your {getProviderName(profile().provider)}{" "}
|
||||||
account doesn't have a password. To delete your
|
account doesn't have a password. To delete your
|
||||||
account, please set a password first, then return
|
account, please set a password first, then return
|
||||||
here to proceed with deletion.
|
here to proceed with deletion.
|
||||||
@@ -1007,12 +1236,6 @@ export default function AccountPage() {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ export async function GET(_event: APIEvent) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stream the XML content from S3
|
|
||||||
const body = await response.Body.transformToString();
|
const body = await response.Body.transformToString();
|
||||||
|
|
||||||
return new Response(body, {
|
return new Response(body, {
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ export async function GET(_event: APIEvent) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stream the XML content from S3
|
|
||||||
const body = await response.Body.transformToString();
|
const body = await response.Body.transformToString();
|
||||||
|
|
||||||
return new Response(body, {
|
return new Response(body, {
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export async function GET(event: APIEvent) {
|
|||||||
"emailLogin",
|
"emailLogin",
|
||||||
(caller, params) => caller.auth.emailLogin(params),
|
(caller, params) => caller.auth.emailLogin(params),
|
||||||
(error) => {
|
(error) => {
|
||||||
// Check for token expiration
|
|
||||||
const message = error instanceof Error ? error.message : "";
|
const message = error instanceof Error ? error.message : "";
|
||||||
const isTokenError =
|
const isTokenError =
|
||||||
message.includes("expired") || message.includes("invalid");
|
message.includes("expired") || message.includes("invalid");
|
||||||
|
|||||||
@@ -62,17 +62,14 @@ export async function GET(event: APIEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create tRPC caller to invoke the emailVerification procedure
|
|
||||||
const caller = await createServerCaller(event);
|
const caller = await createServerCaller(event);
|
||||||
|
|
||||||
// Call the email verification handler
|
|
||||||
const result = await caller.auth.emailVerification({
|
const result = await caller.auth.emailVerification({
|
||||||
email,
|
email,
|
||||||
token
|
token
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Show success page
|
|
||||||
return new Response(
|
return new Response(
|
||||||
`
|
`
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
@@ -136,7 +133,6 @@ export async function GET(event: APIEvent) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Email verification callback error:", error);
|
console.error("Email verification callback error:", error);
|
||||||
|
|
||||||
// Check if it's a token expiration error
|
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
error instanceof Error ? error.message : "server_error";
|
error instanceof Error ? error.message : "server_error";
|
||||||
const isTokenError =
|
const isTokenError =
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ export async function GET(event: APIEvent) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate filename format (only allow Gaze or InputHalo files)
|
|
||||||
const validPrefixes = ["Gaze", "InputHalo"];
|
const validPrefixes = ["Gaze", "InputHalo"];
|
||||||
const isValidPrefix = validPrefixes.some((prefix) => filename.startsWith(prefix));
|
const isValidPrefix = validPrefixes.some((prefix) => filename.startsWith(prefix));
|
||||||
if (
|
if (
|
||||||
@@ -70,12 +69,10 @@ export async function GET(event: APIEvent) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get content type based on file extension
|
|
||||||
const contentType = filename.endsWith(".dmg")
|
const contentType = filename.endsWith(".dmg")
|
||||||
? "application/x-apple-diskimage"
|
? "application/x-apple-diskimage"
|
||||||
: "application/octet-stream";
|
: "application/octet-stream";
|
||||||
|
|
||||||
// Stream the file content from S3
|
|
||||||
const body = await response.Body.transformToByteArray();
|
const body = await response.Body.transformToByteArray();
|
||||||
|
|
||||||
console.log(`✓ Serving ${filename} (${body.length} bytes)`);
|
console.log(`✓ Serving ${filename} (${body.length} bytes)`);
|
||||||
@@ -93,7 +90,6 @@ export async function GET(event: APIEvent) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to fetch ${filename} from S3:`, error);
|
console.error(`Failed to fetch ${filename} from S3:`, error);
|
||||||
|
|
||||||
// Check if it's a not found error
|
|
||||||
if (error instanceof Error && error.name === "NoSuchKey") {
|
if (error instanceof Error && error.name === "NoSuchKey") {
|
||||||
return new Response("File not found in storage", {
|
return new Response("File not found in storage", {
|
||||||
status: 404,
|
status: 404,
|
||||||
|
|||||||
@@ -58,8 +58,3 @@ export function bearerToken(event: APIEvent): string | null {
|
|||||||
const m = auth.match(/^Bearer\s+(.+)$/i);
|
const m = auth.match(/^Bearer\s+(.+)$/i);
|
||||||
return m?.[1]?.trim() ?? null;
|
return m?.[1]?.trim() ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse the JSON request body. */
|
|
||||||
export async function jsonBody<T = any>(event: APIEvent): Promise<T> {
|
|
||||||
return await event.request.json();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ function MainDownloadsPage() {
|
|||||||
const [gazeText, setGazeText] = createSignal("Gaze");
|
const [gazeText, setGazeText] = createSignal("Gaze");
|
||||||
const [inputHaloText, setInputHaloText] = createSignal("InputHalo");
|
const [inputHaloText, setInputHaloText] = createSignal("InputHalo");
|
||||||
|
|
||||||
// Track loading states for each download button
|
|
||||||
const [loadingState, setLoadingState] = createSignal<Record<string, boolean>>(
|
const [loadingState, setLoadingState] = createSignal<Record<string, boolean>>(
|
||||||
{
|
{
|
||||||
lineage: false,
|
lineage: false,
|
||||||
@@ -32,10 +31,8 @@ function MainDownloadsPage() {
|
|||||||
// Prevent multiple rapid clicks
|
// Prevent multiple rapid clicks
|
||||||
if (loadingState()[assetName]) return;
|
if (loadingState()[assetName]) return;
|
||||||
|
|
||||||
// Set loading state
|
|
||||||
setLoadingState((prev) => ({ ...prev, [assetName]: true }));
|
setLoadingState((prev) => ({ ...prev, [assetName]: true }));
|
||||||
|
|
||||||
// Call the tRPC endpoint directly
|
|
||||||
import("~/lib/api").then(({ api }) => {
|
import("~/lib/api").then(({ api }) => {
|
||||||
api.downloads.getDownloadUrl
|
api.downloads.getDownloadUrl
|
||||||
.query({ asset_name: assetName })
|
.query({ asset_name: assetName })
|
||||||
@@ -45,11 +42,9 @@ function MainDownloadsPage() {
|
|||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("Download error:", error);
|
console.error("Download error:", error);
|
||||||
// Optionally show user a message
|
|
||||||
alert("Failed to initiate download. Please try again.");
|
alert("Failed to initiate download. Please try again.");
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
// Reset loading state regardless of success/failure
|
|
||||||
setLoadingState((prev) => ({ ...prev, [assetName]: false }));
|
setLoadingState((prev) => ({ ...prev, [assetName]: false }));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ export const route = {
|
|||||||
load: () => checkAuth()
|
load: () => checkAuth()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper to convert expiry string to human-readable format
|
|
||||||
function expiryToHuman(expiry: string): string {
|
function expiryToHuman(expiry: string): string {
|
||||||
const value = parseInt(expiry);
|
const value = parseInt(expiry);
|
||||||
if (expiry.endsWith("m")) {
|
if (expiry.endsWith("m")) {
|
||||||
@@ -77,7 +76,6 @@ export default function LoginPage() {
|
|||||||
const register = () => searchParams.mode === "register";
|
const register = () => searchParams.mode === "register";
|
||||||
const usePassword = () => searchParams.auth === "password";
|
const usePassword = () => searchParams.auth === "password";
|
||||||
|
|
||||||
// Load server data using createAsync
|
|
||||||
const loginData = createAsync(() => getLoginData(), {
|
const loginData = createAsync(() => getLoginData(), {
|
||||||
deferStream: true
|
deferStream: true
|
||||||
});
|
});
|
||||||
@@ -149,18 +147,12 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const formHandler = async (e: Event) => {
|
const isRateLimited = (errorCode: string | undefined, message: string) =>
|
||||||
e.preventDefault();
|
errorCode === "TOO_MANY_REQUESTS" || message.includes("Too many attempts");
|
||||||
setLoading(true);
|
|
||||||
setError("");
|
|
||||||
setShowPasswordError(false);
|
|
||||||
setShowPasswordSuccess(false);
|
|
||||||
|
|
||||||
try {
|
const submitRegister = async () => {
|
||||||
if (register()) {
|
|
||||||
if (!emailRef || !passwordRef || !passwordConfRef) {
|
if (!emailRef || !passwordRef || !passwordConfRef) {
|
||||||
setError("Please fill in all fields");
|
setError("Please fill in all fields");
|
||||||
setLoading(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,20 +162,17 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
if (!isValidEmail(email)) {
|
if (!isValidEmail(email)) {
|
||||||
setError("Invalid email address");
|
setError("Invalid email address");
|
||||||
setLoading(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordValidation = validatePassword(password);
|
const passwordValidation = validatePassword(password);
|
||||||
if (!passwordValidation.isValid) {
|
if (!passwordValidation.isValid) {
|
||||||
setError(passwordValidation.errors[0] || "Invalid password");
|
setError(passwordValidation.errors[0] || "Invalid password");
|
||||||
setLoading(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password !== passwordConf) {
|
if (password !== passwordConf) {
|
||||||
setError("passwordMismatch");
|
setError("passwordMismatch");
|
||||||
setLoading(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,17 +190,16 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
if (response.ok && result.result?.data) {
|
if (response.ok && result.result?.data) {
|
||||||
navigate("/account", { replace: true });
|
navigate("/account", { replace: true });
|
||||||
} else {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const errorMsg =
|
const errorMsg =
|
||||||
result.error?.message ||
|
result.error?.message ||
|
||||||
result.result?.data?.message ||
|
result.result?.data?.message ||
|
||||||
"Registration failed";
|
"Registration failed";
|
||||||
const errorCode = result.error?.data?.code;
|
const errorCode = result.error?.data?.code;
|
||||||
|
|
||||||
if (
|
if (isRateLimited(errorCode, errorMsg)) {
|
||||||
errorCode === "TOO_MANY_REQUESTS" ||
|
|
||||||
errorMsg.includes("Too many attempts")
|
|
||||||
) {
|
|
||||||
setError(errorMsg);
|
setError(errorMsg);
|
||||||
} else if (
|
} else if (
|
||||||
errorMsg.includes("duplicate") ||
|
errorMsg.includes("duplicate") ||
|
||||||
@@ -225,11 +213,11 @@ export default function LoginPage() {
|
|||||||
} else {
|
} else {
|
||||||
setError(errorMsg);
|
setError(errorMsg);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
} else if (usePassword()) {
|
|
||||||
|
const submitPasswordLogin = async () => {
|
||||||
if (!emailRef || !passwordRef || !rememberMeRef) {
|
if (!emailRef || !passwordRef || !rememberMeRef) {
|
||||||
setError("Please fill in all fields");
|
setError("Please fill in all fields");
|
||||||
setLoading(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,14 +239,13 @@ export default function LoginPage() {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
navigate("/account", { replace: true });
|
navigate("/account", { replace: true });
|
||||||
}, 500);
|
}, 500);
|
||||||
} else {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const errorMessage = result.error?.message || "";
|
const errorMessage = result.error?.message || "";
|
||||||
const errorCode = result.error?.data?.code;
|
const errorCode = result.error?.data?.code;
|
||||||
|
|
||||||
if (
|
if (isRateLimited(errorCode, errorMessage)) {
|
||||||
errorCode === "TOO_MANY_REQUESTS" ||
|
|
||||||
errorMessage.includes("Too many attempts")
|
|
||||||
) {
|
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
} else if (
|
} else if (
|
||||||
errorCode === "FORBIDDEN" ||
|
errorCode === "FORBIDDEN" ||
|
||||||
@@ -269,11 +256,11 @@ export default function LoginPage() {
|
|||||||
} else {
|
} else {
|
||||||
setShowPasswordError(true);
|
setShowPasswordError(true);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
} else {
|
|
||||||
|
const submitEmailLink = async () => {
|
||||||
if (!emailRef || !rememberMeRef) {
|
if (!emailRef || !rememberMeRef) {
|
||||||
setError("Please enter your email");
|
setError("Please enter your email");
|
||||||
setLoading(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,7 +269,6 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
if (!isValidEmail(email)) {
|
if (!isValidEmail(email)) {
|
||||||
setError("Invalid email address");
|
setError("Invalid email address");
|
||||||
setLoading(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,7 +288,9 @@ export default function LoginPage() {
|
|||||||
Date.now() + COOLDOWN_TIMERS.EMAIL_LOGIN_LINK_MS
|
Date.now() + COOLDOWN_TIMERS.EMAIL_LOGIN_LINK_MS
|
||||||
);
|
);
|
||||||
startCountdown(expirationTime);
|
startCountdown(expirationTime);
|
||||||
} else {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const errorMsg =
|
const errorMsg =
|
||||||
result.error?.message ||
|
result.error?.message ||
|
||||||
result.result?.data?.message ||
|
result.result?.data?.message ||
|
||||||
@@ -310,9 +298,8 @@ export default function LoginPage() {
|
|||||||
const errorCode = result.error?.data?.code;
|
const errorCode = result.error?.data?.code;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
errorCode === "TOO_MANY_REQUESTS" ||
|
isRateLimited(errorCode, errorMsg) ||
|
||||||
errorMsg.includes("countdown not expired") ||
|
errorMsg.includes("countdown not expired")
|
||||||
errorMsg.includes("Too many attempts")
|
|
||||||
) {
|
) {
|
||||||
setError(
|
setError(
|
||||||
errorMsg.includes("countdown")
|
errorMsg.includes("countdown")
|
||||||
@@ -332,7 +319,22 @@ export default function LoginPage() {
|
|||||||
} else {
|
} else {
|
||||||
setError(errorMsg);
|
setError(errorMsg);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const formHandler = async (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
setShowPasswordError(false);
|
||||||
|
setShowPasswordSuccess(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (register()) {
|
||||||
|
await submitRegister();
|
||||||
|
} else if (usePassword()) {
|
||||||
|
await submitPasswordLogin();
|
||||||
|
} else {
|
||||||
|
await submitEmailLink();
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Login error:", err);
|
console.error("Login error:", err);
|
||||||
|
|||||||
@@ -863,7 +863,6 @@ export default function TestPage() {
|
|||||||
setErrors({ ...errors(), [key]: "" });
|
setErrors({ ...errors(), [key]: "" });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get input - either from edited JSON or sample
|
|
||||||
let input = endpoint.sampleInput;
|
let input = endpoint.sampleInput;
|
||||||
const editedInput = inputEdits()[key];
|
const editedInput = inputEdits()[key];
|
||||||
if (editedInput) {
|
if (editedInput) {
|
||||||
|
|||||||
@@ -117,14 +117,11 @@ function scheduleAnalyticsFlush(): void {
|
|||||||
*/
|
*/
|
||||||
export async function logVisit(entry: AnalyticsEntry): Promise<void> {
|
export async function logVisit(entry: AnalyticsEntry): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Add to buffer
|
|
||||||
analyticsBuffer.entries.push(entry);
|
analyticsBuffer.entries.push(entry);
|
||||||
|
|
||||||
// Flush if batch size reached
|
|
||||||
if (analyticsBuffer.entries.length >= CACHE_CONFIG.ANALYTICS_BATCH_SIZE) {
|
if (analyticsBuffer.entries.length >= CACHE_CONFIG.ANALYTICS_BATCH_SIZE) {
|
||||||
await flushAnalyticsBuffer();
|
await flushAnalyticsBuffer();
|
||||||
} else {
|
} else {
|
||||||
// Schedule periodic flush
|
|
||||||
scheduleAnalyticsFlush();
|
scheduleAnalyticsFlush();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -422,7 +419,6 @@ export async function getPerformanceStats(days: number = 30): Promise<{
|
|||||||
}> {
|
}> {
|
||||||
const conn = ConnectionFactory();
|
const conn = ConnectionFactory();
|
||||||
|
|
||||||
// Get average metrics
|
|
||||||
const avgResult = await conn.execute({
|
const avgResult = await conn.execute({
|
||||||
sql: `SELECT
|
sql: `SELECT
|
||||||
AVG(lcp) as avgLcp,
|
AVG(lcp) as avgLcp,
|
||||||
@@ -472,7 +468,6 @@ export async function getPerformanceStats(days: number = 30): Promise<{
|
|||||||
args: []
|
args: []
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get performance by path (only for non-API paths)
|
|
||||||
const byPathResult = await conn.execute({
|
const byPathResult = await conn.execute({
|
||||||
sql: `SELECT
|
sql: `SELECT
|
||||||
path,
|
path,
|
||||||
|
|||||||
@@ -80,11 +80,9 @@ import {
|
|||||||
* In development: ctx.event might be H3Event directly
|
* In development: ctx.event might be H3Event directly
|
||||||
*/
|
*/
|
||||||
function getH3Event(ctx: Context): H3Event {
|
function getH3Event(ctx: Context): H3Event {
|
||||||
// Check if nativeEvent exists (production)
|
|
||||||
if (ctx.event && "nativeEvent" in ctx.event && ctx.event.nativeEvent) {
|
if (ctx.event && "nativeEvent" in ctx.event && ctx.event.nativeEvent) {
|
||||||
return ctx.event.nativeEvent as H3Event;
|
return ctx.event.nativeEvent as H3Event;
|
||||||
}
|
}
|
||||||
// Otherwise, assume ctx.event is H3Event (development)
|
|
||||||
return ctx.event as unknown as H3Event;
|
return ctx.event as unknown as H3Event;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,7 +243,6 @@ export const authRouter = createTRPCRouter({
|
|||||||
try {
|
try {
|
||||||
await conn.execute({ sql: insertQuery, args: insertParams });
|
await conn.execute({ sql: insertQuery, args: insertParams });
|
||||||
|
|
||||||
// Also create UserProvider entry for new user
|
|
||||||
await linkProvider(userId, "github", {
|
await linkProvider(userId, "github", {
|
||||||
providerUserId: login,
|
providerUserId: login,
|
||||||
email: email,
|
email: email,
|
||||||
@@ -300,7 +297,6 @@ export const authRouter = createTRPCRouter({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[GitHub Callback] Error during OAuth flow:", error);
|
console.error("[GitHub Callback] Error during OAuth flow:", error);
|
||||||
|
|
||||||
// Log failed OAuth login
|
|
||||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
eventType: "auth.login.failed",
|
eventType: "auth.login.failed",
|
||||||
@@ -452,7 +448,6 @@ export const authRouter = createTRPCRouter({
|
|||||||
args: insertParams
|
args: insertParams
|
||||||
});
|
});
|
||||||
|
|
||||||
// Also create UserProvider entry for new user
|
|
||||||
await linkProvider(userId, "google", {
|
await linkProvider(userId, "google", {
|
||||||
providerUserId: email,
|
providerUserId: email,
|
||||||
email: email,
|
email: email,
|
||||||
@@ -481,7 +476,6 @@ export const authRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Issue JWT (OAuth defaults to remember me)
|
|
||||||
const event = getH3Event(ctx);
|
const event = getH3Event(ctx);
|
||||||
const clientIP = getClientIP(event);
|
const clientIP = getClientIP(event);
|
||||||
const userAgent = getUserAgent(event);
|
const userAgent = getUserAgent(event);
|
||||||
@@ -631,7 +625,6 @@ export const authRouter = createTRPCRouter({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Email Login] Error during login:", error);
|
console.error("[Email Login] Error during login:", error);
|
||||||
|
|
||||||
// Log failed email link login
|
|
||||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
eventType: "auth.login.failed",
|
eventType: "auth.login.failed",
|
||||||
@@ -685,10 +678,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if there's a valid JWT token with this code
|
// Tokens aren't stored in DB; verify the code against the JWT cookie set when the email was sent
|
||||||
// We need to find the token that was generated for this email
|
|
||||||
// Since we can't store tokens in DB efficiently, we'll verify against the cookie
|
|
||||||
// Get the token from cookie (we'll store it when sending email)
|
|
||||||
const storedToken = getCookie(getH3Event(ctx), "emailLoginToken");
|
const storedToken = getCookie(getH3Event(ctx), "emailLoginToken");
|
||||||
if (!storedToken) {
|
if (!storedToken) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -697,7 +687,6 @@ export const authRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the JWT and check the code
|
|
||||||
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
||||||
let payload;
|
let payload;
|
||||||
try {
|
try {
|
||||||
@@ -756,7 +745,6 @@ export const authRouter = createTRPCRouter({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Email Code Login] Error during login:", error);
|
console.error("[Email Code Login] Error during login:", error);
|
||||||
|
|
||||||
// Log failed code login
|
|
||||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
eventType: "auth.login.failed",
|
eventType: "auth.login.failed",
|
||||||
@@ -808,7 +796,6 @@ export const authRouter = createTRPCRouter({
|
|||||||
|
|
||||||
const conn = ConnectionFactory();
|
const conn = ConnectionFactory();
|
||||||
|
|
||||||
// Get user ID for audit log
|
|
||||||
const userRes = await conn.execute({
|
const userRes = await conn.execute({
|
||||||
sql: "SELECT id FROM User WHERE email = ?",
|
sql: "SELECT id FROM User WHERE email = ?",
|
||||||
args: [email]
|
args: [email]
|
||||||
@@ -819,7 +806,6 @@ export const authRouter = createTRPCRouter({
|
|||||||
const params = [true, email];
|
const params = [true, email];
|
||||||
await conn.execute({ sql: query, args: params });
|
await conn.execute({ sql: query, args: params });
|
||||||
|
|
||||||
// Log successful email verification
|
|
||||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
userId,
|
userId,
|
||||||
@@ -835,7 +821,6 @@ export const authRouter = createTRPCRouter({
|
|||||||
message: "Email verification success, you may close this window"
|
message: "Email verification success, you may close this window"
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Log failed email verification
|
|
||||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
eventType: "auth.email.verify.complete",
|
eventType: "auth.email.verify.complete",
|
||||||
@@ -864,7 +849,6 @@ export const authRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const { email, password, passwordConfirmation, rememberMe } = input;
|
const { email, password, passwordConfirmation, rememberMe } = input;
|
||||||
|
|
||||||
// Apply rate limiting
|
|
||||||
const clientIP = getClientIP(getH3Event(ctx));
|
const clientIP = getClientIP(getH3Event(ctx));
|
||||||
await rateLimitRegistration(clientIP, getH3Event(ctx));
|
await rateLimitRegistration(clientIP, getH3Event(ctx));
|
||||||
|
|
||||||
@@ -876,10 +860,8 @@ export const authRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if email already exists (User table or UserProvider table)
|
|
||||||
const existingUserId = await findUserByEmail(email);
|
const existingUserId = await findUserByEmail(email);
|
||||||
if (existingUserId) {
|
if (existingUserId) {
|
||||||
// User exists - check if they have a password
|
|
||||||
const conn = ConnectionFactory();
|
const conn = ConnectionFactory();
|
||||||
const userCheck = await conn.execute({
|
const userCheck = await conn.execute({
|
||||||
sql: "SELECT password_hash, provider FROM User WHERE id = ?",
|
sql: "SELECT password_hash, provider FROM User WHERE id = ?",
|
||||||
@@ -916,13 +898,11 @@ export const authRouter = createTRPCRouter({
|
|||||||
args: [userId, email, passwordHash, "email"]
|
args: [userId, email, passwordHash, "email"]
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create UserProvider entry for email auth
|
|
||||||
await linkProvider(userId, "email", {
|
await linkProvider(userId, "email", {
|
||||||
providerUserId: email,
|
providerUserId: email,
|
||||||
email: email
|
email: email
|
||||||
});
|
});
|
||||||
|
|
||||||
// Issue auth token with client info
|
|
||||||
const event = getH3Event(ctx);
|
const event = getH3Event(ctx);
|
||||||
const clientIP = getClientIP(event);
|
const clientIP = getClientIP(event);
|
||||||
const userAgent = getUserAgent(event);
|
const userAgent = getUserAgent(event);
|
||||||
@@ -930,13 +910,11 @@ export const authRouter = createTRPCRouter({
|
|||||||
await issueAuthToken({
|
await issueAuthToken({
|
||||||
event,
|
event,
|
||||||
userId,
|
userId,
|
||||||
rememberMe: rememberMe ?? true
|
rememberMe,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set CSRF token
|
|
||||||
setCSRFToken(event);
|
setCSRFToken(event);
|
||||||
|
|
||||||
// Log successful registration
|
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
userId,
|
userId,
|
||||||
eventType: "auth.register.success",
|
eventType: "auth.register.success",
|
||||||
@@ -948,7 +926,6 @@ export const authRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return { success: true, message: "success" };
|
return { success: true, message: "success" };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Log failed registration
|
|
||||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
eventType: "auth.register.failed",
|
eventType: "auth.register.failed",
|
||||||
@@ -976,7 +953,6 @@ export const authRouter = createTRPCRouter({
|
|||||||
try {
|
try {
|
||||||
const { email, password, rememberMe } = input;
|
const { email, password, rememberMe } = input;
|
||||||
|
|
||||||
// Apply rate limiting
|
|
||||||
const clientIP = getClientIP(getH3Event(ctx));
|
const clientIP = getClientIP(getH3Event(ctx));
|
||||||
await rateLimitLogin(email, clientIP, getH3Event(ctx));
|
await rateLimitLogin(email, clientIP, getH3Event(ctx));
|
||||||
|
|
||||||
@@ -992,9 +968,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
const passwordHash = user?.password_hash || null;
|
const passwordHash = user?.password_hash || null;
|
||||||
const passwordMatch = await checkPasswordSafe(password, passwordHash);
|
const passwordMatch = await checkPasswordSafe(password, passwordHash);
|
||||||
|
|
||||||
// Check all conditions after password verification
|
|
||||||
if (!user || !passwordHash || !passwordMatch) {
|
if (!user || !passwordHash || !passwordMatch) {
|
||||||
// Record failed login attempt if user exists
|
|
||||||
if (user?.id) {
|
if (user?.id) {
|
||||||
const lockoutStatus = await recordFailedLogin(user.id);
|
const lockoutStatus = await recordFailedLogin(user.id);
|
||||||
|
|
||||||
@@ -1032,7 +1006,6 @@ export const authRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log failed login attempt
|
|
||||||
try {
|
try {
|
||||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
@@ -1060,7 +1033,6 @@ export const authRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if account is locked before allowing login
|
|
||||||
const lockoutCheck = await checkAccountLockout(user.id);
|
const lockoutCheck = await checkAccountLockout(user.id);
|
||||||
if (lockoutCheck.isLocked) {
|
if (lockoutCheck.isLocked) {
|
||||||
const remainingSec = Math.ceil(
|
const remainingSec = Math.ceil(
|
||||||
@@ -1083,22 +1055,18 @@ export const authRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset failed attempts on successful login
|
|
||||||
await resetFailedAttempts(user.id);
|
await resetFailedAttempts(user.id);
|
||||||
|
|
||||||
// Reset rate limits on successful login
|
|
||||||
await resetLoginRateLimits(email, clientIP);
|
await resetLoginRateLimits(email, clientIP);
|
||||||
|
|
||||||
// Issue JWT for authenticated user
|
|
||||||
const event = getH3Event(ctx);
|
const event = getH3Event(ctx);
|
||||||
const userAgent = getUserAgent(event);
|
const userAgent = getUserAgent(event);
|
||||||
await issueAuthToken({
|
await issueAuthToken({
|
||||||
event,
|
event,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
rememberMe: rememberMe ?? false
|
rememberMe,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set CSRF token for authenticated user
|
|
||||||
setCSRFToken(event);
|
setCSRFToken(event);
|
||||||
|
|
||||||
// Log successful login (wrap in try-catch to ensure it never blocks auth flow)
|
// Log successful login (wrap in try-catch to ensure it never blocks auth flow)
|
||||||
@@ -1106,7 +1074,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
eventType: "auth.login.success",
|
eventType: "auth.login.success",
|
||||||
eventData: { method: "password", rememberMe: rememberMe ?? false },
|
eventData: { method: "password", rememberMe },
|
||||||
ipAddress: clientIP,
|
ipAddress: clientIP,
|
||||||
userAgent,
|
userAgent,
|
||||||
success: true
|
success: true
|
||||||
@@ -1251,7 +1219,6 @@ export const authRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const { email } = input;
|
const { email } = input;
|
||||||
|
|
||||||
// Apply rate limiting
|
|
||||||
const clientIP = getClientIP(getH3Event(ctx));
|
const clientIP = getClientIP(getH3Event(ctx));
|
||||||
await rateLimitPasswordReset(clientIP, getH3Event(ctx));
|
await rateLimitPasswordReset(clientIP, getH3Event(ctx));
|
||||||
|
|
||||||
@@ -1303,7 +1270,6 @@ export const authRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Log password reset request
|
|
||||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -1316,7 +1282,6 @@ export const authRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return { success: true, message: "email sent" };
|
return { success: true, message: "email sent" };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Log failed password reset request (only if not rate limited)
|
|
||||||
if (
|
if (
|
||||||
!(error instanceof TRPCError && error.code === "TOO_MANY_REQUESTS")
|
!(error instanceof TRPCError && error.code === "TOO_MANY_REQUESTS")
|
||||||
) {
|
) {
|
||||||
@@ -1371,7 +1336,6 @@ export const authRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Validate and consume the password reset token
|
|
||||||
const tokenValidation = await validatePasswordResetToken(token);
|
const tokenValidation = await validatePasswordResetToken(token);
|
||||||
|
|
||||||
if (!tokenValidation) {
|
if (!tokenValidation) {
|
||||||
@@ -1415,10 +1379,8 @@ export const authRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark token as used
|
|
||||||
await markPasswordResetTokenUsed(tokenId);
|
await markPasswordResetTokenUsed(tokenId);
|
||||||
|
|
||||||
// Log successful password reset
|
|
||||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
userId: userId,
|
userId: userId,
|
||||||
@@ -1431,7 +1393,6 @@ export const authRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return { success: true, message: "success" };
|
return { success: true, message: "success" };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Log failed password reset
|
|
||||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
eventType: "auth.password.reset.complete",
|
eventType: "auth.password.reset.complete",
|
||||||
@@ -1459,7 +1420,6 @@ export const authRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const { email } = input;
|
const { email } = input;
|
||||||
|
|
||||||
// Apply rate limiting
|
|
||||||
const clientIP = getClientIP(getH3Event(ctx));
|
const clientIP = getClientIP(getH3Event(ctx));
|
||||||
await rateLimitEmailVerification(clientIP, getH3Event(ctx));
|
await rateLimitEmailVerification(clientIP, getH3Event(ctx));
|
||||||
|
|
||||||
@@ -1520,7 +1480,6 @@ export const authRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Log email verification request
|
|
||||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -1533,7 +1492,6 @@ export const authRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return { success: true, message: "Verification email sent" };
|
return { success: true, message: "Verification email sent" };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Log failed email verification request (only if not rate limited)
|
|
||||||
if (
|
if (
|
||||||
!(error instanceof TRPCError && error.code === "TOO_MANY_REQUESTS")
|
!(error instanceof TRPCError && error.code === "TOO_MANY_REQUESTS")
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -462,58 +462,50 @@ export const databaseRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let query = "UPDATE Post SET ";
|
let sets: string[] = [];
|
||||||
let params: any[] = [];
|
let params: any[] = [];
|
||||||
let first = true;
|
|
||||||
|
|
||||||
if (input.title !== undefined && input.title !== null) {
|
if (input.title !== undefined && input.title !== null) {
|
||||||
query += first ? "title = ?" : ", title = ?";
|
sets.push("title = ?");
|
||||||
params.push(input.title);
|
params.push(input.title);
|
||||||
first = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.subtitle !== undefined && input.subtitle !== null) {
|
if (input.subtitle !== undefined && input.subtitle !== null) {
|
||||||
query += first ? "subtitle = ?" : ", subtitle = ?";
|
sets.push("subtitle = ?");
|
||||||
params.push(input.subtitle);
|
params.push(input.subtitle);
|
||||||
first = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.body !== undefined && input.body !== null) {
|
if (input.body !== undefined && input.body !== null) {
|
||||||
query += first ? "body = ?" : ", body = ?";
|
sets.push("body = ?");
|
||||||
params.push(input.body);
|
params.push(input.body);
|
||||||
first = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.banner_photo !== undefined && input.banner_photo !== null) {
|
if (input.banner_photo !== undefined && input.banner_photo !== null) {
|
||||||
query += first ? "banner_photo = ?" : ", banner_photo = ?";
|
sets.push("banner_photo = ?");
|
||||||
if (input.banner_photo === "_DELETE_IMAGE_") {
|
if (input.banner_photo === "_DELETE_IMAGE_") {
|
||||||
params.push(null);
|
params.push(null);
|
||||||
} else {
|
} else {
|
||||||
params.push(env.VITE_AWS_BUCKET_STRING + input.banner_photo);
|
params.push(env.VITE_AWS_BUCKET_STRING + input.banner_photo);
|
||||||
}
|
}
|
||||||
first = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.published !== undefined && input.published !== null) {
|
if (input.published !== undefined && input.published !== null) {
|
||||||
query += first ? "published = ?" : ", published = ?";
|
sets.push("published = ?");
|
||||||
params.push(input.published);
|
params.push(input.published);
|
||||||
first = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldSetPublishDate) {
|
if (shouldSetPublishDate) {
|
||||||
query += first ? "date = ?" : ", date = ?";
|
sets.push("date = ?");
|
||||||
params.push(new Date().toISOString());
|
params.push(new Date().toISOString());
|
||||||
first = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
query += first ? "last_edited_date = ?" : ", last_edited_date = ?";
|
sets.push("last_edited_date = ?");
|
||||||
params.push(new Date().toISOString());
|
params.push(new Date().toISOString());
|
||||||
first = false;
|
|
||||||
|
|
||||||
query += first ? "author_id = ?" : ", author_id = ?";
|
sets.push("author_id = ?");
|
||||||
params.push(input.author_id);
|
params.push(input.author_id);
|
||||||
|
|
||||||
query += " WHERE id = ?";
|
let query = "UPDATE Post SET " + sets.join(", ") + " WHERE id = ?";
|
||||||
params.push(input.id);
|
params.push(input.id);
|
||||||
|
|
||||||
const results = await conn.execute({ sql: query, args: params });
|
const results = await conn.execute({ sql: query, args: params });
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ async function getLatestDMG(
|
|||||||
throw new Error(`No DMG files found in S3 with prefix ${prefix}`);
|
throw new Error(`No DMG files found in S3 with prefix ${prefix}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter for .dmg files only and sort by LastModified (newest first)
|
|
||||||
const dmgFiles = response.Contents.filter((obj) =>
|
const dmgFiles = response.Contents.filter((obj) =>
|
||||||
obj.Key?.endsWith(".dmg")
|
obj.Key?.endsWith(".dmg")
|
||||||
).sort((a, b) => {
|
).sort((a, b) => {
|
||||||
@@ -103,7 +102,6 @@ export const downloadsRouter = createTRPCRouter({
|
|||||||
} else if (input.asset_name === "inputhalo") {
|
} else if (input.asset_name === "inputhalo") {
|
||||||
fileKey = await getLatestInputHaloDMG(client, bucket);
|
fileKey = await getLatestInputHaloDMG(client, bucket);
|
||||||
} else {
|
} else {
|
||||||
// Use static mapping for other assets
|
|
||||||
fileKey = assets[input.asset_name];
|
fileKey = assets[input.asset_name];
|
||||||
|
|
||||||
if (!fileKey) {
|
if (!fileKey) {
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ export const gitActivityRouter = createTRPCRouter({
|
|||||||
`github-commits-${input.limit}`,
|
`github-commits-${input.limit}`,
|
||||||
CACHE_CONFIG.GIT_ACTIVITY_CACHE_TTL_MS,
|
CACHE_CONFIG.GIT_ACTIVITY_CACHE_TTL_MS,
|
||||||
async () => {
|
async () => {
|
||||||
// Use Events API to get recent push events
|
|
||||||
const eventsResponse = await fetchWithTimeout(
|
const eventsResponse = await fetchWithTimeout(
|
||||||
`https://api.github.com/users/MikeFreno/events/public?per_page=100`,
|
`https://api.github.com/users/MikeFreno/events/public?per_page=100`,
|
||||||
{
|
{
|
||||||
@@ -48,7 +47,6 @@ export const gitActivityRouter = createTRPCRouter({
|
|||||||
await checkResponse(eventsResponse);
|
await checkResponse(eventsResponse);
|
||||||
const events = await eventsResponse.json();
|
const events = await eventsResponse.json();
|
||||||
|
|
||||||
// Collect (repo, sha) pairs from push events up front
|
|
||||||
const toFetch: { repoName: string; sha: string }[] = [];
|
const toFetch: { repoName: string; sha: string }[] = [];
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
if (event.type !== "PushEvent") continue;
|
if (event.type !== "PushEvent") continue;
|
||||||
|
|||||||
@@ -72,13 +72,7 @@ export function assertS3KeyOwnership(key: string, userId: string | null): void {
|
|||||||
// Account-deletion request email — product-aware
|
// Account-deletion request email — product-aware
|
||||||
// ============================================================
|
// ============================================================
|
||||||
//
|
//
|
||||||
// Pure helpers live in `./deletion-email.ts` (env-free) so they can be unit-
|
// Bare "export … from" doesn't bind names locally — import first or the router throws ReferenceError (503s every /api/trpc call)
|
||||||
// tested in `bun:test` without a populated `.env`. Re-exported here for the
|
|
||||||
// tRPC mutation below + for callers that already import from `misc`.
|
|
||||||
// Import into local scope FIRST — `sendDeletionRequestEmail` below uses
|
|
||||||
// these names directly. A bare `export { ... } from` re-export does NOT make
|
|
||||||
// the bindings available locally, which caused a ReferenceError that crashed
|
|
||||||
// the entire tRPC router (503 on every /api/trpc call).
|
|
||||||
import {
|
import {
|
||||||
DELETION_PRODUCT_SCHEMA,
|
DELETION_PRODUCT_SCHEMA,
|
||||||
deletionCookieName,
|
deletionCookieName,
|
||||||
@@ -256,7 +250,6 @@ export const miscRouter = createTRPCRouter({
|
|||||||
lastModified: item.LastModified?.toISOString() || ""
|
lastModified: item.LastModified?.toISOString() || ""
|
||||||
})) || [];
|
})) || [];
|
||||||
|
|
||||||
// Filter out thumbnail files (ending with -small.ext)
|
|
||||||
const mainFiles = files.filter(
|
const mainFiles = files.filter(
|
||||||
(file) => !file.key.match(/-small\.(jpg|jpeg|png|gif)$/i)
|
(file) => !file.key.match(/-small\.(jpg|jpeg|png|gif)$/i)
|
||||||
);
|
);
|
||||||
@@ -376,7 +369,6 @@ export const miscRouter = createTRPCRouter({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
// Verify Cloudflare Turnstile token
|
|
||||||
const turnstileValid = await verifyTurnstileToken(
|
const turnstileValid = await verifyTurnstileToken(
|
||||||
input.turnstileToken,
|
input.turnstileToken,
|
||||||
env.TURNSTILE_SECRET_KEY,
|
env.TURNSTILE_SECRET_KEY,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
import type { Client } from "@libsql/client/web";
|
import type { Client } from "@libsql/client/web";
|
||||||
|
|
||||||
// Prevent the env/server.ts client-side guard from throwing during tests
|
// Prevent the env/server.ts client-side guard from throwing during tests
|
||||||
@@ -288,7 +289,6 @@ describe("static audit: every targeted mutation handler uses ctx", () => {
|
|||||||
const source = await Bun.file(import.meta.dir + "/nessa.ts").text();
|
const source = await Bun.file(import.meta.dir + "/nessa.ts").text();
|
||||||
|
|
||||||
for (const name of MUTATIONS) {
|
for (const name of MUTATIONS) {
|
||||||
// Match: name: nessaProcedure ... .mutation(async ({ input }) — but NOT ({ input, ctx
|
|
||||||
const re = new RegExp(
|
const re = new RegExp(
|
||||||
`${name}:\\s*nessaProcedure[^}]*\\.mutation\\(async \\({\\s*input\\s*}\\)`,
|
`${name}:\\s*nessaProcedure[^}]*\\.mutation\\(async \\({\\s*input\\s*}\\)`,
|
||||||
"s"
|
"s"
|
||||||
@@ -305,7 +305,6 @@ describe("static audit: every targeted mutation handler uses ctx", () => {
|
|||||||
const source = await Bun.file(import.meta.dir + "/nessa.ts").text();
|
const source = await Bun.file(import.meta.dir + "/nessa.ts").text();
|
||||||
|
|
||||||
for (const name of MUTATIONS) {
|
for (const name of MUTATIONS) {
|
||||||
// Find the block for this mutation and check it references ctx
|
|
||||||
const re = new RegExp(
|
const re = new RegExp(
|
||||||
`${name}:\\s*nessaProcedure[\\s\\S]*?\\.mutation\\([\\s\\S]*?\\n \\}\\),`,
|
`${name}:\\s*nessaProcedure[\\s\\S]*?\\.mutation\\([\\s\\S]*?\\n \\}\\),`,
|
||||||
"s"
|
"s"
|
||||||
@@ -318,12 +317,55 @@ describe("static audit: every targeted mutation handler uses ctx", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("bulkUpsert filters exerciseLibrary by userId", async () => {
|
it("upsertExerciseLibrary rejects an exercise owned by another user", async () => {
|
||||||
const source = await Bun.file(import.meta.dir + "/nessa.ts").text();
|
const mod = await import("./nessa");
|
||||||
const bulkSection = source.match(
|
const conn = makeMockConn([]);
|
||||||
/if \(input\.exerciseLibrary\?\.length\) \{[\s\S]*?\n {8}\}/
|
const exercise = {
|
||||||
);
|
id: EXERCISE_ID,
|
||||||
expect(bulkSection).toBeTruthy();
|
userId: USER_B,
|
||||||
expect(bulkSection![0]).toContain("userId !== ctx.nessaUserId");
|
name: "Squat",
|
||||||
|
category: "Strength"
|
||||||
|
};
|
||||||
|
let caught: unknown;
|
||||||
|
try {
|
||||||
|
await mod.upsertExerciseLibrary(conn, USER_A, [exercise]);
|
||||||
|
} catch (err) {
|
||||||
|
caught = err;
|
||||||
|
}
|
||||||
|
expect(caught).toBeInstanceOf(TRPCError);
|
||||||
|
expect((caught as TRPCError).code).toBe("FORBIDDEN");
|
||||||
|
expect((caught as TRPCError).message).toBe("User mismatch");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upsertExerciseLibrary upserts an exercise owned by the caller", async () => {
|
||||||
|
const mod = await import("./nessa");
|
||||||
|
const conn = makeMockConn([{ userId: USER_A }]);
|
||||||
|
const exercise = {
|
||||||
|
id: EXERCISE_ID,
|
||||||
|
userId: USER_A,
|
||||||
|
name: "Squat",
|
||||||
|
category: "Strength"
|
||||||
|
};
|
||||||
|
await expect(
|
||||||
|
mod.upsertExerciseLibrary(conn, USER_A, [exercise])
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
expect(conn.execute).toHaveBeenCalledWith({
|
||||||
|
sql: expect.stringContaining(
|
||||||
|
"INSERT INTO exerciseLibrary (id, userId, name, category"
|
||||||
|
),
|
||||||
|
args: [
|
||||||
|
EXERCISE_ID,
|
||||||
|
USER_A,
|
||||||
|
"Squat",
|
||||||
|
"Strength",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
]
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,25 +7,45 @@ import type { Client } from "@libsql/client/web";
|
|||||||
|
|
||||||
const NESSA_CACHE_TTL_MS = 5 * 60 * 1000;
|
const NESSA_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert that the record identified by id in the given table belongs to userId.
|
||||||
|
* Shared by assertWorkoutOwned, assertAuthProviderOwned, and
|
||||||
|
* assertExerciseLibraryOwned.
|
||||||
|
*/
|
||||||
|
async function assertOwnedBy(
|
||||||
|
conn: Client,
|
||||||
|
table: string,
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
notFoundMessage: string,
|
||||||
|
forbiddenMessage: string
|
||||||
|
) {
|
||||||
|
const row = await conn.execute({
|
||||||
|
sql: `SELECT userId FROM ${table} WHERE id = ?`,
|
||||||
|
args: [id]
|
||||||
|
});
|
||||||
|
if (row.rows.length === 0) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: notFoundMessage });
|
||||||
|
}
|
||||||
|
if (row.rows[0].userId !== userId) {
|
||||||
|
throw new TRPCError({ code: "FORBIDDEN", message: forbiddenMessage });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Assert that the workout identified by workoutId is owned by userId */
|
/** Assert that the workout identified by workoutId is owned by userId */
|
||||||
export async function assertWorkoutOwned(
|
export async function assertWorkoutOwned(
|
||||||
conn: Client,
|
conn: Client,
|
||||||
workoutId: string,
|
workoutId: string,
|
||||||
userId: string
|
userId: string
|
||||||
) {
|
) {
|
||||||
const row = await conn.execute({
|
await assertOwnedBy(
|
||||||
sql: "SELECT userId FROM workouts WHERE id = ?",
|
conn,
|
||||||
args: [workoutId]
|
"workouts",
|
||||||
});
|
workoutId,
|
||||||
if (row.rows.length === 0) {
|
userId,
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Workout not found" });
|
"Workout not found",
|
||||||
}
|
"Not the workout owner"
|
||||||
if ((row.rows[0] as any).userId !== userId) {
|
);
|
||||||
throw new TRPCError({
|
|
||||||
code: "FORBIDDEN",
|
|
||||||
message: "Not the workout owner"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Assert that the auth provider record identified by providerId is owned by userId */
|
/** Assert that the auth provider record identified by providerId is owned by userId */
|
||||||
@@ -34,22 +54,14 @@ export async function assertAuthProviderOwned(
|
|||||||
providerId: string,
|
providerId: string,
|
||||||
userId: string
|
userId: string
|
||||||
) {
|
) {
|
||||||
const row = await conn.execute({
|
await assertOwnedBy(
|
||||||
sql: "SELECT userId FROM authProviders WHERE id = ?",
|
conn,
|
||||||
args: [providerId]
|
"authProviders",
|
||||||
});
|
providerId,
|
||||||
if (row.rows.length === 0) {
|
userId,
|
||||||
throw new TRPCError({
|
"Auth provider not found",
|
||||||
code: "NOT_FOUND",
|
"Not the auth provider owner"
|
||||||
message: "Auth provider not found"
|
);
|
||||||
});
|
|
||||||
}
|
|
||||||
if ((row.rows[0] as any).userId !== userId) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "FORBIDDEN",
|
|
||||||
message: "Not the auth provider owner"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Assert that the exercise library record identified by exerciseId is owned by userId */
|
/** Assert that the exercise library record identified by exerciseId is owned by userId */
|
||||||
@@ -58,19 +70,14 @@ export async function assertExerciseLibraryOwned(
|
|||||||
exerciseId: string,
|
exerciseId: string,
|
||||||
userId: string
|
userId: string
|
||||||
) {
|
) {
|
||||||
const row = await conn.execute({
|
await assertOwnedBy(
|
||||||
sql: "SELECT userId FROM exerciseLibrary WHERE id = ?",
|
conn,
|
||||||
args: [exerciseId]
|
"exerciseLibrary",
|
||||||
});
|
exerciseId,
|
||||||
if (row.rows.length === 0) {
|
userId,
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Exercise not found" });
|
"Exercise not found",
|
||||||
}
|
"Not the exercise owner"
|
||||||
if ((row.rows[0] as any).userId !== userId) {
|
);
|
||||||
throw new TRPCError({
|
|
||||||
code: "FORBIDDEN",
|
|
||||||
message: "Not the exercise owner"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const paginatedQuerySchema = z.object({
|
const paginatedQuerySchema = z.object({
|
||||||
@@ -239,6 +246,401 @@ const bulkSchema = z.object({
|
|||||||
authProviders: z.array(providerSchema).optional()
|
authProviders: z.array(providerSchema).optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function upsertUsers(
|
||||||
|
conn: Client,
|
||||||
|
userId: string,
|
||||||
|
users: z.infer<typeof userInputSchema>[]
|
||||||
|
) {
|
||||||
|
for (const user of users) {
|
||||||
|
if (user.id !== userId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "User mismatch"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await conn.execute({
|
||||||
|
sql: `INSERT INTO users (id, email, emailVerified, firstName, lastName, displayName, avatarUrl, provider, appleUserId, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET email = excluded.email, emailVerified = excluded.emailVerified, firstName = excluded.firstName, lastName = excluded.lastName, displayName = excluded.displayName, avatarUrl = excluded.avatarUrl, provider = excluded.provider, appleUserId = excluded.appleUserId, status = excluded.status, updatedAt = datetime('now')`,
|
||||||
|
args: [
|
||||||
|
user.id,
|
||||||
|
user.email ?? null,
|
||||||
|
user.emailVerified ?? 0,
|
||||||
|
user.firstName ?? null,
|
||||||
|
user.lastName ?? null,
|
||||||
|
user.displayName ?? null,
|
||||||
|
user.avatarUrl ?? null,
|
||||||
|
user.provider ?? null,
|
||||||
|
user.appleUserId ?? null,
|
||||||
|
user.status ?? "active"
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertExerciseLibrary(
|
||||||
|
conn: Client,
|
||||||
|
userId: string,
|
||||||
|
exerciseLibrary: z.infer<typeof exerciseLibrarySchema>[]
|
||||||
|
) {
|
||||||
|
for (const exercise of exerciseLibrary) {
|
||||||
|
if (exercise.userId !== userId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "User mismatch"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await conn.execute({
|
||||||
|
sql: `INSERT INTO exerciseLibrary (id, userId, name, category, muscleGroups, equipment, instructions, defaultSets, defaultReps, defaultRestSeconds, notes)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET userId = excluded.userId, name = excluded.name, category = excluded.category, muscleGroups = excluded.muscleGroups, equipment = excluded.equipment, instructions = excluded.instructions, defaultSets = excluded.defaultSets, defaultReps = excluded.defaultReps, defaultRestSeconds = excluded.defaultRestSeconds, notes = excluded.notes, updatedAt = datetime('now')`,
|
||||||
|
args: [
|
||||||
|
exercise.id,
|
||||||
|
exercise.userId,
|
||||||
|
exercise.name,
|
||||||
|
exercise.category,
|
||||||
|
exercise.muscleGroups ?? null,
|
||||||
|
exercise.equipment ?? null,
|
||||||
|
exercise.instructions ?? null,
|
||||||
|
exercise.defaultSets ?? null,
|
||||||
|
exercise.defaultReps ?? null,
|
||||||
|
exercise.defaultRestSeconds ?? null,
|
||||||
|
exercise.notes ?? null
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertWorkoutPlans(
|
||||||
|
conn: Client,
|
||||||
|
userId: string,
|
||||||
|
workoutPlans: z.infer<typeof workoutPlanSchema>[]
|
||||||
|
) {
|
||||||
|
for (const plan of workoutPlans) {
|
||||||
|
if (plan.userId !== userId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "User mismatch"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await conn.execute({
|
||||||
|
sql: `INSERT INTO workoutPlans (id, userId, name, description, category, difficulty, durationMinutes, type, isPublic)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET name = excluded.name, description = excluded.description, category = excluded.category, difficulty = excluded.difficulty, durationMinutes = excluded.durationMinutes, type = excluded.type, isPublic = excluded.isPublic, updatedAt = datetime('now')`,
|
||||||
|
args: [
|
||||||
|
plan.id,
|
||||||
|
plan.userId,
|
||||||
|
plan.name,
|
||||||
|
plan.description ?? null,
|
||||||
|
plan.category,
|
||||||
|
plan.difficulty ?? "intermediate",
|
||||||
|
plan.durationMinutes ?? null,
|
||||||
|
plan.type,
|
||||||
|
plan.isPublic ?? 0
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertPlanExercises(
|
||||||
|
conn: Client,
|
||||||
|
userId: string,
|
||||||
|
planExercises: z.infer<typeof planExerciseSchema>[]
|
||||||
|
) {
|
||||||
|
for (const planExercise of planExercises) {
|
||||||
|
const planCheck = await conn.execute({
|
||||||
|
sql: "SELECT userId FROM workoutPlans WHERE id = ?",
|
||||||
|
args: [planExercise.planId]
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!planCheck.rows.length ||
|
||||||
|
planCheck.rows[0].userId !== userId
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "User mismatch"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await conn.execute({
|
||||||
|
sql: `INSERT INTO planExercises (id, planId, exerciseId, name, category, orderIndex, notes)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET exerciseId = excluded.exerciseId, name = excluded.name, category = excluded.category, orderIndex = excluded.orderIndex, notes = excluded.notes`,
|
||||||
|
args: [
|
||||||
|
planExercise.id,
|
||||||
|
planExercise.planId,
|
||||||
|
planExercise.exerciseId ?? null,
|
||||||
|
planExercise.name,
|
||||||
|
planExercise.category,
|
||||||
|
planExercise.orderIndex,
|
||||||
|
planExercise.notes ?? null
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertPlanSets(
|
||||||
|
conn: Client,
|
||||||
|
userId: string,
|
||||||
|
planSets: z.infer<typeof planSetSchema>[]
|
||||||
|
) {
|
||||||
|
for (const planSet of planSets) {
|
||||||
|
const planExerciseCheck = await conn.execute({
|
||||||
|
sql: "SELECT planId FROM planExercises WHERE id = ?",
|
||||||
|
args: [planSet.planExerciseId]
|
||||||
|
});
|
||||||
|
if (planExerciseCheck.rows.length) {
|
||||||
|
const planCheck = await conn.execute({
|
||||||
|
sql: "SELECT userId FROM workoutPlans WHERE id = ?",
|
||||||
|
args: [planExerciseCheck.rows[0].planId]
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!planCheck.rows.length ||
|
||||||
|
planCheck.rows[0].userId !== userId
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "User mismatch"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await conn.execute({
|
||||||
|
sql: `INSERT INTO planSets (id, planExerciseId, setNumber, reps, weight, durationSeconds, rpe, restAfterSeconds, isWarmup, isDropset, notes)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET setNumber = excluded.setNumber, reps = excluded.reps, weight = excluded.weight, durationSeconds = excluded.durationSeconds, rpe = excluded.rpe, restAfterSeconds = excluded.restAfterSeconds, isWarmup = excluded.isWarmup, isDropset = excluded.isDropset, notes = excluded.notes`,
|
||||||
|
args: [
|
||||||
|
planSet.id,
|
||||||
|
planSet.planExerciseId,
|
||||||
|
planSet.setNumber,
|
||||||
|
planSet.reps ?? null,
|
||||||
|
planSet.weight ?? null,
|
||||||
|
planSet.durationSeconds ?? null,
|
||||||
|
planSet.rpe ?? null,
|
||||||
|
planSet.restAfterSeconds ?? null,
|
||||||
|
planSet.isWarmup ?? 0,
|
||||||
|
planSet.isDropset ?? 0,
|
||||||
|
planSet.notes ?? null
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertRoutePoints(
|
||||||
|
conn: Client,
|
||||||
|
userId: string,
|
||||||
|
routePoints: z.infer<typeof routePointSchema>[]
|
||||||
|
) {
|
||||||
|
for (const point of routePoints) {
|
||||||
|
const planCheck = await conn.execute({
|
||||||
|
sql: "SELECT userId FROM workoutPlans WHERE id = ?",
|
||||||
|
args: [point.planId]
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!planCheck.rows.length ||
|
||||||
|
planCheck.rows[0].userId !== userId
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "User mismatch"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await conn.execute({
|
||||||
|
sql: `INSERT INTO routePoints (id, planId, latitude, longitude, orderIndex, isWaypoint)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET latitude = excluded.latitude, longitude = excluded.longitude, orderIndex = excluded.orderIndex, isWaypoint = excluded.isWaypoint`,
|
||||||
|
args: [
|
||||||
|
point.id,
|
||||||
|
point.planId,
|
||||||
|
point.latitude,
|
||||||
|
point.longitude,
|
||||||
|
point.orderIndex,
|
||||||
|
point.isWaypoint ?? 0
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertWorkouts(
|
||||||
|
conn: Client,
|
||||||
|
userId: string,
|
||||||
|
workouts: z.infer<typeof workoutSchema>[]
|
||||||
|
) {
|
||||||
|
for (const workout of workouts) {
|
||||||
|
if (workout.userId !== userId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "User mismatch"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await conn.execute({
|
||||||
|
sql: `INSERT INTO workouts (id, userId, planId, type, name, startDate, endDate, durationSeconds, distanceMeters, calories, averageHeartRate, maxHeartRate, averagePace, elevationGain, status, source, healthKitUUID, notes)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET planId = excluded.planId, type = excluded.type, name = excluded.name, startDate = excluded.startDate, endDate = excluded.endDate, durationSeconds = excluded.durationSeconds, distanceMeters = excluded.distanceMeters, calories = excluded.calories, averageHeartRate = excluded.averageHeartRate, maxHeartRate = excluded.maxHeartRate, averagePace = excluded.averagePace, elevationGain = excluded.elevationGain, status = excluded.status, source = excluded.source, healthKitUUID = excluded.healthKitUUID, notes = excluded.notes, updatedAt = datetime('now')`,
|
||||||
|
args: [
|
||||||
|
workout.id,
|
||||||
|
workout.userId,
|
||||||
|
workout.planId ?? null,
|
||||||
|
workout.type,
|
||||||
|
workout.name ?? null,
|
||||||
|
workout.startDate,
|
||||||
|
workout.endDate ?? null,
|
||||||
|
workout.durationSeconds ?? null,
|
||||||
|
workout.distanceMeters ?? null,
|
||||||
|
workout.calories ?? null,
|
||||||
|
workout.averageHeartRate ?? null,
|
||||||
|
workout.maxHeartRate ?? null,
|
||||||
|
workout.averagePace ?? null,
|
||||||
|
workout.elevationGain ?? null,
|
||||||
|
workout.status,
|
||||||
|
workout.source,
|
||||||
|
workout.healthKitUUID ?? null,
|
||||||
|
workout.notes ?? null
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertHeartRateSamples(
|
||||||
|
conn: Client,
|
||||||
|
userId: string,
|
||||||
|
heartRateSamples: z.infer<typeof heartRateSchema>[]
|
||||||
|
) {
|
||||||
|
for (const sample of heartRateSamples) {
|
||||||
|
const workoutCheck = await conn.execute({
|
||||||
|
sql: "SELECT userId FROM workouts WHERE id = ?",
|
||||||
|
args: [sample.workoutId]
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!workoutCheck.rows.length ||
|
||||||
|
workoutCheck.rows[0].userId !== userId
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "User mismatch"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await conn.execute({
|
||||||
|
sql: `INSERT INTO heartRateSamples (id, workoutId, timestamp, bpm, source)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET timestamp = excluded.timestamp, bpm = excluded.bpm, source = excluded.source`,
|
||||||
|
args: [
|
||||||
|
sample.id,
|
||||||
|
sample.workoutId,
|
||||||
|
sample.timestamp,
|
||||||
|
sample.bpm,
|
||||||
|
sample.source ?? null
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertLocationSamples(
|
||||||
|
conn: Client,
|
||||||
|
userId: string,
|
||||||
|
locationSamples: z.infer<typeof locationSampleSchema>[]
|
||||||
|
) {
|
||||||
|
for (const sample of locationSamples) {
|
||||||
|
const workoutCheck = await conn.execute({
|
||||||
|
sql: "SELECT userId FROM workouts WHERE id = ?",
|
||||||
|
args: [sample.workoutId]
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!workoutCheck.rows.length ||
|
||||||
|
workoutCheck.rows[0].userId !== userId
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "User mismatch"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await conn.execute({
|
||||||
|
sql: `INSERT INTO locationSamples (id, workoutId, timestamp, latitude, longitude, altitude, horizontalAccuracy, verticalAccuracy, speed, course)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET timestamp = excluded.timestamp, latitude = excluded.latitude, longitude = excluded.longitude, altitude = excluded.altitude, horizontalAccuracy = excluded.horizontalAccuracy, verticalAccuracy = excluded.verticalAccuracy, speed = excluded.speed, course = excluded.course`,
|
||||||
|
args: [
|
||||||
|
sample.id,
|
||||||
|
sample.workoutId,
|
||||||
|
sample.timestamp,
|
||||||
|
sample.latitude,
|
||||||
|
sample.longitude,
|
||||||
|
sample.altitude ?? null,
|
||||||
|
sample.horizontalAccuracy ?? null,
|
||||||
|
sample.verticalAccuracy ?? null,
|
||||||
|
sample.speed ?? null,
|
||||||
|
sample.course ?? null
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertWorkoutSplits(
|
||||||
|
conn: Client,
|
||||||
|
userId: string,
|
||||||
|
workoutSplits: z.infer<typeof workoutSplitSchema>[]
|
||||||
|
) {
|
||||||
|
for (const split of workoutSplits) {
|
||||||
|
const workoutCheck = await conn.execute({
|
||||||
|
sql: "SELECT userId FROM workouts WHERE id = ?",
|
||||||
|
args: [split.workoutId]
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!workoutCheck.rows.length ||
|
||||||
|
workoutCheck.rows[0].userId !== userId
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "User mismatch"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await conn.execute({
|
||||||
|
sql: `INSERT INTO workoutSplits (id, workoutId, splitNumber, distanceMeters, durationSeconds, startTimestamp, endTimestamp, averageHeartRate, averagePace, elevationGain, elevationLoss)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET splitNumber = excluded.splitNumber, distanceMeters = excluded.distanceMeters, durationSeconds = excluded.durationSeconds, startTimestamp = excluded.startTimestamp, endTimestamp = excluded.endTimestamp, averageHeartRate = excluded.averageHeartRate, averagePace = excluded.averagePace, elevationGain = excluded.elevationGain, elevationLoss = excluded.elevationLoss`,
|
||||||
|
args: [
|
||||||
|
split.id,
|
||||||
|
split.workoutId,
|
||||||
|
split.splitNumber,
|
||||||
|
split.distanceMeters,
|
||||||
|
split.durationSeconds,
|
||||||
|
split.startTimestamp,
|
||||||
|
split.endTimestamp,
|
||||||
|
split.averageHeartRate ?? null,
|
||||||
|
split.averagePace ?? null,
|
||||||
|
split.elevationGain ?? null,
|
||||||
|
split.elevationLoss ?? null
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertAuthProviders(
|
||||||
|
conn: Client,
|
||||||
|
userId: string,
|
||||||
|
authProviders: z.infer<typeof providerSchema>[]
|
||||||
|
) {
|
||||||
|
for (const provider of authProviders) {
|
||||||
|
if (provider.userId !== userId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "User mismatch"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await conn.execute({
|
||||||
|
sql: `INSERT INTO authProviders (id, userId, provider, providerUserId, email, displayName, avatarUrl)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET provider = excluded.provider, providerUserId = excluded.providerUserId, email = excluded.email, displayName = excluded.displayName, avatarUrl = excluded.avatarUrl, lastUsedAt = datetime('now')`,
|
||||||
|
args: [
|
||||||
|
provider.id,
|
||||||
|
provider.userId,
|
||||||
|
provider.provider,
|
||||||
|
provider.providerUserId ?? null,
|
||||||
|
provider.email ?? null,
|
||||||
|
provider.displayName ?? null,
|
||||||
|
provider.avatarUrl ?? null
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const nessaDbRouter = createTRPCRouter({
|
export const nessaDbRouter = createTRPCRouter({
|
||||||
health: nessaProcedure.query(async () => {
|
health: nessaProcedure.query(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -1826,356 +2228,41 @@ export const nessaDbRouter = createTRPCRouter({
|
|||||||
try {
|
try {
|
||||||
const conn = NessaConnectionFactory();
|
const conn = NessaConnectionFactory();
|
||||||
|
|
||||||
if (input.users?.length) {
|
await upsertUsers(conn, ctx.nessaUserId, input.users ?? []);
|
||||||
for (const user of input.users) {
|
await upsertExerciseLibrary(
|
||||||
if (user.id !== ctx.nessaUserId) {
|
conn,
|
||||||
throw new TRPCError({
|
ctx.nessaUserId,
|
||||||
code: "FORBIDDEN",
|
input.exerciseLibrary ?? []
|
||||||
message: "User mismatch"
|
);
|
||||||
});
|
await upsertWorkoutPlans(conn, ctx.nessaUserId, input.workoutPlans ?? []);
|
||||||
}
|
await upsertPlanExercises(
|
||||||
await conn.execute({
|
conn,
|
||||||
sql: `INSERT INTO users (id, email, emailVerified, firstName, lastName, displayName, avatarUrl, provider, appleUserId, status)
|
ctx.nessaUserId,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
input.planExercises ?? []
|
||||||
ON CONFLICT(id) DO UPDATE SET email = excluded.email, emailVerified = excluded.emailVerified, firstName = excluded.firstName, lastName = excluded.lastName, displayName = excluded.displayName, avatarUrl = excluded.avatarUrl, provider = excluded.provider, appleUserId = excluded.appleUserId, status = excluded.status, updatedAt = datetime('now')`,
|
);
|
||||||
args: [
|
await upsertPlanSets(conn, ctx.nessaUserId, input.planSets ?? []);
|
||||||
user.id,
|
await upsertRoutePoints(conn, ctx.nessaUserId, input.routePoints ?? []);
|
||||||
user.email ?? null,
|
await upsertWorkouts(conn, ctx.nessaUserId, input.workouts ?? []);
|
||||||
user.emailVerified ?? 0,
|
await upsertHeartRateSamples(
|
||||||
user.firstName ?? null,
|
conn,
|
||||||
user.lastName ?? null,
|
ctx.nessaUserId,
|
||||||
user.displayName ?? null,
|
input.heartRateSamples ?? []
|
||||||
user.avatarUrl ?? null,
|
);
|
||||||
user.provider ?? null,
|
await upsertLocationSamples(
|
||||||
user.appleUserId ?? null,
|
conn,
|
||||||
user.status ?? "active"
|
ctx.nessaUserId,
|
||||||
]
|
input.locationSamples ?? []
|
||||||
});
|
);
|
||||||
}
|
await upsertWorkoutSplits(
|
||||||
}
|
conn,
|
||||||
|
ctx.nessaUserId,
|
||||||
if (input.exerciseLibrary?.length) {
|
input.workoutSplits ?? []
|
||||||
for (const exercise of input.exerciseLibrary) {
|
);
|
||||||
if (exercise.userId !== ctx.nessaUserId) {
|
await upsertAuthProviders(
|
||||||
throw new TRPCError({
|
conn,
|
||||||
code: "FORBIDDEN",
|
ctx.nessaUserId,
|
||||||
message: "User mismatch"
|
input.authProviders ?? []
|
||||||
});
|
);
|
||||||
}
|
|
||||||
await conn.execute({
|
|
||||||
sql: `INSERT INTO exerciseLibrary (id, userId, name, category, muscleGroups, equipment, instructions, defaultSets, defaultReps, defaultRestSeconds, notes)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(id) DO UPDATE SET userId = excluded.userId, name = excluded.name, category = excluded.category, muscleGroups = excluded.muscleGroups, equipment = excluded.equipment, instructions = excluded.instructions, defaultSets = excluded.defaultSets, defaultReps = excluded.defaultReps, defaultRestSeconds = excluded.defaultRestSeconds, notes = excluded.notes, updatedAt = datetime('now')`,
|
|
||||||
args: [
|
|
||||||
exercise.id,
|
|
||||||
exercise.userId,
|
|
||||||
exercise.name,
|
|
||||||
exercise.category,
|
|
||||||
exercise.muscleGroups ?? null,
|
|
||||||
exercise.equipment ?? null,
|
|
||||||
exercise.instructions ?? null,
|
|
||||||
exercise.defaultSets ?? null,
|
|
||||||
exercise.defaultReps ?? null,
|
|
||||||
exercise.defaultRestSeconds ?? null,
|
|
||||||
exercise.notes ?? null
|
|
||||||
]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.workoutPlans?.length) {
|
|
||||||
for (const plan of input.workoutPlans) {
|
|
||||||
if (plan.userId !== ctx.nessaUserId) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "FORBIDDEN",
|
|
||||||
message: "User mismatch"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await conn.execute({
|
|
||||||
sql: `INSERT INTO workoutPlans (id, userId, name, description, category, difficulty, durationMinutes, type, isPublic)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(id) DO UPDATE SET name = excluded.name, description = excluded.description, category = excluded.category, difficulty = excluded.difficulty, durationMinutes = excluded.durationMinutes, type = excluded.type, isPublic = excluded.isPublic, updatedAt = datetime('now')`,
|
|
||||||
args: [
|
|
||||||
plan.id,
|
|
||||||
plan.userId,
|
|
||||||
plan.name,
|
|
||||||
plan.description ?? null,
|
|
||||||
plan.category,
|
|
||||||
plan.difficulty ?? "intermediate",
|
|
||||||
plan.durationMinutes ?? null,
|
|
||||||
plan.type,
|
|
||||||
plan.isPublic ?? 0
|
|
||||||
]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.planExercises?.length) {
|
|
||||||
for (const planExercise of input.planExercises) {
|
|
||||||
const planCheck = await conn.execute({
|
|
||||||
sql: "SELECT userId FROM workoutPlans WHERE id = ?",
|
|
||||||
args: [planExercise.planId]
|
|
||||||
});
|
|
||||||
if (
|
|
||||||
!planCheck.rows.length ||
|
|
||||||
planCheck.rows[0].userId !== ctx.nessaUserId
|
|
||||||
) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "FORBIDDEN",
|
|
||||||
message: "User mismatch"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await conn.execute({
|
|
||||||
sql: `INSERT INTO planExercises (id, planId, exerciseId, name, category, orderIndex, notes)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(id) DO UPDATE SET exerciseId = excluded.exerciseId, name = excluded.name, category = excluded.category, orderIndex = excluded.orderIndex, notes = excluded.notes`,
|
|
||||||
args: [
|
|
||||||
planExercise.id,
|
|
||||||
planExercise.planId,
|
|
||||||
planExercise.exerciseId ?? null,
|
|
||||||
planExercise.name,
|
|
||||||
planExercise.category,
|
|
||||||
planExercise.orderIndex,
|
|
||||||
planExercise.notes ?? null
|
|
||||||
]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.planSets?.length) {
|
|
||||||
for (const planSet of input.planSets) {
|
|
||||||
const planExerciseCheck = await conn.execute({
|
|
||||||
sql: "SELECT planId FROM planExercises WHERE id = ?",
|
|
||||||
args: [planSet.planExerciseId]
|
|
||||||
});
|
|
||||||
if (planExerciseCheck.rows.length) {
|
|
||||||
const planCheck = await conn.execute({
|
|
||||||
sql: "SELECT userId FROM workoutPlans WHERE id = ?",
|
|
||||||
args: [planExerciseCheck.rows[0].planId]
|
|
||||||
});
|
|
||||||
if (
|
|
||||||
!planCheck.rows.length ||
|
|
||||||
planCheck.rows[0].userId !== ctx.nessaUserId
|
|
||||||
) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "FORBIDDEN",
|
|
||||||
message: "User mismatch"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await conn.execute({
|
|
||||||
sql: `INSERT INTO planSets (id, planExerciseId, setNumber, reps, weight, durationSeconds, rpe, restAfterSeconds, isWarmup, isDropset, notes)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(id) DO UPDATE SET setNumber = excluded.setNumber, reps = excluded.reps, weight = excluded.weight, durationSeconds = excluded.durationSeconds, rpe = excluded.rpe, restAfterSeconds = excluded.restAfterSeconds, isWarmup = excluded.isWarmup, isDropset = excluded.isDropset, notes = excluded.notes`,
|
|
||||||
args: [
|
|
||||||
planSet.id,
|
|
||||||
planSet.planExerciseId,
|
|
||||||
planSet.setNumber,
|
|
||||||
planSet.reps ?? null,
|
|
||||||
planSet.weight ?? null,
|
|
||||||
planSet.durationSeconds ?? null,
|
|
||||||
planSet.rpe ?? null,
|
|
||||||
planSet.restAfterSeconds ?? null,
|
|
||||||
planSet.isWarmup ?? 0,
|
|
||||||
planSet.isDropset ?? 0,
|
|
||||||
planSet.notes ?? null
|
|
||||||
]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.routePoints?.length) {
|
|
||||||
for (const point of input.routePoints) {
|
|
||||||
const planCheck = await conn.execute({
|
|
||||||
sql: "SELECT userId FROM workoutPlans WHERE id = ?",
|
|
||||||
args: [point.planId]
|
|
||||||
});
|
|
||||||
if (
|
|
||||||
!planCheck.rows.length ||
|
|
||||||
planCheck.rows[0].userId !== ctx.nessaUserId
|
|
||||||
) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "FORBIDDEN",
|
|
||||||
message: "User mismatch"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await conn.execute({
|
|
||||||
sql: `INSERT INTO routePoints (id, planId, latitude, longitude, orderIndex, isWaypoint)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(id) DO UPDATE SET latitude = excluded.latitude, longitude = excluded.longitude, orderIndex = excluded.orderIndex, isWaypoint = excluded.isWaypoint`,
|
|
||||||
args: [
|
|
||||||
point.id,
|
|
||||||
point.planId,
|
|
||||||
point.latitude,
|
|
||||||
point.longitude,
|
|
||||||
point.orderIndex,
|
|
||||||
point.isWaypoint ?? 0
|
|
||||||
]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.workouts?.length) {
|
|
||||||
for (const workout of input.workouts) {
|
|
||||||
if (workout.userId !== ctx.nessaUserId) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "FORBIDDEN",
|
|
||||||
message: "User mismatch"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await conn.execute({
|
|
||||||
sql: `INSERT INTO workouts (id, userId, planId, type, name, startDate, endDate, durationSeconds, distanceMeters, calories, averageHeartRate, maxHeartRate, averagePace, elevationGain, status, source, healthKitUUID, notes)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(id) DO UPDATE SET planId = excluded.planId, type = excluded.type, name = excluded.name, startDate = excluded.startDate, endDate = excluded.endDate, durationSeconds = excluded.durationSeconds, distanceMeters = excluded.distanceMeters, calories = excluded.calories, averageHeartRate = excluded.averageHeartRate, maxHeartRate = excluded.maxHeartRate, averagePace = excluded.averagePace, elevationGain = excluded.elevationGain, status = excluded.status, source = excluded.source, healthKitUUID = excluded.healthKitUUID, notes = excluded.notes, updatedAt = datetime('now')`,
|
|
||||||
args: [
|
|
||||||
workout.id,
|
|
||||||
workout.userId,
|
|
||||||
workout.planId ?? null,
|
|
||||||
workout.type,
|
|
||||||
workout.name ?? null,
|
|
||||||
workout.startDate,
|
|
||||||
workout.endDate ?? null,
|
|
||||||
workout.durationSeconds ?? null,
|
|
||||||
workout.distanceMeters ?? null,
|
|
||||||
workout.calories ?? null,
|
|
||||||
workout.averageHeartRate ?? null,
|
|
||||||
workout.maxHeartRate ?? null,
|
|
||||||
workout.averagePace ?? null,
|
|
||||||
workout.elevationGain ?? null,
|
|
||||||
workout.status,
|
|
||||||
workout.source,
|
|
||||||
workout.healthKitUUID ?? null,
|
|
||||||
workout.notes ?? null
|
|
||||||
]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.heartRateSamples?.length) {
|
|
||||||
for (const sample of input.heartRateSamples) {
|
|
||||||
const workoutCheck = await conn.execute({
|
|
||||||
sql: "SELECT userId FROM workouts WHERE id = ?",
|
|
||||||
args: [sample.workoutId]
|
|
||||||
});
|
|
||||||
if (
|
|
||||||
!workoutCheck.rows.length ||
|
|
||||||
workoutCheck.rows[0].userId !== ctx.nessaUserId
|
|
||||||
) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "FORBIDDEN",
|
|
||||||
message: "User mismatch"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await conn.execute({
|
|
||||||
sql: `INSERT INTO heartRateSamples (id, workoutId, timestamp, bpm, source)
|
|
||||||
VALUES (?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(id) DO UPDATE SET timestamp = excluded.timestamp, bpm = excluded.bpm, source = excluded.source`,
|
|
||||||
args: [
|
|
||||||
sample.id,
|
|
||||||
sample.workoutId,
|
|
||||||
sample.timestamp,
|
|
||||||
sample.bpm,
|
|
||||||
sample.source ?? null
|
|
||||||
]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.locationSamples?.length) {
|
|
||||||
for (const sample of input.locationSamples) {
|
|
||||||
const workoutCheck = await conn.execute({
|
|
||||||
sql: "SELECT userId FROM workouts WHERE id = ?",
|
|
||||||
args: [sample.workoutId]
|
|
||||||
});
|
|
||||||
if (
|
|
||||||
!workoutCheck.rows.length ||
|
|
||||||
workoutCheck.rows[0].userId !== ctx.nessaUserId
|
|
||||||
) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "FORBIDDEN",
|
|
||||||
message: "User mismatch"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await conn.execute({
|
|
||||||
sql: `INSERT INTO locationSamples (id, workoutId, timestamp, latitude, longitude, altitude, horizontalAccuracy, verticalAccuracy, speed, course)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(id) DO UPDATE SET timestamp = excluded.timestamp, latitude = excluded.latitude, longitude = excluded.longitude, altitude = excluded.altitude, horizontalAccuracy = excluded.horizontalAccuracy, verticalAccuracy = excluded.verticalAccuracy, speed = excluded.speed, course = excluded.course`,
|
|
||||||
args: [
|
|
||||||
sample.id,
|
|
||||||
sample.workoutId,
|
|
||||||
sample.timestamp,
|
|
||||||
sample.latitude,
|
|
||||||
sample.longitude,
|
|
||||||
sample.altitude ?? null,
|
|
||||||
sample.horizontalAccuracy ?? null,
|
|
||||||
sample.verticalAccuracy ?? null,
|
|
||||||
sample.speed ?? null,
|
|
||||||
sample.course ?? null
|
|
||||||
]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.workoutSplits?.length) {
|
|
||||||
for (const split of input.workoutSplits) {
|
|
||||||
const workoutCheck = await conn.execute({
|
|
||||||
sql: "SELECT userId FROM workouts WHERE id = ?",
|
|
||||||
args: [split.workoutId]
|
|
||||||
});
|
|
||||||
if (
|
|
||||||
!workoutCheck.rows.length ||
|
|
||||||
workoutCheck.rows[0].userId !== ctx.nessaUserId
|
|
||||||
) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "FORBIDDEN",
|
|
||||||
message: "User mismatch"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await conn.execute({
|
|
||||||
sql: `INSERT INTO workoutSplits (id, workoutId, splitNumber, distanceMeters, durationSeconds, startTimestamp, endTimestamp, averageHeartRate, averagePace, elevationGain, elevationLoss)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(id) DO UPDATE SET splitNumber = excluded.splitNumber, distanceMeters = excluded.distanceMeters, durationSeconds = excluded.durationSeconds, startTimestamp = excluded.startTimestamp, endTimestamp = excluded.endTimestamp, averageHeartRate = excluded.averageHeartRate, averagePace = excluded.averagePace, elevationGain = excluded.elevationGain, elevationLoss = excluded.elevationLoss`,
|
|
||||||
args: [
|
|
||||||
split.id,
|
|
||||||
split.workoutId,
|
|
||||||
split.splitNumber,
|
|
||||||
split.distanceMeters,
|
|
||||||
split.durationSeconds,
|
|
||||||
split.startTimestamp,
|
|
||||||
split.endTimestamp,
|
|
||||||
split.averageHeartRate ?? null,
|
|
||||||
split.averagePace ?? null,
|
|
||||||
split.elevationGain ?? null,
|
|
||||||
split.elevationLoss ?? null
|
|
||||||
]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.authProviders?.length) {
|
|
||||||
for (const provider of input.authProviders) {
|
|
||||||
if (provider.userId !== ctx.nessaUserId) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "FORBIDDEN",
|
|
||||||
message: "User mismatch"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await conn.execute({
|
|
||||||
sql: `INSERT INTO authProviders (id, userId, provider, providerUserId, email, displayName, avatarUrl)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(id) DO UPDATE SET provider = excluded.provider, providerUserId = excluded.providerUserId, email = excluded.email, displayName = excluded.displayName, avatarUrl = excluded.avatarUrl, lastUsedAt = datetime('now')`,
|
|
||||||
args: [
|
|
||||||
provider.id,
|
|
||||||
provider.userId,
|
|
||||||
provider.provider,
|
|
||||||
provider.providerUserId ?? null,
|
|
||||||
provider.email ?? null,
|
|
||||||
provider.displayName ?? null,
|
|
||||||
provider.avatarUrl ?? null
|
|
||||||
]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { z } from "zod";
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import diff from "fast-diff";
|
import diff from "fast-diff";
|
||||||
|
|
||||||
export function createDiffPatch(
|
function createDiffPatch(
|
||||||
oldContent: string,
|
oldContent: string,
|
||||||
newContent: string
|
newContent: string
|
||||||
): string {
|
): string {
|
||||||
@@ -12,7 +12,7 @@ export function createDiffPatch(
|
|||||||
return JSON.stringify(changes);
|
return JSON.stringify(changes);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyDiffPatch(baseContent: string, patchJson: string): string {
|
function applyDiffPatch(baseContent: string, patchJson: string): string {
|
||||||
const changes = JSON.parse(patchJson);
|
const changes = JSON.parse(patchJson);
|
||||||
let result = "";
|
let result = "";
|
||||||
let position = 0;
|
let position = 0;
|
||||||
@@ -96,7 +96,6 @@ export const postHistoryRouter = createTRPCRouter({
|
|||||||
|
|
||||||
const conn = ConnectionFactory();
|
const conn = ConnectionFactory();
|
||||||
|
|
||||||
// Verify post exists and user is author
|
|
||||||
const postCheck = await conn.execute({
|
const postCheck = await conn.execute({
|
||||||
sql: "SELECT author_id FROM Post WHERE id = ?",
|
sql: "SELECT author_id FROM Post WHERE id = ?",
|
||||||
args: [input.postId]
|
args: [input.postId]
|
||||||
|
|||||||
@@ -254,7 +254,6 @@ export const userRouter = createTRPCRouter({
|
|||||||
args: [passwordHash, userId]
|
args: [passwordHash, userId]
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send email notification about password being set
|
|
||||||
if (user.email) {
|
if (user.email) {
|
||||||
try {
|
try {
|
||||||
const h3Event = ctx.event.nativeEvent
|
const h3Event = ctx.event.nativeEvent
|
||||||
|
|||||||
@@ -6,105 +6,6 @@ import { z } from "zod";
|
|||||||
* Schemas for post creation, updating, querying, and interactions
|
* Schemas for post creation, updating, querying, and interactions
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Post Category and Status
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Post category enum (deprecated but kept for backward compatibility)
|
|
||||||
*/
|
|
||||||
export const postCategorySchema = z.enum(["blog", "project"]);
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Post Creation and Updates
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create new post schema
|
|
||||||
*/
|
|
||||||
export const createPostSchema = z.object({
|
|
||||||
title: z
|
|
||||||
.string()
|
|
||||||
.min(1, "Title is required")
|
|
||||||
.max(200, "Title must be under 200 characters"),
|
|
||||||
subtitle: z
|
|
||||||
.string()
|
|
||||||
.max(300, "Subtitle must be under 300 characters")
|
|
||||||
.optional(),
|
|
||||||
body: z.string().min(1, "Post body is required"),
|
|
||||||
banner_photo: z.string().url("Must be a valid URL").optional(),
|
|
||||||
published: z.boolean().default(false),
|
|
||||||
category: postCategorySchema.default("blog"),
|
|
||||||
attachments: z.string().optional()
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update post schema (partial updates)
|
|
||||||
*/
|
|
||||||
export const updatePostSchema = z.object({
|
|
||||||
postId: z.number(),
|
|
||||||
title: z.string().min(1).max(200).optional(),
|
|
||||||
subtitle: z.string().max(300).optional(),
|
|
||||||
body: z.string().min(1).optional(),
|
|
||||||
banner_photo: z.string().url().optional(),
|
|
||||||
published: z.boolean().optional(),
|
|
||||||
attachments: z.string().optional()
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete post schema
|
|
||||||
*/
|
|
||||||
export const deletePostSchema = z.object({
|
|
||||||
postId: z.number()
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Post Queries and Filtering
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Post sort mode enum
|
|
||||||
* Defines available sorting options for blog posts
|
|
||||||
*/
|
|
||||||
export const postSortModeSchema = z.enum([
|
|
||||||
"newest",
|
|
||||||
"oldest",
|
|
||||||
"most_liked",
|
|
||||||
"most_read",
|
|
||||||
"most_comments"
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Post query input schema
|
|
||||||
* Accepts optional filters (pipe-separated tags) and sort mode
|
|
||||||
*/
|
|
||||||
export const postQueryInputSchema = z.object({
|
|
||||||
/**
|
|
||||||
* Pipe-separated list of tags to filter by
|
|
||||||
* e.g., "tech|design|javascript"
|
|
||||||
* Empty string or undefined means no filter
|
|
||||||
*/
|
|
||||||
filters: z.string().optional(),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sort mode for posts
|
|
||||||
* Defaults to "newest" if not specified
|
|
||||||
*/
|
|
||||||
sortBy: postSortModeSchema.default("newest")
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get single post by ID or slug
|
|
||||||
*/
|
|
||||||
export const getPostSchema = z
|
|
||||||
.object({
|
|
||||||
postId: z.number().optional(),
|
|
||||||
slug: z.string().optional()
|
|
||||||
})
|
|
||||||
.refine((data) => data.postId || data.slug, {
|
|
||||||
message: "Either postId or slug must be provided"
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Post Interactions
|
// Post Interactions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -116,55 +17,8 @@ export const incrementPostReadSchema = z.object({
|
|||||||
postId: z.number()
|
postId: z.number()
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Like/unlike post
|
|
||||||
*/
|
|
||||||
export const togglePostLikeSchema = z.object({
|
|
||||||
postId: z.number()
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Tag Management
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add tags to post
|
|
||||||
*/
|
|
||||||
export const addTagsToPostSchema = z.object({
|
|
||||||
postId: z.number(),
|
|
||||||
tags: z
|
|
||||||
.array(z.string().min(1).max(50))
|
|
||||||
.min(1, "At least one tag is required")
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove tag from post
|
|
||||||
*/
|
|
||||||
export const removeTagFromPostSchema = z.object({
|
|
||||||
tagId: z.number()
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update post tags (replaces all tags)
|
|
||||||
*/
|
|
||||||
export const updatePostTagsSchema = z.object({
|
|
||||||
postId: z.number(),
|
|
||||||
tags: z.array(z.string().min(1).max(50))
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Type Exports
|
// Type Exports
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export type PostCategory = z.infer<typeof postCategorySchema>;
|
|
||||||
export type CreatePostInput = z.infer<typeof createPostSchema>;
|
|
||||||
export type UpdatePostInput = z.infer<typeof updatePostSchema>;
|
|
||||||
export type DeletePostInput = z.infer<typeof deletePostSchema>;
|
|
||||||
export type PostSortMode = z.infer<typeof postSortModeSchema>;
|
|
||||||
export type PostQueryInput = z.infer<typeof postQueryInputSchema>;
|
|
||||||
export type GetPostInput = z.infer<typeof getPostSchema>;
|
|
||||||
export type IncrementPostReadInput = z.infer<typeof incrementPostReadSchema>;
|
export type IncrementPostReadInput = z.infer<typeof incrementPostReadSchema>;
|
||||||
export type TogglePostLikeInput = z.infer<typeof togglePostLikeSchema>;
|
|
||||||
export type AddTagsToPostInput = z.infer<typeof addTagsToPostSchema>;
|
|
||||||
export type RemoveTagFromPostInput = z.infer<typeof removeTagFromPostSchema>;
|
|
||||||
export type UpdatePostTagsInput = z.infer<typeof updatePostTagsSchema>;
|
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
/**
|
|
||||||
* Comment API Validation Schemas
|
|
||||||
*
|
|
||||||
* Zod schemas for comment-related tRPC procedures:
|
|
||||||
* - Comment creation, updating, deletion
|
|
||||||
* - Comment reactions
|
|
||||||
* - Comment sorting and filtering
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Comment CRUD Operations
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create new comment schema
|
|
||||||
*/
|
|
||||||
export const createCommentSchema = z.object({
|
|
||||||
body: z
|
|
||||||
.string()
|
|
||||||
.min(1, "Comment cannot be empty")
|
|
||||||
.max(5000, "Comment too long"),
|
|
||||||
post_id: z.number(),
|
|
||||||
parent_comment_id: z.number().optional()
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update comment schema
|
|
||||||
*/
|
|
||||||
export const updateCommentSchema = z.object({
|
|
||||||
commentId: z.number(),
|
|
||||||
body: z
|
|
||||||
.string()
|
|
||||||
.min(1, "Comment cannot be empty")
|
|
||||||
.max(5000, "Comment too long")
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete comment schema
|
|
||||||
*/
|
|
||||||
export const deleteCommentSchema = z.object({
|
|
||||||
commentId: z.number(),
|
|
||||||
deletionType: z.enum(["user", "admin", "database"]).optional()
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get comments for post schema
|
|
||||||
*/
|
|
||||||
export const getCommentsSchema = z.object({
|
|
||||||
postId: z.number(),
|
|
||||||
sortBy: z.enum(["newest", "oldest", "highest_rated", "hot"]).default("newest")
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Comment Reactions
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Valid reaction types
|
|
||||||
*/
|
|
||||||
export const reactionTypeSchema = z.enum([
|
|
||||||
"tears",
|
|
||||||
"blank",
|
|
||||||
"tongue",
|
|
||||||
"cry",
|
|
||||||
"heartEye",
|
|
||||||
"angry",
|
|
||||||
"moneyEye",
|
|
||||||
"sick",
|
|
||||||
"upsideDown",
|
|
||||||
"worried"
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add/remove reaction to comment
|
|
||||||
*/
|
|
||||||
export const toggleCommentReactionSchema = z.object({
|
|
||||||
commentId: z.number(),
|
|
||||||
reactionType: reactionTypeSchema
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get reactions for comment
|
|
||||||
*/
|
|
||||||
export const getCommentReactionsSchema = z.object({
|
|
||||||
commentId: z.number()
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Comment Sorting
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Valid comment sorting modes
|
|
||||||
*/
|
|
||||||
export const commentSortSchema = z
|
|
||||||
.enum(["newest", "oldest", "highest_rated", "hot"])
|
|
||||||
.default("newest");
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Type Exports
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export type CommentSortMode = z.infer<typeof commentSortSchema>;
|
|
||||||
export type ReactionType = z.infer<typeof reactionTypeSchema>;
|
|
||||||
export type CreateCommentInput = z.infer<typeof createCommentSchema>;
|
|
||||||
export type UpdateCommentInput = z.infer<typeof updateCommentSchema>;
|
|
||||||
export type DeleteCommentInput = z.infer<typeof deleteCommentSchema>;
|
|
||||||
export type GetCommentsInput = z.infer<typeof getCommentsSchema>;
|
|
||||||
export type ToggleCommentReactionInput = z.infer<
|
|
||||||
typeof toggleCommentReactionSchema
|
|
||||||
>;
|
|
||||||
export type GetCommentReactionsInput = z.infer<
|
|
||||||
typeof getCommentReactionsSchema
|
|
||||||
>;
|
|
||||||
@@ -7,71 +7,10 @@ import { z } from "zod";
|
|||||||
* Use these schemas for validating database inputs and outputs in tRPC procedures
|
* Use these schemas for validating database inputs and outputs in tRPC procedures
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// User Schemas
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Full User schema matching database structure
|
|
||||||
*/
|
|
||||||
export const userSchema = z.object({
|
|
||||||
id: z.string(),
|
|
||||||
email: z.string().email().nullable().optional(),
|
|
||||||
email_verified: z.number(),
|
|
||||||
password_hash: z.string().nullable().optional(),
|
|
||||||
display_name: z.string().nullable().optional(),
|
|
||||||
provider: z.enum(["email", "google", "github"]).nullable().optional(),
|
|
||||||
image: z.string().url().nullable().optional(),
|
|
||||||
apple_user_string: z.string().nullable().optional(),
|
|
||||||
database_name: z.string().nullable().optional(),
|
|
||||||
database_token: z.string().nullable().optional(),
|
|
||||||
database_url: z.string().nullable().optional(),
|
|
||||||
db_destroy_date: z.string().nullable().optional(),
|
|
||||||
created_at: z.string(),
|
|
||||||
updated_at: z.string()
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User creation input (for registration)
|
|
||||||
*/
|
|
||||||
export const createUserSchema = z.object({
|
|
||||||
email: z.string().email().optional(),
|
|
||||||
password: z.string().min(8).optional(),
|
|
||||||
display_name: z.string().min(1).max(50).optional(),
|
|
||||||
provider: z.enum(["email", "google", "github"]).optional(),
|
|
||||||
image: z.string().url().optional()
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User update input (partial updates)
|
|
||||||
*/
|
|
||||||
export const updateUserSchema = z.object({
|
|
||||||
email: z.string().email().optional(),
|
|
||||||
display_name: z.string().min(1).max(50).optional(),
|
|
||||||
image: z.string().url().optional()
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Post Schemas
|
// Post Schemas
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* Full Post schema matching database structure
|
|
||||||
*/
|
|
||||||
export const postSchema = z.object({
|
|
||||||
id: z.number(),
|
|
||||||
category: z.enum(["blog", "project"]),
|
|
||||||
title: z.string(),
|
|
||||||
subtitle: z.string().optional(),
|
|
||||||
body: z.string(),
|
|
||||||
banner_photo: z.string().optional(),
|
|
||||||
date: z.string(),
|
|
||||||
published: z.boolean(),
|
|
||||||
author_id: z.string(),
|
|
||||||
reads: z.number(),
|
|
||||||
attachments: z.string().optional()
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Post creation input
|
* Post creation input
|
||||||
*/
|
*/
|
||||||
@@ -97,47 +36,6 @@ export const updatePostSchema = z.object({
|
|||||||
attachments: z.string().optional()
|
attachments: z.string().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Post with aggregated data
|
|
||||||
*/
|
|
||||||
export const postWithCommentsAndLikesSchema = postSchema.extend({
|
|
||||||
total_likes: z.number(),
|
|
||||||
total_comments: z.number()
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Comment Schemas
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Full Comment schema matching database structure
|
|
||||||
*/
|
|
||||||
export const commentSchema = z.object({
|
|
||||||
id: z.number(),
|
|
||||||
body: z.string(),
|
|
||||||
post_id: z.number(),
|
|
||||||
parent_comment_id: z.number().optional(),
|
|
||||||
date: z.string(),
|
|
||||||
edited: z.boolean(),
|
|
||||||
commenter_id: z.string()
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Comment creation input
|
|
||||||
*/
|
|
||||||
export const createCommentSchema = z.object({
|
|
||||||
body: z.string().min(1).max(5000),
|
|
||||||
post_id: z.number(),
|
|
||||||
parent_comment_id: z.number().optional()
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Comment update input
|
|
||||||
*/
|
|
||||||
export const updateCommentSchema = z.object({
|
|
||||||
body: z.string().min(1).max(5000)
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// CommentReaction Schemas
|
// CommentReaction Schemas
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -160,94 +58,6 @@ export const reactionTypeSchema = z.enum([
|
|||||||
"downVote"
|
"downVote"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
|
||||||
* Full CommentReaction schema matching database structure
|
|
||||||
*/
|
|
||||||
export const commentReactionSchema = z.object({
|
|
||||||
id: z.number(),
|
|
||||||
type: reactionTypeSchema,
|
|
||||||
comment_id: z.number(),
|
|
||||||
user_id: z.string()
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Comment reaction creation input
|
|
||||||
*/
|
|
||||||
export const createCommentReactionSchema = z.object({
|
|
||||||
type: reactionTypeSchema,
|
|
||||||
comment_id: z.number()
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// PostLike Schemas
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Full PostLike schema matching database structure
|
|
||||||
*/
|
|
||||||
export const postLikeSchema = z.object({
|
|
||||||
id: z.number(),
|
|
||||||
user_id: z.string(),
|
|
||||||
post_id: z.number()
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PostLike creation input
|
|
||||||
*/
|
|
||||||
export const createPostLikeSchema = z.object({
|
|
||||||
post_id: z.number()
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Tag Schemas
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Full Tag schema matching database structure
|
|
||||||
*/
|
|
||||||
export const tagSchema = z.object({
|
|
||||||
id: z.number(),
|
|
||||||
value: z.string(),
|
|
||||||
post_id: z.number()
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tag creation input
|
|
||||||
*/
|
|
||||||
export const createTagSchema = z.object({
|
|
||||||
value: z.string().min(1).max(50),
|
|
||||||
post_id: z.number()
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PostWithTags schema
|
|
||||||
*/
|
|
||||||
export const postWithTagsSchema = postSchema.extend({
|
|
||||||
tags: z.array(tagSchema)
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Connection Schemas
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Full Connection schema matching database structure
|
|
||||||
*/
|
|
||||||
export const connectionSchema = z.object({
|
|
||||||
id: z.number(),
|
|
||||||
user_id: z.string(),
|
|
||||||
connection_id: z.string(),
|
|
||||||
post_id: z.number().optional()
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connection creation input
|
|
||||||
*/
|
|
||||||
export const createConnectionSchema = z.object({
|
|
||||||
connection_id: z.string(),
|
|
||||||
post_id: z.number().optional()
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Common Query Schemas
|
// Common Query Schemas
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -259,26 +69,6 @@ export const idSchema = z.object({
|
|||||||
id: z.number()
|
id: z.number()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const userIdSchema = z.object({
|
|
||||||
userId: z.string()
|
|
||||||
});
|
|
||||||
|
|
||||||
export const postIdSchema = z.object({
|
|
||||||
postId: z.number()
|
|
||||||
});
|
|
||||||
|
|
||||||
export const commentIdSchema = z.object({
|
|
||||||
commentId: z.number()
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pagination schema
|
|
||||||
*/
|
|
||||||
export const paginationSchema = z.object({
|
|
||||||
limit: z.number().min(1).max(100).default(10),
|
|
||||||
offset: z.number().min(0).default(0)
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Additional Database Router Schemas
|
// Additional Database Router Schemas
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -343,10 +133,6 @@ export const getUserByIdSchema = z.object({
|
|||||||
id: z.string()
|
id: z.string()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getUserPublicDataSchema = z.object({
|
|
||||||
id: z.string()
|
|
||||||
});
|
|
||||||
|
|
||||||
export const updateUserImageSchema = z.object({
|
export const updateUserImageSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
imageURL: z.string()
|
imageURL: z.string()
|
||||||
@@ -365,15 +151,6 @@ export const updateUserEmailSchema = z.object({
|
|||||||
export type ReactionType = z.infer<typeof reactionTypeSchema>;
|
export type ReactionType = z.infer<typeof reactionTypeSchema>;
|
||||||
export type CreatePostInput = z.infer<typeof createPostSchema>;
|
export type CreatePostInput = z.infer<typeof createPostSchema>;
|
||||||
export type UpdatePostInput = z.infer<typeof updatePostSchema>;
|
export type UpdatePostInput = z.infer<typeof updatePostSchema>;
|
||||||
export type CreateCommentInput = z.infer<typeof createCommentSchema>;
|
|
||||||
export type UpdateCommentInput = z.infer<typeof updateCommentSchema>;
|
|
||||||
export type CreateCommentReactionInput = z.infer<
|
|
||||||
typeof createCommentReactionSchema
|
|
||||||
>;
|
|
||||||
export type CreatePostLikeInput = z.infer<typeof createPostLikeSchema>;
|
|
||||||
export type CreateTagInput = z.infer<typeof createTagSchema>;
|
|
||||||
export type CreateConnectionInput = z.infer<typeof createConnectionSchema>;
|
|
||||||
export type PaginationInput = z.infer<typeof paginationSchema>;
|
|
||||||
export type GetPostByIdInput = z.infer<typeof getPostByIdSchema>;
|
export type GetPostByIdInput = z.infer<typeof getPostByIdSchema>;
|
||||||
export type GetPostByTitleInput = z.infer<typeof getPostByTitleSchema>;
|
export type GetPostByTitleInput = z.infer<typeof getPostByTitleSchema>;
|
||||||
export type GetCommentsByPostIdInput = z.infer<
|
export type GetCommentsByPostIdInput = z.infer<
|
||||||
|
|||||||
@@ -65,11 +65,6 @@ export const loginUserSchema = z.object({
|
|||||||
rememberMe: z.boolean().optional().default(false)
|
rememberMe: z.boolean().optional().default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* OAuth provider schema
|
|
||||||
*/
|
|
||||||
export const oauthProviderSchema = z.enum(["google", "github"]);
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Profile Management Schemas
|
// Profile Management Schemas
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -168,20 +163,12 @@ export const deleteAccountSchema = z.object({
|
|||||||
password: z.string().min(1, "Password is required to delete account")
|
password: z.string().min(1, "Password is required to delete account")
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Email verification schema
|
|
||||||
*/
|
|
||||||
export const verifyEmailSchema = z.object({
|
|
||||||
token: z.string().min(1)
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Type Exports
|
// Type Exports
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export type RegisterUserInput = z.infer<typeof registerUserSchema>;
|
export type RegisterUserInput = z.infer<typeof registerUserSchema>;
|
||||||
export type LoginUserInput = z.infer<typeof loginUserSchema>;
|
export type LoginUserInput = z.infer<typeof loginUserSchema>;
|
||||||
export type OAuthProvider = z.infer<typeof oauthProviderSchema>;
|
|
||||||
export type UpdateEmailInput = z.infer<typeof updateEmailSchema>;
|
export type UpdateEmailInput = z.infer<typeof updateEmailSchema>;
|
||||||
export type UpdateDisplayNameInput = z.infer<typeof updateDisplayNameSchema>;
|
export type UpdateDisplayNameInput = z.infer<typeof updateDisplayNameSchema>;
|
||||||
export type UpdateProfileImageInput = z.infer<typeof updateProfileImageSchema>;
|
export type UpdateProfileImageInput = z.infer<typeof updateProfileImageSchema>;
|
||||||
@@ -192,4 +179,3 @@ export type RequestPasswordResetInput = z.infer<
|
|||||||
>;
|
>;
|
||||||
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
|
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
|
||||||
export type DeleteAccountInput = z.infer<typeof deleteAccountSchema>;
|
export type DeleteAccountInput = z.infer<typeof deleteAccountSchema>;
|
||||||
export type VerifyEmailInput = z.infer<typeof verifyEmailSchema>;
|
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ describe("Audit Logging System", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should not throw errors on logging failures", async () => {
|
it("should not throw errors on logging failures", async () => {
|
||||||
// This should not throw even if there's an invalid event type
|
|
||||||
await expect(
|
await expect(
|
||||||
logAuditEvent({
|
logAuditEvent({
|
||||||
eventType: "invalid.test.event",
|
eventType: "invalid.test.event",
|
||||||
@@ -101,7 +100,6 @@ describe("Audit Logging System", () => {
|
|||||||
|
|
||||||
describe("queryAuditLogs", () => {
|
describe("queryAuditLogs", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
// Create test logs
|
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
eventType: "auth.login.success",
|
eventType: "auth.login.success",
|
||||||
eventData: { test: "test-query-1", testUser: "user-1" },
|
eventData: { test: "test-query-1", testUser: "user-1" },
|
||||||
@@ -199,7 +197,6 @@ describe("Audit Logging System", () => {
|
|||||||
|
|
||||||
describe("getFailedLoginAttempts", () => {
|
describe("getFailedLoginAttempts", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
// Create failed login attempts
|
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
eventType: "auth.login.failed",
|
eventType: "auth.login.failed",
|
||||||
@@ -212,7 +209,6 @@ describe("Audit Logging System", () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create successful logins (should be excluded)
|
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
eventType: "auth.login.success",
|
eventType: "auth.login.success",
|
||||||
eventData: { test: "test-success-1" },
|
eventData: { test: "test-success-1" },
|
||||||
@@ -241,7 +237,6 @@ describe("Audit Logging System", () => {
|
|||||||
const attemptsIn1h = await getFailedLoginAttempts(1, 100);
|
const attemptsIn1h = await getFailedLoginAttempts(1, 100);
|
||||||
|
|
||||||
expect(attemptsIn24h.length).toBeGreaterThanOrEqual(5);
|
expect(attemptsIn24h.length).toBeGreaterThanOrEqual(5);
|
||||||
// Recent attempts should be within 1 hour
|
|
||||||
expect(attemptsIn1h.length).toBeGreaterThanOrEqual(5);
|
expect(attemptsIn1h.length).toBeGreaterThanOrEqual(5);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -302,7 +297,6 @@ describe("Audit Logging System", () => {
|
|||||||
|
|
||||||
describe("detectSuspiciousActivity", () => {
|
describe("detectSuspiciousActivity", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
// Create suspicious pattern: many failed logins from same IP
|
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
eventType: "auth.login.failed",
|
eventType: "auth.login.failed",
|
||||||
@@ -315,7 +309,6 @@ describe("Audit Logging System", () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create normal activity
|
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
eventType: "auth.login.success",
|
eventType: "auth.login.success",
|
||||||
eventData: { test: "test-normal-1" },
|
eventData: { test: "test-normal-1" },
|
||||||
@@ -344,7 +337,6 @@ describe("Audit Logging System", () => {
|
|||||||
it("should return empty array when no suspicious activity", async () => {
|
it("should return empty array when no suspicious activity", async () => {
|
||||||
await cleanupTestLogs();
|
await cleanupTestLogs();
|
||||||
|
|
||||||
// Create only successful logins
|
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
eventType: "auth.login.success",
|
eventType: "auth.login.success",
|
||||||
eventData: { test: "test-clean-1" },
|
eventData: { test: "test-clean-1" },
|
||||||
@@ -378,7 +370,6 @@ describe("Audit Logging System", () => {
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clean up logs older than 90 days
|
|
||||||
const deleted = await cleanupOldLogs(90);
|
const deleted = await cleanupOldLogs(90);
|
||||||
|
|
||||||
expect(deleted).toBeGreaterThanOrEqual(1);
|
expect(deleted).toBeGreaterThanOrEqual(1);
|
||||||
@@ -399,7 +390,6 @@ describe("Audit Logging System", () => {
|
|||||||
|
|
||||||
const logsAfter = await queryAuditLogs({ limit: 100 });
|
const logsAfter = await queryAuditLogs({ limit: 100 });
|
||||||
|
|
||||||
// Should still have recent logs
|
|
||||||
expect(logsAfter.length).toBeGreaterThan(0);
|
expect(logsAfter.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -405,7 +405,6 @@ export async function detectSuspiciousActivity(
|
|||||||
const currentIp = currentIpOrMinAttempts as string;
|
const currentIp = currentIpOrMinAttempts as string;
|
||||||
const reasons: string[] = [];
|
const reasons: string[] = [];
|
||||||
|
|
||||||
// Check for excessive failed logins
|
|
||||||
const failedAttempts = (await getFailedLoginAttempts(
|
const failedAttempts = (await getFailedLoginAttempts(
|
||||||
userId,
|
userId,
|
||||||
"user_id",
|
"user_id",
|
||||||
@@ -415,7 +414,6 @@ export async function detectSuspiciousActivity(
|
|||||||
reasons.push(`${failedAttempts} failed login attempts in last 15 minutes`);
|
reasons.push(`${failedAttempts} failed login attempts in last 15 minutes`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for rapid location changes (different IPs in short time)
|
|
||||||
const recentIps = await conn.execute({
|
const recentIps = await conn.execute({
|
||||||
sql: `SELECT DISTINCT ip_address FROM AuditLog
|
sql: `SELECT DISTINCT ip_address FROM AuditLog
|
||||||
WHERE user_id = ?
|
WHERE user_id = ?
|
||||||
@@ -431,7 +429,6 @@ export async function detectSuspiciousActivity(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for new IP if user has login history
|
|
||||||
const ipHistory = await conn.execute({
|
const ipHistory = await conn.execute({
|
||||||
sql: `SELECT COUNT(*) as count FROM AuditLog
|
sql: `SELECT COUNT(*) as count FROM AuditLog
|
||||||
WHERE user_id = ?
|
WHERE user_id = ?
|
||||||
|
|||||||
@@ -100,7 +100,6 @@ export async function withCacheAndStale<T>(
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const entry = store.get(key) as CacheEntry<T> | undefined;
|
const entry = store.get(key) as CacheEntry<T> | undefined;
|
||||||
|
|
||||||
// Fresh hit
|
|
||||||
if (entry && entry.expiresAt > now) return entry.data;
|
if (entry && entry.expiresAt > now) return entry.data;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -116,7 +115,6 @@ export async function withCacheAndStale<T>(
|
|||||||
console.error(`Error fetching data for cache key "${key}":`, error);
|
console.error(`Error fetching data for cache key "${key}":`, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stale fallback
|
|
||||||
if (entry && entry.staleExpiresAt > now) {
|
if (entry && entry.staleExpiresAt > now) {
|
||||||
if (logErrors) console.log(`Serving stale data for cache key "${key}"`);
|
if (logErrors) console.log(`Serving stale data for cache key "${key}"`);
|
||||||
return entry.data;
|
return entry.data;
|
||||||
|
|||||||
@@ -254,7 +254,6 @@ describe("Clerk user.created webhook", () => {
|
|||||||
|
|
||||||
describe("Clerk user.updated webhook", () => {
|
describe("Clerk user.updated webhook", () => {
|
||||||
it("updates mutable fields and leaves clerkUserId unchanged", async () => {
|
it("updates mutable fields and leaves clerkUserId unchanged", async () => {
|
||||||
// seed via created
|
|
||||||
await call(sign(userCreatedPayload()));
|
await call(sign(userCreatedPayload()));
|
||||||
|
|
||||||
const before = getUserByClerkId("user_abc123");
|
const before = getUserByClerkId("user_abc123");
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
import type { H3Event } from "vinxi/http";
|
|
||||||
import { UAParser } from "ua-parser-js";
|
|
||||||
|
|
||||||
export interface DeviceInfo {
|
export interface DeviceInfo {
|
||||||
deviceName?: string;
|
deviceName?: string;
|
||||||
deviceType?: "desktop" | "mobile" | "tablet";
|
deviceType?: "desktop" | "mobile" | "tablet";
|
||||||
@@ -8,61 +5,6 @@ export interface DeviceInfo {
|
|||||||
os?: string;
|
os?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse user agent string to extract device information
|
|
||||||
* @param userAgent - User agent string from request headers
|
|
||||||
* @returns Parsed device information
|
|
||||||
*/
|
|
||||||
export function parseDeviceInfo(userAgent: string): DeviceInfo {
|
|
||||||
const parser = new UAParser(userAgent);
|
|
||||||
const result = parser.getResult();
|
|
||||||
|
|
||||||
// Determine device type
|
|
||||||
let deviceType: "desktop" | "mobile" | "tablet" = "desktop";
|
|
||||||
if (result.device.type === "mobile") {
|
|
||||||
deviceType = "mobile";
|
|
||||||
} else if (result.device.type === "tablet") {
|
|
||||||
deviceType = "tablet";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build device name (e.g., "iPhone 14", "Windows PC", "iPad Pro")
|
|
||||||
let deviceName: string | undefined;
|
|
||||||
if (result.device.vendor && result.device.model) {
|
|
||||||
deviceName = `${result.device.vendor} ${result.device.model}`;
|
|
||||||
} else if (result.os.name) {
|
|
||||||
deviceName = `${result.os.name} ${deviceType === "desktop" ? "Computer" : deviceType}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Browser info (e.g., "Chrome 120")
|
|
||||||
const browser =
|
|
||||||
result.browser.name && result.browser.version
|
|
||||||
? `${result.browser.name} ${result.browser.version.split(".")[0]}`
|
|
||||||
: result.browser.name;
|
|
||||||
|
|
||||||
// OS info (e.g., "macOS 14.1", "Windows 11", "iOS 17")
|
|
||||||
const os =
|
|
||||||
result.os.name && result.os.version
|
|
||||||
? `${result.os.name} ${result.os.version}`
|
|
||||||
: result.os.name;
|
|
||||||
|
|
||||||
return {
|
|
||||||
deviceName,
|
|
||||||
deviceType,
|
|
||||||
browser,
|
|
||||||
os
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract device information from H3Event
|
|
||||||
* @param event - H3Event
|
|
||||||
* @returns Device information
|
|
||||||
*/
|
|
||||||
export function getDeviceInfo(event: H3Event): DeviceInfo {
|
|
||||||
const userAgent = event.node.req.headers["user-agent"] || "";
|
|
||||||
return parseDeviceInfo(userAgent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a human-readable device description
|
* Generate a human-readable device description
|
||||||
* @param deviceInfo - Device information
|
* @param deviceInfo - Device information
|
||||||
@@ -85,18 +27,3 @@ export function formatDeviceDescription(deviceInfo: DeviceInfo): string {
|
|||||||
|
|
||||||
return parts.length > 0 ? parts.join(" • ") : "Unknown Device";
|
return parts.length > 0 ? parts.join(" • ") : "Unknown Device";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a short device fingerprint for comparison
|
|
||||||
* Not cryptographic, just for grouping similar logins
|
|
||||||
* @param deviceInfo - Device information
|
|
||||||
* @returns Short fingerprint string
|
|
||||||
*/
|
|
||||||
export function createDeviceFingerprint(deviceInfo: DeviceInfo): string {
|
|
||||||
const parts = [
|
|
||||||
deviceInfo.deviceType || "unknown",
|
|
||||||
deviceInfo.os?.split(" ")[0] || "unknown",
|
|
||||||
deviceInfo.browser?.split(" ")[0] || "unknown"
|
|
||||||
];
|
|
||||||
return parts.join("-").toLowerCase();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import loginLinkTemplate from "./login-link.html?raw";
|
|||||||
import passwordResetTemplate from "./password-reset.html?raw";
|
import passwordResetTemplate from "./password-reset.html?raw";
|
||||||
import emailVerificationTemplate from "./email-verification.html?raw";
|
import emailVerificationTemplate from "./email-verification.html?raw";
|
||||||
import providerLinkedTemplate from "./provider-linked.html?raw";
|
import providerLinkedTemplate from "./provider-linked.html?raw";
|
||||||
import newDeviceLoginTemplate from "./new-device-login.html?raw";
|
|
||||||
import passwordSetTemplate from "./password-set.html?raw";
|
import passwordSetTemplate from "./password-set.html?raw";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -119,29 +118,6 @@ export function generateProviderLinkedEmail(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NewDeviceLoginEmailParams {
|
|
||||||
deviceInfo: string;
|
|
||||||
loginTime: string;
|
|
||||||
ipAddress: string;
|
|
||||||
loginMethod: string;
|
|
||||||
accountUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate new device login notification email HTML
|
|
||||||
*/
|
|
||||||
export function generateNewDeviceLoginEmail(
|
|
||||||
params: NewDeviceLoginEmailParams
|
|
||||||
): string {
|
|
||||||
return processTemplate(newDeviceLoginTemplate, {
|
|
||||||
DEVICE_INFO: params.deviceInfo,
|
|
||||||
LOGIN_TIME: params.loginTime,
|
|
||||||
IP_ADDRESS: params.ipAddress,
|
|
||||||
LOGIN_METHOD: params.loginMethod,
|
|
||||||
ACCOUNT_URL: params.accountUrl
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PasswordSetEmailParams {
|
export interface PasswordSetEmailParams {
|
||||||
providerName: string;
|
providerName: string;
|
||||||
setTime: string;
|
setTime: string;
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
async function testTimeoutError() {
|
async function testTimeoutError() {
|
||||||
console.log("\n=== Testing Timeout Error ===");
|
console.log("\n=== Testing Timeout Error ===");
|
||||||
try {
|
try {
|
||||||
// This should timeout after 1ms
|
|
||||||
await fetchWithTimeout("https://httpbin.org/delay/10", { timeout: 1 });
|
await fetchWithTimeout("https://httpbin.org/delay/10", { timeout: 1 });
|
||||||
console.log("❌ Should have thrown TimeoutError");
|
console.log("❌ Should have thrown TimeoutError");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -29,7 +28,6 @@ async function testTimeoutError() {
|
|||||||
async function testNetworkError() {
|
async function testNetworkError() {
|
||||||
console.log("\n=== Testing Network Error ===");
|
console.log("\n=== Testing Network Error ===");
|
||||||
try {
|
try {
|
||||||
// This should fail to connect
|
|
||||||
await fetchWithTimeout(
|
await fetchWithTimeout(
|
||||||
"https://invalid-domain-that-does-not-exist-12345.com"
|
"https://invalid-domain-that-does-not-exist-12345.com"
|
||||||
);
|
);
|
||||||
@@ -47,7 +45,6 @@ async function testNetworkError() {
|
|||||||
async function testAPIError() {
|
async function testAPIError() {
|
||||||
console.log("\n=== Testing API Error ===");
|
console.log("\n=== Testing API Error ===");
|
||||||
try {
|
try {
|
||||||
// This should return 404
|
|
||||||
const response = await fetchWithTimeout("https://httpbin.org/status/404");
|
const response = await fetchWithTimeout("https://httpbin.org/status/404");
|
||||||
await checkResponse(response);
|
await checkResponse(response);
|
||||||
console.log("❌ Should have thrown APIError");
|
console.log("❌ Should have thrown APIError");
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
import { defineMiddleware, setHeaders } from "vinxi/http";
|
|
||||||
|
|
||||||
// Security headers middleware — sets CSP and hardening headers on all responses
|
|
||||||
export default defineMiddleware({
|
|
||||||
onRequest: (event) => {
|
|
||||||
setHeaders(event, {
|
|
||||||
"Content-Security-Policy":
|
|
||||||
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
|
|
||||||
"X-Content-Type-Options": "nosniff",
|
|
||||||
"X-Frame-Options": "DENY",
|
|
||||||
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
||||||
"Permissions-Policy": "camera=(), microphone=(), geolocation=()"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -57,7 +57,6 @@ describe("verifyNessaToken with Clerk JWT", () => {
|
|||||||
expect(result.exp).toBe(mockPayload.exp);
|
expect(result.exp).toBe(mockPayload.exp);
|
||||||
expect(result.iat).toBe(mockPayload.iat);
|
expect(result.iat).toBe(mockPayload.iat);
|
||||||
|
|
||||||
// Verify verifyToken was called with correct options
|
|
||||||
expect(mockVerifyToken).toHaveBeenCalledWith(
|
expect(mockVerifyToken).toHaveBeenCalledWith(
|
||||||
"valid-clerk-session-token",
|
"valid-clerk-session-token",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ export async function verifyNessaToken(
|
|||||||
): Promise<NessaAuthPayload> {
|
): Promise<NessaAuthPayload> {
|
||||||
const payload = await verifyToken(token, {
|
const payload = await verifyToken(token, {
|
||||||
secretKey: env.NESSA_CLERK_SECRET,
|
secretKey: env.NESSA_CLERK_SECRET,
|
||||||
// Optional: restrict to specific issuers / apps
|
|
||||||
// audience: env.NESSA_CLERK_JWT_ISSUER,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!payload.sub) {
|
if (!payload.sub) {
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ export async function linkProvider(
|
|||||||
): Promise<UserProvider> {
|
): Promise<UserProvider> {
|
||||||
const conn = ConnectionFactory();
|
const conn = ConnectionFactory();
|
||||||
|
|
||||||
// Check if provider already linked to this user
|
|
||||||
const existing = await conn.execute({
|
const existing = await conn.execute({
|
||||||
sql: "SELECT * FROM UserProvider WHERE user_id = ? AND provider = ?",
|
sql: "SELECT * FROM UserProvider WHERE user_id = ? AND provider = ?",
|
||||||
args: [userId, provider]
|
args: [userId, provider]
|
||||||
@@ -44,7 +43,6 @@ export async function linkProvider(
|
|||||||
throw new Error(`Provider ${provider} already linked to this account`);
|
throw new Error(`Provider ${provider} already linked to this account`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if provider identity is already used by another user
|
|
||||||
if (providerData.providerUserId) {
|
if (providerData.providerUserId) {
|
||||||
const conflictCheck = await conn.execute({
|
const conflictCheck = await conn.execute({
|
||||||
sql: "SELECT user_id FROM UserProvider WHERE provider = ? AND provider_user_id = ?",
|
sql: "SELECT user_id FROM UserProvider WHERE provider = ? AND provider_user_id = ?",
|
||||||
@@ -61,7 +59,6 @@ export async function linkProvider(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new provider link
|
|
||||||
const id = uuidV4();
|
const id = uuidV4();
|
||||||
await conn.execute({
|
await conn.execute({
|
||||||
sql: `INSERT INTO UserProvider (id, user_id, provider, provider_user_id, email, display_name, image)
|
sql: `INSERT INTO UserProvider (id, user_id, provider, provider_user_id, email, display_name, image)
|
||||||
@@ -77,7 +74,6 @@ export async function linkProvider(
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch created record
|
|
||||||
const result = await conn.execute({
|
const result = await conn.execute({
|
||||||
sql: "SELECT * FROM UserProvider WHERE id = ?",
|
sql: "SELECT * FROM UserProvider WHERE id = ?",
|
||||||
args: [id]
|
args: [id]
|
||||||
@@ -85,7 +81,6 @@ export async function linkProvider(
|
|||||||
|
|
||||||
const userProvider = result.rows[0] as unknown as UserProvider;
|
const userProvider = result.rows[0] as unknown as UserProvider;
|
||||||
|
|
||||||
// Log audit event
|
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
userId,
|
userId,
|
||||||
eventType: "auth.provider.linked",
|
eventType: "auth.provider.linked",
|
||||||
@@ -99,7 +94,6 @@ export async function linkProvider(
|
|||||||
// Send notification email if requested and user has email
|
// Send notification email if requested and user has email
|
||||||
if (options?.sendEmail !== false) {
|
if (options?.sendEmail !== false) {
|
||||||
try {
|
try {
|
||||||
// Get user email
|
|
||||||
const userResult = await conn.execute({
|
const userResult = await conn.execute({
|
||||||
sql: "SELECT email FROM User WHERE id = ?",
|
sql: "SELECT email FROM User WHERE id = ?",
|
||||||
args: [userId]
|
args: [userId]
|
||||||
@@ -150,7 +144,6 @@ export async function unlinkProvider(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const conn = ConnectionFactory();
|
const conn = ConnectionFactory();
|
||||||
|
|
||||||
// Check how many providers this user has
|
|
||||||
const providersResult = await conn.execute({
|
const providersResult = await conn.execute({
|
||||||
sql: "SELECT COUNT(*) as count FROM UserProvider WHERE user_id = ?",
|
sql: "SELECT COUNT(*) as count FROM UserProvider WHERE user_id = ?",
|
||||||
args: [userId]
|
args: [userId]
|
||||||
@@ -164,7 +157,6 @@ export async function unlinkProvider(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete provider
|
|
||||||
const result = await conn.execute({
|
const result = await conn.execute({
|
||||||
sql: "DELETE FROM UserProvider WHERE user_id = ? AND provider = ?",
|
sql: "DELETE FROM UserProvider WHERE user_id = ? AND provider = ?",
|
||||||
args: [userId, provider]
|
args: [userId, provider]
|
||||||
@@ -174,7 +166,6 @@ export async function unlinkProvider(
|
|||||||
throw new Error(`Provider ${provider} not found for this user`);
|
throw new Error(`Provider ${provider} not found for this user`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log audit event
|
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
userId,
|
userId,
|
||||||
eventType: "auth.provider.unlinked",
|
eventType: "auth.provider.unlinked",
|
||||||
@@ -261,7 +252,6 @@ export async function findUserByProviderEmail(
|
|||||||
export async function findUserByEmail(email: string): Promise<string | null> {
|
export async function findUserByEmail(email: string): Promise<string | null> {
|
||||||
const conn = ConnectionFactory();
|
const conn = ConnectionFactory();
|
||||||
|
|
||||||
// First check User table
|
|
||||||
const userResult = await conn.execute({
|
const userResult = await conn.execute({
|
||||||
sql: "SELECT id FROM User WHERE email = ?",
|
sql: "SELECT id FROM User WHERE email = ?",
|
||||||
args: [email]
|
args: [email]
|
||||||
@@ -271,7 +261,6 @@ export async function findUserByEmail(email: string): Promise<string | null> {
|
|||||||
return (userResult.rows[0] as any).id;
|
return (userResult.rows[0] as any).id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then check UserProvider table
|
|
||||||
const providerResult = await conn.execute({
|
const providerResult = await conn.execute({
|
||||||
sql: "SELECT user_id FROM UserProvider WHERE email = ? LIMIT 1",
|
sql: "SELECT user_id FROM UserProvider WHERE email = ? LIMIT 1",
|
||||||
args: [email]
|
args: [email]
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ describe("CSRF Protection", () => {
|
|||||||
const token = generateCSRFToken();
|
const token = generateCSRFToken();
|
||||||
expect(token).toBeDefined();
|
expect(token).toBeDefined();
|
||||||
expect(typeof token).toBe("string");
|
expect(typeof token).toBe("string");
|
||||||
// UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
|
|
||||||
expect(token).toMatch(
|
expect(token).toMatch(
|
||||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||||
);
|
);
|
||||||
@@ -34,7 +33,6 @@ describe("CSRF Protection", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should generate cryptographically secure tokens", () => {
|
it("should generate cryptographically secure tokens", () => {
|
||||||
// Generate multiple tokens and ensure no collisions
|
|
||||||
const tokens = new Set<string>();
|
const tokens = new Set<string>();
|
||||||
for (let i = 0; i < 1000; i++) {
|
for (let i = 0; i < 1000; i++) {
|
||||||
tokens.add(generateCSRFToken());
|
tokens.add(generateCSRFToken());
|
||||||
@@ -50,7 +48,6 @@ describe("CSRF Protection", () => {
|
|||||||
|
|
||||||
expect(token).toBeDefined();
|
expect(token).toBeDefined();
|
||||||
expect(typeof token).toBe("string");
|
expect(typeof token).toBe("string");
|
||||||
// Token should be a UUID
|
|
||||||
expect(token).toMatch(
|
expect(token).toMatch(
|
||||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||||
);
|
);
|
||||||
@@ -122,7 +119,6 @@ describe("CSRF Protection", () => {
|
|||||||
const invalidToken1 = "b".repeat(36);
|
const invalidToken1 = "b".repeat(36);
|
||||||
const invalidToken2 = "b".repeat(35) + "a";
|
const invalidToken2 = "b".repeat(35) + "a";
|
||||||
|
|
||||||
// Test timing for completely different tokens
|
|
||||||
const event1 = createMockEvent({
|
const event1 = createMockEvent({
|
||||||
headers: { "x-csrf-token": invalidToken1 },
|
headers: { "x-csrf-token": invalidToken1 },
|
||||||
cookies: { "csrf-token": validToken }
|
cookies: { "csrf-token": validToken }
|
||||||
@@ -132,7 +128,6 @@ describe("CSRF Protection", () => {
|
|||||||
validateCSRFToken(event1);
|
validateCSRFToken(event1);
|
||||||
const time1 = performance.now() - start1;
|
const time1 = performance.now() - start1;
|
||||||
|
|
||||||
// Test timing for tokens that differ only at the end
|
|
||||||
const event2 = createMockEvent({
|
const event2 = createMockEvent({
|
||||||
headers: { "x-csrf-token": invalidToken2 },
|
headers: { "x-csrf-token": invalidToken2 },
|
||||||
cookies: { "csrf-token": validToken }
|
cookies: { "csrf-token": validToken }
|
||||||
@@ -142,7 +137,6 @@ describe("CSRF Protection", () => {
|
|||||||
validateCSRFToken(event2);
|
validateCSRFToken(event2);
|
||||||
const time2 = performance.now() - start2;
|
const time2 = performance.now() - start2;
|
||||||
|
|
||||||
// Timing difference should be minimal (less than 1ms)
|
|
||||||
// This tests for constant-time comparison
|
// This tests for constant-time comparison
|
||||||
const timeDiff = Math.abs(time1 - time2);
|
const timeDiff = Math.abs(time1 - time2);
|
||||||
expect(timeDiff).toBeLessThan(1);
|
expect(timeDiff).toBeLessThan(1);
|
||||||
@@ -161,7 +155,6 @@ describe("CSRF Protection", () => {
|
|||||||
|
|
||||||
describe("CSRF Attack Scenarios", () => {
|
describe("CSRF Attack Scenarios", () => {
|
||||||
it("should prevent basic CSRF attack", () => {
|
it("should prevent basic CSRF attack", () => {
|
||||||
// Attacker doesn't have access to the CSRF token cookie
|
|
||||||
const attackEvent = createMockEvent({
|
const attackEvent = createMockEvent({
|
||||||
headers: { "x-csrf-token": "attacker-guessed-token" }
|
headers: { "x-csrf-token": "attacker-guessed-token" }
|
||||||
});
|
});
|
||||||
@@ -174,7 +167,6 @@ describe("CSRF Protection", () => {
|
|||||||
const token1 = generateCSRFToken();
|
const token1 = generateCSRFToken();
|
||||||
const token2 = generateCSRFToken();
|
const token2 = generateCSRFToken();
|
||||||
|
|
||||||
// User has token1, attacker tries to use token2
|
|
||||||
const event = createMockEvent({
|
const event = createMockEvent({
|
||||||
headers: { "x-csrf-token": token2 },
|
headers: { "x-csrf-token": token2 },
|
||||||
cookies: { "csrf-token": token1 }
|
cookies: { "csrf-token": token1 }
|
||||||
@@ -198,7 +190,6 @@ describe("CSRF Protection", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should prevent replay attacks with old tokens", () => {
|
it("should prevent replay attacks with old tokens", () => {
|
||||||
// Simulate an old token that was captured
|
|
||||||
const oldToken = "old-captured-token-12345";
|
const oldToken = "old-captured-token-12345";
|
||||||
|
|
||||||
const event = createMockEvent({
|
const event = createMockEvent({
|
||||||
@@ -206,10 +197,8 @@ describe("CSRF Protection", () => {
|
|||||||
cookies: { "csrf-token": oldToken }
|
cookies: { "csrf-token": oldToken }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Even if tokens match, they should be validated by the system
|
|
||||||
// This test validates the structure works correctly
|
|
||||||
const isValid = validateCSRFToken(event);
|
const isValid = validateCSRFToken(event);
|
||||||
expect(isValid).toBe(true); // Matches are valid
|
expect(isValid).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -270,9 +259,7 @@ describe("CSRF Protection", () => {
|
|||||||
tokens.push(generateCSRFToken());
|
tokens.push(generateCSRFToken());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for sequential patterns
|
|
||||||
for (let i = 1; i < tokens.length; i++) {
|
for (let i = 1; i < tokens.length; i++) {
|
||||||
// Tokens should not be incrementing
|
|
||||||
expect(tokens[i]).not.toBe(
|
expect(tokens[i]).not.toBe(
|
||||||
String(Number(tokens[i - 1].replace(/-/g, "")) + 1)
|
String(Number(tokens[i - 1].replace(/-/g, "")) + 1)
|
||||||
);
|
);
|
||||||
@@ -281,11 +268,9 @@ describe("CSRF Protection", () => {
|
|||||||
|
|
||||||
it("should generate tokens with sufficient entropy", () => {
|
it("should generate tokens with sufficient entropy", () => {
|
||||||
const token = generateCSRFToken();
|
const token = generateCSRFToken();
|
||||||
// UUID without dashes should be 32 hex characters
|
|
||||||
const hexString = token.replace(/-/g, "");
|
const hexString = token.replace(/-/g, "");
|
||||||
expect(hexString).toMatch(/^[0-9a-f]{32}$/i);
|
expect(hexString).toMatch(/^[0-9a-f]{32}$/i);
|
||||||
|
|
||||||
// Check that not all characters are the same
|
|
||||||
const uniqueChars = new Set(hexString.split(""));
|
const uniqueChars = new Set(hexString.split(""));
|
||||||
expect(uniqueChars.size).toBeGreaterThan(5);
|
expect(uniqueChars.size).toBeGreaterThan(5);
|
||||||
});
|
});
|
||||||
@@ -299,7 +284,6 @@ describe("CSRF Protection", () => {
|
|||||||
}
|
}
|
||||||
const duration = performance.now() - start;
|
const duration = performance.now() - start;
|
||||||
|
|
||||||
// Should generate 1000 tokens in less than 100ms
|
|
||||||
expect(duration).toBeLessThan(100);
|
expect(duration).toBeLessThan(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -316,7 +300,6 @@ describe("CSRF Protection", () => {
|
|||||||
}
|
}
|
||||||
const duration = performance.now() - start;
|
const duration = performance.now() - start;
|
||||||
|
|
||||||
// Should validate 10000 tokens in less than 100ms
|
|
||||||
expect(duration).toBeLessThan(100);
|
expect(duration).toBeLessThan(100);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -402,7 +385,6 @@ describe("CSRF Protection", () => {
|
|||||||
const sessionAToken = generateCSRFToken();
|
const sessionAToken = generateCSRFToken();
|
||||||
const sessionBToken = generateCSRFToken();
|
const sessionBToken = generateCSRFToken();
|
||||||
|
|
||||||
// Session A's cookie with Session B's header token
|
|
||||||
const event = createMockEvent({
|
const event = createMockEvent({
|
||||||
headers: { "x-csrf-token": sessionBToken },
|
headers: { "x-csrf-token": sessionBToken },
|
||||||
cookies: { "csrf-token": sessionAToken }
|
cookies: { "csrf-token": sessionAToken }
|
||||||
@@ -522,12 +504,10 @@ describe("CSRF Protection", () => {
|
|||||||
it("should issue CSRF token on setCSRFToken then validate it", () => {
|
it("should issue CSRF token on setCSRFToken then validate it", () => {
|
||||||
const event = createMockEvent({});
|
const event = createMockEvent({});
|
||||||
|
|
||||||
// Step 1: Login issues CSRF token
|
|
||||||
const token = setCSRFToken(event);
|
const token = setCSRFToken(event);
|
||||||
expect(token).toBeDefined();
|
expect(token).toBeDefined();
|
||||||
expect(typeof token).toBe("string");
|
expect(typeof token).toBe("string");
|
||||||
|
|
||||||
// Step 2: Subsequent mutation sends token back
|
|
||||||
const mutationEvent = createMockEvent({
|
const mutationEvent = createMockEvent({
|
||||||
headers: { "x-csrf-token": token },
|
headers: { "x-csrf-token": token },
|
||||||
cookies: { "csrf-token": token }
|
cookies: { "csrf-token": token }
|
||||||
@@ -538,7 +518,6 @@ describe("CSRF Protection", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should reject cross-origin POST without CSRF token", () => {
|
it("should reject cross-origin POST without CSRF token", () => {
|
||||||
// Simulated cross-site POST: attacker can read cookies but not set headers
|
|
||||||
const attackEvent = createMockEvent({
|
const attackEvent = createMockEvent({
|
||||||
// No x-csrf-token header (cross-origin requests can't set custom headers)
|
// No x-csrf-token header (cross-origin requests can't set custom headers)
|
||||||
cookies: { "csrf-token": "victim-token" }
|
cookies: { "csrf-token": "victim-token" }
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ describe("Input Validation and Injection Prevention", () => {
|
|||||||
for (const email of sqlEmails) {
|
for (const email of sqlEmails) {
|
||||||
// Either reject as invalid, or it's properly escaped in queries
|
// Either reject as invalid, or it's properly escaped in queries
|
||||||
const isValid = isValidEmail(email);
|
const isValid = isValidEmail(email);
|
||||||
// Test documents the behavior
|
|
||||||
expect(typeof isValid).toBe("boolean");
|
expect(typeof isValid).toBe("boolean");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -68,7 +67,6 @@ describe("Input Validation and Injection Prevention", () => {
|
|||||||
const longEmail = "a".repeat(1000) + "@example.com";
|
const longEmail = "a".repeat(1000) + "@example.com";
|
||||||
const result = isValidEmail(longEmail);
|
const result = isValidEmail(longEmail);
|
||||||
|
|
||||||
// Should handle gracefully
|
|
||||||
expect(typeof result).toBe("boolean");
|
expect(typeof result).toBe("boolean");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -130,7 +128,6 @@ describe("Input Validation and Injection Prevention", () => {
|
|||||||
it("should use parameterized queries for user authentication", async () => {
|
it("should use parameterized queries for user authentication", async () => {
|
||||||
const conn = ConnectionFactory();
|
const conn = ConnectionFactory();
|
||||||
|
|
||||||
// Test that SQL injection attempts don't work
|
|
||||||
const maliciousEmail = "admin'--";
|
const maliciousEmail = "admin'--";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -140,7 +137,6 @@ describe("Input Validation and Injection Prevention", () => {
|
|||||||
args: [maliciousEmail]
|
args: [maliciousEmail]
|
||||||
});
|
});
|
||||||
|
|
||||||
// Should return no results (no user with that exact email)
|
|
||||||
expect(result.rows.length).toBe(0);
|
expect(result.rows.length).toBe(0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If error, ensure it's not a SQL error
|
// If error, ensure it's not a SQL error
|
||||||
@@ -153,7 +149,6 @@ describe("Input Validation and Injection Prevention", () => {
|
|||||||
|
|
||||||
for (const payload of SQL_INJECTION_PAYLOADS) {
|
for (const payload of SQL_INJECTION_PAYLOADS) {
|
||||||
try {
|
try {
|
||||||
// Test various injection points
|
|
||||||
await conn.execute({
|
await conn.execute({
|
||||||
sql: "SELECT * FROM User WHERE email = ?",
|
sql: "SELECT * FROM User WHERE email = ?",
|
||||||
args: [payload]
|
args: [payload]
|
||||||
@@ -164,7 +159,6 @@ describe("Input Validation and Injection Prevention", () => {
|
|||||||
args: [payload]
|
args: [payload]
|
||||||
});
|
});
|
||||||
|
|
||||||
// Queries should complete without SQL errors
|
|
||||||
expect(true).toBe(true);
|
expect(true).toBe(true);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// If error occurs, should not be SQL injection syntax error
|
// If error occurs, should not be SQL injection syntax error
|
||||||
@@ -184,10 +178,8 @@ describe("Input Validation and Injection Prevention", () => {
|
|||||||
args: [unionPayload]
|
args: [unionPayload]
|
||||||
});
|
});
|
||||||
|
|
||||||
// Should not return password hashes
|
|
||||||
if (result.rows.length > 0) {
|
if (result.rows.length > 0) {
|
||||||
for (const row of result.rows) {
|
for (const row of result.rows) {
|
||||||
// Ensure we don't get password_hash column
|
|
||||||
expect(row).not.toHaveProperty("password_hash");
|
expect(row).not.toHaveProperty("password_hash");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,7 +192,6 @@ describe("Input Validation and Injection Prevention", () => {
|
|||||||
it("should prevent blind SQL injection timing attacks", async () => {
|
it("should prevent blind SQL injection timing attacks", async () => {
|
||||||
const conn = ConnectionFactory();
|
const conn = ConnectionFactory();
|
||||||
|
|
||||||
// Timing-based payload
|
|
||||||
const timingPayload = "admin' AND SLEEP(5)--";
|
const timingPayload = "admin' AND SLEEP(5)--";
|
||||||
|
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
@@ -210,18 +201,15 @@ describe("Input Validation and Injection Prevention", () => {
|
|||||||
args: [timingPayload]
|
args: [timingPayload]
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ignore errors
|
|
||||||
}
|
}
|
||||||
const duration = performance.now() - start;
|
const duration = performance.now() - start;
|
||||||
|
|
||||||
// Should not delay for 5 seconds
|
|
||||||
expect(duration).toBeLessThan(1000);
|
expect(duration).toBeLessThan(1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should prevent second-order SQL injection", async () => {
|
it("should prevent second-order SQL injection", async () => {
|
||||||
const conn = ConnectionFactory();
|
const conn = ConnectionFactory();
|
||||||
|
|
||||||
// Store malicious data
|
|
||||||
const maliciousName = "admin'--";
|
const maliciousName = "admin'--";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -236,7 +224,6 @@ describe("Input Validation and Injection Prevention", () => {
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
// Retrieve and use (should still be safe with parameterized queries)
|
|
||||||
const result = await conn.execute({
|
const result = await conn.execute({
|
||||||
sql: "SELECT display_name FROM User WHERE email = ?",
|
sql: "SELECT display_name FROM User WHERE email = ?",
|
||||||
args: ["test-sqli@example.com"]
|
args: ["test-sqli@example.com"]
|
||||||
@@ -244,13 +231,11 @@ describe("Input Validation and Injection Prevention", () => {
|
|||||||
|
|
||||||
expect(result.rows.length).toBeGreaterThanOrEqual(0);
|
expect(result.rows.length).toBeGreaterThanOrEqual(0);
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
await conn.execute({
|
await conn.execute({
|
||||||
sql: "DELETE FROM User WHERE email = ?",
|
sql: "DELETE FROM User WHERE email = ?",
|
||||||
args: ["test-sqli@example.com"]
|
args: ["test-sqli@example.com"]
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Should not have SQL syntax errors
|
|
||||||
expect(error).toBeDefined();
|
expect(error).toBeDefined();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -260,7 +245,6 @@ describe("Input Validation and Injection Prevention", () => {
|
|||||||
it("should identify potentially dangerous XSS patterns", () => {
|
it("should identify potentially dangerous XSS patterns", () => {
|
||||||
// These payloads should be handled by frontend sanitization
|
// These payloads should be handled by frontend sanitization
|
||||||
for (const payload of XSS_PAYLOADS) {
|
for (const payload of XSS_PAYLOADS) {
|
||||||
// Document that these patterns exist
|
|
||||||
expect(payload).toBeDefined();
|
expect(payload).toBeDefined();
|
||||||
expect(typeof payload).toBe("string");
|
expect(typeof payload).toBe("string");
|
||||||
|
|
||||||
@@ -272,11 +256,9 @@ describe("Input Validation and Injection Prevention", () => {
|
|||||||
it("should handle script tags in user input", () => {
|
it("should handle script tags in user input", () => {
|
||||||
const scriptInput = "<script>alert('XSS')</script>";
|
const scriptInput = "<script>alert('XSS')</script>";
|
||||||
|
|
||||||
// Validation should not crash
|
|
||||||
const nameValid = isValidDisplayName(scriptInput);
|
const nameValid = isValidDisplayName(scriptInput);
|
||||||
expect(typeof nameValid).toBe("boolean");
|
expect(typeof nameValid).toBe("boolean");
|
||||||
|
|
||||||
// Email validation
|
|
||||||
const emailValid = isValidEmail(scriptInput);
|
const emailValid = isValidEmail(scriptInput);
|
||||||
expect(typeof emailValid).toBe("boolean");
|
expect(typeof emailValid).toBe("boolean");
|
||||||
});
|
});
|
||||||
@@ -450,7 +432,6 @@ describe("Input Validation and Injection Prevention", () => {
|
|||||||
|
|
||||||
expect(typeof emailValid).toBe("boolean");
|
expect(typeof emailValid).toBe("boolean");
|
||||||
expect(typeof nameValid).toBe("boolean");
|
expect(typeof nameValid).toBe("boolean");
|
||||||
// Should complete quickly (no ReDoS)
|
|
||||||
expect(duration).toBeLessThan(100);
|
expect(duration).toBeLessThan(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -508,14 +489,12 @@ describe("Input Validation and Injection Prevention", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should not be vulnerable to ReDoS attacks", () => {
|
it("should not be vulnerable to ReDoS attacks", () => {
|
||||||
// ReDoS payload with many repetitions
|
|
||||||
const redosPayload = "a".repeat(1000) + "!";
|
const redosPayload = "a".repeat(1000) + "!";
|
||||||
|
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
validatePassword(redosPayload);
|
validatePassword(redosPayload);
|
||||||
const duration = performance.now() - start;
|
const duration = performance.now() - start;
|
||||||
|
|
||||||
// Should complete quickly
|
|
||||||
expect(duration).toBeLessThan(100);
|
expect(duration).toBeLessThan(100);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ describe("Password Security", () => {
|
|||||||
|
|
||||||
expect(hash).toBeDefined();
|
expect(hash).toBeDefined();
|
||||||
expect(typeof hash).toBe("string");
|
expect(typeof hash).toBe("string");
|
||||||
// Bcrypt hashes start with $2b$ or $2a$
|
|
||||||
expect(hash).toMatch(/^\$2[ab]\$/);
|
expect(hash).toMatch(/^\$2[ab]\$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -36,7 +35,6 @@ describe("Password Security", () => {
|
|||||||
const password = "TestPassword123!";
|
const password = "TestPassword123!";
|
||||||
const hash = await hashPassword(password);
|
const hash = await hashPassword(password);
|
||||||
|
|
||||||
// Bcrypt hashes are 60 characters long
|
|
||||||
expect(hash.length).toBe(60);
|
expect(hash.length).toBe(60);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -132,32 +130,26 @@ describe("Password Security", () => {
|
|||||||
const password = "TestPassword123!";
|
const password = "TestPassword123!";
|
||||||
const hash = await hashPassword(password);
|
const hash = await hashPassword(password);
|
||||||
|
|
||||||
// Measure time for correct password
|
|
||||||
const { duration: correctDuration } = await measureTime(() =>
|
const { duration: correctDuration } = await measureTime(() =>
|
||||||
checkPasswordSafe(password, hash)
|
checkPasswordSafe(password, hash)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Measure time for incorrect password
|
|
||||||
const { duration: incorrectDuration } = await measureTime(() =>
|
const { duration: incorrectDuration } = await measureTime(() =>
|
||||||
checkPasswordSafe("WrongPassword123!", hash)
|
checkPasswordSafe("WrongPassword123!", hash)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Bcrypt comparison should take similar time regardless
|
|
||||||
const timingDifference = Math.abs(correctDuration - incorrectDuration);
|
const timingDifference = Math.abs(correctDuration - incorrectDuration);
|
||||||
|
|
||||||
// Allow reasonable variance (bcrypt is inherently slow)
|
|
||||||
expect(timingDifference).toBeLessThan(50);
|
expect(timingDifference).toBeLessThan(50);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle null hash without timing leak", async () => {
|
it("should handle null hash without timing leak", async () => {
|
||||||
const password = "TestPassword123!";
|
const password = "TestPassword123!";
|
||||||
|
|
||||||
// Measure time for null hash
|
|
||||||
const { result: result1, duration: duration1 } = await measureTime(() =>
|
const { result: result1, duration: duration1 } = await measureTime(() =>
|
||||||
checkPasswordSafe(password, null)
|
checkPasswordSafe(password, null)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Measure time for undefined hash
|
|
||||||
const { result: result2, duration: duration2 } = await measureTime(() =>
|
const { result: result2, duration: duration2 } = await measureTime(() =>
|
||||||
checkPasswordSafe(password, undefined)
|
checkPasswordSafe(password, undefined)
|
||||||
);
|
);
|
||||||
@@ -165,7 +157,6 @@ describe("Password Security", () => {
|
|||||||
expect(result1).toBe(false);
|
expect(result1).toBe(false);
|
||||||
expect(result2).toBe(false);
|
expect(result2).toBe(false);
|
||||||
|
|
||||||
// Should take similar time
|
|
||||||
const timingDifference = Math.abs(duration1 - duration2);
|
const timingDifference = Math.abs(duration1 - duration2);
|
||||||
expect(timingDifference).toBeLessThan(50);
|
expect(timingDifference).toBeLessThan(50);
|
||||||
});
|
});
|
||||||
@@ -178,7 +169,6 @@ describe("Password Security", () => {
|
|||||||
checkPasswordSafe(password, null)
|
checkPasswordSafe(password, null)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Should take at least a few milliseconds (bcrypt is slow)
|
|
||||||
expect(duration).toBeGreaterThan(1);
|
expect(duration).toBeGreaterThan(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -186,12 +176,10 @@ describe("Password Security", () => {
|
|||||||
const password = "TestPassword123!";
|
const password = "TestPassword123!";
|
||||||
const hash = await hashPassword(password);
|
const hash = await hashPassword(password);
|
||||||
|
|
||||||
// User exists
|
|
||||||
const { duration: existsDuration } = await measureTime(() =>
|
const { duration: existsDuration } = await measureTime(() =>
|
||||||
checkPasswordSafe("WrongPassword", hash)
|
checkPasswordSafe("WrongPassword", hash)
|
||||||
);
|
);
|
||||||
|
|
||||||
// User doesn't exist (null hash)
|
|
||||||
const { duration: notExistsDuration } = await measureTime(() =>
|
const { duration: notExistsDuration } = await measureTime(() =>
|
||||||
checkPasswordSafe("WrongPassword", null)
|
checkPasswordSafe("WrongPassword", null)
|
||||||
);
|
);
|
||||||
@@ -280,9 +268,9 @@ describe("Password Security", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should calculate password strength correctly", () => {
|
it("should calculate password strength correctly", () => {
|
||||||
const fairPassword = "MyP@ssw0rd12"; // 12 chars
|
const fairPassword = "MyP@ssw0rd12";
|
||||||
const goodPassword = "MyStr0ng!P@ssw0rd"; // 17 chars
|
const goodPassword = "MyStr0ng!P@ssw0rd";
|
||||||
const strongPassword = "MyV3ry!Str0ng@P@ssw0rd123"; // 25 chars
|
const strongPassword = "MyV3ry!Str0ng@P@ssw0rd123";
|
||||||
|
|
||||||
expect(validatePassword(fairPassword).strength).toBe("fair");
|
expect(validatePassword(fairPassword).strength).toBe("fair");
|
||||||
expect(validatePassword(goodPassword).strength).toBe("good");
|
expect(validatePassword(goodPassword).strength).toBe("good");
|
||||||
@@ -337,7 +325,6 @@ describe("Password Security", () => {
|
|||||||
const password = "TestPassword123!";
|
const password = "TestPassword123!";
|
||||||
const hash = await hashPassword(password);
|
const hash = await hashPassword(password);
|
||||||
|
|
||||||
// Measure time for multiple checks (simulating brute force)
|
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
const attempts = 10;
|
const attempts = 10;
|
||||||
|
|
||||||
@@ -348,7 +335,6 @@ describe("Password Security", () => {
|
|||||||
const duration = performance.now() - start;
|
const duration = performance.now() - start;
|
||||||
const avgPerAttempt = duration / attempts;
|
const avgPerAttempt = duration / attempts;
|
||||||
|
|
||||||
// Each attempt should take significant time (bcrypt is slow)
|
|
||||||
// This makes brute force impractical
|
// This makes brute force impractical
|
||||||
expect(avgPerAttempt).toBeGreaterThan(5); // At least 5ms per attempt
|
expect(avgPerAttempt).toBeGreaterThan(5); // At least 5ms per attempt
|
||||||
});
|
});
|
||||||
@@ -356,18 +342,15 @@ describe("Password Security", () => {
|
|||||||
it("should prevent rainbow table attacks with unique salts", async () => {
|
it("should prevent rainbow table attacks with unique salts", async () => {
|
||||||
const password = "CommonPassword123!";
|
const password = "CommonPassword123!";
|
||||||
|
|
||||||
// Generate multiple hashes for same password
|
|
||||||
const hashes = await Promise.all(
|
const hashes = await Promise.all(
|
||||||
Array.from({ length: 10 }, () => hashPassword(password))
|
Array.from({ length: 10 }, () => hashPassword(password))
|
||||||
);
|
);
|
||||||
|
|
||||||
// All hashes should be unique (different salts)
|
|
||||||
const uniqueHashes = new Set(hashes);
|
const uniqueHashes = new Set(hashes);
|
||||||
expect(uniqueHashes.size).toBe(10);
|
expect(uniqueHashes.size).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should prevent password spraying with validation", () => {
|
it("should prevent password spraying with validation", () => {
|
||||||
// Common passwords that should be rejected
|
|
||||||
const commonPasswords = [
|
const commonPasswords = [
|
||||||
"Password123!",
|
"Password123!",
|
||||||
"Welcome123!",
|
"Welcome123!",
|
||||||
@@ -382,7 +365,6 @@ describe("Password Security", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should resist dictionary attacks", () => {
|
it("should resist dictionary attacks", () => {
|
||||||
// Dictionary words that should be caught
|
|
||||||
const dictionaryBased = ["Sunshine123!", "Princess456!", "Dragon789!@"];
|
const dictionaryBased = ["Sunshine123!", "Princess456!", "Dragon789!@"];
|
||||||
|
|
||||||
for (const password of dictionaryBased) {
|
for (const password of dictionaryBased) {
|
||||||
@@ -394,7 +376,7 @@ describe("Password Security", () => {
|
|||||||
|
|
||||||
describe("Edge Cases", () => {
|
describe("Edge Cases", () => {
|
||||||
it("should handle very long passwords", async () => {
|
it("should handle very long passwords", async () => {
|
||||||
const longPassword = "A1!a" + "x".repeat(1000); // Very long but valid
|
const longPassword = "A1!a" + "x".repeat(1000);
|
||||||
const hash = await hashPassword(longPassword);
|
const hash = await hashPassword(longPassword);
|
||||||
const match = await checkPassword(longPassword, hash);
|
const match = await checkPassword(longPassword, hash);
|
||||||
|
|
||||||
@@ -413,7 +395,6 @@ describe("Password Security", () => {
|
|||||||
const hash = await hashPassword(nullBytePassword);
|
const hash = await hashPassword(nullBytePassword);
|
||||||
const match = await checkPassword(nullBytePassword, hash);
|
const match = await checkPassword(nullBytePassword, hash);
|
||||||
|
|
||||||
// Behavior may vary - just ensure no crash
|
|
||||||
expect(typeof match).toBe("boolean");
|
expect(typeof match).toBe("boolean");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -450,7 +431,6 @@ describe("Password Security", () => {
|
|||||||
const duration = performance.now() - start;
|
const duration = performance.now() - start;
|
||||||
|
|
||||||
// Bcrypt should be slow enough to deter brute force
|
// Bcrypt should be slow enough to deter brute force
|
||||||
// With 10 rounds, should take at least a few milliseconds
|
|
||||||
expect(duration).toBeGreaterThan(5);
|
expect(duration).toBeGreaterThan(5);
|
||||||
// But not too slow for normal operation
|
// But not too slow for normal operation
|
||||||
expect(duration).toBeLessThan(500);
|
expect(duration).toBeLessThan(500);
|
||||||
@@ -467,11 +447,9 @@ describe("Password Security", () => {
|
|||||||
durations.push(performance.now() - start);
|
durations.push(performance.now() - start);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Timing should be relatively consistent
|
|
||||||
const avg = durations.reduce((a, b) => a + b, 0) / durations.length;
|
const avg = durations.reduce((a, b) => a + b, 0) / durations.length;
|
||||||
const maxDeviation = Math.max(...durations.map((d) => Math.abs(d - avg)));
|
const maxDeviation = Math.max(...durations.map((d) => Math.abs(d - avg)));
|
||||||
|
|
||||||
// Allow reasonable variance
|
|
||||||
expect(maxDeviation).toBeLessThan(avg * 0.5);
|
expect(maxDeviation).toBeLessThan(avg * 0.5);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -484,7 +462,6 @@ describe("Password Security", () => {
|
|||||||
}
|
}
|
||||||
const duration = performance.now() - start;
|
const duration = performance.now() - start;
|
||||||
|
|
||||||
// Validation is CPU-bound but should be fast
|
|
||||||
expect(duration).toBeLessThan(100);
|
expect(duration).toBeLessThan(100);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -494,12 +471,9 @@ describe("Password Security", () => {
|
|||||||
const password = "TestPassword123!";
|
const password = "TestPassword123!";
|
||||||
const hash = await hashPassword(password);
|
const hash = await hashPassword(password);
|
||||||
|
|
||||||
// Check that hash uses correct salt rounds
|
|
||||||
// Bcrypt format: $2b$rounds$salthash
|
|
||||||
const parts = hash.split("$");
|
const parts = hash.split("$");
|
||||||
const rounds = parseInt(parts[2]);
|
const rounds = parseInt(parts[2]);
|
||||||
|
|
||||||
// Should use 10 rounds (from password.ts)
|
|
||||||
expect(rounds).toBe(10);
|
expect(rounds).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -509,17 +483,14 @@ describe("Password Security", () => {
|
|||||||
Array.from({ length: 100 }, () => hashPassword(password))
|
Array.from({ length: 100 }, () => hashPassword(password))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Extract salts from hashes
|
|
||||||
const salts = hashes.map((hash) => {
|
const salts = hashes.map((hash) => {
|
||||||
const parts = hash.split("$");
|
const parts = hash.split("$");
|
||||||
return parts[3].substring(0, 22); // Salt is 22 characters
|
return parts[3].substring(0, 22); // Salt is 22 characters
|
||||||
});
|
});
|
||||||
|
|
||||||
// All salts should be unique
|
|
||||||
const uniqueSalts = new Set(salts);
|
const uniqueSalts = new Set(salts);
|
||||||
expect(uniqueSalts.size).toBe(100);
|
expect(uniqueSalts.size).toBe(100);
|
||||||
|
|
||||||
// Check for patterns in salts (should be random)
|
|
||||||
for (let i = 1; i < salts.length; i++) {
|
for (let i = 1; i < salts.length; i++) {
|
||||||
// Salts should not be sequential or predictable
|
// Salts should not be sequential or predictable
|
||||||
expect(salts[i]).not.toBe(salts[i - 1]);
|
expect(salts[i]).not.toBe(salts[i - 1]);
|
||||||
|
|||||||
@@ -60,12 +60,10 @@ describe("Rate Limiting", () => {
|
|||||||
const maxAttempts = 3;
|
const maxAttempts = 3;
|
||||||
const windowMs = 60000;
|
const windowMs = 60000;
|
||||||
|
|
||||||
// Use up all attempts
|
|
||||||
for (let i = 0; i < maxAttempts; i++) {
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
await checkRateLimit(identifier, maxAttempts, windowMs);
|
await checkRateLimit(identifier, maxAttempts, windowMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Next attempt should throw
|
|
||||||
try {
|
try {
|
||||||
await checkRateLimit(identifier, maxAttempts, windowMs);
|
await checkRateLimit(identifier, maxAttempts, windowMs);
|
||||||
expect.unreachable("Should have thrown");
|
expect.unreachable("Should have thrown");
|
||||||
@@ -79,7 +77,6 @@ describe("Rate Limiting", () => {
|
|||||||
const maxAttempts = 2;
|
const maxAttempts = 2;
|
||||||
const windowMs = 60000;
|
const windowMs = 60000;
|
||||||
|
|
||||||
// Use up all attempts
|
|
||||||
await checkRateLimit(identifier, maxAttempts, windowMs);
|
await checkRateLimit(identifier, maxAttempts, windowMs);
|
||||||
await checkRateLimit(identifier, maxAttempts, windowMs);
|
await checkRateLimit(identifier, maxAttempts, windowMs);
|
||||||
|
|
||||||
@@ -99,12 +96,10 @@ describe("Rate Limiting", () => {
|
|||||||
const maxAttempts = 3;
|
const maxAttempts = 3;
|
||||||
const windowMs = 500; // 500ms window for testing
|
const windowMs = 500; // 500ms window for testing
|
||||||
|
|
||||||
// Use up all attempts
|
|
||||||
for (let i = 0; i < maxAttempts; i++) {
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
await checkRateLimit(identifier, maxAttempts, windowMs);
|
await checkRateLimit(identifier, maxAttempts, windowMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should be blocked immediately after
|
|
||||||
try {
|
try {
|
||||||
await checkRateLimit(identifier, maxAttempts, windowMs);
|
await checkRateLimit(identifier, maxAttempts, windowMs);
|
||||||
expect.unreachable("Should have thrown");
|
expect.unreachable("Should have thrown");
|
||||||
@@ -112,10 +107,8 @@ describe("Rate Limiting", () => {
|
|||||||
expect(error).toBeInstanceOf(TRPCError);
|
expect(error).toBeInstanceOf(TRPCError);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for window to expire
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||||
|
|
||||||
// Should be allowed again
|
|
||||||
const remaining = await checkRateLimit(identifier, maxAttempts, windowMs);
|
const remaining = await checkRateLimit(identifier, maxAttempts, windowMs);
|
||||||
expect(remaining).toBe(maxAttempts - 1);
|
expect(remaining).toBe(maxAttempts - 1);
|
||||||
});
|
});
|
||||||
@@ -125,13 +118,11 @@ describe("Rate Limiting", () => {
|
|||||||
const maxAttempts = 10;
|
const maxAttempts = 10;
|
||||||
const windowMs = 60000;
|
const windowMs = 60000;
|
||||||
|
|
||||||
// Simulate concurrent requests
|
|
||||||
const results: number[] = [];
|
const results: number[] = [];
|
||||||
for (let i = 0; i < maxAttempts; i++) {
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
results.push(await checkRateLimit(identifier, maxAttempts, windowMs));
|
results.push(await checkRateLimit(identifier, maxAttempts, windowMs));
|
||||||
}
|
}
|
||||||
|
|
||||||
// All should succeed with decreasing remaining counts
|
|
||||||
expect(results).toEqual([9, 8, 7, 6, 5, 4, 3, 2, 1, 0]);
|
expect(results).toEqual([9, 8, 7, 6, 5, 4, 3, 2, 1, 0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -142,12 +133,10 @@ describe("Rate Limiting", () => {
|
|||||||
const id1 = uniqueId("test1");
|
const id1 = uniqueId("test1");
|
||||||
const id2 = uniqueId("test2");
|
const id2 = uniqueId("test2");
|
||||||
|
|
||||||
// Use up attempts for id1
|
|
||||||
for (let i = 0; i < maxAttempts; i++) {
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
await checkRateLimit(id1, maxAttempts, windowMs);
|
await checkRateLimit(id1, maxAttempts, windowMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
// id1 should be blocked
|
|
||||||
try {
|
try {
|
||||||
await checkRateLimit(id1, maxAttempts, windowMs);
|
await checkRateLimit(id1, maxAttempts, windowMs);
|
||||||
expect.unreachable("Should have thrown");
|
expect.unreachable("Should have thrown");
|
||||||
@@ -155,7 +144,6 @@ describe("Rate Limiting", () => {
|
|||||||
expect(error).toBeInstanceOf(TRPCError);
|
expect(error).toBeInstanceOf(TRPCError);
|
||||||
}
|
}
|
||||||
|
|
||||||
// id2 should still work
|
|
||||||
const remaining = await checkRateLimit(id2, maxAttempts, windowMs);
|
const remaining = await checkRateLimit(id2, maxAttempts, windowMs);
|
||||||
expect(remaining).toBe(maxAttempts - 1);
|
expect(remaining).toBe(maxAttempts - 1);
|
||||||
});
|
});
|
||||||
@@ -225,12 +213,10 @@ describe("Rate Limiting", () => {
|
|||||||
const email = `test-${Date.now()}@example.com`;
|
const email = `test-${Date.now()}@example.com`;
|
||||||
|
|
||||||
// IP rate limiting is skipped in test/dev, so only email limit applies
|
// IP rate limiting is skipped in test/dev, so only email limit applies
|
||||||
// Use up email rate limit with same email
|
|
||||||
for (let i = 0; i < RATE_LIMITS.LOGIN_EMAIL.maxAttempts; i++) {
|
for (let i = 0; i < RATE_LIMITS.LOGIN_EMAIL.maxAttempts; i++) {
|
||||||
await rateLimitLogin(email, ip);
|
await rateLimitLogin(email, ip);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Next attempt should fail due to email limit
|
|
||||||
try {
|
try {
|
||||||
await rateLimitLogin(email, ip);
|
await rateLimitLogin(email, ip);
|
||||||
expect.unreachable("Should have thrown");
|
expect.unreachable("Should have thrown");
|
||||||
@@ -242,12 +228,10 @@ describe("Rate Limiting", () => {
|
|||||||
it("should limit by email independently of IP", async () => {
|
it("should limit by email independently of IP", async () => {
|
||||||
const email = `test-${Date.now()}@example.com`;
|
const email = `test-${Date.now()}@example.com`;
|
||||||
|
|
||||||
// Use different IPs but same email
|
|
||||||
for (let i = 0; i < RATE_LIMITS.LOGIN_EMAIL.maxAttempts; i++) {
|
for (let i = 0; i < RATE_LIMITS.LOGIN_EMAIL.maxAttempts; i++) {
|
||||||
await rateLimitLogin(email, randomIP());
|
await rateLimitLogin(email, randomIP());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Next attempt with different IP should still fail due to email limit
|
|
||||||
try {
|
try {
|
||||||
await rateLimitLogin(email, randomIP());
|
await rateLimitLogin(email, randomIP());
|
||||||
expect.unreachable("Should have thrown");
|
expect.unreachable("Should have thrown");
|
||||||
@@ -260,13 +244,11 @@ describe("Rate Limiting", () => {
|
|||||||
const ip = randomIP();
|
const ip = randomIP();
|
||||||
|
|
||||||
// In test/dev, IP rate limiting is skipped
|
// In test/dev, IP rate limiting is skipped
|
||||||
// Should allow many different emails from same IP
|
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
const email = `test${i}-${Date.now()}@example.com`;
|
const email = `test${i}-${Date.now()}@example.com`;
|
||||||
await rateLimitLogin(email, ip);
|
await rateLimitLogin(email, ip);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should not throw since IP limits are disabled in test/dev
|
|
||||||
expect(true).toBe(true);
|
expect(true).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -276,12 +258,10 @@ describe("Rate Limiting", () => {
|
|||||||
const ip = randomIP();
|
const ip = randomIP();
|
||||||
|
|
||||||
// IP rate limiting is skipped in test/dev
|
// IP rate limiting is skipped in test/dev
|
||||||
// Should allow many attempts
|
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
await rateLimitPasswordReset(ip);
|
await rateLimitPasswordReset(ip);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should not throw in test/dev
|
|
||||||
expect(true).toBe(true);
|
expect(true).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -304,12 +284,10 @@ describe("Rate Limiting", () => {
|
|||||||
const ip = randomIP();
|
const ip = randomIP();
|
||||||
|
|
||||||
// IP rate limiting is skipped in test/dev
|
// IP rate limiting is skipped in test/dev
|
||||||
// Should allow many attempts
|
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
await rateLimitRegistration(ip);
|
await rateLimitRegistration(ip);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should not throw in test/dev
|
|
||||||
expect(true).toBe(true);
|
expect(true).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -319,12 +297,10 @@ describe("Rate Limiting", () => {
|
|||||||
const ip = randomIP();
|
const ip = randomIP();
|
||||||
|
|
||||||
// IP rate limiting is skipped in test/dev
|
// IP rate limiting is skipped in test/dev
|
||||||
// Should allow many attempts
|
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
await rateLimitEmailVerification(ip);
|
await rateLimitEmailVerification(ip);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should not throw in test/dev
|
|
||||||
expect(true).toBe(true);
|
expect(true).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -334,7 +310,6 @@ describe("Rate Limiting", () => {
|
|||||||
const email = "victim@example.com";
|
const email = "victim@example.com";
|
||||||
const attackerIP = "1.2.3.4";
|
const attackerIP = "1.2.3.4";
|
||||||
|
|
||||||
// Simulate brute force attack
|
|
||||||
let blockedAtAttempt = 0;
|
let blockedAtAttempt = 0;
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
try {
|
try {
|
||||||
@@ -347,7 +322,6 @@ describe("Rate Limiting", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should be blocked before 10 attempts
|
|
||||||
expect(blockedAtAttempt).toBeLessThan(10);
|
expect(blockedAtAttempt).toBeLessThan(10);
|
||||||
expect(blockedAtAttempt).toBeGreaterThan(0);
|
expect(blockedAtAttempt).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
@@ -355,7 +329,6 @@ describe("Rate Limiting", () => {
|
|||||||
it("should prevent distributed brute force from multiple IPs", async () => {
|
it("should prevent distributed brute force from multiple IPs", async () => {
|
||||||
const email = "victim@example.com";
|
const email = "victim@example.com";
|
||||||
|
|
||||||
// Simulate distributed attack from different IPs
|
|
||||||
let blockedAtAttempt = 0;
|
let blockedAtAttempt = 0;
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
try {
|
try {
|
||||||
@@ -368,7 +341,6 @@ describe("Rate Limiting", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should be blocked at email limit (3 attempts)
|
|
||||||
expect(blockedAtAttempt).toBeLessThanOrEqual(
|
expect(blockedAtAttempt).toBeLessThanOrEqual(
|
||||||
RATE_LIMITS.LOGIN_EMAIL.maxAttempts
|
RATE_LIMITS.LOGIN_EMAIL.maxAttempts
|
||||||
);
|
);
|
||||||
@@ -383,7 +355,6 @@ describe("Rate Limiting", () => {
|
|||||||
await rateLimitRegistration(attackerIP);
|
await rateLimitRegistration(attackerIP);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should not block in test/dev (IP limits disabled)
|
|
||||||
expect(true).toBe(true);
|
expect(true).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -396,7 +367,6 @@ describe("Rate Limiting", () => {
|
|||||||
await rateLimitPasswordReset(attackerIP);
|
await rateLimitPasswordReset(attackerIP);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should not block in test/dev (IP limits disabled)
|
|
||||||
expect(true).toBe(true);
|
expect(true).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -408,14 +378,12 @@ describe("Rate Limiting", () => {
|
|||||||
const unknownIP = "unknown";
|
const unknownIP = "unknown";
|
||||||
const email = `test-${Date.now()}@example.com`;
|
const email = `test-${Date.now()}@example.com`;
|
||||||
|
|
||||||
// Should allow many login attempts in development with unknown IP
|
|
||||||
// (only email rate limit applies)
|
// (only email rate limit applies)
|
||||||
for (let i = 0; i < RATE_LIMITS.LOGIN_EMAIL.maxAttempts; i++) {
|
for (let i = 0; i < RATE_LIMITS.LOGIN_EMAIL.maxAttempts; i++) {
|
||||||
const testEmail = `test-${Date.now()}-${i}@example.com`;
|
const testEmail = `test-${Date.now()}-${i}@example.com`;
|
||||||
await rateLimitLogin(testEmail, unknownIP);
|
await rateLimitLogin(testEmail, unknownIP);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should be able to continue with different emails (no IP limit in dev)
|
|
||||||
await rateLimitLogin(`final-${Date.now()}@example.com`, unknownIP);
|
await rateLimitLogin(`final-${Date.now()}@example.com`, unknownIP);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -423,12 +391,10 @@ describe("Rate Limiting", () => {
|
|||||||
const unknownIP = "unknown";
|
const unknownIP = "unknown";
|
||||||
const email = `test-${Date.now()}@example.com`;
|
const email = `test-${Date.now()}@example.com`;
|
||||||
|
|
||||||
// Use up email rate limit
|
|
||||||
for (let i = 0; i < RATE_LIMITS.LOGIN_EMAIL.maxAttempts; i++) {
|
for (let i = 0; i < RATE_LIMITS.LOGIN_EMAIL.maxAttempts; i++) {
|
||||||
await rateLimitLogin(email, unknownIP);
|
await rateLimitLogin(email, unknownIP);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Next attempt should fail due to email limit
|
|
||||||
try {
|
try {
|
||||||
await rateLimitLogin(email, unknownIP);
|
await rateLimitLogin(email, unknownIP);
|
||||||
expect.unreachable("Should have thrown");
|
expect.unreachable("Should have thrown");
|
||||||
@@ -440,55 +406,46 @@ describe("Rate Limiting", () => {
|
|||||||
it("should handle unknown IP in password reset", async () => {
|
it("should handle unknown IP in password reset", async () => {
|
||||||
const unknownIP = "unknown";
|
const unknownIP = "unknown";
|
||||||
|
|
||||||
// In development, should allow many attempts (no IP limit)
|
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
await rateLimitPasswordReset(unknownIP);
|
await rateLimitPasswordReset(unknownIP);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should not throw in development
|
|
||||||
expect(true).toBe(true);
|
expect(true).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle unknown IP in registration", async () => {
|
it("should handle unknown IP in registration", async () => {
|
||||||
const unknownIP = "unknown";
|
const unknownIP = "unknown";
|
||||||
|
|
||||||
// In development, should allow many attempts (no IP limit)
|
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
await rateLimitRegistration(unknownIP);
|
await rateLimitRegistration(unknownIP);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should not throw in development
|
|
||||||
expect(true).toBe(true);
|
expect(true).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle unknown IP in email verification", async () => {
|
it("should handle unknown IP in email verification", async () => {
|
||||||
const unknownIP = "unknown";
|
const unknownIP = "unknown";
|
||||||
|
|
||||||
// In development, should allow many attempts (no IP limit)
|
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
await rateLimitEmailVerification(unknownIP);
|
await rateLimitEmailVerification(unknownIP);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should not throw in development
|
|
||||||
expect(true).toBe(true);
|
expect(true).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Rate Limit Configuration", () => {
|
describe("Rate Limit Configuration", () => {
|
||||||
it("should have reasonable limits configured", () => {
|
it("should have reasonable limits configured", () => {
|
||||||
// Login should be more permissive than registration
|
|
||||||
expect(RATE_LIMITS.LOGIN_IP.maxAttempts).toBeGreaterThan(
|
expect(RATE_LIMITS.LOGIN_IP.maxAttempts).toBeGreaterThan(
|
||||||
RATE_LIMITS.REGISTRATION_IP.maxAttempts
|
RATE_LIMITS.REGISTRATION_IP.maxAttempts
|
||||||
);
|
);
|
||||||
|
|
||||||
// All limits should be positive
|
|
||||||
expect(RATE_LIMITS.LOGIN_IP.maxAttempts).toBeGreaterThan(0);
|
expect(RATE_LIMITS.LOGIN_IP.maxAttempts).toBeGreaterThan(0);
|
||||||
expect(RATE_LIMITS.LOGIN_EMAIL.maxAttempts).toBeGreaterThan(0);
|
expect(RATE_LIMITS.LOGIN_EMAIL.maxAttempts).toBeGreaterThan(0);
|
||||||
expect(RATE_LIMITS.PASSWORD_RESET_IP.maxAttempts).toBeGreaterThan(0);
|
expect(RATE_LIMITS.PASSWORD_RESET_IP.maxAttempts).toBeGreaterThan(0);
|
||||||
expect(RATE_LIMITS.REGISTRATION_IP.maxAttempts).toBeGreaterThan(0);
|
expect(RATE_LIMITS.REGISTRATION_IP.maxAttempts).toBeGreaterThan(0);
|
||||||
expect(RATE_LIMITS.EMAIL_VERIFICATION_IP.maxAttempts).toBeGreaterThan(0);
|
expect(RATE_LIMITS.EMAIL_VERIFICATION_IP.maxAttempts).toBeGreaterThan(0);
|
||||||
|
|
||||||
// All windows should be at least 1 minute
|
|
||||||
expect(RATE_LIMITS.LOGIN_IP.windowMs).toBeGreaterThanOrEqual(60000);
|
expect(RATE_LIMITS.LOGIN_IP.windowMs).toBeGreaterThanOrEqual(60000);
|
||||||
expect(RATE_LIMITS.LOGIN_EMAIL.windowMs).toBeGreaterThanOrEqual(60000);
|
expect(RATE_LIMITS.LOGIN_EMAIL.windowMs).toBeGreaterThanOrEqual(60000);
|
||||||
expect(RATE_LIMITS.PASSWORD_RESET_IP.windowMs).toBeGreaterThanOrEqual(
|
expect(RATE_LIMITS.PASSWORD_RESET_IP.windowMs).toBeGreaterThanOrEqual(
|
||||||
@@ -552,7 +509,6 @@ describe("Rate Limiting", () => {
|
|||||||
const maxAttempts = 3;
|
const maxAttempts = 3;
|
||||||
const windowMs = 60000;
|
const windowMs = 60000;
|
||||||
|
|
||||||
// Exhaust the limit: 3 allowed, 4th blocked.
|
|
||||||
for (let i = 0; i < maxAttempts; i++) {
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
await checkRateLimit(id, maxAttempts, windowMs);
|
await checkRateLimit(id, maxAttempts, windowMs);
|
||||||
}
|
}
|
||||||
@@ -574,7 +530,6 @@ describe("Rate Limiting", () => {
|
|||||||
const maxAttempts = 5;
|
const maxAttempts = 5;
|
||||||
const windowMs = 60000;
|
const windowMs = 60000;
|
||||||
|
|
||||||
// Instance A: 3 attempts.
|
|
||||||
clearRateLimitLocalCache();
|
clearRateLimitLocalCache();
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
await checkRateLimit(id, maxAttempts, windowMs);
|
await checkRateLimit(id, maxAttempts, windowMs);
|
||||||
@@ -582,9 +537,9 @@ describe("Rate Limiting", () => {
|
|||||||
|
|
||||||
// Instance B (fresh local cache) makes 2 more -> combined count = 5.
|
// Instance B (fresh local cache) makes 2 more -> combined count = 5.
|
||||||
clearRateLimitLocalCache();
|
clearRateLimitLocalCache();
|
||||||
await checkRateLimit(id, maxAttempts, windowMs); // count 4
|
await checkRateLimit(id, maxAttempts, windowMs);
|
||||||
const remaining = await checkRateLimit(id, maxAttempts, windowMs); // count 5
|
const remaining = await checkRateLimit(id, maxAttempts, windowMs);
|
||||||
expect(remaining).toBe(0); // 5th allowed, no remaining
|
expect(remaining).toBe(0);
|
||||||
|
|
||||||
// A 6th attempt from a fresh instance must be blocked — the shared store
|
// A 6th attempt from a fresh instance must be blocked — the shared store
|
||||||
// aggregated the count across the two "instances".
|
// aggregated the count across the two "instances".
|
||||||
|
|||||||
@@ -4,8 +4,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { H3Event } from "vinxi/http";
|
import type { H3Event } from "vinxi/http";
|
||||||
import { SignJWT } from "jose";
|
|
||||||
import { env } from "~/env/server";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a mock H3Event for testing
|
* Create a mock H3Event for testing
|
||||||
@@ -62,55 +60,6 @@ export function createMockEvent(options: {
|
|||||||
return mockEvent;
|
return mockEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a valid JWT token for testing
|
|
||||||
*/
|
|
||||||
export async function createTestJWT(
|
|
||||||
userId: string,
|
|
||||||
expiresIn: string = "1h"
|
|
||||||
): Promise<string> {
|
|
||||||
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
|
||||||
return await new SignJWT({ id: userId })
|
|
||||||
.setProtectedHeader({ alg: "HS256" })
|
|
||||||
.setExpirationTime(expiresIn)
|
|
||||||
.sign(secret);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate an expired JWT token for testing
|
|
||||||
*/
|
|
||||||
export async function createExpiredJWT(userId: string): Promise<string> {
|
|
||||||
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
|
||||||
return await new SignJWT({ id: userId })
|
|
||||||
.setProtectedHeader({ alg: "HS256" })
|
|
||||||
.setExpirationTime("-1h") // Expired 1 hour ago
|
|
||||||
.sign(secret);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a JWT with invalid signature
|
|
||||||
*/
|
|
||||||
export async function createInvalidSignatureJWT(
|
|
||||||
userId: string
|
|
||||||
): Promise<string> {
|
|
||||||
const wrongSecret = new TextEncoder().encode("wrong-secret-key");
|
|
||||||
return await new SignJWT({ id: userId })
|
|
||||||
.setProtectedHeader({ alg: "HS256" })
|
|
||||||
.setExpirationTime("1h")
|
|
||||||
.sign(wrongSecret);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate test credentials
|
|
||||||
*/
|
|
||||||
export function createTestCredentials() {
|
|
||||||
return {
|
|
||||||
email: `test-${Date.now()}@example.com`,
|
|
||||||
password: "TestPass123!@#",
|
|
||||||
passwordConfirmation: "TestPass123!@#"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Common SQL injection payloads
|
* Common SQL injection payloads
|
||||||
*/
|
*/
|
||||||
@@ -138,26 +87,6 @@ export const XSS_PAYLOADS = [
|
|||||||
"<input onfocus=alert('XSS') autofocus>"
|
"<input onfocus=alert('XSS') autofocus>"
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* Wait for async operations with timeout
|
|
||||||
*/
|
|
||||||
export async function waitFor(
|
|
||||||
condition: () => boolean | Promise<boolean>,
|
|
||||||
timeout: number = 5000,
|
|
||||||
interval: number = 100
|
|
||||||
): Promise<void> {
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
while (Date.now() - startTime < timeout) {
|
|
||||||
if (await condition()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Timeout waiting for condition after ${timeout}ms`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Measure execution time
|
* Measure execution time
|
||||||
*/
|
*/
|
||||||
@@ -170,18 +99,6 @@ export async function measureTime<T>(
|
|||||||
return { result, duration };
|
return { result, duration };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate random string for testing
|
|
||||||
*/
|
|
||||||
export function randomString(length: number = 10): string {
|
|
||||||
const chars =
|
|
||||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
||||||
return Array.from(
|
|
||||||
{ length },
|
|
||||||
() => chars[Math.floor(Math.random() * chars.length)]
|
|
||||||
).join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate random IP address
|
* Generate random IP address
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user