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
60 lines
2.0 KiB
C#
60 lines
2.0 KiB
C#
using Microsoft.IdentityModel.Tokens;
|
|
|
|
using PrivaPub.ClientModels;
|
|
using PrivaPub.ClientModels.User;
|
|
using PrivaPub.Models.User;
|
|
|
|
using System.IdentityModel.Tokens.Jwt;
|
|
using System.Security.Claims;
|
|
using System.Text;
|
|
|
|
namespace PrivaPub.StaticServices
|
|
{
|
|
public class AuthTokenManager
|
|
{
|
|
public const string SessionStamp = "ss";
|
|
const string SessionStampClaim = SessionStamp;//the root's RootUser.SessionStamp when issued; JwtEvents refuses a stale one
|
|
|
|
readonly IConfiguration Configuration;
|
|
|
|
public AuthTokenManager(IConfiguration configuration)
|
|
{
|
|
Configuration = configuration;
|
|
}
|
|
|
|
public JwtUser GenerateToken(RootUser user, ViewUserSettings userSettings)
|
|
{
|
|
var expiration = DateTime.UtcNow.AddHours(int.Parse(Configuration["AppConfiguration:Jwt:HoursTimeout"]));
|
|
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["AppConfiguration:Jwt:Key"]));
|
|
var jwtUser = new JwtUser
|
|
{
|
|
UserId = user.ID,
|
|
Email = user.Email,
|
|
Username = user.UserName,
|
|
Expiration = expiration.Ticks,
|
|
Policies = user.Policies,
|
|
UserSettings = userSettings
|
|
};
|
|
var claims = new List<Claim>
|
|
{
|
|
new(ClaimTypes.UserData, user.ID),
|
|
new(ClaimTypes.Name, user.UserName),
|
|
new(SessionStampClaim, user.SessionStamp ?? string.Empty)
|
|
};
|
|
|
|
claims.Add(new(Policies.IsUser, $"{user.Policies.Contains(Policies.IsUser)}".ToLower()));
|
|
claims.Add(new(Policies.IsModerator, $"{user.Policies.Contains(Policies.IsModerator)}".ToLower()));
|
|
claims.Add(new(Policies.IsAdmin, $"{user.Policies.Contains(Policies.IsAdmin)}".ToLower()));
|
|
|
|
var token = new JwtSecurityToken(issuer: Configuration["AppConfiguration:Jwt:Issuer"], audience: Configuration["AppConfiguration:Jwt:Audience"],
|
|
claims: claims,
|
|
notBefore: DateTime.UtcNow,
|
|
expires: expiration,
|
|
signingCredentials: new(securityKey, SecurityAlgorithms.HmacSha512)
|
|
);
|
|
var tokenHandler = new JwtSecurityTokenHandler();
|
|
jwtUser.Token = tokenHandler.WriteToken(token);
|
|
return jwtUser;
|
|
}
|
|
}
|
|
} |