Compare commits

..

8 Commits

Author SHA1 Message Date
7dc5166e90 missed 2026-05-28 22:23:21 -04:00
30b2d03c68 cleanup 2026-05-28 20:22:30 -04:00
d48bbc0fc3 security cleanup, fix turnstile 2026-05-28 16:48:06 -04:00
b7187721db reference fixes 2026-05-28 13:59:46 -04:00
fbc8215410 turnstile added 2026-05-28 10:24:23 -04:00
8b6551330f input halo sections, sparkle handling 2026-04-30 06:38:54 -04:00
3635133994 fix github activity 2026-04-06 14:41:30 -04:00
4dbd0ac965 (sidebar)forget redis, better parallization 2026-04-06 13:32:46 -04:00
41 changed files with 1101 additions and 889 deletions

View File

@@ -7,7 +7,7 @@ export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: (id) => {
manualChunks: (id: string) => {
// Bundle highlight.js and lowlight together
if (id.includes("highlight.js") || id.includes("lowlight")) {
return "highlight";

BIN
bun.lockb

Binary file not shown.

View File

@@ -55,7 +55,6 @@
"jose": "^6.1.3",
"mermaid": "^11.12.2",
"motion": "^12.23.26",
"redis": "^5.10.0",
"solid-js": "^1.9.5",
"solid-tiptap": "^0.8.0",
"ua-parser-js": "^2.0.7",

View File

@@ -46,6 +46,34 @@ interface ContributionDay {
count: number;
}
// Four independent cached promises — first RightBarContent instance to mount
// starts each fetch; the second gets the already-in-flight promise.
let ghCommitsPromise: Promise<GitCommit[]> | null = null;
let gtCommitsPromise: Promise<GitCommit[]> | null = null;
let ghActivityPromise: Promise<ContributionDay[]> | null = null;
let gtActivityPromise: Promise<ContributionDay[]> | null = null;
function getGhCommitsPromise(): Promise<GitCommit[]> {
return (ghCommitsPromise ??= api.gitActivity.getGitHubCommits
.query({ limit: 6 })
.catch(() => []));
}
function getGtCommitsPromise(): Promise<GitCommit[]> {
return (gtCommitsPromise ??= api.gitActivity.getGiteaCommits
.query({ limit: 6 })
.catch(() => []));
}
function getGhActivityPromise(): Promise<ContributionDay[]> {
return (ghActivityPromise ??= api.gitActivity.getGitHubActivity
.query()
.catch(() => []));
}
function getGtActivityPromise(): Promise<ContributionDay[]> {
return (gtActivityPromise ??= api.gitActivity.getGiteaActivity
.query()
.catch(() => []));
}
export function RightBarContent() {
const { setLeftBarVisible } = useBars();
const [githubCommits, setGithubCommits] = createSignal<GitCommit[]>([]);
@@ -54,7 +82,8 @@ export function RightBarContent() {
[]
);
const [giteaActivity, setGiteaActivity] = createSignal<ContributionDay[]>([]);
const [loading, setLoading] = createSignal(true);
const [githubCommitsLoading, setGithubCommitsLoading] = createSignal(true);
const [giteaCommitsLoading, setGiteaCommitsLoading] = createSignal(true);
const handleLinkClick = () => {
if (
@@ -66,41 +95,23 @@ export function RightBarContent() {
};
onMount(() => {
const fetchData = async () => {
try {
// Fetch more commits to account for deduplication
const [ghCommits, gtCommits, ghActivity, gtActivity] =
await Promise.all([
api.gitActivity.getGitHubCommits
.query({ limit: 6 })
.catch(() => []),
api.gitActivity.getGiteaCommits.query({ limit: 6 }).catch(() => []),
api.gitActivity.getGitHubActivity.query().catch(() => []),
api.gitActivity.getGiteaActivity.query().catch(() => [])
]);
// Take first 3 from GitHub
const displayedGithubCommits = ghCommits.slice(0, 3);
// Deduplicate Gitea commits - only against the 3 shown in GitHub section
const githubShas = new Set(displayedGithubCommits.map((c) => c.sha));
const uniqueGiteaCommits = gtCommits.filter(
(commit) => !githubShas.has(commit.sha)
);
setGithubCommits(displayedGithubCommits);
setGiteaCommits(uniqueGiteaCommits.slice(0, 3));
setGithubActivity(ghActivity);
setGiteaActivity(gtActivity);
} catch (error) {
console.error("Failed to fetch git activity:", error);
} finally {
setLoading(false);
}
};
setTimeout(() => {
fetchData();
getGhCommitsPromise().then((commits) => {
setGithubCommits(commits.slice(0, 3));
setGithubCommitsLoading(false);
});
// Deduplicate Gitea against whatever GitHub has resolved by the time this lands
getGtCommitsPromise().then((gtCommits) => {
const ghShas = new Set(githubCommits().map((c) => c.sha));
setGiteaCommits(
gtCommits.filter((c) => !ghShas.has(c.sha)).slice(0, 3)
);
setGiteaCommitsLoading(false);
});
getGhActivityPromise().then((activity) => setGithubActivity(activity));
getGtActivityPromise().then((activity) => setGiteaActivity(activity));
}, 0);
});
@@ -190,7 +201,7 @@ export function RightBarContent() {
<RecentCommits
commits={giteaCommits()}
title="Recent Gitea Commits"
loading={loading()}
loading={giteaCommitsLoading()}
/>
<ActivityHeatmap
contributions={giteaActivity()}
@@ -199,7 +210,7 @@ export function RightBarContent() {
<RecentCommits
commits={githubCommits()}
title="Recent GitHub Commits"
loading={loading()}
loading={githubCommitsLoading()}
/>
<ActivityHeatmap
contributions={githubActivity()}

View File

@@ -5,7 +5,7 @@ import LikeIcon from "~/components/icons/LikeIcon";
export interface PostLike {
id: number;
user_id: string;
post_id: string;
post_id: number;
}
export interface AuthenticatedLikeProps {
@@ -36,15 +36,15 @@ export default function AuthenticatedLike(props: AuthenticatedLikeProps) {
if (initialHasLiked) {
const result = await api.database.removePostLike.mutate({
user_id: props.currentUserID,
post_id: props.projectID.toString()
post_id: props.projectID
});
setLikes(result.newLikes as PostLike[]);
setLikes(result.newLikes as unknown as PostLike[]);
} else {
const result = await api.database.addPostLike.mutate({
user_id: props.currentUserID,
post_id: props.projectID.toString()
post_id: props.projectID
});
setLikes(result.newLikes as PostLike[]);
setLikes(result.newLikes as unknown as PostLike[]);
}
setInstantOffset(0);
} catch (error) {

View File

@@ -6,7 +6,8 @@ import type {
UserPublicData,
ReactionType,
ModificationType,
SortingMode
SortingMode,
CommentSectionProps
} from "~/types/comment";
import CommentInputBlock from "./CommentInputBlock";
import CommentSortingSelect from "./CommentSortingSelect";

View File

@@ -20,6 +20,7 @@ export default function DeletePostButton(props: DeletePostButtonProps) {
await api.database.deletePost.mutate({ id: props.postID });
window.location.reload();
} catch (error) {
console.error("Failed to delete post:", error);
alert("Failed to delete post");
setLoading(false);
}

View File

@@ -1,5 +1,42 @@
import { onMount } from "solid-js";
/**
* Sanitize Mermaid SVG output by removing dangerous elements and attributes.
* Prevents stored XSS via malicious Mermaid diagram code.
*/
function sanitizeMermaidSvg(svgString: string): string {
const parser = new DOMParser();
const doc = parser.parseFromString(svgString, "text/html");
// Remove dangerous elements
doc.querySelectorAll("script, iframe, object, embed, form, link, meta, base").forEach((el) => {
el.remove();
});
// Remove event handlers and dangerous attributes from all elements
doc.querySelectorAll("[on*], [href*='javascript:'], [style*='expression(']").forEach((el) => {
const attrs = Array.from(el.attributes);
attrs.forEach((attr) => {
if (
attr.name.startsWith("on") ||
attr.name === "href" ||
attr.name === "style"
) {
const value = attr.value;
if (
attr.name.startsWith("on") ||
value.includes("javascript:") ||
value.includes("expression(")
) {
el.removeAttribute(attr.name);
}
}
});
});
return doc.body.innerHTML;
}
export default function MermaidRenderer() {
onMount(async () => {
const mermaidPres = document.querySelectorAll('pre[data-type="mermaid"]');
@@ -12,7 +49,7 @@ export default function MermaidRenderer() {
mermaid.initialize({
startOnLoad: false,
theme: "dark",
securityLevel: "loose",
securityLevel: "strict",
fontFamily: "monospace",
themeVariables: {
darkMode: true,
@@ -38,7 +75,7 @@ export default function MermaidRenderer() {
const wrapper = document.createElement("div");
wrapper.className = "mermaid-rendered";
wrapper.innerHTML = svg;
wrapper.innerHTML = sanitizeMermaidSvg(svg);
pre.replaceWith(wrapper);
} catch (err) {
console.error("Failed to render mermaid diagram:", err);

View File

@@ -3,6 +3,46 @@ import type { HLJSApi } from "highlight.js";
const MermaidRenderer = lazy(() => import("./MermaidRenderer"));
/**
* Sanitize HTML content to prevent XSS when rendering user-generated blog content.
* Removes dangerous elements (script, iframe, object, etc.) and event handlers.
*/
function sanitizeHtml(html: string): string {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
// Remove dangerous elements
doc
.querySelectorAll(
"script, iframe, object, embed, form, link, meta, base, svg script"
)
.forEach((el) => el.remove());
// Remove event handler attributes and dangerous URLs from all elements
doc.querySelectorAll("[on*], [href], [style], [action]").forEach((el) => {
const attrs = Array.from(el.attributes);
attrs.forEach((attr) => {
const name = attr.name;
const value = attr.value;
if (
name.startsWith("on") ||
(name === "href" &&
(value.startsWith("javascript:") ||
value.startsWith("data:text/html"))) ||
(name === "style" &&
(value.includes("expression(") ||
value.includes("url(") ||
value.includes("javascript:"))) ||
(name === "action" && value.startsWith("javascript:"))
) {
el.removeAttribute(name);
}
});
});
return doc.body.innerHTML;
}
export interface PostBodyClientProps {
body: string;
hasCodeBlock: boolean;
@@ -21,10 +61,10 @@ export default function PostBodyClient(props: PostBodyClientProps) {
const processCodeBlocks = () => {
if (!contentRef) return;
const codeBlocks = contentRef.querySelectorAll("pre code");
const codeBlocks = contentRef.querySelectorAll<HTMLElement>("pre code");
codeBlocks.forEach((codeBlock) => {
const pre = codeBlock.parentElement;
const pre = codeBlock.parentElement as HTMLPreElement | null;
if (!pre) return;
if (pre.dataset.type === "mermaid") return;
@@ -228,14 +268,15 @@ export default function PostBodyClient(props: PostBodyClientProps) {
const referencesHeadingText =
marker?.getAttribute("data-heading") || "References";
const headings = contentRef.querySelectorAll("h2");
const headings = contentRef.querySelectorAll<HTMLElement>("h2");
let referencesSection: HTMLElement | null = null;
headings.forEach((heading) => {
for (const heading of headings) {
if (heading.textContent?.trim() === referencesHeadingText) {
referencesSection = heading;
break;
}
});
}
if (referencesSection) {
referencesSection.className = "text-2xl font-bold mb-4 text-text";
@@ -245,7 +286,7 @@ export default function PostBodyClient(props: PostBodyClientProps) {
parentDiv.classList.add("references-heading");
}
let currentElement = referencesSection.nextElementSibling;
let currentElement: Element | null = referencesSection.nextElementSibling;
while (currentElement) {
if (currentElement.tagName === "P") {
@@ -401,7 +442,7 @@ export default function PostBodyClient(props: PostBodyClientProps) {
id="post-content-body"
ref={contentRef}
class="text-text prose dark:prose-invert max-w-none"
innerHTML={props.body}
innerHTML={sanitizeHtml(props.body)}
/>
<Show when={props.hasMermaid}>
<MermaidRenderer />

View File

@@ -22,7 +22,7 @@ interface PostFormProps {
published: boolean;
tags: string[];
};
userID: number;
userID: string;
}
export default function PostForm(props: PostFormProps) {
@@ -89,7 +89,7 @@ export default function PostForm(props: PostFormProps) {
tags: null,
author_id: props.userID
});
const newId = result.data as number;
const newId = Number(result.data);
setCreatedPostId(newId);
setHasSaved(true);
return newId;

View File

@@ -1,10 +1,11 @@
import { JSX, splitProps } from "solid-js";
import { type JSX, splitProps } from "solid-js";
export interface InputProps extends JSX.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
helperText?: string;
ref?: HTMLInputElement | ((el: HTMLInputElement) => void);
containerClass?: string;
}
export default function Input(props: InputProps) {
@@ -12,11 +13,16 @@ export default function Input(props: InputProps) {
"label",
"error",
"helperText",
"ref"
"ref",
"containerClass"
]);
const containerClasses = ["input-group", local.containerClass]
.filter(Boolean)
.join(" ");
return (
<div class="input-group">
<div class={containerClasses}>
<input
{...others}
ref={local.ref}

View File

@@ -244,6 +244,15 @@ export const TEXT_EDITOR_CONFIG = {
SCROLL_TO_CHANGE_DELAY_MS: 100
} as const;
// ============================================================
// CLOUDFLARE TURNSTILE (BOT PROTECTION)
// ============================================================
export const TURNSTILE_CONFIG = {
VERIFY_URL: "https://challenges.cloudflare.com/turnstile/v0/siteverify",
RESPONSE_TIMEOUT_MS: 10000
} as const;
// ============================================================
// VALIDATION
// ============================================================

105
src/env/client.ts vendored
View File

@@ -1,80 +1,48 @@
import { z } from "zod";
export interface ClientEnv {
VITE_DOMAIN: string;
VITE_AWS_BUCKET_STRING: string;
VITE_DOWNLOAD_BUCKET_STRING: string;
VITE_GOOGLE_CLIENT_ID: string;
VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE: string;
VITE_GITHUB_CLIENT_ID: string;
VITE_WEBSOCKET: string;
VITE_INFILL_ENDPOINT: string;
VITE_TURNSTILE_SITE_KEY: string
}
const clientEnvSchema = z.object({
VITE_DOMAIN: z.string().min(1),
VITE_AWS_BUCKET_STRING: z.string().min(1),
VITE_DOWNLOAD_BUCKET_STRING: z.string().min(1),
VITE_GOOGLE_CLIENT_ID: z.string().min(1),
VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE: z.string().min(1),
VITE_GITHUB_CLIENT_ID: z.string().min(1),
VITE_WEBSOCKET: z.string().min(1),
VITE_INFILL_ENDPOINT: z.string().min(1)
});
export type ClientEnv = z.infer<typeof clientEnvSchema>;
const requiredKeys: (keyof ClientEnv)[] = [
"VITE_DOMAIN",
"VITE_AWS_BUCKET_STRING",
"VITE_DOWNLOAD_BUCKET_STRING",
"VITE_GOOGLE_CLIENT_ID",
"VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE",
"VITE_GITHUB_CLIENT_ID",
"VITE_WEBSOCKET",
"VITE_INFILL_ENDPOINT",
"VITE_TURNSTILE_SITE_KEY"
];
export const validateClientEnv = (
envVars: Record<string, string | undefined>
): ClientEnv => {
try {
return clientEnvSchema.parse(envVars);
} catch (error) {
if (error instanceof z.ZodError) {
const formattedErrors = error.format();
const missingVars = Object.entries(formattedErrors)
.filter(
([key, value]) =>
key !== "_errors" &&
typeof value === "object" &&
value._errors?.length > 0 &&
value._errors[0] === "Required"
)
.map(([key, _]) => key);
const missing = requiredKeys.filter(
(key) => !envVars[key] || envVars[key]!.trim() === ""
);
const invalidVars = Object.entries(formattedErrors)
.filter(
([key, value]) =>
key !== "_errors" &&
typeof value === "object" &&
value._errors?.length > 0 &&
value._errors[0] !== "Required"
)
.map(([key, value]) => ({
key,
error: value._errors[0]
}));
let errorMessage = "Client environment validation failed:\n";
if (missingVars.length > 0) {
errorMessage += `Missing required variables: ${missingVars.join(", ")}\n`;
}
if (invalidVars.length > 0) {
errorMessage += "Invalid values:\n";
invalidVars.forEach(({ key, error }) => {
errorMessage += ` ${key}: ${error}\n`;
});
}
console.error(errorMessage);
throw new Error(errorMessage);
}
console.error(
"Client environment validation failed with unknown error:",
error
);
throw new Error("Client environment validation failed with unknown error");
if (missing.length > 0) {
const message = `Client environment validation failed:\nMissing required variables: ${missing.join(", ")}`;
console.error(message);
throw new Error(message);
}
return envVars as unknown as ClientEnv;
};
const validateAndExportEnv = (): ClientEnv => {
try {
const validated = validateClientEnv(import.meta.env);
console.log("✅ Client environment validation successful");
return validated;
} catch (error) {
console.error("❌ Client environment validation failed:", error);
throw error;
}
};
@@ -86,14 +54,5 @@ export const isMissingEnvVar = (varName: string): boolean => {
};
export const getMissingEnvVars = (): string[] => {
const requiredClientVars = [
"VITE_DOMAIN",
"VITE_AWS_BUCKET_STRING",
"VITE_GOOGLE_CLIENT_ID",
"VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE",
"VITE_GITHUB_CLIENT_ID",
"VITE_WEBSOCKET"
];
return requiredClientVars.filter((varName) => isMissingEnvVar(varName));
return requiredKeys.filter((varName) => isMissingEnvVar(varName));
};

28
src/env/server.ts vendored
View File

@@ -1,3 +1,26 @@
// ──────────────────────────────────────────────
// 🔒 GUARD: This module MUST NEVER be imported client-side.
// It contains secrets: DB tokens, JWT keys, AWS credentials, API tokens.
// ──────────────────────────────────────────────
// Build-time guard — Vite dead-code eliminates everything below when SSR=true.
// If this module is bundled client-side, it throws before any secrets are parsed.
if (!import.meta.env.SSR) {
throw new Error(
"[SERVER-ONLY] src/env/server.ts was imported client-side! " +
"This module contains server-only secrets (database credentials, JWT keys, API tokens) " +
"and must never be bundled in client code."
);
}
// Runtime defense-in-depth — throws if somehow evaluated in a browser context.
if (typeof window !== "undefined") {
throw new Error(
"[SERVER-ONLY] src/env/server.ts was loaded in a browser context. " +
"This is a critical security violation."
);
}
import { z } from "zod";
const serverEnvSchema = z.object({
@@ -33,7 +56,10 @@ const serverEnvSchema = z.object({
REDIS_URL: z.string().min(1),
NESSA_DB_URL: z.string().min(1),
NESSA_DB_TOKEN: z.string().min(1),
NESSA_JWT_SECRET: z.string().min(1)
NESSA_JWT_SECRET: z.string().min(1),
APPLE_CLIENT_ID: z.string().min(1).optional(),
VITE_TURNSTILE_SITE_KEY: z.string().min(1),
TURNSTILE_SECRET_KEY: z.string().min(1)
});
export type ServerEnv = z.infer<typeof serverEnvSchema>;

View File

@@ -0,0 +1,79 @@
import type { APIEvent } from "@solidjs/start/server";
import { createServerCaller } from "~/server/api/root";
/**
* Result from an auth callback tRPC procedure
*/
interface AuthCallbackResult {
success: boolean;
redirectTo?: string;
}
/**
* Handle a successful auth callback result by redirecting
*/
export function redirectSuccess(result: AuthCallbackResult) {
return new Response(null, {
status: 302,
headers: { Location: result.redirectTo || "/account" }
});
}
/**
* Redirect to login with an error parameter
*/
export function redirectError(error: string) {
return new Response(null, {
status: 302,
headers: { Location: `/login?error=${encodeURIComponent(error)}` }
});
}
/**
* Handle tRPC CONFLICT error (email already in use)
*/
export function isConflictError(error: unknown): boolean {
return (
error != null &&
typeof error === "object" &&
"code" in error &&
(error as { code: string }).code === "CONFLICT"
);
}
/**
* Create an auth callback handler that calls a tRPC procedure and redirects
*/
export function createAuthCallbackHandler<Params extends object>(
procedureName: string,
callProcedure: (
caller: ReturnType<typeof createServerCaller> extends Promise<infer T>
? T
: never,
params: Params
) => Promise<AuthCallbackResult>,
handleError?: (error: unknown) => Response
) {
return async (event: APIEvent, params: Params) => {
try {
const caller = await createServerCaller(event);
const result = await callProcedure(caller, params);
if (result.success) {
return redirectSuccess(result);
}
return redirectError("auth_failed");
} catch (error) {
if (handleError) {
return handleError(error);
}
if (isConflictError(error)) {
return redirectError("email_in_use");
}
return redirectError("server_error");
}
};
}

View File

@@ -24,66 +24,76 @@ export function initPerformanceTracking() {
return;
}
const supported = new Set(PerformanceObserver.supportedEntryTypes ?? []);
// Observe LCP
try {
const lcpObserver = new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
const lastEntry = entries[entries.length - 1] as any;
metrics.lcp = lastEntry.renderTime || lastEntry.loadTime;
});
lcpObserver.observe({ type: "largest-contentful-paint", buffered: true });
} catch (e) {
console.debug("LCP not supported");
if (supported.has("largest-contentful-paint")) {
try {
const lcpObserver = new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
const lastEntry = entries[entries.length - 1] as any;
metrics.lcp = lastEntry.renderTime || lastEntry.loadTime;
});
lcpObserver.observe({ type: "largest-contentful-paint", buffered: true });
} catch (e) {
console.debug("LCP observer failed");
}
}
// Observe CLS
try {
const clsObserver = new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
const layoutShift = entry as any;
if (!layoutShift.hadRecentInput) {
clsValue += layoutShift.value;
clsEntries.push(layoutShift.value);
if (supported.has("layout-shift")) {
try {
const clsObserver = new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
const layoutShift = entry as any;
if (!layoutShift.hadRecentInput) {
clsValue += layoutShift.value;
clsEntries.push(layoutShift.value);
}
}
}
metrics.cls = clsValue;
});
clsObserver.observe({ type: "layout-shift", buffered: true });
} catch (e) {
console.debug("CLS not supported");
metrics.cls = clsValue;
});
clsObserver.observe({ type: "layout-shift", buffered: true });
} catch (e) {
console.debug("CLS observer failed");
}
}
// Observe FID
try {
const fidObserver = new PerformanceObserver((entryList) => {
const firstInput = entryList.getEntries()[0] as any;
if (firstInput) {
metrics.fid = firstInput.processingStart - firstInput.startTime;
}
});
fidObserver.observe({ type: "first-input", buffered: true });
} catch (e) {
console.debug("FID not supported");
if (supported.has("first-input")) {
try {
const fidObserver = new PerformanceObserver((entryList) => {
const firstInput = entryList.getEntries()[0] as any;
if (firstInput) {
metrics.fid = firstInput.processingStart - firstInput.startTime;
}
});
fidObserver.observe({ type: "first-input", buffered: true });
} catch (e) {
console.debug("FID observer failed");
}
}
// Observe INP (event timing)
try {
const interactions: number[] = [];
const inpObserver = new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
const eventEntry = entry as any;
if (eventEntry.interactionId) {
interactions.push(eventEntry.duration);
const sorted = [...interactions].sort((a, b) => b - a);
const p98Index = Math.floor(sorted.length * 0.02);
inpValue = sorted[p98Index] || sorted[0] || 0;
metrics.inp = inpValue;
if (supported.has("event")) {
try {
const interactions: number[] = [];
const inpObserver = new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
const eventEntry = entry as any;
if (eventEntry.interactionId) {
interactions.push(eventEntry.duration);
const sorted = [...interactions].sort((a, b) => b - a);
const p98Index = Math.floor(sorted.length * 0.02);
inpValue = sorted[p98Index] || sorted[0] || 0;
metrics.inp = inpValue;
}
}
}
});
inpObserver.observe({ type: "event", buffered: true });
} catch (e) {
console.debug("INP not supported");
});
inpObserver.observe({ type: "event", buffered: true });
} catch (e) {
console.debug("INP observer failed");
}
}
// Get navigation timing metrics

View File

@@ -8,7 +8,7 @@ import { env } from "~/env/server";
*
* URL: https://freno.me/api/Gaze/appcast.xml
*/
export async function GET(event: APIEvent) {
export async function GET(_event: APIEvent) {
const bucket = env.VITE_DOWNLOAD_BUCKET_STRING;
const key = "api/Gaze/appcast.xml";
@@ -47,7 +47,7 @@ export async function GET(event: APIEvent) {
headers: {
"Content-Type": "application/xml; charset=utf-8",
"Cache-Control": "public, max-age=300", // Cache for 5 minutes
"Access-Control-Allow-Origin": "*" // Allow CORS for appcast
"Access-Control-Allow-Origin": "*" // Allow CORS for Sparkle appcast
}
});
} catch (error) {

View File

@@ -0,0 +1,62 @@
import type { APIEvent } from "@solidjs/start/server";
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { env } from "~/env/server";
/**
* Serves the InputHalo appcast.xml file from S3
* This endpoint is used by Sparkle updater to check for new versions
*
* URL: https://freno.me/api/InputHalo/appcast.xml
*/
export async function GET(_event: APIEvent) {
const bucket = env.VITE_DOWNLOAD_BUCKET_STRING;
const key = "api/InputHalo/appcast.xml";
const credentials = {
accessKeyId: env.MY_AWS_ACCESS_KEY,
secretAccessKey: env.MY_AWS_SECRET_KEY
};
try {
const client = new S3Client({
region: env.AWS_REGION,
credentials: credentials
});
const command = new GetObjectCommand({
Bucket: bucket,
Key: key
});
const response = await client.send(command);
if (!response.Body) {
return new Response("Appcast not found", {
status: 404,
headers: {
"Content-Type": "text/plain"
}
});
}
// Stream the XML content from S3
const body = await response.Body.transformToString();
return new Response(body, {
status: 200,
headers: {
"Content-Type": "application/xml; charset=utf-8",
"Cache-Control": "public, max-age=300", // Cache for 5 minutes
"Access-Control-Allow-Origin": "*" // Allow CORS for Sparkle appcast
}
});
} catch (error) {
console.error("Failed to fetch appcast:", error);
return new Response("Internal Server Error", {
status: 500,
headers: {
"Content-Type": "text/plain"
}
});
}
}

View File

@@ -1,86 +1,26 @@
import type { APIEvent } from "@solidjs/start/server";
import { createServerCaller } from "~/server/api/root";
import {
createAuthCallbackHandler,
redirectError
} from "~/lib/auth-callback-utils";
export async function GET(event: APIEvent) {
const url = new URL(event.request.url);
const code = url.searchParams.get("code");
const error = url.searchParams.get("error");
console.log("[GitHub OAuth Callback] Request received:", {
hasCode: !!code,
codeLength: code?.length,
error
});
if (error) {
console.error("[GitHub OAuth Callback] OAuth error from provider:", error);
return new Response(null, {
status: 302,
headers: { Location: `/login?error=${encodeURIComponent(error)}` }
});
return redirectError(error);
}
if (!code) {
console.error("[GitHub OAuth Callback] Missing authorization code");
return new Response(null, {
status: 302,
headers: { Location: "/login?error=missing_code" }
});
return redirectError("missing_code");
}
try {
console.log("[GitHub OAuth Callback] Creating tRPC caller...");
const caller = await createServerCaller(event);
const handler = createAuthCallbackHandler<{ code: string }>(
"githubCallback",
(caller, params) => caller.auth.githubCallback(params)
);
console.log("[GitHub OAuth Callback] Calling githubCallback procedure...");
const result = await caller.auth.githubCallback({ code });
console.log("[GitHub OAuth Callback] Result:", result);
if (result.success) {
console.log(
"[GitHub OAuth Callback] Login successful, redirecting to:",
result.redirectTo
);
// Auth handler already set cookie headers
// Just redirect - the cookies are already in the response
const redirectUrl = result.redirectTo || "/account";
return new Response(null, {
status: 302,
headers: { Location: redirectUrl }
});
} else {
console.error(
"[GitHub OAuth Callback] Login failed (result.success=false)"
);
return new Response(null, {
status: 302,
headers: { Location: "/login?error=auth_failed" }
});
}
} catch (error) {
console.error("[GitHub OAuth Callback] Error caught:", error);
if (error && typeof error === "object" && "code" in error) {
const trpcError = error as { code: string; message?: string };
console.error("[GitHub OAuth Callback] tRPC error:", {
code: trpcError.code,
message: trpcError.message
});
if (trpcError.code === "CONFLICT") {
return new Response(null, {
status: 302,
headers: { Location: "/login?error=email_in_use" }
});
}
}
return new Response(null, {
status: 302,
headers: { Location: "/login?error=server_error" }
});
}
return handler(event, { code });
}

View File

@@ -1,86 +1,26 @@
import type { APIEvent } from "@solidjs/start/server";
import { createServerCaller } from "~/server/api/root";
import {
createAuthCallbackHandler,
redirectError
} from "~/lib/auth-callback-utils";
export async function GET(event: APIEvent) {
const url = new URL(event.request.url);
const code = url.searchParams.get("code");
const error = url.searchParams.get("error");
console.log("[Google OAuth Callback] Request received:", {
hasCode: !!code,
codeLength: code?.length,
error
});
if (error) {
console.error("[Google OAuth Callback] OAuth error from provider:", error);
return new Response(null, {
status: 302,
headers: { Location: `/login?error=${encodeURIComponent(error)}` }
});
return redirectError(error);
}
if (!code) {
console.error("[Google OAuth Callback] Missing authorization code");
return new Response(null, {
status: 302,
headers: { Location: "/login?error=missing_code" }
});
return redirectError("missing_code");
}
try {
console.log("[Google OAuth Callback] Creating tRPC caller...");
const caller = await createServerCaller(event);
const handler = createAuthCallbackHandler<{ code: string }>(
"googleCallback",
(caller, params) => caller.auth.googleCallback(params)
);
console.log("[Google OAuth Callback] Calling googleCallback procedure...");
const result = await caller.auth.googleCallback({ code });
console.log("[Google OAuth Callback] Result:", result);
if (result.success) {
console.log(
"[Google OAuth Callback] Login successful, redirecting to:",
result.redirectTo
);
// Auth handler already set cookie headers
// Just redirect - the cookies are already in the response
const redirectUrl = result.redirectTo || "/account";
return new Response(null, {
status: 302,
headers: { Location: redirectUrl }
});
} else {
console.error(
"[Google OAuth Callback] Login failed (result.success=false)"
);
return new Response(null, {
status: 302,
headers: { Location: "/login?error=auth_failed" }
});
}
} catch (error) {
console.error("[Google OAuth Callback] Error caught:", error);
if (error && typeof error === "object" && "code" in error) {
const trpcError = error as { code: string; message?: string };
console.error("[Google OAuth Callback] tRPC error:", {
code: trpcError.code,
message: trpcError.message
});
if (trpcError.code === "CONFLICT") {
return new Response(null, {
status: 302,
headers: { Location: "/login?error=email_in_use" }
});
}
}
return new Response(null, {
status: 302,
headers: { Location: "/login?error=server_error" }
});
}
return handler(event, { code });
}

View File

@@ -1,86 +1,32 @@
import type { APIEvent } from "@solidjs/start/server";
import { createServerCaller } from "~/server/api/root";
import {
createAuthCallbackHandler,
redirectError
} from "~/lib/auth-callback-utils";
export async function GET(event: APIEvent) {
const url = new URL(event.request.url);
const email = url.searchParams.get("email");
const token = url.searchParams.get("token");
console.log("[Email Login Callback] Request received:", {
email,
hasToken: !!token,
tokenLength: token?.length
});
if (!email || !token) {
console.error("[Email Login Callback] Missing required parameters:", {
hasEmail: !!email,
hasToken: !!token
});
return new Response(null, {
status: 302,
headers: { Location: "/login?error=missing_params" }
});
return redirectError("missing_params");
}
try {
console.log("[Email Login Callback] Creating tRPC caller...");
// Create tRPC caller to invoke the emailLogin procedure
const caller = await createServerCaller(event);
console.log("[Email Login Callback] Calling emailLogin procedure...");
// Call the email login handler - rememberMe will be read from JWT payload
const result = await caller.auth.emailLogin({
email,
token
});
console.log("[Email Login Callback] Login result:", result);
if (result.success) {
console.log(
"[Email Login Callback] Login successful, redirecting to:",
result.redirectTo
);
// Auth handler already set cookie headers
// Just redirect - the cookies are already in the response
const redirectUrl = result.redirectTo || "/account";
return new Response(null, {
status: 302,
headers: { Location: redirectUrl }
});
} else {
console.error(
"[Email Login Callback] Login failed (result.success=false)"
);
return new Response(null, {
status: 302,
headers: { Location: "/login?error=auth_failed" }
});
const handler = createAuthCallbackHandler<{
email: string;
token: string;
}>(
"emailLogin",
(caller, params) => caller.auth.emailLogin(params),
(error) => {
// Check for token expiration
const message = error instanceof Error ? error.message : "";
const isTokenError =
message.includes("expired") || message.includes("invalid");
return redirectError(isTokenError ? "link_expired" : "server_error");
}
} catch (error) {
console.error("[Email Login Callback] Error caught:", error);
);
// Check if it's a token expiration error
const errorMessage =
error instanceof Error ? error.message : "server_error";
const isTokenError =
errorMessage.includes("expired") || errorMessage.includes("invalid");
console.error("[Email Login Callback] Error details:", {
errorMessage,
isTokenError,
errorType: error instanceof Error ? error.constructor.name : typeof error
});
return new Response(null, {
status: 302,
headers: {
Location: isTokenError
? "/login?error=link_expired"
: "/login?error=server_error"
}
});
}
return handler(event, { email, token });
}

View File

@@ -3,12 +3,12 @@ import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { env } from "~/env/server";
/**
* Serves Gaze DMG files and delta updates from S3
* Serves macOS app DMG files and delta updates from S3
* This endpoint is used by Sparkle updater to download updates
*
* Handles:
* - Full DMG files: /api/downloads/Gaze-0.2.2.dmg
* - Delta updates: /api/downloads/Gaze3-2.delta
* - Full DMG files: /api/downloads/Gaze-0.2.2.dmg, /api/downloads/InputHalo-0.1.0.dmg
* - Delta updates: /api/downloads/Gaze3-2.delta, /api/downloads/InputHalo3-2.delta
*
* URL: https://freno.me/api/downloads/[filename]
*/
@@ -24,9 +24,11 @@ export async function GET(event: APIEvent) {
});
}
// Validate filename format (only allow Gaze files)
// Validate filename format (only allow Gaze or InputHalo files)
const validPrefixes = ["Gaze", "InputHalo"];
const isValidPrefix = validPrefixes.some((prefix) => filename.startsWith(prefix));
if (
!filename.startsWith("Gaze") ||
!isValidPrefix ||
(!filename.endsWith(".dmg") && !filename.endsWith(".delta"))
) {
return new Response("Invalid file format", {

View File

@@ -1,16 +1,10 @@
import { createSignal, onMount, createEffect, Show } from "solid-js";
import {
useSearchParams,
useNavigate,
useLocation,
query,
createAsync
} from "@solidjs/router";
import { useSearchParams, query, createAsync } from "@solidjs/router";
import { A } from "@solidjs/router";
import { action, redirect } from "@solidjs/router";
import { PageHead } from "~/components/PageHead";
import { api } from "~/lib/api";
import { getClientCookie, setClientCookie } from "~/lib/cookies.client";
import { getClientCookie } from "~/lib/cookies.client";
import CountdownCircleTimer from "~/components/CountdownCircleTimer";
import RevealDropDown from "~/components/RevealDropDown";
import Input from "~/components/ui/Input";
@@ -19,20 +13,22 @@ import { useCountdown } from "~/lib/useCountdown";
import type { UserProfile } from "~/types/user";
import { getCookie, setCookie } from "vinxi/http";
import { z } from "zod";
import { env } from "~/env/server";
import { env as clientEnv } from "~/env/client";
import {
fetchWithTimeout,
checkResponse,
fetchWithRetry,
NetworkError,
TimeoutError,
APIError
APIError,
verifyTurnstileToken
} from "~/server/fetch-utils";
import {
NETWORK_CONFIG,
COOLDOWN_TIMERS,
VALIDATION_CONFIG,
COUNTDOWN_CONFIG
COUNTDOWN_CONFIG,
TURNSTILE_CONFIG
} from "~/config";
const getContactData = query(async () => {
@@ -53,6 +49,7 @@ const sendContactEmail = action(async (formData: FormData) => {
const name = formData.get("name") as string;
const email = formData.get("email") as string;
const message = formData.get("message") as string;
const turnstileToken = formData.get("cf-turnstile-response") as string;
const schema = z.object({
name: z.string().min(1, "Name is required"),
@@ -71,6 +68,20 @@ const sendContactEmail = action(async (formData: FormData) => {
);
}
// Verify Cloudflare Turnstile token
const turnstileValid = await verifyTurnstileToken(
turnstileToken,
env.TURNSTILE_SECRET_KEY,
TURNSTILE_CONFIG.VERIFY_URL,
TURNSTILE_CONFIG.RESPONSE_TIMEOUT_MS
);
if (!turnstileValid) {
return redirect(
"/contact?error=Security verification failed. Please refresh and try again."
);
}
const contactExp = getCookie("contactRequestSent");
if (contactExp) {
const expires = new Date(contactExp);
@@ -146,8 +157,6 @@ const sendContactEmail = action(async (formData: FormData) => {
export default function ContactPage() {
const [searchParams] = useSearchParams();
const location = useLocation();
const navigate = useNavigate();
const viewer = () => searchParams.viewer ?? "default";
// Load server data using createAsync
@@ -159,17 +168,46 @@ export default function ContactPage() {
searchParams.success === "true"
);
const [error, setError] = createSignal<string>(
searchParams.error ? decodeURIComponent(searchParams.error) : ""
searchParams.error ? decodeURIComponent(String(searchParams.error)) : ""
);
const [loading, setLoading] = createSignal<boolean>(false);
const [user, setUser] = createSignal<UserProfile | null>(null);
const [jsEnabled, setJsEnabled] = createSignal<boolean>(false);
const [turnstileToken, setTurnstileToken] = createSignal<string>("");
const [turnstileWidgetId, setTurnstileWidgetId] = createSignal<string | null>(
null
);
const { remainingTime, startCountdown, setRemainingTime } = useCountdown();
onMount(() => {
setJsEnabled(true);
// Load Cloudflare Turnstile script with explicit rendering
const script = document.createElement("script");
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
script.async = true;
script.defer = true;
script.onload = () => {
if (typeof window !== "undefined" && (window as any).turnstile) {
const container = document.getElementById("turnstile-widget-1");
if (container) {
const id = (window as any).turnstile.render(container, {
sitekey: clientEnv.VITE_TURNSTILE_SITE_KEY,
theme: "dark",
callback: (token: string) => {
setTurnstileToken(token);
},
"expired-callback": () => {
setTurnstileToken("");
}
});
setTurnstileWidgetId(id);
}
}
};
document.head.appendChild(script);
api.user.getProfile
.query()
.then((userData) => {
@@ -206,13 +244,34 @@ export default function ContactPage() {
if (!jsEnabled()) return;
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
const form = e.target as unknown as HTMLFormElement;
const formData = new FormData(form);
const name = formData.get("name") as string;
const email = formData.get("email") as string;
const message = formData.get("message") as string;
if (name && email && message) {
// Get fresh Turnstile token
let currentToken = turnstileToken();
if (
!currentToken &&
typeof window !== "undefined" &&
(window as any).turnstile
) {
const widgetEl = document.getElementById("turnstile-widget-1");
if (widgetEl) {
const id = turnstileWidgetId();
currentToken = (window as any).turnstile.getResponse(id || widgetEl);
}
}
if (!currentToken || currentToken.trim() === "") {
setError("Please complete the security check.");
setLoading(false);
return;
}
setLoading(true);
setError("");
setEmailSent(false);
@@ -221,13 +280,24 @@ export default function ContactPage() {
const res = await api.misc.sendContactRequest.mutate({
name,
email,
message
message,
turnstileToken: currentToken
});
if (res.message === "email sent") {
setEmailSent(true);
setError("");
(e.target as HTMLFormElement).reset();
form.reset();
// Reset Turnstile widget
if (typeof window !== "undefined" && (window as any).turnstile) {
const widgetEl = document.getElementById("turnstile-widget-1");
if (widgetEl) {
const id = turnstileWidgetId();
(window as any).turnstile.reset(id || widgetEl);
}
}
setTurnstileToken("");
// Set countdown directly - cookie might not be readable immediately
const expirationTime = new Date(
@@ -392,7 +462,8 @@ export default function ContactPage() {
<label class="underlinedInputLabel">Message</label>
</div>
</div>
<div class="mx-auto flex w-full justify-end pt-4">
<div class="mx-auto flex w-full justify-between pt-4">
<div id="turnstile-widget-1"></div>
<Show
when={
remainingTime() > 0 ||

View File

@@ -10,6 +10,7 @@ export default function DownloadsPage() {
const [SwAText, setSwAText] = createSignal("Shapes with Abigail!");
const [corkText, setCorkText] = createSignal("Cork");
const [gazeText, setGazeText] = createSignal("Gaze");
const [inputHaloText, setInputHaloText] = createSignal("InputHalo");
// Track loading states for each download button
const [loadingState, setLoadingState] = createSignal<Record<string, boolean>>(
@@ -17,7 +18,8 @@ export default function DownloadsPage() {
lineage: false,
cork: false,
gaze: false,
"shapes-with-abigail": false
"shapes-with-abigail": false,
inputhalo: false
}
);
@@ -53,12 +55,14 @@ export default function DownloadsPage() {
const swaInterval = glitchText(SwAText(), setSwAText);
const corkInterval = glitchText(corkText(), setCorkText);
const gazeInterval = glitchText(gazeText(), setGazeText);
const inputHaloInterval = glitchText(inputHaloText(), setInputHaloText);
onCleanup(() => {
clearInterval(lalInterval);
clearInterval(swaInterval);
clearInterval(corkInterval);
clearInterval(gazeInterval);
clearInterval(inputHaloInterval);
});
});
@@ -66,7 +70,7 @@ export default function DownloadsPage() {
<>
<PageHead
title="Downloads"
description="Download Life and Lineage, Shapes with Abigail, and Cork for macOS. Available on iOS, Android, and macOS."
description="Download InputHalo, Gaze, Life and Lineage, Shapes with Abigail, and Cork. Available on iOS, Android, and macOS."
/>
<div class="bg-base relative min-h-screen overflow-hidden px-4 pt-[15vh] pb-12 md:px-8">
@@ -86,6 +90,52 @@ export default function DownloadsPage() {
Ordered by date of initial release
</div>
<div class="mx-auto max-w-5xl space-y-16">
{/* InputHalo */}
<div class="border-overlay0 rounded-lg border p-6 md:p-8">
<h2 class="text-text mb-6 font-mono text-2xl">
<span class="text-yellow">{">"}</span> {inputHaloText()}
</h2>
<div class="flex flex-col gap-8 lg:flex-row lg:justify-around">
<div class="flex flex-col items-center gap-3">
<span class="text-subtext0 font-mono text-sm">
platform: macOS (14.6+)
</span>
<Button
variant="download"
size="lg"
loading={loadingState()["inputhalo"]}
onClick={() => download("inputhalo")}
>
download.dmg
</Button>
</div>
<div class="flex flex-col items-center gap-3">
<span class="text-subtext0 font-mono text-sm">
variant: paid (coming soon)
</span>
<A
class="transition-all duration-200 ease-out hover:scale-105 active:scale-95"
href="https://apps.apple.com/us/app/inputhalo/"
>
<DownloadOnAppStore size={50} />
</A>
</div>
<div class="flex flex-col items-center gap-3">
<span class="text-subtext0 font-mono text-sm">
variant: free (coming soon)
</span>
<A
class="transition-all duration-200 ease-out hover:scale-105 active:scale-95"
href=""
>
<DownloadOnAppStore size={50} />
</A>
</div>
</div>
</div>
{/* Gaze */}
<div class="border-overlay0 rounded-lg border p-6 md:p-8">
<h2 class="text-text mb-6 font-mono text-2xl">

View File

@@ -1173,10 +1173,10 @@ export const authRouter = createTRPCRouter({
});
}
// Generate 6-digit code
const loginCode = Math.floor(
100000 + Math.random() * 900000
).toString();
// Generate cryptographically secure 6-digit code (p8-010)
const randomBytes = new Uint32Array(1);
crypto.getRandomValues(randomBytes);
const loginCode = (100000 + (randomBytes[0] % 900000)).toString();
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const token = await new SignJWT({

View File

@@ -372,7 +372,7 @@ export const databaseRouter = createTRPCRouter({
body: z.string().nullable(),
banner_photo: z.string().nullable(),
published: z.boolean(),
tags: z.array(z.string()).nullable(),
tags: z.array(z.string().max(50).trim()).nullable(),
author_id: z.string()
})
)
@@ -405,12 +405,13 @@ export const databaseRouter = createTRPCRouter({
const results = await conn.execute({ sql: query, args: params });
if (input.tags && input.tags.length > 0) {
let tagQuery = "INSERT INTO Tag (value, post_id) VALUES ";
let values = input.tags.map(
(tag) => `("${tag}", ${results.lastInsertRowid})`
);
tagQuery += values.join(", ");
await conn.execute(tagQuery);
const validTags = input.tags.filter((t) => t.length > 0);
for (const tag of validTags) {
await conn.execute({
sql: "INSERT INTO Tag (value, post_id) VALUES (?, ?)",
args: [tag, results.lastInsertRowid]
});
}
}
await cache.deleteByPrefix("blog-");
@@ -434,7 +435,7 @@ export const databaseRouter = createTRPCRouter({
body: z.string().nullable().optional(),
banner_photo: z.string().nullable().optional(),
published: z.boolean().nullable().optional(),
tags: z.array(z.string()).nullable().optional(),
tags: z.array(z.string().max(50).trim()).nullable().optional(),
author_id: z.string()
})
)
@@ -523,10 +524,13 @@ export const databaseRouter = createTRPCRouter({
});
if (input.tags && input.tags.length > 0) {
let tagQuery = "INSERT INTO Tag (value, post_id) VALUES ";
let values = input.tags.map((tag) => `("${tag}", ${input.id})`);
tagQuery += values.join(", ");
await conn.execute(tagQuery);
const validTags = input.tags.filter((t) => t.length > 0);
for (const tag of validTags) {
await conn.execute({
sql: "INSERT INTO Tag (value, post_id) VALUES (?, ?)",
args: [tag, input.id]
});
}
}
await cache.deleteByPrefix("blog-");

View File

@@ -16,23 +16,24 @@ const assets: Record<string, string> = {
};
/**
* Get the latest Gaze DMG from S3 by finding the most recent file in downloads/ folder
* Get the latest DMG from S3 by finding the most recent file with the given prefix
*/
async function getLatestGazeDMG(
async function getLatestDMG(
client: S3Client,
bucket: string
bucket: string,
prefix: string
): Promise<string> {
try {
const listCommand = new ListObjectsV2Command({
Bucket: bucket,
Prefix: "downloads/Gaze-",
Prefix: prefix,
MaxKeys: 100
});
const response = await client.send(listCommand);
if (!response.Contents || response.Contents.length === 0) {
throw new Error("No Gaze DMG files found in S3");
throw new Error(`No DMG files found in S3 with prefix ${prefix}`);
}
// Filter for .dmg files only and sort by LastModified (newest first)
@@ -45,18 +46,38 @@ async function getLatestGazeDMG(
});
if (dmgFiles.length === 0) {
throw new Error("No .dmg files found in downloads/Gaze-* prefix");
throw new Error(`No .dmg files found in ${prefix} prefix`);
}
const latestFile = dmgFiles[0].Key!;
console.log(`Latest Gaze DMG: ${latestFile}`);
console.log(`Latest DMG: ${latestFile}`);
return latestFile;
} catch (error) {
console.error("Error finding latest Gaze DMG:", error);
console.error(`Error finding latest DMG for ${prefix}:`, error);
throw error;
}
}
/**
* Get the latest Gaze DMG from S3
*/
async function getLatestGazeDMG(
client: S3Client,
bucket: string
): Promise<string> {
return getLatestDMG(client, bucket, "downloads/Gaze-");
}
/**
* Get the latest InputHalo DMG from S3
*/
async function getLatestInputHaloDMG(
client: S3Client,
bucket: string
): Promise<string> {
return getLatestDMG(client, bucket, "downloads/InputHalo-");
}
export const downloadsRouter = createTRPCRouter({
getDownloadUrl: publicProcedure
.input(z.object({ asset_name: z.string() }))
@@ -76,9 +97,11 @@ export const downloadsRouter = createTRPCRouter({
try {
let fileKey: string;
// Special handling for Gaze - find latest version automatically
// Special handling for macOS apps - find latest version automatically
if (input.asset_name === "gaze") {
fileKey = await getLatestGazeDMG(client, bucket);
} else if (input.asset_name === "inputhalo") {
fileKey = await getLatestInputHaloDMG(client, bucket);
} else {
// Use static mapping for other assets
fileKey = assets[input.asset_name];

View File

@@ -33,7 +33,7 @@ export const gitActivityRouter = createTRPCRouter({
`github-commits-${input.limit}`,
CACHE_CONFIG.GIT_ACTIVITY_CACHE_TTL_MS,
async () => {
// Use Events API to get recent push events - much more efficient
// Use Events API to get recent push events
const eventsResponse = await fetchWithTimeout(
`https://api.github.com/users/MikeFreno/events/public?per_page=100`,
{
@@ -47,20 +47,23 @@ export const gitActivityRouter = createTRPCRouter({
await checkResponse(eventsResponse);
const events = await eventsResponse.json();
const allCommits: GitCommit[] = [];
// Extract push events and fetch commit details
// Collect (repo, sha) pairs from push events up front
const toFetch: { repoName: string; sha: string }[] = [];
for (const event of events) {
if (event.type !== "PushEvent") continue;
if (allCommits.length >= input.limit * 5) break; // Get extra to ensure we have enough
if (toFetch.length >= input.limit * 5) break;
toFetch.push({
repoName: event.repo.name,
sha: event.payload.head
});
}
const repoName = event.repo.name;
const commitSha = event.payload.head;
try {
// Fetch the actual commit details to get the message
const commitResponse = await fetchWithTimeout(
`https://api.github.com/repos/${repoName}/commits/${commitSha}`,
// Fetch all commits in parallel instead of serially
const results = await Promise.allSettled(
toFetch.map(({ repoName, sha }) =>
fetchWithTimeout(
`https://api.github.com/repos/${repoName}/commits/${sha}`,
{
headers: {
Authorization: `Bearer ${env.GITHUB_API_TOKEN}`,
@@ -68,50 +71,38 @@ export const gitActivityRouter = createTRPCRouter({
},
timeout: 5000
}
);
)
.then((res) => (res.ok ? res.json() : null))
.catch(() => null)
)
);
if (commitResponse.ok) {
const commit = await commitResponse.json();
const allCommits: GitCommit[] = [];
for (let i = 0; i < results.length; i++) {
const result = results[i];
if (result.status === "rejected" || !result.value) continue;
const commit = result.value;
const { repoName } = toFetch[i];
// Filter for your commits
if (
commit.author?.login === "MikeFreno" ||
commit.author?.login === "mikefreno" ||
commit.commit?.author?.email?.includes("mike")
) {
allCommits.push({
sha: commit.sha?.substring(0, 7) || "unknown",
message:
commit.commit?.message?.split("\n")[0] || "No message",
author:
commit.commit?.author?.name ||
commit.author?.login ||
"Unknown",
date:
commit.commit?.author?.date || new Date().toISOString(),
repo: repoName,
url: `https://github.com/${repoName}/commit/${commit.sha}`
});
}
}
} catch (error) {
if (
error instanceof NetworkError ||
error instanceof TimeoutError
) {
console.warn(
`Network error fetching commit ${commitSha} for ${repoName}, skipping`
);
} else {
console.error(
`Error fetching commit ${commitSha} for ${repoName}:`,
error
);
}
if (
commit.author?.login === "MikeFreno" ||
commit.author?.login === "mikefreno" ||
commit.commit?.author?.email?.includes("mike")
) {
allCommits.push({
sha: commit.sha?.substring(0, 7) || "unknown",
message: commit.commit?.message?.split("\n")[0] || "No message",
author:
commit.commit?.author?.name ||
commit.author?.login ||
"Unknown",
date: commit.commit?.author?.date || new Date().toISOString(),
repo: repoName,
url: `https://github.com/${repoName}/commit/${commit.sha}`
});
}
}
// Already sorted by event date, but sort again by commit date to be precise
allCommits.sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
);
@@ -155,13 +146,11 @@ export const gitActivityRouter = createTRPCRouter({
await checkResponse(reposResponse);
const repos = await reposResponse.json();
const allCommits: GitCommit[] = [];
for (const repo of repos) {
if (allCommits.length >= input.limit * 3) break; // Get extra to sort later
try {
const commitsResponse = await fetchWithTimeout(
// Fetch commits for all repos in parallel instead of serially
const commitResults = await Promise.allSettled(
repos.map((repo: any) =>
fetchWithTimeout(
`${env.GITEA_URL}/api/v1/repos/Mike/${repo.name}/commits?limit=5`,
{
headers: {
@@ -170,46 +159,36 @@ export const gitActivityRouter = createTRPCRouter({
},
timeout: 10000
}
);
)
.then((res) => (res.ok ? res.json() : []))
.catch(() => [])
)
);
if (commitsResponse.ok) {
const commits = await commitsResponse.json();
for (const commit of commits) {
if (
(commit.commit?.author?.email &&
commit.commit.author.email.includes(
"michael@freno.me"
)) ||
commit.commit.author.email.includes(
"michaelt.freno@gmail.com"
) // Filter for your commits
) {
allCommits.push({
sha: commit.sha?.substring(0, 7) || "unknown",
message:
commit.commit?.message?.split("\n")[0] || "No message",
author: commit.commit?.author?.name || repo.owner.login,
date:
commit.commit?.author?.date || new Date().toISOString(),
repo: repo.full_name,
url: `${env.GITEA_URL}/${repo.full_name}/commit/${commit.sha}`
});
}
}
}
} catch (error) {
const allCommits: GitCommit[] = [];
for (let i = 0; i < commitResults.length; i++) {
const result = commitResults[i];
if (result.status === "rejected") continue;
const repo = repos[i];
const commits: any[] = result.value;
for (const commit of commits) {
const email: string = commit.commit?.author?.email ?? "";
if (
error instanceof NetworkError ||
error instanceof TimeoutError
email.includes("michael@freno.me") ||
email.includes("michaelt.freno@gmail.com")
) {
console.warn(
`Network error fetching commits for ${repo.name}, skipping`
);
} else {
console.error(
`Error fetching commits for ${repo.name}:`,
error
);
allCommits.push({
sha: commit.sha?.substring(0, 7) || "unknown",
message:
commit.commit?.message?.split("\n")[0] || "No message",
author:
commit.commit?.author?.name ||
repo.owner?.login ||
"Unknown",
date: commit.commit?.author?.date || new Date().toISOString(),
repo: repo.full_name,
url: `${env.GITEA_URL}/${repo.full_name}/commit/${commit.sha}`
});
}
}
}
@@ -336,11 +315,13 @@ export const gitActivityRouter = createTRPCRouter({
const threeMonthsAgo = new Date();
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
const sinceParam = threeMonthsAgo.toISOString();
for (const repo of repos) {
try {
const commitsResponse = await fetchWithTimeout(
`${env.GITEA_URL}/api/v1/repos/${repo.owner.login}/${repo.name}/commits?limit=100`,
// Fetch commits for all repos in parallel, scoped to the 3-month window
const commitResults = await Promise.allSettled(
repos.map((repo: any) =>
fetchWithTimeout(
`${env.GITEA_URL}/api/v1/repos/${repo.owner.login}/${repo.name}/commits?limit=100&since=${sinceParam}`,
{
headers: {
Authorization: `token ${env.GITEA_TOKEN}`,
@@ -348,31 +329,23 @@ export const gitActivityRouter = createTRPCRouter({
},
timeout: 10000
}
);
)
.then((res) => (res.ok ? res.json() : []))
.catch(() => [])
)
);
if (commitsResponse.ok) {
const commits = await commitsResponse.json();
for (const commit of commits) {
const date = new Date(commit.commit.author.date)
.toISOString()
.split("T")[0];
contributionsByDay.set(
date,
(contributionsByDay.get(date) || 0) + 1
);
}
}
} catch (error) {
if (
error instanceof NetworkError ||
error instanceof TimeoutError
) {
console.warn(
`Network error fetching commits for ${repo.name}, skipping`
);
} else {
console.error(`Error fetching commits for ${repo.name}:`, error);
}
for (const result of commitResults) {
if (result.status === "rejected") continue;
const commits: any[] = result.value;
for (const commit of commits) {
const date = new Date(commit.commit.author.date)
.toISOString()
.split("T")[0];
contributionsByDay.set(
date,
(contributionsByDay.get(date) || 0) + 1
);
}
}

View File

@@ -10,7 +10,7 @@ import {
} from "~/server/utils";
import { env } from "~/env/server";
import { TRPCError } from "@trpc/server";
import { SignJWT, jwtVerify } from "jose";
import { SignJWT, jwtVerify, importJWK } from "jose";
import { LibsqlError } from "@libsql/client/web";
import { createClient as createAPIClient } from "@tursodatabase/api";
@@ -354,11 +354,68 @@ export const lineageAuthRouter = createTRPCRouter({
.input(
z.object({
email: z.string().email().optional(),
userString: z.string(),
idToken: z.string(),
})
)
.mutation(async ({ input }) => {
const { email, userString } = input;
const { email } = input;
// Verify Apple ID token signature using JWKS
const appleKeysResponse = await fetch(
"https://appleid.apple.com/auth/keys"
);
if (!appleKeysResponse.ok) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to fetch Apple public keys",
});
}
const appleKeys = (await appleKeysResponse.json()) as {
keys: Array<{
kty: string;
kid: string;
use: string;
alg: string;
n: string;
e: string;
}>;
};
// Decode JWT header to find matching key
const [headerB64] = input.idToken.split(".");
if (!headerB64) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid Apple ID token format",
});
}
const headerJson = Buffer.from(headerB64, "base64url").toString("utf8");
const header = JSON.parse(headerJson) as { kid: string };
const jwk = appleKeys.keys.find((k) => k.kid === header.kid);
if (!jwk) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Apple public key not found",
});
}
const publicKey = await importJWK(jwk, "RS256");
const jwtOptions: Parameters<typeof jwtVerify>[2] = {
algorithms: ["RS256"],
issuer: "https://appleid.apple.com",
};
if (env.APPLE_CLIENT_ID) {
jwtOptions.audience = env.APPLE_CLIENT_ID;
}
const { payload: tokenPayload } = await jwtVerify(
input.idToken,
publicKey,
jwtOptions
);
// Use verified Apple user ID from token (not from user input)
const userString = tokenPayload.sub as string;
let dbName;
let dbToken;

View File

@@ -6,8 +6,6 @@ import {
} from "~/server/utils";
import { env } from "~/env/server";
import { TRPCError } from "@trpc/server";
import { OAuth2Client } from "google-auth-library";
import { jwtVerify } from "jose";
import { createTRPCRouter, publicProcedure } from "~/server/api/utils";
import {
fetchWithTimeout,
@@ -18,84 +16,8 @@ import {
} from "~/server/fetch-utils";
export const lineageDatabaseRouter = createTRPCRouter({
credentials: publicProcedure
.input(
z.object({
email: z.string().email(),
provider: z.enum(["email", "google", "apple"]),
authToken: z.string()
})
)
.mutation(async ({ input }) => {
const { email, provider, authToken } = input;
try {
let valid_request = false;
if (provider === "email") {
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
const { payload } = await jwtVerify(authToken, secret);
if (payload.email === email) {
valid_request = true;
}
} else if (provider === "google") {
const CLIENT_ID = env.VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE;
if (!CLIENT_ID) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Google client ID not configured"
});
}
const client = new OAuth2Client(CLIENT_ID);
const ticket = await client.verifyIdToken({
idToken: authToken,
audience: CLIENT_ID
});
if (ticket.getPayload()?.email === email) {
valid_request = true;
}
} else {
const conn = LineageConnectionFactory();
const query = "SELECT * FROM User WHERE apple_user_string = ?";
const res = await conn.execute({ sql: query, args: [authToken] });
if (res.rows.length > 0 && res.rows[0].email === email) {
valid_request = true;
}
}
if (valid_request) {
const conn = LineageConnectionFactory();
const query = "SELECT * FROM User WHERE email = ? LIMIT 1";
const params = [email];
const res = await conn.execute({ sql: query, args: params });
if (res.rows.length === 1) {
const user = res.rows[0];
return {
success: true,
db_name: user.database_name as string,
db_token: user.database_token as string
};
}
throw new TRPCError({
code: "NOT_FOUND",
message: "No user found"
});
} else {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid credentials"
});
}
} catch (error) {
if (error instanceof TRPCError) throw error;
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Authentication failed"
});
}
}),
// credentials endpoint removed (p8-008): was exposing persistent DB tokens to clients.
// Database access should be proxied through tRPC server-side procedures.
deletionInit: publicProcedure
.input(
@@ -155,6 +77,14 @@ export const lineageDatabaseRouter = createTRPCRouter({
if (skip_cron) {
if (send_dump_target) {
// Validate dump target matches the authenticated user's email (p8-005)
if (send_dump_target !== email) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Dump target must match account email"
});
}
const dumpRes = await dumpAndSendDB({
dbName: db_name,
dbToken: db_token,

View File

@@ -19,9 +19,10 @@ import {
fetchWithRetry,
NetworkError,
TimeoutError,
APIError
APIError,
verifyTurnstileToken
} from "~/server/fetch-utils";
import { NETWORK_CONFIG, COOLDOWN_TIMERS, VALIDATION_CONFIG } from "~/config";
import { NETWORK_CONFIG, COOLDOWN_TIMERS, VALIDATION_CONFIG, TURNSTILE_CONFIG } from "~/config";
const assets: Record<string, string> = {
"shapes-with-abigail": "shapes-with-abigail.apk",
"magic-delve": "magic-delve.apk",
@@ -188,7 +189,7 @@ export const miscRouter = createTRPCRouter({
z.object({
key: z.string(),
newAttachmentString: z.string(),
type: z.string(),
type: z.enum(["Post", "Comment", "User"]),
id: z.number()
})
)
@@ -213,6 +214,7 @@ export const miscRouter = createTRPCRouter({
const res = await client.send(command);
const conn = ConnectionFactory();
// input.type is validated by z.enum allowlist above — safe for identifier use
const query = `UPDATE ${input.type} SET attachments = ? WHERE id = ?`;
await conn.execute({
sql: query,
@@ -304,10 +306,27 @@ export const miscRouter = createTRPCRouter({
message: z
.string()
.min(1)
.max(VALIDATION_CONFIG.MAX_CONTACT_MESSAGE_LENGTH)
.max(VALIDATION_CONFIG.MAX_CONTACT_MESSAGE_LENGTH),
turnstileToken: z.string().min(1, "Please complete the security check")
})
)
.mutation(async ({ input }) => {
// Verify Cloudflare Turnstile token
const turnstileValid = await verifyTurnstileToken(
input.turnstileToken,
env.TURNSTILE_SECRET_KEY,
TURNSTILE_CONFIG.VERIFY_URL,
TURNSTILE_CONFIG.RESPONSE_TIMEOUT_MS
);
if (!turnstileValid) {
console.error("Turnstile verification failed for contact form submission");
throw new TRPCError({
code: "FORBIDDEN",
message: "Security verification failed. Please refresh the page and try again."
});
}
const contactExp = getCookie("contactRequestSent");
let remaining = 0;
@@ -326,13 +345,22 @@ export const miscRouter = createTRPCRouter({
const apiKey = env.SENDINBLUE_KEY;
const apiUrl = "https://api.sendinblue.com/v3/smtp/email";
// HTML-escape user input to prevent HTML injection in email (p8-006)
const escapeHtml = (str: string) =>
str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
const sendinblueData = {
sender: {
name: "freno.me",
email: "michael@freno.me"
},
to: [{ email: "michael@freno.me" }],
htmlContent: `<html><head></head><body><div>Request Name: ${input.name}</div><div>Request Email: ${input.email}</div><div>Request Message: ${input.message}</div></body></html>`,
htmlContent: `<html><head></head><body><div>Request Name: ${escapeHtml(input.name)}</div><div>Request Email: ${escapeHtml(input.email)}</div><div>Request Message: ${escapeHtml(input.message)}</div></body></html>`,
subject: "freno.me Contact Request"
};

View File

@@ -1,6 +1,7 @@
import { createTRPCRouter, nessaProcedure, publicProcedure } from "../utils";
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { jwtVerify, importJWK } from "jose";
import { NessaConnectionFactory } from "~/server/database";
import { cache } from "~/server/cache";
import { hashPassword, checkPasswordSafe } from "~/server/utils";
@@ -628,45 +629,30 @@ export const nessaDbRouter = createTRPCRouter({
const header = JSON.parse(headerJson) as { kid: string; alg: string };
// Find the matching key
const key = appleKeys.keys.find((k) => k.kid === header.kid);
if (!key) {
const jwk = appleKeys.keys.find((k) => k.kid === header.kid);
if (!jwk) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Apple public key not found"
});
}
// For simplicity, we'll decode the payload and verify basic claims
// In production, you should use a proper JWT library like jose to verify the signature
const [, payloadB64] = input.idToken.split(".");
if (!payloadB64) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid Apple ID token format"
});
}
// Import the Apple JWK key for signature verification
const publicKey = await importJWK(jwk, "RS256");
const payloadJson = Buffer.from(payloadB64, "base64url").toString(
"utf8"
// Verify the Apple ID token signature and claims using jose
const jwtOptions: Parameters<typeof jwtVerify>[2] = {
algorithms: ["RS256"],
issuer: "https://appleid.apple.com"
};
if (env.APPLE_CLIENT_ID) {
jwtOptions.audience = env.APPLE_CLIENT_ID;
}
const { payload: tokenPayload } = await jwtVerify(
input.idToken,
publicKey,
jwtOptions
);
const tokenPayload = JSON.parse(payloadJson) as AppleTokenPayload;
// Validate the token payload
if (tokenPayload.iss !== "https://appleid.apple.com") {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid token issuer"
});
}
// Check if token is expired
const now = Math.floor(Date.now() / 1000);
if (tokenPayload.exp < now) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Token has expired"
});
}
// Apple user ID from token should match the one provided
if (tokenPayload.sub !== input.appleUserId) {
@@ -676,7 +662,7 @@ export const nessaDbRouter = createTRPCRouter({
});
}
const appleUserId = tokenPayload.sub;
const appleUserId = tokenPayload.sub as string;
// Apple only sends email on first sign-in, so use input.email if token doesn't have it
const email = tokenPayload.email ?? input.email;
const firstName = input.firstName ?? "Apple";

View File

@@ -155,7 +155,9 @@ export const reactionTypeSchema = z.enum([
"moneyEye",
"sick",
"upsideDown",
"worried"
"worried",
"upVote",
"downVote"
]);
/**

View File

@@ -1,10 +1,10 @@
import type { H3Event } from "vinxi/http";
import { getCookie, setCookie } from "vinxi/http";
import { getCookie, setCookie, getHeader } from "vinxi/http";
import { OAuth2Client } from "google-auth-library";
import type { Row } from "@libsql/client/web";
import { SignJWT, jwtVerify } from "jose";
import { env } from "~/env/server";
import { ConnectionFactory } from "./database";
import { ConnectionFactory } from "./db-connections";
import { AUTH_CONFIG, expiryToSeconds, getAccessTokenExpiry } from "~/config";
export const authCookieName = "auth_token";
@@ -30,7 +30,7 @@ function getAuthCookieOptions(rememberMe: boolean) {
}
function getAuthHeaderToken(event: H3Event): string | null {
const requestHeader = event.request?.headers?.get?.("authorization") || null;
const requestHeader = getHeader(event, "authorization") || null;
const eventHeader = event.headers
? typeof (event.headers as any).get === "function"
? (event.headers as any).get("authorization")
@@ -199,6 +199,7 @@ export async function validateLineageRequest({
return false;
}
} catch (err) {
console.error("Failed to verify email auth token:", err);
return false;
}
} else if (provider == "apple") {

View File

@@ -1,167 +1,89 @@
/**
* Redis-backed Cache for Serverless
* In-memory cache with TTL
*
* Uses Redis for persistent caching across serverless invocations.
* Redis provides:
* - Fast in-memory storage
* - Built-in TTL expiration (automatic cleanup)
* - Persistence across function invocations
* - Native support in Vercel and other platforms
* Redis was replaced because on a low-traffic site the cache TTL almost always
* expires between visits, so every request paid Redis connection + round-trip
* overhead with no benefit. A module-level Map has zero network latency:
* cache hits are a single dictionary lookup, misses fall through immediately.
*/
import { createClient } from "redis";
import { env } from "~/env/server";
import { CACHE_CONFIG } from "~/config";
let redisClient: ReturnType<typeof createClient> | null = null;
let isConnecting = false;
let connectionError: Error | null = null;
/**
* Get or create Redis client (singleton pattern)
*/
async function getRedisClient() {
if (redisClient && redisClient.isOpen) {
return redisClient;
}
if (isConnecting) {
// Wait for existing connection attempt
await new Promise((resolve) => setTimeout(resolve, 100));
return getRedisClient();
}
if (connectionError) {
throw connectionError;
}
try {
isConnecting = true;
redisClient = createClient({ url: env.REDIS_URL });
redisClient.on("error", (err) => {
console.error("Redis Client Error:", err);
connectionError = err;
});
await redisClient.connect();
isConnecting = false;
connectionError = null;
return redisClient;
} catch (error) {
isConnecting = false;
connectionError = error as Error;
console.error("Failed to connect to Redis:", error);
throw error;
}
interface CacheEntry<T> {
data: T;
/** Absolute timestamp (ms) after which this entry is considered stale */
expiresAt: number;
/** Absolute timestamp (ms) after which stale fallback is also discarded */
staleExpiresAt: number;
}
/**
* Redis-backed cache interface
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const store = new Map<string, CacheEntry<any>>();
export const cache = {
async get<T>(key: string): Promise<T | null> {
try {
const client = await getRedisClient();
const value = await client.get(key);
get<T>(key: string): T | null {
const entry = store.get(key) as CacheEntry<T> | undefined;
if (!entry) return null;
if (Date.now() > entry.expiresAt) return null;
return entry.data;
},
if (!value) {
return null;
}
set<T>(key: string, data: T, ttlMs: number): void {
const existing = store.get(key);
store.set(key, {
data,
expiresAt: Date.now() + ttlMs,
// Preserve an existing stale expiry if it's longer, otherwise default
staleExpiresAt:
existing?.staleExpiresAt ?? Date.now() + CACHE_CONFIG.MAX_STALE_DATA_MS
});
},
return JSON.parse(value) as T;
} catch (error) {
console.error(`Cache get error for key "${key}":`, error);
return null;
delete(key: string): void {
store.delete(key);
},
deleteByPrefix(prefix: string): void {
for (const key of store.keys()) {
if (key.startsWith(prefix)) store.delete(key);
}
},
async set<T>(key: string, data: T, ttlMs: number): Promise<void> {
try {
const client = await getRedisClient();
const value = JSON.stringify(data);
// Redis SET with EX (expiry in seconds)
await client.set(key, value, {
EX: Math.ceil(ttlMs / 1000)
});
} catch (error) {
console.error(`Cache set error for key "${key}":`, error);
}
clear(): void {
store.clear();
},
async delete(key: string): Promise<void> {
try {
const client = await getRedisClient();
await client.del(key);
} catch (error) {
console.error(`Cache delete error for key "${key}":`, error);
}
},
async deleteByPrefix(prefix: string): Promise<void> {
try {
const client = await getRedisClient();
const keys = await client.keys(`${prefix}*`);
if (keys.length > 0) {
await client.del(keys);
}
} catch (error) {
console.error(
`Cache deleteByPrefix error for prefix "${prefix}":`,
error
);
}
},
async clear(): Promise<void> {
try {
const client = await getRedisClient();
await client.flushDb();
} catch (error) {
console.error("Cache clear error:", error);
}
},
async has(key: string): Promise<boolean> {
try {
const client = await getRedisClient();
const exists = await client.exists(key);
return exists === 1;
} catch (error) {
console.error(`Cache has error for key "${key}":`, error);
return false;
}
has(key: string): boolean {
const entry = store.get(key);
if (!entry) return false;
return Date.now() <= entry.expiresAt;
}
};
/**
* Execute function with Redis caching
* Execute function with in-memory caching.
*/
export async function withCache<T>(
key: string,
ttlMs: number,
fn: () => Promise<T>
): Promise<T> {
const cached = await cache.get<T>(key);
if (cached !== null) {
return cached;
}
const cached = cache.get<T>(key);
if (cached !== null) return cached;
const result = await fn();
await cache.set(key, result, ttlMs);
cache.set(key, result, ttlMs);
return result;
}
/**
* Execute function with Redis caching and stale data fallback
* Execute function with caching and stale-data fallback.
*
* Strategy:
* 1. Try to get fresh cached data (within TTL)
* 2. If not found, execute function
* 3. If function fails, try to get stale data (ignore TTL)
* 4. Store result with TTL for future requests
* 1. Return data if fresh (within TTL).
* 2. Otherwise run fn().
* 3. If fn() throws, return stale data if still within maxStaleMs.
* 4. Store fresh result for future requests.
*/
export async function withCacheAndStale<T>(
key: string,
@@ -175,34 +97,29 @@ export async function withCacheAndStale<T>(
const { maxStaleMs = CACHE_CONFIG.MAX_STALE_DATA_MS, logErrors = true } =
options;
// Try fresh cache
const cached = await cache.get<T>(key);
if (cached !== null) {
return cached;
}
const now = Date.now();
const entry = store.get(key) as CacheEntry<T> | undefined;
// Fresh hit
if (entry && entry.expiresAt > now) return entry.data;
try {
// Execute function
const result = await fn();
await cache.set(key, result, ttlMs);
// Also store with longer TTL for stale fallback
const staleKey = `${key}:stale`;
await cache.set(staleKey, result, maxStaleMs);
store.set(key, {
data: result,
expiresAt: now + ttlMs,
staleExpiresAt: now + maxStaleMs
});
return result;
} catch (error) {
if (logErrors) {
console.error(`Error fetching data for cache key "${key}":`, error);
}
// Try stale cache with longer TTL key
const staleKey = `${key}:stale`;
const staleData = await cache.get<T>(staleKey);
if (staleData !== null) {
if (logErrors) {
console.log(`Serving stale data for cache key "${key}"`);
}
return staleData;
// Stale fallback
if (entry && entry.staleExpiresAt > now) {
if (logErrors) console.log(`Serving stale data for cache key "${key}"`);
return entry.data;
}
throw error;

View File

@@ -11,43 +11,13 @@ import {
TimeoutError,
APIError
} from "~/server/fetch-utils";
let mainDBConnection: ReturnType<typeof createClient> | null = null;
let lineageDBConnection: ReturnType<typeof createClient> | null = null;
let nessaDBConnection: ReturnType<typeof createClient> | null = null;
export function ConnectionFactory() {
if (!mainDBConnection) {
const config = {
url: env.TURSO_DB_URL,
authToken: env.TURSO_DB_TOKEN
};
mainDBConnection = createClient(config);
}
return mainDBConnection;
}
export function LineageConnectionFactory() {
if (!lineageDBConnection) {
const config = {
url: env.TURSO_LINEAGE_URL,
authToken: env.TURSO_LINEAGE_TOKEN
};
lineageDBConnection = createClient(config);
}
return lineageDBConnection;
}
export function NessaConnectionFactory() {
if (!nessaDBConnection) {
const config = {
url: env.NESSA_DB_URL,
authToken: env.NESSA_DB_TOKEN
};
nessaDBConnection = createClient(config);
}
return nessaDBConnection;
}
import {
ConnectionFactory,
LineageConnectionFactory,
NessaConnectionFactory
} from "~/server/db-connections";
// Re-export connection factories to avoid circular import with auth.ts
export { ConnectionFactory, LineageConnectionFactory, NessaConnectionFactory };
export async function LineageDBInit() {
const turso = createAPIClient({
@@ -209,7 +179,7 @@ export async function getUserBasicInfo(event: H3Event): Promise<{
return { email: null, isAuthenticated: false };
}
const user = res.rows[0] as { email: string | null };
const user = res.rows[0] as unknown as { email: string | null };
return {
email: user.email,
isAuthenticated: true

View File

@@ -0,0 +1,39 @@
import { createClient } from "@libsql/client/web";
import { env } from "~/env/server";
let mainDBConnection: ReturnType<typeof createClient> | null = null;
let lineageDBConnection: ReturnType<typeof createClient> | null = null;
let nessaDBConnection: ReturnType<typeof createClient> | null = null;
export function ConnectionFactory() {
if (!mainDBConnection) {
const config = {
url: env.TURSO_DB_URL,
authToken: env.TURSO_DB_TOKEN
};
mainDBConnection = createClient(config);
}
return mainDBConnection;
}
export function LineageConnectionFactory() {
if (!lineageDBConnection) {
const config = {
url: env.TURSO_LINEAGE_URL,
authToken: env.TURSO_LINEAGE_TOKEN
};
lineageDBConnection = createClient(config);
}
return lineageDBConnection;
}
export function NessaConnectionFactory() {
if (!nessaDBConnection) {
const config = {
url: env.NESSA_DB_URL,
authToken: env.NESSA_DB_TOKEN
};
nessaDBConnection = createClient(config);
}
return nessaDBConnection;
}

View File

@@ -135,3 +135,56 @@ export async function fetchWithRetry<T>(
throw lastError;
}
// ============================================================
// CLOUDFLARE TURNSTILE VERIFICATION
// ============================================================
interface TurnstileResponse {
success: boolean;
"challenge-ts"?: string;
action?: string;
cdata?: string;
"error-codes"?: string[];
}
export async function verifyTurnstileToken(
token: string,
secretKey: string,
verifyUrl: string = "https://challenges.cloudflare.com/turnstile/v0/siteverify",
timeoutMs: number = 10000
): Promise<boolean> {
if (!token || token.trim() === "") {
return false;
}
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
const response = await fetch(verifyUrl, {
method: "POST",
body: new URLSearchParams({
secret: secretKey,
response: token,
}),
headers: {
"content-type": "application/x-www-form-urlencoded",
},
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
console.error(`Turnstile verification failed with status ${response.status}`);
return false;
}
const data = (await response.json()) as TurnstileResponse;
return data.success === true;
} catch (error) {
console.error("Turnstile verification error:", error);
return false;
}
}

View File

@@ -0,0 +1,15 @@
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=()"
});
}
});

View File

@@ -51,7 +51,12 @@ function getCookieValue(event: H3Event, name: string): string | undefined {
try {
const value = getCookie(event, name);
if (value) return value;
} catch (e) {}
} catch (e) {
console.warn(
"[security] getCookie failed, falling back to header parse:",
e
);
}
try {
const cookieHeader =
@@ -252,17 +257,34 @@ async function cleanupExpiredRateLimits(): Promise<void> {
}
/**
* Get client IP address from request headers
* Get client IP address from request headers.
* Only trusts X-Forwarded-For in production (set by Vercel edge network).
* In development/test, uses socket address to prevent header spoofing.
*/
export function getClientIP(event: H3Event): string {
const forwarded = getHeaderValue(event, "x-forwarded-for");
if (forwarded) {
return forwarded.split(",")[0].trim();
// In production on Vercel, X-Forwarded-For is set by the edge network
// and cannot be spoofed by clients. In dev/test, ignore it.
if (env.NODE_ENV === "production") {
const forwarded = getHeaderValue(event, "x-forwarded-for");
if (forwarded) {
return forwarded.split(",")[0].trim();
}
const realIP = getHeaderValue(event, "x-real-ip");
if (realIP) {
return realIP;
}
}
const realIP = getHeaderValue(event, "x-real-ip");
if (realIP) {
return realIP;
// Fallback: try socket remote address
try {
const nodeReq = event.node.req;
if (nodeReq?.socket?.remoteAddress) {
const addr = nodeReq.socket.remoteAddress;
// Clean up IPv6-mapped IPv4 addresses
return addr.replace(/^::ffff:/, "");
}
} catch {
// socket access failed
}
return "unknown";

View File

@@ -26,7 +26,9 @@ export type ReactionType =
| "moneyEye"
| "sick"
| "upsideDown"
| "worried";
| "worried"
| "upVote"
| "downVote";
export interface UserPublicData {
email?: string;