Files
SocialPub/PrivaPub/Api/Mastodon/Controllers/TrendsController.cs
T

145 lines
7.3 KiB
C#
Raw Normal View History

using Microsoft.AspNetCore.Mvc;
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Api.Mastodon.Mappers;
using PrivaPub.Domain.Privacy;
using PrivaPub.Domain.Social;
using PrivaPub.Federation.Actors;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
namespace PrivaPub.Api.Mastodon.Controllers
{
// Trends and the directory (owner decision 2026-10-04, back from the cut list), from what PrivaPub already holds
// (Trends). The directory lists only accounts that let themselves be discovered.
public class TrendsController : MastodonController
{
readonly Trends _trends;
readonly MastodonMapper _mapper;
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
public TrendsController(Trends trends, MastodonMapper mapper, DbEntities dbEntities, ILocalActorService localActors)
{
_trends = trends;
_mapper = mapper;
_dbEntities = dbEntities;
_localActors = localActors;
}
static List<object> History(Trends.Trend trend) => trend.History.Select(h => (object)new
{
day = new DateTimeOffset(h.Day, TimeSpan.Zero).ToUnixTimeSeconds().ToString(),
uses = h.Uses.ToString(),
accounts = h.Accounts.ToString()
}).ToList();
IEnumerable<T> Window<T>(IEnumerable<T> items, int fallback, int max) =>
items.Skip(Math.Max(0, Params.Int("offset") ?? 0)).Take(Limit(fallback, max));
[HttpGet("/api/v1/trends"), HttpGet("/api/v1/trends/tags"), Scope("read", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Tags(CancellationToken token)
{
var followed = MyId == default
? new HashSet<string>()
: (await DB.Default.Find<Models.Social.FollowedTag>().Match(t => t.AvatarId == MyId).ExecuteAsync(token)).Select(t => t.Name).ToHashSet();
return Json(Window(await _trends.Tags(token), 10, 20).Select(t => new
{
name = t.Key,
url = $"{_localActors.BaseAddress}/tags/{Uri.EscapeDataString(t.Key)}",
history = History(t),
following = MyId == default ? default(bool?) : followed.Contains(t.Key)
}).ToList());
}
[HttpGet("/api/v1/trends/statuses"), Scope("read", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Statuses(CancellationToken token)
{
var ids = Window(await _trends.Statuses(token), 20, 40).ToList();
var posts = (await _dbEntities.Posts.Match(p => ids.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token)).ToDictionary(p => p.ID);
return Json(await _mapper.Statuses(ids.Where(posts.ContainsKey).Select(id => posts[id]).ToList(), MyId, token));
}
[HttpGet("/api/v1/trends/links"), Scope("read", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Links(CancellationToken token)
{
var trends = Window(await _trends.Links(token), 10, 20).ToList();
var sampleIds = trends.Select(t => t.SamplePostId).ToList();
var samples = (await _dbEntities.Posts.Match(p => sampleIds.Contains(p.ID)).ExecuteAsync(token)).ToDictionary(p => p.ID);
return Json(trends.Where(t => samples.ContainsKey(t.SamplePostId)).Select(t =>
{
var link = samples[t.SamplePostId].Link;
var card = _mapper.LinkCard(t.Key, link.Title, link.Description, link.ImageURL);
return card == default ? default : (object)new
{
card.Url, card.Title, card.Description, card.Type, card.AuthorName, card.AuthorUrl, card.ProviderName, card.ProviderUrl,
card.Html, card.Width, card.Height, card.Image, card.ImageDescription, card.EmbedUrl, card.Blurhash, card.Language,
card.PublishedAt, card.Authors, History = History(t)
};
}).Where(c => c != default).ToList());
}
// order=active: by their latest public post PrivaPub holds; order=new: by when they say they joined, which for a
// persona is its published day (never its creation, so personas made together are not told apart by their order)
[HttpGet("/api/v1/directory"), Scope("read", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Directory(CancellationToken token)
{
var offset = Math.Max(0, Params.Int("offset") ?? 0);
var limit = Limit(40, 80);
var localOnly = Params.Bool("local") == true;
var barred = await Counted.BarredPersonas(token);
var wanted = offset + limit;
var ordered = new List<string>();
if (Params.Get("order") == "new")
{
var personas = (await _dbEntities.Avatars.Match(a => a.Settings.IsDiscoverable && !a.DeletionAt.HasValue && !barred.Contains(a.ID))
.Sort(a => a.PublishedOn, Order.Descending).Limit(wanted * 4).ExecuteAsync(token))
.Select(a => (a.ID, Joined: a.PublishedOn, Tie: Tie(a.UserName)));
var remote = localOnly
? Enumerable.Empty<(string ID, DateTime Joined, string Tie)>()
: (await _dbEntities.ForeignAvatars.Match(f => f.IsDiscoverable && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue && f.Published != null)
.Sort(f => f.Published, Order.Descending).Limit(wanted).ExecuteAsync(token))
.Select(f => (f.ID, Joined: f.Published.Value.Date, Tie: Tie(f.ActorURI)));
ordered = personas.Concat(remote).OrderByDescending(a => a.Joined).ThenBy(a => a.Tie, StringComparer.Ordinal)
.Select(a => a.ID).Skip(offset).Take(limit).ToList();
}
else
{
var discoverableLocal = (await _dbEntities.Avatars.Match(a => a.Settings.IsDiscoverable && !a.DeletionAt.HasValue && !barred.Contains(a.ID))
.Project(a => a.Include(x => x.ID)).ExecuteAsync(token)).Select(a => a.ID).ToHashSet();
var seen = new HashSet<string>();
var maxId = default(string);
for (var round = 0; round < 20 && ordered.Count < wanted; round++)
{
var page = _dbEntities.Posts.Match(p => p.Visibility == Models.Post.PostVisibility.Public && !p.DeletedAt.HasValue && p.ReblogOfPostId == null);
if (maxId != default)
page.Match(f => f.Lt(p => p.ID, maxId));
if (localOnly)
page.Match(p => !p.IsFederatedCopy);
var batch = await page.Sort(p => p.ID, Order.Descending).Limit(500)
.Project(p => p.Include(x => x.ID).Include(x => x.AuthorAccountId).Include(x => x.GroupUserId).Include(x => x.IsFederatedCopy))
.ExecuteAsync(token);
if (batch.Count == 0)
break;
maxId = batch[^1].ID;
var fresh = batch.Select(p => (Id: p.AuthorAccountId ?? p.GroupUserId, p.IsFederatedCopy)).Where(a => a.Id != default && seen.Add(a.Id)).ToList();
var remoteIds = fresh.Where(a => a.IsFederatedCopy).Select(a => a.Id).ToList();
var discoverableRemote = remoteIds.Count == 0
? new HashSet<string>()
: (await _dbEntities.ForeignAvatars.Match(f => remoteIds.Contains(f.ID) && f.IsDiscoverable && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue)
.Project(f => f.Include(x => x.ID)).ExecuteAsync(token)).Select(f => f.ID).ToHashSet();
ordered.AddRange(fresh.Where(a => a.IsFederatedCopy ? discoverableRemote.Contains(a.Id) : discoverableLocal.Contains(a.Id)).Select(a => a.Id));
}
ordered = ordered.Skip(offset).Take(limit).ToList();
}
var accounts = await _mapper.Accounts(ordered, token);
return Json(ordered.Where(accounts.ContainsKey).Select(id => accounts[id]).ToList());
}
static string Tie(string name) =>
Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(name ?? string.Empty)));
}
}