using Microsoft.AspNetCore.Mvc; using MongoDB.Entities; using PrivaPub.Api.Mastodon.Entities; using PrivaPub.Api.Mastodon.Infrastructure; using PrivaPub.Api.Mastodon.Mappers; using PrivaPub.Domain.Privacy; using PrivaPub.Models.Post; using PrivaPub.Models.Social; using PrivaPub.StaticServices; using PostEntity = PrivaPub.Models.Post.Post; using NotificationEntity = PrivaPub.Models.Social.Notification; using MarkerEntity = PrivaPub.Models.Social.Marker; namespace PrivaPub.Api.Mastodon.Controllers { public class TimelinesController : MastodonController { readonly MastodonMapper _mapper; readonly DbEntities _dbEntities; public TimelinesController(MastodonMapper mapper, DbEntities dbEntities) { _mapper = mapper; _dbEntities = dbEntities; } [HttpGet("/api/v1/timelines/home"), Scope("read:statuses")] public async Task Home(CancellationToken token) { var entries = await Page.From(Params, Limit()).Fetch(_dbEntities.TimelineEntries.Match(e => e.AvatarId == MyId), e => e.PostId, token); var ids = entries.Select(e => e.PostId).ToList(); var posts = (await _dbEntities.Posts.Match(p => ids.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token)).ToDictionary(p => p.ID); var statuses = await _mapper.Statuses(ids.Where(posts.ContainsKey).Select(id => posts[id]).ToList(), MyId, token); Link("/api/v1/timelines/home", entries.LastOrDefault()?.PostId, entries.FirstOrDefault()?.PostId); return Json(statuses); } [HttpGet("/api/v1/timelines/public"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] public async Task Public(CancellationToken token) { var query = _dbEntities.Posts.Match(p => p.Visibility == PostVisibility.Public && !p.DeletedAt.HasValue && p.ReblogOfPostId == null); if (Params.Bool("local") == true) query.Match(p => !p.IsFederatedCopy); if (Params.Bool("remote") == true) query.Match(p => p.IsFederatedCopy); if (Params.Bool("only_media") == true) query.Match(p => p.Media.Count > 0); return await Respond(query, "/api/v1/timelines/public", token); } [HttpGet("/api/v1/timelines/tag/{hashtag}"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] public async Task Tag(string hashtag, CancellationToken token) { var tag = hashtag.TrimStart('#').ToLowerInvariant(); var query = _dbEntities.Posts.Match(p => p.Tags.Contains(tag) && p.Visibility == PostVisibility.Public && !p.DeletedAt.HasValue && p.ReblogOfPostId == null); if (Params.Bool("local") == true) query.Match(p => !p.IsFederatedCopy); return await Respond(query, $"/api/v1/timelines/tag/{hashtag}", token); } [HttpGet("/api/v1/timelines/list/{id}"), Scope("read:lists")] public IActionResult List(string id) => Json(Array.Empty()); [HttpGet("/api/v1/favourites"), Scope("read:favourites")] public async Task Favourites(CancellationToken token) { var favourites = await Page.From(Params, Limit()).Fetch(_dbEntities.Favourites.Match(f => f.AccountId == MyId), f => f.ID, token); var ids = favourites.Select(f => f.PostId).ToList(); var posts = (await _dbEntities.Posts.Match(p => ids.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token)).ToDictionary(p => p.ID); var visible = new List(); foreach (var id in ids.Where(posts.ContainsKey)) if (await VisibilityPolicy.CanSee(posts[id], MyId, token)) visible.Add(posts[id]); Link("/api/v1/favourites", favourites.LastOrDefault()?.ID, favourites.FirstOrDefault()?.ID); return Json(await _mapper.Statuses(visible, MyId, token)); } [HttpGet("/api/v1/conversations"), Scope("read:statuses")] public async Task Conversations(CancellationToken token) { var groups = await _dbEntities.DmGroups.Match(g => !g.DeletionAt.HasValue && g.Members.Any(m => !m.IsForeign && m.AvatarId == MyId)) .Sort(g => g.UpdatedAt, Order.Descending).Limit(Limit()).ExecuteAsync(token); var conversations = new List(); foreach (var group in groups) { var last = await _dbEntities.Posts.Match(p => p.ConversationId == group.ID && !p.DeletedAt.HasValue).Sort(p => p.ID, Order.Descending).ExecuteFirstAsync(token); if (last == default) continue; var others = new List(); foreach (var member in group.Members.Where(m => !(m.AvatarId == MyId && !m.IsForeign))) others.Add(member.IsForeign ? (await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == member.AvatarId).ExecuteFirstAsync(token))?.ID : member.AvatarId); var accounts = await _mapper.Accounts(others, token); conversations.Add(new Conversation { Id = group.ID, Accounts = accounts.Values.ToList(), LastStatus = await _mapper.Status(last, MyId, token) }); } return Json(conversations); } [HttpPost("/api/v1/conversations/{id}/read"), Scope("write:conversations")] public async Task ReadConversation(string id, CancellationToken token) { var group = await _dbEntities.DmGroups.Match(g => g.ID == id && g.Members.Any(m => !m.IsForeign && m.AvatarId == MyId)).ExecuteFirstAsync(token); if (group == default) return NotFoundError(); var last = await _dbEntities.Posts.Match(p => p.ConversationId == group.ID && !p.DeletedAt.HasValue).Sort(p => p.ID, Order.Descending).ExecuteFirstAsync(token); return Json(new Conversation { Id = group.ID, LastStatus = last == default ? default : await _mapper.Status(last, MyId, token) }); } [HttpGet("/api/v1/markers"), Scope("read:statuses")] public async Task Markers(CancellationToken token) { var wanted = Params.List("timeline"); var markers = await DB.Default.Find().Match(m => m.AvatarId == MyId).ExecuteAsync(token); return Json(markers.Where(m => wanted.Count == 0 || wanted.Contains(m.Timeline)) .ToDictionary(m => m.Timeline, m => new Entities.Marker { LastReadId = m.LastReadId, Version = m.Version, UpdatedAt = MastodonJson.Time(m.UpdatedAt) })); } [HttpPost("/api/v1/markers"), Scope("write:statuses")] public async Task SaveMarkers(CancellationToken token) { var result = new Dictionary(); foreach (var timeline in new[] { "home", "notifications" }) { var lastRead = Params.Get($"{timeline}[last_read_id]"); if (string.IsNullOrEmpty(lastRead)) continue; var marker = await DB.Default.UpdateAndGet() .Match(m => m.AvatarId == MyId && m.Timeline == timeline) .Modify(m => m.LastReadId, lastRead) .Modify(m => m.UpdatedAt, DateTime.UtcNow) .Modify(b => b.Inc(m => m.Version, 1)) .Option(o => o.IsUpsert = true) .ExecuteAsync(token); result[timeline] = new Entities.Marker { LastReadId = marker.LastReadId, Version = marker.Version, UpdatedAt = MastodonJson.Time(marker.UpdatedAt) }; } return Json(result); } async Task Respond(Find query, string path, CancellationToken token) { var posts = await Page.From(Params, Limit()).Fetch(query, p => p.ID, token); Link(path, posts.LastOrDefault()?.ID, posts.FirstOrDefault()?.ID); return Json(await _mapper.Statuses(posts, MyId, token)); } } public class NotificationsController : MastodonController { static readonly Dictionary Names = new() { [NotificationType.Mention] = "mention", [NotificationType.Follow] = "follow", [NotificationType.FollowRequest] = "follow_request", [NotificationType.Favourite] = "favourite", [NotificationType.Reblog] = "reblog", [NotificationType.Update] = "update" }; readonly MastodonMapper _mapper; readonly DbEntities _dbEntities; public NotificationsController(MastodonMapper mapper, DbEntities dbEntities) { _mapper = mapper; _dbEntities = dbEntities; } [HttpGet("/api/v1/notifications"), Scope("read:notifications")] public async Task List(CancellationToken token) { var types = Params.List("types").Select(Parse).Where(t => t.HasValue).Select(t => t.Value).ToList(); var excluded = Params.List("exclude_types").Select(Parse).Where(t => t.HasValue).Select(t => t.Value).ToList(); var query = _dbEntities.Notifications.Match(n => n.AvatarId == MyId); if (types.Count > 0) query.Match(n => types.Contains(n.Type)); if (excluded.Count > 0) query.Match(n => !excluded.Contains(n.Type)); if (Params.Get("account_id") is { } accountId) query.Match(n => n.FromAccountId == accountId); var notifications = await Page.From(Params, Limit(15, 30)).Fetch(query, n => n.ID, token); Link("/api/v1/notifications", notifications.LastOrDefault()?.ID, notifications.FirstOrDefault()?.ID); return Json(await Map(notifications, token)); } [HttpGet("/api/v1/notifications/{id}"), Scope("read:notifications")] public async Task Get(string id, CancellationToken token) { var notification = await _dbEntities.Notifications.Match(n => n.ID == id && n.AvatarId == MyId).ExecuteFirstAsync(token); return notification == default ? NotFoundError() : Json((await Map(new List { notification }, token)).FirstOrDefault()); } [HttpPost("/api/v1/notifications/clear"), Scope("write:notifications")] public async Task Clear(CancellationToken token) { await DB.Default.DeleteAsync(n => n.AvatarId == MyId); return Json(new { }); } [HttpPost("/api/v1/notifications/{id}/dismiss"), Scope("write:notifications")] public async Task Dismiss(string id, CancellationToken token) { await DB.Default.DeleteAsync(n => n.ID == id && n.AvatarId == MyId); return Json(new { }); } [HttpGet("/api/v1/notifications/unread_count"), Scope("read:notifications")] public async Task UnreadCount(CancellationToken token) => Json(new { count = await DB.Default.CountAsync(n => n.AvatarId == MyId && !n.IsRead, token) }); async Task> Map(List notifications, CancellationToken token) { var accounts = await _mapper.Accounts(notifications.Select(n => n.FromAccountId), token); var postIds = notifications.Where(n => n.PostId != default).Select(n => n.PostId).Distinct().ToList(); var posts = postIds.Count == 0 ? new List() : await _dbEntities.Posts.Match(p => postIds.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token); var statuses = (await _mapper.Statuses(posts, MyId, token)).ToDictionary(s => s.Id); return notifications .Where(n => accounts.ContainsKey(n.FromAccountId ?? string.Empty) && (n.PostId == default || statuses.ContainsKey(n.PostId))) .Select(n => new Entities.Notification { Id = n.ID, Type = Names[n.Type], CreatedAt = MastodonJson.Time(n.CreatedAt), GroupKey = $"ungrouped-{n.ID}", Account = accounts[n.FromAccountId], Status = n.PostId == default ? default : statuses[n.PostId] }) .ToList(); } static NotificationType? Parse(string name) => Names.FirstOrDefault(n => n.Value == name) is { Value: not null } pair ? pair.Key : (NotificationType?)null; } }