Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "isInstanceAdmin" BOOLEAN NOT NULL DEFAULT false;
7 changes: 4 additions & 3 deletions packages/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ model User {
jobTitle String? @db.VarChar(100)
phone String? @db.VarChar(30)
timezone String? @db.VarChar(50)
emailVerified Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
emailVerified Boolean @default(false)
isInstanceAdmin Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

workspaces WorkspaceMember[]
projectMemberships ProjectMember[]
Expand Down
1 change: 1 addition & 0 deletions packages/backend/prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ async function main() {
passwordHash,
displayName: "Alice Johnson",
emailVerified: true,
isInstanceAdmin: true,
},
}),
prisma.user.create({
Expand Down
18 changes: 6 additions & 12 deletions packages/backend/src/middleware/instance-admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,12 @@ export async function instanceAdmin(
throw new ForbiddenError("Authentication required");
}

const adminMembership = await request.server.prisma.workspaceMember.findFirst(
{
where: {
userId: request.user.id,
role: "ADMIN",
},
},
);
const user = await request.server.prisma.user.findUnique({
where: { id: request.user.id },
select: { isInstanceAdmin: true },
});

if (!adminMembership) {
throw new ForbiddenError(
"Instance admin access required (must be admin of at least one workspace)",
);
if (!user?.isInstanceAdmin) {
throw new ForbiddenError("Instance admin access required");
}
}
13 changes: 9 additions & 4 deletions packages/backend/src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,17 @@ export async function register(

const passwordHash = await hashPassword(data.password);

// First user becomes instance admin
const userCount = await prisma.user.count();
const isFirstUser = userCount === 0;

const user = await prisma.user.create({
data: {
email: data.email.toLowerCase(),
passwordHash,
displayName: data.displayName,
emailVerified: false,
isInstanceAdmin: isFirstUser,
},
select: {
id: true,
Expand Down Expand Up @@ -143,8 +148,8 @@ export async function register(
},
});

// Send verification email (fire-and-forget)
sendVerificationEmail(user.email, verificationToken);
// Send verification email
await sendVerificationEmail(user.email, verificationToken);

// Handle invite token if provided
if (data.inviteToken) {
Expand Down Expand Up @@ -386,7 +391,7 @@ export async function forgotPassword(prisma: PrismaClient, email: string) {
},
});

sendPasswordResetEmail(user.email, token);
await sendPasswordResetEmail(user.email, token);
}

export async function resetPassword(
Expand Down Expand Up @@ -506,7 +511,7 @@ export async function resendVerification(
},
});

sendVerificationEmail(user.email, token);
await sendVerificationEmail(user.email, token);

return { message: "Verification email sent." };
}
Expand Down
20 changes: 14 additions & 6 deletions packages/backend/src/modules/auth/oidc/oidc.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@ import type { FastifyRequest, FastifyReply } from "fastify";
import * as oidcService from "./oidc.service.js";
import { env } from "../../../config/env.js";

function sanitizeReturnUrl(raw: string | undefined): string {
if (!raw) return "/";
// Check both raw and decoded values
for (const value of [raw, decodeURIComponent(raw)]) {
if (!value.startsWith("/") || value.startsWith("//") || value.includes("\\") || value.includes("\0")) {
return "/";
}
}
return raw;
}

export async function oidcLoginHandler(
request: FastifyRequest,
reply: FastifyReply,
Expand All @@ -12,10 +23,11 @@ export async function oidcLoginHandler(
};

try {
const returnUrl = sanitizeReturnUrl(query.return_url);
const authUrl = await oidcService.initiateOidcLogin(
request.server.prisma,
query.connector_id,
query.return_url,
returnUrl,
);

return reply.redirect(authUrl);
Expand Down Expand Up @@ -71,11 +83,7 @@ export async function oidcCallbackHandler(
});

// Redirect to the OIDC complete page
const returnUrl = result.returnUrl || "/";
const safeReturnUrl =
returnUrl.startsWith("/") && !returnUrl.startsWith("//")
? returnUrl
: "/";
const safeReturnUrl = sanitizeReturnUrl(result.returnUrl ?? undefined);
return reply.redirect(
`${env.FRONTEND_URL}/auth/oidc/complete?return_url=${encodeURIComponent(safeReturnUrl)}`,
);
Expand Down
23 changes: 23 additions & 0 deletions packages/backend/src/modules/backup/backup-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { CustomFieldType, Priority } from "@prisma/client";
import type { Prisma } from "@prisma/client";

const CUSTOM_FIELD_TYPES = new Set<string>(Object.values(CustomFieldType));
const PRIORITIES = new Set<string>(Object.values(Priority));

export function toCustomFieldType(value: string): CustomFieldType {
if (!CUSTOM_FIELD_TYPES.has(value)) {
throw new Error(`Invalid custom field type: ${value}`);
}
return value as CustomFieldType;
}

export function toPriority(value: string): Priority {
if (!PRIORITIES.has(value)) {
throw new Error(`Invalid priority: ${value}`);
}
return value as Priority;
}

export function toJsonValue(value: unknown): Prisma.InputJsonValue {
return value as Prisma.InputJsonValue;
}
20 changes: 16 additions & 4 deletions packages/backend/src/modules/backup/backup.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,8 +226,9 @@ export async function exportBackup(

messages = dbMessages.map((m) => {
userIdSet.add(m.userId);
const reactions = (m as any).reactions
? (m as any).reactions.map((r: any) => {
const mWithReactions = m as typeof m & { reactions?: Array<{ userId: string; emoji: string }> };
const reactions = mWithReactions.reactions
? mWithReactions.reactions.map((r) => {
userIdSet.add(r.userId);
return { userRef: r.userId, emoji: r.emoji };
})
Expand Down Expand Up @@ -277,8 +278,19 @@ export async function exportBackup(
const pages = dbPages.map((p) => {
if (p.createdById) userIdSet.add(p.createdById);

const comments = (p as any).comments
? (p as any).comments.map((c: any) => {
type WikiComment = {
id: string;
body: string;
authorId: string;
resolved: boolean;
highlightId: string;
selectionStart: unknown;
selectionEnd: unknown;
createdAt: Date;
};
const pWithComments = p as typeof p & { comments?: WikiComment[] };
const comments = pWithComments.comments
? pWithComments.comments.map((c) => {
if (c.authorId) userIdSet.add(c.authorId);
return {
_originalId: c.id,
Expand Down
17 changes: 9 additions & 8 deletions packages/backend/src/modules/backup/restore.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { StorageProvider } from "../../services/storage.js";
import { IdMapper, UserMapper } from "./id-mapper.js";
import { ValidationError } from "../../utils/errors.js";
import { rewriteUrls } from "./backup-assets.js";
import { toCustomFieldType, toPriority, toJsonValue } from "./backup-types.js";

/** Parse and validate a backup JSON buffer. */
function parseBackupFile(buffer: Buffer): BackupFile {
Expand Down Expand Up @@ -242,8 +243,8 @@ export async function executeRestore(
data: {
projectId: newProject.id,
name: f.name,
type: f.type as any,
options: f.options as any,
type: toCustomFieldType(f.type),
options: toJsonValue(f.options),
required: f.required,
position: f.position,
},
Expand Down Expand Up @@ -284,7 +285,7 @@ export async function executeRestore(
statusId,
title: t.title,
description: t.description,
priority: t.priority as any,
priority: toPriority(t.priority),
position: t.position,
dueDate: t.dueDate,
startDate: t.startDate,
Expand Down Expand Up @@ -337,7 +338,7 @@ export async function executeRestore(
data: {
taskId: newTask.id,
fieldId,
value: v.value as any,
value: toJsonValue(v.value),
},
}).catch(() => {});
}
Expand All @@ -362,7 +363,7 @@ export async function executeRestore(
data: {
taskId: newTask.id,
action: a.action,
details: a.details as any,
details: toJsonValue(a.details),
actorId: userMapper.resolveOptional(a.actorRef),
createdAt: new Date(a.createdAt),
},
Expand Down Expand Up @@ -510,7 +511,7 @@ export async function executeRestore(
data: {
spaceId: newSpace.id,
title: p.title,
content: content as any,
content: toJsonValue(content),
icon: p.icon,
position: p.position,
parentId: null,
Expand All @@ -529,8 +530,8 @@ export async function executeRestore(
body: c.body,
resolved: c.resolved,
highlightId: c.highlightId,
selectionStart: c.selectionStart as any,
selectionEnd: c.selectionEnd as any,
selectionStart: toJsonValue(c.selectionStart),
selectionEnd: toJsonValue(c.selectionEnd),
createdAt: new Date(c.createdAt),
},
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export default async function workspacesRoutes(fastify: FastifyInstance) {
fastify.route({
method: "POST",
url: "/",
config: { rateLimit: { max: 5, timeWindow: "1 minute" } },
preHandler: [authenticate, validate(CreateWorkspaceSchema)],
handler: createWorkspaceHandler,
});
Expand Down
18 changes: 18 additions & 0 deletions packages/backend/src/plugins/static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import type { FastifyInstance } from "fastify";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { env } from "../config/env.js";
import { authenticate } from "../middleware/authenticate.js";
import { ForbiddenError } from "../utils/errors.js";

const __dirname = path.dirname(fileURLToPath(import.meta.url));

Expand All @@ -27,9 +29,25 @@ export default fp(async (fastify: FastifyInstance) => {
// Workspace-scoped uploads
fastify.get<{ Params: { workspaceId: string; '*': string } }>(
'/storage/:workspaceId/uploads/*',
{ preHandler: [authenticate] },
async (request, reply) => {
const { workspaceId } = request.params;
const subPath = request.params['*'];

// Path traversal protection
const resolved = path.resolve(storageDir, workspaceId, 'uploads', subPath);
if (!resolved.startsWith(storageDir + path.sep)) {
throw new ForbiddenError("Invalid file path");
}

// Workspace membership check
const membership = await fastify.prisma.workspaceMember.findUnique({
where: { userId_workspaceId: { userId: request.user!.id, workspaceId } },
});
if (!membership) {
throw new ForbiddenError("Not a member of this workspace");
}

return reply.sendFile(
path.join(workspaceId, 'uploads', subPath),
storageDir,
Expand Down
16 changes: 12 additions & 4 deletions packages/backend/src/services/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ export class LocalStorageProvider implements StorageProvider {
this.rootDir = path.resolve(rootDir);
}

private safePath(storagePath: string): string {
const fullPath = path.resolve(this.rootDir, storagePath);
if (!fullPath.startsWith(this.rootDir + path.sep) && fullPath !== this.rootDir) {
throw new Error("Path traversal detected");
}
return fullPath;
}

private buildPath(
category: string,
scopeId: string,
Expand Down Expand Up @@ -118,24 +126,24 @@ export class LocalStorageProvider implements StorageProvider {
}

async read(storagePath: string): Promise<Buffer> {
const fullPath = path.join(this.rootDir, storagePath);
const fullPath = this.safePath(storagePath);
return fs.readFile(fullPath);
}

readStream(storagePath: string): fss.ReadStream {
const fullPath = path.join(this.rootDir, storagePath);
const fullPath = this.safePath(storagePath);
return fss.createReadStream(fullPath);
}

async delete(storagePath: string): Promise<void> {
const fullPath = path.join(this.rootDir, storagePath);
const fullPath = this.safePath(storagePath);
await fs.unlink(fullPath).catch(() => {
// Ignore if file already deleted
});
}

async exists(storagePath: string): Promise<boolean> {
const fullPath = path.join(this.rootDir, storagePath);
const fullPath = this.safePath(storagePath);
try {
await fs.access(fullPath);
return true;
Expand Down
11 changes: 8 additions & 3 deletions packages/frontend/src/features/wiki/hooks/use-page-lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,17 +39,22 @@ export function usePageLock(pageId: string) {
releaseLock.mutate();
}, [releaseLock]);

const releaseLockRef = useRef(releaseLock);
const isLockedByMeRef = useRef(isLockedByMe);
useEffect(() => { releaseLockRef.current = releaseLock; }, [releaseLock]);
useEffect(() => { isLockedByMeRef.current = isLockedByMe; }, [isLockedByMe]);

// Cleanup on unmount
useEffect(() => {
return () => {
if (heartbeatInterval.current) {
clearInterval(heartbeatInterval.current);
}
if (isLockedByMe) {
releaseLock.mutate();
if (isLockedByMeRef.current) {
releaseLockRef.current.mutate();
}
};
}, [pageId]); // eslint-disable-line react-hooks/exhaustive-deps
}, [pageId]);

return {
lock,
Expand Down