310 lines
8.4 KiB
Go
310 lines
8.4 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
"crypto/md5"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html"
|
|
"io"
|
|
"log"
|
|
"mime"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"path"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"text/template"
|
|
"time"
|
|
)
|
|
|
|
type ManifestArticle struct {
|
|
ID string `json:"id"`
|
|
BannerMain string `json:"banner_main"`
|
|
BannerList string `json:"banner_list"`
|
|
BannerDescription string `json:"banner_description"`
|
|
Date time.Time `json:"date"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
Author string `json:"author"`
|
|
Tags []string `json:"tags"`
|
|
}
|
|
|
|
type ManifestNode struct {
|
|
ID string `json:"id"`
|
|
Icon string `json:"icon"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
}
|
|
|
|
type Manifest struct {
|
|
Links []ManifestNode `json:"links"`
|
|
Authors []ManifestNode `json:"authors"`
|
|
Tags []ManifestNode `json:"tags"`
|
|
Articles []ManifestArticle `json:"articles"`
|
|
}
|
|
|
|
type CacheInfo struct {
|
|
HeaderETag string
|
|
HeaderContentType string
|
|
ContentPlainText []byte
|
|
ContentCompressed []byte
|
|
}
|
|
|
|
var (
|
|
MANIFEST Manifest
|
|
MANIFEST_RAW string
|
|
HTTP_ADDRESS = os.Getenv("HTTP_ADDRESS")
|
|
CACHE_VERSION = fmt.Sprintf("%X", time.Now().Unix())
|
|
)
|
|
|
|
func init() {
|
|
// Default Values
|
|
if HTTP_ADDRESS == "" {
|
|
HTTP_ADDRESS = "127.0.0.1:8080"
|
|
}
|
|
|
|
// Manifest Parsing
|
|
data, err := os.ReadFile("public/blog/manifest.json")
|
|
if err != nil {
|
|
log.Fatalln("Cannot Read Manifest JSON:", err)
|
|
}
|
|
if err := json.Unmarshal(data, &MANIFEST); err != nil {
|
|
log.Fatalln("Cannot Parse Manifest JSON:", err)
|
|
}
|
|
MANIFEST_RAW = string(data)
|
|
}
|
|
|
|
func main() {
|
|
// Startup Services
|
|
var stopCtx, stop = context.WithCancel(context.Background())
|
|
var stopWg sync.WaitGroup
|
|
go SetupHTTP(stopCtx, &stopWg)
|
|
|
|
// Await Shutdown Signal
|
|
cancel := make(chan os.Signal, 1)
|
|
signal.Notify(cancel, syscall.SIGINT, syscall.SIGTERM)
|
|
<-cancel
|
|
stop()
|
|
|
|
// Begin Shutdown Process
|
|
timeout, finish := context.WithTimeout(context.Background(), time.Minute)
|
|
defer finish()
|
|
go func() {
|
|
<-timeout.Done()
|
|
if timeout.Err() == context.DeadlineExceeded {
|
|
log.Fatalln("[main] Cleanup timeout! Exiting now.")
|
|
}
|
|
}()
|
|
stopWg.Wait()
|
|
log.Println("[main] All done, bye bye!")
|
|
os.Exit(0)
|
|
}
|
|
|
|
func SetupHTTP(stop context.Context, await *sync.WaitGroup) {
|
|
mux := http.NewServeMux()
|
|
loc := map[string]any{
|
|
"Version": CACHE_VERSION,
|
|
}
|
|
|
|
// Webpage Endpoints //
|
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/" {
|
|
ServeTemplate(w, r, "templates/home.html", loc)
|
|
return
|
|
}
|
|
if r.Method == http.MethodGet {
|
|
ServeTemplate(w, r, "templates/404.html", loc)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
})
|
|
mux.HandleFunc("/blog/{slug...}", func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
// Although this is a SPA, we still need to render
|
|
// the OG meta tags for SEO / Social Media Sites
|
|
|
|
var article *ManifestArticle
|
|
for _, art := range MANIFEST.Articles {
|
|
if strings.EqualFold(art.ID, r.PathValue("slug")) {
|
|
article = &art
|
|
break
|
|
}
|
|
}
|
|
ServeTemplate(w, r, "templates/blog.html", map[string]any{
|
|
"Version": CACHE_VERSION,
|
|
"ManifestRaw": html.EscapeString(MANIFEST_RAW),
|
|
"Manifest": MANIFEST,
|
|
"Article": article,
|
|
})
|
|
})
|
|
|
|
// Secret Endpoints //
|
|
mux.HandleFunc("/secret/sprites", func(w http.ResponseWriter, r *http.Request) {
|
|
ServeTemplate(w, r, "templates/secret_sprites.html", loc)
|
|
})
|
|
mux.HandleFunc("/secret/picross", func(w http.ResponseWriter, r *http.Request) {
|
|
ServeTemplate(w, r, "templates/secret_picross_board.html", loc)
|
|
})
|
|
mux.HandleFunc("/secret/picross/award", func(w http.ResponseWriter, r *http.Request) {
|
|
ServeTemplate(w, r, "templates/secret_picross_award.html", loc)
|
|
})
|
|
|
|
// Special Endpoints //
|
|
mux.HandleFunc("/blog/rss.xml", func(w http.ResponseWriter, r *http.Request) {
|
|
ServeTemplate(w, r, "templates/rss.xml", map[string]any{
|
|
"Version": CACHE_VERSION,
|
|
"Manifest": MANIFEST,
|
|
"Date": time.Now().UTC().Format(time.RFC1123),
|
|
"RFC1123": time.RFC1123,
|
|
})
|
|
})
|
|
mux.HandleFunc("/sitemap.xml", func(w http.ResponseWriter, r *http.Request) {
|
|
ServeTemplate(w, r, "templates/sitemap.xml", map[string]any{
|
|
"Version": CACHE_VERSION,
|
|
"Manifest": MANIFEST,
|
|
"Date": time.Now().UTC().Format("2006-01-02"),
|
|
"Locations": []string{
|
|
"https://panca.kz/",
|
|
"https://panca.kz/blog",
|
|
},
|
|
})
|
|
})
|
|
mux.HandleFunc("/qr", func(w http.ResponseWriter, r *http.Request) {
|
|
redirects := []string{
|
|
"https://www.youtube.com/watch?v=dQw4w9WgXcQ", // Risk Astley - Never Gonna Give You Up
|
|
"https://www.youtube.com/watch?v=DnmHG3mJv1o", // Masayoshi Takanaka - Tropic Birds
|
|
"https://www.youtube.com/watch?v=ZZCt92zLmLI", // Blur - Intermission
|
|
"https://www.twitch.tv/robcdee", // awesome sauce
|
|
"https://piapro.net/intl/en.html", // mikudayooo
|
|
}
|
|
index := int(time.Now().Unix()/60) % len(redirects) // rotate every minute
|
|
http.Redirect(w, r, redirects[index], http.StatusTemporaryRedirect)
|
|
})
|
|
|
|
// Resource Endpoints //
|
|
mux.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
|
|
ServeFile(w, r, "favicon.ico")
|
|
})
|
|
mux.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) {
|
|
ServeFile(w, r, "robots.txt")
|
|
})
|
|
mux.HandleFunc("/public/{path...}", func(w http.ResponseWriter, r *http.Request) {
|
|
ServeFile(w, r, path.Clean(r.PathValue("path")))
|
|
})
|
|
|
|
// Create HTTP Server //
|
|
svr := http.Server{
|
|
Handler: mux,
|
|
Addr: HTTP_ADDRESS,
|
|
MaxHeaderBytes: 4096,
|
|
IdleTimeout: 5 * time.Second,
|
|
ReadHeaderTimeout: 5 * time.Second,
|
|
WriteTimeout: 30 * time.Second,
|
|
ReadTimeout: 30 * time.Second,
|
|
}
|
|
|
|
await.Add(1)
|
|
go func() {
|
|
defer await.Done()
|
|
<-stop.Done()
|
|
svr.Shutdown(context.Background())
|
|
log.Println("[http] Cleaned up HTTP")
|
|
}()
|
|
|
|
log.Printf("[http] Listening @ %s\n", svr.Addr)
|
|
if err := svr.ListenAndServe(); err != http.ErrServerClosed {
|
|
log.Fatalln("[http] Listen Error:", err)
|
|
}
|
|
|
|
}
|
|
|
|
func ServeTemplate(w http.ResponseWriter, r *http.Request, filepath string, locals any) {
|
|
if r.Method != http.MethodGet {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
clientCacheKey := r.Header.Get("If-None-Match")
|
|
clientCompress := strings.Contains(r.Header.Get("Accept-Encoding"), "gzip")
|
|
|
|
// Render Template //
|
|
html := bytes.Buffer{}
|
|
tmpl, err := template.ParseFiles(filepath)
|
|
if err != nil {
|
|
log.Printf("[http] Cannot parse template '%s': %s\n", filepath, err)
|
|
return
|
|
}
|
|
if err := tmpl.Execute(&html, locals); err != nil {
|
|
log.Printf("[http] Cannot render template '%s': %s \n", filepath, err)
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Cache Validation //
|
|
serverCacheKey := fmt.Sprintf("%x", md5.Sum(html.Bytes()))
|
|
serverFileType := mime.TypeByExtension(path.Ext(filepath))
|
|
if strings.EqualFold(clientCacheKey, serverCacheKey) {
|
|
w.WriteHeader(http.StatusNotModified)
|
|
return
|
|
}
|
|
|
|
// Stream Contents //
|
|
w.Header().Set("ETag", serverCacheKey)
|
|
w.Header().Set("Content-Type", serverFileType)
|
|
// w.Header().Set("Cache-Control", "public, max-age=3600, must-revalidate")
|
|
|
|
if clientCompress {
|
|
w.Header().Set("Content-Encoding", "gzip")
|
|
pack := gzip.NewWriter(w)
|
|
pack.Write(html.Bytes())
|
|
pack.Close()
|
|
} else {
|
|
w.Write(html.Bytes())
|
|
}
|
|
}
|
|
|
|
func ServeFile(w http.ResponseWriter, r *http.Request, filepath string) {
|
|
if r.Method != http.MethodGet {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Open File //
|
|
file, err := os.Open("./public/" + filepath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
log.Printf("[http] Cannot open file '%s': %s\n", filepath, err)
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
// Skip Compression on Large/Media Files //
|
|
serverFileType := mime.TypeByExtension(path.Ext(filepath))
|
|
clientCompress := strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") &&
|
|
!strings.Contains(serverFileType, "image") &&
|
|
!strings.Contains(serverFileType, "audio") &&
|
|
!strings.Contains(serverFileType, "video")
|
|
|
|
// Stream Contents //
|
|
w.Header().Set("Content-Type", serverFileType)
|
|
w.Header().Set("X-Robots-Tag", "noindex")
|
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
|
|
if clientCompress {
|
|
w.Header().Set("Content-Encoding", "gzip")
|
|
pack := gzip.NewWriter(w)
|
|
io.Copy(pack, file)
|
|
pack.Close()
|
|
} else {
|
|
io.Copy(w, file)
|
|
}
|
|
}
|