Files
xk-signature/main.go
2026-06-23 13:17:26 +08:00

560 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"embed"
"encoding/base64"
"encoding/json"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// ============================================================================
// 嵌入前端文件
// ============================================================================
//go:embed index.html
var content embed.FS
// ============================================================================
// 数据结构定义
// ============================================================================
// UploadRequest 前端发送的签名上传请求结构
type UploadRequest struct {
Name string `json:"name"` // 签署人姓名
Image string `json:"image"` // Base64 编码的签名图片
}
// UploadResponse 服务器返回的响应结构
type UploadResponse struct {
Success bool `json:"success"` // 是否成功
Message string `json:"message"` // 提示信息
Path string `json:"path"` // 文件保存路径
}
// LoginRequest 登录请求结构
type LoginRequest struct {
Account string `json:"account"` // 账号
Password string `json:"password"` // 密码
}
// LoginResponse 登录响应结构
type LoginResponse struct {
Success bool `json:"success"` // 是否成功
Token string `json:"token"` // 认证令牌
Message string `json:"message"` // 提示信息
}
// SignatureItem 签名文件信息结构
type SignatureItem struct {
Name string `json:"name"` // 签署人姓名
Path string `json:"path"` // 文件路径
Date string `json:"date"` // 签名日期
Size int64 `json:"size"` // 文件大小(字节)
}
// ============================================================================
// 全局变量
// ============================================================================
// 存储账号密码信息
var accountConfig struct {
Account string `json:"account"` // 账号
Password string `json:"password"` // 密码
}
// 存储有效的认证令牌,格式:令牌 -> 账号
var validTokens = make(map[string]string)
// Token 过期时间24小时
const tokenExpireDuration = 24 * time.Hour
// 存储令牌的创建时间
var tokenCreateTimes = make(map[string]time.Time)
// uploads 目录的绝对路径
var uploadsDir string
// ============================================================================
// 主函数
// ============================================================================
func main() {
// 获取可执行文件所在目录
execPath, err := os.Executable()
if err != nil {
log.Printf("获取可执行文件路径失败: %v使用当前目录", err)
execPath = filepath.Join(".", "placeholder")
}
execDir := filepath.Dir(execPath)
// 初始化 uploads 目录的绝对路径
uploadsDir = filepath.Join(execDir, "uploads")
// 加载账号密码配置文件
loadAccountConfig(execDir)
// 注册路由
setupRoutes()
// 启动服务器
port := ":10081"
fmt.Println("========================================")
fmt.Println(" 电子签名归档系统 v2.0")
fmt.Println("========================================")
fmt.Printf("监听端口: %s\n", port)
fmt.Printf("签名存储: %s\n", uploadsDir)
fmt.Printf("访问地址: http://localhost%s\n", port)
fmt.Println("========================================")
if err := http.ListenAndServe(port, nil); err != nil {
log.Fatal("服务器启动失败:", err)
}
}
// ============================================================================
// 路由设置
// ============================================================================
// setupRoutes 注册所有 HTTP 路由
func setupRoutes() {
// 主页面 - 从嵌入的文件系统提供前端页面
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
data, err := content.ReadFile("index.html")
if err != nil {
http.Error(w, "系统错误: 页面文件丢失", 500)
return
}
w.Write(data)
})
// 签名上传接口(无需认证)
http.HandleFunc("/upload", uploadHandler)
// 登录接口
http.HandleFunc("/login", loginHandler)
// 验证登录状态接口
http.HandleFunc("/api/check-auth", checkAuthHandler)
// 获取签名列表接口(需要认证)
http.HandleFunc("/api/signatures", signaturesHandler)
// 下载签名文件接口(需要认证)
http.HandleFunc("/download", downloadHandler)
}
// ============================================================================
// 认证相关函数
// ============================================================================
// loadAccountConfig 从 pwd.json 加载账号密码配置
// 优先从可执行文件目录加载,如果找不到则从当前工作目录加载
func loadAccountConfig(execDir string) {
// 尝试从可执行文件目录加载
configPath := filepath.Join(execDir, "pwd.json")
if err := loadConfigFromFile(configPath); err != nil {
// 如果失败,尝试从当前工作目录加载
configPath = filepath.Join(".", "pwd.json")
if err := loadConfigFromFile(configPath); err != nil {
log.Printf("警告: 未找到 pwd.json 配置文件,登录功能将不可用")
return
}
}
log.Printf("已加载账号配置: %s", configPath)
}
// loadConfigFromFile 从指定路径加载配置文件
func loadConfigFromFile(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
return json.Unmarshal(data, &accountConfig)
}
// generateToken 生成认证令牌
// 格式base64(账号:时间戳),简单但足够用于本地场景
func generateToken(account string) string {
// 组合账号和时间戳
raw := fmt.Sprintf("%s:%d", account, time.Now().UnixNano())
// Base64 编码
token := base64.StdEncoding.EncodeToString([]byte(raw))
// 记录令牌创建时间
validTokens[token] = account
tokenCreateTimes[token] = time.Now()
return token
}
// validateToken 验证令牌是否有效
// 返回:是否有效、对应的账号
func validateToken(token string) (bool, string) {
if token == "" {
return false, ""
}
// 检查令牌是否存在
account, exists := validTokens[token]
if !exists {
return false, ""
}
// 检查令牌是否过期
createTime, ok := tokenCreateTimes[token]
if !ok || time.Since(createTime) > tokenExpireDuration {
// 令牌已过期,删除它
delete(validTokens, token)
delete(tokenCreateTimes, token)
return false, ""
}
return true, account
}
// cleanExpiredTokens 清理过期的令牌
func cleanExpiredTokens() {
now := time.Now()
for token, createTime := range tokenCreateTimes {
if now.Sub(createTime) > tokenExpireDuration {
delete(validTokens, token)
delete(tokenCreateTimes, token)
}
}
}
// getAuthTokenFromRequest 从请求中提取认证令牌
// 支持从 Authorization 头或查询参数获取
func getAuthTokenFromRequest(r *http.Request) string {
// 首先尝试从 Authorization 头获取
authHeader := r.Header.Get("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
return strings.TrimPrefix(authHeader, "Bearer ")
}
// 然后尝试从查询参数获取
return r.URL.Query().Get("token")
}
// ============================================================================
// 路由处理函数
// ============================================================================
// loginHandler 处理登录请求
// POST /login - 验证账号密码,返回认证令牌
func loginHandler(w http.ResponseWriter, r *http.Request) {
// 只允许 POST 方法
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// 检查是否配置了账号密码
if accountConfig.Account == "" {
sendJSONResponse(w, false, "系统未配置登录账号", "")
return
}
// 解析请求体
var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSONResponse(w, false, "无效的请求数据", "")
return
}
// 去除前后空格
req.Account = strings.TrimSpace(req.Account)
req.Password = strings.TrimSpace(req.Password)
// 验证账号密码
if req.Account == "" || req.Password == "" {
sendJSONResponse(w, false, "账号和密码不能为空", "")
return
}
// 对比账号密码
if req.Account != accountConfig.Account || req.Password != accountConfig.Password {
log.Printf("登录失败: 账号 %s 密码错误", req.Account)
sendJSONResponse(w, false, "账号或密码错误", "")
return
}
// 登录成功,生成令牌
token := generateToken(req.Account)
log.Printf("登录成功: 账号 %s", req.Account)
// 返回成功响应(使用 LoginResponse 结构)
w.Header().Set("Content-Type", "application/json")
resp := LoginResponse{
Success: true,
Token: token,
Message: "登录成功",
}
json.NewEncoder(w).Encode(resp)
}
// checkAuthHandler 检查认证状态
// GET /api/check-auth - 验证令牌是否有效
func checkAuthHandler(w http.ResponseWriter, r *http.Request) {
token := getAuthTokenFromRequest(r)
valid, account := validateToken(token)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"authenticated": valid,
"account": account,
})
}
// uploadHandler 处理签名上传
// POST /upload - 接收 Base64 编码的签名图片并保存
func uploadHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// 解析请求体
var req UploadRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSONResponse(w, false, "无效的请求数据", "")
return
}
// 验证姓名不为空
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
sendJSONResponse(w, false, "名字不能为空", "")
return
}
// 验证签名数据不为空
if req.Image == "" {
sendJSONResponse(w, false, "签名数据为空", "")
return
}
// 去除 Base64 前缀data:image/png;base64,
b64data := req.Image
if idx := strings.Index(b64data, ","); idx != -1 {
b64data = b64data[idx+1:]
}
// 解码 Base64 数据
imgBytes, err := base64.StdEncoding.DecodeString(b64data)
if err != nil {
sendJSONResponse(w, false, "图片数据解码失败", "")
return
}
// 生成安全的文件名
dateStr := time.Now().Format("2006-1-2")
safeName := filepath.Base(req.Name)
// 创建保存目录
saveDir := filepath.Join("uploads", safeName)
if err := os.MkdirAll(saveDir, 0755); err != nil {
log.Printf("创建目录失败: %v", err)
sendJSONResponse(w, false, "服务器存储错误", "")
return
}
// 生成完整的文件路径
fileName := fmt.Sprintf("%s+%s.png", safeName, dateStr)
fullPath := filepath.Join(saveDir, fileName)
// 写入文件
if err := os.WriteFile(fullPath, imgBytes, 0644); err != nil {
log.Printf("写入文件失败: %v", err)
sendJSONResponse(w, false, "保存文件失败", "")
return
}
log.Printf("已保存签名: %s", fullPath)
sendJSONResponse(w, true, "签名保存成功!", fullPath)
}
// signaturesHandler 获取签名列表
// GET /api/signatures - 返回所有签名文件的信息(需要认证)
func signaturesHandler(w http.ResponseWriter, r *http.Request) {
// 验证认证
token := getAuthTokenFromRequest(r)
valid, _ := validateToken(token)
if !valid {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": "未登录或登录已过期",
})
return
}
// 收集所有签名文件
signatures := collectSignatureFiles()
// 返回结果
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"signatures": signatures,
})
}
// downloadHandler 下载签名文件
// GET /download?path=xxx - 下载指定的签名图片(需要认证)
func downloadHandler(w http.ResponseWriter, r *http.Request) {
// 验证认证
token := getAuthTokenFromRequest(r)
valid, _ := validateToken(token)
if !valid {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": "未登录或登录已过期",
})
return
}
// 获取文件路径参数
filePath := r.URL.Query().Get("path")
if filePath == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": "缺少文件路径参数",
})
return
}
// 安全检查:确保路径在 uploads 目录内,防止路径穿越攻击
absPath, err := filepath.Abs(filePath)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": "无效的文件路径",
})
return
}
// 获取 uploads 目录的绝对路径
uploadsAbs, _ := filepath.Abs("uploads")
if !strings.HasPrefix(absPath, uploadsAbs) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": "无权访问该文件",
})
return
}
// 检查文件是否存在
if _, err := os.Stat(absPath); os.IsNotExist(err) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": "文件不存在",
})
return
}
// 设置响应头,触发浏览器下载
fileName := filepath.Base(absPath)
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName))
w.Header().Set("Content-Type", "image/png")
// 提供文件
http.ServeFile(w, r, absPath)
}
// ============================================================================
// 辅助函数
// ============================================================================
// sendJSONResponse 发送 JSON 格式的响应(上传接口专用)
func sendJSONResponse(w http.ResponseWriter, success bool, message string, path string) {
w.Header().Set("Content-Type", "application/json")
resp := UploadResponse{
Success: success,
Message: message,
Path: path,
}
json.NewEncoder(w).Encode(resp)
}
// collectSignatureFiles 收集 uploads 目录下所有签名文件
// 返回按修改时间倒序排列的签名文件列表
func collectSignatureFiles() []SignatureItem {
var signatures []SignatureItem
// 检查 uploads 目录是否存在
if _, err := os.Stat("uploads"); os.IsNotExist(err) {
return signatures
}
// 遍历 uploads 目录下的所有子目录(每个子目录对应一个签署人)
err := filepath.Walk("uploads", func(path string, info fs.FileInfo, err error) error {
if err != nil {
return nil // 跳过错误的文件
}
// 只处理 .png 文件
if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), ".png") {
// 从文件路径中提取签署人姓名(目录名)
relPath, _ := filepath.Rel("uploads", path)
parts := strings.SplitN(relPath, string(filepath.Separator), 2)
name := ""
if len(parts) > 0 {
name = parts[0]
}
// 从文件名中提取日期
// 文件名格式:<姓名>+<日期>.png
baseName := strings.TrimSuffix(info.Name(), ".png")
datePart := ""
if idx := strings.LastIndex(baseName, "+"); idx != -1 {
datePart = baseName[idx+1:]
}
signatures = append(signatures, SignatureItem{
Name: name,
Path: path,
Date: datePart,
Size: info.Size(),
})
}
return nil
})
if err != nil {
log.Printf("遍历签名目录失败: %v", err)
}
// 按修改时间倒序排列
sort.Slice(signatures, func(i, j int) bool {
fileInfoI, _ := os.Stat(signatures[i].Path)
fileInfoJ, _ := os.Stat(signatures[j].Path)
if fileInfoI == nil || fileInfoJ == nil {
return false
}
return fileInfoI.ModTime().After(fileInfoJ.ModTime())
})
return signatures
}