SOURCE / PINNED RELEASE
Made of little things.
Powder Tool V600Billion
- Release
- 142767edcab8…
- Author-recorded commit
- 6d92971effd0…
- License
- LICENSE
- Author’s source reference
- nostr://npub1fllw8kw0thjj55wds0uugcnp5kej2nfxd36eruq39d56wwz8r44q5q78wj/wss%3A%2F%2Fgit.napplet.soy%2F/powder-toy
Archive hash verified: ed7d6a8ea7083197…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
/**
* The Powder Toy's server API, answered in the page.
*
* The web build talks to https://powdertoy.co.uk and its static host through
* fetch (upstream src/client/http/). Each endpoint below returns what the
* matching request class parses, with the data coming from the store (Nostr
* through the host). Formats follow upstream v100.1.400:
* responseOk Save.api, Vote.api: "OK" / "OK <id>", anything else is shown as the error
* responseJson {"Status":1,...} or {"Status":0,"Error":"..."}, the error is shown
* responseData plain JSON; failures can only be HTTP status codes
*/
import { ServerError } from './store.js';
export const API_HOST = 'powdertoy.co.uk';
export const STATIC_HOST = 'static.powdertoy.co.uk';
const PAGE = 20;
const json = (value, status = 200) => ({ status, type: 'application/json', body: JSON.stringify(value) });
const text = (value, status = 200) => ({ status, type: 'text/plain; charset=utf-8', body: value });
const bytes = (value, type) => ({ status: 200, type, body: value });
const notFound = () => text('Not Found', 404);
const failed = (error) => (error instanceof ServerError ? error.message : `The host could not do that: ${error?.message ?? error}`);
const int = (value, fallback = 0) => {
const number = Number.parseInt(value ?? '', 10);
return Number.isFinite(number) ? number : fallback;
};
function saveSummary(info) {
return {
ID: info.id, Created: info.created, Updated: info.updated, Version: 0,
Score: info.up - info.down, ScoreUp: info.up, ScoreDown: info.down,
Name: info.name, ShortName: info.name.slice(0, 32), Username: info.username,
Comments: 0, Published: info.published,
};
}
/**
* @param {ReturnType<import('./store.js').createStore>} store
* @param {{ motd?: string, login?: () => Promise<object|null>, onLogin?: (session: object) => void }} options
*/
export function createApi(store, { motd = '', login = async () => null, onLogin = () => {} } = {}) {
async function api(path, params, method, form) {
const withStatus = async (fn) => {
try { return json({ Status: 1, ...(await fn()) }); } catch (error) { return json({ Status: 0, Error: failed(error) }); }
};
switch (path) {
case '/Startup.json':
return json({ Status: 1, Session: Boolean(store.session), MessageOfTheDay: motd, Notifications: [], Updates: {} });
case '/Login.json': return withStatus(async () => {
const session = store.session ?? await login();
if (!session) throw new ServerError('Sign in to Nappelin first (in the bar), then press Sign in here again. No password needed.');
store.setSession(session);
onLogin(session);
return {
UserID: session.userId, SessionID: 'nappelin', SessionKey: 'nappelin', Username: session.username,
Elevation: 'None', Notifications: [],
};
});
case '/Logout.json': return json({ Status: 1 });
case '/Browse.json': {
const start = Math.max(0, int(params.get('Start')));
const count = Math.min(60, Math.max(1, int(params.get('Count'), PAGE)));
const query = params.get('Search_Query') ?? '';
const category = params.get('Category') ?? '';
const frontPage = !query && !category;
try {
const result = await store.list({ query, category, start, count });
// On the default view the game counts one extra page for the front page
// (upstream SearchModel::GetPageCount). The page-one saves are the best ones
// anyway, so report 20 fewer and every save shows exactly once.
const reported = frontPage ? Math.max(0, result.count - PAGE) : result.count;
return json({ Count: reported, Saves: result.items.map(saveSummary) });
} catch {
return json({ Count: 0, Saves: [] });
}
}
case '/Browse/View.json': {
const id = int(params.get('ID'));
if (method === 'POST') return withStatus(async () => ({ ID: await store.publishPrivate(id) }));
try {
const info = await store.info(id);
return json({
ID: info.id, DateCreated: info.created, Date: info.updated, Version: 0,
ScoreUp: info.up, ScoreDown: info.down, ScoreMine: info.mine,
Username: info.username, Name: info.name, Description: info.description,
Published: info.published, Tags: info.tags, Comments: info.comments, Favourite: info.favourite,
Views: 0, Elevation: 'None', IsBanned: false,
});
} catch {
return notFound();
}
}
case '/Browse/Comments.json': {
const id = int(params.get('ID'));
if (method === 'POST') return withStatus(async () => { await store.addComment(id, form.get('Comment')); return {}; });
try {
const list = await store.comments(id, Math.max(0, int(params.get('Start'))), Math.min(100, Math.max(1, int(params.get('Count'), 20))));
return json(list.map((comment) => ({
Username: comment.username, Text: comment.text, Elevation: 'None', IsBanned: false,
Timestamp: comment.at, ID: comment.id,
})));
} catch {
return json([]);
}
}
case '/Browse/EditTag.json':
return withStatus(async () => ({ Tags: await store.editTag(int(params.get('ID')), params.get('Op'), params.get('Tag')) }));
case '/Browse/Delete.json':
return withStatus(async () => { await store.remove(int(params.get('ID')), params.get('Mode') === 'Unpublish' ? 'Unpublish' : 'Delete'); return {}; });
case '/Browse/Favourite.json':
return withStatus(async () => { await store.favourite(int(params.get('ID')), params.get('Mode') !== 'Remove'); return {}; });
case '/Browse/Report.json':
return json({ Status: 0, Error: 'Reports cannot be sent from the game here. Tell the guild moderators in Nappelin instead.' });
case '/Browse/Tags.json': {
try {
const tags = await store.tags({ start: Math.max(0, int(params.get('Start'))), count: Math.min(100, Math.max(1, int(params.get('Count'), 50))), query: params.get('Search_Query') ?? '' });
return json({ Tags: tags.map(([tag, count]) => ({ Tag: tag, Count: count })) });
} catch {
return json({ Tags: [] });
}
}
case '/User.json': {
try {
const user = await store.user(params.get('Name') ?? '');
return json({ User: {
ID: user.id, Age: 0, Username: user.username, Biography: user.biography, Location: user.location,
Website: user.website, Saves: { Count: user.saves, AverageScore: user.average, HighestScore: user.highest },
Forum: { Topics: 0, Replies: 0, Reputation: 0 },
} });
} catch {
return notFound();
}
}
case '/Profile.json':
return json({ Status: 0, Error: 'Your profile is your Nappelin profile. Edit it in Nappelin.' });
case '/Save.api': {
try {
const data = form.get('Data');
const payload = data instanceof Uint8Array ? data : null;
if (!payload?.length) throw new ServerError('The save was empty.');
const id = await store.upload({
name: form.get('Name'), description: form.get('Description') ?? '', bytes: payload,
publish: form.get('Publish') === 'Public',
});
return text(`OK ${id}`);
} catch (error) {
return text(failed(error));
}
}
case '/Vote.api': {
try {
const action = form.get('Action');
await store.vote(int(form.get('ID')), action === 'Up' ? 1 : action === 'Down' ? -1 : 0);
return text('OK');
} catch (error) {
return text(failed(error));
}
}
default:
return notFound();
}
}
async function staticFile(path) {
const match = /^\/(\d+)(?:_(\d+))?(_small\.png|\.cps)$/.exec(path);
if (!match) return notFound();
const id = int(match[1]);
try {
if (match[3] === '.cps') return bytes(await store.data(id), 'application/octet-stream');
return bytes(await store.thumbnail(id), 'image/png');
} catch {
return notFound();
}
}
/**
* @param {{ url: URL, method: string, form: Map<string, string|Uint8Array> }} request
*/
return async function handle({ url, method, form }) {
if (url.hostname === STATIC_HOST) return staticFile(url.pathname);
if (url.hostname === API_HOST) return api(url.pathname, url.searchParams, method, form);
return notFound();
};
}
