@darkclaw921/umgt 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +161 -2
- package/architecture.md +105 -0
- package/bin/lm-game-hub.js +7 -0
- package/dist/server/activity.d.ts +17 -0
- package/dist/server/activity.js +55 -0
- package/dist/server/activity.js.map +1 -0
- package/dist/server/agents/activity.d.ts +36 -0
- package/dist/server/agents/activity.js +117 -0
- package/dist/server/agents/activity.js.map +1 -0
- package/dist/server/agents/claude.d.ts +5 -0
- package/dist/server/agents/claude.js +107 -0
- package/dist/server/agents/claude.js.map +1 -0
- package/dist/server/agents/codex.d.ts +5 -0
- package/dist/server/agents/codex.js +128 -0
- package/dist/server/agents/codex.js.map +1 -0
- package/dist/server/agents/core-tools.d.ts +23 -0
- package/dist/server/agents/core-tools.js +298 -0
- package/dist/server/agents/core-tools.js.map +1 -0
- package/dist/server/agents/core.d.ts +9 -0
- package/dist/server/agents/core.js +106 -0
- package/dist/server/agents/core.js.map +1 -0
- package/dist/server/agents/index.d.ts +5 -0
- package/dist/server/agents/index.js +41 -0
- package/dist/server/agents/index.js.map +1 -0
- package/dist/server/agents/kimi.d.ts +5 -0
- package/dist/server/agents/kimi.js +123 -0
- package/dist/server/agents/kimi.js.map +1 -0
- package/dist/server/agents/rpc.d.ts +19 -0
- package/dist/server/agents/rpc.js +102 -0
- package/dist/server/agents/rpc.js.map +1 -0
- package/dist/server/agents/types.d.ts +51 -0
- package/dist/server/agents/types.js +7 -0
- package/dist/server/agents/types.js.map +1 -0
- package/dist/server/app.d.ts +21 -0
- package/dist/server/app.js +38 -0
- package/dist/server/app.js.map +1 -0
- package/dist/server/asset-catalog.d.ts +5 -0
- package/dist/server/asset-catalog.js +525 -0
- package/dist/server/asset-catalog.js.map +1 -0
- package/dist/server/asset-media.d.ts +16 -0
- package/dist/server/asset-media.js +170 -0
- package/dist/server/asset-media.js.map +1 -0
- package/dist/server/asset-selection.d.ts +71 -0
- package/dist/server/asset-selection.js +273 -0
- package/dist/server/asset-selection.js.map +1 -0
- package/dist/server/assets.d.ts +69 -0
- package/dist/server/assets.js +669 -0
- package/dist/server/assets.js.map +1 -0
- package/dist/server/browser-gateway.d.ts +82 -0
- package/dist/server/browser-gateway.js +285 -0
- package/dist/server/browser-gateway.js.map +1 -0
- package/dist/server/chat-computer.d.ts +13 -0
- package/dist/server/chat-computer.js +29 -0
- package/dist/server/chat-computer.js.map +1 -0
- package/dist/server/chat-games.d.ts +38 -0
- package/dist/server/chat-games.js +202 -0
- package/dist/server/chat-games.js.map +1 -0
- package/dist/server/chat.d.ts +42 -0
- package/dist/server/chat.js +318 -0
- package/dist/server/chat.js.map +1 -0
- package/dist/server/cli.d.ts +14 -0
- package/dist/server/cli.js +117 -0
- package/dist/server/cli.js.map +1 -0
- package/dist/server/core-client.d.ts +69 -0
- package/dist/server/core-client.js +357 -0
- package/dist/server/core-client.js.map +1 -0
- package/dist/server/directory-picker.d.ts +14 -0
- package/dist/server/directory-picker.js +113 -0
- package/dist/server/directory-picker.js.map +1 -0
- package/dist/server/engine.d.ts +36 -0
- package/dist/server/engine.js +507 -0
- package/dist/server/engine.js.map +1 -0
- package/dist/server/events.d.ts +12 -0
- package/dist/server/events.js +137 -0
- package/dist/server/events.js.map +1 -0
- package/dist/server/game-launch.d.ts +16 -0
- package/dist/server/game-launch.js +143 -0
- package/dist/server/game-launch.js.map +1 -0
- package/dist/server/html5.d.ts +9 -0
- package/dist/server/html5.js +107 -0
- package/dist/server/html5.js.map +1 -0
- package/dist/server/interview.d.ts +241 -0
- package/dist/server/interview.js +190 -0
- package/dist/server/interview.js.map +1 -0
- package/dist/server/mcp.d.ts +27 -0
- package/dist/server/mcp.js +91 -0
- package/dist/server/mcp.js.map +1 -0
- package/dist/server/plugins.d.ts +8 -0
- package/dist/server/plugins.js +144 -0
- package/dist/server/plugins.js.map +1 -0
- package/dist/server/process.d.ts +15 -0
- package/dist/server/process.js +89 -0
- package/dist/server/process.js.map +1 -0
- package/dist/server/publication-materials.d.ts +18 -0
- package/dist/server/publication-materials.js +104 -0
- package/dist/server/publication-materials.js.map +1 -0
- package/dist/server/publication-mcp.d.ts +3 -0
- package/dist/server/publication-mcp.js +43 -0
- package/dist/server/publication-mcp.js.map +1 -0
- package/dist/server/publication.d.ts +54 -0
- package/dist/server/publication.js +334 -0
- package/dist/server/publication.js.map +1 -0
- package/dist/server/push.d.ts +81 -0
- package/dist/server/push.js +122 -0
- package/dist/server/push.js.map +1 -0
- package/dist/server/reports.d.ts +4 -0
- package/dist/server/reports.js +40 -0
- package/dist/server/reports.js.map +1 -0
- package/dist/server/roblox-assets.d.ts +41 -0
- package/dist/server/roblox-assets.js +131 -0
- package/dist/server/roblox-assets.js.map +1 -0
- package/dist/server/roblox-connection.d.ts +54 -0
- package/dist/server/roblox-connection.js +331 -0
- package/dist/server/roblox-connection.js.map +1 -0
- package/dist/server/roblox-install.d.ts +4 -0
- package/dist/server/roblox-install.js +170 -0
- package/dist/server/roblox-install.js.map +1 -0
- package/dist/server/roblox-mcp.d.ts +2 -0
- package/dist/server/roblox-mcp.js +34 -0
- package/dist/server/roblox-mcp.js.map +1 -0
- package/dist/server/roblox-play.d.ts +23 -0
- package/dist/server/roblox-play.js +84 -0
- package/dist/server/roblox-play.js.map +1 -0
- package/dist/server/roblox-project-tools.d.ts +11 -0
- package/dist/server/roblox-project-tools.js +119 -0
- package/dist/server/roblox-project-tools.js.map +1 -0
- package/dist/server/roblox-setup.d.ts +37 -0
- package/dist/server/roblox-setup.js +206 -0
- package/dist/server/roblox-setup.js.map +1 -0
- package/dist/server/roblox-skills.d.ts +15 -0
- package/dist/server/roblox-skills.js +15 -0
- package/dist/server/roblox-skills.js.map +1 -0
- package/dist/server/roblox.d.ts +30 -0
- package/dist/server/roblox.js +248 -0
- package/dist/server/roblox.js.map +1 -0
- package/dist/server/routes.d.ts +11 -0
- package/dist/server/routes.js +223 -0
- package/dist/server/routes.js.map +1 -0
- package/dist/server/security.d.ts +4 -0
- package/dist/server/security.js +64 -0
- package/dist/server/security.js.map +1 -0
- package/dist/server/skills.d.ts +4 -0
- package/dist/server/skills.js +123 -0
- package/dist/server/skills.js.map +1 -0
- package/dist/server/store.d.ts +25 -0
- package/dist/server/store.js +89 -0
- package/dist/server/store.js.map +1 -0
- package/dist/server/templates/roblox-coin-run.d.ts +2 -0
- package/dist/server/templates/roblox-coin-run.js +326 -0
- package/dist/server/templates/roblox-coin-run.js.map +1 -0
- package/dist/server/templates/roblox-skills.json +55 -0
- package/dist/server/templates/yandex-sdk.d.ts +3 -0
- package/dist/server/templates/yandex-sdk.js +177 -0
- package/dist/server/templates/yandex-sdk.js.map +1 -0
- package/dist/server/updates.d.ts +28 -0
- package/dist/server/updates.js +99 -0
- package/dist/server/updates.js.map +1 -0
- package/dist/server/video-media.d.ts +47 -0
- package/dist/server/video-media.js +224 -0
- package/dist/server/video-media.js.map +1 -0
- package/dist/server/video-routes.d.ts +7 -0
- package/dist/server/video-routes.js +91 -0
- package/dist/server/video-routes.js.map +1 -0
- package/dist/server/video.d.ts +214 -0
- package/dist/server/video.js +301 -0
- package/dist/server/video.js.map +1 -0
- package/dist/server/web-build.d.ts +16 -0
- package/dist/server/web-build.js +232 -0
- package/dist/server/web-build.js.map +1 -0
- package/dist/server/workflow.d.ts +221 -0
- package/dist/server/workflow.js +826 -0
- package/dist/server/workflow.js.map +1 -0
- package/dist/server/yandex-config.d.ts +33 -0
- package/dist/server/yandex-config.js +12 -0
- package/dist/server/yandex-config.js.map +1 -0
- package/dist/server/yandex-godot.d.ts +5 -0
- package/dist/server/yandex-godot.js +107 -0
- package/dist/server/yandex-godot.js.map +1 -0
- package/dist/shared/aspects.d.ts +48 -0
- package/dist/shared/aspects.js +22 -0
- package/dist/shared/aspects.js.map +1 -0
- package/dist/shared/assistant.d.ts +7 -0
- package/dist/shared/assistant.js +36 -0
- package/dist/shared/assistant.js.map +1 -0
- package/dist/shared/chat.d.ts +46 -0
- package/dist/shared/chat.js +2 -0
- package/dist/shared/chat.js.map +1 -0
- package/dist/shared/settings.d.ts +11 -0
- package/dist/shared/settings.js +25 -0
- package/dist/shared/settings.js.map +1 -0
- package/dist/shared/types.d.ts +510 -0
- package/dist/shared/types.js +2 -0
- package/dist/shared/types.js.map +1 -0
- package/dist/web/apple-touch-icon.png +0 -0
- package/dist/web/assets/AgentActivity-sYJeY5Yf.js +9 -0
- package/dist/web/assets/ArrowRight.es-9w41WqD8.js +1 -0
- package/dist/web/assets/ArrowUpRight.es-BmIrpjWN.js +1 -0
- package/dist/web/assets/AssetMediaPreview-D2cd6oQO.js +1 -0
- package/dist/web/assets/AssetSelectionWizard-a43_6NO7.js +1 -0
- package/dist/web/assets/CaretDown.es-Cq33NYip.js +1 -0
- package/dist/web/assets/CheckCircle.es-D5B3hdvz.js +1 -0
- package/dist/web/assets/CoreAccount-D4ZC8CWv.js +1 -0
- package/dist/web/assets/DownloadSimple.es-DwOWFbs2.js +1 -0
- package/dist/web/assets/FileText.es-D83IIiOG.js +1 -0
- package/dist/web/assets/ImageSquare.es-DxCFt99_.js +1 -0
- package/dist/web/assets/KidsApp-BXWaALoK.css +1 -0
- package/dist/web/assets/KidsApp-D-8t3HPy.js +2 -0
- package/dist/web/assets/MagnifyingGlass.es-rkdOU2rH.js +1 -0
- package/dist/web/assets/PaperPlaneRight.es-U0twumpP.js +1 -0
- package/dist/web/assets/PencilSimple.es-JVHwRvGx.js +1 -0
- package/dist/web/assets/PlainChat-BC4K_tmj.css +1 -0
- package/dist/web/assets/PlainChat-CMxYwKko.js +1 -0
- package/dist/web/assets/Sparkle.es-CmGpvnNm.js +1 -0
- package/dist/web/assets/Square.es-BjNN9Ypc.js +1 -0
- package/dist/web/assets/UploadSimple.es-DxIuxpCC.js +1 -0
- package/dist/web/assets/WarningCircle.es-D63H8cL9.js +1 -0
- package/dist/web/assets/WorkflowJournal-Did0sRta.js +1 -0
- package/dist/web/assets/YandexConfigFields-DDL22Bfw.js +1 -0
- package/dist/web/assets/YandexGamesView-R64RSJRL.js +2 -0
- package/dist/web/assets/bg-build-DIalyCDw.svg +3 -0
- package/dist/web/assets/bg-idea-DHcE-Dhs.svg +3 -0
- package/dist/web/assets/bg-materials-UDVFaueI.svg +3 -0
- package/dist/web/assets/bg-plan-DpfwIsqJ.svg +3 -0
- package/dist/web/assets/bg-play-CNc1QZFK.svg +3 -0
- package/dist/web/assets/dist-7vx7MTaJ.js +1 -0
- package/dist/web/assets/index-Izzbh11U.css +1 -0
- package/dist/web/assets/index-sNciOZYG.js +13 -0
- package/dist/web/assets/jsx-runtime-CdVFa8IP.js +1 -0
- package/dist/web/assets/manrope-cyrillic-wght-normal-Dvxsihut.woff2 +0 -0
- package/dist/web/assets/manrope-greek-wght-normal-DL7QRZyv.woff2 +0 -0
- package/dist/web/assets/manrope-latin-ext-wght-normal-Ch3YOpNY.woff2 +0 -0
- package/dist/web/assets/manrope-latin-wght-normal-DHIcAJRg.woff2 +0 -0
- package/dist/web/assets/manrope-vietnamese-wght-normal-usUDDRr7.woff2 +0 -0
- package/dist/web/assets/resources-DppXCagL.js +1 -0
- package/dist/web/assets/robot-happy-B6YvlU94.svg +24 -0
- package/dist/web/assets/robot-hello-CCsQgsS-.svg +24 -0
- package/dist/web/assets/robot-listen-Buuz9xII.svg +24 -0
- package/dist/web/assets/robot-sad-CRtiNVWs.svg +24 -0
- package/dist/web/assets/robot-think-CJ5fDBTm.svg +24 -0
- package/dist/web/assets/star-big-CSFDKjws.svg +22 -0
- package/dist/web/assets/star-empty-CC-5vioJ.svg +3 -0
- package/dist/web/assets/star-next-ztuDjOHU.svg +9 -0
- package/dist/web/assets/star-on-l_krO3aV.svg +10 -0
- package/dist/web/assets/ui-wHE_yBK0.js +41 -0
- package/dist/web/assets/umgt-logo-DAOkTN9u.png +0 -0
- package/dist/web/assets/unbounded-cyrillic-wght-normal-C627f1f8.woff2 +0 -0
- package/dist/web/assets/unbounded-latin-ext-wght-normal-D6H-S6MW.woff2 +0 -0
- package/dist/web/assets/unbounded-latin-wght-normal-D5afBTBx.woff2 +0 -0
- package/dist/web/assets/unbounded-vietnamese-wght-normal-rat7GD3o.woff2 +0 -0
- package/dist/web/assets/workflow-BhcU9uvG.css +1 -0
- package/dist/web/assets/workflow-BrIKN5ER.js +34 -0
- package/dist/web/favicon.ico +0 -0
- package/dist/web/icons/favicon-16.png +0 -0
- package/dist/web/icons/favicon-32.png +0 -0
- package/dist/web/icons/icon-192.png +0 -0
- package/dist/web/icons/icon-512.png +0 -0
- package/dist/web/icons/maskable-192.png +0 -0
- package/dist/web/icons/maskable-512.png +0 -0
- package/dist/web/index.html +28 -0
- package/dist/web/manifest.webmanifest +19 -0
- package/dist/web/sw.js +25 -0
- package/docs/agent-activity-ui.md +33 -0
- package/docs/agent-activity.md +43 -0
- package/docs/agent-permissions-ui.md +15 -0
- package/docs/api.md +145 -0
- package/docs/approval-journal.md +13 -0
- package/docs/asset-media.md +27 -0
- package/docs/asset-providers.md +34 -0
- package/docs/asset-selection.md +31 -0
- package/docs/automatic-approvals.md +15 -0
- package/docs/background-game-launch.md +17 -0
- package/docs/billing-validation.md +23 -0
- package/docs/core-isolation.md +37 -0
- package/docs/core.md +45 -0
- package/docs/generated-text-ui.md +41 -0
- package/docs/interactive-interview-ui.md +29 -0
- package/docs/interactive-interview.md +52 -0
- package/docs/local-codex.md +51 -0
- package/docs/local-meshy.md +13 -0
- package/docs/markdown.md +11 -0
- package/docs/permission-ui.md +11 -0
- package/docs/plan.md +47 -0
- package/docs/remote-core-deployment.md +47 -0
- package/docs/roblox.md +90 -0
- package/docs/validation.md +228 -0
- package/docs/video-reference.md +35 -0
- package/docs/workflow-journal-ui.md +21 -0
- package/docs/yandex-foundation.md +27 -0
- package/docs/yandex-games.md +45 -0
- package/package.json +95 -4
- package/scripts/create-roblox-demo.mjs +31 -0
- package/scripts/diagnose-roblox-mcp.mjs +45 -0
- package/scripts/open-hub-chrome.mjs +60 -0
- package/scripts/smoke-agent.mjs +91 -0
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { createWriteStream } from 'node:fs';
|
|
3
|
+
import { copyFile, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { pipeline } from 'node:stream/promises';
|
|
7
|
+
import { Transform, Readable } from 'node:stream';
|
|
8
|
+
import yauzl from 'yauzl';
|
|
9
|
+
import { localMediaMetadata, registerAssetMediaRoutes, checkedMediaPath } from './asset-media.js';
|
|
10
|
+
import { curatedAssets } from './asset-catalog.js';
|
|
11
|
+
const MAX_DOWNLOAD = 512 * 1024 * 1024;
|
|
12
|
+
const MAX_EXPANDED = 1024 * 1024 * 1024;
|
|
13
|
+
const allowedHosts = new Set(['api.polyhaven.com', 'dl.polyhaven.org', 'ambientcg.com', 'www.ambientcg.com', 'acg-download.struffelproductions.com', 'acg-media.struffelproductions.com', 'f003.backblazeb2.com', 'f004.backblazeb2.com', 'opengameart.org', 'kenney.nl', 'media.w3.org', 'download.blender.org']);
|
|
14
|
+
const safeExtensions = new Set(['.mp4', '.webm', '.ogv', '.gif', '.png', '.jpg', '.jpeg', '.webp', '.svg', '.bmp', '.tga', '.exr', '.hdr', '.glb', '.gltf', '.bin', '.obj', '.mtl', '.fbx', '.wav', '.ogg', '.mp3', '.flac', '.ttf', '.otf', '.woff', '.woff2', '.txt', '.md', '.json', '.license', '.csv']);
|
|
15
|
+
const licenseUrls = {
|
|
16
|
+
'CC0-1.0': 'https://creativecommons.org/publicdomain/zero/1.0/',
|
|
17
|
+
'CC-BY-3.0': 'https://creativecommons.org/licenses/by/3.0/',
|
|
18
|
+
'CC-BY-4.0': 'https://creativecommons.org/licenses/by/4.0/',
|
|
19
|
+
};
|
|
20
|
+
function httpsUrl(value) {
|
|
21
|
+
try {
|
|
22
|
+
const url = new URL(value ?? '');
|
|
23
|
+
return url.protocol === 'https:' && !url.username && !url.password;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Eligibility for recommendations is stricter than an unknown user-upload license. */
|
|
30
|
+
export function isAutoSelectableAsset(asset) {
|
|
31
|
+
return !!licenseUrls[asset.license] && asset.licenseUrl === licenseUrls[asset.license]
|
|
32
|
+
&& !!asset.author?.trim() && httpsUrl(asset.sourceUrl) && httpsUrl(asset.provenanceUrl)
|
|
33
|
+
&& !!asset.verifiedAt && !!asset.formats?.length
|
|
34
|
+
&& (asset.license === 'CC0-1.0' || !!asset.attribution?.trim());
|
|
35
|
+
}
|
|
36
|
+
async function trustedFetch(input, signal) {
|
|
37
|
+
signal?.throwIfAborted();
|
|
38
|
+
let url = new URL(input);
|
|
39
|
+
for (let i = 0; i < 6; i++) {
|
|
40
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.port || !allowedHosts.has(url.hostname))
|
|
41
|
+
throw new Error('Asset URL is outside the trusted provider domains');
|
|
42
|
+
const response = await fetch(url, { redirect: 'manual', signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000), headers: { 'User-Agent': 'LMGameHub/0.1 (local game development; Poly Haven integration)' } });
|
|
43
|
+
if (response.status >= 300 && response.status < 400) {
|
|
44
|
+
const location = response.headers.get('location');
|
|
45
|
+
await response.body?.cancel();
|
|
46
|
+
if (!location)
|
|
47
|
+
throw new Error('Provider returned an invalid redirect');
|
|
48
|
+
url = new URL(location, url);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (!response.ok) {
|
|
52
|
+
await response.body?.cancel();
|
|
53
|
+
throw new Error(`Provider request failed (${response.status})`);
|
|
54
|
+
}
|
|
55
|
+
return response;
|
|
56
|
+
}
|
|
57
|
+
throw new Error('Too many asset download redirects');
|
|
58
|
+
}
|
|
59
|
+
async function json(url, signal) {
|
|
60
|
+
const response = await trustedFetch(url, signal);
|
|
61
|
+
const reader = response.body?.getReader();
|
|
62
|
+
if (!reader)
|
|
63
|
+
throw new Error('Empty provider response');
|
|
64
|
+
const chunks = [];
|
|
65
|
+
let length = 0;
|
|
66
|
+
for (;;) {
|
|
67
|
+
const { done, value } = await reader.read();
|
|
68
|
+
if (done)
|
|
69
|
+
break;
|
|
70
|
+
length += value.length;
|
|
71
|
+
if (length > 24 * 1024 * 1024) {
|
|
72
|
+
await reader.cancel();
|
|
73
|
+
throw new Error('Provider response exceeds limit');
|
|
74
|
+
}
|
|
75
|
+
chunks.push(value);
|
|
76
|
+
}
|
|
77
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
78
|
+
}
|
|
79
|
+
const searchCache = new Map();
|
|
80
|
+
const polyAsset = (id, record) => ({ id, provider: 'polyhaven', name: record.name, type: ['hdris', 'textures', 'models'][record.type] ?? 'models', thumbnail: record.thumbnail_url && httpsUrl(record.thumbnail_url) ? record.thumbnail_url : undefined, sourceUrl: `https://polyhaven.com/a/${encodeURIComponent(id)}`, provenanceUrl: 'https://polyhaven.com/license', licenseUrl: licenseUrls['CC0-1.0'], verifiedAt: new Date().toISOString(), formats: record.type === 0 ? ['HDR', 'EXR'] : record.type === 1 ? ['JPG', 'PNG'] : ['GLTF'], license: 'CC0-1.0', author: Object.keys(record.authors ?? {}).join(', '), tags: [...(record.categories ?? []), ...(record.tags ?? [])], importMode: 'automatic' });
|
|
81
|
+
function ambientAsset(record) {
|
|
82
|
+
const downloads = Object.values(record.downloadFolders ?? {}).flatMap(folder => Object.values(folder.downloadFiletypeCategories ?? {}).flatMap(category => category.downloads ?? []));
|
|
83
|
+
const download = downloads.find(item => item.attribute === '1K-JPG') ?? downloads.filter(item => item.size < MAX_DOWNLOAD).sort((a, b) => a.size - b.size)[0];
|
|
84
|
+
return { id: record.assetId, provider: 'ambientcg', name: record.displayName || record.assetId, type: record.dataType === 'HDRI' ? 'hdris' : record.dataType === '3DModel' ? 'models' : 'textures', thumbnail: record.previewImage?.['512-PNG'] ?? Object.values(record.previewImage ?? {})[0], sourceUrl: `https://ambientcg.com/view?id=${encodeURIComponent(record.assetId)}`, provenanceUrl: 'https://docs.ambientcg.com/license/', licenseUrl: licenseUrls['CC0-1.0'], verifiedAt: new Date().toISOString(), license: 'CC0-1.0', author: 'ambientCG', tags: record.tags ?? [], downloadUrl: download?.downloadLink, formats: ['ZIP'], importMode: download ? 'automatic' : 'manual' };
|
|
85
|
+
}
|
|
86
|
+
const queryStopWords = new Set(['a', 'an', 'the', 'for', 'with', 'and', 'or', 'game', 'asset', 'assets', 'pack', 'free']);
|
|
87
|
+
function searchTerms(value) {
|
|
88
|
+
return [...new Set(value.toLowerCase().normalize('NFKC').split(/[^\p{L}\p{N}]+/u).filter(term => term.length > 1 && !queryStopWords.has(term)).map(term => term.replace(/s$/, '')))];
|
|
89
|
+
}
|
|
90
|
+
export async function searchAssets(query, options = {}) {
|
|
91
|
+
options.signal?.throwIfAborted();
|
|
92
|
+
const selected = options.provider && options.provider !== 'all' ? [options.provider] : ['polyhaven', 'ambientcg', 'kenney', 'opengameart', 'blender'];
|
|
93
|
+
const aliases = { 'model': 'models', 'texture': 'textures', '3d': 'models', 'music': 'audio', 'sfx': 'audio', 'sprites': '2d', 'tiles': '2d', 'characters': '2d' };
|
|
94
|
+
const type = aliases[options.type ?? ''] ?? options.type;
|
|
95
|
+
const terms = searchTerms(query), warnings = [];
|
|
96
|
+
const settled = await Promise.allSettled(selected.map(async (provider) => {
|
|
97
|
+
options.signal?.throwIfAborted();
|
|
98
|
+
const key = `${provider}:${query}:${type ?? ''}`;
|
|
99
|
+
const cached = searchCache.get(key);
|
|
100
|
+
if (cached && cached.until > Date.now())
|
|
101
|
+
return cached.items;
|
|
102
|
+
let items;
|
|
103
|
+
if (provider === 'kenney' || provider === 'opengameart' || provider === 'blender')
|
|
104
|
+
items = structuredClone(curatedAssets.filter(asset => asset.provider === provider));
|
|
105
|
+
else if (provider === 'polyhaven') {
|
|
106
|
+
const filter = ['hdris', 'textures', 'models'].includes(type ?? '') ? `?t=${type}` : '';
|
|
107
|
+
const records = await json(`https://api.polyhaven.com/assets${filter}`, options.signal);
|
|
108
|
+
items = Object.entries(records).map(([id, record]) => polyAsset(id, record));
|
|
109
|
+
}
|
|
110
|
+
else if (provider === 'ambientcg') {
|
|
111
|
+
const params = new URLSearchParams({ limit: '30', include: 'downloadData', q: query });
|
|
112
|
+
const result = await json(`https://ambientcg.com/api/v2/full_json?${params}`, options.signal);
|
|
113
|
+
items = result.foundAssets.map(ambientAsset);
|
|
114
|
+
}
|
|
115
|
+
else
|
|
116
|
+
throw new Error('Неизвестный провайдер');
|
|
117
|
+
const ranked = items.filter(asset => !type || type === 'all' || asset.type === type).map(asset => {
|
|
118
|
+
const haystack = searchTerms(`${asset.name} ${asset.tags.join(' ')} ${asset.type}`).join(' ');
|
|
119
|
+
return { asset, score: terms.filter(term => haystack.includes(term)).length };
|
|
120
|
+
});
|
|
121
|
+
items = ranked.filter(item => provider === 'ambientcg' || !terms.length || item.score > 0).sort((a, b) => b.score - a.score).slice(0, 30).map(item => item.asset);
|
|
122
|
+
options.signal?.throwIfAborted();
|
|
123
|
+
if (searchCache.size > 100)
|
|
124
|
+
searchCache.clear();
|
|
125
|
+
searchCache.set(key, { until: Date.now() + 300_000, items });
|
|
126
|
+
return items;
|
|
127
|
+
}));
|
|
128
|
+
options.signal?.throwIfAborted();
|
|
129
|
+
const assets = settled.flatMap((result, index) => { if (result.status === 'fulfilled')
|
|
130
|
+
return result.value; warnings.push(`${selected[index]}: ${result.reason instanceof Error ? result.reason.message : 'Сервис недоступен'}`); return []; });
|
|
131
|
+
return { assets: assets.map(catalogMediaMetadata), warnings };
|
|
132
|
+
}
|
|
133
|
+
export async function assetDetails(provider, id, options = {}) {
|
|
134
|
+
options.signal?.throwIfAborted();
|
|
135
|
+
if (!/^[a-zA-Z0-9_-]{1,120}$/.test(id))
|
|
136
|
+
throw Object.assign(new Error('Invalid asset identifier'), { statusCode: 400 });
|
|
137
|
+
if (provider === 'kenney' || provider === 'opengameart' || provider === 'blender') {
|
|
138
|
+
const item = curatedAssets.find(asset => asset.provider === provider && asset.id === id);
|
|
139
|
+
if (item)
|
|
140
|
+
return catalogMediaMetadata(structuredClone(item));
|
|
141
|
+
}
|
|
142
|
+
if (provider === 'polyhaven')
|
|
143
|
+
return polyAsset(id, await json(`https://api.polyhaven.com/info/${encodeURIComponent(id)}`, options.signal));
|
|
144
|
+
if (provider === 'ambientcg') {
|
|
145
|
+
const result = await json(`https://ambientcg.com/api/v2/full_json?assetId=${encodeURIComponent(id)}&include=downloadData`, options.signal);
|
|
146
|
+
const record = result.foundAssets.find(item => item.assetId === id);
|
|
147
|
+
if (record)
|
|
148
|
+
return ambientAsset(record);
|
|
149
|
+
}
|
|
150
|
+
throw Object.assign(new Error('Ассет не найден'), { statusCode: 404 });
|
|
151
|
+
}
|
|
152
|
+
async function downloadsFor(asset, signal) {
|
|
153
|
+
signal?.throwIfAborted();
|
|
154
|
+
if (asset.provider === 'opengameart' && asset.importMode === 'automatic' && asset.downloadUrl) {
|
|
155
|
+
return [{ url: asset.downloadUrl, name: decodeURIComponent(basename(new URL(asset.downloadUrl).pathname)) }];
|
|
156
|
+
}
|
|
157
|
+
if (asset.provider === 'ambientcg' && asset.downloadUrl) {
|
|
158
|
+
const url = new URL(asset.downloadUrl);
|
|
159
|
+
return [{ url: url.href, name: url.searchParams.get('file') ?? `${asset.id}.zip` }];
|
|
160
|
+
}
|
|
161
|
+
if (asset.provider === 'polyhaven') {
|
|
162
|
+
const files = await json(`https://api.polyhaven.com/files/${encodeURIComponent(asset.id)}`, signal);
|
|
163
|
+
const gltf = files.gltf?.['1k']?.gltf ?? files.gltf?.['2k']?.gltf;
|
|
164
|
+
if (gltf)
|
|
165
|
+
return [{ url: gltf.url, name: basename(new URL(gltf.url).pathname), size: gltf.size }, ...Object.entries(gltf.include ?? {}).map(([name, item]) => ({ url: item.url, name, size: item.size }))];
|
|
166
|
+
const hdri = files.hdri?.['1k']?.hdr ?? files.hdri?.['1k']?.exr;
|
|
167
|
+
if (hdri)
|
|
168
|
+
return [{ url: hdri.url, name: basename(new URL(hdri.url).pathname), size: hdri.size }];
|
|
169
|
+
const textures = Object.values(files).flatMap(resolutions => { const item = resolutions['1k']?.jpg ?? resolutions['1k']?.png; return item ? [{ url: item.url, name: basename(new URL(item.url).pathname), size: item.size }] : []; });
|
|
170
|
+
if (textures.length)
|
|
171
|
+
return textures;
|
|
172
|
+
}
|
|
173
|
+
throw Object.assign(new Error('Скачайте ассет на сайте источника и импортируйте файл через меню.'), { statusCode: 422 });
|
|
174
|
+
}
|
|
175
|
+
export function safeAssetPath(root, name) {
|
|
176
|
+
if (!name || name.includes('\\') || name.includes('\0') || /^[a-zA-Z]:/.test(name) || name.startsWith('/') || name.split('/').some(part => part === '..' || part.startsWith('.')))
|
|
177
|
+
throw new Error('Unsafe asset path');
|
|
178
|
+
const path = resolve(root, name);
|
|
179
|
+
const rel = relative(root, path);
|
|
180
|
+
if (!rel || rel.startsWith('..' + sep) || rel === '..')
|
|
181
|
+
throw new Error('Unsafe asset path');
|
|
182
|
+
return path;
|
|
183
|
+
}
|
|
184
|
+
export async function extractZip(archive, destination, signal) {
|
|
185
|
+
signal?.throwIfAborted();
|
|
186
|
+
await mkdir(destination, { recursive: true });
|
|
187
|
+
return new Promise((resolvePromise, reject) => {
|
|
188
|
+
yauzl.open(archive, { lazyEntries: true, validateEntrySizes: true, strictFileNames: true }, (error, zip) => {
|
|
189
|
+
if (error || !zip) {
|
|
190
|
+
reject(error ?? new Error('Invalid archive'));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const files = [];
|
|
194
|
+
let size = 0;
|
|
195
|
+
let count = 0;
|
|
196
|
+
let failed = false;
|
|
197
|
+
const cleanup = () => signal?.removeEventListener('abort', abort);
|
|
198
|
+
const fail = (error) => { if (failed)
|
|
199
|
+
return; failed = true; cleanup(); zip.close(); reject(error); };
|
|
200
|
+
const abort = () => fail(signal?.reason ?? new Error('Import aborted'));
|
|
201
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
202
|
+
if (signal?.aborted) {
|
|
203
|
+
abort();
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
zip.on('error', fail);
|
|
207
|
+
zip.on('end', () => { cleanup(); resolvePromise(files); });
|
|
208
|
+
zip.on('entry', (entry) => {
|
|
209
|
+
void (async () => {
|
|
210
|
+
signal?.throwIfAborted();
|
|
211
|
+
count++;
|
|
212
|
+
size += entry.uncompressedSize;
|
|
213
|
+
if (count > 10_000 || size > MAX_EXPANDED)
|
|
214
|
+
throw new Error('Archive exceeds extraction limits');
|
|
215
|
+
if (((entry.externalFileAttributes >>> 16) & 0xf000) === 0xa000)
|
|
216
|
+
throw new Error('Symlinks are not allowed in asset archives');
|
|
217
|
+
const name = entry.fileName.replace(/\/$/, '');
|
|
218
|
+
const parts = name.split('/');
|
|
219
|
+
const macMetadata = parts[0] === '__MACOSX';
|
|
220
|
+
const finderMetadata = parts.at(-1) === '.DS_Store';
|
|
221
|
+
// Validate bounds and every path component before skipping known archive metadata.
|
|
222
|
+
// AppleDouble entries are allowed only inside __MACOSX, never as game files.
|
|
223
|
+
if (macMetadata || finderMetadata) {
|
|
224
|
+
safeAssetPath(destination, parts.map((part, index) => (finderMetadata && index === parts.length - 1) || (macMetadata && part.startsWith('._')) ? 'metadata' : part).join('/'));
|
|
225
|
+
zip.readEntry();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const path = safeAssetPath(destination, name);
|
|
229
|
+
if (entry.fileName.endsWith('/')) {
|
|
230
|
+
await mkdir(path, { recursive: true });
|
|
231
|
+
zip.readEntry();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (!safeExtensions.has(extname(path).toLowerCase())) {
|
|
235
|
+
zip.readEntry();
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
await mkdir(dirname(path), { recursive: true });
|
|
239
|
+
const stream = await new Promise((resolve, rejectStream) => zip.openReadStream(entry, (error, stream) => error || !stream ? rejectStream(error) : resolve(stream)));
|
|
240
|
+
await pipeline(stream, createWriteStream(path, { flags: 'wx' }), { signal });
|
|
241
|
+
files.push(entry.fileName);
|
|
242
|
+
zip.readEntry();
|
|
243
|
+
})().catch(fail);
|
|
244
|
+
});
|
|
245
|
+
zip.readEntry();
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
async function downloadFile(item, destination, options) {
|
|
250
|
+
options.signal?.throwIfAborted();
|
|
251
|
+
const maximum = Math.min(MAX_DOWNLOAD, options.maxBytes ?? MAX_DOWNLOAD);
|
|
252
|
+
const cacheDir = options.cacheDir ?? join(homedir(), '.lm-game-hub/cache');
|
|
253
|
+
await mkdir(cacheDir, { recursive: true });
|
|
254
|
+
const key = createHash('sha256').update(item.url).digest('hex');
|
|
255
|
+
const cachePath = join(cacheDir, key);
|
|
256
|
+
try {
|
|
257
|
+
const info = await lstat(cachePath);
|
|
258
|
+
if (info.isFile() && !info.isSymbolicLink() && info.size <= maximum) {
|
|
259
|
+
await copyFile(cachePath, destination);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
catch { /* cache miss */ }
|
|
264
|
+
const response = await trustedFetch(item.url, options.signal);
|
|
265
|
+
if (!response.body)
|
|
266
|
+
throw new Error('Provider returned empty file');
|
|
267
|
+
const total = Number(response.headers.get('content-length')) || item.size;
|
|
268
|
+
if (total && total > maximum) {
|
|
269
|
+
await response.body.cancel();
|
|
270
|
+
throw new Error('Asset exceeds 512 MiB limit');
|
|
271
|
+
}
|
|
272
|
+
let downloaded = 0;
|
|
273
|
+
let last = 0;
|
|
274
|
+
const meter = new Transform({ transform(chunk, _encoding, callback) {
|
|
275
|
+
downloaded += chunk.length;
|
|
276
|
+
if (downloaded > maximum)
|
|
277
|
+
return callback(new Error('Asset exceeds 512 MiB limit'));
|
|
278
|
+
if (Date.now() - last > 150) {
|
|
279
|
+
last = Date.now();
|
|
280
|
+
options.emit?.({ downloaded, total, file: item.name });
|
|
281
|
+
}
|
|
282
|
+
callback(null, chunk);
|
|
283
|
+
} });
|
|
284
|
+
await pipeline(Readable.fromWeb(response.body), meter, createWriteStream(destination, { flags: 'wx' }), { signal: options.signal });
|
|
285
|
+
options.emit?.({ downloaded, total, file: item.name });
|
|
286
|
+
const cacheTemporary = `${cachePath}.${randomUUID()}.tmp`;
|
|
287
|
+
try {
|
|
288
|
+
await copyFile(destination, cacheTemporary);
|
|
289
|
+
await rename(cacheTemporary, cachePath);
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
await rm(cacheTemporary, { force: true }).catch(() => undefined);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
async function assetWorkspace(project) {
|
|
296
|
+
const root = await realpath(project.path);
|
|
297
|
+
const temporary = join(root, '.lm-game-hub/tmp');
|
|
298
|
+
const assets = join(root, 'assets');
|
|
299
|
+
for (const path of [join(root, '.lm-game-hub'), temporary, assets]) {
|
|
300
|
+
try {
|
|
301
|
+
if ((await lstat(path)).isSymbolicLink())
|
|
302
|
+
throw new Error('Asset folders cannot be symbolic links outside the managed layout');
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT'))
|
|
306
|
+
throw error;
|
|
307
|
+
}
|
|
308
|
+
await mkdir(path, { recursive: true });
|
|
309
|
+
}
|
|
310
|
+
for (const dir of [temporary, assets])
|
|
311
|
+
if (!(await realpath(dir)).startsWith(root + sep))
|
|
312
|
+
throw new Error('Asset folders cannot point outside the project');
|
|
313
|
+
return { root, stage: await mkdtemp(join(temporary, 'asset-')), assets };
|
|
314
|
+
}
|
|
315
|
+
async function finishImport(project, asset, workspace, files) {
|
|
316
|
+
if (!files.length)
|
|
317
|
+
throw new Error('В архиве нет поддерживаемых игровых ресурсов.');
|
|
318
|
+
if (files.includes('LMGH-ASSET.json') || files.includes('LMGH-CREDITS.txt'))
|
|
319
|
+
throw new Error('Archive uses a reserved provenance filename');
|
|
320
|
+
const id = `${asset.provider}-${asset.id}-${randomUUID().slice(0, 8)}`;
|
|
321
|
+
const directory = join(workspace.assets, id);
|
|
322
|
+
const record = { ...asset, sourceId: asset.id, id, importedAt: new Date().toISOString(), directory: relative(workspace.root, directory), files, ...localMediaMetadata(project, 'imported', id, files) };
|
|
323
|
+
if (asset.license.startsWith('CC-BY-')) {
|
|
324
|
+
await writeFile(join(workspace.stage, 'LMGH-CREDITS.txt'), `${asset.name}\nCreator: ${asset.author}\nSource: ${asset.sourceUrl}\nLicense: ${asset.license} — ${asset.licenseUrl}\n${asset.attribution ?? ''}\nChanges at import: none; archive unpacking only. Record subsequent modifications and include this credit when distributing the game.\n`);
|
|
325
|
+
}
|
|
326
|
+
await writeFile(join(workspace.stage, 'LMGH-ASSET.json'), JSON.stringify(record, null, 2));
|
|
327
|
+
await rename(workspace.stage, directory);
|
|
328
|
+
return record;
|
|
329
|
+
}
|
|
330
|
+
async function performImport(project, requestedAsset, options = {}) {
|
|
331
|
+
options.signal?.throwIfAborted();
|
|
332
|
+
const asset = await assetDetails(requestedAsset.provider, requestedAsset.id, options);
|
|
333
|
+
if (!isAutoSelectableAsset(asset))
|
|
334
|
+
throw new Error('Automatic import requires verified CC0 or CC-BY metadata with attribution');
|
|
335
|
+
const existing = await findExistingImport(project, asset);
|
|
336
|
+
if (existing)
|
|
337
|
+
return existing;
|
|
338
|
+
const downloads = await downloadsFor(asset, options.signal);
|
|
339
|
+
const workspace = await assetWorkspace(project);
|
|
340
|
+
const files = [];
|
|
341
|
+
let totalSize = 0;
|
|
342
|
+
try {
|
|
343
|
+
for (const item of downloads) {
|
|
344
|
+
const target = safeAssetPath(workspace.stage, item.name);
|
|
345
|
+
await mkdir(dirname(target), { recursive: true });
|
|
346
|
+
await downloadFile(item, target, options);
|
|
347
|
+
totalSize += (await stat(target)).size;
|
|
348
|
+
if (totalSize > MAX_EXPANDED)
|
|
349
|
+
throw new Error('Asset bundle exceeds 1 GiB limit');
|
|
350
|
+
if (extname(target).toLowerCase() === '.zip') {
|
|
351
|
+
files.push(...await extractZip(target, workspace.stage, options.signal));
|
|
352
|
+
await rm(target);
|
|
353
|
+
}
|
|
354
|
+
else {
|
|
355
|
+
if (!safeExtensions.has(extname(target).toLowerCase()))
|
|
356
|
+
throw new Error('Unsupported asset file type');
|
|
357
|
+
files.push(item.name);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
options.signal?.throwIfAborted();
|
|
361
|
+
return await finishImport(project, asset, workspace, files);
|
|
362
|
+
}
|
|
363
|
+
catch (error) {
|
|
364
|
+
await rm(workspace.stage, { recursive: true, force: true });
|
|
365
|
+
throw error;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
/** Resolve authoritative metadata, then reuse a verified previous import for the same source. */
|
|
369
|
+
export async function importAsset(project, requestedAsset, options = {}) {
|
|
370
|
+
options.signal?.throwIfAborted();
|
|
371
|
+
const root = await realpath(project.path);
|
|
372
|
+
return serializedImport(`provider:${root}:${requestedAsset.provider}:${requestedAsset.id}`, options.signal, () => performImport(project, requestedAsset, options));
|
|
373
|
+
}
|
|
374
|
+
function localMetadata(filename, metadata, id = randomUUID()) {
|
|
375
|
+
const license = metadata.license ?? 'User supplied — verify license';
|
|
376
|
+
const asset = { id, provider: 'local', name: metadata.name || filename, type: metadata.type || 'custom', sourceUrl: metadata.sourceUrl ?? '', license,
|
|
377
|
+
author: metadata.author, licenseUrl: metadata.licenseUrl ?? licenseUrls[license], attribution: metadata.attribution,
|
|
378
|
+
provenanceUrl: metadata.provenanceUrl, verifiedAt: metadata.verifiedAt, formats: metadata.formats ?? [extname(filename).slice(1).toUpperCase()], tags: metadata.tags ?? [], importMode: 'manual' };
|
|
379
|
+
if (license.startsWith('CC-BY-') && (!licenseUrls[license] || !asset.author?.trim() || !httpsUrl(asset.sourceUrl) || asset.licenseUrl !== licenseUrls[license])) {
|
|
380
|
+
throw Object.assign(new Error('CC-BY uploads require a supported 3.0/4.0 license, creator and HTTPS source URL'), { statusCode: 400 });
|
|
381
|
+
}
|
|
382
|
+
if (license.startsWith('CC-BY-') && !asset.attribution)
|
|
383
|
+
asset.attribution = `${asset.name} by ${asset.author}. ${asset.sourceUrl} — ${asset.license}.`;
|
|
384
|
+
return asset;
|
|
385
|
+
}
|
|
386
|
+
async function receiveLocalFiles(destination, filename, stream, signal) {
|
|
387
|
+
signal?.throwIfAborted();
|
|
388
|
+
const safeName = basename(filename.replaceAll('\\', '/')), target = safeAssetPath(destination, safeName);
|
|
389
|
+
let size = 0;
|
|
390
|
+
const meter = new Transform({ transform(chunk, _encoding, callback) { size += chunk.length; callback(size > MAX_DOWNLOAD ? new Error('File exceeds 512 MiB limit') : null, chunk); } });
|
|
391
|
+
await pipeline(stream, meter, createWriteStream(target, { flags: 'wx' }), { signal });
|
|
392
|
+
if (stream.truncated)
|
|
393
|
+
throw new Error('File exceeds upload limit');
|
|
394
|
+
const zipped = extname(target).toLowerCase() === '.zip';
|
|
395
|
+
if (!zipped && !safeExtensions.has(extname(target).toLowerCase()))
|
|
396
|
+
throw new Error('Unsupported asset file type');
|
|
397
|
+
const files = zipped ? await extractZip(target, destination, signal) : [safeName];
|
|
398
|
+
if (zipped)
|
|
399
|
+
await rm(target);
|
|
400
|
+
if (!files.length)
|
|
401
|
+
throw new Error('В архиве нет поддерживаемых игровых ресурсов.');
|
|
402
|
+
return files;
|
|
403
|
+
}
|
|
404
|
+
/** Validate an upload outside the game project; only return an opaque project-bound handle. */
|
|
405
|
+
export async function stageLocalAsset(project, filename, stream, metadata = {}, options) {
|
|
406
|
+
options.signal?.throwIfAborted();
|
|
407
|
+
const projectPath = await realpath(project.path);
|
|
408
|
+
await mkdir(options.stagingDir, { recursive: true, mode: 0o700 });
|
|
409
|
+
if ((await lstat(options.stagingDir)).isSymbolicLink())
|
|
410
|
+
throw new Error('Upload staging cannot be a symbolic link');
|
|
411
|
+
const root = await realpath(options.stagingDir);
|
|
412
|
+
if (root === projectPath || root.startsWith(projectPath + sep))
|
|
413
|
+
throw new Error('Upload staging must be outside the game project');
|
|
414
|
+
const handle = randomUUID(), folder = join(root, handle), data = join(folder, 'files');
|
|
415
|
+
const asset = localMetadata(basename(filename.replaceAll('\\', '/')), metadata, handle);
|
|
416
|
+
await mkdir(data, { recursive: true, mode: 0o700 });
|
|
417
|
+
try {
|
|
418
|
+
const files = await receiveLocalFiles(data, filename, stream, options.signal);
|
|
419
|
+
options.signal?.throwIfAborted();
|
|
420
|
+
Object.assign(asset, localMediaMetadata(project, 'staged', handle, files));
|
|
421
|
+
await writeFile(join(folder, 'manifest.json'), JSON.stringify({ projectId: project.id, projectPath, asset, files }), { flag: 'wx', mode: 0o600 });
|
|
422
|
+
return { handle, asset };
|
|
423
|
+
}
|
|
424
|
+
catch (error) {
|
|
425
|
+
await rm(folder, { recursive: true, force: true });
|
|
426
|
+
throw error;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
async function checkedFile(root, name) {
|
|
430
|
+
const file = safeAssetPath(root, name);
|
|
431
|
+
const actual = await realpath(file);
|
|
432
|
+
if (!actual.startsWith(root + sep) || (await lstat(file)).isSymbolicLink() || !(await stat(file)).isFile())
|
|
433
|
+
throw new Error('Asset file points outside its managed directory');
|
|
434
|
+
return file;
|
|
435
|
+
}
|
|
436
|
+
async function validImported(project, record) {
|
|
437
|
+
try {
|
|
438
|
+
const root = await realpath(project.path), directory = safeAssetPath(root, record.directory);
|
|
439
|
+
if (!directory.startsWith(join(root, 'assets') + sep) || (await lstat(directory)).isSymbolicLink())
|
|
440
|
+
return false;
|
|
441
|
+
const actual = await realpath(directory);
|
|
442
|
+
if (!actual.startsWith(root + sep) || !Array.isArray(record.files) || !record.files.length)
|
|
443
|
+
return false;
|
|
444
|
+
for (const file of record.files)
|
|
445
|
+
await checkedFile(actual, file);
|
|
446
|
+
return true;
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
async function findExistingImport(project, asset) {
|
|
453
|
+
const root = await realpath(project.path), folder = join(root, 'assets');
|
|
454
|
+
try {
|
|
455
|
+
if ((await lstat(folder)).isSymbolicLink() || !(await realpath(folder)).startsWith(root + sep))
|
|
456
|
+
return undefined;
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
return undefined;
|
|
460
|
+
}
|
|
461
|
+
for (const entry of await readdir(folder, { withFileTypes: true })) {
|
|
462
|
+
if (!entry.isDirectory() || entry.isSymbolicLink())
|
|
463
|
+
continue;
|
|
464
|
+
try {
|
|
465
|
+
const record = JSON.parse(await readFile(await checkedFile(join(folder, entry.name), 'LMGH-ASSET.json'), 'utf8'));
|
|
466
|
+
if (record.provider === asset.provider && record.sourceId === asset.id && record.sourceUrl === asset.sourceUrl && record.license === asset.license && await validImported(project, record))
|
|
467
|
+
return { ...record, ...localMediaMetadata(project, 'imported', record.id, record.files) };
|
|
468
|
+
}
|
|
469
|
+
catch { /* A user-owned or incomplete directory is not an import receipt. */ }
|
|
470
|
+
}
|
|
471
|
+
return undefined;
|
|
472
|
+
}
|
|
473
|
+
const imports = new Map();
|
|
474
|
+
async function serializedImport(key, signal, work) {
|
|
475
|
+
// Wait for an existing request, then recheck the persisted result. Each caller
|
|
476
|
+
// keeps its own cancellation semantics instead of sharing an AbortController.
|
|
477
|
+
while (imports.has(key)) {
|
|
478
|
+
await imports.get(key)?.catch(() => undefined);
|
|
479
|
+
signal?.throwIfAborted();
|
|
480
|
+
}
|
|
481
|
+
const promise = work();
|
|
482
|
+
imports.set(key, promise);
|
|
483
|
+
try {
|
|
484
|
+
return await promise;
|
|
485
|
+
}
|
|
486
|
+
finally {
|
|
487
|
+
if (imports.get(key) === promise)
|
|
488
|
+
imports.delete(key);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
/** Commit validated staged files after the plan gate. Repeated handles reuse verified results. */
|
|
492
|
+
export async function importStagedAsset(project, handle, options) {
|
|
493
|
+
options.signal?.throwIfAborted();
|
|
494
|
+
if (!/^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(handle))
|
|
495
|
+
throw Object.assign(new Error('Invalid upload handle'), { statusCode: 400 });
|
|
496
|
+
const root = await realpath(options.stagingDir), folder = join(root, handle);
|
|
497
|
+
if ((await lstat(options.stagingDir)).isSymbolicLink() || (await lstat(folder)).isSymbolicLink() || !(await realpath(folder)).startsWith(root + sep))
|
|
498
|
+
throw new Error('Upload staging points outside its managed directory');
|
|
499
|
+
return serializedImport(`staged:${folder}`, options.signal, async () => {
|
|
500
|
+
const manifest = JSON.parse(await readFile(await checkedFile(folder, 'manifest.json'), 'utf8'));
|
|
501
|
+
if (manifest.projectId !== project.id || manifest.projectPath !== await realpath(project.path))
|
|
502
|
+
throw Object.assign(new Error('Upload belongs to another project'), { statusCode: 409 });
|
|
503
|
+
const previous = await checkedFile(folder, 'imported.json').then(file => readFile(file, 'utf8')).then(text => JSON.parse(text)).catch(error => { if (error?.code === 'ENOENT')
|
|
504
|
+
return undefined; throw error; });
|
|
505
|
+
if (previous) {
|
|
506
|
+
if (await validImported(project, previous))
|
|
507
|
+
return previous;
|
|
508
|
+
throw new Error('Previously imported files have changed or disappeared. Upload the source again.');
|
|
509
|
+
}
|
|
510
|
+
const recovered = await findExistingImport(project, manifest.asset);
|
|
511
|
+
if (recovered) {
|
|
512
|
+
await writeFile(join(folder, 'imported.json'), JSON.stringify(recovered), { flag: 'wx', mode: 0o600 });
|
|
513
|
+
return recovered;
|
|
514
|
+
}
|
|
515
|
+
if (!Array.isArray(manifest.files) || !manifest.files.length || manifest.files.length > 10_000)
|
|
516
|
+
throw new Error('Invalid upload manifest');
|
|
517
|
+
const data = join(folder, 'files');
|
|
518
|
+
if ((await lstat(data)).isSymbolicLink() || !(await realpath(data)).startsWith(folder + sep))
|
|
519
|
+
throw new Error('Staged files point outside the upload');
|
|
520
|
+
const workspace = await assetWorkspace(project);
|
|
521
|
+
try {
|
|
522
|
+
let total = 0;
|
|
523
|
+
for (const name of manifest.files) {
|
|
524
|
+
options.signal?.throwIfAborted();
|
|
525
|
+
const source = await checkedFile(data, name), target = safeAssetPath(workspace.stage, name);
|
|
526
|
+
if (!safeExtensions.has(extname(name).toLowerCase()))
|
|
527
|
+
throw new Error('Unsupported staged file type');
|
|
528
|
+
total += (await stat(source)).size;
|
|
529
|
+
if (total > MAX_EXPANDED)
|
|
530
|
+
throw new Error('Asset bundle exceeds 1 GiB limit');
|
|
531
|
+
await mkdir(dirname(target), { recursive: true });
|
|
532
|
+
await copyFile(source, target);
|
|
533
|
+
options.emit?.({ downloaded: total, file: name });
|
|
534
|
+
}
|
|
535
|
+
options.signal?.throwIfAborted();
|
|
536
|
+
const imported = await finishImport(project, manifest.asset, workspace, manifest.files);
|
|
537
|
+
await writeFile(join(folder, 'imported.json'), JSON.stringify(imported), { flag: 'wx', mode: 0o600 });
|
|
538
|
+
return imported;
|
|
539
|
+
}
|
|
540
|
+
catch (error) {
|
|
541
|
+
await rm(workspace.stage, { recursive: true, force: true });
|
|
542
|
+
throw error;
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
export async function importLocalAsset(project, filename, stream, metadata = {}) {
|
|
547
|
+
const workspace = await assetWorkspace(project);
|
|
548
|
+
try {
|
|
549
|
+
const asset = localMetadata(basename(filename.replaceAll('\\', '/')), metadata);
|
|
550
|
+
const files = await receiveLocalFiles(workspace.stage, filename, stream);
|
|
551
|
+
return await finishImport(project, asset, workspace, files);
|
|
552
|
+
}
|
|
553
|
+
catch (error) {
|
|
554
|
+
await rm(workspace.stage, { recursive: true, force: true });
|
|
555
|
+
throw error;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
export function catalogMediaMetadata(asset) {
|
|
559
|
+
const media = [...(asset.media ?? [])];
|
|
560
|
+
if (asset.thumbnail && !media.some(item => item.url === asset.thumbnail))
|
|
561
|
+
media.push({ kind: 'image', url: asset.thumbnail, name: asset.name });
|
|
562
|
+
if (asset.previewAudioUrl && !media.some(item => item.url === asset.previewAudioUrl))
|
|
563
|
+
media.push({ kind: 'audio', url: asset.previewAudioUrl, name: asset.name });
|
|
564
|
+
if (asset.previewVideoUrl && !media.some(item => item.url === asset.previewVideoUrl))
|
|
565
|
+
media.push({ kind: 'video', url: asset.previewVideoUrl, name: asset.name, poster: asset.thumbnail });
|
|
566
|
+
return { ...asset, media, ...(['kenney', 'opengameart', 'blender'].includes(asset.provider) && asset.downloadUrl ? { mediaGalleryUrl: `/api/v1/assets/${encodeURIComponent(asset.provider)}/${encodeURIComponent(asset.id)}/media` } : {}) };
|
|
567
|
+
}
|
|
568
|
+
const mediaJobs = new Map();
|
|
569
|
+
/** Lazy official-source preview cache. Never creates a game project or touches its files. */
|
|
570
|
+
export async function prepareProviderMedia(provider, id, options) {
|
|
571
|
+
options.signal?.throwIfAborted();
|
|
572
|
+
const asset = await assetDetails(provider, id, { signal: options.signal });
|
|
573
|
+
if (!['kenney', 'opengameart', 'blender'].includes(provider) || !asset.downloadUrl || !isAutoSelectableAsset(asset))
|
|
574
|
+
throw Object.assign(new Error('Preview archive is unavailable; open the original source'), { statusCode: 422 });
|
|
575
|
+
await mkdir(options.mediaDir, { recursive: true, mode: 0o700 });
|
|
576
|
+
if ((await lstat(options.mediaDir)).isSymbolicLink())
|
|
577
|
+
throw new Error('Media cache cannot be a symlink');
|
|
578
|
+
const root = await realpath(options.mediaDir), key = createHash('sha256').update(asset.downloadUrl).digest('hex'), directory = join(root, key);
|
|
579
|
+
const existing = async () => { try {
|
|
580
|
+
const folder = await checkedMediaPath(root, key), manifest = JSON.parse(await readFile(await checkedMediaPath(folder, 'media.json'), 'utf8'));
|
|
581
|
+
if (manifest.sourceUrl !== asset.downloadUrl || !Array.isArray(manifest.files) || manifest.files.length > 10000)
|
|
582
|
+
throw new Error('Invalid media cache');
|
|
583
|
+
return { root: folder, files: manifest.files };
|
|
584
|
+
}
|
|
585
|
+
catch (error) {
|
|
586
|
+
if (error.code === 'ENOENT')
|
|
587
|
+
return undefined;
|
|
588
|
+
throw error;
|
|
589
|
+
} };
|
|
590
|
+
const cached = await existing();
|
|
591
|
+
if (cached)
|
|
592
|
+
return cached;
|
|
593
|
+
while (mediaJobs.has(directory)) {
|
|
594
|
+
await mediaJobs.get(directory);
|
|
595
|
+
options.signal?.throwIfAborted();
|
|
596
|
+
const result = await existing();
|
|
597
|
+
if (result)
|
|
598
|
+
return result;
|
|
599
|
+
}
|
|
600
|
+
const work = (async () => {
|
|
601
|
+
const stage = await mkdtemp(join(root, 'preview-'));
|
|
602
|
+
try {
|
|
603
|
+
const name = decodeURIComponent(basename(new URL(asset.downloadUrl).pathname)), target = safeAssetPath(stage, name);
|
|
604
|
+
await downloadFile({ url: asset.downloadUrl, name }, target, { ...options, maxBytes: 64 * 1024 * 1024 });
|
|
605
|
+
let files;
|
|
606
|
+
if (extname(name).toLowerCase() === '.zip') {
|
|
607
|
+
files = await extractZip(target, stage, options.signal);
|
|
608
|
+
await rm(target);
|
|
609
|
+
}
|
|
610
|
+
else {
|
|
611
|
+
if (!safeExtensions.has(extname(name).toLowerCase()))
|
|
612
|
+
throw new Error('Unsupported preview source');
|
|
613
|
+
files = [name];
|
|
614
|
+
}
|
|
615
|
+
options.signal?.throwIfAborted();
|
|
616
|
+
await writeFile(join(stage, 'media.json'), JSON.stringify({ files, sourceUrl: asset.downloadUrl }), { flag: 'wx' });
|
|
617
|
+
await rename(stage, directory);
|
|
618
|
+
return { root: directory, files };
|
|
619
|
+
}
|
|
620
|
+
catch (error) {
|
|
621
|
+
await rm(stage, { recursive: true, force: true });
|
|
622
|
+
throw error;
|
|
623
|
+
}
|
|
624
|
+
})();
|
|
625
|
+
mediaJobs.set(directory, work);
|
|
626
|
+
try {
|
|
627
|
+
return await work;
|
|
628
|
+
}
|
|
629
|
+
finally {
|
|
630
|
+
if (mediaJobs.get(directory) === work)
|
|
631
|
+
mediaJobs.delete(directory);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
export async function registerAssetRoutes(app, hub, guards = {}) {
|
|
635
|
+
registerAssetMediaRoutes(app, hub, (provider, id, signal) => prepareProviderMedia(provider, id, { cacheDir: join(hub.dataDir, 'cache'), mediaDir: join(hub.dataDir, 'provider-media'), signal }));
|
|
636
|
+
const projectFor = (id) => { const project = hub.store.get('projects', id); if (!project)
|
|
637
|
+
throw Object.assign(new Error('Project not found'), { statusCode: 404 }); return project; };
|
|
638
|
+
app.get('/api/v1/assets', request => searchAssets((request.query.q ?? '').slice(0, 200), request.query));
|
|
639
|
+
app.get('/api/v1/assets/:provider/:id', request => assetDetails(request.params.provider, request.params.id));
|
|
640
|
+
app.post('/api/v1/projects/:id/assets', async (request) => {
|
|
641
|
+
const project = projectFor(request.params.id);
|
|
642
|
+
guards.assertIdle?.(project.id);
|
|
643
|
+
const { provider, id } = request.body ?? {};
|
|
644
|
+
if (typeof provider !== 'string' || typeof id !== 'string')
|
|
645
|
+
throw Object.assign(new Error('provider and id are required'), { statusCode: 400 });
|
|
646
|
+
const asset = await assetDetails(provider, id);
|
|
647
|
+
const imported = await importAsset(project, asset, { cacheDir: join(hub.dataDir, 'cache'), emit: progress => hub.events.publish('asset.progress', progress, project.id) });
|
|
648
|
+
hub.store.project(project.id).put('assets', imported);
|
|
649
|
+
hub.events.publish('asset.imported', imported, project.id);
|
|
650
|
+
return imported;
|
|
651
|
+
});
|
|
652
|
+
app.post('/api/v1/projects/:id/assets/upload', async (request) => {
|
|
653
|
+
const project = projectFor(request.params.id);
|
|
654
|
+
guards.assertIdle?.(project.id);
|
|
655
|
+
const file = await request.file();
|
|
656
|
+
if (!file)
|
|
657
|
+
throw Object.assign(new Error('Select a file'), { statusCode: 400 });
|
|
658
|
+
const field = (name) => { const value = file.fields[name]; return value && !Array.isArray(value) && value.type === 'field' && typeof value.value === 'string' ? value.value : undefined; };
|
|
659
|
+
const imported = await importLocalAsset(project, file.filename, file.file, { sourceUrl: field('sourceUrl'), author: field('author'), license: field('license'), licenseUrl: field('licenseUrl'), attribution: field('attribution') });
|
|
660
|
+
if (file.file.truncated) {
|
|
661
|
+
await rm(join(project.path, imported.directory), { recursive: true, force: true });
|
|
662
|
+
throw new Error('File exceeds upload limit');
|
|
663
|
+
}
|
|
664
|
+
hub.store.project(project.id).put('assets', imported);
|
|
665
|
+
hub.events.publish('asset.imported', imported, project.id);
|
|
666
|
+
return imported;
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
//# sourceMappingURL=assets.js.map
|