The doPost() handler to paste into the feedback Sheet's bound Apps Script
project (see the setup notes at the top of R/feedback.R). The client posts
one JSON object as text/plain:
{app, url, release, viewport, theme, text, email, image (data:image/png;base64,…), website}
— website is the honeypot and must be empty.
Usage
cc_feedback_script(
repos = c(explore = "CalCOFI/explore"),
label = "feedback",
max_per_hour = 20L,
branch = "main"
)Arguments
- repos
named character vector: which GitHub repository each
appfiles its public issue in (c(explore = "CalCOFI/explore")); an app not listed here files no issue- label
the issue label
- max_per_hour
the spam cap: submissions accepted per hour, across all apps
- branch
the branch the screenshot is committed to (the raw URL the issue embeds points at it)
Examples
cat(cc_feedback_script())
#> // Code.gs — CalCOFI app feedback (bound to the "CalCOFI app feedback" Sheet).
#> // Generated by calcofi4r::cc_feedback_script() — do not hand-edit.
#> // Deploy: Deploy > New deployment > type "Web app", execute as "Me", who has
#> // access "Anyone". Copy the /exec URL into the app (explorer:
#> // VITE_FEEDBACK_URL at build time; Shiny: CALCOFI_FEEDBACK_URL).
#> // Tabs: `feedback` (header = calcofi4r::cc_feedback_header()) and
#> // `recipients` (A1 = "email", one address per row — edit a cell to add
#> // or remove someone; no redeploy).
#> // Script properties: GITHUB_TOKEN (optional; fine-grained, contents + issues on
#> // the repo below) enables the public issue; DRIVE_FOLDER_ID (optional).
#> //
#> // The client posts one JSON object as text/plain so the request stays
#> // CORS-simple (this endpoint does not answer OPTIONS): {app, url, release,
#> // viewport, theme, text, email, image, website}. `website` is a honeypot.
#>
#> var COLS = ["ts","app","url","release","viewport","theme","text","email","image_url","issue_url","id","user_agent","status"];
#> var REPOS = {"explore":"CalCOFI/explore"};
#> var LABEL = "feedback";
#> var BRANCH = "main";
#> var MAX_PER_HOUR = 20;
#> var MAX_TEXT = 4000, MAX_IMAGE_BYTES = 6 * 1024 * 1024;
#>
#> // Health check: a GET answers {ok:true,...} so the deployment can be verified at
#> // a glance instead of reading "Script function not found: doGet".
#> function doGet(e) {
#> try {
#> var sh = _tab("feedback");
#> return _json({ ok: true, endpoint: "calcofi-feedback", rows: sh.getLastRow() - 1,
#> recipients: _recipients().length, github: !!_prop("GITHUB_TOKEN") });
#> } catch (err) { return _json({ ok: false, error: String(err) }); }
#> }
#>
#> function doPost(e) {
#> try {
#> var b = JSON.parse(e.postData.contents || "{}");
#> if (b.website) return _json({ ok: true, skipped: "honeypot" }); // a bot filled the hidden field
#> if (!b.text || !String(b.text).trim()) return _json({ ok: false, error: "empty text" });
#> if (_rateLimited()) return _json({ ok: false, error: "rate limited: try again in an hour" });
#> var id = Utilities.getUuid().replace(/-/g, "").slice(0, 10), ts = new Date();
#> var app = String(b.app || "app").replace(/[^a-z0-9_-]/gi, "");
#> var text = String(b.text).slice(0, MAX_TEXT);
#> var image_url = "", issue_url = "", status = [];
#> // 1. the screenshot to Drive (the team's copy; the issue embeds its own from the repo)
#> if (b.image && /^data:image\/png;base64,/.test(b.image)) {
#> var bytes = Utilities.base64Decode(b.image.split(",")[1]);
#> if (bytes.length <= MAX_IMAGE_BYTES) {
#> var f = _folder(app).createFile(Utilities.newBlob(bytes, "image/png", _stamp(ts) + "_" + id + ".png"));
#> image_url = f.getUrl(); status.push("image");
#> } else status.push("image too large");
#> }
#> // 4. the public issue (before the row, so the row can carry its URL)
#> var repo = REPOS[app];
#> if (repo && _prop("GITHUB_TOKEN")) {
#> try { issue_url = _openIssue(repo, id, ts, b, text, bytes); status.push("issue"); }
#> catch (err) { status.push("issue failed: " + String(err).slice(0, 120)); }
#> } else status.push(repo ? "issue skipped: no GITHUB_TOKEN" : "issue skipped: no repo for " + app);
#> // 2. the row
#> var row = { ts: ts, app: app, url: b.url || "", release: b.release || "", viewport: b.viewport || "", theme: b.theme || "",
#> text: text, email: b.email || "", image_url: image_url, issue_url: issue_url, id: id, user_agent: b.user_agent || "", status: status.join("; ") };
#> var sh = _tab("feedback");
#> sh.getRange(sh.getLastRow() + 1, 1, 1, COLS.length).setValues([COLS.map(function (c) { return row[c] === undefined ? "" : row[c]; })]);
#> // 3. the mail — the screenshot inline (cid), so the annotated view is in the message itself, not behind a Drive click
#> var to = _recipients();
#> if (to.length) {
#> var subject = "[" + app + "] " + text.split("\n")[0].slice(0, 80);
#> var inline = (image_url && bytes && bytes.length) ? { shot: Utilities.newBlob(bytes, "image/png", "view.png") } : null; // the same PNG Drive holds; an over-size one is neither
#> var html = "<p>" + _esc(text).replace(/\n/g, "<br>") + "</p>" +
#> (inline ? "<p><a href=\"" + _esc(row.url) + "\"><img src=\"cid:shot\" alt=\"the view\" style=\"max-width:100%;border:1px solid #ccc\"></a></p>" : "") +
#> "<p><b>View:</b> <a href=\"" + _esc(row.url) + "\">" + _esc(row.url) + "</a><br>" +
#> "<b>Release:</b> " + _esc(row.release) + " · " + _esc(row.viewport) + " · " + _esc(row.theme) + "<br>" +
#> (row.email ? "<b>From:</b> " + _esc(row.email) + "<br>" : "") +
#> (image_url ? "<b>Screenshot:</b> <a href=\"" + image_url + "\">Drive</a><br>" : "") +
#> (issue_url ? "<b>Issue:</b> <a href=\"" + issue_url + "\">" + issue_url + "</a><br>" : "") +
#> "<b>Sheet row id:</b> " + id + "</p>";
#> var mail = { to: to.join(","), subject: subject, htmlBody: html, name: "CalCOFI app feedback" };
#> if (inline) mail.inlineImages = inline;
#> MailApp.sendEmail(mail);
#> status.push("mailed " + to.length + (inline ? " (screenshot inline)" : ""));
#> }
#> return _json({ ok: true, id: id, image_url: image_url, issue_url: issue_url, status: status.join("; ") });
#> } catch (err) { return _json({ ok: false, error: String(err) }); }
#> }
#>
#> // the public issue: view URL, text, release/viewport line, the screenshot committed under feedback/<id>.png.
#> // The submitter's email is NOT passed in: it stays in the Sheet.
#> function _openIssue(repo, id, ts, b, text, bytes) {
#> var api = "https://api.github.com/repos/" + repo;
#> var headers = { Authorization: "Bearer " + _prop("GITHUB_TOKEN"), Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" };
#> var img = "";
#> if (bytes && bytes.length) {
#> var path = "feedback/" + id + ".png";
#> var put = UrlFetchApp.fetch(api + "/contents/" + path, { method: "put", headers: headers, contentType: "application/json", muteHttpExceptions: true,
#> payload: JSON.stringify({ message: "feedback " + id + ": screenshot", content: Utilities.base64Encode(bytes), branch: BRANCH }) });
#> if (put.getResponseCode() < 300) img = "\n\n";
#> }
#> var title = text.split("\n")[0].slice(0, 100);
#> var body = "**View:** " + (b.url || "") + "\n**Release:** " + (b.release || "") + " · " + (b.viewport || "") + " · " + (b.theme || "") +
#> "\n\n" + text + img + "\n\n_Sent from the app's feedback dialog · " + ts.toISOString() + " · id " + id + "_";
#> var res = UrlFetchApp.fetch(api + "/issues", { method: "post", headers: headers, contentType: "application/json", muteHttpExceptions: true,
#> payload: JSON.stringify({ title: title, body: body, labels: [LABEL] }) });
#> if (res.getResponseCode() >= 300) throw new Error("GitHub " + res.getResponseCode() + ": " + res.getContentText().slice(0, 200));
#> return JSON.parse(res.getContentText()).html_url;
#> }
#>
#> function _recipients() {
#> var sh = _tab("recipients"); if (!sh || sh.getLastRow() < 2) return [];
#> return sh.getRange(2, 1, sh.getLastRow() - 1, 1).getValues().map(function (r) { return String(r[0]).trim(); })
#> .filter(function (v) { return /^[^@\s]+@[^@\s]+$/.test(v); });
#> }
#> function _rateLimited() {
#> var cache = CacheService.getScriptCache(), key = "fb:" + Math.floor(Date.now() / 3600000);
#> var n = parseInt(cache.get(key) || "0", 10) + 1;
#> cache.put(key, String(n), 3600);
#> return n > MAX_PER_HOUR;
#> }
#> function _folder(app) {
#> var id = _prop("DRIVE_FOLDER_ID"), root;
#> if (id) root = DriveApp.getFolderById(id);
#> else {
#> var ss = SpreadsheetApp.getActiveSpreadsheet(), parents = DriveApp.getFileById(ss.getId()).getParents();
#> var parent = parents.hasNext() ? parents.next() : DriveApp.getRootFolder();
#> var it = parent.getFoldersByName("CalCOFI app feedback");
#> root = it.hasNext() ? it.next() : parent.createFolder("CalCOFI app feedback");
#> }
#> var sub = root.getFoldersByName(app);
#> return sub.hasNext() ? sub.next() : root.createFolder(app);
#> }
#> function _tab(name) { return SpreadsheetApp.getActiveSpreadsheet().getSheetByName(name); }
#> function _prop(k) { return PropertiesService.getScriptProperties().getProperty(k); }
#> function _stamp(d) { return Utilities.formatDate(d, "UTC", "yyyyMMdd_HHmmss"); }
#> function _esc(s) { return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); }
#> function _json(o) { return ContentService.createTextOutput(JSON.stringify(o)).setMimeType(ContentService.MimeType.JSON); }