feat: 初始化 kenaito-notebook 桌面记事本项目

This commit is contained in:
2026-06-13 21:08:00 +08:00
commit 5fafcd28e3
55 changed files with 9722 additions and 0 deletions
+89
View File
@@ -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"],
},
}
}
+48
View File
@@ -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
View File
@@ -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=
+338
View File
@@ -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()
}
}
+182
View File
@@ -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
}
+60
View File
@@ -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()})
}
+170
View File
@@ -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
View File
@@ -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)
}
}
+22
View File
@@ -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"`
}
+9
View File
@@ -0,0 +1,9 @@
package models
import "time"
type User struct {
ID int64 `json:"id"`
Email string `json:"email"`
CreatedAt time.Time `json:"createdAt"`
}
+142
View File
@@ -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
}
+581
View File
@@ -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(
&note.ID, &note.ParentID, &note.Title, &note.Content,
&note.SortOrder, &note.IsFolder, &note.Tag,
&note.CreatedAt, &note.UpdatedAt, &note.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(
&note.ID, &note.ParentID, &note.Title, &note.Content,
&note.SortOrder, &note.IsFolder, &note.Tag,
&note.CreatedAt, &note.UpdatedAt, &note.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(
&note.ID, &note.ParentID, &note.Title, &note.Content,
&note.SortOrder, &note.IsFolder, &note.Tag,
&note.CreatedAt, &note.UpdatedAt, &note.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(
&note.ID, &note.ParentID, &note.Title, &note.Content,
&note.SortOrder, &note.IsFolder, &note.Tag,
&note.CreatedAt, &note.UpdatedAt, &note.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(
&note.ID, &note.ParentID, &note.Title, &note.Content,
&note.SortOrder, &note.IsFolder, &note.Tag,
&note.CreatedAt, &note.UpdatedAt, &note.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(
&note.ID, &note.ParentID, &note.Title, &note.Content,
&note.SortOrder, &note.IsFolder, &note.Tag,
&note.CreatedAt, &note.UpdatedAt, &note.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(
&note.ID, &note.ParentID, &note.Title, &note.Content,
&note.SortOrder, &note.IsFolder, &note.Tag,
&note.CreatedAt, &note.UpdatedAt, &note.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
}
+136
View File
@@ -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()
}
+247
View File
@@ -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
}
+122
View File
@@ -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
}
+116
View File
@@ -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()
}
}