560 lines
16 KiB
Go
560 lines
16 KiB
Go
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
|
||
}
|