2026-04-08 15:14:03 +03:00
|
|
|
use std::sync::Arc;
|
|
|
|
|
use uuid::Uuid;
|
|
|
|
|
|
|
|
|
|
use crate::application::dtos::playlist_dto::{
|
|
|
|
|
AddTracksDto, AudioMetadataDto, CreatePlaylistDto, PlaylistDto, PlaylistItemDto,
|
|
|
|
|
PlaylistQueryDto, PlaylistShareInfoDto, ReorderTracksDto, SharePlaylistDto, UpdatePlaylistDto,
|
|
|
|
|
};
|
2026-07-04 23:31:10 +02:00
|
|
|
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
2026-04-08 15:14:03 +03:00
|
|
|
use crate::application::ports::music_ports::{MusicStoragePort, MusicUseCase};
|
|
|
|
|
use crate::common::errors::{DomainError, ErrorKind};
|
2026-07-04 23:31:10 +02:00
|
|
|
use crate::domain::services::authorization::{Permission, Resource, Subject};
|
2026-04-08 15:14:03 +03:00
|
|
|
use crate::infrastructure::adapters::music_storage_adapter::MusicStorageAdapter;
|
2026-07-04 23:31:10 +02:00
|
|
|
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
2026-04-08 15:14:03 +03:00
|
|
|
|
|
|
|
|
pub struct MusicService {
|
|
|
|
|
storage: Arc<MusicStorageAdapter>,
|
2026-07-04 23:31:10 +02:00
|
|
|
/// ReBAC engine — Round 1 fix from `docs/plan/authz_audit/`.
|
|
|
|
|
/// Currently used ONLY by `get_audio_metadata` to close the
|
|
|
|
|
/// cross-tenant IDOR (`_user_id: Uuid` was deliberately unused).
|
|
|
|
|
/// The full engine rewrite (Round 3 — `Resource::Playlist` +
|
|
|
|
|
/// authz.require on every playlist verb) is a separate PR;
|
|
|
|
|
/// don't extend the bespoke `user_has_access` / `user_can_write`
|
|
|
|
|
/// pattern to new methods, use `require` here instead.
|
|
|
|
|
authorization: Arc<PgAclEngine>,
|
2026-04-08 15:14:03 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl MusicService {
|
2026-07-04 23:31:10 +02:00
|
|
|
pub fn new(storage: Arc<MusicStorageAdapter>, authorization: Arc<PgAclEngine>) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
storage,
|
|
|
|
|
authorization,
|
|
|
|
|
}
|
2026-04-08 15:14:03 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl MusicUseCase for MusicService {
|
|
|
|
|
async fn create_playlist(
|
|
|
|
|
&self,
|
|
|
|
|
dto: CreatePlaylistDto,
|
|
|
|
|
user_id: Uuid,
|
|
|
|
|
) -> Result<PlaylistDto, DomainError> {
|
|
|
|
|
self.storage.create_playlist(dto, user_id).await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn update_playlist(
|
|
|
|
|
&self,
|
|
|
|
|
playlist_id: &str,
|
|
|
|
|
dto: UpdatePlaylistDto,
|
|
|
|
|
user_id: Uuid,
|
|
|
|
|
) -> Result<PlaylistDto, DomainError> {
|
|
|
|
|
let has_access = self.storage.user_has_access(playlist_id, user_id).await?;
|
|
|
|
|
if !has_access {
|
|
|
|
|
return Err(DomainError::new(
|
|
|
|
|
ErrorKind::AccessDenied,
|
|
|
|
|
"Playlist",
|
|
|
|
|
"You don't have permission to update this playlist",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let can_write = self.storage.user_can_write(playlist_id, user_id).await?;
|
|
|
|
|
if !can_write {
|
|
|
|
|
return Err(DomainError::new(
|
|
|
|
|
ErrorKind::AccessDenied,
|
|
|
|
|
"Playlist",
|
|
|
|
|
"You need write access to update this playlist",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
self.storage.update_playlist(playlist_id, dto).await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn delete_playlist(&self, playlist_id: &str, user_id: Uuid) -> Result<(), DomainError> {
|
|
|
|
|
let playlist = self.storage.get_playlist(playlist_id).await?;
|
|
|
|
|
let playlist = match playlist {
|
|
|
|
|
Some(p) => p,
|
|
|
|
|
None => {
|
|
|
|
|
return Err(DomainError::new(
|
|
|
|
|
ErrorKind::NotFound,
|
|
|
|
|
"Playlist",
|
|
|
|
|
"Playlist not found",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
if playlist.owner_id != user_id.to_string() {
|
|
|
|
|
return Err(DomainError::new(
|
|
|
|
|
ErrorKind::AccessDenied,
|
|
|
|
|
"Playlist",
|
|
|
|
|
"Only the owner can delete this playlist",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
self.storage.delete_playlist(playlist_id).await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn get_playlist(
|
|
|
|
|
&self,
|
|
|
|
|
playlist_id: &str,
|
|
|
|
|
user_id: Uuid,
|
|
|
|
|
) -> Result<PlaylistDto, DomainError> {
|
|
|
|
|
let has_access = self.storage.user_has_access(playlist_id, user_id).await?;
|
|
|
|
|
if !has_access {
|
|
|
|
|
return Err(DomainError::new(
|
|
|
|
|
ErrorKind::AccessDenied,
|
|
|
|
|
"Playlist",
|
|
|
|
|
"You don't have permission to view this playlist",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let playlist = self.storage.get_playlist(playlist_id).await?;
|
|
|
|
|
match playlist {
|
|
|
|
|
Some(p) => Ok(p),
|
|
|
|
|
None => Err(DomainError::new(
|
|
|
|
|
ErrorKind::NotFound,
|
|
|
|
|
"Playlist",
|
|
|
|
|
"Playlist not found",
|
|
|
|
|
)),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn list_playlists(
|
|
|
|
|
&self,
|
|
|
|
|
query: PlaylistQueryDto,
|
|
|
|
|
user_id: Uuid,
|
|
|
|
|
) -> Result<Vec<PlaylistDto>, DomainError> {
|
|
|
|
|
let include_shared = query.include_shared.unwrap_or(true);
|
|
|
|
|
let include_public = query.include_public.unwrap_or(false);
|
|
|
|
|
let limit = query.limit.unwrap_or(100);
|
|
|
|
|
let offset = query.offset.unwrap_or(0);
|
|
|
|
|
|
|
|
|
|
let mut playlists = Vec::new();
|
|
|
|
|
|
|
|
|
|
let owned = self.storage.list_playlists_by_owner(user_id).await?;
|
|
|
|
|
playlists.extend(owned);
|
|
|
|
|
|
|
|
|
|
if include_shared {
|
|
|
|
|
let shared = self.storage.list_shared_with_user(user_id).await?;
|
|
|
|
|
for s in shared {
|
|
|
|
|
if !playlists.iter().any(|p: &PlaylistDto| p.id == s.id) {
|
|
|
|
|
playlists.push(s);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if include_public {
|
|
|
|
|
let public = self.storage.list_public_playlists(limit, offset).await?;
|
|
|
|
|
for p in public {
|
|
|
|
|
if !playlists.iter().any(|pl: &PlaylistDto| pl.id == p.id) {
|
|
|
|
|
playlists.push(p);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(playlists)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn add_tracks(
|
|
|
|
|
&self,
|
|
|
|
|
playlist_id: &str,
|
|
|
|
|
dto: AddTracksDto,
|
|
|
|
|
user_id: Uuid,
|
|
|
|
|
) -> Result<Vec<PlaylistItemDto>, DomainError> {
|
|
|
|
|
let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| {
|
|
|
|
|
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID")
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
let has_access = self.storage.user_has_access(playlist_id, user_id).await?;
|
|
|
|
|
if !has_access {
|
|
|
|
|
return Err(DomainError::new(
|
|
|
|
|
ErrorKind::AccessDenied,
|
|
|
|
|
"Playlist",
|
|
|
|
|
"You don't have permission to modify this playlist",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let can_write = self.storage.user_can_write(playlist_id, user_id).await?;
|
|
|
|
|
if !can_write {
|
|
|
|
|
return Err(DomainError::new(
|
|
|
|
|
ErrorKind::AccessDenied,
|
|
|
|
|
"Playlist",
|
|
|
|
|
"You need write access to add tracks",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let file_ids: Result<Vec<Uuid>, _> =
|
|
|
|
|
dto.file_ids.iter().map(|id| Uuid::parse_str(id)).collect();
|
|
|
|
|
let file_ids = file_ids.map_err(|_| {
|
|
|
|
|
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid file ID")
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
self.storage.add_tracks(&playlist_uuid, &file_ids).await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn remove_track(
|
|
|
|
|
&self,
|
|
|
|
|
playlist_id: &str,
|
|
|
|
|
file_id: &str,
|
|
|
|
|
user_id: Uuid,
|
|
|
|
|
) -> Result<(), DomainError> {
|
|
|
|
|
let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| {
|
|
|
|
|
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID")
|
|
|
|
|
})?;
|
|
|
|
|
let file_uuid = Uuid::parse_str(file_id).map_err(|_| {
|
|
|
|
|
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid file ID")
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
let has_access = self.storage.user_has_access(playlist_id, user_id).await?;
|
|
|
|
|
if !has_access {
|
|
|
|
|
return Err(DomainError::new(
|
|
|
|
|
ErrorKind::AccessDenied,
|
|
|
|
|
"Playlist",
|
|
|
|
|
"You don't have permission to modify this playlist",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let can_write = self.storage.user_can_write(playlist_id, user_id).await?;
|
|
|
|
|
if !can_write {
|
|
|
|
|
return Err(DomainError::new(
|
|
|
|
|
ErrorKind::AccessDenied,
|
|
|
|
|
"Playlist",
|
|
|
|
|
"You need write access to remove tracks",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
self.storage.remove_track(&playlist_uuid, &file_uuid).await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn reorder_tracks(
|
|
|
|
|
&self,
|
|
|
|
|
playlist_id: &str,
|
|
|
|
|
dto: ReorderTracksDto,
|
|
|
|
|
user_id: Uuid,
|
|
|
|
|
) -> Result<(), DomainError> {
|
|
|
|
|
let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| {
|
|
|
|
|
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID")
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
let has_access = self.storage.user_has_access(playlist_id, user_id).await?;
|
|
|
|
|
if !has_access {
|
|
|
|
|
return Err(DomainError::new(
|
|
|
|
|
ErrorKind::AccessDenied,
|
|
|
|
|
"Playlist",
|
|
|
|
|
"You don't have permission to modify this playlist",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let can_write = self.storage.user_can_write(playlist_id, user_id).await?;
|
|
|
|
|
if !can_write {
|
|
|
|
|
return Err(DomainError::new(
|
|
|
|
|
ErrorKind::AccessDenied,
|
|
|
|
|
"Playlist",
|
|
|
|
|
"You need write access to reorder tracks",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let item_ids: Result<Vec<Uuid>, _> =
|
|
|
|
|
dto.item_ids.iter().map(|id| Uuid::parse_str(id)).collect();
|
|
|
|
|
let item_ids = item_ids.map_err(|_| {
|
|
|
|
|
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid item ID")
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
self.storage.reorder_tracks(&playlist_uuid, &item_ids).await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn list_playlist_tracks(
|
|
|
|
|
&self,
|
|
|
|
|
playlist_id: &str,
|
|
|
|
|
user_id: Uuid,
|
|
|
|
|
) -> Result<Vec<PlaylistItemDto>, DomainError> {
|
|
|
|
|
let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| {
|
|
|
|
|
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID")
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
let has_access = self.storage.user_has_access(playlist_id, user_id).await?;
|
|
|
|
|
if !has_access {
|
|
|
|
|
return Err(DomainError::new(
|
|
|
|
|
ErrorKind::AccessDenied,
|
|
|
|
|
"Playlist",
|
|
|
|
|
"You don't have permission to view this playlist",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
self.storage.list_playlist_tracks(&playlist_uuid).await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn share_playlist(
|
|
|
|
|
&self,
|
|
|
|
|
playlist_id: &str,
|
|
|
|
|
dto: SharePlaylistDto,
|
|
|
|
|
caller_id: Uuid,
|
|
|
|
|
) -> Result<(), DomainError> {
|
|
|
|
|
let playlist = self.storage.get_playlist(playlist_id).await?;
|
|
|
|
|
let playlist = match playlist {
|
|
|
|
|
Some(p) => p,
|
|
|
|
|
None => {
|
|
|
|
|
return Err(DomainError::new(
|
|
|
|
|
ErrorKind::NotFound,
|
|
|
|
|
"Playlist",
|
|
|
|
|
"Playlist not found",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
if playlist.owner_id != caller_id.to_string() {
|
|
|
|
|
return Err(DomainError::new(
|
|
|
|
|
ErrorKind::AccessDenied,
|
|
|
|
|
"Playlist",
|
|
|
|
|
"Only the owner can share this playlist",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| {
|
|
|
|
|
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID")
|
|
|
|
|
})?;
|
|
|
|
|
let target_user_id = Uuid::parse_str(&dto.user_id).map_err(|_| {
|
|
|
|
|
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid user ID")
|
|
|
|
|
})?;
|
|
|
|
|
let can_write = dto.can_write.unwrap_or(false);
|
|
|
|
|
|
|
|
|
|
self.storage
|
|
|
|
|
.share_playlist(&playlist_uuid, target_user_id, can_write)
|
|
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn remove_share(
|
|
|
|
|
&self,
|
|
|
|
|
playlist_id: &str,
|
|
|
|
|
target_user_id: &str,
|
|
|
|
|
caller_id: Uuid,
|
|
|
|
|
) -> Result<(), DomainError> {
|
|
|
|
|
let playlist = self.storage.get_playlist(playlist_id).await?;
|
|
|
|
|
let playlist = match playlist {
|
|
|
|
|
Some(p) => p,
|
|
|
|
|
None => {
|
|
|
|
|
return Err(DomainError::new(
|
|
|
|
|
ErrorKind::NotFound,
|
|
|
|
|
"Playlist",
|
|
|
|
|
"Playlist not found",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
if playlist.owner_id != caller_id.to_string() {
|
|
|
|
|
return Err(DomainError::new(
|
|
|
|
|
ErrorKind::AccessDenied,
|
|
|
|
|
"Playlist",
|
|
|
|
|
"Only the owner can manage sharing",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| {
|
|
|
|
|
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID")
|
|
|
|
|
})?;
|
|
|
|
|
let target_uuid = Uuid::parse_str(target_user_id).map_err(|_| {
|
|
|
|
|
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid user ID")
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
self.storage.remove_share(&playlist_uuid, target_uuid).await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn get_playlist_shares(
|
|
|
|
|
&self,
|
|
|
|
|
playlist_id: &str,
|
|
|
|
|
user_id: Uuid,
|
|
|
|
|
) -> Result<Vec<PlaylistShareInfoDto>, DomainError> {
|
|
|
|
|
let playlist = self.storage.get_playlist(playlist_id).await?;
|
|
|
|
|
let playlist = match playlist {
|
|
|
|
|
Some(p) => p,
|
|
|
|
|
None => {
|
|
|
|
|
return Err(DomainError::new(
|
|
|
|
|
ErrorKind::NotFound,
|
|
|
|
|
"Playlist",
|
|
|
|
|
"Playlist not found",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
if playlist.owner_id != user_id.to_string() {
|
|
|
|
|
return Err(DomainError::new(
|
|
|
|
|
ErrorKind::AccessDenied,
|
|
|
|
|
"Playlist",
|
|
|
|
|
"Only the owner can view sharing info",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let playlist_uuid = Uuid::parse_str(playlist_id).map_err(|_| {
|
|
|
|
|
DomainError::new(ErrorKind::InvalidInput, "Playlist", "Invalid playlist ID")
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
let shares = self.storage.get_shares(&playlist_uuid).await?;
|
|
|
|
|
Ok(shares
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|(uid, can_write)| PlaylistShareInfoDto {
|
|
|
|
|
user_id: uid.to_string(),
|
|
|
|
|
can_write,
|
|
|
|
|
})
|
|
|
|
|
.collect())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn get_audio_metadata(
|
|
|
|
|
&self,
|
|
|
|
|
file_id: &str,
|
2026-07-04 23:31:10 +02:00
|
|
|
caller_id: Uuid,
|
2026-04-08 15:14:03 +03:00
|
|
|
) -> Result<Option<AudioMetadataDto>, DomainError> {
|
|
|
|
|
let file_uuid = Uuid::parse_str(file_id)
|
|
|
|
|
.map_err(|_| DomainError::new(ErrorKind::InvalidInput, "Music", "Invalid file ID"))?;
|
2026-07-04 23:31:10 +02:00
|
|
|
// AuthZ pre-read: caller must have `Read` on the underlying
|
|
|
|
|
// audio file. Before this check the endpoint returned
|
|
|
|
|
// metadata for any known file id (cross-tenant IDOR — the
|
|
|
|
|
// `_user_id` parameter was deliberately unused). `require`
|
|
|
|
|
// returns 404 on denial to match the anti-enum shape used
|
|
|
|
|
// everywhere else. Post-Drive AuthZ audit fix (Round 1
|
|
|
|
|
// BLOCKER — `docs/plan/authz_audit/rest_storage.md`).
|
|
|
|
|
self.authorization
|
|
|
|
|
.require(
|
|
|
|
|
Subject::User(caller_id),
|
|
|
|
|
Permission::Read,
|
|
|
|
|
Resource::File(file_uuid),
|
|
|
|
|
)
|
|
|
|
|
.await?;
|
2026-04-08 15:14:03 +03:00
|
|
|
self.storage.get_audio_metadata(&file_uuid).await
|
|
|
|
|
}
|
|
|
|
|
}
|