1945 lines
72 KiB
JavaScript
1945 lines
72 KiB
JavaScript
/**
|
|
* Tekton Nakama Server Runtime Module
|
|
*
|
|
* This module provides secure admin operations via RPC calls.
|
|
* Deploy this to your Nakama server's runtime directory.
|
|
*/
|
|
|
|
// Initialize RPC endpoints
|
|
function InitModule(ctx, logger, nk, initializer) {
|
|
// Admin RPCs
|
|
initializer.registerRpc("admin_kick_player", rpcAdminKickPlayer);
|
|
initializer.registerRpc("admin_ban_player", rpcAdminBanPlayer);
|
|
initializer.registerRpc("admin_unban_player", rpcAdminUnbanPlayer);
|
|
initializer.registerRpc("admin_get_ban_list", rpcAdminGetBanList);
|
|
initializer.registerRpc("admin_get_server_stats", rpcAdminGetServerStats);
|
|
initializer.registerRpc("admin_get_player_list", rpcAdminGetPlayerList);
|
|
initializer.registerRpc("admin_end_match", rpcAdminEndMatch);
|
|
initializer.registerRpc("admin_set_user_role", rpcAdminSetUserRole);
|
|
initializer.registerRpc("admin_list_users", rpcAdminListUsers);
|
|
initializer.registerRpc("admin_delete_users", rpcAdminDeleteUsers);
|
|
initializer.registerRpc("admin_topup_gold", rpcAdminTopupGold);
|
|
initializer.registerRpc("admin_clear_global_chat", rpcAdminClearGlobalChat);
|
|
|
|
// User management RPCs
|
|
initializer.registerRpc("get_user_profile", rpcGetUserProfile);
|
|
initializer.registerRpc("update_user_profile", rpcUpdateUserProfile);
|
|
initializer.registerRpc("search_users", rpcSearchUsers);
|
|
|
|
// Store RPCs
|
|
initializer.registerRpc("purchase_item", rpcPurchaseItem);
|
|
initializer.registerRpc("get_shop_catalog", rpcGetShopCatalog);
|
|
initializer.registerRpc("buy_currency", rpcBuyCurrency);
|
|
|
|
// Leaderboard RPCs
|
|
initializer.registerRpc("get_leaderboard_stats", rpcGetLeaderboardStats);
|
|
initializer.registerRpc("admin_update_stats", rpcAdminUpdateStats);
|
|
initializer.registerRpc("admin_delete_stats", rpcAdminDeleteStats);
|
|
initializer.registerRpc("admin_sync_leaderboard", rpcAdminSyncLeaderboard);
|
|
// Client-accessible score submission (authoritative leaderboard requires server-side writes)
|
|
initializer.registerRpc("submit_score", rpcSubmitScore);
|
|
initializer.registerRpc("sync_leaderboard", rpcSyncLeaderboard);
|
|
initializer.registerRpc("change_credentials", rpcChangeCredentials);
|
|
initializer.registerRpc("reset_stats", rpcResetStats);
|
|
initializer.registerRpc("send_lobby_invite", rpcSendLobbyInvite);
|
|
initializer.registerRpc("send_friend_request", rpcSendFriendRequest);
|
|
|
|
// Daily Rewards RPCs
|
|
initializer.registerRpc("claim_daily_reward", rpcClaimDailyReward);
|
|
initializer.registerRpc("get_daily_reward_state", rpcGetDailyRewardState);
|
|
initializer.registerRpc("set_daily_reward_config", rpcSetDailyRewardConfig);
|
|
initializer.registerRpc("get_daily_reward_config_admin", rpcGetDailyRewardConfigAdmin);
|
|
|
|
// Inbox System RPCs
|
|
initializer.registerRpc("admin_send_mail", rpcAdminSendMail);
|
|
initializer.registerRpc("admin_list_mail", rpcAdminListMail);
|
|
initializer.registerRpc("admin_update_mail", rpcAdminUpdateMail);
|
|
initializer.registerRpc("admin_delete_mail_server", rpcAdminDeleteMailServer);
|
|
initializer.registerRpc("get_mail", rpcGetMail);
|
|
initializer.registerRpc("claim_mail_reward", rpcClaimMailReward);
|
|
initializer.registerRpc("delete_mail", rpcDeleteMail);
|
|
initializer.registerRpc("save_mail_state", rpcSaveMailState);
|
|
|
|
// Steam auth hooks
|
|
initializer.registerAfterAuthenticateSteam(afterAuthenticateSteam);
|
|
|
|
// Create default native leaderboard
|
|
// id: "global_high_score", authoritative: true, sort: "desc", operator: "best", reset: None
|
|
try { nk.leaderboardCreate("global_high_score", true, "desc", "best", null, {}); } catch(e) {}
|
|
|
|
logger.info("Tekton admin module loaded");
|
|
}
|
|
|
|
// =============================================================================
|
|
// Authorization Helpers
|
|
// =============================================================================
|
|
|
|
var ADMIN_ROLES = ["admin", "moderator", "owner"];
|
|
|
|
// =============================================================================
|
|
// Shop Catalog Definitions
|
|
// =============================================================================
|
|
// To add a new item: append ONE entry to SHOP_CATALOG_DEFS.
|
|
// Fields:
|
|
// id (String) — must match item_id in game inventory + SkinManager
|
|
// name (String) — display name shown in shop
|
|
// category (String) — "head" | "costume" | "glove" | "accessory"
|
|
// gold (Number) — gold price (0 = not sold for gold)
|
|
// star (Number) — star price (0 = not sold for star)
|
|
// rarity (String) — "Common" | "Rare" | "Epic" | "Legendary"
|
|
// character (String) — (optional) which character the skin targets, e.g. "Oldpop"
|
|
|
|
// [BEGIN_SHOP_CATALOG_DEFS]
|
|
var SHOP_CATALOG_DEFS = [
|
|
// ── HEAD ────────────────────────────────────────────────────────────
|
|
{ id: "oldpop-blue-hat", name: "Oldpop Blue Hat", category: "head", gold: 100, star: 0, rarity: "Common", character: "Oldpop" },
|
|
{ id: "oldpop-green-hat", name: "Oldpop Green Hat", category: "head", gold: 100, star: 0, rarity: "Common", character: "Oldpop" },
|
|
{ id: "oldpop-red-hat", name: "Oldpop Red Hat", category: "head", gold: 100, star: 0, rarity: "Common", character: "Oldpop" },
|
|
{ id: "oldpop-yellow-hat", name: "Oldpop Yellow Hat", category: "head", gold: 100, star: 0, rarity: "Common", character: "Oldpop" },
|
|
// ── COSTUME ────────────────────────────────────────────────────────────
|
|
{ id: "oldpop-og-pant", name: "Copper OG Pant", category: "costume", gold: 0, star: 0, rarity: "Common", character: "Oldpop" },
|
|
{ id: "oldpop-grey-pant", name: "Copper Grey Pant", category: "costume", gold: 150, star: 0, rarity: "Common", character: "Oldpop" },
|
|
{ id: "oldpop-red-pant", name: "Copper Red Pant", category: "costume", gold: 150, star: 0, rarity: "Common", character: "Oldpop" },
|
|
{ id: "oldpop-yellow-pant", name: "Copper Yellow Pant", category: "costume", gold: 150, star: 0, rarity: "Common", character: "Oldpop" },
|
|
// ── GLOVE ────────────────────────────────────────────────────────────
|
|
{ id: "oldpop-blue-gloves", name: "Oldpop Blue Gloves", category: "glove", gold: 75, star: 0, rarity: "Common", character: "Oldpop" },
|
|
{ id: "oldpop-green-gloves", name: "Oldpop Green Gloves", category: "glove", gold: 75, star: 0, rarity: "Common", character: "Oldpop" },
|
|
{ id: "oldpop-red-gloves", name: "Oldpop Red Gloves", category: "glove", gold: 75, star: 0, rarity: "Common", character: "Oldpop" },
|
|
{ id: "oldpop-yellow-gloves", name: "Oldpop Yellow Gloves", category: "glove", gold: 75, star: 0, rarity: "Common", character: "Oldpop" },
|
|
];
|
|
// [END_SHOP_CATALOG_DEFS]
|
|
|
|
/** Groups SHOP_CATALOG_DEFS by category for the shop RPC response. */
|
|
function buildShopCatalog() {
|
|
var catalog = {};
|
|
for (var i = 0; i < SHOP_CATALOG_DEFS.length; i++) {
|
|
var def = SHOP_CATALOG_DEFS[i];
|
|
var cat = def.category;
|
|
if (!catalog[cat]) catalog[cat] = [];
|
|
var entry = {
|
|
id: def.id,
|
|
name: def.name,
|
|
gold: def.gold || 0,
|
|
star: def.star || 0,
|
|
rarity: def.rarity || "Common"
|
|
};
|
|
if (def.character) entry.character = def.character;
|
|
catalog[cat].push(entry);
|
|
}
|
|
return catalog;
|
|
}
|
|
|
|
|
|
function isAdmin(ctx, nk) {
|
|
if (!ctx.userId) return false;
|
|
|
|
try {
|
|
var account = nk.accountGetId(ctx.userId);
|
|
var metadata;
|
|
if (typeof account.user.metadata === "string") {
|
|
metadata = JSON.parse(account.user.metadata || "{}");
|
|
} else {
|
|
metadata = account.user.metadata || {};
|
|
}
|
|
var role = metadata.role || "";
|
|
return ADMIN_ROLES.indexOf(role) !== -1;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function isMatchHost(ctx, nk, matchId) {
|
|
if (!ctx.userId || !matchId) return false;
|
|
|
|
try {
|
|
// Get match state to check host
|
|
var match = nk.matchGet(matchId);
|
|
if (!match) return false;
|
|
|
|
// The first user to join (presence) is typically the host
|
|
// This logic may need adjustment based on your match handler
|
|
var state = JSON.parse(match.state || "{}");
|
|
return state.hostUserId === ctx.userId;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function requireAdmin(ctx, nk) {
|
|
if (!isAdmin(ctx, nk)) {
|
|
throw new Error("Admin privileges required");
|
|
}
|
|
}
|
|
|
|
function requireAdminOrHost(ctx, nk, matchId) {
|
|
if (!isAdmin(ctx, nk) && !isMatchHost(ctx, nk, matchId)) {
|
|
throw new Error("Admin or host privileges required");
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Lobby Invite RPC
|
|
// =============================================================================
|
|
|
|
function rpcSendLobbyInvite(ctx, logger, nk, payload) {
|
|
if (!ctx.userId) throw new Error("Not authenticated");
|
|
var req = JSON.parse(payload || "{}");
|
|
var toUserId = req.to_user_id;
|
|
var matchId = req.match_id;
|
|
if (!toUserId || !matchId) throw new Error("Missing to_user_id or match_id");
|
|
|
|
var sender = nk.accountGetId(ctx.userId);
|
|
var senderName = sender.user.displayName || sender.user.username || "Someone";
|
|
|
|
nk.notificationSend(
|
|
toUserId,
|
|
senderName + " invited you to their lobby",
|
|
JSON.stringify({ match_id: matchId, from_name: senderName }),
|
|
1001,
|
|
ctx.userId,
|
|
true
|
|
);
|
|
|
|
logger.info("Lobby invite sent from " + ctx.userId + " to " + toUserId + " for match " + matchId);
|
|
return JSON.stringify({ success: true });
|
|
}
|
|
|
|
// =============================================================================
|
|
// Steam Auth Hook
|
|
// =============================================================================
|
|
|
|
function afterAuthenticateSteam(ctx, logger, nk, out, request) {
|
|
if (!ctx.userId) return;
|
|
|
|
try {
|
|
var account = nk.accountGetId(ctx.userId);
|
|
|
|
// On first login (no display name set), use Steam username
|
|
if (!account.user.displayName) {
|
|
var steamName = request.username || "SteamPlayer";
|
|
nk.accountUpdateId(ctx.userId, null, steamName, null, null, null, null, null);
|
|
logger.info("Steam user " + ctx.userId + " display name set to: " + steamName);
|
|
}
|
|
|
|
// Set default role if not set
|
|
var metadata = {};
|
|
try {
|
|
metadata = typeof account.user.metadata === "string"
|
|
? JSON.parse(account.user.metadata || "{}")
|
|
: (account.user.metadata || {});
|
|
} catch (e) {}
|
|
|
|
if (!metadata.role) {
|
|
metadata.role = "player";
|
|
nk.accountUpdateId(ctx.userId, null, null, null, null, null, null, JSON.stringify(metadata));
|
|
}
|
|
} catch (e) {
|
|
logger.error("afterAuthenticateSteam error: " + e);
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Admin RPCs
|
|
// =============================================================================
|
|
|
|
function rpcAdminKickPlayer(ctx, logger, nk, payload) {
|
|
var request = JSON.parse(payload);
|
|
|
|
requireAdminOrHost(ctx, nk, request.match_id);
|
|
|
|
// Can't kick yourself
|
|
if (request.user_id === ctx.userId) {
|
|
throw new Error("Cannot kick yourself");
|
|
}
|
|
|
|
try {
|
|
// Signal the match to kick the player
|
|
nk.matchSignal(request.match_id, JSON.stringify({
|
|
action: "kick",
|
|
user_id: request.user_id,
|
|
reason: request.reason || "Kicked by admin"
|
|
}));
|
|
|
|
logger.info("Player " + request.user_id + " kicked from match " + request.match_id + " by " + ctx.userId);
|
|
|
|
return JSON.stringify({ success: true });
|
|
} catch (e) {
|
|
logger.error("Failed to kick player: " + e);
|
|
throw new Error("Failed to kick player");
|
|
}
|
|
}
|
|
|
|
function rpcAdminBanPlayer(ctx, logger, nk, payload) {
|
|
var request = JSON.parse(payload);
|
|
|
|
// Only full admins can ban (not just match hosts)
|
|
requireAdmin(ctx, nk);
|
|
|
|
if (request.user_id === ctx.userId) {
|
|
throw new Error("Cannot ban yourself");
|
|
}
|
|
|
|
try {
|
|
// Get target user's account
|
|
var targetAccount = nk.accountGetId(request.user_id);
|
|
var metadata = JSON.parse(targetAccount.user.metadata || "{}");
|
|
|
|
// Don't allow banning other admins
|
|
if (ADMIN_ROLES.indexOf(metadata.role || "") !== -1) {
|
|
throw new Error("Cannot ban an admin");
|
|
}
|
|
|
|
// Set ban in metadata
|
|
var banExpires = request.duration_hours && request.duration_hours > 0
|
|
? new Date(Date.now() + request.duration_hours * 60 * 60 * 1000).toISOString()
|
|
: null;
|
|
|
|
metadata.banned = true;
|
|
metadata.ban_reason = request.reason || "Banned by admin";
|
|
if (banExpires) {
|
|
metadata.ban_expires = banExpires;
|
|
}
|
|
|
|
nk.accountUpdateId(request.user_id, null, null, null, null, null, null, JSON.stringify(metadata));
|
|
|
|
// Also kick from current match if specified
|
|
if (request.match_id) {
|
|
nk.matchSignal(request.match_id, JSON.stringify({
|
|
action: "kick",
|
|
user_id: request.user_id,
|
|
reason: "Banned: " + (request.reason || "")
|
|
}));
|
|
}
|
|
|
|
// Store in ban list (for quick lookup)
|
|
var banRecord = {
|
|
user_id: request.user_id,
|
|
username: targetAccount.user.username,
|
|
banned_by: ctx.userId,
|
|
banned_at: new Date().toISOString(),
|
|
reason: request.reason,
|
|
expires: banExpires
|
|
};
|
|
|
|
nk.storageWrite([{
|
|
collection: "bans",
|
|
key: request.user_id,
|
|
userId: "00000000-0000-0000-0000-000000000000", // System-owned
|
|
value: banRecord,
|
|
permissionRead: 2, // Public read
|
|
permissionWrite: 0 // No one can write (except server)
|
|
}]);
|
|
|
|
logger.warn("Player " + request.user_id + " banned by " + ctx.userId + ". Reason: " + request.reason);
|
|
|
|
return JSON.stringify({ success: true, ban: banRecord });
|
|
} catch (e) {
|
|
logger.error("Failed to ban player: " + e);
|
|
throw new Error("Failed to ban player: " + e);
|
|
}
|
|
}
|
|
|
|
function rpcAdminUnbanPlayer(ctx, logger, nk, payload) {
|
|
var request = JSON.parse(payload);
|
|
|
|
requireAdmin(ctx, nk);
|
|
|
|
try {
|
|
// Get target user's account
|
|
var targetAccount = nk.accountGetId(request.user_id);
|
|
var metadata = JSON.parse(targetAccount.user.metadata || "{}");
|
|
|
|
// Remove ban
|
|
delete metadata.banned;
|
|
delete metadata.ban_reason;
|
|
delete metadata.ban_expires;
|
|
|
|
nk.accountUpdateId(request.user_id, null, null, null, null, null, null, JSON.stringify(metadata));
|
|
|
|
// Remove from ban list
|
|
nk.storageDelete([{
|
|
collection: "bans",
|
|
key: request.user_id,
|
|
userId: "00000000-0000-0000-0000-000000000000"
|
|
}]);
|
|
|
|
logger.info("Player " + request.user_id + " unbanned by " + ctx.userId);
|
|
|
|
return JSON.stringify({ success: true });
|
|
} catch (e) {
|
|
logger.error("Failed to unban player: " + e);
|
|
throw new Error("Failed to unban player");
|
|
}
|
|
}
|
|
|
|
function rpcAdminGetBanList(ctx, logger, nk, payload) {
|
|
requireAdmin(ctx, nk);
|
|
|
|
try {
|
|
var result = nk.storageList(
|
|
"00000000-0000-0000-0000-000000000000",
|
|
"bans",
|
|
100,
|
|
""
|
|
);
|
|
|
|
var bans = result.objects ? result.objects.map(function(obj) { return obj.value; }) : [];
|
|
|
|
return JSON.stringify({ bans: bans });
|
|
} catch (e) {
|
|
logger.error("Failed to get ban list: " + e);
|
|
return JSON.stringify({ bans: [] });
|
|
}
|
|
}
|
|
|
|
function rpcAdminGetServerStats(ctx, logger, nk, payload) {
|
|
var request = JSON.parse(payload || "{}");
|
|
|
|
if (request.match_id) {
|
|
requireAdminOrHost(ctx, nk, request.match_id);
|
|
} else {
|
|
requireAdmin(ctx, nk);
|
|
}
|
|
|
|
try {
|
|
// Get server-wide stats
|
|
var matches = nk.matchList(100, true, null, null, null, null);
|
|
var activeMatchCount = matches ? matches.length : 0;
|
|
|
|
var totalPlayers = 0;
|
|
if (matches) {
|
|
for (var i = 0; i < matches.length; i++) {
|
|
totalPlayers += matches[i].size || 0;
|
|
}
|
|
}
|
|
|
|
var stats = {
|
|
active_matches: activeMatchCount,
|
|
total_players: totalPlayers,
|
|
server_time: new Date().toISOString()
|
|
};
|
|
|
|
// If specific match requested, include match details
|
|
if (request.match_id) {
|
|
try {
|
|
var match = nk.matchGet(request.match_id);
|
|
if (match) {
|
|
stats.match = {
|
|
id: match.matchId,
|
|
size: match.size,
|
|
tick_rate: match.tickRate,
|
|
authoritative: match.authoritative
|
|
};
|
|
}
|
|
} catch (e) {
|
|
// Match not found
|
|
}
|
|
}
|
|
|
|
return JSON.stringify(stats);
|
|
} catch (e) {
|
|
logger.error("Failed to get server stats: " + e);
|
|
throw new Error("Failed to get server stats");
|
|
}
|
|
}
|
|
|
|
function rpcAdminGetPlayerList(ctx, logger, nk, payload) {
|
|
var request = JSON.parse(payload);
|
|
|
|
requireAdminOrHost(ctx, nk, request.match_id);
|
|
|
|
try {
|
|
var match = nk.matchGet(request.match_id);
|
|
if (!match) {
|
|
throw new Error("Match not found");
|
|
}
|
|
|
|
// Get player details
|
|
var players = [];
|
|
|
|
// Note: In actual implementation, you'd need to track presences
|
|
// This is a simplified version - adjust based on your match handler
|
|
|
|
return JSON.stringify({ players: players });
|
|
} catch (e) {
|
|
logger.error("Failed to get player list: " + e);
|
|
throw new Error("Failed to get player list");
|
|
}
|
|
}
|
|
|
|
function rpcAdminEndMatch(ctx, logger, nk, payload) {
|
|
var request = JSON.parse(payload);
|
|
|
|
requireAdminOrHost(ctx, nk, request.match_id);
|
|
|
|
try {
|
|
// Signal match to end
|
|
nk.matchSignal(request.match_id, JSON.stringify({
|
|
action: "end_match",
|
|
reason: request.reason || "Ended by admin"
|
|
}));
|
|
|
|
logger.info("Match " + request.match_id + " ended by " + ctx.userId);
|
|
|
|
return JSON.stringify({ success: true });
|
|
} catch (e) {
|
|
logger.error("Failed to end match: " + e);
|
|
throw new Error("Failed to end match");
|
|
}
|
|
}
|
|
|
|
function rpcAdminSetUserRole(ctx, logger, nk, payload) {
|
|
var request = JSON.parse(payload);
|
|
|
|
// Only owner/super-admin can set roles
|
|
var callerAccount = nk.accountGetId(ctx.userId);
|
|
var callerMetadata = JSON.parse(callerAccount.user.metadata || "{}");
|
|
|
|
if (callerMetadata.role !== "owner") {
|
|
throw new Error("Only owners can modify user roles");
|
|
}
|
|
|
|
var validRoles = ["player", "moderator", "admin"];
|
|
if (validRoles.indexOf(request.role) === -1) {
|
|
throw new Error("Invalid role");
|
|
}
|
|
|
|
try {
|
|
var targetAccount = nk.accountGetId(request.user_id);
|
|
var metadata = JSON.parse(targetAccount.user.metadata || "{}");
|
|
|
|
metadata.role = request.role;
|
|
|
|
nk.accountUpdateId(request.user_id, null, null, null, null, null, null, JSON.stringify(metadata));
|
|
|
|
logger.info("User " + request.user_id + " role set to " + request.role + " by " + ctx.userId);
|
|
|
|
return JSON.stringify({ success: true, role: request.role });
|
|
} catch (e) {
|
|
logger.error("Failed to set user role: " + e);
|
|
throw new Error("Failed to set user role");
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Store / Economy RPCs
|
|
// =============================================================================
|
|
|
|
function rpcGetShopCatalog(ctx, logger, nk, payload) {
|
|
if (!ctx.userId) throw new Error("Not authenticated");
|
|
return JSON.stringify({ catalog: buildShopCatalog() });
|
|
}
|
|
|
|
function rpcAdminTopupGold(ctx, logger, nk, payload) {
|
|
requireAdmin(ctx, nk);
|
|
try {
|
|
nk.walletUpdate(ctx.userId, { "gold": 999999 }, {}, true);
|
|
logger.info("Admin gold top-up applied for user " + ctx.userId);
|
|
return JSON.stringify({ success: true, gold_added: 999999 });
|
|
} catch (e) {
|
|
logger.error("Top-up failed: " + e);
|
|
throw new Error("Top-up failed: " + e);
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Admin Clear Global Chat RPC
|
|
// =============================================================================
|
|
|
|
function rpcAdminClearGlobalChat(ctx, logger, nk, payload) {
|
|
requireAdmin(ctx, nk);
|
|
|
|
// Nakama's channel message list uses the channel ID, not the room name.
|
|
// We need to find the channel ID for "social_global" Room type.
|
|
// Room channel IDs are deterministic: we can list messages by cursor.
|
|
var req = JSON.parse(payload || "{}");
|
|
var channelId = req.channel_id || "";
|
|
|
|
if (!channelId) {
|
|
throw new Error("channel_id is required. Pass the channel ID from the client.");
|
|
}
|
|
|
|
var deleted = 0;
|
|
var cursor = "";
|
|
|
|
try {
|
|
// Paginate through all messages and remove each one
|
|
do {
|
|
var result = nk.channelMessagesList(channelId, 100, false, cursor);
|
|
var messages = result.messages || [];
|
|
|
|
for (var i = 0; i < messages.length; i++) {
|
|
try {
|
|
nk.channelMessageRemove(channelId, messages[i].messageId);
|
|
deleted++;
|
|
} catch (e2) {
|
|
logger.warn("Failed to remove message " + messages[i].messageId + ": " + e2);
|
|
}
|
|
}
|
|
|
|
cursor = result.nextCursor || "";
|
|
} while (cursor !== "");
|
|
|
|
logger.info("[AdminClearGlobalChat] Deleted " + deleted + " messages by " + ctx.userId);
|
|
return JSON.stringify({ success: true, deleted: deleted });
|
|
} catch (e) {
|
|
logger.error("admin_clear_global_chat failed: " + e);
|
|
throw new Error("Failed to clear global chat: " + e);
|
|
}
|
|
}
|
|
|
|
|
|
function rpcBuyCurrency(ctx, logger, nk, payload) {
|
|
if (!ctx.userId) throw new Error("Not authenticated");
|
|
|
|
var request = JSON.parse(payload);
|
|
var packageId = request.package_id;
|
|
|
|
var changeset = { "gold": 0, "star": 0 };
|
|
|
|
if (packageId === "gold_100") changeset["gold"] = 100;
|
|
else if (packageId === "gold_500") changeset["gold"] = 550;
|
|
else if (packageId === "gold_1000") changeset["gold"] = 1150;
|
|
else if (packageId === "gold_2000") changeset["gold"] = 2400;
|
|
else if (packageId === "gold_5000") changeset["gold"] = 6250;
|
|
else if (packageId === "gold_10000") changeset["gold"] = 13000;
|
|
else if (packageId === "star_100") { changeset["star"] = 100; changeset["gold"] = -500; }
|
|
else if (packageId === "star_250") { changeset["star"] = 250; changeset["gold"] = -1100; }
|
|
else if (packageId === "star_600") { changeset["star"] = 600; changeset["gold"] = -2500; }
|
|
else throw new Error("Invalid package ID");
|
|
|
|
try {
|
|
nk.walletUpdate(ctx.userId, changeset, {}, true);
|
|
logger.info("User " + ctx.userId + " bought currency package " + packageId);
|
|
return JSON.stringify({ success: true, package_id: packageId });
|
|
} catch (e) {
|
|
logger.error("Currency purchase failed: " + e.message);
|
|
throw new Error("Currency purchase failed: " + e.message);
|
|
}
|
|
}
|
|
|
|
function rpcPurchaseItem(ctx, logger, nk, payload) {
|
|
if (!ctx.userId) throw new Error("Not authenticated");
|
|
|
|
var request = JSON.parse(payload);
|
|
var itemId = request.item_id;
|
|
var priceGold = request.price_gold || 0;
|
|
var priceStar = request.price_star || 0;
|
|
var category = request.category || "accessory"; // head, costume, glove, accessory
|
|
|
|
if (!itemId) throw new Error("Item ID required");
|
|
|
|
try {
|
|
var changeset = {};
|
|
if (priceGold > 0) changeset["gold"] = -priceGold;
|
|
if (priceStar > 0) changeset["star"] = -priceStar;
|
|
|
|
// Check wallet and deduct
|
|
// nk.walletUpdate throws an error if insufficient funds
|
|
nk.walletUpdate(ctx.userId, changeset, {}, true);
|
|
|
|
// Record purchase in inventory
|
|
var inventoryObj = {
|
|
collection: "inventory",
|
|
key: itemId,
|
|
userId: ctx.userId,
|
|
value: { category: category, purchased_at: new Date().toISOString() },
|
|
permissionRead: 1,
|
|
permissionWrite: 0
|
|
};
|
|
nk.storageWrite([inventoryObj]);
|
|
|
|
logger.info("User " + ctx.userId + " purchased " + itemId);
|
|
return JSON.stringify({ success: true, item: itemId });
|
|
} catch (e) {
|
|
logger.error("Purchase failed: " + e.message);
|
|
throw new Error("Purchase failed: " + e.message);
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Daily Rewards RPCs
|
|
// =============================================================================
|
|
|
|
function rpcClaimDailyReward(ctx, logger, nk, payload) {
|
|
if (!ctx.userId) throw new Error("Not authenticated");
|
|
var now = new Date();
|
|
var currentMonth = now.toISOString().substring(5, 7); // e.g. "05"
|
|
var todayStr = now.toISOString().substring(0, 10);
|
|
var todayIndex = now.getUTCDate() - 1; // 0 to 30
|
|
|
|
var stateObjs = nk.storageRead([{ collection: "daily_rewards", key: "state", userId: ctx.userId }]);
|
|
var state = { claimed_days: [], last_claim_date: "", month: "" };
|
|
if (stateObjs && stateObjs.length > 0) {
|
|
var val = stateObjs[0].value;
|
|
state.last_claim_date = val.last_claim_date || "";
|
|
state.month = val.month || "";
|
|
if (typeof val.claimed_days === 'number') {
|
|
var arr = [];
|
|
for (var i=0; i<val.claimed_days; i++) arr.push(i);
|
|
state.claimed_days = arr;
|
|
} else if (Array.isArray(val.claimed_days)) {
|
|
state.claimed_days = val.claimed_days;
|
|
} else {
|
|
state.claimed_days = [];
|
|
}
|
|
}
|
|
|
|
if (state.month !== currentMonth) {
|
|
state.claimed_days = [];
|
|
state.month = currentMonth;
|
|
}
|
|
|
|
if (state.last_claim_date === todayStr) {
|
|
throw new Error("Already claimed today");
|
|
}
|
|
|
|
var configObjs = nk.storageRead([{ collection: "config", key: "daily_rewards", userId: "00000000-0000-0000-0000-000000000000" }]);
|
|
var config = {};
|
|
if (configObjs && configObjs.length > 0) {
|
|
config = configObjs[0].value;
|
|
}
|
|
|
|
var monthRewards = config[currentMonth];
|
|
if (!monthRewards || monthRewards.length === 0) {
|
|
monthRewards = [];
|
|
for (var i = 0; i < 31; i++) {
|
|
monthRewards.push({ type: "star", amount: Math.min(10 + i * 5, 100) });
|
|
}
|
|
}
|
|
|
|
var dayIndex = todayIndex;
|
|
if (dayIndex >= monthRewards.length) {
|
|
throw new Error("Already claimed all rewards for this month");
|
|
}
|
|
if (state.claimed_days.indexOf(dayIndex) !== -1) {
|
|
throw new Error("Already claimed today's reward");
|
|
}
|
|
|
|
var rewardData = monthRewards[dayIndex];
|
|
if (typeof rewardData === "number") {
|
|
rewardData = { type: "star", amount: rewardData };
|
|
}
|
|
|
|
var rewardType = rewardData.type || "star";
|
|
var rewardAmount = rewardData.amount || 0;
|
|
|
|
if (rewardType === "star" || rewardType === "gold") {
|
|
var changes = {};
|
|
changes[rewardType] = rewardAmount;
|
|
nk.walletUpdate(ctx.userId, changes, {}, true);
|
|
} else if (rewardType.startsWith("frag_")) {
|
|
var invObjs = nk.storageRead([{ collection: "inventory", key: "fragments", userId: ctx.userId }]);
|
|
var frags = {};
|
|
if (invObjs && invObjs.length > 0) {
|
|
frags = invObjs[0].value;
|
|
}
|
|
frags[rewardType] = (frags[rewardType] || 0) + rewardAmount;
|
|
nk.storageWrite([{
|
|
collection: "inventory",
|
|
key: "fragments",
|
|
userId: ctx.userId,
|
|
value: frags,
|
|
permissionRead: 1,
|
|
permissionWrite: 0
|
|
}]);
|
|
}
|
|
|
|
state.claimed_days.push(dayIndex);
|
|
state.last_claim_date = todayStr;
|
|
|
|
nk.storageWrite([{
|
|
collection: "daily_rewards",
|
|
key: "state",
|
|
userId: ctx.userId,
|
|
value: state,
|
|
permissionRead: 1,
|
|
permissionWrite: 0
|
|
}]);
|
|
|
|
return JSON.stringify({ success: true, reward_type: rewardType, reward_amount: rewardAmount, day: dayIndex + 1 });
|
|
}
|
|
|
|
function rpcGetDailyRewardState(ctx, logger, nk, payload) {
|
|
if (!ctx.userId) throw new Error("Not authenticated");
|
|
var now = new Date();
|
|
var currentMonth = now.toISOString().substring(5, 7); // e.g. "05"
|
|
var todayStr = now.toISOString().substring(0, 10);
|
|
var todayIndex = now.getUTCDate() - 1;
|
|
|
|
var stateObjs = nk.storageRead([{ collection: "daily_rewards", key: "state", userId: ctx.userId }]);
|
|
var state = { claimed_days: [], last_claim_date: "", month: "" };
|
|
if (stateObjs && stateObjs.length > 0) {
|
|
var val = stateObjs[0].value;
|
|
state.last_claim_date = val.last_claim_date || "";
|
|
state.month = val.month || "";
|
|
if (typeof val.claimed_days === 'number') {
|
|
var arr = [];
|
|
for (var i=0; i<val.claimed_days; i++) arr.push(i);
|
|
state.claimed_days = arr;
|
|
} else if (Array.isArray(val.claimed_days)) {
|
|
state.claimed_days = val.claimed_days;
|
|
} else {
|
|
state.claimed_days = [];
|
|
}
|
|
}
|
|
if (state.month !== currentMonth) {
|
|
state.claimed_days = [];
|
|
state.month = currentMonth;
|
|
}
|
|
|
|
var configObjs = nk.storageRead([{ collection: "config", key: "daily_rewards", userId: "00000000-0000-0000-0000-000000000000" }]);
|
|
var config = {};
|
|
if (configObjs && configObjs.length > 0) {
|
|
config = configObjs[0].value;
|
|
}
|
|
var monthRewards = config[currentMonth];
|
|
if (!monthRewards || monthRewards.length === 0) {
|
|
monthRewards = [];
|
|
for (var i = 0; i < 31; i++) {
|
|
monthRewards.push({ type: "star", amount: Math.min(10 + i * 5, 100) });
|
|
}
|
|
}
|
|
|
|
return JSON.stringify({
|
|
state: state,
|
|
month_rewards: monthRewards,
|
|
can_claim_today: state.last_claim_date !== todayStr && state.claimed_days.indexOf(todayIndex) === -1 && todayIndex < monthRewards.length,
|
|
today_date: todayStr,
|
|
today_index: todayIndex,
|
|
server_month: now.getUTCMonth() + 1
|
|
});
|
|
}
|
|
|
|
function rpcSetDailyRewardConfig(ctx, logger, nk, payload) {
|
|
requireAdmin(ctx, nk);
|
|
var request = JSON.parse(payload || "{}");
|
|
nk.storageWrite([{
|
|
collection: "config",
|
|
key: "daily_rewards",
|
|
userId: "00000000-0000-0000-0000-000000000000",
|
|
value: request.config,
|
|
permissionRead: 2,
|
|
permissionWrite: 0
|
|
}]);
|
|
return JSON.stringify({ success: true });
|
|
}
|
|
|
|
function rpcGetDailyRewardConfigAdmin(ctx, logger, nk, payload) {
|
|
requireAdmin(ctx, nk);
|
|
var configObjs = nk.storageRead([{ collection: "config", key: "daily_rewards", userId: "00000000-0000-0000-0000-000000000000" }]);
|
|
var config = {};
|
|
if (configObjs && configObjs.length > 0) {
|
|
config = configObjs[0].value;
|
|
}
|
|
return JSON.stringify({ config: config });
|
|
}
|
|
|
|
// =============================================================================
|
|
// User Profile RPCs
|
|
// =============================================================================
|
|
|
|
function rpcGetUserProfile(ctx, logger, nk, payload) {
|
|
var request = JSON.parse(payload || "{}");
|
|
var targetUserId = request.user_id || ctx.userId;
|
|
|
|
try {
|
|
var account = nk.accountGetId(targetUserId);
|
|
var metadata = JSON.parse(account.user.metadata || "{}");
|
|
|
|
// Check if banned
|
|
if (metadata.banned && targetUserId === ctx.userId) {
|
|
// Check if ban expired
|
|
if (metadata.ban_expires) {
|
|
var expiresAt = new Date(metadata.ban_expires);
|
|
if (expiresAt <= new Date()) {
|
|
// Ban expired, remove it
|
|
delete metadata.banned;
|
|
delete metadata.ban_reason;
|
|
delete metadata.ban_expires;
|
|
nk.accountUpdateId(targetUserId, null, null, null, null, null, null, JSON.stringify(metadata));
|
|
} else {
|
|
throw new Error("Account banned until " + metadata.ban_expires + ". Reason: " + metadata.ban_reason);
|
|
}
|
|
} else {
|
|
throw new Error("Account permanently banned. Reason: " + metadata.ban_reason);
|
|
}
|
|
}
|
|
|
|
return JSON.stringify({
|
|
user_id: account.user.id,
|
|
username: account.user.username,
|
|
display_name: account.user.displayName,
|
|
avatar_url: account.user.avatarUrl,
|
|
create_time: account.user.createTime,
|
|
role: metadata.role || "player"
|
|
});
|
|
} catch (e) {
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
function rpcUpdateUserProfile(ctx, logger, nk, payload) {
|
|
if (!ctx.userId) {
|
|
throw new Error("Not authenticated");
|
|
}
|
|
|
|
var request = JSON.parse(payload);
|
|
|
|
try {
|
|
nk.accountUpdateId(
|
|
ctx.userId,
|
|
null, // username
|
|
request.display_name || null,
|
|
null, // timezone
|
|
null, // location
|
|
null, // lang
|
|
request.avatar_url || null,
|
|
null // metadata
|
|
);
|
|
|
|
return JSON.stringify({ success: true });
|
|
} catch (e) {
|
|
logger.error("Failed to update profile: " + e);
|
|
throw new Error("Failed to update profile");
|
|
}
|
|
}
|
|
|
|
function rpcSearchUsers(ctx, logger, nk, payload) {
|
|
if (!ctx.userId) {
|
|
throw new Error("Not authenticated");
|
|
}
|
|
|
|
var request = {};
|
|
try {
|
|
request = JSON.parse(payload || "{}");
|
|
} catch (e) {}
|
|
|
|
var query = request.query || "";
|
|
|
|
try {
|
|
var users = [];
|
|
var sql = "";
|
|
var params = [];
|
|
|
|
if (query === "") {
|
|
sql = "SELECT id, username, display_name, metadata FROM users WHERE id != '00000000-0000-0000-0000-000000000000' ORDER BY create_time DESC LIMIT 100";
|
|
} else {
|
|
sql = "SELECT id, username, display_name, metadata FROM users WHERE (username ILIKE $1 OR display_name ILIKE $1) AND id != '00000000-0000-0000-0000-000000000000' ORDER BY create_time DESC LIMIT 100";
|
|
params = ["%" + query + "%"];
|
|
}
|
|
|
|
var rows = nk.sqlQuery(sql, params);
|
|
|
|
for (var i = 0; i < rows.length; i++) {
|
|
var row = rows[i];
|
|
var metadata = {};
|
|
try { metadata = JSON.parse(row.metadata || "{}"); } catch(e) {}
|
|
|
|
users.push({
|
|
user_id: row.id,
|
|
username: row.username || "",
|
|
display_name: row.display_name || row.username || "",
|
|
avatar_url: metadata.avatar_url || ""
|
|
});
|
|
}
|
|
|
|
return JSON.stringify({ users: users });
|
|
} catch (e) {
|
|
logger.error("Failed to search users: " + e);
|
|
return JSON.stringify({ users: [] });
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Leaderboard RPCs
|
|
// =============================================================================
|
|
|
|
function rpcGetLeaderboardStats(ctx, logger, nk, payload) {
|
|
try {
|
|
var limit = 50;
|
|
var records = nk.leaderboardRecordsList("global_high_score", null, limit, "");
|
|
var leaderboardData = [];
|
|
|
|
var ownerRecords = records.records || [];
|
|
for (var i = 0; i < ownerRecords.length; i++) {
|
|
var record = ownerRecords[i];
|
|
var metadata = {};
|
|
try { metadata = JSON.parse(record.metadata || "{}"); } catch (e) {}
|
|
|
|
leaderboardData.push({
|
|
user_id: record.ownerId,
|
|
username: record.username,
|
|
display_name: record.displayName || record.username,
|
|
avatar_url: metadata.avatar_url || "",
|
|
loadout_character: metadata.loadout_character || "Copper",
|
|
high_score: record.score || 0,
|
|
games_played: metadata.games_played || 0,
|
|
games_won: metadata.games_won || 0
|
|
});
|
|
}
|
|
|
|
return JSON.stringify({ leaderboard: leaderboardData });
|
|
} catch (e) {
|
|
logger.error("Failed to get native leaderboard stats: " + e);
|
|
// Fallback to storage-based if native fails (e.g. not created yet)
|
|
return JSON.stringify({ leaderboard: [] });
|
|
}
|
|
}
|
|
|
|
// Any authenticated user can submit their own score (server writes on their behalf)
|
|
function rpcSubmitScore(ctx, logger, nk, payload) {
|
|
if (!ctx.userId) throw new Error("Not authenticated");
|
|
try {
|
|
var request = JSON.parse(payload || "{}");
|
|
var score = parseInt(request.score) || 0;
|
|
var account = nk.accountGetId(ctx.userId);
|
|
var metadata = {
|
|
games_played: request.games_played || 0,
|
|
games_won: request.games_won || 0,
|
|
avatar_url: account.user.avatarUrl || request.avatar_url || "",
|
|
loadout_character: request.loadout_character || "Copper"
|
|
};
|
|
nk.leaderboardRecordWrite(
|
|
"global_high_score",
|
|
ctx.userId,
|
|
account.user.username,
|
|
score,
|
|
0,
|
|
metadata
|
|
);
|
|
logger.info("Score submitted for user " + ctx.userId + ": " + score);
|
|
return JSON.stringify({ success: true });
|
|
} catch (e) {
|
|
logger.error("Failed to submit score for " + ctx.userId + ": " + e);
|
|
throw new Error("Failed to submit score: " + e);
|
|
}
|
|
}
|
|
|
|
// Any authenticated user can trigger a bulk sync (reads all public stats, populates leaderboard)
|
|
function rpcSyncLeaderboard(ctx, logger, nk, payload) {
|
|
if (!ctx.userId) throw new Error("Not authenticated");
|
|
try {
|
|
var result = nk.storageList(null, "stats", 100, "");
|
|
var statsObjects = result.objects || [];
|
|
var userGroup = {};
|
|
|
|
for (var i = 0; i < statsObjects.length; i++) {
|
|
var obj = statsObjects[i];
|
|
var userId = obj.userId;
|
|
var value;
|
|
try {
|
|
value = (typeof obj.value === "string") ? JSON.parse(obj.value) : obj.value;
|
|
} catch (e) { continue; }
|
|
if (!value) continue;
|
|
|
|
if (!userGroup[userId]) {
|
|
userGroup[userId] = {
|
|
high_score: value.high_score || 0,
|
|
games_played: value.games_played || 0,
|
|
games_won: value.games_won || 0,
|
|
avatar_url: value.avatar_url || "",
|
|
loadout_character: value.loadout_character || ""
|
|
};
|
|
} else {
|
|
userGroup[userId].high_score = Math.max(userGroup[userId].high_score, value.high_score || 0);
|
|
userGroup[userId].games_played = Math.max(userGroup[userId].games_played, value.games_played || 0);
|
|
userGroup[userId].games_won = Math.max(userGroup[userId].games_won, value.games_won || 0);
|
|
}
|
|
|
|
// Prioritize avatar and character from game_stats or if current is empty
|
|
if (obj.key === "game_stats" || !userGroup[userId].avatar_url) {
|
|
if (value.avatar_url) userGroup[userId].avatar_url = value.avatar_url;
|
|
if (value.loadout_character) userGroup[userId].loadout_character = value.loadout_character;
|
|
}
|
|
}
|
|
|
|
// Phase 2: Read profiles collection to get loadout and avatars!
|
|
var profileResult = nk.storageList(null, "profiles", 100, "");
|
|
var profileObjects = profileResult.objects || [];
|
|
for (var i = 0; i < profileObjects.length; i++) {
|
|
var obj = profileObjects[i];
|
|
var userId = obj.userId;
|
|
if (obj.key !== "profile") continue;
|
|
var value;
|
|
try {
|
|
value = (typeof obj.value === "string") ? JSON.parse(obj.value) : obj.value;
|
|
} catch (e) { continue; }
|
|
|
|
if (!userGroup[userId]) {
|
|
userGroup[userId] = {
|
|
high_score: 0, games_played: 0, games_won: 0,
|
|
avatar_url: "", loadout_character: ""
|
|
};
|
|
}
|
|
// If the profile has avatar or loadout, merge them. Natively preferred over empty
|
|
if (value.avatar_url && !userGroup[userId].avatar_url) userGroup[userId].avatar_url = value.avatar_url;
|
|
if (value.loadout_character && !userGroup[userId].loadout_character) userGroup[userId].loadout_character = value.loadout_character;
|
|
}
|
|
|
|
var count = 0;
|
|
var debugLogs = [];
|
|
for (var uid in userGroup) {
|
|
try {
|
|
var stats = userGroup[uid];
|
|
var account = nk.accountGetId(uid);
|
|
var meta = {
|
|
games_played: stats.games_played || 0,
|
|
games_won: stats.games_won || 0,
|
|
avatar_url: stats.avatar_url || account.user.avatarUrl || "res://assets/graphics/character_selection/sc_characters/sc_copper.png",
|
|
loadout_character: stats.loadout_character || "Copper"
|
|
};
|
|
nk.leaderboardRecordWrite("global_high_score", uid, account.user.username, stats.high_score, 0, meta);
|
|
count++;
|
|
} catch (inner) {
|
|
debugLogs.push("Error user " + uid + ": " + inner);
|
|
logger.error("Failed to sync record for " + uid + ": " + inner);
|
|
}
|
|
}
|
|
logger.info("Synced " + count + " records to leaderboard by user " + ctx.userId);
|
|
return JSON.stringify({ success: true, synced: count, objects_found: statsObjects.length, debug: debugLogs });
|
|
} catch (e) {
|
|
logger.error("Leaderboard sync failed: " + e);
|
|
throw new Error("Sync failed: " + e);
|
|
}
|
|
}
|
|
|
|
// Change Email / Password securely
|
|
function rpcChangeCredentials(ctx, logger, nk, payload) {
|
|
if (!ctx.userId) throw new Error("Not authenticated");
|
|
var req = {};
|
|
try { req = JSON.parse(payload || "{}"); } catch (e) {}
|
|
|
|
var account = nk.accountGetId(ctx.userId);
|
|
|
|
// If not a guest (has email), verify current password and unlink
|
|
if (account.email) {
|
|
if (!req.current_password) throw new Error("Current password required");
|
|
try {
|
|
nk.authenticateEmail(account.email, req.current_password, false);
|
|
} catch (e) {
|
|
throw new Error("Incorrect current password.");
|
|
}
|
|
nk.unlinkEmail(ctx.userId, account.email, req.current_password);
|
|
}
|
|
|
|
try {
|
|
nk.linkEmail(ctx.userId, req.new_email, req.new_password);
|
|
} catch (e) {
|
|
// Safe rollback
|
|
if (account.email) nk.linkEmail(ctx.userId, account.email, req.current_password);
|
|
throw new Error("Failed to set new credentials: " + e.message);
|
|
}
|
|
|
|
return JSON.stringify({ success: true });
|
|
}
|
|
|
|
// Reset Game Stats
|
|
function rpcResetStats(ctx, logger, nk, payload) {
|
|
if (!ctx.userId) throw new Error("Not authenticated");
|
|
var account = nk.accountGetId(ctx.userId);
|
|
|
|
// Delete native leaderboard rank
|
|
try { nk.leaderboardRecordDelete("global_high_score", ctx.userId); } catch (e) {}
|
|
|
|
// Wipe storage stats
|
|
var zeros = { games_played: 0, games_won: 0, high_score: 0, total_kills: 0, total_deaths: 0 };
|
|
nk.storageWrite([{
|
|
collection: "stats",
|
|
key: "game_stats",
|
|
userId: ctx.userId,
|
|
value: zeros,
|
|
permissionRead: 2,
|
|
permissionWrite: 1
|
|
}]);
|
|
|
|
return JSON.stringify({ success: true });
|
|
}
|
|
|
|
// =============================================================================
|
|
// Admin User Management RPCs
|
|
// =============================================================================
|
|
|
|
function rpcAdminListUsers(ctx, logger, nk, payload) {
|
|
requireAdmin(ctx, nk);
|
|
|
|
try {
|
|
var users = [];
|
|
var rows = nk.sqlQuery("SELECT id, username, display_name, metadata, create_time FROM users WHERE id != '00000000-0000-0000-0000-000000000000' ORDER BY create_time DESC LIMIT 500", []);
|
|
|
|
for (var i = 0; i < rows.length; i++) {
|
|
var row = rows[i];
|
|
var metadata = {};
|
|
try { metadata = JSON.parse(row.metadata || "{}"); } catch(e) {}
|
|
users.push({
|
|
user_id: row.id,
|
|
username: row.username || "",
|
|
display_name: row.display_name || row.username || "",
|
|
create_time: row.create_time,
|
|
role: metadata.role || "player",
|
|
banned: metadata.banned || false,
|
|
ban_reason: metadata.ban_reason || ""
|
|
});
|
|
}
|
|
|
|
return JSON.stringify({ users: users, count: users.length });
|
|
} catch (e) {
|
|
logger.error("Failed to list users: " + e);
|
|
throw new Error("Failed to list users: " + e);
|
|
}
|
|
}
|
|
|
|
function rpcAdminDeleteUsers(ctx, logger, nk, payload) {
|
|
requireAdmin(ctx, nk);
|
|
|
|
var request = JSON.parse(payload);
|
|
var userIds = request.user_ids || [];
|
|
|
|
if (userIds.length === 0) {
|
|
throw new Error("No user IDs provided");
|
|
}
|
|
|
|
// Don't allow deleting yourself
|
|
for (var i = 0; i < userIds.length; i++) {
|
|
if (userIds[i] === ctx.userId) {
|
|
throw new Error("Cannot delete your own account");
|
|
}
|
|
}
|
|
|
|
var deleted = [];
|
|
var failed = [];
|
|
|
|
for (var j = 0; j < userIds.length; j++) {
|
|
var uid = userIds[j];
|
|
try {
|
|
// Check if target is admin — don't allow deleting admins
|
|
var account = nk.accountGetId(uid);
|
|
var meta = {};
|
|
try { meta = JSON.parse(account.user.metadata || "{}"); } catch(e) {}
|
|
if (ADMIN_ROLES.indexOf(meta.role || "") !== -1) {
|
|
failed.push({ user_id: uid, reason: "Cannot delete admin account" });
|
|
continue;
|
|
}
|
|
nk.accountDeleteId(uid, false);
|
|
deleted.push(uid);
|
|
logger.warn("User " + uid + " deleted by " + ctx.userId);
|
|
} catch (e) {
|
|
failed.push({ user_id: uid, reason: "" + e });
|
|
}
|
|
}
|
|
|
|
return JSON.stringify({ success: true, deleted: deleted, failed: failed });
|
|
}
|
|
|
|
function rpcAdminUpdateStats(ctx, logger, nk, payload) {
|
|
requireAdmin(ctx, nk);
|
|
|
|
var request = JSON.parse(payload);
|
|
var targetUserId = request.user_id;
|
|
var stats = request.stats; // { high_score: X, games_played: Y, games_won: Z }
|
|
|
|
if (!targetUserId || !stats) {
|
|
throw new Error("User ID and stats are required");
|
|
}
|
|
|
|
try {
|
|
// 1. Update Storage (priority: game_stats)
|
|
nk.storageWrite([{
|
|
collection: "stats",
|
|
key: "game_stats",
|
|
userId: targetUserId,
|
|
value: JSON.stringify(stats),
|
|
permissionRead: 1, // Public read
|
|
permissionWrite: 0
|
|
}]);
|
|
|
|
// 2. Update Native Leaderboard
|
|
var account = nk.accountGetId(targetUserId);
|
|
var score = stats.high_score || 0;
|
|
var subscore = 0;
|
|
var metadata = {
|
|
games_played: stats.games_played || 0,
|
|
games_won: stats.games_won || 0,
|
|
avatar_url: account.user.avatarUrl || "",
|
|
loadout_character: stats.loadout_character || "Copper"
|
|
};
|
|
|
|
nk.leaderboardRecordWrite("global_high_score", targetUserId, account.user.username, score, subscore, metadata);
|
|
|
|
logger.info("Stats updated for user " + targetUserId + " by admin " + ctx.userId + " (game_stats + Native)");
|
|
return JSON.stringify({ success: true });
|
|
} catch (e) {
|
|
logger.error("Failed to update stats: " + e);
|
|
throw new Error("Failed to update stats: " + e);
|
|
}
|
|
}
|
|
|
|
function rpcAdminDeleteStats(ctx, logger, nk, payload) {
|
|
requireAdmin(ctx, nk);
|
|
|
|
var request = JSON.parse(payload);
|
|
var targetUserId = request.user_id;
|
|
|
|
if (!targetUserId) {
|
|
throw new Error("User ID is required");
|
|
}
|
|
|
|
try {
|
|
// 1. Delete Storage (Both keys)
|
|
nk.storageDelete([
|
|
{ collection: "stats", key: "stats", userId: targetUserId },
|
|
{ collection: "stats", key: "game_stats", userId: targetUserId }
|
|
]);
|
|
|
|
// 2. Delete Native Leaderboard Record
|
|
nk.leaderboardRecordDelete("global_high_score", targetUserId);
|
|
|
|
logger.info("Stats deleted for user " + targetUserId + " by admin " + ctx.userId);
|
|
return JSON.stringify({ success: true });
|
|
} catch (e) {
|
|
logger.error("Failed to delete stats: " + e);
|
|
throw new Error("Failed to delete stats");
|
|
}
|
|
}
|
|
|
|
function rpcAdminSyncLeaderboard(ctx, logger, nk, payload) {
|
|
requireAdmin(ctx, nk);
|
|
|
|
try {
|
|
var result = nk.storageList(null, "stats", 100, "");
|
|
var statsObjects = result.objects || [];
|
|
var userGroup = {}; // [userId] = { highScore, gamesPlayed, gamesWon, avatar }
|
|
|
|
// Phase 1: Group and merge
|
|
for (var i = 0; i < statsObjects.length; i++) {
|
|
var obj = statsObjects[i];
|
|
var userId = obj.userId;
|
|
var key = obj.key; // "stats" or "game_stats"
|
|
var value;
|
|
|
|
try {
|
|
value = JSON.parse(obj.value || "{}");
|
|
} catch (jsonErr) {
|
|
logger.error("Skipping key " + key + " for user " + userId + " due to corrupt JSON");
|
|
continue;
|
|
}
|
|
|
|
if (!userGroup[userId]) {
|
|
userGroup[userId] = {
|
|
high_score: value.high_score || 0,
|
|
games_played: value.games_played || 0,
|
|
games_won: value.games_won || 0,
|
|
avatar_url: "",
|
|
loadout_character: value.loadout_character || "Copper"
|
|
};
|
|
} else {
|
|
// Merge logic: sum counts, max high score
|
|
userGroup[userId].high_score = Math.max(userGroup[userId].high_score, value.high_score || 0);
|
|
userGroup[userId].games_played += (value.games_played || 0);
|
|
userGroup[userId].games_won += (value.games_won || 0);
|
|
}
|
|
|
|
// Prioritize avatar and character from game_stats
|
|
if (key === "game_stats" || !userGroup[userId].avatar_url) {
|
|
try {
|
|
if (value.avatar_url) userGroup[userId].avatar_url = value.avatar_url;
|
|
if (value.loadout_character) userGroup[userId].loadout_character = value.loadout_character;
|
|
} catch (e) {}
|
|
}
|
|
}
|
|
|
|
// Phase 2: Write to native leaderboard
|
|
var count = 0;
|
|
for (var userId in userGroup) {
|
|
try {
|
|
var stats = userGroup[userId];
|
|
var account = nk.accountGetId(userId);
|
|
|
|
var metadata = {
|
|
games_played: stats.games_played,
|
|
games_won: stats.games_won,
|
|
avatar_url: stats.avatar_url || account.user.avatarUrl || "",
|
|
loadout_character: stats.loadout_character || "Copper"
|
|
};
|
|
|
|
nk.leaderboardRecordWrite("global_high_score", userId, account.user.username, stats.high_score, 0, metadata);
|
|
count++;
|
|
} catch (inner) {
|
|
logger.error("Failed to sync merged record for " + userId + ": " + inner);
|
|
}
|
|
}
|
|
|
|
return JSON.stringify({ success: true, synced: count });
|
|
} catch (e) {
|
|
logger.error("Leaderboard sync failed: " + e);
|
|
throw new Error("Sync failed: " + e);
|
|
}
|
|
}
|
|
|
|
// Before login hook to check ban status
|
|
function beforeAuthenticateEmail(ctx, logger, nk, data) {
|
|
// Can't check ban before auth, so we check in afterAuthenticate
|
|
return data;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Social / Friend RPCs
|
|
// =============================================================================
|
|
|
|
// Sends a real-time notification (code 1002) to the target so their client
|
|
// can refresh the friends list immediately. The actual friend relationship
|
|
// is added by the client via add_friends_async BEFORE calling this RPC.
|
|
function rpcSendFriendRequest(ctx, logger, nk, payload) {
|
|
if (!ctx.userId) throw new Error("Not authenticated");
|
|
|
|
var request = {};
|
|
try { request = JSON.parse(payload || "{}"); } catch (e) {}
|
|
|
|
var targetUserId = request.user_id || "";
|
|
if (!targetUserId) throw new Error("user_id is required");
|
|
if (targetUserId === ctx.userId) throw new Error("Cannot add yourself");
|
|
|
|
var senderAccount = nk.accountGetId(ctx.userId);
|
|
var senderName = senderAccount.user.displayName || senderAccount.user.username || "Someone";
|
|
|
|
// Send a persistent notification to target so they see it on next login too
|
|
nk.notificationSend(
|
|
targetUserId,
|
|
"Friend Request",
|
|
{ from_user_id: ctx.userId, from_name: senderName },
|
|
1002, // code: friend request
|
|
ctx.userId, // sender
|
|
true // persistent (survives offline)
|
|
);
|
|
|
|
logger.info("Friend request notification sent from " + ctx.userId + " to " + targetUserId);
|
|
return JSON.stringify({ success: true });
|
|
}
|
|
|
|
function rpcSendLobbyInvite(ctx, logger, nk, payload) {
|
|
if (!ctx.userId) throw new Error("Not authenticated");
|
|
|
|
var request = {};
|
|
try { request = JSON.parse(payload || "{}"); } catch (e) {}
|
|
|
|
var toUserId = request.to_user_id || "";
|
|
var matchId = request.match_id || "";
|
|
if (!toUserId) throw new Error("to_user_id is required");
|
|
if (!matchId) throw new Error("match_id is required");
|
|
|
|
var senderAccount = nk.accountGetId(ctx.userId);
|
|
var senderName = senderAccount.user.displayName || senderAccount.user.username || "Someone";
|
|
|
|
nk.notificationSend(
|
|
toUserId,
|
|
"Lobby Invite",
|
|
{ from_name: senderName, match_id: matchId },
|
|
1001, // code: 1001 = lobby invite
|
|
ctx.userId,
|
|
false // not persistent
|
|
);
|
|
|
|
logger.info("Lobby invite sent from " + ctx.userId + " to " + toUserId + " for match " + matchId);
|
|
return JSON.stringify({ success: true });
|
|
}
|
|
|
|
// =============================================================================
|
|
// Inbox System RPCs
|
|
// =============================================================================
|
|
|
|
function rpcAdminSendMail(ctx, logger, nk, payload) {
|
|
requireAdmin(ctx, nk);
|
|
var request = JSON.parse(payload || "{}");
|
|
|
|
var nowStr = new Date().toISOString();
|
|
var startDate = request.start_date || nowStr;
|
|
var endDate = request.end_date || "";
|
|
|
|
// Auto-delete / expire after 30 days from start
|
|
var startObj = new Date(startDate);
|
|
startObj.setDate(startObj.getDate() + 30);
|
|
var expiryDate = startObj.toISOString();
|
|
|
|
var mailObj = {
|
|
id: nk.uuidv4(),
|
|
title: request.title || "Announcement",
|
|
content: request.content || "",
|
|
sender: "TEKTON DEV TEAM",
|
|
date: startDate,
|
|
start_date: startDate,
|
|
end_date: endDate,
|
|
expiry_date: expiryDate,
|
|
rewards: request.rewards || []
|
|
};
|
|
|
|
if (request.target_user_id) {
|
|
mailObj.type = "personal";
|
|
var invObjs = nk.storageRead([{ collection: "inbox", key: "personal", userId: request.target_user_id }]);
|
|
var personalMails = [];
|
|
if (invObjs && invObjs.length > 0) {
|
|
personalMails = invObjs[0].value.mails || [];
|
|
}
|
|
personalMails.push(mailObj);
|
|
nk.storageWrite([{
|
|
collection: "inbox",
|
|
key: "personal",
|
|
userId: request.target_user_id,
|
|
value: { mails: personalMails },
|
|
permissionRead: 1,
|
|
permissionWrite: 0
|
|
}]);
|
|
logger.info("Personal mail sent to " + request.target_user_id);
|
|
} else {
|
|
mailObj.type = "global";
|
|
var globalObjs = nk.storageRead([{ collection: "config", key: "global_mail", userId: "00000000-0000-0000-0000-000000000000" }]);
|
|
var globalMails = [];
|
|
if (globalObjs && globalObjs.length > 0) {
|
|
globalMails = globalObjs[0].value.mails || [];
|
|
}
|
|
globalMails.push(mailObj);
|
|
nk.storageWrite([{
|
|
collection: "config",
|
|
key: "global_mail",
|
|
userId: "00000000-0000-0000-0000-000000000000",
|
|
value: { mails: globalMails },
|
|
permissionRead: 2,
|
|
permissionWrite: 0
|
|
}]);
|
|
logger.info("Global mail sent");
|
|
}
|
|
|
|
return JSON.stringify({ success: true, mail: mailObj });
|
|
}
|
|
|
|
function rpcGetMail(ctx, logger, nk, payload) {
|
|
if (!ctx.userId) throw new Error("Not authenticated");
|
|
|
|
var personalObjs = nk.storageRead([{ collection: "inbox", key: "personal", userId: ctx.userId }]);
|
|
var globalObjs = nk.storageRead([{ collection: "config", key: "global_mail", userId: "00000000-0000-0000-0000-000000000000" }]);
|
|
var stateObjs = nk.storageRead([{ collection: "inbox", key: "state", userId: ctx.userId }]);
|
|
|
|
var personalMails = (personalObjs && personalObjs.length > 0) ? (personalObjs[0].value.mails || []) : [];
|
|
var globalMails = (globalObjs && globalObjs.length > 0) ? (globalObjs[0].value.mails || []) : [];
|
|
|
|
var state = { claimed_ids: [], deleted_ids: [], read_ids: [] };
|
|
if (stateObjs && stateObjs.length > 0) {
|
|
var val = stateObjs[0].value;
|
|
state.claimed_ids = val.claimed_ids || [];
|
|
state.deleted_ids = val.deleted_ids || [];
|
|
state.read_ids = val.read_ids || [];
|
|
}
|
|
|
|
var allMails = personalMails.concat(globalMails);
|
|
var filteredMails = [];
|
|
var nowStr = new Date().toISOString();
|
|
|
|
for (var i = 0; i < allMails.length; i++) {
|
|
var mail = allMails[i];
|
|
if (state.deleted_ids.indexOf(mail.id) !== -1) continue;
|
|
|
|
// Expiry check
|
|
if (mail.expiry_date && nowStr > mail.expiry_date) {
|
|
continue;
|
|
}
|
|
|
|
// Scheduled start
|
|
if (mail.start_date && nowStr < mail.start_date) {
|
|
continue;
|
|
}
|
|
|
|
// Scheduled end
|
|
if (mail.type === "global" && mail.end_date && nowStr > mail.end_date) {
|
|
continue;
|
|
}
|
|
|
|
filteredMails.push(mail);
|
|
}
|
|
|
|
return JSON.stringify({ mails: filteredMails, state: state });
|
|
}
|
|
|
|
function rpcClaimMailReward(ctx, logger, nk, payload) {
|
|
if (!ctx.userId) throw new Error("Not authenticated");
|
|
var request = JSON.parse(payload || "{}");
|
|
var mailId = request.mail_id;
|
|
if (!mailId) throw new Error("mail_id required");
|
|
|
|
// fetch all mails to find it
|
|
var personalObjs = nk.storageRead([{ collection: "inbox", key: "personal", userId: ctx.userId }]);
|
|
var globalObjs = nk.storageRead([{ collection: "config", key: "global_mail", userId: "00000000-0000-0000-0000-000000000000" }]);
|
|
var stateObjs = nk.storageRead([{ collection: "inbox", key: "state", userId: ctx.userId }]);
|
|
|
|
var state = { claimed_ids: [], deleted_ids: [], read_ids: [] };
|
|
if (stateObjs && stateObjs.length > 0) {
|
|
var val = stateObjs[0].value;
|
|
state.claimed_ids = val.claimed_ids || [];
|
|
state.deleted_ids = val.deleted_ids || [];
|
|
state.read_ids = val.read_ids || [];
|
|
}
|
|
|
|
if (state.claimed_ids.indexOf(mailId) !== -1) {
|
|
throw new Error("Reward already claimed");
|
|
}
|
|
|
|
var personalMails = (personalObjs && personalObjs.length > 0) ? (personalObjs[0].value.mails || []) : [];
|
|
var globalMails = (globalObjs && globalObjs.length > 0) ? (globalObjs[0].value.mails || []) : [];
|
|
var allMails = personalMails.concat(globalMails);
|
|
|
|
var targetMail = null;
|
|
for (var i = 0; i < allMails.length; i++) {
|
|
if (allMails[i].id === mailId) {
|
|
targetMail = allMails[i];
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!targetMail) throw new Error("Mail not found");
|
|
|
|
var rewards = targetMail.rewards || [];
|
|
var starTotal = 0;
|
|
var goldTotal = 0;
|
|
|
|
// Support legacy dictionary if it exists
|
|
if (!Array.isArray(rewards)) {
|
|
starTotal = rewards.star || 0;
|
|
goldTotal = rewards.gold || 0;
|
|
rewards = []; // prevent array loop
|
|
}
|
|
|
|
var fragsToUpdate = {};
|
|
var skinsToAdd = [];
|
|
|
|
for (var j = 0; j < rewards.length; j++) {
|
|
var r = rewards[j];
|
|
var type = r.type || "star";
|
|
var amount = r.amount || 0;
|
|
|
|
if (type === "star") starTotal += amount;
|
|
else if (type === "gold") goldTotal += amount;
|
|
else if (type.startsWith("frag_") || type === "item") {
|
|
var fragId = r.id || type;
|
|
fragsToUpdate[fragId] = (fragsToUpdate[fragId] || 0) + amount;
|
|
}
|
|
else if (type === "skin") {
|
|
if (r.id) skinsToAdd.push(r.id);
|
|
}
|
|
}
|
|
|
|
if (starTotal > 0 || goldTotal > 0) {
|
|
var changes = {};
|
|
if (starTotal > 0) changes["star"] = starTotal;
|
|
if (goldTotal > 0) changes["gold"] = goldTotal;
|
|
nk.walletUpdate(ctx.userId, changes, {}, true);
|
|
}
|
|
|
|
if (Object.keys(fragsToUpdate).length > 0) {
|
|
var invObjs = nk.storageRead([{ collection: "inventory", key: "fragments", userId: ctx.userId }]);
|
|
var frags = {};
|
|
if (invObjs && invObjs.length > 0) {
|
|
frags = invObjs[0].value;
|
|
}
|
|
for (var fId in fragsToUpdate) {
|
|
frags[fId] = (frags[fId] || 0) + fragsToUpdate[fId];
|
|
}
|
|
nk.storageWrite([{
|
|
collection: "inventory",
|
|
key: "fragments",
|
|
userId: ctx.userId,
|
|
value: frags,
|
|
permissionRead: 1,
|
|
permissionWrite: 0
|
|
}]);
|
|
}
|
|
|
|
if (skinsToAdd.length > 0) {
|
|
var skinWrites = [];
|
|
for (var s = 0; s < skinsToAdd.length; s++) {
|
|
skinWrites.push({
|
|
collection: "inventory",
|
|
key: skinsToAdd[s],
|
|
userId: ctx.userId,
|
|
value: { acquired_via: "mail", purchased_at: new Date().toISOString() },
|
|
permissionRead: 1,
|
|
permissionWrite: 0
|
|
});
|
|
}
|
|
nk.storageWrite(skinWrites);
|
|
}
|
|
|
|
state.claimed_ids.push(mailId);
|
|
if (state.read_ids.indexOf(mailId) === -1) {
|
|
state.read_ids.push(mailId);
|
|
}
|
|
|
|
nk.storageWrite([{
|
|
collection: "inbox",
|
|
key: "state",
|
|
userId: ctx.userId,
|
|
value: state,
|
|
permissionRead: 1,
|
|
permissionWrite: 0
|
|
}]);
|
|
|
|
return JSON.stringify({ success: true, claimed_ids: state.claimed_ids });
|
|
}
|
|
|
|
function rpcDeleteMail(ctx, logger, nk, payload) {
|
|
if (!ctx.userId) throw new Error("Not authenticated");
|
|
var request = JSON.parse(payload || "{}");
|
|
var mailId = request.mail_id;
|
|
if (!mailId) throw new Error("mail_id required");
|
|
|
|
var stateObjs = nk.storageRead([{ collection: "inbox", key: "state", userId: ctx.userId }]);
|
|
var state = { claimed_ids: [], deleted_ids: [], read_ids: [] };
|
|
if (stateObjs && stateObjs.length > 0) {
|
|
var val = stateObjs[0].value;
|
|
state.claimed_ids = val.claimed_ids || [];
|
|
state.deleted_ids = val.deleted_ids || [];
|
|
state.read_ids = val.read_ids || [];
|
|
}
|
|
|
|
if (state.deleted_ids.indexOf(mailId) === -1) {
|
|
state.deleted_ids.push(mailId);
|
|
}
|
|
if (state.read_ids.indexOf(mailId) === -1) {
|
|
state.read_ids.push(mailId);
|
|
}
|
|
|
|
nk.storageWrite([{
|
|
collection: "inbox",
|
|
key: "state",
|
|
userId: ctx.userId,
|
|
value: state,
|
|
permissionRead: 1,
|
|
permissionWrite: 0
|
|
}]);
|
|
|
|
return JSON.stringify({ success: true, deleted_ids: state.deleted_ids });
|
|
}
|
|
|
|
function rpcSaveMailState(ctx, logger, nk, payload) {
|
|
if (!ctx.userId) throw new Error("Not authenticated");
|
|
var request = JSON.parse(payload || "{}");
|
|
|
|
// Load existing state to merge (don't clobber claimed_ids from client)
|
|
var stateObjs = nk.storageRead([{ collection: "inbox", key: "state", userId: ctx.userId }]);
|
|
var state = { claimed_ids: [], deleted_ids: [], read_ids: [] };
|
|
if (stateObjs && stateObjs.length > 0) {
|
|
var val = stateObjs[0].value;
|
|
state.claimed_ids = val.claimed_ids || [];
|
|
state.deleted_ids = val.deleted_ids || [];
|
|
state.read_ids = val.read_ids || [];
|
|
}
|
|
|
|
// Merge read_ids from client (add any new ones)
|
|
var newReadIds = request.read_ids || [];
|
|
for (var i = 0; i < newReadIds.length; i++) {
|
|
if (state.read_ids.indexOf(newReadIds[i]) === -1) {
|
|
state.read_ids.push(newReadIds[i]);
|
|
}
|
|
}
|
|
|
|
nk.storageWrite([{
|
|
collection: "inbox",
|
|
key: "state",
|
|
userId: ctx.userId,
|
|
value: state,
|
|
permissionRead: 1,
|
|
permissionWrite: 0
|
|
}]);
|
|
|
|
return JSON.stringify({ success: true });
|
|
}
|
|
|
|
// =============================================================================
|
|
// Admin Mail Management RPCs
|
|
// =============================================================================
|
|
|
|
function rpcAdminListMail(ctx, logger, nk, payload) {
|
|
requireAdmin(ctx, nk);
|
|
|
|
// --- Global mails ---
|
|
var globalObjs = nk.storageRead([{ collection: "config", key: "global_mail", userId: "00000000-0000-0000-0000-000000000000" }]);
|
|
var globalMails = (globalObjs && globalObjs.length > 0) ? (globalObjs[0].value.mails || []) : [];
|
|
for (var i = 0; i < globalMails.length; i++) {
|
|
globalMails[i].type = "global";
|
|
}
|
|
|
|
// --- Personal mails: scan ALL users' inbox/personal objects ---
|
|
var personalMails = [];
|
|
var cursor = null;
|
|
try {
|
|
do {
|
|
// storageList(userId, collection, limit, cursor)
|
|
// Using empty string userId means list across all users for this collection
|
|
var listResult = nk.storageList("", "inbox", 100, cursor);
|
|
var objects = listResult.objects || [];
|
|
for (var j = 0; j < objects.length; j++) {
|
|
var obj = objects[j];
|
|
if (obj.key !== "personal") continue;
|
|
var ownerUserId = obj.userId;
|
|
var mails = obj.value.mails || [];
|
|
for (var k = 0; k < mails.length; k++) {
|
|
var m = mails[k];
|
|
m.type = "personal";
|
|
m.target_user_id = ownerUserId;
|
|
personalMails.push(m);
|
|
}
|
|
}
|
|
cursor = listResult.cursor || null;
|
|
} while (cursor);
|
|
} catch (e) {
|
|
logger.warn("admin_list_mail: could not list personal inboxes: " + e);
|
|
}
|
|
|
|
var allMails = globalMails.concat(personalMails);
|
|
// Sort newest first
|
|
allMails.sort(function(a, b) {
|
|
return (b.date || "").localeCompare(a.date || "");
|
|
});
|
|
|
|
return JSON.stringify({ mails: allMails });
|
|
}
|
|
|
|
function rpcAdminUpdateMail(ctx, logger, nk, payload) {
|
|
requireAdmin(ctx, nk);
|
|
var request = JSON.parse(payload || "{}");
|
|
var mailId = request.mail_id;
|
|
if (!mailId) throw new Error("mail_id required");
|
|
|
|
var isGlobal = request.type !== "personal";
|
|
var targetUserId = request.target_user_id || "";
|
|
var newTargetUserId = request.new_target_user_id;
|
|
var hasNewTarget = (newTargetUserId !== undefined && newTargetUserId !== null);
|
|
|
|
// Step 1: Find and extract the mail object from its current location
|
|
var mailObj = null;
|
|
|
|
if (isGlobal) {
|
|
var globalObjs = nk.storageRead([{ collection: "config", key: "global_mail", userId: "00000000-0000-0000-0000-000000000000" }]);
|
|
var globalMails = (globalObjs && globalObjs.length > 0) ? (globalObjs[0].value.mails || []) : [];
|
|
for (var i = 0; i < globalMails.length; i++) {
|
|
if (globalMails[i].id === mailId) {
|
|
mailObj = globalMails.splice(i, 1)[0];
|
|
break;
|
|
}
|
|
}
|
|
if (!mailObj) throw new Error("Mail not found in global");
|
|
// Write back without this mail (it may move)
|
|
nk.storageWrite([{
|
|
collection: "config",
|
|
key: "global_mail",
|
|
userId: "00000000-0000-0000-0000-000000000000",
|
|
value: { mails: globalMails },
|
|
permissionRead: 2,
|
|
permissionWrite: 0
|
|
}]);
|
|
} else {
|
|
if (!targetUserId) throw new Error("target_user_id required for personal mail");
|
|
var pObjs = nk.storageRead([{ collection: "inbox", key: "personal", userId: targetUserId }]);
|
|
var personalMails = (pObjs && pObjs.length > 0) ? (pObjs[0].value.mails || []) : [];
|
|
for (var j = 0; j < personalMails.length; j++) {
|
|
if (personalMails[j].id === mailId) {
|
|
mailObj = personalMails.splice(j, 1)[0];
|
|
break;
|
|
}
|
|
}
|
|
if (!mailObj) throw new Error("Mail not found in personal inbox");
|
|
// Write back without this mail
|
|
nk.storageWrite([{
|
|
collection: "inbox",
|
|
key: "personal",
|
|
userId: targetUserId,
|
|
value: { mails: personalMails },
|
|
permissionRead: 1,
|
|
permissionWrite: 0
|
|
}]);
|
|
}
|
|
|
|
// Step 2: Apply field updates to the mail object
|
|
if (request.title !== undefined) mailObj.title = request.title;
|
|
if (request.content !== undefined) mailObj.content = request.content;
|
|
if (request.end_date !== undefined) mailObj.end_date = request.end_date;
|
|
if (request.expiry_date !== undefined) mailObj.expiry_date = request.expiry_date;
|
|
|
|
// Step 3: Determine destination
|
|
var destUserId = hasNewTarget ? newTargetUserId : (isGlobal ? "" : targetUserId);
|
|
|
|
if (destUserId === "") {
|
|
// Write to global
|
|
mailObj.type = "global";
|
|
var gObjs = nk.storageRead([{ collection: "config", key: "global_mail", userId: "00000000-0000-0000-0000-000000000000" }]);
|
|
var gMails = (gObjs && gObjs.length > 0) ? (gObjs[0].value.mails || []) : [];
|
|
gMails.push(mailObj);
|
|
nk.storageWrite([{
|
|
collection: "config",
|
|
key: "global_mail",
|
|
userId: "00000000-0000-0000-0000-000000000000",
|
|
value: { mails: gMails },
|
|
permissionRead: 2,
|
|
permissionWrite: 0
|
|
}]);
|
|
} else {
|
|
// Write to personal inbox of destUserId
|
|
mailObj.type = "personal";
|
|
var dObjs = nk.storageRead([{ collection: "inbox", key: "personal", userId: destUserId }]);
|
|
var dMails = (dObjs && dObjs.length > 0) ? (dObjs[0].value.mails || []) : [];
|
|
dMails.push(mailObj);
|
|
nk.storageWrite([{
|
|
collection: "inbox",
|
|
key: "personal",
|
|
userId: destUserId,
|
|
value: { mails: dMails },
|
|
permissionRead: 1,
|
|
permissionWrite: 0
|
|
}]);
|
|
}
|
|
|
|
logger.info("Admin updated mail " + mailId + " by " + ctx.userId + (hasNewTarget ? " (moved to " + destUserId + ")" : ""));
|
|
return JSON.stringify({ success: true });
|
|
}
|
|
|
|
function rpcAdminDeleteMailServer(ctx, logger, nk, payload) {
|
|
requireAdmin(ctx, nk);
|
|
var request = JSON.parse(payload || "{}");
|
|
var mailId = request.mail_id;
|
|
if (!mailId) throw new Error("mail_id required");
|
|
|
|
var isGlobal = request.type !== "personal";
|
|
var targetUserId = request.target_user_id || "";
|
|
|
|
if (isGlobal) {
|
|
var globalObjs = nk.storageRead([{ collection: "config", key: "global_mail", userId: "00000000-0000-0000-0000-000000000000" }]);
|
|
var globalMails = (globalObjs && globalObjs.length > 0) ? (globalObjs[0].value.mails || []) : [];
|
|
var before = globalMails.length;
|
|
globalMails = globalMails.filter(function(m) { return m.id !== mailId; });
|
|
if (globalMails.length === before) throw new Error("Mail not found");
|
|
nk.storageWrite([{
|
|
collection: "config",
|
|
key: "global_mail",
|
|
userId: "00000000-0000-0000-0000-000000000000",
|
|
value: { mails: globalMails },
|
|
permissionRead: 2,
|
|
permissionWrite: 0
|
|
}]);
|
|
} else {
|
|
if (!targetUserId) throw new Error("target_user_id required for personal mail");
|
|
var pObjs = nk.storageRead([{ collection: "inbox", key: "personal", userId: targetUserId }]);
|
|
var personalMails = (pObjs && pObjs.length > 0) ? (pObjs[0].value.mails || []) : [];
|
|
var pBefore = personalMails.length;
|
|
personalMails = personalMails.filter(function(m) { return m.id !== mailId; });
|
|
if (personalMails.length === pBefore) throw new Error("Mail not found");
|
|
nk.storageWrite([{
|
|
collection: "inbox",
|
|
key: "personal",
|
|
userId: targetUserId,
|
|
value: { mails: personalMails },
|
|
permissionRead: 1,
|
|
permissionWrite: 0
|
|
}]);
|
|
}
|
|
|
|
logger.info("Admin deleted mail " + mailId + " from server by " + ctx.userId);
|
|
return JSON.stringify({ success: true });
|
|
}
|