Skip to content

Commit d866aa0

Browse files
NiallJoeMaherclaude
andcommitted
fix(relaunch): address self-review (mobile nav, discussion 404, write spam)
Review fixes before PR: - H1: discussion/question/til/resource posts now resolve in the [username]/[slug] reader (getUserPost broadened from article-only); ComposeModal builds the canonical /{username}/{slug} URL (threaded username) instead of the legacy /articles redirect that 404s for new posts - H2: add a bottom MobileNav (≤720px) so mobile users can reach Home/Discussions/ Jobs/Notifications/Profile + Create; hide the sign-in bar on mobile - H3: rate-limit content.create when published (10/5min per user) — the quick compose path can no longer be scripted to flood the feed - H4: rate-limit + IP-throttle the public sponsor.submit (3/hr per IP) - M1: escape LIKE metacharacters in search to prevent wildcard scans - L1: sponsor email subject falls back to name when company is omitted Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 04fecab commit d866aa0

10 files changed

Lines changed: 146 additions & 9 deletions

File tree

app/(app)/[username]/[slug]/page.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import type { JSONContent } from "@tiptap/core";
2020
import NotFound from "@/components/NotFound/NotFound";
2121
import { db } from "@/server/db";
2222
import { posts, user, feed_sources, post_tags, tag } from "@/server/db/schema";
23-
import { eq, and, lte } from "drizzle-orm";
23+
import { eq, and, lte, inArray } from "drizzle-orm";
2424
import FeedArticleContent from "./_feedArticleContent";
2525
import LinkContentDetail from "./_linkContentDetail";
2626
import UserLinkDetail from "./_userLinkDetail";
@@ -73,7 +73,9 @@ async function getUserPost(username: string, postSlug: string) {
7373
eq(posts.slug, postSlug),
7474
eq(posts.authorId, userRecord.id),
7575
eq(posts.status, "published"),
76-
eq(posts.type, "article"),
76+
// Text-content kinds all render via the article reader (title + body +
77+
// discussion). Links have their own resolver below.
78+
inArray(posts.type, ["article", "discussion", "question", "til", "resource"]),
7779
lte(posts.publishedAt, new Date().toISOString()),
7880
),
7981
)

components/Create/ComposeModal.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,11 @@ function parseDomain(url: string): string | null {
2828
*/
2929
export function ComposeModal({
3030
mode,
31+
username,
3132
onClose,
3233
}: {
3334
mode: ComposeMode;
35+
username: string | null;
3436
onClose: () => void;
3537
}) {
3638
const router = useRouter();
@@ -47,7 +49,10 @@ export function ComposeModal({
4749
api.content.create.useMutation({
4850
onSuccess: (post) => {
4951
void utils.content.getFeed.invalidate();
50-
const href = post?.slug ? `/articles/${post.slug}` : "/feed";
52+
// New posts live in the `posts` table; the canonical URL is
53+
// /{username}/{slug}. Fall back to the feed if we lack the username.
54+
const href =
55+
post?.slug && username ? `/${username}/${post.slug}` : "/feed";
5156
setDone({ href });
5257
},
5358
onError: (err) => {

components/Create/ShellActionsProvider.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,11 @@ export function useShellActions(): ShellActions {
3131
*/
3232
export function ShellActionsProvider({
3333
authed,
34+
username,
3435
children,
3536
}: {
3637
authed: boolean;
38+
username: string | null;
3739
children: React.ReactNode;
3840
}) {
3941
const [compose, setCompose] = useState<ComposeMode | null>(null);
@@ -77,7 +79,11 @@ export function ShellActionsProvider({
7779
/>
7880
)}
7981
{compose && (
80-
<ComposeModal mode={compose} onClose={() => setCompose(null)} />
82+
<ComposeModal
83+
mode={compose}
84+
username={username}
85+
onClose={() => setCompose(null)}
86+
/>
8187
)}
8288
{topicsOpen && <TopicsModal onClose={() => setTopicsOpen(false)} />}
8389
</ShellActionsContext.Provider>

components/Layout/AppShell.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { TopBar } from "./TopBar";
66
import { LeftRail } from "./LeftRail";
77
import { RightRail } from "./RightRail";
88
import { SignInBar } from "./SignInBar";
9+
import { MobileNav } from "./MobileNav";
910
import { CommandPalette } from "@/components/CommandPalette/CommandPalette";
1011
import { ShellActionsProvider } from "@/components/Create/ShellActionsProvider";
1112

@@ -54,7 +55,7 @@ export function AppShell({ children, session, username }: AppShellProps) {
5455
}, [paletteOpen]);
5556

5657
return (
57-
<ShellActionsProvider authed={!!session}>
58+
<ShellActionsProvider authed={!!session} username={username}>
5859
<div
5960
className="min-h-svh bg-canvas text-fg"
6061
style={{ paddingBottom: session ? 0 : 56 }}
@@ -72,6 +73,7 @@ export function AppShell({ children, session, username }: AppShellProps) {
7273

7374
{paletteOpen && <CommandPalette onClose={closePalette} />}
7475
{!session && <SignInBar />}
76+
<MobileNav session={session} username={username} />
7577
</div>
7678
</ShellActionsProvider>
7779
);

components/Layout/MobileNav.tsx

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"use client";
2+
3+
import Link from "next/link";
4+
import { usePathname } from "next/navigation";
5+
import { signIn } from "next-auth/react";
6+
import { type Session } from "next-auth";
7+
import { useShellActions } from "@/components/Create/ShellActionsProvider";
8+
9+
/**
10+
* Bottom nav for small screens (≤720px), where the top-bar nav is absent and
11+
* the left rail is hidden. Keeps the primary destinations + Create reachable on
12+
* mobile. Shown via the `.app-mobilenav` media query in globals.css.
13+
*/
14+
export function MobileNav({
15+
session,
16+
username,
17+
}: {
18+
session: Session | null;
19+
username: string | null;
20+
}) {
21+
const pathname = usePathname();
22+
const { openCompose } = useShellActions();
23+
24+
const items = session
25+
? [
26+
{ name: "Home", href: "/feed" },
27+
{ name: "Discuss", href: "/discussions" },
28+
{ name: "Jobs", href: "/jobs" },
29+
{ name: "Alerts", href: "/notifications" },
30+
{ name: "You", href: `/${username || "settings"}` },
31+
]
32+
: [
33+
{ name: "Home", href: "/feed" },
34+
{ name: "Discuss", href: "/discussions" },
35+
{ name: "Jobs", href: "/jobs" },
36+
];
37+
38+
const isActive = (href: string) =>
39+
href === "/feed"
40+
? pathname === "/feed" || pathname === "/"
41+
: pathname?.startsWith(href);
42+
43+
return (
44+
<nav className="app-mobilenav" aria-label="Primary">
45+
{items.map((item) => (
46+
<Link
47+
key={item.name}
48+
href={item.href}
49+
aria-current={isActive(item.href) ? "page" : undefined}
50+
className={`flex flex-1 items-center justify-center py-2 text-xs font-medium transition-colors ${
51+
isActive(item.href) ? "text-fg" : "text-muted"
52+
}`}
53+
>
54+
{item.name}
55+
</Link>
56+
))}
57+
{session ? (
58+
<button
59+
onClick={() => openCompose("discussion")}
60+
aria-label="Create a post"
61+
className="flex flex-1 items-center justify-center py-2 text-xs font-semibold text-accent"
62+
>
63+
+ Create
64+
</button>
65+
) : (
66+
<button
67+
onClick={() => signIn()}
68+
className="flex flex-1 items-center justify-center py-2 text-xs font-semibold text-accent"
69+
>
70+
Join
71+
</button>
72+
)}
73+
</nav>
74+
);
75+
}

components/Layout/SignInBar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export function SignInBar() {
1313

1414
return (
1515
<div
16-
className="fixed inset-x-0 bottom-0 z-[45] border-t border-strong"
16+
className="fixed inset-x-0 bottom-0 z-[45] border-t border-strong max-[720px]:hidden"
1717
style={{
1818
background: "color-mix(in srgb, rgb(var(--color-elevated)) 92%, transparent)",
1919
backdropFilter: "blur(12px)",

server/api/router/content.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import {
4848
screenContent,
4949
notifyAdminOfReview,
5050
} from "@/server/lib/moderation";
51+
import { rateLimit } from "@/server/lib/rateLimit";
5152
import crypto from "crypto";
5253

5354
// Helper to generate slug from title
@@ -562,6 +563,18 @@ export const contentRouter = createTRPCRouter({
562563
.mutation(async ({ ctx, input }) => {
563564
const userId = ctx.session.user.id;
564565

566+
// Throttle publishing so the quick-compose path can't be scripted to
567+
// flood the feed (drafts are unmetered). 10 published posts / 5 min.
568+
if (input.published) {
569+
const { success } = rateLimit(`create:${userId}`, 10, 5 * 60_000);
570+
if (!success) {
571+
throw new TRPCError({
572+
code: "TOO_MANY_REQUESTS",
573+
message: "You're posting too fast. Take a breather and try again.",
574+
});
575+
}
576+
}
577+
565578
// Validate based on content type
566579
if (input.type === "POST" && !input.body) {
567580
throw new TRPCError({

server/api/router/search.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,10 @@ export const searchRouter = createTRPCRouter({
4040
});
4141
}
4242

43-
const like = `%${query}%`;
43+
// Escape LIKE metacharacters so a query of "%"/"_" can't broaden into an
44+
// expensive full scan (Postgres treats backslash as the default escape).
45+
const escaped = query.replace(/[\\%_]/g, "\\$&");
46+
const like = `%${escaped}%`;
4447
const limit = input.limit;
4548

4649
const [postRows, peopleRows, tagRows] = await Promise.all([

server/api/router/sponsor.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,23 @@ import { TRPCError } from "@trpc/server";
1010
import { createSponsorInquiryEmailTemplate } from "@/utils/createSponsorInquiryEmailTemplate";
1111
import { sponsor_inquiry } from "@/server/db/schema";
1212
import { db } from "@/server/db";
13+
import { rateLimit, clientIpFromHeaders } from "@/server/lib/rateLimit";
1314

1415
export const sponsorRouter = createTRPCRouter({
1516
submit: publicProcedure
1617
.input(SponsorInquirySchema)
17-
.mutation(async ({ input }) => {
18+
.mutation(async ({ input, ctx }) => {
19+
// Public, unauthenticated endpoint that writes a row + sends an email, so
20+
// throttle by IP to stop scripted flooding. 3 inquiries / hour per IP.
21+
const ip = clientIpFromHeaders(ctx.headers);
22+
const { success } = rateLimit(`sponsor:${ip}`, 3, 60 * 60_000);
23+
if (!success) {
24+
throw new TRPCError({
25+
code: "TOO_MANY_REQUESTS",
26+
message: "Too many inquiries. Please email partnerships@codu.co.",
27+
});
28+
}
29+
1830
try {
1931
const { name, email, company, phone, interests, budgetRange, goals } =
2032
input;
@@ -64,7 +76,7 @@ export const sponsorRouter = createTRPCRouter({
6476
await sendEmail({
6577
recipient: adminEmail,
6678
htmlMessage,
67-
subject: `New Sponsor Inquiry from ${company}`,
79+
subject: `New Sponsor Inquiry from ${company || name}`,
6880
});
6981

7082
return {

styles/globals.css

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,25 @@ body {
224224
}
225225
}
226226

227+
/* Bottom nav for ≤720px (top-bar nav is gone, left rail hidden). */
228+
.app-mobilenav {
229+
display: none;
230+
}
231+
@media (max-width: 720px) {
232+
.app-mobilenav {
233+
position: fixed;
234+
left: 0;
235+
right: 0;
236+
bottom: 0;
237+
z-index: 40;
238+
display: flex;
239+
align-items: stretch;
240+
border-top: 1px solid rgb(var(--color-hairline));
241+
background: color-mix(in srgb, rgb(var(--color-canvas)) 92%, transparent);
242+
backdrop-filter: blur(12px);
243+
}
244+
}
245+
227246
.dropdown-button {
228247
@apply inline-flex items-center justify-center border border-neutral-300 bg-white bg-gradient-to-r px-4 py-2 font-medium text-neutral-800 shadow-sm hover:bg-neutral-200 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:border-neutral-600 dark:bg-neutral-900 dark:text-white dark:hover:bg-neutral-800;
229248
}

0 commit comments

Comments
 (0)