forms-free.openapps.xyz

Source

This app’s code and revision history are public. Download a copy or explore the files below.

Public
The app’s code and revision history are public.
Owned app · rev ce303909347f
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone cf614aedcf4cdabfa9c45095
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
← All files
functions/api.ts
// Forms' public HTTP API: no account. Anyone can create a form and fill one in; reading responses and
// changing a form need the owner key handed back at creation. POST /forms/:id/responses doubles as a
// website form endpoint: point an HTML form's action at it. GET / describes the routes.
// Writes go through the same policy rules as the web app, as this route's public principal.
import { SITE_URL, API_URL } from "./lib/config";
import {
  checkQuestions, checkFormText, parseQuestions, checkAnswers, checkFreeFields, parseSubmission, newestFirst, columns, summarize, toCsv,
  randomId, QUESTION_TYPES, type FormDoc, type Question, type ResponseDoc, type Submission,
} from "./lib/forms";

const NOW = { operation: "time", value: "now" };
const ID_RE = /^[A-Za-z0-9]{8,32}$/;

const cors = {
  "access-control-allow-origin": "*",
  "access-control-allow-methods": "GET, POST, PATCH, DELETE, OPTIONS",
  "access-control-allow-headers": "content-type, x-owner-key, idempotency-key",
};
const json = (body: unknown, status = 200) =>
  new Response(JSON.stringify(body, null, 2), { status, headers: { "content-type": "application/json; charset=utf-8", ...cors } });
const fail = (status: number, error: string, extra?: Record<string, unknown>) => json({ error, ...extra }, status);

class HttpError extends Error { constructor(public status: number, message: string) { super(message); } }

async function jsonBody(req: Request): Promise<Record<string, any>> {
  try {
    const parsed = await req.json();
    if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
  } catch { /* fall through */ }
  throw new HttpError(400, "Send a JSON object body.");
}

/** A submission body: JSON {answers: {...}} (or the answers object itself), or an HTML form post. */
async function submissionBody(req: Request, type: string): Promise<Record<string, unknown>> {
  if (type.includes("application/x-www-form-urlencoded") || type.includes("multipart/form-data")) {
    const fields: Record<string, unknown> = {};
    const add = (k: string, v: string) => {
      const prev = fields[k];
      fields[k] = prev === undefined ? v : Array.isArray(prev) ? [...prev, v] : [prev, v];
    };
    if (type.includes("multipart/form-data")) {
      let data: FormData;
      try { data = await req.formData(); } catch { throw new HttpError(400, "The form data could not be read."); }
      data.forEach((v, k) => { if (typeof v === "string") add(k, v); });
    } else {
      new URLSearchParams(await req.text()).forEach((v, k) => add(k, v));
    }
    return fields;
  }
  const input = await jsonBody(req);
  const answers = input.answers ?? input;
  if (!answers || typeof answers !== "object" || Array.isArray(answers)) throw new HttpError(400, "answers must be an object of question id or label to answer.");
  return answers;
}

async function listAll(ctx: any, path: string): Promise<any[]> {
  const rows: any[] = [];
  let cursor: string | undefined;
  for (let page = 0; page < 100; page++) {
    const res = await ctx.bounded.get(path, { limit: 1000, ...(cursor ? { cursor } : {}) });
    // Rows name their path in _id (or pathId); the bare id is its last segment.
    for (const r of res?.data || []) rows.push({ ...r, id: r.id ?? String(r._id ?? r.pathId ?? "").split("/").pop() });
    cursor = res?.nextCursor;
    if (!cursor) break;
  }
  return rows;
}

/** Joins the form as this route (knowing the id is the permission to read it), then reads it. */
async function openForm(ctx: any, formId: string): Promise<FormDoc> {
  if (!ID_RE.test(formId)) throw new HttpError(404, "No form with that id.");
  // Once this route has joined, one read is enough (a refused read comes back empty, not as an error).
  const joined = await ctx.bounded.get(`forms/${formId}`);
  if (joined) return joined;
  try {
    await ctx.bounded.set(`forms/${formId}/joins/${ctx.user.id}`, { at: NOW });
  } catch {
    // A request that arrived at the same moment may have joined first; then the read below works.
  }
  const form = await ctx.bounded.get(`forms/${formId}`);
  if (!form) throw new HttpError(404, "No form with that id.");
  return form;
}

/** Checks the owner key, then makes this route an owner of the form (once) so it can read responses. */
async function asOwner(ctx: any, req: Request, url: URL, formId: string): Promise<FormDoc> {
  const key = (req.headers.get("x-owner-key") || url.searchParams.get("key") || "").trim();
  if (!key) throw new HttpError(401, "Send the form's owner key in the x-owner-key header (or as ?key=).");
  const form = await openForm(ctx, formId);
  const stored = await ctx.bounded.get(`formkeys/${formId}`);
  if (!stored?.key || stored.key !== key) throw new HttpError(403, "That owner key does not match this form.");
  const me = ctx.user.id;
  if (!(await ctx.bounded.get(`forms/${formId}/owners/${me}`))) {
    await ctx.bounded.setMany([
      { path: `formclaims/${formId}/c/${me}`, document: { key } },
      { path: `forms/${formId}/owners/${me}`, document: { at: NOW } },
    ]);
  }
  return form;
}

const links = (formId: string, key?: string) => ({
  url: `${SITE_URL}/f/${formId}`,
  endpoint: `${API_URL}/forms/${formId}/responses`,
  ...(key ? { adminUrl: `${SITE_URL}/f/${formId}/edit#k=${key}`, resultsUrl: `${SITE_URL}/f/${formId}/results#k=${key}` } : {}),
});

function publicForm(formId: string, form: FormDoc) {
  const questions = parseQuestions(form.questions);
  return {
    id: formId,
    title: form.title,
    description: form.description || undefined,
    thankYou: form.thankYou || undefined,
    closed: Boolean(form.closed),
    questions: questions.map((q) => ({ id: q.id, type: q.type, label: q.label, help: q.help, required: Boolean(q.required), options: q.options, showIf: q.showIf })),
    ...links(formId),
    howToAnswer: questions.length
      ? `POST ${API_URL}/forms/${formId}/responses with {"answers": {"<question id or label>": value}}. Text, email, date ("2026-10-18") and choice answers are strings; number, rating (1 to 5) and scale (0 to 10) are numbers; checkboxes is a list of options; yes_no is "yes" or "no". A question with showIf is asked only when that earlier question's answer is (or, for checkboxes, includes) showIf.is; otherwise leave it out.`
      : `This form has no questions: POST any fields (up to 50) to ${API_URL}/forms/${formId}/responses as JSON {"answers": {...}} or as an HTML form post.`,
  };
}

/** Answers keyed by column label (labels that repeat get the question id added) for people and assistants. */
function labelled(cols: ReturnType<typeof columns>, s: Submission) {
  const seen = new Set<string>();
  const out: Record<string, unknown> = {};
  for (const c of cols) {
    const name = seen.has(c.label) ? `${c.label} (${c.key})` : c.label;
    seen.add(c.label);
    if (s.answers[c.key] !== undefined) out[name] = s.answers[c.key];
  }
  return out;
}

const esc = (s: string) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);

/** The small page a browser sees after posting a website form straight to the endpoint. */
function htmlPage(status: number, title: string, body: string, referer: string | null) {
  let back = "";
  try {
    const u = referer ? new URL(referer) : null;
    if (u && (u.protocol === "https:" || u.protocol === "http:")) back = `<a class="btn" href="${esc(u.href)}">Back to ${esc(u.host)}</a>`;
  } catch { /* no link back */ }
  const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex"><title>${esc(title)}</title>
<style>body{margin:0;min-height:100vh;display:grid;place-items:center;background:#fdfdfc;color:#15171b;font:16px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif;padding:24px;box-sizing:border-box}main{max-width:520px;width:100%;background:#fff;border:1px solid #e3e6ea;border-radius:18px;padding:32px 28px;box-shadow:0 8px 24px -12px #1a1d2426}h1{margin:0 0 10px;font:400 30px/1.15 Georgia,"Times New Roman",serif;letter-spacing:-.02em}p,li{color:#4a4e56;white-space:pre-line}ul{padding-left:20px}.btn{display:inline-block;margin-top:18px;padding:12px 18px;border-radius:10px;background:#0b0b0c;color:#fff;font-weight:600;text-decoration:none}.foot{margin-top:24px;font-size:13px;color:#5d6169}.foot a{color:#2e5ec4;text-decoration:none}</style></head>
<body><main><h1>${esc(title)}</h1>${body}${back}<p class="foot">Sent with <a href="${SITE_URL}/">Forms</a>, free from OpenApps.</p></main></body></html>`;
  return new Response(html, { status, headers: { "content-type": "text/html; charset=utf-8", ...cors } });
}

const routes = {
  name: "Forms by OpenApps",
  about: "Free forms and surveys with unlimited responses. No account. Creating a form returns an owner key: keep it to read responses and change the form.",
  docs: `${SITE_URL}/llms.txt`,
  openapi: `${SITE_URL}/openapi.json`,
  questionTypes: Object.keys(QUESTION_TYPES),
  routes: [
    "POST /forms {title, description?, questions: [{type, label, required?, options?, help?, showIf?: {question, is}}], thankYou?} -> formId, url, adminUrl, resultsUrl, ownerKey, endpoint",
    "GET /forms/:id -> the public form: title, questions with ids, closed",
    "POST /forms/:id/responses {answers: {<question id or label>: value}} -> or an HTML form post (urlencoded or multipart) whose field names are question labels or ids",
    "GET /forms/:id/responses?format=json|csv (header x-owner-key) -> every response, with a summary per question",
    "PATCH /forms/:id {title?, description?, questions?, thankYou?, closed?} (header x-owner-key)",
    "DELETE /forms/:id/responses/:responseId (header x-owner-key)",
  ],
};

export default async function api(req: Request, ctx: any) {
  if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: cors });
  const url = new URL(req.url);
  const parts = url.pathname.split("/").filter(Boolean);
  // Accept both /forms/... and /api/forms/... so a copied path works either way.
  if (parts[0] === "api") parts.shift();
  const type = (req.headers.get("content-type") || "").toLowerCase();
  const htmlPost = req.method === "POST" && parts[2] === "responses" && (type.includes("application/x-www-form-urlencoded") || type.includes("multipart/form-data"))
    && !(req.headers.get("accept") || "").includes("application/json");
  const referer = req.headers.get("referer");
  try {
    if (!parts.length && req.method === "GET") return json(routes);
    if (parts[0] !== "forms") return fail(404, "Unknown route. GET / lists the routes.");
    const me = ctx.user.id;

    if (parts.length === 1 && req.method === "POST") {
      const input = await jsonBody(req);
      const text = checkFormText(input);
      if ("error" in text) return fail(400, text.error);
      const checked = checkQuestions(input.questions);
      if ("error" in checked) return fail(400, checked.error);
      const formId = randomId(20);
      const key = randomId(24);
      const doc = { ...text, questions: JSON.stringify(checked.questions), closed: false };
      await ctx.bounded.setMany([
        { path: `forms/${formId}`, document: { ...doc, createdBy: me, createdAt: NOW } },
        { path: `forms/${formId}/joins/${me}`, document: { at: NOW } },
        { path: `forms/${formId}/owners/${me}`, document: { at: NOW } },
        { path: `formkeys/${formId}`, document: { key } },
      ]);
      return json({
        formId, ...links(formId, key), ownerKey: key,
        form: publicForm(formId, { ...doc, createdBy: me, createdAt: 0 }),
        note: "Send people the url. Keep ownerKey and adminUrl private: they let anyone read the responses and change the form.",
      }, 201);
    }

    const formId = parts[1];

    if (parts.length === 2 && req.method === "GET") return json(publicForm(formId, await openForm(ctx, formId)));

    if (parts.length === 2 && req.method === "PATCH") {
      const form = await asOwner(ctx, req, url, formId);
      const input = await jsonBody(req);
      const text = checkFormText(input, form);
      if ("error" in text) return fail(400, text.error);
      const update: Record<string, unknown> = { ...text };
      if (input.questions !== undefined) {
        const checked = checkQuestions(input.questions);
        if ("error" in checked) return fail(400, checked.error);
        update.questions = JSON.stringify(checked.questions);
      }
      if (input.closed !== undefined) {
        if (typeof input.closed !== "boolean") return fail(400, "closed must be true or false.");
        update.closed = input.closed;
      }
      await ctx.bounded.set(`forms/${formId}`, update);
      return json(publicForm(formId, { ...form, ...update } as FormDoc));
    }

    if (parts[2] === "responses" && parts.length === 3 && req.method === "POST") {
      const form = await openForm(ctx, formId);
      const fields = await submissionBody(req, type);
      const thanks = form.thankYou || "Thanks. Your response was sent.";
      if (form.closed) {
        if (htmlPost) return htmlPage(403, "This form is closed", "<p>It no longer takes responses.</p>", referer);
        return fail(403, "This form is closed and no longer takes responses.");
      }
      // A filled honeypot field means a bot: answer as if it worked and store nothing.
      if (String(fields._gotcha ?? "").trim()) return htmlPost ? htmlPage(200, "Thank you", `<p>${esc(thanks)}</p>`, referer) : json({ ok: true, message: thanks }, 201);
      const questions = parseQuestions(form.questions);
      let answers;
      let ignored: string[] = [];
      if (questions.length) {
        const checked = checkAnswers(questions, fields);
        if (Object.keys(checked.errors).length) {
          const byLabel = Object.fromEntries(Object.entries(checked.errors).map(([id, e]) => [questions.find((q) => q.id === id)?.label ?? "Form", e]));
          if (htmlPost) {
            const items = Object.entries(byLabel).map(([l, e]) => `<li><strong>${esc(l)}</strong>: ${esc(e)}</li>`).join("");
            return htmlPage(400, "Some answers need fixing", `<p>Go back, fix these, and send the form again.</p><ul>${items}</ul>`, referer);
          }
          return fail(400, "Some answers need fixing.", { errors: byLabel });
        }
        answers = checked.answers;
        ignored = checked.ignored;
      } else {
        const free = checkFreeFields(fields);
        if ("error" in free) return htmlPost ? htmlPage(400, "That did not go through", `<p>${esc(free.error)}</p>`, referer) : fail(400, free.error);
        answers = free.answers;
      }
      const responseId = randomId(16);
      try {
        await ctx.bounded.set(`forms/${formId}/responses/${responseId}`, { answers: JSON.stringify(answers), by: me, createdAt: NOW });
      } catch (err) {
        // The rule refuses a response the moment the form closes, even if it closed after we read it.
        const closedNow = (await ctx.bounded.get(`forms/${formId}`))?.closed;
        if (!closedNow) throw err;
        return htmlPost ? htmlPage(403, "This form is closed", "<p>It no longer takes responses.</p>", referer) : fail(403, "This form is closed and no longer takes responses.");
      }
      if (htmlPost) return htmlPage(200, "Thank you", `<p>${esc(thanks)}</p>`, referer);
      return json({ ok: true, responseId, message: thanks, ...(ignored.length ? { ignored, note: "These fields were not saved: they matched no question, or the other answers hide that question." } : {}) }, 201);
    }

    if (parts[2] === "responses" && parts.length === 3 && req.method === "GET") {
      const form = await asOwner(ctx, req, url, formId);
      const questions: Question[] = parseQuestions(form.questions);
      const subs = (await listAll(ctx, `forms/${formId}/responses`) as ResponseDoc[]).map(parseSubmission).sort(newestFirst);
      const format = (url.searchParams.get("format") || "json").toLowerCase();
      if (format === "csv") {
        const name = form.title.replace(/[^\w-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "responses";
        return new Response(toCsv(questions, subs), {
          headers: { "content-type": "text/csv; charset=utf-8", "content-disposition": `attachment; filename="${name}.csv"`, ...cors },
        });
      }
      if (format !== "json") return fail(400, "format must be json or csv.");
      const cols = columns(questions, subs);
      return json({
        form: publicForm(formId, form),
        count: subs.length,
        summary: summarize(questions, subs),
        responses: subs.map((s) => ({ id: s.id, submittedAt: new Date(s.createdAt * 1000).toISOString(), answers: labelled(cols, s) })),
      });
    }

    if (parts[2] === "responses" && parts.length === 4 && req.method === "DELETE") {
      await asOwner(ctx, req, url, formId);
      const path = `forms/${formId}/responses/${parts[3]}`;
      if (!ID_RE.test(parts[3]) || !(await ctx.bounded.get(path))) return fail(404, "No response with that id in this form.");
      await ctx.bounded.set(path, null);
      return json({ ok: true, deleted: parts[3] });
    }

    return fail(404, "Unknown route. GET / lists the routes.");
  } catch (err) {
    const status = err instanceof HttpError ? err.status : 500;
    const message = err instanceof HttpError ? err.message : `Something went wrong: ${err instanceof Error ? err.message : String(err)}`;
    if (htmlPost) return htmlPage(status, "That did not go through", `<p>${esc(message)}</p>`, referer);
    return fail(status, message);
  }
}

Change history

Quality pass: conditional questions, 0 to 10 scale, QR code, prefill links, results tabs, API docs
ce30390934 · 4 hours ago
Bounded source sync
7d82add8ae · 1 day ago
Bounded source sync
8230fd7d59 · 1 day ago
Bounded source sync
d92a6e3b44 · 1 day ago
Bounded source sync
b548641922 · 1 day ago
Bounded source sync
b89074ffd2 · 1 day ago