using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using System.Security.Cryptography; using Campus_live_rewrite.api.dbs; using Campus_live_rewrite.api.server; using Campus_live_rewrite.api.server.Classes; using Campus_live_rewrite.api.server.Classes.db_class; namespace Campus_live_rewrite.Controllers { /// /// Settings > Export, the parts that need the server's data: "Export Data" (the player's own account as one JSON file) and /// "Export Rooms" (the rooms they CREATED, in the same folder layout the admin Archive import reads). Like Export Photos, /// the server only answers questions - the browser builds the zip, nothing is assembled or stored here. Auth is the WEBSITE /// session (campus_site cookie), and every room route re-checks that the signed-in player is the room's creator. /// /// What is deliberately NOT exported: the password hash, login tokens, device ids and IP addresses, 2FA secrets, messages /// with other players, and moderation records - they are either security material or other people's data. /// public class SiteExportController : ControllerBase { private const string SiteCookie = "campus_site"; private player_info? CurrentUser() { string token = Request.Cookies[SiteCookie] ?? ""; if (string.IsNullOrEmpty(token)) return null; string data = ClientSecurity.DecodeToken(token, requireSurface: "website"); if (string.IsNullOrEmpty(data) || data.StartsWith("error_")) return null; try { return JsonConvert.DeserializeObject(data); } catch { return null; } } private static string BlobDir => Path.Combine(Environment.CurrentDirectory, "Data", "CDN", "RoomBlobs"); private static string ImageDir => Path.Combine(Environment.CurrentDirectory, "Data", "Images"); // --------------------------------------------------------------------------------------------- // Export Data // --------------------------------------------------------------------------------------------- // GET /api/site/me/export - the player's own account as JSON. The browser saves it as a file. [HttpGet("/api/site/me/export")] public IActionResult ExportData() { var me = CurrentUser(); if (me == null) return Unauthorized(new { ok = false, error = "not signed in" }); var full = PlayerDB.GetFullAccount(me.Id); if (full?.player == null) return NotFound(new { ok = false, error = "account not found" }); var p = full.player; var x = full.player_Extra ?? new player_extra(); string Name(ulong id) => PlayerDB.GetAccountById(id)?.Username ?? ""; var sitePrefs = SitePrefsDB.Get(me.Id); var friends = new List(); try { foreach (var r in FriendsDB.GetRelationships(me.Id)) { ulong other = r.PlayerID == me.Id ? r.OtherPlayerID : r.PlayerID; friends.Add(new { accountId = other, username = Name(other), displayName = PlayerDB.GetAccountById(other)?.DisplayName ?? "", relationship = r.RelationshipType.ToString(), favorited = r.Favorited.ToString(), muted = r.Muted.ToString(), ignored = r.Ignored.ToString(), voiceVolume = r.VoiceVolume, nickname = SitePrefsDB.NicknameFor(me.Id, other), }); } } catch (Exception ex) { Console.WriteLine($"[export] {me.Id} friends failed: {ex.Message}"); } // Settings can hold anything the game stores; leave out anything that looks like a credential. var settings = new Dictionary(); try { foreach (var s in SettingsDB.LoadSettings(me.Id)) { if (string.IsNullOrEmpty(s.Key)) continue; string k = s.Key.ToLowerInvariant(); if (k.Contains("token") || k.Contains("password") || k.Contains("secret")) continue; settings[s.Key] = s.Value; } } catch (Exception ex) { Console.WriteLine($"[export] {me.Id} settings failed: {ex.Message}"); } var rooms = RoomDB.col.Find(r => r.CreatorAccountId == me.Id).Select(r => new { roomId = r.RoomId, name = r.Name, description = r.Description, isDorm = r.IsDorm, createdAt = r.CreatedAt, accessibility = r.Accessibility.ToString(), }).ToList(); var photos = new List(); try { foreach (var i in ImageMetadataDB.GetPlayerFeed(me.Id, me.Id, 0, 5000) ?? new()) photos.Add(new { id = i.SavedImageId, file = i.ImageName, description = i.Description, createdAt = i.CreatedAt, takenByMe = i.PlayerId == me.Id, taggedMe = i.TaggedPlayerIds != null && i.TaggedPlayerIds.Contains(me.Id), }); } catch (Exception ex) { Console.WriteLine($"[export] {me.Id} photos failed: {ex.Message}"); } var inventions = new List(); try { foreach (var v in InventionDB.GetByCreator(me.Id)) inventions.Add(new { id = v.InventionId, name = v.Name, description = v.Description, published = v.IsPublished, createdAt = v.CreatedAt, modifiedAt = v.ModifiedAt, tags = v.Tags }); } catch (Exception ex) { Console.WriteLine($"[export] {me.Id} inventions failed: {ex.Message}"); } object? wishlist = null; try { wishlist = WishlistDB.For(me.Id).Select(w => new { storefront = w.StorefrontType, itemId = w.PurchasableItemId, customItem = w.CustomAvatarItemId, addedAt = w.CreatedAt }).ToList(); } catch { } var streak = LoginStreaks.Get(me.Id); var doc = new Dictionary { ["format"] = "campus-account-export", ["version"] = 1, ["exportedAt"] = DateTime.UtcNow, ["notIncluded"] = "Password, login tokens, device and IP records, two-factor secrets, messages with other players and moderation records are never exported.", ["account"] = new { accountId = p.Id, username = p.Username, displayName = p.DisplayName, displayEmoji = p.DisplayEmoji, email = p.Email, birthday = p.Birthday, bio = x.Bio, level = p.Level, xp = p.XP, isJunior = p.IsJunior, avoidJuniors = p.AvoidJuniors, pronounFlags = p.pronounFlags, identityFlags = p.identityFlags, createdAt = p.created_at, lastLogin = p.last_login_time, profileImage = p.profileImage, bannerImage = p.bannerImage, platforms = (p.PlatformIds ?? new()).Select(i => i.Platform.ToString()).Distinct().ToList(), photoTagging = x.PlayerPhotoTaggingSetting.ToString(), }, ["balances"] = x.Balances.ToDictionary(b => Enum.IsDefined(typeof(CurrencyType), b.Key) ? ((CurrencyType)b.Key).ToString() : b.Key.ToString(), b => b.Value), ["avatar"] = new { current = x.avatar, savedOutfits = x.SavedOutfits, ownedItems = x.AvatarItems, customItems = x.CustomItems, equipmentSkins = x.EquipmentSkins, consumables = x.Consumables, }, ["social"] = new { friends, followingAccountIds = x.SubbedTo, followerCount = x.sub_count, favoritedRoomIds = x.FavoritedRooms, cheeredRoomIds = x.CheeredRooms, recentRoomIds = p.recent_rooms, }, ["progress"] = new { dailyRewardClaims = x.DailyRewardClaims, objectives = x.Objectives, objectiveGroups = x.ObjectiveGroups, challengeStatuses = x.ChallengeStatuses, loginStreak = streak == null ? null : new { current = streak.Current, best = streak.Best, totalDays = streak.TotalDays, lastDay = streak.LastDay }, }, ["settings"] = settings, ["website"] = new { pinnedFriendIds = sitePrefs.Pinned, quickChat = sitePrefs.QuickChat }, ["wishlist"] = wishlist, ["rooms"] = rooms, ["photos"] = photos, ["inventions"] = inventions, }; Response.Headers["Cache-Control"] = "private, no-store"; Console.WriteLine($"[export] {me.Id} exported their account data"); return new ContentResult { Content = JsonConvert.SerializeObject(doc, Formatting.Indented, new Newtonsoft.Json.Converters.StringEnumConverter()), ContentType = "application/json" }; } // --------------------------------------------------------------------------------------------- // Export Rooms (creator only) // --------------------------------------------------------------------------------------------- private static List OwnedRooms(ulong me) => RoomDB.GetRoomsByCreator(me).OrderBy(r => r.Name, StringComparer.OrdinalIgnoreCase).ToList(); // GET /api/site/me/rooms - the rooms this player created (only those), to pick from. [HttpGet("/api/site/me/rooms")] public IActionResult MyRooms() { var me = CurrentUser(); if (me == null) return Unauthorized(new { ok = false, error = "not signed in" }); var rooms = OwnedRooms(me.Id).Select(r => new { id = r.RoomId.ToString(), name = r.Name, isDorm = r.IsDorm, subrooms = (r.SubRooms ?? new()).Count, }).ToList(); return Ok(new { ok = true, rooms }); } // Every server file a room's export may include, by the name it has on disk. private sealed class ExportFile { public string Path = ""; public string Name = ""; public string Kind = ""; } private static string SafeName(string? s, string fallback) { var chars = (s ?? "").Where(c => char.IsLetterOrDigit(c) || c == '-' || c == '_' || c == ' ' || c == '.').ToArray(); string t = new string(chars).Trim().Trim('.'); if (t.Length > 40) t = t[..40].Trim(); return t.Length == 0 ? fallback : t.Replace(' ', '_'); } private static string? HashOf(string path) { try { using var fs = System.IO.File.OpenRead(path); return Convert.ToBase64String(SHA256.HashData(fs)); } catch { return null; } } // GET /api/site/me/rooms/{roomId}/manifest - RoomDetails.json plus the list of files to fetch. Layout matches the // admin Archive importer: /RoomDetails.json, RoomImage., super_room_data.binpb, // SubRoom__/persisted_room_data.binpb, plus every older save under that subroom's older_saves/. [HttpGet("/api/site/me/rooms/{roomId}/manifest")] public IActionResult RoomManifest(ulong roomId) { var me = CurrentUser(); if (me == null) return Unauthorized(new { ok = false, error = "not signed in" }); var room = OwnedRooms(me.Id).FirstOrDefault(r => r.RoomId == roomId); if (room == null) return NotFound(new { ok = false, error = "that room is not yours" }); var files = new List(); var details = JObject.FromObject(room, JsonSerializer.CreateDefault(new JsonSerializerSettings { Converters = { new Newtonsoft.Json.Converters.StringEnumConverter() } })); void Add(string zipPath, string kind, string? name, string? hashInto, JObject? hashTarget) { if (string.IsNullOrWhiteSpace(name)) return; string disk = Path.Combine(kind == "image" ? ImageDir : BlobDir, Path.GetFileName(name)); if (!System.IO.File.Exists(disk)) return; files.Add(new { path = zipPath, name = Path.GetFileName(name) }); if (hashTarget != null && hashInto != null) { var h = HashOf(disk); if (h != null) hashTarget[hashInto] = h; } } // room-level data blob and picture Add("super_room_data.binpb", "blob", room.DataBlob, "DataBlobHash", details); if (!string.IsNullOrWhiteSpace(room.ImageName) && !room.ImageName.StartsWith("Default", StringComparison.OrdinalIgnoreCase)) Add("RoomImage" + (Path.GetExtension(room.ImageName) is { Length: > 0 } ext ? ext : ".jpg"), "image", room.ImageName, null, null); var subs = details["SubRooms"] as JArray; foreach (var sub in room.SubRooms ?? new()) { string folder = $"SubRoom_{sub.SubRoomId}_{SafeName(sub.Name, "subroom")}"; var subTok = subs?.FirstOrDefault(t => (ulong?)t["SubRoomId"] == sub.SubRoomId) as JObject; if (sub.CurrentSave != null) Add($"{folder}/persisted_room_data.binpb", "blob", sub.CurrentSave.DataBlob, "DataBlobHash", subTok?["CurrentSave"] as JObject); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); if (sub.CurrentSave?.DataBlob != null) seen.Add(sub.CurrentSave.DataBlob); int n = 0; foreach (var old in (sub.AllSaves ?? new()).OrderByDescending(s => s.CreatedAt)) { if (string.IsNullOrWhiteSpace(old.DataBlob) || !seen.Add(old.DataBlob)) continue; n++; Add($"{folder}/older_saves/{old.CreatedAt:yyyy-MM-dd_HH-mm}_{n}.binpb", "blob", old.DataBlob, null, null); } } Response.Headers["Cache-Control"] = "private, no-store"; return Ok(new { ok = true, folder = $"{SafeName(room.Name, "room")}_{room.RoomId}", details = details.ToString(Formatting.Indented), files, }); } // GET /api/site/me/rooms/{roomId}/file?name= - one file of an owned room, by the name the manifest // listed. Only names the room itself references are served, so this can't read anything else from the server. [HttpGet("/api/site/me/rooms/{roomId}/file")] public IActionResult RoomFile(ulong roomId, [FromQuery] string? name) { var me = CurrentUser(); if (me == null) return Unauthorized(new { ok = false, error = "not signed in" }); var room = OwnedRooms(me.Id).FirstOrDefault(r => r.RoomId == roomId); if (room == null || string.IsNullOrWhiteSpace(name)) return NotFound(); name = Path.GetFileName(name); bool isImage = string.Equals(name, room.ImageName, StringComparison.OrdinalIgnoreCase); bool isBlob = string.Equals(name, room.DataBlob, StringComparison.OrdinalIgnoreCase) || (room.SubRooms ?? new()).Any(s => string.Equals(s.CurrentSave?.DataBlob, name, StringComparison.OrdinalIgnoreCase) || (s.AllSaves ?? new()).Any(a => string.Equals(a.DataBlob, name, StringComparison.OrdinalIgnoreCase))); if (!isImage && !isBlob) return NotFound(); string disk = Path.Combine(isImage ? ImageDir : BlobDir, name); if (!System.IO.File.Exists(disk)) return NotFound(); Response.Headers["Cache-Control"] = "private, no-store"; return PhysicalFile(disk, "application/octet-stream"); } } }