Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
f3a2918
feat: add collection type definitions and Firestore rules
rindrics Feb 10, 2026
d942ca4
feat: add collection CRUD library functions
rindrics Feb 10, 2026
2fa46d4
feat: add collection UI components
rindrics Feb 10, 2026
2948163
feat: add collection pages
rindrics Feb 10, 2026
f8c3cf0
feat: integrate collection button into content pages
rindrics Feb 10, 2026
7139b32
fix: prevent userId mutation in publicCollections update rule
rindrics Feb 11, 2026
1c8d76c
fix: prevent unauthenticated users from navigating to protected /coll…
rindrics Feb 11, 2026
dc306b4
fix: surface collection operation errors to user with inline messages
rindrics Feb 11, 2026
d644650
refactor: extract content display helpers to shared module
rindrics Feb 11, 2026
eb181bf
fix: prevent orphaned documents in deleteCollection with cleanup pass
rindrics Feb 11, 2026
380516e
perf: eliminate N+1 reads in getCollections by denormalizing contentC…
rindrics Feb 11, 2026
4525629
fix: add null-safe chaining to all Timestamp.toMillis() calls
rindrics Feb 11, 2026
45c8692
perf: parallelize reads in getCollectionsForContent with Promise.all
rindrics Feb 11, 2026
30d8e7d
fix: verify isPublic flag in getPublicCollectionWithContents
rindrics Feb 11, 2026
f061350
fix(collections): improve error handling
rindrics Feb 11, 2026
c96cc60
fix(collections): remove expensive subcollection read
rindrics Feb 11, 2026
f50fa61
fix(collections): make content add/remove operations atomic using tra…
rindrics Feb 11, 2026
d029015
fix(collections): eliminate TOCTOU race condition in updateCollection…
rindrics Feb 11, 2026
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
25 changes: 24 additions & 1 deletion firestore.rules
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,30 @@ service cloud.firestore {
allow read, write: if request.auth != null && request.auth.uid == userId;
}
}


// Collections: users can only read/write their own collections
match /collections/{userId} {
allow read, write: if request.auth != null && request.auth.uid == userId;

// Collection items subcollection
match /items/{collectionId} {
allow read, write: if request.auth != null && request.auth.uid == userId;

// Contents within a collection
match /contents/{contentId} {
allow read, write: if request.auth != null && request.auth.uid == userId;
}
}
}

// Public collections: anyone can read, only owner can write
match /publicCollections/{collectionId} {
allow read: if true;
allow create: if request.auth != null && request.resource.data.userId == request.auth.uid;
allow update: if request.auth != null && resource.data.userId == request.auth.uid && request.resource.data.userId == resource.data.userId;
allow delete: if request.auth != null && resource.data.userId == request.auth.uid;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Legacy admin-only rules for other collections
match /{document=**} {
// Anyone can read (public content)
Comment on lines +27 to 52

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

6. Catch-all read bypass 🐞 Bug ⛨ Security

• The new /collections/{userId} rules intend to restrict reads/writes to the owning user, but the
  existing catch-all match /{document=**} still grants allow read: if true.
• Firestore allows are effectively OR’ed across matching rules, so this makes collections (and other
  user data) publicly readable despite the new restrictions.
• This is a critical privacy/security issue and should be fixed before shipping collections.
Agent Prompt
## Issue description
The Firestore rules contain a global wildcard rule that allows any read (`allow read: if true`). This defeats the intent of the new `/collections/{userId}` owner-only rules, making private collections publicly readable.

## Issue Context
Firestore rules are not “first match wins”; permissive rules on any matching `match` path can grant access. A wildcard read rule at `/{document=**}` will match every document and can therefore bypass more restrictive rules.

## Fix Focus Areas
- firestore.rules[27-56]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Expand Down
23 changes: 19 additions & 4 deletions src/app/books/[bookId]/[sectionId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { Suspense } from 'react';
import { AddToCollectionButton } from '@/components/AddToCollectionButton';
import { FavoriteButton } from '@/components/FavoriteButton';
import { KeyboardNavigation } from '@/components/KeyboardNavigation';
import { ListWithFavoriteSidebar } from '@/components/ListWithFavoriteSidebar';
Expand Down Expand Up @@ -77,9 +78,17 @@ export default async function SectionPage({ params }: Props) {
{book.name}
</Link>
</nav>
<h1 className="mb-4 text-3xl font-bold text-black dark:text-white">
{section.name}
</h1>
<div className="flex items-center gap-2">
<h1 className="text-3xl font-bold text-black dark:text-white">
{section.name}
</h1>
<Suspense fallback={null}>
<AddToCollectionButton
contentId={`${book.id}/${section.id}`}
contentType="section"
/>
</Suspense>
</div>
<nav className="flex items-center justify-between gap-1 border-t border-zinc-200 pt-2 dark:border-zinc-800">
{prevUrl ? (
<Link
Expand Down Expand Up @@ -145,10 +154,16 @@ export default async function SectionPage({ params }: Props) {
{previewText}
</span>
</Link>
<div className="shrink-0">
<div className="flex shrink-0 items-center gap-1">
<Suspense fallback={null}>
<FavoriteButton contentId={contentId} />
</Suspense>
<Suspense fallback={null}>
<AddToCollectionButton
contentId={contentId}
contentType="chapter"
/>
</Suspense>
</div>
</div>
</li>
Expand Down
276 changes: 276 additions & 0 deletions src/app/collections/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
'use client';

import Link from 'next/link';
import { useCallback, useEffect, useState } from 'react';
import { CollectionDetailModal } from '@/components/CollectionDetailModal';
import { CreateCollectionModal } from '@/components/CreateCollectionModal';
import { PageWithSidebar } from '@/components/PageWithSidebar';
import { useAuth } from '@/contexts/AuthContext';
import {
deleteCollection,
getCollections,
updateCollection,
} from '@/lib/collections';
import type { CollectionSummary } from '@/types/collection';

export default function CollectionsPage() {
const { user, loading } = useAuth();
const [collections, setCollections] = useState<CollectionSummary[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [viewingId, setViewingId] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [editingName, setEditingName] = useState('');

const loadCollections = useCallback(async () => {
if (!user) return;

setIsLoading(true);
try {
const cols = await getCollections(user.uid);
setCollections(cols);
} catch (error) {
console.error('Failed to load collections:', error);
setCollections([]);
} finally {
setIsLoading(false);
}
}, [user]);

useEffect(() => {
if (!user || loading) {
setCollections([]);
return;
}

loadCollections();

const handleCollectionsChanged = () => {
loadCollections();
};
window.addEventListener('collections-changed', handleCollectionsChanged);

return () => {
window.removeEventListener(
'collections-changed',
handleCollectionsChanged,
);
};
}, [user, loading, loadCollections]);

const handleDelete = async (collectionId: string, collectionName: string) => {
if (!user) return;
if (!confirm(`「${collectionName}」を削除しますか?`)) return;

try {
await deleteCollection(user.uid, collectionId);
window.dispatchEvent(new CustomEvent('collections-changed'));
} catch (error) {
console.error('Failed to delete collection:', error);
alert('削除に失敗しました');
}
};

const handleStartEdit = (collection: CollectionSummary) => {
setEditingId(collection.id);
setEditingName(collection.name);
};

const handleSaveEdit = async () => {
if (!user || !editingId || !editingName.trim()) return;

try {
await updateCollection(user.uid, editingId, { name: editingName.trim() });
setEditingId(null);
setEditingName('');
window.dispatchEvent(new CustomEvent('collections-changed'));
} catch (error) {
console.error('Failed to update collection:', error);
alert('更新に失敗しました');
}
};

const handleCancelEdit = () => {
setEditingId(null);
setEditingName('');
};

const handleTogglePublic = async (collection: CollectionSummary) => {
if (!user) return;

try {
await updateCollection(user.uid, collection.id, {
isPublic: !collection.isPublic,
});
window.dispatchEvent(new CustomEvent('collections-changed'));
} catch (error) {
console.error('Failed to toggle public status:', error);
alert('公開設定の変更に失敗しました');
}
};

if (loading || isLoading) {
return (
<PageWithSidebar maxWidth="4xl" showSidebar={false}>
<h1 className="mb-8 text-3xl font-bold text-black dark:text-white">
マイコレクション
</h1>
<div className="text-zinc-500">読み込み中...</div>
</PageWithSidebar>
);
}

if (!user) {
return (
<PageWithSidebar maxWidth="4xl" showSidebar={false}>
<h1 className="mb-8 text-3xl font-bold text-black dark:text-white">
マイコレクション
</h1>
<div className="text-zinc-500">
コレクション機能をご利用いただくには、右上の「
<span className="font-medium text-zinc-700 dark:text-zinc-300">
我入門也
</span>
」からログインしてください。
</div>
</PageWithSidebar>
);
}

return (
<PageWithSidebar maxWidth="4xl" showSidebar={false}>
<div className="mb-8 flex items-center justify-between">
<h1 className="text-3xl font-bold text-black dark:text-white">
マイコレクション
</h1>
<button
type="button"
onClick={() => setIsCreating(true)}
className="rounded-lg bg-zinc-700 px-4 py-2 text-sm text-white hover:bg-zinc-800 dark:bg-zinc-600 dark:hover:bg-zinc-500"
>
新規作成
</button>
</div>

<div className="mb-4">
<Link
href="/collections/public"
className="text-sm text-zinc-600 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200"
>
公開コレクションを見る →
</Link>
</div>

{collections.length === 0 ? (
<div className="text-zinc-500">
コレクションがありません。「新規作成」ボタンからコレクションを作成してください。
</div>
) : (
<ul className="space-y-4">
{collections.map((collection) => (
<li
key={collection.id}
className="rounded-lg bg-white p-4 shadow-sm dark:bg-zinc-900"
>
{editingId === collection.id ? (
<div className="flex items-center gap-2">
<input
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
className="flex-1 rounded border border-zinc-300 px-2 py-1 text-sm dark:border-zinc-700 dark:bg-zinc-800 dark:text-white"
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveEdit();
if (e.key === 'Escape') handleCancelEdit();
}}
/>
<button
type="button"
onClick={handleSaveEdit}
className="rounded bg-zinc-700 px-3 py-1 text-sm text-white hover:bg-zinc-800"
>
保存
</button>
<button
type="button"
onClick={handleCancelEdit}
className="rounded bg-zinc-200 px-3 py-1 text-sm text-zinc-700 hover:bg-zinc-300 dark:bg-zinc-800 dark:text-zinc-300"
>
キャンセル
</button>
</div>
) : (
<div className="flex items-start justify-between gap-4">
<button
type="button"
onClick={() => setViewingId(collection.id)}
className="min-w-0 flex-1 text-left"
>
<div className="flex items-center gap-2">
<span className="font-medium text-black dark:text-white">
{collection.name}
</span>
{collection.isPublic && (
<span className="rounded bg-zinc-200 px-1.5 py-0.5 text-xs text-zinc-600 dark:bg-zinc-700 dark:text-zinc-400">
公開
</span>
)}
</div>
{collection.description && (
<div className="mt-1 text-sm text-zinc-500">
{collection.description}
</div>
)}
<div className="mt-1 text-xs text-zinc-400">
{collection.contentCount}件のコンテンツ
</div>
</button>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={() => handleTogglePublic(collection)}
className="rounded px-2 py-1 text-xs text-zinc-500 hover:bg-zinc-100 hover:text-zinc-700 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
title={collection.isPublic ? '非公開にする' : '公開する'}
>
{collection.isPublic ? '非公開にする' : '公開する'}
</button>
<button
type="button"
onClick={() => handleStartEdit(collection)}
className="rounded px-2 py-1 text-xs text-zinc-500 hover:bg-zinc-100 hover:text-zinc-700 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
>
編集
</button>
<button
type="button"
onClick={() =>
handleDelete(collection.id, collection.name)
}
className="rounded px-2 py-1 text-xs text-red-500 hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-900/20"
>
削除
</button>
</div>
</div>
)}
</li>
))}
</ul>
)}

{isCreating && (
<CreateCollectionModal
onClose={() => setIsCreating(false)}
onCreated={() => setIsCreating(false)}
/>
)}

{viewingId && (
<CollectionDetailModal
collectionId={viewingId}
onClose={() => setViewingId(null)}
/>
)}
</PageWithSidebar>
);
}
Loading