Skip to content
Merged

Dev #15

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,9 @@ PORT=8080
REALTIME_PORT=8000
REDIS_URL=redis://127.0.0.1:6379
SELF_HOSTED=true
S3_ENDPOINT=https://your-account-id.r2.cloudflarestorage.com
S3_ACCESS_KEY_ID=your-access-key-id
S3_SECRET_ACCESS_KEY=your-secret-access-key
S3_BUCKET_NAME=townhall
S3_REGION=auto
S3_PUBLIC_URL=https://cdn.yourdomain.com
Comment thread
BuckyMcYolo marked this conversation as resolved.
68 changes: 63 additions & 5 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,76 @@
- [x] Refactor DM membership checks into a shared helper and remove redundant member remapping in `getDM`.
- [x] Move realtime runtime config to validated env values (`REALTIME_PORT`, `REALTIME_CORS_ORIGIN`).

## Next (Short-Term Hardening)
---

## Phase 1 — Core UX Gaps

- [x] File/image attachment uploads (R2)
- [ ] Message deletion
Comment on lines +22 to +25
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add reply support to the shipped roadmap items.

This PR ships message replies as well as attachments, but the roadmap update only reflects the attachment half. That leaves ROADMAP.md stale immediately after merge. Please add a completed item for replies here or fold it into the completed entry so the roadmap matches the delivered scope.

📝 Suggested update
-## Phase 1 — Core UX Gaps
-
-- [x] File/image attachment uploads (R2)
+## Phase 1 — Core UX Gaps
+
+- [x] Message replies
+- [x] File/image attachment uploads
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
## Phase 1 — Core UX Gaps
- [x] File/image attachment uploads (R2)
- [ ] Message deletion
## Phase 1 — Core UX Gaps
- [x] Message replies
- [x] File/image attachment uploads
- [ ] Message deletion
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ROADMAP.md` around lines 22 - 25, Update the Phase 1 — Core UX Gaps list in
ROADMAP.md to reflect that message replies were shipped: either add a checked
item like "- [x] Message replies" under the same section or merge it into the
existing "- [x] File/image attachment uploads (R2)" entry so the roadmap matches
the delivered scope; ensure the checkbox is checked and the wording matches
other items for consistency (e.g., "Message replies" or "Replies and
attachments").

- [ ] Message editing UI
- [ ] User profiles (bio, custom status, avatar upload)
- [ ] Channel edit/delete
- [ ] Settings pages

## Phase 2 — Permissions & Moderation

- [ ] Granular permission system (beyond owner/admin/member)
- [ ] Member management UI (kick, banish, silence, role assignment)
- [ ] Rate limiting enforcement (API-level + per-channel)
- [ ] Audit logs

## Phase 3 — Social Features

- [ ] Shareable invite links (not just email invites)
- [ ] Ally (friend) system with requests
- [ ] User blocking
- [ ] Privacy settings

## Phase 4 — Tests & CI/CD

- [ ] API endpoint tests
- [ ] Critical path integration tests
- [ ] CI pipeline (lint, type-check, test, build)
- [ ] Docker / deployment configs

## Phase 5 — Polish

- [ ] Message search
- [ ] Typing indicators
- [ ] Pinned messages panel
- [ ] Thread support
- [ ] Notification preferences
- [ ] Error handling & loading state improvements

## Phase 6 — Infrastructure

- [ ] Structured logger (Pino/Winston) replacing `console.error`
- [ ] Production environment management
- [ ] Production startup guard for `REALTIME_CORS_ORIGIN` on localhost defaults
- [ ] Database migration workflow
- [ ] Monitoring & logging (observability)
- [ ] CORS lockdown for production domains

## Phase 7 — v2 Features

- [ ] Voice/video (Voice Chambers)
- [ ] Bots & webhooks
- [ ] Custom emojis (Sigils & Crests)
- [ ] Server discovery
- [ ] Forum channel posts

---

## Backlog (Short-Term Hardening)

- [ ] Add explicit error logging in `initializeConnection` before disconnecting a socket (include `socket.id` + `userId` context).
- [ ] Replace `console.error` with a proper structured logger package (e.g. Pino or Winston) and wire logs for external observability platforms (Datadog/New Relic).
- [ ] Update onboarding `normalizeSlugInput` to collapse repeated hyphens while typing.
- [ ] Use `DM_CHANNEL_TYPES` constant everywhere in DM route filters to avoid drift.
- [ ] Extract shared DM last-message projection/formatter helper so list/get endpoints use one source of truth.

## Later (Architecture / Scale)
## Backlog (Architecture / Scale)

- [ ] Move `@everyone` fanout off the realtime request path:
- [ ] Enqueue one guild-fanout job instead of per-member synchronous work in `message:send`.
- [ ] Process fanout in a worker with batched DB writes and chunked emits.
- [ ] Add rate-limiting and permission checks for mass mentions.
- [ ] Add a production startup guard that fails fast (or loudly warns) when `REALTIME_CORS_ORIGIN` is left on localhost defaults.
- [ ] Extract shared DM last-message projection/formatter helper so list/get endpoints use one source of truth.
2 changes: 2 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
"check-types": "tsc --noEmit"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1004.0",
"@aws-sdk/s3-request-presigner": "^3.1004.0",
"@hono/node-server": "^1.19.9",
"@hono/zod-openapi": "^1.2.2",
"@repo/auth": "workspace:*",
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import index from "@/routes/index.route"
import channelsRouter from "@/routes/v1/channels/index"
import dmsRouter from "@/routes/v1/dms/index"
import guildsRouter from "@/routes/v1/guilds/index"
import uploadsRouter from "@/routes/v1/uploads/index"
import waitlistRouter from "@/routes/waitlist/index"

const app = createApp()
Expand All @@ -31,6 +32,7 @@ const routes = app
.route("/v1", channelsRouter)
.route("/v1", guildsRouter)
.route("/v1", dmsRouter)
.route("/v1", uploadsRouter)

export type AppType = typeof routes

Expand Down
9 changes: 9 additions & 0 deletions apps/api/src/lib/helpers/openapi/message-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,20 @@ export const messageEmbedSchema = z.object({
siteName: z.string().optional(),
})

export const referencedMessageSchema = z
.object({
id: z.string().uuid(),
content: z.string().nullable(),
author: messageAuthorSchema,
})
.nullable()

export const messageWithAuthorSchema = selectMessageSchema.extend({
author: messageAuthorSchema,
mentions: z.array(messageAuthorSchema),
reactions: z.array(messageReactionSchema),
embeds: z.array(messageEmbedSchema),
referencedMessage: referencedMessageSchema,
})

export const listMessagesQuerySchema = paginationQuerySchema
Expand Down
7 changes: 7 additions & 0 deletions apps/api/src/lib/helpers/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ export const forbiddenSchema = jsonContent({
description: "Forbidden",
})

export const payloadTooLargeSchema = jsonContent({
schema: errorSchema.openapi({
example: { success: false, message: "File too large" },
}),
description: "Payload too large",
})

export const notFoundSchema = jsonContent({
schema: errorSchema.openapi({
example: { success: false, message: "Not found" },
Expand Down
42 changes: 42 additions & 0 deletions apps/api/src/lib/queries/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,45 @@ export async function fetchMessagePage(
.where(inArray(messageReaction.messageId, messageIds))
: []

// Batch-fetch referenced messages for replies
const referencedMessageIds = messages
.map((msg) => msg.referencedMessageId)
.filter((id): id is string => id !== null)

const referencedMessageRows =
referencedMessageIds.length > 0
? await db
.select({
id: message.id,
content: message.content,
authorId: user.id,
authorName: user.name,
authorUsername: user.username,
authorDisplayUsername: user.displayUsername,
authorImage: user.image,
})
.from(message)
.innerJoin(user, eq(message.authorId, user.id))
.where(inArray(message.id, referencedMessageIds))
: []

const referencedMessagesById = new Map(
referencedMessageRows.map((row) => [
row.id,
{
id: row.id,
content: row.content,
author: {
id: row.authorId,
name: row.authorName,
username: row.authorUsername,
displayUsername: row.authorDisplayUsername,
image: row.authorImage,
},
},
])
)

const mentionsByMessageId = new Map<
string,
Array<{
Expand Down Expand Up @@ -138,6 +177,9 @@ export async function fetchMessagePage(
embeds: msg.embeds ?? [],
mentions: mentionsByMessageId.get(msg.id) ?? [],
reactions: Array.from(reactionsByMessageId.get(msg.id)?.values() ?? []),
referencedMessage: msg.referencedMessageId
? (referencedMessagesById.get(msg.referencedMessageId) ?? null)
: null,
}))

return {
Expand Down
11 changes: 11 additions & 0 deletions apps/api/src/lib/s3.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { S3Client } from "@aws-sdk/client-s3"
import { env } from "@repo/env/server"

export const s3Client = new S3Client({
region: env.S3_REGION,
endpoint: env.S3_ENDPOINT,
credentials: {
accessKeyId: env.S3_ACCESS_KEY_ID,
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
},
})
109 changes: 109 additions & 0 deletions apps/api/src/routes/v1/uploads/handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { PutObjectCommand } from "@aws-sdk/client-s3"
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
import { db } from "@repo/db"
import { channel, channelMember, guildMember } from "@repo/db/schema"
import { env } from "@repo/env/server"
import { and, eq } from "drizzle-orm"
import * as HttpStatusCodes from "@/lib/helpers/http/status-codes"
import { s3Client } from "@/lib/s3"
import type { AppRouteHandler } from "@/lib/types/app-types"
import type { PresignRoute } from "./routes"
import { PRESIGNED_URL_EXPIRY_SECONDS } from "./schema"

const DM_CHANNEL_TYPES = ["dm", "group_dm"] as const

export const presign: AppRouteHandler<PresignRoute> = async (c) => {
const user = c.var.user
const { channelId, filename, contentType, size } = c.req.valid("json")

if (size > env.MAX_FILE_UPLOAD_SIZE) {
return c.json(
{ success: false, message: "File too large" },
HttpStatusCodes.REQUEST_TOO_LONG
)
}
Comment on lines +19 to +24
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Consider using HTTP 413 for payload size violations.

Returning FORBIDDEN (403) for oversized files conflates authorization failures with payload issues. HTTP 413 PAYLOAD_TOO_LARGE is semantically correct here and helps clients distinguish between "you're not allowed" vs "your file is too big."

Suggested fix
   if (size > env.MAX_FILE_UPLOAD_SIZE) {
     return c.json(
       { success: false, message: "File too large" },
-      HttpStatusCodes.FORBIDDEN
+      HttpStatusCodes.CONTENT_TOO_LARGE
     )
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/routes/v1/uploads/handlers.ts` around lines 19 - 24, Replace the
incorrect 403 response for oversized uploads with the proper 413 status: locate
the branch that checks "if (size > env.MAX_FILE_UPLOAD_SIZE)" and change the
c.json response that currently uses HttpStatusCodes.FORBIDDEN to use
HttpStatusCodes.PAYLOAD_TOO_LARGE (and adjust the message to indicate the
payload is too large) so clients receive the correct HTTP 413 semantics for
payload size violations.


// Fetch the channel to determine access check strategy
const ch = await db
.select({ id: channel.id, guildId: channel.guildId, type: channel.type })
.from(channel)
.where(eq(channel.id, channelId))
.limit(1)
.then((rows) => rows[0])

if (!ch) {
return c.json(
{ success: false, message: "Forbidden" },
HttpStatusCodes.FORBIDDEN
)
}

// Guild channel — verify guild membership
if (ch.guildId) {
const member = await db
.select({ id: guildMember.id })
.from(guildMember)
.where(
and(
eq(guildMember.guildId, ch.guildId),
eq(guildMember.userId, user.id)
)
)
.limit(1)
.then((rows) => rows[0])

if (!member) {
return c.json(
{ success: false, message: "Forbidden" },
HttpStatusCodes.FORBIDDEN
)
}
} else if (
DM_CHANNEL_TYPES.includes(ch.type as (typeof DM_CHANNEL_TYPES)[number])
) {
// DM/group DM — verify channel membership
const member = await db
.select({ id: channelMember.id })
.from(channelMember)
.where(
and(
eq(channelMember.channelId, channelId),
eq(channelMember.userId, user.id)
)
)
.limit(1)
.then((rows) => rows[0])

if (!member) {
return c.json(
{ success: false, message: "Forbidden" },
HttpStatusCodes.FORBIDDEN
)
}
} else {
// Unknown channel type with no guild — reject
return c.json(
{ success: false, message: "Forbidden" },
HttpStatusCodes.FORBIDDEN
)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const fileId = crypto.randomUUID()
const sanitizedFilename = filename.replace(/[^a-zA-Z0-9._-]/g, "_")
const key = `attachments/${channelId}/${fileId}/${sanitizedFilename}`

const command = new PutObjectCommand({
Bucket: env.S3_BUCKET_NAME,
Key: key,
ContentType: contentType,
ContentLength: size,
})

const uploadUrl = await getSignedUrl(s3Client, command, {
expiresIn: PRESIGNED_URL_EXPIRY_SECONDS,
})

const fileUrl = `${env.S3_PUBLIC_URL.replace(/\/$/, "")}/${key}`

return c.json({ uploadUrl, fileUrl, key }, HttpStatusCodes.OK)
}
7 changes: 7 additions & 0 deletions apps/api/src/routes/v1/uploads/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { createRouter } from "@/lib/helpers/app/create-app"
import * as handlers from "./handlers"
import * as routes from "./routes"

const uploadsRouter = createRouter().openapi(routes.presign, handlers.presign)

export default uploadsRouter
39 changes: 39 additions & 0 deletions apps/api/src/routes/v1/uploads/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { createRoute } from "@hono/zod-openapi"
import * as HttpStatusCodes from "@/lib/helpers/http/status-codes"
import jsonContent from "@/lib/helpers/openapi/json-content"
import {
forbiddenSchema,
internalServerErrorSchema,
payloadTooLargeSchema,
unauthorizedSchema,
} from "@/lib/helpers/openapi/schemas"
import { sessionAuthMiddleware } from "@/middleware/session-auth"
import { presignRequestSchema, presignResponseSchema } from "./schema"

export const presign = createRoute({
path: "/uploads/presign",
method: "post",
summary: "Request a presigned upload URL",
description:
"Returns a presigned URL for direct upload to S3-compatible storage.",
tags: ["Uploads"],
middleware: [sessionAuthMiddleware] as const,
request: {
body: jsonContent({
schema: presignRequestSchema,
description: "File metadata for upload",
}),
},
responses: {
[HttpStatusCodes.OK]: jsonContent({
schema: presignResponseSchema,
description: "Presigned URL for upload",
}),
[HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema,
[HttpStatusCodes.FORBIDDEN]: forbiddenSchema,
[HttpStatusCodes.REQUEST_TOO_LONG]: payloadTooLargeSchema,
[HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema,
},
})

export type PresignRoute = typeof presign
Loading