214 lines
6.5 KiB
Go
214 lines
6.5 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"html"
|
|
"image"
|
|
"image/color"
|
|
"image/draw"
|
|
_ "image/gif"
|
|
_ "image/jpeg"
|
|
"image/png"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// artifactLocalPath maps an artifact URI emitted by the worker back into the
|
|
// configured artifact directory. It deliberately rejects traversal and files
|
|
// outside /artifacts/ so the public preview endpoint cannot become a generic
|
|
// file reader.
|
|
func artifactLocalPath(root, artifactURI string) (string, error) {
|
|
if strings.TrimSpace(root) == "" {
|
|
return "", fmt.Errorf("artifact storage disabled")
|
|
}
|
|
u, err := url.Parse(artifactURI)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
p := u.Path
|
|
const prefix = "/artifacts/"
|
|
i := strings.Index(p, prefix)
|
|
if i < 0 {
|
|
return "", fmt.Errorf("artifact URI outside artifact namespace")
|
|
}
|
|
rel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(p[i:], prefix)))
|
|
if rel == "." || rel == "" || filepath.IsAbs(rel) || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
|
return "", fmt.Errorf("invalid artifact path")
|
|
}
|
|
rootAbs, err := filepath.Abs(root)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
candidate := filepath.Join(rootAbs, rel)
|
|
candidateAbs, err := filepath.Abs(candidate)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
check, err := filepath.Rel(rootAbs, candidateAbs)
|
|
if err != nil || check == ".." || strings.HasPrefix(check, ".."+string(filepath.Separator)) {
|
|
return "", fmt.Errorf("artifact path escapes storage")
|
|
}
|
|
return candidateAbs, nil
|
|
}
|
|
|
|
func watermarkPreviewFile(path, label string) ([]byte, string, error) {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
if len(b) > 64<<20 {
|
|
return nil, "", fmt.Errorf("artifact too large for preview")
|
|
}
|
|
ext := strings.ToLower(filepath.Ext(path))
|
|
if ext == ".svg" || bytes.Contains(bytes.ToLower(b[:minInt(len(b), 512)]), []byte("<svg")) {
|
|
out, err := watermarkSVG(b, label)
|
|
return out, "image/svg+xml; charset=utf-8", err
|
|
}
|
|
img, _, err := image.Decode(bytes.NewReader(b))
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("decode preview image: %w", err)
|
|
}
|
|
bounds := img.Bounds()
|
|
if bounds.Dx() < 1 || bounds.Dy() < 1 {
|
|
return nil, "", fmt.Errorf("empty artifact image")
|
|
}
|
|
dst := image.NewNRGBA(bounds)
|
|
draw.Draw(dst, bounds, img, bounds.Min, draw.Src)
|
|
drawRasterWatermark(dst, label)
|
|
var out bytes.Buffer
|
|
if err := png.Encode(&out, dst); err != nil {
|
|
return nil, "", err
|
|
}
|
|
return out.Bytes(), "image/png", nil
|
|
}
|
|
|
|
func watermarkSVG(src []byte, label string) ([]byte, error) {
|
|
s := string(src)
|
|
i := strings.LastIndex(strings.ToLower(s), "</svg>")
|
|
if i < 0 {
|
|
return nil, fmt.Errorf("invalid svg artifact")
|
|
}
|
|
label = html.EscapeString(strings.ToUpper(strings.TrimSpace(label)))
|
|
if label == "" {
|
|
label = "NEURAL HUNT PREVIEW"
|
|
}
|
|
overlay := fmt.Sprintf(`<defs><pattern id="nh-preview-watermark" width="420" height="190" patternUnits="userSpaceOnUse" patternTransform="rotate(-24)"><text x="18" y="96" fill="white" fill-opacity="0.16" font-family="system-ui,sans-serif" font-size="28" font-weight="800" letter-spacing="5">%s</text></pattern></defs><rect x="0" y="0" width="100%%" height="100%%" fill="url(#nh-preview-watermark)" pointer-events="none"/><rect x="2%%" y="92%%" width="96%%" height="5%%" rx="12" fill="black" fill-opacity="0.30"/><text x="50%%" y="95.5%%" text-anchor="middle" fill="white" fill-opacity="0.72" font-family="system-ui,sans-serif" font-size="18" font-weight="800" letter-spacing="4">WATERMARKED LEADERBOARD PREVIEW</text>`, label)
|
|
return []byte(s[:i] + overlay + s[i:]), nil
|
|
}
|
|
|
|
var pixelFont = map[rune][7]string{
|
|
'A': {"01110", "10001", "10001", "11111", "10001", "10001", "10001"},
|
|
'E': {"11111", "10000", "10000", "11110", "10000", "10000", "11111"},
|
|
'H': {"10001", "10001", "10001", "11111", "10001", "10001", "10001"},
|
|
'I': {"11111", "00100", "00100", "00100", "00100", "00100", "11111"},
|
|
'L': {"10000", "10000", "10000", "10000", "10000", "10000", "11111"},
|
|
'N': {"10001", "11001", "10101", "10101", "10011", "10001", "10001"},
|
|
'P': {"11110", "10001", "10001", "11110", "10000", "10000", "10000"},
|
|
'R': {"11110", "10001", "10001", "11110", "10100", "10010", "10001"},
|
|
'T': {"11111", "00100", "00100", "00100", "00100", "00100", "00100"},
|
|
'U': {"10001", "10001", "10001", "10001", "10001", "10001", "01110"},
|
|
'V': {"10001", "10001", "10001", "10001", "10001", "01010", "00100"},
|
|
'W': {"10001", "10001", "10001", "10101", "10101", "10101", "01010"},
|
|
}
|
|
|
|
func drawRasterWatermark(img *image.NRGBA, label string) {
|
|
b := img.Bounds()
|
|
w, h := b.Dx(), b.Dy()
|
|
if w < 1 || h < 1 {
|
|
return
|
|
}
|
|
label = strings.ToUpper(strings.TrimSpace(label))
|
|
if label == "" {
|
|
label = "NEURAL HUNT PREVIEW"
|
|
}
|
|
// Keep the bitmap watermark large enough to survive thumbnail scaling.
|
|
scale := minInt(w, h) / 360
|
|
if scale < 1 {
|
|
scale = 1
|
|
}
|
|
if scale > 6 {
|
|
scale = 6
|
|
}
|
|
textW := pixelTextWidth(label, scale)
|
|
rowStep := 72 * scale
|
|
colStep := textW + 54*scale
|
|
for y, row := b.Min.Y+18*scale, 0; y < b.Max.Y; y, row = y+rowStep, row+1 {
|
|
offset := 0
|
|
if row%2 == 1 {
|
|
offset = -(colStep / 2)
|
|
}
|
|
for x := b.Min.X + offset; x < b.Max.X; x += colStep {
|
|
drawPixelText(img, x+scale, y+scale, label, scale, color.NRGBA{0, 0, 0, 72})
|
|
drawPixelText(img, x, y, label, scale, color.NRGBA{255, 255, 255, 48})
|
|
}
|
|
}
|
|
// Strong lower preview band so cropped screenshots still visibly carry a
|
|
// watermark. It contains a repeated NH glyph rather than metadata.
|
|
bandH := maxInt(16*scale, h/18)
|
|
bandY := b.Max.Y - bandH
|
|
draw.Draw(img, image.Rect(b.Min.X, bandY, b.Max.X, b.Max.Y), &image.Uniform{C: color.NRGBA{0, 0, 0, 92}}, image.Point{}, draw.Over)
|
|
for x := b.Min.X + 8*scale; x < b.Max.X; x += 40 * scale {
|
|
drawPixelText(img, x, bandY+4*scale, "NH", scale, color.NRGBA{255, 255, 255, 118})
|
|
}
|
|
}
|
|
|
|
func pixelTextWidth(text string, scale int) int {
|
|
if scale < 1 {
|
|
scale = 1
|
|
}
|
|
w := 0
|
|
for _, r := range text {
|
|
if r == ' ' {
|
|
w += 4 * scale
|
|
} else {
|
|
w += 6 * scale
|
|
}
|
|
}
|
|
return w
|
|
}
|
|
|
|
func drawPixelText(dst draw.Image, x, y int, text string, scale int, col color.Color) {
|
|
if scale < 1 {
|
|
scale = 1
|
|
}
|
|
cx := x
|
|
for _, r := range text {
|
|
if r == ' ' {
|
|
cx += 4 * scale
|
|
continue
|
|
}
|
|
glyph, ok := pixelFont[r]
|
|
if !ok {
|
|
cx += 6 * scale
|
|
continue
|
|
}
|
|
for gy, row := range glyph {
|
|
for gx, bit := range row {
|
|
if bit != '1' {
|
|
continue
|
|
}
|
|
rect := image.Rect(cx+gx*scale, y+gy*scale, cx+(gx+1)*scale, y+(gy+1)*scale)
|
|
draw.Draw(dst, rect, &image.Uniform{C: col}, image.Point{}, draw.Over)
|
|
}
|
|
}
|
|
cx += 6 * scale
|
|
}
|
|
}
|
|
|
|
func minInt(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func maxInt(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|