用户要求:备案号与域名都应当可配置,仓库内容要脱敏。 功能(不只是替换字面量): - 被代理页面注入的备案号改为读后台「站点信息」的 icp/gongan,**未配置就不注入**; 原先写死具体备案号,等于每个部署者都会把别人的备案信息印到自己页面上 - 注入内容做 nginx 安全处理(去引号/反斜杠/换行/美元符),并有测试覆盖 - auth.go:PORTAL_COOKIE_DOMAIN / PORTAL_HOME_URL 不再预置域名, cookie 默认 host-only、首页默认相对路径 "/" - gateway.go:默认 loginUrl 不再预置域名(受保护路由缺配置会明确报错,不静默生成跳不出去的登录页) - 首装脚本在给了 NAVI_DOMAIN 时自动注入 PORTAL_COOKIE_DOMAIN/PORTAL_HOME_URL, 避免"去掉写死默认值"导致跨子域单点登录失效 脱敏:内网 IP(10.0.0.10)与真实备案号清零;真实域名仅剩门户页内容 8 处(待定); 测试夹具/示例/错误页/脚本/后台占位提示全部通用化。 README:新增「跨子域单点登录需要的两个环境变量」;环境变量表改为 NAVI_HOST 必填。 测试:新增备案号注入三条(用配置、未配置不注入、危险字符清理)。
265 lines
8.5 KiB
Go
265 lines
8.5 KiB
Go
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/binary"
|
||
"image"
|
||
"image/color"
|
||
"image/png"
|
||
"testing"
|
||
)
|
||
|
||
// ── 图标格式白名单 ──
|
||
|
||
func TestRenderableByDashy(t *testing.T) {
|
||
// Dashy 的 isImage() 白名单只有 svg/png/jpg,其余一律渲染不出来。
|
||
cases := map[string]string{
|
||
".png": ".png",
|
||
".PNG": "", // 调用方负责先转小写
|
||
".svg": ".svg",
|
||
".jpg": ".jpg",
|
||
".jpeg": ".jpg",
|
||
".ico": "",
|
||
".gif": "",
|
||
".webp": "",
|
||
"": "",
|
||
}
|
||
for in, want := range cases {
|
||
got, ok := renderableByDashy(in)
|
||
if want == "" {
|
||
if ok {
|
||
t.Errorf("renderableByDashy(%q) = %q,true; want not renderable", in, got)
|
||
}
|
||
continue
|
||
}
|
||
if !ok || got != want {
|
||
t.Errorf("renderableByDashy(%q) = %q,%v; want %q,true", in, got, ok, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── sniffExt:绝不能把 SPA 的 HTML 兜底页当图标 ──
|
||
|
||
func TestSniffExtRejectsHTMLFallback(t *testing.T) {
|
||
// 真实场景:SPA 对 /favicon.ico 回 200 + text/html,body 是 index.html,
|
||
// 而 index.html 里恰好引用了 image/svg+xml(webui4frpc 就是这样)。
|
||
body := []byte(`<!DOCTYPE html><html><head><link rel="icon" href="/favicon.svg" type="image/svg+xml"><title>x</title></head></html>`)
|
||
|
||
if got := sniffExt(body, "text/html; charset=utf-8", "http://h/favicon.ico"); got != "" {
|
||
t.Fatalf("HTML fallback body must be rejected, got %q", got)
|
||
}
|
||
// 即使服务端把 Content-Type 谎报成 svg,只要正文不是 SVG 根元素也要拒。
|
||
if got := sniffExt(body, "image/svg+xml", "http://h/favicon.ico"); got == ".svg" {
|
||
t.Fatalf("non-SVG body must not be stored as .svg")
|
||
}
|
||
}
|
||
|
||
func TestSniffExtByMagicAndType(t *testing.T) {
|
||
pngMagic := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 16)...)
|
||
if got := sniffExt(pngMagic, "", "http://h/favicon.ico"); got != ".png" {
|
||
t.Errorf("png magic: got %q want .png", got)
|
||
}
|
||
jpgMagic := append([]byte("\xff\xd8\xff"), make([]byte, 16)...)
|
||
if got := sniffExt(jpgMagic, "", "http://h/favicon.ico"); got != ".jpg" {
|
||
t.Errorf("jpeg magic: got %q want .jpg", got)
|
||
}
|
||
svg := []byte(`<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"/>`)
|
||
if got := sniffExt(svg, "", "http://h/favicon.svg"); got != ".svg" {
|
||
t.Errorf("svg body: got %q want .svg", got)
|
||
}
|
||
// ico 要被识别出来交给后续转换,而不是直接落盘
|
||
icoMagic := []byte("\x00\x00\x01\x00\x01\x00")
|
||
if got := sniffExt(icoMagic, "", "http://h/favicon.ico"); got != ".ico" {
|
||
t.Errorf("ico magic: got %q want .ico", got)
|
||
}
|
||
// Dashy 渲染不了的格式直接拒绝
|
||
gif := append([]byte("GIF89a"), make([]byte, 16)...)
|
||
if got := sniffExt(gif, "", "http://h/favicon.gif"); got != "" {
|
||
t.Errorf("gif must be rejected (Dashy cannot render it), got %q", got)
|
||
}
|
||
webp := append([]byte("RIFF\x00\x00\x00\x00WEBP"), make([]byte, 8)...)
|
||
if got := sniffExt(webp, "", "http://h/favicon.webp"); got != "" {
|
||
t.Errorf("webp must be rejected, got %q", got)
|
||
}
|
||
}
|
||
|
||
// ── ICO 解析 ──
|
||
|
||
// buildICO 把若干条目打包成 ICO 容器。
|
||
func buildICO(entries ...[]byte) []byte {
|
||
var buf bytes.Buffer
|
||
buf.Write([]byte{0, 0, 1, 0})
|
||
_ = binary.Write(&buf, binary.LittleEndian, uint16(len(entries)))
|
||
offset := 6 + 16*len(entries)
|
||
for _, e := range entries {
|
||
buf.Write([]byte{16, 16, 0, 0}) // w,h,colors,reserved
|
||
_ = binary.Write(&buf, binary.LittleEndian, uint16(1))
|
||
_ = binary.Write(&buf, binary.LittleEndian, uint16(32))
|
||
_ = binary.Write(&buf, binary.LittleEndian, uint32(len(e)))
|
||
_ = binary.Write(&buf, binary.LittleEndian, uint32(offset))
|
||
offset += len(e)
|
||
}
|
||
for _, e := range entries {
|
||
buf.Write(e)
|
||
}
|
||
return buf.Bytes()
|
||
}
|
||
|
||
func encodePNG(t *testing.T, w, h int) []byte {
|
||
t.Helper()
|
||
img := image.NewNRGBA(image.Rect(0, 0, w, h))
|
||
img.SetNRGBA(0, 0, color.NRGBA{R: 1, G: 2, B: 3, A: 255})
|
||
var buf bytes.Buffer
|
||
if err := png.Encode(&buf, img); err != nil {
|
||
t.Fatalf("png encode: %v", err)
|
||
}
|
||
return buf.Bytes()
|
||
}
|
||
|
||
func TestPngFromICO(t *testing.T) {
|
||
small := encodePNG(t, 1, 1)
|
||
big := encodePNG(t, 16, 16)
|
||
ico := buildICO(small, big)
|
||
|
||
got, ok := pngFromICO(ico)
|
||
if !ok {
|
||
t.Fatal("expected an embedded png")
|
||
}
|
||
if !bytes.Equal(got, big) {
|
||
t.Fatalf("should pick the largest embedded png: got %d bytes want %d", len(got), len(big))
|
||
}
|
||
if _, ok := pngFromICO([]byte("not an ico")); ok {
|
||
t.Fatal("garbage must not yield a png")
|
||
}
|
||
}
|
||
|
||
// buildDIB 构造 ICO 内常见的 DIB 位图(32bpp,自下而上,行 4 字节对齐)。
|
||
func buildDIB(w, h int, rows [][]color.NRGBA) []byte {
|
||
var buf bytes.Buffer
|
||
hdr := make([]byte, 40)
|
||
binary.LittleEndian.PutUint32(hdr[0:4], 40)
|
||
binary.LittleEndian.PutUint32(hdr[4:8], uint32(w))
|
||
binary.LittleEndian.PutUint32(hdr[8:12], uint32(2*h)) // ICO 高度是两倍
|
||
binary.LittleEndian.PutUint16(hdr[12:14], 1)
|
||
binary.LittleEndian.PutUint16(hdr[14:16], 32)
|
||
buf.Write(hdr)
|
||
for _, row := range rows {
|
||
for _, c := range row {
|
||
buf.Write([]byte{c.B, c.G, c.R, c.A})
|
||
}
|
||
}
|
||
return buf.Bytes()
|
||
}
|
||
|
||
func decodePNGSize(t *testing.T, data []byte) image.Image {
|
||
t.Helper()
|
||
img, err := png.Decode(bytes.NewReader(data))
|
||
if err != nil {
|
||
t.Fatalf("decode produced png: %v", err)
|
||
}
|
||
return img
|
||
}
|
||
|
||
func TestDibToPNG(t *testing.T) {
|
||
// DIB 自下而上:rows[0] 是图像最底下那一行。
|
||
topLeft := color.NRGBA{R: 10, G: 20, B: 30, A: 255}
|
||
bottomLeft := color.NRGBA{R: 200, G: 100, B: 50, A: 255}
|
||
rows := [][]color.NRGBA{
|
||
{bottomLeft, {R: 1, G: 1, B: 1, A: 255}},
|
||
{topLeft, {R: 2, G: 2, B: 2, A: 255}},
|
||
}
|
||
out, err := dibToPNG(buildDIB(2, 2, rows))
|
||
if err != nil {
|
||
t.Fatalf("dibToPNG: %v", err)
|
||
}
|
||
img := decodePNGSize(t, out)
|
||
if b := img.Bounds(); b.Dx() != 2 || b.Dy() != 2 {
|
||
t.Fatalf("size = %dx%d, want 2x2", b.Dx(), b.Dy())
|
||
}
|
||
r, g, b, a := img.At(0, 0).RGBA()
|
||
// 注意:解出的 PNG 是 NRGBA,At() 返回预乘值;这里 alpha=255 故直读高位
|
||
if uint8(r>>8) != topLeft.R || uint8(g>>8) != topLeft.G || uint8(b>>8) != topLeft.B || uint8(a>>8) != 255 {
|
||
t.Fatalf("pixel(0,0) = %d,%d,%d,%d; want %d,%d,%d,255",
|
||
r>>8, g>>8, b>>8, a>>8, topLeft.R, topLeft.G, topLeft.B)
|
||
}
|
||
}
|
||
|
||
func TestDibToPNGTreatsAllZeroAlphaAsOpaque(t *testing.T) {
|
||
// 老图标常把 alpha 全写 0;若照实解释会得到整张全透明图。
|
||
rows := [][]color.NRGBA{{{R: 9, G: 8, B: 7, A: 0}, {R: 9, G: 8, B: 7, A: 0}}}
|
||
out, err := dibToPNG(buildDIB(2, 1, rows))
|
||
if err != nil {
|
||
t.Fatalf("dibToPNG: %v", err)
|
||
}
|
||
img := decodePNGSize(t, out)
|
||
if _, _, _, a := img.At(0, 0).RGBA(); a>>8 != 255 {
|
||
t.Fatalf("all-zero alpha should be treated as opaque, got alpha=%d", a>>8)
|
||
}
|
||
}
|
||
|
||
func TestDibToPNGRejectsUnsupported(t *testing.T) {
|
||
hdr := make([]byte, 40)
|
||
binary.LittleEndian.PutUint32(hdr[0:4], 40)
|
||
binary.LittleEndian.PutUint32(hdr[4:8], 16)
|
||
binary.LittleEndian.PutUint32(hdr[8:12], 32)
|
||
binary.LittleEndian.PutUint16(hdr[14:16], 8) // 8bpp 不支持
|
||
if _, err := dibToPNG(hdr); err == nil {
|
||
t.Fatal("8bpp dib should be rejected")
|
||
}
|
||
if _, err := dibToPNG([]byte("short")); err == nil {
|
||
t.Fatal("short data should be rejected")
|
||
}
|
||
}
|
||
|
||
func TestDibToPNGFromICOPrefersLargest(t *testing.T) {
|
||
rows := [][]color.NRGBA{{{R: 1, G: 1, B: 1, A: 255}}}
|
||
small := buildDIB(1, 1, rows)
|
||
tallRows := [][]color.NRGBA{
|
||
{{R: 3, G: 3, B: 3, A: 255}, {R: 3, G: 3, B: 3, A: 255}},
|
||
{{R: 3, G: 3, B: 3, A: 255}, {R: 3, G: 3, B: 3, A: 255}},
|
||
}
|
||
large := buildDIB(2, 2, tallRows)
|
||
out, err := dibToPNGFromICO(buildICO(small, large))
|
||
if err != nil {
|
||
t.Fatalf("dibToPNGFromICO: %v", err)
|
||
}
|
||
if b := decodePNGSize(t, out).Bounds(); b.Dx() != 2 || b.Dy() != 2 {
|
||
t.Fatalf("should pick the largest entry, got %dx%d", b.Dx(), b.Dy())
|
||
}
|
||
}
|
||
|
||
// ── 文件名与段落名 ──
|
||
|
||
func TestSanitizeHostKey(t *testing.T) {
|
||
cases := map[string]string{
|
||
"example.com": "example.com",
|
||
"10.0.0.9:3000": "10.0.0.9_3000",
|
||
"example.com:8443": "example.com_8443",
|
||
"[::1]:8080": "___1__8080",
|
||
}
|
||
for in, want := range cases {
|
||
if got := sanitizeHostKey(in); got != want {
|
||
t.Errorf("sanitizeHostKey(%q) = %q, want %q", in, got, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestSectionNameMatching(t *testing.T) {
|
||
// 公开示例 conf.yml 用英文段落名,生产用中文;两者都必须能匹配。
|
||
for _, n := range []string{"常用服务", "Favorites", "favorites", "FAVORITES"} {
|
||
if !isFavSection(n) {
|
||
t.Errorf("isFavSection(%q) = false, want true", n)
|
||
}
|
||
}
|
||
for _, n := range []string{"历史访问", "History", "history", "HISTORY"} {
|
||
if !isHistSection(n) {
|
||
t.Errorf("isHistSection(%q) = false, want true", n)
|
||
}
|
||
}
|
||
for _, n := range []string{"Tools", "工具", "", "Favourite"} {
|
||
if isFavSection(n) || isHistSection(n) {
|
||
t.Errorf("section %q should not match favorites/history", n)
|
||
}
|
||
}
|
||
}
|