Owner decision 2026-10-04: fix the mismatches and every other mismatch of the same kind.
- One counting rule (Domain/Privacy/Counted), Mastodon's. It is used for a persona's statuses_count, its outbox
totalItems, NodeInfo localPosts and the instance status_count, which used to count four different things. It
counts every post that is neither deleted nor a DM, boosts included, and circle and located posts too (owner
decision). A group's count includes its remote members' posts.
- Users. Personas of banned or deleted roots no longer count, and are not found in search. NodeInfo now gives
activeMonth and activeHalfyear, and the v2 instance gives active_month instead of a constant 0.
- replies_count counts only public and unlisted replies, so it no longer tells anyone that a private reply exists.
Migration _012 recounts it.
- A remote account that deletes itself takes everything out of every count (GoneActors): its likes, downvotes,
reactions and poll votes go and their counters come back, as do its boosts', replies' and quotes' counts, and its
notifications. Lookups, account lists, search and favourited_by no longer show it. Migration _012 applies this to
accounts already gone.
- Deleting a post also deletes its pins and the local boosts of it.
- /stalking gives the same total as following_count. Members are still never listed, and hide_collections is now
always true, since the setting never did anything.
- Joining a community by invitation is following it, so /flock and /groupies agree; leaving unfollows.
- Search. Anyone may search, as on Mastodon; resolve and offset need a sign-in, offset pages, and deleted accounts
are never found.
- notifications/unread_count counts what the list shows, and the owner's follower and following lists page with
Link.
- The instance API advertises what is enforced:
- max_characters, now enforced with a 422;
- max_pinned_statuses = MaxPins;
- the media types and limits MediaService and MediaOptions accept;
- PollService's limits;
- the configured languages;
- no streaming URL until streaming exists.
domain_count counts the servers we have exchanged with; which ones stays unpublished (peers is empty).
- Routes Mastodon answers now answer instead of 404:
- directory, tags/{name}, timelines/link and identity_proofs;
- instance/languages, translation_languages, domain_blocks and privacy_policy;
- the v1 and v2 notification policy, and notification requests.
Also, from phase 3: a recovered password ends /clientapi sessions through a per-root SessionStamp claim instead of
comparing the JWT's whole-second nbf with the change time. That comparison let a token issued in the same second
survive, which made a test flaky.
671 tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
134 lines
4.8 KiB
C#
134 lines
4.8 KiB
C#
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Models.Federation;
|
|
using PrivaPub.Models.Post;
|
|
using PrivaPub.Models.User;
|
|
using PrivaPub.StaticServices;
|
|
|
|
using System.Text.Json.Nodes;
|
|
|
|
using PostEntity = PrivaPub.Models.Post.Post;
|
|
using PrivaPub.Domain.Privacy;
|
|
using PrivaPub.Federation.Actors;
|
|
using PrivaPub.Infrastructure;
|
|
|
|
namespace PrivaPub.Federation.Controllers
|
|
{
|
|
[ApiController]
|
|
public class WellKnownController : ControllerBase
|
|
{
|
|
readonly ILocalActorService _localActors;
|
|
readonly DbEntities _dbEntities;
|
|
readonly IOptionsMonitor<RegistrationOptions> _registrations;
|
|
|
|
public WellKnownController(ILocalActorService localActors, DbEntities dbEntities, IOptionsMonitor<RegistrationOptions> registrations)
|
|
{
|
|
_registrations = registrations;
|
|
_localActors = localActors;
|
|
_dbEntities = dbEntities;
|
|
}
|
|
|
|
[HttpGet, Route("/.well-known/webfinger")]
|
|
public async Task<IActionResult> WebFinger([FromQuery] string resource, CancellationToken token)
|
|
{
|
|
if (string.IsNullOrEmpty(resource))
|
|
return BadRequest();
|
|
|
|
LocalActor actor;
|
|
// Mastodon, GoToSocial and Pleroma also take a bare user@domain or @user@domain, so we do too.
|
|
var acct = resource.StartsWith("acct:", StringComparison.OrdinalIgnoreCase) ? resource[5..]
|
|
: !resource.Contains("://", StringComparison.Ordinal) && resource.Contains('@') ? resource
|
|
: default;
|
|
if (acct != default)
|
|
{
|
|
var parts = acct.TrimStart('@').Split('@');
|
|
var domain = new Uri(_localActors.BaseAddress).Authority;
|
|
if (parts.Length != 2 || !parts[1].Equals(domain, StringComparison.OrdinalIgnoreCase))
|
|
return NotFound();
|
|
actor = await _localActors.FindByUserName(parts[0], token);
|
|
if (actor == default && await _localActors.Gone(parts[0], token) != default)
|
|
return StatusCode(StatusCodes.Status410Gone);
|
|
}
|
|
else
|
|
actor = await _localActors.FindByUri(resource, token);
|
|
|
|
if (actor is not { IsFederated: true })
|
|
return NotFound();
|
|
|
|
var document = new JsonObject
|
|
{
|
|
["subject"] = $"acct:{actor.Handle}",
|
|
["aliases"] = actor.Kind == LocalActorKind.Application ? new JsonArray(actor.Uri) : new JsonArray(actor.HtmlUrl, actor.Uri),
|
|
["links"] = actor.Kind == LocalActorKind.Application
|
|
? new JsonArray(new JsonObject { ["rel"] = "self", ["type"] = "application/activity+json", ["href"] = actor.Uri })
|
|
: new JsonArray(
|
|
new JsonObject { ["rel"] = "http://webfinger.net/rel/profile-page", ["type"] = "text/html", ["href"] = actor.HtmlUrl },
|
|
new JsonObject { ["rel"] = "self", ["type"] = "application/activity+json", ["href"] = actor.Uri })
|
|
};
|
|
return Content(document.ToJsonString(), "application/jrd+json; charset=utf-8");
|
|
}
|
|
|
|
[HttpGet, Route("/.well-known/nodeinfo")]
|
|
public IActionResult NodeInfoLinks()
|
|
{
|
|
var document = new JsonObject
|
|
{
|
|
["links"] = new JsonArray(
|
|
new JsonObject
|
|
{
|
|
["rel"] = "http://nodeinfo.diaspora.software/ns/schema/2.1",
|
|
["href"] = $"{_localActors.BaseAddress}/nodeinfo/2.1"
|
|
},
|
|
new JsonObject
|
|
{
|
|
["rel"] = "http://nodeinfo.diaspora.software/ns/schema/2.0",
|
|
["href"] = $"{_localActors.BaseAddress}/nodeinfo/2.0"
|
|
})
|
|
};
|
|
return Content(document.ToJsonString(), "application/json; charset=utf-8");
|
|
}
|
|
|
|
[HttpGet, Route("/nodeinfo/{version:regex(^2\\.[[01]]$)}")]
|
|
public async Task<IActionResult> NodeInfo(string version, CancellationToken token)
|
|
{
|
|
var users = await Counted.Users(token);
|
|
var posts = await Counted.LocalPosts(token);
|
|
var software = new JsonObject { ["name"] = "privapub", ["version"] = BuildInfo.Ref };
|
|
if (version == "2.1")
|
|
{
|
|
software["repository"] = "https://git.thepra.dev/thepra/SocialPub";
|
|
software["homepage"] = "https://git.thepra.dev/thepra/SocialPub";
|
|
}
|
|
var document = new JsonObject
|
|
{
|
|
["version"] = version,
|
|
["software"] = software,
|
|
["protocols"] = new JsonArray("activitypub"),
|
|
["services"] = new JsonObject { ["inbound"] = new JsonArray(), ["outbound"] = new JsonArray() },
|
|
["openRegistrations"] = _registrations.CurrentValue.IsOpen,
|
|
["usage"] = new JsonObject
|
|
{
|
|
["users"] = new JsonObject
|
|
{
|
|
["total"] = users,
|
|
["activeMonth"] = await Counted.ActiveUsers(30, token),
|
|
["activeHalfyear"] = await Counted.ActiveUsers(180, token)
|
|
},
|
|
["localPosts"] = posts
|
|
},
|
|
["metadata"] = new JsonObject
|
|
{
|
|
["nodeName"] = "PrivaPub",
|
|
["nodeDescription"] = "A small ActivityPub server where one private login keeps several unlinkable public personas.",
|
|
["federation"] = new JsonObject { ["document"] = "https://git.thepra.dev/thepra/SocialPub/src/branch/master/FEDERATION.md" }
|
|
}
|
|
};
|
|
return Content(document.ToJsonString(),
|
|
$"application/json; profile=\"http://nodeinfo.diaspora.software/ns/schema/{version}#\"; charset=utf-8");
|
|
}
|
|
}
|
|
}
|