[backend/asp] Refactor controllers

This commit aims to improve readability of MVC controllers & actions. The main change is the switch to custom [ProducesResults] and [ProducesErrors] attributes.
This commit is contained in:
Laura Hausmann 2024-07-06 05:15:17 +02:00
parent 5a8295e4f7
commit 0776a50cbe
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
40 changed files with 1246 additions and 1097 deletions

View file

@ -1,3 +1,4 @@
using System.Net;
using System.Text; using System.Text;
using Iceshrimp.Backend.Controllers.Federation.Attributes; using Iceshrimp.Backend.Controllers.Federation.Attributes;
using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Controllers.Shared.Attributes;
@ -9,17 +10,17 @@ using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Queues; using Iceshrimp.Backend.Core.Queues;
using Iceshrimp.Backend.Core.Services; using Iceshrimp.Backend.Core.Services;
using Iceshrimp.Shared.Schemas.Web;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Newtonsoft.Json.Linq;
namespace Iceshrimp.Backend.Controllers.Federation; namespace Iceshrimp.Backend.Controllers.Federation;
[FederationApiController] [FederationApiController]
[FederationSemaphore] [FederationSemaphore]
[UseNewtonsoftJson] [UseNewtonsoftJson]
[Produces("application/activity+json", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")] [ProducesActivityStreamsPayload]
public class ActivityPubController( public class ActivityPubController(
DatabaseContext db, DatabaseContext db,
QueueService queues, QueueService queues,
@ -31,71 +32,78 @@ public class ActivityPubController(
[HttpGet("/notes/{id}")] [HttpGet("/notes/{id}")]
[AuthorizedFetch] [AuthorizedFetch]
[MediaTypeRouteFilter("application/activity+json", "application/ld+json")] [MediaTypeRouteFilter("application/activity+json", "application/ld+json")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASNote))] [OverrideResultType<ASNote>]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(ErrorResponse))] [ProducesResults(HttpStatusCode.OK, HttpStatusCode.MovedPermanently)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetNote(string id) public async Task<ActionResult<JObject>> GetNote(string id)
{ {
var actor = HttpContext.GetActor(); var actor = HttpContext.GetActor();
var note = await db.Notes var note = await db.Notes
.IncludeCommonProperties() .IncludeCommonProperties()
.EnsureVisibleFor(actor) .EnsureVisibleFor(actor)
.FirstOrDefaultAsync(p => p.Id == id); .FirstOrDefaultAsync(p => p.Id == id);
if (note == null) return NotFound(); if (note == null) throw GracefulException.NotFound("Note not found");
if (note.User.IsRemoteUser) if (note.User.IsRemoteUser)
return RedirectPermanent(note.Uri ?? throw new Exception("Refusing to render remote note without uri")); return RedirectPermanent(note.Uri ?? throw new Exception("Refusing to render remote note without uri"));
var rendered = await noteRenderer.RenderAsync(note); var rendered = await noteRenderer.RenderAsync(note);
var compacted = rendered.Compact(); return rendered.Compact();
return Ok(compacted);
} }
[HttpGet("/notes/{id}/activity")] [HttpGet("/notes/{id}/activity")]
[AuthorizedFetch] [AuthorizedFetch]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASActivity))] [OverrideResultType<ASAnnounce>]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(ErrorResponse))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetRenote(string id) public async Task<JObject> GetRenote(string id)
{ {
var actor = HttpContext.GetActor(); var actor = HttpContext.GetActor();
var note = await db.Notes var note = await db.Notes
.IncludeCommonProperties() .IncludeCommonProperties()
.EnsureVisibleFor(actor) .EnsureVisibleFor(actor)
.FirstOrDefaultAsync(p => p.Id == id && p.UserHost == null); .Where(p => p.Id == id && p.UserHost == null && p.IsPureRenote && p.Renote != null)
.FirstOrDefaultAsync() ??
throw GracefulException.NotFound("Note not found");
if (note is not { IsPureRenote: true, Renote: not null }) return NotFound(); return ActivityPub.ActivityRenderer
.RenderAnnounce(noteRenderer.RenderLite(note.Renote!),
var rendered = ActivityPub.ActivityRenderer.RenderAnnounce(noteRenderer.RenderLite(note.Renote), note.GetPublicUri(config.Value),
note.GetPublicUri(config.Value), userRenderer.RenderLite(note.User),
userRenderer.RenderLite(note.User), note.Visibility,
note.Visibility, note.User.GetPublicUri(config.Value) + "/followers")
note.User.GetPublicUri(config.Value) + "/followers"); .Compact();
var compacted = rendered.Compact();
return Ok(compacted);
} }
[HttpGet("/users/{id}")] [HttpGet("/users/{id}")]
[AuthorizedFetch] [AuthorizedFetch]
[MediaTypeRouteFilter("application/activity+json", "application/ld+json")] [MediaTypeRouteFilter("application/activity+json", "application/ld+json")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASActor))] [OverrideResultType<ASActor>]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesResults(HttpStatusCode.OK, HttpStatusCode.MovedPermanently)]
public async Task<IActionResult> GetUser(string id) [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<ActionResult<JObject>> GetUser(string id)
{ {
var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id); var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id);
if (user == null) return NotFound(); if (user == null) throw GracefulException.NotFound("User not found");
if (user.IsRemoteUser) return user.Uri != null ? RedirectPermanent(user.Uri) : NotFound(); if (user.IsRemoteUser)
var rendered = await userRenderer.RenderAsync(user); {
var compacted = LdHelpers.Compact(rendered); if (user.Uri != null)
return Ok(compacted); return RedirectPermanent(user.Uri);
throw GracefulException.NotFound("User not found");
}
var rendered = await userRenderer.RenderAsync(user);
return ((ASObject)rendered).Compact();
} }
[HttpGet("/users/{id}/collections/featured")] [HttpGet("/users/{id}/collections/featured")]
[AuthorizedFetch] [AuthorizedFetch]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASActor))] [OverrideResultType<ASOrderedCollection>]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> GetUserFeatured(string id) [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<JObject> GetUserFeatured(string id)
{ {
var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id && p.IsLocalUser); var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id && p.IsLocalUser);
if (user == null) return NotFound(); if (user == null) throw GracefulException.NotFound("User not found");
var pins = await db.UserNotePins.Where(p => p.User == user) var pins = await db.UserNotePins.Where(p => p.User == user)
.OrderByDescending(p => p.Id) .OrderByDescending(p => p.Id)
@ -114,65 +122,72 @@ public class ActivityPubController(
Items = rendered.Cast<ASObject>().ToList() Items = rendered.Cast<ASObject>().ToList()
}; };
var compacted = res.Compact(); return res.Compact();
return Ok(compacted);
} }
[HttpGet("/@{acct}")] [HttpGet("/@{acct}")]
[AuthorizedFetch] [AuthorizedFetch]
[MediaTypeRouteFilter("application/activity+json", "application/ld+json")] [MediaTypeRouteFilter("application/activity+json", "application/ld+json")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASActor))] [OverrideResultType<ASActor>]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesResults(HttpStatusCode.OK, HttpStatusCode.MovedPermanently)]
public async Task<IActionResult> GetUserByUsername(string acct) [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<ActionResult<JObject>> GetUserByUsername(string acct)
{ {
var split = acct.Split('@'); var split = acct.Split('@');
if (acct.Split('@').Length > 2) return NotFound(); if (acct.Split('@').Length > 2) throw GracefulException.NotFound("User not found");
if (split.Length == 2) if (split.Length == 2)
{ {
var remoteUser = await db.Users.IncludeCommonProperties() var remoteUser = await db.Users
.IncludeCommonProperties()
.FirstOrDefaultAsync(p => p.UsernameLower == split[0].ToLowerInvariant() && .FirstOrDefaultAsync(p => p.UsernameLower == split[0].ToLowerInvariant() &&
p.Host == split[1].ToLowerInvariant().ToPunycode()); p.Host == split[1].ToLowerInvariant().ToPunycode());
return remoteUser?.Uri != null ? RedirectPermanent(remoteUser.Uri) : NotFound();
if (remoteUser?.Uri != null)
return RedirectPermanent(remoteUser.Uri);
throw GracefulException.NotFound("User not found");
} }
var user = await db.Users.IncludeCommonProperties() var user = await db.Users
.IncludeCommonProperties()
.FirstOrDefaultAsync(p => p.UsernameLower == acct.ToLowerInvariant() && p.IsLocalUser); .FirstOrDefaultAsync(p => p.UsernameLower == acct.ToLowerInvariant() && p.IsLocalUser);
if (user == null) return NotFound();
var rendered = await userRenderer.RenderAsync(user); if (user == null) throw GracefulException.NotFound("User not found");
var compacted = LdHelpers.Compact(rendered); var rendered = await userRenderer.RenderAsync(user);
return Ok(compacted); return ((ASObject)rendered).Compact();
} }
[HttpPost("/inbox")] [HttpPost("/inbox")]
[HttpPost("/users/{id}/inbox")] [HttpPost("/users/{id}/inbox")]
[InboxValidation] [InboxValidation]
[EnableRequestBuffering(1024 * 1024)] [EnableRequestBuffering(1024 * 1024)]
[Produces("text/plain")] [ConsumesActivityStreamsPayload]
[Consumes("application/activity+json", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")] [ProducesResults(HttpStatusCode.Accepted)]
public async Task<IActionResult> Inbox(string? id) public async Task<AcceptedResult> Inbox(string? id)
{ {
using var reader = new StreamReader(Request.Body, Encoding.UTF8, true, 1024, true); using var reader = new StreamReader(Request.Body, Encoding.UTF8, true, 1024, true);
var body = await reader.ReadToEndAsync(); var body = await reader.ReadToEndAsync();
Request.Body.Position = 0; Request.Body.Position = 0;
await queues.InboxQueue.EnqueueAsync(new InboxJobData await queues.InboxQueue.EnqueueAsync(new InboxJobData
{ {
Body = body, Body = body,
InboxUserId = id, InboxUserId = id,
AuthenticatedUserId = HttpContext.GetActor()?.Id AuthenticatedUserId = HttpContext.GetActor()?.Id
}); });
return Accepted(); return Accepted();
} }
[HttpGet("/emoji/{name}")] [HttpGet("/emoji/{name}")]
[AuthorizedFetch] [AuthorizedFetch]
[MediaTypeRouteFilter("application/activity+json", "application/ld+json")] [MediaTypeRouteFilter("application/activity+json", "application/ld+json")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASEmoji))] [OverrideResultType<ASEmoji>]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(ErrorResponse))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetEmoji(string name) public async Task<ActionResult<JObject>> GetEmoji(string name)
{ {
var emoji = await db.Emojis.FirstOrDefaultAsync(p => p.Name == name && p.Host == null); var emoji = await db.Emojis.FirstOrDefaultAsync(p => p.Name == name && p.Host == null);
if (emoji == null) return NotFound(); if (emoji == null) throw GracefulException.NotFound("Emoji not found");
var rendered = new ASEmoji var rendered = new ASEmoji
{ {
@ -181,7 +196,6 @@ public class ActivityPubController(
Image = new ASImage { Url = new ASLink(emoji.PublicUrl) } Image = new ASImage { Url = new ASLink(emoji.PublicUrl) }
}; };
var compacted = LdHelpers.Compact(rendered); return LdHelpers.Compact(rendered);
return Ok(compacted);
} }
} }

View file

@ -1,5 +1,7 @@
using System.Net;
using System.Net.Mime; using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Federation.Attributes; using Iceshrimp.Backend.Controllers.Federation.Attributes;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Federation.WebFinger; using Iceshrimp.Backend.Core.Federation.WebFinger;
@ -18,8 +20,8 @@ public class NodeInfoController(IOptions<Config.InstanceSection> config, Databas
{ {
[HttpGet("2.1")] [HttpGet("2.1")]
[HttpGet("2.0")] [HttpGet("2.0")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(WebFingerResponse))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> GetNodeInfo() public async Task<NodeInfoResponse> GetNodeInfo()
{ {
var cutoffMonth = DateTime.UtcNow - TimeSpan.FromDays(30); var cutoffMonth = DateTime.UtcNow - TimeSpan.FromDays(30);
var cutoffHalfYear = DateTime.UtcNow - TimeSpan.FromDays(180); var cutoffHalfYear = DateTime.UtcNow - TimeSpan.FromDays(180);
@ -36,7 +38,7 @@ public class NodeInfoController(IOptions<Config.InstanceSection> config, Databas
p.LastActiveDate > cutoffHalfYear); p.LastActiveDate > cutoffHalfYear);
var localPosts = await db.Notes.LongCountAsync(p => p.UserHost == null); var localPosts = await db.Notes.LongCountAsync(p => p.UserHost == null);
var result = new NodeInfoResponse return new NodeInfoResponse
{ {
Version = Request.Path.Value?.EndsWith("2.1") ?? false ? "2.1" : "2.0", Version = Request.Path.Value?.EndsWith("2.1") ?? false ? "2.1" : "2.0",
Software = new NodeInfoResponse.NodeInfoSoftware Software = new NodeInfoResponse.NodeInfoSoftware
@ -90,7 +92,5 @@ public class NodeInfoController(IOptions<Config.InstanceSection> config, Databas
}, },
OpenRegistrations = false OpenRegistrations = false
}; };
return Ok(result);
} }
} }

View file

@ -1,14 +1,16 @@
using System.Net;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Net.Mime; using System.Net.Mime;
using System.Text; using System.Text;
using System.Xml.Serialization; using System.Xml.Serialization;
using Iceshrimp.Backend.Controllers.Federation.Attributes; using Iceshrimp.Backend.Controllers.Federation.Attributes;
using Iceshrimp.Backend.Controllers.Federation.Schemas; using Iceshrimp.Backend.Controllers.Federation.Schemas;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Federation.WebFinger; using Iceshrimp.Backend.Core.Federation.WebFinger;
using Iceshrimp.Shared.Schemas.Web; using Iceshrimp.Backend.Core.Middleware;
using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -23,9 +25,9 @@ public class WellKnownController(IOptions<Config.InstanceSection> config, Databa
{ {
[HttpGet("webfinger")] [HttpGet("webfinger")]
[Produces(MediaTypeNames.Application.Json)] [Produces(MediaTypeNames.Application.Json)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(WebFingerResponse))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> WebFinger([FromQuery] string resource) public async Task<WebFingerResponse> WebFinger([FromQuery] string resource)
{ {
User? user; User? user;
if (resource.StartsWith($"https://{config.Value.WebDomain}/users/")) if (resource.StartsWith($"https://{config.Value.WebDomain}/users/"))
@ -39,20 +41,20 @@ public class WellKnownController(IOptions<Config.InstanceSection> config, Databa
resource = resource[5..]; resource = resource[5..];
var split = resource.TrimStart('@').Split('@'); var split = resource.TrimStart('@').Split('@');
if (split.Length > 2) return NotFound(); if (split.Length > 2) throw GracefulException.NotFound("User not found");
if (split.Length == 2) if (split.Length == 2)
{ {
List<string> domains = [config.Value.AccountDomain, config.Value.WebDomain]; List<string> domains = [config.Value.AccountDomain, config.Value.WebDomain];
if (!domains.Contains(split[1])) return NotFound(); if (!domains.Contains(split[1])) throw GracefulException.NotFound("User not found");
} }
user = await db.Users.FirstOrDefaultAsync(p => p.UsernameLower == split[0].ToLowerInvariant() && user = await db.Users.FirstOrDefaultAsync(p => p.UsernameLower == split[0].ToLowerInvariant() &&
p.IsLocalUser); p.IsLocalUser);
} }
if (user == null) return NotFound(); if (user == null) throw GracefulException.NotFound("User not found");
var response = new WebFingerResponse return new WebFingerResponse
{ {
Subject = $"acct:{user.Username}@{config.Value.AccountDomain}", Subject = $"acct:{user.Username}@{config.Value.AccountDomain}",
Links = Links =
@ -76,16 +78,14 @@ public class WellKnownController(IOptions<Config.InstanceSection> config, Databa
} }
] ]
}; };
return Ok(response);
} }
[HttpGet("nodeinfo")] [HttpGet("nodeinfo")]
[Produces(MediaTypeNames.Application.Json)] [Produces(MediaTypeNames.Application.Json)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(NodeInfoIndexResponse))] [ProducesResults(HttpStatusCode.OK)]
public IActionResult NodeInfo() public NodeInfoIndexResponse NodeInfo()
{ {
var response = new NodeInfoIndexResponse return new NodeInfoIndexResponse
{ {
Links = Links =
[ [
@ -101,14 +101,12 @@ public class WellKnownController(IOptions<Config.InstanceSection> config, Databa
} }
] ]
}; };
return Ok(response);
} }
[HttpGet("host-meta")] [HttpGet("host-meta")]
[Produces("application/xrd+xml", "application/jrd+json")] [Produces("application/xrd+xml", "application/jrd+json")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(HostMetaJsonResponse))] [ProducesResults(HttpStatusCode.OK)]
public IActionResult HostMeta() public ActionResult<HostMetaJsonResponse> HostMeta()
{ {
var accept = Request.Headers.Accept.OfType<string>() var accept = Request.Headers.Accept.OfType<string>()
.SelectMany(p => p.Split(",")) .SelectMany(p => p.Split(","))
@ -117,7 +115,7 @@ public class WellKnownController(IOptions<Config.InstanceSection> config, Databa
.ToList(); .ToList();
if (accept.Contains("application/jrd+json") || accept.Contains("application/json")) if (accept.Contains("application/jrd+json") || accept.Contains("application/json"))
return HostMetaJson(); return Ok(HostMetaJson());
var obj = new HostMetaXmlResponse(config.Value.WebDomain); var obj = new HostMetaXmlResponse(config.Value.WebDomain);
var serializer = new XmlSerializer(obj.GetType()); var serializer = new XmlSerializer(obj.GetType());
@ -129,10 +127,10 @@ public class WellKnownController(IOptions<Config.InstanceSection> config, Databa
[HttpGet("host-meta.json")] [HttpGet("host-meta.json")]
[Produces("application/jrd+json")] [Produces("application/jrd+json")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(HostMetaJsonResponse))] [ProducesResults(HttpStatusCode.OK)]
public IActionResult HostMetaJson() public HostMetaJsonResponse HostMetaJson()
{ {
return Ok(new HostMetaJsonResponse(config.Value.WebDomain)); return new HostMetaJsonResponse(config.Value.WebDomain);
} }
private class Utf8StringWriter : StringWriter private class Utf8StringWriter : StringWriter

View file

@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Net.Mime; using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Mastodon.Attributes; using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Controllers.Mastodon.Renderers; using Iceshrimp.Backend.Controllers.Mastodon.Renderers;
@ -37,18 +38,17 @@ public class AccountController(
{ {
[HttpGet("verify_credentials")] [HttpGet("verify_credentials")]
[Authorize("read:accounts")] [Authorize("read:accounts")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AccountEntity))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> VerifyUserCredentials() public async Task<AccountEntity> VerifyUserCredentials()
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var res = await userRenderer.RenderAsync(user, user.UserProfile, source: true); return await userRenderer.RenderAsync(user, user.UserProfile, source: true);
return Ok(res);
} }
[HttpPatch("update_credentials")] [HttpPatch("update_credentials")]
[Authorize("write:accounts")] [Authorize("write:accounts")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AccountEntity))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> UpdateUserCredentials([FromHybrid] AccountSchemas.AccountUpdateRequest request) public async Task<AccountEntity> UpdateUserCredentials([FromHybrid] AccountSchemas.AccountUpdateRequest request)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
if (user.UserProfile == null) if (user.UserProfile == null)
@ -124,15 +124,13 @@ public class AccountController(
} }
user = await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId); user = await userSvc.UpdateLocalUserAsync(user, prevAvatarId, prevBannerId);
return await userRenderer.RenderAsync(user, user.UserProfile, source: true);
var res = await userRenderer.RenderAsync(user, user.UserProfile, source: true);
return Ok(res);
} }
[HttpDelete("/api/v1/profile/avatar")] [HttpDelete("/api/v1/profile/avatar")]
[Authorize("write:accounts")] [Authorize("write:accounts")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AccountEntity))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> DeleteUserAvatar() public async Task<AccountEntity> DeleteUserAvatar()
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
if (user.AvatarId != null) if (user.AvatarId != null)
@ -153,8 +151,8 @@ public class AccountController(
[HttpDelete("/api/v1/profile/header")] [HttpDelete("/api/v1/profile/header")]
[Authorize("write:accounts")] [Authorize("write:accounts")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AccountEntity))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> DeleteUserBanner() public async Task<AccountEntity> DeleteUserBanner()
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
if (user.BannerId != null) if (user.BannerId != null)
@ -174,9 +172,9 @@ public class AccountController(
} }
[HttpGet("{id}")] [HttpGet("{id}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AccountEntity))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetUser(string id) public async Task<AccountEntity> GetUser(string id)
{ {
var localUser = HttpContext.GetUser(); var localUser = HttpContext.GetUser();
if (config.Value.PublicPreview == Enums.PublicPreview.Lockdown && localUser == null) if (config.Value.PublicPreview == Enums.PublicPreview.Lockdown && localUser == null)
@ -188,16 +186,15 @@ public class AccountController(
if (config.Value.PublicPreview <= Enums.PublicPreview.Restricted && user.IsRemoteUser && localUser == null) if (config.Value.PublicPreview <= Enums.PublicPreview.Restricted && user.IsRemoteUser && localUser == null)
throw GracefulException.Forbidden("Public preview is disabled on this instance"); throw GracefulException.Forbidden("Public preview is disabled on this instance");
var res = await userRenderer.RenderAsync(await userResolver.GetUpdatedUser(user)); return await userRenderer.RenderAsync(await userResolver.GetUpdatedUser(user));
return Ok(res);
} }
[HttpPost("{id}/follow")] [HttpPost("{id}/follow")]
[Authorize("write:follows")] [Authorize("write:follows")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RelationshipEntity))] [ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.Forbidden, HttpStatusCode.NotFound)]
//TODO: [FromHybrid] request (bool reblogs, bool notify, bool languages) //TODO: [FromHybrid] request (bool reblogs, bool notify, bool languages)
public async Task<IActionResult> FollowUser(string id) public async Task<RelationshipEntity> FollowUser(string id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
if (user.Id == id) if (user.Id == id)
@ -222,15 +219,14 @@ public class AccountController(
followee.PrecomputedIsFollowedBy = true; followee.PrecomputedIsFollowedBy = true;
} }
var res = RenderRelationship(followee); return RenderRelationship(followee);
return Ok(res);
} }
[HttpPost("{id}/unfollow")] [HttpPost("{id}/unfollow")]
[Authorize("write:follows")] [Authorize("write:follows")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RelationshipEntity))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> UnfollowUser(string id) [ProducesErrors(HttpStatusCode.BadRequest)]
public async Task<RelationshipEntity> UnfollowUser(string id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
if (user.Id == id) if (user.Id == id)
@ -244,16 +240,14 @@ public class AccountController(
throw GracefulException.RecordNotFound(); throw GracefulException.RecordNotFound();
await userSvc.UnfollowUserAsync(user, followee); await userSvc.UnfollowUserAsync(user, followee);
return RenderRelationship(followee);
var res = RenderRelationship(followee);
return Ok(res);
} }
[HttpPost("{id}/mute")] [HttpPost("{id}/mute")]
[Authorize("write:mutes")] [Authorize("write:mutes")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RelationshipEntity))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> MuteUser(string id, [FromHybrid] AccountSchemas.AccountMuteRequest request) [ProducesErrors(HttpStatusCode.BadRequest)]
public async Task<RelationshipEntity> MuteUser(string id, [FromHybrid] AccountSchemas.AccountMuteRequest request)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
if (user.Id == id) if (user.Id == id)
@ -269,16 +263,14 @@ public class AccountController(
//TODO: handle notifications parameter //TODO: handle notifications parameter
DateTime? expiration = request.Duration == 0 ? null : DateTime.UtcNow + TimeSpan.FromSeconds(request.Duration); DateTime? expiration = request.Duration == 0 ? null : DateTime.UtcNow + TimeSpan.FromSeconds(request.Duration);
await userSvc.MuteUserAsync(user, mutee, expiration); await userSvc.MuteUserAsync(user, mutee, expiration);
return RenderRelationship(mutee);
var res = RenderRelationship(mutee);
return Ok(res);
} }
[HttpPost("{id}/unmute")] [HttpPost("{id}/unmute")]
[Authorize("write:mutes")] [Authorize("write:mutes")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RelationshipEntity))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> UnmuteUser(string id) [ProducesErrors(HttpStatusCode.BadRequest)]
public async Task<RelationshipEntity> UnmuteUser(string id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
if (user.Id == id) if (user.Id == id)
@ -292,16 +284,14 @@ public class AccountController(
throw GracefulException.RecordNotFound(); throw GracefulException.RecordNotFound();
await userSvc.UnmuteUserAsync(user, mutee); await userSvc.UnmuteUserAsync(user, mutee);
return RenderRelationship(mutee);
var res = RenderRelationship(mutee);
return Ok(res);
} }
[HttpPost("{id}/block")] [HttpPost("{id}/block")]
[Authorize("write:blocks")] [Authorize("write:blocks")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RelationshipEntity))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> BlockUser(string id) [ProducesErrors(HttpStatusCode.BadRequest)]
public async Task<RelationshipEntity> BlockUser(string id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
if (user.Id == id) if (user.Id == id)
@ -315,16 +305,14 @@ public class AccountController(
throw GracefulException.RecordNotFound(); throw GracefulException.RecordNotFound();
await userSvc.BlockUserAsync(user, blockee); await userSvc.BlockUserAsync(user, blockee);
return RenderRelationship(blockee);
var res = RenderRelationship(blockee);
return Ok(res);
} }
[HttpPost("{id}/unblock")] [HttpPost("{id}/unblock")]
[Authorize("write:blocks")] [Authorize("write:blocks")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RelationshipEntity))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> UnblockUser(string id) [ProducesErrors(HttpStatusCode.BadRequest)]
public async Task<RelationshipEntity> UnblockUser(string id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
if (user.Id == id) if (user.Id == id)
@ -338,16 +326,13 @@ public class AccountController(
throw GracefulException.RecordNotFound(); throw GracefulException.RecordNotFound();
await userSvc.UnblockUserAsync(user, blockee); await userSvc.UnblockUserAsync(user, blockee);
return RenderRelationship(blockee);
var res = RenderRelationship(blockee);
return Ok(res);
} }
[HttpGet("relationships")] [HttpGet("relationships")]
[Authorize("read:follows")] [Authorize("read:follows")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RelationshipEntity[]))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> GetRelationships([FromQuery(Name = "id")] List<string> ids) public async Task<IEnumerable<RelationshipEntity>> GetRelationships([FromQuery(Name = "id")] List<string> ids)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
@ -357,41 +342,38 @@ public class AccountController(
.PrecomputeRelationshipData(user) .PrecomputeRelationshipData(user)
.ToListAsync(); .ToListAsync();
var res = users.Select(RenderRelationship); return users.Select(RenderRelationship);
return Ok(res);
} }
[HttpGet("{id}/statuses")] [HttpGet("{id}/statuses")]
[Authorize("read:statuses")] [Authorize("read:statuses")]
[LinkPagination(20, 40)] [LinkPagination(20, 40)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<StatusEntity>))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> GetUserStatuses( [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IEnumerable<StatusEntity>> GetUserStatuses(
string id, AccountSchemas.AccountStatusesRequest request, MastodonPaginationQuery query string id, AccountSchemas.AccountStatusesRequest request, MastodonPaginationQuery query
) )
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var account = await db.Users.FirstOrDefaultAsync(p => p.Id == id) ?? throw GracefulException.RecordNotFound(); var account = await db.Users.FirstOrDefaultAsync(p => p.Id == id) ?? throw GracefulException.RecordNotFound();
var res = await db.Notes return await db.Notes
.IncludeCommonProperties() .IncludeCommonProperties()
.FilterByUser(account) .FilterByUser(account)
.FilterByAccountStatusesRequest(request) .FilterByAccountStatusesRequest(request)
.EnsureVisibleFor(user) .EnsureVisibleFor(user)
.FilterHidden(user, db, except: id) .FilterHidden(user, db, except: id)
.Paginate(query, ControllerContext) .Paginate(query, ControllerContext)
.PrecomputeVisibilities(user) .PrecomputeVisibilities(user)
.RenderAllForMastodonAsync(noteRenderer, user, Filter.FilterContext.Accounts); .RenderAllForMastodonAsync(noteRenderer, user, Filter.FilterContext.Accounts);
return Ok(res);
} }
[HttpGet("{id}/followers")] [HttpGet("{id}/followers")]
[Authenticate("read:accounts")] [Authenticate("read:accounts")]
[LinkPagination(40, 80)] [LinkPagination(40, 80)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<AccountEntity>))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.Forbidden, HttpStatusCode.NotFound)]
public async Task<IActionResult> GetUserFollowers(string id, MastodonPaginationQuery query) public async Task<IEnumerable<AccountEntity>> GetUserFollowers(string id, MastodonPaginationQuery query)
{ {
var user = HttpContext.GetUser(); var user = HttpContext.GetUser();
if (config.Value.PublicPreview == Enums.PublicPreview.Lockdown && user == null) if (config.Value.PublicPreview == Enums.PublicPreview.Lockdown && user == null)
@ -408,28 +390,26 @@ public class AccountController(
if (user == null || user.Id != account.Id) if (user == null || user.Id != account.Id)
{ {
if (account.UserProfile?.FFVisibility == UserProfile.UserProfileFFVisibility.Private) if (account.UserProfile?.FFVisibility == UserProfile.UserProfileFFVisibility.Private)
return Ok((List<AccountEntity>) []); return [];
if (account.UserProfile?.FFVisibility == UserProfile.UserProfileFFVisibility.Followers) if (account.UserProfile?.FFVisibility == UserProfile.UserProfileFFVisibility.Followers)
if (user == null || !await db.Users.AnyAsync(p => p == account && p.Followers.Contains(user))) if (user == null || !await db.Users.AnyAsync(p => p == account && p.Followers.Contains(user)))
return Ok((List<AccountEntity>) []); return [];
} }
var res = await db.Users return await db.Users
.Where(p => p == account) .Where(p => p == account)
.SelectMany(p => p.Followers) .SelectMany(p => p.Followers)
.IncludeCommonProperties() .IncludeCommonProperties()
.Paginate(query, ControllerContext) .Paginate(query, ControllerContext)
.RenderAllForMastodonAsync(userRenderer); .RenderAllForMastodonAsync(userRenderer);
return Ok(res);
} }
[HttpGet("{id}/following")] [HttpGet("{id}/following")]
[Authenticate("read:accounts")] [Authenticate("read:accounts")]
[LinkPagination(40, 80)] [LinkPagination(40, 80)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<AccountEntity>))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.Forbidden, HttpStatusCode.NotFound)]
public async Task<IActionResult> GetUserFollowing(string id, MastodonPaginationQuery query) public async Task<IEnumerable<AccountEntity>> GetUserFollowing(string id, MastodonPaginationQuery query)
{ {
var user = HttpContext.GetUser(); var user = HttpContext.GetUser();
if (config.Value.PublicPreview == Enums.PublicPreview.Lockdown && user == null) if (config.Value.PublicPreview == Enums.PublicPreview.Lockdown && user == null)
@ -446,41 +426,38 @@ public class AccountController(
if (user == null || user.Id != account.Id) if (user == null || user.Id != account.Id)
{ {
if (account.UserProfile?.FFVisibility == UserProfile.UserProfileFFVisibility.Private) if (account.UserProfile?.FFVisibility == UserProfile.UserProfileFFVisibility.Private)
return Ok((List<AccountEntity>) []); return [];
if (account.UserProfile?.FFVisibility == UserProfile.UserProfileFFVisibility.Followers) if (account.UserProfile?.FFVisibility == UserProfile.UserProfileFFVisibility.Followers)
if (user == null || !await db.Users.AnyAsync(p => p == account && p.Followers.Contains(user))) if (user == null || !await db.Users.AnyAsync(p => p == account && p.Followers.Contains(user)))
return Ok((List<AccountEntity>) []); return [];
} }
var res = await db.Users return await db.Users
.Where(p => p == account) .Where(p => p == account)
.SelectMany(p => p.Following) .SelectMany(p => p.Following)
.IncludeCommonProperties() .IncludeCommonProperties()
.Paginate(query, ControllerContext) .Paginate(query, ControllerContext)
.RenderAllForMastodonAsync(userRenderer); .RenderAllForMastodonAsync(userRenderer);
return Ok(res);
} }
[HttpGet("{id}/featured_tags")] [HttpGet("{id}/featured_tags")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<object>))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetUserFeaturedTags(string id) public async Task<IEnumerable<object>> GetUserFeaturedTags(string id)
{ {
_ = await db.Users _ = await db.Users
.Include(p => p.UserProfile) .Include(p => p.UserProfile)
.FirstOrDefaultAsync(p => p.Id == id) ?? .FirstOrDefaultAsync(p => p.Id == id) ??
throw GracefulException.RecordNotFound(); throw GracefulException.RecordNotFound();
var res = Array.Empty<object>(); return [];
return Ok(res);
} }
[HttpGet("/api/v1/follow_requests")] [HttpGet("/api/v1/follow_requests")]
[Authorize("read:follows")] [Authorize("read:follows")]
[LinkPagination(40, 80)] [LinkPagination(40, 80)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<AccountEntity>))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> GetFollowRequests(MastodonPaginationQuery query) public async Task<IEnumerable<AccountEntity>> GetFollowRequests(MastodonPaginationQuery query)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var requests = await db.FollowRequests var requests = await db.FollowRequests
@ -491,17 +468,15 @@ public class AccountController(
.ToListAsync(); .ToListAsync();
HttpContext.SetPaginationData(requests); HttpContext.SetPaginationData(requests);
var res = await userRenderer.RenderManyAsync(requests.Select(p => p.Entity)); return await userRenderer.RenderManyAsync(requests.Select(p => p.Entity));
return Ok(res);
} }
[HttpGet("/api/v1/favourites")] [HttpGet("/api/v1/favourites")]
[Authorize("read:favourites")] [Authorize("read:favourites")]
[LinkPagination(20, 40)] [LinkPagination(20, 40)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<StatusEntity>))] [ProducesResults(HttpStatusCode.OK)]
[SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall", Justification = "Projectables")] [SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall", Justification = "Projectables")]
public async Task<IActionResult> GetLikedNotes(MastodonPaginationQuery query) public async Task<IEnumerable<StatusEntity>> GetLikedNotes(MastodonPaginationQuery query)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var likes = await db.NoteLikes var likes = await db.NoteLikes
@ -515,16 +490,15 @@ public class AccountController(
.ToListAsync(); .ToListAsync();
HttpContext.SetPaginationData(likes); HttpContext.SetPaginationData(likes);
var res = await noteRenderer.RenderManyAsync(likes.Select(p => p.Entity).EnforceRenoteReplyVisibility(), user); return await noteRenderer.RenderManyAsync(likes.Select(p => p.Entity).EnforceRenoteReplyVisibility(), user);
return Ok(res);
} }
[HttpGet("/api/v1/bookmarks")] [HttpGet("/api/v1/bookmarks")]
[Authorize("read:bookmarks")] [Authorize("read:bookmarks")]
[LinkPagination(20, 40)] [LinkPagination(20, 40)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<StatusEntity>))] [ProducesResults(HttpStatusCode.OK)]
[SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall", Justification = "Projectables")] [SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall", Justification = "Projectables")]
public async Task<IActionResult> GetBookmarkedNotes(MastodonPaginationQuery query) public async Task<IEnumerable<StatusEntity>> GetBookmarkedNotes(MastodonPaginationQuery query)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var bookmarks = await db.NoteBookmarks var bookmarks = await db.NoteBookmarks
@ -538,17 +512,15 @@ public class AccountController(
.ToListAsync(); .ToListAsync();
HttpContext.SetPaginationData(bookmarks); HttpContext.SetPaginationData(bookmarks);
var res = return await noteRenderer.RenderManyAsync(bookmarks.Select(p => p.Entity).EnforceRenoteReplyVisibility(), user);
await noteRenderer.RenderManyAsync(bookmarks.Select(p => p.Entity).EnforceRenoteReplyVisibility(), user);
return Ok(res);
} }
[HttpGet("/api/v1/blocks")] [HttpGet("/api/v1/blocks")]
[Authorize("read:blocks")] [Authorize("read:blocks")]
[LinkPagination(40, 80)] [LinkPagination(40, 80)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<AccountEntity>))] [ProducesResults(HttpStatusCode.OK)]
[SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall", Justification = "Projectables")] [SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall", Justification = "Projectables")]
public async Task<IActionResult> GetBlockedUsers(MastodonPaginationQuery pq) public async Task<IEnumerable<AccountEntity>> GetBlockedUsers(MastodonPaginationQuery pq)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var blocks = await db.Blockings var blocks = await db.Blockings
@ -559,17 +531,15 @@ public class AccountController(
.ToListAsync(); .ToListAsync();
HttpContext.SetPaginationData(blocks); HttpContext.SetPaginationData(blocks);
var res = await userRenderer.RenderManyAsync(blocks.Select(p => p.Entity)); return await userRenderer.RenderManyAsync(blocks.Select(p => p.Entity));
return Ok(res);
} }
[HttpGet("/api/v1/mutes")] [HttpGet("/api/v1/mutes")]
[Authorize("read:mutes")] [Authorize("read:mutes")]
[LinkPagination(40, 80)] [LinkPagination(40, 80)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<AccountEntity>))] [ProducesResults(HttpStatusCode.OK)]
[SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall", Justification = "Projectables")] [SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall", Justification = "Projectables")]
public async Task<IActionResult> GetMutedUsers(MastodonPaginationQuery pq) public async Task<IEnumerable<AccountEntity>> GetMutedUsers(MastodonPaginationQuery pq)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var mutes = await db.Mutings var mutes = await db.Mutings
@ -580,16 +550,14 @@ public class AccountController(
.ToListAsync(); .ToListAsync();
HttpContext.SetPaginationData(mutes); HttpContext.SetPaginationData(mutes);
var res = await userRenderer.RenderManyAsync(mutes.Select(p => p.Entity)); return await userRenderer.RenderManyAsync(mutes.Select(p => p.Entity));
return Ok(res);
} }
[HttpPost("/api/v1/follow_requests/{id}/authorize")] [HttpPost("/api/v1/follow_requests/{id}/authorize")]
[Authorize("write:follows")] [Authorize("write:follows")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RelationshipEntity))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> AcceptFollowRequest(string id) public async Task<RelationshipEntity> AcceptFollowRequest(string id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var request = await db.FollowRequests.Where(p => p.Followee == user && p.FollowerId == id) var request = await db.FollowRequests.Where(p => p.Followee == user && p.FollowerId == id)
@ -600,23 +568,19 @@ public class AccountController(
if (request != null) if (request != null)
await userSvc.AcceptFollowRequestAsync(request); await userSvc.AcceptFollowRequestAsync(request);
var relationship = await db.Users.Where(p => id == p.Id) return await db.Users.Where(p => id == p.Id)
.IncludeCommonProperties() .IncludeCommonProperties()
.PrecomputeRelationshipData(user) .PrecomputeRelationshipData(user)
.Select(u => RenderRelationship(u)) .Select(u => RenderRelationship(u))
.FirstOrDefaultAsync(); .FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound();
if (relationship == null)
throw GracefulException.RecordNotFound();
return Ok(relationship);
} }
[HttpPost("/api/v1/follow_requests/{id}/reject")] [HttpPost("/api/v1/follow_requests/{id}/reject")]
[Authorize("write:follows")] [Authorize("write:follows")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RelationshipEntity))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> RejectFollowRequest(string id) public async Task<RelationshipEntity> RejectFollowRequest(string id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var request = await db.FollowRequests.Where(p => p.Followee == user && p.FollowerId == id) var request = await db.FollowRequests.Where(p => p.Followee == user && p.FollowerId == id)
@ -627,26 +591,21 @@ public class AccountController(
if (request != null) if (request != null)
await userSvc.RejectFollowRequestAsync(request); await userSvc.RejectFollowRequestAsync(request);
var relationship = await db.Users.Where(p => id == p.Id) return await db.Users.Where(p => id == p.Id)
.IncludeCommonProperties() .IncludeCommonProperties()
.PrecomputeRelationshipData(user) .PrecomputeRelationshipData(user)
.Select(u => RenderRelationship(u)) .Select(u => RenderRelationship(u))
.FirstOrDefaultAsync(); .FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound();
if (relationship == null)
throw GracefulException.RecordNotFound();
return Ok(relationship);
} }
[HttpGet("lookup")] [HttpGet("lookup")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AccountEntity))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> LookupUser([FromQuery] string acct) public async Task<AccountEntity> LookupUser([FromQuery] string acct)
{ {
var user = await userResolver.LookupAsync(acct) ?? throw GracefulException.RecordNotFound(); var user = await userResolver.LookupAsync(acct) ?? throw GracefulException.RecordNotFound();
var res = await userRenderer.RenderAsync(user); return await userRenderer.RenderAsync(user);
return Ok(res);
} }
private static RelationshipEntity RenderRelationship(User u) private static RelationshipEntity RenderRelationship(User u)

View file

@ -2,8 +2,8 @@ using System.Diagnostics.CodeAnalysis;
using System.Net; using System.Net;
using System.Net.Mime; using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Mastodon.Attributes; using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Extensions;
@ -27,17 +27,17 @@ public class AnnouncementController(DatabaseContext db, MfmConverter mfmConverte
{ {
[HttpGet] [HttpGet]
[Authorize] [Authorize]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<AnnouncementEntity>))] [ProducesResults(HttpStatusCode.OK)]
[SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall", Justification = "Projectables")] [SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall", Justification = "Projectables")]
public async Task<IActionResult> GetAnnouncements([FromQuery(Name = "with_dismissed")] bool withDismissed) public async Task<IEnumerable<AnnouncementEntity>> GetAnnouncements(
[FromQuery(Name = "with_dismissed")] bool withDismissed
)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var announcements = db.Announcements.AsQueryable(); var announcements = db.Announcements.AsQueryable();
if (!withDismissed) if (!withDismissed)
{
announcements = announcements.Where(p => p.IsReadBy(user)); announcements = announcements.Where(p => p.IsReadBy(user));
}
var res = await announcements.OrderByDescending(p => p.UpdatedAt ?? p.CreatedAt) var res = await announcements.OrderByDescending(p => p.UpdatedAt ?? p.CreatedAt)
.Select(p => new AnnouncementEntity .Select(p => new AnnouncementEntity
@ -56,20 +56,20 @@ public class AnnouncementController(DatabaseContext db, MfmConverter mfmConverte
.ToListAsync(); .ToListAsync();
await res.Select(async p => p.Content = await mfmConverter.ToHtmlAsync(p.Content, [], null)).AwaitAllAsync(); await res.Select(async p => p.Content = await mfmConverter.ToHtmlAsync(p.Content, [], null)).AwaitAllAsync();
return res;
return Ok(res);
} }
[HttpPost("{id}/dismiss")] [HttpPost("{id}/dismiss")]
[Authorize("write:accounts")] [Authorize("write:accounts")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))] [OverrideResultType<object>]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
[SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall", Justification = "Projectables")] [SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall", Justification = "Projectables")]
public async Task<IActionResult> DismissAnnouncement(string id) public async Task<object> DismissAnnouncement(string id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var announcement = await db.Announcements.FirstOrDefaultAsync(p => p.Id == id) ?? var announcement = await db.Announcements.FirstOrDefaultAsync(p => p.Id == id) ??
throw GracefulException.RecordNotFound(); throw GracefulException.NotFound("Announcement not found");
if (await db.Announcements.AnyAsync(p => p == announcement && !p.IsReadBy(user))) if (await db.Announcements.AnyAsync(p => p == announcement && !p.IsReadBy(user)))
{ {
@ -84,19 +84,19 @@ public class AnnouncementController(DatabaseContext db, MfmConverter mfmConverte
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
return Ok(new object()); return new object();
} }
[HttpPut("{id}/reactions/{name}")] [HttpPut("{id}/reactions/{name}")]
[Authorize("write:favourites")] [Authorize("write:favourites")]
[ProducesResponseType(StatusCodes.Status501NotImplemented, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.NotImplemented)]
public IActionResult ReactToAnnouncement(string id, string name) => public IActionResult ReactToAnnouncement(string id, string name) =>
throw new GracefulException(HttpStatusCode.NotImplemented, throw new GracefulException(HttpStatusCode.NotImplemented,
"Iceshrimp.NET does not support this endpoint due to database schema differences to Mastodon"); "Iceshrimp.NET does not support this endpoint due to database schema differences to Mastodon");
[HttpDelete("{id}/reactions/{name}")] [HttpDelete("{id}/reactions/{name}")]
[Authorize("write:favourites")] [Authorize("write:favourites")]
[ProducesResponseType(StatusCodes.Status501NotImplemented, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.NotImplemented)]
public IActionResult RemoveAnnouncementReaction(string id, string name) => public IActionResult RemoveAnnouncementReaction(string id, string name) =>
throw new GracefulException(HttpStatusCode.NotImplemented, throw new GracefulException(HttpStatusCode.NotImplemented,
"Iceshrimp.NET does not support this endpoint due to database schema differences to Mastodon"); "Iceshrimp.NET does not support this endpoint due to database schema differences to Mastodon");

View file

@ -1,6 +1,7 @@
using System.Net;
using System.Net.Mime; using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Mastodon.Attributes; using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas; using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Extensions;
@ -11,6 +12,7 @@ using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using static Iceshrimp.Backend.Controllers.Mastodon.Schemas.AuthSchemas;
namespace Iceshrimp.Backend.Controllers.Mastodon; namespace Iceshrimp.Backend.Controllers.Mastodon;
@ -22,27 +24,24 @@ public class AuthController(DatabaseContext db, MetaService meta) : ControllerBa
{ {
[HttpGet("/api/v1/apps/verify_credentials")] [HttpGet("/api/v1/apps/verify_credentials")]
[Authenticate] [Authenticate]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthSchemas.VerifyAppCredentialsResponse))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.Unauthorized)]
public async Task<IActionResult> VerifyAppCredentials() public async Task<VerifyAppCredentialsResponse> VerifyAppCredentials()
{ {
var token = HttpContext.GetOauthToken(); var token = HttpContext.GetOauthToken() ?? throw GracefulException.Unauthorized("The access token is invalid");
if (token == null) throw GracefulException.Unauthorized("The access token is invalid");
var res = new AuthSchemas.VerifyAppCredentialsResponse return new VerifyAppCredentialsResponse
{ {
App = token.App, VapidKey = await meta.Get(MetaEntity.VapidPublicKey) App = token.App, VapidKey = await meta.Get(MetaEntity.VapidPublicKey)
}; };
return Ok(res);
} }
[HttpPost("/api/v1/apps")] [HttpPost("/api/v1/apps")]
[EnableRateLimiting("auth")] [EnableRateLimiting("auth")]
[ConsumesHybrid] [ConsumesHybrid]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthSchemas.RegisterAppResponse))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.BadRequest)]
public async Task<IActionResult> RegisterApp([FromHybrid] AuthSchemas.RegisterAppRequest request) public async Task<RegisterAppResponse> RegisterApp([FromHybrid] RegisterAppRequest request)
{ {
if (request.RedirectUris.Count == 0) if (request.RedirectUris.Count == 0)
throw GracefulException.BadRequest("Invalid redirect_uris parameter"); throw GracefulException.BadRequest("Invalid redirect_uris parameter");
@ -79,19 +78,14 @@ public class AuthController(DatabaseContext db, MetaService meta) : ControllerBa
await db.AddAsync(app); await db.AddAsync(app);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var res = new AuthSchemas.RegisterAppResponse return new RegisterAppResponse { App = app, VapidKey = await meta.Get(MetaEntity.VapidPublicKey) };
{
App = app, VapidKey = await meta.Get(MetaEntity.VapidPublicKey)
};
return Ok(res);
} }
[HttpPost("/oauth/token")] [HttpPost("/oauth/token")]
[ConsumesHybrid] [ConsumesHybrid]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthSchemas.OauthTokenResponse))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.BadRequest)]
public async Task<IActionResult> GetOauthToken([FromHybrid] AuthSchemas.OauthTokenRequest request) public async Task<OauthTokenResponse> GetOauthToken([FromHybrid] OauthTokenRequest request)
{ {
//TODO: app-level access (grant_type = "client_credentials") //TODO: app-level access (grant_type = "client_credentials")
if (request.GrantType != "authorization_code") if (request.GrantType != "authorization_code")
@ -99,49 +93,46 @@ public class AuthController(DatabaseContext db, MetaService meta) : ControllerBa
var token = await db.OauthTokens.FirstOrDefaultAsync(p => p.Code == request.Code && var token = await db.OauthTokens.FirstOrDefaultAsync(p => p.Code == request.Code &&
p.App.ClientId == request.ClientId && p.App.ClientId == request.ClientId &&
p.App.ClientSecret == request.ClientSecret); p.App.ClientSecret == request.ClientSecret);
// @formatter:off
if (token == null) if (token == null)
throw GracefulException throw GracefulException.Unauthorized("Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method.");
.Unauthorized("Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method.");
if (token.Active) if (token.Active)
throw GracefulException throw GracefulException.BadRequest("The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client.");
.BadRequest("The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client."); // @formatter:on
if (MastodonOauthHelpers.ExpandScopes(request.Scopes ?? []) var invalidScope = MastodonOauthHelpers.ExpandScopes(request.Scopes ?? [])
.Except(MastodonOauthHelpers.ExpandScopes(token.Scopes)) .Except(MastodonOauthHelpers.ExpandScopes(token.Scopes))
.Any()) .Any();
if (invalidScope)
throw GracefulException.BadRequest("The requested scope is invalid, unknown, or malformed."); throw GracefulException.BadRequest("The requested scope is invalid, unknown, or malformed.");
token.Scopes = request.Scopes ?? token.Scopes; token.Scopes = request.Scopes ?? token.Scopes;
token.Active = true; token.Active = true;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var res = new AuthSchemas.OauthTokenResponse return new OauthTokenResponse
{ {
CreatedAt = token.CreatedAt, CreatedAt = token.CreatedAt,
Scopes = token.Scopes, Scopes = token.Scopes,
AccessToken = token.Token AccessToken = token.Token
}; };
return Ok(res);
} }
[HttpPost("/oauth/revoke")] [HttpPost("/oauth/revoke")]
[ConsumesHybrid] [ConsumesHybrid]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))] [OverrideResultType<object>]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(MastodonErrorResponse))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.Forbidden)]
public async Task<IActionResult> RevokeOauthToken([FromHybrid] AuthSchemas.OauthTokenRevocationRequest request) public async Task<object> RevokeOauthToken([FromHybrid] OauthTokenRevocationRequest request)
{ {
var token = await db.OauthTokens.FirstOrDefaultAsync(p => p.Token == request.Token && var token = await db.OauthTokens.FirstOrDefaultAsync(p => p.Token == request.Token &&
p.App.ClientId == request.ClientId && p.App.ClientId == request.ClientId &&
p.App.ClientSecret == request.ClientSecret); p.App.ClientSecret == request.ClientSecret) ??
if (token == null) throw GracefulException.Forbidden("You are not authorized to revoke this token");
throw GracefulException.Forbidden("You are not authorized to revoke this token");
db.Remove(token); db.Remove(token);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(new object()); return new object();
} }
} }

View file

@ -31,8 +31,8 @@ public class ConversationsController(
[HttpGet] [HttpGet]
[Authorize("read:statuses")] [Authorize("read:statuses")]
[LinkPagination(20, 40)] [LinkPagination(20, 40)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<ConversationEntity>))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> GetConversations(MastodonPaginationQuery pq) public async Task<IEnumerable<ConversationEntity>> GetConversations(MastodonPaginationQuery pq)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
@ -69,7 +69,7 @@ public class ConversationsController(
var notes = await noteRenderer.RenderManyAsync(conversations.Select(p => p.LastNote), user, accounts: accounts); var notes = await noteRenderer.RenderManyAsync(conversations.Select(p => p.LastNote), user, accounts: accounts);
var res = conversations.Select(p => new ConversationEntity return conversations.Select(p => new ConversationEntity
{ {
Id = p.Id, Id = p.Id,
Unread = p.Unread, Unread = p.Unread,
@ -78,21 +78,19 @@ public class ConversationsController(
.DefaultIfEmpty(accounts.First(a => a.Id == user.Id)) .DefaultIfEmpty(accounts.First(a => a.Id == user.Id))
.ToList() .ToList()
}); });
return Ok(res);
} }
[HttpDelete("{id}")] [HttpDelete("{id}")]
[Authorize("write:conversations")] [Authorize("write:conversations")]
[ProducesResponseType(StatusCodes.Status501NotImplemented, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.NotImplemented)]
public IActionResult RemoveConversation(string id) => throw new GracefulException(HttpStatusCode.NotImplemented, public IActionResult RemoveConversation(string id) => throw new GracefulException(HttpStatusCode.NotImplemented,
"Iceshrimp.NET does not support this endpoint due to database schema differences to Mastodon"); "Iceshrimp.NET does not support this endpoint due to database schema differences to Mastodon");
[HttpPost("{id}/read")] [HttpPost("{id}/read")]
[Authorize("write:conversations")] [Authorize("write:conversations")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ConversationEntity))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> MarkRead(string id) public async Task<ConversationEntity> MarkRead(string id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var conversation = await db.Conversations(user) var conversation = await db.Conversations(user)
@ -137,15 +135,13 @@ public class ConversationsController(
var noteRendererDto = new NoteRenderer.NoteRendererDto { Accounts = accounts }; var noteRendererDto = new NoteRenderer.NoteRendererDto { Accounts = accounts };
var res = new ConversationEntity return new ConversationEntity
{ {
Id = conversation.Id, Id = conversation.Id,
Unread = conversation.Unread, Unread = conversation.Unread,
LastStatus = await noteRenderer.RenderAsync(conversation.LastNote, user, data: noteRendererDto), LastStatus = await noteRenderer.RenderAsync(conversation.LastNote, user, data: noteRendererDto),
Accounts = accounts Accounts = accounts
}; };
return Ok(res);
} }
private class Conversation private class Conversation

View file

@ -1,8 +1,10 @@
using System.Net;
using System.Net.Mime; using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Mastodon.Attributes; using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Controllers.Mastodon.Renderers; using Iceshrimp.Backend.Controllers.Mastodon.Renderers;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas; using Iceshrimp.Backend.Controllers.Mastodon.Schemas;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Extensions;
@ -26,33 +28,32 @@ public class FilterController(DatabaseContext db, QueueService queueSvc, EventSe
{ {
[HttpGet] [HttpGet]
[Authorize("read:filters")] [Authorize("read:filters")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<FilterEntity>))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> GetFilters() public async Task<IEnumerable<FilterEntity>> GetFilters()
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var filters = await db.Filters.Where(p => p.User == user).ToListAsync(); var filters = await db.Filters.Where(p => p.User == user).ToListAsync();
var res = filters.Select(FilterRenderer.RenderOne); return filters.Select(FilterRenderer.RenderOne);
return Ok(res);
} }
[HttpGet("{id:long}")] [HttpGet("{id:long}")]
[Authorize("read:filters")] [Authorize("read:filters")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<FilterEntity>))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetFilter(long id) public async Task<FilterEntity> GetFilter(long id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var filter = await db.Filters.Where(p => p.User == user && p.Id == id).FirstOrDefaultAsync() ?? var filter = await db.Filters.Where(p => p.User == user && p.Id == id).FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound(); throw GracefulException.RecordNotFound();
return Ok(FilterRenderer.RenderOne(filter)); return FilterRenderer.RenderOne(filter);
} }
[HttpPost] [HttpPost]
[Authorize("write:filters")] [Authorize("write:filters")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<FilterEntity>))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> CreateFilter([FromHybrid] FilterSchemas.CreateFilterRequest request) [ProducesErrors(HttpStatusCode.BadRequest)]
public async Task<FilterEntity> CreateFilter([FromHybrid] FilterSchemas.CreateFilterRequest request)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var action = request.Action switch var action = request.Action switch
@ -102,14 +103,14 @@ public class FilterController(DatabaseContext db, QueueService queueSvc, EventSe
await queueSvc.BackgroundTaskQueue.ScheduleAsync(data, expiry.Value); await queueSvc.BackgroundTaskQueue.ScheduleAsync(data, expiry.Value);
} }
return Ok(FilterRenderer.RenderOne(filter)); return FilterRenderer.RenderOne(filter);
} }
[HttpPut("{id:long}")] [HttpPut("{id:long}")]
[Authorize("write:filters")] [Authorize("write:filters")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<FilterEntity>))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> UpdateFilter(long id, [FromHybrid] FilterSchemas.UpdateFilterRequest request) public async Task<FilterEntity> UpdateFilter(long id, [FromHybrid] FilterSchemas.UpdateFilterRequest request)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var filter = await db.Filters.FirstOrDefaultAsync(p => p.User == user && p.Id == id) ?? var filter = await db.Filters.FirstOrDefaultAsync(p => p.User == user && p.Id == id) ??
@ -166,14 +167,15 @@ public class FilterController(DatabaseContext db, QueueService queueSvc, EventSe
await queueSvc.BackgroundTaskQueue.ScheduleAsync(data, expiry.Value); await queueSvc.BackgroundTaskQueue.ScheduleAsync(data, expiry.Value);
} }
return Ok(FilterRenderer.RenderOne(filter)); return FilterRenderer.RenderOne(filter);
} }
[HttpDelete("{id:long}")] [HttpDelete("{id:long}")]
[Authorize("write:filters")] [Authorize("write:filters")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))] [OverrideResultType<object>]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> DeleteFilter(long id) [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<object> DeleteFilter(long id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var filter = await db.Filters.Where(p => p.User == user && p.Id == id).FirstOrDefaultAsync() ?? var filter = await db.Filters.Where(p => p.User == user && p.Id == id).FirstOrDefaultAsync() ??
@ -183,27 +185,27 @@ public class FilterController(DatabaseContext db, QueueService queueSvc, EventSe
await db.SaveChangesAsync(); await db.SaveChangesAsync();
eventSvc.RaiseFilterRemoved(this, filter); eventSvc.RaiseFilterRemoved(this, filter);
return Ok(new object()); return new object();
} }
[HttpGet("{id:long}/keywords")] [HttpGet("{id:long}/keywords")]
[Authorize("read:filters")] [Authorize("read:filters")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<FilterKeyword>))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetFilterKeywords(long id) public async Task<IEnumerable<FilterKeyword>> GetFilterKeywords(long id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var filter = await db.Filters.Where(p => p.User == user && p.Id == id).FirstOrDefaultAsync() ?? var filter = await db.Filters.Where(p => p.User == user && p.Id == id).FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound(); throw GracefulException.RecordNotFound();
return Ok(filter.Keywords.Select((p, i) => new FilterKeyword(p, filter.Id, i))); return filter.Keywords.Select((p, i) => new FilterKeyword(p, filter.Id, i));
} }
[HttpPost("{id:long}/keywords")] [HttpPost("{id:long}/keywords")]
[Authorize("write:filters")] [Authorize("write:filters")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(FilterKeyword))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> AddFilterKeyword( public async Task<FilterKeyword> AddFilterKeyword(
long id, [FromHybrid] FilterSchemas.FilterKeywordsAttributes request long id, [FromHybrid] FilterSchemas.FilterKeywordsAttributes request
) )
{ {
@ -218,14 +220,14 @@ public class FilterController(DatabaseContext db, QueueService queueSvc, EventSe
await db.SaveChangesAsync(); await db.SaveChangesAsync();
eventSvc.RaiseFilterUpdated(this, filter); eventSvc.RaiseFilterUpdated(this, filter);
return Ok(new FilterKeyword(keyword, filter.Id, filter.Keywords.Count - 1)); return new FilterKeyword(keyword, filter.Id, filter.Keywords.Count - 1);
} }
[HttpGet("keywords/{filterId:long}-{keywordId:int}")] [HttpGet("keywords/{filterId:long}-{keywordId:int}")]
[Authorize("read:filters")] [Authorize("read:filters")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(FilterKeyword))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetFilterKeyword(long filterId, int keywordId) public async Task<FilterKeyword> GetFilterKeyword(long filterId, int keywordId)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var filter = await db.Filters.Where(p => p.User == user && p.Id == filterId).FirstOrDefaultAsync() ?? var filter = await db.Filters.Where(p => p.User == user && p.Id == filterId).FirstOrDefaultAsync() ??
@ -234,14 +236,14 @@ public class FilterController(DatabaseContext db, QueueService queueSvc, EventSe
if (filter.Keywords.Count < keywordId) if (filter.Keywords.Count < keywordId)
throw GracefulException.RecordNotFound(); throw GracefulException.RecordNotFound();
return Ok(new FilterKeyword(filter.Keywords[keywordId], filter.Id, keywordId)); return new FilterKeyword(filter.Keywords[keywordId], filter.Id, keywordId);
} }
[HttpPut("keywords/{filterId:long}-{keywordId:int}")] [HttpPut("keywords/{filterId:long}-{keywordId:int}")]
[Authorize("write:filters")] [Authorize("write:filters")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(FilterKeyword))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> UpdateFilterKeyword( public async Task<FilterKeyword> UpdateFilterKeyword(
long filterId, int keywordId, [FromHybrid] FilterSchemas.FilterKeywordsAttributes request long filterId, int keywordId, [FromHybrid] FilterSchemas.FilterKeywordsAttributes request
) )
{ {
@ -257,14 +259,15 @@ public class FilterController(DatabaseContext db, QueueService queueSvc, EventSe
await db.SaveChangesAsync(); await db.SaveChangesAsync();
eventSvc.RaiseFilterUpdated(this, filter); eventSvc.RaiseFilterUpdated(this, filter);
return Ok(new FilterKeyword(filter.Keywords[keywordId], filter.Id, keywordId)); return new FilterKeyword(filter.Keywords[keywordId], filter.Id, keywordId);
} }
[HttpDelete("keywords/{filterId:long}-{keywordId:int}")] [HttpDelete("keywords/{filterId:long}-{keywordId:int}")]
[Authorize("write:filters")] [Authorize("write:filters")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))] [OverrideResultType<object>]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> DeleteFilterKeyword(long filterId, int keywordId) [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<object> DeleteFilterKeyword(long filterId, int keywordId)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var filter = await db.Filters.Where(p => p.User == user && p.Id == filterId).FirstOrDefaultAsync() ?? var filter = await db.Filters.Where(p => p.User == user && p.Id == filterId).FirstOrDefaultAsync() ??
@ -278,7 +281,7 @@ public class FilterController(DatabaseContext db, QueueService queueSvc, EventSe
await db.SaveChangesAsync(); await db.SaveChangesAsync();
eventSvc.RaiseFilterUpdated(this, filter); eventSvc.RaiseFilterUpdated(this, filter);
return Ok(new object()); return new object();
} }
//TODO: status filters (first: what are they even for?) //TODO: status filters (first: what are they even for?)

View file

@ -1,7 +1,9 @@
using System.Net;
using System.Net.Mime; using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Mastodon.Attributes; using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas; using Iceshrimp.Backend.Controllers.Mastodon.Schemas;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Extensions;
@ -21,8 +23,8 @@ namespace Iceshrimp.Backend.Controllers.Mastodon;
public class InstanceController(DatabaseContext db, MetaService meta) : ControllerBase public class InstanceController(DatabaseContext db, MetaService meta) : ControllerBase
{ {
[HttpGet("/api/v1/instance")] [HttpGet("/api/v1/instance")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(InstanceInfoV1Response))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> GetInstanceInfoV1([FromServices] IOptionsSnapshot<Config> config) public async Task<InstanceInfoV1Response> GetInstanceInfoV1([FromServices] IOptionsSnapshot<Config> config)
{ {
var userCount = var userCount =
await db.Users.LongCountAsync(p => p.IsLocalUser && !Constants.SystemUsers.Contains(p.UsernameLower)); await db.Users.LongCountAsync(p => p.IsLocalUser && !Constants.SystemUsers.Contains(p.UsernameLower));
@ -32,17 +34,15 @@ public class InstanceController(DatabaseContext db, MetaService meta) : Controll
var (instanceName, instanceDescription, adminContact) = var (instanceName, instanceDescription, adminContact) =
await meta.GetMany(MetaEntity.InstanceName, MetaEntity.InstanceDescription, MetaEntity.AdminContactEmail); await meta.GetMany(MetaEntity.InstanceName, MetaEntity.InstanceDescription, MetaEntity.AdminContactEmail);
var res = new InstanceInfoV1Response(config.Value, instanceName, instanceDescription, adminContact) return new InstanceInfoV1Response(config.Value, instanceName, instanceDescription, adminContact)
{ {
Stats = new InstanceStats(userCount, noteCount, instanceCount) Stats = new InstanceStats(userCount, noteCount, instanceCount)
}; };
return Ok(res);
} }
[HttpGet("/api/v2/instance")] [HttpGet("/api/v2/instance")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(InstanceInfoV2Response))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> GetInstanceInfoV2([FromServices] IOptionsSnapshot<Config> config) public async Task<InstanceInfoV2Response> GetInstanceInfoV2([FromServices] IOptionsSnapshot<Config> config)
{ {
var cutoff = DateTime.UtcNow - TimeSpan.FromDays(30); var cutoff = DateTime.UtcNow - TimeSpan.FromDays(30);
var activeMonth = await db.Users.LongCountAsync(p => p.IsLocalUser && var activeMonth = await db.Users.LongCountAsync(p => p.IsLocalUser &&
@ -52,46 +52,38 @@ public class InstanceController(DatabaseContext db, MetaService meta) : Controll
var (instanceName, instanceDescription, adminContact) = var (instanceName, instanceDescription, adminContact) =
await meta.GetMany(MetaEntity.InstanceName, MetaEntity.InstanceDescription, MetaEntity.AdminContactEmail); await meta.GetMany(MetaEntity.InstanceName, MetaEntity.InstanceDescription, MetaEntity.AdminContactEmail);
var res = new InstanceInfoV2Response(config.Value, instanceName, instanceDescription, adminContact) return new InstanceInfoV2Response(config.Value, instanceName, instanceDescription, adminContact)
{ {
Usage = new InstanceUsage { Users = new InstanceUsersUsage { ActiveMonth = activeMonth } } Usage = new InstanceUsage { Users = new InstanceUsersUsage { ActiveMonth = activeMonth } }
}; };
return Ok(res);
} }
[HttpGet("/api/v1/custom_emojis")] [HttpGet("/api/v1/custom_emojis")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<EmojiEntity>))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> GetCustomEmojis() public async Task<IEnumerable<EmojiEntity>> GetCustomEmojis()
{ {
var res = await db.Emojis.Where(p => p.Host == null) return await db.Emojis.Where(p => p.Host == null)
.Select(p => new EmojiEntity .Select(p => new EmojiEntity
{ {
Id = p.Id, Id = p.Id,
Shortcode = p.Name, Shortcode = p.Name,
Url = p.PublicUrl, Url = p.PublicUrl,
StaticUrl = p.PublicUrl, //TODO StaticUrl = p.PublicUrl, //TODO
VisibleInPicker = true, VisibleInPicker = true,
Category = p.Category Category = p.Category
}) })
.ToListAsync(); .ToListAsync();
return Ok(res);
} }
[HttpGet("/api/v1/instance/translation_languages")] [HttpGet("/api/v1/instance/translation_languages")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Dictionary<string, IEnumerable<string>>))] [ProducesResults(HttpStatusCode.OK)]
public IActionResult GetTranslationLanguages() public Dictionary<string, IEnumerable<string>> GetTranslationLanguages() => new();
{
return Ok(new Dictionary<string, IEnumerable<string>>());
}
[HttpGet("/api/v1/instance/extended_description")] [HttpGet("/api/v1/instance/extended_description")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(InstanceExtendedDescription))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> GetExtendedDescription() public async Task<InstanceExtendedDescription> GetExtendedDescription()
{ {
var description = await meta.Get(MetaEntity.InstanceDescription); var description = await meta.Get(MetaEntity.InstanceDescription);
var res = new InstanceExtendedDescription(description); return new InstanceExtendedDescription(description);
return Ok(res);
} }
} }

View file

@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Net.Mime; using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Mastodon.Attributes; using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Controllers.Mastodon.Renderers; using Iceshrimp.Backend.Controllers.Mastodon.Renderers;
@ -28,51 +29,47 @@ public class ListController(DatabaseContext db, UserRenderer userRenderer, Event
{ {
[HttpGet] [HttpGet]
[Authorize("read:lists")] [Authorize("read:lists")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<ListEntity>))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> GetLists() public async Task<IEnumerable<ListEntity>> GetLists()
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var res = await db.UserLists return await db.UserLists
.Where(p => p.User == user) .Where(p => p.User == user)
.Select(p => new ListEntity .Select(p => new ListEntity
{ {
Id = p.Id, Id = p.Id,
Title = p.Name, Title = p.Name,
Exclusive = p.HideFromHomeTl Exclusive = p.HideFromHomeTl
}) })
.ToListAsync(); .ToListAsync();
return Ok(res);
} }
[HttpGet("{id}")] [HttpGet("{id}")]
[Authorize("read:lists")] [Authorize("read:lists")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ListEntity))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetList(string id) public async Task<ListEntity> GetList(string id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var res = await db.UserLists return await db.UserLists
.Where(p => p.User == user && p.Id == id) .Where(p => p.User == user && p.Id == id)
.Select(p => new ListEntity .Select(p => new ListEntity
{ {
Id = p.Id, Id = p.Id,
Title = p.Name, Title = p.Name,
Exclusive = p.HideFromHomeTl Exclusive = p.HideFromHomeTl
}) })
.FirstOrDefaultAsync() ?? .FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound(); throw GracefulException.RecordNotFound();
return Ok(res);
} }
[HttpPost] [HttpPost]
[Authorize("write:lists")] [Authorize("write:lists")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ListEntity))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.UnprocessableEntity)]
public async Task<IActionResult> CreateList([FromHybrid] ListSchemas.ListCreationRequest request) public async Task<ListEntity> CreateList([FromHybrid] ListSchemas.ListCreationRequest request)
{ {
if (string.IsNullOrWhiteSpace(request.Title)) if (string.IsNullOrWhiteSpace(request.Title))
throw GracefulException.UnprocessableEntity("Validation failed: Title can't be blank"); throw GracefulException.UnprocessableEntity("Validation failed: Title can't be blank");
@ -90,21 +87,19 @@ public class ListController(DatabaseContext db, UserRenderer userRenderer, Event
await db.AddAsync(list); await db.AddAsync(list);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var res = new ListEntity return new ListEntity
{ {
Id = list.Id, Id = list.Id,
Title = list.Name, Title = list.Name,
Exclusive = list.HideFromHomeTl Exclusive = list.HideFromHomeTl
}; };
return Ok(res);
} }
[HttpPut("{id}")] [HttpPut("{id}")]
[Authorize("write:lists")] [Authorize("write:lists")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ListEntity))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound, HttpStatusCode.UnprocessableEntity)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity, Type = typeof(MastodonErrorResponse))] public async Task<ListEntity> UpdateList(string id, [FromHybrid] ListSchemas.ListUpdateRequest request)
public async Task<IActionResult> UpdateList(string id, [FromHybrid] ListSchemas.ListUpdateRequest request)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var list = await db.UserLists var list = await db.UserLists
@ -121,20 +116,20 @@ public class ListController(DatabaseContext db, UserRenderer userRenderer, Event
db.Update(list); db.Update(list);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var res = new ListEntity return new ListEntity
{ {
Id = list.Id, Id = list.Id,
Title = list.Name, Title = list.Name,
Exclusive = list.HideFromHomeTl Exclusive = list.HideFromHomeTl
}; };
return Ok(res);
} }
[HttpDelete("{id}")] [HttpDelete("{id}")]
[Authorize("write:lists")] [Authorize("write:lists")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))] [OverrideResultType<object>]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> DeleteList(string id) [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<object> DeleteList(string id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var list = await db.UserLists var list = await db.UserLists
@ -145,15 +140,15 @@ public class ListController(DatabaseContext db, UserRenderer userRenderer, Event
db.Remove(list); db.Remove(list);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
eventSvc.RaiseListMembersUpdated(this, list); eventSvc.RaiseListMembersUpdated(this, list);
return Ok(new object()); return new object();
} }
[LinkPagination(40, 80)] [LinkPagination(40, 80)]
[HttpGet("{id}/accounts")] [HttpGet("{id}/accounts")]
[Authorize("read:lists")] [Authorize("read:lists")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<AccountEntity>))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetListMembers(string id, MastodonPaginationQuery pq) public async Task<List<AccountEntity>> GetListMembers(string id, MastodonPaginationQuery pq)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var list = await db.UserLists var list = await db.UserLists
@ -161,7 +156,7 @@ public class ListController(DatabaseContext db, UserRenderer userRenderer, Event
.FirstOrDefaultAsync() ?? .FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound(); throw GracefulException.RecordNotFound();
var res = pq.Limit == 0 return pq.Limit == 0
? await db.UserListMembers ? await db.UserListMembers
.Where(p => p.UserList == list) .Where(p => p.UserList == list)
.Include(p => p.User.UserProfile) .Include(p => p.User.UserProfile)
@ -173,17 +168,15 @@ public class ListController(DatabaseContext db, UserRenderer userRenderer, Event
.Include(p => p.User.UserProfile) .Include(p => p.User.UserProfile)
.Select(p => p.User) .Select(p => p.User)
.RenderAllForMastodonAsync(userRenderer); .RenderAllForMastodonAsync(userRenderer);
return Ok(res);
} }
[HttpPost("{id}/accounts")] [HttpPost("{id}/accounts")]
[Authorize("write:lists")] [Authorize("write:lists")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))] [OverrideResultType<object>]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound, HttpStatusCode.UnprocessableEntity)]
[SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall", Justification = "Projectables")] [SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall", Justification = "Projectables")]
public async Task<IActionResult> AddListMember(string id, [FromHybrid] ListSchemas.ListUpdateMembersRequest request) public async Task<object> AddListMember(string id, [FromHybrid] ListSchemas.ListUpdateMembersRequest request)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var list = await db.UserLists var list = await db.UserLists
@ -214,14 +207,15 @@ public class ListController(DatabaseContext db, UserRenderer userRenderer, Event
eventSvc.RaiseListMembersUpdated(this, list); eventSvc.RaiseListMembersUpdated(this, list);
return Ok(new object()); return new object();
} }
[HttpDelete("{id}/accounts")] [HttpDelete("{id}/accounts")]
[Authorize("write:lists")] [Authorize("write:lists")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))] [OverrideResultType<object>]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> RemoveListMember( [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<object> RemoveListMember(
string id, [FromHybrid] ListSchemas.ListUpdateMembersRequest request string id, [FromHybrid] ListSchemas.ListUpdateMembersRequest request
) )
{ {
@ -237,6 +231,6 @@ public class ListController(DatabaseContext db, UserRenderer userRenderer, Event
eventSvc.RaiseListMembersUpdated(this, list); eventSvc.RaiseListMembersUpdated(this, list);
return Ok(new object()); return new object();
} }
} }

View file

@ -3,6 +3,7 @@ using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Mastodon.Attributes; using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas; using Iceshrimp.Backend.Controllers.Mastodon.Schemas;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Extensions;
@ -24,28 +25,29 @@ public class MarkerController(DatabaseContext db) : ControllerBase
{ {
[HttpGet] [HttpGet]
[Authorize("read:statuses")] [Authorize("read:statuses")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Dictionary<string, MarkerEntity>))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> GetMarkers([FromQuery(Name = "timeline")] List<string> types) public async Task<Dictionary<string, MarkerEntity>> GetMarkers([FromQuery(Name = "timeline")] List<string> types)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var markers = await db.Markers.Where(p => p.User == user && types.Select(DecodeType).Contains(p.Type)) var markers = await db.Markers.Where(p => p.User == user && types.Select(DecodeType).Contains(p.Type))
.ToListAsync(); .ToListAsync();
var res = markers.ToDictionary(p => EncodeType(p.Type), return markers.ToDictionary(p => EncodeType(p.Type),
p => new MarkerEntity p => new MarkerEntity
{ {
Position = p.Position, Position = p.Position,
Version = p.Version, Version = p.Version,
UpdatedAt = p.LastUpdatedAt.ToStringIso8601Like() UpdatedAt = p.LastUpdatedAt.ToStringIso8601Like()
}); });
return Ok(res);
} }
[HttpPost] [HttpPost]
[Authorize("write:statuses")] [Authorize("write:statuses")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Dictionary<string, MarkerEntity>))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> SetMarkers([FromHybrid] Dictionary<string, MarkerSchemas.MarkerPosition> request) [ProducesErrors(HttpStatusCode.Conflict)]
public async Task<Dictionary<string, MarkerEntity>> SetMarkers(
[FromHybrid] Dictionary<string, MarkerSchemas.MarkerPosition> request
)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
try try

View file

@ -1,8 +1,10 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Net.Mime; using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Mastodon.Attributes; using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas; using Iceshrimp.Backend.Controllers.Mastodon.Schemas;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Extensions;
@ -21,13 +23,12 @@ namespace Iceshrimp.Backend.Controllers.Mastodon;
[EnableCors("mastodon")] [EnableCors("mastodon")]
[EnableRateLimiting("sliding")] [EnableRateLimiting("sliding")]
[Produces(MediaTypeNames.Application.Json)] [Produces(MediaTypeNames.Application.Json)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AttachmentEntity))]
public class MediaController(DriveService driveSvc, DatabaseContext db) : ControllerBase public class MediaController(DriveService driveSvc, DatabaseContext db) : ControllerBase
{ {
[HttpPost("/api/v1/media")] [HttpPost("/api/v1/media")]
[HttpPost("/api/v2/media")] [HttpPost("/api/v2/media")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AttachmentEntity))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> UploadAttachment(MediaSchemas.UploadMediaRequest request) public async Task<AttachmentEntity> UploadAttachment(MediaSchemas.UploadMediaRequest request)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var rq = new DriveFileCreationRequest var rq = new DriveFileCreationRequest
@ -38,15 +39,15 @@ public class MediaController(DriveService driveSvc, DatabaseContext db) : Contro
MimeType = request.File.ContentType MimeType = request.File.ContentType
}; };
var file = await driveSvc.StoreFile(request.File.OpenReadStream(), user, rq); var file = await driveSvc.StoreFile(request.File.OpenReadStream(), user, rq);
var res = RenderAttachment(file); return RenderAttachment(file);
return Ok(res);
} }
[HttpPut("/api/v1/media/{id}")] [HttpPut("/api/v1/media/{id}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AttachmentEntity))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> UpdateAttachment(string id, [FromHybrid] MediaSchemas.UpdateMediaRequest request) public async Task<AttachmentEntity> UpdateAttachment(
string id, [FromHybrid] MediaSchemas.UpdateMediaRequest request
)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.Id == id && p.User == user) ?? var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.Id == id && p.User == user) ??
@ -54,26 +55,24 @@ public class MediaController(DriveService driveSvc, DatabaseContext db) : Contro
file.Comment = request.Description; file.Comment = request.Description;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var res = RenderAttachment(file); return RenderAttachment(file);
return Ok(res);
} }
[HttpGet("/api/v1/media/{id}")] [HttpGet("/api/v1/media/{id}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AttachmentEntity))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetAttachment(string id) public async Task<AttachmentEntity> GetAttachment(string id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.Id == id && p.User == user) ?? var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.Id == id && p.User == user) ??
throw GracefulException.RecordNotFound(); throw GracefulException.RecordNotFound();
var res = RenderAttachment(file); return RenderAttachment(file);
return Ok(res);
} }
[HttpPut("/api/v2/media/{id}")] [HttpPut("/api/v2/media/{id}")]
[HttpGet("/api/v2/media/{id}")] [HttpGet("/api/v2/media/{id}")]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public IActionResult FallbackMediaRoute([SuppressMessage("ReSharper", "UnusedParameter.Global")] string id) => public IActionResult FallbackMediaRoute([SuppressMessage("ReSharper", "UnusedParameter.Global")] string id) =>
throw GracefulException.NotFound("This endpoint is not implemented, but some clients expect a 404 here."); throw GracefulException.NotFound("This endpoint is not implemented, but some clients expect a 404 here.");

View file

@ -1,3 +1,4 @@
using System.Net;
using System.Net.Mime; using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Mastodon.Attributes; using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Controllers.Mastodon.Renderers; using Iceshrimp.Backend.Controllers.Mastodon.Renderers;
@ -26,41 +27,39 @@ public class NotificationController(DatabaseContext db, NotificationRenderer not
[HttpGet] [HttpGet]
[Authorize("read:notifications")] [Authorize("read:notifications")]
[LinkPagination(40, 80)] [LinkPagination(40, 80)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<NotificationEntity>))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetNotifications( public async Task<List<NotificationEntity>> GetNotifications(
MastodonPaginationQuery query, NotificationSchemas.GetNotificationsRequest request MastodonPaginationQuery query, NotificationSchemas.GetNotificationsRequest request
) )
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var res = await db.Notifications return await db.Notifications
.IncludeCommonProperties() .IncludeCommonProperties()
.Where(p => p.Notifiee == user) .Where(p => p.Notifiee == user)
.Where(p => p.Notifier != null) .Where(p => p.Notifier != null)
.Where(p => p.Type == NotificationType.Follow || .Where(p => p.Type == NotificationType.Follow ||
p.Type == NotificationType.Mention || p.Type == NotificationType.Mention ||
p.Type == NotificationType.Reply || p.Type == NotificationType.Reply ||
p.Type == NotificationType.Renote || p.Type == NotificationType.Renote ||
p.Type == NotificationType.Quote || p.Type == NotificationType.Quote ||
p.Type == NotificationType.Like || p.Type == NotificationType.Like ||
p.Type == NotificationType.PollEnded || p.Type == NotificationType.PollEnded ||
p.Type == NotificationType.FollowRequestReceived || p.Type == NotificationType.FollowRequestReceived ||
p.Type == NotificationType.Edit) p.Type == NotificationType.Edit)
.FilterByGetNotificationsRequest(request) .FilterByGetNotificationsRequest(request)
.EnsureNoteVisibilityFor(p => p.Note, user) .EnsureNoteVisibilityFor(p => p.Note, user)
.FilterHiddenNotifications(user, db) .FilterHiddenNotifications(user, db)
.Paginate(p => p.MastoId, query, ControllerContext) .Paginate(p => p.MastoId, query, ControllerContext)
.PrecomputeNoteVisibilities(user) .PrecomputeNoteVisibilities(user)
.RenderAllForMastodonAsync(notificationRenderer, user); .RenderAllForMastodonAsync(notificationRenderer, user);
return Ok(res);
} }
[HttpGet("{id:long}")] [HttpGet("{id:long}")]
[Authorize("read:notifications")] [Authorize("read:notifications")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<NotificationEntity>))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetNotification(long id) public async Task<NotificationEntity> GetNotification(long id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var notification = await db.Notifications var notification = await db.Notifications
@ -72,7 +71,6 @@ public class NotificationController(DatabaseContext db, NotificationRenderer not
throw GracefulException.RecordNotFound(); throw GracefulException.RecordNotFound();
var res = await notificationRenderer.RenderAsync(notification.EnforceRenoteReplyVisibility(p => p.Note), user); var res = await notificationRenderer.RenderAsync(notification.EnforceRenoteReplyVisibility(p => p.Note), user);
return res;
return Ok(res);
} }
} }

View file

@ -1,8 +1,10 @@
using System.Net;
using System.Net.Mime; using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Mastodon.Attributes; using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Controllers.Mastodon.Renderers; using Iceshrimp.Backend.Controllers.Mastodon.Renderers;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas; using Iceshrimp.Backend.Controllers.Mastodon.Schemas;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Database.Tables;
@ -32,9 +34,9 @@ public class PollController(
) : ControllerBase ) : ControllerBase
{ {
[HttpGet] [HttpGet]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PollEntity))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetPoll(string id) public async Task<PollEntity> GetPoll(string id)
{ {
var user = HttpContext.GetUser(); var user = HttpContext.GetUser();
if (security.Value.PublicPreview == Enums.PublicPreview.Lockdown && user == null) if (security.Value.PublicPreview == Enums.PublicPreview.Lockdown && user == null)
@ -44,16 +46,14 @@ public class PollController(
throw GracefulException.RecordNotFound(); throw GracefulException.RecordNotFound();
var poll = await db.Polls.Where(p => p.Note == note).FirstOrDefaultAsync() ?? var poll = await db.Polls.Where(p => p.Note == note).FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound(); throw GracefulException.RecordNotFound();
var res = await pollRenderer.RenderAsync(poll, user); return await pollRenderer.RenderAsync(poll, user);
return Ok(res);
} }
[HttpPost("votes")] [HttpPost("votes")]
[Authorize("read:statuses")] [Authorize("read:statuses")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PollEntity))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] public async Task<PollEntity> VotePoll(string id, [FromHybrid] PollSchemas.PollVoteRequest request)
public async Task<IActionResult> VotePoll(string id, [FromHybrid] PollSchemas.PollVoteRequest request)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var note = await db.Notes.Where(p => p.Id == id) var note = await db.Notes.Where(p => p.Id == id)
@ -118,7 +118,6 @@ public class PollController(
await pollSvc.RegisterPollVote(vote, poll, note, votes.IndexOf(vote) == 0); await pollSvc.RegisterPollVote(vote, poll, note, votes.IndexOf(vote) == 0);
await db.ReloadEntityAsync(poll); await db.ReloadEntityAsync(poll);
var res = await pollRenderer.RenderAsync(poll, user); return await pollRenderer.RenderAsync(poll, user);
return Ok(res);
} }
} }

View file

@ -1,8 +1,9 @@
using System.Net;
using System.Net.Mime; using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Mastodon.Attributes; using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas; using Iceshrimp.Backend.Controllers.Mastodon.Schemas;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Helpers; using Iceshrimp.Backend.Core.Helpers;
using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Middleware;
@ -11,6 +12,8 @@ using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using static Iceshrimp.Backend.Controllers.Mastodon.Schemas.PushSchemas;
using PushSubscription = Iceshrimp.Backend.Core.Database.Tables.PushSubscription;
namespace Iceshrimp.Backend.Controllers.Mastodon; namespace Iceshrimp.Backend.Controllers.Mastodon;
@ -24,11 +27,13 @@ namespace Iceshrimp.Backend.Controllers.Mastodon;
public class PushController(DatabaseContext db, MetaService meta) : ControllerBase public class PushController(DatabaseContext db, MetaService meta) : ControllerBase
{ {
[HttpPost] [HttpPost]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PushSchemas.PushSubscription))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> RegisterSubscription([FromHybrid] PushSchemas.RegisterPushRequest request) [ProducesErrors(HttpStatusCode.Unauthorized)]
public async Task<PushSchemas.PushSubscription> RegisterSubscription(
[FromHybrid] RegisterPushRequest request
)
{ {
var token = HttpContext.GetOauthToken(); var token = HttpContext.GetOauthToken() ?? throw GracefulException.Unauthorized("The access token is invalid");
if (token == null) throw GracefulException.Unauthorized("The access token is invalid");
var pushSubscription = await db.PushSubscriptions.FirstOrDefaultAsync(p => p.OauthToken == token); var pushSubscription = await db.PushSubscriptions.FirstOrDefaultAsync(p => p.OauthToken == token);
if (pushSubscription == null) if (pushSubscription == null)
{ {
@ -49,54 +54,46 @@ public class PushController(DatabaseContext db, MetaService meta) : ControllerBa
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
var res = await RenderSubscription(pushSubscription); return await RenderSubscription(pushSubscription);
return Ok(res);
} }
[HttpPut] [HttpPut]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PushSchemas.PushSubscription))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.Unauthorized, HttpStatusCode.NotFound)]
public async Task<IActionResult> EditSubscription([FromHybrid] PushSchemas.EditPushRequest request) public async Task<PushSchemas.PushSubscription> EditSubscription([FromHybrid] EditPushRequest request)
{ {
var token = HttpContext.GetOauthToken(); var token = HttpContext.GetOauthToken() ?? throw GracefulException.Unauthorized("The access token is invalid");
if (token == null) throw GracefulException.Unauthorized("The access token is invalid");
var pushSubscription = await db.PushSubscriptions.FirstOrDefaultAsync(p => p.OauthToken == token) ?? var pushSubscription = await db.PushSubscriptions.FirstOrDefaultAsync(p => p.OauthToken == token) ??
throw GracefulException.RecordNotFound(); throw GracefulException.NotFound("Push subscription not found");
pushSubscription.Types = GetTypes(request.Data.Alerts); pushSubscription.Types = GetTypes(request.Data.Alerts);
pushSubscription.Policy = GetPolicy(request.Data.Policy); pushSubscription.Policy = GetPolicy(request.Data.Policy);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var res = await RenderSubscription(pushSubscription); return await RenderSubscription(pushSubscription);
return Ok(res);
} }
[HttpGet] [HttpGet]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PushSchemas.PushSubscription))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.Unauthorized, HttpStatusCode.NotFound)]
public async Task<IActionResult> GetSubscription() public async Task<PushSchemas.PushSubscription> GetSubscription()
{ {
var token = HttpContext.GetOauthToken(); var token = HttpContext.GetOauthToken() ?? throw GracefulException.Unauthorized("The access token is invalid");
if (token == null) throw GracefulException.Unauthorized("The access token is invalid");
var pushSubscription = await db.PushSubscriptions.FirstOrDefaultAsync(p => p.OauthToken == token) ?? var pushSubscription = await db.PushSubscriptions.FirstOrDefaultAsync(p => p.OauthToken == token) ??
throw GracefulException.RecordNotFound(); throw GracefulException.NotFound("Push subscription not found");
var res = await RenderSubscription(pushSubscription); return await RenderSubscription(pushSubscription);
return Ok(res);
} }
[HttpDelete] [HttpDelete]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))] [OverrideResultType<object>]
public async Task<IActionResult> DeleteSubscription() [ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.Unauthorized)]
public async Task<object> DeleteSubscription()
{ {
var token = HttpContext.GetOauthToken(); var token = HttpContext.GetOauthToken() ?? throw GracefulException.Unauthorized("The access token is invalid");
if (token == null) throw GracefulException.Unauthorized("The access token is invalid");
await db.PushSubscriptions.Where(p => p.OauthToken == token).ExecuteDeleteAsync(); await db.PushSubscriptions.Where(p => p.OauthToken == token).ExecuteDeleteAsync();
return new object();
return Ok(new object());
} }
private static PushSubscription.PushPolicy GetPolicy(string policy) private static PushSubscription.PushPolicy GetPolicy(string policy)
@ -123,7 +120,7 @@ public class PushController(DatabaseContext db, MetaService meta) : ControllerBa
}; };
} }
private static List<string> GetTypes(PushSchemas.Alerts alerts) private static List<string> GetTypes(Alerts alerts)
{ {
List<string> types = []; List<string> types = [];
@ -155,7 +152,7 @@ public class PushController(DatabaseContext db, MetaService meta) : ControllerBa
Endpoint = sub.Endpoint, Endpoint = sub.Endpoint,
ServerKey = await meta.Get(MetaEntity.VapidPublicKey), ServerKey = await meta.Get(MetaEntity.VapidPublicKey),
Policy = GetPolicyString(sub.Policy), Policy = GetPolicyString(sub.Policy),
Alerts = new PushSchemas.Alerts Alerts = new Alerts
{ {
Favourite = sub.Types.Contains("favourite"), Favourite = sub.Types.Contains("favourite"),
Follow = sub.Types.Contains("follow"), Follow = sub.Types.Contains("follow"),

View file

@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Net.Mime; using System.Net.Mime;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Iceshrimp.Backend.Controllers.Mastodon.Attributes; using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
@ -37,38 +38,36 @@ public class SearchController(
[HttpGet("/api/v2/search")] [HttpGet("/api/v2/search")]
[Authorize("read:search")] [Authorize("read:search")]
[LinkPagination(20, 40)] [LinkPagination(20, 40)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(SearchSchemas.SearchResponse))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> Search(SearchSchemas.SearchRequest search, MastodonPaginationQuery pagination) public async Task<SearchSchemas.SearchResponse> Search(
{
if (search.Query == null)
throw GracefulException.BadRequest("Query is missing or invalid");
var result = new SearchSchemas.SearchResponse
{
Accounts = search.Type is null or "accounts" ? await SearchUsersAsync(search, pagination) : [],
Statuses = search.Type is null or "statuses" ? await SearchNotesAsync(search, pagination) : [],
Hashtags = search.Type is null or "hashtags" ? await SearchTagsAsync(search, pagination) : []
};
return Ok(result);
}
[HttpGet("/api/v1/accounts/search")]
[Authorize("read:accounts")]
[LinkPagination(20, 40, true)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<AccountEntity>))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> SearchAccounts(
SearchSchemas.SearchRequest search, MastodonPaginationQuery pagination SearchSchemas.SearchRequest search, MastodonPaginationQuery pagination
) )
{ {
if (search.Query == null) if (search.Query == null)
throw GracefulException.BadRequest("Query is missing or invalid"); throw GracefulException.BadRequest("Query is missing or invalid");
var result = await SearchUsersAsync(search, pagination); return new SearchSchemas.SearchResponse
{
Accounts = search.Type is null or "accounts" ? await SearchUsersAsync(search, pagination) : [],
Statuses = search.Type is null or "statuses" ? await SearchNotesAsync(search, pagination) : [],
Hashtags = search.Type is null or "hashtags" ? await SearchTagsAsync(search, pagination) : []
};
}
return Ok(result); [HttpGet("/api/v1/accounts/search")]
[Authorize("read:accounts")]
[LinkPagination(20, 40, true)]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<List<AccountEntity>> SearchAccounts(
SearchSchemas.SearchRequest search, MastodonPaginationQuery pagination
)
{
if (search.Query == null)
throw GracefulException.BadRequest("Query is missing or invalid");
return await SearchUsersAsync(search, pagination);
} }
[SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall", [SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall",

View file

@ -1,3 +1,4 @@
using System.Net;
using System.Net.Mime; using System.Net.Mime;
using AsyncKeyedLock; using AsyncKeyedLock;
using Iceshrimp.Backend.Controllers.Mastodon.Attributes; using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
@ -47,9 +48,9 @@ public class StatusController(
[HttpGet("{id}")] [HttpGet("{id}")]
[Authenticate("read:statuses")] [Authenticate("read:statuses")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.Forbidden, HttpStatusCode.NotFound)]
public async Task<IActionResult> GetNote(string id) public async Task<StatusEntity> GetNote(string id)
{ {
var user = HttpContext.GetUser(); var user = HttpContext.GetUser();
if (security.Value.PublicPreview == Enums.PublicPreview.Lockdown && user == null) if (security.Value.PublicPreview == Enums.PublicPreview.Lockdown && user == null)
@ -68,15 +69,14 @@ public class StatusController(
if (security.Value.PublicPreview <= Enums.PublicPreview.Restricted && note.User.IsRemoteUser && user == null) if (security.Value.PublicPreview <= Enums.PublicPreview.Restricted && note.User.IsRemoteUser && user == null)
throw GracefulException.Forbidden("Public preview is disabled on this instance"); throw GracefulException.Forbidden("Public preview is disabled on this instance");
var res = await noteRenderer.RenderAsync(note.EnforceRenoteReplyVisibility(), user); return await noteRenderer.RenderAsync(note.EnforceRenoteReplyVisibility(), user);
return Ok(res);
} }
[HttpGet("{id}/context")] [HttpGet("{id}/context")]
[Authenticate("read:statuses")] [Authenticate("read:statuses")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusContext))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.Forbidden, HttpStatusCode.NotFound)]
public async Task<IActionResult> GetStatusContext(string id) public async Task<StatusContext> GetStatusContext(string id)
{ {
var user = HttpContext.GetUser(); var user = HttpContext.GetUser();
if (security.Value.PublicPreview == Enums.PublicPreview.Lockdown && user == null) if (security.Value.PublicPreview == Enums.PublicPreview.Lockdown && user == null)
@ -102,7 +102,7 @@ public class StatusController(
.AnyAsync(); .AnyAsync();
if (!shouldShowContext) if (!shouldShowContext)
return Ok(new StatusContext { Ancestors = [], Descendants = [] }); return new StatusContext { Ancestors = [], Descendants = [] };
var ancestors = await db.NoteAncestors(id, maxAncestors) var ancestors = await db.NoteAncestors(id, maxAncestors)
.IncludeCommonProperties() .IncludeCommonProperties()
@ -119,19 +119,17 @@ public class StatusController(
.PrecomputeVisibilities(user) .PrecomputeVisibilities(user)
.RenderAllForMastodonAsync(noteRenderer, user, Filter.FilterContext.Threads); .RenderAllForMastodonAsync(noteRenderer, user, Filter.FilterContext.Threads);
var res = new StatusContext return new StatusContext
{ {
Ancestors = ancestors.OrderAncestors(), Descendants = descendants.OrderDescendants() Ancestors = ancestors.OrderAncestors(), Descendants = descendants.OrderDescendants()
}; };
return Ok(res);
} }
[HttpPost("{id}/favourite")] [HttpPost("{id}/favourite")]
[Authorize("write:favourites")] [Authorize("write:favourites")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> LikeNote(string id) public async Task<StatusEntity> LikeNote(string id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var note = await db.Notes.Where(p => p.Id == id) var note = await db.Notes.Where(p => p.Id == id)
@ -150,9 +148,9 @@ public class StatusController(
[HttpPost("{id}/unfavourite")] [HttpPost("{id}/unfavourite")]
[Authorize("write:favourites")] [Authorize("write:favourites")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> UnlikeNote(string id) public async Task<StatusEntity> UnlikeNote(string id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var note = await db.Notes.Where(p => p.Id == id) var note = await db.Notes.Where(p => p.Id == id)
@ -171,9 +169,9 @@ public class StatusController(
[HttpPost("{id}/react/{reaction}")] [HttpPost("{id}/react/{reaction}")]
[Authorize("write:favourites")] [Authorize("write:favourites")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> ReactNote(string id, string reaction) public async Task<StatusEntity> ReactNote(string id, string reaction)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var note = await db.Notes.Where(p => p.Id == id) var note = await db.Notes.Where(p => p.Id == id)
@ -192,9 +190,9 @@ public class StatusController(
[HttpPost("{id}/unreact/{reaction}")] [HttpPost("{id}/unreact/{reaction}")]
[Authorize("write:favourites")] [Authorize("write:favourites")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> UnreactNote(string id, string reaction) public async Task<StatusEntity> UnreactNote(string id, string reaction)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var note = await db.Notes.Where(p => p.Id == id) var note = await db.Notes.Where(p => p.Id == id)
@ -213,9 +211,9 @@ public class StatusController(
[HttpPost("{id}/bookmark")] [HttpPost("{id}/bookmark")]
[Authorize("write:bookmarks")] [Authorize("write:bookmarks")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> BookmarkNote(string id) public async Task<StatusEntity> BookmarkNote(string id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var note = await db.Notes.Where(p => p.Id == id) var note = await db.Notes.Where(p => p.Id == id)
@ -231,9 +229,9 @@ public class StatusController(
[HttpPost("{id}/unbookmark")] [HttpPost("{id}/unbookmark")]
[Authorize("write:bookmarks")] [Authorize("write:bookmarks")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> UnbookmarkNote(string id) public async Task<StatusEntity> UnbookmarkNote(string id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var note = await db.Notes.Where(p => p.Id == id) var note = await db.Notes.Where(p => p.Id == id)
@ -249,10 +247,9 @@ public class StatusController(
[HttpPost("{id}/pin")] [HttpPost("{id}/pin")]
[Authorize("write:accounts")] [Authorize("write:accounts")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound, HttpStatusCode.UnprocessableEntity)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity, Type = typeof(MastodonErrorResponse))] public async Task<StatusEntity> PinNote(string id)
public async Task<IActionResult> PinNote(string id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var note = await db.Notes.Where(p => p.Id == id) var note = await db.Notes.Where(p => p.Id == id)
@ -267,10 +264,9 @@ public class StatusController(
[HttpPost("{id}/unpin")] [HttpPost("{id}/unpin")]
[Authorize("write:accounts")] [Authorize("write:accounts")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity, Type = typeof(MastodonErrorResponse))] public async Task<StatusEntity> UnpinNote(string id)
public async Task<IActionResult> UnpinNote(string id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var note = await db.Notes.Where(p => p.Id == id).FirstOrDefaultAsync() ?? var note = await db.Notes.Where(p => p.Id == id).FirstOrDefaultAsync() ??
@ -282,9 +278,9 @@ public class StatusController(
[HttpPost("{id}/reblog")] [HttpPost("{id}/reblog")]
[Authorize("write:favourites")] [Authorize("write:favourites")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> Renote(string id, [FromHybrid] StatusSchemas.ReblogRequest? request) public async Task<StatusEntity> Renote(string id, [FromHybrid] StatusSchemas.ReblogRequest? request)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var renote = await db.Notes.IncludeCommonProperties() var renote = await db.Notes.IncludeCommonProperties()
@ -312,9 +308,9 @@ public class StatusController(
[HttpPost("{id}/unreblog")] [HttpPost("{id}/unreblog")]
[Authorize("write:favourites")] [Authorize("write:favourites")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> UndoRenote(string id) public async Task<StatusEntity> UndoRenote(string id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var note = await db.Notes.Where(p => p.Id == id) var note = await db.Notes.Where(p => p.Id == id)
@ -330,9 +326,9 @@ public class StatusController(
[HttpPost] [HttpPost]
[Authorize("write:statuses")] [Authorize("write:statuses")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.UnprocessableEntity)]
public async Task<IActionResult> PostNote([FromHybrid] StatusSchemas.PostStatusRequest request) public async Task<StatusEntity> PostNote([FromHybrid] StatusSchemas.PostStatusRequest request)
{ {
var token = HttpContext.GetOauthToken() ?? throw new Exception("Token must not be null at this stage"); var token = HttpContext.GetOauthToken() ?? throw new Exception("Token must not be null at this stage");
var user = token.User; var user = token.User;
@ -454,17 +450,14 @@ public class StatusController(
if (idempotencyKey != null) if (idempotencyKey != null)
await cache.SetAsync($"idempotency:{user.Id}:{idempotencyKey}", note.Id, TimeSpan.FromHours(24)); await cache.SetAsync($"idempotency:{user.Id}:{idempotencyKey}", note.Id, TimeSpan.FromHours(24));
var res = await noteRenderer.RenderAsync(note, user); return await noteRenderer.RenderAsync(note, user);
return Ok(res);
} }
[HttpPut("{id}")] [HttpPut("{id}")]
[Authorize("write:statuses")] [Authorize("write:statuses")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] public async Task<StatusEntity> EditNote(string id, [FromHybrid] StatusSchemas.EditStatusRequest request)
public async Task<IActionResult> EditNote(string id, [FromHybrid] StatusSchemas.EditStatusRequest request)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var note = await db.Notes var note = await db.Notes
@ -502,16 +495,14 @@ public class StatusController(
} }
note = await noteSvc.UpdateNoteAsync(note, request.Text, request.Cw, attachments, poll); note = await noteSvc.UpdateNoteAsync(note, request.Text, request.Cw, attachments, poll);
var res = await noteRenderer.RenderAsync(note, user); return await noteRenderer.RenderAsync(note, user);
return Ok(res);
} }
[HttpDelete("{id}")] [HttpDelete("{id}")]
[Authorize("write:statuses")] [Authorize("write:statuses")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> DeleteNote(string id) public async Task<StatusEntity> DeleteNote(string id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var note = await db.Notes.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id && p.User == user) ?? var note = await db.Notes.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id && p.User == user) ??
@ -519,36 +510,33 @@ public class StatusController(
var res = await noteRenderer.RenderAsync(note, user, data: new NoteRenderer.NoteRendererDto { Source = true }); var res = await noteRenderer.RenderAsync(note, user, data: new NoteRenderer.NoteRendererDto { Source = true });
await noteSvc.DeleteNoteAsync(note); await noteSvc.DeleteNoteAsync(note);
return res;
return Ok(res);
} }
[HttpGet("{id}/source")] [HttpGet("{id}/source")]
[Authorize("read:statuses")] [Authorize("read:statuses")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusSource))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetNoteSource(string id) public async Task<StatusSource> GetNoteSource(string id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var res = await db.Notes.Where(p => p.Id == id && p.User == user) return await db.Notes.Where(p => p.Id == id && p.User == user)
.Select(p => new StatusSource .Select(p => new StatusSource
{ {
Id = p.Id, Id = p.Id,
ContentWarning = p.Cw ?? "", ContentWarning = p.Cw ?? "",
Text = p.Text ?? "" Text = p.Text ?? ""
}) })
.FirstOrDefaultAsync() ?? .FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound(); throw GracefulException.RecordNotFound();
return Ok(res);
} }
[HttpGet("{id}/favourited_by")] [HttpGet("{id}/favourited_by")]
[Authenticate("read:statuses")] [Authenticate("read:statuses")]
[LinkPagination(40, 80)] [LinkPagination(40, 80)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<AccountEntity>))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.Forbidden, HttpStatusCode.NotFound)]
public async Task<IActionResult> GetNoteLikes(string id, MastodonPaginationQuery pq) public async Task<IEnumerable<AccountEntity>> GetNoteLikes(string id, MastodonPaginationQuery pq)
{ {
var user = HttpContext.GetUser(); var user = HttpContext.GetUser();
if (security.Value.PublicPreview == Enums.PublicPreview.Lockdown && user == null) if (security.Value.PublicPreview == Enums.PublicPreview.Lockdown && user == null)
@ -570,16 +558,15 @@ public class StatusController(
.ToListAsync(); .ToListAsync();
HttpContext.SetPaginationData(likes); HttpContext.SetPaginationData(likes);
var res = await userRenderer.RenderManyAsync(likes.Select(p => p.Entity)); return await userRenderer.RenderManyAsync(likes.Select(p => p.Entity));
return Ok(res);
} }
[HttpGet("{id}/reblogged_by")] [HttpGet("{id}/reblogged_by")]
[Authenticate("read:statuses")] [Authenticate("read:statuses")]
[LinkPagination(40, 80)] [LinkPagination(40, 80)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<AccountEntity>))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.Forbidden, HttpStatusCode.NotFound)]
public async Task<IActionResult> GetNoteRenotes(string id, MastodonPaginationQuery pq) public async Task<IEnumerable<AccountEntity>> GetNoteRenotes(string id, MastodonPaginationQuery pq)
{ {
var user = HttpContext.GetUser(); var user = HttpContext.GetUser();
if (security.Value.PublicPreview == Enums.PublicPreview.Lockdown && user == null) if (security.Value.PublicPreview == Enums.PublicPreview.Lockdown && user == null)
@ -604,15 +591,14 @@ public class StatusController(
.ToListAsync(); .ToListAsync();
HttpContext.SetPaginationData(renotes); HttpContext.SetPaginationData(renotes);
var res = await userRenderer.RenderManyAsync(renotes.Select(p => p.Entity)); return await userRenderer.RenderManyAsync(renotes.Select(p => p.Entity));
return Ok(res);
} }
[HttpGet("{id}/history")] [HttpGet("{id}/history")]
[Authenticate("read:statuses")] [Authenticate("read:statuses")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<AccountEntity>))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesErrors(HttpStatusCode.Forbidden, HttpStatusCode.NotFound)]
public async Task<IActionResult> GetNoteEditHistory(string id) public async Task<List<StatusEdit>> GetNoteEditHistory(string id)
{ {
var user = HttpContext.GetUser(); var user = HttpContext.GetUser();
if (security.Value.PublicPreview == Enums.PublicPreview.Lockdown && user == null) if (security.Value.PublicPreview == Enums.PublicPreview.Lockdown && user == null)
@ -629,7 +615,6 @@ public class StatusController(
if (security.Value.PublicPreview <= Enums.PublicPreview.Restricted && note.User.IsRemoteUser && user == null) if (security.Value.PublicPreview <= Enums.PublicPreview.Restricted && note.User.IsRemoteUser && user == null)
throw GracefulException.Forbidden("Public preview is disabled on this instance"); throw GracefulException.Forbidden("Public preview is disabled on this instance");
var res = await noteRenderer.RenderHistoryAsync(note); return await noteRenderer.RenderHistoryAsync(note);
return Ok(res);
} }
} }

View file

@ -1,3 +1,4 @@
using System.Net;
using System.Net.Mime; using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Mastodon.Attributes; using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Controllers.Mastodon.Renderers; using Iceshrimp.Backend.Controllers.Mastodon.Renderers;
@ -27,84 +28,73 @@ public class TimelineController(DatabaseContext db, NoteRenderer noteRenderer, C
{ {
[Authorize("read:statuses")] [Authorize("read:statuses")]
[HttpGet("home")] [HttpGet("home")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<StatusEntity>))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> GetHomeTimeline(MastodonPaginationQuery query) public async Task<IEnumerable<StatusEntity>> GetHomeTimeline(MastodonPaginationQuery query)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var heuristic = await QueryableTimelineExtensions.GetHeuristic(user, db, cache); var heuristic = await QueryableTimelineExtensions.GetHeuristic(user, db, cache);
return await db.Notes
var res = await db.Notes .IncludeCommonProperties()
.IncludeCommonProperties() .FilterByFollowingAndOwn(user, db, heuristic)
.FilterByFollowingAndOwn(user, db, heuristic) .EnsureVisibleFor(user)
.EnsureVisibleFor(user) .FilterHidden(user, db, filterHiddenListMembers: true)
.FilterHidden(user, db, filterHiddenListMembers: true) .Paginate(query, ControllerContext)
.Paginate(query, ControllerContext) .PrecomputeVisibilities(user)
.PrecomputeVisibilities(user) .RenderAllForMastodonAsync(noteRenderer, user, Filter.FilterContext.Home);
.RenderAllForMastodonAsync(noteRenderer, user, Filter.FilterContext.Home);
return Ok(res);
} }
[Authorize("read:statuses")] [Authorize("read:statuses")]
[HttpGet("public")] [HttpGet("public")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<StatusEntity>))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> GetPublicTimeline( public async Task<IEnumerable<StatusEntity>> GetPublicTimeline(
MastodonPaginationQuery query, TimelineSchemas.PublicTimelineRequest request MastodonPaginationQuery query, TimelineSchemas.PublicTimelineRequest request
) )
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
return await db.Notes
var res = await db.Notes .IncludeCommonProperties()
.IncludeCommonProperties() .HasVisibility(Note.NoteVisibility.Public)
.HasVisibility(Note.NoteVisibility.Public) .FilterByPublicTimelineRequest(request)
.FilterByPublicTimelineRequest(request) .FilterHidden(user, db)
.FilterHidden(user, db) .Paginate(query, ControllerContext)
.Paginate(query, ControllerContext) .PrecomputeVisibilities(user)
.PrecomputeVisibilities(user) .RenderAllForMastodonAsync(noteRenderer, user, Filter.FilterContext.Public);
.RenderAllForMastodonAsync(noteRenderer, user, Filter.FilterContext.Public);
return Ok(res);
} }
[Authorize("read:statuses")] [Authorize("read:statuses")]
[HttpGet("tag/{hashtag}")] [HttpGet("tag/{hashtag}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<StatusEntity>))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> GetHashtagTimeline( public async Task<IEnumerable<StatusEntity>> GetHashtagTimeline(
string hashtag, MastodonPaginationQuery query, TimelineSchemas.HashtagTimelineRequest request string hashtag, MastodonPaginationQuery query, TimelineSchemas.HashtagTimelineRequest request
) )
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
return await db.Notes
var res = await db.Notes .IncludeCommonProperties()
.IncludeCommonProperties() .Where(p => p.Tags.Contains(hashtag.ToLowerInvariant()))
.Where(p => p.Tags.Contains(hashtag.ToLowerInvariant())) .FilterByHashtagTimelineRequest(request)
.FilterByHashtagTimelineRequest(request) .FilterHidden(user, db)
.FilterHidden(user, db) .Paginate(query, ControllerContext)
.Paginate(query, ControllerContext) .PrecomputeVisibilities(user)
.PrecomputeVisibilities(user) .RenderAllForMastodonAsync(noteRenderer, user, Filter.FilterContext.Public);
.RenderAllForMastodonAsync(noteRenderer, user, Filter.FilterContext.Public);
return Ok(res);
} }
[Authorize("read:lists")] [Authorize("read:lists")]
[HttpGet("list/{id}")] [HttpGet("list/{id}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<StatusEntity>))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> GetListTimeline(string id, MastodonPaginationQuery query) public async Task<IEnumerable<StatusEntity>> GetListTimeline(string id, MastodonPaginationQuery query)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
if (!await db.UserLists.AnyAsync(p => p.Id == id && p.User == user)) if (!await db.UserLists.AnyAsync(p => p.Id == id && p.User == user))
throw GracefulException.RecordNotFound(); throw GracefulException.RecordNotFound();
var res = await db.Notes return await db.Notes
.IncludeCommonProperties() .IncludeCommonProperties()
.Where(p => db.UserListMembers.Any(l => l.UserListId == id && l.UserId == p.UserId)) .Where(p => db.UserListMembers.Any(l => l.UserListId == id && l.UserId == p.UserId))
.EnsureVisibleFor(user) .EnsureVisibleFor(user)
.FilterHidden(user, db) .FilterHidden(user, db)
.Paginate(query, ControllerContext) .Paginate(query, ControllerContext)
.PrecomputeVisibilities(user) .PrecomputeVisibilities(user)
.RenderAllForMastodonAsync(noteRenderer, user, Filter.FilterContext.Lists); .RenderAllForMastodonAsync(noteRenderer, user, Filter.FilterContext.Lists);
return Ok(res);
} }
} }

View file

@ -0,0 +1,6 @@
using Microsoft.AspNetCore.Mvc;
namespace Iceshrimp.Backend.Controllers.Shared.Attributes;
public class ConsumesActivityStreamsPayload() : ConsumesAttribute("application/activity+json",
"application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"");

View file

@ -0,0 +1,30 @@
using System.Net;
using Microsoft.AspNetCore.Mvc;
namespace Iceshrimp.Backend.Controllers.Shared.Attributes;
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public class ProducesErrorsAttribute(HttpStatusCode statusCode, params HttpStatusCode[] additional)
: ProducesResponseTypeAttribute((int)statusCode)
{
public IEnumerable<HttpStatusCode> StatusCodes => additional.Prepend(statusCode);
}
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public class ProducesResultsAttribute(HttpStatusCode statusCode, params HttpStatusCode[] additional)
: ProducesResponseTypeAttribute((int)statusCode)
{
public IEnumerable<HttpStatusCode> StatusCodes => additional.Prepend(statusCode);
}
public abstract class OverrideResultTypeAttribute(Type type) : Attribute
{
public Type Type => type;
}
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public class OverrideResultTypeAttribute<T>() : OverrideResultTypeAttribute(typeof(T));
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public class ProducesActivityStreamsPayload() : ProducesAttribute("application/activity+json",
"application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"");

View file

@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Net.Mime; using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Federation; using Iceshrimp.Backend.Controllers.Federation;
using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Controllers.Shared.Attributes;
@ -15,6 +16,7 @@ using Iceshrimp.Shared.Schemas.Web;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Newtonsoft.Json.Linq;
namespace Iceshrimp.Backend.Controllers.Web; namespace Iceshrimp.Backend.Controllers.Web;
@ -28,13 +30,16 @@ public class AdminController(
DatabaseContext db, DatabaseContext db,
ActivityPubController apController, ActivityPubController apController,
ActivityPub.ActivityFetcherService fetchSvc, ActivityPub.ActivityFetcherService fetchSvc,
ActivityPub.NoteRenderer noteRenderer,
ActivityPub.UserRenderer userRenderer,
IOptions<Config.InstanceSection> config,
QueueService queueSvc QueueService queueSvc
) : ControllerBase ) : ControllerBase
{ {
[HttpPost("invites/generate")] [HttpPost("invites/generate")]
[Produces(MediaTypeNames.Application.Json)] [Produces(MediaTypeNames.Application.Json)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(InviteResponse))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> GenerateInvite() public async Task<InviteResponse> GenerateInvite()
{ {
var invite = new RegistrationInvite var invite = new RegistrationInvite
{ {
@ -46,18 +51,15 @@ public class AdminController(
await db.AddAsync(invite); await db.AddAsync(invite);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var res = new InviteResponse { Code = invite.Code }; return new InviteResponse { Code = invite.Code };
return Ok(res);
} }
[HttpPost("users/{id}/reset-password")] [HttpPost("users/{id}/reset-password")]
[Produces(MediaTypeNames.Application.Json)] [Produces(MediaTypeNames.Application.Json)]
[Consumes(MediaTypeNames.Application.Json)] [Consumes(MediaTypeNames.Application.Json)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound)]
[ProducesResponseType(StatusCodes.Status200OK)] public async Task ResetPassword(string id, [FromBody] ResetPasswordRequest request)
public async Task<IActionResult> ResetPassword(string id, [FromBody] ResetPasswordRequest request)
{ {
var profile = await db.UserProfiles.FirstOrDefaultAsync(p => p.UserId == id && p.UserHost == null) ?? var profile = await db.UserProfiles.FirstOrDefaultAsync(p => p.UserId == id && p.UserHost == null) ??
throw GracefulException.RecordNotFound(); throw GracefulException.RecordNotFound();
@ -67,16 +69,12 @@ public class AdminController(
profile.Password = AuthHelpers.HashPassword(request.Password); profile.Password = AuthHelpers.HashPassword(request.Password);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok();
} }
[HttpPost("instances/{host}/force-state/{state}")] [HttpPost("instances/{host}/force-state/{state}")]
[Produces(MediaTypeNames.Application.Json)] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] public async Task ForceInstanceState(string host, AdminSchemas.InstanceState state)
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> ForceInstanceState(string host, AdminSchemas.InstanceState state)
{ {
var instance = await db.Instances.FirstOrDefaultAsync(p => p.Host == host.ToLowerInvariant()) ?? var instance = await db.Instances.FirstOrDefaultAsync(p => p.Host == host.ToLowerInvariant()) ??
throw GracefulException.NotFound("Instance not found"); throw GracefulException.NotFound("Instance not found");
@ -97,93 +95,97 @@ public class AdminController(
} }
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok();
} }
[HttpPost("queue/jobs/{id::guid}/retry")] [HttpPost("queue/jobs/{id::guid}/retry")]
[Produces(MediaTypeNames.Application.Json)] [Produces(MediaTypeNames.Application.Json)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound)]
[ProducesResponseType(StatusCodes.Status200OK)] public async Task RetryQueueJob(Guid id)
public async Task<IActionResult> RetryQueueJob(Guid id)
{ {
var job = await db.Jobs.FirstOrDefaultAsync(p => p.Id == id) ?? var job = await db.Jobs.FirstOrDefaultAsync(p => p.Id == id) ??
throw GracefulException.NotFound($"Job {id} was not found."); throw GracefulException.NotFound($"Job {id} was not found.");
await queueSvc.RetryJobAsync(job); await queueSvc.RetryJobAsync(job);
return Ok();
} }
[UseNewtonsoftJson] [UseNewtonsoftJson]
[HttpGet("activities/notes/{id}")] [HttpGet("activities/notes/{id}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASNote))] [OverrideResultType<ASNote>]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
[Produces("application/activity+json", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")] [Produces("application/activity+json", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")]
public async Task<IActionResult> GetNoteActivity(string id, [FromServices] ActivityPub.NoteRenderer noteRenderer) public async Task<JObject> GetNoteActivity(string id)
{ {
var note = await db.Notes var note = await db.Notes
.IncludeCommonProperties() .IncludeCommonProperties()
.FirstOrDefaultAsync(p => p.Id == id && p.UserHost == null); .FirstOrDefaultAsync(p => p.Id == id && p.UserHost == null);
if (note == null) return NotFound(); if (note == null) throw GracefulException.NotFound("Note not found");
var rendered = await noteRenderer.RenderAsync(note); var rendered = await noteRenderer.RenderAsync(note);
var compacted = rendered.Compact(); return rendered.Compact() ?? throw new Exception("Failed to compact JSON-LD payload");
return Ok(compacted);
} }
[UseNewtonsoftJson] [UseNewtonsoftJson]
[HttpGet("activities/notes/{id}/activity")] [HttpGet("activities/notes/{id}/activity")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASAnnounce))] [OverrideResultType<ASAnnounce>]
[Produces("application/activity+json", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")] [ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
[ProducesActivityStreamsPayload]
[SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataUsage")] [SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataUsage")]
[SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataQuery")] [SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataQuery")]
public async Task<IActionResult> GetRenoteActivity( public async Task<JObject> GetRenoteActivity(string id)
string id, [FromServices] ActivityPub.NoteRenderer noteRenderer,
[FromServices] ActivityPub.UserRenderer userRenderer, [FromServices] IOptions<Config.InstanceSection> config
)
{ {
var note = await db.Notes var note = await db.Notes
.IncludeCommonProperties() .IncludeCommonProperties()
.FirstOrDefaultAsync(p => p.Id == id && p.UserHost == null); .Where(p => p.Id == id && p.UserHost == null && p.IsPureRenote && p.Renote != null)
if (note is not { IsPureRenote: true, Renote: not null }) return NotFound(); .FirstOrDefaultAsync() ??
var rendered = ActivityPub.ActivityRenderer.RenderAnnounce(noteRenderer.RenderLite(note.Renote), throw GracefulException.NotFound("Note not found");
note.GetPublicUri(config.Value),
userRenderer.RenderLite(note.User), return ActivityPub.ActivityRenderer
note.Visibility, .RenderAnnounce(noteRenderer.RenderLite(note.Renote!),
note.User.GetPublicUri(config.Value) + "/followers"); note.GetPublicUri(config.Value),
var compacted = rendered.Compact(); userRenderer.RenderLite(note.User),
return Ok(compacted); note.Visibility,
note.User.GetPublicUri(config.Value) + "/followers")
.Compact();
} }
[UseNewtonsoftJson] [UseNewtonsoftJson]
[HttpGet("activities/users/{id}")] [HttpGet("activities/users/{id}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASActor))] [OverrideResultType<ASActor>]
[Produces("application/activity+json", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> GetUserActivity(string id) [ProducesErrors(HttpStatusCode.NotFound)]
[ProducesActivityStreamsPayload]
public async Task<ActionResult<JObject>> GetUserActivity(string id)
{ {
return await apController.GetUser(id); return await apController.GetUser(id);
} }
[UseNewtonsoftJson] [UseNewtonsoftJson]
[HttpGet("activities/users/{id}/collections/featured")] [HttpGet("activities/users/{id}/collections/featured")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASActor))] [OverrideResultType<ASOrderedCollection>]
[Produces("application/activity+json", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> GetUserFeaturedActivity(string id) [ProducesActivityStreamsPayload]
public async Task<JObject> GetUserFeaturedActivity(string id)
{ {
return await apController.GetUserFeatured(id); return await apController.GetUserFeatured(id);
} }
[UseNewtonsoftJson] [UseNewtonsoftJson]
[HttpGet("activities/users/@{acct}")] [HttpGet("activities/users/@{acct}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASActor))] [OverrideResultType<ASActor>]
[Produces("application/activity+json", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> GetUserActivityByUsername(string acct) [ProducesActivityStreamsPayload]
public async Task<ActionResult<JObject>> GetUserActivityByUsername(string acct)
{ {
return await apController.GetUserByUsername(acct); return await apController.GetUserByUsername(acct);
} }
[UseNewtonsoftJson] [UseNewtonsoftJson]
[HttpGet("activities/fetch")] [HttpGet("activities/fetch")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASObject))] [OverrideResultType<ASObject>]
[Produces("application/activity+json", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")] [ProducesResults(HttpStatusCode.OK)]
[ProducesActivityStreamsPayload]
public async Task<IActionResult> FetchActivityAsync([FromQuery] string uri, [FromQuery] string? userId) public async Task<IActionResult> FetchActivityAsync([FromQuery] string uri, [FromQuery] string? userId)
{ {
var user = userId != null ? await db.Users.FirstOrDefaultAsync(p => p.Id == userId && p.IsLocalUser) : null; var user = userId != null ? await db.Users.FirstOrDefaultAsync(p => p.Id == userId && p.IsLocalUser) : null;
@ -194,9 +196,10 @@ public class AdminController(
[UseNewtonsoftJson] [UseNewtonsoftJson]
[HttpGet("activities/fetch-raw")] [HttpGet("activities/fetch-raw")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASObject))] [OverrideResultType<ASObject>]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity, Type = typeof(ErrorResponse))] [ProducesResults(HttpStatusCode.OK)]
[Produces("application/activity+json", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")] [ProducesErrors(HttpStatusCode.UnprocessableEntity)]
[ProducesActivityStreamsPayload]
public async Task FetchRawActivityAsync([FromQuery] string uri, [FromQuery] string? userId) 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 user = userId != null ? await db.Users.FirstOrDefaultAsync(p => p.Id == userId && p.IsLocalUser) : null;

View file

@ -1,5 +1,7 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Net.Mime; using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Controllers.Web.Renderers; using Iceshrimp.Backend.Controllers.Web.Renderers;
using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Database.Tables;
@ -22,34 +24,31 @@ public class AuthController(DatabaseContext db, UserService userSvc, UserRendere
{ {
[HttpGet] [HttpGet]
[Authenticate] [Authenticate]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthResponse))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> GetAuthStatus() public async Task<AuthResponse> GetAuthStatus()
{ {
var session = HttpContext.GetSession(); var session = HttpContext.GetSession();
if (session == null) return new AuthResponse { Status = AuthStatusEnum.Guest };
if (session == null) return new AuthResponse
return Ok(new AuthResponse { Status = AuthStatusEnum.Guest });
return Ok(new AuthResponse
{ {
Status = session.Active ? AuthStatusEnum.Authenticated : AuthStatusEnum.TwoFactor, Status = session.Active ? AuthStatusEnum.Authenticated : AuthStatusEnum.TwoFactor,
Token = session.Token, Token = session.Token,
IsAdmin = session.User.IsAdmin, IsAdmin = session.User.IsAdmin,
IsModerator = session.User.IsModerator, IsModerator = session.User.IsModerator,
User = await userRenderer.RenderOne(session.User) User = await userRenderer.RenderOne(session.User)
}); };
} }
[HttpPost("login")] [HttpPost("login")]
[HideRequestDuration] [HideRequestDuration]
[EnableRateLimiting("auth")] [EnableRateLimiting("auth")]
[Consumes(MediaTypeNames.Application.Json)] [Consumes(MediaTypeNames.Application.Json)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthResponse))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))] [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.Forbidden)]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(ErrorResponse))]
[SuppressMessage("ReSharper.DPA", "DPA0011: High execution time of MVC action", [SuppressMessage("ReSharper.DPA", "DPA0011: High execution time of MVC action",
Justification = "Argon2 is execution time-heavy by design")] Justification = "Argon2 is execution time-heavy by design")]
public async Task<IActionResult> Login([FromBody] AuthRequest request) public async Task<AuthResponse> Login([FromBody] AuthRequest request)
{ {
var user = await db.Users.FirstOrDefaultAsync(p => p.IsLocalUser && var user = await db.Users.FirstOrDefaultAsync(p => p.IsLocalUser &&
p.UsernameLower == request.Username.ToLowerInvariant()); p.UsernameLower == request.Username.ToLowerInvariant());
@ -76,24 +75,22 @@ public class AuthController(DatabaseContext db, UserService userSvc, UserRendere
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
return Ok(new AuthResponse return new AuthResponse
{ {
Status = session.Active ? AuthStatusEnum.Authenticated : AuthStatusEnum.TwoFactor, Status = session.Active ? AuthStatusEnum.Authenticated : AuthStatusEnum.TwoFactor,
Token = session.Token, Token = session.Token,
IsAdmin = session.User.IsAdmin, IsAdmin = session.User.IsAdmin,
IsModerator = session.User.IsModerator, IsModerator = session.User.IsModerator,
User = await userRenderer.RenderOne(user) User = await userRenderer.RenderOne(user)
}); };
} }
[HttpPost("register")] [HttpPost("register")]
[EnableRateLimiting("auth")] [EnableRateLimiting("auth")]
[Consumes(MediaTypeNames.Application.Json)] [Consumes(MediaTypeNames.Application.Json)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthResponse))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))] [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden)]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(ErrorResponse))] public async Task<AuthResponse> Register([FromBody] RegistrationRequest request)
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(ErrorResponse))]
public async Task<IActionResult> Register([FromBody] RegistrationRequest request)
{ {
//TODO: captcha support //TODO: captcha support
@ -106,11 +103,11 @@ public class AuthController(DatabaseContext db, UserService userSvc, UserRendere
[Authorize] [Authorize]
[EnableRateLimiting("auth")] [EnableRateLimiting("auth")]
[Consumes(MediaTypeNames.Application.Json)] [Consumes(MediaTypeNames.Application.Json)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthResponse))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))] [ProducesErrors(HttpStatusCode.BadRequest)]
[SuppressMessage("ReSharper.DPA", "DPA0011: High execution time of MVC action", [SuppressMessage("ReSharper.DPA", "DPA0011: High execution time of MVC action",
Justification = "Argon2 is execution time-heavy by design")] Justification = "Argon2 is execution time-heavy by design")]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request) public async Task<AuthResponse> ChangePassword([FromBody] ChangePasswordRequest request)
{ {
var user = HttpContext.GetUser() ?? throw new GracefulException("HttpContext.GetUser() was null"); var user = HttpContext.GetUser() ?? throw new GracefulException("HttpContext.GetUser() was null");
var userProfile = await db.UserProfiles.FirstOrDefaultAsync(p => p.User == user); var userProfile = await db.UserProfiles.FirstOrDefaultAsync(p => p.User == user);

View file

@ -1,5 +1,6 @@
using System.Diagnostics.CodeAnalysis; using System.Net;
using System.Net.Mime; using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Middleware;
@ -17,7 +18,6 @@ namespace Iceshrimp.Backend.Controllers.Web;
public class DriveController( public class DriveController(
DatabaseContext db, DatabaseContext db,
ObjectStorageService objectStorage, ObjectStorageService objectStorage,
[SuppressMessage("ReSharper", "SuggestBaseTypeForParameterInConstructor")]
IOptionsSnapshot<Config.StorageSection> options, IOptionsSnapshot<Config.StorageSection> options,
ILogger<DriveController> logger, ILogger<DriveController> logger,
DriveService driveSvc DriveService driveSvc
@ -25,6 +25,8 @@ public class DriveController(
{ {
[EnableCors("drive")] [EnableCors("drive")]
[HttpGet("/files/{accessKey}")] [HttpGet("/files/{accessKey}")]
[ProducesResults(HttpStatusCode.OK, HttpStatusCode.NoContent)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetFileByAccessKey(string accessKey) public async Task<IActionResult> GetFileByAccessKey(string accessKey)
{ {
var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.AccessKey == accessKey || var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.AccessKey == accessKey ||
@ -33,7 +35,7 @@ public class DriveController(
if (file == null) if (file == null)
{ {
Response.Headers.CacheControl = "max-age=86400"; Response.Headers.CacheControl = "max-age=86400";
return NotFound(); throw GracefulException.NotFound("File not found");
} }
if (file.StoredInternal) if (file.StoredInternal)
@ -42,7 +44,7 @@ public class DriveController(
if (string.IsNullOrWhiteSpace(pathBase)) if (string.IsNullOrWhiteSpace(pathBase))
{ {
logger.LogError("Failed to get file {accessKey} from local storage: path does not exist", accessKey); logger.LogError("Failed to get file {accessKey} from local storage: path does not exist", accessKey);
return NotFound(); throw GracefulException.NotFound("File not found");
} }
var path = Path.Join(pathBase, accessKey); var path = Path.Join(pathBase, accessKey);
@ -64,7 +66,7 @@ public class DriveController(
if (stream == null) if (stream == null)
{ {
logger.LogError("Failed to get file {accessKey} from object storage", accessKey); logger.LogError("Failed to get file {accessKey} from object storage", accessKey);
return NotFound(); throw GracefulException.NotFound("File not found");
} }
Response.Headers.CacheControl = "max-age=31536000, immutable"; Response.Headers.CacheControl = "max-age=31536000, immutable";
@ -76,8 +78,9 @@ public class DriveController(
[HttpPost] [HttpPost]
[Authenticate] [Authenticate]
[Authorize] [Authorize]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DriveFileResponse))] [Produces(MediaTypeNames.Application.Json)]
public async Task<IActionResult> UploadFile(IFormFile file) [ProducesResults(HttpStatusCode.OK)]
public async Task<DriveFileResponse> UploadFile(IFormFile file)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var request = new DriveFileCreationRequest var request = new DriveFileCreationRequest
@ -93,15 +96,16 @@ public class DriveController(
[HttpGet("{id}")] [HttpGet("{id}")]
[Authenticate] [Authenticate]
[Authorize] [Authorize]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DriveFileResponse))] [Produces(MediaTypeNames.Application.Json)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> GetFileById(string id) [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<DriveFileResponse> GetFileById(string id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Id == id); var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Id == id) ??
if (file == null) return NotFound(); throw GracefulException.NotFound("File not found");
var res = new DriveFileResponse return new DriveFileResponse
{ {
Id = file.Id, Id = file.Id,
Url = file.PublicUrl, Url = file.PublicUrl,
@ -111,21 +115,20 @@ public class DriveController(
Description = file.Comment, Description = file.Comment,
Sensitive = file.IsSensitive Sensitive = file.IsSensitive
}; };
return Ok(res);
} }
[HttpPatch("{id}")] [HttpPatch("{id}")]
[Authenticate] [Authenticate]
[Authorize] [Authorize]
[Consumes(MediaTypeNames.Application.Json)] [Consumes(MediaTypeNames.Application.Json)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DriveFileResponse))] [Produces(MediaTypeNames.Application.Json)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> UpdateFile(string id, UpdateDriveFileRequest request) [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<DriveFileResponse> UpdateFile(string id, UpdateDriveFileRequest request)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Id == id); var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Id == id) ??
if (file == null) return NotFound(); throw GracefulException.NotFound("File not found");
file.Name = request.Filename ?? file.Name; file.Name = request.Filename ?? file.Name;
file.IsSensitive = request.Sensitive ?? file.IsSensitive; file.IsSensitive = request.Sensitive ?? file.IsSensitive;

View file

@ -1,4 +1,6 @@
using System.Net;
using System.Net.Mime; using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Services; using Iceshrimp.Backend.Core.Services;
@ -22,36 +24,33 @@ public class EmojiController(
) : ControllerBase ) : ControllerBase
{ {
[HttpGet] [HttpGet]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<EmojiResponse>))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> GetAllEmoji() public async Task<IEnumerable<EmojiResponse>> GetAllEmoji()
{ {
var res = await db.Emojis return await db.Emojis
.Where(p => p.Host == null) .Where(p => p.Host == null)
.Select(p => new EmojiResponse .Select(p => new EmojiResponse
{ {
Id = p.Id, Id = p.Id,
Name = p.Name, Name = p.Name,
Uri = p.Uri, Uri = p.Uri,
Aliases = p.Aliases, Aliases = p.Aliases,
Category = p.Category, Category = p.Category,
PublicUrl = p.PublicUrl, PublicUrl = p.PublicUrl,
License = p.License License = p.License
}) })
.ToListAsync(); .ToListAsync();
return Ok(res);
} }
[HttpGet("{id}")] [HttpGet("{id}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(EmojiResponse))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetEmoji(string id) public async Task<EmojiResponse> GetEmoji(string id)
{ {
var emoji = await db.Emojis.FirstOrDefaultAsync(p => p.Id == id); var emoji = await db.Emojis.FirstOrDefaultAsync(p => p.Id == id) ??
throw GracefulException.NotFound("Emoji not found");
if (emoji == null) return NotFound(); return new EmojiResponse
var res = new EmojiResponse
{ {
Id = emoji.Id, Id = emoji.Id,
Name = emoji.Name, Name = emoji.Name,
@ -61,19 +60,17 @@ public class EmojiController(
PublicUrl = emoji.PublicUrl, PublicUrl = emoji.PublicUrl,
License = emoji.License License = emoji.License
}; };
return Ok(res);
} }
[HttpPost] [HttpPost]
[Authorize("role:admin")] [Authorize("role:admin")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(EmojiResponse))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status409Conflict, Type = typeof(ErrorResponse))] [ProducesErrors(HttpStatusCode.Conflict)]
public async Task<IActionResult> UploadEmoji(IFormFile file) public async Task<EmojiResponse> UploadEmoji(IFormFile file)
{ {
var emoji = await emojiSvc.CreateEmojiFromStream(file.OpenReadStream(), file.FileName, file.ContentType); var emoji = await emojiSvc.CreateEmojiFromStream(file.OpenReadStream(), file.FileName, file.ContentType);
var res = new EmojiResponse return new EmojiResponse
{ {
Id = emoji.Id, Id = emoji.Id,
Name = emoji.Name, Name = emoji.Name,
@ -83,25 +80,22 @@ public class EmojiController(
PublicUrl = emoji.PublicUrl, PublicUrl = emoji.PublicUrl,
License = null License = null
}; };
return Ok(res);
} }
[HttpPost("clone/{name}@{host}")] [HttpPost("clone/{name}@{host}")]
[Authorize("role:admin")] [Authorize("role:admin")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(EmojiResponse))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound, HttpStatusCode.Conflict)]
[ProducesResponseType(StatusCodes.Status409Conflict, Type = typeof(ErrorResponse))] public async Task<EmojiResponse> CloneEmoji(string name, string host)
public async Task<IActionResult> CloneEmoji(string name, string host)
{ {
var localEmojo = await db.Emojis.FirstOrDefaultAsync(e => e.Name == name && e.Host == null); var localEmojo = await db.Emojis.FirstOrDefaultAsync(e => e.Name == name && e.Host == null);
if (localEmojo != null) return Conflict(); if (localEmojo != null) throw GracefulException.Conflict("An emoji with that name already exists");
var emojo = await db.Emojis.FirstOrDefaultAsync(e => e.Name == name && e.Host == host); var emojo = await db.Emojis.FirstOrDefaultAsync(e => e.Name == name && e.Host == host);
if (emojo == null) return NotFound(); if (emojo == null) throw GracefulException.NotFound("Emoji not found");
var cloned = await emojiSvc.CloneEmoji(emojo); var cloned = await emojiSvc.CloneEmoji(emojo);
var response = new EmojiResponse return new EmojiResponse
{ {
Id = cloned.Id, Id = cloned.Id,
Name = cloned.Name, Name = cloned.Name,
@ -111,33 +105,30 @@ public class EmojiController(
PublicUrl = cloned.PublicUrl, PublicUrl = cloned.PublicUrl,
License = null License = null
}; };
return Ok(response);
} }
[HttpPost("import")] [HttpPost("import")]
[Authorize("role:admin")] [Authorize("role:admin")]
[ProducesResponseType(StatusCodes.Status202Accepted)] [ProducesResults(HttpStatusCode.Accepted)]
public async Task<IActionResult> ImportEmoji(IFormFile file) public async Task<AcceptedResult> ImportEmoji(IFormFile file)
{ {
var zip = await emojiImportSvc.Parse(file.OpenReadStream()); var zip = await emojiImportSvc.Parse(file.OpenReadStream());
await emojiImportSvc.Import(zip); // TODO: run in background. this will take a while await emojiImportSvc.Import(zip); // TODO: run in background. this will take a while
return Accepted(); return Accepted();
} }
[HttpPatch("{id}")] [HttpPatch("{id}")]
[Authorize("role:admin")] [Authorize("role:admin")]
[Consumes(MediaTypeNames.Application.Json)] [Consumes(MediaTypeNames.Application.Json)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(EmojiResponse))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> UpdateEmoji(string id, UpdateEmojiRequest request) public async Task<EmojiResponse> UpdateEmoji(string id, UpdateEmojiRequest request)
{ {
var emoji = await emojiSvc.UpdateLocalEmoji(id, request.Name, request.Aliases, request.Category, var emoji = await emojiSvc.UpdateLocalEmoji(id, request.Name, request.Aliases, request.Category,
request.License); request.License) ??
if (emoji == null) return NotFound(); throw GracefulException.NotFound("Emoji not found");
var res = new EmojiResponse return new EmojiResponse
{ {
Id = emoji.Id, Id = emoji.Id,
Name = emoji.Name, Name = emoji.Name,
@ -147,17 +138,14 @@ public class EmojiController(
PublicUrl = emoji.PublicUrl, PublicUrl = emoji.PublicUrl,
License = emoji.License License = emoji.License
}; };
return Ok(res);
} }
[HttpDelete("{id}")] [HttpDelete("{id}")]
[Authorize("role:admin")] [Authorize("role:admin")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> DeleteEmoji(string id) public async Task DeleteEmoji(string id)
{ {
await emojiSvc.DeleteEmoji(id); await emojiSvc.DeleteEmoji(id);
return Ok();
} }
} }

View file

@ -1,7 +1,7 @@
using System.Net; using System.Net;
using System.Net.Mime; using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Shared.Schemas.Web;
using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -11,10 +11,10 @@ namespace Iceshrimp.Backend.Controllers.Web;
public class FallbackController : ControllerBase public class FallbackController : ControllerBase
{ {
[EnableCors("fallback")] [EnableCors("fallback")]
[ProducesResponseType(StatusCodes.Status501NotImplemented, Type = typeof(ErrorResponse))] [ProducesErrors(HttpStatusCode.NotImplemented)]
public IActionResult FallbackAction() public IActionResult FallbackAction()
{ {
throw new GracefulException(HttpStatusCode.NotImplemented, throw new GracefulException(HttpStatusCode.NotImplemented, "This API method has not been implemented",
"This API method has not been implemented", Request.Path); Request.Path);
} }
} }

View file

@ -1,3 +1,4 @@
using System.Net;
using System.Net.Mime; using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Controllers.Shared.Schemas; using Iceshrimp.Backend.Controllers.Shared.Schemas;
@ -27,8 +28,8 @@ public class FollowRequestController(
{ {
[HttpGet] [HttpGet]
[LinkPagination(20, 40)] [LinkPagination(20, 40)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<FollowRequestResponse>))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> GetFollowRequests(PaginationQuery pq) public async Task<IEnumerable<FollowRequestResponse>> GetFollowRequests(PaginationQuery pq)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var requests = await db.FollowRequests var requests = await db.FollowRequests
@ -39,17 +40,16 @@ public class FollowRequestController(
.ToListAsync(); .ToListAsync();
var users = await userRenderer.RenderMany(requests.Select(p => p.Follower)); var users = await userRenderer.RenderMany(requests.Select(p => p.Follower));
var res = requests.Select(p => new FollowRequestResponse return requests.Select(p => new FollowRequestResponse
{ {
Id = p.Id, User = users.First(u => u.Id == p.Follower.Id) Id = p.Id, User = users.First(u => u.Id == p.Follower.Id)
}); });
return Ok(res.ToList());
} }
[HttpPost("{id}/accept")] [HttpPost("{id}/accept")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> AcceptFollowRequest(string id) public async Task AcceptFollowRequest(string id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var request = await db.FollowRequests var request = await db.FollowRequests
@ -58,13 +58,12 @@ public class FollowRequestController(
throw GracefulException.NotFound("Follow request not found"); throw GracefulException.NotFound("Follow request not found");
await userSvc.AcceptFollowRequestAsync(request); await userSvc.AcceptFollowRequestAsync(request);
return Ok();
} }
[HttpPost("{id}/reject")] [HttpPost("{id}/reject")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> RejectFollowRequest(string id) public async Task RejectFollowRequest(string id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var request = await db.FollowRequests var request = await db.FollowRequests
@ -73,6 +72,5 @@ public class FollowRequestController(
throw GracefulException.NotFound("Follow request not found"); throw GracefulException.NotFound("Follow request not found");
await userSvc.RejectFollowRequestAsync(request); await userSvc.RejectFollowRequestAsync(request);
return Ok();
} }
} }

View file

@ -1,6 +1,8 @@
using System.IO.Hashing; using System.IO.Hashing;
using System.Net;
using System.Net.Mime; using System.Net.Mime;
using System.Text; using System.Text;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using SixLabors.ImageSharp; using SixLabors.ImageSharp;
@ -15,10 +17,10 @@ namespace Iceshrimp.Backend.Controllers.Web;
[Route("/identicon/{id}")] [Route("/identicon/{id}")]
[Route("/identicon/{id}.png")] [Route("/identicon/{id}.png")]
[Produces(MediaTypeNames.Image.Png)] [Produces(MediaTypeNames.Image.Png)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))]
public class IdenticonController : ControllerBase public class IdenticonController : ControllerBase
{ {
[HttpGet] [HttpGet]
[ProducesResults(HttpStatusCode.OK)]
public async Task GetIdenticon(string id) public async Task GetIdenticon(string id)
{ {
using var image = new Image<Rgb24>(Size, Size); using var image = new Image<Rgb24>(Size, Size);

View file

@ -1,5 +1,6 @@
using System.ComponentModel; using System.ComponentModel;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Net;
using System.Net.Mime; using System.Net.Mime;
using AsyncKeyedLock; using AsyncKeyedLock;
using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Controllers.Shared.Attributes;
@ -37,9 +38,9 @@ public class NoteController(
[HttpGet("{id}")] [HttpGet("{id}")]
[Authenticate] [Authenticate]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(NoteResponse))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetNote(string id) public async Task<NoteResponse> GetNote(string id)
{ {
var user = HttpContext.GetUser(); var user = HttpContext.GetUser();
var note = await db.Notes.Where(p => p.Id == id) var note = await db.Notes.Where(p => p.Id == id)
@ -50,29 +51,28 @@ public class NoteController(
.FirstOrDefaultAsync() ?? .FirstOrDefaultAsync() ??
throw GracefulException.NotFound("Note not found"); throw GracefulException.NotFound("Note not found");
return Ok(await noteRenderer.RenderOne(note.EnforceRenoteReplyVisibility(), user)); return await noteRenderer.RenderOne(note.EnforceRenoteReplyVisibility(), user);
} }
[HttpDelete("{id}")] [HttpDelete("{id}")]
[Authenticate] [Authenticate]
[Authorize] [Authorize]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> DeleteNote(string id) public async Task DeleteNote(string id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var note = await db.Notes.FirstOrDefaultAsync(p => p.Id == id && p.User == user) ?? var note = await db.Notes.FirstOrDefaultAsync(p => p.Id == id && p.User == user) ??
throw GracefulException.NotFound("Note not found"); throw GracefulException.NotFound("Note not found");
await noteSvc.DeleteNoteAsync(note); await noteSvc.DeleteNoteAsync(note);
return Ok();
} }
[HttpGet("{id}/ascendants")] [HttpGet("{id}/ascendants")]
[Authenticate] [Authenticate]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<NoteResponse>))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetNoteAscendants( public async Task<IEnumerable<NoteResponse>> GetNoteAscendants(
string id, [FromQuery] [DefaultValue(20)] [Range(1, 100)] int? limit string id, [FromQuery] [DefaultValue(20)] [Range(1, 100)] int? limit
) )
{ {
@ -94,14 +94,14 @@ public class NoteController(
var res = await noteRenderer.RenderMany(notes.EnforceRenoteReplyVisibility(), user, var res = await noteRenderer.RenderMany(notes.EnforceRenoteReplyVisibility(), user,
Filter.FilterContext.Threads); Filter.FilterContext.Threads);
return Ok(res.ToList().OrderAncestors()); return res.ToList().OrderAncestors();
} }
[HttpGet("{id}/descendants")] [HttpGet("{id}/descendants")]
[Authenticate] [Authenticate]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<NoteResponse>))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetNoteDescendants( public async Task<IEnumerable<NoteResponse>> GetNoteDescendants(
string id, [FromQuery] [DefaultValue(20)] [Range(1, 100)] int? depth string id, [FromQuery] [DefaultValue(20)] [Range(1, 100)] int? depth
) )
{ {
@ -123,16 +123,16 @@ public class NoteController(
var notes = hits.EnforceRenoteReplyVisibility(); var notes = hits.EnforceRenoteReplyVisibility();
var res = await noteRenderer.RenderMany(notes, user, Filter.FilterContext.Threads); var res = await noteRenderer.RenderMany(notes, user, Filter.FilterContext.Threads);
return Ok(res.ToList().OrderDescendants()); return res.ToList().OrderDescendants();
} }
[HttpGet("{id}/reactions/{name}")] [HttpGet("{id}/reactions/{name}")]
[Authenticate] [Authenticate]
[Authorize] [Authorize]
[LinkPagination(20, 40)] [LinkPagination(20, 40)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<UserResponse>))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetNoteReactions(string id, string name) public async Task<IEnumerable<UserResponse>> GetNoteReactions(string id, string name)
{ {
var user = HttpContext.GetUser(); var user = HttpContext.GetUser();
var note = await db.Notes var note = await db.Notes
@ -147,15 +147,15 @@ public class NoteController(
.Select(p => p.User) .Select(p => p.User)
.ToListAsync(); .ToListAsync();
return Ok(await userRenderer.RenderMany(users)); return await userRenderer.RenderMany(users);
} }
[HttpPost("{id}/like")] [HttpPost("{id}/like")]
[Authenticate] [Authenticate]
[Authorize] [Authorize]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ValueResponse))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> LikeNote(string id) public async Task<ValueResponse> LikeNote(string id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var note = await db.Notes var note = await db.Notes
@ -167,15 +167,15 @@ public class NoteController(
var success = await noteSvc.LikeNoteAsync(note, user); var success = await noteSvc.LikeNoteAsync(note, user);
return Ok(new ValueResponse(success ? ++note.LikeCount : note.LikeCount)); return new ValueResponse(success ? ++note.LikeCount : note.LikeCount);
} }
[HttpPost("{id}/unlike")] [HttpPost("{id}/unlike")]
[Authenticate] [Authenticate]
[Authorize] [Authorize]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ValueResponse))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> UnlikeNote(string id) public async Task<ValueResponse> UnlikeNote(string id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var note = await db.Notes var note = await db.Notes
@ -187,15 +187,15 @@ public class NoteController(
var success = await noteSvc.UnlikeNoteAsync(note, user); var success = await noteSvc.UnlikeNoteAsync(note, user);
return Ok(new ValueResponse(success ? --note.LikeCount : note.LikeCount)); return new ValueResponse(success ? --note.LikeCount : note.LikeCount);
} }
[HttpPost("{id}/renote")] [HttpPost("{id}/renote")]
[Authenticate] [Authenticate]
[Authorize] [Authorize]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ValueResponse))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> RenoteNote(string id, [FromQuery] NoteVisibility? visibility = null) public async Task<ValueResponse> RenoteNote(string id, [FromQuery] NoteVisibility? visibility = null)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var note = await db.Notes var note = await db.Notes
@ -206,15 +206,15 @@ public class NoteController(
throw GracefulException.NotFound("Note not found"); throw GracefulException.NotFound("Note not found");
var success = await noteSvc.RenoteNoteAsync(note, user, (Note.NoteVisibility?)visibility); var success = await noteSvc.RenoteNoteAsync(note, user, (Note.NoteVisibility?)visibility);
return Ok(new ValueResponse(success != null ? ++note.RenoteCount : note.RenoteCount)); return new ValueResponse(success != null ? ++note.RenoteCount : note.RenoteCount);
} }
[HttpPost("{id}/unrenote")] [HttpPost("{id}/unrenote")]
[Authenticate] [Authenticate]
[Authorize] [Authorize]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ValueResponse))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> UnrenoteNote(string id) public async Task<ValueResponse> UnrenoteNote(string id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var note = await db.Notes var note = await db.Notes
@ -225,15 +225,15 @@ public class NoteController(
throw GracefulException.NotFound("Note not found"); throw GracefulException.NotFound("Note not found");
var count = await noteSvc.UnrenoteNoteAsync(note, user); var count = await noteSvc.UnrenoteNoteAsync(note, user);
return Ok(new ValueResponse(note.RenoteCount - count)); return new ValueResponse(note.RenoteCount - count);
} }
[HttpPost("{id}/react/{name}")] [HttpPost("{id}/react/{name}")]
[Authenticate] [Authenticate]
[Authorize] [Authorize]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ValueResponse))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> ReactToNote(string id, string name) public async Task<ValueResponse> ReactToNote(string id, string name)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var note = await db.Notes var note = await db.Notes
@ -245,15 +245,15 @@ public class NoteController(
var res = await noteSvc.ReactToNoteAsync(note, user, name); var res = await noteSvc.ReactToNoteAsync(note, user, name);
note.Reactions.TryGetValue(res.name, out var count); note.Reactions.TryGetValue(res.name, out var count);
return Ok(new ValueResponse(res.success ? ++count : count)); return new ValueResponse(res.success ? ++count : count);
} }
[HttpPost("{id}/unreact/{name}")] [HttpPost("{id}/unreact/{name}")]
[Authenticate] [Authenticate]
[Authorize] [Authorize]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ValueResponse))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> RemoveReactionFromNote(string id, string name) public async Task<ValueResponse> RemoveReactionFromNote(string id, string name)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var note = await db.Notes.Where(p => p.Id == id) var note = await db.Notes.Where(p => p.Id == id)
@ -264,16 +264,16 @@ public class NoteController(
var res = await noteSvc.RemoveReactionFromNoteAsync(note, user, name); var res = await noteSvc.RemoveReactionFromNoteAsync(note, user, name);
note.Reactions.TryGetValue(res.name, out var count); note.Reactions.TryGetValue(res.name, out var count);
return Ok(new ValueResponse(res.success ? --count : count)); return new ValueResponse(res.success ? --count : count);
} }
[HttpPost("{id}/refetch")] [HttpPost("{id}/refetch")]
[Authenticate] [Authenticate]
[Authorize] [Authorize]
[EnableRateLimiting("strict")] [EnableRateLimiting("strict")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(NoteRefetchResponse))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> RefetchNote(string id) public async Task<NoteRefetchResponse> RefetchNote(string id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var note = await db.Notes.Where(p => p.Id == id && p.User.Host != null && p.Uri != null) var note = await db.Notes.Where(p => p.Id == id && p.User.Host != null && p.Uri != null)
@ -330,19 +330,19 @@ public class NoteController(
.FirstOrDefaultAsync() ?? .FirstOrDefaultAsync() ??
throw new Exception("Note disappeared during refetch"); throw new Exception("Note disappeared during refetch");
var res = new NoteRefetchResponse return new NoteRefetchResponse
{ {
Note = await noteRenderer.RenderOne(note.EnforceRenoteReplyVisibility(), user), Errors = errors Note = await noteRenderer.RenderOne(note.EnforceRenoteReplyVisibility(), user), Errors = errors
}; };
return Ok(res);
} }
[HttpPost] [HttpPost]
[Authenticate] [Authenticate]
[Authorize] [Authorize]
[Consumes(MediaTypeNames.Application.Json)] [Consumes(MediaTypeNames.Application.Json)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(NoteResponse))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> CreateNote(NoteCreateRequest request) [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<NoteResponse> CreateNote(NoteCreateRequest request)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
@ -397,6 +397,6 @@ public class NoteController(
if (request.IdempotencyKey != null) if (request.IdempotencyKey != null)
await cache.SetAsync($"idempotency:{user.Id}:{request.IdempotencyKey}", note.Id, TimeSpan.FromHours(24)); await cache.SetAsync($"idempotency:{user.Id}:{request.IdempotencyKey}", note.Id, TimeSpan.FromHours(24));
return Ok(await noteRenderer.RenderOne(note, user)); return await noteRenderer.RenderOne(note, user);
} }
} }

View file

@ -1,3 +1,4 @@
using System.Net;
using System.Net.Mime; using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Controllers.Shared.Schemas; using Iceshrimp.Backend.Controllers.Shared.Schemas;
@ -22,8 +23,8 @@ public class NotificationController(DatabaseContext db, NotificationRenderer not
{ {
[HttpGet] [HttpGet]
[LinkPagination(20, 80)] [LinkPagination(20, 80)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<NotificationResponse>))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> GetNotifications(PaginationQuery query) public async Task<IEnumerable<NotificationResponse>> GetNotifications(PaginationQuery query)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var notifications = await db.Notifications var notifications = await db.Notifications
@ -35,13 +36,13 @@ public class NotificationController(DatabaseContext db, NotificationRenderer not
.PrecomputeNoteVisibilities(user) .PrecomputeNoteVisibilities(user)
.ToListAsync(); .ToListAsync();
return Ok(await notificationRenderer.RenderMany(notifications.EnforceRenoteReplyVisibility(p => p.Note), user)); return await notificationRenderer.RenderMany(notifications.EnforceRenoteReplyVisibility(p => p.Note), user);
} }
[HttpPost("{id}/read")] [HttpPost("{id}/read")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> MarkNotificationAsRead(string id) public async Task MarkNotificationAsRead(string id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var notification = await db.Notifications.FirstOrDefaultAsync(p => p.Notifiee == user && p.Id == id) ?? var notification = await db.Notifications.FirstOrDefaultAsync(p => p.Notifiee == user && p.Id == id) ??
@ -52,25 +53,21 @@ public class NotificationController(DatabaseContext db, NotificationRenderer not
notification.IsRead = true; notification.IsRead = true;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
return Ok(new object());
} }
[HttpPost("read")] [HttpPost("read")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> MarkAllNotificationsAsRead() public async Task MarkAllNotificationsAsRead()
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
await db.Notifications.Where(p => p.Notifiee == user && !p.IsRead) await db.Notifications.Where(p => p.Notifiee == user && !p.IsRead)
.ExecuteUpdateAsync(p => p.SetProperty(n => n.IsRead, true)); .ExecuteUpdateAsync(p => p.SetProperty(n => n.IsRead, true));
return Ok(new object());
} }
[HttpDelete("{id}")] [HttpDelete("{id}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> DeleteNotification(string id) public async Task DeleteNotification(string id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var notification = await db.Notifications.FirstOrDefaultAsync(p => p.Notifiee == user && p.Id == id) ?? var notification = await db.Notifications.FirstOrDefaultAsync(p => p.Notifiee == user && p.Id == id) ??
@ -78,18 +75,14 @@ public class NotificationController(DatabaseContext db, NotificationRenderer not
db.Remove(notification); db.Remove(notification);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(new object());
} }
[HttpDelete] [HttpDelete]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> DeleteAllNotifications() public async Task DeleteAllNotifications()
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
await db.Notifications.Where(p => p.Notifiee == user) await db.Notifications.Where(p => p.Notifiee == user)
.ExecuteDeleteAsync(); .ExecuteDeleteAsync();
return Ok(new object());
} }
} }

View file

@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Net.Mime; using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Controllers.Shared.Schemas; using Iceshrimp.Backend.Controllers.Shared.Schemas;
@ -30,13 +31,14 @@ public class SearchController(
UserRenderer userRenderer, UserRenderer userRenderer,
ActivityPub.UserResolver userResolver, ActivityPub.UserResolver userResolver,
IOptions<Config.InstanceSection> config IOptions<Config.InstanceSection> config
) ) : ControllerBase
: ControllerBase
{ {
[HttpGet("notes")] [HttpGet("notes")]
[LinkPagination(20, 80)] [LinkPagination(20, 80)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<NoteResponse>))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> SearchNotes([FromQuery(Name = "q")] string query, PaginationQuery pagination) public async Task<IEnumerable<NoteResponse>> SearchNotes(
[FromQuery(Name = "q")] string query, PaginationQuery pagination
)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var notes = await db.Notes var notes = await db.Notes
@ -48,15 +50,17 @@ public class SearchController(
.PrecomputeVisibilities(user) .PrecomputeVisibilities(user)
.ToListAsync(); .ToListAsync();
return Ok(await noteRenderer.RenderMany(notes.EnforceRenoteReplyVisibility(), user)); return await noteRenderer.RenderMany(notes.EnforceRenoteReplyVisibility(), user);
} }
[HttpGet("users")] [HttpGet("users")]
[LinkPagination(20, 80)] [LinkPagination(20, 80)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<UserResponse>))] [ProducesResults(HttpStatusCode.OK)]
[SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall", [SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall",
Justification = "Inspection doesn't know about the Projectable attribute")] Justification = "Inspection doesn't know about the Projectable attribute")]
public async Task<IActionResult> SearchUsers([FromQuery(Name = "q")] string query, PaginationQuery pagination) public async Task<IEnumerable<UserResponse>> SearchUsers(
[FromQuery(Name = "q")] string query, PaginationQuery pagination
)
{ {
var users = await db.Users var users = await db.Users
.IncludeCommonProperties() .IncludeCommonProperties()
@ -66,14 +70,13 @@ public class SearchController(
.OrderByDescending(p => p.NotesCount) .OrderByDescending(p => p.NotesCount)
.ToListAsync(); .ToListAsync();
return Ok(await userRenderer.RenderMany(users)); return await userRenderer.RenderMany(users);
} }
[HttpGet("lookup")] [HttpGet("lookup")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RedirectResponse))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))] [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] public async Task<RedirectResponse> Lookup([FromQuery(Name = "target")] string target)
public async Task<IActionResult> Lookup([FromQuery(Name = "target")] string target)
{ {
target = target.Trim(); target = target.Trim();
@ -85,7 +88,7 @@ public class SearchController(
if (target.StartsWith('@') || target.StartsWith(userPrefixAlt)) if (target.StartsWith('@') || target.StartsWith(userPrefixAlt))
{ {
var hit = await userResolver.ResolveAsyncOrNull(target); var hit = await userResolver.ResolveAsyncOrNull(target);
if (hit != null) return Ok(new RedirectResponse { TargetUrl = $"/users/{hit.Id}" }); if (hit != null) return new RedirectResponse { TargetUrl = $"/users/{hit.Id}" };
throw GracefulException.NotFound("No result found"); throw GracefulException.NotFound("No result found");
} }
@ -112,18 +115,18 @@ public class SearchController(
} }
noteHit ??= await db.Notes.FirstOrDefaultAsync(p => p.Uri == target || p.Url == target); noteHit ??= await db.Notes.FirstOrDefaultAsync(p => p.Uri == target || p.Url == target);
if (noteHit != null) return Ok(new RedirectResponse { TargetUrl = $"/notes/{noteHit.Id}" }); if (noteHit != null) return new RedirectResponse { TargetUrl = $"/notes/{noteHit.Id}" };
userHit ??= await db.Users.FirstOrDefaultAsync(p => p.Uri == target || userHit ??= await db.Users.FirstOrDefaultAsync(p => p.Uri == target ||
(p.UserProfile != null && (p.UserProfile != null &&
p.UserProfile.Url == target)); p.UserProfile.Url == target));
if (userHit != null) return Ok(new RedirectResponse { TargetUrl = $"/users/{userHit.Id}" }); if (userHit != null) return new RedirectResponse { TargetUrl = $"/users/{userHit.Id}" };
noteHit = await noteSvc.ResolveNoteAsync(target); noteHit = await noteSvc.ResolveNoteAsync(target);
if (noteHit != null) return Ok(new RedirectResponse { TargetUrl = $"/notes/{noteHit.Id}" }); if (noteHit != null) return new RedirectResponse { TargetUrl = $"/notes/{noteHit.Id}" };
userHit = await userResolver.ResolveAsyncOrNull(target); userHit = await userResolver.ResolveAsyncOrNull(target);
if (userHit != null) return Ok(new RedirectResponse { TargetUrl = $"/users/{userHit.Id}" }); if (userHit != null) return new RedirectResponse { TargetUrl = $"/users/{userHit.Id}" };
throw GracefulException.NotFound("No result found"); throw GracefulException.NotFound("No result found");
} }

View file

@ -1,4 +1,6 @@
using System.Net;
using System.Net.Mime; using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Middleware;
@ -17,26 +19,23 @@ namespace Iceshrimp.Backend.Controllers.Web;
public class SettingsController(DatabaseContext db) : ControllerBase public class SettingsController(DatabaseContext db) : ControllerBase
{ {
[HttpGet] [HttpGet]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(UserSettingsEntity))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> GetSettings() public async Task<UserSettingsEntity> GetSettings()
{ {
var settings = await GetOrInitUserSettings(); var settings = await GetOrInitUserSettings();
return new UserSettingsEntity
var res = new UserSettingsEntity
{ {
FilterInaccessible = settings.FilterInaccessible, FilterInaccessible = settings.FilterInaccessible,
PrivateMode = settings.PrivateMode, PrivateMode = settings.PrivateMode,
DefaultNoteVisibility = (NoteVisibility)settings.DefaultNoteVisibility, DefaultNoteVisibility = (NoteVisibility)settings.DefaultNoteVisibility,
DefaultRenoteVisibility = (NoteVisibility)settings.DefaultNoteVisibility DefaultRenoteVisibility = (NoteVisibility)settings.DefaultNoteVisibility
}; };
return Ok(res);
} }
[HttpPut] [HttpPut]
[Consumes(MediaTypeNames.Application.Json)] [Consumes(MediaTypeNames.Application.Json)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))] [ProducesResults(HttpStatusCode.OK)]
public async Task<IActionResult> UpdateSettings(UserSettingsEntity newSettings) public async Task UpdateSettings(UserSettingsEntity newSettings)
{ {
var settings = await GetOrInitUserSettings(); var settings = await GetOrInitUserSettings();
@ -46,21 +45,18 @@ public class SettingsController(DatabaseContext db) : ControllerBase
settings.DefaultRenoteVisibility = (Note.NoteVisibility)newSettings.DefaultRenoteVisibility; settings.DefaultRenoteVisibility = (Note.NoteVisibility)newSettings.DefaultRenoteVisibility;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Ok(new object());
} }
private async Task<UserSettings> GetOrInitUserSettings() private async Task<UserSettings> GetOrInitUserSettings()
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var settings = user.UserSettings; var settings = user.UserSettings;
if (settings == null) if (settings != null) return settings;
{
settings = new UserSettings { User = user };
db.Add(settings);
await db.SaveChangesAsync();
await db.ReloadEntityAsync(settings);
}
settings = new UserSettings { User = user };
db.Add(settings);
await db.SaveChangesAsync();
await db.ReloadEntityAsync(settings);
return settings; return settings;
} }
} }

View file

@ -1,3 +1,4 @@
using System.Net;
using System.Net.Mime; using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Controllers.Shared.Schemas; using Iceshrimp.Backend.Controllers.Shared.Schemas;
@ -24,9 +25,8 @@ public class TimelineController(DatabaseContext db, NoteRenderer noteRenderer, C
[HttpGet("home")] [HttpGet("home")]
[Authenticate] [Authenticate]
[Authorize] [Authorize]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<NoteResponse>))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] public async Task<IEnumerable<NoteResponse>> GetHomeTimeline(PaginationQuery pq)
public async Task<IActionResult> GetHomeTimeline(PaginationQuery pq)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var heuristic = await QueryableTimelineExtensions.GetHeuristic(user, db, cache); var heuristic = await QueryableTimelineExtensions.GetHeuristic(user, db, cache);
@ -38,6 +38,6 @@ public class TimelineController(DatabaseContext db, NoteRenderer noteRenderer, C
.PrecomputeVisibilities(user) .PrecomputeVisibilities(user)
.ToListAsync(); .ToListAsync();
return Ok(await noteRenderer.RenderMany(notes.EnforceRenoteReplyVisibility(), user, Filter.FilterContext.Home)); return await noteRenderer.RenderMany(notes.EnforceRenoteReplyVisibility(), user, Filter.FilterContext.Home);
} }
} }

View file

@ -1,3 +1,4 @@
using System.Net;
using System.Net.Mime; using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Controllers.Shared.Schemas; using Iceshrimp.Backend.Controllers.Shared.Schemas;
@ -33,21 +34,21 @@ public class UserController(
) : ControllerBase ) : ControllerBase
{ {
[HttpGet("{id}")] [HttpGet("{id}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(UserResponse))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetUser(string id) public async Task<UserResponse> GetUser(string id)
{ {
var user = await db.Users.IncludeCommonProperties() var user = await db.Users.IncludeCommonProperties()
.FirstOrDefaultAsync(p => p.Id == id) ?? .FirstOrDefaultAsync(p => p.Id == id) ??
throw GracefulException.NotFound("User not found"); throw GracefulException.NotFound("User not found");
return Ok(await userRenderer.RenderOne(await userResolver.GetUpdatedUser(user))); return await userRenderer.RenderOne(await userResolver.GetUpdatedUser(user));
} }
[HttpGet("lookup")] [HttpGet("lookup")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(UserResponse))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> LookupUser([FromQuery] string username, [FromQuery] string? host) public async Task<UserResponse> LookupUser([FromQuery] string username, [FromQuery] string? host)
{ {
username = username.ToLowerInvariant(); username = username.ToLowerInvariant();
host = host?.ToLowerInvariant(); host = host?.ToLowerInvariant();
@ -59,27 +60,27 @@ public class UserController(
.FirstOrDefaultAsync(p => p.UsernameLower == username && p.Host == host) ?? .FirstOrDefaultAsync(p => p.UsernameLower == username && p.Host == host) ??
throw GracefulException.NotFound("User not found"); throw GracefulException.NotFound("User not found");
return Ok(await userRenderer.RenderOne(await userResolver.GetUpdatedUser(user))); return await userRenderer.RenderOne(await userResolver.GetUpdatedUser(user));
} }
[HttpGet("{id}/profile")] [HttpGet("{id}/profile")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(UserProfileResponse))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetUserProfile(string id) public async Task<UserProfileResponse> GetUserProfile(string id)
{ {
var localUser = HttpContext.GetUserOrFail(); var localUser = HttpContext.GetUserOrFail();
var user = await db.Users.IncludeCommonProperties() var user = await db.Users.IncludeCommonProperties()
.FirstOrDefaultAsync(p => p.Id == id) ?? .FirstOrDefaultAsync(p => p.Id == id) ??
throw GracefulException.NotFound("User not found"); throw GracefulException.NotFound("User not found");
return Ok(await userProfileRenderer.RenderOne(await userResolver.GetUpdatedUser(user), localUser)); return await userProfileRenderer.RenderOne(await userResolver.GetUpdatedUser(user), localUser);
} }
[HttpGet("{id}/notes")] [HttpGet("{id}/notes")]
[LinkPagination(20, 80)] [LinkPagination(20, 80)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<NoteResponse>))] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] [ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetUserNotes(string id, PaginationQuery pq) public async Task<IEnumerable<NoteResponse>> GetUserNotes(string id, PaginationQuery pq)
{ {
var localUser = HttpContext.GetUserOrFail(); var localUser = HttpContext.GetUserOrFail();
var user = await db.Users.FirstOrDefaultAsync(p => p.Id == id) ?? var user = await db.Users.FirstOrDefaultAsync(p => p.Id == id) ??
@ -92,20 +93,18 @@ public class UserController(
.FilterHidden(localUser, db, filterMutes: false) .FilterHidden(localUser, db, filterMutes: false)
.Paginate(pq, ControllerContext) .Paginate(pq, ControllerContext)
.PrecomputeVisibilities(localUser) .PrecomputeVisibilities(localUser)
.ToListAsync(); .ToListAsync()
.ContinueWith(n => n.Result.EnforceRenoteReplyVisibility());
return Ok(await noteRenderer.RenderMany(notes.EnforceRenoteReplyVisibility(), localUser, return await noteRenderer.RenderMany(notes, localUser, Filter.FilterContext.Accounts);
Filter.FilterContext.Accounts));
} }
[HttpPost("{id}/follow")] [HttpPost("{id}/follow")]
[Authenticate] [Authenticate]
[Authorize] [Authorize]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResults(HttpStatusCode.OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))] [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.Forbidden, HttpStatusCode.NotFound)]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(ErrorResponse))] public async Task FollowUser(string id)
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))]
public async Task<IActionResult> FollowUser(string id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
if (user.Id == id) if (user.Id == id)
@ -115,22 +114,18 @@ public class UserController(
.Where(p => p.Id == id) .Where(p => p.Id == id)
.PrecomputeRelationshipData(user) .PrecomputeRelationshipData(user)
.FirstOrDefaultAsync() ?? .FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound(); throw GracefulException.NotFound("User not found");
if ((followee.PrecomputedIsBlockedBy ?? true) || (followee.PrecomputedIsBlocking ?? true)) if ((followee.PrecomputedIsBlockedBy ?? true) || (followee.PrecomputedIsBlocking ?? true))
throw GracefulException.Forbidden("This action is not allowed"); throw GracefulException.Forbidden("This action is not allowed");
if (!(followee.PrecomputedIsFollowedBy ?? false) && !(followee.PrecomputedIsRequestedBy ?? false)) if (!(followee.PrecomputedIsFollowedBy ?? false) && !(followee.PrecomputedIsRequestedBy ?? false))
await userSvc.FollowUserAsync(user, followee); await userSvc.FollowUserAsync(user, followee);
return Ok();
} }
[HttpPost("{id}/unfollow")] [HttpPost("{id}/unfollow")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))] public async Task UnfollowUser(string id)
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))]
public async Task<IActionResult> UnfollowUser(string id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
if (user.Id == id) if (user.Id == id)
@ -144,7 +139,5 @@ public class UserController(
throw GracefulException.RecordNotFound(); throw GracefulException.RecordNotFound();
await userSvc.UnfollowUserAsync(user, followee); await userSvc.UnfollowUserAsync(user, followee);
return Ok();
} }
} }

View file

@ -2,8 +2,12 @@ using System.Diagnostics.CodeAnalysis;
using System.Reflection; using System.Reflection;
using Iceshrimp.Backend.Controllers.Federation.Attributes; using Iceshrimp.Backend.Controllers.Federation.Attributes;
using Iceshrimp.Backend.Controllers.Mastodon.Attributes; using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Shared.Schemas.Web;
using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.OpenApi.Any; using Microsoft.OpenApi.Any;
using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen; using Swashbuckle.AspNetCore.SwaggerGen;
@ -18,8 +22,11 @@ public static class SwaggerGenOptionsExtensions
options.SupportNonNullableReferenceTypes(); // Sets Nullable flags appropriately. options.SupportNonNullableReferenceTypes(); // Sets Nullable flags appropriately.
options.UseAllOfToExtendReferenceSchemas(); // Allows $ref enums to be nullable options.UseAllOfToExtendReferenceSchemas(); // Allows $ref enums to be nullable
options.UseAllOfForInheritance(); // Allows $ref objects to be nullable options.UseAllOfForInheritance(); // Allows $ref objects to be nullable
options.OperationFilter<AuthorizeCheckOperationFilter>(); options.OperationFilter<AuthorizeCheckOperationDocumentFilter>();
options.OperationFilter<HybridRequestOperationFilter>(); options.OperationFilter<HybridRequestOperationFilter>();
options.OperationFilter<PossibleErrorsOperationFilter>();
options.OperationFilter<PossibleResultsOperationFilter>();
options.DocumentFilter<AuthorizeCheckOperationDocumentFilter>();
options.DocInclusionPredicate(DocInclusionPredicate); options.DocInclusionPredicate(DocInclusionPredicate);
} }
@ -63,8 +70,91 @@ public static class SwaggerGenOptionsExtensions
[SuppressMessage("ReSharper", "ClassNeverInstantiated.Local", [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local",
Justification = "SwaggerGenOptions.OperationFilter<T> instantiates this class at runtime")] Justification = "SwaggerGenOptions.OperationFilter<T> instantiates this class at runtime")]
private class AuthorizeCheckOperationFilter : IOperationFilter private class AuthorizeCheckOperationDocumentFilter : IOperationFilter, IDocumentFilter
{ {
private const string Web401 =
"""
{
"statusCode": 401,
"error": "Unauthorized",
"message": "This method requires an authenticated user"
}
""";
private const string Web403 =
"""
{
"statusCode": 403,
"error": "Forbidden",
"message": "This action is outside the authorized scopes"
}
""";
private const string Masto401 =
"""
{
"error": "This method requires an authenticated user"
}
""";
private const string Masto403 =
"""
{
"message": "This action is outside the authorized scopes"
}
""";
private static readonly OpenApiString MastoExample401 = new(Masto401);
private static readonly OpenApiString MastoExample403 = new(Masto403);
private static readonly OpenApiString WebExample401 = new(Web401);
private static readonly OpenApiString WebExample403 = new(Web403);
private static readonly OpenApiReference Ref401 =
new() { Type = ReferenceType.Response, Id = "error-401" };
private static readonly OpenApiReference Ref403 =
new() { Type = ReferenceType.Response, Id = "error-403" };
private static readonly OpenApiResponse MastoRes401 = new()
{
Reference = Ref401,
Description = "Unauthorized",
Content = new Dictionary<string, OpenApiMediaType>
{
{ "application/json", new OpenApiMediaType { Example = MastoExample401 } }
}
};
private static readonly OpenApiResponse MastoRes403 = new()
{
Reference = Ref403,
Description = "Forbidden",
Content = new Dictionary<string, OpenApiMediaType>
{
{ "application/json", new OpenApiMediaType { Example = MastoExample403 } }
}
};
private static readonly OpenApiResponse WebRes401 = new()
{
Reference = Ref401,
Description = "Unauthorized",
Content = new Dictionary<string, OpenApiMediaType>
{
{ "application/json", new OpenApiMediaType { Example = WebExample401 } }
}
};
private static readonly OpenApiResponse WebRes403 = new()
{
Reference = Ref403,
Description = "Forbidden",
Content = new Dictionary<string, OpenApiMediaType>
{
{ "application/json", new OpenApiMediaType { Example = WebExample403 } }
}
};
public void Apply(OpenApiOperation operation, OperationFilterContext context) public void Apply(OpenApiOperation operation, OperationFilterContext context)
{ {
if (context.MethodInfo.DeclaringType is null) if (context.MethodInfo.DeclaringType is null)
@ -102,69 +192,125 @@ public static class SwaggerGenOptionsExtensions
if (authorizeAttribute == null) return; if (authorizeAttribute == null) return;
const string web401 =
"""
{
"statusCode": 401,
"error": "Unauthorized",
"message": "This method requires an authenticated user"
}
""";
const string web403 =
"""
{
"statusCode": 403,
"error": "Forbidden",
"message": "This action is outside the authorized scopes"
}
""";
const string masto401 =
"""
{
"error": "This method requires an authenticated user"
}
""";
const string masto403 =
"""
{
"message": "This action is outside the authorized scopes"
}
""";
var example401 = new OpenApiString(isMastodonController ? masto401 : web401);
var res401 = new OpenApiResponse
{
Description = "Unauthorized",
Content = new Dictionary<string, OpenApiMediaType>
{
{ "application/json", new OpenApiMediaType { Example = example401 } }
}
};
operation.Responses.Remove("401"); operation.Responses.Remove("401");
operation.Responses.Add("401", res401); operation.Responses.Add("401", new OpenApiResponse { Reference = Ref401 });
if (authorizeAttribute is { AdminRole: false, ModeratorRole: false, Scopes.Length: 0 } && if (authorizeAttribute is { AdminRole: false, ModeratorRole: false, Scopes.Length: 0 } &&
authenticateAttribute is { AdminRole: false, ModeratorRole: false, Scopes.Length: 0 }) authenticateAttribute is { AdminRole: false, ModeratorRole: false, Scopes.Length: 0 })
return; return;
operation.Responses.Remove("403"); operation.Responses.Remove("403");
operation.Responses.Add("403", new OpenApiResponse { Reference = Ref403 });
}
var example403 = new OpenApiString(isMastodonController ? masto403 : web403); public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
{
var res403 = new OpenApiResponse if (swaggerDoc.Info.Title == "Mastodon")
{ {
Description = "Forbidden", swaggerDoc.Components.Responses.Add(Ref401.Id, MastoRes401);
Content = new Dictionary<string, OpenApiMediaType> swaggerDoc.Components.Responses.Add(Ref403.Id, MastoRes403);
}
else
{
swaggerDoc.Components.Responses.Add(Ref401.Id, WebRes401);
swaggerDoc.Components.Responses.Add(Ref403.Id, WebRes403);
}
}
}
[SuppressMessage("ReSharper", "ClassNeverInstantiated.Local",
Justification = "SwaggerGenOptions.OperationFilter<T> instantiates this class at runtime")]
private class PossibleErrorsOperationFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
if (context.MethodInfo.DeclaringType is null)
return;
var attribute = context.MethodInfo.GetCustomAttributes(true)
.OfType<ProducesErrorsAttribute>()
.FirstOrDefault() ??
context.MethodInfo.DeclaringType.GetCustomAttributes(true)
.OfType<ProducesErrorsAttribute>()
.FirstOrDefault();
if (attribute == null) return;
var isMastodonController = context.MethodInfo.DeclaringType.GetCustomAttributes(true)
.OfType<MastodonApiControllerAttribute>()
.Any();
var type = isMastodonController ? typeof(MastodonErrorResponse) : typeof(ErrorResponse);
var schema = context.SchemaGenerator.GenerateSchema(type, context.SchemaRepository);
foreach (var status in attribute.StatusCodes.Distinct())
{
var res = new OpenApiResponse
{ {
{ "application/json", new OpenApiMediaType { Example = example403 } } Description = ReasonPhrases.GetReasonPhrase((int)status),
} Content = new Dictionary<string, OpenApiMediaType>
}; {
operation.Responses.Add("403", res403); { "application/json", new OpenApiMediaType { Schema = schema } }
}
};
operation.Responses.Remove(((int)status).ToString());
operation.Responses.Add(((int)status).ToString(), res);
}
}
}
[SuppressMessage("ReSharper", "ClassNeverInstantiated.Local",
Justification = "SwaggerGenOptions.OperationFilter<T> instantiates this class at runtime")]
private class PossibleResultsOperationFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
if (context.MethodInfo.DeclaringType is null)
return;
var attribute = context.MethodInfo.GetCustomAttributes(true)
.OfType<ProducesResultsAttribute>()
.FirstOrDefault() ??
context.MethodInfo.DeclaringType.GetCustomAttributes(true)
.OfType<ProducesResultsAttribute>()
.FirstOrDefault();
if (attribute == null) return;
var overrideType = context.MethodInfo.GetCustomAttributes(true)
.OfType<OverrideResultTypeAttribute>()
.FirstOrDefault() ??
context.MethodInfo.DeclaringType.GetCustomAttributes(true)
.OfType<OverrideResultTypeAttribute>()
.FirstOrDefault();
var type = overrideType?.Type ??
context.ApiDescription.SupportedResponseTypes.FirstOrDefault(p => p.Type != typeof(void))?.Type;
var schema = type != null
? context.SchemaGenerator.GenerateSchema(type, context.SchemaRepository)
: null;
var openApiMediaType = new OpenApiMediaType { Schema = schema };
foreach (var status in attribute.StatusCodes.Distinct())
{
var content = schema != null
? context.ApiDescription.SupportedResponseTypes
.Where(p => p.StatusCode == (int)status)
.SelectMany(p => p.ApiResponseFormats.Select(i => i.MediaType))
.Distinct()
.ToDictionary(contentType => contentType, _ => openApiMediaType)
: null;
var res = new OpenApiResponse
{
Description = ReasonPhrases.GetReasonPhrase((int)status), Content = content
};
operation.Responses.Remove(((int)status).ToString());
operation.Responses.Add(((int)status).ToString(), res);
}
} }
} }

View file

@ -114,27 +114,29 @@ public static class LdHelpers
return payload; return payload;
} }
public static JObject? Compact(this ASObject obj) public static JObject Compact(this ASObject obj)
{ {
return Compact((object)obj); return Compact((object)obj) ?? throw new Exception("Failed to compact JSON-LD paylaod");
} }
public static JObject? Compact(object obj) public static JObject Compact(object obj)
{ {
return Compact(JToken.FromObject(obj, JsonSerializer)); return Compact(JToken.FromObject(obj, JsonSerializer)) ??
throw new Exception("Failed to compact JSON-LD paylaod");
} }
public static JArray? Expand(object obj) public static JArray Expand(object obj)
{ {
return Expand(JToken.FromObject(obj, JsonSerializer)); return Expand(JToken.FromObject(obj, JsonSerializer)) ??
throw new Exception("Failed to expand JSON-LD paylaod");
} }
public static JObject? Compact(JToken? json) public static JObject Compact(JToken? json)
{ {
return JsonLdProcessor.Compact(json, FederationContext, Options); return JsonLdProcessor.Compact(json, FederationContext, Options);
} }
public static JArray? Expand(JToken? json) public static JArray Expand(JToken? json)
{ {
return JsonLdProcessor.Expand(json, Options); return JsonLdProcessor.Expand(json, Options);
} }

View file

@ -77,13 +77,13 @@ public static class LdSignature
activity.Add($"{Constants.W3IdSecurityNs}#signature", JToken.FromObject(options)); activity.Add($"{Constants.W3IdSecurityNs}#signature", JToken.FromObject(options));
return LdHelpers.Expand(activity)?[0] as JObject ?? return LdHelpers.Expand(activity)[0] as JObject ??
throw new GracefulException(HttpStatusCode.UnprocessableEntity, "Failed to expand signed activity"); throw new GracefulException(HttpStatusCode.UnprocessableEntity, "Failed to expand signed activity");
} }
private static Task<byte[]?> GetSignatureDataAsync(JToken data, SignatureOptions options) private static Task<byte[]?> GetSignatureDataAsync(JToken data, SignatureOptions options)
{ {
return GetSignatureDataAsync(data, LdHelpers.Expand(JObject.FromObject(options))!); return GetSignatureDataAsync(data, LdHelpers.Expand(JObject.FromObject(options)));
} }
private static async Task<byte[]?> GetSignatureDataAsync(JToken data, JToken options) private static async Task<byte[]?> GetSignatureDataAsync(JToken data, JToken options)

View file

@ -367,6 +367,79 @@
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ECSharpPlaceEmbeddedOnSameLineMigration/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ECSharpPlaceEmbeddedOnSameLineMigration/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ECSharpUseContinuousIndentInsideBracesMigration/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ECSharpUseContinuousIndentInsideBracesMigration/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EMigrateBlankLinesAroundFieldToBlankLinesAroundProperty/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EMigrateBlankLinesAroundFieldToBlankLinesAroundProperty/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=82C6671DFF72A14F823A7B0C48905556/@KeyIndexDefined">True</s:Boolean>
<s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=82C6671DFF72A14F823A7B0C48905556/Applicability/=Live/@EntryIndexedValue">True</s:Boolean>
<s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=82C6671DFF72A14F823A7B0C48905556/Description/@EntryValue">ProducesResultsAndErrors</s:String>
<s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=82C6671DFF72A14F823A7B0C48905556/Field/=BadRequest/@KeyIndexDefined">True</s:Boolean>
<s:Int64 x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=82C6671DFF72A14F823A7B0C48905556/Field/=BadRequest/Order/@EntryValue">1</s:Int64>
<s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=82C6671DFF72A14F823A7B0C48905556/Field/=OK/@KeyIndexDefined">True</s:Boolean>
<s:Int64 x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=82C6671DFF72A14F823A7B0C48905556/Field/=OK/Order/@EntryValue">0</s:Int64>
<s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=82C6671DFF72A14F823A7B0C48905556/Scope/=B68999B9D6B43E47A02B22C12A54C3CC/@KeyIndexDefined">True</s:Boolean>
<s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=82C6671DFF72A14F823A7B0C48905556/Scope/=B68999B9D6B43E47A02B22C12A54C3CC/CustomProperties/=minimumLanguageVersion/@EntryIndexedValue">2.0</s:String>
<s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=82C6671DFF72A14F823A7B0C48905556/Scope/=B68999B9D6B43E47A02B22C12A54C3CC/Type/@EntryValue">InCSharpTypeMember</s:String>
<s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=82C6671DFF72A14F823A7B0C48905556/Shortcut/@EntryValue">prese</s:String>
<s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=82C6671DFF72A14F823A7B0C48905556/ShortenQualifiedReferences/@EntryValue">True</s:Boolean>
<s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=82C6671DFF72A14F823A7B0C48905556/Text/@EntryValue">[ProducesResults(HttpStatusCode.$OK$)]
[ProducesErrors(HttpStatusCode.$BadRequest$)]$SELECTION$$END$</s:String>
<s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=8BD853BBFDD9844391296D061B40C3C1/@KeyIndexDefined">True</s:Boolean>
<s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=8BD853BBFDD9844391296D061B40C3C1/Applicability/=Live/@EntryIndexedValue">True</s:Boolean>
<s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=8BD853BBFDD9844391296D061B40C3C1/Description/@EntryValue">ProducesResults</s:String>
<s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=8BD853BBFDD9844391296D061B40C3C1/Field/=OK/@KeyIndexDefined">True</s:Boolean>
<s:Int64 x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=8BD853BBFDD9844391296D061B40C3C1/Field/=OK/Order/@EntryValue">0</s:Int64>
<s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=8BD853BBFDD9844391296D061B40C3C1/Field/=RES/@KeyIndexDefined">False</s:Boolean>
<s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=8BD853BBFDD9844391296D061B40C3C1/Reformat/@EntryValue">False</s:Boolean>
<s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=8BD853BBFDD9844391296D061B40C3C1/Scope/=B68999B9D6B43E47A02B22C12A54C3CC/@KeyIndexDefined">True</s:Boolean>
<s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=8BD853BBFDD9844391296D061B40C3C1/Scope/=B68999B9D6B43E47A02B22C12A54C3CC/CustomProperties/=minimumLanguageVersion/@EntryIndexedValue">2.0</s:String>
<s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=8BD853BBFDD9844391296D061B40C3C1/Scope/=B68999B9D6B43E47A02B22C12A54C3CC/Type/@EntryValue">InCSharpTypeMember</s:String>
<s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=8BD853BBFDD9844391296D061B40C3C1/Shortcut/@EntryValue">pres</s:String>
<s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=8BD853BBFDD9844391296D061B40C3C1/ShortenQualifiedReferences/@EntryValue">True</s:Boolean>
<s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=8BD853BBFDD9844391296D061B40C3C1/Text/@EntryValue">[ProducesResults(HttpStatusCode.$OK$)]$SELECTION$$END$</s:String>
<s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=9629E918AE752F4DABD159FCB795983B/@KeyIndexDefined">True</s:Boolean>
<s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=9629E918AE752F4DABD159FCB795983B/Applicability/=Live/@EntryIndexedValue">True</s:Boolean>
<s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=9629E918AE752F4DABD159FCB795983B/Description/@EntryValue">ProducesOkResult</s:String>
<s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=9629E918AE752F4DABD159FCB795983B/Scope/=B68999B9D6B43E47A02B22C12A54C3CC/@KeyIndexDefined">True</s:Boolean>
<s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=9629E918AE752F4DABD159FCB795983B/Scope/=B68999B9D6B43E47A02B22C12A54C3CC/CustomProperties/=minimumLanguageVersion/@EntryIndexedValue">2.0</s:String>
<s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=9629E918AE752F4DABD159FCB795983B/Scope/=B68999B9D6B43E47A02B22C12A54C3CC/Type/@EntryValue">InCSharpTypeMember</s:String>
<s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=9629E918AE752F4DABD159FCB795983B/Shortcut/@EntryValue">pro</s:String>
<s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=9629E918AE752F4DABD159FCB795983B/ShortenQualifiedReferences/@EntryValue">True</s:Boolean>
<s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=9629E918AE752F4DABD159FCB795983B/Text/@EntryValue">[ProducesResults(HttpStatusCode.OK)]$SELECTION$$END$</s:String>
<s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=9E19E30B0BA8D0449B8950655FAC5118/@KeyIndexDefined">True</s:Boolean>
<s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=9E19E30B0BA8D0449B8950655FAC5118/Applicability/=Live/@EntryIndexedValue">True</s:Boolean>
<s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=9E19E30B0BA8D0449B8950655FAC5118/Description/@EntryValue">ProducesOkResult</s:String>
<s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=9E19E30B0BA8D0449B8950655FAC5118/Scope/=B68999B9D6B43E47A02B22C12A54C3CC/@KeyIndexDefined">True</s:Boolean>
<s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=9E19E30B0BA8D0449B8950655FAC5118/Scope/=B68999B9D6B43E47A02B22C12A54C3CC/CustomProperties/=minimumLanguageVersion/@EntryIndexedValue">2.0</s:String>
<s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=9E19E30B0BA8D0449B8950655FAC5118/Scope/=B68999B9D6B43E47A02B22C12A54C3CC/Type/@EntryValue">InCSharpTypeMember</s:String>
<s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=9E19E30B0BA8D0449B8950655FAC5118/Shortcut/@EntryValue">presok</s:String>
<s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=9E19E30B0BA8D0449B8950655FAC5118/ShortenQualifiedReferences/@EntryValue">True</s:Boolean>
<s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=9E19E30B0BA8D0449B8950655FAC5118/Text/@EntryValue">[ProducesResults(HttpStatusCode.OK)]$SELECTION$$END$</s:String>
<s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=B90DFEF27AA100438EDC5CE3D174909B/@KeyIndexDefined">True</s:Boolean>
<s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=B90DFEF27AA100438EDC5CE3D174909B/Applicability/=Live/@EntryIndexedValue">True</s:Boolean>
<s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=B90DFEF27AA100438EDC5CE3D174909B/Description/@EntryValue">OverrideResultType</s:String>
<s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=B90DFEF27AA100438EDC5CE3D174909B/Field/=object/@KeyIndexDefined">True</s:Boolean>
<s:Int64 x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=B90DFEF27AA100438EDC5CE3D174909B/Field/=object/Order/@EntryValue">0</s:Int64>
<s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=B90DFEF27AA100438EDC5CE3D174909B/Scope/=B68999B9D6B43E47A02B22C12A54C3CC/@KeyIndexDefined">True</s:Boolean>
<s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=B90DFEF27AA100438EDC5CE3D174909B/Scope/=B68999B9D6B43E47A02B22C12A54C3CC/CustomProperties/=minimumLanguageVersion/@EntryIndexedValue">2.0</s:String>
<s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=B90DFEF27AA100438EDC5CE3D174909B/Scope/=B68999B9D6B43E47A02B22C12A54C3CC/Type/@EntryValue">InCSharpTypeMember</s:String>
<s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=B90DFEF27AA100438EDC5CE3D174909B/Shortcut/@EntryValue">ort</s:String>
<s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=B90DFEF27AA100438EDC5CE3D174909B/ShortenQualifiedReferences/@EntryValue">True</s:Boolean>
<s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=B90DFEF27AA100438EDC5CE3D174909B/Text/@EntryValue">[OverrideResultType&lt;$object$&gt;]$SELECTION$$END$</s:String>
<s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=D5FAEF0320AE5E47B3F2B73E55C26611/@KeyIndexDefined">True</s:Boolean>
<s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=D5FAEF0320AE5E47B3F2B73E55C26611/Applicability/=Live/@EntryIndexedValue">True</s:Boolean>
<s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=D5FAEF0320AE5E47B3F2B73E55C26611/Description/@EntryValue">ProducesErrors</s:String>
<s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=D5FAEF0320AE5E47B3F2B73E55C26611/Field/=BadRequest/@KeyIndexDefined">True</s:Boolean>
<s:Int64 x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=D5FAEF0320AE5E47B3F2B73E55C26611/Field/=BadRequest/Order/@EntryValue">0</s:Int64>
<s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=D5FAEF0320AE5E47B3F2B73E55C26611/Field/=CODE/@KeyIndexDefined">False</s:Boolean>
<s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=D5FAEF0320AE5E47B3F2B73E55C26611/Field/=ERR/@KeyIndexDefined">False</s:Boolean>
<s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=D5FAEF0320AE5E47B3F2B73E55C26611/Reformat/@EntryValue">False</s:Boolean>
<s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=D5FAEF0320AE5E47B3F2B73E55C26611/Scope/=B68999B9D6B43E47A02B22C12A54C3CC/@KeyIndexDefined">True</s:Boolean>
<s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=D5FAEF0320AE5E47B3F2B73E55C26611/Scope/=B68999B9D6B43E47A02B22C12A54C3CC/CustomProperties/=minimumLanguageVersion/@EntryIndexedValue">2.0</s:String>
<s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=D5FAEF0320AE5E47B3F2B73E55C26611/Scope/=B68999B9D6B43E47A02B22C12A54C3CC/Type/@EntryValue">InCSharpTypeMember</s:String>
<s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=D5FAEF0320AE5E47B3F2B73E55C26611/Shortcut/@EntryValue">perr</s:String>
<s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=D5FAEF0320AE5E47B3F2B73E55C26611/ShortenQualifiedReferences/@EntryValue">True</s:Boolean>
<s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=D5FAEF0320AE5E47B3F2B73E55C26611/Text/@EntryValue">[ProducesErrors(HttpStatusCode.$BadRequest$)]$SELECTION$$END$</s:String>
<s:Boolean x:Key="/Default/UserDictionary/Words/=blurhash/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=blurhash/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Iceshrimp/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=Iceshrimp/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Identicon/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=Identicon/@EntryIndexedValue">True</s:Boolean>

View file

@ -21,7 +21,7 @@ public class HttpSignatureTests
[TestInitialize] [TestInitialize]
public void Initialize() public void Initialize()
{ {
_expanded = LdHelpers.Expand(_actor)!; _expanded = LdHelpers.Expand(_actor);
_expanded.Should().NotBeNull(); _expanded.Should().NotBeNull();
} }

View file

@ -18,7 +18,7 @@ public class LdSignatureTests
[TestInitialize] [TestInitialize]
public async Task Initialize() public async Task Initialize()
{ {
_expanded = LdHelpers.Expand(_actor)!; _expanded = LdHelpers.Expand(_actor);
_signed = await LdSignature.SignAsync(_expanded, _keypair.ExportRSAPrivateKeyPem(), _actor.Id + "#main-key"); _signed = await LdSignature.SignAsync(_expanded, _keypair.ExportRSAPrivateKeyPem(), _actor.Id + "#main-key");
_expanded.Should().NotBeNull(); _expanded.Should().NotBeNull();
@ -47,7 +47,7 @@ public class LdSignatureTests
data.Should().NotBeNull(); data.Should().NotBeNull();
data.Add("https://example.org/ns#test", JToken.FromObject("value")); data.Add("https://example.org/ns#test", JToken.FromObject("value"));
var expanded = LdHelpers.Expand(data)!; var expanded = LdHelpers.Expand(data);
expanded.Should().NotBeNull(); expanded.Should().NotBeNull();
var verify = await LdSignature.VerifyAsync(expanded, expanded, _keypair.ExportRSAPublicKeyPem()); var verify = await LdSignature.VerifyAsync(expanded, expanded, _keypair.ExportRSAPublicKeyPem());

View file

@ -11,7 +11,7 @@ public class JsonLdTests
[TestMethod] [TestMethod]
public void RoundtripTest() public void RoundtripTest()
{ {
var expanded = LdHelpers.Expand(_actor)!; var expanded = LdHelpers.Expand(_actor);
expanded.Should().NotBeNull(); expanded.Should().NotBeNull();
var canonicalized = LdHelpers.Canonicalize(expanded); var canonicalized = LdHelpers.Canonicalize(expanded);
@ -20,11 +20,11 @@ public class JsonLdTests
var compacted = LdHelpers.Compact(expanded); var compacted = LdHelpers.Compact(expanded);
compacted.Should().NotBeNull(); compacted.Should().NotBeNull();
var expanded2 = LdHelpers.Expand(compacted)!; var expanded2 = LdHelpers.Expand(compacted);
expanded2.Should().NotBeNull(); expanded2.Should().NotBeNull();
expanded2.Should().BeEquivalentTo(expanded); expanded2.Should().BeEquivalentTo(expanded);
var compacted2 = LdHelpers.Compact(expanded2)!; var compacted2 = LdHelpers.Compact(expanded2);
compacted2.Should().NotBeNull(); compacted2.Should().NotBeNull();
compacted2.Should().BeEquivalentTo(compacted); compacted2.Should().BeEquivalentTo(compacted);