diff --git a/convex/comments.js b/convex/comments.js index 91e362e3..3f7b150e 100644 --- a/convex/comments.js +++ b/convex/comments.js @@ -1,11 +1,13 @@ import { ConvexError, v } from 'convex/values'; import { mutation, query } from './_generated/server.js'; import { limiter } from './rateLimits.js'; -import { assertAdmin, isAdmin } from './lib/auth.js'; +import { assertAdmin, assertServer, isAdmin } from './lib/auth.js'; import { publicComment } from './lib/serialize.js'; const MAX_DEPTH = 2; const MAX_COMMENTS = 200; +const MAX_USERNAME_LENGTH = 32; +const MAX_TEXT_LENGTH = 200; async function isBanned(ctx, ipHash) { const ban = await ctx.db @@ -63,8 +65,9 @@ export const list = query({ }); export const getForAuth = query({ - args: { commentId: v.id('comments') }, - handler: async (ctx, { commentId }) => { + args: { commentId: v.id('comments'), serverSecret: v.string() }, + handler: async (ctx, { commentId, serverSecret }) => { + assertServer(serverSecret); const doc = await ctx.db.get(commentId); if (!doc) return null; return { passwordHash: doc.passwordHash, deletedAt: doc.deletedAt }; @@ -79,12 +82,24 @@ export const create = mutation({ text: v.string(), parentId: v.optional(v.id('comments')), ipHash: v.string(), + serverSecret: v.string(), adminSecret: v.optional(v.string()) }, handler: async (ctx, args) => { + assertServer(args.serverSecret); const admin = isAdmin(args.adminSecret); + const username = args.username.trim() || 'Anonymous'; + const text = args.text.trim(); - if (await isBanned(ctx, args.ipHash)) { + if ( + username.length > MAX_USERNAME_LENGTH || + text.length === 0 || + text.length > MAX_TEXT_LENGTH + ) { + throw new ConvexError({ kind: 'BadRequest', message: 'Invalid comment' }); + } + + if (!admin && (await isBanned(ctx, args.ipHash))) { throw new ConvexError({ kind: 'Banned' }); } @@ -100,15 +115,21 @@ export const create = mutation({ if (parent.depth >= MAX_DEPTH) { throw new ConvexError({ kind: 'BadRequest', message: 'Maximum reply depth reached' }); } + if (parent.url !== args.url) { + throw new ConvexError({ + kind: 'BadRequest', + message: 'Parent comment belongs to another page' + }); + } depth = parent.depth + 1; parentId = args.parentId; } const id = await ctx.db.insert('comments', { url: args.url, - username: args.username.trim() || 'Anonymous', + username, passwordHash: args.passwordHash, - text: args.text, + text, ipHash: args.ipHash, parentId, depth, @@ -127,12 +148,14 @@ export const vote = mutation({ commentId: v.id('comments'), voteType: v.union(v.literal('up'), v.literal('down')), ipHash: v.string(), + serverSecret: v.string(), adminSecret: v.optional(v.string()) }, handler: async (ctx, args) => { + assertServer(args.serverSecret); const admin = isAdmin(args.adminSecret); - if (await isBanned(ctx, args.ipHash)) { + if (!admin && (await isBanned(ctx, args.ipHash))) { throw new ConvexError({ kind: 'Banned' }); } @@ -172,10 +195,20 @@ export const applyEdit = mutation({ commentId: v.id('comments'), text: v.string(), ipHash: v.string(), + serverSecret: v.string(), adminSecret: v.optional(v.string()) }, handler: async (ctx, args) => { + assertServer(args.serverSecret); const admin = isAdmin(args.adminSecret); + const text = args.text.trim(); + + if (text.length === 0 || text.length > MAX_TEXT_LENGTH) { + throw new ConvexError({ kind: 'BadRequest', message: 'Invalid comment' }); + } + if (!admin && (await isBanned(ctx, args.ipHash))) { + throw new ConvexError({ kind: 'Banned' }); + } if (!admin) await consumeRateLimit(ctx, 'edit', args.ipHash); const comment = await ctx.db.get(args.commentId); @@ -184,8 +217,28 @@ export const applyEdit = mutation({ } const updatedAt = Date.now(); - await ctx.db.patch(args.commentId, { text: args.text, updatedAt }); - return { id: args.commentId, text: args.text, updatedAt }; + await ctx.db.patch(args.commentId, { text, updatedAt }); + return { id: args.commentId, text, updatedAt }; + } +}); + +export const softDelete = mutation({ + args: { + commentId: v.id('comments'), + serverSecret: v.string() + }, + handler: async (ctx, { commentId, serverSecret }) => { + assertServer(serverSecret); + const comment = await ctx.db.get(commentId); + if (!comment || comment.deletedAt !== null) { + throw new ConvexError({ kind: 'NotFound', message: 'Comment not found' }); + } + + await ctx.db.patch(commentId, { + username: '[deleted]', + text: '[deleted]', + updatedAt: Date.now() + }); } }); diff --git a/convex/lib/auth.js b/convex/lib/auth.js index c9400a05..6d118582 100644 --- a/convex/lib/auth.js +++ b/convex/lib/auth.js @@ -4,6 +4,12 @@ export function isAdmin(secret) { return Boolean(process.env.ADMIN_SECRET) && secret === process.env.ADMIN_SECRET; } +export function assertServer(secret) { + if (!isAdmin(secret)) { + throw new ConvexError({ kind: 'Unauthorized' }); + } +} + export function assertAdmin(secret) { if (!isAdmin(secret)) { throw new ConvexError({ kind: 'Unauthorized' }); diff --git a/convex/likes.js b/convex/likes.js index b9c2b51c..0ec74965 100644 --- a/convex/likes.js +++ b/convex/likes.js @@ -1,7 +1,7 @@ import { ConvexError, v } from 'convex/values'; import { mutation, query } from './_generated/server.js'; import { limiter } from './rateLimits.js'; -import { isAdmin } from './lib/auth.js'; +import { assertServer, isAdmin } from './lib/auth.js'; async function isBanned(ctx, ipHash) { const ban = await ctx.db @@ -31,12 +31,14 @@ export const toggle = mutation({ args: { url: v.string(), ipHash: v.string(), + serverSecret: v.string(), adminSecret: v.optional(v.string()) }, handler: async (ctx, args) => { + assertServer(args.serverSecret); const admin = isAdmin(args.adminSecret); - if (await isBanned(ctx, args.ipHash)) { + if (!admin && (await isBanned(ctx, args.ipHash))) { throw new ConvexError({ kind: 'Banned' }); } diff --git a/convex/now.js b/convex/now.js index d6b602a0..c5cd7bbe 100644 --- a/convex/now.js +++ b/convex/now.js @@ -1,5 +1,8 @@ import { query, mutation } from './_generated/server'; -import { v } from 'convex/values'; +import { ConvexError, v } from 'convex/values'; +import { assertAdmin } from './lib/auth.js'; + +const MAX_CONTENT_LENGTH = 20000; export const get = query({ args: {}, @@ -11,9 +14,15 @@ export const get = query({ export const update = mutation({ args: { - content: v.string() + content: v.string(), + adminSecret: v.string() }, handler: async (ctx, args) => { + assertAdmin(args.adminSecret); + if (args.content.length > MAX_CONTENT_LENGTH) { + throw new ConvexError({ kind: 'BadRequest', message: 'Now page content is too long' }); + } + const docs = await ctx.db.query('nowPage').take(1); if (docs.length > 0) { await ctx.db.patch(docs[0]._id, { diff --git a/mdsvex.config.js b/mdsvex.config.js index 5a55447c..a3118e47 100644 --- a/mdsvex.config.js +++ b/mdsvex.config.js @@ -17,7 +17,10 @@ const config = { highlight: { highlighter: createHighlighter({ // keepBackground: false, - theme: 'github-dark' + theme: { + light: 'github-light', + dark: 'github-dark' + } }) }, smartypants: { @@ -28,13 +31,16 @@ const config = { remarkMath, remarkGfm, remarkGemoji, - [remarkEmbedder.default, { - transformers: [oembedTransformer.default], - handleError: ({ error, url }) => { - console.error(`Failed to embed ${url}:`, error); - return `${url}`; + [ + remarkEmbedder.default, + { + transformers: [oembedTransformer.default], + handleError: ({ error, url }) => { + console.error(`Failed to embed ${url}:`, error); + return `${url}`; + } } - }] + ] // [enhancedImage, { attributes: { loading: 'lazy', fetchpriority: 'low' } }] ], rehypePlugins: [ diff --git a/src/app.css b/src/app.css index 0ca87640..8972df69 100644 --- a/src/app.css +++ b/src/app.css @@ -54,23 +54,22 @@ ::selection { background-color: rgba(120, 120, 120, 0.25); } - - /* Reserve room for the mobile floating dock (rendered by NavBar) so - nothing — including the footer — hides behind it. Disabled at sm+ - where the dock is replaced by the in-nav links. */ - @media (max-width: 639.98px) { - body { - padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 5rem); - } - } } /* Vote button pop */ @keyframes vote-pop { - 0% { transform: scale(1); } - 40% { transform: scale(1.25); } - 70% { transform: scale(0.9); } - 100% { transform: scale(1); } + 0% { + transform: scale(1); + } + 40% { + transform: scale(1.25); + } + 70% { + transform: scale(0.9); + } + 100% { + transform: scale(1); + } } .vote-pop { @@ -79,9 +78,17 @@ /* Like button heart confetti */ @keyframes heart-particle { - 0% { transform: translate(0, 0) scale(0) rotate(var(--r)); opacity: 1; } - 20% { opacity: 1; } - 100% { transform: translate(var(--tx), var(--ty)) scale(var(--s)) rotate(var(--r)); opacity: 0; } + 0% { + transform: translate(0, 0) scale(0) rotate(var(--r)); + opacity: 1; + } + 20% { + opacity: 1; + } + 100% { + transform: translate(var(--tx), var(--ty)) scale(var(--s)) rotate(var(--r)); + opacity: 0; + } } .heart-particle { @@ -92,31 +99,29 @@ /* Shimmer for skeletons / marquee tracks */ @keyframes shimmer { - 0% { background-position: -200% 0; } - 100% { background-position: 200% 0; } + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } } .shimmer { - background-image: linear-gradient( - 90deg, - transparent 0%, - rgba(255, 255, 255, 0.06) 50%, - transparent 100% - ), - linear-gradient(var(--color-neutral-200), var(--color-neutral-200)); - background-size: 200% 100%, 100% 100%; + background-image: + linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.06) 50%, transparent 100%), + linear-gradient(var(--color-neutral-200), var(--color-neutral-200)); + background-size: + 200% 100%, + 100% 100%; background-repeat: no-repeat; animation: shimmer 1.6s var(--ease-out-soft) infinite; } .dark .shimmer { - background-image: linear-gradient( - 90deg, - transparent 0%, - rgba(255, 255, 255, 0.05) 50%, - transparent 100% - ), - linear-gradient(var(--color-neutral-800), var(--color-neutral-800)); + background-image: + linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.05) 50%, transparent 100%), + linear-gradient(var(--color-neutral-800), var(--color-neutral-800)); } /* Tabular numerals for metrics, dates, counters */ @@ -160,14 +165,16 @@ Transition only after first positioning (.nav-animate) so it doesn't blink in from its empty initial state on load. */ .nav-indicator.nav-animate { - transition: transform var(--motion-base) var(--ease-out-soft), + transition: + transform var(--motion-base) var(--ease-out-soft), width var(--motion-base) var(--ease-out-soft), opacity var(--motion-fast) ease; } /* Active-colored label layer clipped to the sliding pill (slides in sync). */ .nav-clip.nav-animate { - transition: clip-path var(--motion-base) var(--ease-out-soft), + transition: + clip-path var(--motion-base) var(--ease-out-soft), opacity var(--motion-fast) ease; } @@ -176,7 +183,7 @@ Extracted from blog/[slug] and projects/[slug] so both stay in sync. */ @utility prose-post { - @apply prose prose-neutral dark:prose-invert max-w-none mx-auto; + @apply prose prose-neutral dark:prose-invert mx-auto max-w-none; @apply prose-p:text-neutral-900 dark:prose-p:text-neutral-100 prose-p:leading-relaxed prose-li:leading-relaxed prose-p:font-normal; @apply prose-h1:font-semibold prose-h1:text-3xl prose-h1:tracking-tight; @apply prose-h2:font-semibold prose-h2:tracking-tight; @@ -186,7 +193,6 @@ @apply prose-img:mx-auto prose-img:cursor-zoom-in; } - @utility prose { & :where(iframe):not(:where([class~='not-prose'] *)) { @apply aspect-video h-auto w-full rounded-sm; @@ -208,7 +214,11 @@ width: 0.5rem; margin-right: 1.25rem; text-align: right; - @apply text-neutral-500 dark:text-neutral-400; + color: var(--color-neutral-500); +} + +.dark .prose code[data-line-numbers] > span[data-line]::before { + color: var(--color-neutral-400); } .prose code[data-line-numbers-max-digits='2'] > span[data-line]::before { @@ -219,6 +229,26 @@ } /* Theme switching visibility */ +.prose pre[data-theme~='github-light'], +.prose code[data-theme~='github-light'] { + background-color: var(--shiki-light-bg); + color: var(--shiki-light); +} + +.prose code[data-theme~='github-light'] span { + color: var(--shiki-light); +} + +.dark .prose pre[data-theme~='github-dark'], +.dark .prose code[data-theme~='github-dark'] { + background-color: var(--shiki-dark-bg); + color: var(--shiki-dark); +} + +.dark .prose code[data-theme~='github-dark'] span { + color: var(--shiki-dark); +} + .light .prose pre[data-theme='dark'], .light .prose code[data-theme='dark'], .dark .prose pre[data-theme='light'], diff --git a/src/app.html b/src/app.html index aa549072..ca27e247 100644 --- a/src/app.html +++ b/src/app.html @@ -33,7 +33,7 @@
%sveltekit.body%
diff --git a/src/lib/LikeButton.svelte b/src/lib/LikeButton.svelte index 1e6183f1..3932998b 100644 --- a/src/lib/LikeButton.svelte +++ b/src/lib/LikeButton.svelte @@ -160,11 +160,6 @@ onclick={toggleLike} disabled={busy} aria-pressed={isLiked} - /* - transition-[background-color,border-color,color,transform,width] enables width, see next line - ease-out and duration-200 for animation - will-change-[width,background-color,border-color,color,transform] for hinting - */ class="inline-flex items-center justify-center gap-1.5 rounded-full border px-4 py-2 text-sm font-medium transition-[background-color,border-color,color,transform,width] duration-200 ease-out will-change-[width,background-color,border-color,color,transform] active:scale-[0.96] disabled:cursor-not-allowed disabled:opacity-60 {isLiked ? 'border-rose-300/70 bg-rose-50 text-rose-600 hover:bg-rose-100 dark:border-rose-900/60 dark:bg-rose-950/40 dark:text-rose-300 dark:hover:bg-rose-950/60' diff --git a/src/lib/NavBar.svelte b/src/lib/NavBar.svelte index 835bfff1..49162392 100644 --- a/src/lib/NavBar.svelte +++ b/src/lib/NavBar.svelte @@ -30,18 +30,18 @@ href="/" onclick={() => trigger([{ duration: 35 }], { intensity: 1 })} aria-label="Home — Injoon Oh" - class="group inline-flex items-center {showName ? '' : 'pointer-events-none'}" + class="group inline-flex min-h-10 items-center" > Injoon Oh 오인준 @@ -54,10 +54,12 @@ trigger([{ duration: 25 }], { intensity: 0.7 })} - class="p-0 text-base font-medium transition-colors duration-150 {isActive(item.href) + class="inline-flex min-h-10 items-center p-0 text-base font-medium transition-colors duration-150 {isActive( + item.href + ) ? 'text-neutral-900 dark:text-neutral-100' : 'text-neutral-500 hover:text-neutral-900 dark:text-neutral-500 dark:hover:text-neutral-100'} md:p-1" - aria-current={isActive(item.href) ? 'page' : 'false'} + aria-current={isActive(item.href) ? 'page' : undefined} > {item.label} diff --git a/src/lib/SeriesList.svelte b/src/lib/SeriesList.svelte index ca4ad571..da905eef 100644 --- a/src/lib/SeriesList.svelte +++ b/src/lib/SeriesList.svelte @@ -1,41 +1,43 @@
-
+
{#key series[0].series}

Series: {series[0].series}

{/key}
{#each ordered as post, index (post.slug)} - - diff --git a/src/lib/comments/CommentNode.svelte b/src/lib/comments/CommentNode.svelte index c9392829..02374c3e 100644 --- a/src/lib/comments/CommentNode.svelte +++ b/src/lib/comments/CommentNode.svelte @@ -124,8 +124,7 @@ async function submitReply() { replyError = ''; if (!replyText.trim()) return (replyError = 'Reply cannot be empty.'); - if (replyPassword.length < 4) - return (replyError = 'Password must be at least 4 characters.'); + if (replyPassword.length < 4) return (replyError = 'Password must be at least 4 characters.'); if (replyText.length > MAX_LENGTH) return (replyError = `Max ${MAX_LENGTH} characters.`); replySubmitting = true; @@ -156,8 +155,7 @@ async function confirmDelete() { deleteError = ''; - if (deletePassword.length < 4) - return (deleteError = 'Password must be at least 4 characters.'); + if (deletePassword.length < 4) return (deleteError = 'Password must be at least 4 characters.'); deleteSubmitting = true; try { @@ -186,272 +184,280 @@
-{#snippet card()} -
- -
-

- {comment.username} -

-
- {#if !isDeleted} - - - + {#snippet card()} +
+ +
+

+ {comment.username} +

+
+ {#if !isDeleted} + + + - + ? 'text-emerald-600 dark:text-emerald-400' + : 'text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100'}" + > + {#if isVoting && votingAnim.side === 'up'} +
-
+ + {/if} +
+
- - {#if mode === 'edit' && !isDeleted} -
- - - {#if editError} -

{editError}

+ + {#if mode === 'edit' && !isDeleted} +
+ + + {#if editError} +

{editError}

+ {/if} +
+ + +
+
+ {:else if isDeleted} +

[deleted]

+ {:else} +

{comment.text}

{/if} -
- - -
-
- {:else if isDeleted} -

[deleted]

- {:else} -

{comment.text}

- {/if} +

+ Enter your password to delete this comment. +

+ + {#if deleteError} +

{deleteError}

+ {/if} +
+ + +
+
+ {/if} - - {#if mode === 'delete' && !isDeleted} -
-

- Enter your password to permanently delete this comment. + +

+ {new Date(comment.createdAt).toLocaleString()} + {#if comment.updatedAt && comment.updatedAt !== comment.createdAt} + (edited) + {/if}

- - {#if deleteError} -

{deleteError}

+ + + {#if comment.reply} +
+

Admin Reply:

+

{comment.reply}

+
{/if} -
- + + + {#if mode === 'reply'} +
+ +
+ + {#if showReplyCharsLeft} +

+ Characters left: {replyCharsLeft} +

+ {/if} +
+ + {#if replyError} +

{replyError}

+ {/if} +
+ + +
+
+ {:else if comment.depth < 2 && mode === null && !isDeleted} -
+ {/if}
- {/if} + {/snippet} - -

- {new Date(comment.createdAt).toLocaleString()} - {#if comment.updatedAt && comment.updatedAt !== comment.createdAt} - (edited) + {#snippet kids()} + {#if comment.children && comment.children.length > 0} +

+ {#each comment.children as child (child.id)} + + {/each} +
{/if} -

- - - {#if comment.reply} -
-

Admin Reply:

-

{comment.reply}

-
- {/if} + {/snippet} - - {#if mode === 'reply'} -
- -
- - {#if showReplyCharsLeft} -

Characters left: {replyCharsLeft}

- {/if} -
- - {#if replyError} -

{replyError}

- {/if} -
- - -
-
- {:else if comment.depth < 2 && mode === null && !isDeleted} - - {/if} -
-{/snippet} - -{#snippet kids()} - {#if comment.children && comment.children.length > 0} +

[deleted]

+

[deleted]

+
- {#each comment.children as child (child.id)} - - {/each} + {@render card()} + {@render kids()}
- {/if} -{/snippet} - -{#if comment.stray} - -
-

[deleted]

-

[deleted]

-
-
+ {:else} {@render card()} {@render kids()} -
-{:else} - {@render card()} - {@render kids()} -{/if} + {/if}
diff --git a/src/lib/comments/CommentsSection.svelte b/src/lib/comments/CommentsSection.svelte index 801a8d5b..7d0790e0 100644 --- a/src/lib/comments/CommentsSection.svelte +++ b/src/lib/comments/CommentsSection.svelte @@ -28,7 +28,7 @@ let submitError = $state(''); // Generate a Linear-style anonymous handle for the placeholder - // (e.g. "stranger-7sl", "wanderer-k2x"). Stable per session. + // (e.g. "stranger-7sl", "wanderer-k2x"). Stable per browser session. const handleAdjectives = [ 'stranger', 'wanderer', @@ -48,9 +48,18 @@ const suffix = Math.random().toString(36).slice(2, 5); return `${adj}-${suffix}`; } - const fallbackHandle = makeHandle(); + let fallbackHandle = $state('guest'); - onMount(resolveIpHash); + onMount(() => { + try { + const saved = sessionStorage.getItem('comment-fallback-handle'); + fallbackHandle = saved || makeHandle(); + if (!saved) sessionStorage.setItem('comment-fallback-handle', fallbackHandle); + } catch { + fallbackHandle = makeHandle(); + } + resolveIpHash(); + }); // Reactive comments query — live updates across tabs const query = useQuery( @@ -192,6 +201,7 @@ {#if showCharsLeft} -

Characters left: {charsLeft}

+

Characters left: {charsLeft}

{/if}
{submitting ? 'Submitting…' : 'Submit'} @@ -242,7 +254,7 @@
{#if query.isLoading} - {#each [1, 2, 3] as _} + {#each [1, 2, 3] as skeleton (skeleton)}
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 340f8d63..10a8b050 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -2,7 +2,8 @@ import '../app.css'; import NavBar from '$lib/NavBar.svelte'; import { onMount, onDestroy } from 'svelte'; - import { configure } from 'onedollarstats'; + import { afterNavigate } from '$app/navigation'; + import { configure, view } from 'onedollarstats'; import { createWebHaptics } from 'web-haptics/svelte'; import { page } from '$app/stores'; import { setupConvex } from 'convex-svelte'; @@ -10,8 +11,8 @@ import { theme } from '$lib/theme.js'; import { env as publicEnv } from '$env/dynamic/public'; - const commit = publicEnv.PUBLIC_GIT_COMMIT ?? ''; + const currentYear = new Date().getFullYear(); const commitDate = (() => { const raw = publicEnv.PUBLIC_GIT_COMMIT_DATE; if (!raw) return ''; @@ -31,9 +32,16 @@ const { trigger, destroy } = createWebHaptics(); onDestroy(destroy); + let { children } = $props(); + const SCROLL_DURATION = 500; function scrollToTop() { + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + window.scrollTo(0, 0); + return; + } + const start = window.scrollY; const startTime = performance.now(); @@ -52,17 +60,31 @@ } let cleanupTheme; - let scrolled = false; + let scrolled = $state(false); + let analyticsReady = false; + let trackedPath = ''; function onScroll() { scrolled = window.scrollY > 8; } + function trackPageView(pathname) { + if (!analyticsReady || !pathname || pathname === trackedPath) return; + trackedPath = pathname; + view(pathname); + } + + afterNavigate(({ to }) => { + trackPageView(to?.url.pathname); + }); + onMount(async () => { configure({ collectorUrl: 'https://collector.onedollarstats.com/events', - autocollect: true + autocollect: false }); + analyticsReady = true; + trackPageView($page.url.pathname); cleanupTheme = theme.syncWithOS(); onScroll(); window.addEventListener('scroll', onScroll, { passive: true }); @@ -82,9 +104,7 @@ diff --git a/src/routes/api/comments/+server.ts b/src/routes/api/comments/+server.ts index 3f9599d4..e80e0e3a 100644 --- a/src/routes/api/comments/+server.ts +++ b/src/routes/api/comments/+server.ts @@ -13,6 +13,7 @@ import bcrypt from 'bcryptjs'; export const GET: RequestHandler = async ({ url, request }) => { const pageUrl = url.searchParams.get('url'); if (!pageUrl) throw error(400, 'Missing url parameter'); + if (!isValidPageUrl(pageUrl)) throw error(404, 'Page not found'); const ipHash = hashIp(getClientIp(request)); return runConvex( () => convex.query(api.comments.list, { url: pageUrl, ipHash }), @@ -46,6 +47,7 @@ export const POST: RequestHandler = async ({ request }) => { text, parentId, ipHash, + serverSecret: ADMIN_SECRET, adminSecret: admin ? ADMIN_SECRET : undefined }), (comment) => json({ comment }, { status: 201 }) diff --git a/src/routes/api/comments/[id]/+server.ts b/src/routes/api/comments/[id]/+server.ts index f541a5ab..58d9aa3a 100644 --- a/src/routes/api/comments/[id]/+server.ts +++ b/src/routes/api/comments/[id]/+server.ts @@ -25,7 +25,10 @@ export const PATCH: RequestHandler = async ({ params, request }) => { let auth; try { - auth = await convex.query(api.comments.getForAuth, { commentId: params.id }); + auth = await convex.query(api.comments.getForAuth, { + commentId: params.id, + serverSecret: ADMIN_SECRET + }); } catch (err) { return handleConvexErr(err); } @@ -40,6 +43,7 @@ export const PATCH: RequestHandler = async ({ params, request }) => { commentId: params.id, text, ipHash, + serverSecret: ADMIN_SECRET, adminSecret: admin ? ADMIN_SECRET : undefined }), (updated) => json({ comment: updated }) @@ -62,7 +66,10 @@ export const DELETE: RequestHandler = async ({ params, request }) => { let auth; try { - auth = await convex.query(api.comments.getForAuth, { commentId: params.id }); + auth = await convex.query(api.comments.getForAuth, { + commentId: params.id, + serverSecret: ADMIN_SECRET + }); } catch (err) { return handleConvexErr(err); } @@ -73,7 +80,16 @@ export const DELETE: RequestHandler = async ({ params, request }) => { } return runConvex( - () => convex.mutation(api.comments.hardDelete, { commentId: params.id, adminSecret: ADMIN_SECRET }), + () => + admin + ? convex.mutation(api.comments.hardDelete, { + commentId: params.id, + adminSecret: ADMIN_SECRET + }) + : convex.mutation(api.comments.softDelete, { + commentId: params.id, + serverSecret: ADMIN_SECRET + }), () => json({ success: true }) ); }; diff --git a/src/routes/api/comments/[id]/vote/+server.ts b/src/routes/api/comments/[id]/vote/+server.ts index 50630226..bd9329c1 100644 --- a/src/routes/api/comments/[id]/vote/+server.ts +++ b/src/routes/api/comments/[id]/vote/+server.ts @@ -1,4 +1,4 @@ -import { json, error } from '@sveltejs/kit'; +import { error } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { convex } from '$lib/server/convex'; import { api } from '$convex/_generated/api'; @@ -26,6 +26,7 @@ export const POST: RequestHandler = async ({ params, request }) => { commentId: params.id, voteType: parsed.data.voteType, ipHash, + serverSecret: ADMIN_SECRET, adminSecret: admin ? ADMIN_SECRET : undefined }) ); diff --git a/src/routes/api/likes/+server.ts b/src/routes/api/likes/+server.ts index 139b46f2..1c503519 100644 --- a/src/routes/api/likes/+server.ts +++ b/src/routes/api/likes/+server.ts @@ -12,6 +12,7 @@ import { ADMIN_SECRET } from '$env/static/private'; export const GET: RequestHandler = async ({ url, request }) => { const pageUrl = url.searchParams.get('url'); if (!pageUrl) throw error(400, 'Missing url parameter'); + if (!isValidPageUrl(pageUrl)) throw error(404, 'Page not found'); const ipHash = getRequestIpHash(request); return runConvex(() => convex.query(api.likes.get, { url: pageUrl, ipHash })); }; @@ -36,6 +37,7 @@ export const POST: RequestHandler = async ({ request }) => { convex.mutation(api.likes.toggle, { url: pageUrl, ipHash, + serverSecret: ADMIN_SECRET, adminSecret: admin ? ADMIN_SECRET : undefined }) ); diff --git a/src/routes/api/now/+server.js b/src/routes/api/now/+server.js index e932c70e..e804c935 100644 --- a/src/routes/api/now/+server.js +++ b/src/routes/api/now/+server.js @@ -2,10 +2,11 @@ import { json, error } from '@sveltejs/kit'; import { ADMIN_SECRET } from '$env/static/private'; import { convex } from '$lib/server/convex'; import { api } from '$convex/_generated/api'; +import { secretsMatch } from '$lib/server/admin'; export async function POST({ request, cookies }) { const token = cookies.get('admin_token'); - if (!ADMIN_SECRET || token !== ADMIN_SECRET) { + if (!token || !secretsMatch(token)) { throw error(401, 'Unauthorized'); } @@ -14,6 +15,6 @@ export async function POST({ request, cookies }) { throw error(400, 'Missing content'); } - await convex.mutation(api.now.update, { content: body.content }); + await convex.mutation(api.now.update, { content: body.content, adminSecret: ADMIN_SECRET }); return json({ ok: true }); } diff --git a/src/routes/blog/[slug]/+page.svelte b/src/routes/blog/[slug]/+page.svelte index e4c40ddd..9d89cbd5 100644 --- a/src/routes/blog/[slug]/+page.svelte +++ b/src/routes/blog/[slug]/+page.svelte @@ -6,9 +6,7 @@ import { page } from '$app/stores'; import Lightbox from '../../../lib/Lightbox.svelte'; import { lightboxAction } from '$lib/lightbox.js'; - import TableOfContents from '$lib/TableOfContents.svelte'; import Languages from '@lucide/svelte/icons/languages'; - import NumberFlow from '@number-flow/svelte'; import { onMount, tick } from 'svelte'; import { fly, blur } from 'svelte/transition'; @@ -37,7 +35,9 @@ try { localStorage.setItem('preferred-lang', l); document.cookie = `preferred-lang=${l}; path=/; max-age=31536000; samesite=lax`; - } catch (e) {} + } catch { + // Ignore storage/cookie failures; the selected language still changes in memory. + } } // Direction of the language swap, used to slide content the right way. @@ -104,12 +104,23 @@ $: animate = mounted && !reduceMotion; $: titleBlur = { amount: 8, opacity: 0, duration: animate ? 420 : 0, easing: cubicOut }; // Both directions share duration + easing so the panels stay a constant gap apart while sliding. - $: bodyIn = { x: dir * (bodyWidth + 32), opacity: 1, duration: animate ? 440 : 0, easing: cubicOut }; - $: bodyOut = { x: -dir * (bodyWidth + 32), opacity: 1, duration: animate ? 440 : 0, easing: cubicOut }; + $: bodyIn = { + x: dir * (bodyWidth + 32), + opacity: 1, + duration: animate ? 440 : 0, + easing: cubicOut + }; + $: bodyOut = { + x: -dir * (bodyWidth + 32), + opacity: 1, + duration: animate ? 440 : 0, + easing: cubicOut + }; - $: currentMeta = (displayLang === 'ko' && data.koMeta) ? data.koMeta : (data.enMeta ?? data.meta); - $: currentContent = (displayLang === 'ko' && data.koContent) ? data.koContent : data.enContent; - $: currentReadingTime = (displayLang === 'ko' && data.koReadingTime) ? data.koReadingTime : data.enReadingTime; + $: currentMeta = displayLang === 'ko' && data.koMeta ? data.koMeta : (data.enMeta ?? data.meta); + $: currentContent = displayLang === 'ko' && data.koContent ? data.koContent : data.enContent; + $: currentReadingTime = + displayLang === 'ko' && data.koReadingTime ? data.koReadingTime : data.enReadingTime; $: readingMinutes = parseInt(currentReadingTime ?? '', 10); $: currentSeries = displayLang === 'ko' ? data.koSeries : data.enSeries; $: ogImageUrl = `https://og.ij5.dev/api/og/?title=${encodeURIComponent(data.meta.title)}&subheading=Injoon+Oh`; @@ -132,7 +143,7 @@ pillStyle = `transform: translateX(${left}px); width: ${rect.width}px; opacity: 1;`; clipStyle = `clip-path: inset(4px ${right}px 4px ${left}px round 9999px); opacity: 1;`; } - $: lang, updatePill(); + $: (lang, updatePill()); onMount(() => { updatePill(); const ro = new ResizeObserver(updatePill); @@ -155,7 +166,9 @@
-
+
{#if currentSeries?.[0]?.series} @@ -184,16 +197,20 @@ {/key}
-
+

{formatDate(currentMeta.date)}

{#if !isNaN(readingMinutes)}

·

- +

{readingMinutes} min read

{/if}
{#if data.availableLangs.length > 1}
setLang(l)} - class="relative z-10 rounded-full px-3 {l === 'en' ? 'mr-0.5' : ''} py-1 text-sm font-semibold text-neutral-500 transition-colors duration-150 hover:text-neutral-900 dark:hover:text-neutral-100" + type="button" + aria-pressed={lang === l} + class="relative z-10 rounded-full px-3 {l === 'en' + ? 'mr-0.5' + : ''} py-1 text-sm font-semibold text-neutral-500 transition-colors duration-150 hover:text-neutral-900 dark:hover:text-neutral-100" > {l === 'en' ? 'English' : '한국어'} @@ -218,7 +239,9 @@ > {#each data.availableLangs as l} {l === 'en' ? 'English' : '한국어'} @@ -257,9 +280,12 @@ 이 글은 AI의 도움을 받아 번역되었습니다. 일부 내용에 오류가 있을 수 있습니다.

{:else} -

AI Translation

+

+ AI Translation +

- This post was translated from Korean with the help of AI. Some nuance may be lost. + This post was translated from Korean with the help of AI. Some nuance may be + lost.

{/if}
diff --git a/src/routes/now/+page.svelte b/src/routes/now/+page.svelte index ee5102f8..2da82d3a 100644 --- a/src/routes/now/+page.svelte +++ b/src/routes/now/+page.svelte @@ -16,9 +16,7 @@ const content = $derived(doc?.content ?? ''); const updatedAt = $derived(doc?.updatedAt ? new Date(doc.updatedAt) : null); const html = $derived( - content - ? marked.parse(content, { gfm: true, breaks: false }) - : '' + content ? sanitizeHtml(marked.parse(content, { gfm: true, breaks: false })) : '' ); /** @type {Array<{ unit: Intl.RelativeTimeFormatUnit, secs: number }>} */ @@ -95,6 +93,79 @@ resize(); return { destroy: () => node.removeEventListener('input', resize) }; } + + const ALLOWED_TAGS = new Set([ + 'a', + 'blockquote', + 'br', + 'code', + 'em', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'hr', + 'li', + 'ol', + 'p', + 'pre', + 'strong', + 'ul' + ]); + const ALLOWED_ATTRS = new Map([ + ['a', new Set(['href', 'title'])], + ['code', new Set(['class'])] + ]); + + function sanitizeHtml(input) { + if (typeof document === 'undefined') return ''; + + const template = document.createElement('template'); + template.innerHTML = input; + cleanNode(template.content); + return template.innerHTML; + } + + function cleanNode(node) { + for (const child of [...node.childNodes]) { + if (child.nodeType === Node.TEXT_NODE) continue; + if (child.nodeType !== Node.ELEMENT_NODE) { + child.remove(); + continue; + } + + const tag = child.tagName.toLowerCase(); + if (!ALLOWED_TAGS.has(tag)) { + child.replaceWith(document.createTextNode(child.textContent ?? '')); + continue; + } + + const allowedAttrs = ALLOWED_ATTRS.get(tag) ?? new Set(); + for (const attr of [...child.attributes]) { + if (!allowedAttrs.has(attr.name) || attr.name.startsWith('on')) { + child.removeAttribute(attr.name); + } + } + + if (tag === 'a') { + const href = child.getAttribute('href') ?? ''; + if (!isSafeHref(href)) { + child.removeAttribute('href'); + } else if (/^https?:\/\//i.test(href)) { + child.setAttribute('target', '_blank'); + child.setAttribute('rel', 'noopener noreferrer'); + } + } + + cleanNode(child); + } + } + + function isSafeHref(href) { + return /^(https?:|mailto:|\/|#)/i.test(href); + } @@ -103,26 +174,23 @@
-

+

Now

-{#if !nowQuery.isLoading && updatedAt} + {#if !nowQuery.isLoading && updatedAt}

- Updated {formatRelative(updatedAt)} - + Updated {formatRelative(updatedAt)}

- {:else} -

- Loading... - -

- - {/if} + {:else} +

+ Loading... +

+ {/if}
{#if nowQuery.isLoading} @@ -136,24 +204,26 @@ use:autoResize bind:value={editContent} disabled={saving} - class="w-full min-h-[320px] resize-none rounded-none border-0 bg-neutral-50 dark:bg-neutral-900 px-4 py-3 font-mono text-sm text-neutral-900 dark:text-neutral-100 leading-relaxed outline-none focus:ring-1 focus:ring-neutral-300 dark:focus:ring-neutral-700 disabled:opacity-50 transition-colors" + aria-label="Now page markdown" + class="min-h-[320px] w-full resize-none rounded-none border-0 bg-neutral-50 px-4 py-3 font-mono text-sm leading-relaxed text-neutral-900 transition-colors outline-none focus:ring-1 focus:ring-neutral-300 disabled:opacity-50 dark:bg-neutral-900 dark:text-neutral-100 dark:focus:ring-neutral-700" placeholder="Write in markdown…" spellcheck="false" autocomplete="off" > {:else if html}
+ {@html html}
{:else if data.isAdmin} @@ -170,7 +240,7 @@ {#if !editing} @@ -178,14 +248,14 @@ diff --git a/src/routes/projects/[slug]/+page.svelte b/src/routes/projects/[slug]/+page.svelte index ed10dff0..323b7d33 100644 --- a/src/routes/projects/[slug]/+page.svelte +++ b/src/routes/projects/[slug]/+page.svelte @@ -10,9 +10,10 @@ let lang = data.availableLangs[0] ?? 'en'; - $: currentMeta = (lang === 'ko' && data.koMeta) ? data.koMeta : (data.enMeta ?? data.meta); - $: currentContent = (lang === 'ko' && data.koContent) ? data.koContent : data.enContent; - $: currentReadingTime = (lang === 'ko' && data.koReadingTime) ? data.koReadingTime : data.enReadingTime; + $: currentMeta = lang === 'ko' && data.koMeta ? data.koMeta : (data.enMeta ?? data.meta); + $: currentContent = lang === 'ko' && data.koContent ? data.koContent : data.enContent; + $: currentReadingTime = + lang === 'ko' && data.koReadingTime ? data.koReadingTime : data.enReadingTime; $: ogImageUrl = `https://og.ij5.dev/api/og/?title=${encodeURIComponent(data.meta.title)}&subheading=Injoon+Oh`; // Animated language toggle pill @@ -20,6 +21,7 @@ let langButtons = {}; let pillStyle = ''; let clipStyle = ''; + let mounted = false; async function updatePill() { await tick(); const el = langButtons[lang]; @@ -33,12 +35,13 @@ pillStyle = `transform: translateX(${left}px); width: ${rect.width}px; opacity: 1;`; clipStyle = `clip-path: inset(4px ${right}px 4px ${left}px round 9999px); opacity: 1;`; } - $: lang, updatePill(); + $: (lang, updatePill()); onMount(() => { updatePill(); const ro = new ResizeObserver(updatePill); const container = Object.values(langButtons)[0]?.parentElement; if (container) ro.observe(container); + requestAnimationFrame(() => requestAnimationFrame(() => (mounted = true))); return () => ro.disconnect(); }); @@ -55,11 +58,16 @@
-
+
+

{currentMeta.title}

-
+

{currentMeta.year}

·

{currentReadingTime} @@ -67,9 +75,12 @@ {#if data.availableLangs.length > 1}
@@ -77,6 +88,8 @@