Back to Powder Tool V600Billion
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.

source/src/server/api.js
/**
 * 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();
  };
}