diff options
Diffstat (limited to 'website.go')
| -rw-r--r-- | website.go | 205 |
1 files changed, 201 insertions, 4 deletions
@@ -1,15 +1,28 @@ package main import ( + "cmp" + "embed" "encoding/json" + "errors" "fmt" + "io/fs" + "log" "net/http" + "os" + "regexp" + "slices" "strings" "sync" + "time" "github.com/a-h/templ" + "gopkg.in/yaml.v3" ) +//go:embed public blogs +var embedded embed.FS + func Assert(ok bool) { if !ok { panic("assertion failure") @@ -128,6 +141,117 @@ func styleToCss(style Style) templ.SafeCSS { return templ.SafeCSS(b.String()) } +type Blog struct { + Slug string + Title string + Desc string + Date time.Time + Body string // raw markdown for rendering +} + +type frontmatter struct { + Title string `yaml:"title"` + Desc string `yaml:"description"` + Date time.Time `yaml:"date"` +} + +var datePrefix = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2})-(.+)\.md$`) + +func parseBlog(name, raw string) (Blog, error) { + raw = strings.ReplaceAll(raw, "\r\n", "\n") + body := raw + var frontmatter frontmatter + if rest, ok := strings.CutPrefix(raw, "---\n"); ok { + if head, after, found := strings.Cut(rest, "\n---\n"); found { + if err := yaml.Unmarshal([]byte(head), &frontmatter); err != nil { + return Blog{}, err + } + body = after + } + } + + slug := strings.TrimSuffix(name, ".md") + date := frontmatter.Date // frontmatter wins + if match := datePrefix.FindStringSubmatch(name); match != nil { + slug = match[2] + if date.IsZero() { + date, _ = time.Parse(time.DateOnly, match[1]) + } + } + if date.IsZero() { + return Blog{}, errors.New("no date in frontmatter or filename") + } + + title := frontmatter.Title + if title == "" { + title = slug + } + return Blog{ + Slug: slug, + Title: title, + Date: date, + Body: body, + Desc: frontmatter.Desc, + }, nil +} + +func LoadBlogs(fsys fs.FS) ([]Blog, error) { + entries, err := fs.ReadDir(fsys, ".") + if err != nil { + return nil, err + } + var blogs []Blog + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") || strings.HasPrefix(e.Name(), "_") { + continue + } + raw, err := fs.ReadFile(fsys, e.Name()) + if err != nil { + return nil, err + } + blog, err := parseBlog(e.Name(), string(raw)) + if err != nil { + return nil, fmt.Errorf("%s: %w", e.Name(), err) + } + blogs = append(blogs, blog) + } + slices.SortFunc(blogs, func(a, b Blog) int { // newest first + return cmp.Or(b.Date.Compare(a.Date), strings.Compare(a.Slug, b.Slug)) + }) + return blogs, nil +} + +func loadBlogs() ([]Blog, error) { + blogFS, err := fs.Sub(embedded, "blogs") + if err != nil { + return nil, err + } + return LoadBlogs(blogFS) +} + +var ( + devMode = os.Getenv("DEV") != "" + cachedBlogs []Blog +) + +func getBlogs() []Blog { + if devMode || cachedBlogs == nil { + var err error + cachedBlogs, err = loadBlogs() + if err != nil { + log.Println("reloading blogs failed:", err) + } + } + return cachedBlogs +} + +func latestBlog(blogs []Blog) *Blog { + if len(blogs) == 0 { + return nil + } + return &blogs[0] +} + type Project struct { Name string Type string @@ -193,10 +317,76 @@ func styleHandler(cookie string, lookup func(string) (map[string]string, bool)) } } +func notFoundHandler(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + NotFoundPage(currentStyle(r), r.URL.Path).Render(r.Context(), w) +} + +type MyUptime struct { + mu sync.RWMutex + Uptime string + LastUpdate time.Time +} + +var myUptime MyUptime + +// GetMyUptime returns the current status string (handles offline state) +func GetMyUptime() string { + myUptime.mu.RLock() + defer myUptime.mu.RUnlock() + + // If no ping received in 3 minutes, treat as offline + if time.Since(myUptime.LastUpdate) > 1*time.Minute { + return "Asleep" + } + + uptime := myUptime.Uptime + uptime, _ = strings.CutPrefix(uptime, "up ") + uptime = "Awake for " + uptime + + return uptime +} + +func handleUptimePing(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "uptime": GetMyUptime(), + }) + } else if r.Method == http.MethodPost { + if devMode == false { + secretToken := os.Getenv("MY_UPTIME_TOKEN") + if r.Header.Get("Authorization") != "Bearer "+secretToken { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + } + + var req struct { + Uptime string `json:"uptime"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + myUptime.mu.Lock() + myUptime.Uptime = req.Uptime + myUptime.LastUpdate = time.Now() + myUptime.mu.Unlock() + + w.WriteHeader(http.StatusOK) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } +} + func main() { carbonightCat := Theme{ primary: "#94e2d5", - secondary: "#b4befe", + // secondary: "#b4befe", + secondary: "#BB91F0", accent: "#74c7ec", extra: "#a6e3a1", text: "#cdd6f4", @@ -269,11 +459,14 @@ func main() { mux := http.NewServeMux() - fileServer := http.FileServer(http.Dir("./public")) - mux.Handle("GET /public/", http.StripPrefix("/public/", fileServer)) + mux.Handle("GET /public/", http.FileServerFS(embedded)) + mux.HandleFunc("GET /api/my-uptime", handleUptimePing) + mux.HandleFunc("POST /api/my-uptime", handleUptimePing) + + mux.HandleFunc("/", notFoundHandler) // matches everything mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { - HomePage(currentStyle(r)).Render(r.Context(), w) + HomePage(currentStyle(r), latestBlog(getBlogs())).Render(r.Context(), w) }) mux.HandleFunc("GET /skills", func(w http.ResponseWriter, r *http.Request) { @@ -284,6 +477,10 @@ func main() { ProjectsPage(currentStyle(r), projects).Render(r.Context(), w) }) + mux.HandleFunc("GET /blogs", func(w http.ResponseWriter, r *http.Request) { + BlogsPage(currentStyle(r), getBlogs()).Render(r.Context(), w) + }) + mux.HandleFunc("GET /theme/{name}", styleHandler("theme", func(n string) (map[string]string, bool) { theme, ok := GetTheme(n) return theme.Vars(), ok |
