package main import ( _ "embed" "encoding/gob" "errors" "fmt" "html/template" "io" "log" "net/http" "os" "os/signal" "runtime/debug" "time" ) // File for persistent storage var DataFilename = "ideas.data" // Version number used for debugging compatibility with the .data file var Version string // layout for how the date should be output in html var DateLayout string = "02/01/2006 on 15:04" // template for ideas html var ( //go:embed t_idea/index.html ideas_html string //go:embed t_idea/edit.html idea_edit_html string ) var Ideas []Idea // ToDo's // - [ ] Create a Server out of this so you can run multiple instances // - [x] edit a post // - [x] Store ideas to a file (encoder/gob) // - [x] Change the date format printing // - [x] outsource removing the posts to a separate cli tool // - [x] Add a post // - [x] Remove a post // Represents an idea // CreatedAt is a formatted date string // out of 5 rating of the idea // formatted time string with DateLayout type Idea struct { Title string Text string Author string CreatedAt string } // Data passed to the ideas_html template type PageData struct { Ideas []Idea Error string } func GetVersion() string { buildinfo, ok := debug.ReadBuildInfo() if !ok { log.Fatalln("Could not read buildinfo to know package version.") } return buildinfo.Main.Version } func main() { Version = GetVersion() // If the .data file does not exist, create it in the cache directory { p := os.Getenv("XDG_CACHE_HOME") if p == "" { p = "." } DataFilename = p + "/" + DataFilename f, err := os.Open(DataFilename) if errors.Is(err, os.ErrNotExist) { // The file does not exist, so create one with only Version encoded log.Println("File does not exist, creating", DataFilename) f, err = os.Create(DataFilename) if err != nil { log.Fatalln("Error while creating data file:", err) } enc := gob.NewEncoder(f) if err := enc.Encode(Version); err != nil { log.Fatalln("Error while encoding Version:", err) } } else if err != nil { log.Fatalln("Error while opening data file:", err) } else { dec := gob.NewDecoder(f) // Check the version var v string if err := dec.Decode(&v); err != io.EOF && err != nil { log.Fatalln("Error while decoding version:", err) } if v != Version { log.Fatalf("Version mismatch for datafile@%s != package@%s\n", v, Version) } // Decode the data and import it into Ideas err = dec.Decode(&Ideas) if err != nil && err != io.EOF { log.Fatalln("Error while decoding ideas:", err) } log.Printf("Imported @%s: %d ideas\n", v, len(Ideas)) if err := f.Close(); err != nil { log.Fatalln("Error while closing file:", err) } } } // Handle SIGINT // Save all Ideas into the data file // NOTE (Luca): This system might seem dumb, but it only adds overhead at startup // and exit of the program which is fine. go func() { c := make(chan os.Signal, 1) signal.Notify(c, os.Interrupt) <-c f, err := os.Create(DataFilename) if err != nil { log.Fatalln(err) } defer f.Close() enc := gob.NewEncoder(f) if err := enc.Encode(Version); err != nil { log.Fatalln(err) } if err := enc.Encode(Ideas); err != nil { log.Fatalln("Error while saving ideas:", err) } log.Println("data saved.") os.Exit(0) }() // Handling http tmpl, err := template.New("ideas_html").Parse(ideas_html) if err != nil { log.Fatalln(err) } _, err = tmpl.New("edit").Parse(idea_edit_html) if err != nil { log.Fatalln(err) } // TODO (Luca): Make the app more interactive by using websockets instead, // such that adding or editing does not require to // refresh the page. // Another approach would be to use htmx? // mux := http.NewServeMux() fs := http.FileServer(http.Dir("assets/")) mux.Handle("/static/", http.StripPrefix("/static/", fs)) mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/ideas/", http.StatusMovedPermanently) }) mux.HandleFunc("GET /ideas/", func(w http.ResponseWriter, r *http.Request) { tmpl.Execute(w, PageData{Ideas, ""}) }) mux.HandleFunc("POST /idea/create/", func(w http.ResponseWriter, r *http.Request) { i := Idea{ Title: r.FormValue("title"), Author: r.FormValue("author"), CreatedAt: time.Now().Format(DateLayout), Text: r.FormValue("text"), } if i.Title == "" || i.Author == "" || i.Text == "" { tmpl.Execute(w, PageData{Ideas, "All fields are required"}) return } for _, v := range Ideas { if i.Title == v.Title { tmpl.Execute(w, PageData{Ideas, "An idea with title '" + v.Title + "' already exists!"}) return } } Ideas = append(Ideas, i) log.Println("Added new idea:", i.Title) http.Redirect(w, r, "/ideas/", http.StatusMovedPermanently) }) mux.HandleFunc("GET /idea/edit/", func(w http.ResponseWriter, r *http.Request) { t := r.URL.Query().Get("t") if t == "" { tmpl.Execute(w, PageData{Ideas, "You must provide a title."}) return } for _, i := range Ideas { if i.Title == t { tmpl.ExecuteTemplate(w, "edit", i) return } } tmpl.Execute(w, PageData{Ideas, "No idea with title '" + t + "'."}) }) mux.HandleFunc("POST /idea/edit/", func(w http.ResponseWriter, r *http.Request) { t := r.FormValue("title") if t == "" { tmpl.Execute(w, PageData{Ideas, "You must provide a title."}) return } var i *Idea for j := range Ideas { if Ideas[j].Title == t { i = &Ideas[j] break } } if i.Title == "" { tmpl.Execute(w, PageData{Ideas, "No idea with title '" + t + "'."}) } i.Title = r.FormValue("title") i.Text = r.FormValue("text") log.Printf("Edited '%s'\n", i.Title) http.Redirect(w, r, "/ideas/", http.StatusMovedPermanently) }) mux.HandleFunc("POST /idea/delete/", func(w http.ResponseWriter, r *http.Request) { t := r.FormValue("title") if t == "" { tmpl.Execute(w, PageData{Ideas, "You must provide a title."}) return } for i, v := range Ideas { if t == v.Title { log.Println("Deleted:", v.Title) Ideas = append(Ideas[:i], Ideas[i+1:]...) http.Redirect(w, r, "/ideas/", http.StatusMovedPermanently) return } } tmpl.Execute(w, PageData{Ideas, "No idea with title '" + t + "'."}) }) mux.HandleFunc("POST /comment/create/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Not implemented yet.") }) log.Println("Listening on http://localhost:8080/ideas") err = http.ListenAndServe(":8080", mux) if err != nil { log.Fatalln(err) } }