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 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 Objects(JsonNode node) => node switch { JsonArray array => array.OfType(), JsonObject single => new[] { single }, _ => Enumerable.Empty() }; public static (string Url, string MediaType) MediaLink(JsonNode node) { switch (node) { case JsonValue value when value.TryGetValue(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(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 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 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 { id }, _ => new List() }; 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(out var closed) => closed ? Moment(Value(note, "endTime")) ?? DateTime.UtcNow : default(DateTime?), JsonValue value when value.TryGetValue(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(out var number): return number >= 0 && number < 1e7 ? number : default; case JsonValue value when value.TryGetValue(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().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(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(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 Variants(List 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(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(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(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(out var number) && double.IsFinite(number) ? number : (double?)null; static bool? Bool(JsonObject node, string property) => node?[property] is JsonValue value && value.TryGetValue(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(); } }