feat: 初始化 kenaito-notebook 桌面记事本项目
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"kenaito-notebook-server/services"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port int
|
||||
BaseURL string
|
||||
SMTP services.EmailConfig
|
||||
}
|
||||
|
||||
func loadProperties(filename string) map[string]string {
|
||||
props := make(map[string]string)
|
||||
|
||||
// Try executable directory first
|
||||
exe, err := os.Executable()
|
||||
if err == nil {
|
||||
exeDir := filepath.Dir(exe)
|
||||
props = loadFromFile(filepath.Join(exeDir, filename))
|
||||
if len(props) > 0 {
|
||||
return props
|
||||
}
|
||||
}
|
||||
|
||||
// Try working directory
|
||||
props = loadFromFile(filename)
|
||||
return props
|
||||
}
|
||||
|
||||
func loadFromFile(path string) map[string]string {
|
||||
props := make(map[string]string)
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return props
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if idx := strings.Index(line, "="); idx > 0 {
|
||||
key := strings.TrimSpace(line[:idx])
|
||||
value := strings.TrimSpace(line[idx+1:])
|
||||
props[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return props
|
||||
}
|
||||
|
||||
func DefaultConfig() *Config {
|
||||
props := loadProperties("config.properties")
|
||||
|
||||
port := 8080
|
||||
if p := props["server.port"]; p != "" {
|
||||
if v, err := strconv.Atoi(p); err == nil {
|
||||
port = v
|
||||
}
|
||||
}
|
||||
|
||||
baseURL := props["server.base_url"]
|
||||
if baseURL == "" {
|
||||
baseURL = fmt.Sprintf("http://localhost:%d", port)
|
||||
}
|
||||
|
||||
return &Config{
|
||||
Port: port,
|
||||
BaseURL: baseURL,
|
||||
SMTP: services.EmailConfig{
|
||||
Host: props["smtp.host"],
|
||||
Port: props["smtp.port"],
|
||||
Username: props["smtp.username"],
|
||||
Password: props["smtp.password"],
|
||||
From: props["smtp.from"],
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
module kenaito-notebook-server
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/yuin/goldmark v1.8.2
|
||||
golang.org/x/crypto v0.48.0
|
||||
modernc.org/sqlite v1.53.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
modernc.org/libc v1.73.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
|
||||
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
|
||||
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
|
||||
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
|
||||
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
|
||||
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,338 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"kenaito-notebook-server/services"
|
||||
)
|
||||
|
||||
func isValidEmail(email string) bool {
|
||||
_, err := mail.ParseAddress(email)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
type AuthHandler struct {
|
||||
db *services.Database
|
||||
auth *services.AuthService
|
||||
email *services.EmailService
|
||||
verify *services.VerificationService
|
||||
}
|
||||
|
||||
func NewAuthHandler(db *services.Database, auth *services.AuthService, email *services.EmailService, verify *services.VerificationService) *AuthHandler {
|
||||
return &AuthHandler{db: db, auth: auth, email: email, verify: verify}
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
Email string `json:"email" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Code string `json:"code" binding:"required"`
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type SendCodeRequest struct {
|
||||
Email string `json:"email" binding:"required"`
|
||||
Purpose string `json:"purpose" binding:"required"`
|
||||
}
|
||||
|
||||
type VerifyCodeRequest struct {
|
||||
Email string `json:"email" binding:"required"`
|
||||
Code string `json:"code" binding:"required"`
|
||||
Purpose string `json:"purpose" binding:"required"`
|
||||
}
|
||||
|
||||
type ResetPasswordRequest struct {
|
||||
Email string `json:"email" binding:"required"`
|
||||
Code string `json:"code" binding:"required"`
|
||||
NewPassword string `json:"newPassword" binding:"required"`
|
||||
}
|
||||
|
||||
// SendCode 发送验证码
|
||||
func (h *AuthHandler) SendCode(c *gin.Context) {
|
||||
var req SendCodeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
log.Printf("[Auth] SendCode bind error: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写完整信息"})
|
||||
return
|
||||
}
|
||||
|
||||
if !isValidEmail(req.Email) {
|
||||
log.Printf("[Auth] SendCode invalid email: %s", req.Email)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "邮箱格式不正确"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Purpose != "register" && req.Purpose != "reset" {
|
||||
log.Printf("[Auth] SendCode invalid purpose: %s", req.Purpose)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求类型"})
|
||||
return
|
||||
}
|
||||
|
||||
// 注册时检查邮箱是否已注册
|
||||
if req.Purpose == "register" {
|
||||
existing, _ := h.db.GetUserByEmail(req.Email)
|
||||
if existing != nil {
|
||||
log.Printf("[Auth] SendCode email already registered: %s", req.Email)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "邮箱已被注册"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 重置密码时检查邮箱是否存在
|
||||
if req.Purpose == "reset" {
|
||||
existing, _ := h.db.GetUserByEmail(req.Email)
|
||||
if existing == nil {
|
||||
log.Printf("[Auth] SendCode email not found for reset: %s", req.Email)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "邮箱未注册"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 限频检查
|
||||
if !h.verify.CheckRateLimit(req.Email, req.Purpose) {
|
||||
log.Printf("[Auth] SendCode rate limit exceeded: %s, purpose: %s", req.Email, req.Purpose)
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": "发送过于频繁,请 60 秒后重试"})
|
||||
return
|
||||
}
|
||||
|
||||
code := h.verify.GenerateCode(req.Email, req.Purpose)
|
||||
if err := h.email.SendVerificationCode(req.Email, code, req.Purpose); err != nil {
|
||||
log.Printf("[Auth] SendCode send email failed: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "发送验证码失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "验证码已发送"})
|
||||
}
|
||||
|
||||
// VerifyCode 验证验证码
|
||||
func (h *AuthHandler) VerifyCode(c *gin.Context) {
|
||||
var req VerifyCodeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
log.Printf("[Auth] VerifyCode bind error: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写完整信息"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.verify.VerifyCode(req.Email, req.Code, req.Purpose); err != nil {
|
||||
log.Printf("[Auth] VerifyCode failed: email=%s, purpose=%s, err=%v", req.Email, req.Purpose, err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "验证码正确"})
|
||||
}
|
||||
|
||||
// Register 用户注册
|
||||
func (h *AuthHandler) Register(c *gin.Context) {
|
||||
var req RegisterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
log.Printf("[Auth] Register bind error: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写完整信息"})
|
||||
return
|
||||
}
|
||||
|
||||
if !isValidEmail(req.Email) {
|
||||
log.Printf("[Auth] Register invalid email: %s", req.Email)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "邮箱格式不正确"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查邮箱是否已注册
|
||||
existing, _ := h.db.GetUserByEmail(req.Email)
|
||||
if existing != nil {
|
||||
log.Printf("[Auth] Register email already registered: %s", req.Email)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "邮箱已被注册"})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证并消耗验证码
|
||||
if err := h.verify.UseCode(req.Email, req.Code, "register"); err != nil {
|
||||
log.Printf("[Auth] Register use code failed: email=%s, err=%v", req.Email, err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 哈希密码
|
||||
hashedPassword, err := h.auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
log.Printf("[Auth] Register hash password failed: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "注册失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 创建用户
|
||||
user, err := h.db.CreateUser(req.Email, hashedPassword)
|
||||
if err != nil {
|
||||
log.Printf("[Auth] Register create user failed: email=%s, err=%v", req.Email, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "注册失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 生成 token
|
||||
token, err := h.auth.GenerateToken(user.ID, user.Email)
|
||||
if err != nil {
|
||||
log.Printf("[Auth] Register generate token failed: userID=%d, err=%v", user.ID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "注册失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"token": token,
|
||||
"user": user,
|
||||
})
|
||||
}
|
||||
|
||||
// Login 用户登录
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
var req LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
log.Printf("[Auth] Login bind error: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写邮箱和密码"})
|
||||
return
|
||||
}
|
||||
|
||||
if !isValidEmail(req.Email) {
|
||||
log.Printf("[Auth] Login invalid email: %s", req.Email)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "邮箱格式不正确"})
|
||||
return
|
||||
}
|
||||
|
||||
// 查找用户
|
||||
user, err := h.db.GetUserByEmail(req.Email)
|
||||
if err != nil {
|
||||
log.Printf("[Auth] Login user not found: %s, err=%v", req.Email, err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "邮箱或密码错误"})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
if !h.auth.CheckPassword(req.Password, user.HashedPassword) {
|
||||
log.Printf("[Auth] Login password mismatch: email=%s", req.Email)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "邮箱或密码错误"})
|
||||
return
|
||||
}
|
||||
|
||||
// 生成 token
|
||||
token, err := h.auth.GenerateToken(user.ID, user.Email)
|
||||
if err != nil {
|
||||
log.Printf("[Auth] Login generate token failed: userID=%d, err=%v", user.ID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "登录失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": token,
|
||||
"user": user,
|
||||
})
|
||||
}
|
||||
|
||||
// ResetPassword 重置密码
|
||||
func (h *AuthHandler) ResetPassword(c *gin.Context) {
|
||||
var req ResetPasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
log.Printf("[Auth] ResetPassword bind error: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写完整信息"})
|
||||
return
|
||||
}
|
||||
|
||||
if !isValidEmail(req.Email) {
|
||||
log.Printf("[Auth] ResetPassword invalid email: %s", req.Email)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "邮箱格式不正确"})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证并消耗验证码
|
||||
if err := h.verify.UseCode(req.Email, req.Code, "reset"); err != nil {
|
||||
log.Printf("[Auth] ResetPassword use code failed: email=%s, err=%v", req.Email, err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 查找用户
|
||||
user, err := h.db.GetUserByEmail(req.Email)
|
||||
if err != nil {
|
||||
log.Printf("[Auth] ResetPassword user not found: %s, err=%v", req.Email, err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
// 哈希新密码
|
||||
hashedPassword, err := h.auth.HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
log.Printf("[Auth] ResetPassword hash password failed: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "重置失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 更新密码
|
||||
if err := h.db.UpdatePassword(user.ID, hashedPassword); err != nil {
|
||||
log.Printf("[Auth] ResetPassword update password failed: userID=%d, err=%v", user.ID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "重置失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "密码重置成功"})
|
||||
}
|
||||
|
||||
// GetMe 获取当前用户信息
|
||||
func (h *AuthHandler) GetMe(c *gin.Context) {
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
log.Printf("[Auth] GetMe userID not found in context")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.db.GetUserByID(userID.(int64))
|
||||
if err != nil {
|
||||
log.Printf("[Auth] GetMe user not found: userID=%v, err=%v", userID, err)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, user)
|
||||
}
|
||||
|
||||
// AuthMiddleware JWT 认证中间件
|
||||
func AuthMiddleware(auth *services.AuthService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
log.Printf("[Auth] Middleware missing Authorization header: ip=%s", c.ClientIP())
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 提取 Bearer token
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
log.Printf("[Auth] Middleware invalid auth format: ip=%s", c.ClientIP())
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "无效的认证格式"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
payload, err := auth.ValidateToken(parts[1])
|
||||
if err != nil {
|
||||
log.Printf("[Auth] Middleware token validation failed: ip=%s, err=%v", c.ClientIP(), err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 将用户信息存入上下文
|
||||
c.Set("userID", payload.UserID)
|
||||
c.Set("email", payload.Email)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"kenaito-notebook-server/models"
|
||||
"kenaito-notebook-server/services"
|
||||
)
|
||||
|
||||
type ExportHandler struct {
|
||||
db *services.Database
|
||||
exportSvc *services.ExportService
|
||||
}
|
||||
|
||||
func NewExportHandler(db *services.Database, exportSvc *services.ExportService) *ExportHandler {
|
||||
return &ExportHandler{db: db, exportSvc: exportSvc}
|
||||
}
|
||||
|
||||
func (h *ExportHandler) ExportHTML(c *gin.Context) {
|
||||
var req struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Content string `json:"content" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
outputPath, err := h.getTempExportPath(req.Title, ".html")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.exportSvc.ExportHTML(req.Title, req.Content, outputPath); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"filePath": outputPath})
|
||||
}
|
||||
|
||||
func (h *ExportHandler) ExportMarkdown(c *gin.Context) {
|
||||
var req struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Content string `json:"content" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
outputPath, err := h.getTempExportPath(req.Title, ".md")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.exportSvc.ExportMarkdown(req.Title, req.Content, outputPath); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"filePath": outputPath})
|
||||
}
|
||||
|
||||
func (h *ExportHandler) ExportFolderZip(c *gin.Context) {
|
||||
userID := c.GetInt64("userID")
|
||||
|
||||
folderID, err := strconv.ParseInt(c.Param("folderId"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid folder id"})
|
||||
return
|
||||
}
|
||||
|
||||
folderTree, err := h.db.GetFolderTree(userID, folderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
noteInfoTree := convertNoteToNoteInfo(folderTree)
|
||||
|
||||
outputPath, err := h.getTempExportPath(folderTree.Title, ".zip")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.exportSvc.ExportFolderZip(folderTree.Title, noteInfoTree.Children, outputPath); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"filePath": outputPath})
|
||||
}
|
||||
|
||||
func (h *ExportHandler) ExportAllZip(c *gin.Context) {
|
||||
userID := c.GetInt64("userID")
|
||||
|
||||
allNotesTree, err := h.db.GetAllNotesTree(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
noteInfos := make([]*services.NoteInfo, len(allNotesTree))
|
||||
for i, noteTree := range allNotesTree {
|
||||
noteInfos[i] = convertNoteTreeToNoteInfo(noteTree)
|
||||
}
|
||||
|
||||
outputPath, err := h.getTempExportPath("所有笔记", ".zip")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.exportSvc.ExportAllZip(noteInfos, outputPath); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"filePath": outputPath})
|
||||
}
|
||||
|
||||
func (h *ExportHandler) getTempExportPath(title, ext string) (string, error) {
|
||||
tmpDir := filepath.Join(os.TempDir(), "kenaito-notebook-export")
|
||||
if err := os.MkdirAll(tmpDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create temp export dir: %w", err)
|
||||
}
|
||||
|
||||
safeName := h.exportSvc.GetSafeFilename(title)
|
||||
return filepath.Join(tmpDir, fmt.Sprintf("%s%s", safeName, ext)), nil
|
||||
}
|
||||
|
||||
func convertNoteToNoteInfo(note *models.Note) *services.NoteInfo {
|
||||
if note == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
info := &services.NoteInfo{
|
||||
Title: note.Title,
|
||||
Content: note.Content,
|
||||
IsFolder: note.IsFolder,
|
||||
}
|
||||
|
||||
if note.Children != nil && len(note.Children) > 0 {
|
||||
info.Children = make([]*services.NoteInfo, len(note.Children))
|
||||
for i, child := range note.Children {
|
||||
info.Children[i] = convertNoteToNoteInfo(child)
|
||||
}
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
func convertNoteTreeToNoteInfo(noteTree *models.NoteTree) *services.NoteInfo {
|
||||
if noteTree == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
info := &services.NoteInfo{
|
||||
Title: noteTree.Title,
|
||||
Content: noteTree.Content,
|
||||
IsFolder: noteTree.IsFolder,
|
||||
}
|
||||
|
||||
if noteTree.Children != nil && len(noteTree.Children) > 0 {
|
||||
info.Children = make([]*services.NoteInfo, len(noteTree.Children))
|
||||
for i, child := range noteTree.Children {
|
||||
info.Children[i] = convertNoteTreeToNoteInfo(child)
|
||||
}
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"kenaito-notebook-server/services"
|
||||
)
|
||||
|
||||
type FileHandler struct {
|
||||
fileSvc *services.FileService
|
||||
}
|
||||
|
||||
func NewFileHandler(fileSvc *services.FileService) *FileHandler {
|
||||
return &FileHandler{fileSvc: fileSvc}
|
||||
}
|
||||
|
||||
func (h *FileHandler) SaveImage(c *gin.Context) {
|
||||
var req struct {
|
||||
Data string `json:"data" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
url, err := h.fileSvc.SaveImage(req.Data)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"url": url})
|
||||
}
|
||||
|
||||
func (h *FileHandler) SaveFile(c *gin.Context) {
|
||||
var req struct {
|
||||
Data string `json:"data" binding:"required"`
|
||||
Filename string `json:"filename" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
url, err := h.fileSvc.SaveFile(req.Data, req.Filename)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"url": url})
|
||||
}
|
||||
|
||||
func (h *FileHandler) GetFileServerURL(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"url": h.fileSvc.GetBaseURL()})
|
||||
}
|
||||
|
||||
func (h *FileHandler) GetAssetsPath(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"path": h.fileSvc.GetAssetsPath()})
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"kenaito-notebook-server/services"
|
||||
)
|
||||
|
||||
type NoteHandler struct {
|
||||
db *services.Database
|
||||
}
|
||||
|
||||
func NewNoteHandler(db *services.Database) *NoteHandler {
|
||||
return &NoteHandler{db: db}
|
||||
}
|
||||
|
||||
func (h *NoteHandler) GetNoteTree(c *gin.Context) {
|
||||
userID := c.GetInt64("userID")
|
||||
result, err := h.db.GetNoteTree(userID)
|
||||
if err != nil {
|
||||
log.Printf("[API] GetNoteTree error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *NoteHandler) GetNote(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
note, err := h.db.GetNote(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, note)
|
||||
}
|
||||
|
||||
func (h *NoteHandler) CreateNote(c *gin.Context) {
|
||||
userID := c.GetInt64("userID")
|
||||
|
||||
var req struct {
|
||||
ParentID int64 `json:"parentId"`
|
||||
Title string `json:"title"`
|
||||
IsFolder bool `json:"isFolder"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
note, err := h.db.CreateNote(userID, req.ParentID, req.Title, req.IsFolder)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, note)
|
||||
}
|
||||
|
||||
func (h *NoteHandler) UpdateNote(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.UpdateNote(id, req.Title, req.Content, req.Tag); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "updated"})
|
||||
}
|
||||
|
||||
func (h *NoteHandler) DeleteNote(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.DeleteNote(id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||
}
|
||||
|
||||
func (h *NoteHandler) SearchNotes(c *gin.Context) {
|
||||
userID := c.GetInt64("userID")
|
||||
|
||||
keyword := c.Query("q")
|
||||
if keyword == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "keyword is required"})
|
||||
return
|
||||
}
|
||||
|
||||
notes, err := h.db.SearchNotes(userID, keyword)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, notes)
|
||||
}
|
||||
|
||||
func (h *NoteHandler) GetTrashNotes(c *gin.Context) {
|
||||
userID := c.GetInt64("userID")
|
||||
|
||||
notes, err := h.db.GetTrashNotes(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, notes)
|
||||
}
|
||||
|
||||
func (h *NoteHandler) RestoreNote(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.RestoreNote(id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "restored"})
|
||||
}
|
||||
|
||||
func (h *NoteHandler) PermanentDeleteNote(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.PermanentDeleteNote(id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "permanently deleted"})
|
||||
}
|
||||
|
||||
func (h *NoteHandler) EmptyTrash(c *gin.Context) {
|
||||
userID := c.GetInt64("userID")
|
||||
|
||||
if err := h.db.EmptyTrash(userID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "trash emptied"})
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"kenaito-notebook-server/handlers"
|
||||
"kenaito-notebook-server/services"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
db, err := services.NewDatabase()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
log.Println("[Server] Database initialized")
|
||||
|
||||
fileSvc, err := services.NewFileService(cfg.BaseURL)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize file service: %v", err)
|
||||
}
|
||||
log.Println("[Server] File service initialized")
|
||||
|
||||
exportSvc := services.NewExportService()
|
||||
authSvc := services.NewAuthService()
|
||||
emailSvc := services.NewEmailService(cfg.SMTP)
|
||||
verifySvc := services.NewVerificationService()
|
||||
log.Println("[Server] Services initialized")
|
||||
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.Default()
|
||||
|
||||
// CORS
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(200)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
|
||||
noteHandler := handlers.NewNoteHandler(db)
|
||||
fileHandler := handlers.NewFileHandler(fileSvc)
|
||||
exportHandler := handlers.NewExportHandler(db, exportSvc)
|
||||
authHandler := handlers.NewAuthHandler(db, authSvc, emailSvc, verifySvc)
|
||||
|
||||
// 静态文件服务(图片、附件等)
|
||||
r.Static("/api/files/assets", fileSvc.GetAssetsPath())
|
||||
|
||||
api := r.Group("/api")
|
||||
{
|
||||
// 认证路由(无需登录)
|
||||
auth := api.Group("/auth")
|
||||
{
|
||||
auth.POST("/register", authHandler.Register)
|
||||
auth.POST("/login", authHandler.Login)
|
||||
auth.POST("/send-code", authHandler.SendCode)
|
||||
auth.POST("/verify-code", authHandler.VerifyCode)
|
||||
auth.POST("/reset-password", authHandler.ResetPassword)
|
||||
}
|
||||
|
||||
// 需要登录的路由
|
||||
protected := api.Group("")
|
||||
protected.Use(handlers.AuthMiddleware(authSvc))
|
||||
{
|
||||
// 用户信息
|
||||
protected.GET("/auth/me", authHandler.GetMe)
|
||||
|
||||
// 笔记管理
|
||||
notes := protected.Group("/notes")
|
||||
{
|
||||
notes.GET("/tree", noteHandler.GetNoteTree)
|
||||
notes.GET("/search", noteHandler.SearchNotes)
|
||||
notes.GET("/:id", noteHandler.GetNote)
|
||||
notes.POST("", noteHandler.CreateNote)
|
||||
notes.PUT("/:id", noteHandler.UpdateNote)
|
||||
notes.DELETE("/:id", noteHandler.DeleteNote)
|
||||
}
|
||||
|
||||
// 回收站
|
||||
trash := protected.Group("/trash")
|
||||
{
|
||||
trash.GET("", noteHandler.GetTrashNotes)
|
||||
trash.POST("/:id/restore", noteHandler.RestoreNote)
|
||||
trash.DELETE("/:id", noteHandler.PermanentDeleteNote)
|
||||
trash.DELETE("", noteHandler.EmptyTrash)
|
||||
}
|
||||
|
||||
// 文件管理
|
||||
files := protected.Group("/files")
|
||||
{
|
||||
files.POST("/image", fileHandler.SaveImage)
|
||||
files.POST("/upload", fileHandler.SaveFile)
|
||||
files.GET("/server-url", fileHandler.GetFileServerURL)
|
||||
files.GET("/assets-path", fileHandler.GetAssetsPath)
|
||||
}
|
||||
|
||||
// 导出
|
||||
export := protected.Group("/export")
|
||||
{
|
||||
export.POST("/html", exportHandler.ExportHTML)
|
||||
export.POST("/markdown", exportHandler.ExportMarkdown)
|
||||
export.POST("/folder-zip/:folderId", exportHandler.ExportFolderZip)
|
||||
export.POST("/all-zip", exportHandler.ExportAllZip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf(":%d", cfg.Port)
|
||||
log.Printf("[Server] Starting on %s", addr)
|
||||
if err := r.Run(addr); err != nil {
|
||||
log.Fatalf("Failed to start server: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Note struct {
|
||||
ID int64 `json:"id"`
|
||||
ParentID int64 `json:"parentId"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
IsFolder bool `json:"isFolder"`
|
||||
Tag string `json:"tag"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt *time.Time `json:"deletedAt"`
|
||||
Children []*Note `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
type NoteTree struct {
|
||||
Note
|
||||
Children []*NoteTree `json:"children"`
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
Email string `json:"email"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("邮箱或密码错误")
|
||||
ErrUserExists = errors.New("邮箱已被注册")
|
||||
ErrInvalidToken = errors.New("无效的 token")
|
||||
ErrTokenExpired = errors.New("token 已过期")
|
||||
)
|
||||
|
||||
// JWT 密钥,生产环境应从环境变量或配置文件读取
|
||||
var jwtSecret = []byte("kenaito-notebook-jwt-secret-2026")
|
||||
|
||||
type jwtHeader struct {
|
||||
Alg string `json:"alg"`
|
||||
Typ string `json:"typ"`
|
||||
}
|
||||
|
||||
type jwtPayload struct {
|
||||
UserID int64 `json:"userId"`
|
||||
Email string `json:"email"`
|
||||
Exp int64 `json:"exp"`
|
||||
Iat int64 `json:"iat"`
|
||||
}
|
||||
|
||||
type AuthService struct{}
|
||||
|
||||
func NewAuthService() *AuthService {
|
||||
return &AuthService{}
|
||||
}
|
||||
|
||||
// HashPassword 使用 bcrypt 哈希密码
|
||||
func (s *AuthService) HashPassword(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
// CheckPassword 验证密码
|
||||
func (s *AuthService) CheckPassword(password, hash string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// GenerateToken 生成 JWT token
|
||||
func (s *AuthService) GenerateToken(userID int64, email string) (string, error) {
|
||||
now := time.Now()
|
||||
payload := jwtPayload{
|
||||
UserID: userID,
|
||||
Email: email,
|
||||
Exp: now.Add(7 * 24 * time.Hour).Unix(), // 7 天过期
|
||||
Iat: now.Unix(),
|
||||
}
|
||||
|
||||
// Header
|
||||
header := jwtHeader{Alg: "HS256", Typ: "JWT"}
|
||||
headerJSON, err := json.Marshal(header)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
|
||||
|
||||
// Payload
|
||||
payloadJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON)
|
||||
|
||||
// 签名
|
||||
signingInput := headerB64 + "." + payloadB64
|
||||
mac := hmac.New(sha256.New, jwtSecret)
|
||||
mac.Write([]byte(signingInput))
|
||||
signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
|
||||
return signingInput + "." + signature, nil
|
||||
}
|
||||
|
||||
// ValidateToken 验证 JWT token,返回 payload
|
||||
func (s *AuthService) ValidateToken(tokenStr string) (*jwtPayload, error) {
|
||||
parts := strings.Split(tokenStr, ".")
|
||||
if len(parts) != 3 {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
signingInput := parts[0] + "." + parts[1]
|
||||
|
||||
// 验证签名
|
||||
mac := hmac.New(sha256.New, jwtSecret)
|
||||
mac.Write([]byte(signingInput))
|
||||
expectedSig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
|
||||
if !hmac.Equal([]byte(parts[2]), []byte(expectedSig)) {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
// 解析 payload
|
||||
payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
var payload jwtPayload
|
||||
if err := json.Unmarshal(payloadJSON, &payload); err != nil {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
// 检查过期
|
||||
if time.Now().Unix() > payload.Exp {
|
||||
return nil, ErrTokenExpired
|
||||
}
|
||||
|
||||
return &payload, nil
|
||||
}
|
||||
|
||||
// ExtractTokenID 从 token 字符串中提取用户 ID(不验证签名,仅用于快速检查)
|
||||
func ExtractTokenID(token string) (int64, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return 0, fmt.Errorf("invalid token format")
|
||||
}
|
||||
payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var payload jwtPayload
|
||||
if err := json.Unmarshal(payloadJSON, &payload); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return payload.UserID, nil
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"kenaito-notebook-server/models"
|
||||
)
|
||||
|
||||
type Database struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewDatabase() (*Database, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
|
||||
dbDir := filepath.Join(homeDir, ".kenaito-notebook")
|
||||
if err := os.MkdirAll(dbDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create data directory: %w", err)
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(dbDir, "notes.db")
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("failed to enable WAL mode: %w", err)
|
||||
}
|
||||
|
||||
if err := createTables(db); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Database{db: db}, nil
|
||||
}
|
||||
|
||||
func createTables(db *sql.DB) error {
|
||||
query := `
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
parent_id INTEGER DEFAULT 0,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
content TEXT DEFAULT '',
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
is_folder BOOLEAN DEFAULT FALSE,
|
||||
tag TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at DATETIME DEFAULT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_parent_id ON notes(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_sort_order ON notes(sort_order);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_deleted_at ON notes(deleted_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_user_id ON notes(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
hashed_password TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`
|
||||
|
||||
// 迁移:给已有表加 deleted_at 列
|
||||
var count int
|
||||
err := db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('notes') WHERE name='deleted_at'").Scan(&count)
|
||||
if err == nil && count == 0 {
|
||||
_, err = db.Exec("ALTER TABLE notes ADD COLUMN deleted_at DATETIME DEFAULT NULL")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 迁移:给已有表加 user_id 列
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('notes') WHERE name='user_id'").Scan(&count)
|
||||
if err == nil && count == 0 {
|
||||
_, err = db.Exec("ALTER TABLE notes ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_, err = db.Exec(query)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Database) Close() error {
|
||||
return d.db.Close()
|
||||
}
|
||||
|
||||
// --- 笔记相关方法 ---
|
||||
|
||||
func (d *Database) CreateNote(userID int64, parentID int64, title string, isFolder bool) (*models.Note, error) {
|
||||
now := time.Now()
|
||||
result, err := d.db.Exec(`
|
||||
INSERT INTO notes (user_id, parent_id, title, is_folder, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`, userID, parentID, title, isFolder, now, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &models.Note{
|
||||
ID: id,
|
||||
ParentID: parentID,
|
||||
Title: title,
|
||||
IsFolder: isFolder,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetNote(id int64) (*models.Note, error) {
|
||||
note := &models.Note{}
|
||||
err := d.db.QueryRow(`
|
||||
SELECT id, parent_id, title, content, sort_order, is_folder, tag, created_at, updated_at, deleted_at
|
||||
FROM notes WHERE id = ?
|
||||
`, id).Scan(
|
||||
¬e.ID, ¬e.ParentID, ¬e.Title, ¬e.Content,
|
||||
¬e.SortOrder, ¬e.IsFolder, ¬e.Tag,
|
||||
¬e.CreatedAt, ¬e.UpdatedAt, ¬e.DeletedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return note, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetNotesByParentID(userID, parentID int64) ([]*models.Note, error) {
|
||||
rows, err := d.db.Query(`
|
||||
SELECT id, parent_id, title, content, sort_order, is_folder, tag, created_at, updated_at, deleted_at
|
||||
FROM notes WHERE user_id = ? AND parent_id = ? AND deleted_at IS NULL
|
||||
ORDER BY sort_order, created_at
|
||||
`, userID, parentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var notes []*models.Note
|
||||
for rows.Next() {
|
||||
note := &models.Note{}
|
||||
err := rows.Scan(
|
||||
¬e.ID, ¬e.ParentID, ¬e.Title, ¬e.Content,
|
||||
¬e.SortOrder, ¬e.IsFolder, ¬e.Tag,
|
||||
¬e.CreatedAt, ¬e.UpdatedAt, ¬e.DeletedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
notes = append(notes, note)
|
||||
}
|
||||
return notes, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAllNotes(userID int64) ([]*models.Note, error) {
|
||||
rows, err := d.db.Query(`
|
||||
SELECT id, parent_id, title, content, sort_order, is_folder, tag, created_at, updated_at, deleted_at
|
||||
FROM notes WHERE user_id = ? AND deleted_at IS NULL AND is_folder = FALSE
|
||||
ORDER BY sort_order, created_at
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var notes []*models.Note
|
||||
for rows.Next() {
|
||||
note := &models.Note{}
|
||||
err := rows.Scan(
|
||||
¬e.ID, ¬e.ParentID, ¬e.Title, ¬e.Content,
|
||||
¬e.SortOrder, ¬e.IsFolder, ¬e.Tag,
|
||||
¬e.CreatedAt, ¬e.UpdatedAt, ¬e.DeletedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
notes = append(notes, note)
|
||||
}
|
||||
return notes, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAllNotesWithFolders(userID int64) ([]*models.Note, error) {
|
||||
rows, err := d.db.Query(`
|
||||
SELECT id, parent_id, title, content, sort_order, is_folder, tag, created_at, updated_at, deleted_at
|
||||
FROM notes WHERE user_id = ? AND deleted_at IS NULL
|
||||
ORDER BY sort_order, created_at
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var notes []*models.Note
|
||||
for rows.Next() {
|
||||
note := &models.Note{}
|
||||
err := rows.Scan(
|
||||
¬e.ID, ¬e.ParentID, ¬e.Title, ¬e.Content,
|
||||
¬e.SortOrder, ¬e.IsFolder, ¬e.Tag,
|
||||
¬e.CreatedAt, ¬e.UpdatedAt, ¬e.DeletedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
notes = append(notes, note)
|
||||
}
|
||||
return notes, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetFolderTree(userID, folderID int64) (*models.Note, error) {
|
||||
folder, err := d.GetNote(folderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
children, err := d.GetNotesByParentID(userID, folderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
folder.Children = make([]*models.Note, len(children))
|
||||
for i, child := range children {
|
||||
if child.IsFolder {
|
||||
childFolder, err := d.GetFolderTree(userID, child.ID)
|
||||
if err == nil {
|
||||
folder.Children[i] = childFolder
|
||||
} else {
|
||||
folder.Children[i] = child
|
||||
}
|
||||
} else {
|
||||
folder.Children[i] = child
|
||||
}
|
||||
}
|
||||
|
||||
return folder, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAllNotesTree(userID int64) ([]*models.NoteTree, error) {
|
||||
notes, err := d.GetAllNotesWithFolders(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buildTree(notes, 0), nil
|
||||
}
|
||||
|
||||
func (d *Database) UpdateNote(id int64, title, content, tag string) error {
|
||||
now := time.Now()
|
||||
_, err := d.db.Exec(`
|
||||
UPDATE notes SET title = ?, content = ?, tag = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`, title, content, tag, now, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Database) DeleteNote(id int64) error {
|
||||
now := time.Now()
|
||||
|
||||
err := d.softDeleteChildren(id, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = d.db.Exec("UPDATE notes SET deleted_at = ? WHERE id = ?", now, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Database) softDeleteChildren(parentID int64, deletedAt time.Time) error {
|
||||
rows, err := d.db.Query("SELECT id FROM notes WHERE parent_id = ? AND deleted_at IS NULL", parentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.softDeleteChildren(id, deletedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_, err = d.db.Exec("UPDATE notes SET deleted_at = ? WHERE parent_id = ? AND deleted_at IS NULL", deletedAt, parentID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Database) GetNoteTree(userID int64) ([]*models.NoteTree, error) {
|
||||
notes, err := d.getAllNotes(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buildTree(notes, 0), nil
|
||||
}
|
||||
|
||||
func (d *Database) getAllNotes(userID int64) ([]*models.Note, error) {
|
||||
rows, err := d.db.Query(`
|
||||
SELECT id, parent_id, title, content, sort_order, is_folder, tag, created_at, updated_at, deleted_at
|
||||
FROM notes
|
||||
WHERE user_id = ? AND deleted_at IS NULL
|
||||
ORDER BY sort_order, created_at
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var notes []*models.Note
|
||||
for rows.Next() {
|
||||
note := &models.Note{}
|
||||
err := rows.Scan(
|
||||
¬e.ID, ¬e.ParentID, ¬e.Title, ¬e.Content,
|
||||
¬e.SortOrder, ¬e.IsFolder, ¬e.Tag,
|
||||
¬e.CreatedAt, ¬e.UpdatedAt, ¬e.DeletedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
notes = append(notes, note)
|
||||
}
|
||||
return notes, nil
|
||||
}
|
||||
|
||||
func buildTree(notes []*models.Note, parentID int64) []*models.NoteTree {
|
||||
var result []*models.NoteTree
|
||||
for _, note := range notes {
|
||||
if note.ParentID == parentID {
|
||||
node := &models.NoteTree{
|
||||
Note: *note,
|
||||
}
|
||||
node.Children = buildTree(notes, note.ID)
|
||||
result = append(result, node)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (d *Database) SearchNotes(userID int64, keyword string) ([]*models.Note, error) {
|
||||
rows, err := d.db.Query(`
|
||||
SELECT id, parent_id, title, content, sort_order, is_folder, tag, created_at, updated_at, deleted_at
|
||||
FROM notes
|
||||
WHERE user_id = ? AND (title LIKE ? OR content LIKE ?) AND deleted_at IS NULL
|
||||
ORDER BY updated_at DESC
|
||||
`, userID, "%"+keyword+"%", "%"+keyword+"%")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var notes []*models.Note
|
||||
for rows.Next() {
|
||||
note := &models.Note{}
|
||||
err := rows.Scan(
|
||||
¬e.ID, ¬e.ParentID, ¬e.Title, ¬e.Content,
|
||||
¬e.SortOrder, ¬e.IsFolder, ¬e.Tag,
|
||||
¬e.CreatedAt, ¬e.UpdatedAt, ¬e.DeletedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
notes = append(notes, note)
|
||||
}
|
||||
return notes, nil
|
||||
}
|
||||
|
||||
func (d *Database) UpdateSortOrder(id int64, sortOrder int) error {
|
||||
_, err := d.db.Exec("UPDATE notes SET sort_order = ? WHERE id = ?", sortOrder, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Database) GetTrashNotes(userID int64) ([]*models.Note, error) {
|
||||
rows, err := d.db.Query(`
|
||||
SELECT id, parent_id, title, content, sort_order, is_folder, tag, created_at, updated_at, deleted_at
|
||||
FROM notes
|
||||
WHERE user_id = ? AND deleted_at IS NOT NULL
|
||||
ORDER BY deleted_at DESC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var notes []*models.Note
|
||||
for rows.Next() {
|
||||
note := &models.Note{}
|
||||
err := rows.Scan(
|
||||
¬e.ID, ¬e.ParentID, ¬e.Title, ¬e.Content,
|
||||
¬e.SortOrder, ¬e.IsFolder, ¬e.Tag,
|
||||
¬e.CreatedAt, ¬e.UpdatedAt, ¬e.DeletedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
notes = append(notes, note)
|
||||
}
|
||||
return notes, nil
|
||||
}
|
||||
|
||||
func (d *Database) RestoreNote(id int64) error {
|
||||
note, err := d.GetNote(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if note.IsFolder {
|
||||
err = d.restoreChildrenRecursive(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = d.db.Exec("UPDATE notes SET deleted_at = NULL WHERE id = ?", id)
|
||||
} else {
|
||||
_, err = d.db.Exec("UPDATE notes SET deleted_at = NULL, parent_id = 0 WHERE id = ?", id)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Database) restoreChildrenRecursive(parentID int64) error {
|
||||
var parentDeletedAt *time.Time
|
||||
err := d.db.QueryRow("SELECT deleted_at FROM notes WHERE id = ?", parentID).Scan(&parentDeletedAt)
|
||||
if err != nil || parentDeletedAt == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
rows, err := d.db.Query(
|
||||
"SELECT id, is_folder FROM notes WHERE parent_id = ? AND deleted_at = ?",
|
||||
parentID, *parentDeletedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type childInfo struct {
|
||||
id int64
|
||||
isFolder bool
|
||||
}
|
||||
var children []childInfo
|
||||
|
||||
for rows.Next() {
|
||||
var child childInfo
|
||||
if err := rows.Scan(&child.id, &child.isFolder); err != nil {
|
||||
return err
|
||||
}
|
||||
children = append(children, child)
|
||||
}
|
||||
|
||||
for _, child := range children {
|
||||
if child.isFolder {
|
||||
if err := d.restoreChildrenRecursive(child.id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(children) > 0 {
|
||||
_, err = d.db.Exec("UPDATE notes SET deleted_at = NULL WHERE parent_id = ? AND deleted_at = ?", parentID, *parentDeletedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) PermanentDeleteNote(id int64) error {
|
||||
err := d.permanentDeleteChildren(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = d.db.Exec("DELETE FROM notes WHERE id = ?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Database) permanentDeleteChildren(parentID int64) error {
|
||||
rows, err := d.db.Query("SELECT id FROM notes WHERE parent_id = ?", parentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.permanentDeleteChildren(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_, err = d.db.Exec("DELETE FROM notes WHERE parent_id = ?", parentID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Database) EmptyTrash(userID int64) error {
|
||||
_, err := d.db.Exec("DELETE FROM notes WHERE user_id = ? AND deleted_at IS NOT NULL", userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- 用户相关方法 ---
|
||||
|
||||
type UserRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
HashedPassword string `json:"-"`
|
||||
Email string `json:"email"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
func (d *Database) CreateUser(email, hashedPassword string) (*UserRecord, error) {
|
||||
now := time.Now()
|
||||
result, err := d.db.Exec(`
|
||||
INSERT INTO users (email, hashed_password, created_at)
|
||||
VALUES (?, ?, ?)
|
||||
`, email, hashedPassword, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &UserRecord{
|
||||
ID: id,
|
||||
HashedPassword: hashedPassword,
|
||||
Email: email,
|
||||
CreatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetUserByEmail(email string) (*UserRecord, error) {
|
||||
user := &UserRecord{}
|
||||
err := d.db.QueryRow(`
|
||||
SELECT id, hashed_password, email, created_at
|
||||
FROM users WHERE email = ?
|
||||
`, email).Scan(&user.ID, &user.HashedPassword, &user.Email, &user.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetUserByID(id int64) (*UserRecord, error) {
|
||||
user := &UserRecord{}
|
||||
err := d.db.QueryRow(`
|
||||
SELECT id, hashed_password, email, created_at
|
||||
FROM users WHERE id = ?
|
||||
`, id).Scan(&user.ID, &user.HashedPassword, &user.Email, &user.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (d *Database) UpdatePassword(id int64, hashedPassword string) error {
|
||||
_, err := d.db.Exec("UPDATE users SET hashed_password = ? WHERE id = ?", hashedPassword, id)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type EmailConfig struct {
|
||||
Host string
|
||||
Port string
|
||||
Username string
|
||||
Password string
|
||||
From string
|
||||
}
|
||||
|
||||
type EmailService struct {
|
||||
cfg EmailConfig
|
||||
configured bool
|
||||
}
|
||||
|
||||
func NewEmailService(cfg EmailConfig) *EmailService {
|
||||
configured := cfg.Host != "" && cfg.Username != "" && cfg.Password != ""
|
||||
if !configured {
|
||||
log.Println("[Email] SMTP not configured, will print codes to console")
|
||||
}
|
||||
return &EmailService{cfg: cfg, configured: configured}
|
||||
}
|
||||
|
||||
// SendVerificationCode 发送验证码邮件
|
||||
func (s *EmailService) SendVerificationCode(to, code, purpose string) error {
|
||||
var subject, body string
|
||||
switch purpose {
|
||||
case "register":
|
||||
subject = "Kenaito Notebook - 注册验证码"
|
||||
body = fmt.Sprintf(`
|
||||
<div style="font-family: Arial, sans-serif; padding: 20px;">
|
||||
<h2 style="color: #333;">Kenaito Notebook</h2>
|
||||
<p>你正在注册账号,验证码如下:</p>
|
||||
<div style="background: #f5f7fa; padding: 16px; border-radius: 8px; text-align: center; margin: 20px 0;">
|
||||
<span style="font-size: 32px; font-weight: bold; color: #409eff; letter-spacing: 8px;">%s</span>
|
||||
</div>
|
||||
<p style="color: #909399; font-size: 14px;">验证码 5 分钟内有效,请勿泄露给他人。</p>
|
||||
</div>`, code)
|
||||
case "reset":
|
||||
subject = "Kenaito Notebook - 重置密码验证码"
|
||||
body = fmt.Sprintf(`
|
||||
<div style="font-family: Arial, sans-serif; padding: 20px;">
|
||||
<h2 style="color: #333;">Kenaito Notebook</h2>
|
||||
<p>你正在重置密码,验证码如下:</p>
|
||||
<div style="background: #f5f7fa; padding: 16px; border-radius: 8px; text-align: center; margin: 20px 0;">
|
||||
<span style="font-size: 32px; font-weight: bold; color: #e6a23c; letter-spacing: 8px;">%s</span>
|
||||
</div>
|
||||
<p style="color: #909399; font-size: 14px;">验证码 5 分钟内有效,请勿泄露给他人。</p>
|
||||
</div>`, code)
|
||||
default:
|
||||
return fmt.Errorf("unknown purpose: %s", purpose)
|
||||
}
|
||||
|
||||
if !s.configured {
|
||||
log.Printf("[Email] SMTP not configured. Verification code for %s (%s): %s", to, purpose, code)
|
||||
return nil
|
||||
}
|
||||
|
||||
return s.send(to, subject, body)
|
||||
}
|
||||
|
||||
func (s *EmailService) send(to, subject, htmlBody string) error {
|
||||
addr := net.JoinHostPort(s.cfg.Host, s.cfg.Port)
|
||||
|
||||
headers := make(map[string]string)
|
||||
headers["From"] = s.cfg.From
|
||||
headers["To"] = to
|
||||
headers["Subject"] = subject
|
||||
headers["MIME-Version"] = "1.0"
|
||||
headers["Content-Type"] = "text/html; charset=UTF-8"
|
||||
|
||||
var msg strings.Builder
|
||||
for k, v := range headers {
|
||||
msg.WriteString(fmt.Sprintf("%s: %s\r\n", k, v))
|
||||
}
|
||||
msg.WriteString("\r\n")
|
||||
msg.WriteString(htmlBody)
|
||||
|
||||
auth := smtp.PlainAuth("", s.cfg.Username, s.cfg.Password, s.cfg.Host)
|
||||
|
||||
// SSL connection (port 465)
|
||||
if s.cfg.Port == "465" {
|
||||
return s.sendTLS(addr, auth, to, msg.String())
|
||||
}
|
||||
|
||||
// STARTTLS connection (port 587)
|
||||
return smtp.SendMail(addr, auth, s.cfg.From, []string{to}, []byte(msg.String()))
|
||||
}
|
||||
|
||||
func (s *EmailService) sendTLS(addr string, auth smtp.Auth, to, msg string) error {
|
||||
conn, err := tls.Dial("tcp", addr, &tls.Config{
|
||||
ServerName: s.cfg.Host,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to SMTP server: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client, err := smtp.NewClient(conn, s.cfg.Host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create SMTP client: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if err = client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("SMTP auth failed: %w", err)
|
||||
}
|
||||
if err = client.Mail(s.cfg.From); err != nil {
|
||||
return fmt.Errorf("SMTP MAIL FROM failed: %w", err)
|
||||
}
|
||||
if err = client.Rcpt(to); err != nil {
|
||||
return fmt.Errorf("SMTP RCPT TO failed: %w", err)
|
||||
}
|
||||
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("SMTP DATA failed: %w", err)
|
||||
}
|
||||
if _, err = w.Write([]byte(msg)); err != nil {
|
||||
return fmt.Errorf("SMTP write failed: %w", err)
|
||||
}
|
||||
if err = w.Close(); err != nil {
|
||||
return fmt.Errorf("SMTP close data failed: %w", err)
|
||||
}
|
||||
|
||||
return client.Quit()
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
)
|
||||
|
||||
type ExportService struct{}
|
||||
|
||||
func NewExportService() *ExportService {
|
||||
return &ExportService{}
|
||||
}
|
||||
|
||||
func (es *ExportService) ExportHTML(title, content, outputPath string) error {
|
||||
var htmlContent strings.Builder
|
||||
if err := goldmark.Convert([]byte(content), &htmlContent); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
htmlTemplate := `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
color: #333;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
h1 { border-bottom: 2px solid #eee; padding-bottom: 10px; }
|
||||
code {
|
||||
background-color: #f4f4f4;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: "Consolas", "Monaco", monospace;
|
||||
}
|
||||
pre {
|
||||
background-color: #f8f8f8;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
blockquote {
|
||||
border-left: 4px solid #ddd;
|
||||
margin: 0;
|
||||
padding: 0 20px;
|
||||
color: #666;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 1em 0;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
a {
|
||||
color: #0066cc;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>{{.Title}}</h1>
|
||||
{{.Content}}
|
||||
<hr>
|
||||
<p style="color: #999; font-size: 12px;">
|
||||
导出于 {{.ExportTime}} | Kenaito Notebook
|
||||
</p>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
tmpl, err := template.New("html").Parse(htmlTemplate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
file, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
return tmpl.Execute(file, map[string]string{
|
||||
"Title": title,
|
||||
"Content": htmlContent.String(),
|
||||
"ExportTime": fmt.Sprintf("%s", time.Now().Format("2006-01-02 15:04:05")),
|
||||
})
|
||||
}
|
||||
|
||||
func (es *ExportService) ExportMarkdown(title, content, outputPath string) error {
|
||||
fullContent := fmt.Sprintf("# %s\n\n%s", title, content)
|
||||
return os.WriteFile(outputPath, []byte(fullContent), 0644)
|
||||
}
|
||||
|
||||
func (es *ExportService) ExportFolderMarkdown(folderTitle string, notes []NoteInfo, outputPath string) error {
|
||||
var content strings.Builder
|
||||
content.WriteString(fmt.Sprintf("# %s\n\n", folderTitle))
|
||||
|
||||
for _, note := range notes {
|
||||
content.WriteString(fmt.Sprintf("## %s\n\n", note.Title))
|
||||
content.WriteString(note.Content)
|
||||
content.WriteString("\n\n---\n\n")
|
||||
}
|
||||
|
||||
return os.WriteFile(outputPath, []byte(content.String()), 0644)
|
||||
}
|
||||
|
||||
func (es *ExportService) ExportAllMarkdown(notes []NoteInfo, outputPath string) error {
|
||||
var content strings.Builder
|
||||
content.WriteString("# 所有笔记\n\n")
|
||||
|
||||
for _, note := range notes {
|
||||
content.WriteString(fmt.Sprintf("## %s\n\n", note.Title))
|
||||
content.WriteString(note.Content)
|
||||
content.WriteString("\n\n---\n\n")
|
||||
}
|
||||
|
||||
return os.WriteFile(outputPath, []byte(content.String()), 0644)
|
||||
}
|
||||
|
||||
func (es *ExportService) ExportFolderZip(folderTitle string, notes []*NoteInfo, outputPath string) error {
|
||||
file, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
w := zip.NewWriter(file)
|
||||
defer w.Close()
|
||||
|
||||
rootFolder := es.GetSafeFilename(folderTitle) + "/"
|
||||
_, err = w.Create(rootFolder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
es.addNotesToZip(w, notes, rootFolder)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (es *ExportService) ExportAllZip(notes []*NoteInfo, outputPath string) error {
|
||||
file, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
w := zip.NewWriter(file)
|
||||
defer w.Close()
|
||||
|
||||
rootFolder := "所有笔记/"
|
||||
_, err = w.Create(rootFolder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
es.addNotesToZip(w, notes, rootFolder)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (es *ExportService) addNotesToZip(w *zip.Writer, notes []*NoteInfo, parentPath string) {
|
||||
for _, note := range notes {
|
||||
safeName := es.GetSafeFilename(note.Title)
|
||||
|
||||
if note.IsFolder {
|
||||
folderPath := parentPath + safeName + "/"
|
||||
_, _ = w.Create(folderPath)
|
||||
|
||||
if note.Children != nil {
|
||||
es.addNotesToZip(w, note.Children, folderPath)
|
||||
}
|
||||
} else {
|
||||
filePath := parentPath + safeName + ".md"
|
||||
fullContent := fmt.Sprintf("# %s\n\n%s", note.Title, note.Content)
|
||||
|
||||
f, err := w.Create(filePath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_, _ = f.Write([]byte(fullContent))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type NoteInfo struct {
|
||||
Title string
|
||||
Content string
|
||||
IsFolder bool
|
||||
Children []*NoteInfo
|
||||
}
|
||||
|
||||
func (es *ExportService) GetDefaultExportPath(title, ext string) string {
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
exportDir := filepath.Join(homeDir, "Desktop")
|
||||
safeName := es.GetSafeFilename(title)
|
||||
return filepath.Join(exportDir, fmt.Sprintf("%s%s", safeName, ext))
|
||||
}
|
||||
|
||||
func (es *ExportService) GetSafeFilename(title string) string {
|
||||
safeName := strings.ReplaceAll(title, "/", "-")
|
||||
safeName = strings.ReplaceAll(safeName, "\\", "-")
|
||||
safeName = strings.ReplaceAll(safeName, ":", "-")
|
||||
safeName = strings.ReplaceAll(safeName, "*", "")
|
||||
safeName = strings.ReplaceAll(safeName, "?", "")
|
||||
safeName = strings.ReplaceAll(safeName, "\"", "")
|
||||
safeName = strings.ReplaceAll(safeName, "<", "")
|
||||
safeName = strings.ReplaceAll(safeName, ">", "")
|
||||
safeName = strings.ReplaceAll(safeName, "|", "")
|
||||
|
||||
if strings.TrimSpace(safeName) == "" {
|
||||
safeName = "未命名笔记"
|
||||
}
|
||||
|
||||
return safeName
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const MaxFileSize = 6 * 1024 * 1024 // 6MB
|
||||
|
||||
type FileService struct {
|
||||
dataDir string
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func NewFileService(baseURL string) (*FileService, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dataDir := filepath.Join(homeDir, ".kenaito-notebook", "assets")
|
||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Printf("[FileService] Assets directory: %s", dataDir)
|
||||
log.Printf("[FileService] Base URL: %s", baseURL)
|
||||
|
||||
return &FileService{
|
||||
dataDir: dataDir,
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (fs *FileService) GetBaseURL() string {
|
||||
return fs.baseURL
|
||||
}
|
||||
|
||||
func (fs *FileService) SaveImage(base64Data string) (string, error) {
|
||||
parts := strings.Split(base64Data, ",")
|
||||
if len(parts) != 2 {
|
||||
return "", fmt.Errorf("invalid base64 format")
|
||||
}
|
||||
|
||||
header := parts[0]
|
||||
ext := ".png"
|
||||
if strings.Contains(header, "jpeg") || strings.Contains(header, "jpg") {
|
||||
ext = ".jpg"
|
||||
} else if strings.Contains(header, "gif") {
|
||||
ext = ".gif"
|
||||
} else if strings.Contains(header, "webp") {
|
||||
ext = ".webp"
|
||||
}
|
||||
|
||||
data, err := base64.StdEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if int64(len(data)) > MaxFileSize {
|
||||
return "", fmt.Errorf("文件大小超过 6MB 限制")
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%s%s", uuid.New().String()[:8], ext)
|
||||
|
||||
dateDir := time.Now().Format("2006/01")
|
||||
dirPath := filepath.Join(fs.dataDir, dateDir)
|
||||
if err := os.MkdirAll(dirPath, 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
filePath := filepath.Join(dirPath, filename)
|
||||
if err := os.WriteFile(filePath, data, 0644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s/api/files/assets/%s/%s", fs.baseURL, dateDir, filename), nil
|
||||
}
|
||||
|
||||
func (fs *FileService) SaveFile(base64Data, filename string) (string, error) {
|
||||
parts := strings.Split(base64Data, ",")
|
||||
if len(parts) != 2 {
|
||||
return "", fmt.Errorf("invalid base64 format")
|
||||
}
|
||||
|
||||
data, err := base64.StdEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if int64(len(data)) > MaxFileSize {
|
||||
return "", fmt.Errorf("文件大小超过 6MB 限制")
|
||||
}
|
||||
|
||||
ext := filepath.Ext(filename)
|
||||
nameWithoutExt := strings.TrimSuffix(filename, ext)
|
||||
uniqueFilename := fmt.Sprintf("%s_%s%s", nameWithoutExt, uuid.New().String()[:8], ext)
|
||||
|
||||
dateDir := time.Now().Format("2006/01")
|
||||
dirPath := filepath.Join(fs.dataDir, dateDir)
|
||||
if err := os.MkdirAll(dirPath, 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
filePath := filepath.Join(dirPath, uniqueFilename)
|
||||
if err := os.WriteFile(filePath, data, 0644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s/api/files/assets/%s/%s", fs.baseURL, dateDir, uniqueFilename), nil
|
||||
}
|
||||
|
||||
func (fs *FileService) GetAssetsPath() string {
|
||||
return fs.dataDir
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type verificationEntry struct {
|
||||
code string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type VerificationService struct {
|
||||
mu sync.RWMutex
|
||||
codes map[string]*verificationEntry // key: "email:purpose"
|
||||
times map[string]time.Time // key: "email:purpose" — 上次发送时间
|
||||
}
|
||||
|
||||
func NewVerificationService() *VerificationService {
|
||||
v := &VerificationService{
|
||||
codes: make(map[string]*verificationEntry),
|
||||
times: make(map[string]time.Time),
|
||||
}
|
||||
// 定期清理过期验证码
|
||||
go v.cleanup()
|
||||
return v
|
||||
}
|
||||
|
||||
// GenerateCode 生成 6 位随机验证码
|
||||
func (v *VerificationService) GenerateCode(email, purpose string) string {
|
||||
code := fmt.Sprintf("%06d", rand.Intn(1000000))
|
||||
key := email + ":" + purpose
|
||||
|
||||
v.mu.Lock()
|
||||
v.codes[key] = &verificationEntry{
|
||||
code: code,
|
||||
expiresAt: time.Now().Add(5 * time.Minute),
|
||||
}
|
||||
v.times[key] = time.Now()
|
||||
v.mu.Unlock()
|
||||
|
||||
return code
|
||||
}
|
||||
|
||||
// CheckRateLimit 检查是否在 60 秒限频内
|
||||
func (v *VerificationService) CheckRateLimit(email, purpose string) bool {
|
||||
key := email + ":" + purpose
|
||||
v.mu.RLock()
|
||||
last, exists := v.times[key]
|
||||
v.mu.RUnlock()
|
||||
|
||||
if exists && time.Since(last) < 60*time.Second {
|
||||
return false // 被限频
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// VerifyCode 验证验证码(不消耗,仅校验)
|
||||
func (v *VerificationService) VerifyCode(email, code, purpose string) error {
|
||||
key := email + ":" + purpose
|
||||
v.mu.RLock()
|
||||
entry, exists := v.codes[key]
|
||||
v.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("验证码不存在或已过期")
|
||||
}
|
||||
if time.Now().After(entry.expiresAt) {
|
||||
return fmt.Errorf("验证码已过期")
|
||||
}
|
||||
if entry.code != code {
|
||||
return fmt.Errorf("验证码错误")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UseCode 验证并消耗验证码(验证通过后删除)
|
||||
func (v *VerificationService) UseCode(email, code, purpose string) error {
|
||||
key := email + ":" + purpose
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
entry, exists := v.codes[key]
|
||||
if !exists {
|
||||
return fmt.Errorf("验证码不存在或已过期")
|
||||
}
|
||||
if time.Now().After(entry.expiresAt) {
|
||||
delete(v.codes, key)
|
||||
return fmt.Errorf("验证码已过期")
|
||||
}
|
||||
if entry.code != code {
|
||||
return fmt.Errorf("验证码错误")
|
||||
}
|
||||
|
||||
delete(v.codes, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanup 定期清理过期验证码
|
||||
func (v *VerificationService) cleanup() {
|
||||
ticker := time.NewTicker(10 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
v.mu.Lock()
|
||||
now := time.Now()
|
||||
for key, entry := range v.codes {
|
||||
if now.After(entry.expiresAt) {
|
||||
delete(v.codes, key)
|
||||
}
|
||||
}
|
||||
v.mu.Unlock()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user