Files
SocialPub/PrivaPub/Federation/Objects/ObjectShapes.cs
T
thepraandClaude Opus 5.5 ab526ed291
Build / Build (push) Successful in 4m51s
Deploy / privapub.thepra.dev (push) Successful in 5m2s
T12, T13: Sharkey and Akkoma in the pasture, and two bugs Akkoma found
Sharkey 2025.4.7 is the Misskey peer under another name, image, database, Redis db and
home. Its scenario is Misskey's plus edits both ways and the FEP-e232 quote tag: 40 checks
pass.

Akkoma 3.20.1 publishes no image, so tools/pasture/images/akkoma installs its OTP
release, pinned by checksum, and appends Caddy's CA to the CA bundles the release ships.
Its scenario has 44 checks, which pass three runs in a row:
- follows, posts, CW, followers-only posts and the published time;
- replies, likes, boosts and their undos;
- EmojiReact both ways and withdrawn;
- DMs, polls, quotes, media, edits with history and deletes, both ways;
- unfollow, block, unblock and statistics.

The two bugs, both fixed:
- Followers-only posts arrived as DMs on Pleroma and Akkoma. They call a post private
  only if an address in `to` contains "/followers" or its cc is not empty. Ours is
  /groupies, and a post mentioning nobody had an empty cc. The followers collection is now
  named in cc as well, which tells nobody anything new.
- Akkoma's open polls showed as ended and refused votes. Akkoma carries an open poll's
  end in `closed` and sends no `endTime`. A `closed` in the future is now read as the end.

Neither of these is a PrivaPub bug:
- Akkoma's Linkify never takes @user@host.test for a mention, so its DM addresses alice
  with to[].
- Its API never reports a remote blocker as blocked_by, so the block is read from its
  database.

Also in this commit:
- The rate limits are configuration (RateLimits: AccountsPerMinute, InboxBurst,
  InboxPerTenSeconds), with the old values as defaults. The pasture raises the accounts
  limit, which back-to-back runs from one address had hit.
- A new Lemmy never sends what it queued for a server before it started that server's
  send worker, so the scenario waits for the worker before its first follow.

All six peers in one clean pass: GoToSocial 54 (+1 expected), Mastodon 49 (+2), Misskey
35, Sharkey 40, Akkoma 44, and Lemmy 20 (+3) once the worker wait was added. 642 tests
pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
2026-10-03 15:24:08 +02:00

373 lines
15 KiB
C#

using PrivaPub.Models.Post;
using System.Globalization;
using System.Net;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using System.Xml;
using static PrivaPub.Federation.Objects.ActivityJson;
namespace PrivaPub.Federation.Objects
{
public static partial class ObjectShapes
{
const int MaxVariants = 20;
const int MaxCaptions = 20;
const int MaxPlaces = 4;
const int CoverMaxWidth = 1280;
const int MaxEmojis = 64;
const int MaxPollOptions = 20;
const int MaxAudience = 32;
static readonly Dictionary<string, string> MediaTypesByExtension = new(StringComparer.OrdinalIgnoreCase)
{
[".jpg"] = "image/jpeg", [".jpeg"] = "image/jpeg", [".png"] = "image/png", [".gif"] = "image/gif", [".webp"] = "image/webp",
[".avif"] = "image/avif", [".heic"] = "image/heic", [".svg"] = "image/svg+xml",
[".mp4"] = "video/mp4", [".m4v"] = "video/mp4", [".webm"] = "video/webm", [".mov"] = "video/quicktime", [".mkv"] = "video/x-matroska",
[".mp3"] = "audio/mpeg", [".ogg"] = "audio/ogg", [".oga"] = "audio/ogg", [".opus"] = "audio/opus", [".m4a"] = "audio/mp4",
[".flac"] = "audio/flac", [".wav"] = "audio/wav", [".m3u8"] = "application/x-mpegURL"
};
public static IEnumerable<JsonObject> Objects(JsonNode node) => node switch
{
JsonArray array => array.OfType<JsonObject>(),
JsonObject single => new[] { single },
_ => Enumerable.Empty<JsonObject>()
};
public static (string Url, string MediaType) MediaLink(JsonNode node)
{
switch (node)
{
case JsonValue value when value.TryGetValue<string>(out var text):
return (text, default);
case JsonObject link when Value(link, "href") is { } href:
return (href, Value(link, "mediaType"));
case JsonObject wrapper when wrapper["url"] is { } inner:
var (url, mediaType) = MediaLink(inner);
return (url, mediaType ?? Value(wrapper, "mediaType"));
case JsonArray array:
var links = array.Select(MediaLink).Where(l => l.Url != default).ToList();
return links.FirstOrDefault(l => l.MediaType?.StartsWith("text/html", StringComparison.OrdinalIgnoreCase) != true);
default:
return default;
}
}
public static string InferMediaType(string declared, string url, string objectType)
{
if (!string.IsNullOrWhiteSpace(declared) && declared.Contains('/'))
return declared.Trim();
if (Uri.TryCreate(url, UriKind.Absolute, out var uri) && MediaTypesByExtension.TryGetValue(Path.GetExtension(uri.AbsolutePath), out var byExtension))
return byExtension;
return objectType switch
{
"Image" => "image/*",
"Video" => "video/*",
"Audio" => "audio/*",
_ => default
};
}
public static string Image(JsonNode node)
{
var candidates = node switch
{
JsonValue value when value.TryGetValue<string>(out var text) => new[] { (Url: text, Width: (int?)default) },
JsonArray array => array.Select(item => (Url: MediaLink(item).Url, Width: Int(item as JsonObject, "width"))).ToArray(),
JsonObject single => new[] { (Url: MediaLink(single).Url, Width: Int(single, "width")) },
_ => Array.Empty<(string Url, int? Width)>()
};
var usable = candidates.Where(c => Origin.Of(c.Url) != default).ToList();
return usable.Where(c => c.Width <= CoverMaxWidth).OrderByDescending(c => c.Width).Select(c => c.Url).FirstOrDefault()
?? usable.Select(c => c.Url).FirstOrDefault();
}
public static List<CustomEmoji> Emojis(JsonNode tags) =>
Objects(tags)
.Where(t => Value(t, "type") == "Emoji")
.Select(t => new CustomEmoji
{
Shortcode = Value(t, "name")?.Trim(':'),
URL = MediaLink(t["icon"]).Url,
Updated = Moment(Value(t, "updated"))
})
.Where(e => !string.IsNullOrEmpty(e.Shortcode) && e.Shortcode.Length <= 100 && Shortcode().IsMatch(e.Shortcode) && Origin.Of(e.URL) != default)
.DistinctBy(e => e.Shortcode)
.Take(MaxEmojis)
.ToList();
public static InteractionRule QuotePolicy(JsonObject note)
{
if (note["interactionPolicy"] is not JsonObject policy || policy["canQuote"] is not JsonObject canQuote)
return default;
static List<string> Who(JsonNode node) => node switch
{
JsonArray array => array.Select(Id).Where(id => id != default).Take(MaxAudience).ToList(),
JsonNode single when Id(single) is { } id => new List<string> { id },
_ => new List<string>()
};
return new InteractionRule
{
Automatic = Who(canQuote["automaticApproval"] ?? canQuote["always"]),
Manual = Who(canQuote["manualApproval"] ?? canQuote["approvalRequired"])
};
}
public static PostPoll Poll(JsonObject note)
{
if (Value(note, "type") != "Question")
return default;
var multiple = note["anyOf"] is JsonArray;
var options = Objects(note[multiple ? "anyOf" : "oneOf"])
.Select(o => new PollOption
{
Title = Text(Value(o, "name"), 200),
Votes = o["replies"] is JsonObject replies && Int(replies, "totalItems") is { } total ? total : Int(o, "_misskey_votes") ?? 0
})
.Where(o => o.Title != default)
.Take(MaxPollOptions)
.ToList();
if (options.Count < 2)
return default;
var closedAt = note["closed"] switch
{
JsonValue value when value.TryGetValue<bool>(out var closed) => closed ? Moment(Value(note, "endTime")) ?? DateTime.UtcNow : default(DateTime?),
JsonValue value when value.TryGetValue<string>(out var at) => Moment(at),
_ => default
};
var expiresAt = Moment(Value(note, "endTime"));
// Pleroma and Akkoma put an open poll's end in `closed`, often without `endTime`: it is closed once that has passed
if (closedAt > DateTime.UtcNow)
{
expiresAt ??= closedAt;
closedAt = default;
}
return new PostPoll
{
Options = options,
Multiple = multiple,
ExpiresAt = expiresAt,
ClosedAt = closedAt,
VotersCount = Int(note, "votersCount")
};
}
public static string Cover(JsonObject note) =>
Image(note["image"]) ?? Image(note["icon"]) ?? (note["preview"] is JsonObject preview && Value(preview, "type") == "Image" ? Image(preview) : default);
public static double? Seconds(JsonNode node)
{
switch (node)
{
case JsonValue value when value.TryGetValue<double>(out var number):
return number >= 0 && number < 1e7 ? number : default;
case JsonValue value when value.TryGetValue<string>(out var text) && !string.IsNullOrWhiteSpace(text):
try
{
var span = XmlConvert.ToTimeSpan(text.Trim());
return span >= TimeSpan.Zero ? span.TotalSeconds : default;
}
catch (FormatException)
{
return double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var seconds) && seconds >= 0 ? seconds : default;
}
default:
return default;
}
}
public static string Text(string html, int max)
{
if (string.IsNullOrWhiteSpace(html))
return default;
var text = WebUtility.HtmlDecode(Tags().Replace(html, " "));
text = Spaces().Replace(text, " ").Trim();
if (text.Length == 0)
return default;
return text.Length <= max ? text : text[..max];
}
public static string Language(JsonObject note) =>
note["@context"] is JsonArray context
? context.OfType<JsonObject>().Select(entry => Value(entry, "@language")).FirstOrDefault(language => !string.IsNullOrWhiteSpace(language))
: default;
public static PostSource Source(JsonObject note) => note["source"] is JsonObject source && Value(source, "content") is { } content
? new PostSource { Content = content.Length <= 64 * 1024 ? content : content[..(64 * 1024)], MediaType = Value(source, "mediaType") }
: default;
public static PostLink Link(JsonObject note)
{
var attached = Objects(note["attachment"]).FirstOrDefault(a => Value(a, "type") == "Link" && IsWeb(Value(a, "href")));
if (attached != default)
{
var preview = attached["preview"] as JsonObject;
return new PostLink
{
Href = Value(attached, "href"),
MediaType = Value(attached, "mediaType"),
Title = Text(Value(preview, "name") ?? Value(attached, "name"), 300),
Description = Text(Value(preview, "summary") ?? Value(preview, "content"), 1000),
ImageURL = preview == default ? Image(note["image"]) : Image(preview["image"]) ?? Image(preview["icon"]),
FromPublisher = preview != default
};
}
if (note["source"] is JsonValue source && source.TryGetValue<string>(out var sourceUrl) && IsWeb(sourceUrl))
return new PostLink { Href = sourceUrl, ImageURL = Image(note["image"]) };
return default;
}
public static VideoDetails Video(JsonObject note)
{
if (Value(note, "type") != "Video")
return default;
var links = Objects(note["url"]).ToList();
var nested = links.Where(l => Value(l, "mediaType")?.Equals("application/x-mpegURL", StringComparison.OrdinalIgnoreCase) == true)
.SelectMany(l => Objects(l["tag"]))
.ToList();
return new VideoDetails
{
DurationSeconds = Seconds(note["duration"]),
Variants = Variants(links.Concat(nested).ToList(), "video/", "audio/"),
PlaylistURL = links.Where(l => Value(l, "mediaType")?.Equals("application/x-mpegURL", StringComparison.OrdinalIgnoreCase) == true)
.Select(l => Value(l, "href")).FirstOrDefault(IsWeb),
IsLive = Bool(note, "isLiveBroadcast") == true,
State = Int(note, "state"),
Views = Long(note, "views"),
CommentsPolicy = Int(note, "commentsPolicy"),
DownloadEnabled = Bool(note, "downloadEnabled"),
Licence = Named(note["licence"] ?? note["license"]),
Category = Named(note["category"]),
ChannelURI = Objects(note["attributedTo"]).Where(a => Value(a, "type") == "Group").Select(a => Id(a)).FirstOrDefault(),
EmbedURL = Value(note, "embedUrl") is { } embed && IsWeb(embed) ? embed : default,
ChaptersURL = Value(note, "hasParts") is { } chapters && IsWeb(chapters) ? chapters : default,
Captions = Objects(note["subtitleLanguage"])
.Select(c => new Caption
{
Language = Value(c, "identifier"),
Url = Objects(c["url"]).Where(u => Value(u, "mediaType") == "text/vtt").Select(u => Value(u, "href")).FirstOrDefault(IsWeb)
?? (Value(c, "url") is { } plain && IsWeb(plain) ? plain : default)
})
.Where(c => c.Url != default)
.Take(MaxCaptions)
.ToList()
};
}
public static AudioDetails Audio(JsonObject note)
{
if (Value(note, "type") != "Audio")
return default;
return new AudioDetails
{
DurationSeconds = Seconds(note["duration"]),
Variants = Variants(Objects(note["url"]).ToList(), "audio/"),
Album = Named(note["album"]),
Position = Int(note, "position"),
Licence = Named(note["license"] ?? note["licence"])
};
}
public static EventDetails Event(JsonObject note)
{
if (Value(note, "type") != "Event")
return default;
var start = Value(note, "startTime");
var places = Objects(note["location"]).Select(Place).Where(p => p != default).Take(MaxPlaces).ToList();
return new EventDetails
{
Start = Moment(start),
End = Moment(Value(note, "endTime")),
TimeZone = Value(note, "timezone"),
FloatingTime = start?.EndsWith("-00:00", StringComparison.Ordinal) == true,
Places = places,
OnlineURL = places.Select(p => p.Url).FirstOrDefault(u => u != default)
?? Objects(note["attachment"]).Where(a => Value(a, "type") == "Link" && Value(a, "name") == "Website").Select(a => Value(a, "href")).FirstOrDefault(IsWeb),
JoinMode = Value(note, "joinMode"),
ParticipantCount = Int(note, "participantCount"),
Capacity = Int(note, "maximumAttendeeCapacity"),
Status = Value(note, "ical:status") ?? Value(note, "eventStatus") ?? Value(note, "status"),
Category = Named(note["category"])
};
}
static EventPlace Place(JsonObject location)
{
var type = Value(location, "type");
if (type == "VirtualLocation")
return Value(location, "url") is { } url && IsWeb(url) ? new EventPlace { Name = Text(Value(location, "name"), 200), Url = url } : default;
var address = location["address"] switch
{
JsonValue value when value.TryGetValue<string>(out var text) => text,
JsonObject postal => string.Join(", ", new[] { "streetAddress", "postalCode", "addressLocality", "addressRegion", "addressCountry" }
.Select(field => Value(postal, field)).Where(part => !string.IsNullOrWhiteSpace(part))),
_ => default
};
var place = new EventPlace
{
Name = Text(Value(location, "name") ?? Value(location, "content"), 200),
Address = Text(address, 300),
Latitude = Double(location, "latitude"),
Longitude = Double(location, "longitude")
};
return place.Name == default && place.Address == default && place.Latitude == default ? default : place;
}
static List<MediaVariant> Variants(List<JsonObject> links, params string[] prefixes) =>
links.Where(l => Value(l, "mediaType") is { } type && prefixes.Any(p => type.StartsWith(p, StringComparison.OrdinalIgnoreCase)) && IsWeb(Value(l, "href")))
.Select(l => new MediaVariant
{
Url = Value(l, "href"),
MediaType = Value(l, "mediaType"),
Width = Int(l, "width"),
Height = Int(l, "height"),
Fps = Double(l, "fps"),
Size = Long(l, "size"),
Streams = Objects(l["attachment"])
.Where(p => Value(p, "name") == "ffprobe_codec_type")
.Select(p => Value(p, "value"))
.Where(v => v is "audio" or "video")
.Distinct()
.ToList()
})
.Take(MaxVariants)
.ToList();
static string Named(JsonNode node) => node switch
{
JsonValue value when value.TryGetValue<string>(out var text) => Text(text, 200),
JsonObject named => Text(Value(named, "name") ?? Value(named, "identifier"), 200),
_ => default
};
static DateTime? Moment(string value) =>
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed) ? parsed.UtcDateTime : (DateTime?)null;
static bool IsWeb(string url) =>
Uri.TryCreate(url, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp);
static int? Int(JsonObject node, string property) =>
node?[property] is JsonValue value && value.TryGetValue<double>(out var number) && number is >= 0 and < int.MaxValue ? (int)number : (int?)null;
static long? Long(JsonObject node, string property) =>
node?[property] is JsonValue value && value.TryGetValue<double>(out var number) && number is >= 0 and < long.MaxValue ? (long)number : (long?)null;
static double? Double(JsonObject node, string property) =>
node?[property] is JsonValue value && value.TryGetValue<double>(out var number) && double.IsFinite(number) ? number : (double?)null;
static bool? Bool(JsonObject node, string property) =>
node?[property] is JsonValue value && value.TryGetValue<bool>(out var flag) ? flag : (bool?)null;
[GeneratedRegex(@"^[\w+\-.@]+$")]
private static partial Regex Shortcode();
[GeneratedRegex("<[^>]*>")]
private static partial Regex Tags();
[GeneratedRegex(@"\s+")]
private static partial Regex Spaces();
}
}