using System.Net; using System.Net.Mime; using System.Text; using AngleSharp.Text; 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.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using QRCoder; namespace Iceshrimp.Backend.Controllers.Web; [ApiController] [Authenticate] [Authorize] [EnableRateLimiting("sliding")] [Route("/api/iceshrimp/settings")] [Produces(MediaTypeNames.Application.Json)] public class SettingsController( DatabaseContext db, ImportExportService importExportSvc, MetaService meta, QueueService queueSvc, IOptions instance ) : ControllerBase { [HttpGet] [ProducesResults(HttpStatusCode.OK)] public async Task GetSettings() { var settings = await GetOrInitUserSettings(); return new UserSettingsResponse { FilterInaccessible = settings.FilterInaccessible, PrivateMode = settings.PrivateMode, AlwaysMarkSensitive = settings.AlwaysMarkSensitive, AutoAcceptFollowed = settings.AutoAcceptFollowed, DefaultNoteVisibility = (NoteVisibility)settings.DefaultNoteVisibility, DefaultRenoteVisibility = (NoteVisibility)settings.DefaultNoteVisibility, TwoFactorEnrolled = settings.TwoFactorEnabled }; } [HttpPut] [Consumes(MediaTypeNames.Application.Json)] [ProducesResults(HttpStatusCode.OK)] public async Task UpdateSettings(UserSettingsRequest newSettings) { var settings = await GetOrInitUserSettings(); if (newSettings.DefaultRenoteVisibility == NoteVisibility.Specified) throw GracefulException.BadRequest("Default renote visibility cannot be 'specified'"); settings.FilterInaccessible = newSettings.FilterInaccessible; settings.PrivateMode = newSettings.PrivateMode; settings.AlwaysMarkSensitive = newSettings.AlwaysMarkSensitive; settings.AutoAcceptFollowed = newSettings.AutoAcceptFollowed; settings.DefaultNoteVisibility = (Note.NoteVisibility)newSettings.DefaultNoteVisibility; settings.DefaultRenoteVisibility = (Note.NoteVisibility)newSettings.DefaultRenoteVisibility; await db.SaveChangesAsync(); } [HttpPost("2fa/enroll")] [EnableRateLimiting("auth")] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.BadRequest)] public async Task EnrollTwoFactor() { var user = HttpContext.GetUserOrFail(); if (user.UserSettings is not { } settings) throw new Exception("Failed to get user settings object"); if (settings.TwoFactorEnabled) throw GracefulException.BadRequest("2FA is already enabled."); return await EnrollNewTwoFactorSecret(settings, user); } [HttpPost("2fa/reenroll")] [EnableRateLimiting("auth")] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.Forbidden)] public async Task ReenrollTwoFactor(TwoFactorRequest request) { var user = HttpContext.GetUserOrFail(); if (user.UserSettings is not { } settings) throw new Exception("Failed to get user settings object"); if (!settings.TwoFactorEnabled) throw GracefulException.BadRequest("2FA is not enabled."); if (settings.TwoFactorSecret is not { } secret) throw new Exception("2FA is enabled but no secret is set"); if (!TotpHelper.Validate(secret, request.Code)) throw GracefulException.Forbidden("Invalid TOTP"); return await EnrollNewTwoFactorSecret(settings, user); } [HttpPost("2fa/confirm")] [EnableRateLimiting("auth")] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.Forbidden)] public async Task ConfirmTwoFactor(TwoFactorRequest request) { var user = HttpContext.GetUserOrFail(); if (user.UserSettings is not { } settings) throw new Exception("Failed to get user settings object"); if (settings.TwoFactorTempSecret is not { } secret) throw GracefulException.BadRequest("No pending 2FA enrollment found"); if (!TotpHelper.Validate(secret, request.Code)) throw GracefulException.Forbidden("Invalid TOTP"); settings.TwoFactorEnabled = true; settings.TwoFactorSecret = secret; settings.TwoFactorTempSecret = null; await db.SaveChangesAsync(); } [HttpPost("2fa/disable")] [EnableRateLimiting("auth")] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.Forbidden)] public async Task DisableTwoFactor(TwoFactorRequest request) { var user = HttpContext.GetUserOrFail(); if (user.UserSettings is not { } settings) throw new Exception("Failed to get user settings object"); if (!settings.TwoFactorEnabled) throw GracefulException.BadRequest("2FA is not enabled."); if (settings.TwoFactorSecret is not { } secret) throw new Exception("2FA is enabled but no secret is set"); if (!TotpHelper.Validate(secret, request.Code)) throw GracefulException.Forbidden("Invalid TOTP"); settings.TwoFactorEnabled = false; settings.TwoFactorSecret = null; settings.TwoFactorTempSecret = null; await db.SaveChangesAsync(); } [HttpPost("export/following")] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.BadRequest)] public async Task ExportFollowing() { var user = HttpContext.GetUserOrFail(); var followCount = await db.Followings .CountAsync(p => p.FollowerId == user.Id); if (followCount < 1) throw GracefulException.BadRequest("You do not follow any users"); var following = await importExportSvc.ExportFollowingAsync(user); return File(Encoding.UTF8.GetBytes(following), "text/csv", $"following-{DateTime.Now:yyyy-MM-dd-HH-mm-ss}.csv"); } // TODO: Choose a more appropriate rate limit [HttpPost("export/notes")] [EnableRateLimiting("imports")] [ProducesResults(HttpStatusCode.Accepted)] [ProducesErrors(HttpStatusCode.BadRequest)] public async Task ExportNotes([FromQuery] bool includePrivate = false) { var user = HttpContext.GetUserOrFail(); var noteCount = await db.Notes .CountAsync(p => p.UserId == user.Id); if (noteCount < 1) throw GracefulException.BadRequest("You do not have any notes"); await queueSvc.BackgroundTaskQueue.EnqueueAsync(new NoteExportJobData { UserId = user.Id, IncludePrivate = includePrivate }); return Accepted(); } [HttpPost("import/following")] [EnableRateLimiting("imports")] [ProducesResults(HttpStatusCode.Accepted)] public async Task ImportFollowing(IFormFile file) { var user = HttpContext.GetUserOrFail(); var reader = new StreamReader(file.OpenReadStream()); var contents = await reader.ReadToEndAsync(); var fqns = contents .Split("\n") .Where(line => !string.IsNullOrWhiteSpace(line)) .Select(line => line.SplitCommas().First()) .Where(fqn => fqn.Contains('@')) .ToList(); await importExportSvc.ImportFollowingAsync(user, fqns); return Accepted(); } private async Task GetOrInitUserSettings() { var user = HttpContext.GetUserOrFail(); var settings = user.UserSettings; if (settings != null) return settings; settings = new UserSettings { User = user }; db.Add(settings); await db.SaveChangesAsync(); await db.ReloadEntityAsync(settings); return settings; } private async Task EnrollNewTwoFactorSecret(UserSettings settings, User user) { settings.TwoFactorTempSecret = TotpHelper.GenerateSecret(); await db.SaveChangesAsync(); var secret = settings.TwoFactorTempSecret; var instanceName = await meta.GetAsync(MetaEntity.InstanceName) ?? "Iceshrimp.NET"; var label = $"@{user.Username}@{instance.Value.AccountDomain}".Replace(':', '_'); var issuer = instanceName.Replace(':', '_'); var url = $"otpauth://totp/{label.UrlEncode()}?secret={secret}&issuer={issuer.UrlEncode()}"; using var qrData = QRCodeGenerator.GenerateQrCode(url, QRCodeGenerator.ECCLevel.Default, true, true); using var qrPng = new PngByteQRCode(qrData); var qrPngBytes = qrPng.GetGraphic(10, false); var qrPngBase64 = Convert.ToBase64String(qrPngBytes); return new TwoFactorEnrollmentResponse { Secret = settings.TwoFactorTempSecret, Url = url, QrPng = $"data:image/png;base64,{qrPngBase64}" }; } }