home to your local SPACEGIRL 馃挮
arimelody.space
1package main
2
3import (
4 "bufio"
5 "embed"
6 "errors"
7 "fmt"
8 stdLog "log"
9 "math"
10 "math/rand"
11 "net"
12 "net/http"
13 "os"
14 "path/filepath"
15 "strconv"
16 "strings"
17 "time"
18
19 "arimelody-web/admin"
20 "arimelody-web/api"
21 "arimelody-web/colour"
22 "arimelody-web/controller"
23 "arimelody-web/cursor"
24 "arimelody-web/log"
25 "arimelody-web/model"
26 "arimelody-web/view"
27
28 "github.com/jmoiron/sqlx"
29 _ "github.com/lib/pq"
30 "golang.org/x/crypto/bcrypt"
31)
32
33// used for database migrations
34const DB_VERSION = 1
35
36const DEFAULT_PORT int64 = 8080
37const HRT_DATE int64 = 1756478697
38
39//go:embed "public"
40var publicFS embed.FS
41
42func main() {
43 fmt.Printf("made with <3 by ari melody\n\n")
44
45 app := model.AppState{
46 Config: controller.GetConfig(),
47 Twitch: nil,
48 PublicFS: publicFS,
49 }
50
51 // initialise database connection
52 if app.Config.DB.Host == "" {
53 fmt.Fprintf(os.Stderr, "FATAL: db.host not provided! Exiting...\n")
54 os.Exit(1)
55 }
56 if app.Config.DB.Name == "" {
57 fmt.Fprintf(os.Stderr, "FATAL: db.name not provided! Exiting...\n")
58 os.Exit(1)
59 }
60 if app.Config.DB.User == "" {
61 fmt.Fprintf(os.Stderr, "FATAL: db.user not provided! Exiting...\n")
62 os.Exit(1)
63 }
64 if app.Config.DB.Pass == "" {
65 fmt.Fprintf(os.Stderr, "FATAL: db.pass not provided! Exiting...\n")
66 os.Exit(1)
67 }
68
69 var err error
70 app.DB, err = sqlx.Connect(
71 "postgres",
72 fmt.Sprintf(
73 "host=%s port=%d user=%s dbname=%s password='%s' sslmode=disable",
74 app.Config.DB.Host,
75 app.Config.DB.Port,
76 app.Config.DB.User,
77 app.Config.DB.Name,
78 app.Config.DB.Pass,
79 ),
80 )
81 if err != nil {
82 fmt.Fprintf(os.Stderr, "FATAL: Unable to initialise database: %v\n", err)
83 os.Exit(1)
84 }
85 app.DB.SetConnMaxLifetime(time.Minute * 3)
86 app.DB.SetMaxOpenConns(10)
87 app.DB.SetMaxIdleConns(10)
88 defer app.DB.Close()
89
90 app.Log = log.Logger{ DB: app.DB }
91
92 // handle command arguments
93 if len(os.Args) > 1 {
94 arg := os.Args[1]
95
96 switch arg {
97 case "createTOTP":
98 if len(os.Args) < 4 {
99 fmt.Fprintf(os.Stderr, "FATAL: `username` and `name` must be specified for createTOTP.\n")
100 os.Exit(1)
101 }
102 username := os.Args[2]
103 totpName := os.Args[3]
104
105 account, err := controller.GetAccountByUsername(app.DB, username)
106 if err != nil {
107 fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
108 os.Exit(1)
109 }
110
111 if account == nil {
112 fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
113 os.Exit(1)
114 }
115
116 secret := controller.GenerateTOTPSecret(controller.TOTP_SECRET_LENGTH)
117 totp := model.TOTP {
118 AccountID: account.ID,
119 Name: totpName,
120 Secret: string(secret),
121 }
122
123 err = controller.CreateTOTP(app.DB, &totp)
124 if err != nil {
125 if strings.HasPrefix(err.Error(), "pq: duplicate key") {
126 fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" already has a TOTP method named \"%s\"!\n", account.Username, totp.Name)
127 os.Exit(1)
128 }
129 fmt.Fprintf(os.Stderr, "FATAL: Failed to create TOTP method: %v\n", err)
130 os.Exit(1)
131 }
132
133 app.Log.Info(log.TYPE_ACCOUNT, "TOTP method \"%s\" for \"%s\" created via config utility.", totp.Name, account.Username)
134 url := controller.GenerateTOTPURI(account.Username, totp.Secret)
135 fmt.Printf("%s\n", url)
136 return
137
138 case "deleteTOTP":
139 if len(os.Args) < 4 {
140 fmt.Fprintf(os.Stderr, "FATAL: `username` and `name` must be specified for deleteTOTP.\n")
141 os.Exit(1)
142 }
143 username := os.Args[2]
144 totpName := os.Args[3]
145
146 account, err := controller.GetAccountByUsername(app.DB, username)
147 if err != nil {
148 fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
149 os.Exit(1)
150 }
151
152 if account == nil {
153 fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
154 os.Exit(1)
155 }
156
157 err = controller.DeleteTOTP(app.DB, account.ID, totpName)
158 if err != nil {
159 fmt.Fprintf(os.Stderr, "FATAL: Failed to create TOTP method: %v\n", err)
160 os.Exit(1)
161 }
162
163 app.Log.Info(log.TYPE_ACCOUNT, "TOTP method \"%s\" for \"%s\" deleted via config utility.", totpName, account.Username)
164 fmt.Printf("TOTP method \"%s\" deleted.\n", totpName)
165 return
166
167 case "listTOTP":
168 if len(os.Args) < 3 {
169 fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for listTOTP.\n")
170 os.Exit(1)
171 }
172 username := os.Args[2]
173
174 account, err := controller.GetAccountByUsername(app.DB, username)
175 if err != nil {
176 fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
177 os.Exit(1)
178 }
179
180 if account == nil {
181 fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
182 os.Exit(1)
183 }
184
185 totps, err := controller.GetTOTPsForAccount(app.DB, account.ID)
186 if err != nil {
187 fmt.Fprintf(os.Stderr, "FATAL: Failed to create TOTP methods: %v\n", err)
188 os.Exit(1)
189 }
190
191 for i, totp := range totps {
192 fmt.Printf("%d. %s - Created %s\n", i + 1, totp.Name, totp.CreatedAt)
193 }
194 if len(totps) == 0 {
195 fmt.Printf("\"%s\" has no TOTP methods.\n", account.Username)
196 }
197 return
198
199 case "testTOTP":
200 if len(os.Args) < 4 {
201 fmt.Fprintf(os.Stderr, "FATAL: `username` and `name` must be specified for testTOTP.\n")
202 os.Exit(1)
203 }
204 username := os.Args[2]
205 totpName := os.Args[3]
206
207 account, err := controller.GetAccountByUsername(app.DB, username)
208 if err != nil {
209 fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
210 os.Exit(1)
211 }
212
213 if account == nil {
214 fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
215 os.Exit(1)
216 }
217
218 totp, err := controller.GetTOTP(app.DB, account.ID, totpName)
219 if err != nil {
220 fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch TOTP method \"%s\": %v\n", totpName, err)
221 os.Exit(1)
222 }
223
224 if totp == nil {
225 fmt.Fprintf(os.Stderr, "FATAL: TOTP method \"%s\" does not exist for account \"%s\"\n", totpName, username)
226 os.Exit(1)
227 }
228
229 code := controller.GenerateTOTP(totp.Secret, 0)
230 fmt.Printf("%s\n", code)
231 return
232
233 case "cleanTOTP":
234 err := controller.DeleteUnconfirmedTOTPs(app.DB)
235 if err != nil {
236 fmt.Fprintf(os.Stderr, "FATAL: Failed to clean up TOTP methods: %v\n", err)
237 os.Exit(1)
238 }
239 app.Log.Info(log.TYPE_ACCOUNT, "TOTP methods pruned via config utility.")
240 fmt.Printf("Cleaned up dangling TOTP methods successfully.\n")
241 return
242
243 case "createInvite":
244 fmt.Printf("Creating invite...\n")
245 invite, err := controller.CreateInvite(app.DB, 16, time.Hour * 24)
246 if err != nil {
247 fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite code: %v\n", err)
248 os.Exit(1)
249 }
250
251 app.Log.Info(log.TYPE_ACCOUNT, "Invite generted via config utility (%s).", invite.Code)
252 fmt.Printf(
253 "Here you go! This code expires in %d hours: %s\n",
254 int(math.Ceil(invite.ExpiresAt.Sub(invite.CreatedAt).Hours())),
255 invite.Code,
256 )
257 return
258
259 case "purgeInvites":
260 fmt.Printf("Deleting all invites...\n")
261 err := controller.DeleteAllInvites(app.DB)
262 if err != nil {
263 fmt.Fprintf(os.Stderr, "FATAL: Failed to delete invites: %v\n", err)
264 os.Exit(1)
265 }
266
267 app.Log.Info(log.TYPE_ACCOUNT, "Invites purged via config utility.")
268 fmt.Printf("Invites deleted successfully.\n")
269 return
270
271 case "listAccounts":
272 accounts, err := controller.GetAllAccounts(app.DB)
273 if err != nil {
274 fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch accounts: %v\n", err)
275 os.Exit(1)
276 }
277
278 for _, account := range accounts {
279 email := "<none>"
280 if account.Email.Valid { email = account.Email.String }
281 fmt.Printf(
282 "User: %s\n" +
283 "\tID: %s\n" +
284 "\tEmail: %s\n" +
285 "\tCreated: %s\n" +
286 "\tLocked: %t\n",
287 account.Username,
288 account.ID,
289 email,
290 account.CreatedAt,
291 account.Locked,
292 )
293 }
294 return
295
296 case "changePassword":
297 if len(os.Args) < 4 {
298 fmt.Fprintf(os.Stderr, "FATAL: `username` and `password` must be specified for changePassword\n")
299 os.Exit(1)
300 }
301
302 username := os.Args[2]
303 password := os.Args[3]
304 account, err := controller.GetAccountByUsername(app.DB, username)
305 if err != nil {
306 fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
307 os.Exit(1)
308 }
309 if account == nil {
310 fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
311 os.Exit(1)
312 }
313
314 hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
315 if err != nil {
316 fmt.Fprintf(os.Stderr, "FATAL: Failed to update password: %v\n", err)
317 os.Exit(1)
318 }
319 account.Password = string(hashedPassword)
320 err = controller.UpdateAccount(app.DB, account)
321 if err != nil {
322 fmt.Fprintf(os.Stderr, "FATAL: Failed to update password: %v\n", err)
323 os.Exit(1)
324 }
325
326 app.Log.Info(log.TYPE_ACCOUNT, "Password for '%s' updated via config utility.", account.Username)
327 fmt.Printf("Password for \"%s\" updated successfully.\n", account.Username)
328 return
329
330 case "deleteAccount":
331 if len(os.Args) < 3 {
332 fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for deleteAccount\n")
333 os.Exit(1)
334 }
335 username := os.Args[2]
336 fmt.Printf("Deleting account \"%s\"...\n", username)
337
338 account, err := controller.GetAccountByUsername(app.DB, username)
339 if err != nil {
340 fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
341 os.Exit(1)
342 }
343
344 if account == nil {
345 fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
346 os.Exit(1)
347 }
348
349 fmt.Printf("You are about to delete \"%s\". Are you sure? (y/[N]): ", account.Username)
350 res := ""
351 fmt.Scanln(&res)
352 if !strings.HasPrefix(res, "y") {
353 return
354 }
355
356 err = controller.DeleteAccount(app.DB, account.ID)
357 if err != nil {
358 fmt.Fprintf(os.Stderr, "FATAL: Failed to delete account: %v\n", err)
359 os.Exit(1)
360 }
361
362 app.Log.Info(log.TYPE_ACCOUNT, "Account '%s' deleted via config utility.", account.Username)
363 fmt.Printf("Account \"%s\" deleted successfully.\n", account.Username)
364 return
365
366 case "lockAccount":
367 if len(os.Args) < 3 {
368 fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for lockAccount\n")
369 os.Exit(1)
370 }
371 username := os.Args[2]
372 fmt.Printf("Unlocking account \"%s\"...\n", username)
373
374 account, err := controller.GetAccountByUsername(app.DB, username)
375 if err != nil {
376 fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
377 os.Exit(1)
378 }
379
380 if account == nil {
381 fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
382 os.Exit(1)
383 }
384
385 err = controller.LockAccount(app.DB, account.ID)
386 if err != nil {
387 fmt.Fprintf(os.Stderr, "FATAL: Failed to lock account: %v\n", err)
388 os.Exit(1)
389 }
390
391 app.Log.Info(log.TYPE_ACCOUNT, "Account '%s' locked via config utility.", account.Username)
392 fmt.Printf("Account \"%s\" locked successfully.\n", account.Username)
393 return
394
395 case "unlockAccount":
396 if len(os.Args) < 3 {
397 fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for unlockAccount\n")
398 os.Exit(1)
399 }
400 username := os.Args[2]
401 fmt.Printf("Unlocking account \"%s\"...\n", username)
402
403 account, err := controller.GetAccountByUsername(app.DB, username)
404 if err != nil {
405 fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
406 os.Exit(1)
407 }
408
409 if account == nil {
410 fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
411 os.Exit(1)
412 }
413
414 err = controller.UnlockAccount(app.DB, account.ID)
415 if err != nil {
416 fmt.Fprintf(os.Stderr, "FATAL: Failed to unlock account: %v\n", err)
417 os.Exit(1)
418 }
419
420 app.Log.Info(log.TYPE_ACCOUNT, "Account '%s' unlocked via config utility.", account.Username)
421 fmt.Printf("Account \"%s\" unlocked successfully.\n", account.Username)
422 return
423
424 case "logs":
425 // TODO: add log search parameters
426 logs, err := app.Log.Search([]log.LogLevel{}, []string{}, "", 100, 0)
427 if err != nil {
428 fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch logs: %v\n", err)
429 os.Exit(1)
430 }
431 for _, item := range(logs) {
432 levelStr := ""
433 switch item.Level {
434 case log.LEVEL_INFO:
435 levelStr = "INFO"
436 case log.LEVEL_WARN:
437 levelStr = "WARN"
438 default:
439 levelStr = fmt.Sprintf("? (%d)", item.Level)
440 }
441 fmt.Printf("[%s] %s:\n\t[%s] %s: %s\n", item.CreatedAt.Format(time.UnixDate), item.ID, item.Type, levelStr, item.Content)
442 }
443 return
444 }
445
446 // command help
447 fmt.Print(
448 "Available commands:\n\n" +
449 "createTOTP <username> <name>:\n\tCreates a timed one-time passcode method.\n" +
450 "listTOTP <username>:\n\tLists an account's TOTP methods.\n" +
451 "deleteTOTP <username> <name>:\n\tDeletes an account's TOTP method.\n" +
452 "testTOTP <username> <name>:\n\tGenerates the code for an account's TOTP method.\n" +
453 "cleanTOTP:\n\tCleans up unconfirmed (dangling) TOTP methods.\n" +
454 "\n" +
455 "createInvite:\n\tCreates an invite code to register new accounts.\n" +
456 "purgeInvites:\n\tDeletes all available invite codes.\n" +
457 "listAccounts:\n\tLists all active accounts.\n",
458 "deleteAccount <username>:\n\tDeletes the account under `username`.\n",
459 "lockAccount <username>:\n\tLocks the account under `username`.\n",
460 "unlockAccount <username>:\n\tUnlocks the account under `username`.\n",
461 "logs:\n\tShows system logs.\n",
462 )
463 return
464 }
465
466 // handle DB migrations
467 controller.CheckDBVersionAndMigrate(app.DB)
468
469 if app.Config.Twitch != nil {
470 err = controller.TwitchSetup(&app)
471 if err != nil {
472 fmt.Fprintf(os.Stderr, "WARN: Failed to set up Twitch integration: %v\n", err)
473 }
474 }
475
476 // initial invite code
477 accountsCount := 0
478 err = app.DB.Get(&accountsCount, "SELECT count(*) FROM account")
479 if err != nil { panic(err) }
480 if accountsCount == 0 {
481 _, err := app.DB.Exec("DELETE FROM invite")
482 if err != nil {
483 fmt.Fprintf(os.Stderr, "FATAL: Failed to clear existing invite codes: %v\n", err)
484 os.Exit(1)
485 }
486
487 invite, err := controller.CreateInvite(app.DB, 16, time.Hour * 24)
488 if err != nil {
489 fmt.Fprintf(os.Stderr, "FATAL: Failed to create invite code: %v\n", err)
490 os.Exit(1)
491 }
492
493 fmt.Printf("No accounts exist! Generated invite code: %s\n", invite.Code)
494 }
495
496 // delete expired sessions
497 err = controller.DeleteExpiredSessions(app.DB)
498 if err != nil {
499 fmt.Fprintf(os.Stderr, "FATAL: Failed to clear expired sessions: %v\n", err)
500 os.Exit(1)
501 }
502
503 // delete expired invites
504 err = controller.DeleteExpiredInvites(app.DB)
505 if err != nil {
506 fmt.Fprintf(os.Stderr, "FATAL: Failed to clear expired invite codes: %v\n", err)
507 os.Exit(1)
508 }
509
510 // clean up unconfirmed TOTP methods
511 err = controller.DeleteUnconfirmedTOTPs(app.DB)
512 if err != nil {
513 fmt.Fprintf(os.Stderr, "FATAL: Failed to clean up unconfirmed TOTP methods: %v\n", err)
514 os.Exit(1)
515 }
516
517 go cursor.StartCursor(&app)
518
519 // start the web server!
520 mux := createServeMux(&app)
521 fmt.Printf("Now serving at http://%s:%d\n", app.Config.Host, app.Config.Port)
522 stdLog.Fatal(
523 http.ListenAndServe(fmt.Sprintf("%s:%d", app.Config.Host, app.Config.Port),
524 CheckRequest(&app, HTTPLog(DefaultHeaders(mux))),
525 ))
526}
527
528func createServeMux(app *model.AppState) *http.ServeMux {
529 mux := http.NewServeMux()
530
531 mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler(app)))
532 mux.Handle("/api/", http.StripPrefix("/api", api.Handler(app)))
533 mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler(app)))
534 mux.Handle("/uploads/", http.StripPrefix("/uploads", view.ServeFiles(filepath.Join(app.Config.DataDirectory, "uploads"))))
535 mux.Handle("/cursor-ws", cursor.Handler(app))
536 mux.Handle("/", view.IndexHandler(app))
537
538 return mux
539}
540
541var PoweredByStrings = []string{
542 "nerd rage",
543 "estrogen",
544 "your mother",
545 "awesome powers beyond comprehension",
546 "jared",
547 "the weight of my sins",
548 "the arc reactor",
549 "AA batteries",
550 "15 euro solar panel from ebay",
551 "magnets, how do they work",
552 "a fax machine",
553 "dell optiplex",
554 "a trans girl's nintendo wii",
555 "BASS",
556 "electricity, duh",
557 "seven hamsters in a big wheel",
558 "girls",
559 "mzungu hosting",
560 "golang",
561 "the state of the world right now",
562 "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1)",
563 "the good folks at aperture science",
564 "free2play CDs",
565 "aridoodle",
566 "the love of creating",
567 "not for the sake of art; not for the sake of money; we like painting naked people",
568 "30 billion dollars in VC funding",
569}
570
571func CheckRequest(app *model.AppState, next http.Handler) http.Handler {
572 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
573 // requests with empty user agents are considered suspicious.
574 // every browser supplies them; hell, even curl supplies them.
575 // i only ever see null user-agents paired with malicious requests,
576 // so i'm canning them altogether.
577 if len(r.Header.Get("User-Agent")) == 0 {
578 http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
579 return
580 }
581
582 // obviously .php requests these don't affect me, but these tend to be
583 // lazy wordpress intrusion attempts. if that's what you're about, i
584 // don't want you on my site.
585 if strings.HasSuffix(r.URL.Path, ".php") ||
586 strings.HasSuffix(r.URL.Path, ".php7") {
587 http.NotFound(w, r)
588 fmt.Fprintf(
589 os.Stderr,
590 "WARN: Suspicious activity blocked: {\"path\":\"%s\",\"address\":\"%s\"}\n",
591 r.URL.Path,
592 r.RemoteAddr,
593 )
594 return
595 }
596
597 next.ServeHTTP(w, r)
598 })
599}
600
601func DefaultHeaders(next http.Handler) http.Handler {
602 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
603 w.Header().Add("Server", "ari melody webbed site")
604 w.Header().Add("Do-Not-Stab", "1")
605 w.Header().Add("X-Clacks-Overhead", "GNU Terry Pratchett")
606 w.Header().Add("X-Hacker", "spare me please")
607 w.Header().Add("X-Robots-TXT", "'; DROP TABLE pages;")
608 w.Header().Add("X-Thinking-With", "Portals")
609 w.Header().Add(
610 "X-Powered-By",
611 PoweredByStrings[rand.Intn(len(PoweredByStrings))],
612 )
613 w.Header().Add(
614 "X-Days-Since-HRT",
615 fmt.Sprint(math.Round(time.Since(time.Unix(HRT_DATE, 0)).Hours() / 24)),
616 )
617 next.ServeHTTP(w, r)
618 })
619}
620
621type LoggingResponseWriter struct {
622 http.ResponseWriter
623 Status int
624}
625
626func (lrw *LoggingResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
627 hijack, ok := lrw.ResponseWriter.(http.Hijacker)
628 if !ok {
629 return nil, nil, errors.New("Server does not support hijacking\n")
630 }
631 return hijack.Hijack()
632}
633
634func (lrw *LoggingResponseWriter) WriteHeader(status int) {
635 lrw.Status = status
636 lrw.ResponseWriter.WriteHeader(status)
637}
638
639func HTTPLog(next http.Handler) http.Handler {
640 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
641 start := time.Now()
642
643 lrw := LoggingResponseWriter{w, http.StatusOK}
644
645 next.ServeHTTP(&lrw, r)
646
647 after := time.Now()
648 difference := (after.Nanosecond() - start.Nanosecond()) / 1_000_000
649 elapsed := "<1"
650 if difference >= 1 {
651 elapsed = strconv.Itoa(difference)
652 }
653
654 statusColour := colour.Reset
655
656 if lrw.Status - 600 <= 0 { statusColour = colour.Red }
657 if lrw.Status - 500 <= 0 { statusColour = colour.Yellow }
658 if lrw.Status - 400 <= 0 { statusColour = colour.White }
659 if lrw.Status - 300 <= 0 { statusColour = colour.Green }
660
661 fmt.Printf("[%s] %s %s - %s%d%s (%sms) (%s)\n",
662 after.Format(time.UnixDate),
663 r.Method,
664 r.URL.Path,
665 statusColour,
666 lrw.Status,
667 colour.Reset,
668 elapsed,
669 r.Header.Get("User-Agent"))
670 })
671}