Function recipes
These are complete HTTP request functions you can paste into the Functions screen. Each one lists the content types and settings it needs. Change the content type names, field names and template ids to match your site.
Before you start
- Create each function with the trigger HTTP request and mark it active.
- Field names in code are field developer names, not labels.
- Writing a content item needs a web template id. Find one in the admin, or with
GET /raytha/api/v1/WebTemplates, and choose a template that has access to the content type. An id that is not a valid 22-character id throws an error. - Public pages and feeds are cheapest when they return a small page of data. Each call builds a fresh engine and queries the database.
Contact form that saves and emails
Create a content type contact_messages with a Single line text primary field title and these fields: name (single line text), email (single line text), message (long text). Create a function with the developer name contact, and optionally the route path api/contact.
var CONTACT_TEMPLATE_ID = "REPLACE_WITH_WEB_TEMPLATE_ID"; // Settings > Templates, a template that can render contact_messages
var NOTIFY_TO = "[email protected]";
function field(payload, name) {
if (Array.isArray(payload)) {
var entry = payload.find(function (i) { return i.Key === name; });
return entry && entry.Value.length > 0 ? entry.Value[0].trim() : "";
}
return payload && payload[name] !== undefined ? String(payload[name]).trim() : "";
}
function escapeHtml(s) {
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
}
function post(payload, query) {
var wantsJson = !Array.isArray(payload);
// Honeypot: real visitors leave the hidden "website" input empty.
if (field(payload, "website") !== "") {
return wantsJson ? new JsonResult({ ok: true }) : new RedirectResult("/thanks");
}
var name = field(payload, "name");
var email = field(payload, "email");
var message = field(payload, "message");
if (!name || !email || !message || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email) || message.length > 5000) {
return new StatusCodeResult(400, "name, a valid email and a message under 5000 characters are required");
}
var saved = API_V1.CreateContentItem("contact_messages", false, CONTACT_TEMPLATE_ID, {
title: name + " <" + email + ">",
name: name,
email: email,
message: message
});
if (!saved.Success) {
return new StatusCodeResult(500, saved.Error);
}
var mail = EmailMessage.From(
"New contact message from " + name,
"<p><strong>" + escapeHtml(name) + "</strong> (" + escapeHtml(email) + ")</p><p>" +
escapeHtml(message).replace(/\n/g, "<br>") + "</p>",
NOTIFY_TO,
CurrentOrganization.SmtpDefaultFromAddress,
CurrentOrganization.SmtpDefaultFromName
);
Emailer.SendEmail(mail);
return wantsJson ? new JsonResult({ ok: true }) : new RedirectResult("/thanks");
}
The function accepts a form post or a JSON post, rejects input that is missing or malformed, ignores posts that fill in the hidden website field, saves the message as a published content item, and emails you. A form post is redirected to /thanks, which you create as a page. Point your form at it as shown in HTTP request trigger. For mail settings, see Sending emails.
Raytha stores the message as published content. If the content type has a public detail template, anyone who knows the path can see the message. Give contact_messages a template that renders nothing, or turn the item into a draft by passing true as the second argument of CreateContentItem.
sitemap.xml
Create a function with the route path sitemap.xml. It lists every published posts item. Change the content type name, and add more loops for other types.
function xmlEscape(s) {
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
}
function get(query) {
var base = (CurrentOrganization.WebsiteUrl || "").replace(/\/+$/, "") + (CurrentOrganization.PathBase || "");
var urls = [];
var page = 1;
while (true) {
var res = API_V1.GetContentItems("posts", "", "", "IsPublished eq 'true'", "CreationTime desc", page, 1000);
if (!res.Success) {
return new StatusCodeResult(500, res.Error);
}
for (var item of res.Result.Items) {
var modified = item.LastModificationTime || item.CreationTime;
urls.push(
" <url><loc>" + xmlEscape(base + "/" + item.RoutePath) + "</loc>" +
"<lastmod>" + modified.ToString("yyyy-MM-dd") + "</lastmod></url>"
);
}
if (page * 1000 >= res.Result.TotalCount) {
break;
}
page++;
}
var xml = '<?xml version="1.0" encoding="UTF-8"?>\n' +
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' + urls.join("\n") + "\n</urlset>\n";
return new ContentResult(xml, "application/xml; charset=utf-8");
}
Visit /sitemap.xml. The response is application/xml; charset=utf-8. It pages through the results 1000 at a time, which is the largest page API_V1 allows without a view. Add the URL to your robots.txt, which you can also serve from a function with a ContentResult and the route path robots.txt.
RSS feed
Route path feed.xml. It uses an optional long text field summary on posts.
function xmlEscape(s) {
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
}
function textOf(item, name) {
var content = item.PublishedContent;
return content.ContainsKey(name) ? content.Item(name).Text : "";
}
function get(query) {
var base = (CurrentOrganization.WebsiteUrl || "").replace(/\/+$/, "") + (CurrentOrganization.PathBase || "");
var res = API_V1.GetContentItems("posts", "", "", "IsPublished eq 'true'", "CreationTime desc", 1, 20);
if (!res.Success) {
return new StatusCodeResult(500, res.Error);
}
var items = [];
for (var item of res.Result.Items) {
items.push(
" <item>\n" +
" <title>" + xmlEscape(item.PrimaryField) + "</title>\n" +
" <link>" + xmlEscape(base + "/" + item.RoutePath) + "</link>\n" +
" <guid isPermaLink=\"false\">" + item.Id.ToString() + "</guid>\n" +
" <pubDate>" + item.CreationTime.ToString("R") + "</pubDate>\n" +
" <description>" + xmlEscape(textOf(item, "summary")) + "</description>\n" +
" </item>"
);
}
var xml = '<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0">\n <channel>\n' +
" <title>" + xmlEscape(CurrentOrganization.OrganizationName) + "</title>\n" +
" <link>" + xmlEscape(base) + "/</link>\n" +
" <description>Latest posts</description>\n" + items.join("\n") + "\n </channel>\n</rss>\n";
return new ContentResult(xml, "application/rss+xml; charset=utf-8");
}
The content type is application/rss+xml; charset=utf-8. Link to the feed from your layout with <link rel="alternate" type="application/rss+xml" href="/feed.xml">. A missing field is safe: textOf checks that the key exists before it reads it.
JSON search index
Route path search-index.json. A small client-side search library can fetch it once and search in the browser.
function textOf(item, name) {
var content = item.PublishedContent;
return content.ContainsKey(name) ? content.Item(name).Text : "";
}
function get(query) {
var index = [];
var page = 1;
while (true) {
var res = API_V1.GetContentItems("posts", "", "", "IsPublished eq 'true'", "CreationTime desc", page, 1000);
if (!res.Success) {
return new StatusCodeResult(500, res.Error);
}
for (var item of res.Result.Items) {
index.push({
id: item.Id.ToString(),
title: item.PrimaryField,
url: "/" + item.RoutePath,
summary: textOf(item, "summary")
});
}
if (page * 1000 >= res.Result.TotalCount) {
break;
}
page++;
}
return new JsonResult(index);
}
The function builds each object by hand. Do not return the raw API_V1 response: it would expose internal fields and write ids as objects. For a large site, build the file once with the REST API and host it as a static file instead. See API recipes.
Short links and redirects
Create a content type short_links. The primary field holds the code, and a single line text field target holds the destination. Give the function the route path go, and visit /go?c=launch.
// Content type short_links: primary field = the code, text field "target" = destination URL.
// Visit /go?c=launch
function textOf(item, name) {
var content = item.PublishedContent;
return content.ContainsKey(name) ? content.Item(name).Text : "";
}
function get(query) {
var entry = query.find(function (i) { return i.Key === "c"; });
var code = entry ? entry.Value[0] : "";
if (!/^[A-Za-z0-9_-]{1,64}$/.test(code)) {
return new StatusCodeResult(404, "Unknown link");
}
var res = API_V1.GetContentItems("short_links", "", "", "PrimaryField eq '" + code + "' and IsPublished eq 'true'", "", 1, 1);
if (!res.Success || res.Result.TotalCount === 0) {
return new StatusCodeResult(404, "Unknown link");
}
var target = null;
for (var item of res.Result.Items) {
target = textOf(item, "target");
}
if (!/^https?:\/\//.test(target)) {
return new StatusCodeResult(404, "Unknown link");
}
return new RedirectResult(target);
}
The code is checked against a strict pattern before it goes into the filter, and the destination must start with http:// or https://. Both checks matter: the first keeps filter text safe, and the second prevents a javascript: or other odd link. The response is a 302. Short links with a path, such as /go/launch, are not possible, because a route path is an exact match.
Honeypot and rate limiting
The contact form above includes a honeypot: a field called website that real visitors never see. Hide it with CSS in your page, not with type="hidden", because bots skip hidden inputs. If the field has a value, the function returns the same success response and saves nothing, so the bot learns nothing.
For rate limiting, count how many messages an address sent recently. Store the address in a text field ip on contact_messages, add ip: ip to the saved content, and call this before you save:
// Content type contact_messages has a text field "ip". Call this before saving a message.
function tooManyRecently(ip, limit, seconds) {
var since = new Date(Date.now() - seconds * 1000).toISOString();
var filter = "ip eq '" + ip.replace(/'/g, "''") + "' and CreationTime ge '" + since + "'";
var res = API_V1.GetContentItems("contact_messages", "", "", filter, "", 1, 1);
return res.Success && res.Result.TotalCount >= limit;
}
function post(payload, query) {
var ip = CurrentUser.RemoteIpAddress || "unknown";
if (tooManyRecently(ip, 5, 600)) {
return new StatusCodeResult(429, "Too many messages. Try again later.");
}
// ... validate, then CreateContentItem("contact_messages", false, templateId, { ..., ip: ip })
return new JsonResult({ ok: true });
}
CurrentUser.RemoteIpAddress is the real client address only when TRUSTED_PROXIES is set correctly behind a proxy. See Running behind a proxy. Without it every visitor looks like the proxy and the first five messages lock everyone out. The check is not atomic: two simultaneous posts can both pass. That is acceptable for spam control, not for billing.
Gotchas
- Escape output. Raytha does not escape anything for you in a function. Escape text you place in HTML or XML, as these examples do.
- Filters take quoted strings. Double any single quote in a value you put in a filter, or validate the value first. See Filtering.
- Drafts and unpublished items are returned by
GetContentItems. Every public listing here filters onIsPublished eq 'true'. - Timeouts. A function stops after 10 seconds by default. A site with many thousands of items needs a static file, not a live feed.
Mathis the .NET class. UseMath.Floor, notMath.floor.
Next steps
- Built-in objects: every method these recipes call.
- HTTP request trigger: results, errors and limits.
- Content event triggers: do work after content changes.
- API recipes: build static output from outside Raytha.