Skip to contents

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 app files 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)

Value

character scalar of JavaScript source

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![view](https://raw.githubusercontent.com/" + repo + "/" + BRANCH + "/" + path + ")";
#>   }
#>   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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); }
#> function _json(o) { return ContentService.createTextOutput(JSON.stringify(o)).setMimeType(ContentService.MimeType.JSON); }