Compare commits
8 Commits
67bf77815e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 7dc5166e90 | |||
| 30b2d03c68 | |||
| d48bbc0fc3 | |||
| b7187721db | |||
| fbc8215410 | |||
| 8b6551330f | |||
| 3635133994 | |||
| 4dbd0ac965 |
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -6,7 +6,8 @@ import type {
|
||||
UserPublicData,
|
||||
ReactionType,
|
||||
ModificationType,
|
||||
SortingMode
|
||||
SortingMode,
|
||||
CommentSectionProps
|
||||
} from "~/types/comment";
|
||||
import CommentInputBlock from "./CommentInputBlock";
|
||||
import CommentSortingSelect from "./CommentSortingSelect";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
105
src/env/client.ts
vendored
@@ -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
28
src/env/server.ts
vendored
@@ -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>;
|
||||
|
||||
79
src/lib/auth-callback-utils.ts
Normal file
79
src/lib/auth-callback-utils.ts
Normal 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");
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
62
src/routes/api/InputHalo/appcast.xml.ts
Normal file
62
src/routes/api/InputHalo/appcast.xml.ts
Normal 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"
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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-");
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
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"
|
||||
};
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -155,7 +155,9 @@ export const reactionTypeSchema = z.enum([
|
||||
"moneyEye",
|
||||
"sick",
|
||||
"upsideDown",
|
||||
"worried"
|
||||
"worried",
|
||||
"upVote",
|
||||
"downVote"
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
39
src/server/db-connections.ts
Normal file
39
src/server/db-connections.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
15
src/server/middleare/security-headers.ts
Normal file
15
src/server/middleare/security-headers.ts
Normal 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=()"
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -26,7 +26,9 @@ export type ReactionType =
|
||||
| "moneyEye"
|
||||
| "sick"
|
||||
| "upsideDown"
|
||||
| "worried";
|
||||
| "worried"
|
||||
| "upVote"
|
||||
| "downVote";
|
||||
|
||||
export interface UserPublicData {
|
||||
email?: string;
|
||||
|
||||
Reference in New Issue
Block a user