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

View file

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

View file

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

View file

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

View file

@ -2,8 +2,8 @@ using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
@ -27,17 +27,17 @@ public class AnnouncementController(DatabaseContext db, MfmConverter mfmConverte
{
[HttpGet]
[Authorize]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<AnnouncementEntity>))]
[ProducesResults(HttpStatusCode.OK)]
[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 announcements = db.Announcements.AsQueryable();
if (!withDismissed)
{
announcements = announcements.Where(p => p.IsReadBy(user));
}
var res = await announcements.OrderByDescending(p => p.UpdatedAt ?? p.CreatedAt)
.Select(p => new AnnouncementEntity
@ -56,20 +56,20 @@ public class AnnouncementController(DatabaseContext db, MfmConverter mfmConverte
.ToListAsync();
await res.Select(async p => p.Content = await mfmConverter.ToHtmlAsync(p.Content, [], null)).AwaitAllAsync();
return Ok(res);
return res;
}
[HttpPost("{id}/dismiss")]
[Authorize("write:accounts")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))]
[OverrideResultType<object>]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
[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 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)))
{
@ -84,19 +84,19 @@ public class AnnouncementController(DatabaseContext db, MfmConverter mfmConverte
await db.SaveChangesAsync();
}
return Ok(new object());
return new object();
}
[HttpPut("{id}/reactions/{name}")]
[Authorize("write:favourites")]
[ProducesResponseType(StatusCodes.Status501NotImplemented, Type = typeof(MastodonErrorResponse))]
[ProducesErrors(HttpStatusCode.NotImplemented)]
public IActionResult ReactToAnnouncement(string id, string name) =>
throw new GracefulException(HttpStatusCode.NotImplemented,
"Iceshrimp.NET does not support this endpoint due to database schema differences to Mastodon");
[HttpDelete("{id}/reactions/{name}")]
[Authorize("write:favourites")]
[ProducesResponseType(StatusCodes.Status501NotImplemented, Type = typeof(MastodonErrorResponse))]
[ProducesErrors(HttpStatusCode.NotImplemented)]
public IActionResult RemoveAnnouncementReaction(string id, string name) =>
throw new GracefulException(HttpStatusCode.NotImplemented,
"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 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.Tables;
using Iceshrimp.Backend.Core.Extensions;
@ -11,6 +12,7 @@ using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using static Iceshrimp.Backend.Controllers.Mastodon.Schemas.AuthSchemas;
namespace Iceshrimp.Backend.Controllers.Mastodon;
@ -22,27 +24,24 @@ public class AuthController(DatabaseContext db, MetaService meta) : ControllerBa
{
[HttpGet("/api/v1/apps/verify_credentials")]
[Authenticate]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthSchemas.VerifyAppCredentialsResponse))]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> VerifyAppCredentials()
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.Unauthorized)]
public async Task<VerifyAppCredentialsResponse> VerifyAppCredentials()
{
var token = HttpContext.GetOauthToken();
if (token == null) throw GracefulException.Unauthorized("The access token is invalid");
var token = HttpContext.GetOauthToken() ?? 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)
};
return Ok(res);
}
[HttpPost("/api/v1/apps")]
[EnableRateLimiting("auth")]
[ConsumesHybrid]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthSchemas.RegisterAppResponse))]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> RegisterApp([FromHybrid] AuthSchemas.RegisterAppRequest request)
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.BadRequest)]
public async Task<RegisterAppResponse> RegisterApp([FromHybrid] RegisterAppRequest request)
{
if (request.RedirectUris.Count == 0)
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.SaveChangesAsync();
var res = new AuthSchemas.RegisterAppResponse
{
App = app, VapidKey = await meta.Get(MetaEntity.VapidPublicKey)
};
return Ok(res);
return new RegisterAppResponse { App = app, VapidKey = await meta.Get(MetaEntity.VapidPublicKey) };
}
[HttpPost("/oauth/token")]
[ConsumesHybrid]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthSchemas.OauthTokenResponse))]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> GetOauthToken([FromHybrid] AuthSchemas.OauthTokenRequest request)
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.BadRequest)]
public async Task<OauthTokenResponse> GetOauthToken([FromHybrid] OauthTokenRequest request)
{
//TODO: app-level access (grant_type = "client_credentials")
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 &&
p.App.ClientId == request.ClientId &&
p.App.ClientSecret == request.ClientSecret);
// @formatter:off
if (token == null)
throw GracefulException
.Unauthorized("Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method.");
throw GracefulException.Unauthorized("Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method.");
if (token.Active)
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.");
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.");
// @formatter:on
if (MastodonOauthHelpers.ExpandScopes(request.Scopes ?? [])
.Except(MastodonOauthHelpers.ExpandScopes(token.Scopes))
.Any())
var invalidScope = MastodonOauthHelpers.ExpandScopes(request.Scopes ?? [])
.Except(MastodonOauthHelpers.ExpandScopes(token.Scopes))
.Any();
if (invalidScope)
throw GracefulException.BadRequest("The requested scope is invalid, unknown, or malformed.");
token.Scopes = request.Scopes ?? token.Scopes;
token.Active = true;
await db.SaveChangesAsync();
var res = new AuthSchemas.OauthTokenResponse
return new OauthTokenResponse
{
CreatedAt = token.CreatedAt,
Scopes = token.Scopes,
AccessToken = token.Token
};
return Ok(res);
}
[HttpPost("/oauth/revoke")]
[ConsumesHybrid]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(MastodonErrorResponse))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> RevokeOauthToken([FromHybrid] AuthSchemas.OauthTokenRevocationRequest request)
[OverrideResultType<object>]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.Forbidden)]
public async Task<object> RevokeOauthToken([FromHybrid] OauthTokenRevocationRequest request)
{
var token = await db.OauthTokens.FirstOrDefaultAsync(p => p.Token == request.Token &&
p.App.ClientId == request.ClientId &&
p.App.ClientSecret == request.ClientSecret);
if (token == null)
throw GracefulException.Forbidden("You are not authorized to revoke this token");
p.App.ClientSecret == request.ClientSecret) ??
throw GracefulException.Forbidden("You are not authorized to revoke this token");
db.Remove(token);
await db.SaveChangesAsync();
return Ok(new object());
return new object();
}
}

View file

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

View file

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

View file

@ -1,7 +1,9 @@
using System.Net;
using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Extensions;
@ -21,8 +23,8 @@ namespace Iceshrimp.Backend.Controllers.Mastodon;
public class InstanceController(DatabaseContext db, MetaService meta) : ControllerBase
{
[HttpGet("/api/v1/instance")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(InstanceInfoV1Response))]
public async Task<IActionResult> GetInstanceInfoV1([FromServices] IOptionsSnapshot<Config> config)
[ProducesResults(HttpStatusCode.OK)]
public async Task<InstanceInfoV1Response> GetInstanceInfoV1([FromServices] IOptionsSnapshot<Config> config)
{
var userCount =
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) =
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)
};
return Ok(res);
}
[HttpGet("/api/v2/instance")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(InstanceInfoV2Response))]
public async Task<IActionResult> GetInstanceInfoV2([FromServices] IOptionsSnapshot<Config> config)
[ProducesResults(HttpStatusCode.OK)]
public async Task<InstanceInfoV2Response> GetInstanceInfoV2([FromServices] IOptionsSnapshot<Config> config)
{
var cutoff = DateTime.UtcNow - TimeSpan.FromDays(30);
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) =
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 } }
};
return Ok(res);
}
[HttpGet("/api/v1/custom_emojis")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<EmojiEntity>))]
public async Task<IActionResult> GetCustomEmojis()
[ProducesResults(HttpStatusCode.OK)]
public async Task<IEnumerable<EmojiEntity>> GetCustomEmojis()
{
var res = await db.Emojis.Where(p => p.Host == null)
.Select(p => new EmojiEntity
{
Id = p.Id,
Shortcode = p.Name,
Url = p.PublicUrl,
StaticUrl = p.PublicUrl, //TODO
VisibleInPicker = true,
Category = p.Category
})
.ToListAsync();
return Ok(res);
return await db.Emojis.Where(p => p.Host == null)
.Select(p => new EmojiEntity
{
Id = p.Id,
Shortcode = p.Name,
Url = p.PublicUrl,
StaticUrl = p.PublicUrl, //TODO
VisibleInPicker = true,
Category = p.Category
})
.ToListAsync();
}
[HttpGet("/api/v1/instance/translation_languages")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Dictionary<string, IEnumerable<string>>))]
public IActionResult GetTranslationLanguages()
{
return Ok(new Dictionary<string, IEnumerable<string>>());
}
[ProducesResults(HttpStatusCode.OK)]
public Dictionary<string, IEnumerable<string>> GetTranslationLanguages() => new();
[HttpGet("/api/v1/instance/extended_description")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(InstanceExtendedDescription))]
public async Task<IActionResult> GetExtendedDescription()
[ProducesResults(HttpStatusCode.OK)]
public async Task<InstanceExtendedDescription> GetExtendedDescription()
{
var description = await meta.Get(MetaEntity.InstanceDescription);
var res = new InstanceExtendedDescription(description);
return Ok(res);
return new InstanceExtendedDescription(description);
}
}

View file

@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Controllers.Mastodon.Renderers;
@ -28,51 +29,47 @@ public class ListController(DatabaseContext db, UserRenderer userRenderer, Event
{
[HttpGet]
[Authorize("read:lists")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<ListEntity>))]
public async Task<IActionResult> GetLists()
[ProducesResults(HttpStatusCode.OK)]
public async Task<IEnumerable<ListEntity>> GetLists()
{
var user = HttpContext.GetUserOrFail();
var res = await db.UserLists
.Where(p => p.User == user)
.Select(p => new ListEntity
{
Id = p.Id,
Title = p.Name,
Exclusive = p.HideFromHomeTl
})
.ToListAsync();
return Ok(res);
return await db.UserLists
.Where(p => p.User == user)
.Select(p => new ListEntity
{
Id = p.Id,
Title = p.Name,
Exclusive = p.HideFromHomeTl
})
.ToListAsync();
}
[HttpGet("{id}")]
[Authorize("read:lists")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ListEntity))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> GetList(string id)
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<ListEntity> GetList(string id)
{
var user = HttpContext.GetUserOrFail();
var res = await db.UserLists
.Where(p => p.User == user && p.Id == id)
.Select(p => new ListEntity
{
Id = p.Id,
Title = p.Name,
Exclusive = p.HideFromHomeTl
})
.FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound();
return Ok(res);
return await db.UserLists
.Where(p => p.User == user && p.Id == id)
.Select(p => new ListEntity
{
Id = p.Id,
Title = p.Name,
Exclusive = p.HideFromHomeTl
})
.FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound();
}
[HttpPost]
[Authorize("write:lists")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ListEntity))]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> CreateList([FromHybrid] ListSchemas.ListCreationRequest request)
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.UnprocessableEntity)]
public async Task<ListEntity> CreateList([FromHybrid] ListSchemas.ListCreationRequest request)
{
if (string.IsNullOrWhiteSpace(request.Title))
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.SaveChangesAsync();
var res = new ListEntity
return new ListEntity
{
Id = list.Id,
Title = list.Name,
Exclusive = list.HideFromHomeTl
};
return Ok(res);
}
[HttpPut("{id}")]
[Authorize("write:lists")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ListEntity))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> UpdateList(string id, [FromHybrid] ListSchemas.ListUpdateRequest request)
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound, HttpStatusCode.UnprocessableEntity)]
public async Task<ListEntity> UpdateList(string id, [FromHybrid] ListSchemas.ListUpdateRequest request)
{
var user = HttpContext.GetUserOrFail();
var list = await db.UserLists
@ -121,20 +116,20 @@ public class ListController(DatabaseContext db, UserRenderer userRenderer, Event
db.Update(list);
await db.SaveChangesAsync();
var res = new ListEntity
return new ListEntity
{
Id = list.Id,
Title = list.Name,
Exclusive = list.HideFromHomeTl
};
return Ok(res);
}
[HttpDelete("{id}")]
[Authorize("write:lists")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> DeleteList(string id)
[OverrideResultType<object>]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<object> DeleteList(string id)
{
var user = HttpContext.GetUserOrFail();
var list = await db.UserLists
@ -145,15 +140,15 @@ public class ListController(DatabaseContext db, UserRenderer userRenderer, Event
db.Remove(list);
await db.SaveChangesAsync();
eventSvc.RaiseListMembersUpdated(this, list);
return Ok(new object());
return new object();
}
[LinkPagination(40, 80)]
[HttpGet("{id}/accounts")]
[Authorize("read:lists")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<AccountEntity>))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> GetListMembers(string id, MastodonPaginationQuery pq)
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<List<AccountEntity>> GetListMembers(string id, MastodonPaginationQuery pq)
{
var user = HttpContext.GetUserOrFail();
var list = await db.UserLists
@ -161,7 +156,7 @@ public class ListController(DatabaseContext db, UserRenderer userRenderer, Event
.FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound();
var res = pq.Limit == 0
return pq.Limit == 0
? await db.UserListMembers
.Where(p => p.UserList == list)
.Include(p => p.User.UserProfile)
@ -173,17 +168,15 @@ public class ListController(DatabaseContext db, UserRenderer userRenderer, Event
.Include(p => p.User.UserProfile)
.Select(p => p.User)
.RenderAllForMastodonAsync(userRenderer);
return Ok(res);
}
[HttpPost("{id}/accounts")]
[Authorize("write:lists")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity, Type = typeof(MastodonErrorResponse))]
[OverrideResultType<object>]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound, HttpStatusCode.UnprocessableEntity)]
[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 list = await db.UserLists
@ -214,14 +207,15 @@ public class ListController(DatabaseContext db, UserRenderer userRenderer, Event
eventSvc.RaiseListMembersUpdated(this, list);
return Ok(new object());
return new object();
}
[HttpDelete("{id}/accounts")]
[Authorize("write:lists")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> RemoveListMember(
[OverrideResultType<object>]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<object> RemoveListMember(
string id, [FromHybrid] ListSchemas.ListUpdateMembersRequest request
)
{
@ -237,6 +231,6 @@ public class ListController(DatabaseContext db, UserRenderer userRenderer, Event
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.Schemas;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
@ -24,28 +25,29 @@ public class MarkerController(DatabaseContext db) : ControllerBase
{
[HttpGet]
[Authorize("read:statuses")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Dictionary<string, MarkerEntity>))]
public async Task<IActionResult> GetMarkers([FromQuery(Name = "timeline")] List<string> types)
[ProducesResults(HttpStatusCode.OK)]
public async Task<Dictionary<string, MarkerEntity>> GetMarkers([FromQuery(Name = "timeline")] List<string> types)
{
var user = HttpContext.GetUserOrFail();
var markers = await db.Markers.Where(p => p.User == user && types.Select(DecodeType).Contains(p.Type))
.ToListAsync();
var res = markers.ToDictionary(p => EncodeType(p.Type),
p => new MarkerEntity
{
Position = p.Position,
Version = p.Version,
UpdatedAt = p.LastUpdatedAt.ToStringIso8601Like()
});
return Ok(res);
return markers.ToDictionary(p => EncodeType(p.Type),
p => new MarkerEntity
{
Position = p.Position,
Version = p.Version,
UpdatedAt = p.LastUpdatedAt.ToStringIso8601Like()
});
}
[HttpPost]
[Authorize("write:statuses")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Dictionary<string, MarkerEntity>))]
public async Task<IActionResult> SetMarkers([FromHybrid] Dictionary<string, MarkerSchemas.MarkerPosition> request)
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.Conflict)]
public async Task<Dictionary<string, MarkerEntity>> SetMarkers(
[FromHybrid] Dictionary<string, MarkerSchemas.MarkerPosition> request
)
{
var user = HttpContext.GetUserOrFail();
try

View file

@ -1,8 +1,10 @@
using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
@ -21,13 +23,12 @@ namespace Iceshrimp.Backend.Controllers.Mastodon;
[EnableCors("mastodon")]
[EnableRateLimiting("sliding")]
[Produces(MediaTypeNames.Application.Json)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AttachmentEntity))]
public class MediaController(DriveService driveSvc, DatabaseContext db) : ControllerBase
{
[HttpPost("/api/v1/media")]
[HttpPost("/api/v2/media")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AttachmentEntity))]
public async Task<IActionResult> UploadAttachment(MediaSchemas.UploadMediaRequest request)
[ProducesResults(HttpStatusCode.OK)]
public async Task<AttachmentEntity> UploadAttachment(MediaSchemas.UploadMediaRequest request)
{
var user = HttpContext.GetUserOrFail();
var rq = new DriveFileCreationRequest
@ -38,15 +39,15 @@ public class MediaController(DriveService driveSvc, DatabaseContext db) : Contro
MimeType = request.File.ContentType
};
var file = await driveSvc.StoreFile(request.File.OpenReadStream(), user, rq);
var res = RenderAttachment(file);
return Ok(res);
return RenderAttachment(file);
}
[HttpPut("/api/v1/media/{id}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AttachmentEntity))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> UpdateAttachment(string id, [FromHybrid] MediaSchemas.UpdateMediaRequest request)
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<AttachmentEntity> UpdateAttachment(
string id, [FromHybrid] MediaSchemas.UpdateMediaRequest request
)
{
var user = HttpContext.GetUserOrFail();
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;
await db.SaveChangesAsync();
var res = RenderAttachment(file);
return Ok(res);
return RenderAttachment(file);
}
[HttpGet("/api/v1/media/{id}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AttachmentEntity))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> GetAttachment(string id)
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<AttachmentEntity> GetAttachment(string id)
{
var user = HttpContext.GetUserOrFail();
var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.Id == id && p.User == user) ??
throw GracefulException.RecordNotFound();
var res = RenderAttachment(file);
return Ok(res);
return RenderAttachment(file);
}
[HttpPut("/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) =>
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 Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Controllers.Mastodon.Renderers;
@ -26,41 +27,39 @@ public class NotificationController(DatabaseContext db, NotificationRenderer not
[HttpGet]
[Authorize("read:notifications")]
[LinkPagination(40, 80)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<NotificationEntity>))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> GetNotifications(
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<List<NotificationEntity>> GetNotifications(
MastodonPaginationQuery query, NotificationSchemas.GetNotificationsRequest request
)
{
var user = HttpContext.GetUserOrFail();
var res = await db.Notifications
.IncludeCommonProperties()
.Where(p => p.Notifiee == user)
.Where(p => p.Notifier != null)
.Where(p => p.Type == NotificationType.Follow ||
p.Type == NotificationType.Mention ||
p.Type == NotificationType.Reply ||
p.Type == NotificationType.Renote ||
p.Type == NotificationType.Quote ||
p.Type == NotificationType.Like ||
p.Type == NotificationType.PollEnded ||
p.Type == NotificationType.FollowRequestReceived ||
p.Type == NotificationType.Edit)
.FilterByGetNotificationsRequest(request)
.EnsureNoteVisibilityFor(p => p.Note, user)
.FilterHiddenNotifications(user, db)
.Paginate(p => p.MastoId, query, ControllerContext)
.PrecomputeNoteVisibilities(user)
.RenderAllForMastodonAsync(notificationRenderer, user);
return Ok(res);
return await db.Notifications
.IncludeCommonProperties()
.Where(p => p.Notifiee == user)
.Where(p => p.Notifier != null)
.Where(p => p.Type == NotificationType.Follow ||
p.Type == NotificationType.Mention ||
p.Type == NotificationType.Reply ||
p.Type == NotificationType.Renote ||
p.Type == NotificationType.Quote ||
p.Type == NotificationType.Like ||
p.Type == NotificationType.PollEnded ||
p.Type == NotificationType.FollowRequestReceived ||
p.Type == NotificationType.Edit)
.FilterByGetNotificationsRequest(request)
.EnsureNoteVisibilityFor(p => p.Note, user)
.FilterHiddenNotifications(user, db)
.Paginate(p => p.MastoId, query, ControllerContext)
.PrecomputeNoteVisibilities(user)
.RenderAllForMastodonAsync(notificationRenderer, user);
}
[HttpGet("{id:long}")]
[Authorize("read:notifications")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<NotificationEntity>))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> GetNotification(long id)
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<NotificationEntity> GetNotification(long id)
{
var user = HttpContext.GetUserOrFail();
var notification = await db.Notifications
@ -72,7 +71,6 @@ public class NotificationController(DatabaseContext db, NotificationRenderer not
throw GracefulException.RecordNotFound();
var res = await notificationRenderer.RenderAsync(notification.EnforceRenoteReplyVisibility(p => p.Note), user);
return Ok(res);
return res;
}
}

View file

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

View file

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

View file

@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Net.Mime;
using System.Text.RegularExpressions;
using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
@ -37,38 +38,36 @@ public class SearchController(
[HttpGet("/api/v2/search")]
[Authorize("read:search")]
[LinkPagination(20, 40)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(SearchSchemas.SearchResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> Search(SearchSchemas.SearchRequest search, MastodonPaginationQuery pagination)
{
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(
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<SearchSchemas.SearchResponse> Search(
SearchSchemas.SearchRequest search, MastodonPaginationQuery pagination
)
{
if (search.Query == null)
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",

View file

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

View file

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

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

View file

@ -1,5 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Controllers.Web.Renderers;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
@ -22,34 +24,31 @@ public class AuthController(DatabaseContext db, UserService userSvc, UserRendere
{
[HttpGet]
[Authenticate]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthResponse))]
public async Task<IActionResult> GetAuthStatus()
[ProducesResults(HttpStatusCode.OK)]
public async Task<AuthResponse> GetAuthStatus()
{
var session = HttpContext.GetSession();
if (session == null) return new AuthResponse { Status = AuthStatusEnum.Guest };
if (session == null)
return Ok(new AuthResponse { Status = AuthStatusEnum.Guest });
return Ok(new AuthResponse
return new AuthResponse
{
Status = session.Active ? AuthStatusEnum.Authenticated : AuthStatusEnum.TwoFactor,
Token = session.Token,
IsAdmin = session.User.IsAdmin,
IsModerator = session.User.IsModerator,
User = await userRenderer.RenderOne(session.User)
});
};
}
[HttpPost("login")]
[HideRequestDuration]
[EnableRateLimiting("auth")]
[Consumes(MediaTypeNames.Application.Json)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthResponse))]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(ErrorResponse))]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.Forbidden)]
[SuppressMessage("ReSharper.DPA", "DPA0011: High execution time of MVC action",
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 &&
p.UsernameLower == request.Username.ToLowerInvariant());
@ -76,24 +75,22 @@ public class AuthController(DatabaseContext db, UserService userSvc, UserRendere
await db.SaveChangesAsync();
}
return Ok(new AuthResponse
return new AuthResponse
{
Status = session.Active ? AuthStatusEnum.Authenticated : AuthStatusEnum.TwoFactor,
Token = session.Token,
IsAdmin = session.User.IsAdmin,
IsModerator = session.User.IsModerator,
User = await userRenderer.RenderOne(user)
});
};
}
[HttpPost("register")]
[EnableRateLimiting("auth")]
[Consumes(MediaTypeNames.Application.Json)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthResponse))]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(ErrorResponse))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(ErrorResponse))]
public async Task<IActionResult> Register([FromBody] RegistrationRequest request)
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden)]
public async Task<AuthResponse> Register([FromBody] RegistrationRequest request)
{
//TODO: captcha support
@ -106,11 +103,11 @@ public class AuthController(DatabaseContext db, UserService userSvc, UserRendere
[Authorize]
[EnableRateLimiting("auth")]
[Consumes(MediaTypeNames.Application.Json)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthResponse))]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.BadRequest)]
[SuppressMessage("ReSharper.DPA", "DPA0011: High execution time of MVC action",
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 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 Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Middleware;
@ -17,7 +18,6 @@ namespace Iceshrimp.Backend.Controllers.Web;
public class DriveController(
DatabaseContext db,
ObjectStorageService objectStorage,
[SuppressMessage("ReSharper", "SuggestBaseTypeForParameterInConstructor")]
IOptionsSnapshot<Config.StorageSection> options,
ILogger<DriveController> logger,
DriveService driveSvc
@ -25,6 +25,8 @@ public class DriveController(
{
[EnableCors("drive")]
[HttpGet("/files/{accessKey}")]
[ProducesResults(HttpStatusCode.OK, HttpStatusCode.NoContent)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IActionResult> GetFileByAccessKey(string accessKey)
{
var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.AccessKey == accessKey ||
@ -33,7 +35,7 @@ public class DriveController(
if (file == null)
{
Response.Headers.CacheControl = "max-age=86400";
return NotFound();
throw GracefulException.NotFound("File not found");
}
if (file.StoredInternal)
@ -42,7 +44,7 @@ public class DriveController(
if (string.IsNullOrWhiteSpace(pathBase))
{
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);
@ -64,7 +66,7 @@ public class DriveController(
if (stream == null)
{
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";
@ -76,8 +78,9 @@ public class DriveController(
[HttpPost]
[Authenticate]
[Authorize]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DriveFileResponse))]
public async Task<IActionResult> UploadFile(IFormFile file)
[Produces(MediaTypeNames.Application.Json)]
[ProducesResults(HttpStatusCode.OK)]
public async Task<DriveFileResponse> UploadFile(IFormFile file)
{
var user = HttpContext.GetUserOrFail();
var request = new DriveFileCreationRequest
@ -93,15 +96,16 @@ public class DriveController(
[HttpGet("{id}")]
[Authenticate]
[Authorize]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DriveFileResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))]
public async Task<IActionResult> GetFileById(string id)
[Produces(MediaTypeNames.Application.Json)]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<DriveFileResponse> GetFileById(string id)
{
var user = HttpContext.GetUserOrFail();
var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Id == id);
if (file == null) return NotFound();
var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Id == id) ??
throw GracefulException.NotFound("File not found");
var res = new DriveFileResponse
return new DriveFileResponse
{
Id = file.Id,
Url = file.PublicUrl,
@ -111,21 +115,20 @@ public class DriveController(
Description = file.Comment,
Sensitive = file.IsSensitive
};
return Ok(res);
}
[HttpPatch("{id}")]
[Authenticate]
[Authorize]
[Consumes(MediaTypeNames.Application.Json)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DriveFileResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))]
public async Task<IActionResult> UpdateFile(string id, UpdateDriveFileRequest request)
[Produces(MediaTypeNames.Application.Json)]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<DriveFileResponse> UpdateFile(string id, UpdateDriveFileRequest request)
{
var user = HttpContext.GetUserOrFail();
var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Id == id);
if (file == null) return NotFound();
var file = await db.DriveFiles.FirstOrDefaultAsync(p => p.User == user && p.Id == id) ??
throw GracefulException.NotFound("File not found");
file.Name = request.Filename ?? file.Name;
file.IsSensitive = request.Sensitive ?? file.IsSensitive;

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,6 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Net;
using System.Net.Mime;
using AsyncKeyedLock;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
@ -37,9 +38,9 @@ public class NoteController(
[HttpGet("{id}")]
[Authenticate]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(NoteResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))]
public async Task<IActionResult> GetNote(string id)
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<NoteResponse> GetNote(string id)
{
var user = HttpContext.GetUser();
var note = await db.Notes.Where(p => p.Id == id)
@ -50,29 +51,28 @@ public class NoteController(
.FirstOrDefaultAsync() ??
throw GracefulException.NotFound("Note not found");
return Ok(await noteRenderer.RenderOne(note.EnforceRenoteReplyVisibility(), user));
return await noteRenderer.RenderOne(note.EnforceRenoteReplyVisibility(), user);
}
[HttpDelete("{id}")]
[Authenticate]
[Authorize]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))]
public async Task<IActionResult> DeleteNote(string id)
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task DeleteNote(string id)
{
var user = HttpContext.GetUserOrFail();
var note = await db.Notes.FirstOrDefaultAsync(p => p.Id == id && p.User == user) ??
throw GracefulException.NotFound("Note not found");
await noteSvc.DeleteNoteAsync(note);
return Ok();
}
[HttpGet("{id}/ascendants")]
[Authenticate]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<NoteResponse>))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))]
public async Task<IActionResult> GetNoteAscendants(
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IEnumerable<NoteResponse>> GetNoteAscendants(
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,
Filter.FilterContext.Threads);
return Ok(res.ToList().OrderAncestors());
return res.ToList().OrderAncestors();
}
[HttpGet("{id}/descendants")]
[Authenticate]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<NoteResponse>))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))]
public async Task<IActionResult> GetNoteDescendants(
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IEnumerable<NoteResponse>> GetNoteDescendants(
string id, [FromQuery] [DefaultValue(20)] [Range(1, 100)] int? depth
)
{
@ -123,16 +123,16 @@ public class NoteController(
var notes = hits.EnforceRenoteReplyVisibility();
var res = await noteRenderer.RenderMany(notes, user, Filter.FilterContext.Threads);
return Ok(res.ToList().OrderDescendants());
return res.ToList().OrderDescendants();
}
[HttpGet("{id}/reactions/{name}")]
[Authenticate]
[Authorize]
[LinkPagination(20, 40)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<UserResponse>))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))]
public async Task<IActionResult> GetNoteReactions(string id, string name)
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<IEnumerable<UserResponse>> GetNoteReactions(string id, string name)
{
var user = HttpContext.GetUser();
var note = await db.Notes
@ -147,15 +147,15 @@ public class NoteController(
.Select(p => p.User)
.ToListAsync();
return Ok(await userRenderer.RenderMany(users));
return await userRenderer.RenderMany(users);
}
[HttpPost("{id}/like")]
[Authenticate]
[Authorize]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ValueResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))]
public async Task<IActionResult> LikeNote(string id)
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<ValueResponse> LikeNote(string id)
{
var user = HttpContext.GetUserOrFail();
var note = await db.Notes
@ -167,15 +167,15 @@ public class NoteController(
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")]
[Authenticate]
[Authorize]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ValueResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))]
public async Task<IActionResult> UnlikeNote(string id)
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<ValueResponse> UnlikeNote(string id)
{
var user = HttpContext.GetUserOrFail();
var note = await db.Notes
@ -187,15 +187,15 @@ public class NoteController(
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")]
[Authenticate]
[Authorize]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ValueResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))]
public async Task<IActionResult> RenoteNote(string id, [FromQuery] NoteVisibility? visibility = null)
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<ValueResponse> RenoteNote(string id, [FromQuery] NoteVisibility? visibility = null)
{
var user = HttpContext.GetUserOrFail();
var note = await db.Notes
@ -206,15 +206,15 @@ public class NoteController(
throw GracefulException.NotFound("Note not found");
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")]
[Authenticate]
[Authorize]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ValueResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))]
public async Task<IActionResult> UnrenoteNote(string id)
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<ValueResponse> UnrenoteNote(string id)
{
var user = HttpContext.GetUserOrFail();
var note = await db.Notes
@ -225,15 +225,15 @@ public class NoteController(
throw GracefulException.NotFound("Note not found");
var count = await noteSvc.UnrenoteNoteAsync(note, user);
return Ok(new ValueResponse(note.RenoteCount - count));
return new ValueResponse(note.RenoteCount - count);
}
[HttpPost("{id}/react/{name}")]
[Authenticate]
[Authorize]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ValueResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))]
public async Task<IActionResult> ReactToNote(string id, string name)
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<ValueResponse> ReactToNote(string id, string name)
{
var user = HttpContext.GetUserOrFail();
var note = await db.Notes
@ -245,15 +245,15 @@ public class NoteController(
var res = await noteSvc.ReactToNoteAsync(note, user, name);
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}")]
[Authenticate]
[Authorize]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ValueResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))]
public async Task<IActionResult> RemoveReactionFromNote(string id, string name)
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<ValueResponse> RemoveReactionFromNote(string id, string name)
{
var user = HttpContext.GetUserOrFail();
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);
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")]
[Authenticate]
[Authorize]
[EnableRateLimiting("strict")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(NoteRefetchResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))]
public async Task<IActionResult> RefetchNote(string id)
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<NoteRefetchResponse> RefetchNote(string id)
{
var user = HttpContext.GetUserOrFail();
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() ??
throw new Exception("Note disappeared during refetch");
var res = new NoteRefetchResponse
return new NoteRefetchResponse
{
Note = await noteRenderer.RenderOne(note.EnforceRenoteReplyVisibility(), user), Errors = errors
};
return Ok(res);
}
[HttpPost]
[Authenticate]
[Authorize]
[Consumes(MediaTypeNames.Application.Json)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(NoteResponse))]
public async Task<IActionResult> CreateNote(NoteCreateRequest request)
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<NoteResponse> CreateNote(NoteCreateRequest request)
{
var user = HttpContext.GetUserOrFail();
@ -397,6 +397,6 @@ public class NoteController(
if (request.IdempotencyKey != null)
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 Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Controllers.Shared.Schemas;
@ -22,8 +23,8 @@ public class NotificationController(DatabaseContext db, NotificationRenderer not
{
[HttpGet]
[LinkPagination(20, 80)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<NotificationResponse>))]
public async Task<IActionResult> GetNotifications(PaginationQuery query)
[ProducesResults(HttpStatusCode.OK)]
public async Task<IEnumerable<NotificationResponse>> GetNotifications(PaginationQuery query)
{
var user = HttpContext.GetUserOrFail();
var notifications = await db.Notifications
@ -35,13 +36,13 @@ public class NotificationController(DatabaseContext db, NotificationRenderer not
.PrecomputeNoteVisibilities(user)
.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")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))]
public async Task<IActionResult> MarkNotificationAsRead(string id)
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task MarkNotificationAsRead(string id)
{
var user = HttpContext.GetUserOrFail();
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;
await db.SaveChangesAsync();
}
return Ok(new object());
}
[HttpPost("read")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))]
public async Task<IActionResult> MarkAllNotificationsAsRead()
[ProducesResults(HttpStatusCode.OK)]
public async Task MarkAllNotificationsAsRead()
{
var user = HttpContext.GetUserOrFail();
await db.Notifications.Where(p => p.Notifiee == user && !p.IsRead)
.ExecuteUpdateAsync(p => p.SetProperty(n => n.IsRead, true));
return Ok(new object());
}
[HttpDelete("{id}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))]
public async Task<IActionResult> DeleteNotification(string id)
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task DeleteNotification(string id)
{
var user = HttpContext.GetUserOrFail();
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);
await db.SaveChangesAsync();
return Ok(new object());
}
[HttpDelete]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))]
public async Task<IActionResult> DeleteAllNotifications()
[ProducesResults(HttpStatusCode.OK)]
public async Task DeleteAllNotifications()
{
var user = HttpContext.GetUserOrFail();
await db.Notifications.Where(p => p.Notifiee == user)
.ExecuteDeleteAsync();
return Ok(new object());
}
}

View file

@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Controllers.Shared.Schemas;
@ -30,13 +31,14 @@ public class SearchController(
UserRenderer userRenderer,
ActivityPub.UserResolver userResolver,
IOptions<Config.InstanceSection> config
)
: ControllerBase
) : ControllerBase
{
[HttpGet("notes")]
[LinkPagination(20, 80)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<NoteResponse>))]
public async Task<IActionResult> SearchNotes([FromQuery(Name = "q")] string query, PaginationQuery pagination)
[ProducesResults(HttpStatusCode.OK)]
public async Task<IEnumerable<NoteResponse>> SearchNotes(
[FromQuery(Name = "q")] string query, PaginationQuery pagination
)
{
var user = HttpContext.GetUserOrFail();
var notes = await db.Notes
@ -48,15 +50,17 @@ public class SearchController(
.PrecomputeVisibilities(user)
.ToListAsync();
return Ok(await noteRenderer.RenderMany(notes.EnforceRenoteReplyVisibility(), user));
return await noteRenderer.RenderMany(notes.EnforceRenoteReplyVisibility(), user);
}
[HttpGet("users")]
[LinkPagination(20, 80)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<UserResponse>))]
[ProducesResults(HttpStatusCode.OK)]
[SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall",
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
.IncludeCommonProperties()
@ -66,14 +70,13 @@ public class SearchController(
.OrderByDescending(p => p.NotesCount)
.ToListAsync();
return Ok(await userRenderer.RenderMany(users));
return await userRenderer.RenderMany(users);
}
[HttpGet("lookup")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RedirectResponse))]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ErrorResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))]
public async Task<IActionResult> Lookup([FromQuery(Name = "target")] string target)
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound)]
public async Task<RedirectResponse> Lookup([FromQuery(Name = "target")] string target)
{
target = target.Trim();
@ -85,7 +88,7 @@ public class SearchController(
if (target.StartsWith('@') || target.StartsWith(userPrefixAlt))
{
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");
}
@ -112,18 +115,18 @@ public class SearchController(
}
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 ||
(p.UserProfile != null &&
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);
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);
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");
}

View file

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

View file

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

View file

@ -2,8 +2,12 @@ using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using Iceshrimp.Backend.Controllers.Federation.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.Shared.Schemas.Web;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.OpenApi.Any;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
@ -18,8 +22,11 @@ public static class SwaggerGenOptionsExtensions
options.SupportNonNullableReferenceTypes(); // Sets Nullable flags appropriately.
options.UseAllOfToExtendReferenceSchemas(); // Allows $ref enums to be nullable
options.UseAllOfForInheritance(); // Allows $ref objects to be nullable
options.OperationFilter<AuthorizeCheckOperationFilter>();
options.OperationFilter<AuthorizeCheckOperationDocumentFilter>();
options.OperationFilter<HybridRequestOperationFilter>();
options.OperationFilter<PossibleErrorsOperationFilter>();
options.OperationFilter<PossibleResultsOperationFilter>();
options.DocumentFilter<AuthorizeCheckOperationDocumentFilter>();
options.DocInclusionPredicate(DocInclusionPredicate);
}
@ -63,8 +70,91 @@ public static class SwaggerGenOptionsExtensions
[SuppressMessage("ReSharper", "ClassNeverInstantiated.Local",
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)
{
if (context.MethodInfo.DeclaringType is null)
@ -102,69 +192,125 @@ public static class SwaggerGenOptionsExtensions
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.Add("401", res401);
operation.Responses.Add("401", new OpenApiResponse { Reference = Ref401 });
if (authorizeAttribute is { AdminRole: false, ModeratorRole: false, Scopes.Length: 0 } &&
authenticateAttribute is { AdminRole: false, ModeratorRole: false, Scopes.Length: 0 })
return;
operation.Responses.Remove("403");
operation.Responses.Add("403", new OpenApiResponse { Reference = Ref403 });
}
var example403 = new OpenApiString(isMastodonController ? masto403 : web403);
var res403 = new OpenApiResponse
public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
{
if (swaggerDoc.Info.Title == "Mastodon")
{
Description = "Forbidden",
Content = new Dictionary<string, OpenApiMediaType>
swaggerDoc.Components.Responses.Add(Ref401.Id, MastoRes401);
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 } }
}
};
operation.Responses.Add("403", res403);
Description = ReasonPhrases.GetReasonPhrase((int)status),
Content = new Dictionary<string, OpenApiMediaType>
{
{ "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;
}
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);
}
public static JArray? Expand(JToken? json)
public static JArray Expand(JToken? json)
{
return JsonLdProcessor.Expand(json, Options);
}

View file

@ -77,13 +77,13 @@ public static class LdSignature
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");
}
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)

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_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/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/=Iceshrimp/@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]
public void Initialize()
{
_expanded = LdHelpers.Expand(_actor)!;
_expanded = LdHelpers.Expand(_actor);
_expanded.Should().NotBeNull();
}

View file

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

View file

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