skia2/experimental/webtry/webtry.go
fmalita@google.com 950306ccf1 Use CodeMirror for WebTry snippets.
CodeMirror (http://codemirror.net) is a rich code editor. This CL
adds the needed library and wires it with some minimal features, to
update the editor appearance to something like this:
http://codemirror.net/demo/theme.html?ambiance

We can later add Skia-customized features (custom keyword highlighting,
code completion, etc.).

(only tested locally)

R=jcgregorio@google.com, mtklein@google.com

Review URL: https://codereview.chromium.org/261693003

git-svn-id: http://skia.googlecode.com/svn/trunk@14499 2bbb7eff-a529-9590-31e7-b0007b416f81
2014-05-01 15:14:56 +00:00

687 lines
22 KiB
Go

package main
import (
"bytes"
"crypto/md5"
"database/sql"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
_ "github.com/go-sql-driver/mysql"
_ "github.com/mattn/go-sqlite3"
htemplate "html/template"
"io/ioutil"
"log"
"math/rand"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"text/template"
"time"
)
const (
RESULT_COMPILE = `../../experimental/webtry/safec++ -DSK_GAMMA_SRGB -DSK_GAMMA_APPLY_TO_A8 -DSK_SCALAR_TO_FLOAT_EXCLUDED -DSK_ALLOW_STATIC_GLOBAL_INITIALIZERS=1 -DSK_SUPPORT_GPU=0 -DSK_SUPPORT_OPENCL=0 -DSK_FORCE_DISTANCEFIELD_FONTS=0 -DSK_SCALAR_IS_FLOAT -DSK_CAN_USE_FLOAT -DSK_SAMPLES_FOR_X -DSK_BUILD_FOR_UNIX -DSK_USE_POSIX_THREADS -DSK_SYSTEM_ZLIB=1 -DSK_DEBUG -DSK_DEVELOPER=1 -I../../src/core -I../../src/images -I../../tools/flags -I../../include/config -I../../include/core -I../../include/pathops -I../../include/pipe -I../../include/effects -I../../include/ports -I../../src/sfnt -I../../include/utils -I../../src/utils -I../../include/images -g -fno-exceptions -fstrict-aliasing -Wall -Wextra -Winit-self -Wpointer-arith -Wno-unused-parameter -m64 -fno-rtti -Wnon-virtual-dtor -c ../../../cache/%s.cpp -o ../../../cache/%s.o`
LINK = `../../experimental/webtry/safec++ -m64 -lstdc++ -lm -o ../../../inout/%s -Wl,--start-group ../../../cache/%s.o obj/experimental/webtry/webtry.main.o obj/gyp/libflags.a libskia_images.a libskia_core.a libskia_effects.a obj/gyp/libjpeg.a obj/gyp/libwebp_dec.a obj/gyp/libwebp_demux.a obj/gyp/libwebp_dsp.a obj/gyp/libwebp_enc.a obj/gyp/libwebp_utils.a libskia_utils.a libskia_opts.a libskia_opts_ssse3.a libskia_ports.a libskia_sfnt.a -Wl,--end-group -lpng -lz -lgif -lpthread -lfontconfig -ldl -lfreetype`
DEFAULT_SAMPLE = `void draw(SkCanvas* canvas) {
SkPaint p;
p.setColor(SK_ColorRED);
p.setAntiAlias(true);
p.setStyle(SkPaint::kStroke_Style);
p.setStrokeWidth(10);
canvas->drawLine(20, 20, 100, 100, p);
}`
// Don't increase above 2^16 w/o altering the db tables to accept something bigger than TEXT.
MAX_TRY_SIZE = 64000
)
var (
// codeTemplate is the cpp code template the user's code is copied into.
codeTemplate *template.Template = nil
// indexTemplate is the main index.html page we serve.
indexTemplate *htemplate.Template = nil
// iframeTemplate is the main index.html page we serve.
iframeTemplate *htemplate.Template = nil
// recentTemplate is a list of recent images.
recentTemplate *htemplate.Template = nil
// workspaceTemplate is the page for workspaces, a series of webtrys.
workspaceTemplate *htemplate.Template = nil
// db is the database, nil if we don't have an SQL database to store data into.
db *sql.DB = nil
// directLink is the regex that matches URLs paths that are direct links.
directLink = regexp.MustCompile("^/c/([a-f0-9]+)$")
// iframeLink is the regex that matches URLs paths that are links to iframes.
iframeLink = regexp.MustCompile("^/iframe/([a-f0-9]+)$")
// imageLink is the regex that matches URLs paths that are direct links to PNGs.
imageLink = regexp.MustCompile("^/i/([a-f0-9]+.png)$")
// tryInfoLink is the regex that matches URLs paths that are direct links to data about a single try.
tryInfoLink = regexp.MustCompile("^/json/([a-f0-9]+)$")
// workspaceLink is the regex that matches URLs paths for workspaces.
workspaceLink = regexp.MustCompile("^/w/([a-z0-9-]+)$")
// workspaceNameAdj is a list of adjectives for building workspace names.
workspaceNameAdj = []string{
"autumn", "hidden", "bitter", "misty", "silent", "empty", "dry", "dark",
"summer", "icy", "delicate", "quiet", "white", "cool", "spring", "winter",
"patient", "twilight", "dawn", "crimson", "wispy", "weathered", "blue",
"billowing", "broken", "cold", "damp", "falling", "frosty", "green",
"long", "late", "lingering", "bold", "little", "morning", "muddy", "old",
"red", "rough", "still", "small", "sparkling", "throbbing", "shy",
"wandering", "withered", "wild", "black", "young", "holy", "solitary",
"fragrant", "aged", "snowy", "proud", "floral", "restless", "divine",
"polished", "ancient", "purple", "lively", "nameless",
}
// workspaceNameNoun is a list of nouns for building workspace names.
workspaceNameNoun = []string{
"waterfall", "river", "breeze", "moon", "rain", "wind", "sea", "morning",
"snow", "lake", "sunset", "pine", "shadow", "leaf", "dawn", "glitter",
"forest", "hill", "cloud", "meadow", "sun", "glade", "bird", "brook",
"butterfly", "bush", "dew", "dust", "field", "fire", "flower", "firefly",
"feather", "grass", "haze", "mountain", "night", "pond", "darkness",
"snowflake", "silence", "sound", "sky", "shape", "surf", "thunder",
"violet", "water", "wildflower", "wave", "water", "resonance", "sun",
"wood", "dream", "cherry", "tree", "fog", "frost", "voice", "paper",
"frog", "smoke", "star",
}
gitHash = ""
gitInfo = ""
)
// flags
var (
useChroot = flag.Bool("use_chroot", false, "Run the compiled code in the schroot jail.")
port = flag.String("port", ":8000", "HTTP service address (e.g., ':8000')")
)
// lineNumbers adds #line numbering to the user's code.
func LineNumbers(c string) string {
lines := strings.Split(c, "\n")
ret := []string{}
for i, line := range lines {
ret = append(ret, fmt.Sprintf("#line %d", i+1))
ret = append(ret, line)
}
return strings.Join(ret, "\n")
}
func init() {
rand.Seed(time.Now().UnixNano())
// Change the current working directory to the directory of the executable.
var err error
cwd, err := filepath.Abs(filepath.Dir(os.Args[0]))
if err != nil {
log.Fatal(err)
}
os.Chdir(cwd)
codeTemplate, err = template.ParseFiles(filepath.Join(cwd, "templates/template.cpp"))
if err != nil {
panic(err)
}
indexTemplate, err = htemplate.ParseFiles(
filepath.Join(cwd, "templates/index.html"),
filepath.Join(cwd, "templates/titlebar.html"),
filepath.Join(cwd, "templates/content.html"),
)
if err != nil {
panic(err)
}
iframeTemplate, err = htemplate.ParseFiles(
filepath.Join(cwd, "templates/iframe.html"),
filepath.Join(cwd, "templates/content.html"),
)
if err != nil {
panic(err)
}
recentTemplate, err = htemplate.ParseFiles(
filepath.Join(cwd, "templates/recent.html"),
filepath.Join(cwd, "templates/titlebar.html"),
)
if err != nil {
panic(err)
}
workspaceTemplate, err = htemplate.ParseFiles(
filepath.Join(cwd, "templates/workspace.html"),
filepath.Join(cwd, "templates/titlebar.html"),
filepath.Join(cwd, "templates/content.html"),
)
if err != nil {
panic(err)
}
// The git command returns output of the format:
//
// f672cead70404080a991ebfb86c38316a4589b23 2014-04-27 19:21:51 +0000
//
logOutput, err := doCmd(`git log --format=%H%x20%ai HEAD^..HEAD`, true)
if err != nil {
panic(err)
}
logInfo := strings.Split(logOutput, " ")
gitHash = logInfo[0]
gitInfo = logInfo[1] + " " + logInfo[2] + " " + logInfo[0][0:6]
// Connect to MySQL server. First, get the password from the metadata server.
// See https://developers.google.com/compute/docs/metadata#custom.
req, err := http.NewRequest("GET", "http://metadata/computeMetadata/v1/instance/attributes/password", nil)
if err != nil {
panic(err)
}
client := http.Client{}
req.Header.Add("X-Google-Metadata-Request", "True")
if resp, err := client.Do(req); err == nil {
password, err := ioutil.ReadAll(resp.Body)
if err != nil {
log.Printf("ERROR: Failed to read password from metadata server: %q\n", err)
panic(err)
}
// The IP address of the database is found here:
// https://console.developers.google.com/project/31977622648/sql/instances/webtry/overview
// And 3306 is the default port for MySQL.
db, err = sql.Open("mysql", fmt.Sprintf("webtry:%s@tcp(173.194.83.52:3306)/webtry?parseTime=true", password))
if err != nil {
log.Printf("ERROR: Failed to open connection to SQL server: %q\n", err)
panic(err)
}
} else {
log.Printf("INFO: Failed to find metadata, unable to connect to MySQL server (Expected when running locally): %q\n", err)
// Fallback to sqlite for local use.
db, err = sql.Open("sqlite3", "./webtry.db")
if err != nil {
log.Printf("ERROR: Failed to open: %q\n", err)
panic(err)
}
sql := `CREATE TABLE webtry (
code TEXT DEFAULT '' NOT NULL,
create_ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
hash CHAR(64) DEFAULT '' NOT NULL,
PRIMARY KEY(hash)
)`
_, err = db.Exec(sql)
log.Printf("Info: status creating sqlite table for webtry: %q\n", err)
sql = `CREATE TABLE workspace (
name CHAR(64) DEFAULT '' NOT NULL,
create_ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
PRIMARY KEY(name)
)`
_, err = db.Exec(sql)
log.Printf("Info: status creating sqlite table for workspace: %q\n", err)
sql = `CREATE TABLE workspacetry (
name CHAR(64) DEFAULT '' NOT NULL,
create_ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
hash CHAR(64) DEFAULT '' NOT NULL,
hidden INTEGER DEFAULT 0 NOT NULL,
FOREIGN KEY (name) REFERENCES workspace(name)
)`
_, err = db.Exec(sql)
log.Printf("Info: status creating sqlite table for workspace try: %q\n", err)
}
}
// Titlebar is used in titlebar template expansion.
type Titlebar struct {
GitHash string
GitInfo string
}
// userCode is used in template expansion.
type userCode struct {
Code string
Hash string
Titlebar Titlebar
}
// expandToFile expands the template and writes the result to the file.
func expandToFile(filename string, code string, t *template.Template) error {
f, err := os.Create(filename)
if err != nil {
return err
}
defer f.Close()
return t.Execute(f, userCode{Code: code, Titlebar: Titlebar{GitHash: gitHash, GitInfo: gitInfo}})
}
// expandCode expands the template into a file and calculate the MD5 hash.
func expandCode(code string) (string, error) {
h := md5.New()
h.Write([]byte(code))
hash := fmt.Sprintf("%x", h.Sum(nil))
// At this point we are running in skia/experimental/webtry, making cache a
// peer directory to skia.
// TODO(jcgregorio) Make all relative directories into flags.
err := expandToFile(fmt.Sprintf("../../../cache/%s.cpp", hash), code, codeTemplate)
return hash, err
}
// response is serialized to JSON as a response to POSTs.
type response struct {
Message string `json:"message"`
StdOut string `json:"stdout"`
Img string `json:"img"`
Hash string `json:"hash"`
}
// doCmd executes the given command line string in either the out/Debug
// directory or the inout directory. Returns the stdout and stderr.
func doCmd(commandLine string, moveToDebug bool) (string, error) {
log.Printf("Command: %q\n", commandLine)
programAndArgs := strings.SplitN(commandLine, " ", 2)
program := programAndArgs[0]
args := []string{}
if len(programAndArgs) > 1 {
args = strings.Split(programAndArgs[1], " ")
}
cmd := exec.Command(program, args...)
abs, err := filepath.Abs("../../out/Debug")
if err != nil {
return "", fmt.Errorf("Failed to find absolute path to Debug directory.")
}
if moveToDebug {
cmd.Dir = abs
} else if !*useChroot { // Don't set cmd.Dir when using chroot.
abs, err := filepath.Abs("../../../inout")
if err != nil {
return "", fmt.Errorf("Failed to find absolute path to inout directory.")
}
cmd.Dir = abs
}
log.Printf("Run in directory: %q\n", cmd.Dir)
message, err := cmd.CombinedOutput()
log.Printf("StdOut + StdErr: %s\n", string(message))
if err != nil {
log.Printf("Exit status: %s\n", err.Error())
return string(message), fmt.Errorf("Failed to run command.")
}
return string(message), nil
}
// reportError formats an HTTP error response and also logs the detailed error message.
func reportError(w http.ResponseWriter, r *http.Request, err error, message string) {
log.Printf("Error: %s\n%s", message, err.Error())
http.Error(w, message, 500)
}
// reportTryError formats an HTTP error response in JSON and also logs the detailed error message.
func reportTryError(w http.ResponseWriter, r *http.Request, err error, message, hash string) {
m := response{
Message: message,
Hash: hash,
}
log.Printf("Error: %s\n%s", message, err.Error())
resp, err := json.Marshal(m)
if err != nil {
http.Error(w, "Failed to serialize a response", 500)
return
}
w.Write(resp)
}
func writeToDatabase(hash string, code string, workspaceName string) {
if db == nil {
return
}
if _, err := db.Exec("INSERT INTO webtry (code, hash) VALUES(?, ?)", code, hash); err != nil {
log.Printf("ERROR: Failed to insert code into database: %q\n", err)
}
if workspaceName != "" {
if _, err := db.Exec("INSERT INTO workspacetry (name, hash) VALUES(?, ?)", workspaceName, hash); err != nil {
log.Printf("ERROR: Failed to insert into workspacetry table: %q\n", err)
}
}
}
// imageHandler serves up the PNG of a specific try.
func imageHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("Image Handler: %q\n", r.URL.Path)
if r.Method != "GET" {
http.NotFound(w, r)
return
}
match := imageLink.FindStringSubmatch(r.URL.Path)
if len(match) != 2 {
http.NotFound(w, r)
return
}
filename := match[1]
http.ServeFile(w, r, fmt.Sprintf("../../../inout/%s", filename))
}
type Try struct {
Hash string `json:"hash"`
CreateTS string `json:"create_ts"`
}
type Recent struct {
Tries []Try
Titlebar Titlebar
}
// recentHandler shows the last 20 tries.
func recentHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("Recent Handler: %q\n", r.URL.Path)
var err error
rows, err := db.Query("SELECT create_ts, hash FROM webtry ORDER BY create_ts DESC LIMIT 20")
if err != nil {
http.NotFound(w, r)
return
}
recent := []Try{}
for rows.Next() {
var hash string
var create_ts time.Time
if err := rows.Scan(&create_ts, &hash); err != nil {
log.Printf("Error: failed to fetch from database: %q", err)
continue
}
recent = append(recent, Try{Hash: hash, CreateTS: create_ts.Format("2006-02-01")})
}
if err := recentTemplate.Execute(w, Recent{Tries: recent, Titlebar: Titlebar{GitHash: gitHash, GitInfo: gitInfo}}); err != nil {
log.Printf("ERROR: Failed to expand template: %q\n", err)
}
}
type Workspace struct {
Name string
Code string
Hash string
Tries []Try
Titlebar Titlebar
}
// newWorkspace generates a new random workspace name and stores it in the database.
func newWorkspace() (string, error) {
for i := 0; i < 10; i++ {
adj := workspaceNameAdj[rand.Intn(len(workspaceNameAdj))]
noun := workspaceNameNoun[rand.Intn(len(workspaceNameNoun))]
suffix := rand.Intn(1000)
name := fmt.Sprintf("%s-%s-%d", adj, noun, suffix)
if _, err := db.Exec("INSERT INTO workspace (name) VALUES(?)", name); err == nil {
return name, nil
} else {
log.Printf("ERROR: Failed to insert workspace into database: %q\n", err)
}
}
return "", fmt.Errorf("Failed to create a new workspace")
}
// getCode returns the code for a given hash, or the empty string if not found.
func getCode(hash string) (string, error) {
code := ""
if err := db.QueryRow("SELECT code FROM webtry WHERE hash=?", hash).Scan(&code); err != nil {
log.Printf("ERROR: Code for hash is missing: %q\n", err)
return code, err
}
return code, nil
}
func workspaceHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("Workspace Handler: %q\n", r.URL.Path)
if r.Method == "GET" {
tries := []Try{}
match := workspaceLink.FindStringSubmatch(r.URL.Path)
name := ""
if len(match) == 2 {
name = match[1]
rows, err := db.Query("SELECT create_ts, hash FROM workspacetry WHERE name=? ORDER BY create_ts", name)
if err != nil {
reportError(w, r, err, "Failed to select.")
return
}
for rows.Next() {
var hash string
var create_ts time.Time
if err := rows.Scan(&create_ts, &hash); err != nil {
log.Printf("Error: failed to fetch from database: %q", err)
continue
}
tries = append(tries, Try{Hash: hash, CreateTS: create_ts.Format("2006-02-01")})
}
}
var code string
var hash string
if len(tries) == 0 {
code = DEFAULT_SAMPLE
} else {
hash = tries[len(tries)-1].Hash
code, _ = getCode(hash)
}
if err := workspaceTemplate.Execute(w, Workspace{Tries: tries, Code: code, Name: name, Hash: hash, Titlebar: Titlebar{GitHash: gitHash, GitInfo: gitInfo}}); err != nil {
log.Printf("ERROR: Failed to expand template: %q\n", err)
}
} else if r.Method == "POST" {
name, err := newWorkspace()
if err != nil {
http.Error(w, "Failed to create a new workspace.", 500)
return
}
http.Redirect(w, r, "/w/"+name, 302)
}
}
// hasPreProcessor returns true if any line in the code begins with a # char.
func hasPreProcessor(code string) bool {
lines := strings.Split(code, "\n")
for _, s := range lines {
if strings.HasPrefix(strings.TrimSpace(s), "#") {
return true
}
}
return false
}
type TryRequest struct {
Code string `json:"code"`
Name string `json:"name"` // Optional name of the workspace the code is in.
}
// iframeHandler handles the GET and POST of the main page.
func iframeHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("IFrame Handler: %q\n", r.URL.Path)
if r.Method != "GET" {
http.NotFound(w, r)
return
}
match := iframeLink.FindStringSubmatch(r.URL.Path)
if len(match) != 2 {
http.NotFound(w, r)
return
}
hash := match[1]
if db == nil {
http.NotFound(w, r)
return
}
var code string
code, err := getCode(hash)
if err != nil {
http.NotFound(w, r)
return
}
// Expand the template.
if err := iframeTemplate.Execute(w, userCode{Code: code, Hash: hash}); err != nil {
log.Printf("ERROR: Failed to expand template: %q\n", err)
}
}
type TryInfo struct {
Hash string `json:"hash"`
Code string `json:"code"`
}
// tryInfoHandler returns information about a specific try.
func tryInfoHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("Try Info Handler: %q\n", r.URL.Path)
if r.Method != "GET" {
http.NotFound(w, r)
return
}
match := tryInfoLink.FindStringSubmatch(r.URL.Path)
if len(match) != 2 {
http.NotFound(w, r)
return
}
hash := match[1]
code, err := getCode(hash)
if err != nil {
http.NotFound(w, r)
return
}
m := TryInfo{
Hash: hash,
Code: code,
}
resp, err := json.Marshal(m)
if err != nil {
reportError(w, r, err, "Failed to serialize a response.")
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(resp)
}
func cleanCompileOutput(s, hash string) string {
old := "../../../cache/" + hash + ".cpp:"
log.Printf("INFO: replacing %q\n", old)
return strings.Replace(s, old, "usercode.cpp:", -1)
}
// mainHandler handles the GET and POST of the main page.
func mainHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("Main Handler: %q\n", r.URL.Path)
if r.Method == "GET" {
code := DEFAULT_SAMPLE
match := directLink.FindStringSubmatch(r.URL.Path)
var hash string
if len(match) == 2 && r.URL.Path != "/" {
hash = match[1]
if db == nil {
http.NotFound(w, r)
return
}
// Update 'code' with the code found in the database.
if err := db.QueryRow("SELECT code FROM webtry WHERE hash=?", hash).Scan(&code); err != nil {
http.NotFound(w, r)
return
}
}
// Expand the template.
if err := indexTemplate.Execute(w, userCode{Code: code, Hash: hash, Titlebar: Titlebar{GitHash: gitHash, GitInfo: gitInfo}}); err != nil {
log.Printf("ERROR: Failed to expand template: %q\n", err)
}
} else if r.Method == "POST" {
w.Header().Set("Content-Type", "application/json")
buf := bytes.NewBuffer(make([]byte, 0, MAX_TRY_SIZE))
n, err := buf.ReadFrom(r.Body)
if err != nil {
reportTryError(w, r, err, "Failed to read a request body.", "")
return
}
if n == MAX_TRY_SIZE {
err := fmt.Errorf("Code length equal to, or exceeded, %d", MAX_TRY_SIZE)
reportTryError(w, r, err, "Code too large.", "")
return
}
request := TryRequest{}
if err := json.Unmarshal(buf.Bytes(), &request); err != nil {
reportTryError(w, r, err, "Coulnd't decode JSON.", "")
return
}
if hasPreProcessor(request.Code) {
err := fmt.Errorf("Found preprocessor macro in code.")
reportTryError(w, r, err, "Preprocessor macros aren't allowed.", "")
return
}
hash, err := expandCode(LineNumbers(request.Code))
if err != nil {
reportTryError(w, r, err, "Failed to write the code to compile.", hash)
return
}
writeToDatabase(hash, request.Code, request.Name)
message, err := doCmd(fmt.Sprintf(RESULT_COMPILE, hash, hash), true)
if err != nil {
message = cleanCompileOutput(message, hash)
reportTryError(w, r, err, message, hash)
return
}
linkMessage, err := doCmd(fmt.Sprintf(LINK, hash, hash), true)
if err != nil {
linkMessage = cleanCompileOutput(linkMessage, hash)
reportTryError(w, r, err, linkMessage, hash)
return
}
message += linkMessage
cmd := hash + " --out " + hash + ".png"
if *useChroot {
cmd = "schroot -c webtry --directory=/inout -- /inout/" + cmd
} else {
abs, err := filepath.Abs("../../../inout")
if err != nil {
reportTryError(w, r, err, "Failed to find executable directory.", hash)
return
}
cmd = abs + "/" + cmd
}
execMessage, err := doCmd(cmd, false)
if err != nil {
reportTryError(w, r, err, "Failed to run the code:\n"+execMessage, hash)
return
}
png, err := ioutil.ReadFile("../../../inout/" + hash + ".png")
if err != nil {
reportTryError(w, r, err, "Failed to open the generated PNG.", hash)
return
}
m := response{
Message: message,
StdOut: execMessage,
Img: base64.StdEncoding.EncodeToString([]byte(png)),
Hash: hash,
}
resp, err := json.Marshal(m)
if err != nil {
reportTryError(w, r, err, "Failed to serialize a response.", hash)
return
}
w.Write(resp)
}
}
func main() {
flag.Parse()
http.HandleFunc("/i/", imageHandler)
http.HandleFunc("/w/", workspaceHandler)
http.HandleFunc("/recent/", recentHandler)
http.HandleFunc("/iframe/", iframeHandler)
http.HandleFunc("/json/", tryInfoHandler)
// Resources are served directly
// TODO add support for caching/etags/gzip
http.Handle("/res/", http.FileServer(http.Dir("./")))
// TODO Break out /c/ as it's own handler.
http.HandleFunc("/", mainHandler)
log.Fatal(http.ListenAndServe(*port, nil))
}