| | | 1 | | using Chronicis.Api.Data; |
| | | 2 | | using Chronicis.Shared.DTOs; |
| | | 3 | | using Chronicis.Shared.DTOs.Maps; |
| | | 4 | | using Chronicis.Shared.Enums; |
| | | 5 | | using Chronicis.Shared.Models; |
| | | 6 | | using Microsoft.EntityFrameworkCore; |
| | | 7 | | using System.Text.Json; |
| | | 8 | | |
| | | 9 | | namespace Chronicis.Api.Services; |
| | | 10 | | |
| | | 11 | | /// <summary> |
| | | 12 | | /// Service for anonymous public access to worlds. |
| | | 13 | | /// All methods return only publicly visible content - no authentication required. |
| | | 14 | | /// </summary> |
| | | 15 | | public sealed class PublicWorldService : IPublicWorldService |
| | | 16 | | { |
| | 1 | 17 | | private static readonly JsonSerializerOptions GeometryJsonOptions = new(JsonSerializerDefaults.Web); |
| | | 18 | | |
| | | 19 | | private readonly ChronicisDbContext _context; |
| | | 20 | | private readonly ILogger<PublicWorldService> _logger; |
| | | 21 | | private readonly IArticleHierarchyService _hierarchyService; |
| | | 22 | | private readonly IBlobStorageService _blobStorage; |
| | | 23 | | private readonly IReadAccessPolicyService _readAccessPolicy; |
| | | 24 | | private readonly IMapBlobStore _mapBlobStore; |
| | | 25 | | |
| | | 26 | | public PublicWorldService( |
| | | 27 | | ChronicisDbContext context, |
| | | 28 | | ILogger<PublicWorldService> logger, |
| | | 29 | | IArticleHierarchyService hierarchyService, |
| | | 30 | | IBlobStorageService blobStorage, |
| | | 31 | | IReadAccessPolicyService readAccessPolicy, |
| | | 32 | | IMapBlobStore mapBlobStore) |
| | | 33 | | { |
| | 18 | 34 | | _context = context; |
| | 18 | 35 | | _logger = logger; |
| | 18 | 36 | | _hierarchyService = hierarchyService; |
| | 18 | 37 | | _blobStorage = blobStorage; |
| | 18 | 38 | | _readAccessPolicy = readAccessPolicy; |
| | 18 | 39 | | _mapBlobStore = mapBlobStore; |
| | 18 | 40 | | } |
| | | 41 | | |
| | | 42 | | /// <summary> |
| | | 43 | | /// Get a public world by its public slug. |
| | | 44 | | /// Returns null if world doesn't exist or is not public. |
| | | 45 | | /// </summary> |
| | | 46 | | public async Task<WorldDetailDto?> GetPublicWorldAsync(string publicSlug) |
| | | 47 | | { |
| | | 48 | | var normalizedSlug = _readAccessPolicy.NormalizePublicSlug(publicSlug); |
| | | 49 | | |
| | | 50 | | var world = await _readAccessPolicy |
| | | 51 | | .ApplyPublicWorldSlugFilter(_context.Worlds.AsNoTracking(), normalizedSlug) |
| | | 52 | | .Include(w => w.Owner) |
| | | 53 | | .Include(w => w.Campaigns) |
| | | 54 | | .FirstOrDefaultAsync(); |
| | | 55 | | |
| | | 56 | | if (world == null) |
| | | 57 | | { |
| | | 58 | | _logger.LogTraceSanitized("Public world not found for slug '{PublicSlug}'", normalizedSlug); |
| | | 59 | | return null; |
| | | 60 | | } |
| | | 61 | | |
| | | 62 | | _logger.LogTraceSanitized("Public world '{WorldName}' accessed via slug '{PublicSlug}'", |
| | | 63 | | world.Name, normalizedSlug); |
| | | 64 | | |
| | | 65 | | return new WorldDetailDto |
| | | 66 | | { |
| | | 67 | | Id = world.Id, |
| | | 68 | | Name = world.Name, |
| | | 69 | | Slug = world.Slug, |
| | | 70 | | Description = world.Description, |
| | | 71 | | OwnerId = world.OwnerId, |
| | | 72 | | OwnerName = world.Owner?.DisplayName ?? "Unknown", |
| | | 73 | | CreatedAt = world.CreatedAt, |
| | | 74 | | CampaignCount = world.Campaigns?.Count ?? 0, |
| | | 75 | | IsPublic = world.IsPublic, |
| | | 76 | | IsTutorial = world.IsTutorial, |
| | | 77 | | PublicSlug = world.PublicSlug, |
| | | 78 | | // Include public campaigns |
| | | 79 | | Campaigns = world.Campaigns?.Select(c => new CampaignDto |
| | | 80 | | { |
| | | 81 | | Id = c.Id, |
| | | 82 | | Name = c.Name, |
| | | 83 | | Description = c.Description, |
| | | 84 | | WorldId = c.WorldId, |
| | | 85 | | IsActive = c.IsActive, |
| | | 86 | | StartedAt = c.StartedAt |
| | | 87 | | }).ToList() ?? new List<CampaignDto>() |
| | | 88 | | }; |
| | | 89 | | } |
| | | 90 | | |
| | | 91 | | /// <summary> |
| | | 92 | | /// Get the article tree for a public world. |
| | | 93 | | /// Only returns articles with Public visibility. |
| | | 94 | | /// Returns a hierarchical tree structure organized by virtual groups (Campaigns, Characters, Wiki). |
| | | 95 | | /// </summary> |
| | | 96 | | public async Task<List<ArticleTreeDto>> GetPublicArticleTreeAsync(string publicSlug) |
| | | 97 | | { |
| | | 98 | | var normalizedSlug = _readAccessPolicy.NormalizePublicSlug(publicSlug); |
| | | 99 | | |
| | | 100 | | // First, verify the world exists and is public |
| | | 101 | | var world = await _readAccessPolicy |
| | | 102 | | .ApplyPublicWorldSlugFilter(_context.Worlds.AsNoTracking(), normalizedSlug) |
| | | 103 | | .Include(w => w.Campaigns) |
| | | 104 | | .ThenInclude(c => c.Arcs) |
| | | 105 | | .ThenInclude(a => a.SessionEntities) |
| | | 106 | | .FirstOrDefaultAsync(); |
| | | 107 | | |
| | | 108 | | if (world == null) |
| | | 109 | | { |
| | | 110 | | _logger.LogTraceSanitized("Public world not found for slug '{PublicSlug}'", normalizedSlug); |
| | | 111 | | return new List<ArticleTreeDto>(); |
| | | 112 | | } |
| | | 113 | | |
| | | 114 | | // Get all public articles for this world |
| | | 115 | | var allPublicArticles = await _readAccessPolicy |
| | | 116 | | .ApplyPublicArticleFilter(_context.Articles.AsNoTracking(), world.Id) |
| | | 117 | | .Select(a => new ArticleTreeDto |
| | | 118 | | { |
| | | 119 | | Id = a.Id, |
| | | 120 | | Title = a.Title, |
| | | 121 | | Slug = a.Slug, |
| | | 122 | | ParentId = a.ParentId, |
| | | 123 | | WorldId = a.WorldId, |
| | | 124 | | CampaignId = a.CampaignId, |
| | | 125 | | ArcId = a.ArcId, |
| | | 126 | | SessionId = a.SessionId, |
| | | 127 | | Type = a.Type, |
| | | 128 | | Visibility = a.Visibility, |
| | | 129 | | HasChildren = false, // Will calculate below |
| | | 130 | | ChildCount = 0, // Will calculate below |
| | | 131 | | Children = new List<ArticleTreeDto>(), |
| | | 132 | | CreatedAt = a.CreatedAt, |
| | | 133 | | EffectiveDate = a.EffectiveDate, |
| | | 134 | | IconEmoji = a.IconEmoji, |
| | | 135 | | CreatedBy = a.CreatedBy |
| | | 136 | | }) |
| | | 137 | | .ToListAsync(); |
| | | 138 | | |
| | | 139 | | // Build article index and children relationships |
| | | 140 | | var articleIndex = allPublicArticles.ToDictionary(a => a.Id); |
| | | 141 | | var sessionNotesBySessionId = allPublicArticles |
| | | 142 | | .Where(a => a.Type == ArticleType.SessionNote && a.SessionId.HasValue) |
| | | 143 | | .GroupBy(a => a.SessionId!.Value) |
| | | 144 | | .ToDictionary(g => g.Key, g => g.ToList()); |
| | | 145 | | |
| | | 146 | | // Link children to parents |
| | | 147 | | foreach (var article in allPublicArticles) |
| | | 148 | | { |
| | | 149 | | if (article.ParentId.HasValue && articleIndex.TryGetValue(article.ParentId.Value, out var parent)) |
| | | 150 | | { |
| | | 151 | | parent.Children ??= new List<ArticleTreeDto>(); |
| | | 152 | | parent.Children.Add(article); |
| | | 153 | | parent.HasChildren = true; |
| | | 154 | | parent.ChildCount++; |
| | | 155 | | } |
| | | 156 | | } |
| | | 157 | | |
| | | 158 | | // Sort children by title |
| | | 159 | | foreach (var article in allPublicArticles.Where(a => a.Children?.Any() == true)) |
| | | 160 | | { |
| | | 161 | | article.Children = article.Children!.OrderBy(c => c.Title).ToList(); |
| | | 162 | | } |
| | | 163 | | |
| | | 164 | | // Build virtual groups |
| | | 165 | | var result = new List<ArticleTreeDto>(); |
| | | 166 | | |
| | | 167 | | // 1. Campaigns group - contains campaigns with their arcs and sessions |
| | | 168 | | var campaignsGroup = CreateVirtualGroup("campaigns", "Campaigns", "fa-solid fa-dungeon"); |
| | | 169 | | foreach (var campaign in world.Campaigns?.OrderBy(c => c.Name) ?? Enumerable.Empty<Chronicis.Shared.Models.Campa |
| | | 170 | | { |
| | | 171 | | var campaignNode = new ArticleTreeDto |
| | | 172 | | { |
| | | 173 | | Id = campaign.Id, |
| | | 174 | | Title = campaign.Name, |
| | | 175 | | Slug = campaign.Name.ToLowerInvariant().Replace(" ", "-"), |
| | | 176 | | Type = ArticleType.WikiArticle, // Use WikiArticle as placeholder |
| | | 177 | | IconEmoji = "fa-solid fa-dungeon", |
| | | 178 | | Children = new List<ArticleTreeDto>(), |
| | | 179 | | IsVirtualGroup = true |
| | | 180 | | }; |
| | | 181 | | |
| | | 182 | | // Add arcs under campaign |
| | | 183 | | foreach (var arc in campaign.Arcs?.OrderBy(a => a.SortOrder).ThenBy(a => a.Name) ?? Enumerable.Empty<Chronic |
| | | 184 | | { |
| | | 185 | | var arcNode = new ArticleTreeDto |
| | | 186 | | { |
| | | 187 | | Id = arc.Id, |
| | | 188 | | Title = arc.Name, |
| | | 189 | | Slug = arc.Name.ToLowerInvariant().Replace(" ", "-"), |
| | | 190 | | Type = ArticleType.WikiArticle, |
| | | 191 | | IconEmoji = "fa-solid fa-book-open", |
| | | 192 | | Children = new List<ArticleTreeDto>(), |
| | | 193 | | IsVirtualGroup = true |
| | | 194 | | }; |
| | | 195 | | |
| | | 196 | | var sessions = arc.SessionEntities? |
| | | 197 | | .OrderBy(s => s.SessionDate ?? DateTime.MaxValue) |
| | | 198 | | .ThenBy(s => s.Name) |
| | | 199 | | .ThenBy(s => s.CreatedAt) |
| | | 200 | | ?? Enumerable.Empty<Chronicis.Shared.Models.Session>(); |
| | | 201 | | |
| | | 202 | | foreach (var session in sessions) |
| | | 203 | | { |
| | | 204 | | var rootSessionNotes = GetRootSessionNotesForSession(sessionNotesBySessionId, session.Id); |
| | | 205 | | |
| | | 206 | | if (!rootSessionNotes.Any()) |
| | | 207 | | { |
| | | 208 | | continue; |
| | | 209 | | } |
| | | 210 | | |
| | | 211 | | var sessionNode = new ArticleTreeDto |
| | | 212 | | { |
| | | 213 | | Id = session.Id, |
| | | 214 | | Title = session.Name, |
| | | 215 | | Slug = session.Name.ToLowerInvariant().Replace(" ", "-"), |
| | | 216 | | Type = ArticleType.Session, |
| | | 217 | | IconEmoji = "fa-solid fa-calendar-day", |
| | | 218 | | Children = new List<ArticleTreeDto>(), |
| | | 219 | | IsVirtualGroup = true, |
| | | 220 | | HasAISummary = !string.IsNullOrWhiteSpace(session.AiSummary) |
| | | 221 | | }; |
| | | 222 | | |
| | | 223 | | foreach (var note in rootSessionNotes) |
| | | 224 | | { |
| | | 225 | | sessionNode.Children.Add(note); |
| | | 226 | | sessionNode.HasChildren = true; |
| | | 227 | | sessionNode.ChildCount++; |
| | | 228 | | } |
| | | 229 | | |
| | | 230 | | arcNode.Children.Add(sessionNode); |
| | | 231 | | arcNode.HasChildren = true; |
| | | 232 | | arcNode.ChildCount++; |
| | | 233 | | } |
| | | 234 | | |
| | | 235 | | if (arcNode.Children.Any()) |
| | | 236 | | { |
| | | 237 | | campaignNode.Children.Add(arcNode); |
| | | 238 | | campaignNode.HasChildren = true; |
| | | 239 | | campaignNode.ChildCount++; |
| | | 240 | | } |
| | | 241 | | } |
| | | 242 | | |
| | | 243 | | if (campaignNode.Children.Any()) |
| | | 244 | | { |
| | | 245 | | campaignsGroup.Children!.Add(campaignNode); |
| | | 246 | | campaignsGroup.HasChildren = true; |
| | | 247 | | campaignsGroup.ChildCount++; |
| | | 248 | | } |
| | | 249 | | } |
| | | 250 | | |
| | | 251 | | if (campaignsGroup.Children!.Any()) |
| | | 252 | | { |
| | | 253 | | result.Add(campaignsGroup); |
| | | 254 | | } |
| | | 255 | | |
| | | 256 | | // 2. Player Characters group |
| | | 257 | | var charactersGroup = CreateVirtualGroup("characters", "Player Characters", "fa-solid fa-user-ninja"); |
| | | 258 | | var characterArticles = allPublicArticles |
| | | 259 | | .Where(a => a.Type == ArticleType.Character && a.ParentId == null) |
| | | 260 | | .OrderBy(a => a.Title) |
| | | 261 | | .ToList(); |
| | | 262 | | |
| | | 263 | | foreach (var article in characterArticles) |
| | | 264 | | { |
| | | 265 | | charactersGroup.Children!.Add(article); |
| | | 266 | | charactersGroup.HasChildren = true; |
| | | 267 | | charactersGroup.ChildCount++; |
| | | 268 | | } |
| | | 269 | | |
| | | 270 | | if (charactersGroup.Children!.Any()) |
| | | 271 | | { |
| | | 272 | | result.Add(charactersGroup); |
| | | 273 | | } |
| | | 274 | | |
| | | 275 | | // 3. Wiki group |
| | | 276 | | var wikiGroup = CreateVirtualGroup("wiki", "Wiki", "fa-solid fa-book"); |
| | | 277 | | var wikiArticles = allPublicArticles |
| | | 278 | | .Where(a => a.Type == ArticleType.WikiArticle && a.ParentId == null) |
| | | 279 | | .OrderBy(a => a.Title) |
| | | 280 | | .ToList(); |
| | | 281 | | |
| | | 282 | | foreach (var article in wikiArticles) |
| | | 283 | | { |
| | | 284 | | wikiGroup.Children!.Add(article); |
| | | 285 | | wikiGroup.HasChildren = true; |
| | | 286 | | wikiGroup.ChildCount++; |
| | | 287 | | } |
| | | 288 | | |
| | | 289 | | if (wikiGroup.Children!.Any()) |
| | | 290 | | { |
| | | 291 | | result.Add(wikiGroup); |
| | | 292 | | } |
| | | 293 | | |
| | | 294 | | // 4. Uncategorized (Legacy and other types without parents not already included) |
| | | 295 | | var includedIds = new HashSet<Guid>(); |
| | | 296 | | |
| | | 297 | | // Collect all IDs from campaigns/arcs/sessions |
| | | 298 | | foreach (var campaign in result.FirstOrDefault(r => r.Slug == "campaigns")?.Children ?? new List<ArticleTreeDto> |
| | | 299 | | { |
| | | 300 | | foreach (var arc in campaign.Children ?? new List<ArticleTreeDto>()) |
| | | 301 | | { |
| | | 302 | | foreach (var session in arc.Children ?? new List<ArticleTreeDto>()) |
| | | 303 | | { |
| | | 304 | | CollectArticleIds(session, includedIds); |
| | | 305 | | } |
| | | 306 | | } |
| | | 307 | | } |
| | | 308 | | |
| | | 309 | | // Collect character and wiki IDs |
| | | 310 | | foreach (var article in characterArticles) |
| | | 311 | | { |
| | | 312 | | CollectArticleIds(article, includedIds); |
| | | 313 | | } |
| | | 314 | | foreach (var article in wikiArticles) |
| | | 315 | | { |
| | | 316 | | CollectArticleIds(article, includedIds); |
| | | 317 | | } |
| | | 318 | | |
| | | 319 | | var uncategorizedArticles = allPublicArticles |
| | | 320 | | .Where(a => a.ParentId == null && |
| | | 321 | | !includedIds.Contains(a.Id)) |
| | | 322 | | .OrderBy(a => a.Title) |
| | | 323 | | .ToList(); |
| | | 324 | | |
| | | 325 | | if (uncategorizedArticles.Any()) |
| | | 326 | | { |
| | | 327 | | var uncategorizedGroup = CreateVirtualGroup("uncategorized", "Uncategorized", "fa-solid fa-folder"); |
| | | 328 | | foreach (var article in uncategorizedArticles) |
| | | 329 | | { |
| | | 330 | | uncategorizedGroup.Children!.Add(article); |
| | | 331 | | uncategorizedGroup.HasChildren = true; |
| | | 332 | | uncategorizedGroup.ChildCount++; |
| | | 333 | | } |
| | | 334 | | result.Add(uncategorizedGroup); |
| | | 335 | | } |
| | | 336 | | |
| | | 337 | | _logger.LogTraceSanitized("Retrieved {Count} public articles for world '{PublicSlug}' in {GroupCount} groups", |
| | | 338 | | allPublicArticles.Count, normalizedSlug, result.Count); |
| | | 339 | | |
| | | 340 | | return result; |
| | | 341 | | } |
| | | 342 | | |
| | | 343 | | private static List<ArticleTreeDto> GetRootSessionNotesForSession( |
| | | 344 | | IReadOnlyDictionary<Guid, List<ArticleTreeDto>> sessionNotesBySessionId, |
| | | 345 | | Guid sessionId) |
| | | 346 | | { |
| | 4 | 347 | | if (!sessionNotesBySessionId.TryGetValue(sessionId, out var notes)) |
| | | 348 | | { |
| | 1 | 349 | | return new List<ArticleTreeDto>(); |
| | | 350 | | } |
| | | 351 | | |
| | 3 | 352 | | var sessionNoteIds = notes.Select(n => n.Id).ToHashSet(); |
| | 3 | 353 | | return notes |
| | 3 | 354 | | .Where(n => !n.ParentId.HasValue || !sessionNoteIds.Contains(n.ParentId.Value)) |
| | 3 | 355 | | .OrderBy(n => n.Title) |
| | 3 | 356 | | .ToList(); |
| | | 357 | | } |
| | | 358 | | |
| | | 359 | | private static ArticleTreeDto CreateVirtualGroup(string slug, string title, string icon) |
| | | 360 | | { |
| | 7 | 361 | | return new ArticleTreeDto |
| | 7 | 362 | | { |
| | 7 | 363 | | Id = Guid.NewGuid(), // Virtual ID |
| | 7 | 364 | | Title = title, |
| | 7 | 365 | | Slug = slug, |
| | 7 | 366 | | Type = ArticleType.WikiArticle, |
| | 7 | 367 | | IconEmoji = icon, |
| | 7 | 368 | | HasChildren = false, |
| | 7 | 369 | | ChildCount = 0, |
| | 7 | 370 | | Children = new List<ArticleTreeDto>(), |
| | 7 | 371 | | IsVirtualGroup = true |
| | 7 | 372 | | }; |
| | | 373 | | } |
| | | 374 | | |
| | | 375 | | private static void CollectArticleIds(ArticleTreeDto article, HashSet<Guid> ids) |
| | | 376 | | { |
| | 6 | 377 | | ids.Add(article.Id); |
| | 6 | 378 | | if (article.Children != null) |
| | | 379 | | { |
| | 16 | 380 | | foreach (var child in article.Children) |
| | | 381 | | { |
| | 3 | 382 | | CollectArticleIds(child, ids); |
| | | 383 | | } |
| | | 384 | | } |
| | 6 | 385 | | } |
| | | 386 | | |
| | | 387 | | /// <summary> |
| | | 388 | | /// Get a specific article by path in a public world. |
| | | 389 | | /// Returns null if article doesn't exist, world is not public, or article is not Public visibility. |
| | | 390 | | /// Path format: "article-slug/child-slug" (does not include world slug) |
| | | 391 | | /// </summary> |
| | | 392 | | public async Task<ArticleDto?> GetPublicArticleAsync(string publicSlug, string articlePath) |
| | | 393 | | { |
| | | 394 | | var normalizedSlug = _readAccessPolicy.NormalizePublicSlug(publicSlug); |
| | | 395 | | |
| | | 396 | | // First, verify the world exists and is public |
| | | 397 | | var world = await _readAccessPolicy |
| | | 398 | | .ApplyPublicWorldSlugFilter(_context.Worlds.AsNoTracking(), normalizedSlug) |
| | | 399 | | .Select(w => new { w.Id, w.Name, w.Slug }) |
| | | 400 | | .FirstOrDefaultAsync(); |
| | | 401 | | |
| | | 402 | | if (world == null) |
| | | 403 | | { |
| | | 404 | | _logger.LogTraceSanitized("Public world not found for slug '{PublicSlug}'", normalizedSlug); |
| | | 405 | | return null; |
| | | 406 | | } |
| | | 407 | | |
| | | 408 | | if (string.IsNullOrWhiteSpace(articlePath)) |
| | | 409 | | { |
| | | 410 | | _logger.LogTraceSanitized("Empty article path for public world '{PublicSlug}'", normalizedSlug); |
| | | 411 | | return null; |
| | | 412 | | } |
| | | 413 | | |
| | | 414 | | var slugs = articlePath.Split('/', StringSplitOptions.RemoveEmptyEntries); |
| | | 415 | | if (slugs.Length == 0) |
| | | 416 | | return null; |
| | | 417 | | |
| | | 418 | | var resolvedArticle = await ArticleSlugPathResolver.ResolveAsync( |
| | | 419 | | slugs, |
| | | 420 | | async (slug, parentId, isRootLevel) => |
| | | 421 | | { |
| | | 422 | | var query = _readAccessPolicy |
| | | 423 | | .ApplyPublicArticleFilter(_context.Articles.AsNoTracking(), world.Id) |
| | | 424 | | .Where(a => a.Slug == slug); |
| | | 425 | | |
| | | 426 | | query = isRootLevel |
| | | 427 | | ? query.Where(a => a.ParentId == null) |
| | | 428 | | : query.Where(a => a.ParentId == parentId); |
| | | 429 | | |
| | | 430 | | var article = await query |
| | | 431 | | .Select(a => new { a.Id, a.Type }) |
| | | 432 | | .FirstOrDefaultAsync(); |
| | | 433 | | |
| | | 434 | | return article == null |
| | | 435 | | ? null |
| | | 436 | | : (article.Id, article.Type); |
| | | 437 | | }, |
| | | 438 | | (_, _, _) => Task.FromResult<(Guid Id, ArticleType Type)?>(null)); |
| | | 439 | | |
| | | 440 | | if (resolvedArticle == null) |
| | | 441 | | { |
| | | 442 | | _logger.LogTraceSanitized("Public article not found for path '{Path}' in world '{PublicSlug}'", |
| | | 443 | | articlePath, normalizedSlug); |
| | | 444 | | return null; |
| | | 445 | | } |
| | | 446 | | |
| | | 447 | | // Found the article, now get full details |
| | | 448 | | var article = await _readAccessPolicy |
| | | 449 | | .ApplyPublicArticleFilter(_context.Articles.AsNoTracking(), world.Id) |
| | | 450 | | .Where(a => a.Id == resolvedArticle.Value.Id) |
| | | 451 | | .Select(ArticleReadModelProjection.ArticleDetail) |
| | | 452 | | .FirstOrDefaultAsync(); |
| | | 453 | | |
| | | 454 | | if (article == null) |
| | | 455 | | return null; |
| | | 456 | | |
| | | 457 | | // Build breadcrumbs (only including public articles) using centralised hierarchy service |
| | | 458 | | article.Breadcrumbs = await _hierarchyService.BuildBreadcrumbsAsync(resolvedArticle.Value.Id, new HierarchyWalkO |
| | | 459 | | { |
| | | 460 | | PublicOnly = true, |
| | | 461 | | IncludeWorldBreadcrumb = true, |
| | | 462 | | IncludeVirtualGroups = true, |
| | | 463 | | World = new WorldContext { Id = world.Id, Name = world.Name, Slug = world.Slug } |
| | | 464 | | }); |
| | | 465 | | |
| | | 466 | | _logger.LogTraceSanitized("Public article '{Title}' accessed in world '{PublicSlug}'", |
| | | 467 | | article.Title, normalizedSlug); |
| | | 468 | | |
| | | 469 | | return article; |
| | | 470 | | } |
| | | 471 | | |
| | | 472 | | /// <summary> |
| | | 473 | | /// Resolve an article ID to its public URL path. |
| | | 474 | | /// Returns null if the article doesn't exist, is not public, or doesn't belong to the specified world. |
| | | 475 | | /// </summary> |
| | | 476 | | public async Task<string?> GetPublicArticlePathAsync(string publicSlug, Guid articleId) |
| | | 477 | | { |
| | | 478 | | var normalizedSlug = _readAccessPolicy.NormalizePublicSlug(publicSlug); |
| | | 479 | | |
| | | 480 | | // Verify the world exists and is public |
| | | 481 | | var world = await _readAccessPolicy |
| | | 482 | | .ApplyPublicWorldSlugFilter(_context.Worlds.AsNoTracking(), normalizedSlug) |
| | | 483 | | .Select(w => new { w.Id }) |
| | | 484 | | .FirstOrDefaultAsync(); |
| | | 485 | | |
| | | 486 | | if (world == null) |
| | | 487 | | { |
| | | 488 | | _logger.LogTraceSanitized("Public world not found for slug '{PublicSlug}'", normalizedSlug); |
| | | 489 | | return null; |
| | | 490 | | } |
| | | 491 | | |
| | | 492 | | // Get the article and verify it's public and belongs to this world |
| | | 493 | | var article = await _readAccessPolicy |
| | | 494 | | .ApplyPublicArticleFilter(_context.Articles.AsNoTracking(), world.Id) |
| | | 495 | | .Where(a => a.Id == articleId) |
| | | 496 | | .Select(a => new { a.Id, a.Slug, a.ParentId }) |
| | | 497 | | .FirstOrDefaultAsync(); |
| | | 498 | | |
| | | 499 | | if (article == null) |
| | | 500 | | { |
| | | 501 | | _logger.LogTraceSanitized("Public article {ArticleId} not found in world '{PublicSlug}'", articleId, normali |
| | | 502 | | return null; |
| | | 503 | | } |
| | | 504 | | |
| | | 505 | | // Build the path by walking up the parent tree. |
| | | 506 | | var slugs = new List<string> { article.Slug }; |
| | | 507 | | |
| | | 508 | | var currentParentId = article.ParentId; |
| | | 509 | | |
| | | 510 | | while (currentParentId.HasValue) |
| | | 511 | | { |
| | | 512 | | var parentArticle = await _readAccessPolicy |
| | | 513 | | .ApplyPublicArticleFilter(_context.Articles.AsNoTracking(), world.Id) |
| | | 514 | | .Where(a => a.Id == currentParentId.Value) |
| | | 515 | | .Select(a => new { a.Slug, a.ParentId }) |
| | | 516 | | .FirstOrDefaultAsync(); |
| | | 517 | | |
| | | 518 | | if (parentArticle == null) |
| | | 519 | | { |
| | | 520 | | // Parent is not public - this article's path is broken |
| | | 521 | | _logger.LogTraceSanitized("Parent article not public in chain for article {ArticleId}", articleId); |
| | | 522 | | return null; |
| | | 523 | | } |
| | | 524 | | |
| | | 525 | | slugs.Insert(0, parentArticle.Slug); |
| | | 526 | | currentParentId = parentArticle.ParentId; |
| | | 527 | | } |
| | | 528 | | |
| | | 529 | | var path = string.Join("/", slugs); |
| | | 530 | | _logger.LogTraceSanitized("Resolved article {ArticleId} to path '{Path}' in world '{PublicSlug}'", |
| | | 531 | | articleId, path, normalizedSlug); |
| | | 532 | | |
| | | 533 | | return path; |
| | | 534 | | } |
| | | 535 | | |
| | | 536 | | /// <summary> |
| | | 537 | | /// Resolve a public inline-image document ID to a fresh download URL. |
| | | 538 | | /// The document must be an image attached to a public article in a public world. |
| | | 539 | | /// </summary> |
| | | 540 | | public async Task<string?> GetPublicDocumentDownloadUrlAsync(Guid documentId) |
| | | 541 | | { |
| | | 542 | | var publicArticles = _readAccessPolicy |
| | | 543 | | .ApplyPublicVisibilityFilter(_context.Articles.AsNoTracking()); |
| | | 544 | | var publicWorlds = _readAccessPolicy |
| | | 545 | | .ApplyPublicWorldFilter(_context.Worlds.AsNoTracking()); |
| | | 546 | | |
| | | 547 | | var document = await _context.WorldDocuments |
| | | 548 | | .AsNoTracking() |
| | | 549 | | .Where(d => d.Id == documentId |
| | | 550 | | && d.ArticleId.HasValue |
| | | 551 | | && d.ContentType.StartsWith("image/")) |
| | | 552 | | .Join( |
| | | 553 | | publicArticles, |
| | | 554 | | d => d.ArticleId, |
| | | 555 | | a => a.Id, |
| | | 556 | | (d, a) => new |
| | | 557 | | { |
| | | 558 | | d.BlobPath, |
| | | 559 | | d.WorldId, |
| | | 560 | | ArticleWorldId = a.WorldId |
| | | 561 | | }) |
| | | 562 | | .Join( |
| | | 563 | | publicWorlds, |
| | | 564 | | joined => joined.WorldId, |
| | | 565 | | w => w.Id, |
| | | 566 | | (joined, _) => joined) |
| | | 567 | | .FirstOrDefaultAsync(); |
| | | 568 | | |
| | | 569 | | if (document == null |
| | | 570 | | || document.ArticleWorldId != document.WorldId) |
| | | 571 | | { |
| | | 572 | | return null; |
| | | 573 | | } |
| | | 574 | | |
| | | 575 | | return await _blobStorage.GenerateDownloadSasUrlAsync(document.BlobPath); |
| | | 576 | | } |
| | | 577 | | |
| | | 578 | | /// <inheritdoc /> |
| | | 579 | | public async Task<(GetBasemapReadUrlResponseDto? Basemap, string? Error)> GetPublicMapBasemapReadUrlAsync(string pub |
| | | 580 | | { |
| | | 581 | | var map = await GetPublicMapAsync(publicSlug, mapId); |
| | | 582 | | if (map == null) |
| | | 583 | | { |
| | | 584 | | return (null, "Map not found or not public"); |
| | | 585 | | } |
| | | 586 | | |
| | | 587 | | if (string.IsNullOrWhiteSpace(map.BasemapBlobKey)) |
| | | 588 | | { |
| | | 589 | | return (null, "Basemap is missing for this map."); |
| | | 590 | | } |
| | | 591 | | |
| | | 592 | | var readUrl = await _mapBlobStore.GenerateReadSasUrlAsync(map.BasemapBlobKey); |
| | | 593 | | return (new GetBasemapReadUrlResponseDto { ReadUrl = readUrl }, null); |
| | | 594 | | } |
| | | 595 | | |
| | | 596 | | /// <inheritdoc /> |
| | | 597 | | public async Task<List<MapLayerDto>?> GetPublicMapLayersAsync(string publicSlug, Guid mapId) |
| | | 598 | | { |
| | | 599 | | var map = await GetPublicMapAsync(publicSlug, mapId); |
| | | 600 | | if (map == null) |
| | | 601 | | { |
| | | 602 | | return null; |
| | | 603 | | } |
| | | 604 | | |
| | | 605 | | return await _context.MapLayers |
| | | 606 | | .AsNoTracking() |
| | | 607 | | .Where(layer => layer.WorldMapId == mapId) |
| | | 608 | | .OrderBy(layer => layer.SortOrder) |
| | | 609 | | .Select(layer => new MapLayerDto |
| | | 610 | | { |
| | | 611 | | MapLayerId = layer.MapLayerId, |
| | | 612 | | ParentLayerId = layer.ParentLayerId, |
| | | 613 | | Name = layer.Name, |
| | | 614 | | SortOrder = layer.SortOrder, |
| | | 615 | | IsEnabled = layer.IsEnabled, |
| | | 616 | | }) |
| | | 617 | | .ToListAsync(); |
| | | 618 | | } |
| | | 619 | | |
| | | 620 | | /// <inheritdoc /> |
| | | 621 | | public async Task<List<MapPinResponseDto>?> GetPublicMapPinsAsync(string publicSlug, Guid mapId) |
| | | 622 | | { |
| | | 623 | | var map = await GetPublicMapAsync(publicSlug, mapId); |
| | | 624 | | if (map == null) |
| | | 625 | | { |
| | | 626 | | return null; |
| | | 627 | | } |
| | | 628 | | |
| | | 629 | | var pins = await _context.MapFeatures |
| | | 630 | | .AsNoTracking() |
| | | 631 | | .Where(feature => feature.WorldMapId == mapId && feature.FeatureType == MapFeatureType.Point) |
| | | 632 | | .OrderBy(feature => feature.MapFeatureId) |
| | | 633 | | .Select(feature => new |
| | | 634 | | { |
| | | 635 | | feature.MapFeatureId, |
| | | 636 | | feature.WorldMapId, |
| | | 637 | | feature.MapLayerId, |
| | | 638 | | feature.Name, |
| | | 639 | | X = feature.X, |
| | | 640 | | Y = feature.Y, |
| | | 641 | | feature.LinkedArticleId, |
| | | 642 | | }) |
| | | 643 | | .ToListAsync(); |
| | | 644 | | |
| | | 645 | | var linkedArticles = await GetPublicLinkedArticleTitlesAsync( |
| | | 646 | | map.WorldId, |
| | | 647 | | pins.Where(pin => pin.LinkedArticleId.HasValue).Select(pin => pin.LinkedArticleId!.Value)); |
| | | 648 | | |
| | | 649 | | return pins |
| | | 650 | | .Select(pin => new MapPinResponseDto |
| | | 651 | | { |
| | | 652 | | PinId = pin.MapFeatureId, |
| | | 653 | | MapId = pin.WorldMapId, |
| | | 654 | | LayerId = pin.MapLayerId, |
| | | 655 | | Name = pin.Name, |
| | | 656 | | X = pin.X, |
| | | 657 | | Y = pin.Y, |
| | | 658 | | LinkedArticle = pin.LinkedArticleId.HasValue |
| | | 659 | | && linkedArticles.TryGetValue(pin.LinkedArticleId.Value, out var title) |
| | | 660 | | ? new LinkedArticleSummaryDto |
| | | 661 | | { |
| | | 662 | | ArticleId = pin.LinkedArticleId.Value, |
| | | 663 | | Title = title, |
| | | 664 | | } |
| | | 665 | | : null, |
| | | 666 | | }) |
| | | 667 | | .ToList(); |
| | | 668 | | } |
| | | 669 | | |
| | | 670 | | /// <inheritdoc /> |
| | | 671 | | public async Task<List<MapFeatureDto>?> GetPublicMapFeaturesAsync(string publicSlug, Guid mapId) |
| | | 672 | | { |
| | | 673 | | var map = await GetPublicMapAsync(publicSlug, mapId); |
| | | 674 | | if (map == null) |
| | | 675 | | { |
| | | 676 | | return null; |
| | | 677 | | } |
| | | 678 | | |
| | | 679 | | var features = await _context.MapFeatures |
| | | 680 | | .AsNoTracking() |
| | | 681 | | .Where(feature => feature.WorldMapId == mapId) |
| | | 682 | | .OrderBy(feature => feature.MapFeatureId) |
| | | 683 | | .ToListAsync(); |
| | | 684 | | |
| | | 685 | | return await ToPublicMapFeatureDtosAsync(map.WorldId, features); |
| | | 686 | | } |
| | | 687 | | |
| | | 688 | | private async Task<PublicMapLookup?> GetPublicMapAsync(string publicSlug, Guid mapId) |
| | | 689 | | { |
| | | 690 | | var normalizedSlug = _readAccessPolicy.NormalizePublicSlug(publicSlug); |
| | | 691 | | var worldId = await _readAccessPolicy |
| | | 692 | | .ApplyPublicWorldSlugFilter(_context.Worlds.AsNoTracking(), normalizedSlug) |
| | | 693 | | .Select(world => (Guid?)world.Id) |
| | | 694 | | .FirstOrDefaultAsync(); |
| | | 695 | | |
| | | 696 | | if (!worldId.HasValue) |
| | | 697 | | { |
| | | 698 | | return null; |
| | | 699 | | } |
| | | 700 | | |
| | | 701 | | return await _context.WorldMaps |
| | | 702 | | .AsNoTracking() |
| | | 703 | | .Where(map => map.WorldId == worldId.Value && map.WorldMapId == mapId) |
| | | 704 | | .Select(map => new PublicMapLookup |
| | | 705 | | { |
| | | 706 | | WorldId = map.WorldId, |
| | | 707 | | BasemapBlobKey = map.BasemapBlobKey, |
| | | 708 | | }) |
| | | 709 | | .FirstOrDefaultAsync(); |
| | | 710 | | } |
| | | 711 | | |
| | | 712 | | private async Task<Dictionary<Guid, string>> GetPublicLinkedArticleTitlesAsync(Guid worldId, IEnumerable<Guid> linke |
| | | 713 | | { |
| | | 714 | | var ids = linkedArticleIds |
| | | 715 | | .Distinct() |
| | | 716 | | .ToList(); |
| | | 717 | | |
| | | 718 | | if (ids.Count == 0) |
| | | 719 | | { |
| | | 720 | | return []; |
| | | 721 | | } |
| | | 722 | | |
| | | 723 | | return await _readAccessPolicy |
| | | 724 | | .ApplyPublicArticleFilter(_context.Articles.AsNoTracking(), worldId) |
| | | 725 | | .Where(article => ids.Contains(article.Id)) |
| | | 726 | | .ToDictionaryAsync(article => article.Id, article => article.Title); |
| | | 727 | | } |
| | | 728 | | |
| | | 729 | | private Task<Dictionary<Guid, string>> GetPublicLinkedArticleTitlesAsync(Guid worldId, IEnumerable<MapFeature> featu |
| | | 730 | | { |
| | 2 | 731 | | return GetPublicLinkedArticleTitlesAsync( |
| | 2 | 732 | | worldId, |
| | 2 | 733 | | features |
| | 2 | 734 | | .Where(feature => feature.LinkedArticleId.HasValue) |
| | 2 | 735 | | .Select(feature => feature.LinkedArticleId!.Value)); |
| | | 736 | | } |
| | | 737 | | |
| | | 738 | | private async Task<List<MapFeatureDto>> ToPublicMapFeatureDtosAsync(Guid worldId, IReadOnlyCollection<MapFeature> fe |
| | | 739 | | { |
| | | 740 | | var linkedArticles = await GetPublicLinkedArticleTitlesAsync(worldId, features); |
| | | 741 | | var results = new List<MapFeatureDto>(features.Count); |
| | | 742 | | |
| | | 743 | | foreach (var feature in features) |
| | | 744 | | { |
| | | 745 | | PolygonGeometryDto? polygon = null; |
| | | 746 | | if (feature.FeatureType == MapFeatureType.Polygon && !string.IsNullOrWhiteSpace(feature.GeometryBlobKey)) |
| | | 747 | | { |
| | | 748 | | var geometryJson = await _mapBlobStore.LoadFeatureGeometryAsync(feature.GeometryBlobKey); |
| | | 749 | | polygon = geometryJson == null |
| | | 750 | | ? null |
| | | 751 | | : JsonSerializer.Deserialize<PolygonGeometryDto>(geometryJson, GeometryJsonOptions); |
| | | 752 | | } |
| | | 753 | | |
| | | 754 | | results.Add(new MapFeatureDto |
| | | 755 | | { |
| | | 756 | | FeatureId = feature.MapFeatureId, |
| | | 757 | | MapId = feature.WorldMapId, |
| | | 758 | | LayerId = feature.MapLayerId, |
| | | 759 | | FeatureType = feature.FeatureType, |
| | | 760 | | Name = feature.Name, |
| | | 761 | | Color = feature.Color, |
| | | 762 | | LinkedArticleId = feature.LinkedArticleId, |
| | | 763 | | LinkedArticle = feature.LinkedArticleId.HasValue |
| | | 764 | | && linkedArticles.TryGetValue(feature.LinkedArticleId.Value, out var title) |
| | | 765 | | ? new LinkedArticleSummaryDto |
| | | 766 | | { |
| | | 767 | | ArticleId = feature.LinkedArticleId.Value, |
| | | 768 | | Title = title, |
| | | 769 | | } |
| | | 770 | | : null, |
| | | 771 | | Point = feature.FeatureType == MapFeatureType.Point |
| | | 772 | | ? new MapFeaturePointDto |
| | | 773 | | { |
| | | 774 | | X = feature.X, |
| | | 775 | | Y = feature.Y, |
| | | 776 | | } |
| | | 777 | | : null, |
| | | 778 | | Polygon = polygon, |
| | | 779 | | Geometry = feature.FeatureType == MapFeatureType.Polygon && !string.IsNullOrWhiteSpace(feature.GeometryB |
| | | 780 | | ? new MapFeatureGeometryReferenceDto |
| | | 781 | | { |
| | | 782 | | BlobKey = feature.GeometryBlobKey, |
| | | 783 | | ETag = feature.GeometryETag, |
| | | 784 | | } |
| | | 785 | | : null, |
| | | 786 | | }); |
| | | 787 | | } |
| | | 788 | | |
| | | 789 | | return results; |
| | | 790 | | } |
| | | 791 | | |
| | | 792 | | private sealed class PublicMapLookup |
| | | 793 | | { |
| | | 794 | | public Guid WorldId { get; init; } |
| | | 795 | | |
| | | 796 | | public string? BasemapBlobKey { get; init; } |
| | | 797 | | } |
| | | 798 | | } |