diff options
author | 2014-04-19 13:55:50 +0000 | |
---|---|---|
committer | 2014-04-19 13:55:50 +0000 | |
commit | d04e1dd9e1dbe7e458932a60801fb31a72b49267 (patch) | |
tree | f5bc3fdb8fa7ab65081c9884582900f20b28a2dc /experimental | |
parent | 8a777a5e6ad081f54ec169dcf714343c38517161 (diff) |
First pass at workspaces.
Ability to create new workspaces. Run tries in a workspace, each try is added to a history of a workspace.
BUG=skia:
R=mtklein@google.com
Author: jcgregorio@google.com
Review URL: https://codereview.chromium.org/240773003
git-svn-id: http://skia.googlecode.com/svn/trunk@14265 2bbb7eff-a529-9590-31e7-b0007b416f81
Diffstat (limited to 'experimental')
-rw-r--r-- | experimental/webtry/DESIGN.md | 92 | ||||
-rw-r--r-- | experimental/webtry/css/webtry.css | 11 | ||||
-rw-r--r-- | experimental/webtry/js/run.js | 90 | ||||
-rw-r--r-- | experimental/webtry/main.cpp | 2 | ||||
-rw-r--r-- | experimental/webtry/templates/index.html | 60 | ||||
-rw-r--r-- | experimental/webtry/templates/recent.html | 24 | ||||
-rw-r--r-- | experimental/webtry/templates/titlebar.html | 6 | ||||
-rw-r--r-- | experimental/webtry/templates/workspace.html | 50 | ||||
-rw-r--r-- | experimental/webtry/webtry.go | 178 |
9 files changed, 401 insertions, 112 deletions
diff --git a/experimental/webtry/DESIGN.md b/experimental/webtry/DESIGN.md index 0c4f3a1b87..3aaf2e707c 100644 --- a/experimental/webtry/DESIGN.md +++ b/experimental/webtry/DESIGN.md @@ -36,35 +36,33 @@ Architecture The server runs on GCE, and consists of a Go Web Server that calls out to the c++ compiler and executes code in a chroot jail. See the diagram below: - - +–––––––––––––+ - | | - | Browser | - | | - +––––––+––––––+ - | - +––––––+––––––+ - | | - | | - | Web Server | - | | - | (Go) | - | | - | | - +–––––––+–––––+ - | - +–––––––+––––––––––+ - | chroot jail | - | +––––––––––––––+| - | | seccomp || - | | +––––––––––+|| - | | |User code ||| - | | | ||| - | | +––––––––––+|| - | +––––––––––––––+| - | | - +––––––––––––––––––+ - + +–––––––––––––+ + | | + | Browser | + | | + +––––––+––––––+ + | + +––––––+––––––+ + | | + | | + | Web Server | + | | + | (Go) | + | | + | | + +–––––––+–––––+ + | + +–––––––+––––––––––+ + | chroot jail | + | +––––––––––––––+| + | | seccomp || + | | +––––––––––+|| + | | |User code ||| + | | | ||| + | | +––––––––––+|| + | +––––––––––––––+| + | | + +––––––––––––––––––+ The user code is expanded into a simple template and linked against libskia and a couple other .o files that contain main() and the code that sets up the @@ -147,6 +145,21 @@ Initial setup of the database, the user, and the only table: PRIMARY KEY(hash) ); + CREATE TABLE workspace ( + name CHAR(64) DEFAULT '' NOT NULL, + create_ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + PRIMARY KEY(name) + ); + + 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) + ); + Common queries webtry.go will use: INSERT INTO webtry (code, hash) VALUES('int i = 0;...', 'abcdef...'); @@ -161,9 +174,18 @@ Common queries webtry.go will use: // Run before and after to confirm the password changed: SELECT Host, User, Password FROM mysql.user; +Common queries for workspaces: + + SELECT hash, create_ts FROM workspace ORDER BY create_ts DESC; + + INSERT INTO workspace (name, hash) VALUES('autumn-river-12354', 'abcdef...'); + + SELECT name FROM workspace GROUP BY name; + Password for the database will be stored in the metadata instance, if the -metadata server can't be found, i.e. running locally, then data will not be -stored. To see the current password stored in metadata and the fingerprint: +metadata server can't be found, i.e. running locally, then a local sqlite +database will be used. To see the current password stored in metadata and the +fingerprint: gcutil --project=google.com:skia-buildbots getinstance skia-webtry-b @@ -179,6 +201,14 @@ the metadata server: N.B. If you need to change the MySQL password that webtry uses, you must change it both in MySQL and the value stored in the metadata server. +Workspaces +---------- + +Workspaces are implemented by the workspace and workspacetry tables. The +workspace table keeps the unique list of all workspaces. The workspacetry table +keeps track of all the tries that have occured in a workspace. Right now the +hidden column of workspacetry is not used, it's for future functionality. + Installation ------------ See the README file. diff --git a/experimental/webtry/css/webtry.css b/experimental/webtry/css/webtry.css index ee87b94daa..d9487c6fcb 100644 --- a/experimental/webtry/css/webtry.css +++ b/experimental/webtry/css/webtry.css @@ -58,3 +58,14 @@ pre, code { #content { padding: 1em; } + +#tryHistory { + position: absolute; + top: 3em; + right: 10px; + width: 75px; +} + +#tryHistory .tries { + float: none; +} diff --git a/experimental/webtry/js/run.js b/experimental/webtry/js/run.js new file mode 100644 index 0000000000..165aae328a --- /dev/null +++ b/experimental/webtry/js/run.js @@ -0,0 +1,90 @@ +/** + * Common JS that talks XHR back to the server and runs the code and receives + * the results. + */ + +/** + * All the functionality is wrapped up in this anonymous closure, but we need + * to be told if we are on the workspace page or a normal try page, so the + * workspaceName is passed into the closure, it must be set in the global + * namespace. If workspaceName is the empty string then we know we aren't + * running on a workspace page. + */ +(function(workspaceName) { + var run = document.getElementById('run'); + var code = document.getElementById('code'); + var output = document.getElementById('output'); + var img = document.getElementById('img'); + var tryHistory = document.getElementById('tryHistory'); + var parser = new DOMParser(); + + + function beginWait() { + document.body.classList.add('waiting'); + run.disabled = true; + } + + + function endWait() { + document.body.classList.remove('waiting'); + run.disabled = false; + } + + + /** + * Callback for when the XHR returns after attempting to run the code. + * @param e The callback event. + */ + function codeComplete(e) { + // The response is JSON of the form: + // { + // "message": "you had an error...", + // "img": "<base64 encoded image but only on success>" + // } + // + // The img is optional and only appears if there is a valid + // image to display. + endWait(); + console.log(e.target.response); + body = JSON.parse(e.target.response); + output.innerText = body.message; + if (body.hasOwnProperty('img')) { + img.src = 'data:image/png;base64,' + body.img; + } else { + img.src = ''; + } + // Add the image to the history if we are on a workspace page. + if (tryHistory) { + var newHistoryStr = '<div class=tries>' + + '<a href="/c/' + body.hash + '">' + + ' <img width=64 height=64 src="/i/' + body.hash + '.png">' + + '</a></div>'; + + var newHistory = parser.parseFromString(newHistoryStr, "text/html"); + tryHistory.insertBefore(newHistory.body.firstChild, tryHistory.firstChild); + } + } + + + /** + * Callback where there's an XHR error. + * @param e The callback event. + */ + function codeError(e) { + endWait(); + alert('Something bad happened: ' + e); + } + + + function onSubmitCode() { + beginWait(); + var req = new XMLHttpRequest(); + req.addEventListener('load', codeComplete); + req.addEventListener('error', codeError); + req.overrideMimeType('application/json'); + req.open('POST', '/', true); + req.setRequestHeader('content-type', 'application/json'); + req.send(JSON.stringify({"code": code.value, "name": workspaceName})); + } + run.addEventListener('click', onSubmitCode); +})(workspaceName); diff --git a/experimental/webtry/main.cpp b/experimental/webtry/main.cpp index 9e7df1484f..7ccb9322f9 100644 --- a/experimental/webtry/main.cpp +++ b/experimental/webtry/main.cpp @@ -91,7 +91,7 @@ int main(int argc, char** argv) { } SkFILEWStream stream(FLAGS_out[0]); - SkImageInfo info = SkImageInfo::MakeN32(300, 300, kPremul_SkAlphaType); + SkImageInfo info = SkImageInfo::MakeN32(256, 256, kPremul_SkAlphaType); SkAutoTUnref<SkSurface> surface(SkSurface::NewRaster(info)); SkCanvas* canvas = surface->getCanvas(); diff --git a/experimental/webtry/templates/index.html b/experimental/webtry/templates/index.html index d2ba8592cb..c79dc124c0 100644 --- a/experimental/webtry/templates/index.html +++ b/experimental/webtry/templates/index.html @@ -6,11 +6,7 @@ <link rel="stylesheet" href="/css/" type="text/css" media="screen"> </head> <body> - <section id=title> - <a href="/">Home</a> - <a href="/recent">Recent</a> - <a href="https://github.com/google/skia/tree/master/experimental/webtry">Code</a> - </section> + {{template "titlebar.html"}} <section id=content> <pre><code>#include "SkCanvas.h" @@ -28,57 +24,9 @@ void draw(SkCanvas* canvas) { </section> <script type='text/javascript' charset='utf-8'> - var run = document.getElementById('run'); - var code = document.getElementById('code'); - var output = document.getElementById('output'); - var img = document.getElementById('img'); - - function beginWait() { - document.body.classList.add('waiting'); - run.disabled = true; - } - - function endWait() { - document.body.classList.remove('waiting'); - run.disabled = false; - } - - function codeComplete(e) { - // The response is JSON of the form: - // { - // "message": "you had an error...", - // "img": "<base64 encoded image but only on success>" - // } - // - // The img is optional and only appears if there is a valid - // image to display. - endWait(); - console.log(e.target.response); - body = JSON.parse(e.target.response); - output.innerText = body.message; - if (body.hasOwnProperty('img')) { - img.src = 'data:image/png;base64,' + body.img; - } else { - img.src = ''; - } - window.history.pushState(null, null, "/c/" + body.hash); - } - - function codeError(e) { - endWait(); - alert('Something bad happened: ' + e); - } - - run.addEventListener('click', onSubmitCode); - function onSubmitCode() { - beginWait(); - var req = new XMLHttpRequest(); - req.addEventListener('load', codeComplete); - req.addEventListener('error', codeError); - req.overrideMimeType('application/json'); - req.open('POST', '.', true); - req.send(code.value); - } + // Not running in a workspace. + var workspaceName = ""; </script> + <script src="/js/run.js" type="text/javascript" charset="utf-8"></script> </body> </html> diff --git a/experimental/webtry/templates/recent.html b/experimental/webtry/templates/recent.html index 051ac3f605..96be7141cd 100644 --- a/experimental/webtry/templates/recent.html +++ b/experimental/webtry/templates/recent.html @@ -6,21 +6,19 @@ <link rel="stylesheet" href="/css/" type="text/css" media="screen"> </head> <body> - <section id=title> - <a href="/">Home</a> - <a href="/recent">Recent</a> - <a href="https://github.com/google/skia/tree/master/experimental/webtry">Code</a> - </section> + {{template "titlebar.html"}} <section id=content> <h1>Recent Activity</h1> - {{range .Tries}} - <section class=tries> - <h2><a href="/c/{{.Hash}}">{{.CreateTS}}</a></h2> - <a href="/c/{{.Hash}}"> - <img width=100 height=100 src="/i/{{.Hash}}.png"> - </a> - </section> - {{end}} + <section> + {{range .Tries}} + <div class=tries> + <h2><a href="/c/{{.Hash}}">{{.CreateTS}}</a></h2> + <a href="/c/{{.Hash}}"> + <img width=128 height=128 src="/i/{{.Hash}}.png"> + </a> + </div> + {{end}} + </section> </section> </body> </html> diff --git a/experimental/webtry/templates/titlebar.html b/experimental/webtry/templates/titlebar.html new file mode 100644 index 0000000000..93f64109af --- /dev/null +++ b/experimental/webtry/templates/titlebar.html @@ -0,0 +1,6 @@ + <section id=title> + <a href="/">Home</a> + <a href="/recent/">Recent</a> + <a href="/w/">Workspace</a> + <a href="https://github.com/google/skia/tree/master/experimental/webtry">Code</a> + </section> diff --git a/experimental/webtry/templates/workspace.html b/experimental/webtry/templates/workspace.html new file mode 100644 index 0000000000..3d7003512f --- /dev/null +++ b/experimental/webtry/templates/workspace.html @@ -0,0 +1,50 @@ +<!DOCTYPE html> +<html> +<head> + <title>Workspace</title> + <meta charset='utf-8' /> + <link rel="stylesheet" href="/css/" type="text/css" media="screen"> +</head> +<body> + {{template "titlebar.html"}} + <section id=content> + <h1>Create</h1> +{{if .Name}} + <pre><code>#include "SkCanvas.h" + +void draw(SkCanvas* canvas) { + <textarea name='code' id='code' rows='15' cols='80'>{{.Code}}</textarea> +} +</code></pre> + + <input type='button' value='Run' id='run'> + + <p>Image appears here:</p> + <img id='img' src=''/> + + <pre><code id='output'></code></pre> + </section> + <section id=tryHistory> + {{range .Tries}} + <div class=tries> + <a href="/c/{{.Hash}}"> + <img width=64 height=64 src="/i/{{.Hash}}.png"> + </a> + </div> + {{end}} + </section> + + <script type='text/javascript' charset='utf-8'> + // Set the workspace name so run.js also updates the history. + var workspaceName = "{{.Name}}"; + </script> + <script src="/js/run.js" type="text/javascript" charset="utf-8"></script> +{{else}} + Create a new workspace: + <form action="." method="POST" accept-charset="utf-8"> + <p><input type="submit" value="Create"></p> + </form> +{{end}} + </section> +</body> +</html> diff --git a/experimental/webtry/webtry.go b/experimental/webtry/webtry.go index 1b678ecb77..16f6f4ff22 100644 --- a/experimental/webtry/webtry.go +++ b/experimental/webtry/webtry.go @@ -13,6 +13,7 @@ import ( htemplate "html/template" "io/ioutil" "log" + "math/rand" "net/http" "os" "os/exec" @@ -45,9 +46,12 @@ var ( // indexTemplate is the main index.html page we serve. indexTemplate *htemplate.Template = nil - // recentTemplate is a list of recent images. + // 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 @@ -56,6 +60,35 @@ var ( // imageLink is the regex that matches URLs paths that are direct links to PNGs. imageLink = regexp.MustCompile("^/i/([a-f0-9]+.png)$") + + // 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", + } ) // flags @@ -90,12 +123,24 @@ func init() { panic(err) } // Convert index.html into a template, which is expanded with the code. - indexTemplate, err = htemplate.ParseFiles(filepath.Join(cwd, "templates/index.html")) + indexTemplate, err = htemplate.ParseFiles( + filepath.Join(cwd, "templates/index.html"), + filepath.Join(cwd, "templates/titlebar.html"), + ) if err != nil { panic(err) } - - recentTemplate, err = htemplate.ParseFiles(filepath.Join(cwd, "templates/recent.html")) + 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"), + ) if err != nil { panic(err) } @@ -123,6 +168,7 @@ func init() { 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 { @@ -135,8 +181,25 @@ func init() { hash CHAR(64) DEFAULT '' NOT NULL, PRIMARY KEY(hash) )` - db.Exec(sql) - log.Printf("INFO: Failed to find metadata, unable to connect to MySQL server (Expected when running locally): %q\n", err) + _, 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) } } @@ -231,19 +294,28 @@ func reportError(w http.ResponseWriter, r *http.Request, err error, message stri w.Write(resp) } -func writeToDatabase(hash string, code string) { +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) + } + } } func cssHandler(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "css/webtry.css") } +func jsHandler(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "js/run.js") +} + // 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) @@ -294,6 +366,79 @@ func recentHandler(w http.ResponseWriter, r *http.Request) { } } +type Workspace struct { + Name string + Code string + Tries []Try +} + +// 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 { + 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 +} + +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 DESC ", 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 + if len(tries) == 0 { + code = DEFAULT_SAMPLE + } else { + code = getCode(tries[len(tries)-1].Hash) + } + if err := workspaceTemplate.Execute(w, Workspace{Tries: tries, Code: code, Name: name}); 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") @@ -305,6 +450,11 @@ func hasPreProcessor(code string) bool { return false } +type TryRequest struct { + Code string `json:"code"` + Name string `json:"name"` +} + // 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) @@ -340,18 +490,22 @@ func mainHandler(w http.ResponseWriter, r *http.Request) { reportError(w, r, err, "Code too large.") return } - code := string(buf.Bytes()) - if hasPreProcessor(code) { + request := TryRequest{} + if err := json.Unmarshal(buf.Bytes(), &request); err != nil { + reportError(w, r, err, "Coulnd't decode JSON.") + return + } + if hasPreProcessor(request.Code) { err := fmt.Errorf("Found preprocessor macro in code.") reportError(w, r, err, "Preprocessor macros aren't allowed.") return } - hash, err := expandCode(LineNumbers(code)) + hash, err := expandCode(LineNumbers(request.Code)) if err != nil { reportError(w, r, err, "Failed to write the code to compile.") return } - writeToDatabase(hash, code) + writeToDatabase(hash, request.Code, request.Name) message, err := doCmd(fmt.Sprintf(RESULT_COMPILE, hash, hash), true) if err != nil { reportError(w, r, err, "Failed to compile the code:\n"+message) @@ -403,8 +557,10 @@ func mainHandler(w http.ResponseWriter, r *http.Request) { func main() { flag.Parse() http.HandleFunc("/i/", imageHandler) + http.HandleFunc("/w/", workspaceHandler) http.HandleFunc("/recent/", recentHandler) http.HandleFunc("/css/", cssHandler) + http.HandleFunc("/js/", jsHandler) http.HandleFunc("/", mainHandler) log.Fatal(http.ListenAndServe(*port, nil)) } |