diff --git a/Media.JoshHeaps.Net/Api/FolderApi.cs b/Media.JoshHeaps.Net/Api/FolderApi.cs index 2c66abf..7048680 100644 --- a/Media.JoshHeaps.Net/Api/FolderApi.cs +++ b/Media.JoshHeaps.Net/Api/FolderApi.cs @@ -55,6 +55,16 @@ public class FolderApi : ControllerBase return BadRequest(new { error = "Folder name is required" }); } + // If parent folder specified, check ownership (can't create in shared folders) + if (request.ParentFolderId.HasValue) + { + var parentOwnerId = await _folderService.GetFolderOwnerIdAsync(request.ParentFolderId.Value); + if (parentOwnerId != userId.Value) + { + return Forbid("Cannot create folders in shared folders"); + } + } + var folder = await _folderService.CreateFolderAsync(userId.Value, request.Name, request.ParentFolderId); if (folder == null) { @@ -78,6 +88,13 @@ public class FolderApi : ControllerBase return BadRequest(new { error = "New folder name is required" }); } + // Check ownership (can't rename shared folders) + var ownerId = await _folderService.GetFolderOwnerIdAsync(request.FolderId); + if (ownerId != userId.Value) + { + return Forbid("Cannot rename shared folders"); + } + var success = await _folderService.RenameFolderAsync(request.FolderId, userId.Value, request.NewName); if (!success) { @@ -96,6 +113,13 @@ public class FolderApi : ControllerBase return Unauthorized(new { error = "Not authenticated" }); } + // Check ownership (can't delete shared folders) + var ownerId = await _folderService.GetFolderOwnerIdAsync(folderId); + if (ownerId != userId.Value) + { + return Forbid("Cannot delete shared folders"); + } + var success = await _folderService.DeleteFolderAsync(folderId, userId.Value, deleteContents); if (!success) { @@ -114,6 +138,13 @@ public class FolderApi : ControllerBase return Unauthorized(new { error = "Not authenticated" }); } + // Check ownership (can't move shared folders) + var ownerId = await _folderService.GetFolderOwnerIdAsync(request.FolderId); + if (ownerId != userId.Value) + { + return Forbid("Cannot move shared folders"); + } + var success = await _folderService.MoveFolderAsync(request.FolderId, userId.Value, request.NewParentFolderId); if (!success) { diff --git a/Media.JoshHeaps.Net/Api/FolderShareApi.cs b/Media.JoshHeaps.Net/Api/FolderShareApi.cs new file mode 100644 index 0000000..2e83f65 --- /dev/null +++ b/Media.JoshHeaps.Net/Api/FolderShareApi.cs @@ -0,0 +1,120 @@ +using Media.JoshHeaps.Net.Services; +using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; + +namespace Media.JoshHeaps.Net.Api; + +[ApiController] +[Route("api/folder-share")] +public class FolderShareApi : ControllerBase +{ + private readonly FolderService _folderService; + private readonly UserService _userService; + + public FolderShareApi(FolderService folderService, UserService userService) + { + _folderService = folderService; + _userService = userService; + } + + [HttpPost("share")] + public async Task ShareFolder([FromBody] ShareFolderRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(new { error = "Not authenticated" }); + } + + var share = await _folderService.ShareFolderAsync(request.FolderId, userId.Value, request.SharedWithUserId); + if (share == null) + { + return BadRequest(new { error = "Failed to share folder" }); + } + + return Ok(share); + } + + [HttpDelete("unshare")] + public async Task UnshareFolder([FromQuery] long folderId, [FromQuery] long sharedWithUserId) + { + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(new { error = "Not authenticated" }); + } + + var success = await _folderService.UnshareFolderAsync(folderId, userId.Value, sharedWithUserId); + if (!success) + { + return BadRequest(new { error = "Failed to unshare folder" }); + } + + return Ok(new { success = true }); + } + + [HttpGet("list-shares")] + public async Task ListShares([FromQuery] long folderId) + { + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(new { error = "Not authenticated" }); + } + + var shares = await _folderService.GetFolderSharesAsync(folderId, userId.Value); + return Ok(shares); + } + + [HttpGet("shared-with-me")] + public async Task GetSharedWithMe() + { + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(new { error = "Not authenticated" }); + } + + var sharedFolders = await _folderService.GetSharedFoldersAsync(userId.Value); + return Ok(sharedFolders); + } + + [HttpGet("search-users")] + public async Task SearchUsers([FromQuery] string query) + { + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(new { error = "Not authenticated" }); + } + + if (string.IsNullOrWhiteSpace(query) || query.Length < 2) + { + return Ok(new List()); + } + + var users = await _userService.SearchUsersAsync(query, userId.Value); + return Ok(users); + } + + private long? GetUserIdFromAuth() + { + // First try JWT claims (for mobile/API authentication) + var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (!string.IsNullOrEmpty(userIdClaim) && long.TryParse(userIdClaim, out var jwtUserId)) + { + return jwtUserId; + } + + // Fall back to session (for web authentication) + var userIdString = HttpContext.Session.GetString("UserId"); + if (!string.IsNullOrEmpty(userIdString) && long.TryParse(userIdString, out var sessionUserId)) + { + return sessionUserId; + } + + return null; + } +} + +public record ShareFolderRequest(long FolderId, long SharedWithUserId); diff --git a/Media.JoshHeaps.Net/Api/MediaApi.cs b/Media.JoshHeaps.Net/Api/MediaApi.cs index b5bccdc..debd78e 100644 --- a/Media.JoshHeaps.Net/Api/MediaApi.cs +++ b/Media.JoshHeaps.Net/Api/MediaApi.cs @@ -9,10 +9,12 @@ namespace Media.JoshHeaps.Net.Api; public class MediaApi : ControllerBase { private readonly MediaService _mediaService; + private readonly FolderService _folderService; - public MediaApi(MediaService mediaService) + public MediaApi(MediaService mediaService, FolderService folderService) { _mediaService = mediaService; + _folderService = folderService; } [HttpGet("load")] @@ -25,7 +27,7 @@ public class MediaApi : ControllerBase return Unauthorized(); } - var media = await _mediaService.GetUserMediaAsync(userId.Value, offset, limit, folderId); + var media = await _mediaService.GetUserMediaAsync(userId.Value, offset, limit, folderId, userId.Value); return Ok(media); } @@ -68,6 +70,16 @@ public class MediaApi : ControllerBase return Unauthorized(new { error = "Not authenticated" }); } + // Check folder ownership if uploading to a folder (can't upload to shared folders) + if (folderId.HasValue) + { + var ownerId = await _folderService.GetFolderOwnerIdAsync(folderId.Value); + if (ownerId != userId.Value) + { + return Forbid("Cannot upload to shared folders"); + } + } + if (file == null || file.Length == 0) { return BadRequest(new { error = "No file provided" }); diff --git a/Media.JoshHeaps.Net/Database/006_folder_shares.sql b/Media.JoshHeaps.Net/Database/006_folder_shares.sql new file mode 100644 index 0000000..e3baebf --- /dev/null +++ b/Media.JoshHeaps.Net/Database/006_folder_shares.sql @@ -0,0 +1,23 @@ +-- Folder Shares Table +-- Stores folder sharing permissions between users +CREATE TABLE IF NOT EXISTS app.folder_shares ( + id BIGSERIAL PRIMARY KEY, + folder_id BIGINT NOT NULL, + owner_user_id BIGINT NOT NULL, + shared_with_user_id BIGINT NOT NULL, + permission_level VARCHAR(50) DEFAULT 'read_only', -- Future: could be 'read_write' + include_subfolders BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (folder_id) REFERENCES app.folders(id) ON DELETE CASCADE, + FOREIGN KEY (owner_user_id) REFERENCES app.users(id) ON DELETE CASCADE, + FOREIGN KEY (shared_with_user_id) REFERENCES app.users(id) ON DELETE CASCADE, + -- Ensure a folder can only be shared once with each user + UNIQUE (folder_id, shared_with_user_id), + -- Prevent self-sharing + CHECK (owner_user_id != shared_with_user_id) +); + +CREATE INDEX IF NOT EXISTS idx_folder_shares_folder_id ON app.folder_shares(folder_id); +CREATE INDEX IF NOT EXISTS idx_folder_shares_shared_with_user_id ON app.folder_shares(shared_with_user_id); +CREATE INDEX IF NOT EXISTS idx_folder_shares_owner_user_id ON app.folder_shares(owner_user_id); diff --git a/Media.JoshHeaps.Net/Models/FolderShare.cs b/Media.JoshHeaps.Net/Models/FolderShare.cs new file mode 100644 index 0000000..2e341a2 --- /dev/null +++ b/Media.JoshHeaps.Net/Models/FolderShare.cs @@ -0,0 +1,35 @@ +namespace Media.JoshHeaps.Net.Models; + +public class FolderShare +{ + public long Id { get; set; } + public long FolderId { get; set; } + public long OwnerUserId { get; set; } + public long SharedWithUserId { get; set; } + public string PermissionLevel { get; set; } = "read_only"; + public bool IncludeSubfolders { get; set; } = true; + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} + +public class FolderShareWithUser +{ + public long Id { get; set; } + public long FolderId { get; set; } + public long OwnerUserId { get; set; } + public long SharedWithUserId { get; set; } + public string SharedWithUsername { get; set; } = string.Empty; + public string SharedWithEmail { get; set; } = string.Empty; + public string PermissionLevel { get; set; } = "read_only"; + public DateTime CreatedAt { get; set; } +} + +public class SharedFolderInfo +{ + public long FolderId { get; set; } + public string FolderName { get; set; } = string.Empty; + public long OwnerUserId { get; set; } + public string OwnerUsername { get; set; } = string.Empty; + public string PermissionLevel { get; set; } = "read_only"; + public DateTime SharedAt { get; set; } +} diff --git a/Media.JoshHeaps.Net/Pages/AuthenticatedPageModel.cs b/Media.JoshHeaps.Net/Pages/AuthenticatedPageModel.cs index eb4cf15..81d6bd9 100644 --- a/Media.JoshHeaps.Net/Pages/AuthenticatedPageModel.cs +++ b/Media.JoshHeaps.Net/Pages/AuthenticatedPageModel.cs @@ -5,7 +5,7 @@ namespace Media.JoshHeaps.Net.Pages; public abstract class AuthenticatedPageModel : PageModel { - protected long UserId { get; private set; } + public long UserId { get; private set; } protected string Username { get; private set; } = string.Empty; protected string Email { get; private set; } = string.Empty; protected bool EmailVerified { get; private set; } diff --git a/Media.JoshHeaps.Net/Pages/Index.cshtml b/Media.JoshHeaps.Net/Pages/Index.cshtml index 35ca9c3..6aabf0e 100644 --- a/Media.JoshHeaps.Net/Pages/Index.cshtml +++ b/Media.JoshHeaps.Net/Pages/Index.cshtml @@ -59,14 +59,28 @@

@if (Model.CurrentFolderId.HasValue) @@ -82,7 +96,10 @@