Initial Release
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user