Files
asciiwebserv/main.go
T

346 lines
7.7 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.%s.%d.txt"
allowedUA = "curl"
maxImageDim = 2000
fallbackTag = "fallback"
truecolorTag = "tc"
forceFallbackEnv = "FORCE_FALLBACK"
)
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)
}
scanAndPreGen(defaultWidth)
refreshCachedPaths()
http.HandleFunc("/", rootHandler)
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
}
}
tag := chooseTag(ua)
cacheLock.RLock()
candidates := filteredCachedPaths(width, tag)
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, tag)
if err := generateAndSaveASCII(imgFile, cachePath, width, tag); err != nil {
http.Error(w, "failed to generate ascii\n", http.StatusInternalServerError)
return
}
cacheLock.Lock()
refreshCachedPaths()
candidates = filteredCachedPaths(width, tag)
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 chooseTag(ua string) string {
if os.Getenv(forceFallbackEnv) == "1" {
return fallbackTag
}
if isLikelyMac(ua) {
return fallbackTag
}
return truecolorTag
}
func isLikelyMac(ua string) bool {
if strings.Contains(ua, "darwin") || strings.Contains(ua, "macintosh") || strings.Contains(ua, "mac os") || strings.Contains(ua, "macos") || strings.Contains(ua, "iphone") || strings.Contains(ua, "ipad") {
return true
}
return false
}
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)
}
return nil
})
return files
}
func cacheFilePath(imagePath string, width int, tag string) 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, tag, width))
}
func scanAndPreGen(width int) {
images := listImageFiles()
for _, img := range images {
for _, tag := range []string{truecolorTag, fallbackTag} {
cache := cacheFilePath(img, width, tag)
if _, err := os.Stat(cache); err == nil {
continue
}
_ = generateAndSaveASCII(img, cache, width, tag)
}
}
}
func refreshCachedPaths() {
found := []string{}
_ = filepath.Walk(imgDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
if strings.Contains(path, ".ansi.") && strings.HasSuffix(path, ".txt") {
found = append(found, path)
}
return nil
})
cacheLock.Lock()
cachedPaths = found
cacheLock.Unlock()
}
func filteredCachedPaths(width int, tag string) []string {
suffix := fmt.Sprintf(cacheSuffixFmt, tag, 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, tag string) 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
}
var outStr string
if tag == truecolorTag {
outStr = ImageToANSI(img, width)
} else {
outStr = ImageToASCIIFallback(img, width)
}
if err := os.MkdirAll(filepath.Dir(cachePath), 0755); err != nil {
return err
}
if err := os.WriteFile(cachePath, []byte(outStr), 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()
}
func ImageToASCIIFallback(src image.Image, cols int) string {
b := src.Bounds()
origW := b.Dx()
origH := b.Dy()
if origW == 0 || origH == 0 {
return ""
}
ratio := 0.5
charRowsFloat := (float64(origH) / float64(origW)) * float64(cols) * ratio
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)
palette := []rune("MNHQ$OC?7>!:-;,. ")
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)
lum := (int(tr.R)+int(tr.G)+int(tr.B)+int(br.R)+int(br.G)+int(br.B))/6
idx := (lum * (len(palette) - 1)) / 255
ch := palette[idx]
out.WriteRune(ch)
out.WriteRune(ch)
}
out.WriteString("\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
}