feat: 门户网站初始提交
- Go + Gin + html/template 服务端渲染 - 主页:Google 风格搜索框 + 导航卡片 - 后台:卡片 CRUD、搜索引擎配置、主页背景/标题配置 - 图片上传:支持 jpg/jpeg/png/gif,自动压缩,缩略图参数 ?thumb=1 - 安全:登录日志、修改密码、IP 自动封禁、IP 白名单 - 访问统计:主页访问/卡片点击/搜索追踪、实时流量、IP 统计 - SQLite 存储(modernc.org/sqlite,纯 Go) - 内存 Session + bcrypt 密码哈希
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// allowedMIMETypes defines the permitted upload file MIME types.
|
||||
var allowedMIMETypes = map[string]string{
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/gif": ".gif",
|
||||
}
|
||||
|
||||
// maxUploadSize is the maximum allowed file size in bytes (5 MB).
|
||||
const maxUploadSize = 5 << 20
|
||||
|
||||
// uploadDir is the directory where uploaded files are stored.
|
||||
const uploadDir = "./data/uploads"
|
||||
|
||||
// thumbSuffix is the suffix appended to compressed image filenames.
|
||||
const thumbSuffix = "_thumb"
|
||||
|
||||
// UploadResponse is the JSON response returned after a successful upload.
|
||||
type UploadResponse struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// UploadHandler handles image upload requests.
|
||||
// POST /admin/upload
|
||||
// Accepts multipart form with "file" field and optional "type" parameter ("icon" or "background").
|
||||
// Returns JSON: {"url": "/uploads/{uuid}.{ext}"}
|
||||
func UploadHandler(c *gin.Context) {
|
||||
// Parse multipart form with size limit
|
||||
if err := c.Request.ParseMultipartForm(maxUploadSize); err != nil {
|
||||
c.JSON(400, gin.H{"error": "文件太大,最大允许 5MB"})
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": "请选择要上传的文件"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Validate file size
|
||||
if header.Size > maxUploadSize {
|
||||
c.JSON(400, gin.H{"error": "文件太大,最大允许 5MB"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate MIME type by reading first 512 bytes
|
||||
buf := make([]byte, 512)
|
||||
n, err := file.Read(buf)
|
||||
if err != nil && err != io.EOF {
|
||||
c.JSON(500, gin.H{"error": "读取文件失败"})
|
||||
return
|
||||
}
|
||||
// Seek back to start
|
||||
if _, err := file.Seek(0, 0); err != nil {
|
||||
c.JSON(500, gin.H{"error": "处理文件失败"})
|
||||
return
|
||||
}
|
||||
|
||||
mimeType := strings.Split(c.Request.Header.Get("Content-Type"), ";")[0]
|
||||
// Also detect from file content
|
||||
detectedType := detectMIMEType(buf[:n])
|
||||
if detectedType == "" {
|
||||
detectedType = mimeType
|
||||
}
|
||||
|
||||
ext, ok := allowedMIMETypes[detectedType]
|
||||
if !ok {
|
||||
// Try the header's content type as fallback
|
||||
ext, ok = allowedMIMETypes[mimeType]
|
||||
if !ok {
|
||||
c.JSON(400, gin.H{"error": "不支持的文件格式,仅支持 jpg, png, gif"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Generate UUID filename
|
||||
fileUUID := uuid.New().String()
|
||||
filename := fileUUID + ext
|
||||
|
||||
// Ensure upload directory exists
|
||||
if err := os.MkdirAll(uploadDir, 0755); err != nil {
|
||||
c.JSON(500, gin.H{"error": "创建上传目录失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// Save original file
|
||||
originalPath := filepath.Join(uploadDir, filename)
|
||||
dst, err := os.Create(originalPath)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": "保存文件失败"})
|
||||
return
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
if _, err := io.Copy(dst, file); err != nil {
|
||||
os.Remove(originalPath)
|
||||
c.JSON(500, gin.H{"error": "保存文件失败"})
|
||||
return
|
||||
}
|
||||
dst.Close()
|
||||
|
||||
// Generate compressed thumbnail
|
||||
uploadType := c.PostForm("type")
|
||||
if err := generateThumbnail(originalPath, fileUUID, uploadType); err != nil {
|
||||
// Log the error but don't fail the upload — thumbnail is optional
|
||||
fmt.Printf("Warning: failed to generate thumbnail for %s: %v\n", filename, err)
|
||||
}
|
||||
|
||||
url := "/uploads/" + filename
|
||||
c.JSON(200, UploadResponse{URL: url})
|
||||
}
|
||||
|
||||
// ServeUploadHandler serves uploaded files.
|
||||
// GET /uploads/:filename
|
||||
// If query param thumb=1, returns the compressed version.
|
||||
func ServeUploadHandler(c *gin.Context) {
|
||||
filename := c.Param("filename")
|
||||
if filename == "" {
|
||||
c.Status(404)
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent directory traversal
|
||||
if strings.Contains(filename, "..") || strings.Contains(filename, "/") || strings.Contains(filename, "\\") {
|
||||
c.Status(404)
|
||||
return
|
||||
}
|
||||
|
||||
filePath := filepath.Join(uploadDir, filename)
|
||||
|
||||
// Check if thumb=1 query parameter is requested
|
||||
if c.Query("thumb") == "1" {
|
||||
// Try to serve the thumbnail version
|
||||
ext := filepath.Ext(filename)
|
||||
baseName := filename[:len(filename)-len(ext)]
|
||||
thumbPath := filepath.Join(uploadDir, baseName+thumbSuffix+".jpg")
|
||||
|
||||
if _, err := os.Stat(thumbPath); err == nil {
|
||||
c.File(thumbPath)
|
||||
return
|
||||
}
|
||||
// If thumbnail doesn't exist, fall through to serve original
|
||||
}
|
||||
|
||||
// Serve original file
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
c.Status(404)
|
||||
return
|
||||
}
|
||||
c.File(filePath)
|
||||
}
|
||||
|
||||
// detectMIMEType detects the MIME type from file content bytes.
|
||||
func detectMIMEType(data []byte) string {
|
||||
if len(data) < 3 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// JPEG: starts with FF D8 FF
|
||||
if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
|
||||
return "image/jpeg"
|
||||
}
|
||||
// PNG: starts with 89 50 4E 47
|
||||
if len(data) >= 4 && data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 {
|
||||
return "image/png"
|
||||
}
|
||||
// GIF: starts with "GIF"
|
||||
if len(data) >= 3 && data[0] == 'G' && data[1] == 'I' && data[2] == 'F' {
|
||||
return "image/gif"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// generateThumbnail creates a compressed version of the uploaded image.
|
||||
// Icon type: max 200x200, quality 80
|
||||
// Background type: max 1920x1080, quality 85
|
||||
// Default: max 800x600, quality 85
|
||||
// Thumbnails are saved as JPG format.
|
||||
func generateThumbnail(originalPath, fileUUID, uploadType string) error {
|
||||
// Open the original image
|
||||
img, err := imaging.Open(originalPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open image for thumbnail: %w", err)
|
||||
}
|
||||
|
||||
var maxWidth, maxHeight int
|
||||
var quality int
|
||||
|
||||
switch uploadType {
|
||||
case "icon":
|
||||
maxWidth = 200
|
||||
maxHeight = 200
|
||||
quality = 80
|
||||
case "background":
|
||||
maxWidth = 1920
|
||||
maxHeight = 1080
|
||||
quality = 85
|
||||
default:
|
||||
maxWidth = 800
|
||||
maxHeight = 600
|
||||
quality = 85
|
||||
}
|
||||
|
||||
// Resize if needed, maintaining aspect ratio
|
||||
thumb := fitImage(img, maxWidth, maxHeight)
|
||||
|
||||
// Save thumbnail as JPEG
|
||||
thumbPath := filepath.Join(uploadDir, fileUUID+thumbSuffix+".jpg")
|
||||
thumbFile, err := os.Create(thumbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create thumbnail file: %w", err)
|
||||
}
|
||||
defer thumbFile.Close()
|
||||
|
||||
if err := jpeg.Encode(thumbFile, thumb, &jpeg.Options{Quality: quality}); err != nil {
|
||||
os.Remove(thumbPath)
|
||||
return fmt.Errorf("failed to encode thumbnail: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// fitImage resizes the image to fit within maxWidth x maxHeight while
|
||||
// maintaining aspect ratio. If the image is already smaller, it is returned as-is.
|
||||
func fitImage(img image.Image, maxWidth, maxHeight int) image.Image {
|
||||
bounds := img.Bounds()
|
||||
w := bounds.Dx()
|
||||
h := bounds.Dy()
|
||||
|
||||
if w <= maxWidth && h <= maxHeight {
|
||||
return img
|
||||
}
|
||||
|
||||
// Calculate the scaling factor to fit within bounds
|
||||
scaleW := float64(maxWidth) / float64(w)
|
||||
scaleH := float64(maxHeight) / float64(h)
|
||||
scale := scaleW
|
||||
if scaleH < scaleW {
|
||||
scale = scaleH
|
||||
}
|
||||
|
||||
newW := int(float64(w) * scale)
|
||||
newH := int(float64(h) * scale)
|
||||
|
||||
if newW < 1 {
|
||||
newW = 1
|
||||
}
|
||||
if newH < 1 {
|
||||
newH = 1
|
||||
}
|
||||
|
||||
return imaging.Resize(img, newW, newH, imaging.Lanczos)
|
||||
}
|
||||
Reference in New Issue
Block a user