From 0a59059738a95147a602321bda62eaf3934a1f5c Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:57:14 +0300 Subject: [PATCH] feat(playlists): share playlists with revocable read-only links --- api/openapi.yaml | 59 ++ apps/mobile/src/app/playlists.tsx | 21 +- apps/web/e2e/playlists.spec.ts | 13 + apps/web/src/App.test.tsx | 54 +- apps/web/src/app/router.tsx | 2 + apps/web/src/entities/playlists.tsx | 11 +- apps/web/src/pages/PlaylistsPage.tsx | 41 +- apps/web/src/shared/lib/share.test.ts | 34 +- apps/web/src/shared/lib/share.ts | 12 +- apps/web/src/test/fakeApi.ts | 24 +- docs/playlists.md | 23 +- packages/api-client/src/generated/schema.ts | 115 +++ packages/core/src/index.ts | 2 +- packages/core/src/share.test.ts | 7 +- packages/core/src/share.ts | 5 + packages/i18n/locales/en/common.json | 5 + packages/i18n/locales/ru/common.json | 5 + services/api/internal/api/gen/api.gen.go | 892 ++++++++++++------ .../api/internal/playback/playlist_sharing.go | 69 ++ services/api/internal/playback/playlists.go | 22 +- .../playback/playlists_integration_test.go | 46 + .../api/internal/playback/playlists_test.go | 14 + .../api/migrations/00016_playlist_sharing.sql | 7 + 23 files changed, 1164 insertions(+), 319 deletions(-) create mode 100644 services/api/internal/playback/playlist_sharing.go create mode 100644 services/api/migrations/00016_playlist_sharing.sql diff --git a/api/openapi.yaml b/api/openapi.yaml index 4d9f392..d1cd2bc 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1707,6 +1707,54 @@ paths: '204': description: Deleted + /api/v1/me/playlists/{id}/share: + parameters: + - in: path + name: id + required: true + schema: { type: string, format: uuid } + post: + tags: [collection] + operationId: sharePlaylist + summary: Создать или вернуть ссылку только для чтения (только владелец) + responses: + default: + $ref: '#/components/responses/Problem' + '200': + description: Идентификатор ссылки для /shared/playlist/{shareId}; для открытия требуется вход + content: + application/json: + schema: + $ref: '#/components/schemas/PlaylistShare' + delete: + tags: [collection] + operationId: revokePlaylistShare + summary: Отозвать ссылку (только владелец); повторная публикация создаёт новую ссылку + responses: + default: + $ref: '#/components/responses/Problem' + '204': + description: Доступ по прежней ссылке отключён + /api/v1/shared/playlists/{shareId}: + parameters: + - in: path + name: shareId + required: true + schema: { type: string, format: uuid } + get: + tags: [collection] + operationId: getSharedPlaylist + summary: Прочитать плейлист по действующей ссылке (требуется вход) + responses: + default: + $ref: '#/components/responses/Problem' + '200': + description: Плейлист с реакциями и ссылками на стрим для текущего слушателя + content: + application/json: + schema: + $ref: '#/components/schemas/Playlist' + components: parameters: SourceId: @@ -1767,6 +1815,11 @@ components: schema: $ref: "#/components/schemas/Problem" schemas: + PlaylistShare: + type: object + required: [share_id] + properties: + share_id: { type: string, format: uuid } PlaylistInput: type: object required: [name, description, track_ids] @@ -1803,6 +1856,12 @@ components: - type: object required: [tracks] properties: + share_id: + type: string + format: uuid + nullable: true + readOnly: true + description: Действующая ссылка; возвращается только владельцу через /me/playlists tracks: type: array items: { $ref: '#/components/schemas/Track' } diff --git a/apps/mobile/src/app/playlists.tsx b/apps/mobile/src/app/playlists.tsx index 2689915..b6b5ab1 100644 --- a/apps/mobile/src/app/playlists.tsx +++ b/apps/mobile/src/app/playlists.tsx @@ -1,11 +1,11 @@ import type { components } from '@pipu/api-client'; -import { unwrap } from '@pipu/core'; +import { playlistShareUrl, unwrap } from '@pipu/core'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Alert, FlatList, Modal, ScrollView, View } from 'react-native'; -import { useSession, useUser } from '../lib/session'; +import { Alert, FlatList, Modal, ScrollView, Share, View } from 'react-native'; +import { useSession, useSessionCtx, useUser } from '../lib/session'; import { player } from '../player/controller'; import { Button, ErrorBox, Field, H1, Muted, Screen, TrackRow, formatDuration, useErrorText } from '../ui/kit'; @@ -78,6 +78,7 @@ function Editor({ playlist, track, onClose, onSaved }: { playlist?: Playlist; tr function Detail({ id }: { id: string }) { const session = useSession(); + const { server } = useSessionCtx(); const user = useUser(); const router = useRouter(); const { t } = useTranslation(); @@ -89,6 +90,15 @@ function Detail({ id }: { id: string }) { const key = ['m-playlists', user.id, id]; const query = useQuery({ queryKey: key, queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) }); const p = query.data; + const sharing = useMutation({ mutationFn: async () => { + if (!server || !p) throw new Error('Server unavailable'); + const token = unwrap(await session.client.POST('/api/v1/me/playlists/{id}/share', { params: { path: { id } } })).share_id; + cache.setQueryData(key, current => current ? { ...current, share_id: token } : current); + await Share.share({ title: p.name, message: playlistShareUrl(server, token) }, { dialogTitle: t('playlists.share') }); + } }); + const revoke = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}/share', { params: { path: { id } } })), + onSuccess: () => { cache.setQueryData(key, current => current ? { ...current, share_id: null } : current); } }); + const save = useMutation({ mutationFn: async (ids: string[]) => unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body: { ...input(p!), track_ids: ids } })), onSuccess: data => { cache.setQueryData(key, data); void cache.invalidateQueries({ queryKey: ['m-playlists'] }); }, onError: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); } }); @@ -105,11 +115,14 @@ function Detail({ id }: { id: string }) { return t.id} onRefresh={() => void query.refetch()} refreshing={query.isRefetching} ListHeaderComponent={ + + {!readOnly && <> + {playlist.share_id && } + } + {!readOnly &&

{t('playlists.share_hint')}

} + {copied &&

{t('playlists.link_copied')}

} + {sharing.error ? t('player.share_error') : revoke.error ? errorText(revoke.error) : null} {save.error ? errorText(save.error) : null} {!playlist.tracks.length && ( {t('playlists.find_tracks')}}> @@ -103,13 +126,13 @@ function PlaylistDetail({ playlist }: { playlist: Playlist }) { )} String(i + 1)} - actions={manage ? (track, i) => + actions={!readOnly && manage ? (track, i) => : undefined} /> - {edit && setEdit(false)} onSaved={() => setEdit(false)} />} - {remove && ( + {!readOnly && edit && setEdit(false)} onSaved={() => setEdit(false)} />} + {!readOnly && remove && ( { diff --git a/apps/web/src/shared/lib/share.test.ts b/apps/web/src/shared/lib/share.test.ts index 1436434..141e075 100644 --- a/apps/web/src/shared/lib/share.test.ts +++ b/apps/web/src/shared/lib/share.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { shareTrack } from './share'; +import { sharePlaylist, shareTrack } from './share'; +import * as desktop from '../../desktop'; afterEach(() => vi.restoreAllMocks()); @@ -38,3 +39,34 @@ describe('shareTrack', () => { finally { vi.unstubAllGlobals(); } }); }); + + +describe('sharePlaylist', () => { + it('copies the shared page URL without exposing streaming links', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { userAgent: 'Windows', clipboard: { writeText } }); + try { + expect(await sharePlaylist('token', 'Mix')).toBe('copied'); + expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/shared/playlist/token`); + } finally { vi.unstubAllGlobals(); } + }); + + it('uses the configured music server when sharing from desktop', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + vi.spyOn(desktop, 'desktopHandle').mockReturnValue({ server: 'https://music.example' } as desktop.DesktopHandle); + vi.stubGlobal('navigator', { userAgent: 'Windows', clipboard: { writeText } }); + try { + await sharePlaylist('token', 'Mix'); + expect(writeText).toHaveBeenCalledWith('https://music.example/shared/playlist/token'); + } finally { vi.unstubAllGlobals(); } + }); + + it('opens the native share sheet on mobile browsers', async () => { + const share = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { userAgent: 'Android', share }); + try { + expect(await sharePlaylist('token', 'Mix')).toBe('shared'); + expect(share).toHaveBeenCalledWith({ title: 'Mix', url: `${window.location.origin}/shared/playlist/token` }); + } finally { vi.unstubAllGlobals(); } + }); +}); diff --git a/apps/web/src/shared/lib/share.ts b/apps/web/src/shared/lib/share.ts index 911c76d..1a71e32 100644 --- a/apps/web/src/shared/lib/share.ts +++ b/apps/web/src/shared/lib/share.ts @@ -1,8 +1,16 @@ -import { trackShareUrl } from '@pipu/core'; +import { playlistShareUrl, trackShareUrl } from '@pipu/core'; +import { desktopHandle } from '../../desktop'; /** Desktop always copies; mobile browsers can open their native share sheet. */ export async function shareTrack(id: string, title: string): Promise<'copied' | 'shared' | 'cancelled'> { - const url = trackShareUrl(window.location.origin, id); + return shareUrl(trackShareUrl(desktopHandle()?.server ?? window.location.origin, id), title); +} + +export async function sharePlaylist(shareId: string, title: string): Promise<'copied' | 'shared' | 'cancelled'> { + return shareUrl(playlistShareUrl(desktopHandle()?.server ?? window.location.origin, shareId), title); +} + +async function shareUrl(url: string, title: string): Promise<'copied' | 'shared' | 'cancelled'> { if (/Android|iPhone|iPad|iPod/i.test(navigator.userAgent) && navigator.share) { try { await navigator.share({ title, url }); diff --git a/apps/web/src/test/fakeApi.ts b/apps/web/src/test/fakeApi.ts index 6957de8..0de4864 100644 --- a/apps/web/src/test/fakeApi.ts +++ b/apps/web/src/test/fakeApi.ts @@ -76,6 +76,7 @@ export function fakeApi(initial: FakeUser[]) { let token = 0; const calls: { method: string; path: string; body: unknown }[] = []; const reactions = new Map(); + const playlistOwners = new Map(); const playlists = new Map(); const me = (u: FakeUser): Me => ({ @@ -157,20 +158,37 @@ export function fakeApi(initial: FakeUser[]) { if (!user || !req.headers.get('Authorization')) return problem(401, 'auth.unauthorized'); - if (path === '/api/v1/me/playlists' && req.method === 'GET') return json({ items: [...playlists.values()] }); + + const sharedPath = /^\/api\/v1\/shared\/playlists\/([^/]+)$/.exec(path); + if (sharedPath && req.method === 'GET') { + const item = [...playlists.values()].find(p => p.share_id === sharedPath[1]); + return item ? json({ ...item, share_id: null }) : problem(404, 'playlist.not_found'); + } + const sharePath = /^\/api\/v1\/me\/playlists\/([^/]+)\/share$/.exec(path); + if (sharePath) { + const item = playlists.get(sharePath[1]!); + if (!item || playlistOwners.get(item.id) !== user.id) return problem(404, 'playlist.not_found'); + if (req.method === 'POST') { + const share_id = item.share_id ?? `00000000-0000-4000-9001-${String(++seq).padStart(12, '0')}`; + playlists.set(item.id, { ...item, share_id }); return json({ share_id }); + } + if (req.method === 'DELETE') { playlists.set(item.id, { ...item, share_id: null }); return new Response(null, { status: 204 }); } + } + if (path === '/api/v1/me/playlists' && req.method === 'GET') return json({ items: [...playlists.values()].filter(p => !playlistOwners.has(p.id) || playlistOwners.get(p.id) === user.id) }); const playlistPath = /^\/api\/v1\/me\/playlists\/([^/]+)$/.exec(path); if (path === '/api/v1/me/playlists' && req.method === 'POST' || playlistPath) { const id = playlistPath?.[1] ?? `00000000-0000-4000-9000-${String(++seq).padStart(12, '0')}`; const old = playlists.get(id); - if (playlistPath && !old) return problem(404, 'playlist.not_found'); + if (playlistPath && (!old || (playlistOwners.has(id) && playlistOwners.get(id) !== user.id))) return problem(404, 'playlist.not_found'); if (req.method === 'GET') return json(old); if (req.method === 'DELETE') { playlists.delete(id); return new Response(null, { status: 204 }); } if (old && body?.revision !== old.revision) return problem(409, 'playlist.conflict'); const tracks = (body?.track_ids as string[]).map(id => albumTracks.find(t => t.id === id)!); - const item = { id, name: body?.name as string, description: body?.description as string, tracks, + const item = { ...old, id, name: body?.name as string, description: body?.description as string, tracks, track_count: tracks.length, duration_ms: tracks.reduce((sum, t) => sum + t.duration_ms, 0), revision: (old?.revision ?? 0) + 1, created_at: old?.created_at ?? new Date().toISOString(), updated_at: new Date().toISOString() }; playlists.set(id, item); + if (!old) playlistOwners.set(id, user.id); return json(item, old ? 200 : 201); } diff --git a/docs/playlists.md b/docs/playlists.md index 1537f50..7861bd3 100644 --- a/docs/playlists.md +++ b/docs/playlists.md @@ -1,6 +1,6 @@ # Личные плейлисты -Плейлисты хранятся в PostgreSQL и доступны владельцу на вебе и в React Native-клиенте. В вебе раздел находится в коллекции; на узком экране — в меню «Ещё». В мобильном клиенте — кнопка «Плейлисты» в коллекции. +Плейлисты хранятся в PostgreSQL. Владелец управляет ими в вебе, desktop и React Native-клиенте; по желанию может поделиться ссылкой для прослушивания. В вебе раздел находится в коллекции; на узком экране — в меню «Ещё». В мобильном клиенте — кнопка «Плейлисты» в коллекции. Можно создать плейлист, изменить название и описание, добавить трек из поиска, альбома или коллекции, убрать трек и переместить его выше/ниже. В вебе можно добавить целый альбом. Один трек встречается в плейлисте один раз: повторное добавление не создаёт дубль. Воспроизведение доступно в сохранённом или случайном порядке; недоступные треки остаются видимыми, но пропускаются при запуске всего плейлиста. Удаление плейлиста требует подтверждения и не удаляет музыку из каталога. @@ -11,12 +11,31 @@ - `GET /api/v1/me/playlists/{id}` — метаданные и упорядоченные треки с персональными реакциями и подписанными ссылками. - `PUT /api/v1/me/playlists/{id}` — атомарная замена метаданных и порядка треков; дополнительно требуется `revision` из прочитанного плейлиста. - `DELETE /api/v1/me/playlists/{id}` — удаление. +- `POST /api/v1/me/playlists/{id}/share` — создать или вернуть действующий `share_id` (только владелец). +- `DELETE /api/v1/me/playlists/{id}/share` — отключить доступ по ссылке (только владелец). +- `GET /api/v1/shared/playlists/{shareId}` — прочитать плейлист по действующей ссылке; требуется вход на этот сервер. Все операции требуют авторизации. Чужой и отсутствующий плейлист возвращают одинаковый `404`. Изменения блокируют строку плейлиста в транзакции; устаревшая версия возвращает `409 playlist.conflict`. Редактор сохраняет исходную версию до закрытия, даже если фоновый запрос получил новую. Ошибка трека откатывает изменение целиком. Ввод ограничен 120 символами названия, 2000 символами описания и 2000 уникальными треками. Миграция `00013_playlists.sql` создаёт таблицы `playlists`, `playlist_tracks` и индексы. Удаление пользователя очищает его плейлисты; удаление трека из каталога очищает его членство. Таблицы не затрагивают музыкальные файлы. -Плейлисты личные; публичные ссылки, совместное редактирование и загрузка пользовательских обложек не входят в эту реализацию. Карточки имеют градиентную обложку, страница использует обложку первого трека. Контекст воспроизведения — `collection` с UUID и названием плейлиста. +Изначально плейлисты личные. Кнопка «Поделиться плейлистом» включает доступ по ссылке +`/shared/playlist/{shareId}`. В вебе/desktop ссылка копируется, в мобильном браузере и Android открывается +системное меню «Поделиться». Desktop использует адрес музыкального сервера, а не локального WebView. +Как и ссылки на треки, ссылка требует входа на тот же сервер. После входа открывается запрошенный плейлист, +без автоматического запуска музыки. Получатель может слушать в обычном или случайном порядке и переслать +ту же ссылку, но не может редактировать или удалять исходный плейлист. + +Ссылка содержит отдельный случайный UUID и не раскрывает токены авторизации или подписанные URL аудио. +Список `/me/playlists` и обычное чтение по UUID плейлиста остаются доступны только владельцу. Реакции +на треки и ссылки стрима в общем плейлисте принадлежат текущему слушателю. Данные общих страниц в веб-кэше +разделяются по пользователям. + +«Отключить доступ по ссылке» отзывает прежний `share_id`; следующая публикация создаёт новый. +Повторное нажатие «Поделиться» без отзыва сохраняет ссылку. Ссылка показывает актуальный порядок и метаданные; +изменение доступа не меняет `revision` и не вызывает конфликт редактора. При удалении плейлиста или блокировке +владельца ссылка возвращает `404`. Миграция `00016_playlist_sharing.sql` добавляет `share_id` и уникальный индекс. +Совместное редактирование и загрузка пользовательских обложек не входят в эту реализацию. Карточки имеют градиентную обложку, страница использует обложку первого трека. Контекст воспроизведения — `collection` с UUID и названием плейлиста. ## Проверки diff --git a/packages/api-client/src/generated/schema.ts b/packages/api-client/src/generated/schema.ts index b509136..b9eace6 100644 --- a/packages/api-client/src/generated/schema.ts +++ b/packages/api-client/src/generated/schema.ts @@ -1439,10 +1439,53 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/me/playlists/{id}/share": { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** Создать или вернуть ссылку только для чтения (только владелец) */ + post: operations["sharePlaylist"]; + /** Отозвать ссылку (только владелец); повторная публикация создаёт новую ссылку */ + delete: operations["revokePlaylistShare"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/shared/playlists/{shareId}": { + parameters: { + query?: never; + header?: never; + path: { + shareId: string; + }; + cookie?: never; + }; + /** Прочитать плейлист по действующей ссылке (требуется вход) */ + get: operations["getSharedPlaylist"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { schemas: { + PlaylistShare: { + /** Format: uuid */ + share_id: string; + }; PlaylistInput: { name: string; description: string; @@ -1466,6 +1509,11 @@ export interface components { updated_at: string; }; Playlist: components["schemas"]["PlaylistSummary"] & { + /** + * Format: uuid + * @description Действующая ссылка; возвращается только владельцу через /me/playlists + */ + readonly share_id?: string | null; tracks: components["schemas"]["Track"][]; }; PlaylistList: { @@ -4607,4 +4655,71 @@ export interface operations { default: components["responses"]["Problem"]; }; }; + sharePlaylist: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Идентификатор ссылки для /shared/playlist/{shareId}; для открытия требуется вход */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PlaylistShare"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + revokePlaylistShare: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Доступ по прежней ссылке отключён */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + default: components["responses"]["Problem"]; + }; + }; + getSharedPlaylist: { + parameters: { + query?: never; + header?: never; + path: { + shareId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Плейлист с реакциями и ссылками на стрим для текущего слушателя */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Playlist"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 09df8e9..5540f02 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -22,7 +22,7 @@ export { type Track, } from './player'; export { normalizeGain, TARGET_LUFS } from './gain'; -export { trackShareUrl } from './share'; +export { playlistShareUrl, trackShareUrl } from './share'; export { checkServer, normalizeServerUrl, type ServerCheck } from './server'; export { initialWaveState, mix, rgb, rgba, step, WAVE_COLORS, wavePalette, waveScale, type Rgb, type WaveState } from './wave-anim'; diff --git a/packages/core/src/share.test.ts b/packages/core/src/share.test.ts index 5575b4c..97cc752 100644 --- a/packages/core/src/share.test.ts +++ b/packages/core/src/share.test.ts @@ -1,9 +1,14 @@ import { describe, expect, it } from 'vitest'; -import { trackShareUrl } from './share'; +import { playlistShareUrl, trackShareUrl } from './share'; describe('trackShareUrl', () => { it('builds a page URL without stream credentials', () => { expect(trackShareUrl('https://music.example', 'ab/cd')).toBe('https://music.example/track/ab%2Fcd'); }); }); + + +it('builds a shared playlist URL from the server without stream credentials', () => { + expect(playlistShareUrl('https://music.example/api/v1', 'ab/cd')).toBe('https://music.example/shared/playlist/ab%2Fcd'); +}); diff --git a/packages/core/src/share.ts b/packages/core/src/share.ts index 6fac8ea..ebe74eb 100644 --- a/packages/core/src/share.ts +++ b/packages/core/src/share.ts @@ -2,3 +2,8 @@ export function trackShareUrl(server: string, trackId: string): string { return new URL(`/track/${encodeURIComponent(trackId)}`, server).href; } + +/** Revocable read-only playlist page; requires a listener account on this server. */ +export function playlistShareUrl(server: string, shareId: string): string { + return new URL(`/shared/playlist/${encodeURIComponent(shareId)}`, server).href; +} diff --git a/packages/i18n/locales/en/common.json b/packages/i18n/locales/en/common.json index 103c744..b1dfec5 100644 --- a/packages/i18n/locales/en/common.json +++ b/packages/i18n/locales/en/common.json @@ -340,6 +340,11 @@ } }, "playlists": { + "share": "Share playlist", + "link_copied": "Playlist link copied", + "shared": "Shared by link", + "revoke_share": "Disable link access", + "share_hint": "Anyone with an account on this server and this link can listen. Only you can edit the playlist.", "name": "Name", "delete_confirm": "Delete playlist “{{name}}”? Tracks will remain in your library.", "manage": "Manage tracks", diff --git a/packages/i18n/locales/ru/common.json b/packages/i18n/locales/ru/common.json index 214a317..f5704da 100644 --- a/packages/i18n/locales/ru/common.json +++ b/packages/i18n/locales/ru/common.json @@ -340,6 +340,11 @@ } }, "playlists": { + "share": "Поделиться плейлистом", + "link_copied": "Ссылка на плейлист скопирована", + "shared": "Доступ по ссылке", + "revoke_share": "Отключить доступ по ссылке", + "share_hint": "Любой пользователь этого сервера со ссылкой сможет слушать плейлист. Изменять его можете только вы.", "name": "Название", "delete_confirm": "Удалить плейлист «{{name}}»? Треки останутся в библиотеке.", "manage": "Управлять треками", diff --git a/services/api/internal/api/gen/api.gen.go b/services/api/internal/api/gen/api.gen.go index a0d5b1a..e98a13d 100644 --- a/services/api/internal/api/gen/api.gen.go +++ b/services/api/internal/api/gen/api.gen.go @@ -742,9 +742,12 @@ type Playlist struct { Id openapi_types.UUID `json:"id"` Name string `json:"name"` Revision int `json:"revision"` - TrackCount int `json:"track_count"` - Tracks []Track `json:"tracks"` - UpdatedAt time.Time `json:"updated_at"` + + // ShareId Действующая ссылка; возвращается только владельцу через /me/playlists + ShareId *openapi_types.UUID `json:"share_id"` + TrackCount int `json:"track_count"` + Tracks []Track `json:"tracks"` + UpdatedAt time.Time `json:"updated_at"` } // PlaylistInput defines model for PlaylistInput. @@ -759,6 +762,11 @@ type PlaylistList struct { Items []PlaylistSummary `json:"items"` } +// PlaylistShare defines model for PlaylistShare. +type PlaylistShare struct { + ShareId openapi_types.UUID `json:"share_id"` +} + // PlaylistSummary defines model for PlaylistSummary. type PlaylistSummary struct { CreatedAt time.Time `json:"created_at"` @@ -1901,6 +1909,12 @@ type ServerInterface interface { // (PUT /api/v1/me/playlists/{id}) UpdatePlaylist(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) + // Отозвать ссылку (только владелец); повторная публикация создаёт новую ссылку + // (DELETE /api/v1/me/playlists/{id}/share) + RevokePlaylistShare(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) + // Создать или вернуть ссылку только для чтения (только владелец) + // (POST /api/v1/me/playlists/{id}/share) + SharePlaylist(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) // Снять реакцию // (DELETE /api/v1/me/reactions/tracks/{id}) ClearTrackReaction(w http.ResponseWriter, r *http.Request, id EntityId) @@ -1925,6 +1939,9 @@ type ServerInterface interface { // Сведения о сервере для клиентов // (GET /api/v1/server-info) GetServerInfo(w http.ResponseWriter, r *http.Request) + // Прочитать плейлист по действующей ссылке (требуется вход) + // (GET /api/v1/shared/playlists/{shareId}) + GetSharedPlaylist(w http.ResponseWriter, r *http.Request, shareId openapi_types.UUID) // Аудио трека (поддерживает Range) // (GET /api/v1/stream/{id}) StreamTrack(w http.ResponseWriter, r *http.Request, id EntityId, params StreamTrackParams) @@ -2362,6 +2379,18 @@ func (_ Unimplemented) UpdatePlaylist(w http.ResponseWriter, r *http.Request, id w.WriteHeader(http.StatusNotImplemented) } +// Отозвать ссылку (только владелец); повторная публикация создаёт новую ссылку +// (DELETE /api/v1/me/playlists/{id}/share) +func (_ Unimplemented) RevokePlaylistShare(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Создать или вернуть ссылку только для чтения (только владелец) +// (POST /api/v1/me/playlists/{id}/share) +func (_ Unimplemented) SharePlaylist(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) { + w.WriteHeader(http.StatusNotImplemented) +} + // Снять реакцию // (DELETE /api/v1/me/reactions/tracks/{id}) func (_ Unimplemented) ClearTrackReaction(w http.ResponseWriter, r *http.Request, id EntityId) { @@ -2410,6 +2439,12 @@ func (_ Unimplemented) GetServerInfo(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } +// Прочитать плейлист по действующей ссылке (требуется вход) +// (GET /api/v1/shared/playlists/{shareId}) +func (_ Unimplemented) GetSharedPlaylist(w http.ResponseWriter, r *http.Request, shareId openapi_types.UUID) { + w.WriteHeader(http.StatusNotImplemented) +} + // Аудио трека (поддерживает Range) // (GET /api/v1/stream/{id}) func (_ Unimplemented) StreamTrack(w http.ResponseWriter, r *http.Request, id EntityId, params StreamTrackParams) { @@ -4388,6 +4423,68 @@ func (siw *ServerInterfaceWrapper) UpdatePlaylist(w http.ResponseWriter, r *http handler.ServeHTTP(w, r) } +// RevokePlaylistShare operation middleware +func (siw *ServerInterfaceWrapper) RevokePlaylistShare(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "id" ------------- + var id openapi_types.UUID + + err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.RevokePlaylistShare(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// SharePlaylist operation middleware +func (siw *ServerInterfaceWrapper) SharePlaylist(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "id" ------------- + var id openapi_types.UUID + + err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.SharePlaylist(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // ClearTrackReaction operation middleware func (siw *ServerInterfaceWrapper) ClearTrackReaction(w http.ResponseWriter, r *http.Request) { @@ -4639,6 +4736,37 @@ func (siw *ServerInterfaceWrapper) GetServerInfo(w http.ResponseWriter, r *http. handler.ServeHTTP(w, r) } +// GetSharedPlaylist operation middleware +func (siw *ServerInterfaceWrapper) GetSharedPlaylist(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "shareId" ------------- + var shareId openapi_types.UUID + + err = runtime.BindStyledParameterWithOptions("simple", "shareId", chi.URLParam(r, "shareId"), &shareId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "shareId", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetSharedPlaylist(w, r, shareId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // StreamTrack operation middleware func (siw *ServerInterfaceWrapper) StreamTrack(w http.ResponseWriter, r *http.Request) { @@ -5448,6 +5576,12 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Put(options.BaseURL+"/api/v1/me/playlists/{id}", wrapper.UpdatePlaylist) }) + r.Group(func(r chi.Router) { + r.Delete(options.BaseURL+"/api/v1/me/playlists/{id}/share", wrapper.RevokePlaylistShare) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/api/v1/me/playlists/{id}/share", wrapper.SharePlaylist) + }) r.Group(func(r chi.Router) { r.Delete(options.BaseURL+"/api/v1/me/reactions/tracks/{id}", wrapper.ClearTrackReaction) }) @@ -5472,6 +5606,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/api/v1/server-info", wrapper.GetServerInfo) }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/api/v1/shared/playlists/{shareId}", wrapper.GetSharedPlaylist) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/api/v1/stream/{id}", wrapper.StreamTrack) }) @@ -7400,6 +7537,63 @@ func (response UpdatePlaylistdefaultApplicationProblemPlusJSONResponse) VisitUpd return json.NewEncoder(w).Encode(response.Body) } +type RevokePlaylistShareRequestObject struct { + Id openapi_types.UUID `json:"id"` +} + +type RevokePlaylistShareResponseObject interface { + VisitRevokePlaylistShareResponse(w http.ResponseWriter) error +} + +type RevokePlaylistShare204Response struct { +} + +func (response RevokePlaylistShare204Response) VisitRevokePlaylistShareResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type RevokePlaylistSharedefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response RevokePlaylistSharedefaultApplicationProblemPlusJSONResponse) VisitRevokePlaylistShareResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + +type SharePlaylistRequestObject struct { + Id openapi_types.UUID `json:"id"` +} + +type SharePlaylistResponseObject interface { + VisitSharePlaylistResponse(w http.ResponseWriter) error +} + +type SharePlaylist200JSONResponse PlaylistShare + +func (response SharePlaylist200JSONResponse) VisitSharePlaylistResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type SharePlaylistdefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response SharePlaylistdefaultApplicationProblemPlusJSONResponse) VisitSharePlaylistResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + type ClearTrackReactionRequestObject struct { Id EntityId `json:"id"` } @@ -7627,6 +7821,35 @@ func (response GetServerInfodefaultApplicationProblemPlusJSONResponse) VisitGetS return json.NewEncoder(w).Encode(response.Body) } +type GetSharedPlaylistRequestObject struct { + ShareId openapi_types.UUID `json:"shareId"` +} + +type GetSharedPlaylistResponseObject interface { + VisitGetSharedPlaylistResponse(w http.ResponseWriter) error +} + +type GetSharedPlaylist200JSONResponse Playlist + +func (response GetSharedPlaylist200JSONResponse) VisitGetSharedPlaylistResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetSharedPlaylistdefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response GetSharedPlaylistdefaultApplicationProblemPlusJSONResponse) VisitGetSharedPlaylistResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + type StreamTrackRequestObject struct { Id EntityId `json:"id"` Params StreamTrackParams @@ -8323,6 +8546,12 @@ type StrictServerInterface interface { // (PUT /api/v1/me/playlists/{id}) UpdatePlaylist(ctx context.Context, request UpdatePlaylistRequestObject) (UpdatePlaylistResponseObject, error) + // Отозвать ссылку (только владелец); повторная публикация создаёт новую ссылку + // (DELETE /api/v1/me/playlists/{id}/share) + RevokePlaylistShare(ctx context.Context, request RevokePlaylistShareRequestObject) (RevokePlaylistShareResponseObject, error) + // Создать или вернуть ссылку только для чтения (только владелец) + // (POST /api/v1/me/playlists/{id}/share) + SharePlaylist(ctx context.Context, request SharePlaylistRequestObject) (SharePlaylistResponseObject, error) // Снять реакцию // (DELETE /api/v1/me/reactions/tracks/{id}) ClearTrackReaction(ctx context.Context, request ClearTrackReactionRequestObject) (ClearTrackReactionResponseObject, error) @@ -8347,6 +8576,9 @@ type StrictServerInterface interface { // Сведения о сервере для клиентов // (GET /api/v1/server-info) GetServerInfo(ctx context.Context, request GetServerInfoRequestObject) (GetServerInfoResponseObject, error) + // Прочитать плейлист по действующей ссылке (требуется вход) + // (GET /api/v1/shared/playlists/{shareId}) + GetSharedPlaylist(ctx context.Context, request GetSharedPlaylistRequestObject) (GetSharedPlaylistResponseObject, error) // Аудио трека (поддерживает Range) // (GET /api/v1/stream/{id}) StreamTrack(ctx context.Context, request StreamTrackRequestObject) (StreamTrackResponseObject, error) @@ -10207,6 +10439,58 @@ func (sh *strictHandler) UpdatePlaylist(w http.ResponseWriter, r *http.Request, } } +// RevokePlaylistShare operation middleware +func (sh *strictHandler) RevokePlaylistShare(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) { + var request RevokePlaylistShareRequestObject + + request.Id = id + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.RevokePlaylistShare(ctx, request.(RevokePlaylistShareRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "RevokePlaylistShare") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(RevokePlaylistShareResponseObject); ok { + if err := validResponse.VisitRevokePlaylistShareResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// SharePlaylist operation middleware +func (sh *strictHandler) SharePlaylist(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) { + var request SharePlaylistRequestObject + + request.Id = id + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.SharePlaylist(ctx, request.(SharePlaylistRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "SharePlaylist") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(SharePlaylistResponseObject); ok { + if err := validResponse.VisitSharePlaylistResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // ClearTrackReaction operation middleware func (sh *strictHandler) ClearTrackReaction(w http.ResponseWriter, r *http.Request, id EntityId) { var request ClearTrackReactionRequestObject @@ -10416,6 +10700,32 @@ func (sh *strictHandler) GetServerInfo(w http.ResponseWriter, r *http.Request) { } } +// GetSharedPlaylist operation middleware +func (sh *strictHandler) GetSharedPlaylist(w http.ResponseWriter, r *http.Request, shareId openapi_types.UUID) { + var request GetSharedPlaylistRequestObject + + request.ShareId = shareId + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetSharedPlaylist(ctx, request.(GetSharedPlaylistRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetSharedPlaylist") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetSharedPlaylistResponseObject); ok { + if err := validResponse.VisitGetSharedPlaylistResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // StreamTrack operation middleware func (sh *strictHandler) StreamTrack(w http.ResponseWriter, r *http.Request, id EntityId, params StreamTrackParams) { var request StreamTrackRequestObject @@ -10910,291 +11220,297 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "7iBqh3IBAA==", + "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", + "FISFPIV+v3Hv1r3/PwAA//8ZFRgBKHoBAA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/services/api/internal/playback/playlist_sharing.go b/services/api/internal/playback/playlist_sharing.go new file mode 100644 index 0000000..44e6428 --- /dev/null +++ b/services/api/internal/playback/playlist_sharing.go @@ -0,0 +1,69 @@ +package playback + +import ( + "context" + "errors" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + + "pipu/api/internal/api/gen" + "pipu/api/internal/platform/apperr" +) + +// Repeated sharing preserves the existing URL. After revocation a fresh UUID is allocated. +func (st *Store) sharePlaylist(ctx context.Context, owner, id uuid.UUID) (uuid.UUID, error) { + var shareID uuid.UUID + err := st.pool.QueryRow(ctx, `UPDATE playlists SET share_id=COALESCE(share_id,$3) + WHERE id=$1 AND user_id=$2 RETURNING share_id`, id, owner, uuid.New()).Scan(&shareID) + if errors.Is(err, pgx.ErrNoRows) { + return uuid.Nil, apperr.NotFound("playlist.not_found", "playlist not found") + } + return shareID, err +} + +func (st *Store) revokePlaylistShare(ctx context.Context, owner, id uuid.UUID) error { + result, err := st.pool.Exec(ctx, `UPDATE playlists SET share_id=NULL WHERE id=$1 AND user_id=$2`, id, owner) + if err != nil { + return err + } + if result.RowsAffected() == 0 { + return apperr.NotFound("playlist.not_found", "playlist not found") + } + return nil +} + +func (h *Handler) SharePlaylist(ctx context.Context, req gen.SharePlaylistRequestObject) (gen.SharePlaylistResponseObject, error) { + user, err := principal(ctx) + if err != nil { + return nil, err + } + shareID, err := h.store.sharePlaylist(ctx, user.UserID, req.Id) + if err != nil { + return nil, err + } + return gen.SharePlaylist200JSONResponse{ShareId: shareID}, nil +} + +func (h *Handler) RevokePlaylistShare(ctx context.Context, req gen.RevokePlaylistShareRequestObject) (gen.RevokePlaylistShareResponseObject, error) { + user, err := principal(ctx) + if err != nil { + return nil, err + } + if err = h.store.revokePlaylistShare(ctx, user.UserID, req.Id); err != nil { + return nil, err + } + return gen.RevokePlaylistShare204Response{}, nil +} + +func (h *Handler) GetSharedPlaylist(ctx context.Context, req gen.GetSharedPlaylistRequestObject) (gen.GetSharedPlaylistResponseObject, error) { + user, err := principal(ctx) + if err != nil { + return nil, err + } + p, err := h.readPlaylist(ctx, user.UserID, req.ShareId, true) + if err != nil { + return nil, err + } + return gen.GetSharedPlaylist200JSONResponse(p), nil +} diff --git a/services/api/internal/playback/playlists.go b/services/api/internal/playback/playlists.go index 0e4abcc..124b333 100644 --- a/services/api/internal/playback/playlists.go +++ b/services/api/internal/playback/playlists.go @@ -67,17 +67,33 @@ func (h *Handler) ListPlaylists(ctx context.Context, _ gen.ListPlaylistsRequestO } func (h *Handler) playlist(ctx context.Context, user, id uuid.UUID) (gen.Playlist, error) { + return h.readPlaylist(ctx, user, id, false) +} + +func (h *Handler) readPlaylist(ctx context.Context, user, id uuid.UUID, shared bool) (gen.Playlist, error) { var out gen.Playlist tx, err := h.store.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}) if err != nil { return out, err } defer tx.Rollback(ctx) - p, err := scanPlaylist(tx.QueryRow(ctx, playlistSelect+` WHERE p.user_id=$1 AND p.id=$2`, user, id)) + filter := ` WHERE p.user_id=$1 AND p.id=$2` + args := []any{user, id} + if shared { + filter = ` WHERE p.share_id=$1 AND EXISTS (SELECT 1 FROM users u WHERE u.id=p.user_id AND NOT u.is_blocked)` + args = []any{id} + } + p, err := scanPlaylist(tx.QueryRow(ctx, playlistSelect+filter, args...)) if err != nil { return out, err } - rows, err := tx.Query(ctx, `SELECT track_id FROM playlist_tracks WHERE playlist_id=$1 ORDER BY position`, id) + var shareID *uuid.UUID + if !shared { + if err = tx.QueryRow(ctx, `SELECT share_id FROM playlists WHERE id=$1`, p.Id).Scan(&shareID); err != nil { + return out, err + } + } + rows, err := tx.Query(ctx, `SELECT track_id FROM playlist_tracks WHERE playlist_id=$1 ORDER BY position`, p.Id) if err != nil { return out, err } @@ -99,7 +115,7 @@ func (h *Handler) playlist(ctx context.Context, user, id uuid.UUID) (gen.Playlis if dtos == nil { dtos = []gen.Track{} } - out = gen.Playlist{Id: p.Id, Name: p.Name, Description: p.Description, Revision: p.Revision, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, TrackCount: len(dtos), Tracks: dtos} + out = gen.Playlist{ShareId: shareID, Id: p.Id, Name: p.Name, Description: p.Description, Revision: p.Revision, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, TrackCount: len(dtos), Tracks: dtos} for _, t := range dtos { out.DurationMs += int64(t.DurationMs) } diff --git a/services/api/internal/playback/playlists_integration_test.go b/services/api/internal/playback/playlists_integration_test.go index c7d35c8..d394a2c 100644 --- a/services/api/internal/playback/playlists_integration_test.go +++ b/services/api/internal/playback/playlists_integration_test.go @@ -54,6 +54,40 @@ func TestPlaylistPersistenceAndIsolation(t *testing.T) { require.ErrorIs(t, st.savePlaylist(ctx, other, id, "stolen", "", nil, 1, false), apperr.NotFound("playlist.not_found", "")) _, err = h.DeletePlaylist(auth.WithPrincipal(ctx, auth.Principal{UserID: other}), gen.DeletePlaylistRequestObject{Id: id}) require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", "")) + + // Sharing grants read access through a separate opaque ID, never through the private owner endpoint. + ownerCtx := auth.WithPrincipal(ctx, auth.Principal{UserID: owner}) + otherCtx := auth.WithPrincipal(ctx, auth.Principal{UserID: other}) + _, err = h.GetSharedPlaylist(otherCtx, gen.GetSharedPlaylistRequestObject{ShareId: id}) + require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", "")) + _, err = st.sharePlaylist(ctx, other, id) + require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", "")) + shareID, err := st.sharePlaylist(ctx, owner, id) + require.NoError(t, err) + require.NotEqual(t, id, shareID) + sameID, err := st.sharePlaylist(ctx, owner, id) + require.NoError(t, err) + require.Equal(t, shareID, sameID) + response, err := h.GetSharedPlaylist(otherCtx, gen.GetSharedPlaylistRequestObject{ShareId: shareID}) + require.NoError(t, err) + shared := gen.Playlist(response.(gen.GetSharedPlaylist200JSONResponse)) + require.Equal(t, []uuid.UUID{first, second}, []uuid.UUID{shared.Tracks[0].Id, shared.Tracks[1].Id}) + require.Nil(t, shared.ShareId, "the public DTO does not expose owner sharing controls") + got, err = h.playlist(ctx, owner, id) + require.NoError(t, err) + require.Equal(t, shareID, *got.ShareId) + require.Equal(t, 1, got.Revision, "sharing does not conflict with track editing") + _, err = h.GetPlaylist(otherCtx, gen.GetPlaylistRequestObject{Id: id}) + require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", "")) + _, err = h.RevokePlaylistShare(otherCtx, gen.RevokePlaylistShareRequestObject{Id: id}) + require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", "")) + _, err = h.RevokePlaylistShare(ownerCtx, gen.RevokePlaylistShareRequestObject{Id: id}) + require.NoError(t, err) + _, err = h.GetSharedPlaylist(otherCtx, gen.GetSharedPlaylistRequestObject{ShareId: shareID}) + require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", "")) + freshID, err := st.sharePlaylist(ctx, owner, id) + require.NoError(t, err) + require.NotEqual(t, shareID, freshID, "revoked links never revive") require.NoError(t, st.savePlaylist(ctx, owner, id, "Mix", "", []uuid.UUID{second, first}, 1, false)) require.ErrorIs(t, st.savePlaylist(ctx, owner, id, "stale", "", nil, 1, false), apperr.Conflict("playlist.conflict", "")) // A missing track rolls back both metadata and membership. @@ -63,6 +97,16 @@ func TestPlaylistPersistenceAndIsolation(t *testing.T) { require.Equal(t, 2, got.Revision) require.Equal(t, "Mix", got.Name) require.Equal(t, second, got.Tracks[0].Id) + response, err = h.GetSharedPlaylist(otherCtx, gen.GetSharedPlaylistRequestObject{ShareId: freshID}) + require.NoError(t, err) + shared = gen.Playlist(response.(gen.GetSharedPlaylist200JSONResponse)) + require.Equal(t, second, shared.Tracks[0].Id, "shared pages follow edits to the live playlist") + _, err = pool.Exec(ctx, `UPDATE users SET is_blocked=true WHERE id=$1`, owner) + require.NoError(t, err) + _, err = h.GetSharedPlaylist(otherCtx, gen.GetSharedPlaylistRequestObject{ShareId: freshID}) + require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", "")) + _, err = pool.Exec(ctx, `UPDATE users SET is_blocked=false WHERE id=$1`, owner) + require.NoError(t, err) // Exactly one concurrent write may consume a revision. results := make(chan error, 2) var wg sync.WaitGroup @@ -86,6 +130,8 @@ func TestPlaylistPersistenceAndIsolation(t *testing.T) { require.Equal(t, 1, successes) _, err = h.DeletePlaylist(auth.WithPrincipal(ctx, auth.Principal{UserID: owner}), gen.DeletePlaylistRequestObject{Id: id}) require.NoError(t, err) + _, err = h.GetSharedPlaylist(otherCtx, gen.GetSharedPlaylistRequestObject{ShareId: freshID}) + require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", "")) var remaining int require.NoError(t, pool.QueryRow(ctx, `SELECT count(*) FROM playlist_tracks WHERE playlist_id=$1`, id).Scan(&remaining)) require.Zero(t, remaining) diff --git a/services/api/internal/playback/playlists_test.go b/services/api/internal/playback/playlists_test.go index 24fc91a..db69224 100644 --- a/services/api/internal/playback/playlists_test.go +++ b/services/api/internal/playback/playlists_test.go @@ -1,8 +1,11 @@ package playback import ( + "context" "github.com/google/uuid" "github.com/stretchr/testify/require" + "pipu/api/internal/api/gen" + "pipu/api/internal/platform/apperr" "strings" "testing" ) @@ -19,3 +22,14 @@ func TestValidatePlaylist(t *testing.T) { require.Error(t, validatePlaylist("Mix", "", []uuid.UUID{uuid.Nil})) require.Error(t, validatePlaylist("Mix", "", make([]uuid.UUID, 2001))) } + +func TestPlaylistSharingRequiresAuthentication(t *testing.T) { + h := &Handler{} + ctx := context.Background() + _, err := h.SharePlaylist(ctx, gen.SharePlaylistRequestObject{Id: uuid.New()}) + require.ErrorIs(t, err, apperr.Unauthorized("auth.unauthorized", "")) + _, err = h.RevokePlaylistShare(ctx, gen.RevokePlaylistShareRequestObject{Id: uuid.New()}) + require.ErrorIs(t, err, apperr.Unauthorized("auth.unauthorized", "")) + _, err = h.GetSharedPlaylist(ctx, gen.GetSharedPlaylistRequestObject{ShareId: uuid.New()}) + require.ErrorIs(t, err, apperr.Unauthorized("auth.unauthorized", "")) +} diff --git a/services/api/migrations/00016_playlist_sharing.sql b/services/api/migrations/00016_playlist_sharing.sql new file mode 100644 index 0000000..bf8ae9f --- /dev/null +++ b/services/api/migrations/00016_playlist_sharing.sql @@ -0,0 +1,7 @@ +-- +goose Up +ALTER TABLE playlists ADD COLUMN share_id uuid; +CREATE UNIQUE INDEX playlists_share_id_idx ON playlists (share_id) WHERE share_id IS NOT NULL; + +-- +goose Down +DROP INDEX playlists_share_id_idx; +ALTER TABLE playlists DROP COLUMN share_id;