Initial commit

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-23 11:07:42 +08:00
co-authored by Claude Opus 5.5
commit 7974a42301
633 changed files with 120248 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
package imgutil
import "bytes"
// 与管理后台 ShowImg / imgCode.js 保持一致。
var (
encryptMagicNumber = []byte{0x88, 0xA8, 0x30, 0xCB, 0x10, 0x76}
decryptKey = []byte{50, 48, 49, 57, 121, 115, 97, 112, 112, 55, 53, 50, 55} // 2019ysapp7527
plainImageHeaders = [][]byte{
{0xFF, 0xD8, 0xFF}, // jpeg
{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, // png
{0x47, 0x49, 0x46}, // gif
}
)
const (
encryptedPrefixLen = 100
encryptXORAllKey = 0xA3
)
// DecryptImage 按后台 handleVerAutoImg 的规则解密图床图片。
// 已是 jpg/png/gif 则原样返回。
func DecryptImage(data []byte) []byte {
if len(data) == 0 {
return data
}
out := append([]byte(nil), data...)
if hasEncryptMagic(out) {
return xorAllLength(out)
}
if !isPlainImage(out) {
xorPrefix(out, decryptKey, encryptedPrefixLen)
}
return out
}
func hasEncryptMagic(data []byte) bool {
return len(data) >= len(encryptMagicNumber) && bytes.Equal(data[:len(encryptMagicNumber)], encryptMagicNumber)
}
func isPlainImage(data []byte) bool {
for _, header := range plainImageHeaders {
if len(data) >= len(header) && bytes.Equal(data[:len(header)], header) {
return true
}
}
return false
}
func xorPrefix(src, key []byte, length int) {
if len(key) == 0 || len(src) == 0 {
return
}
if length <= 0 || length > len(src) {
length = len(src)
}
for i := 0; i < length; {
for j := 0; j < len(key) && i < length; j++ {
src[i] ^= key[j]
i++
}
}
}
func xorAllLength(src []byte) []byte {
out := make([]byte, 0, len(src))
for i, b := range src {
if i < len(encryptMagicNumber) && b == encryptMagicNumber[i] {
continue
}
out = append(out, b^encryptXORAllKey)
}
return out
}
+151
View File
@@ -0,0 +1,151 @@
package imgutil
import (
"bytes"
"crypto/sha1"
"fmt"
"image"
_ "image/gif"
"image/jpeg"
_ "image/png"
"net/url"
"path"
"path/filepath"
"strings"
"unicode"
)
const (
// CoverResizeLimitKB 与 media utils.Resize 一致:超过该大小才缩放
CoverResizeLimitKB = 60
// CoverMaxWidth 与 media Thumbnail 一致
CoverMaxWidth = 750
// CoverMaxHeight 与 media Thumbnail 一致
CoverMaxHeight = 1560
// CoverJPEGQuality 压缩后统一输出 JPEG
CoverJPEGQuality = 75
)
// IsGIF 按文件头判断是否为 GIF,不依赖扩展名。
func IsGIF(data []byte) bool {
return len(data) >= 3 && bytes.Equal(data[:3], []byte{0x47, 0x49, 0x46})
}
// NeedCompress 原图是否超过 media Resize 阈值,未超过则无需压缩重传。
// GIF 保持原格式和动效,不参与压缩。
func NeedCompress(data []byte) bool {
if IsGIF(data) {
return false
}
return int64(len(data))/1024 > CoverResizeLimitKB
}
// CompressCover 按 media Resize(60KB, 750×1560) 压缩封面。
// GIF 原样返回;其它格式未超过阈值时原样返回,超过则缩放后统一输出 JPEG。
func CompressCover(data []byte, fileName string) ([]byte, string, error) {
if len(data) == 0 {
return nil, "", fmt.Errorf("图片内容为空")
}
outName := compressFileName(fileName)
if IsGIF(data) {
return data, withExt(outName, ".gif"), nil
}
if !NeedCompress(data) {
return data, outName, nil
}
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, "", fmt.Errorf("解码图片失败: %w", err)
}
thumb := thumbnail(img, CoverMaxWidth, CoverMaxHeight)
var buf bytes.Buffer
if err = jpeg.Encode(&buf, thumb, &jpeg.Options{Quality: CoverJPEGQuality}); err != nil {
return nil, "", err
}
return buf.Bytes(), withExt(outName, ".jpg"), nil
}
// SafeCoverFileName 生成仅含 ASCII 的上传文件名。
// 原封面路径含中文时,直接用 path.Base 会把中文带进上传名,图床返回地址会异常。
func SafeCoverFileName(src string) string {
src = strings.TrimSpace(src)
ext := asciiImageExt(src)
if src == "" {
return "playlet_cover" + ext
}
sum := sha1.Sum([]byte(src))
return fmt.Sprintf("playlet_cover_%x%s", sum[:8], ext)
}
func thumbnail(img image.Image, maxW, maxH int) image.Image {
b := img.Bounds()
w, h := b.Dx(), b.Dy()
if w <= maxW && h <= maxH {
return img
}
wr := float64(maxW) / float64(w)
hr := float64(maxH) / float64(h)
scale := wr
if hr < wr {
scale = hr
}
nw := int(float64(w) * scale)
nh := int(float64(h) * scale)
if nw < 1 {
nw = 1
}
if nh < 1 {
nh = 1
}
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
for y := 0; y < nh; y++ {
sy := b.Min.Y + y*h/nh
for x := 0; x < nw; x++ {
sx := b.Min.X + x*w/nw
dst.Set(x, y, img.At(sx, sy))
}
}
return dst
}
func compressFileName(fileName string) string {
fileName = filepath.Base(strings.TrimSpace(fileName))
if fileName == "" || fileName == "." || fileName == string(filepath.Separator) || !isASCIIFileName(fileName) {
return "playlet_cover.jpg"
}
return fileName
}
func asciiImageExt(src string) string {
name := src
if u, err := url.Parse(src); err == nil && u.Path != "" {
name = u.Path
}
name = strings.Split(path.Base(name), "?")[0]
if dec, err := url.PathUnescape(name); err == nil {
name = dec
}
ext := strings.ToLower(filepath.Ext(name))
switch ext {
case ".jpg", ".jpeg", ".png", ".gif", ".webp":
return ext
default:
return ".jpg"
}
}
func isASCIIFileName(name string) bool {
if !strings.Contains(name, ".") {
return false
}
for _, r := range name {
if r > unicode.MaxASCII || r < 32 || r == '"' || r == '/' || r == '\\' {
return false
}
}
return true
}
func withExt(fileName, ext string) string {
return strings.TrimSuffix(fileName, filepath.Ext(fileName)) + ext
}