Initial commit Made-with: Cursor
@@ -0,0 +1,7 @@+pic2html +pic2html.exe +*.test +*.out + +.DS_Store +Thumbs.db
@@ -0,0 +1,27 @@+# goASCII + +goASCII is a terminal-style web app by Pablo Murad. + +It renders images as colored ASCII art in the browser. + +## Features + +- built with Go +- default demo image at `/` +- remote image rendering with `/?url=https://example.com/image.png` +- terminal-inspired black interface + +## Run + +```bash +go run . +``` + +Open: + +- `http://localhost:8080/` +- `http://localhost:8080/?url=https://example.com/image.png` + +## Credit + +Original idea and earlier version by Patrik Roos.
@@ -0,0 +1,17 @@+ </td></tr> + </table> + + </td></tr> +</table> +<P> +Read the <A HREF="http://www.text-image.com/help.html">help page</A> for information on how to copy the text image. +<P> +<P> + +<table width="70%"> +<tr><td> +Please note that these images are not stored on the web server, so you cannot link to these pages expecting anyone to see the image you have converted. +</td><tr> +</table> +</body> +</html>
@@ -0,0 +1,17 @@+ </td></tr> + </table> + + </td></tr> +</table> +<P> +Read the <A HREF="http://www.text-image.com/help.html">help page</A> for information on how to copy the text image. +<P> +<P> + +<table width="70%"> +<tr><td> +Please note that these images are not stored on the web server, so you cannot link to these pages expecting anyone to see the image you have converted. +</td><tr> +</table> +</body> +</html>
@@ -0,0 +1,15 @@+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN"> +<html> +<head> +<META http-equiv="Content-Type" content="text/html; charset=UTF-8"> +<title>pic2html :: Result</title> +</head> + +<body> + +<center> +<p> +<table width="80%"> + <tr> + <td> + <h2>Result</h2>
@@ -0,0 +1,146 @@+# Como rodar o goASCII no Debian 12 + +Este guia mostra uma forma simples de rodar o site no Debian 12. + +## 1. Instalar dependências + +Atualize o sistema e instale os pacotes básicos: + +```bash +sudo apt update +sudo apt install -y git curl ca-certificates build-essential +``` + +## 2. Instalar Go + +O projeto usa Go moderno. No Debian 12, prefira usar Go 1.26 ou mais novo. + +Verifique sua versão atual: + +```bash +go version +``` + +Se o comando não existir, ou se a versão for antiga demais, instale uma versão atual do Go antes de continuar. + +Depois confirme novamente: + +```bash +go version +``` + +## 3. Entrar no projeto + +Se você já tiver o projeto na máquina: + +```bash +cd /caminho/para/goASCII1/pic2html +``` + +Se for clonar do seu repositório: + +```bash +git clone https://github.com/runawaydevil/goascii.git +cd goascii +``` + +## 4. Baixar dependências do Go + +```bash +go mod download +``` + +## 5. Testar o projeto + +```bash +go test ./... +``` + +## 6. Gerar o binário + +```bash +go build -o goascii +``` + +Isso vai gerar um executável chamado `goascii`. + +## 7. Rodar o site + +```bash +./goascii +``` + +Por padrão, o site sobe em: + +```text +http://localhost:8080/ +``` + +Para usar outra porta: + +```bash +PORT=3000 ./goascii +``` + +## 8. Testar no navegador + +Abra: + +```text +http://localhost:8080/ +``` + +Para renderizar uma imagem externa: + +```text +http://localhost:8080/?url=https://exemplo.com/imagem.png +``` + +## 9. Rodar em segundo plano com systemd + +Se quiser deixar o site ativo como serviço no Debian 12, crie um arquivo: + +```bash +sudo nano /etc/systemd/system/goascii.service +``` + +Conteúdo sugerido: + +```ini +[Unit] +Description=goASCII web app +After=network.target + +[Service] +Type=simple +User=www-data +WorkingDirectory=/caminho/para/goASCII1/pic2html +Environment=PORT=8080 +ExecStart=/caminho/para/goASCII1/pic2html/goascii +Restart=always +RestartSec=3 + +[Install] +WantedBy=multi-user.target +``` + +Depois rode: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable goascii +sudo systemctl start goascii +sudo systemctl status goascii +``` + +## 10. Ver logs + +```bash +sudo journalctl -u goascii -f +``` + +## Observações + +- O projeto serve a imagem demo local da pasta `img`. +- O parâmetro `?url=` aceita imagens públicas via `http` ou `https`. +- O site faz o download da imagem no servidor e aplica validações básicas de segurança.
A
go.mod
@@ -0,0 +1,5 @@+module pic2html + +go 1.26 + +require golang.org/x/image v0.31.0
A
go.sum
@@ -0,0 +1,2 @@+golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA= +golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA=
@@ -0,0 +1,84 @@+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN"> +<html> +<head> +<META http-equiv="Content-Type" content="text/html; charset=UTF-8"> +<title>pic2html :: Convert</title> +</head> + +<body> + +<center> + +<table> + <tr> + <td> + <h2>Convert into HTML</h2> + <form method="post" enctype="multipart/form-data" action="pic2html.cgi"> + <table cellspacing="8"> + <tr> + <td><label for="image">Image file: </label></td><td><input name="image" size="50" id="image" type="file"></td> + </tr> + + <tr> + <td><label for="characters">Characters: </label></td><td><input name="characters" value="01" id="characters" type="text"> <input checked="checked" name="textType" value="random" type="radio">-Random <input name="textType" value="sequence" type="radio">-Sequence</td> + </tr> + <tr> + <td><label for="fontsize">Font size: </label></td> + <td> + <select name="fontsize" id="fontsize"> + <option selected="selected" value="-3">Smallest</option> + <option value="-1">Small</option> + <option value="1">Normal</option> + <option value="3">Large</option> + <option value="7">Largest</option> + </select> + </td> + </tr> + <tr> + <td><label for="width">Image width: </label></td><td><input name="width" value="130" size="3" maxlength="3" id="width" type="text"> (1-500) characters</td> + </tr> + <tr> + <td><label for="grayscale">Show it in: </label></td> + <td> + <select name="grayscale" id="grayscale"> + <option selected="selected" value="0">Colour</option> + <option value="1">Grayscale</option> + <option value="2">Monochrome</option> + </select> + </td> + </tr> + <tr> + <td><label for="bgcolor">Background: </label></td><td><input name="bgcolor" value="BLACK" id="bgcolor" type="text"></td> + </tr> + <tr> + <td><label for="contrast">Extra contrast: </label></td> + <td> + <select name="contrast" id="contrast"> + <option selected="selected" value="0">No</option> + <option value="1">Yes</option> + </select> + </td> + </tr> + <tr> + <td><label for="browser">Web browser: </label></td> + <td> + <select name="browser" id="browser"> + <option value="ie">Internet Explorer</option> + <option selected="selected" value="firefox">Mozilla Firefox</option> + <option value="firefox">Other</option> + </select> + </td> + </tr> + <tr> + <td><br><input value="Convert!" type="submit"></td> + </tr> + </table> + </form> + <p><br>For help on using the converter, see the <a href="http://www.text-image.com/help.html">help page</a>. + </td> + </tr> +</table> + +</center> +</body> +</html>
@@ -0,0 +1,757 @@+package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "html" + "html/template" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" + "log" + "math/rand" + "net" + "net/http" + "net/netip" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + _ "golang.org/x/image/bmp" + _ "golang.org/x/image/tiff" +) + +const ( + defaultWidth = 130 + defaultCharacters = "01" + asciiHeightScale = 0.58 + maxImageBytes = 8 << 20 + maxRedirects = 4 + requestTimeout = 8 * time.Second +) + +type parameters struct { + bgcolor string + browser string + characters string + contrast int + fontsize string + grayscale int + texttype string + width int + + intensity string + textcolor string + invert int +} + +type pageData struct { + Title string + ASCII template.HTML + SourceLabel string + SourceURL string + StatusLine string + Error string +} + +func defaults() parameters { + return parameters{ + bgcolor: "BLACK", + browser: "firefox", + characters: defaultCharacters, + contrast: 0, + fontsize: "-3", + grayscale: 0, + texttype: "sequence", + width: defaultWidth, + intensity: " `.-:/+osyhdmNM", + invert: 0, + textcolor: "BLACK", + } +} + +func nextChar(kind, characters string) func() string { + chars := []rune(characters) + if len(chars) == 0 { + chars = []rune(defaultCharacters) + } + if kind == "random" { + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + return func() string { + return string(chars[rng.Intn(len(chars))]) + } + } + counter := 0 + return func() string { + i := counter % len(chars) + counter++ + return string(chars[i]) + } +} + +func convertImage(img image.Image, settings map[string]string) (template.HTML, error) { + p := defaults() + + if v := settings["bgcolor"]; v != "" { + p.bgcolor = v + } + if v := settings["browser"]; v != "" { + p.browser = v + } + if v := settings["characters"]; v != "" { + p.characters = v + } + if v := settings["contrast"]; v != "" { + p.contrast, _ = strconv.Atoi(v) + } + if v := settings["fontsize"]; v != "" { + p.fontsize = v + } + if v := settings["grayscale"]; v != "" { + p.grayscale, _ = strconv.Atoi(v) + } + if v := settings["textType"]; v != "" { + p.texttype = v + } + if v := settings["width"]; v != "" { + p.width, _ = strconv.Atoi(v) + if p.width < 1 || p.width > 500 { + p.width = defaultWidth + } + } + + if p.contrast != 0 { + img = applyContrast(img) + } + + height := int(float64(p.width) * (float64(img.Bounds().Dy()) / float64(img.Bounds().Dx())) * asciiHeightScale) + if height < 1 { + height = 1 + } + + resized, err := resize(img, p.width, height) + if err != nil { + return "", err + } + + m, ok := resized.(*image.RGBA) + if !ok { + return "", errors.New("failed to prepare image buffer") + } + + switch p.grayscale { + case 1: + grayscale(m) + case 2: + monochrome(m) + } + + var buffer bytes.Buffer + next := nextChar(p.texttype, p.characters) + b := m.Bounds() + + buffer.WriteString(`<div class="ascii-frame"><pre class="ascii-art">`) + for y := b.Min.Y; y < b.Max.Y; y++ { + j := m.PixOffset(0, y) + current := uint(m.Pix[j+0])<<16 | uint(m.Pix[j+1])<<8 | uint(m.Pix[j+2]) + buffer.WriteString(`<span style="color:#`) + buffer.WriteString(hex(current)) + buffer.WriteString(`">`) + buffer.WriteString(html.EscapeString(next())) + previous := current + + for x := b.Min.X + 1; x < b.Max.X; x++ { + i := m.PixOffset(x, y) + current = uint(m.Pix[i+0])<<16 | uint(m.Pix[i+1])<<8 | uint(m.Pix[i+2]) + if previous != current { + buffer.WriteString(`</span><span style="color:#`) + buffer.WriteString(hex(current)) + buffer.WriteString(`">`) + } + buffer.WriteString(html.EscapeString(next())) + previous = current + } + + buffer.WriteString("</span>\n") + } + buffer.WriteString("</pre></div>") + + return template.HTML(buffer.String()), nil +} + +func applyContrast(img image.Image) image.Image { + src, ok := img.(*image.RGBA) + if !ok { + cloned, err := resize(img, img.Bounds().Dx(), img.Bounds().Dy()) + if err != nil { + return img + } + src, _ = cloned.(*image.RGBA) + } + + if src == nil { + return img + } + + out := image.NewRGBA(src.Bounds()) + copy(out.Pix, src.Pix) + + for i := 0; i < len(out.Pix); i += 4 { + out.Pix[i+0] = contrastValue(out.Pix[i+0]) + out.Pix[i+1] = contrastValue(out.Pix[i+1]) + out.Pix[i+2] = contrastValue(out.Pix[i+2]) + } + + return out +} + +func contrastValue(v uint8) uint8 { + n := (float64(v) - 128.0) * 1.22 + n += 128.0 + if n < 0 { + return 0 + } + if n > 255 { + return 255 + } + return uint8(n) +} + +func main() { + logger := log.New(os.Stdout, "", log.LstdFlags) + app := newApp(logger) + logger.Printf("ASCII server listening on http://localhost:%s", app.port) + if err := http.ListenAndServe(":"+app.port, app.routes()); err != nil { + logger.Fatal(err) + } +} + +type app struct { + logger *log.Logger + httpClient *http.Client + port string + demoPath string +} + +func newApp(logger *log.Logger) *app { + transport := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: safeDialContext, + ForceAttemptHTTP2: true, + MaxIdleConns: 20, + IdleConnTimeout: 30 * time.Second, + TLSHandshakeTimeout: 5 * time.Second, + ResponseHeaderTimeout: 5 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + } + + client := &http.Client{ + Transport: transport, + Timeout: requestTimeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= maxRedirects { + return errors.New("too many redirects") + } + return validateRemoteURL(req.URL) + }, + } + + return &app{ + logger: logger, + httpClient: client, + port: envOrDefault("PORT", "8080"), + demoPath: filepath.Join("img", "cat.png"), + } +} + +func (a *app) routes() http.Handler { + mux := http.NewServeMux() + mux.Handle("/img/", http.StripPrefix("/img/", http.FileServer(http.Dir("img")))) + mux.HandleFunc("/", a.handleHome) + return mux +} + +func (a *app) handleHome(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + sourceURL := strings.TrimSpace(r.URL.Query().Get("url")) + start := time.Now() + + img, sourceLabel, sourceDisplay, err := a.loadImage(r.Context(), sourceURL) + if err != nil { + a.renderPage(w, http.StatusBadRequest, pageData{ + Title: "ASCII Terminal", + SourceLabel: "source:error", + SourceURL: sourceDisplay, + StatusLine: "status:error", + Error: err.Error(), + }) + return + } + + ascii, err := convertImage(img, map[string]string{ + "width": strconv.Itoa(defaultWidth), + "textType": "sequence", + "characters": defaultCharacters, + }) + if err != nil { + a.renderPage(w, http.StatusInternalServerError, pageData{ + Title: "ASCII Terminal", + SourceLabel: sourceLabel, + SourceURL: sourceDisplay, + StatusLine: "status:error", + Error: "could not render image as ASCII", + }) + return + } + + a.renderPage(w, http.StatusOK, pageData{ + Title: "ASCII Terminal", + ASCII: ascii, + SourceLabel: sourceLabel, + SourceURL: sourceDisplay, + StatusLine: fmt.Sprintf("status:rendered in %s", time.Since(start).Round(time.Millisecond)), + }) +} + +func (a *app) loadImage(ctx context.Context, sourceURL string) (image.Image, string, string, error) { + if sourceURL == "" { + f, err := os.Open(a.demoPath) + if err != nil { + return nil, "source: cat.png", "/img/cat.png", errors.New("demo image is unavailable") + } + defer f.Close() + + img, _, err := image.Decode(f) + if err != nil { + return nil, "source: cat.png", "/img/cat.png", errors.New("demo image is invalid") + } + return img, "source: cat.png", "/img/cat.png", nil + } + + u, err := url.Parse(sourceURL) + if err != nil { + return nil, "source: remote", sourceURL, errors.New("invalid image URL") + } + if err := validateRemoteURL(u); err != nil { + return nil, "source: remote", sourceURL, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, "source: remote", sourceURL, errors.New("invalid image request") + } + req.Header.Set("User-Agent", "ascii-terminal/1.0") + req.Header.Set("Accept", "image/*") + + resp, err := a.httpClient.Do(req) + if err != nil { + return nil, "source: remote", sourceURL, errors.New("failed to fetch remote image") + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, "source: remote", sourceURL, fmt.Errorf("image request returned %s", resp.Status) + } + + contentType := strings.ToLower(strings.TrimSpace(resp.Header.Get("Content-Type"))) + if contentType != "" && !strings.HasPrefix(contentType, "image/") { + return nil, "source: remote", sourceURL, errors.New("remote content is not an image") + } + + limited := io.LimitReader(resp.Body, maxImageBytes+1) + body, err := io.ReadAll(limited) + if err != nil { + return nil, "source: remote", sourceURL, errors.New("failed to read remote image") + } + if len(body) > maxImageBytes { + return nil, "source: remote", sourceURL, errors.New("remote image is too large") + } + + img, _, err := image.Decode(bytes.NewReader(body)) + if err != nil { + return nil, "source: remote", sourceURL, errors.New("failed to decode remote image") + } + + return img, "source: remote", sourceURL, nil +} + +func (a *app) renderPage(w http.ResponseWriter, status int, data pageData) { + if data.Title == "" { + data.Title = "ASCII Terminal" + } + if data.SourceLabel == "" { + data.SourceLabel = "source:unknown" + } + if data.SourceURL == "" { + data.SourceURL = "/img/cat.png" + } + if data.StatusLine == "" { + data.StatusLine = "status:idle" + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + + if err := pageTemplate.Execute(w, data); err != nil { + a.logger.Printf("render error: %v", err) + } +} + +func envOrDefault(key, fallback string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return fallback +} + +func validateRemoteURL(u *url.URL) error { + if u == nil { + return errors.New("invalid image URL") + } + if u.Scheme != "http" && u.Scheme != "https" { + return errors.New("image URL must use http or https") + } + if u.Host == "" { + return errors.New("image URL must include a host") + } + + host := strings.TrimSpace(u.Hostname()) + if host == "" { + return errors.New("image URL must include a host") + } + if isBlockedHostname(host) { + return errors.New("target host is not allowed") + } + + ips, err := net.DefaultResolver.LookupNetIP(context.Background(), "ip", host) + if err != nil { + return errors.New("failed to resolve image host") + } + if len(ips) == 0 { + return errors.New("failed to resolve image host") + } + for _, ip := range ips { + if !ip.IsValid() || isPrivateOrLocal(ip) { + return errors.New("target host is not allowed") + } + } + + return nil +} + +func isBlockedHostname(host string) bool { + host = strings.ToLower(strings.TrimSuffix(host, ".")) + if host == "localhost" || strings.HasSuffix(host, ".localhost") { + return true + } + + if addr, err := netip.ParseAddr(host); err == nil { + return isPrivateOrLocal(addr) + } + + return false +} + +func isPrivateOrLocal(ip netip.Addr) bool { + return ip.IsLoopback() || + ip.IsPrivate() || + ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || + ip.IsMulticast() || + ip.IsUnspecified() +} + +func safeDialContext(ctx context.Context, network, address string) (net.Conn, error) { + host, _, err := net.SplitHostPort(address) + if err != nil { + return nil, err + } + if isBlockedHostname(host) { + return nil, errors.New("blocked host") + } + + ips, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host) + if err != nil { + return nil, err + } + for _, ip := range ips { + if isPrivateOrLocal(ip) { + return nil, errors.New("blocked host") + } + } + + var d net.Dialer + return d.DialContext(ctx, network, address) +} + +var pageTemplate = template.Must(template.New("page").Parse(`<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>{{.Title}}</title> + <style> + :root { + --bg: #020202; + --panel: rgba(255, 255, 255, 0.04); + --line: rgba(255, 255, 255, 0.1); + --text: #f3f3ef; + --muted: #b7b7b0; + --error: #ffb4a8; + } + + * { box-sizing: border-box; } + + body { + margin: 0; + min-height: 100vh; + background: + radial-gradient(circle at top left, rgba(255,255,255,0.05), transparent 28rem), + linear-gradient(180deg, rgba(255,255,255,0.03), transparent 18rem), + var(--bg); + color: var(--text); + font-family: "IBM Plex Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + } + + .shell { + position: relative; + min-height: 100vh; + padding: 2rem 1.25rem 2rem; + } + + .meta { + margin-bottom: 1.25rem; + max-width: min(34rem, 100%); + color: var(--muted); + font-size: 0.82rem; + line-height: 1.55; + text-align: left; + white-space: pre-line; + } + + .terminal { + margin-bottom: 1.5rem; + color: var(--text); + font-size: 0.82rem; + line-height: 1.6; + max-width: min(42rem, 100%); + } + + .terminal-output { + white-space: pre-wrap; + word-break: break-word; + } + + .terminal-line { + display: inline-flex; + align-items: center; + gap: 0.08ch; + white-space: nowrap; + max-width: 100%; + position: relative; + cursor: text; + border: 0; + outline: none; + } + + .terminal-prompt { + color: var(--muted); + flex: 0 0 auto; + } + + .terminal-line:focus-visible { + outline: 1px solid rgba(255, 255, 255, 0.18); + outline-offset: 0.22rem; + } + + .terminal-current { + display: inline; + min-width: 0; + color: var(--text); + } + + .terminal-cursor { + display: inline-block; + width: 0.62ch; + height: 1.05em; + background: var(--text); + vertical-align: middle; + animation: blink 1s steps(1, end) infinite; + } + + .canvas { + width: max-content; + max-width: 100%; + margin: 0; + padding: 0; + overflow: auto; + } + + .ascii-frame { + display: inline-block; + } + + .ascii-art { + margin: 0; + color: var(--text); + font-size: clamp(0.34rem, 0.55vw, 0.6rem); + line-height: 1; + white-space: pre; + } + + .error { + color: var(--error); + font-size: 0.95rem; + line-height: 1.6; + white-space: pre-wrap; + } + + @media (max-width: 900px) { + .shell { + padding-top: 0.75rem; + } + + .meta { + text-align: left; + } + + .canvas { + margin-left: 0; + } + } + + @keyframes blink { + 0%, 49% { opacity: 1; } + 50%, 100% { opacity: 0; } + } + </style> +</head> +<body> + <main class="shell"> + <div class="meta">goASCII - developed by Pablo Murad - 2026 +{{.SourceLabel}} +{{.StatusLine}}</div> + + <section class="terminal" aria-label="terminal prompt"> + <div class="terminal-output" id="terminal-output">Type help for usage.</div> + <div id="terminal-line" class="terminal-line" tabindex="0" role="textbox" aria-label="terminal input" aria-multiline="false"> + <span class="terminal-prompt">guest@goascii:~$</span> + <span id="terminal-current" class="terminal-current"></span> + <span class="terminal-cursor" aria-hidden="true"></span> + </div> + </section> + + <section class="canvas" aria-live="polite"> + {{if .Error}} + <div class="error">{{.Error}} + +source: {{.SourceURL}}</div> + {{else}} + {{.ASCII}} + {{end}} + </section> + </main> + <script> + (() => { + const line = document.getElementById("terminal-line"); + const output = document.getElementById("terminal-output"); + const current = document.getElementById("terminal-current"); + if (!line || !output || !current) return; + + const promptLabel = "guest@goascii:~$"; + const helpText = [ + "goASCII help", + "", + "Render ASCII from the current page URL.", + "Append ?url=https://... to this site address.", + "Accepted sources: public http/https image URLs.", + "Example: /?url=https://example.com/cat.png", + "Available commands:", + " help Show this help message." + ].join("\n"); + let command = ""; + + const renderLines = (lines) => { + output.textContent = lines.join("\n\n"); + }; + + const syncCurrentValue = () => { + current.textContent = command; + }; + + const shouldIgnoreKey = (event) => { + if (event.ctrlKey || event.metaKey || event.altKey) return true; + const target = event.target; + if (!target) return false; + const tagName = target.tagName; + return tagName === "INPUT" || tagName === "TEXTAREA" || target.isContentEditable; + }; + + const handleCommand = (rawValue) => { + const value = rawValue.trim(); + if (!value) return; + + const lines = [promptLabel + " " + value]; + if (value.toLowerCase() === "help") { + lines.push(helpText); + } else { + lines.push("unknown command: " + value + "\nType help for usage."); + } + renderLines(lines); + }; + + const handleTerminalKeydown = (event) => { + if (shouldIgnoreKey(event)) return; + + if (event.key === "Enter") { + event.preventDefault(); + handleCommand(command); + command = ""; + syncCurrentValue(); + return; + } + + if (event.key === "Backspace") { + event.preventDefault(); + command = command.slice(0, -1); + syncCurrentValue(); + return; + } + + if (event.key === "Escape") { + event.preventDefault(); + command = ""; + syncCurrentValue(); + return; + } + + if (event.key.length === 1) { + event.preventDefault(); + command += event.key; + syncCurrentValue(); + } + }; + + document.addEventListener("keydown", handleTerminalKeydown); + document.addEventListener("pointerdown", () => line.focus()); + line.addEventListener("pointerdown", () => line.focus()); + window.addEventListener("load", () => { + syncCurrentValue(); + line.focus(); + }); + })(); + </script> +</body> +</html> +`))
@@ -0,0 +1,106 @@+package main + +import ( + "log" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestValidateRemoteURLRejectsBlockedTargets(t *testing.T) { + t.Parallel() + + cases := []string{ + "http://localhost/image.png", + "http://127.0.0.1/image.png", + "http://[::1]/image.png", + } + + for _, raw := range cases { + u, err := url.Parse(raw) + if err != nil { + t.Fatalf("parse %q: %v", raw, err) + } + if err := validateRemoteURL(u); err == nil { + t.Fatalf("expected %q to be blocked", raw) + } + } +} + +func TestValidateRemoteURLAcceptsPublicHTTPS(t *testing.T) { + t.Parallel() + + u, err := url.Parse("https://example.com/image.png") + if err != nil { + t.Fatal(err) + } + if err := validateRemoteURL(u); err != nil { + t.Fatalf("expected public URL to be valid: %v", err) + } +} + +func TestHandleHomeUsesDemoByDefault(t *testing.T) { + t.Parallel() + + app := newApp(log.New(os.Stdout, "", 0)) + app.demoPath = filepath.Join("img", "cat.png") + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + + app.handleHome(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, "goASCII - developed by Pablo Murad - 2026") { + t.Fatalf("expected header text in response") + } + if strings.Contains(body, "hint: append ?url=https://example.com/image.png") { + t.Fatalf("expected old hint to be removed") + } + if !strings.Contains(body, "source: cat.png") { + t.Fatalf("expected cat demo source in response") + } + if !strings.Contains(body, `id="terminal-line"`) { + t.Fatalf("expected terminal line markup in response") + } + if !strings.Contains(body, "Type help for usage.") { + t.Fatalf("expected help prompt text in response") + } +} + +func TestHandleHomeRejectsMalformedURL(t *testing.T) { + t.Parallel() + + app := newApp(log.New(os.Stdout, "", 0)) + req := httptest.NewRequest(http.MethodGet, "/?url=://bad-url", nil) + rec := httptest.NewRecorder() + + app.handleHome(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "invalid image URL") { + t.Fatalf("expected invalid URL error, got %q", rec.Body.String()) + } +} + +func TestLoadImageMarksRemoteSources(t *testing.T) { + t.Parallel() + + app := newApp(log.New(os.Stdout, "", 0)) + _, sourceLabel, _, err := app.loadImage(t.Context(), "https://example.invalid/cat.png") + if err == nil { + t.Fatalf("expected remote image lookup to fail in test") + } + if sourceLabel != "source: remote" { + t.Fatalf("expected remote source label, got %q", sourceLabel) + } +}
A
tools.go
@@ -0,0 +1,124 @@+package main + +import ( + "fmt" + "image" + "image/draw" +) + +func hex(num uint) string { + temp := []byte{'0', '0', '0', '0', '0', '0'} + + for counter := 0; num > 0; counter++ { + n := uint8(num & 0xF) + if n < 10 { + temp[5-counter] = '0' + n + } else { + temp[5-counter] = ('A' - 10) + n + } + num >>= 4 + } + + return string(temp) +} + +func gray(r, g, b uint8) uint8 { + return r/5 + uint8(uint(g)*7/10) + b/10 +} + +func grayscale(img image.Image) { + a := img.(*image.RGBA) + for i := a.PixOffset(0, 0); i < len(a.Pix); i += 4 { + g := gray(a.Pix[i], a.Pix[i+1], a.Pix[i+2]) + a.Pix[i] = g + a.Pix[i+1] = g + a.Pix[i+2] = g + } +} + +func monochrome(img image.Image) { + a := img.(*image.RGBA) + for i := a.PixOffset(0, 0); i < len(a.Pix); i += 4 { + g := gray(a.Pix[i], a.Pix[i+1], a.Pix[i+2]) / 128 * 255 + a.Pix[i] = g + a.Pix[i+1] = g + a.Pix[i+2] = g + } +} + +func resize(original image.Image, w, h int) (image.Image, error) { + src, ok := original.(*image.RGBA) + if !ok { + b := original.Bounds() + src = image.NewRGBA(b) + draw.Draw(src, b, original, b.Min, draw.Src) + } + + width := src.Bounds().Dx() + height := src.Bounds().Dy() + + if width < 1 || height < 1 { + return image.Image(src), fmt.Errorf("image dimensions invalid -- %v, %v", width, height) + } + + if h == 0 { + h = int(float32(w) / (float32(width) / float32(height))) + } + if w < 1 || h < 1 { + return image.Image(src), fmt.Errorf("resize values invalid -- %v, %v", w, h) + } + + dst := image.NewRGBA(image.Rect(0, 0, w, h)) + + xRatio := float32(width) / float32(w) + yRatio := float32(height) / float32(h) + + if width > w { + b := src.Bounds() + i := src.PixOffset(0, 0) + checklist := make([]bool, len(dst.Pix)>>2) + + for y := b.Min.Y; y < b.Max.Y; y++ { + oy := int(float32(y) / yRatio) + for x := b.Min.X; x < b.Max.X; x++ { + ox := int(float32(x) / xRatio) + o := dst.PixOffset(ox, oy) + + if !checklist[o>>2] { + checklist[o>>2] = true + dst.Pix[o+0] = src.Pix[i+0] + dst.Pix[o+1] = src.Pix[i+1] + dst.Pix[o+2] = src.Pix[i+2] + dst.Pix[o+3] = src.Pix[i+3] + } else { + dst.Pix[o+0] = uint8((uint64(dst.Pix[o+0]) + uint64(src.Pix[i+0])) >> 1) + dst.Pix[o+1] = uint8((uint64(dst.Pix[o+1]) + uint64(src.Pix[i+1])) >> 1) + dst.Pix[o+2] = uint8((uint64(dst.Pix[o+2]) + uint64(src.Pix[i+2])) >> 1) + dst.Pix[o+3] = uint8((uint64(dst.Pix[o+3]) + uint64(src.Pix[i+3])) >> 1) + } + + i += 4 + } + } + } else { + b := dst.Bounds() + i := dst.PixOffset(0, 0) + + for y := b.Min.Y; y < b.Max.Y; y++ { + oy := int(float32(y) * yRatio) + for x := b.Min.X; x < b.Max.X; x++ { + ox := int(float32(x) * xRatio) + o := src.PixOffset(ox, oy) + + dst.Pix[i+0] = src.Pix[o+0] + dst.Pix[i+1] = src.Pix[o+1] + dst.Pix[i+2] = src.Pix[o+2] + dst.Pix[i+3] = src.Pix[o+3] + + i += 4 + } + } + } + + return dst, nil +}