mirror of
https://codeberg.org/gigirassy/asciiwebserv
synced 2026-08-30 15:37:40 +00:00
289 lines
6.4 KiB
Go
289 lines
6.4 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"image"
|
|
_ "image/gif"
|
|
_ "image/jpeg"
|
|
_ "image/png"
|
|
"io"
|
|
"log"
|
|
"math/rand"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
xdraw "golang.org/x/image/draw"
|
|
)
|
|
|
|
const (
|
|
defaultListen = ":3333"
|
|
defaultDir = "./images"
|
|
defaultWidth = 80
|
|
cacheSuffixFmt = ".ansi.%d.txt"
|
|
allowedUA = "curl"
|
|
maxImageDim = 2000
|
|
)
|
|
|
|
var (
|
|
imgDir string
|
|
redirectURL string
|
|
cacheLock sync.RWMutex
|
|
cachedPaths []string
|
|
)
|
|
|
|
func main() {
|
|
rand.Seed(time.Now().UnixNano())
|
|
|
|
imgDir = getenv("IMAGES_DIR", defaultDir)
|
|
redirectURL = getenv("REDIRECT_URL", "https://example.com")
|
|
listen := getenv("LISTEN", defaultListen)
|
|
|
|
if err := os.MkdirAll(imgDir, 0755); err != nil {
|
|
log.Fatalf("failed to create images dir: %v", err)
|
|
}
|
|
|
|
log.Printf("images directory: %s", imgDir)
|
|
log.Printf("pre-generating ascii caches at width=%d ...", defaultWidth)
|
|
scanAndPreGen(defaultWidth)
|
|
refreshCachedPaths(defaultWidth)
|
|
|
|
http.HandleFunc("/", rootHandler)
|
|
|
|
log.Printf("listening on %s — only curl clients accepted; non-curl will be redirected to %s", listen, redirectURL)
|
|
if err := http.ListenAndServe(listen, nil); err != nil {
|
|
log.Fatalf("server failed: %v", err)
|
|
}
|
|
}
|
|
|
|
func getenv(k, fallback string) string {
|
|
v := os.Getenv(k)
|
|
if v == "" {
|
|
return fallback
|
|
}
|
|
return v
|
|
}
|
|
|
|
func rootHandler(w http.ResponseWriter, r *http.Request) {
|
|
ua := strings.ToLower(r.Header.Get("User-Agent"))
|
|
if !strings.Contains(ua, allowedUA) {
|
|
http.Redirect(w, r, redirectURL, http.StatusFound)
|
|
return
|
|
}
|
|
|
|
qw := r.URL.Query().Get("w")
|
|
width := defaultWidth
|
|
if qw != "" {
|
|
if n, err := strconv.Atoi(qw); err == nil && n > 10 && n <= 1000 {
|
|
width = n
|
|
}
|
|
}
|
|
|
|
cacheLock.RLock()
|
|
candidates := filteredCachedPaths(width)
|
|
cacheLock.RUnlock()
|
|
|
|
if len(candidates) == 0 {
|
|
images := listImageFiles()
|
|
if len(images) == 0 {
|
|
http.Error(w, "no images found in images directory\n", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
imgFile := images[rand.Intn(len(images))]
|
|
cachePath := cacheFilePath(imgFile, width)
|
|
|
|
log.Printf("generating cache for %s (width=%d) on demand", imgFile, width)
|
|
if err := generateAndSaveASCII(imgFile, cachePath, width); err != nil {
|
|
log.Printf("generation error: %v", err)
|
|
http.Error(w, "failed to generate ascii\n", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
cacheLock.Lock()
|
|
refreshCachedPaths(width)
|
|
candidates = filteredCachedPaths(width)
|
|
cacheLock.Unlock()
|
|
}
|
|
|
|
if len(candidates) == 0 {
|
|
http.Error(w, "no cached ascii available\n", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
chosen := candidates[rand.Intn(len(candidates))]
|
|
f, err := os.Open(chosen)
|
|
if err != nil {
|
|
http.Error(w, "failed to read ascii file\n", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer f.Close()
|
|
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
if _, err := io.Copy(w, f); err != nil {
|
|
log.Printf("write error: %v", err)
|
|
}
|
|
}
|
|
|
|
func listImageFiles() []string {
|
|
var files []string
|
|
_ = filepath.Walk(imgDir, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil || info.IsDir() {
|
|
return nil
|
|
}
|
|
ext := strings.ToLower(filepath.Ext(path))
|
|
switch ext {
|
|
case ".jpg", ".jpeg", ".png", ".gif", ".bmp":
|
|
files = append(files, path)
|
|
default:
|
|
}
|
|
return nil
|
|
})
|
|
return files
|
|
}
|
|
|
|
func cacheFilePath(imagePath string, width int) string {
|
|
dir := filepath.Dir(imagePath)
|
|
base := filepath.Base(imagePath)
|
|
ext := filepath.Ext(base)
|
|
name := strings.TrimSuffix(base, ext)
|
|
return filepath.Join(dir, fmt.Sprintf("%s"+cacheSuffixFmt, name, width))
|
|
}
|
|
|
|
func scanAndPreGen(width int) {
|
|
images := listImageFiles()
|
|
for _, img := range images {
|
|
cache := cacheFilePath(img, width)
|
|
if _, err := os.Stat(cache); err == nil {
|
|
continue
|
|
}
|
|
if err := generateAndSaveASCII(img, cache, width); err != nil {
|
|
log.Printf("failed to generate for %s: %v", img, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func refreshCachedPaths(width int) {
|
|
suffix := fmt.Sprintf(cacheSuffixFmt, width)
|
|
found := []string{}
|
|
_ = filepath.Walk(imgDir, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil || info.IsDir() {
|
|
return nil
|
|
}
|
|
if strings.HasSuffix(path, suffix) {
|
|
found = append(found, path)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
cacheLock.Lock()
|
|
cachedPaths = found
|
|
cacheLock.Unlock()
|
|
}
|
|
|
|
func filteredCachedPaths(width int) []string {
|
|
suffix := fmt.Sprintf(cacheSuffixFmt, width)
|
|
out := make([]string, 0, len(cachedPaths))
|
|
for _, p := range cachedPaths {
|
|
if strings.HasSuffix(p, suffix) {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func generateAndSaveASCII(imagePath, cachePath string, width int) error {
|
|
f, err := os.Open(imagePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
|
|
img, _, err := image.Decode(f)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
w0 := img.Bounds().Dx()
|
|
h0 := img.Bounds().Dy()
|
|
if w0 > maxImageDim || h0 > maxImageDim {
|
|
scale := float64(maxImageDim) / float64(max(w0, h0))
|
|
newW := int(float64(w0) * scale)
|
|
newH := int(float64(h0) * scale)
|
|
if newW < 1 {
|
|
newW = 1
|
|
}
|
|
if newH < 1 {
|
|
newH = 1
|
|
}
|
|
dst := image.NewRGBA(image.Rect(0, 0, newW, newH))
|
|
xdraw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, img.Bounds(), xdraw.Over, nil)
|
|
img = dst
|
|
}
|
|
|
|
ansi := ImageToANSI(img, width)
|
|
|
|
if err := os.MkdirAll(filepath.Dir(cachePath), 0755); err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(cachePath, []byte(ansi), 0644); err != nil {
|
|
return err
|
|
}
|
|
|
|
cacheLock.Lock()
|
|
cachedPaths = append(cachedPaths, cachePath)
|
|
cacheLock.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func ImageToANSI(src image.Image, cols int) string {
|
|
b := src.Bounds()
|
|
origW := b.Dx()
|
|
origH := b.Dy()
|
|
if origW == 0 || origH == 0 {
|
|
return ""
|
|
}
|
|
|
|
charRowsFloat := (float64(origH) / float64(origW)) * float64(cols) * 0.5
|
|
charRows := int(charRowsFloat)
|
|
if charRows < 1 {
|
|
charRows = 1
|
|
}
|
|
targetH := charRows * 2
|
|
targetW := cols
|
|
|
|
dst := image.NewRGBA(image.Rect(0, 0, targetW, targetH))
|
|
xdraw.ApproxBiLinear.Scale(dst, dst.Bounds(), src, src.Bounds(), xdraw.Over, nil)
|
|
|
|
var out bytes.Buffer
|
|
for y := 0; y < targetH; y += 2 {
|
|
for x := 0; x < targetW; x++ {
|
|
tr := colorAt(dst, x, y)
|
|
br := colorAt(dst, x, y+1)
|
|
out.WriteString(fmt.Sprintf("\x1b[38;2;%d;%d;%dm\x1b[48;2;%d;%d;%dm▀",
|
|
tr.R, tr.G, tr.B,
|
|
br.R, br.G, br.B))
|
|
}
|
|
out.WriteString("\x1b[0m\n")
|
|
}
|
|
return out.String()
|
|
}
|
|
|
|
type c8 struct{ R, G, B uint8 }
|
|
|
|
func colorAt(img *image.RGBA, x, y int) c8 {
|
|
r, g, b, _ := img.At(x, y).RGBA()
|
|
return c8{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8)}
|
|
}
|
|
|
|
func max(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
} |