using System.Diagnostics.CodeAnalysis; using System.Net.Mime; using Iceshrimp.Backend.Controllers.Attributes; using Iceshrimp.Backend.Controllers.Federation; using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Shared.Schemas; using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Federation.ActivityStreams; using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types; using Iceshrimp.Backend.Core.Helpers; using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; namespace Iceshrimp.Backend.Controllers; [Authenticate] [Authorize("role:admin")] [ApiController] [Route("/api/iceshrimp/admin")] [SuppressMessage("ReSharper", "SuggestBaseTypeForParameterInConstructor", Justification = "We only have a DatabaseContext in our DI pool, not the base type")] public class AdminController( DatabaseContext db, ActivityPubController apController, ActivityPub.ActivityFetcherService fetchSvc, QueueService queueSvc, EmojiService emojiSvc ) : ControllerBase { [HttpPost("invites/generate")] [Produces(MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(InviteResponse))] public async Task GenerateInvite() { var invite = new RegistrationInvite { Id = IdHelpers.GenerateSlowflakeId(), CreatedAt = DateTime.UtcNow, Code = CryptographyHelpers.GenerateRandomString(32) }; await db.AddAsync(invite); await db.SaveChangesAsync(); var res = new InviteResponse { Code = invite.Code }; return Ok(res); } [HttpPost("users/{id}/reset-password")] [Produces(MediaTypeNames.Application.Json)] [Consumes(MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))] [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesResponseType(StatusCodes.Status200OK)] public async Task ResetPassword(string id, [FromBody] ResetPasswordRequest request) { var profile = await db.UserProfiles.FirstOrDefaultAsync(p => p.UserId == id && p.UserHost == null) ?? throw GracefulException.RecordNotFound(); if (request.Password.Length < 8) throw GracefulException.BadRequest("Password must be at least 8 characters long"); profile.Password = AuthHelpers.HashPassword(request.Password); await db.SaveChangesAsync(); return Ok(); } [HttpPost("instances/{host}/force-state/{state}")] [Produces(MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))] [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesResponseType(StatusCodes.Status200OK)] public async Task ForceInstanceState(string host, AdminSchemas.InstanceState state) { var instance = await db.Instances.FirstOrDefaultAsync(p => p.Host == host.ToLowerInvariant()) ?? throw GracefulException.NotFound("Instance not found"); if (state == AdminSchemas.InstanceState.Active) { instance.IsNotResponding = false; instance.LastCommunicatedAt = DateTime.UtcNow; instance.LatestRequestReceivedAt = DateTime.UtcNow; instance.LatestRequestSentAt = DateTime.UtcNow; } else { instance.IsNotResponding = true; instance.LastCommunicatedAt = DateTime.UnixEpoch; instance.LatestRequestReceivedAt = DateTime.UnixEpoch; instance.LatestRequestSentAt = DateTime.UnixEpoch; } await db.SaveChangesAsync(); return Ok(); } [HttpPost("queue/jobs/{id::guid}/retry")] [Produces(MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))] [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesResponseType(StatusCodes.Status200OK)] public async Task RetryQueueJob(Guid id) { var job = await db.Jobs.FirstOrDefaultAsync(p => p.Id == id) ?? throw GracefulException.NotFound($"Job {id} was not found."); await queueSvc.RetryJobAsync(job); return Ok(); } [UseNewtonsoftJson] [HttpGet("activities/notes/{id}")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASNote))] [Produces("application/activity+json", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")] public async Task GetNoteActivity(string id, [FromServices] ActivityPub.NoteRenderer noteRenderer) { var note = await db.Notes .IncludeCommonProperties() .FirstOrDefaultAsync(p => p.Id == id && p.UserHost == null); if (note == null) return NotFound(); var rendered = await noteRenderer.RenderAsync(note); var compacted = LdHelpers.Compact(rendered); return Ok(compacted); } [UseNewtonsoftJson] [HttpGet("activities/notes/{id}/activity")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASAnnounce))] [Produces("application/activity+json", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")] [SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataUsage")] [SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataQuery")] public async Task GetRenoteActivity( string id, [FromServices] ActivityPub.NoteRenderer noteRenderer, [FromServices] ActivityPub.UserRenderer userRenderer, [FromServices] IOptions config ) { var note = await db.Notes .IncludeCommonProperties() .FirstOrDefaultAsync(p => p.Id == id && p.UserHost == null); if (note is not { IsPureRenote: true, Renote: not null }) return NotFound(); var rendered = ActivityPub.ActivityRenderer.RenderAnnounce(noteRenderer.RenderLite(note.Renote), note.GetPublicUri(config.Value), userRenderer.RenderLite(note.User), note.Visibility, note.User.GetPublicUri(config.Value) + "/followers"); var compacted = rendered.Compact(); return Ok(compacted); } [UseNewtonsoftJson] [HttpGet("activities/users/{id}")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASActor))] [Produces("application/activity+json", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")] public async Task GetUserActivity(string id) { return await apController.GetUser(id); } [UseNewtonsoftJson] [HttpGet("activities/users/{id}/collections/featured")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASActor))] [Produces("application/activity+json", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")] public async Task GetUserFeaturedActivity(string id) { return await apController.GetUserFeatured(id); } [UseNewtonsoftJson] [HttpGet("activities/users/@{acct}")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASActor))] [Produces("application/activity+json", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")] public async Task GetUserActivityByUsername(string acct) { return await apController.GetUserByUsername(acct); } [UseNewtonsoftJson] [HttpGet("activities/fetch")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASObject))] [Produces("application/activity+json", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")] public async Task FetchActivityAsync([FromQuery] string uri, [FromQuery] string? userId) { var user = userId != null ? await db.Users.FirstOrDefaultAsync(p => p.Id == userId && p.IsLocalUser) : null; var activity = await fetchSvc.FetchActivityAsync(uri, user); if (!activity.Any()) throw GracefulException.UnprocessableEntity("Failed to fetch activity"); return Ok(LdHelpers.Compact(activity)); } [UseNewtonsoftJson] [HttpGet("activities/fetch-raw")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASObject))] [ProducesResponseType(StatusCodes.Status422UnprocessableEntity, Type = typeof(ErrorResponse))] [Produces("application/activity+json", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")] public async Task FetchRawActivityAsync([FromQuery] string uri, [FromQuery] string? userId) { var user = userId != null ? await db.Users.FirstOrDefaultAsync(p => p.Id == userId && p.IsLocalUser) : null; var activity = await fetchSvc.FetchRawActivityAsync(uri, user); if (activity == null) throw GracefulException.UnprocessableEntity("Failed to fetch activity"); Response.ContentType = Request.Headers.Accept.Any(p => p != null && p.StartsWith("application/ld+json")) ? "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"" : "application/activity+json"; await Response.WriteAsync(activity); } [HttpPost("emoji")] [Produces(MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(EmojiResponse))] public async Task UploadEmoji(IFormFile file, [FromServices] IOptions config) { var emoji = await emojiSvc.CreateEmojiFromStream(file.OpenReadStream(), file.FileName, file.ContentType, config.Value); var res = new EmojiResponse { Id = emoji.Id, Name = emoji.Name, Uri = emoji.Uri, Aliases = [], Category = null, PublicUrl = emoji.PublicUrl, License = null }; return Ok(res); } [HttpGet("emoji/{id}")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(EmojiResponse))] [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] public async Task GetEmoji(string id) { var emoji = await db.Emojis.FirstOrDefaultAsync(p => p.Id == id); if (emoji == null) return NotFound(); var res = new EmojiResponse { Id = emoji.Id, Name = emoji.Name, Uri = emoji.Uri, Aliases = emoji.Aliases, Category = emoji.Category, PublicUrl = emoji.PublicUrl, License = emoji.License }; return Ok(res); } [HttpPatch("emoji/{id}")] [Consumes(MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(EmojiResponse))] [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] public async Task UpdateEmoji(string id, UpdateEmojiRequest request, [FromServices] IOptions config) { var emoji = await emojiSvc.UpdateLocalEmoji(id, request.Name, request.Aliases, request.Category, request.License, config.Value); if (emoji == null) return NotFound(); var res = new EmojiResponse { Id = emoji.Id, Name = emoji.Name, Uri = emoji.Uri, Aliases = emoji.Aliases, Category = emoji.Category, PublicUrl = emoji.PublicUrl, License = emoji.License }; return Ok(res); } [HttpDelete("emoji/{id}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] public async Task DeleteEmoji(string id) { await emojiSvc.DeleteEmoji(id); return Ok(); } }