using System.Net; using System.Net.Mime; using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Services; using Iceshrimp.Shared.Schemas.Web; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; namespace Iceshrimp.Backend.Controllers.Web; [ApiController] [Authenticate] [Authorize] [EnableRateLimiting("sliding")] [Route("/api/iceshrimp/profile")] [Produces(MediaTypeNames.Application.Json)] public class ProfileController( UserService userSvc, DriveService driveSvc, DatabaseContext db ) : ControllerBase { [HttpGet] [ProducesResults(HttpStatusCode.OK)] public UserProfileEntity GetProfile() { var user = HttpContext.GetUserOrFail(); var profile = user.UserProfile ?? throw new Exception("Local user must have profile"); var fields = profile.Fields.Select(p => new UserProfileEntity.Field { Name = p.Name, Value = p.Value }); var ffVisibility = (UserProfileEntity.FFVisibilityEnum)profile.FFVisibility; return new UserProfileEntity { Description = profile.Description, Location = profile.Location, Birthday = profile.Birthday, FFVisibility = ffVisibility, Fields = fields.ToList(), DisplayName = user.DisplayName ?? "", IsBot = user.IsBot, IsCat = user.IsCat, SpeakAsCat = user.SpeakAsCat, Pronouns = profile.Pronouns ?? [] }; } [HttpPut] [Consumes(MediaTypeNames.Application.Json)] [ProducesResults(HttpStatusCode.OK)] public async Task UpdateProfile(UserProfileEntity newProfile) { var user = HttpContext.GetUserOrFail(); var profile = user.UserProfile ?? throw new Exception("Local user must have profile"); string? birthday = null; if (!string.IsNullOrWhiteSpace(newProfile.Birthday)) { if (!DateOnly.TryParseExact(newProfile.Birthday, "O", out var parsed)) throw GracefulException.BadRequest("Invalid birthday. Expected format is: YYYY-MM-DD"); birthday = parsed.ToString("O"); } var fields = newProfile.Fields.Select(p => new UserProfile.Field { Name = p.Name, Value = p.Value, IsVerified = false }); profile.Description = string.IsNullOrWhiteSpace(newProfile.Description) ? null : newProfile.Description; profile.Location = string.IsNullOrWhiteSpace(newProfile.Location) ? null : newProfile.Location; profile.Birthday = birthday; profile.Fields = fields.ToArray(); profile.FFVisibility = (UserProfile.UserProfileFFVisibility)newProfile.FFVisibility; profile.Pronouns = newProfile.Pronouns; user.DisplayName = string.IsNullOrWhiteSpace(newProfile.DisplayName) ? null : newProfile.DisplayName.Trim(); user.IsBot = newProfile.IsBot; user.IsCat = newProfile.IsCat; user.SpeakAsCat = newProfile is { SpeakAsCat: true, IsCat: true }; if (newProfile.AvatarAlt != null) { var avatar = await db.DriveFiles .FirstOrDefaultAsync(p => p.UserId == user.Id && p.UserAvatar != null); if (avatar != null) { user.Avatar = avatar; user.Avatar.Comment = string.IsNullOrWhiteSpace(newProfile.AvatarAlt) ? null : newProfile.AvatarAlt.Trim(); } } if (newProfile.BannerAlt != null) { var banner = await db.DriveFiles .FirstOrDefaultAsync(p => p.UserId == user.Id && p.UserBanner != null); if (banner != null) { user.Banner = banner; user.Banner.Comment = string.IsNullOrWhiteSpace(newProfile.BannerAlt) ? null : newProfile.BannerAlt.Trim(); } } var prevAvatarId = user.AvatarId; var prevBannerId = user.BannerId; await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId); } [HttpGet("avatar")] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.NotFound)] public async Task GetAvatar() { var user = HttpContext.GetUserOrFail(); var file = await db.DriveFiles .Include(p => p.UserAvatar) .Include(p => p.UserBanner) .FirstOrDefaultAsync(p => p.UserId == user.Id && p.UserAvatar != null) ?? throw GracefulException.RecordNotFound(); return new DriveFileResponse { Id = file.Id, Url = file.RawAccessUrl, ThumbnailUrl = file.RawThumbnailAccessUrl, Filename = file.Name, ContentType = file.Type, Sensitive = file.IsSensitive, Description = file.Comment, IsAvatar = file.UserAvatar != null, IsBanner = file.UserBanner != null }; } [HttpPost("avatar")] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.BadRequest)] public async Task UpdateAvatar(IFormFile file, [FromQuery] string? altText) { var user = HttpContext.GetUserOrFail(); var prevAvatarId = user.AvatarId; var prevBannerId = user.BannerId; if (!file.ContentType.StartsWith("image/")) throw GracefulException.BadRequest("Avatar must be an image"); var rq = new DriveFileCreationRequest { Filename = file.FileName, IsSensitive = false, MimeType = file.ContentType, Comment = altText }; var avatar = await driveSvc.StoreFileAsync(file.OpenReadStream(), user, rq); user.Avatar = avatar; user.AvatarId = avatar.Id; user.AvatarBlurhash = avatar.Blurhash; await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId); } [HttpDelete("avatar")] [ProducesResults(HttpStatusCode.OK)] public async Task DeleteAvatar() { var user = HttpContext.GetUserOrFail(); var prevAvatarId = user.AvatarId; var prevBannerId = user.BannerId; if (prevAvatarId == null) return; user.Avatar = null; user.AvatarBlurhash = null; await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId); } [HttpGet("banner")] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.NotFound)] public async Task GetBanner() { var user = HttpContext.GetUserOrFail(); var file = await db.DriveFiles .Include(p => p.UserAvatar) .Include(p => p.UserBanner) .FirstOrDefaultAsync(p => p.UserId == user.Id && p.UserBanner != null) ?? throw GracefulException.RecordNotFound(); return new DriveFileResponse { Id = file.Id, Url = file.RawAccessUrl, ThumbnailUrl = file.RawThumbnailAccessUrl, Filename = file.Name, ContentType = file.Type, Sensitive = file.IsSensitive, Description = file.Comment, IsAvatar = file.UserAvatar != null, IsBanner = file.UserBanner != null }; } [HttpPost("banner")] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.BadRequest)] public async Task UpdateBanner(IFormFile file, [FromQuery] string? altText) { var user = HttpContext.GetUserOrFail(); var prevAvatarId = user.AvatarId; var prevBannerId = user.BannerId; if (!file.ContentType.StartsWith("image/")) throw GracefulException.BadRequest("Banner must be an image"); var rq = new DriveFileCreationRequest { Filename = file.FileName, IsSensitive = false, MimeType = file.ContentType, Comment = altText }; var banner = await driveSvc.StoreFileAsync(file.OpenReadStream(), user, rq); user.Banner = banner; user.BannerId = banner.Id; user.BannerBlurhash = banner.Blurhash; await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId); } [HttpDelete("banner")] [ProducesResults(HttpStatusCode.OK)] public async Task DeleteBanner() { var user = HttpContext.GetUserOrFail(); var prevAvatarId = user.AvatarId; var prevBannerId = user.BannerId; if (prevBannerId == null) return; user.Banner = null; user.BannerBlurhash = null; await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId); } }