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: {
|
build: {
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
manualChunks: (id) => {
|
manualChunks: (id: string) => {
|
||||||
// Bundle highlight.js and lowlight together
|
// Bundle highlight.js and lowlight together
|
||||||
if (id.includes("highlight.js") || id.includes("lowlight")) {
|
if (id.includes("highlight.js") || id.includes("lowlight")) {
|
||||||
return "highlight";
|
return "highlight";
|
||||||
|
|||||||
@@ -55,7 +55,6 @@
|
|||||||
"jose": "^6.1.3",
|
"jose": "^6.1.3",
|
||||||
"mermaid": "^11.12.2",
|
"mermaid": "^11.12.2",
|
||||||
"motion": "^12.23.26",
|
"motion": "^12.23.26",
|
||||||
"redis": "^5.10.0",
|
|
||||||
"solid-js": "^1.9.5",
|
"solid-js": "^1.9.5",
|
||||||
"solid-tiptap": "^0.8.0",
|
"solid-tiptap": "^0.8.0",
|
||||||
"ua-parser-js": "^2.0.7",
|
"ua-parser-js": "^2.0.7",
|
||||||
|
|||||||
@@ -46,6 +46,34 @@ interface ContributionDay {
|
|||||||
count: number;
|
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() {
|
export function RightBarContent() {
|
||||||
const { setLeftBarVisible } = useBars();
|
const { setLeftBarVisible } = useBars();
|
||||||
const [githubCommits, setGithubCommits] = createSignal<GitCommit[]>([]);
|
const [githubCommits, setGithubCommits] = createSignal<GitCommit[]>([]);
|
||||||
@@ -54,7 +82,8 @@ export function RightBarContent() {
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
const [giteaActivity, setGiteaActivity] = createSignal<ContributionDay[]>([]);
|
const [giteaActivity, setGiteaActivity] = createSignal<ContributionDay[]>([]);
|
||||||
const [loading, setLoading] = createSignal(true);
|
const [githubCommitsLoading, setGithubCommitsLoading] = createSignal(true);
|
||||||
|
const [giteaCommitsLoading, setGiteaCommitsLoading] = createSignal(true);
|
||||||
|
|
||||||
const handleLinkClick = () => {
|
const handleLinkClick = () => {
|
||||||
if (
|
if (
|
||||||
@@ -66,41 +95,23 @@ export function RightBarContent() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMount(() => {
|
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(() => {
|
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);
|
}, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -190,7 +201,7 @@ export function RightBarContent() {
|
|||||||
<RecentCommits
|
<RecentCommits
|
||||||
commits={giteaCommits()}
|
commits={giteaCommits()}
|
||||||
title="Recent Gitea Commits"
|
title="Recent Gitea Commits"
|
||||||
loading={loading()}
|
loading={giteaCommitsLoading()}
|
||||||
/>
|
/>
|
||||||
<ActivityHeatmap
|
<ActivityHeatmap
|
||||||
contributions={giteaActivity()}
|
contributions={giteaActivity()}
|
||||||
@@ -199,7 +210,7 @@ export function RightBarContent() {
|
|||||||
<RecentCommits
|
<RecentCommits
|
||||||
commits={githubCommits()}
|
commits={githubCommits()}
|
||||||
title="Recent GitHub Commits"
|
title="Recent GitHub Commits"
|
||||||
loading={loading()}
|
loading={githubCommitsLoading()}
|
||||||
/>
|
/>
|
||||||
<ActivityHeatmap
|
<ActivityHeatmap
|
||||||
contributions={githubActivity()}
|
contributions={githubActivity()}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import LikeIcon from "~/components/icons/LikeIcon";
|
|||||||
export interface PostLike {
|
export interface PostLike {
|
||||||
id: number;
|
id: number;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
post_id: string;
|
post_id: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthenticatedLikeProps {
|
export interface AuthenticatedLikeProps {
|
||||||
@@ -36,15 +36,15 @@ export default function AuthenticatedLike(props: AuthenticatedLikeProps) {
|
|||||||
if (initialHasLiked) {
|
if (initialHasLiked) {
|
||||||
const result = await api.database.removePostLike.mutate({
|
const result = await api.database.removePostLike.mutate({
|
||||||
user_id: props.currentUserID,
|
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 {
|
} else {
|
||||||
const result = await api.database.addPostLike.mutate({
|
const result = await api.database.addPostLike.mutate({
|
||||||
user_id: props.currentUserID,
|
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);
|
setInstantOffset(0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import type {
|
|||||||
UserPublicData,
|
UserPublicData,
|
||||||
ReactionType,
|
ReactionType,
|
||||||
ModificationType,
|
ModificationType,
|
||||||
SortingMode
|
SortingMode,
|
||||||
|
CommentSectionProps
|
||||||
} from "~/types/comment";
|
} from "~/types/comment";
|
||||||
import CommentInputBlock from "./CommentInputBlock";
|
import CommentInputBlock from "./CommentInputBlock";
|
||||||
import CommentSortingSelect from "./CommentSortingSelect";
|
import CommentSortingSelect from "./CommentSortingSelect";
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export default function DeletePostButton(props: DeletePostButtonProps) {
|
|||||||
await api.database.deletePost.mutate({ id: props.postID });
|
await api.database.deletePost.mutate({ id: props.postID });
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error("Failed to delete post:", error);
|
||||||
alert("Failed to delete post");
|
alert("Failed to delete post");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,42 @@
|
|||||||
import { onMount } from "solid-js";
|
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() {
|
export default function MermaidRenderer() {
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const mermaidPres = document.querySelectorAll('pre[data-type="mermaid"]');
|
const mermaidPres = document.querySelectorAll('pre[data-type="mermaid"]');
|
||||||
@@ -12,7 +49,7 @@ export default function MermaidRenderer() {
|
|||||||
mermaid.initialize({
|
mermaid.initialize({
|
||||||
startOnLoad: false,
|
startOnLoad: false,
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
securityLevel: "loose",
|
securityLevel: "strict",
|
||||||
fontFamily: "monospace",
|
fontFamily: "monospace",
|
||||||
themeVariables: {
|
themeVariables: {
|
||||||
darkMode: true,
|
darkMode: true,
|
||||||
@@ -38,7 +75,7 @@ export default function MermaidRenderer() {
|
|||||||
|
|
||||||
const wrapper = document.createElement("div");
|
const wrapper = document.createElement("div");
|
||||||
wrapper.className = "mermaid-rendered";
|
wrapper.className = "mermaid-rendered";
|
||||||
wrapper.innerHTML = svg;
|
wrapper.innerHTML = sanitizeMermaidSvg(svg);
|
||||||
pre.replaceWith(wrapper);
|
pre.replaceWith(wrapper);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to render mermaid diagram:", err);
|
console.error("Failed to render mermaid diagram:", err);
|
||||||
|
|||||||
@@ -3,6 +3,46 @@ import type { HLJSApi } from "highlight.js";
|
|||||||
|
|
||||||
const MermaidRenderer = lazy(() => import("./MermaidRenderer"));
|
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 {
|
export interface PostBodyClientProps {
|
||||||
body: string;
|
body: string;
|
||||||
hasCodeBlock: boolean;
|
hasCodeBlock: boolean;
|
||||||
@@ -21,10 +61,10 @@ export default function PostBodyClient(props: PostBodyClientProps) {
|
|||||||
const processCodeBlocks = () => {
|
const processCodeBlocks = () => {
|
||||||
if (!contentRef) return;
|
if (!contentRef) return;
|
||||||
|
|
||||||
const codeBlocks = contentRef.querySelectorAll("pre code");
|
const codeBlocks = contentRef.querySelectorAll<HTMLElement>("pre code");
|
||||||
|
|
||||||
codeBlocks.forEach((codeBlock) => {
|
codeBlocks.forEach((codeBlock) => {
|
||||||
const pre = codeBlock.parentElement;
|
const pre = codeBlock.parentElement as HTMLPreElement | null;
|
||||||
if (!pre) return;
|
if (!pre) return;
|
||||||
|
|
||||||
if (pre.dataset.type === "mermaid") return;
|
if (pre.dataset.type === "mermaid") return;
|
||||||
@@ -228,14 +268,15 @@ export default function PostBodyClient(props: PostBodyClientProps) {
|
|||||||
const referencesHeadingText =
|
const referencesHeadingText =
|
||||||
marker?.getAttribute("data-heading") || "References";
|
marker?.getAttribute("data-heading") || "References";
|
||||||
|
|
||||||
const headings = contentRef.querySelectorAll("h2");
|
const headings = contentRef.querySelectorAll<HTMLElement>("h2");
|
||||||
let referencesSection: HTMLElement | null = null;
|
let referencesSection: HTMLElement | null = null;
|
||||||
|
|
||||||
headings.forEach((heading) => {
|
for (const heading of headings) {
|
||||||
if (heading.textContent?.trim() === referencesHeadingText) {
|
if (heading.textContent?.trim() === referencesHeadingText) {
|
||||||
referencesSection = heading;
|
referencesSection = heading;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
if (referencesSection) {
|
if (referencesSection) {
|
||||||
referencesSection.className = "text-2xl font-bold mb-4 text-text";
|
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");
|
parentDiv.classList.add("references-heading");
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentElement = referencesSection.nextElementSibling;
|
let currentElement: Element | null = referencesSection.nextElementSibling;
|
||||||
|
|
||||||
while (currentElement) {
|
while (currentElement) {
|
||||||
if (currentElement.tagName === "P") {
|
if (currentElement.tagName === "P") {
|
||||||
@@ -401,7 +442,7 @@ export default function PostBodyClient(props: PostBodyClientProps) {
|
|||||||
id="post-content-body"
|
id="post-content-body"
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
class="text-text prose dark:prose-invert max-w-none"
|
class="text-text prose dark:prose-invert max-w-none"
|
||||||
innerHTML={props.body}
|
innerHTML={sanitizeHtml(props.body)}
|
||||||
/>
|
/>
|
||||||
<Show when={props.hasMermaid}>
|
<Show when={props.hasMermaid}>
|
||||||
<MermaidRenderer />
|
<MermaidRenderer />
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ interface PostFormProps {
|
|||||||
published: boolean;
|
published: boolean;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
};
|
};
|
||||||
userID: number;
|
userID: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PostForm(props: PostFormProps) {
|
export default function PostForm(props: PostFormProps) {
|
||||||
@@ -89,7 +89,7 @@ export default function PostForm(props: PostFormProps) {
|
|||||||
tags: null,
|
tags: null,
|
||||||
author_id: props.userID
|
author_id: props.userID
|
||||||
});
|
});
|
||||||
const newId = result.data as number;
|
const newId = Number(result.data);
|
||||||
setCreatedPostId(newId);
|
setCreatedPostId(newId);
|
||||||
setHasSaved(true);
|
setHasSaved(true);
|
||||||
return newId;
|
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> {
|
export interface InputProps extends JSX.InputHTMLAttributes<HTMLInputElement> {
|
||||||
label?: string;
|
label?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
helperText?: string;
|
helperText?: string;
|
||||||
ref?: HTMLInputElement | ((el: HTMLInputElement) => void);
|
ref?: HTMLInputElement | ((el: HTMLInputElement) => void);
|
||||||
|
containerClass?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Input(props: InputProps) {
|
export default function Input(props: InputProps) {
|
||||||
@@ -12,11 +13,16 @@ export default function Input(props: InputProps) {
|
|||||||
"label",
|
"label",
|
||||||
"error",
|
"error",
|
||||||
"helperText",
|
"helperText",
|
||||||
"ref"
|
"ref",
|
||||||
|
"containerClass"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const containerClasses = ["input-group", local.containerClass]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="input-group">
|
<div class={containerClasses}>
|
||||||
<input
|
<input
|
||||||
{...others}
|
{...others}
|
||||||
ref={local.ref}
|
ref={local.ref}
|
||||||
|
|||||||
@@ -244,6 +244,15 @@ export const TEXT_EDITOR_CONFIG = {
|
|||||||
SCROLL_TO_CHANGE_DELAY_MS: 100
|
SCROLL_TO_CHANGE_DELAY_MS: 100
|
||||||
} as const;
|
} 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
|
// 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({
|
const requiredKeys: (keyof ClientEnv)[] = [
|
||||||
VITE_DOMAIN: z.string().min(1),
|
"VITE_DOMAIN",
|
||||||
VITE_AWS_BUCKET_STRING: z.string().min(1),
|
"VITE_AWS_BUCKET_STRING",
|
||||||
VITE_DOWNLOAD_BUCKET_STRING: z.string().min(1),
|
"VITE_DOWNLOAD_BUCKET_STRING",
|
||||||
VITE_GOOGLE_CLIENT_ID: z.string().min(1),
|
"VITE_GOOGLE_CLIENT_ID",
|
||||||
VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE: z.string().min(1),
|
"VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE",
|
||||||
VITE_GITHUB_CLIENT_ID: z.string().min(1),
|
"VITE_GITHUB_CLIENT_ID",
|
||||||
VITE_WEBSOCKET: z.string().min(1),
|
"VITE_WEBSOCKET",
|
||||||
VITE_INFILL_ENDPOINT: z.string().min(1)
|
"VITE_INFILL_ENDPOINT",
|
||||||
});
|
"VITE_TURNSTILE_SITE_KEY"
|
||||||
|
];
|
||||||
export type ClientEnv = z.infer<typeof clientEnvSchema>;
|
|
||||||
|
|
||||||
export const validateClientEnv = (
|
export const validateClientEnv = (
|
||||||
envVars: Record<string, string | undefined>
|
envVars: Record<string, string | undefined>
|
||||||
): ClientEnv => {
|
): ClientEnv => {
|
||||||
try {
|
const missing = requiredKeys.filter(
|
||||||
return clientEnvSchema.parse(envVars);
|
(key) => !envVars[key] || envVars[key]!.trim() === ""
|
||||||
} 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 invalidVars = Object.entries(formattedErrors)
|
if (missing.length > 0) {
|
||||||
.filter(
|
const message = `Client environment validation failed:\nMissing required variables: ${missing.join(", ")}`;
|
||||||
([key, value]) =>
|
console.error(message);
|
||||||
key !== "_errors" &&
|
throw new Error(message);
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return envVars as unknown as ClientEnv;
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateAndExportEnv = (): ClientEnv => {
|
const validateAndExportEnv = (): ClientEnv => {
|
||||||
try {
|
try {
|
||||||
const validated = validateClientEnv(import.meta.env);
|
const validated = validateClientEnv(import.meta.env);
|
||||||
console.log("✅ Client environment validation successful");
|
|
||||||
return validated;
|
return validated;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ Client environment validation failed:", error);
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -86,14 +54,5 @@ export const isMissingEnvVar = (varName: string): boolean => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getMissingEnvVars = (): string[] => {
|
export const getMissingEnvVars = (): string[] => {
|
||||||
const requiredClientVars = [
|
return requiredKeys.filter((varName) => isMissingEnvVar(varName));
|
||||||
"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));
|
|
||||||
};
|
};
|
||||||
|
|||||||
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";
|
import { z } from "zod";
|
||||||
|
|
||||||
const serverEnvSchema = z.object({
|
const serverEnvSchema = z.object({
|
||||||
@@ -33,7 +56,10 @@ const serverEnvSchema = z.object({
|
|||||||
REDIS_URL: z.string().min(1),
|
REDIS_URL: z.string().min(1),
|
||||||
NESSA_DB_URL: z.string().min(1),
|
NESSA_DB_URL: z.string().min(1),
|
||||||
NESSA_DB_TOKEN: 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>;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const supported = new Set(PerformanceObserver.supportedEntryTypes ?? []);
|
||||||
|
|
||||||
// Observe LCP
|
// Observe LCP
|
||||||
try {
|
if (supported.has("largest-contentful-paint")) {
|
||||||
const lcpObserver = new PerformanceObserver((entryList) => {
|
try {
|
||||||
const entries = entryList.getEntries();
|
const lcpObserver = new PerformanceObserver((entryList) => {
|
||||||
const lastEntry = entries[entries.length - 1] as any;
|
const entries = entryList.getEntries();
|
||||||
metrics.lcp = lastEntry.renderTime || lastEntry.loadTime;
|
const lastEntry = entries[entries.length - 1] as any;
|
||||||
});
|
metrics.lcp = lastEntry.renderTime || lastEntry.loadTime;
|
||||||
lcpObserver.observe({ type: "largest-contentful-paint", buffered: true });
|
});
|
||||||
} catch (e) {
|
lcpObserver.observe({ type: "largest-contentful-paint", buffered: true });
|
||||||
console.debug("LCP not supported");
|
} catch (e) {
|
||||||
|
console.debug("LCP observer failed");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Observe CLS
|
// Observe CLS
|
||||||
try {
|
if (supported.has("layout-shift")) {
|
||||||
const clsObserver = new PerformanceObserver((entryList) => {
|
try {
|
||||||
for (const entry of entryList.getEntries()) {
|
const clsObserver = new PerformanceObserver((entryList) => {
|
||||||
const layoutShift = entry as any;
|
for (const entry of entryList.getEntries()) {
|
||||||
if (!layoutShift.hadRecentInput) {
|
const layoutShift = entry as any;
|
||||||
clsValue += layoutShift.value;
|
if (!layoutShift.hadRecentInput) {
|
||||||
clsEntries.push(layoutShift.value);
|
clsValue += layoutShift.value;
|
||||||
|
clsEntries.push(layoutShift.value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
metrics.cls = clsValue;
|
||||||
metrics.cls = clsValue;
|
});
|
||||||
});
|
clsObserver.observe({ type: "layout-shift", buffered: true });
|
||||||
clsObserver.observe({ type: "layout-shift", buffered: true });
|
} catch (e) {
|
||||||
} catch (e) {
|
console.debug("CLS observer failed");
|
||||||
console.debug("CLS not supported");
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Observe FID
|
// Observe FID
|
||||||
try {
|
if (supported.has("first-input")) {
|
||||||
const fidObserver = new PerformanceObserver((entryList) => {
|
try {
|
||||||
const firstInput = entryList.getEntries()[0] as any;
|
const fidObserver = new PerformanceObserver((entryList) => {
|
||||||
if (firstInput) {
|
const firstInput = entryList.getEntries()[0] as any;
|
||||||
metrics.fid = firstInput.processingStart - firstInput.startTime;
|
if (firstInput) {
|
||||||
}
|
metrics.fid = firstInput.processingStart - firstInput.startTime;
|
||||||
});
|
}
|
||||||
fidObserver.observe({ type: "first-input", buffered: true });
|
});
|
||||||
} catch (e) {
|
fidObserver.observe({ type: "first-input", buffered: true });
|
||||||
console.debug("FID not supported");
|
} catch (e) {
|
||||||
|
console.debug("FID observer failed");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Observe INP (event timing)
|
// Observe INP (event timing)
|
||||||
try {
|
if (supported.has("event")) {
|
||||||
const interactions: number[] = [];
|
try {
|
||||||
const inpObserver = new PerformanceObserver((entryList) => {
|
const interactions: number[] = [];
|
||||||
for (const entry of entryList.getEntries()) {
|
const inpObserver = new PerformanceObserver((entryList) => {
|
||||||
const eventEntry = entry as any;
|
for (const entry of entryList.getEntries()) {
|
||||||
if (eventEntry.interactionId) {
|
const eventEntry = entry as any;
|
||||||
interactions.push(eventEntry.duration);
|
if (eventEntry.interactionId) {
|
||||||
const sorted = [...interactions].sort((a, b) => b - a);
|
interactions.push(eventEntry.duration);
|
||||||
const p98Index = Math.floor(sorted.length * 0.02);
|
const sorted = [...interactions].sort((a, b) => b - a);
|
||||||
inpValue = sorted[p98Index] || sorted[0] || 0;
|
const p98Index = Math.floor(sorted.length * 0.02);
|
||||||
metrics.inp = inpValue;
|
inpValue = sorted[p98Index] || sorted[0] || 0;
|
||||||
|
metrics.inp = inpValue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
inpObserver.observe({ type: "event", buffered: true });
|
||||||
inpObserver.observe({ type: "event", buffered: true });
|
} catch (e) {
|
||||||
} catch (e) {
|
console.debug("INP observer failed");
|
||||||
console.debug("INP not supported");
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get navigation timing metrics
|
// Get navigation timing metrics
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { env } from "~/env/server";
|
|||||||
*
|
*
|
||||||
* URL: https://freno.me/api/Gaze/appcast.xml
|
* 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 bucket = env.VITE_DOWNLOAD_BUCKET_STRING;
|
||||||
const key = "api/Gaze/appcast.xml";
|
const key = "api/Gaze/appcast.xml";
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ export async function GET(event: APIEvent) {
|
|||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/xml; charset=utf-8",
|
"Content-Type": "application/xml; charset=utf-8",
|
||||||
"Cache-Control": "public, max-age=300", // Cache for 5 minutes
|
"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) {
|
} 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 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) {
|
export async function GET(event: APIEvent) {
|
||||||
const url = new URL(event.request.url);
|
const url = new URL(event.request.url);
|
||||||
const code = url.searchParams.get("code");
|
const code = url.searchParams.get("code");
|
||||||
const error = url.searchParams.get("error");
|
const error = url.searchParams.get("error");
|
||||||
|
|
||||||
console.log("[GitHub OAuth Callback] Request received:", {
|
|
||||||
hasCode: !!code,
|
|
||||||
codeLength: code?.length,
|
|
||||||
error
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error("[GitHub OAuth Callback] OAuth error from provider:", error);
|
return redirectError(error);
|
||||||
return new Response(null, {
|
|
||||||
status: 302,
|
|
||||||
headers: { Location: `/login?error=${encodeURIComponent(error)}` }
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!code) {
|
if (!code) {
|
||||||
console.error("[GitHub OAuth Callback] Missing authorization code");
|
return redirectError("missing_code");
|
||||||
return new Response(null, {
|
|
||||||
status: 302,
|
|
||||||
headers: { Location: "/login?error=missing_code" }
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const handler = createAuthCallbackHandler<{ code: string }>(
|
||||||
console.log("[GitHub OAuth Callback] Creating tRPC caller...");
|
"githubCallback",
|
||||||
const caller = await createServerCaller(event);
|
(caller, params) => caller.auth.githubCallback(params)
|
||||||
|
);
|
||||||
|
|
||||||
console.log("[GitHub OAuth Callback] Calling githubCallback procedure...");
|
return handler(event, { code });
|
||||||
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" }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,86 +1,26 @@
|
|||||||
import type { APIEvent } from "@solidjs/start/server";
|
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) {
|
export async function GET(event: APIEvent) {
|
||||||
const url = new URL(event.request.url);
|
const url = new URL(event.request.url);
|
||||||
const code = url.searchParams.get("code");
|
const code = url.searchParams.get("code");
|
||||||
const error = url.searchParams.get("error");
|
const error = url.searchParams.get("error");
|
||||||
|
|
||||||
console.log("[Google OAuth Callback] Request received:", {
|
|
||||||
hasCode: !!code,
|
|
||||||
codeLength: code?.length,
|
|
||||||
error
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error("[Google OAuth Callback] OAuth error from provider:", error);
|
return redirectError(error);
|
||||||
return new Response(null, {
|
|
||||||
status: 302,
|
|
||||||
headers: { Location: `/login?error=${encodeURIComponent(error)}` }
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!code) {
|
if (!code) {
|
||||||
console.error("[Google OAuth Callback] Missing authorization code");
|
return redirectError("missing_code");
|
||||||
return new Response(null, {
|
|
||||||
status: 302,
|
|
||||||
headers: { Location: "/login?error=missing_code" }
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const handler = createAuthCallbackHandler<{ code: string }>(
|
||||||
console.log("[Google OAuth Callback] Creating tRPC caller...");
|
"googleCallback",
|
||||||
const caller = await createServerCaller(event);
|
(caller, params) => caller.auth.googleCallback(params)
|
||||||
|
);
|
||||||
|
|
||||||
console.log("[Google OAuth Callback] Calling googleCallback procedure...");
|
return handler(event, { code });
|
||||||
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" }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,86 +1,32 @@
|
|||||||
import type { APIEvent } from "@solidjs/start/server";
|
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) {
|
export async function GET(event: APIEvent) {
|
||||||
const url = new URL(event.request.url);
|
const url = new URL(event.request.url);
|
||||||
const email = url.searchParams.get("email");
|
const email = url.searchParams.get("email");
|
||||||
const token = url.searchParams.get("token");
|
const token = url.searchParams.get("token");
|
||||||
|
|
||||||
console.log("[Email Login Callback] Request received:", {
|
|
||||||
email,
|
|
||||||
hasToken: !!token,
|
|
||||||
tokenLength: token?.length
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!email || !token) {
|
if (!email || !token) {
|
||||||
console.error("[Email Login Callback] Missing required parameters:", {
|
return redirectError("missing_params");
|
||||||
hasEmail: !!email,
|
|
||||||
hasToken: !!token
|
|
||||||
});
|
|
||||||
return new Response(null, {
|
|
||||||
status: 302,
|
|
||||||
headers: { Location: "/login?error=missing_params" }
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const handler = createAuthCallbackHandler<{
|
||||||
console.log("[Email Login Callback] Creating tRPC caller...");
|
email: string;
|
||||||
// Create tRPC caller to invoke the emailLogin procedure
|
token: string;
|
||||||
const caller = await createServerCaller(event);
|
}>(
|
||||||
|
"emailLogin",
|
||||||
console.log("[Email Login Callback] Calling emailLogin procedure...");
|
(caller, params) => caller.auth.emailLogin(params),
|
||||||
// Call the email login handler - rememberMe will be read from JWT payload
|
(error) => {
|
||||||
const result = await caller.auth.emailLogin({
|
// Check for token expiration
|
||||||
email,
|
const message = error instanceof Error ? error.message : "";
|
||||||
token
|
const isTokenError =
|
||||||
});
|
message.includes("expired") || message.includes("invalid");
|
||||||
|
return redirectError(isTokenError ? "link_expired" : "server_error");
|
||||||
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" }
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
);
|
||||||
console.error("[Email Login Callback] Error caught:", error);
|
|
||||||
|
|
||||||
// Check if it's a token expiration error
|
return handler(event, { email, token });
|
||||||
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"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
|
|||||||
import { env } from "~/env/server";
|
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
|
* This endpoint is used by Sparkle updater to download updates
|
||||||
*
|
*
|
||||||
* Handles:
|
* Handles:
|
||||||
* - Full DMG files: /api/downloads/Gaze-0.2.2.dmg
|
* - Full DMG files: /api/downloads/Gaze-0.2.2.dmg, /api/downloads/InputHalo-0.1.0.dmg
|
||||||
* - Delta updates: /api/downloads/Gaze3-2.delta
|
* - Delta updates: /api/downloads/Gaze3-2.delta, /api/downloads/InputHalo3-2.delta
|
||||||
*
|
*
|
||||||
* URL: https://freno.me/api/downloads/[filename]
|
* 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 (
|
if (
|
||||||
!filename.startsWith("Gaze") ||
|
!isValidPrefix ||
|
||||||
(!filename.endsWith(".dmg") && !filename.endsWith(".delta"))
|
(!filename.endsWith(".dmg") && !filename.endsWith(".delta"))
|
||||||
) {
|
) {
|
||||||
return new Response("Invalid file format", {
|
return new Response("Invalid file format", {
|
||||||
|
|||||||
@@ -1,16 +1,10 @@
|
|||||||
import { createSignal, onMount, createEffect, Show } from "solid-js";
|
import { createSignal, onMount, createEffect, Show } from "solid-js";
|
||||||
import {
|
import { useSearchParams, query, createAsync } from "@solidjs/router";
|
||||||
useSearchParams,
|
|
||||||
useNavigate,
|
|
||||||
useLocation,
|
|
||||||
query,
|
|
||||||
createAsync
|
|
||||||
} from "@solidjs/router";
|
|
||||||
import { A } from "@solidjs/router";
|
import { A } from "@solidjs/router";
|
||||||
import { action, redirect } from "@solidjs/router";
|
import { action, redirect } from "@solidjs/router";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
import { api } from "~/lib/api";
|
import { api } from "~/lib/api";
|
||||||
import { getClientCookie, setClientCookie } from "~/lib/cookies.client";
|
import { getClientCookie } from "~/lib/cookies.client";
|
||||||
import CountdownCircleTimer from "~/components/CountdownCircleTimer";
|
import CountdownCircleTimer from "~/components/CountdownCircleTimer";
|
||||||
import RevealDropDown from "~/components/RevealDropDown";
|
import RevealDropDown from "~/components/RevealDropDown";
|
||||||
import Input from "~/components/ui/Input";
|
import Input from "~/components/ui/Input";
|
||||||
@@ -19,20 +13,22 @@ import { useCountdown } from "~/lib/useCountdown";
|
|||||||
import type { UserProfile } from "~/types/user";
|
import type { UserProfile } from "~/types/user";
|
||||||
import { getCookie, setCookie } from "vinxi/http";
|
import { getCookie, setCookie } from "vinxi/http";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { env } from "~/env/server";
|
import { env as clientEnv } from "~/env/client";
|
||||||
import {
|
import {
|
||||||
fetchWithTimeout,
|
fetchWithTimeout,
|
||||||
checkResponse,
|
checkResponse,
|
||||||
fetchWithRetry,
|
fetchWithRetry,
|
||||||
NetworkError,
|
NetworkError,
|
||||||
TimeoutError,
|
TimeoutError,
|
||||||
APIError
|
APIError,
|
||||||
|
verifyTurnstileToken
|
||||||
} from "~/server/fetch-utils";
|
} from "~/server/fetch-utils";
|
||||||
import {
|
import {
|
||||||
NETWORK_CONFIG,
|
NETWORK_CONFIG,
|
||||||
COOLDOWN_TIMERS,
|
COOLDOWN_TIMERS,
|
||||||
VALIDATION_CONFIG,
|
VALIDATION_CONFIG,
|
||||||
COUNTDOWN_CONFIG
|
COUNTDOWN_CONFIG,
|
||||||
|
TURNSTILE_CONFIG
|
||||||
} from "~/config";
|
} from "~/config";
|
||||||
|
|
||||||
const getContactData = query(async () => {
|
const getContactData = query(async () => {
|
||||||
@@ -53,6 +49,7 @@ const sendContactEmail = action(async (formData: FormData) => {
|
|||||||
const name = formData.get("name") as string;
|
const name = formData.get("name") as string;
|
||||||
const email = formData.get("email") as string;
|
const email = formData.get("email") as string;
|
||||||
const message = formData.get("message") as string;
|
const message = formData.get("message") as string;
|
||||||
|
const turnstileToken = formData.get("cf-turnstile-response") as string;
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
name: z.string().min(1, "Name is required"),
|
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");
|
const contactExp = getCookie("contactRequestSent");
|
||||||
if (contactExp) {
|
if (contactExp) {
|
||||||
const expires = new Date(contactExp);
|
const expires = new Date(contactExp);
|
||||||
@@ -146,8 +157,6 @@ const sendContactEmail = action(async (formData: FormData) => {
|
|||||||
|
|
||||||
export default function ContactPage() {
|
export default function ContactPage() {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const location = useLocation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const viewer = () => searchParams.viewer ?? "default";
|
const viewer = () => searchParams.viewer ?? "default";
|
||||||
|
|
||||||
// Load server data using createAsync
|
// Load server data using createAsync
|
||||||
@@ -159,17 +168,46 @@ export default function ContactPage() {
|
|||||||
searchParams.success === "true"
|
searchParams.success === "true"
|
||||||
);
|
);
|
||||||
const [error, setError] = createSignal<string>(
|
const [error, setError] = createSignal<string>(
|
||||||
searchParams.error ? decodeURIComponent(searchParams.error) : ""
|
searchParams.error ? decodeURIComponent(String(searchParams.error)) : ""
|
||||||
);
|
);
|
||||||
const [loading, setLoading] = createSignal<boolean>(false);
|
const [loading, setLoading] = createSignal<boolean>(false);
|
||||||
const [user, setUser] = createSignal<UserProfile | null>(null);
|
const [user, setUser] = createSignal<UserProfile | null>(null);
|
||||||
const [jsEnabled, setJsEnabled] = createSignal<boolean>(false);
|
const [jsEnabled, setJsEnabled] = createSignal<boolean>(false);
|
||||||
|
const [turnstileToken, setTurnstileToken] = createSignal<string>("");
|
||||||
|
const [turnstileWidgetId, setTurnstileWidgetId] = createSignal<string | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
const { remainingTime, startCountdown, setRemainingTime } = useCountdown();
|
const { remainingTime, startCountdown, setRemainingTime } = useCountdown();
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
setJsEnabled(true);
|
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
|
api.user.getProfile
|
||||||
.query()
|
.query()
|
||||||
.then((userData) => {
|
.then((userData) => {
|
||||||
@@ -206,13 +244,34 @@ export default function ContactPage() {
|
|||||||
if (!jsEnabled()) return;
|
if (!jsEnabled()) return;
|
||||||
|
|
||||||
e.preventDefault();
|
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 name = formData.get("name") as string;
|
||||||
const email = formData.get("email") as string;
|
const email = formData.get("email") as string;
|
||||||
const message = formData.get("message") as string;
|
const message = formData.get("message") as string;
|
||||||
|
|
||||||
if (name && email && message) {
|
if (name && email && message) {
|
||||||
|
// Get fresh Turnstile token
|
||||||
|
let currentToken = turnstileToken();
|
||||||
|
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);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
setEmailSent(false);
|
setEmailSent(false);
|
||||||
@@ -221,13 +280,24 @@ export default function ContactPage() {
|
|||||||
const res = await api.misc.sendContactRequest.mutate({
|
const res = await api.misc.sendContactRequest.mutate({
|
||||||
name,
|
name,
|
||||||
email,
|
email,
|
||||||
message
|
message,
|
||||||
|
turnstileToken: currentToken
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.message === "email sent") {
|
if (res.message === "email sent") {
|
||||||
setEmailSent(true);
|
setEmailSent(true);
|
||||||
setError("");
|
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
|
// Set countdown directly - cookie might not be readable immediately
|
||||||
const expirationTime = new Date(
|
const expirationTime = new Date(
|
||||||
@@ -392,7 +462,8 @@ export default function ContactPage() {
|
|||||||
<label class="underlinedInputLabel">Message</label>
|
<label class="underlinedInputLabel">Message</label>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<Show
|
||||||
when={
|
when={
|
||||||
remainingTime() > 0 ||
|
remainingTime() > 0 ||
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export default function DownloadsPage() {
|
|||||||
const [SwAText, setSwAText] = createSignal("Shapes with Abigail!");
|
const [SwAText, setSwAText] = createSignal("Shapes with Abigail!");
|
||||||
const [corkText, setCorkText] = createSignal("Cork");
|
const [corkText, setCorkText] = createSignal("Cork");
|
||||||
const [gazeText, setGazeText] = createSignal("Gaze");
|
const [gazeText, setGazeText] = createSignal("Gaze");
|
||||||
|
const [inputHaloText, setInputHaloText] = createSignal("InputHalo");
|
||||||
|
|
||||||
// Track loading states for each download button
|
// Track loading states for each download button
|
||||||
const [loadingState, setLoadingState] = createSignal<Record<string, boolean>>(
|
const [loadingState, setLoadingState] = createSignal<Record<string, boolean>>(
|
||||||
@@ -17,7 +18,8 @@ export default function DownloadsPage() {
|
|||||||
lineage: false,
|
lineage: false,
|
||||||
cork: false,
|
cork: false,
|
||||||
gaze: 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 swaInterval = glitchText(SwAText(), setSwAText);
|
||||||
const corkInterval = glitchText(corkText(), setCorkText);
|
const corkInterval = glitchText(corkText(), setCorkText);
|
||||||
const gazeInterval = glitchText(gazeText(), setGazeText);
|
const gazeInterval = glitchText(gazeText(), setGazeText);
|
||||||
|
const inputHaloInterval = glitchText(inputHaloText(), setInputHaloText);
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
clearInterval(lalInterval);
|
clearInterval(lalInterval);
|
||||||
clearInterval(swaInterval);
|
clearInterval(swaInterval);
|
||||||
clearInterval(corkInterval);
|
clearInterval(corkInterval);
|
||||||
clearInterval(gazeInterval);
|
clearInterval(gazeInterval);
|
||||||
|
clearInterval(inputHaloInterval);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,7 +70,7 @@ export default function DownloadsPage() {
|
|||||||
<>
|
<>
|
||||||
<PageHead
|
<PageHead
|
||||||
title="Downloads"
|
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">
|
<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
|
Ordered by date of initial release
|
||||||
</div>
|
</div>
|
||||||
<div class="mx-auto max-w-5xl space-y-16">
|
<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 */}
|
{/* Gaze */}
|
||||||
<div class="border-overlay0 rounded-lg border p-6 md:p-8">
|
<div class="border-overlay0 rounded-lg border p-6 md:p-8">
|
||||||
<h2 class="text-text mb-6 font-mono text-2xl">
|
<h2 class="text-text mb-6 font-mono text-2xl">
|
||||||
|
|||||||
@@ -1173,10 +1173,10 @@ export const authRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate 6-digit code
|
// Generate cryptographically secure 6-digit code (p8-010)
|
||||||
const loginCode = Math.floor(
|
const randomBytes = new Uint32Array(1);
|
||||||
100000 + Math.random() * 900000
|
crypto.getRandomValues(randomBytes);
|
||||||
).toString();
|
const loginCode = (100000 + (randomBytes[0] % 900000)).toString();
|
||||||
|
|
||||||
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
||||||
const token = await new SignJWT({
|
const token = await new SignJWT({
|
||||||
|
|||||||
@@ -372,7 +372,7 @@ export const databaseRouter = createTRPCRouter({
|
|||||||
body: z.string().nullable(),
|
body: z.string().nullable(),
|
||||||
banner_photo: z.string().nullable(),
|
banner_photo: z.string().nullable(),
|
||||||
published: z.boolean(),
|
published: z.boolean(),
|
||||||
tags: z.array(z.string()).nullable(),
|
tags: z.array(z.string().max(50).trim()).nullable(),
|
||||||
author_id: z.string()
|
author_id: z.string()
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -405,12 +405,13 @@ export const databaseRouter = createTRPCRouter({
|
|||||||
const results = await conn.execute({ sql: query, args: params });
|
const results = await conn.execute({ sql: query, args: params });
|
||||||
|
|
||||||
if (input.tags && input.tags.length > 0) {
|
if (input.tags && input.tags.length > 0) {
|
||||||
let tagQuery = "INSERT INTO Tag (value, post_id) VALUES ";
|
const validTags = input.tags.filter((t) => t.length > 0);
|
||||||
let values = input.tags.map(
|
for (const tag of validTags) {
|
||||||
(tag) => `("${tag}", ${results.lastInsertRowid})`
|
await conn.execute({
|
||||||
);
|
sql: "INSERT INTO Tag (value, post_id) VALUES (?, ?)",
|
||||||
tagQuery += values.join(", ");
|
args: [tag, results.lastInsertRowid]
|
||||||
await conn.execute(tagQuery);
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await cache.deleteByPrefix("blog-");
|
await cache.deleteByPrefix("blog-");
|
||||||
@@ -434,7 +435,7 @@ export const databaseRouter = createTRPCRouter({
|
|||||||
body: z.string().nullable().optional(),
|
body: z.string().nullable().optional(),
|
||||||
banner_photo: z.string().nullable().optional(),
|
banner_photo: z.string().nullable().optional(),
|
||||||
published: z.boolean().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()
|
author_id: z.string()
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -523,10 +524,13 @@ export const databaseRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (input.tags && input.tags.length > 0) {
|
if (input.tags && input.tags.length > 0) {
|
||||||
let tagQuery = "INSERT INTO Tag (value, post_id) VALUES ";
|
const validTags = input.tags.filter((t) => t.length > 0);
|
||||||
let values = input.tags.map((tag) => `("${tag}", ${input.id})`);
|
for (const tag of validTags) {
|
||||||
tagQuery += values.join(", ");
|
await conn.execute({
|
||||||
await conn.execute(tagQuery);
|
sql: "INSERT INTO Tag (value, post_id) VALUES (?, ?)",
|
||||||
|
args: [tag, input.id]
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await cache.deleteByPrefix("blog-");
|
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,
|
client: S3Client,
|
||||||
bucket: string
|
bucket: string,
|
||||||
|
prefix: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const listCommand = new ListObjectsV2Command({
|
const listCommand = new ListObjectsV2Command({
|
||||||
Bucket: bucket,
|
Bucket: bucket,
|
||||||
Prefix: "downloads/Gaze-",
|
Prefix: prefix,
|
||||||
MaxKeys: 100
|
MaxKeys: 100
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await client.send(listCommand);
|
const response = await client.send(listCommand);
|
||||||
|
|
||||||
if (!response.Contents || response.Contents.length === 0) {
|
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)
|
// Filter for .dmg files only and sort by LastModified (newest first)
|
||||||
@@ -45,18 +46,38 @@ async function getLatestGazeDMG(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (dmgFiles.length === 0) {
|
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!;
|
const latestFile = dmgFiles[0].Key!;
|
||||||
console.log(`Latest Gaze DMG: ${latestFile}`);
|
console.log(`Latest DMG: ${latestFile}`);
|
||||||
return latestFile;
|
return latestFile;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error finding latest Gaze DMG:", error);
|
console.error(`Error finding latest DMG for ${prefix}:`, error);
|
||||||
throw 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({
|
export const downloadsRouter = createTRPCRouter({
|
||||||
getDownloadUrl: publicProcedure
|
getDownloadUrl: publicProcedure
|
||||||
.input(z.object({ asset_name: z.string() }))
|
.input(z.object({ asset_name: z.string() }))
|
||||||
@@ -76,9 +97,11 @@ export const downloadsRouter = createTRPCRouter({
|
|||||||
try {
|
try {
|
||||||
let fileKey: string;
|
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") {
|
if (input.asset_name === "gaze") {
|
||||||
fileKey = await getLatestGazeDMG(client, bucket);
|
fileKey = await getLatestGazeDMG(client, bucket);
|
||||||
|
} else if (input.asset_name === "inputhalo") {
|
||||||
|
fileKey = await getLatestInputHaloDMG(client, bucket);
|
||||||
} else {
|
} else {
|
||||||
// Use static mapping for other assets
|
// Use static mapping for other assets
|
||||||
fileKey = assets[input.asset_name];
|
fileKey = assets[input.asset_name];
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export const gitActivityRouter = createTRPCRouter({
|
|||||||
`github-commits-${input.limit}`,
|
`github-commits-${input.limit}`,
|
||||||
CACHE_CONFIG.GIT_ACTIVITY_CACHE_TTL_MS,
|
CACHE_CONFIG.GIT_ACTIVITY_CACHE_TTL_MS,
|
||||||
async () => {
|
async () => {
|
||||||
// Use Events API to get recent push events - much more efficient
|
// Use Events API to get recent push events
|
||||||
const eventsResponse = await fetchWithTimeout(
|
const eventsResponse = await fetchWithTimeout(
|
||||||
`https://api.github.com/users/MikeFreno/events/public?per_page=100`,
|
`https://api.github.com/users/MikeFreno/events/public?per_page=100`,
|
||||||
{
|
{
|
||||||
@@ -47,20 +47,23 @@ export const gitActivityRouter = createTRPCRouter({
|
|||||||
|
|
||||||
await checkResponse(eventsResponse);
|
await checkResponse(eventsResponse);
|
||||||
const events = await eventsResponse.json();
|
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) {
|
for (const event of events) {
|
||||||
if (event.type !== "PushEvent") continue;
|
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;
|
// Fetch all commits in parallel instead of serially
|
||||||
const commitSha = event.payload.head;
|
const results = await Promise.allSettled(
|
||||||
|
toFetch.map(({ repoName, sha }) =>
|
||||||
try {
|
fetchWithTimeout(
|
||||||
// Fetch the actual commit details to get the message
|
`https://api.github.com/repos/${repoName}/commits/${sha}`,
|
||||||
const commitResponse = await fetchWithTimeout(
|
|
||||||
`https://api.github.com/repos/${repoName}/commits/${commitSha}`,
|
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${env.GITHUB_API_TOKEN}`,
|
Authorization: `Bearer ${env.GITHUB_API_TOKEN}`,
|
||||||
@@ -68,50 +71,38 @@ export const gitActivityRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
timeout: 5000
|
timeout: 5000
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
|
.catch(() => null)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
if (commitResponse.ok) {
|
const allCommits: GitCommit[] = [];
|
||||||
const commit = await commitResponse.json();
|
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 (
|
||||||
if (
|
commit.author?.login === "MikeFreno" ||
|
||||||
commit.author?.login === "MikeFreno" ||
|
commit.author?.login === "mikefreno" ||
|
||||||
commit.author?.login === "mikefreno" ||
|
commit.commit?.author?.email?.includes("mike")
|
||||||
commit.commit?.author?.email?.includes("mike")
|
) {
|
||||||
) {
|
allCommits.push({
|
||||||
allCommits.push({
|
sha: commit.sha?.substring(0, 7) || "unknown",
|
||||||
sha: commit.sha?.substring(0, 7) || "unknown",
|
message: commit.commit?.message?.split("\n")[0] || "No message",
|
||||||
message:
|
author:
|
||||||
commit.commit?.message?.split("\n")[0] || "No message",
|
commit.commit?.author?.name ||
|
||||||
author:
|
commit.author?.login ||
|
||||||
commit.commit?.author?.name ||
|
"Unknown",
|
||||||
commit.author?.login ||
|
date: commit.commit?.author?.date || new Date().toISOString(),
|
||||||
"Unknown",
|
repo: repoName,
|
||||||
date:
|
url: `https://github.com/${repoName}/commit/${commit.sha}`
|
||||||
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Already sorted by event date, but sort again by commit date to be precise
|
|
||||||
allCommits.sort(
|
allCommits.sort(
|
||||||
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
|
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||||
);
|
);
|
||||||
@@ -155,13 +146,11 @@ export const gitActivityRouter = createTRPCRouter({
|
|||||||
|
|
||||||
await checkResponse(reposResponse);
|
await checkResponse(reposResponse);
|
||||||
const repos = await reposResponse.json();
|
const repos = await reposResponse.json();
|
||||||
const allCommits: GitCommit[] = [];
|
|
||||||
|
|
||||||
for (const repo of repos) {
|
// Fetch commits for all repos in parallel instead of serially
|
||||||
if (allCommits.length >= input.limit * 3) break; // Get extra to sort later
|
const commitResults = await Promise.allSettled(
|
||||||
|
repos.map((repo: any) =>
|
||||||
try {
|
fetchWithTimeout(
|
||||||
const commitsResponse = await fetchWithTimeout(
|
|
||||||
`${env.GITEA_URL}/api/v1/repos/Mike/${repo.name}/commits?limit=5`,
|
`${env.GITEA_URL}/api/v1/repos/Mike/${repo.name}/commits?limit=5`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
@@ -170,46 +159,36 @@ export const gitActivityRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
timeout: 10000
|
timeout: 10000
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
|
.then((res) => (res.ok ? res.json() : []))
|
||||||
|
.catch(() => [])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
if (commitsResponse.ok) {
|
const allCommits: GitCommit[] = [];
|
||||||
const commits = await commitsResponse.json();
|
for (let i = 0; i < commitResults.length; i++) {
|
||||||
for (const commit of commits) {
|
const result = commitResults[i];
|
||||||
if (
|
if (result.status === "rejected") continue;
|
||||||
(commit.commit?.author?.email &&
|
const repo = repos[i];
|
||||||
commit.commit.author.email.includes(
|
const commits: any[] = result.value;
|
||||||
"michael@freno.me"
|
for (const commit of commits) {
|
||||||
)) ||
|
const email: string = commit.commit?.author?.email ?? "";
|
||||||
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) {
|
|
||||||
if (
|
if (
|
||||||
error instanceof NetworkError ||
|
email.includes("michael@freno.me") ||
|
||||||
error instanceof TimeoutError
|
email.includes("michaelt.freno@gmail.com")
|
||||||
) {
|
) {
|
||||||
console.warn(
|
allCommits.push({
|
||||||
`Network error fetching commits for ${repo.name}, skipping`
|
sha: commit.sha?.substring(0, 7) || "unknown",
|
||||||
);
|
message:
|
||||||
} else {
|
commit.commit?.message?.split("\n")[0] || "No message",
|
||||||
console.error(
|
author:
|
||||||
`Error fetching commits for ${repo.name}:`,
|
commit.commit?.author?.name ||
|
||||||
error
|
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();
|
const threeMonthsAgo = new Date();
|
||||||
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
|
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
|
||||||
|
const sinceParam = threeMonthsAgo.toISOString();
|
||||||
|
|
||||||
for (const repo of repos) {
|
// Fetch commits for all repos in parallel, scoped to the 3-month window
|
||||||
try {
|
const commitResults = await Promise.allSettled(
|
||||||
const commitsResponse = await fetchWithTimeout(
|
repos.map((repo: any) =>
|
||||||
`${env.GITEA_URL}/api/v1/repos/${repo.owner.login}/${repo.name}/commits?limit=100`,
|
fetchWithTimeout(
|
||||||
|
`${env.GITEA_URL}/api/v1/repos/${repo.owner.login}/${repo.name}/commits?limit=100&since=${sinceParam}`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `token ${env.GITEA_TOKEN}`,
|
Authorization: `token ${env.GITEA_TOKEN}`,
|
||||||
@@ -348,31 +329,23 @@ export const gitActivityRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
timeout: 10000
|
timeout: 10000
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
|
.then((res) => (res.ok ? res.json() : []))
|
||||||
|
.catch(() => [])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
if (commitsResponse.ok) {
|
for (const result of commitResults) {
|
||||||
const commits = await commitsResponse.json();
|
if (result.status === "rejected") continue;
|
||||||
for (const commit of commits) {
|
const commits: any[] = result.value;
|
||||||
const date = new Date(commit.commit.author.date)
|
for (const commit of commits) {
|
||||||
.toISOString()
|
const date = new Date(commit.commit.author.date)
|
||||||
.split("T")[0];
|
.toISOString()
|
||||||
contributionsByDay.set(
|
.split("T")[0];
|
||||||
date,
|
contributionsByDay.set(
|
||||||
(contributionsByDay.get(date) || 0) + 1
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from "~/server/utils";
|
} from "~/server/utils";
|
||||||
import { env } from "~/env/server";
|
import { env } from "~/env/server";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { SignJWT, jwtVerify } from "jose";
|
import { SignJWT, jwtVerify, importJWK } from "jose";
|
||||||
import { LibsqlError } from "@libsql/client/web";
|
import { LibsqlError } from "@libsql/client/web";
|
||||||
import { createClient as createAPIClient } from "@tursodatabase/api";
|
import { createClient as createAPIClient } from "@tursodatabase/api";
|
||||||
|
|
||||||
@@ -354,11 +354,68 @@ export const lineageAuthRouter = createTRPCRouter({
|
|||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
email: z.string().email().optional(),
|
email: z.string().email().optional(),
|
||||||
userString: z.string(),
|
idToken: z.string(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ input }) => {
|
.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 dbName;
|
||||||
let dbToken;
|
let dbToken;
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import {
|
|||||||
} from "~/server/utils";
|
} from "~/server/utils";
|
||||||
import { env } from "~/env/server";
|
import { env } from "~/env/server";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { OAuth2Client } from "google-auth-library";
|
|
||||||
import { jwtVerify } from "jose";
|
|
||||||
import { createTRPCRouter, publicProcedure } from "~/server/api/utils";
|
import { createTRPCRouter, publicProcedure } from "~/server/api/utils";
|
||||||
import {
|
import {
|
||||||
fetchWithTimeout,
|
fetchWithTimeout,
|
||||||
@@ -18,84 +16,8 @@ import {
|
|||||||
} from "~/server/fetch-utils";
|
} from "~/server/fetch-utils";
|
||||||
|
|
||||||
export const lineageDatabaseRouter = createTRPCRouter({
|
export const lineageDatabaseRouter = createTRPCRouter({
|
||||||
credentials: publicProcedure
|
// credentials endpoint removed (p8-008): was exposing persistent DB tokens to clients.
|
||||||
.input(
|
// Database access should be proxied through tRPC server-side procedures.
|
||||||
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"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
|
|
||||||
deletionInit: publicProcedure
|
deletionInit: publicProcedure
|
||||||
.input(
|
.input(
|
||||||
@@ -155,6 +77,14 @@ export const lineageDatabaseRouter = createTRPCRouter({
|
|||||||
|
|
||||||
if (skip_cron) {
|
if (skip_cron) {
|
||||||
if (send_dump_target) {
|
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({
|
const dumpRes = await dumpAndSendDB({
|
||||||
dbName: db_name,
|
dbName: db_name,
|
||||||
dbToken: db_token,
|
dbToken: db_token,
|
||||||
|
|||||||
@@ -19,9 +19,10 @@ import {
|
|||||||
fetchWithRetry,
|
fetchWithRetry,
|
||||||
NetworkError,
|
NetworkError,
|
||||||
TimeoutError,
|
TimeoutError,
|
||||||
APIError
|
APIError,
|
||||||
|
verifyTurnstileToken
|
||||||
} from "~/server/fetch-utils";
|
} 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> = {
|
const assets: Record<string, string> = {
|
||||||
"shapes-with-abigail": "shapes-with-abigail.apk",
|
"shapes-with-abigail": "shapes-with-abigail.apk",
|
||||||
"magic-delve": "magic-delve.apk",
|
"magic-delve": "magic-delve.apk",
|
||||||
@@ -188,7 +189,7 @@ export const miscRouter = createTRPCRouter({
|
|||||||
z.object({
|
z.object({
|
||||||
key: z.string(),
|
key: z.string(),
|
||||||
newAttachmentString: z.string(),
|
newAttachmentString: z.string(),
|
||||||
type: z.string(),
|
type: z.enum(["Post", "Comment", "User"]),
|
||||||
id: z.number()
|
id: z.number()
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -213,6 +214,7 @@ export const miscRouter = createTRPCRouter({
|
|||||||
const res = await client.send(command);
|
const res = await client.send(command);
|
||||||
|
|
||||||
const conn = ConnectionFactory();
|
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 = ?`;
|
const query = `UPDATE ${input.type} SET attachments = ? WHERE id = ?`;
|
||||||
await conn.execute({
|
await conn.execute({
|
||||||
sql: query,
|
sql: query,
|
||||||
@@ -304,10 +306,27 @@ export const miscRouter = createTRPCRouter({
|
|||||||
message: z
|
message: z
|
||||||
.string()
|
.string()
|
||||||
.min(1)
|
.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 }) => {
|
.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");
|
const contactExp = getCookie("contactRequestSent");
|
||||||
let remaining = 0;
|
let remaining = 0;
|
||||||
|
|
||||||
@@ -326,13 +345,22 @@ export const miscRouter = createTRPCRouter({
|
|||||||
const apiKey = env.SENDINBLUE_KEY;
|
const apiKey = env.SENDINBLUE_KEY;
|
||||||
const apiUrl = "https://api.sendinblue.com/v3/smtp/email";
|
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 = {
|
const sendinblueData = {
|
||||||
sender: {
|
sender: {
|
||||||
name: "freno.me",
|
name: "freno.me",
|
||||||
email: "michael@freno.me"
|
email: "michael@freno.me"
|
||||||
},
|
},
|
||||||
to: [{ 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"
|
subject: "freno.me Contact Request"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createTRPCRouter, nessaProcedure, publicProcedure } from "../utils";
|
import { createTRPCRouter, nessaProcedure, publicProcedure } from "../utils";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { jwtVerify, importJWK } from "jose";
|
||||||
import { NessaConnectionFactory } from "~/server/database";
|
import { NessaConnectionFactory } from "~/server/database";
|
||||||
import { cache } from "~/server/cache";
|
import { cache } from "~/server/cache";
|
||||||
import { hashPassword, checkPasswordSafe } from "~/server/utils";
|
import { hashPassword, checkPasswordSafe } from "~/server/utils";
|
||||||
@@ -628,45 +629,30 @@ export const nessaDbRouter = createTRPCRouter({
|
|||||||
const header = JSON.parse(headerJson) as { kid: string; alg: string };
|
const header = JSON.parse(headerJson) as { kid: string; alg: string };
|
||||||
|
|
||||||
// Find the matching key
|
// Find the matching key
|
||||||
const key = appleKeys.keys.find((k) => k.kid === header.kid);
|
const jwk = appleKeys.keys.find((k) => k.kid === header.kid);
|
||||||
if (!key) {
|
if (!jwk) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Apple public key not found"
|
message: "Apple public key not found"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// For simplicity, we'll decode the payload and verify basic claims
|
// Import the Apple JWK key for signature verification
|
||||||
// In production, you should use a proper JWT library like jose to verify the signature
|
const publicKey = await importJWK(jwk, "RS256");
|
||||||
const [, payloadB64] = input.idToken.split(".");
|
|
||||||
if (!payloadB64) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "UNAUTHORIZED",
|
|
||||||
message: "Invalid Apple ID token format"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const payloadJson = Buffer.from(payloadB64, "base64url").toString(
|
// Verify the Apple ID token signature and claims using jose
|
||||||
"utf8"
|
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
|
// Apple user ID from token should match the one provided
|
||||||
if (tokenPayload.sub !== input.appleUserId) {
|
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
|
// 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 email = tokenPayload.email ?? input.email;
|
||||||
const firstName = input.firstName ?? "Apple";
|
const firstName = input.firstName ?? "Apple";
|
||||||
|
|||||||
@@ -155,7 +155,9 @@ export const reactionTypeSchema = z.enum([
|
|||||||
"moneyEye",
|
"moneyEye",
|
||||||
"sick",
|
"sick",
|
||||||
"upsideDown",
|
"upsideDown",
|
||||||
"worried"
|
"worried",
|
||||||
|
"upVote",
|
||||||
|
"downVote"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { H3Event } from "vinxi/http";
|
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 { OAuth2Client } from "google-auth-library";
|
||||||
import type { Row } from "@libsql/client/web";
|
import type { Row } from "@libsql/client/web";
|
||||||
import { SignJWT, jwtVerify } from "jose";
|
import { SignJWT, jwtVerify } from "jose";
|
||||||
import { env } from "~/env/server";
|
import { env } from "~/env/server";
|
||||||
import { ConnectionFactory } from "./database";
|
import { ConnectionFactory } from "./db-connections";
|
||||||
import { AUTH_CONFIG, expiryToSeconds, getAccessTokenExpiry } from "~/config";
|
import { AUTH_CONFIG, expiryToSeconds, getAccessTokenExpiry } from "~/config";
|
||||||
|
|
||||||
export const authCookieName = "auth_token";
|
export const authCookieName = "auth_token";
|
||||||
@@ -30,7 +30,7 @@ function getAuthCookieOptions(rememberMe: boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getAuthHeaderToken(event: H3Event): string | null {
|
function getAuthHeaderToken(event: H3Event): string | null {
|
||||||
const requestHeader = event.request?.headers?.get?.("authorization") || null;
|
const requestHeader = getHeader(event, "authorization") || null;
|
||||||
const eventHeader = event.headers
|
const eventHeader = event.headers
|
||||||
? typeof (event.headers as any).get === "function"
|
? typeof (event.headers as any).get === "function"
|
||||||
? (event.headers as any).get("authorization")
|
? (event.headers as any).get("authorization")
|
||||||
@@ -199,6 +199,7 @@ export async function validateLineageRequest({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error("Failed to verify email auth token:", err);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} else if (provider == "apple") {
|
} 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 was replaced because on a low-traffic site the cache TTL almost always
|
||||||
* Redis provides:
|
* expires between visits, so every request paid Redis connection + round-trip
|
||||||
* - Fast in-memory storage
|
* overhead with no benefit. A module-level Map has zero network latency:
|
||||||
* - Built-in TTL expiration (automatic cleanup)
|
* cache hits are a single dictionary lookup, misses fall through immediately.
|
||||||
* - Persistence across function invocations
|
|
||||||
* - Native support in Vercel and other platforms
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createClient } from "redis";
|
|
||||||
import { env } from "~/env/server";
|
|
||||||
import { CACHE_CONFIG } from "~/config";
|
import { CACHE_CONFIG } from "~/config";
|
||||||
|
|
||||||
let redisClient: ReturnType<typeof createClient> | null = null;
|
interface CacheEntry<T> {
|
||||||
let isConnecting = false;
|
data: T;
|
||||||
let connectionError: Error | null = null;
|
/** Absolute timestamp (ms) after which this entry is considered stale */
|
||||||
|
expiresAt: number;
|
||||||
/**
|
/** Absolute timestamp (ms) after which stale fallback is also discarded */
|
||||||
* Get or create Redis client (singleton pattern)
|
staleExpiresAt: number;
|
||||||
*/
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
* Redis-backed cache interface
|
const store = new Map<string, CacheEntry<any>>();
|
||||||
*/
|
|
||||||
export const cache = {
|
export const cache = {
|
||||||
async get<T>(key: string): Promise<T | null> {
|
get<T>(key: string): T | null {
|
||||||
try {
|
const entry = store.get(key) as CacheEntry<T> | undefined;
|
||||||
const client = await getRedisClient();
|
if (!entry) return null;
|
||||||
const value = await client.get(key);
|
if (Date.now() > entry.expiresAt) return null;
|
||||||
|
return entry.data;
|
||||||
|
},
|
||||||
|
|
||||||
if (!value) {
|
set<T>(key: string, data: T, ttlMs: number): void {
|
||||||
return null;
|
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;
|
delete(key: string): void {
|
||||||
} catch (error) {
|
store.delete(key);
|
||||||
console.error(`Cache get error for key "${key}":`, error);
|
},
|
||||||
return null;
|
|
||||||
|
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> {
|
clear(): void {
|
||||||
try {
|
store.clear();
|
||||||
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);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async delete(key: string): Promise<void> {
|
has(key: string): boolean {
|
||||||
try {
|
const entry = store.get(key);
|
||||||
const client = await getRedisClient();
|
if (!entry) return false;
|
||||||
await client.del(key);
|
return Date.now() <= entry.expiresAt;
|
||||||
} 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute function with Redis caching
|
* Execute function with in-memory caching.
|
||||||
*/
|
*/
|
||||||
export async function withCache<T>(
|
export async function withCache<T>(
|
||||||
key: string,
|
key: string,
|
||||||
ttlMs: number,
|
ttlMs: number,
|
||||||
fn: () => Promise<T>
|
fn: () => Promise<T>
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const cached = await cache.get<T>(key);
|
const cached = cache.get<T>(key);
|
||||||
if (cached !== null) {
|
if (cached !== null) return cached;
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await fn();
|
const result = await fn();
|
||||||
await cache.set(key, result, ttlMs);
|
cache.set(key, result, ttlMs);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute function with Redis caching and stale data fallback
|
* Execute function with caching and stale-data fallback.
|
||||||
*
|
*
|
||||||
* Strategy:
|
* Strategy:
|
||||||
* 1. Try to get fresh cached data (within TTL)
|
* 1. Return data if fresh (within TTL).
|
||||||
* 2. If not found, execute function
|
* 2. Otherwise run fn().
|
||||||
* 3. If function fails, try to get stale data (ignore TTL)
|
* 3. If fn() throws, return stale data if still within maxStaleMs.
|
||||||
* 4. Store result with TTL for future requests
|
* 4. Store fresh result for future requests.
|
||||||
*/
|
*/
|
||||||
export async function withCacheAndStale<T>(
|
export async function withCacheAndStale<T>(
|
||||||
key: string,
|
key: string,
|
||||||
@@ -175,34 +97,29 @@ export async function withCacheAndStale<T>(
|
|||||||
const { maxStaleMs = CACHE_CONFIG.MAX_STALE_DATA_MS, logErrors = true } =
|
const { maxStaleMs = CACHE_CONFIG.MAX_STALE_DATA_MS, logErrors = true } =
|
||||||
options;
|
options;
|
||||||
|
|
||||||
// Try fresh cache
|
const now = Date.now();
|
||||||
const cached = await cache.get<T>(key);
|
const entry = store.get(key) as CacheEntry<T> | undefined;
|
||||||
if (cached !== null) {
|
|
||||||
return cached;
|
// Fresh hit
|
||||||
}
|
if (entry && entry.expiresAt > now) return entry.data;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Execute function
|
|
||||||
const result = await fn();
|
const result = await fn();
|
||||||
await cache.set(key, result, ttlMs);
|
store.set(key, {
|
||||||
// Also store with longer TTL for stale fallback
|
data: result,
|
||||||
const staleKey = `${key}:stale`;
|
expiresAt: now + ttlMs,
|
||||||
await cache.set(staleKey, result, maxStaleMs);
|
staleExpiresAt: now + maxStaleMs
|
||||||
|
});
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (logErrors) {
|
if (logErrors) {
|
||||||
console.error(`Error fetching data for cache key "${key}":`, error);
|
console.error(`Error fetching data for cache key "${key}":`, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try stale cache with longer TTL key
|
// Stale fallback
|
||||||
const staleKey = `${key}:stale`;
|
if (entry && entry.staleExpiresAt > now) {
|
||||||
const staleData = await cache.get<T>(staleKey);
|
if (logErrors) console.log(`Serving stale data for cache key "${key}"`);
|
||||||
|
return entry.data;
|
||||||
if (staleData !== null) {
|
|
||||||
if (logErrors) {
|
|
||||||
console.log(`Serving stale data for cache key "${key}"`);
|
|
||||||
}
|
|
||||||
return staleData;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -11,43 +11,13 @@ import {
|
|||||||
TimeoutError,
|
TimeoutError,
|
||||||
APIError
|
APIError
|
||||||
} from "~/server/fetch-utils";
|
} from "~/server/fetch-utils";
|
||||||
|
import {
|
||||||
let mainDBConnection: ReturnType<typeof createClient> | null = null;
|
ConnectionFactory,
|
||||||
let lineageDBConnection: ReturnType<typeof createClient> | null = null;
|
LineageConnectionFactory,
|
||||||
let nessaDBConnection: ReturnType<typeof createClient> | null = null;
|
NessaConnectionFactory
|
||||||
|
} from "~/server/db-connections";
|
||||||
export function ConnectionFactory() {
|
// Re-export connection factories to avoid circular import with auth.ts
|
||||||
if (!mainDBConnection) {
|
export { ConnectionFactory, LineageConnectionFactory, NessaConnectionFactory };
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function LineageDBInit() {
|
export async function LineageDBInit() {
|
||||||
const turso = createAPIClient({
|
const turso = createAPIClient({
|
||||||
@@ -209,7 +179,7 @@ export async function getUserBasicInfo(event: H3Event): Promise<{
|
|||||||
return { email: null, isAuthenticated: false };
|
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 {
|
return {
|
||||||
email: user.email,
|
email: user.email,
|
||||||
isAuthenticated: true
|
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;
|
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 {
|
try {
|
||||||
const value = getCookie(event, name);
|
const value = getCookie(event, name);
|
||||||
if (value) return value;
|
if (value) return value;
|
||||||
} catch (e) {}
|
} catch (e) {
|
||||||
|
console.warn(
|
||||||
|
"[security] getCookie failed, falling back to header parse:",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cookieHeader =
|
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 {
|
export function getClientIP(event: H3Event): string {
|
||||||
const forwarded = getHeaderValue(event, "x-forwarded-for");
|
// In production on Vercel, X-Forwarded-For is set by the edge network
|
||||||
if (forwarded) {
|
// and cannot be spoofed by clients. In dev/test, ignore it.
|
||||||
return forwarded.split(",")[0].trim();
|
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");
|
// Fallback: try socket remote address
|
||||||
if (realIP) {
|
try {
|
||||||
return realIP;
|
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";
|
return "unknown";
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ export type ReactionType =
|
|||||||
| "moneyEye"
|
| "moneyEye"
|
||||||
| "sick"
|
| "sick"
|
||||||
| "upsideDown"
|
| "upsideDown"
|
||||||
| "worried";
|
| "worried"
|
||||||
|
| "upVote"
|
||||||
|
| "downVote";
|
||||||
|
|
||||||
export interface UserPublicData {
|
export interface UserPublicData {
|
||||||
email?: string;
|
email?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user