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
23 changes: 20 additions & 3 deletions client/src/app/routes/app/document.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router';

import { DocumentLayout } from '@/components/layouts/DocumentLayout';
Expand All @@ -8,15 +8,26 @@ import { DocumentHeader } from '@/features/DocumentPage/components/DocumentHeade
import { DocumentMain } from '@/features/DocumentPage/components/DocumentMain';
import { useDocument } from '@/hooks/use-document';
import { useMediaQuery } from '@/hooks/use-media-query';
import { resolveDocumentId } from '@/utils/short-id';

/**
* Editor page: resolves the :id param and wires the document header, collaboration and editor/preview panes.
*/
export default function DocumentPage() {
const { id } = useParams();
const { id: rawId } = useParams();
const navigate = useNavigate();

const id = useMemo(() => {
if (!rawId) return undefined;
try {
return resolveDocumentId(rawId);
} catch {
return undefined;
}
}, [rawId]);

const { doc, editedDoc, setEditedDoc, loading, /*handleSave,*/ access } =
useDocument(id);
const navigate = useNavigate();

const isReadOnly = access?.permission === 'view';
const isCollaborator = access?.isCollaborator;
Expand All @@ -25,6 +36,12 @@ export default function DocumentPage() {

const isSmallScreen = useMediaQuery('(max-width: 768px)');

useEffect(() => {
if (rawId && !id) {
navigate('/not-found', { replace: true });
}
}, [rawId, id, navigate]);

useEffect(() => {
if (loading) return;

Expand Down
5 changes: 4 additions & 1 deletion client/src/config/paths.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { isUuid, uuidToShortId } from '@/utils/short-id';

/**
* Central registry of internal route paths and href builders used by links and redirects.
*/
Expand Down Expand Up @@ -30,7 +32,8 @@ export const paths = {
},
document: {
path: 'doc/:id',
getHref: (id: string) => `/app/doc/${id}`,
getHref: (id: string) =>
`/app/doc/${isUuid(id) ? uuidToShortId(id) : id}`,
},
share: {
path: 'doc/share/:token',
Expand Down
122 changes: 122 additions & 0 deletions client/src/utils/short-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* Reversible short-ID helpers: UUID <-> 22-char base64url.
* Client-side implementation without Node Buffer.
*/

/** Matches a canonical UUID. */
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

/** Matches a 22-char base64url string (no padding). */
const SHORT_RE = /^[A-Za-z0-9_-]{22}$/;

/**
* Encodes a UUID to a 22-character base64url short ID.
* @param uuid - Canonical UUID string.
* @returns 22-char base64url string with no padding.
*/
export function uuidToShortId(uuid: string): string {
if (!UUID_RE.test(uuid)) {
throw new Error(`Invalid UUID: ${uuid}`);
}
const hex = uuid.replace(/-/g, '');
const bytes = hexToBytes(hex);
return bytesToBase64Url(bytes);
}

/**
* Decodes a 22-character base64url short ID back to a UUID.
* @param shortId - 22-char base64url string.
* @returns Canonical UUID string.
*/
export function shortIdToUuid(shortId: string): string {
if (!SHORT_RE.test(shortId)) {
throw new Error(`Invalid short ID: ${shortId}`);
}
const bytes = base64UrlToBytes(shortId);
const hex = bytesToHex(bytes);
if (hex.length !== 32) {
throw new Error(`Invalid short ID: ${shortId}`);
}
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20, 32),
].join('-');
}

/**
* Returns true when the value looks like a canonical UUID.
* @param value - Candidate string.
*/
export function isUuid(value: string): boolean {
return UUID_RE.test(value);
}

/**
* Returns true when the value looks like a 22-char short ID.
* @param value - Candidate string.
*/
export function isShortId(value: string): boolean {
return SHORT_RE.test(value);
}

/**
* Resolves a route param that may be either a UUID or a short ID to a UUID.
* Throws when the input matches neither format (caller should map to 404).
* @param rawId - Raw `:id` param from the route.
* @returns Canonical UUID string.
*/
export function resolveDocumentId(rawId: string): string {
if (isUuid(rawId)) return rawId.toLowerCase();
if (isShortId(rawId)) return shortIdToUuid(rawId);
throw new Error(`Invalid document ID: ${rawId}`);
}

/** Converts a hex string to bytes. */
function hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return bytes;
}

/** Converts bytes to a hex string. */
function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}

/** Encodes bytes to base64url without padding. */
function bytesToBase64Url(bytes: Uint8Array): string {
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]!);
}
// btoa is available in browsers; in Node tests it may not be — fallback to Buffer is not needed in client.
const base64 =
typeof btoa !== 'undefined'
? btoa(binary)
: Buffer.from(binary, 'binary').toString('base64');
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}

/** Decodes base64url to bytes. */
function base64UrlToBytes(base64url: string): Uint8Array {
let base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
const pad = base64.length % 4;
if (pad) base64 += '='.repeat(4 - pad);
const binary =
typeof atob !== 'undefined'
? atob(base64)
: Buffer.from(base64, 'base64').toString('binary');
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- CreateIndex
CREATE INDEX "documents_authorId_idx" ON "documents"("authorId");

-- CreateIndex
CREATE INDEX "collaborators_userId_idx" ON "collaborators"("userId");

-- CreateIndex
CREATE INDEX "collaboration_requests_userId_idx" ON "collaboration_requests"("userId");

-- CreateIndex
CREATE INDEX "collaboration_requests_documentId_idx" ON "collaboration_requests"("documentId");
4 changes: 4 additions & 0 deletions server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ model Document {
Collaborator Collaborator[]
CollaborationRequest CollaborationRequest[]

@@index([authorId])
@@map("documents")
}

Expand All @@ -53,6 +54,7 @@ model Collaborator {
permission String @default("edit") // "edit" or "view"

@@unique([documentId, userId])
@@index([userId])
@@map("collaborators")
}

Expand All @@ -67,6 +69,8 @@ model CollaborationRequest {
createdAt DateTime @default(now())

@@unique([documentId, userId])
@@index([userId])
@@index([documentId])
@@map("collaboration_requests")
}

Expand Down
7 changes: 3 additions & 4 deletions server/src/lib/dbPersistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,11 @@ import * as Y from 'yjs';

import { logger } from '@/lib/logger';
import { prisma } from '@/lib/prisma';
import { slugIDtoFullID } from '@/utils/slugIDtoFullID';

export const dbPersistence = new Database({
fetch: async ({ documentName }) => {
try {
const id = await slugIDtoFullID(documentName);
const id = documentName;
// Fetch the Yjs document state from the database
const record = await prisma.yjsDocumentState.findFirst({
where: {
Expand Down Expand Up @@ -41,7 +40,7 @@ export const dbPersistence = new Database({

store: async ({ documentName, state }) => {
try {
const id = await slugIDtoFullID(documentName);
const id = documentName;
const existing = await prisma.yjsDocumentState.findUnique({
where: { documentId: id },
});
Expand Down Expand Up @@ -87,7 +86,7 @@ export const dbPersistence = new Database({
}),
]);
} else {
logger.warn(`No Document found for ID prefix: ${documentName}`, {
logger.warn(`No Document found for ID: ${documentName}`, {
action: 'DB_STORE_DOC_NOT_FOUND',
});
}
Expand Down
56 changes: 56 additions & 0 deletions server/src/utils/short-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Reversible short-ID helpers: UUID <-> 22-char base64url.
* UUID -> 22-char opaque ID (e.g. VQ6EAOKbQdSnFkRmVUQAAA) for URLs.
* Short -> UUID, throws on malformed input (caller maps to 404).
*/

/** Matches a canonical UUID v4 string. */
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

/** Matches a 22-char base64url string (no padding). */
const SHORT_RE = /^[A-Za-z0-9_-]{22}$/;

/**
* Encodes a UUID to a 22-character base64url short ID.
* @param uuid - Canonical UUID string.
* @returns 22-char base64url string with no padding.
*/
export function uuidToShortId(uuid: string): string {
if (!UUID_RE.test(uuid)) {
throw new Error(`Invalid UUID: ${uuid}`);
}
const hex = uuid.replace(/-/g, '');
return Buffer.from(hex, 'hex').toString('base64url');
}

/**
* Decodes a 22-character base64url short ID back to a UUID.
* @param shortId - 22-char base64url string.
* @returns Canonical UUID string.
*/
export function shortIdToUuid(shortId: string): string {
if (!SHORT_RE.test(shortId)) {
throw new Error(`Invalid short ID: ${shortId}`);
}
const hex = Buffer.from(shortId, 'base64url').toString('hex');
if (hex.length !== 32) {
throw new Error(`Invalid short ID: ${shortId}`);
}
return [hex.slice(0, 8), hex.slice(8, 12), hex.slice(12, 16), hex.slice(16, 20), hex.slice(20, 32)].join('-');
}

/**
* Returns true when the value looks like a canonical UUID.
* @param value - Candidate string.
*/
export function isUuid(value: string): boolean {
return UUID_RE.test(value);
}

/**
* Returns true when the value looks like a 22-char short ID.
* @param value - Candidate string.
*/
export function isShortId(value: string): boolean {
return SHORT_RE.test(value);
}
16 changes: 0 additions & 16 deletions server/src/utils/slugIDtoFullID.ts

This file was deleted.

53 changes: 0 additions & 53 deletions server/test/slugIDtoFullID.test.ts

This file was deleted.

Loading