Files
2026-10-14 06:59:51 +02:00

306 lines
16 KiB
C#

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
{
/// <summary>
/// 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.
/// </summary>
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<player_info>(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<object>();
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<string, string?>();
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<object>();
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<object>();
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<string, object?>
{
["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<RoomDBClasses.RoomRoot> 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: <Room>/RoomDetails.json, RoomImage.<ext>, super_room_data.binpb,
// SubRoom_<id>_<name>/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<object>();
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<string>(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=<server 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");
}
}
}