@@ -0,0 +1,254 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"neuralhunt/internal/artifact"
|
||||
wsx "neuralhunt/internal/ws"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
const maxStyleReferenceBytes = 12 << 20
|
||||
|
||||
func (s *Server) adminCreateCharacterAnchor(w http.ResponseWriter, r *http.Request) {
|
||||
if s.artifactWorker == nil {
|
||||
jsonOut(w, http.StatusServiceUnavailable, map[string]string{"error": "artifact worker unavailable"})
|
||||
return
|
||||
}
|
||||
if err := s.artifactWorker.CreateCharacterAnchor(r.Context()); err != nil {
|
||||
if errors.Is(err, artifact.ErrCharacterAnchorExists) {
|
||||
jsonOut(w, http.StatusConflict, map[string]string{"error": "character anchor already exists"})
|
||||
return
|
||||
}
|
||||
jsonOut(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
jsonOut(w, http.StatusCreated, map[string]any{
|
||||
"ok": true,
|
||||
"url": "/api/admin/artifact/character-anchor?ts=" + fmt.Sprint(time.Now().UnixMilli()),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) adminCharacterAnchorFile(w http.ResponseWriter, r *http.Request) {
|
||||
path := filepath.Join(s.artifactDir, "_collection", "character_anchor.png")
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil || len(b) <= 1024 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.Header().Set("Cache-Control", "private, no-store")
|
||||
w.Header().Set("Content-Length", fmt.Sprint(len(b)))
|
||||
_, _ = w.Write(b)
|
||||
}
|
||||
|
||||
func normalizeUploadedStyle(raw []byte) (ext, contentType string, width, height int, err error) {
|
||||
if len(raw) < 128 {
|
||||
return "", "", 0, 0, errors.New("style reference is empty")
|
||||
}
|
||||
contentType = strings.Split(http.DetectContentType(raw), ";")[0]
|
||||
switch contentType {
|
||||
case "image/jpeg":
|
||||
ext = ".jpg"
|
||||
case "image/png":
|
||||
ext = ".png"
|
||||
default:
|
||||
return "", "", 0, 0, fmt.Errorf("unsupported style image type %q; use JPEG or PNG", contentType)
|
||||
}
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return "", "", 0, 0, fmt.Errorf("decode style image: %w", err)
|
||||
}
|
||||
if cfg.Width < 128 || cfg.Height < 128 || cfg.Width > 8192 || cfg.Height > 8192 || int64(cfg.Width)*int64(cfg.Height) > 40_000_000 {
|
||||
return "", "", 0, 0, fmt.Errorf("style image dimensions %dx%d are outside the allowed range", cfg.Width, cfg.Height)
|
||||
}
|
||||
return ext, contentType, cfg.Width, cfg.Height, nil
|
||||
}
|
||||
|
||||
func writeStyleAsset(dir, name string, raw []byte) error {
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(dir, name)
|
||||
if st, err := os.Stat(target); err == nil && st.Size() == int64(len(raw)) {
|
||||
// The filename is a SHA-256 of the file contents, so an existing asset
|
||||
// with the same name is the same immutable reference. This also avoids
|
||||
// Windows rename-over-existing behavior.
|
||||
return nil
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, ".style-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
if err := tmp.Chmod(0o640); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := tmp.Write(raw); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpName, target)
|
||||
}
|
||||
|
||||
func (s *Server) adminTaskStyleReferencePut(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxStyleReferenceBytes+1<<20)
|
||||
if err := r.ParseMultipartForm(maxStyleReferenceBytes); err != nil {
|
||||
jsonOut(w, http.StatusBadRequest, map[string]string{"error": "invalid multipart upload: " + err.Error()})
|
||||
return
|
||||
}
|
||||
f, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
jsonOut(w, http.StatusBadRequest, map[string]string{"error": "multipart field 'file' is required"})
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(f, maxStyleReferenceBytes+1))
|
||||
if err != nil {
|
||||
jsonOut(w, http.StatusBadRequest, map[string]string{"error": "read upload: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if len(raw) > maxStyleReferenceBytes {
|
||||
jsonOut(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "style reference exceeds 12 MiB"})
|
||||
return
|
||||
}
|
||||
ext, contentType, width, height, err := normalizeUploadedStyle(raw)
|
||||
if err != nil {
|
||||
jsonOut(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h := sha256.Sum256(raw)
|
||||
name := hex.EncodeToString(h[:]) + ext
|
||||
if err := writeStyleAsset(filepath.Join(s.artifactDir, "_styles"), name, raw); err != nil {
|
||||
jsonOut(w, http.StatusInternalServerError, map[string]string{"error": "store style reference: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if err := s.store.SetTaskStyleReference(r.Context(), id, name); err != nil {
|
||||
jsonOut(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_ = s.hub.Publish(r.Context(), wsx.Event{Type: "task_changed", TaskID: id, Data: map[string]string{"action": "style_reference"}})
|
||||
jsonOut(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"name": name,
|
||||
"sha256": hex.EncodeToString(h[:]),
|
||||
"content_type": contentType,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"url": "/api/admin/tasks/" + id + "/style-reference?ts=" + fmt.Sprint(time.Now().UnixMilli()),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) adminTaskStyleReferenceDelete(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := s.store.SetTaskStyleReference(r.Context(), id, ""); err != nil {
|
||||
jsonOut(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_ = s.hub.Publish(r.Context(), wsx.Event{Type: "task_changed", TaskID: id, Data: map[string]string{"action": "style_reference"}})
|
||||
jsonOut(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func serveTaskStyleReference(w http.ResponseWriter, r *http.Request, ref artifact.TaskStyleReference, cache string) {
|
||||
if len(ref.Bytes) == 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
ct := ref.ContentType
|
||||
if ct == "" {
|
||||
ct = http.DetectContentType(ref.Bytes)
|
||||
}
|
||||
w.Header().Set("Content-Type", ct)
|
||||
w.Header().Set("Cache-Control", cache)
|
||||
w.Header().Set("ETag", `"`+ref.SHA256+`"`)
|
||||
w.Header().Set("Content-Length", fmt.Sprint(len(ref.Bytes)))
|
||||
_, _ = w.Write(ref.Bytes)
|
||||
}
|
||||
|
||||
func (s *Server) adminTaskStyleReferenceFile(w http.ResponseWriter, r *http.Request) {
|
||||
if s.artifactWorker == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
ref, err := s.artifactWorker.TaskStyleReference(r.Context(), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
serveTaskStyleReference(w, r, ref, "private, no-store")
|
||||
}
|
||||
|
||||
func (s *Server) publicTaskStyleReference(w http.ResponseWriter, r *http.Request) {
|
||||
if s.artifactWorker == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
id := chi.URLParam(r, "id")
|
||||
var status string
|
||||
if err := s.store.DB.QueryRowContext(r.Context(), `SELECT status FROM tasks WHERE id=?`, id).Scan(&status); err != nil || status != "active" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
ref, err := s.artifactWorker.TaskStyleReference(r.Context(), id)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
serveTaskStyleReference(w, r, ref, "public, max-age=300")
|
||||
}
|
||||
|
||||
func (s *Server) adminCreatePipelineTestCard(w http.ResponseWriter, r *http.Request) {
|
||||
if s.artifactWorker == nil {
|
||||
jsonOut(w, http.StatusServiceUnavailable, map[string]string{"error": "artifact worker unavailable"})
|
||||
return
|
||||
}
|
||||
id := chi.URLParam(r, "id")
|
||||
path, err := s.artifactWorker.CreatePipelineTestCard(r.Context(), id)
|
||||
if err != nil {
|
||||
jsonOut(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_ = path
|
||||
jsonOut(w, http.StatusCreated, map[string]any{"ok": true, "url": "/api/admin/tasks/" + id + "/pipeline-test/card?ts=" + fmt.Sprint(time.Now().UnixMilli()), "api_calls": 0, "cost_usd": 0})
|
||||
}
|
||||
|
||||
func (s *Server) adminPipelineTestCardFile(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if filepath.Base(id) != id || strings.ContainsAny(id, `/\\`) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
path := filepath.Join(s.artifactDir, "_test", id, "image.svg")
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil || len(b) < 128 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
w.Header().Set("Cache-Control", "private, no-store")
|
||||
_, _ = w.Write(b)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type artifactUsageRow struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
TaskID string `json:"task_id,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
Model string `json:"model"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Size string `json:"size"`
|
||||
Quality string `json:"quality"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
InputTextTokens int64 `json:"input_text_tokens"`
|
||||
InputImageTokens int64 `json:"input_image_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
EstimatedCostUSD *float64 `json:"estimated_cost_usd,omitempty"`
|
||||
PricingBasis string `json:"pricing_basis,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) adminArtifactUsage(w http.ResponseWriter, r *http.Request) {
|
||||
now := time.Now().UTC()
|
||||
dayStartMS := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).UnixMilli()
|
||||
if raw := r.URL.Query().Get("day_start_ms"); raw != "" {
|
||||
if v, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
// Accept a browser-local midnight in a reasonable window. This lets the
|
||||
// "today" KPI follow the admin's local calendar day without adding a
|
||||
// timezone setting to the server.
|
||||
candidate := time.UnixMilli(v).UTC()
|
||||
if candidate.After(now.Add(-48*time.Hour)) && candidate.Before(now.Add(24*time.Hour)) {
|
||||
dayStartMS = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var todayCalls, todayCards, todayPriced int64
|
||||
var todayCost float64
|
||||
_ = s.store.DB.QueryRowContext(r.Context(), `SELECT count(*),
|
||||
COALESCE(sum(CASE WHEN kind='artifact' THEN 1 ELSE 0 END),0),
|
||||
COALESCE(sum(CASE WHEN estimated_cost_usd IS NOT NULL THEN 1 ELSE 0 END),0),
|
||||
COALESCE(sum(estimated_cost_usd),0)
|
||||
FROM artifact_api_usage WHERE created_at>=?`, dayStartMS).Scan(&todayCalls, &todayCards, &todayPriced, &todayCost)
|
||||
|
||||
var cardCalls, pricedCardCalls int64
|
||||
var totalCardCost, avgCardCost float64
|
||||
var inputTokens, inputTextTokens, inputImageTokens, outputTokens, totalTokens int64
|
||||
_ = s.store.DB.QueryRowContext(r.Context(), `SELECT count(*),count(estimated_cost_usd),
|
||||
COALESCE(sum(estimated_cost_usd),0),COALESCE(avg(estimated_cost_usd),0),
|
||||
COALESCE(sum(input_tokens),0),COALESCE(sum(input_text_tokens),0),COALESCE(sum(input_image_tokens),0),
|
||||
COALESCE(sum(output_tokens),0),COALESCE(sum(total_tokens),0)
|
||||
FROM artifact_api_usage WHERE kind='artifact'`).Scan(
|
||||
&cardCalls, &pricedCardCalls, &totalCardCost, &avgCardCost,
|
||||
&inputTokens, &inputTextTokens, &inputImageTokens, &outputTokens, &totalTokens)
|
||||
|
||||
var anchorCalls int64
|
||||
var anchorCost float64
|
||||
_ = s.store.DB.QueryRowContext(r.Context(), `SELECT count(*),COALESCE(sum(estimated_cost_usd),0)
|
||||
FROM artifact_api_usage WHERE kind='character_anchor'`).Scan(&anchorCalls, &anchorCost)
|
||||
|
||||
recent := make([]artifactUsageRow, 0, 20)
|
||||
rows, err := s.store.DB.QueryContext(r.Context(), `SELECT created_at,task_id,kind,model,endpoint,size,quality,request_id,
|
||||
input_tokens,input_text_tokens,input_image_tokens,output_tokens,total_tokens,estimated_cost_usd,pricing_basis
|
||||
FROM artifact_api_usage ORDER BY created_at DESC,id DESC LIMIT 20`)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var createdMS int64
|
||||
var taskID, requestID sql.NullString
|
||||
var cost sql.NullFloat64
|
||||
var row artifactUsageRow
|
||||
if err := rows.Scan(&createdMS, &taskID, &row.Kind, &row.Model, &row.Endpoint, &row.Size, &row.Quality, &requestID,
|
||||
&row.InputTokens, &row.InputTextTokens, &row.InputImageTokens, &row.OutputTokens, &row.TotalTokens, &cost, &row.PricingBasis); err != nil {
|
||||
continue
|
||||
}
|
||||
row.CreatedAt = time.UnixMilli(createdMS).UTC()
|
||||
if taskID.Valid {
|
||||
row.TaskID = taskID.String
|
||||
}
|
||||
if requestID.Valid {
|
||||
row.RequestID = requestID.String
|
||||
}
|
||||
if cost.Valid {
|
||||
v := cost.Float64
|
||||
row.EstimatedCostUSD = &v
|
||||
}
|
||||
recent = append(recent, row)
|
||||
}
|
||||
}
|
||||
|
||||
jsonOut(w, 200, map[string]any{
|
||||
"day_start": time.UnixMilli(dayStartMS).UTC(),
|
||||
"today_calls": todayCalls,
|
||||
"today_cards": todayCards,
|
||||
"today_priced_calls": todayPriced,
|
||||
"today_cost_usd": todayCost,
|
||||
"card_generations": cardCalls,
|
||||
"priced_card_generations": pricedCardCalls,
|
||||
"total_card_cost_usd": totalCardCost,
|
||||
"avg_card_cost_usd": avgCardCost,
|
||||
"cost_per_1000_usd": avgCardCost * 1000,
|
||||
"anchor_generations": anchorCalls,
|
||||
"anchor_cost_usd": anchorCost,
|
||||
"card_usage": map[string]int64{
|
||||
"input_tokens": inputTokens,
|
||||
"input_text_tokens": inputTextTokens,
|
||||
"input_image_tokens": inputImageTokens,
|
||||
"output_tokens": outputTokens,
|
||||
"total_tokens": totalTokens,
|
||||
},
|
||||
"cost_method": "estimated from provider-reported token usage using pinned OpenAI standard public token rates; not invoice reconciliation",
|
||||
"recent": recent,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"neuralhunt/internal/auth"
|
||||
)
|
||||
|
||||
func TestDecodeAuthAcceptsExtraJWKMembers(t *testing.T) {
|
||||
body := `{"public_jwk":{"kty":"EC","crv":"P-256","x":"abc","y":"def","ext":true,"key_ops":["verify"],"alg":"ES256","use":"sig","kid":"browser-key","vendor_future_field":"ok"}}`
|
||||
req := httptest.NewRequest("POST", "/api/auth/challenge", strings.NewReader(body))
|
||||
var in struct {
|
||||
PublicJWK auth.PublicJWK `json:"public_jwk"`
|
||||
}
|
||||
if err := decodeAuth(req, &in); err != nil {
|
||||
t.Fatalf("decodeAuth should tolerate optional/unknown JWK metadata: %v", err)
|
||||
}
|
||||
if in.PublicJWK.Kty != "EC" || in.PublicJWK.Crv != "P-256" || in.PublicJWK.X != "abc" || in.PublicJWK.Y != "def" {
|
||||
t.Fatalf("core JWK fields were not decoded: %+v", in.PublicJWK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeAuthRejectsSecondJSONValue(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "/api/auth/challenge", strings.NewReader(`{"public_jwk":{}} {"x":1}`))
|
||||
var in any
|
||||
if err := decodeAuth(req, &in); err == nil {
|
||||
t.Fatal("expected second JSON value to be rejected")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,213 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestArtifactLocalPathRejectsTraversal(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if _, err := artifactLocalPath(root, "/artifacts/../secret.txt"); err == nil {
|
||||
t.Fatal("expected traversal rejection")
|
||||
}
|
||||
got, err := artifactLocalPath(root, "/artifacts/a/image.png")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(root, "a", "image.png")
|
||||
if got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatermarkSVG(t *testing.T) {
|
||||
out, err := watermarkSVG([]byte(`<svg xmlns="http://www.w3.org/2000/svg"><rect width="10" height="10"/></svg>`), "NEURAL HUNT PREVIEW")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(out), "WATERMARKED LEADERBOARD PREVIEW") || !strings.Contains(string(out), "nh-preview-watermark") {
|
||||
t.Fatalf("watermark missing: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatermarkRasterProducesPNG(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "source.png")
|
||||
img := image.NewNRGBA(image.Rect(0, 0, 320, 240))
|
||||
for y := 0; y < 240; y++ {
|
||||
for x := 0; x < 320; x++ {
|
||||
img.Set(x, y, color.NRGBA{20, 40, 60, 255})
|
||||
}
|
||||
}
|
||||
var src bytes.Buffer
|
||||
if err := png.Encode(&src, img); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, src.Bytes(), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, ct, err := watermarkPreviewFile(path, "NEURAL HUNT PREVIEW")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ct != "image/png" || len(out) == 0 {
|
||||
t.Fatalf("unexpected preview %q %d", ct, len(out))
|
||||
}
|
||||
if bytes.Equal(out, src.Bytes()) {
|
||||
t.Fatal("preview should differ from source")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user