Merge pull request #3 from JoshHeaps/feature/Folders

Add folder sharing
This commit is contained in:
Josh Heaps
2025-10-21 17:47:31 -06:00
committed by GitHub
13 changed files with 1112 additions and 30 deletions
+31
View File
@@ -55,6 +55,16 @@ public class FolderApi : ControllerBase
return BadRequest(new { error = "Folder name is required" }); 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); var folder = await _folderService.CreateFolderAsync(userId.Value, request.Name, request.ParentFolderId);
if (folder == null) if (folder == null)
{ {
@@ -78,6 +88,13 @@ public class FolderApi : ControllerBase
return BadRequest(new { error = "New folder name is required" }); 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); var success = await _folderService.RenameFolderAsync(request.FolderId, userId.Value, request.NewName);
if (!success) if (!success)
{ {
@@ -96,6 +113,13 @@ public class FolderApi : ControllerBase
return Unauthorized(new { error = "Not authenticated" }); 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); var success = await _folderService.DeleteFolderAsync(folderId, userId.Value, deleteContents);
if (!success) if (!success)
{ {
@@ -114,6 +138,13 @@ public class FolderApi : ControllerBase
return Unauthorized(new { error = "Not authenticated" }); 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); var success = await _folderService.MoveFolderAsync(request.FolderId, userId.Value, request.NewParentFolderId);
if (!success) if (!success)
{ {
+120
View File
@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<object>());
}
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);
+14 -2
View File
@@ -9,10 +9,12 @@ namespace Media.JoshHeaps.Net.Api;
public class MediaApi : ControllerBase public class MediaApi : ControllerBase
{ {
private readonly MediaService _mediaService; private readonly MediaService _mediaService;
private readonly FolderService _folderService;
public MediaApi(MediaService mediaService) public MediaApi(MediaService mediaService, FolderService folderService)
{ {
_mediaService = mediaService; _mediaService = mediaService;
_folderService = folderService;
} }
[HttpGet("load")] [HttpGet("load")]
@@ -25,7 +27,7 @@ public class MediaApi : ControllerBase
return Unauthorized(); 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); return Ok(media);
} }
@@ -68,6 +70,16 @@ public class MediaApi : ControllerBase
return Unauthorized(new { error = "Not authenticated" }); 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) if (file == null || file.Length == 0)
{ {
return BadRequest(new { error = "No file provided" }); return BadRequest(new { error = "No file provided" });
@@ -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);
+35
View File
@@ -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; }
}
@@ -5,7 +5,7 @@ namespace Media.JoshHeaps.Net.Pages;
public abstract class AuthenticatedPageModel : PageModel 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 Username { get; private set; } = string.Empty;
protected string Email { get; private set; } = string.Empty; protected string Email { get; private set; } = string.Empty;
protected bool EmailVerified { get; private set; } protected bool EmailVerified { get; private set; }
+72 -7
View File
@@ -59,14 +59,28 @@
<div class="card"> <div class="card">
<div class="gallery-header"> <div class="gallery-header">
<div class="breadcrumb-nav"> <div class="breadcrumb-nav">
<a href="/">Home</a> <a href="/[email protected]">Home</a>
@foreach (var folder in Model.FolderPath) @foreach (var folder in Model.FolderPath)
{ {
<span class="breadcrumb-separator">/</span> <span class="breadcrumb-separator">/</span>
<a href="/[email protected]">@folder.Name</a> <a href="/[email protected]&[email protected]">@folder.Name</a>
}
@if (Model.IsSharedFolder && !string.IsNullOrEmpty(Model.SharedByUsername))
{
<span class="shared-indicator">(Shared by @Model.SharedByUsername)</span>
} }
</div> </div>
<div class="header-actions">
<div class="view-mode-toggle">
<a href="/?view=own" class="view-mode-btn @(Model.ViewMode == "own" ? "active" : "")">My Folders</a>
<a href="/?view=shared" class="view-mode-btn @(Model.ViewMode == "shared" ? "active" : "")">Shared with Me</a>
<a href="/?view=all" class="view-mode-btn @(Model.ViewMode == "all" ? "active" : "")">All</a>
</div>
@if (!Model.IsSharedFolder)
{
<button type="button" class="btn btn-secondary" id="new-folder-btn">New Folder</button> <button type="button" class="btn btn-secondary" id="new-folder-btn">New Folder</button>
}
</div>
</div> </div>
<h2> <h2>
@if (Model.CurrentFolderId.HasValue) @if (Model.CurrentFolderId.HasValue)
@@ -82,7 +96,10 @@
<div id="gallery" class="gallery-grid"> <div id="gallery" class="gallery-grid">
@if (Model.CurrentFolderId.HasValue) @if (Model.CurrentFolderId.HasValue)
{ {
<div class="gallery-item folder-item" data-folder-id="back" onclick="window.location.href='/?folderId=@(Model.FolderPath.Count > 1 ? Model.FolderPath[Model.FolderPath.Count - 2].Id.ToString() : "")'"> var backUrl = Model.FolderPath.Count > 1
? $"/?folderId={Model.FolderPath[Model.FolderPath.Count - 2].Id}&view={Model.ViewMode}"
: $"/?view={Model.ViewMode}";
<div class="gallery-item folder-item" data-folder-id="back" onclick="window.location.href='@backUrl'">
<div class="folder-icon"> <div class="folder-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 6l6 6-6 6"/> <path d="M9 6l6 6-6 6"/>
@@ -91,19 +108,38 @@
<div class="folder-name">..</div> <div class="folder-name">..</div>
</div> </div>
} }
@foreach (var sharedFolder in Model.SharedFolders)
{
<div class="gallery-item folder-item shared-folder-item" data-folder-id="@sharedFolder.FolderId" onclick="window.location.href='/[email protected]&[email protected]'">
<div class="folder-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
<path d="M20 12h-2m-2 0h-2" stroke-width="1.5"/>
</svg>
</div>
<div class="folder-name">
@sharedFolder.FolderName
<span class="shared-badge">Shared by @sharedFolder.OwnerUsername</span>
</div>
</div>
}
@foreach (var folder in Model.Folders) @foreach (var folder in Model.Folders)
{ {
<div class="gallery-item folder-item" data-folder-id="@folder.Id" onclick="window.location.href='/[email protected]'"> <div class="gallery-item folder-item @(folder.UserId != Model.UserId ? "shared-folder-item" : "")" data-folder-id="@folder.Id" data-owner-id="@folder.UserId" onclick="window.location.href='/[email protected]&[email protected]'">
<div class="folder-icon"> <div class="folder-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/> <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg> </svg>
</div> </div>
<div class="folder-name">@folder.Name</div> <div class="folder-name">@folder.Name</div>
@if (folder.UserId == Model.UserId && !Model.IsSharedFolder)
{
<div class="folder-actions"> <div class="folder-actions">
<button type="button" class="btn-action" onclick="event.stopPropagation(); showShareModal(@folder.Id, '@folder.Name')">Share</button>
<button type="button" class="btn-action" onclick="event.stopPropagation(); renameFolder(@folder.Id, '@folder.Name')">Rename</button> <button type="button" class="btn-action" onclick="event.stopPropagation(); renameFolder(@folder.Id, '@folder.Name')">Rename</button>
<button type="button" class="btn-action" onclick="event.stopPropagation(); deleteFolder(@folder.Id)">Delete</button> <button type="button" class="btn-action" onclick="event.stopPropagation(); deleteFolder(@folder.Id)">Delete</button>
</div> </div>
}
</div> </div>
} }
@foreach (var media in Model.MediaItems) @foreach (var media in Model.MediaItems)
@@ -147,18 +183,47 @@
</div> </div>
</div> </div>
<!-- Floating Add Files Button --> <!-- Share Folder Modal -->
<button type="button" class="floating-add-btn" id="add-files-btn" title="Add Files"> <div id="share-modal" class="modal" style="display: none;">
<div class="modal-backdrop" onclick="closeShareModal()"></div>
<div class="modal-dialog">
<div class="modal-header">
<h3>Share Folder: <span id="share-folder-name"></span></h3>
<button type="button" class="modal-close" onclick="closeShareModal()">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="user-search">Search users by email or username</label>
<input type="text" id="user-search" class="form-control" placeholder="Type to search..." />
<div id="user-search-results" class="search-results"></div>
</div>
<div class="shared-users-section">
<h4>Shared with:</h4>
<div id="shared-users-list" class="shared-users-list">
<p class="text-muted">Loading...</p>
</div>
</div>
</div>
</div>
</div>
<!-- Floating Add Files Button (hidden in shared folders) -->
@if (!Model.IsSharedFolder)
{
<button type="button" class="floating-add-btn" id="add-files-btn" title="Add Files">
<span class="btn-icon">+</span> <span class="btn-icon">+</span>
</button> </button>
}
@section Scripts { @section Scripts {
<script> <script>
const currentFolderId = @(Model.CurrentFolderId?.ToString() ?? "null"); const currentFolderId = @(Model.CurrentFolderId?.ToString() ?? "null");
const isSharedFolder = @(Model.IsSharedFolder.ToString().ToLower());
</script> </script>
<script src="~/js/gallery.js" asp-append-version="true"></script> <script src="~/js/gallery.js" asp-append-version="true"></script>
<script src="~/js/upload.js" asp-append-version="true"></script> <script src="~/js/upload.js" asp-append-version="true"></script>
<script src="~/js/folders.js" asp-append-version="true"></script> <script src="~/js/folders.js" asp-append-version="true"></script>
<script src="~/js/folder-sharing.js" asp-append-version="true"></script>
<script src="~/js/drag-drop.js" asp-append-version="true"></script> <script src="~/js/drag-drop.js" asp-append-version="true"></script>
<script src="~/js/context-menu.js" asp-append-version="true"></script> <script src="~/js/context-menu.js" asp-append-version="true"></script>
} }
+61 -4
View File
@@ -13,7 +13,12 @@ namespace Media.JoshHeaps.Net.Pages
public List<UserMedia> MediaItems { get; set; } = new(); public List<UserMedia> MediaItems { get; set; } = new();
public List<Folder> Folders { get; set; } = new(); public List<Folder> Folders { get; set; } = new();
public List<Folder> FolderPath { get; set; } = new(); public List<Folder> FolderPath { get; set; } = new();
public List<SharedFolderInfo> SharedFolders { get; set; } = new();
public long? CurrentFolderId { get; set; } public long? CurrentFolderId { get; set; }
public bool IsSharedFolder { get; set; }
public long? FolderOwnerId { get; set; }
public string? SharedByUsername { get; set; }
public string ViewMode { get; set; } = "own"; // "own", "shared", "all"
[BindProperty] [BindProperty]
public IFormFile? UploadedFile { get; set; } public IFormFile? UploadedFile { get; set; }
@@ -27,24 +32,76 @@ namespace Media.JoshHeaps.Net.Pages
public string? UploadMessage { get; set; } public string? UploadMessage { get; set; }
public bool UploadSuccess { get; set; } public bool UploadSuccess { get; set; }
public async Task<IActionResult> OnGetAsync(long? folderId = null) public async Task<IActionResult> OnGetAsync(long? folderId = null, string? view = "own")
{ {
RequireAuthentication(); RequireAuthentication();
LoadUserSession(); LoadUserSession();
CurrentFolderId = folderId; CurrentFolderId = folderId;
ViewMode = view ?? "own";
// Load user dashboard data // Load user dashboard data
Dashboard = await userService.GetUserDashboardAsync(UserId); Dashboard = await userService.GetUserDashboardAsync(UserId);
// Load folders in current directory // Check if current folder is shared with user
if (CurrentFolderId.HasValue)
{
FolderOwnerId = await _folderService.GetFolderOwnerIdAsync(CurrentFolderId.Value);
IsSharedFolder = FolderOwnerId != UserId;
if (IsSharedFolder && FolderOwnerId.HasValue)
{
// Get owner info for display
var ownerDashboard = await userService.GetUserDashboardAsync(FolderOwnerId.Value);
SharedByUsername = ownerDashboard?.Username;
}
}
// Load folders based on view mode
if (ViewMode == "shared")
{
// Show only shared folders at root level
SharedFolders = await _folderService.GetSharedFoldersAsync(UserId);
if (!CurrentFolderId.HasValue)
{
Folders = new List<Folder>();
}
else if (IsSharedFolder && FolderOwnerId.HasValue)
{
Folders = await _folderService.GetUserFoldersAsync(FolderOwnerId.Value, CurrentFolderId);
}
}
else if (ViewMode == "own")
{
// Show only own folders
if (!IsSharedFolder)
{
Folders = await _folderService.GetUserFoldersAsync(UserId, CurrentFolderId); Folders = await _folderService.GetUserFoldersAsync(UserId, CurrentFolderId);
}
}
else // "all"
{
// Show both own and shared
Folders = await _folderService.GetUserFoldersAsync(UserId, CurrentFolderId);
if (!CurrentFolderId.HasValue)
{
SharedFolders = await _folderService.GetSharedFoldersAsync(UserId);
}
}
// Load folder path (breadcrumbs) // Load folder path (breadcrumbs)
if (IsSharedFolder && FolderOwnerId.HasValue)
{
FolderPath = await _folderService.GetFolderPathAsync(CurrentFolderId, FolderOwnerId.Value);
}
else
{
FolderPath = await _folderService.GetFolderPathAsync(CurrentFolderId, UserId); FolderPath = await _folderService.GetFolderPathAsync(CurrentFolderId, UserId);
}
// Load initial set of media // Load initial set of media (with access check for shared folders)
MediaItems = await _mediaService.GetUserMediaAsync(UserId, 0, 20, CurrentFolderId); var effectiveUserId = IsSharedFolder && FolderOwnerId.HasValue ? FolderOwnerId.Value : UserId;
MediaItems = await _mediaService.GetUserMediaAsync(effectiveUserId, 0, 20, CurrentFolderId, UserId);
return Page(); return Page();
} }
@@ -410,4 +410,236 @@ public class FolderService
return sanitized; return sanitized;
} }
// Folder Sharing Methods
public async Task<FolderShare?> ShareFolderAsync(long folderId, long ownerId, long sharedWithUserId)
{
try
{
// Verify folder exists and belongs to owner
var folder = await GetFolderByIdAsync(folderId, ownerId);
if (folder == null)
{
_logger.LogWarning("Cannot share folder {FolderId} - not found or not owned by {OwnerId}", folderId, ownerId);
return null;
}
// Check if already shared
var existingShare = await GetFolderShareAsync(folderId, sharedWithUserId);
if (existingShare != null)
{
return existingShare;
}
var query = @"
INSERT INTO app.folder_shares (folder_id, owner_user_id, shared_with_user_id, permission_level, include_subfolders, created_at, updated_at)
VALUES (@folderId, @ownerId, @sharedWithUserId, @permissionLevel, @includeSubfolders, @createdAt, @updatedAt)
RETURNING id, folder_id, owner_user_id, shared_with_user_id, permission_level, include_subfolders, created_at, updated_at";
var share = await _db.ExecuteReaderAsync(query, reader =>
{
return new FolderShare
{
Id = reader.GetInt64(0),
FolderId = reader.GetInt64(1),
OwnerUserId = reader.GetInt64(2),
SharedWithUserId = reader.GetInt64(3),
PermissionLevel = reader.GetString(4),
IncludeSubfolders = reader.GetBoolean(5),
CreatedAt = reader.GetDateTime(6),
UpdatedAt = reader.GetDateTime(7)
};
}, new
{
folderId,
ownerId,
sharedWithUserId,
permissionLevel = "read_only",
includeSubfolders = true,
createdAt = DateTime.UtcNow,
updatedAt = DateTime.UtcNow
});
return share;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to share folder {FolderId} with user {SharedWithUserId}", folderId, sharedWithUserId);
return null;
}
}
public async Task<bool> UnshareFolderAsync(long folderId, long ownerId, long sharedWithUserId)
{
try
{
var query = @"
DELETE FROM app.folder_shares
WHERE folder_id = @folderId AND owner_user_id = @ownerId AND shared_with_user_id = @sharedWithUserId";
await _db.ExecuteAsync<object>(query, new { folderId, ownerId, sharedWithUserId });
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to unshare folder {FolderId} from user {SharedWithUserId}", folderId, sharedWithUserId);
return false;
}
}
public async Task<List<FolderShareWithUser>> GetFolderSharesAsync(long folderId, long ownerId)
{
try
{
var query = @"
SELECT fs.id, fs.folder_id, fs.owner_user_id, fs.shared_with_user_id,
u.username, u.email, fs.permission_level, fs.created_at
FROM app.folder_shares fs
JOIN app.users u ON fs.shared_with_user_id = u.id
WHERE fs.folder_id = @folderId AND fs.owner_user_id = @ownerId
ORDER BY u.username ASC";
var shares = await _db.ExecuteListReaderAsync(query, reader =>
{
return new FolderShareWithUser
{
Id = reader.GetInt64(0),
FolderId = reader.GetInt64(1),
OwnerUserId = reader.GetInt64(2),
SharedWithUserId = reader.GetInt64(3),
SharedWithUsername = reader.GetString(4),
SharedWithEmail = reader.GetString(5),
PermissionLevel = reader.GetString(6),
CreatedAt = reader.GetDateTime(7)
};
}, new { folderId, ownerId });
return shares;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get shares for folder {FolderId}", folderId);
return new List<FolderShareWithUser>();
}
}
public async Task<List<SharedFolderInfo>> GetSharedFoldersAsync(long userId)
{
try
{
var query = @"
SELECT fs.folder_id, f.name, fs.owner_user_id, u.username, fs.permission_level, fs.created_at
FROM app.folder_shares fs
JOIN app.folders f ON fs.folder_id = f.id
JOIN app.users u ON fs.owner_user_id = u.id
WHERE fs.shared_with_user_id = @userId
ORDER BY f.name ASC";
var sharedFolders = await _db.ExecuteListReaderAsync(query, reader =>
{
return new SharedFolderInfo
{
FolderId = reader.GetInt64(0),
FolderName = reader.GetString(1),
OwnerUserId = reader.GetInt64(2),
OwnerUsername = reader.GetString(3),
PermissionLevel = reader.GetString(4),
SharedAt = reader.GetDateTime(5)
};
}, new { userId });
return sharedFolders;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get shared folders for user {UserId}", userId);
return new List<SharedFolderInfo>();
}
}
private async Task<FolderShare?> GetFolderShareAsync(long folderId, long sharedWithUserId)
{
try
{
var query = @"
SELECT id, folder_id, owner_user_id, shared_with_user_id, permission_level, include_subfolders, created_at, updated_at
FROM app.folder_shares
WHERE folder_id = @folderId AND shared_with_user_id = @sharedWithUserId";
var share = await _db.ExecuteReaderAsync(query, reader =>
{
return new FolderShare
{
Id = reader.GetInt64(0),
FolderId = reader.GetInt64(1),
OwnerUserId = reader.GetInt64(2),
SharedWithUserId = reader.GetInt64(3),
PermissionLevel = reader.GetString(4),
IncludeSubfolders = reader.GetBoolean(5),
CreatedAt = reader.GetDateTime(6),
UpdatedAt = reader.GetDateTime(7)
};
}, new { folderId, sharedWithUserId });
return share;
}
catch
{
return null;
}
}
public async Task<bool> HasFolderAccessAsync(long folderId, long userId)
{
try
{
// Check if user is the owner
var query = "SELECT COUNT(*) FROM app.folders WHERE id = @folderId AND user_id = @userId";
var isOwner = await _db.ExecuteReaderAsync(query, reader => reader.GetInt64(0), new { folderId, userId });
if (isOwner > 0) return true;
// Check if folder is shared with user (including parent folders due to subfolders)
var shareQuery = @"
WITH RECURSIVE folder_hierarchy AS (
-- Base case: the folder itself
SELECT id, parent_folder_id
FROM app.folders
WHERE id = @folderId
UNION ALL
-- Recursive case: parent folders
SELECT f.id, f.parent_folder_id
FROM app.folders f
INNER JOIN folder_hierarchy fh ON f.id = fh.parent_folder_id
)
SELECT COUNT(*)
FROM app.folder_shares fs
INNER JOIN folder_hierarchy fh ON fs.folder_id = fh.id
WHERE fs.shared_with_user_id = @userId AND fs.include_subfolders = true";
var hasShare = await _db.ExecuteReaderAsync(shareQuery, reader => reader.GetInt64(0), new { folderId, userId });
return hasShare > 0;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to check folder access for folder {FolderId} and user {UserId}", folderId, userId);
return false;
}
}
public async Task<long?> GetFolderOwnerIdAsync(long folderId)
{
try
{
var query = "SELECT user_id FROM app.folders WHERE id = @folderId";
var ownerId = await _db.ExecuteReaderAsync(query, reader => reader.GetInt64(0), new { folderId });
return ownerId;
}
catch
{
return null;
}
}
} }
+46 -7
View File
@@ -8,13 +8,15 @@ public class MediaService
private readonly IWebHostEnvironment _environment; private readonly IWebHostEnvironment _environment;
private readonly EncryptionService _encryption; private readonly EncryptionService _encryption;
private readonly ILogger<MediaService> _logger; private readonly ILogger<MediaService> _logger;
private readonly FolderService _folderService;
public MediaService(DbExecutor db, IWebHostEnvironment environment, EncryptionService encryption, ILogger<MediaService> logger) public MediaService(DbExecutor db, IWebHostEnvironment environment, EncryptionService encryption, ILogger<MediaService> logger, FolderService folderService)
{ {
_db = db; _db = db;
_environment = environment; _environment = environment;
_encryption = encryption; _encryption = encryption;
_logger = logger; _logger = logger;
_folderService = folderService;
} }
public async Task<UserMedia?> SaveMediaAsync(long userId, IFormFile file, string? description = null, long? folderId = null) public async Task<UserMedia?> SaveMediaAsync(long userId, IFormFile file, string? description = null, long? folderId = null)
@@ -129,17 +131,35 @@ public class MediaService
} }
} }
public async Task<List<UserMedia>> GetUserMediaAsync(long userId, int offset = 0, int limit = 20, long? folderId = null) public async Task<List<UserMedia>> GetUserMediaAsync(long userId, int offset = 0, int limit = 20, long? folderId = null, long? requestingUserId = null)
{ {
try try
{ {
// If requestingUserId is provided, check access for shared folders
var effectiveUserId = userId;
if (requestingUserId.HasValue && folderId.HasValue)
{
var hasAccess = await _folderService.HasFolderAccessAsync(folderId.Value, requestingUserId.Value);
if (!hasAccess)
{
_logger.LogWarning("User {RequestingUserId} does not have access to folder {FolderId}", requestingUserId.Value, folderId.Value);
return new List<UserMedia>();
}
// Get the actual owner of the folder
var ownerId = await _folderService.GetFolderOwnerIdAsync(folderId.Value);
if (ownerId.HasValue)
{
effectiveUserId = ownerId.Value;
}
}
string query; string query;
if (folderId.HasValue) if (folderId.HasValue)
{ {
query = @" query = @"
SELECT id, user_id, file_name, file_path, file_size, mime_type, width, height, description, is_encrypted, folder_id, created_at, updated_at SELECT id, user_id, file_name, file_path, file_size, mime_type, width, height, description, is_encrypted, folder_id, created_at, updated_at
FROM app.user_media FROM app.user_media
WHERE user_id = @userId AND folder_id = @folderId WHERE user_id = @effectiveUserId AND folder_id = @folderId
ORDER BY created_at DESC ORDER BY created_at DESC
OFFSET @offset LIMIT @limit"; OFFSET @offset LIMIT @limit";
} }
@@ -148,7 +168,7 @@ public class MediaService
query = @" query = @"
SELECT id, user_id, file_name, file_path, file_size, mime_type, width, height, description, is_encrypted, folder_id, created_at, updated_at SELECT id, user_id, file_name, file_path, file_size, mime_type, width, height, description, is_encrypted, folder_id, created_at, updated_at
FROM app.user_media FROM app.user_media
WHERE user_id = @userId AND folder_id IS NULL WHERE user_id = @effectiveUserId AND folder_id IS NULL
ORDER BY created_at DESC ORDER BY created_at DESC
OFFSET @offset LIMIT @limit"; OFFSET @offset LIMIT @limit";
} }
@@ -171,7 +191,7 @@ public class MediaService
CreatedAt = reader.GetDateTime(11), CreatedAt = reader.GetDateTime(11),
UpdatedAt = reader.GetDateTime(12) UpdatedAt = reader.GetDateTime(12)
}; };
}, new { userId, folderId, offset, limit }); }, new { effectiveUserId, folderId, offset, limit });
return mediaList; return mediaList;
} }
@@ -186,10 +206,11 @@ public class MediaService
{ {
try try
{ {
// First, get the media item
var query = @" var query = @"
SELECT id, user_id, file_name, file_path, file_size, mime_type, width, height, description, is_encrypted, folder_id, created_at, updated_at SELECT id, user_id, file_name, file_path, file_size, mime_type, width, height, description, is_encrypted, folder_id, created_at, updated_at
FROM app.user_media FROM app.user_media
WHERE id = @mediaId AND user_id = @userId"; WHERE id = @mediaId";
var media = await _db.ExecuteReaderAsync(query, reader => var media = await _db.ExecuteReaderAsync(query, reader =>
{ {
@@ -209,10 +230,28 @@ public class MediaService
CreatedAt = reader.GetDateTime(11), CreatedAt = reader.GetDateTime(11),
UpdatedAt = reader.GetDateTime(12) UpdatedAt = reader.GetDateTime(12)
}; };
}, new { mediaId, userId }); }, new { mediaId });
if (media == null)
{
return null;
}
// Check if user owns the media
if (media.UserId == userId)
{
return media; return media;
} }
// Check if user has access via shared folder
if (media.FolderId.HasValue && await _folderService.HasFolderAccessAsync(media.FolderId.Value, userId))
{
return media;
}
// User doesn't have access
return null;
}
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Failed to get media {MediaId} for user {UserId}", mediaId, userId); _logger.LogError(ex, "Failed to get media {MediaId} for user {UserId}", mediaId, userId);
@@ -102,4 +102,42 @@ public class UserService
return false; return false;
} }
} }
public async Task<List<UserSearchResult>> SearchUsersAsync(string query, long excludeUserId)
{
try
{
var searchQuery = @"
SELECT id, username, email
FROM app.users
WHERE (LOWER(username) LIKE @query OR LOWER(email) LIKE @query)
AND id != @excludeUserId
AND email_verified = true
ORDER BY username ASC
LIMIT 10";
var users = await _db.ExecuteListReaderAsync(searchQuery, reader =>
{
return new UserSearchResult
{
Id = reader.GetInt64(0),
Username = reader.GetString(1),
Email = reader.GetString(2)
};
}, new { query = $"%{query.ToLower()}%", excludeUserId });
return users;
}
catch (Exception)
{
return new List<UserSearchResult>();
}
}
}
public class UserSearchResult
{
public long Id { get; set; }
public string Username { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
} }
+249
View File
@@ -112,6 +112,14 @@
margin-bottom: 16px; margin-bottom: 16px;
padding-bottom: 12px; padding-bottom: 12px;
border-bottom: 1px solid var(--border-primary); border-bottom: 1px solid var(--border-primary);
flex-wrap: wrap;
gap: 12px;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
} }
.breadcrumb-nav { .breadcrumb-nav {
@@ -136,6 +144,13 @@
color: var(--text-secondary); color: var(--text-secondary);
} }
.shared-indicator {
color: var(--accent-primary);
font-size: 0.9em;
font-style: italic;
margin-left: 8px;
}
.item-count { .item-count {
color: var(--text-secondary); color: var(--text-secondary);
font-weight: normal; font-weight: normal;
@@ -224,6 +239,48 @@
color: var(--bg-primary); color: var(--bg-primary);
} }
.shared-folder-item {
border-color: var(--accent-primary);
background: rgba(66, 153, 225, 0.05);
}
.shared-badge {
display: block;
font-size: 11px;
color: var(--text-secondary);
margin-top: 4px;
font-weight: normal;
}
/* View Mode Toggle */
.view-mode-toggle {
display: flex;
background: var(--bg-tertiary);
border-radius: 6px;
padding: 4px;
gap: 4px;
}
.view-mode-btn {
padding: 6px 12px;
border-radius: 4px;
font-size: 13px;
color: var(--text-secondary);
text-decoration: none;
transition: all 0.2s ease;
cursor: pointer;
}
.view-mode-btn:hover {
color: var(--text-primary);
background: var(--bg-secondary);
}
.view-mode-btn.active {
background: var(--accent-primary);
color: var(--bg-primary);
}
.gallery-item img { .gallery-item img {
width: 100%; width: 100%;
height: 250px; height: 250px;
@@ -560,6 +617,198 @@
margin: 4px 0; margin: 4px 0;
} }
/* Share Modal */
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1100;
display: flex;
align-items: center;
justify-content: center;
}
.modal-backdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(2px);
}
.modal-dialog {
position: relative;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 8px;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
z-index: 1101;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border-primary);
}
.modal-header h3 {
margin: 0;
font-size: 18px;
color: var(--text-primary);
}
.modal-close {
background: transparent;
border: none;
font-size: 28px;
color: var(--text-secondary);
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
line-height: 1;
transition: color 0.2s ease;
}
.modal-close:hover {
color: var(--text-primary);
}
.modal-body {
padding: 20px;
overflow-y: auto;
max-height: calc(80vh - 70px);
}
.form-control {
width: 100%;
padding: 10px;
border: 1px solid var(--border-primary);
border-radius: 6px;
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 14px;
transition: border-color 0.2s ease;
}
.form-control:focus {
outline: none;
border-color: var(--accent-primary);
}
.search-results {
margin-top: 8px;
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--border-primary);
border-radius: 6px;
background: var(--bg-tertiary);
}
.search-result-item,
.shared-user-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
border-bottom: 1px solid var(--border-primary);
}
.search-result-item:last-child,
.shared-user-item:last-child {
border-bottom: none;
}
.user-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.user-info strong {
color: var(--text-primary);
font-size: 14px;
}
.user-email {
color: var(--text-secondary);
font-size: 12px;
}
.btn-add,
.btn-remove {
padding: 6px 12px;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
transition: all 0.2s ease;
border: none;
font-weight: 500;
}
.btn-add {
background: var(--accent-primary);
color: var(--bg-primary);
}
.btn-add:hover {
background: var(--accent-hover);
}
.btn-remove {
background: transparent;
color: var(--danger);
border: 1px solid var(--danger);
}
.btn-remove:hover {
background: var(--danger);
color: var(--text-primary);
}
.shared-users-section {
margin-top: 24px;
}
.shared-users-section h4 {
margin: 0 0 12px 0;
font-size: 15px;
color: var(--text-primary);
}
.shared-users-list {
border: 1px solid var(--border-primary);
border-radius: 6px;
background: var(--bg-tertiary);
min-height: 60px;
}
.text-muted {
color: var(--text-secondary);
font-size: 13px;
padding: 16px;
text-align: center;
margin: 0;
}
.text-error {
color: var(--danger);
font-size: 13px;
padding: 16px;
text-align: center;
margin: 0;
}
/* Responsive Design */ /* Responsive Design */
@media (max-width: 768px) { @media (max-width: 768px) {
.gallery-grid { .gallery-grid {
@@ -0,0 +1,181 @@
// Folder sharing functionality
let currentShareFolderId = null;
let searchTimeout = null;
function showShareModal(folderId, folderName) {
currentShareFolderId = folderId;
const modal = document.getElementById('share-modal');
const folderNameSpan = document.getElementById('share-folder-name');
const userSearch = document.getElementById('user-search');
folderNameSpan.textContent = folderName;
userSearch.value = '';
modal.style.display = 'block';
// Load existing shares
loadSharedUsers(folderId);
// Clear search results
document.getElementById('user-search-results').innerHTML = '';
}
function closeShareModal() {
const modal = document.getElementById('share-modal');
modal.style.display = 'none';
currentShareFolderId = null;
}
async function loadSharedUsers(folderId) {
const list = document.getElementById('shared-users-list');
list.innerHTML = '<p class="text-muted">Loading...</p>';
try {
const response = await fetch(`/api/folder-share/list-shares?folderId=${folderId}`);
if (!response.ok) {
throw new Error('Failed to load shares');
}
const shares = await response.json();
if (shares.length === 0) {
list.innerHTML = '<p class="text-muted">Not shared with anyone yet</p>';
return;
}
list.innerHTML = '';
shares.forEach(share => {
const userItem = document.createElement('div');
userItem.className = 'shared-user-item';
userItem.innerHTML = `
<div class="user-info">
<strong>${share.sharedWithUsername}</strong>
<span class="user-email">${share.sharedWithEmail}</span>
</div>
<button type="button" class="btn-remove" onclick="removeShare(${folderId}, ${share.sharedWithUserId}, '${share.sharedWithUsername}')">
Remove
</button>
`;
list.appendChild(userItem);
});
} catch (error) {
console.error('Error loading shares:', error);
list.innerHTML = '<p class="text-error">Failed to load shares</p>';
}
}
async function removeShare(folderId, sharedWithUserId, username) {
if (!confirm(`Remove access for ${username}?`)) {
return;
}
try {
const response = await fetch(`/api/folder-share/unshare?folderId=${folderId}&sharedWithUserId=${sharedWithUserId}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to remove share');
}
// Reload the shared users list
loadSharedUsers(folderId);
} catch (error) {
console.error('Error removing share:', error);
alert('Failed to remove access');
}
}
// User search with debouncing
document.addEventListener('DOMContentLoaded', function() {
const userSearch = document.getElementById('user-search');
if (!userSearch) return;
userSearch.addEventListener('input', function() {
clearTimeout(searchTimeout);
const query = this.value.trim();
if (query.length < 2) {
document.getElementById('user-search-results').innerHTML = '';
return;
}
searchTimeout = setTimeout(() => searchUsers(query), 300);
});
});
async function searchUsers(query) {
const resultsDiv = document.getElementById('user-search-results');
resultsDiv.innerHTML = '<p class="text-muted">Searching...</p>';
try {
const response = await fetch(`/api/folder-share/search-users?query=${encodeURIComponent(query)}`);
if (!response.ok) {
throw new Error('Search failed');
}
const users = await response.json();
if (users.length === 0) {
resultsDiv.innerHTML = '<p class="text-muted">No users found</p>';
return;
}
resultsDiv.innerHTML = '';
users.forEach(user => {
const userItem = document.createElement('div');
userItem.className = 'search-result-item';
userItem.innerHTML = `
<div class="user-info">
<strong>${user.username}</strong>
<span class="user-email">${user.email}</span>
</div>
<button type="button" class="btn-add" onclick="shareWithUser(${currentShareFolderId}, ${user.id}, '${user.username}')">
Share
</button>
`;
resultsDiv.appendChild(userItem);
});
} catch (error) {
console.error('Error searching users:', error);
resultsDiv.innerHTML = '<p class="text-error">Search failed</p>';
}
}
async function shareWithUser(folderId, userId, username) {
try {
const response = await fetch('/api/folder-share/share', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
folderId: folderId,
sharedWithUserId: userId
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to share');
}
// Clear search
document.getElementById('user-search').value = '';
document.getElementById('user-search-results').innerHTML = '';
// Reload shared users list
loadSharedUsers(folderId);
alert(`Folder shared with ${username}`);
} catch (error) {
console.error('Error sharing folder:', error);
alert('Failed to share folder: ' + error.message);
}
}
// Close modal on escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeShareModal();
}
});