using System.Net; using System.Net.Mime; using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Helpers; using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Queues; using Iceshrimp.Backend.Core.Services; using Iceshrimp.Shared.Schemas.Web; using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; namespace Iceshrimp.Backend.Controllers.Web; [ApiController] [Route("/api/iceshrimp/drive")] public class DriveController( DatabaseContext db, ObjectStorageService objectStorage, IOptionsSnapshot options, ILogger logger, DriveService driveSvc, QueueService queueSvc, HttpClient httpClient ) : ControllerBase { private const string CacheControl = "max-age=31536000, immutable"; [EnableCors("drive")] [EnableRateLimiting("proxy")] [HttpGet("/files/{accessKey}/{version?}")] [ProducesResults(HttpStatusCode.OK, HttpStatusCode.Redirect)] [ProducesErrors(HttpStatusCode.NotFound)] public async Task GetFileByAccessKey(string accessKey, string? version) { return await GetFileByAccessKey(accessKey, version, null); } [EnableCors("drive")] [EnableRateLimiting("proxy")] [HttpGet("/media/emoji/{id}")] [ProducesResults(HttpStatusCode.OK, HttpStatusCode.Redirect)] [ProducesErrors(HttpStatusCode.NotFound)] public async Task GetEmojiById(string id) { var emoji = await db.Emojis.FirstOrDefaultAsync(p => p.Id == id) ?? throw GracefulException.NotFound("Emoji not found"); if (!options.Value.ProxyRemoteMedia || emoji.Host == null) return Redirect(emoji.RawPublicUrl); return await ProxyAsync(emoji.RawPublicUrl, null, null); } [EnableCors("drive")] [EnableRateLimiting("proxy")] [HttpGet("/avatars/{userId}/{version}")] [ProducesResults(HttpStatusCode.OK, HttpStatusCode.Redirect)] [ProducesErrors(HttpStatusCode.NotFound)] public async Task GetAvatarByUserId(string userId, string? version) { var user = await db.Users.Include(p => p.Avatar).FirstOrDefaultAsync(p => p.Id == userId) ?? throw GracefulException.NotFound("User not found"); if (user.Avatar is null) { var stream = await IdenticonHelper.GetIdenticonAsync(user.Id); Response.Headers.CacheControl = CacheControl; return new InlineFileStreamResult(stream, "image/png", $"{user.Id}.png", false); } if (!options.Value.ProxyRemoteMedia) return Redirect(user.Avatar.RawThumbnailAccessUrl); return await GetFileByAccessKey(user.Avatar.AccessKey, "thumbnail", user.Avatar); } [EnableCors("drive")] [EnableRateLimiting("proxy")] [HttpGet("/banners/{userId}/{version}")] [ProducesResults(HttpStatusCode.OK, HttpStatusCode.NoContent, HttpStatusCode.Redirect)] [ProducesErrors(HttpStatusCode.NotFound)] public async Task GetBannerByUserId(string userId, string? version) { var user = await db.Users.Include(p => p.Banner).FirstOrDefaultAsync(p => p.Id == userId) ?? throw GracefulException.NotFound("User not found"); if (user.Banner is null) return NoContent(); if (!options.Value.ProxyRemoteMedia) return Redirect(user.Banner.RawThumbnailAccessUrl); return await GetFileByAccessKey(user.Banner.AccessKey, "thumbnail", user.Banner); } [EnableCors("drive")] [HttpGet("/identicon/{userId}")] [HttpGet("/identicon/{userId}.png")] [Produces(MediaTypeNames.Image.Png)] [ProducesResults(HttpStatusCode.OK, HttpStatusCode.Redirect)] [ProducesErrors(HttpStatusCode.NotFound)] public async Task GetIdenticonByUserId(string userId) { var stream = await IdenticonHelper.GetIdenticonAsync(userId); Response.Headers.CacheControl = CacheControl; return new InlineFileStreamResult(stream, "image/png", $"{userId}.png", false); } [HttpPost] [Authenticate] [Authorize] [Produces(MediaTypeNames.Application.Json)] [ProducesResults(HttpStatusCode.OK)] [MaxRequestSizeIsMaxUploadSize] public async Task UploadFile(IFormFile file, [FromQuery] string? folderId) { var user = HttpContext.GetUserOrFail(); var request = new DriveFileCreationRequest { Filename = file.FileName, MimeType = file.ContentType, IsSensitive = false }; var res = await driveSvc.StoreFileAsync(file.OpenReadStream(), user, request, folderId: folderId); return await GetFileById(res.Id); } [HttpGet("{id}")] [Authenticate] [Authorize] [Produces(MediaTypeNames.Application.Json)] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.NotFound)] public async Task GetFileById(string id) { var user = HttpContext.GetUserOrFail(); var file = await db.DriveFiles .Include(p => p.UserAvatar) .Include(p => p.UserBanner) .FirstOrDefaultAsync(p => p.User == user && p.Id == id) ?? throw GracefulException.NotFound("File not found"); return new DriveFileResponse { Id = file.Id, Url = file.RawAccessUrl, ThumbnailUrl = file.RawThumbnailAccessUrl, Filename = file.Name, ContentType = file.Type, Description = file.Comment, Sensitive = file.IsSensitive, IsAvatar = file.UserAvatar != null, IsBanner = file.UserBanner != null }; } [HttpGet("/by-hash/{sha256}")] [Authenticate] [Authorize] [Produces(MediaTypeNames.Application.Json)] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.NotFound)] public async Task GetFileByHash(string sha256) { var user = HttpContext.GetUserOrFail(); var file = await db.DriveFiles .Include(p => p.UserAvatar) .Include(p => p.UserBanner) .FirstOrDefaultAsync(p => p.User == user && p.Sha256 == sha256) ?? throw GracefulException.NotFound("File not found"); return new DriveFileResponse { Id = file.Id, Url = file.RawAccessUrl, ThumbnailUrl = file.RawThumbnailAccessUrl, Filename = file.Name, ContentType = file.Type, Description = file.Comment, Sensitive = file.IsSensitive, IsAvatar = file.UserAvatar != null, IsBanner = file.UserBanner != null }; } [HttpPatch("{id}")] [Authenticate] [Authorize] [Consumes(MediaTypeNames.Application.Json)] [Produces(MediaTypeNames.Application.Json)] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.NotFound)] public async Task UpdateFile(string id, UpdateDriveFileRequest request) { var user = HttpContext.GetUserOrFail(); var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Id == id) ?? throw GracefulException.NotFound("File not found"); file.Name = request.Filename ?? file.Name; file.IsSensitive = request.Sensitive ?? file.IsSensitive; file.Comment = request.Description; await db.SaveChangesAsync(); return await GetFileById(id); } [HttpDelete("{id}")] [Authenticate] [Authorize] [ProducesResults(HttpStatusCode.Accepted)] [ProducesErrors(HttpStatusCode.NotFound, HttpStatusCode.UnprocessableEntity)] public async Task DeleteFile(string id) { var user = HttpContext.GetUserOrFail(); var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Id == id) ?? throw GracefulException.NotFound("File not found"); if (await db.Users.AnyAsync(p => p.Avatar == file || p.Banner == file)) throw GracefulException.UnprocessableEntity("Refusing to delete file: used in banner or avatar"); if (await db.Notes.AnyAsync(p => p.FileIds.Contains(file.Id))) throw GracefulException.UnprocessableEntity("Refusing to delete file: used in note"); await queueSvc.BackgroundTaskQueue.EnqueueAsync(new DriveFileDeleteJobData { DriveFileId = file.Id, Expire = false }); return StatusCode(StatusCodes.Status202Accepted); } [HttpGet("folder")] [Authenticate] [Authorize] [ProducesResults(HttpStatusCode.OK)] public async Task GetRootFolder() { return await GetFolder(null); } [HttpPost("folder")] [Authenticate] [Authorize] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound, HttpStatusCode.Conflict)] public async Task CreateFolder(DriveFolderRequest request) { var user = HttpContext.GetUserOrFail(); if (string.IsNullOrWhiteSpace(request.Name)) throw GracefulException.BadRequest("Folder name cannot be empty"); var driveFolder = await driveSvc.CreateFolderAsync(user, request.Name.Trim(), request.ParentId); return new DriveFolderResponse { Id = driveFolder.Id, Name = driveFolder.Name, ParentId = driveFolder.ParentId }; } [HttpGet("folder/{id}")] [Authenticate] [Authorize] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.NotFound)] public async Task GetFolder(string? id) { var user = HttpContext.GetUserOrFail(); var folder = id != null ? await db.DriveFolders.FirstOrDefaultAsync(p => p.Id == id && p.UserId == user.Id) ?? throw GracefulException.RecordNotFound() : null; var driveFiles = await db.DriveFiles .Where(p => p.FolderId == id && p.UserId == user.Id) .Select(p => new DriveFileResponse { Id = p.Id, Url = p.AccessUrl, ThumbnailUrl = p.ThumbnailAccessUrl, Filename = p.Name, ContentType = p.Type, Sensitive = p.IsSensitive, Description = p.Comment, IsAvatar = p.UserAvatar != null, IsBanner = p.UserBanner != null }) .ToListAsync(); var driveFolders = await db.DriveFolders .Where(p => p.ParentId == id && p.UserId == user.Id) .Select(p => new DriveFolderResponse { Id = p.Id, Name = p.Name, ParentId = p.ParentId }) .ToListAsync(); return new DriveFolderResponse { Id = folder?.Id, Name = folder?.Name, ParentId = folder?.ParentId, Files = driveFiles, Folders = driveFolders }; } [HttpPut("folder/{id}")] [Authenticate] [Authorize] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound, HttpStatusCode.Conflict)] public async Task UpdateFolder(string id, [FromHybrid] string name) { var user = HttpContext.GetUserOrFail(); if (string.IsNullOrWhiteSpace(name)) throw GracefulException.BadRequest("Name must not be empty"); var folder = await db.DriveFolders .FirstOrDefaultAsync(p => p.Id == id && p.UserId == user.Id) ?? throw GracefulException.RecordNotFound(); var existing = await db.DriveFolders #pragma warning disable CA1862 .FirstOrDefaultAsync(p => p.Name.ToLower() == name.Trim().ToLower() #pragma warning restore CA1862 && p.ParentId == folder.ParentId && p.UserId == user.Id); if (existing != null) throw GracefulException.Conflict("A folder with this name already exists"); folder.Name = name.Trim(); await db.SaveChangesAsync(); return new DriveFolderResponse { Id = folder.Id, Name = folder.Name, ParentId = folder.ParentId }; } [HttpDelete("folder/{id}")] [Authenticate] [Authorize] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound, HttpStatusCode.Conflict)] public async Task DeleteFolder(string id) { var user = HttpContext.GetUserOrFail(); if (string.IsNullOrWhiteSpace(id)) throw GracefulException.BadRequest("Cannot delete root folder"); var folder = await db.DriveFolders .FirstOrDefaultAsync(p => p.Id == id && p.UserId == user.Id) ?? throw GracefulException.RecordNotFound(); var driveFiles = await db.DriveFiles .CountAsync(p => p.FolderId == id); var driveFolders = await db.DriveFolders .CountAsync(p => p.ParentId == id); if (driveFiles != 0 || driveFolders != 0) throw GracefulException.Conflict("Cannot delete a non-empty folder"); db.Remove(folder); await db.SaveChangesAsync(); } [HttpPut("folder/{id}/move")] [Authenticate] [Authorize] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound, HttpStatusCode.Conflict)] public async Task UpdateFolderParent(string id, DriveMoveRequest request) { var user = HttpContext.GetUserOrFail(); if (request.FolderId == id) throw GracefulException.BadRequest("Cannot move a folder into itself"); var folder = await db.DriveFolders .FirstOrDefaultAsync(p => p.Id == id && p.UserId == user.Id) ?? throw GracefulException.RecordNotFound(); if (request.FolderId != null) { var parent = await db.DriveFolders .FirstOrDefaultAsync(p => p.Id == request.FolderId && p.UserId == user.Id); if (parent == null) throw GracefulException.NotFound("The new parent folder doesn't exist"); } var existing = await db.DriveFolders .FirstOrDefaultAsync(p => p.Name == folder.Name && p.ParentId == request.FolderId && p.UserId == user.Id); if (existing != null) throw GracefulException.Conflict("A folder with this name already exists in the new parent folder"); folder.ParentId = request.FolderId; await db.SaveChangesAsync(); return new DriveFolderResponse { Id = folder.Id, Name = folder.Name, ParentId = folder.ParentId, }; } private async Task GetFileByAccessKey(string accessKey, string? version, DriveFile? file) { file ??= await db.DriveFiles.FirstOrDefaultAsync(p => p.AccessKey == accessKey || p.PublicAccessKey == accessKey || p.ThumbnailAccessKey == accessKey); if (file == null) { Response.Headers.CacheControl = "max-age=86400"; throw GracefulException.NotFound("File not found"); } if (file.IsLink) { var fetchUrl = version is "thumbnail" ? file.RawThumbnailAccessUrl : file.RawAccessUrl; if (!options.Value.ProxyRemoteMedia) return Redirect(fetchUrl); try { var filename = file.AccessKey == accessKey || file.Name.EndsWith(".webp") ? file.Name : $"{file.Name}.webp"; return await ProxyAsync(fetchUrl, file.Type, filename); } catch (Exception e) when (e is not GracefulException) { throw GracefulException.BadGateway($"Failed to proxy request: {e.Message}", suppressLog: true); } } if (file.StoredInternal) { var pathBase = options.Value.Local?.Path; if (string.IsNullOrWhiteSpace(pathBase)) { logger.LogError("Failed to get file {accessKey} from local storage: path does not exist", accessKey); throw GracefulException.NotFound("File not found"); } var path = Path.Join(pathBase, accessKey); var stream = System.IO.File.OpenRead(path); Response.Headers.CacheControl = CacheControl; Response.Headers.XContentTypeOptions = "nosniff"; return Constants.BrowserSafeMimeTypes.Contains(file.Type) ? new InlineFileStreamResult(stream, file.Type, file.Name, true) : File(stream, file.Type, file.Name, true); } else { var stream = await objectStorage.GetFileAsync(accessKey); if (stream == null) { logger.LogError("Failed to get file {accessKey} from object storage", accessKey); throw GracefulException.NotFound("File not found"); } Response.Headers.CacheControl = CacheControl; Response.Headers.XContentTypeOptions = "nosniff"; return Constants.BrowserSafeMimeTypes.Contains(file.Type) ? new InlineFileStreamResult(stream, file.Type, file.Name, true) : File(stream, file.Type, file.Name, true); } } private async Task ProxyAsync(string url, string? expectedMediaType, string? filename) { try { // @formatter:off var res = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead); if (!res.IsSuccessStatusCode) throw GracefulException.BadGateway($"Failed to proxy request: response status was {res.StatusCode}", suppressLog: true); if (res.Content.Headers.ContentType?.MediaType is not { } mediaType) throw GracefulException.BadGateway("Failed to proxy request: remote didn't return Content-Type"); if (expectedMediaType != null && mediaType != expectedMediaType && !Constants.BrowserSafeMimeTypes.Contains(mediaType)) throw GracefulException.BadGateway("Failed to proxy request: content type mismatch", suppressLog: true); // @formatter:on Response.Headers.CacheControl = CacheControl; Response.Headers.XContentTypeOptions = "nosniff"; var stream = await res.Content.ReadAsStreamAsync(); return Constants.BrowserSafeMimeTypes.Contains(mediaType) ? new InlineFileStreamResult(stream, mediaType, filename, true) : File(stream, mediaType, filename, true); } catch (Exception e) when (e is not GracefulException) { throw GracefulException.BadGateway($"Failed to proxy request: {e.Message}", suppressLog: true); } } }