Skip to content

Commit bd8f4ce

Browse files
Sora4431claude
andcommitted
fix(share): 共有スナップショットへの不正な編集・削除を禁止 (SOR-20)
- PUT /:id: source_itinerary_id が設定されているスナップショットへの編集を 403 で拒否 - DELETE /:id: 同様にスナップショットの削除を 403 で拒否 - handlePublish: 初回公開のみ syncBookmarks + updateVisibility を実行し、再公開時に可視性を強制変更しないよう修正 - PUT/DELETE の新ガードに対応するテストを追加 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a981e9e commit bd8f4ce

3 files changed

Lines changed: 60 additions & 6 deletions

File tree

apps/api/src/routes/itineraries.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ itineraries.put('/:id', optionalAuthMiddleware, async (c) => {
5757
return c.json({ success: false, error: { code: 'NOT_FOUND', message: 'Itinerary not found' } }, 404);
5858
}
5959

60+
if (existing.source_itinerary_id) {
61+
return c.json({ success: false, error: { code: 'FORBIDDEN', message: 'Cannot edit a shared snapshot' } }, 403);
62+
}
63+
6064
if (existing.password) {
6165
const shioriId = c.get('shioriId');
6266

@@ -155,6 +159,10 @@ itineraries.delete('/:id', optionalAuthMiddleware, async (c) => {
155159
return c.json({ success: false, error: { code: 'NOT_FOUND', message: 'Itinerary not found' } }, 404);
156160
}
157161

162+
if (existing.source_itinerary_id) {
163+
return c.json({ success: false, error: { code: 'FORBIDDEN', message: 'Cannot delete a shared snapshot' } }, 403);
164+
}
165+
158166
if (existing.password) {
159167
const shioriId = c.get('shioriId');
160168

apps/api/test/itineraries.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,29 @@ describe('Itineraries API', () => {
229229

230230
expect(response.status).toBe(404);
231231
});
232+
233+
it('returns 403 when trying to edit a shared snapshot', async () => {
234+
const createRes = await app.fetch(new Request('http://localhost/api/v1/itineraries', {
235+
method: 'POST',
236+
headers: { 'Content-Type': 'application/json' },
237+
body: JSON.stringify({ title: 'オリジナル' }),
238+
}), env);
239+
const { data: original } = await createRes.json() as any;
240+
241+
const publishRes = await app.fetch(new Request(`http://localhost/api/v1/itineraries/${original.id}/publish`, {
242+
method: 'POST',
243+
}), env);
244+
const { data: snapshot } = await publishRes.json() as any;
245+
246+
const updateRes = await app.fetch(new Request(`http://localhost/api/v1/itineraries/${snapshot.id}`, {
247+
method: 'PUT',
248+
headers: { 'Content-Type': 'application/json' },
249+
body: JSON.stringify({ title: '改ざん' }),
250+
}), env);
251+
expect(updateRes.status).toBe(403);
252+
const { error } = await updateRes.json() as any;
253+
expect(error.code).toBe('FORBIDDEN');
254+
});
232255
});
233256

234257
describe('DELETE /api/v1/itineraries/:id', () => {
@@ -261,6 +284,27 @@ describe('Itineraries API', () => {
261284

262285
expect(response.status).toBe(404);
263286
});
287+
288+
it('returns 403 when trying to delete a shared snapshot', async () => {
289+
const createRes = await app.fetch(new Request('http://localhost/api/v1/itineraries', {
290+
method: 'POST',
291+
headers: { 'Content-Type': 'application/json' },
292+
body: JSON.stringify({ title: 'オリジナル' }),
293+
}), env);
294+
const { data: original } = await createRes.json() as any;
295+
296+
const publishRes = await app.fetch(new Request(`http://localhost/api/v1/itineraries/${original.id}/publish`, {
297+
method: 'POST',
298+
}), env);
299+
const { data: snapshot } = await publishRes.json() as any;
300+
301+
const deleteRes = await app.fetch(new Request(`http://localhost/api/v1/itineraries/${snapshot.id}`, {
302+
method: 'DELETE',
303+
}), env);
304+
expect(deleteRes.status).toBe(403);
305+
const { error } = await deleteRes.json() as any;
306+
expect(error.code).toBe('FORBIDDEN');
307+
});
264308
});
265309
});
266310

apps/web/src/routes/profile/+page.svelte

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -111,14 +111,16 @@
111111
112112
let publishingId = $state<string | null>(null);
113113
114-
async function handlePublish(itineraryId: string) {
114+
async function handlePublish(itineraryId: string, isFirstPublish: boolean) {
115115
if (publishingId) return;
116116
publishingId = itineraryId;
117117
try {
118118
const result = await itineraryApi.publish(itineraryId);
119-
// ブックマークに追加して公開状態にする(/users に表示されるようにする)
120-
await userApi.syncBookmarks([result.id]);
121-
await userApi.updateVisibility(result.id, { is_visible: true });
119+
if (isFirstPublish) {
120+
// 初回公開のみ: ブックマークに追加して公開状態にする(/users に表示されるようにする)
121+
await userApi.syncBookmarks([result.id]);
122+
await userApi.updateVisibility(result.id, { is_visible: true });
123+
}
122124
// ブックマーク一覧を再取得して反映
123125
await loadBookmarks();
124126
} catch {
@@ -524,15 +526,15 @@
524526
</span>
525527
{/if}
526528
<button
527-
onclick={() => handlePublish(bookmark.itinerary_id)}
529+
onclick={() => handlePublish(bookmark.itinerary_id, false)}
528530
disabled={publishingId === bookmark.itinerary_id}
529531
class="text-xs px-2 py-1 rounded border border-gray-300 text-gray-600 bg-gray-50 hover:bg-gray-100 disabled:opacity-50 transition-colors"
530532
>
531533
{publishingId === bookmark.itinerary_id ? "公開中..." : "最新版を公開"}
532534
</button>
533535
{:else}
534536
<button
535-
onclick={() => handlePublish(bookmark.itinerary_id)}
537+
onclick={() => handlePublish(bookmark.itinerary_id, true)}
536538
disabled={publishingId === bookmark.itinerary_id}
537539
class="text-xs px-2 py-1 rounded border border-indigo-300 text-indigo-700 bg-indigo-50 hover:bg-indigo-100 disabled:opacity-50 transition-colors"
538540
>

0 commit comments

Comments
 (0)