Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(320)

Side by Side Diff: experimental/webtry/webtry.go

Issue 231853002: Cleaned up the start page, added wait cursor, disabled Run button while waiting for results. (Closed) Base URL: https://skia.googlesource.com/skia.git@master
Patch Set: first pass at workspaces Created 6 years, 8 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch
« no previous file with comments | « experimental/webtry/templates/workspace.html ('k') | no next file » | no next file with comments »
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
OLDNEW
1 package main 1 package main
2 2
3 import ( 3 import (
4 "bytes" 4 "bytes"
5 "crypto/md5" 5 "crypto/md5"
6 "database/sql" 6 "database/sql"
7 "encoding/base64" 7 "encoding/base64"
8 "encoding/json" 8 "encoding/json"
9 "flag" 9 "flag"
10 "fmt" 10 "fmt"
11 _ "github.com/go-sql-driver/mysql" 11 _ "github.com/go-sql-driver/mysql"
12 _ "github.com/mattn/go-sqlite3" 12 _ "github.com/mattn/go-sqlite3"
13 htemplate "html/template" 13 htemplate "html/template"
14 "io/ioutil" 14 "io/ioutil"
15 "log" 15 "log"
16 "math/rand"
16 "net/http" 17 "net/http"
17 "os" 18 "os"
18 "os/exec" 19 "os/exec"
19 "path/filepath" 20 "path/filepath"
20 "regexp" 21 "regexp"
21 "strings" 22 "strings"
22 "text/template" 23 "text/template"
23 "time" 24 "time"
24 ) 25 )
25 26
(...skipping 12 matching lines...) Expand all
38 MAX_TRY_SIZE = 64000 39 MAX_TRY_SIZE = 64000
39 ) 40 )
40 41
41 var ( 42 var (
42 // codeTemplate is the cpp code template the user's code is copied into. 43 // codeTemplate is the cpp code template the user's code is copied into.
43 codeTemplate *template.Template = nil 44 codeTemplate *template.Template = nil
44 45
45 // indexTemplate is the main index.html page we serve. 46 // indexTemplate is the main index.html page we serve.
46 indexTemplate *htemplate.Template = nil 47 indexTemplate *htemplate.Template = nil
47 48
48 » // recentTemplate is a list of recent images. 49 » // recentTemplate is a list of recent images.
49 recentTemplate *htemplate.Template = nil 50 recentTemplate *htemplate.Template = nil
50 51
52 // workspaceTemplate is the page for workspaces, a series of webtrys.
53 workspaceTemplate *htemplate.Template = nil
54
51 // db is the database, nil if we don't have an SQL database to store dat a into. 55 // db is the database, nil if we don't have an SQL database to store dat a into.
52 db *sql.DB = nil 56 db *sql.DB = nil
53 57
54 // directLink is the regex that matches URLs paths that are direct links . 58 // directLink is the regex that matches URLs paths that are direct links .
55 directLink = regexp.MustCompile("^/c/([a-f0-9]+)$") 59 directLink = regexp.MustCompile("^/c/([a-f0-9]+)$")
56 60
57 // imageLink is the regex that matches URLs paths that are direct links to PNGs. 61 // imageLink is the regex that matches URLs paths that are direct links to PNGs.
58 imageLink = regexp.MustCompile("^/i/([a-f0-9]+.png)$") 62 imageLink = regexp.MustCompile("^/i/([a-f0-9]+.png)$")
63
64 // workspaceLink is the regex that matches URLs paths for workspaces.
65 workspaceLink = regexp.MustCompile("^/w/([a-z0-9-]+)$")
66
67 workspaceNameAdj = []string{
68 "autumn", "hidden", "bitter", "misty", "silent", "empty", "dry", "dark",
69 "summer", "icy", "delicate", "quiet", "white", "cool", "spring", "winter",
70 "patient", "twilight", "dawn", "crimson", "wispy", "weathered", "blue",
71 "billowing", "broken", "cold", "damp", "falling", "frosty", "gre en",
72 "long", "late", "lingering", "bold", "little", "morning", "muddy ", "old",
73 "red", "rough", "still", "small", "sparkling", "throbbing", "shy ",
74 "wandering", "withered", "wild", "black", "young", "holy", "soli tary",
75 "fragrant", "aged", "snowy", "proud", "floral", "restless", "div ine",
76 "polished", "ancient", "purple", "lively", "nameless",
77 }
78
79 workspaceNameNoun = []string{
80 "waterfall", "river", "breeze", "moon", "rain", "wind", "sea", " morning",
81 "snow", "lake", "sunset", "pine", "shadow", "leaf", "dawn", "gli tter",
82 "forest", "hill", "cloud", "meadow", "sun", "glade", "bird", "br ook",
83 "butterfly", "bush", "dew", "dust", "field", "fire", "flower", " firefly",
84 "feather", "grass", "haze", "mountain", "night", "pond", "darkne ss",
85 "snowflake", "silence", "sound", "sky", "shape", "surf", "thunde r",
86 "violet", "water", "wildflower", "wave", "water", "resonance", " sun",
87 "wood", "dream", "cherry", "tree", "fog", "frost", "voice", "pap er",
88 "frog", "smoke", "star",
89 }
59 ) 90 )
60 91
61 // flags 92 // flags
62 var ( 93 var (
63 useChroot = flag.Bool("use_chroot", false, "Run the compiled code in the schroot jail.") 94 useChroot = flag.Bool("use_chroot", false, "Run the compiled code in the schroot jail.")
64 port = flag.String("port", ":8000", "HTTP service address (e.g., ': 8000')") 95 port = flag.String("port", ":8000", "HTTP service address (e.g., ': 8000')")
65 ) 96 )
66 97
67 // lineNumbers adds #line numbering to the user's code. 98 // lineNumbers adds #line numbering to the user's code.
68 func LineNumbers(c string) string { 99 func LineNumbers(c string) string {
(...skipping 24 matching lines...) Expand all
93 indexTemplate, err = htemplate.ParseFiles(filepath.Join(cwd, "templates/ index.html")) 124 indexTemplate, err = htemplate.ParseFiles(filepath.Join(cwd, "templates/ index.html"))
94 if err != nil { 125 if err != nil {
95 panic(err) 126 panic(err)
96 } 127 }
97 128
98 recentTemplate, err = htemplate.ParseFiles(filepath.Join(cwd, "templates /recent.html")) 129 recentTemplate, err = htemplate.ParseFiles(filepath.Join(cwd, "templates /recent.html"))
99 if err != nil { 130 if err != nil {
100 panic(err) 131 panic(err)
101 } 132 }
102 133
134 workspaceTemplate, err = htemplate.ParseFiles(filepath.Join(cwd, "templa tes/workspace.html"))
135 if err != nil {
136 panic(err)
137 }
138
103 // Connect to MySQL server. First, get the password from the metadata se rver. 139 // Connect to MySQL server. First, get the password from the metadata se rver.
104 // See https://developers.google.com/compute/docs/metadata#custom. 140 // See https://developers.google.com/compute/docs/metadata#custom.
105 req, err := http.NewRequest("GET", "http://metadata/computeMetadata/v1/i nstance/attributes/password", nil) 141 req, err := http.NewRequest("GET", "http://metadata/computeMetadata/v1/i nstance/attributes/password", nil)
106 if err != nil { 142 if err != nil {
107 panic(err) 143 panic(err)
108 } 144 }
109 client := http.Client{} 145 client := http.Client{}
110 req.Header.Add("X-Google-Metadata-Request", "True") 146 req.Header.Add("X-Google-Metadata-Request", "True")
111 if resp, err := client.Do(req); err == nil { 147 if resp, err := client.Do(req); err == nil {
112 password, err := ioutil.ReadAll(resp.Body) 148 password, err := ioutil.ReadAll(resp.Body)
113 if err != nil { 149 if err != nil {
114 log.Printf("ERROR: Failed to read password from metadata server: %q\n", err) 150 log.Printf("ERROR: Failed to read password from metadata server: %q\n", err)
115 panic(err) 151 panic(err)
116 } 152 }
117 // The IP address of the database is found here: 153 // The IP address of the database is found here:
118 // https://console.developers.google.com/project/31977622648/ sql/instances/webtry/overview 154 // https://console.developers.google.com/project/31977622648/ sql/instances/webtry/overview
119 // And 3306 is the default port for MySQL. 155 // And 3306 is the default port for MySQL.
120 db, err = sql.Open("mysql", fmt.Sprintf("webtry:%s@tcp(173.194.8 3.52:3306)/webtry?parseTime=true", password)) 156 db, err = sql.Open("mysql", fmt.Sprintf("webtry:%s@tcp(173.194.8 3.52:3306)/webtry?parseTime=true", password))
121 if err != nil { 157 if err != nil {
122 log.Printf("ERROR: Failed to open connection to SQL serv er: %q\n", err) 158 log.Printf("ERROR: Failed to open connection to SQL serv er: %q\n", err)
123 panic(err) 159 panic(err)
124 } 160 }
125 } else { 161 } else {
162 log.Printf("INFO: Failed to find metadata, unable to connect to MySQL server (Expected when running locally): %q\n", err)
126 // Fallback to sqlite for local use. 163 // Fallback to sqlite for local use.
127 db, err = sql.Open("sqlite3", "./webtry.db") 164 db, err = sql.Open("sqlite3", "./webtry.db")
128 if err != nil { 165 if err != nil {
129 log.Printf("ERROR: Failed to open: %q\n", err) 166 log.Printf("ERROR: Failed to open: %q\n", err)
130 panic(err) 167 panic(err)
131 } 168 }
132 sql := `CREATE TABLE webtry ( 169 sql := `CREATE TABLE webtry (
133 code TEXT DEFAULT '' NOT NULL, 170 code TEXT DEFAULT '' NOT NULL,
134 create_ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, 171 create_ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
135 hash CHAR(64) DEFAULT '' NOT NULL, 172 hash CHAR(64) DEFAULT '' NOT NULL,
136 PRIMARY KEY(hash) 173 PRIMARY KEY(hash)
137 )` 174 )`
138 » » db.Exec(sql) 175 » » _, err = db.Exec(sql)
139 » » log.Printf("INFO: Failed to find metadata, unable to connect to MySQL server (Expected when running locally): %q\n", err) 176 » » log.Printf("Info: status creating sqlite table for webtry: %q\n" , err)
177 » » sql = `CREATE TABLE workspace (
178 name TEXT DEFAULT '' NOT NULL,
179 create_ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
180 PRIMARY KEY(name)
181 )`
182 » » _, err = db.Exec(sql)
183 » » log.Printf("Info: status creating sqlite table for workspace: %q \n", err)
184 » » sql = `CREATE TABLE workspacetry (
185 name TEXT DEFAULT '' NOT NULL,
186 create_ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
187 hash CHAR(64) DEFAULT '' NOT NULL,
188 hidden INTEGER DEFAULT 0 NOT NULL,
189 FOREIGN KEY (name) REFERENCES workspace(name)
190 )`
191 » » _, err = db.Exec(sql)
192 » » log.Printf("Info: status creating sqlite table for workspace try : %q\n", err)
140 } 193 }
141 } 194 }
142 195
143 // userCode is used in template expansion. 196 // userCode is used in template expansion.
144 type userCode struct { 197 type userCode struct {
145 UserCode string 198 UserCode string
146 } 199 }
147 200
148 // expandToFile expands the template and writes the result to the file. 201 // expandToFile expands the template and writes the result to the file.
149 func expandToFile(filename string, code string, t *template.Template) error { 202 func expandToFile(filename string, code string, t *template.Template) error {
(...skipping 74 matching lines...) Expand 10 before | Expand all | Expand 10 after
224 } 277 }
225 log.Printf("Error: %s\n%s", message, err.Error()) 278 log.Printf("Error: %s\n%s", message, err.Error())
226 resp, err := json.Marshal(m) 279 resp, err := json.Marshal(m)
227 if err != nil { 280 if err != nil {
228 http.Error(w, "Failed to serialize a response", 500) 281 http.Error(w, "Failed to serialize a response", 500)
229 return 282 return
230 } 283 }
231 w.Write(resp) 284 w.Write(resp)
232 } 285 }
233 286
234 func writeToDatabase(hash string, code string) { 287 func writeToDatabase(hash string, code string, workspaceName string) {
235 if db == nil { 288 if db == nil {
236 return 289 return
237 } 290 }
238 if _, err := db.Exec("INSERT INTO webtry (code, hash) VALUES(?, ?)", cod e, hash); err != nil { 291 if _, err := db.Exec("INSERT INTO webtry (code, hash) VALUES(?, ?)", cod e, hash); err != nil {
239 log.Printf("ERROR: Failed to insert code into database: %q\n", e rr) 292 log.Printf("ERROR: Failed to insert code into database: %q\n", e rr)
240 } 293 }
294 if workspaceName != "" {
295 if _, err := db.Exec("INSERT INTO workspacetry (name, hash) VALU ES(?, ?)", workspaceName, hash); err != nil {
296 log.Printf("ERROR: Failed to insert into workspacetry ta ble: %q\n", err)
297 }
298 }
241 } 299 }
242 300
243 func cssHandler(w http.ResponseWriter, r *http.Request) { 301 func cssHandler(w http.ResponseWriter, r *http.Request) {
244 http.ServeFile(w, r, "css/webtry.css") 302 http.ServeFile(w, r, "css/webtry.css")
245 } 303 }
246 304
247 // imageHandler serves up the PNG of a specific try. 305 // imageHandler serves up the PNG of a specific try.
248 func imageHandler(w http.ResponseWriter, r *http.Request) { 306 func imageHandler(w http.ResponseWriter, r *http.Request) {
249 log.Printf("Image Handler: %q\n", r.URL.Path) 307 log.Printf("Image Handler: %q\n", r.URL.Path)
250 if r.Method != "GET" { 308 if r.Method != "GET" {
(...skipping 36 matching lines...) Expand 10 before | Expand all | Expand 10 after
287 log.Printf("Error: failed to fetch from database: %q", e rr) 345 log.Printf("Error: failed to fetch from database: %q", e rr)
288 continue 346 continue
289 } 347 }
290 recent = append(recent, Try{Hash: hash, CreateTS: create_ts.Form at("2006-02-01")}) 348 recent = append(recent, Try{Hash: hash, CreateTS: create_ts.Form at("2006-02-01")})
291 } 349 }
292 if err := recentTemplate.Execute(w, Recent{Tries: recent}); err != nil { 350 if err := recentTemplate.Execute(w, Recent{Tries: recent}); err != nil {
293 log.Printf("ERROR: Failed to expand template: %q\n", err) 351 log.Printf("ERROR: Failed to expand template: %q\n", err)
294 } 352 }
295 } 353 }
296 354
355 type Workspace struct {
356 Name string
357 Code string
358 Tries []Try
359 }
360
361 func newWorkspace() (string, error) {
362 for i := 0; i < 10; i++ {
363 adj := workspaceNameAdj[rand.Intn(len(workspaceNameAdj))]
364 noun := workspaceNameNoun[rand.Intn(len(workspaceNameNoun))]
365 suffix := rand.Intn(1000)
366 name := fmt.Sprintf("%s-%s-%d", adj, noun, suffix)
367 if _, err := db.Exec("INSERT INTO workspace (name) VALUES(?)", n ame); err == nil {
368 return name, nil
369 } else {
370 log.Printf("ERROR: Failed to insert workspace into datab ase: %q\n", err)
371 }
372 }
373 return "", fmt.Errorf("Failed to create a new workspace")
374 }
375
376 func getCode(hash string) string {
377 code := ""
378 if err := db.QueryRow("SELECT code FROM webtry WHERE hash=?", hash).Scan (&code); err != nil {
379 log.Printf("ERROR: Code for hash is missing: %q\n", err)
380 }
381 return code
382 }
383
384 func workspaceHandler(w http.ResponseWriter, r *http.Request) {
385 // POST creates a new workspace and redirect to the newly created worksp ace.
386 // GET w/o a namespace name just gets the 'create' page.
387 // GET w/name gets that workspace doc, which is just a list of tries.
388 // What's the difference? The 'create' flow has a 'Create' button
389 // and no code. A workspace has a 'Run' button
390 // and shows the code for the most recent try. Also includes a list
391 // of all the tries in this workspace with a fingernail for each one.
392 // Run is actually handled by a POST to /.
393 log.Printf("Workspace Handler: %q\n", r.URL.Path)
394 if r.Method == "GET" {
395 tries := []Try{}
396 match := workspaceLink.FindStringSubmatch(r.URL.Path)
397 name := ""
398 if len(match) == 2 {
399 log.Printf("Got a valid match %q\n", match)
400 name = match[1]
401 // Load all the tries for this workspace.
402 rows, err := db.Query("SELECT create_ts, hash FROM works pacetry WHERE name=? ORDER BY create_ts DESC ", name)
403 if err != nil {
404 reportError(w, r, err, "Failed to select.")
405 return
406 }
407 for rows.Next() {
408 var hash string
409 var create_ts time.Time
410 if err := rows.Scan(&create_ts, &hash); err != n il {
411 log.Printf("Error: failed to fetch from database: %q", err)
412 continue
413 }
414 tries = append(tries, Try{Hash: hash, CreateTS: create_ts.Format("2006-02-01")})
415 }
416 }
417 var code string
418 if len(tries) == 0 {
419 code = DEFAULT_SAMPLE
420 } else {
421 code = getCode(tries[len(tries)-1].Hash)
422 }
423 // If len(tries) == 0 then return the 'default code', otherwise include the most recent code.
424 // We don't worry about existence of a workspace with this name since trying to
425 // add a new try will fail.
426
427 if err := workspaceTemplate.Execute(w, Workspace{Tries: tries, C ode: code, Name: name}); err != nil {
428 log.Printf("ERROR: Failed to expand template: %q\n", err )
429 }
430 // Update POST to / so that it takes a JSON document that includ es not only
431 // the code but an optional 'name' parameter. If the name is pre sent the
432 // POST handler should also add a row to the workspacetry table.
433 } else if r.Method == "POST" {
434 // Create a name and try to insert it into the db.
435 // Redirect to /w/<name>
436 if _, err := db.Exec("INSERT INTO workspace (name) VALUES(?)", " autumn-breeze-123"); err != nil {
437 log.Printf("ERROR: Failed to insert workspace into datab ase: %q\n", err)
438 }
439 name, err := newWorkspace()
440 if err != nil {
441 http.Error(w, "Failed to create a new workspace.", 500)
442 return
443 }
444 http.Redirect(w, r, "/w/"+name, 302)
445 }
446 }
447
297 // hasPreProcessor returns true if any line in the code begins with a # char. 448 // hasPreProcessor returns true if any line in the code begins with a # char.
298 func hasPreProcessor(code string) bool { 449 func hasPreProcessor(code string) bool {
299 lines := strings.Split(code, "\n") 450 lines := strings.Split(code, "\n")
300 for _, s := range lines { 451 for _, s := range lines {
301 if strings.HasPrefix(strings.TrimSpace(s), "#") { 452 if strings.HasPrefix(strings.TrimSpace(s), "#") {
302 return true 453 return true
303 } 454 }
304 } 455 }
305 return false 456 return false
306 } 457 }
307 458
459 type TryRequest struct {
460 Code string `json:"code"`
461 Name string `json:"name"`
462 }
463
308 // mainHandler handles the GET and POST of the main page. 464 // mainHandler handles the GET and POST of the main page.
309 func mainHandler(w http.ResponseWriter, r *http.Request) { 465 func mainHandler(w http.ResponseWriter, r *http.Request) {
310 log.Printf("Main Handler: %q\n", r.URL.Path) 466 log.Printf("Main Handler: %q\n", r.URL.Path)
311 if r.Method == "GET" { 467 if r.Method == "GET" {
312 code := DEFAULT_SAMPLE 468 code := DEFAULT_SAMPLE
313 match := directLink.FindStringSubmatch(r.URL.Path) 469 match := directLink.FindStringSubmatch(r.URL.Path)
314 if len(match) == 2 && r.URL.Path != "/" { 470 if len(match) == 2 && r.URL.Path != "/" {
315 hash := match[1] 471 hash := match[1]
316 if db == nil { 472 if db == nil {
317 http.NotFound(w, r) 473 http.NotFound(w, r)
(...skipping 15 matching lines...) Expand all
333 n, err := buf.ReadFrom(r.Body) 489 n, err := buf.ReadFrom(r.Body)
334 if err != nil { 490 if err != nil {
335 reportError(w, r, err, "Failed to read a request body.") 491 reportError(w, r, err, "Failed to read a request body.")
336 return 492 return
337 } 493 }
338 if n == MAX_TRY_SIZE { 494 if n == MAX_TRY_SIZE {
339 err := fmt.Errorf("Code length equal to, or exceeded, %d ", MAX_TRY_SIZE) 495 err := fmt.Errorf("Code length equal to, or exceeded, %d ", MAX_TRY_SIZE)
340 reportError(w, r, err, "Code too large.") 496 reportError(w, r, err, "Code too large.")
341 return 497 return
342 } 498 }
343 » » code := string(buf.Bytes()) 499 » » request := TryRequest{}
344 » » if hasPreProcessor(code) { 500 » » if err := json.Unmarshal(buf.Bytes(), &request); err != nil {
501 » » » reportError(w, r, err, "Coulnd't decode JSON.")
502 » » » return
503 » » }
504 » » if hasPreProcessor(request.Code) {
345 err := fmt.Errorf("Found preprocessor macro in code.") 505 err := fmt.Errorf("Found preprocessor macro in code.")
346 reportError(w, r, err, "Preprocessor macros aren't allow ed.") 506 reportError(w, r, err, "Preprocessor macros aren't allow ed.")
347 return 507 return
348 } 508 }
349 » » hash, err := expandCode(LineNumbers(code)) 509 » » hash, err := expandCode(LineNumbers(request.Code))
350 if err != nil { 510 if err != nil {
351 reportError(w, r, err, "Failed to write the code to comp ile.") 511 reportError(w, r, err, "Failed to write the code to comp ile.")
352 return 512 return
353 } 513 }
354 » » writeToDatabase(hash, code) 514 » » writeToDatabase(hash, request.Code, request.Name)
355 message, err := doCmd(fmt.Sprintf(RESULT_COMPILE, hash, hash), t rue) 515 message, err := doCmd(fmt.Sprintf(RESULT_COMPILE, hash, hash), t rue)
356 if err != nil { 516 if err != nil {
357 reportError(w, r, err, "Failed to compile the code:\n"+m essage) 517 reportError(w, r, err, "Failed to compile the code:\n"+m essage)
358 return 518 return
359 } 519 }
360 linkMessage, err := doCmd(fmt.Sprintf(LINK, hash, hash), true) 520 linkMessage, err := doCmd(fmt.Sprintf(LINK, hash, hash), true)
361 if err != nil { 521 if err != nil {
362 reportError(w, r, err, "Failed to link the code:\n"+link Message) 522 reportError(w, r, err, "Failed to link the code:\n"+link Message)
363 return 523 return
364 } 524 }
(...skipping 31 matching lines...) Expand 10 before | Expand all | Expand 10 after
396 reportError(w, r, err, "Failed to serialize a response." ) 556 reportError(w, r, err, "Failed to serialize a response." )
397 return 557 return
398 } 558 }
399 w.Write(resp) 559 w.Write(resp)
400 } 560 }
401 } 561 }
402 562
403 func main() { 563 func main() {
404 flag.Parse() 564 flag.Parse()
405 http.HandleFunc("/i/", imageHandler) 565 http.HandleFunc("/i/", imageHandler)
566 http.HandleFunc("/w/", workspaceHandler)
406 http.HandleFunc("/recent/", recentHandler) 567 http.HandleFunc("/recent/", recentHandler)
407 http.HandleFunc("/css/", cssHandler) 568 http.HandleFunc("/css/", cssHandler)
408 http.HandleFunc("/", mainHandler) 569 http.HandleFunc("/", mainHandler)
409 log.Fatal(http.ListenAndServe(*port, nil)) 570 log.Fatal(http.ListenAndServe(*port, nil))
410 } 571 }
OLDNEW
« no previous file with comments | « experimental/webtry/templates/workspace.html ('k') | no next file » | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698