@@ -34,7 +38,7 @@
{:else}
diff --git a/apps/server/src/db/messages.ts b/apps/server/src/db/messages.ts
index 696cc7e..45562ac 100644
--- a/apps/server/src/db/messages.ts
+++ b/apps/server/src/db/messages.ts
@@ -41,19 +41,32 @@ export const messages = pgTable(
updatedAt: timestamp("updated_at").defaultNow().notNull(),
editedAt: timestamp("edited_at"),
},
- (table) => [index("messages_channel_idx").on(table.channelId)],
+ (table) => [
+ index("messages_channel_idx").on(table.channelId),
+ index("messages_user_idx").on(table.userId),
+ index("messages_parent_idx").on(table.parentId),
+ index("messages_created_at_idx").on(table.createdAt),
+ index("messages_pinned_at_idx").on(table.pinnedAt),
+ ],
);
// Message attachments (junction table)
-export const messageAttachments = pgTable("message_attachments", {
- id: uuid("id").primaryKey().defaultRandom(),
- messageId: uuid("message_id")
- .notNull()
- .references(() => messages.id, { onDelete: "cascade" }),
- fileId: uuid("file_id")
- .notNull()
- .references(() => files.id, { onDelete: "cascade" }),
-});
+export const messageAttachments = pgTable(
+ "message_attachments",
+ {
+ id: uuid("id").primaryKey().defaultRandom(),
+ messageId: uuid("message_id")
+ .notNull()
+ .references(() => messages.id, { onDelete: "cascade" }),
+ fileId: uuid("file_id")
+ .notNull()
+ .references(() => files.id, { onDelete: "cascade" }),
+ },
+ (table) => [
+ index("message_attachments_message_idx").on(table.messageId),
+ index("message_attachments_file_idx").on(table.fileId),
+ ],
+);
// Reactions
export const reactions = pgTable(
diff --git a/apps/server/src/db/organizations.ts b/apps/server/src/db/organizations.ts
index 884ef8a..813ef5f 100644
--- a/apps/server/src/db/organizations.ts
+++ b/apps/server/src/db/organizations.ts
@@ -6,16 +6,20 @@ import { files } from "./files.ts";
import { personalizations } from "./personalizations.ts";
// Organizations
-export const organizations = pgTable("organizations", {
- id: uuid("id").primaryKey().defaultRandom(),
- name: text("name").notNull(),
- description: text("description"),
- ownerId: text("owner_id")
- .notNull()
- .references(() => users.id, { onDelete: "cascade" }),
- createdAt: timestamp("created_at").defaultNow().notNull(),
- updatedAt: timestamp("updated_at").defaultNow().notNull(),
-});
+export const organizations = pgTable(
+ "organizations",
+ {
+ id: uuid("id").primaryKey().defaultRandom(),
+ name: text("name").notNull(),
+ description: text("description"),
+ ownerId: text("owner_id")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
+ },
+ (table) => [index("organizations_owner_idx").on(table.ownerId)],
+);
// Organization members
export const organizationMembers = pgTable(
diff --git a/apps/server/src/db/personalizations.ts b/apps/server/src/db/personalizations.ts
index a10ceda..6fcfe6e 100644
--- a/apps/server/src/db/personalizations.ts
+++ b/apps/server/src/db/personalizations.ts
@@ -1,18 +1,29 @@
-import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
+import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { users } from "./auth.ts";
import { organizations } from "./organizations.ts";
// Personalization
-export const personalizations = pgTable("personalizations", {
- id: uuid("id").primaryKey().defaultRandom(),
- userId: text("user_id")
- .notNull()
- .references(() => users.id, { onDelete: "cascade" }),
- organizationId: uuid("organization_id")
- .notNull()
- .references(() => organizations.id, { onDelete: "cascade" }),
- nickname: text("nickname").notNull(),
- icon: text("icon"),
- createdAt: timestamp("created_at").defaultNow().notNull(),
- updatedAt: timestamp("updated_at").defaultNow().notNull(),
-});
+export const personalizations = pgTable(
+ "personalizations",
+ {
+ id: uuid("id").primaryKey().defaultRandom(),
+ userId: text("user_id")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ organizationId: uuid("organization_id")
+ .notNull()
+ .references(() => organizations.id, { onDelete: "cascade" }),
+ nickname: text("nickname").notNull(),
+ icon: text("icon"),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
+ },
+ (table) => [
+ index("personalizations_user_idx").on(table.userId),
+ index("personalizations_org_idx").on(table.organizationId),
+ index("personalizations_user_org_idx").on(
+ table.userId,
+ table.organizationId,
+ ),
+ ],
+);
diff --git a/apps/server/src/domains/channels/routes.ts b/apps/server/src/domains/channels/routes.ts
index 67b4b40..706bf7b 100644
--- a/apps/server/src/domains/channels/routes.ts
+++ b/apps/server/src/domains/channels/routes.ts
@@ -2,80 +2,91 @@ import { desc, eq } from "drizzle-orm";
import { Elysia, t } from "elysia";
import { db } from "../../db/index.ts";
import { channels } from "../../db/schema.ts";
+import {
+ BadRequestError,
+ ForbiddenError,
+ handleError,
+ NotFoundError,
+ UnauthorizedError,
+} from "../../lib/errors.ts";
import { authMiddleware } from "../../middleware/auth.ts";
import { getOrganizationPermissions } from "../organizations/permissions.ts";
export const channelRoutes = new Elysia({ prefix: "/channels" })
.use(authMiddleware)
.get("/", async ({ user, query, set }) => {
- if (!user) {
- set.status = 401;
- return { message: "Unauthorized" };
- }
- if (!query.organizationId) {
- set.status = 400;
- return { message: "organizationId is required" };
- }
+ try {
+ if (!user) throw new UnauthorizedError();
+ if (!query.organizationId) {
+ throw new BadRequestError(
+ "organizationId is required",
+ "MISSING_ORGANIZATION_ID",
+ );
+ }
- await getOrganizationPermissions(user.id, query.organizationId);
+ await getOrganizationPermissions(user.id, query.organizationId);
- const channelList = await db
- .select()
- .from(channels)
- .where(eq(channels.organizationId, query.organizationId))
- .orderBy(desc(channels.createdAt));
+ const channelList = await db
+ .select()
+ .from(channels)
+ .where(eq(channels.organizationId, query.organizationId))
+ .orderBy(desc(channels.createdAt));
- return channelList;
+ return channelList;
+ } catch (error) {
+ return handleError(error, set);
+ }
})
.get("/:id", async ({ user, params, set }) => {
- if (!user) {
- set.status = 401;
- return { message: "Unauthorized" };
- }
+ try {
+ if (!user) throw new UnauthorizedError();
- const [channel] = await db
- .select()
- .from(channels)
- .where(eq(channels.id, params.id))
- .limit(1);
+ const [channel] = await db
+ .select()
+ .from(channels)
+ .where(eq(channels.id, params.id))
+ .limit(1);
- if (!channel) {
- set.status = 404;
- return { message: "Channel not found" };
- }
+ if (!channel) throw new NotFoundError("Channel", "CHANNEL_NOT_FOUND");
- await getOrganizationPermissions(user.id, channel.organizationId);
+ await getOrganizationPermissions(user.id, channel.organizationId);
- return channel;
+ return channel;
+ } catch (error) {
+ return handleError(error, set);
+ }
})
.post(
"/",
async ({ user, body, set }) => {
- if (!user) {
- set.status = 401;
- return { message: "Unauthorized" };
- }
+ try {
+ if (!user) throw new UnauthorizedError();
- const perms = await getOrganizationPermissions(
- user.id,
- body.organizationId,
- );
+ const perms = await getOrganizationPermissions(
+ user.id,
+ body.organizationId,
+ );
- if (!perms.canCreateChannels) {
- set.status = 403;
- return { message: "Insufficient permissions" };
- }
+ if (!perms.canCreateChannels) {
+ throw new ForbiddenError(
+ "Insufficient permissions",
+ "CANNOT_CREATE_CHANNEL",
+ );
+ }
- const [channel] = await db
- .insert(channels)
- .values({
- name: body.name,
- description: body.description,
- organizationId: body.organizationId,
- })
- .returning();
+ const [channel] = await db
+ .insert(channels)
+ .values({
+ name: body.name,
+ description: body.description,
+ organizationId: body.organizationId,
+ })
+ .returning();
- return channel;
+ return channel;
+ } catch (error) {
+ return handleError(error, set);
+ }
},
{
body: t.Object({
diff --git a/apps/server/src/domains/files/validation.test.ts b/apps/server/src/domains/files/validation.test.ts
new file mode 100644
index 0000000..0ed8511
--- /dev/null
+++ b/apps/server/src/domains/files/validation.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, test } from "bun:test";
+import { sanitizeFilename } from "./validation.ts";
+
+describe("sanitizeFilename security", () => {
+ test("blocks path traversal attempts", () => {
+ expect(() => sanitizeFilename("..")).toThrow(
+ "Filename cannot contain '..'",
+ );
+ expect(() => sanitizeFilename("../etc/passwd")).toThrow(
+ "Filename cannot contain '..'",
+ );
+ expect(() => sanitizeFilename("foo/../bar")).toThrow(
+ "Filename cannot contain '..'",
+ );
+ });
+
+ test("blocks hidden files", () => {
+ expect(() => sanitizeFilename(".env")).toThrow(
+ "Filename cannot start with '.'",
+ );
+ expect(() => sanitizeFilename(".git")).toThrow(
+ "Filename cannot start with '.'",
+ );
+ });
+
+ test("blocks empty filenames", () => {
+ expect(() => sanitizeFilename("")).toThrow("Filename cannot be empty");
+ expect(() => sanitizeFilename(" ")).toThrow("Filename cannot be empty");
+ });
+
+ test("allows valid filenames", () => {
+ expect(() => sanitizeFilename("document.pdf")).not.toThrow();
+ expect(() => sanitizeFilename("my-file_123.txt")).not.toThrow();
+ expect(() => sanitizeFilename("日本語ファイル.txt")).not.toThrow();
+ });
+
+ test("sanitizes special characters", () => {
+ expect(sanitizeFilename("file/name.txt")).toBe("file_name.txt");
+ expect(sanitizeFilename("file\\name.txt")).toBe("file_name.txt");
+ });
+
+ test("limits filename length", () => {
+ const longName = `${"a".repeat(300)}.txt`;
+ expect(sanitizeFilename(longName).length).toBe(255);
+ });
+});
diff --git a/apps/server/src/domains/files/validation.ts b/apps/server/src/domains/files/validation.ts
index 50bbcf2..a77b0eb 100644
--- a/apps/server/src/domains/files/validation.ts
+++ b/apps/server/src/domains/files/validation.ts
@@ -39,9 +39,25 @@ export function validateFile(size: number, mimeType: string): string | null {
/**
* Sanitizes filename to prevent security issues.
+ * Blocks path traversal attempts and hidden files.
* Removes special characters and limits length.
*/
export function sanitizeFilename(filename: string): string {
+ // Block path traversal attempts
+ if (filename.includes("..")) {
+ throw new Error("Filename cannot contain '..'");
+ }
+
+ // Block hidden files
+ if (filename.startsWith(".")) {
+ throw new Error("Filename cannot start with '.'");
+ }
+
+ // Block empty or whitespace-only filenames
+ if (!filename.trim()) {
+ throw new Error("Filename cannot be empty");
+ }
+
return filename
.replace(/[^a-zA-Z0-9\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF._-]/g, "_")
.substring(0, 255);
diff --git a/apps/server/src/domains/messages/create.routes.ts b/apps/server/src/domains/messages/create.routes.ts
index e38d2e7..8c2167e 100644
--- a/apps/server/src/domains/messages/create.routes.ts
+++ b/apps/server/src/domains/messages/create.routes.ts
@@ -2,6 +2,12 @@ import { eq } from "drizzle-orm";
import { Elysia, t } from "elysia";
import { db } from "../../db/index.ts";
import { channels, messageAttachments, messages } from "../../db/schema.ts";
+import {
+ handleError,
+ InternalServerError,
+ NotFoundError,
+ UnauthorizedError,
+} from "../../lib/errors.ts";
import { authMiddleware } from "../../middleware/auth.ts";
import { wsManager } from "../../ws/manager.ts";
import { requireOrganizationMembership } from "../organizations/permissions.ts";
@@ -12,59 +18,63 @@ import { requireOrganizationMembership } from "../organizations/permissions.ts";
export const messageCreateRoutes = new Elysia().use(authMiddleware).post(
"/",
async ({ user, body, set }) => {
- if (!user) {
- set.status = 401;
- return { message: "Unauthorized" };
- }
+ try {
+ if (!user) throw new UnauthorizedError();
- const [channel] = await db
- .select()
- .from(channels)
- .where(eq(channels.id, body.channelId))
- .limit(1);
+ const [channel] = await db
+ .select()
+ .from(channels)
+ .where(eq(channels.id, body.channelId))
+ .limit(1);
- if (!channel) {
- set.status = 404;
- return { message: "Channel not found" };
- }
+ if (!channel) throw new NotFoundError("Channel", "CHANNEL_NOT_FOUND");
- await requireOrganizationMembership(user.id, channel.organizationId);
+ await requireOrganizationMembership(user.id, channel.organizationId);
- const [message] = await db
- .insert(messages)
- .values({
- channelId: body.channelId,
- content: body.content,
- author: body.author,
- userId: user.id,
- parentId: body.parentId,
- voteId: body.voteId,
- })
- .returning();
+ const [message] = await db
+ .insert(messages)
+ .values({
+ channelId: body.channelId,
+ content: body.content,
+ author: user.name || user.email,
+ userId: user.id,
+ parentId: body.parentId,
+ voteId: body.voteId,
+ })
+ .returning();
- // Handle attachments
- if (body.attachments && body.attachments.length > 0 && message) {
- await db.insert(messageAttachments).values(
- body.attachments.map((fileId: string) => ({
- messageId: message.id,
- fileId,
- })),
- );
- }
+ if (!message) {
+ throw new InternalServerError(
+ "Failed to create message",
+ "MESSAGE_CREATE_FAILED",
+ );
+ }
- wsManager.broadcast(body.channelId, {
- type: "message:created",
- channelId: body.channelId,
- message,
- });
+ // Handle attachments
+ if (body.attachments && body.attachments.length > 0) {
+ await db.insert(messageAttachments).values(
+ body.attachments.map((fileId: string) => ({
+ messageId: message.id,
+ fileId,
+ })),
+ );
+ }
- return message;
+ wsManager.broadcast(body.channelId, {
+ type: "message:created",
+ channelId: body.channelId,
+ message,
+ });
+
+ return message;
+ } catch (error) {
+ return handleError(error, set);
+ }
},
{
body: t.Object({
channelId: t.String(),
content: t.String(),
- author: t.String(),
parentId: t.Optional(t.String()),
attachments: t.Optional(t.Array(t.String())),
voteId: t.Optional(t.String()),
diff --git a/apps/server/src/domains/messages/delete.routes.ts b/apps/server/src/domains/messages/delete.routes.ts
index 33f4b28..62bd861 100644
--- a/apps/server/src/domains/messages/delete.routes.ts
+++ b/apps/server/src/domains/messages/delete.routes.ts
@@ -2,6 +2,12 @@ import { eq } from "drizzle-orm";
import { Elysia, t } from "elysia";
import { db } from "../../db/index.ts";
import { channels, messages } from "../../db/schema.ts";
+import {
+ ForbiddenError,
+ handleError,
+ NotFoundError,
+ UnauthorizedError,
+} from "../../lib/errors.ts";
import { authMiddleware } from "../../middleware/auth.ts";
import { wsManager } from "../../ws/manager.ts";
import { requireOrganizationMembership } from "../organizations/permissions.ts";
@@ -12,49 +18,46 @@ import { requireOrganizationMembership } from "../organizations/permissions.ts";
export const messageDeleteRoutes = new Elysia().use(authMiddleware).delete(
"/:id",
async ({ user, params, set }) => {
- if (!user) {
- set.status = 401;
- return { message: "Unauthorized" };
- }
+ try {
+ if (!user) throw new UnauthorizedError();
- const [message] = await db
- .select()
- .from(messages)
- .where(eq(messages.id, params.id))
- .limit(1);
+ const [message] = await db
+ .select()
+ .from(messages)
+ .where(eq(messages.id, params.id))
+ .limit(1);
- if (!message) {
- set.status = 404;
- return { message: "Message not found" };
- }
+ if (!message) throw new NotFoundError("Message", "MESSAGE_NOT_FOUND");
- if (message.userId !== user.id) {
- set.status = 403;
- return { message: "Forbidden: You can only delete your own messages" };
- }
+ if (message.userId !== user.id) {
+ throw new ForbiddenError(
+ "You can only delete your own messages",
+ "CANNOT_DELETE_MESSAGE",
+ );
+ }
- const [channel] = await db
- .select()
- .from(channels)
- .where(eq(channels.id, message.channelId))
- .limit(1);
+ const [channel] = await db
+ .select()
+ .from(channels)
+ .where(eq(channels.id, message.channelId))
+ .limit(1);
- if (!channel) {
- set.status = 404;
- return { message: "Channel not found" };
- }
+ if (!channel) throw new NotFoundError("Channel", "CHANNEL_NOT_FOUND");
- await requireOrganizationMembership(user.id, channel.organizationId);
+ await requireOrganizationMembership(user.id, channel.organizationId);
- await db.delete(messages).where(eq(messages.id, params.id));
+ await db.delete(messages).where(eq(messages.id, params.id));
- wsManager.broadcast(message.channelId, {
- type: "message:deleted",
- channelId: message.channelId,
- messageId: params.id,
- });
+ wsManager.broadcast(message.channelId, {
+ type: "message:deleted",
+ channelId: message.channelId,
+ messageId: params.id,
+ });
- return { success: true };
+ return { success: true };
+ } catch (error) {
+ return handleError(error, set);
+ }
},
{
params: t.Object({
diff --git a/apps/server/src/domains/messages/reactions.ts b/apps/server/src/domains/messages/reactions.ts
index e3ec868..f776bdb 100644
--- a/apps/server/src/domains/messages/reactions.ts
+++ b/apps/server/src/domains/messages/reactions.ts
@@ -58,6 +58,11 @@ export const messageReactionRoutes = new Elysia()
})
.returning();
+ if (!reaction) {
+ set.status = 500;
+ return { message: "Failed to create reaction" };
+ }
+
// Broadcast reaction:added event
const [message] = await db
.select({ channelId: messages.channelId })
diff --git a/apps/server/src/domains/messages/search.routes.test.ts b/apps/server/src/domains/messages/search.routes.test.ts
new file mode 100644
index 0000000..7e871da
--- /dev/null
+++ b/apps/server/src/domains/messages/search.routes.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, test } from "bun:test";
+import { escapeLikePattern } from "./search.routes.ts";
+
+describe("escapeLikePattern", () => {
+ test("escapes % wildcard", () => {
+ expect(escapeLikePattern("100%")).toBe("100\\%");
+ });
+
+ test("escapes _ wildcard", () => {
+ expect(escapeLikePattern("user_name")).toBe("user\\_name");
+ });
+
+ test("escapes backslash", () => {
+ expect(escapeLikePattern("path\\to\\file")).toBe("path\\\\to\\\\file");
+ });
+
+ test("escapes multiple special chars", () => {
+ expect(escapeLikePattern("50%_discount\\sale")).toBe(
+ "50\\%\\_discount\\\\sale",
+ );
+ });
+
+ test("normal text unchanged", () => {
+ expect(escapeLikePattern("hello world")).toBe("hello world");
+ });
+});
diff --git a/apps/server/src/domains/messages/search.routes.ts b/apps/server/src/domains/messages/search.routes.ts
index e056915..a4ba364 100644
--- a/apps/server/src/domains/messages/search.routes.ts
+++ b/apps/server/src/domains/messages/search.routes.ts
@@ -5,6 +5,17 @@ import { channels, messages, users } from "../../db/schema.ts";
import { authMiddleware } from "../../middleware/auth.ts";
import { requireOrganizationMembership } from "../organizations/permissions.ts";
+/**
+ * Escapes special characters in LIKE pattern (%, _, \).
+ * Prevents SQL injection by treating user input as literal text.
+ */
+export function escapeLikePattern(pattern: string): string {
+ return pattern
+ .replace(/\\/g, "\\\\")
+ .replace(/%/g, "\\%")
+ .replace(/_/g, "\\_");
+}
+
/**
* Handles message search operations.
* Provides endpoint to search messages within a channel.
@@ -49,7 +60,7 @@ export const messageSearchRoutes = new Elysia().use(authMiddleware).get(
.where(
and(
eq(messages.channelId, query.channelId),
- ilike(messages.content, `%${query.q}%`),
+ ilike(messages.content, `%${escapeLikePattern(query.q)}%`),
),
)
.orderBy(asc(messages.createdAt))
diff --git a/apps/server/src/domains/messages/update.routes.ts b/apps/server/src/domains/messages/update.routes.ts
index a2bb7a9..60b684e 100644
--- a/apps/server/src/domains/messages/update.routes.ts
+++ b/apps/server/src/domains/messages/update.routes.ts
@@ -56,6 +56,11 @@ export const messageUpdateRoutes = new Elysia().use(authMiddleware).put(
.where(eq(messages.id, params.id))
.returning();
+ if (!updatedMessage) {
+ set.status = 500;
+ return { message: "Failed to update message" };
+ }
+
wsManager.broadcast(message.channelId, {
type: "message:updated",
channelId: message.channelId,
diff --git a/apps/server/src/domains/organizations/permissions.ts b/apps/server/src/domains/organizations/permissions.ts
index 30987ab..80e2b81 100644
--- a/apps/server/src/domains/organizations/permissions.ts
+++ b/apps/server/src/domains/organizations/permissions.ts
@@ -1,6 +1,7 @@
import { and, eq } from "drizzle-orm";
import { db } from "../../db/index.ts";
import { organizationMembers, organizations } from "../../db/schema.ts";
+import { ForbiddenError, NotFoundError } from "../../lib/errors.ts";
export async function getOrganizationPermissions(
userId: string,
@@ -13,7 +14,7 @@ export async function getOrganizationPermissions(
.limit(1);
if (!org) {
- throw new Error("Organization not found");
+ throw new NotFoundError("Organization", "ORGANIZATION_NOT_FOUND");
}
const [membership] = await db
@@ -28,7 +29,10 @@ export async function getOrganizationPermissions(
.limit(1);
if (!membership) {
- throw new Error("User is not a member of the organization");
+ throw new ForbiddenError(
+ "User is not a member of the organization",
+ "NOT_ORGANIZATION_MEMBER",
+ );
}
const isAdmin = membership.permission === "admin";
@@ -62,7 +66,10 @@ export async function requireOrganizationMembership(
.limit(1);
if (!membership) {
- throw new Error("User is not a member of the organization");
+ throw new ForbiddenError(
+ "User is not a member of the organization",
+ "NOT_ORGANIZATION_MEMBER",
+ );
}
return membership;
diff --git a/apps/server/src/domains/tasks/routes.ts b/apps/server/src/domains/tasks/routes.ts
index 9ef7a09..d1317e4 100644
--- a/apps/server/src/domains/tasks/routes.ts
+++ b/apps/server/src/domains/tasks/routes.ts
@@ -12,7 +12,10 @@ export const taskRoutes = new Elysia({ prefix: "/tasks" })
return { message: "Unauthorized" };
}
- const taskList = await db.select().from(tasks);
+ const taskList = await db
+ .select()
+ .from(tasks)
+ .where(eq(tasks.assigner, user.email));
return taskList;
})
@@ -50,6 +53,22 @@ export const taskRoutes = new Elysia({ prefix: "/tasks" })
return { message: "Unauthorized" };
}
+ // Check if task exists and belongs to current user
+ const [existingTask] = await db
+ .select()
+ .from(tasks)
+ .where(eq(tasks.id, params.id));
+
+ if (!existingTask) {
+ set.status = 404;
+ return { message: "Task not found" };
+ }
+
+ if (existingTask.assigner !== user.email) {
+ set.status = 403;
+ return { message: "Forbidden: You can only update your own tasks" };
+ }
+
const updateData: {
text?: string;
isCompleted?: boolean;
diff --git a/apps/server/src/domains/users/routes.ts b/apps/server/src/domains/users/routes.ts
index fdd55d1..106a527 100644
--- a/apps/server/src/domains/users/routes.ts
+++ b/apps/server/src/domains/users/routes.ts
@@ -60,15 +60,20 @@ export const userRoutes = new Elysia({ prefix: "/users" })
)
.post(
"/names",
- async ({ body }) => {
+ async ({ user, body, set }) => {
+ if (!user) {
+ set.status = 401;
+ return { message: "Unauthorized" };
+ }
+
const userList = await db
.select()
.from(users)
.where(inArray(users.id, body.userIds));
const userNames: Record
= {};
- for (const user of userList) {
- userNames[user.id] = user.name || "";
+ for (const dbUser of userList) {
+ userNames[dbUser.id] = dbUser.name || "";
}
return userNames;
@@ -81,7 +86,12 @@ export const userRoutes = new Elysia({ prefix: "/users" })
)
.post(
"/nicknames",
- async ({ body }) => {
+ async ({ user, body, set }) => {
+ if (!user) {
+ set.status = 401;
+ return { message: "Unauthorized" };
+ }
+
const userList = await db
.select()
.from(users)
@@ -98,9 +108,9 @@ export const userRoutes = new Elysia({ prefix: "/users" })
);
const userNicknames: Record = {};
- for (const user of userList) {
- const p = personalizationList.find((p) => p.userId === user.id);
- userNicknames[user.id] = p?.nickname || user.name || "";
+ for (const dbUser of userList) {
+ const p = personalizationList.find((p) => p.userId === dbUser.id);
+ userNicknames[dbUser.id] = p?.nickname || dbUser.name || "";
}
return userNicknames;
@@ -112,7 +122,12 @@ export const userRoutes = new Elysia({ prefix: "/users" })
}),
},
)
- .get("/search", async ({ query }) => {
+ .get("/search", async ({ user, query, set }) => {
+ if (!user) {
+ set.status = 401;
+ return { message: "Unauthorized" };
+ }
+
if (!query.email) return [];
const userList = await db
diff --git a/apps/server/src/domains/votes/routes.ts b/apps/server/src/domains/votes/routes.ts
index 1040a80..99e0dac 100644
--- a/apps/server/src/domains/votes/routes.ts
+++ b/apps/server/src/domains/votes/routes.ts
@@ -47,9 +47,12 @@ export const voteRoutes = new Elysia({ prefix: "/votes" })
},
{
body: t.Object({
- title: t.String(),
- maxVotes: t.Number(),
- voteOptions: t.Array(t.String()),
+ title: t.String({ minLength: 1, maxLength: 200 }),
+ maxVotes: t.Number({ minimum: 1 }),
+ voteOptions: t.Array(t.String({ minLength: 1 }), {
+ minItems: 2,
+ maxItems: 20,
+ }),
}),
},
)
@@ -72,6 +75,15 @@ export const voteRoutes = new Elysia({ prefix: "/votes" })
return { message: "Vote not found" };
}
+ // Validate votedOptions indices are within bounds
+ const invalidIndices = body.votedOptions.filter(
+ (index) => index < 0 || index >= vote.voteOptions.length,
+ );
+ if (invalidIndices.length > 0) {
+ set.status = 400;
+ return { message: "Invalid vote option indices" };
+ }
+
// Check if user already voted
const existingVoterIndex = vote.voters.findIndex(
(v) => v.userId === user.id,
diff --git a/apps/server/src/lib/errors.ts b/apps/server/src/lib/errors.ts
new file mode 100644
index 0000000..94ae4f0
--- /dev/null
+++ b/apps/server/src/lib/errors.ts
@@ -0,0 +1,108 @@
+/**
+ * 標準的なAPIエラーレスポンスとエラークラス
+ */
+
+/**
+ * APIエラーレスポンスの標準フォーマット
+ */
+export interface ApiErrorResponse {
+ message: string;
+ code?: string;
+ details?: unknown;
+}
+
+/**
+ * HTTPエラーを表す基底クラス
+ */
+export class HttpError extends Error {
+ constructor(
+ public readonly status: number,
+ message: string,
+ public readonly code?: string,
+ public readonly details?: unknown,
+ ) {
+ super(message);
+ this.name = "HttpError";
+ }
+
+ toResponse(): ApiErrorResponse {
+ return {
+ message: this.message,
+ code: this.code,
+ details: this.details,
+ };
+ }
+}
+
+/**
+ * 400 Bad Request
+ */
+export class BadRequestError extends HttpError {
+ constructor(message: string, code?: string, details?: unknown) {
+ super(400, message, code, details);
+ this.name = "BadRequestError";
+ }
+}
+
+/**
+ * 401 Unauthorized
+ */
+export class UnauthorizedError extends HttpError {
+ constructor(message = "Unauthorized", code?: string) {
+ super(401, message, code);
+ this.name = "UnauthorizedError";
+ }
+}
+
+/**
+ * 403 Forbidden
+ */
+export class ForbiddenError extends HttpError {
+ constructor(message: string, code?: string) {
+ super(403, message, code);
+ this.name = "ForbiddenError";
+ }
+}
+
+/**
+ * 404 Not Found
+ */
+export class NotFoundError extends HttpError {
+ constructor(resource: string, code?: string) {
+ super(404, `${resource} not found`, code);
+ this.name = "NotFoundError";
+ }
+}
+
+/**
+ * 500 Internal Server Error
+ */
+export class InternalServerError extends HttpError {
+ constructor(message = "Internal server error", code?: string) {
+ super(500, message, code);
+ this.name = "InternalServerError";
+ }
+}
+
+/**
+ * HttpErrorをハンドリングしてElysiaレスポンスに変換
+ */
+export function handleError(
+ error: unknown,
+ set: { status?: number | string },
+): ApiErrorResponse {
+ if (error instanceof HttpError) {
+ set.status = error.status;
+ return error.toResponse();
+ }
+
+ // 通常のErrorの場合
+ if (error instanceof Error) {
+ set.status = 500;
+ return { message: error.message };
+ }
+
+ // 不明なエラー
+ set.status = 500;
+ return { message: "An unknown error occurred" };
+}
diff --git a/apps/server/src/ws/index.ts b/apps/server/src/ws/index.ts
index 2ed25d7..76f2f55 100644
--- a/apps/server/src/ws/index.ts
+++ b/apps/server/src/ws/index.ts
@@ -1,4 +1,8 @@
+import { eq } from "drizzle-orm";
import { Elysia, t } from "elysia";
+import { db } from "../db/index.ts";
+import { channels } from "../db/schema.ts";
+import { requireOrganizationMembership } from "../domains/organizations/permissions.ts";
import { authMiddleware } from "../middleware/auth.ts";
import { wsManager } from "./manager.ts";
import type { WsConnection } from "./types.ts";
@@ -13,20 +17,44 @@ const wsClientMessage = t.Union([
t.Object({ type: t.Literal("ping") }),
]);
+const wsMessage = t.Object({
+ id: t.String(),
+ channelId: t.String(),
+ content: t.String(),
+ author: t.String(),
+ userId: t.String(),
+ parentId: t.Nullable(t.String()),
+ voteId: t.Nullable(t.String()),
+ pinnedAt: t.Nullable(t.Date()),
+ pinnedBy: t.Nullable(t.String()),
+ createdAt: t.Date(),
+ updatedAt: t.Date(),
+ editedAt: t.Nullable(t.Date()),
+});
+
+const wsReaction = t.Object({
+ id: t.String(),
+ messageId: t.String(),
+ userId: t.String(),
+ emoji: t.String(),
+ createdAt: t.Date(),
+});
+
const wsServerMessage = t.Union([
t.Object({ type: t.Literal("subscribed"), channelId: t.String() }),
t.Object({ type: t.Literal("unsubscribed"), channelId: t.String() }),
t.Object({ type: t.Literal("pong") }),
+ t.Object({ type: t.Literal("error"), message: t.String() }),
t.Object({
type: t.Literal("message:created"),
channelId: t.String(),
- message: t.Unknown(),
+ message: wsMessage,
}),
t.Object({
type: t.Literal("message:updated"),
channelId: t.String(),
messageId: t.String(),
- message: t.Unknown(),
+ message: wsMessage,
}),
t.Object({
type: t.Literal("message:deleted"),
@@ -37,7 +65,7 @@ const wsServerMessage = t.Union([
type: t.Literal("reaction:added"),
channelId: t.String(),
messageId: t.String(),
- reaction: t.Unknown(),
+ reaction: wsReaction,
}),
t.Object({
type: t.Literal("reaction:removed"),
@@ -69,17 +97,40 @@ export const wsRoutes = new Elysia().use(authMiddleware).ws("/ws", {
ws.subscribe("global");
},
- message(ws, msg) {
+ async message(ws, msg) {
const user = ws.data.user;
if (!user) return;
if (msg.type === "subscribe") {
const connections = Array.from(wsManager.connections.values());
const conn = connections.find((c) => c.user.id === user.id);
- if (conn) {
- wsManager.subscribe(conn.id, msg.channelId);
- ws.send({ type: "subscribed", channelId: msg.channelId });
+ if (!conn) return;
+
+ // Lookup channel to verify organization membership
+ const [channel] = await db
+ .select()
+ .from(channels)
+ .where(eq(channels.id, msg.channelId))
+ .limit(1);
+
+ if (!channel) {
+ ws.send({ type: "error", message: "Channel not found" });
+ return;
}
+
+ // Verify user is a member of the channel's organization
+ try {
+ await requireOrganizationMembership(user.id, channel.organizationId);
+ } catch {
+ ws.send({
+ type: "error",
+ message: "Not authorized to access this channel",
+ });
+ return;
+ }
+
+ wsManager.subscribe(conn.id, msg.channelId);
+ ws.send({ type: "subscribed", channelId: msg.channelId });
} else if (msg.type === "unsubscribe") {
const connections = Array.from(wsManager.connections.values());
const conn = connections.find((c) => c.user.id === user.id);
diff --git a/apps/server/src/ws/manager.ts b/apps/server/src/ws/manager.ts
index f4ee9f1..d3c443c 100644
--- a/apps/server/src/ws/manager.ts
+++ b/apps/server/src/ws/manager.ts
@@ -1,3 +1,7 @@
+import { eq } from "drizzle-orm";
+import { db } from "../db/index.ts";
+import { channels } from "../db/schema.ts";
+import { requireOrganizationMembership } from "../domains/organizations/permissions.ts";
import type {
WsBroadcastEvent,
WsConnection,
@@ -39,12 +43,31 @@ export class WsManager {
/**
* Subscribes a connection to a channel.
+ * Verifies user has permission (is member of the organization owning the channel).
+ * @returns true if subscribed successfully, false if permission denied
*/
- subscribe(connectionId: string, channelId: string) {
+ async subscribe(connectionId: string, channelId: string): Promise {
const conn = this.connections.get(connectionId);
- if (conn) {
- conn.channels.add(channelId);
+ if (!conn) return false;
+
+ // Fetch channel to get organization ID
+ const [channel] = await db
+ .select()
+ .from(channels)
+ .where(eq(channels.id, channelId))
+ .limit(1);
+
+ if (!channel) return false;
+
+ // Verify user is member of the organization
+ try {
+ await requireOrganizationMembership(conn.user.id, channel.organizationId);
+ } catch {
+ return false;
}
+
+ conn.channels.add(channelId);
+ return true;
}
/**
diff --git a/apps/server/src/ws/types.ts b/apps/server/src/ws/types.ts
index 9ec7c92..ffdf394 100644
--- a/apps/server/src/ws/types.ts
+++ b/apps/server/src/ws/types.ts
@@ -1,30 +1,60 @@
import type { AuthUser } from "../middleware/auth.ts";
+/**
+ * Message object sent in WebSocket events.
+ */
+export interface WsMessage {
+ id: string;
+ channelId: string;
+ content: string;
+ author: string;
+ userId: string;
+ parentId: string | null;
+ voteId: string | null;
+ pinnedAt: Date | null;
+ pinnedBy: string | null;
+ createdAt: Date;
+ updatedAt: Date;
+ editedAt: Date | null;
+}
+
+/**
+ * Reaction object sent in WebSocket events.
+ */
+export interface WsReaction {
+ id: string;
+ messageId: string;
+ userId: string;
+ emoji: string;
+ createdAt: Date;
+}
+
/**
* Control messages sent to specific clients.
*/
export type WsControlMessage =
| { type: "subscribed"; channelId: string }
| { type: "unsubscribed"; channelId: string }
- | { type: "pong" };
+ | { type: "pong" }
+ | { type: "error"; message: string };
/**
* Broadcast events sent to channel subscribers.
*/
export type WsBroadcastEvent =
- | { type: "message:created"; channelId: string; message: unknown }
+ | { type: "message:created"; channelId: string; message: WsMessage }
| {
type: "message:updated";
channelId: string;
messageId: string;
- message: unknown;
+ message: WsMessage;
}
| { type: "message:deleted"; channelId: string; messageId: string }
| {
type: "reaction:added";
channelId: string;
messageId: string;
- reaction: unknown;
+ reaction: WsReaction;
}
| {
type: "reaction:removed";
diff --git a/packages/api-client/src/route-helpers.ts b/packages/api-client/src/route-helpers.ts
index 9efbf34..0f38d04 100644
--- a/packages/api-client/src/route-helpers.ts
+++ b/packages/api-client/src/route-helpers.ts
@@ -4,7 +4,6 @@ import type {
FileRoute,
MessagesRoute,
OrganizationRoute,
- TaskRoute,
VoteRoute,
} from "./route-types.ts";
@@ -13,35 +12,33 @@ import type {
* Eden Treaty uses Proxy to enable dynamic route access at runtime.
*/
+type DynamicIndex = Record;
+
export function getOrganization(
client: ApiClient,
id: string,
): OrganizationRoute {
- // @ts-expect-error - Eden Treaty Proxy allows dynamic property access
- return client.organizations[id];
+ return (client.organizations as unknown as DynamicIndex)[
+ id
+ ] as OrganizationRoute;
}
export function getChannel(client: ApiClient, id: string): ChannelRoute {
- // @ts-expect-error - Eden Treaty Proxy allows dynamic property access
- return client.channels[id];
+ return (client.channels as unknown as DynamicIndex)[
+ id
+ ] as ChannelRoute;
}
export function getMessage(client: ApiClient, id: string): MessagesRoute {
- // @ts-expect-error - Eden Treaty Proxy allows dynamic property access
- return client.messages[id];
+ return (client.messages as unknown as DynamicIndex)[
+ id
+ ] as MessagesRoute;
}
export function getVote(client: ApiClient, id: string): VoteRoute {
- // @ts-expect-error - Eden Treaty Proxy allows dynamic property access
- return client.votes[id];
-}
-
-export function getTask(client: ApiClient, id: string): TaskRoute {
- // @ts-expect-error - Eden Treaty Proxy allows dynamic property access
- return client.tasks[id];
+ return (client.votes as unknown as DynamicIndex)[id] as VoteRoute;
}
export function getFile(client: ApiClient, id: string): FileRoute {
- // @ts-expect-error - Eden Treaty Proxy allows dynamic property access
- return client.files[id];
+ return (client.files as unknown as DynamicIndex)[id] as FileRoute;
}
diff --git a/packages/api-client/src/types.ts b/packages/api-client/src/types.ts
index 4d6942d..801e9e3 100644
--- a/packages/api-client/src/types.ts
+++ b/packages/api-client/src/types.ts
@@ -1,5 +1,14 @@
// Type definitions for API responses
+/**
+ * 標準的なAPIエラーレスポンス
+ */
+export interface ApiErrorResponse {
+ message: string;
+ code?: string;
+ details?: unknown;
+}
+
export interface User {
id: string;
email?: string | null;