pixelforge-uploader 2.0.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/README.md +52 -0
- package/package.json +14 -0
- package/src.js +273 -0
package/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# PixelForge Uploader
|
|
2
|
+
|
|
3
|
+
A tiny local program that uploads [PixelForge](https://create.roblox.com) icons
|
|
4
|
+
to Roblox through Open Cloud.
|
|
5
|
+
|
|
6
|
+
## Why a separate program?
|
|
7
|
+
|
|
8
|
+
Roblox Studio plugins can't upload assets on their own: `CreateAssetAsync` is
|
|
9
|
+
blocked for Creator-Store installs, and `HttpService` can't reach
|
|
10
|
+
`apis.roblox.com`. A plugin that uploads through a *remote* server gets removed
|
|
11
|
+
for "Misusing Roblox Systems".
|
|
12
|
+
|
|
13
|
+
So the plugin talks to this small program running on **your own machine**
|
|
14
|
+
(`localhost`), and it does the upload from your own connection. Your API key
|
|
15
|
+
lives only here, never in the plugin.
|
|
16
|
+
|
|
17
|
+
## Run it
|
|
18
|
+
|
|
19
|
+
Needs [Node.js](https://nodejs.org) 18+ (one-time install). Then, in a terminal:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
npx pixelforge-uploader
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The first time, it asks for your Open Cloud API key and creator id, then prints
|
|
26
|
+
"PixelForge Uploader is running". Leave it open while you upload; close the
|
|
27
|
+
window when you're done. Same command on Windows and macOS.
|
|
28
|
+
|
|
29
|
+
## First-run setup
|
|
30
|
+
|
|
31
|
+
It asks for:
|
|
32
|
+
|
|
33
|
+
- **API key** - an Open Cloud key with the `asset:write` scope. Create one at
|
|
34
|
+
https://create.roblox.com/dashboard/credentials (add the "asset" API, check
|
|
35
|
+
`asset:write`, set IP Address to `0.0.0.0/0`).
|
|
36
|
+
- **Creator** - upload to your account (user) or a group, and the numeric id.
|
|
37
|
+
|
|
38
|
+
These are saved to `~/.pixelforge-uploader/config.json` (permissions 600). To
|
|
39
|
+
change them later run `npx pixelforge-uploader reconfigure`, or set the env vars
|
|
40
|
+
`PIXELFORGE_API_KEY`, `PIXELFORGE_CREATOR_TYPE`, `PIXELFORGE_CREATOR_ID`.
|
|
41
|
+
|
|
42
|
+
## Use it
|
|
43
|
+
|
|
44
|
+
1. Start it (`npx pixelforge-uploader`).
|
|
45
|
+
2. In Studio, capture an icon in PixelForge and hit Upload.
|
|
46
|
+
|
|
47
|
+
The plugin sends the raw pixels here; this app encodes the PNG, uploads it via
|
|
48
|
+
Open Cloud, and hands the Image id back to the plugin. Default port `34890`
|
|
49
|
+
(override with `PIXELFORGE_PORT`).
|
|
50
|
+
|
|
51
|
+
No dependencies - plain Node `http`/`https`/`zlib`. Your API key only ever goes
|
|
52
|
+
from this app directly to `apis.roblox.com`.
|
package/package.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pixelforge-uploader",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Local helper that uploads PixelForge icons to Roblox via Open Cloud. The Studio plugin sends raw pixels over localhost; this app holds the API key, encodes the PNG, and uploads.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"engines": { "node": ">=18" },
|
|
7
|
+
"main": "src.js",
|
|
8
|
+
"bin": { "pixelforge-uploader": "src.js" },
|
|
9
|
+
"files": ["src.js"],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "node src.js"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {}
|
|
14
|
+
}
|
package/src.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PixelForge Uploader - local helper.
|
|
3
|
+
//
|
|
4
|
+
// A Creator-Store plugin can't upload an asset itself (no CreateAssetAsync, and
|
|
5
|
+
// HttpService can't reach apis.roblox.com), and routing through a REMOTE proxy
|
|
6
|
+
// gets it disabled for "Misusing Roblox Systems". But a plugin CAN talk to
|
|
7
|
+
// localhost. So the plugin sends the captured image here and this app does
|
|
8
|
+
// everything Roblox-related:
|
|
9
|
+
// it holds the API key, encodes the PNG, and uploads via Open Cloud from the
|
|
10
|
+
// user's own machine. The plugin itself stores no key and references no Roblox
|
|
11
|
+
// API - it only POSTs raw pixels to localhost.
|
|
12
|
+
//
|
|
13
|
+
// Zero dependencies: plain Node http/https/zlib.
|
|
14
|
+
//
|
|
15
|
+
// Config (API key + creator) is collected once, interactively, and saved to
|
|
16
|
+
// ~/.pixelforge-uploader/config.json
|
|
17
|
+
// (override with env PIXELFORGE_API_KEY / PIXELFORGE_CREATOR_TYPE /
|
|
18
|
+
// PIXELFORGE_CREATOR_ID; re-run setup with `node src.js reconfigure`).
|
|
19
|
+
//
|
|
20
|
+
// Transport from the plugin:
|
|
21
|
+
// POST http://127.0.0.1:<PORT>/upload
|
|
22
|
+
// headers: x-pf-width, x-pf-height, x-pf-name (plain text)
|
|
23
|
+
// body: raw RGBA bytes (width*height*4)
|
|
24
|
+
// -> 200 { imageId } | { error }
|
|
25
|
+
// GET /ping -> { ok, app, version, configured }
|
|
26
|
+
|
|
27
|
+
const http = require("http");
|
|
28
|
+
const https = require("https");
|
|
29
|
+
const zlib = require("zlib");
|
|
30
|
+
const fs = require("fs");
|
|
31
|
+
const os = require("os");
|
|
32
|
+
const path = require("path");
|
|
33
|
+
const readline = require("readline");
|
|
34
|
+
|
|
35
|
+
const PORT = Number(process.env.PIXELFORGE_PORT || process.argv[3] || 34890);
|
|
36
|
+
const HOST = "127.0.0.1";
|
|
37
|
+
const CONFIG_DIR = path.join(os.homedir(), ".pixelforge-uploader");
|
|
38
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
39
|
+
|
|
40
|
+
const ASSETS_URL = "https://apis.roblox.com/assets/v1/assets";
|
|
41
|
+
const OPERATION_URL = "https://apis.roblox.com/assets/v1/operations/";
|
|
42
|
+
const POLL_INTERVAL_MS = 1500;
|
|
43
|
+
const POLL_MAX_ATTEMPTS = 30;
|
|
44
|
+
|
|
45
|
+
// ---------- config ----------
|
|
46
|
+
|
|
47
|
+
function loadConfig() {
|
|
48
|
+
if (process.env.PIXELFORGE_API_KEY && process.env.PIXELFORGE_CREATOR_ID) {
|
|
49
|
+
return {
|
|
50
|
+
apiKey: process.env.PIXELFORGE_API_KEY,
|
|
51
|
+
creatorType: process.env.PIXELFORGE_CREATOR_TYPE === "group" ? "group" : "user",
|
|
52
|
+
creatorId: process.env.PIXELFORGE_CREATOR_ID,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const c = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
|
|
57
|
+
if (c && c.apiKey && c.creatorId) {
|
|
58
|
+
c.creatorType = c.creatorType === "group" ? "group" : "user";
|
|
59
|
+
return c;
|
|
60
|
+
}
|
|
61
|
+
} catch (_) {}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function saveConfig(cfg) {
|
|
66
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
67
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), { mode: 0o600 });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function ask(rl, q) {
|
|
71
|
+
return new Promise((res) => rl.question(q, (a) => res(a.trim())));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function runSetup() {
|
|
75
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
76
|
+
console.log("\nPixelForge Uploader - first-time setup\n");
|
|
77
|
+
console.log("Create an Open Cloud API key at https://create.roblox.com/dashboard/credentials");
|
|
78
|
+
console.log("(add the \"asset\" API, check asset:write, set IP 0.0.0.0/0).\n");
|
|
79
|
+
const apiKey = await ask(rl, "Paste your API key: ");
|
|
80
|
+
let creatorType = (await ask(rl, "Upload to (1) your account or (2) a group? [1/2]: ")) === "2" ? "group" : "user";
|
|
81
|
+
const creatorId = await ask(rl, `Enter your ${creatorType === "group" ? "group" : "user"} id (number): `);
|
|
82
|
+
rl.close();
|
|
83
|
+
const cfg = { apiKey, creatorType, creatorId };
|
|
84
|
+
saveConfig(cfg);
|
|
85
|
+
console.log(`\nSaved to ${CONFIG_FILE}\n`);
|
|
86
|
+
return cfg;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------- PNG encode (raw RGBA -> PNG, zlib) ----------
|
|
90
|
+
|
|
91
|
+
let CRC_TABLE;
|
|
92
|
+
function crc32(buf) {
|
|
93
|
+
if (!CRC_TABLE) {
|
|
94
|
+
CRC_TABLE = new Int32Array(256);
|
|
95
|
+
for (let n = 0; n < 256; n++) {
|
|
96
|
+
let c = n;
|
|
97
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
98
|
+
CRC_TABLE[n] = c;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
let crc = -1;
|
|
102
|
+
for (let i = 0; i < buf.length; i++) crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ buf[i]) & 0xff];
|
|
103
|
+
return (crc ^ -1) >>> 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function pngChunk(type, data) {
|
|
107
|
+
const len = Buffer.alloc(4);
|
|
108
|
+
len.writeUInt32BE(data.length, 0);
|
|
109
|
+
const t = Buffer.from(type, "ascii");
|
|
110
|
+
const crc = Buffer.alloc(4);
|
|
111
|
+
crc.writeUInt32BE(crc32(Buffer.concat([t, data])), 0);
|
|
112
|
+
return Buffer.concat([len, t, data, crc]);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function encodePng(rgba, width, height) {
|
|
116
|
+
const stride = width * 4;
|
|
117
|
+
const raw = Buffer.alloc((stride + 1) * height);
|
|
118
|
+
for (let y = 0; y < height; y++) {
|
|
119
|
+
raw[y * (stride + 1)] = 0; // filter type 0 (none)
|
|
120
|
+
rgba.copy(raw, y * (stride + 1) + 1, y * stride, y * stride + stride);
|
|
121
|
+
}
|
|
122
|
+
const ihdr = Buffer.alloc(13);
|
|
123
|
+
ihdr.writeUInt32BE(width, 0);
|
|
124
|
+
ihdr.writeUInt32BE(height, 4);
|
|
125
|
+
ihdr[8] = 8; // bit depth
|
|
126
|
+
ihdr[9] = 6; // color type RGBA
|
|
127
|
+
return Buffer.concat([
|
|
128
|
+
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
|
129
|
+
pngChunk("IHDR", ihdr),
|
|
130
|
+
pngChunk("IDAT", zlib.deflateSync(raw, { level: 6 })),
|
|
131
|
+
pngChunk("IEND", Buffer.alloc(0)),
|
|
132
|
+
]);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------- Open Cloud upload ----------
|
|
136
|
+
|
|
137
|
+
function robloxRequest(method, url, headers, bodyBuffer) {
|
|
138
|
+
return new Promise((resolve, reject) => {
|
|
139
|
+
const u = new URL(url);
|
|
140
|
+
const req = https.request({ method, hostname: u.hostname, path: u.pathname + u.search, headers }, (resp) => {
|
|
141
|
+
const chunks = [];
|
|
142
|
+
resp.on("data", (c) => chunks.push(c));
|
|
143
|
+
resp.on("end", () => {
|
|
144
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
145
|
+
let json = null;
|
|
146
|
+
try { json = JSON.parse(raw); } catch (_) {}
|
|
147
|
+
resolve({ status: resp.statusCode, json, raw });
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
req.on("error", reject);
|
|
151
|
+
if (bodyBuffer) req.write(bodyBuffer);
|
|
152
|
+
req.end();
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
157
|
+
|
|
158
|
+
async function uploadImage(cfg, name, pngBuffer) {
|
|
159
|
+
const creator = cfg.creatorType === "group" ? { groupId: Number(cfg.creatorId) } : { userId: Number(cfg.creatorId) };
|
|
160
|
+
const meta = JSON.stringify({ assetType: "Image", displayName: name || "Icon", description: "Created with PixelForge", creationContext: { creator } });
|
|
161
|
+
const boundary = "----PixelForgeUploader" + Date.now().toString(16);
|
|
162
|
+
const CRLF = "\r\n";
|
|
163
|
+
const pre = Buffer.from(
|
|
164
|
+
`--${boundary}${CRLF}Content-Disposition: form-data; name="request"${CRLF}Content-Type: application/json${CRLF}${CRLF}${meta}${CRLF}` +
|
|
165
|
+
`--${boundary}${CRLF}Content-Disposition: form-data; name="fileContent"; filename="icon.png"${CRLF}Content-Type: image/png${CRLF}${CRLF}`, "utf8");
|
|
166
|
+
const body = Buffer.concat([pre, pngBuffer, Buffer.from(`${CRLF}--${boundary}--${CRLF}`, "utf8")]);
|
|
167
|
+
|
|
168
|
+
const post = await robloxRequest("POST", ASSETS_URL, {
|
|
169
|
+
"x-api-key": cfg.apiKey,
|
|
170
|
+
"Content-Type": "multipart/form-data; boundary=" + boundary,
|
|
171
|
+
"Content-Length": body.length,
|
|
172
|
+
}, body);
|
|
173
|
+
|
|
174
|
+
if (post.status === 401 || post.status === 403) {
|
|
175
|
+
throw new Error("API key rejected (HTTP " + post.status + "). Check asset:write and that it matches the creator.");
|
|
176
|
+
}
|
|
177
|
+
if (post.status !== 200 || !post.json) {
|
|
178
|
+
const detail = (post.json && (post.json.message || (post.json.error && post.json.error.message))) || post.raw.slice(0, 200);
|
|
179
|
+
throw new Error("Upload failed (HTTP " + post.status + "): " + detail);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const readId = (op) => {
|
|
183
|
+
if (!op || op.done !== true) return null;
|
|
184
|
+
if (op.error && op.error.message) throw new Error("Asset rejected: " + op.error.message);
|
|
185
|
+
return op.response && op.response.assetId != null ? String(op.response.assetId) : null;
|
|
186
|
+
};
|
|
187
|
+
let id = readId(post.json);
|
|
188
|
+
if (id) return id;
|
|
189
|
+
const opId = (post.json.path || "").match(/operations\/(.+)$/);
|
|
190
|
+
if (!opId) throw new Error("Upload accepted but no operation id returned.");
|
|
191
|
+
for (let i = 0; i < POLL_MAX_ATTEMPTS; i++) {
|
|
192
|
+
await sleep(POLL_INTERVAL_MS);
|
|
193
|
+
const poll = await robloxRequest("GET", OPERATION_URL + opId[1], { "x-api-key": cfg.apiKey });
|
|
194
|
+
if (poll.status === 200 && poll.json) {
|
|
195
|
+
id = readId(poll.json);
|
|
196
|
+
if (id) return id;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
throw new Error("Timed out waiting for Roblox to finish processing the asset.");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---------- server ----------
|
|
203
|
+
|
|
204
|
+
function readBody(req) {
|
|
205
|
+
return new Promise((resolve, reject) => {
|
|
206
|
+
const chunks = [];
|
|
207
|
+
req.on("data", (c) => chunks.push(c));
|
|
208
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
209
|
+
req.on("error", reject);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function json(res, status, obj) {
|
|
214
|
+
const b = JSON.stringify(obj);
|
|
215
|
+
res.writeHead(status, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(b), "Access-Control-Allow-Origin": "*" });
|
|
216
|
+
res.end(b);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function startServer(cfg) {
|
|
220
|
+
const server = http.createServer(async (req, res) => {
|
|
221
|
+
if (req.method === "GET" && req.url === "/ping") {
|
|
222
|
+
return json(res, 200, { ok: true, app: "PixelForge Uploader", version: "2", configured: !!cfg });
|
|
223
|
+
}
|
|
224
|
+
if (req.method === "POST" && req.url.startsWith("/upload")) {
|
|
225
|
+
try {
|
|
226
|
+
if (!cfg) return json(res, 400, { error: "Uploader not configured. Restart it and complete setup." });
|
|
227
|
+
const width = Number(req.headers["x-pf-width"]);
|
|
228
|
+
const height = Number(req.headers["x-pf-height"]);
|
|
229
|
+
const name = req.headers["x-pf-name"] || "Icon";
|
|
230
|
+
const rgba = await readBody(req);
|
|
231
|
+
if (!width || !height) return json(res, 400, { error: "Missing image dimensions." });
|
|
232
|
+
if (rgba.length !== width * height * 4) {
|
|
233
|
+
return json(res, 400, { error: `Pixel data is ${rgba.length} bytes, expected ${width * height * 4}.` });
|
|
234
|
+
}
|
|
235
|
+
console.log(`[upload] ${name} (${width}x${height}) -> ${cfg.creatorType} ${cfg.creatorId}`);
|
|
236
|
+
const png = encodePng(rgba, width, height);
|
|
237
|
+
const imageId = await uploadImage(cfg, name, png);
|
|
238
|
+
console.log(`[upload] done -> Image id ${imageId}`);
|
|
239
|
+
return json(res, 200, { imageId });
|
|
240
|
+
} catch (err) {
|
|
241
|
+
console.error("[upload] " + (err && err.message ? err.message : err));
|
|
242
|
+
return json(res, 502, { error: err && err.message ? err.message : String(err) });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
json(res, 404, { error: "Not found" });
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
server.listen(PORT, HOST, () => {
|
|
249
|
+
console.log("=================================================");
|
|
250
|
+
console.log(" PixelForge Uploader is running.");
|
|
251
|
+
console.log(" Listening on http://localhost:" + PORT);
|
|
252
|
+
console.log(" Uploading to " + cfg.creatorType + " " + cfg.creatorId + ".");
|
|
253
|
+
console.log(" Leave this window open while you upload icons.");
|
|
254
|
+
console.log("=================================================");
|
|
255
|
+
});
|
|
256
|
+
server.on("error", (err) => {
|
|
257
|
+
if (err && err.code === "EADDRINUSE") {
|
|
258
|
+
console.error("Port " + PORT + " is already in use (another copy may be running).");
|
|
259
|
+
} else {
|
|
260
|
+
console.error("Server error:", err);
|
|
261
|
+
}
|
|
262
|
+
process.exit(1);
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
(async function main() {
|
|
267
|
+
let cfg = loadConfig();
|
|
268
|
+
if (process.argv[2] === "reconfigure" || !cfg) {
|
|
269
|
+
if (!cfg) console.log("No saved configuration found.");
|
|
270
|
+
cfg = await runSetup();
|
|
271
|
+
}
|
|
272
|
+
startServer(cfg);
|
|
273
|
+
})();
|