player/src/api/Kubikon3DService.js
МИН 87444ee2c8 Initial public release: Rublox Player v1.0
Open-source web player for Rublox games, dual-licensed under
AGPL-3.0 + Commercial.

Highlights:
- Babylon.js 7 + React 18 + Vite 5 stack
- Self-contained engine (~46k lines): BlockManager, ModelManager,
  PlayerController, ScriptSandboxWorker, MultiplayerSync, 30+ GD
  gamemodes
- Configurable backend via VITE_API_BASE and friends — works against
  staging (dev-api.rublox.pro) out of the box
- Standalone mode (VITE_STANDALONE=true) loads a bundled sample game
  for first-run without any backend
- Full docs: README, ARCHITECTURE, CONTRIBUTING, SECURITY, CHANGELOG
- Lint + format scaffolding (ESLint + Prettier + EditorConfig)
- Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md
- Issue templates: bug_report, feature_request, security_disclosure

Removed before public release:
- frontend_deploy.py (contained production SSH credentials)
- ~27 admin endpoints (kept in private repo)
- Hard-coded internal URLs and IPs
- All previous git history (clean repo init)
2026-05-27 23:04:04 +03:00

488 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* API-сервис для Рублокс 3D (отдельный от 2D-Кубоши).
* Бэкенд: storys-микросервис, префикс /kubikon3d/...
*/
import axios from 'axios';
import { STORYS_addres } from './API';
const api = axios.create({
baseURL: STORYS_addres,
timeout: 30000,
// Поднимаем лимит размера body — без этого axios отказывается отправлять
// payload > ~10МБ. После Этапа 3 voxel-движка RLE-сжатие даёт <2МБ
// для 250м карты, но запас не помешает.
maxContentLength: 100 * 1024 * 1024, // 100 МБ
maxBodyLength: 100 * 1024 * 1024,
});
// Подкладываем JWT в каждый запрос — storys использует его, чтобы дёрнуть
// user-микросервис и узнать имя пользователя (resolve_my_username).
// Если токена нет (гость) — заголовок не ставим, бэк отдаст пустое имя.
//
// В плеере JWT хранится в localStorage['player_jwt'] (а не 'Authorization'
// как в Майнкрафтии), потому что плеер живёт на отдельном поддомене
// player.rublox.pro и его localStorage изолирован. Ключ перенумерован
// в Этапе 2 портирования плеера.
api.interceptors.request.use((config) => {
try {
const token = localStorage.getItem('player_jwt');
if (token) {
config.headers = config.headers || {};
config.headers.Authorization = token;
}
} catch (e) { /* ignore */ }
return config;
});
// ============ ПРОЕКТЫ ============
// Save-операции с увеличенным таймаутом (120с) — для больших карт.
// Стандартный 30с таймаут вызывал ошибку «не удалось сохранить» на 250м
// карте, потому что upload 5+МБ JSON по медленной сети занимает больше.
const SAVE_TIMEOUT = 120000;
export const createProject = (userId, data) =>
api.post('/kubikon3d/projects', { user_id: userId, ...data }, { timeout: SAVE_TIMEOUT });
/**
* Загрузить проект по id.
*
* Бэкенд проверяет права доступа по правилам:
* - published — открыто всем (можно вызвать без userId)
* - draft / review / blocked — только автору и админу
*
* Поэтому если открываем чужой/свой черновик в редакторе — обязательно
* передаём userId, иначе бэк отдаст 403.
*/
export const getProject = (id, userId = null) => {
const params = {};
if (userId != null) params.user_id = userId;
return api.get(`/kubikon3d/projects/${id}`, { params });
};
/**
* Загрузить проект с retry — на случай зависшего/медленного запроса.
*
* ПРОБЛЕМА: иногда запрос getProject подвисает (dev-proxy, перегруженный
* пул соединений, сетевой лаг) — и страница "Загрузка проекта… 0%"
* замирает на 30с (таймаут axios) или до 60с (safety-timer редактора).
* Приходилось перезагружать вручную по 5 раз.
*
* РЕШЕНИЕ: короткий таймаут на ПОПЫТКУ (12с) + до 3 попыток. Зависшая
* попытка отменяется и повторяется сама, без ручной перезагрузки.
* Сетевые/таймаут-ошибки → retry; 4xx (403/404) → сразу пробрасываем
* (повтор не поможет).
*
* @param {number} id — id проекта
* @param {number|null} userId
* @param {number} attempts — сколько попыток (по умолчанию 3)
* @param {number} perTryTimeout — таймаут одной попытки в мс (по умолчанию 12000)
*/
export const getProjectWithRetry = async (
id, userId = null, attempts = 3, perTryTimeout = 12000,
) => {
const params = {};
if (userId != null) params.user_id = userId;
let lastErr = null;
for (let i = 0; i < attempts; i++) {
try {
return await api.get(`/kubikon3d/projects/${id}`, {
params,
timeout: perTryTimeout,
});
} catch (err) {
lastErr = err;
const status = err.response?.status;
// 4xx (кроме 408/429) — повтор не имеет смысла, пробрасываем сразу.
if (status && status >= 400 && status < 500
&& status !== 408 && status !== 429) {
throw err;
}
// Сеть/таймаут/5xx — пробуем ещё раз.
console.warn(
`[Kubikon3DApi] getProject attempt ${i + 1}/${attempts} failed`
+ ` (${err.code || status || 'network'}), retrying...`,
);
}
}
throw lastErr;
};
export const updateProject = (id, data) =>
api.put(`/kubikon3d/projects/${id}`, data, { timeout: SAVE_TIMEOUT });
export const deleteProject = (id, userId) =>
api.delete(`/kubikon3d/projects/${id}`, { data: { user_id: userId } });
export const getMyProjects = (userId) =>
api.get('/kubikon3d/my-projects', { params: { user_id: userId } });
/**
* Лента игр Рублокса (умная лента — RUBLOX_SMART_FEED_PLAN.md).
*
* Второй аргумент — вкладка ленты:
* recommended — ранжирование по hot_score (умная лента);
* new — самые свежие;
* popular — по числу запусков;
* top_week — топ за неделю.
* Бэкенд принимает этот параметр и как `tab`, и как `sort` (старое имя) —
* шлём под обоими именами, чтобы не зависеть от версии бэкенда.
*/
export const getFeed = (page = 1, tab = 'recommended', maxAge = null, minRating = null, opts = {}) =>
api.get('/kubikon3d/feed', {
params: {
page, tab, sort: tab,
...(maxAge != null ? { max_age: maxAge } : {}),
...(minRating != null ? { min_rating: minRating } : {}),
...(opts.rank ? { rank: opts.rank } : {}),
...(opts.multiplayer != null
? { multiplayer: opts.multiplayer ? 'true' : 'false' } : {}),
...(opts.genre ? { genre: opts.genre } : {}),
...(opts.per_page ? { per_page: opts.per_page } : {}),
},
});
export const searchProjects = (q, maxAge = null) =>
api.get('/kubikon3d/search', {
params: { q, ...(maxAge != null ? { max_age: maxAge } : {}) },
});
// ============ ПУБЛИКАЦИЯ И МОДЕРАЦИЯ (Этап 3) ============
/**
* Опубликовать проект (умная лента — RUBLOX_SMART_FEED_PLAN.md).
* Премодерации нет: чистая игра сразу в ленте, подозрительная → review.
* payload: { user_id, age_rating: 6|12|16|18, title?, description?, thumbnail? }
* Ответ: { project, review: bool, too_empty: bool }
*/
export const publishProject = (id, payload) =>
api.post(`/kubikon3d/projects/${id}/publish`, payload);
export const unpublishProject = (id, userId) =>
api.post(`/kubikon3d/projects/${id}/unpublish`, { user_id: userId });
/** Очередь проверки админа — игры со status='review' (подозрительные скрипты). */
export const getModerationQueue = () =>
api.get('/kubikon3d/admin/moderation-queue');
/**
* Решение админа по игре из очереди проверки.
* payload: { admin_user_id, action: 'approve'|'block', comment?, age_rating? }
*/
export const moderateProject = (id, payload) =>
api.post(`/kubikon3d/admin/projects/${id}/moderate`, payload);
/** Заблокировать игру (умная лента, Фаза 7). payload: { admin_user_id, comment? } */
export const blockProject = (id, payload) =>
api.post(`/kubikon3d/admin/projects/${id}/block`, payload);
/** Разблокировать игру → published. payload: { admin_user_id } */
export const unblockProject = (id, payload) =>
api.post(`/kubikon3d/admin/projects/${id}/unblock`, payload);
/** Вернуть demoted-игру в ленту вручную. payload: { admin_user_id } */
export const restoreFeed = (id, payload) =>
api.post(`/kubikon3d/admin/projects/${id}/restore-feed`, payload);
export const getModerationHistory = (id) =>
api.get(`/kubikon3d/projects/${id}/moderation-history`);
// ============ ПЛЕЕР / СОЦИАЛКА (Этап 3.3) ============
/** Загрузить проект для плеера. Передаём user_id для проверки доступа. */
export const getProjectForPlay = (id, userId = null, isAdmin = false) =>
api.get(`/kubikon3d/projects/${id}`, {
params: {
...(userId ? { user_id: userId } : {}),
...(isAdmin ? { is_admin: 'true' } : {}),
},
});
export const incrementPlay = (id) =>
api.post(`/kubikon3d/projects/${id}/play`);
/** kind: 'like' | 'dislike'. Toggle: повторный голос того же типа снимает,
* голос другого типа — переключает. */
export const toggleLike = (id, userId, kind = 'like') =>
api.post(`/kubikon3d/projects/${id}/like`, { user_id: userId, kind });
export const getLikeStatus = (id, userId) =>
api.get(`/kubikon3d/projects/${id}/like-status`, { params: { user_id: userId } });
/** payload: { reporter_user_id, target_type, target_id, category, text } */
export const createReport = (payload) =>
api.post('/kubikon3d/reports', payload);
/** Публичные игры автора. */
export const getUserGames = (userId, maxAge = null) =>
api.get(`/kubikon3d/users/${userId}/games`, {
params: maxAge != null ? { max_age: maxAge } : {},
});
// ============ ЛИДЕРБОРД (рекорды прохождения игр) ============
/** Топ-N рекордов прохождения проекта. По умолчанию 5. */
export const getLeaderboard = (projectId, limit = 5) =>
api.get(`/kubikon3d/projects/${projectId}/leaderboard`, {
params: { limit },
});
/** Отправить рекорд прохождения. Если время лучше предыдущего — обновляется. */
export const submitLeaderboard = (projectId, userId, timeMs) =>
api.post(`/kubikon3d/projects/${projectId}/leaderboard`, {
user_id: userId,
time_ms: timeMs,
});
// ============ ИЗБРАННОЕ ============
export const toggleFavorite = (projectId, userId) =>
api.post(`/kubikon3d/projects/${projectId}/favorite`, { user_id: userId });
export const getFavoriteStatus = (projectId, userId) =>
api.get(`/kubikon3d/projects/${projectId}/favorite-status`,
{ params: { user_id: userId } });
export const getMyFavorites = (userId) =>
api.get(`/kubikon3d/users/${userId}/favorites`);
// ============ ИСТОРИЯ ============
export const getPlayHistory = (userId, limit = 8) =>
api.get(`/kubikon3d/users/${userId}/history`, { params: { limit } });
// ============ ТРЕНДЫ / ТОП-АВТОРЫ / АКТИВНОСТЬ ============
export const getTrending = (limit = 8) =>
api.get('/kubikon3d/trending', { params: { limit } });
export const getTopAuthors = (limit = 10) =>
api.get('/kubikon3d/top-authors', { params: { limit } });
export const getActivity = (limit = 10) =>
api.get('/kubikon3d/activity', { params: { limit } });
export const getCollections = () =>
api.get('/kubikon3d/collections');
export const getEvents = () =>
api.get('/kubikon3d/events');
// ============ PERF LOGS ============
export const submitPerfLog = (sample) =>
api.post('/kubikon3d/perf-log', sample);
// ============ БАГ-РЕПОРТЫ РУБЛОКСА (с прикреплением скриншота) ============
/**
* Создать баг-репорт. Использует multipart/form-data, потому что может нести файл.
* fields: { bug_type, title, message, url_path, user_agent, user_id?, project_id?, screenshot? File }
*/
export const createBugReport = (fields) => {
const fd = new FormData();
Object.entries(fields).forEach(([k, v]) => {
if (v == null || v === '') return;
fd.append(k, v);
});
return api.post('/kubikon3d/bug-reports', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
});
};
export const getAdminBugReports = (status = 'open', limit = 100) =>
api.get('/kubikon3d/admin/bug-reports', { params: { status, limit } });
export const updateAdminBugReport = (id, payload) =>
api.post(`/kubikon3d/admin/bug-reports/${id}`, payload);
// ============ HEARTBEAT / ОНЛАЙН ============
export const playHeartbeat = (sessionId, projectId, userId = null) =>
api.post('/kubikon3d/play/heartbeat', {
session_id: sessionId,
project_id: projectId,
user_id: userId,
});
export const getOnline = () =>
api.get('/kubikon3d/admin/online');
// ============ DASHBOARD / СТАТИСТИКА ============
export const getDashboard = () =>
api.get('/kubikon3d/admin/dashboard');
export const getAdminAllGames = (status = 'all', sort = 'new', limit = 200) =>
api.get('/kubikon3d/admin/all-games', { params: { status, sort, limit } });
export const getAdminAuthors = (limit = 100) =>
api.get('/kubikon3d/admin/authors', { params: { limit } });
// ============ ЖАЛОБЫ (АДМИНКА) ============
export const getAdminReports = (status = 'open', limit = 200) =>
api.get('/kubikon3d/admin/reports', { params: { status, limit } });
export const resolveAdminReport = (id, status, adminUserId) =>
api.post(`/kubikon3d/admin/reports/${id}`, { status, admin_user_id: adminUserId });
// ============ БАН ПУБЛИКАЦИЙ (этап 3.10) ============
/** Публичный — узнать активный бан публикаций пользователя. */
export const getPublishBanStatus = (userId) =>
api.get(`/kubikon3d/users/${userId}/publish-ban-status`);
export const getPublishBanHistory = (userId) =>
api.get(`/kubikon3d/admin/users/${userId}/publish-ban-history`);
// ============ КОММЕНТАРИИ ПОД ИГРАМИ (этап 3.9) ============
export const getProjectComments = (projectId) =>
api.get(`/kubikon3d/projects/${projectId}/comments`);
/** payload: { user_id, username, text } */
export const createProjectComment = (projectId, payload) =>
api.post(`/kubikon3d/projects/${projectId}/comments`, payload);
export const deleteProjectComment = (commentId, userId) =>
api.delete(`/kubikon3d/comments/${commentId}`, { data: { user_id: userId } });
export const editProjectComment = (commentId, userId, text) =>
api.put(`/kubikon3d/comments/${commentId}`, { user_id: userId, text });
// Админ
export const getAdminComments = (filter = 'flagged', projectId = null, limit = 200) =>
api.get('/kubikon3d/admin/comments', {
params: { filter, ...(projectId ? { project: projectId } : {}), limit },
});
// ============ ВНУТРИИГРОВОЙ ЧАТ (этап 3.5/3.6) ============
/** since (опц.) — id последнего сообщения; если задан — вернётся только дельта. */
export const getChat = (projectId, since = null, limit = 50) =>
api.get(`/kubikon3d/projects/${projectId}/chat`, {
params: { ...(since ? { since } : {}), limit },
});
/** payload: { user_id, username, text } */
export const postChatMessage = (projectId, payload) =>
api.post(`/kubikon3d/projects/${projectId}/chat`, payload);
/** Узнать активный мьют чата для пользователя. */
export const getChatMuteStatus = (userId) =>
api.get(`/kubikon3d/users/${userId}/chat-mute-status`);
// Админ
export const getAdminChatMessages = (filter = 'flagged', projectId = null, limit = 200) =>
api.get('/kubikon3d/admin/chat/messages', {
params: { filter, ...(projectId ? { project: projectId } : {}), limit },
});
export const getAdminChatBans = (filter = 'active', limit = 200) =>
api.get('/kubikon3d/admin/chat/bans', { params: { filter, limit } });
// ============================================================================
// Пользовательские модели (Этап 1+ редактора моделей)
// ============================================================================
// Эндпоинты для воксельных и гладких моделей, созданных пользователями.
// Отображаются в Toolbox в разделах "Мои модели" и "Сообщество".
// См. KUBIKON_MODEL_EDITOR_PLAN.md.
/** Создать модель.
* payload: { title, kind: 'voxel'|'smooth', model_data: stringJSON,
* description?, thumbnail_b64? }
* Возвращает serialize_full (с model_data).
*/
export const createUserModel = (userId, payload) =>
api.post('/kubikon3d/models', { user_id: userId, ...payload },
{ timeout: SAVE_TIMEOUT });
/** Загрузить модель по id. userId — для проверки доступа к приватной. */
export const getUserModel = (id, userId = null) => {
const params = {};
if (userId != null) params.user_id = userId;
return api.get(`/kubikon3d/models/${id}`, { params });
};
/** Обновить модель. payload: { title?, description?, model_data?, thumbnail_b64? } */
export const updateUserModel = (id, userId, payload) =>
api.put(`/kubikon3d/models/${id}`, { user_id: userId, ...payload },
{ timeout: SAVE_TIMEOUT });
export const deleteUserModel = (id, userId) =>
api.delete(`/kubikon3d/models/${id}`, {
data: { user_id: userId },
params: { user_id: userId },
});
/** Мои модели (для раздела "Мои" в Toolbox). */
export const getMyUserModels = (userId, opts = {}) =>
api.get('/kubikon3d/models/mine', {
params: {
user_id: userId,
...(opts.kind ? { kind: opts.kind } : {}),
...(opts.limit ? { limit: opts.limit } : {}),
...(opts.offset ? { offset: opts.offset } : {}),
},
});
/** Публичные модели (для раздела "Сообщество" в Toolbox).
* opts: { q, kind, limit, offset, userId }
* userId — чтобы у моделей пришёл флаг liked_by_me (подсветка лайка). */
export const getPublicUserModels = (opts = {}) =>
api.get('/kubikon3d/models/public', {
params: {
...(opts.q ? { q: opts.q } : {}),
...(opts.kind ? { kind: opts.kind } : {}),
...(opts.limit ? { limit: opts.limit } : {}),
...(opts.offset ? { offset: opts.offset } : {}),
...(opts.userId != null ? { user_id: opts.userId } : {}),
},
});
export const publishUserModel = (id, userId) =>
api.post(`/kubikon3d/models/${id}/publish`, { user_id: userId });
export const unpublishUserModel = (id, userId) =>
api.post(`/kubikon3d/models/${id}/unpublish`, { user_id: userId });
/** Инкремент uses_count — вызывать когда модель ставят в проект. */
export const incrementModelUses = (id) =>
api.post(`/kubikon3d/models/${id}/use`);
/** Поставить/снять лайк пользовательской модели (toggle).
* Возвращает { liked, likes_count }. */
export const likeUserModel = (id, userId) =>
api.post(`/kubikon3d/models/${id}/like`, { user_id: userId });
// ============ СКИНЫ ИГРОКА (R15) ============
/** Список купленных скинов игрока. Возвращает { skins: [...], count }. */
export const getOwnedSkins = (userId) =>
api.get('/kubikon3d/rublox/owned-skins', { params: { user_id: userId } });
/** Выбранный (надетый) скин игрока. Возвращает { user_id, skin_folder }.
* Если записи нет — бэк отдаёт дефолт skin_bacon-hair. */
export const getEquippedSkin = (userId) =>
api.get('/kubikon3d/rublox/equipped-skin', { params: { user_id: userId } });
/** Установить выбранный скин игрока. Бэк проверяет владение скином.
* Возвращает { ok, skin_folder } или ошибку. */
export const setEquippedSkin = (userId, skinFolder) =>
api.post('/kubikon3d/rublox/equipped-skin', {
user_id: userId, skin_folder: skinFolder,
});
/** Дизайнерский эндпоинт — получить один скин по id (видит и draft/testing).
* Используется в preview-режиме `/_preview-skin/:itemId`.
* Требует JWT с ролью designer или owner. Возвращает item.serialize. */
export const getDesignerSkin = (itemId) =>
api.get(`/designer/skins/${itemId}`);
/** Текущий outfit игрока (Подфаза 3.9 RUBLOX_ACCESSORY_ATTACHMENT_PLAN.md).
* Возвращает { slots: {hat_id, tool_id, ...}, items: {<id>: serialize, ...} }.
* В items уже есть attachment-поля — плеер сразу делает equipAccessory. */
export const getRubloxOutfit = (userId) =>
api.get('/rublox/outfit', { params: { user_id: userId } });
/** Дизайнерская модель (Фаза 5 RUBLOX_DESIGNER_PLAN.md).
* Видит draft/testing — требует JWT с ролью designer/owner.
* Используется в preview-режиме /_preview-model/:id. */
export const getDesignerModel = (modelId) =>
api.get(`/designer/models/${modelId}`);
/** Дизайнерский аватар (2026-05-27). Видит draft/testing.
* Используется в preview-режиме /_preview-avatar/:id. */
export const getDesignerAvatar = (avatarId) =>
api.get(`/designer/avatars/${avatarId}`);