feat:更新
This commit is contained in:
parent
095e2869cb
commit
d8d0ccbc91
12
.env
Normal file
12
.env
Normal file
@ -0,0 +1,12 @@
|
||||
PORT=18080
|
||||
GIN_MODE=release
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=admin123
|
||||
DB_DSN=myuser:Lzy0916.@tcp(47.95.203.241:3306)/go-dy?parseTime=true&charset=utf8mb4&loc=Local
|
||||
JWT_SECRET=dev-secret-please-change
|
||||
OSS_ENDPOINT=oss-cn-shenzhen.aliyuncs.com
|
||||
OSS_ACCESS_KEY_ID=LTAI5tJLmZMVMgRkeYqVtscu
|
||||
OSS_ACCESS_KEY_SECRET=DhAMQkgk8z3DLewjSJlKZ760Qadnh7
|
||||
OSS_BUCKET=dy-screenshot
|
||||
LOGIN_REQUIRE_CAPTCHA=false
|
||||
REGISTER_REQUIRE_CAPTCHA=false
|
||||
15
.env.example
Normal file
15
.env.example
Normal file
@ -0,0 +1,15 @@
|
||||
PORT=18080
|
||||
GIN_MODE=release
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=your-secure-secret
|
||||
|
||||
# Admin login (simple validation)
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=strong-password
|
||||
|
||||
# Alibaba Cloud OSS
|
||||
OSS_ENDPOINT=oss-cn-shenzhen.aliyuncs.com
|
||||
OSS_ACCESS_KEY_ID=LTAI5tJLmZMVMgRkeYqVtscu
|
||||
OSS_ACCESS_KEY_SECRET=DhAMQkgk8z3DLewjSJlKZ760Qadnh7
|
||||
OSS_BUCKET=dy-screenshot
|
||||
BIN
build/go-dy-linux-amd64
Normal file
BIN
build/go-dy-linux-amd64
Normal file
Binary file not shown.
BIN
build/go-dy-linux-arm64
Normal file
BIN
build/go-dy-linux-arm64
Normal file
Binary file not shown.
BIN
build/go-dy-windows-amd64.exe
Normal file
BIN
build/go-dy-windows-amd64.exe
Normal file
Binary file not shown.
0
captcha.png
Normal file
0
captcha.png
Normal file
62
deploy/README_DEPLOY.md
Normal file
62
deploy/README_DEPLOY.md
Normal file
@ -0,0 +1,62 @@
|
||||
# go-dy 部署说明
|
||||
|
||||
本文档介绍如何在 Linux 与 Windows 服务器上部署。
|
||||
|
||||
## 1. 准备
|
||||
- 数据库:确保 `DB_DSN` 可用(例如:`user:pass@tcp(host:3306)/db?parseTime=true&charset=utf8mb4`)。
|
||||
- 端口:默认 `PORT=8080`,可改为需要的端口。
|
||||
- JWT 密钥:设置 `JWT_SECRET` 为强随机字符串。
|
||||
- 验证码:
|
||||
- 登录验证码由 `LOGIN_REQUIRE_CAPTCHA` 控制(默认 true)。
|
||||
- 注册验证码由 `REGISTER_REQUIRE_CAPTCHA` 控制(默认 true)。
|
||||
|
||||
## 2. 文件构成
|
||||
- 二进制:`go-dy-linux-amd64` 或 `go-dy-linux-arm64` 或 `go-dy-windows-amd64.exe`
|
||||
- 配置:`.env`(可参考 `.env.example`)
|
||||
|
||||
## 3. Linux(systemd)
|
||||
1. 新建工作目录(例如 `/opt/go-dy`),上传二进制与 `.env`。
|
||||
2. 赋予执行权限:`chmod +x /opt/go-dy/go-dy-linux-amd64`
|
||||
3. 创建 systemd 单元:`/etc/systemd/system/go-dy.service`
|
||||
|
||||
参考模板见 `deploy/systemd/go-dy.service`,根据架构与路径调整:
|
||||
- `ExecStart` 指向二进制
|
||||
- `WorkingDirectory` 指向 `.env` 所在目录
|
||||
|
||||
4. 加载与启动:
|
||||
- `sudo systemctl daemon-reload`
|
||||
- `sudo systemctl enable go-dy`
|
||||
- `sudo systemctl start go-dy`
|
||||
- `sudo systemctl status go-dy`
|
||||
|
||||
5. 查看日志:`journalctl -u go-dy -f`
|
||||
|
||||
## 4. Windows(服务/常驻)
|
||||
- 方式 A:使用 NSSM 注册为服务(推荐)。
|
||||
1. 安装 NSSM。
|
||||
2. `nssm install go-dy C:\path\to\go-dy-windows-amd64.exe`
|
||||
3. 设置 `Startup directory` 指向 `.env` 所在目录。
|
||||
4. 启动服务:`nssm start go-dy`
|
||||
|
||||
- 方式 B:任务计划程序或 PowerShell 常驻:
|
||||
- PowerShell:`Start-Process -NoNewWindow -WorkingDirectory C:\path\to -FilePath C:\path\to\go-dy-windows-amd64.exe`
|
||||
|
||||
## 5. 环境变量
|
||||
将以下键放入 `.env` 或服务器环境中:
|
||||
```
|
||||
PORT=18080
|
||||
JWT_SECRET=请替换为强随机密钥
|
||||
DB_DSN=user:pass@tcp(host:3306)/db?parseTime=true&charset=utf8mb4
|
||||
ALLOW_REGISTRATION=true
|
||||
LOGIN_REQUIRE_CAPTCHA=false
|
||||
REGISTER_REQUIRE_CAPTCHA=false
|
||||
```
|
||||
|
||||
## 6. 健康与验证
|
||||
- 启动后日志应包含:数据库连接成功与监听端口。
|
||||
- 登录:`POST /api/login`(表单 `name`, `password`)。
|
||||
- 注册:`POST /api/register`(字段随验证码开关而异)。
|
||||
|
||||
## 7. 迁移与数据
|
||||
- 用户表需包含 `password_hash`(bcrypt)。
|
||||
- 可使用 `tools/update_password.go` 脚本按需写入 bcrypt 密码。
|
||||
14
deploy/systemd/go-dy.service
Normal file
14
deploy/systemd/go-dy.service
Normal file
@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=go-dy web service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/go-dy
|
||||
ExecStart=/opt/go-dy/go-dy-linux-amd64
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
EnvironmentFile=/opt/go-dy/.env
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
15
dist/.env.example
vendored
Normal file
15
dist/.env.example
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
PORT=18080
|
||||
GIN_MODE=release
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=your-secure-secret
|
||||
|
||||
# Admin login (simple validation)
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=strong-password
|
||||
|
||||
# Alibaba Cloud OSS
|
||||
OSS_ENDPOINT=oss-cn-shenzhen.aliyuncs.com
|
||||
OSS_ACCESS_KEY_ID=LTAI5tJLmZMVMgRkeYqVtscu
|
||||
OSS_ACCESS_KEY_SECRET=DhAMQkgk8z3DLewjSJlKZ760Qadnh7
|
||||
OSS_BUCKET=dy-screenshot
|
||||
62
dist/deploy/README_DEPLOY.md
vendored
Normal file
62
dist/deploy/README_DEPLOY.md
vendored
Normal file
@ -0,0 +1,62 @@
|
||||
# go-dy 部署说明
|
||||
|
||||
本文档介绍如何在 Linux 与 Windows 服务器上部署。
|
||||
|
||||
## 1. 准备
|
||||
- 数据库:确保 `DB_DSN` 可用(例如:`user:pass@tcp(host:3306)/db?parseTime=true&charset=utf8mb4`)。
|
||||
- 端口:默认 `PORT=8080`,可改为需要的端口。
|
||||
- JWT 密钥:设置 `JWT_SECRET` 为强随机字符串。
|
||||
- 验证码:
|
||||
- 登录验证码由 `LOGIN_REQUIRE_CAPTCHA` 控制(默认 true)。
|
||||
- 注册验证码由 `REGISTER_REQUIRE_CAPTCHA` 控制(默认 true)。
|
||||
|
||||
## 2. 文件构成
|
||||
- 二进制:`go-dy-linux-amd64` 或 `go-dy-linux-arm64` 或 `go-dy-windows-amd64.exe`
|
||||
- 配置:`.env`(可参考 `.env.example`)
|
||||
|
||||
## 3. Linux(systemd)
|
||||
1. 新建工作目录(例如 `/opt/go-dy`),上传二进制与 `.env`。
|
||||
2. 赋予执行权限:`chmod +x /opt/go-dy/go-dy-linux-amd64`
|
||||
3. 创建 systemd 单元:`/etc/systemd/system/go-dy.service`
|
||||
|
||||
参考模板见 `deploy/systemd/go-dy.service`,根据架构与路径调整:
|
||||
- `ExecStart` 指向二进制
|
||||
- `WorkingDirectory` 指向 `.env` 所在目录
|
||||
|
||||
4. 加载与启动:
|
||||
- `sudo systemctl daemon-reload`
|
||||
- `sudo systemctl enable go-dy`
|
||||
- `sudo systemctl start go-dy`
|
||||
- `sudo systemctl status go-dy`
|
||||
|
||||
5. 查看日志:`journalctl -u go-dy -f`
|
||||
|
||||
## 4. Windows(服务/常驻)
|
||||
- 方式 A:使用 NSSM 注册为服务(推荐)。
|
||||
1. 安装 NSSM。
|
||||
2. `nssm install go-dy C:\path\to\go-dy-windows-amd64.exe`
|
||||
3. 设置 `Startup directory` 指向 `.env` 所在目录。
|
||||
4. 启动服务:`nssm start go-dy`
|
||||
|
||||
- 方式 B:任务计划程序或 PowerShell 常驻:
|
||||
- PowerShell:`Start-Process -NoNewWindow -WorkingDirectory C:\path\to -FilePath C:\path\to\go-dy-windows-amd64.exe`
|
||||
|
||||
## 5. 环境变量
|
||||
将以下键放入 `.env` 或服务器环境中:
|
||||
```
|
||||
PORT=18080
|
||||
JWT_SECRET=请替换为强随机密钥
|
||||
DB_DSN=user:pass@tcp(host:3306)/db?parseTime=true&charset=utf8mb4
|
||||
ALLOW_REGISTRATION=true
|
||||
LOGIN_REQUIRE_CAPTCHA=false
|
||||
REGISTER_REQUIRE_CAPTCHA=false
|
||||
```
|
||||
|
||||
## 6. 健康与验证
|
||||
- 启动后日志应包含:数据库连接成功与监听端口。
|
||||
- 登录:`POST /api/login`(表单 `name`, `password`)。
|
||||
- 注册:`POST /api/register`(字段随验证码开关而异)。
|
||||
|
||||
## 7. 迁移与数据
|
||||
- 用户表需包含 `password_hash`(bcrypt)。
|
||||
- 可使用 `tools/update_password.go` 脚本按需写入 bcrypt 密码。
|
||||
62
dist/deploy/deploy/README_DEPLOY.md
vendored
Normal file
62
dist/deploy/deploy/README_DEPLOY.md
vendored
Normal file
@ -0,0 +1,62 @@
|
||||
# go-dy 部署说明
|
||||
|
||||
本文档介绍如何在 Linux 与 Windows 服务器上部署。
|
||||
|
||||
## 1. 准备
|
||||
- 数据库:确保 `DB_DSN` 可用(例如:`user:pass@tcp(host:3306)/db?parseTime=true&charset=utf8mb4`)。
|
||||
- 端口:默认 `PORT=8080`,可改为需要的端口。
|
||||
- JWT 密钥:设置 `JWT_SECRET` 为强随机字符串。
|
||||
- 验证码:
|
||||
- 登录验证码由 `LOGIN_REQUIRE_CAPTCHA` 控制(默认 true)。
|
||||
- 注册验证码由 `REGISTER_REQUIRE_CAPTCHA` 控制(默认 true)。
|
||||
|
||||
## 2. 文件构成
|
||||
- 二进制:`go-dy-linux-amd64` 或 `go-dy-linux-arm64` 或 `go-dy-windows-amd64.exe`
|
||||
- 配置:`.env`(可参考 `.env.example`)
|
||||
|
||||
## 3. Linux(systemd)
|
||||
1. 新建工作目录(例如 `/opt/go-dy`),上传二进制与 `.env`。
|
||||
2. 赋予执行权限:`chmod +x /opt/go-dy/go-dy-linux-amd64`
|
||||
3. 创建 systemd 单元:`/etc/systemd/system/go-dy.service`
|
||||
|
||||
参考模板见 `deploy/systemd/go-dy.service`,根据架构与路径调整:
|
||||
- `ExecStart` 指向二进制
|
||||
- `WorkingDirectory` 指向 `.env` 所在目录
|
||||
|
||||
4. 加载与启动:
|
||||
- `sudo systemctl daemon-reload`
|
||||
- `sudo systemctl enable go-dy`
|
||||
- `sudo systemctl start go-dy`
|
||||
- `sudo systemctl status go-dy`
|
||||
|
||||
5. 查看日志:`journalctl -u go-dy -f`
|
||||
|
||||
## 4. Windows(服务/常驻)
|
||||
- 方式 A:使用 NSSM 注册为服务(推荐)。
|
||||
1. 安装 NSSM。
|
||||
2. `nssm install go-dy C:\path\to\go-dy-windows-amd64.exe`
|
||||
3. 设置 `Startup directory` 指向 `.env` 所在目录。
|
||||
4. 启动服务:`nssm start go-dy`
|
||||
|
||||
- 方式 B:任务计划程序或 PowerShell 常驻:
|
||||
- PowerShell:`Start-Process -NoNewWindow -WorkingDirectory C:\path\to -FilePath C:\path\to\go-dy-windows-amd64.exe`
|
||||
|
||||
## 5. 环境变量
|
||||
将以下键放入 `.env` 或服务器环境中:
|
||||
```
|
||||
PORT=18080
|
||||
JWT_SECRET=请替换为强随机密钥
|
||||
DB_DSN=user:pass@tcp(host:3306)/db?parseTime=true&charset=utf8mb4
|
||||
ALLOW_REGISTRATION=true
|
||||
LOGIN_REQUIRE_CAPTCHA=false
|
||||
REGISTER_REQUIRE_CAPTCHA=false
|
||||
```
|
||||
|
||||
## 6. 健康与验证
|
||||
- 启动后日志应包含:数据库连接成功与监听端口。
|
||||
- 登录:`POST /api/login`(表单 `name`, `password`)。
|
||||
- 注册:`POST /api/register`(字段随验证码开关而异)。
|
||||
|
||||
## 7. 迁移与数据
|
||||
- 用户表需包含 `password_hash`(bcrypt)。
|
||||
- 可使用 `tools/update_password.go` 脚本按需写入 bcrypt 密码。
|
||||
14
dist/deploy/deploy/systemd/go-dy.service
vendored
Normal file
14
dist/deploy/deploy/systemd/go-dy.service
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=go-dy web service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/go-dy
|
||||
ExecStart=/opt/go-dy/go-dy-linux-amd64
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
EnvironmentFile=/opt/go-dy/.env
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
14
dist/deploy/systemd/go-dy.service
vendored
Normal file
14
dist/deploy/systemd/go-dy.service
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=go-dy web service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/go-dy
|
||||
ExecStart=/opt/go-dy/go-dy-linux-amd64
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
EnvironmentFile=/opt/go-dy/.env
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
BIN
dist/go-dy-linux-amd64
vendored
Normal file
BIN
dist/go-dy-linux-amd64
vendored
Normal file
Binary file not shown.
BIN
dist/go-dy-linux-arm64
vendored
Normal file
BIN
dist/go-dy-linux-arm64
vendored
Normal file
Binary file not shown.
BIN
dist/go-dy-release.zip
vendored
Normal file
BIN
dist/go-dy-release.zip
vendored
Normal file
Binary file not shown.
BIN
dist/go-dy-windows-amd64.exe
vendored
Normal file
BIN
dist/go-dy-windows-amd64.exe
vendored
Normal file
Binary file not shown.
48
go.mod
Normal file
48
go.mod
Normal file
@ -0,0 +1,48 @@
|
||||
module go-dy
|
||||
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.8
|
||||
|
||||
require (
|
||||
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible
|
||||
github.com/dchest/captcha v1.1.0
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/go-sql-driver/mysql v1.7.1
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
golang.org/x/crypto v0.40.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // 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.27.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // 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-20180228061459-e0a39a4cb421 // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
)
|
||||
99
go.sum
Normal file
99
go.sum
Normal file
@ -0,0 +1,99 @@
|
||||
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible h1:8psS8a+wKfiLt1iVDX79F7Y6wUM49Lcha2FMXt4UM8g=
|
||||
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8=
|
||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
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/dchest/captcha v1.1.0 h1:2kt47EoYUUkaISobUdTbqwx55xvKOJxyScVfw25xzhQ=
|
||||
github.com/dchest/captcha v1.1.0/go.mod h1:7zoElIawLp7GUMLcj54K9kbw+jEyvz2K0FDdRRYhvWo=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
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.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
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.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
|
||||
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
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/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 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/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/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.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
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/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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
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.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
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=
|
||||
43
internal/auth/jwt.go
Normal file
43
internal/auth/jwt.go
Normal file
@ -0,0 +1,43 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
jwt "github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
Username string `json:"username"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func GenerateToken(username, secret string, ttl time.Duration) (string, error) {
|
||||
now := time.Now()
|
||||
claims := Claims{
|
||||
Username: username,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(secret))
|
||||
}
|
||||
|
||||
func ParseToken(tokenStr, secret string) (string, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(secret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !token.Valid {
|
||||
return "", errors.New("invalid token")
|
||||
}
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok {
|
||||
return "", errors.New("invalid claims")
|
||||
}
|
||||
return claims.Username, nil
|
||||
}
|
||||
87
internal/config/config.go
Normal file
87
internal/config/config.go
Normal file
@ -0,0 +1,87 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port string
|
||||
JWTSecret string
|
||||
AdminUsername string
|
||||
AdminPassword string
|
||||
OSSEndpoint string
|
||||
OSSAccessKeyID string
|
||||
OSSAccessKeySecret string
|
||||
OSSBucket string
|
||||
DBDsn string
|
||||
AllowRegistration bool
|
||||
LoginRequireCaptcha bool
|
||||
RegisterRequireCaptcha bool
|
||||
}
|
||||
|
||||
func getenv(key, def string) string {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
// Auto-load .env if present to reduce startup parameters.
|
||||
loadDotEnv()
|
||||
return Config{
|
||||
Port: getenv("PORT", "8080"),
|
||||
JWTSecret: getenv("JWT_SECRET", "change-me"),
|
||||
AdminUsername: os.Getenv("ADMIN_USERNAME"),
|
||||
AdminPassword: os.Getenv("ADMIN_PASSWORD"),
|
||||
OSSEndpoint: os.Getenv("OSS_ENDPOINT"),
|
||||
OSSAccessKeyID: os.Getenv("OSS_ACCESS_KEY_ID"),
|
||||
OSSAccessKeySecret: os.Getenv("OSS_ACCESS_KEY_SECRET"),
|
||||
OSSBucket: os.Getenv("OSS_BUCKET"),
|
||||
DBDsn: os.Getenv("DB_DSN"),
|
||||
AllowRegistration: parseBool(getenv("ALLOW_REGISTRATION", "true")),
|
||||
LoginRequireCaptcha: parseBool(getenv("LOGIN_REQUIRE_CAPTCHA", "true")),
|
||||
RegisterRequireCaptcha: parseBool(getenv("REGISTER_REQUIRE_CAPTCHA", "true")),
|
||||
}
|
||||
}
|
||||
|
||||
// loadDotEnv loads environment variables from a local .env file if present.
|
||||
// It only sets variables that are not already defined in the process env.
|
||||
func loadDotEnv() {
|
||||
f, err := os.Open(".env")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
s := bufio.NewScanner(f)
|
||||
for s.Scan() {
|
||||
line := strings.TrimSpace(s.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if i := strings.Index(line, "="); i != -1 {
|
||||
key := strings.TrimSpace(line[:i])
|
||||
val := strings.TrimSpace(line[i+1:])
|
||||
if len(val) >= 2 {
|
||||
if (val[0] == '"' && val[len(val)-1] == '"') || (val[0] == '\'' && val[len(val)-1] == '\'') {
|
||||
val = val[1 : len(val)-1]
|
||||
}
|
||||
}
|
||||
if os.Getenv(key) == "" {
|
||||
_ = os.Setenv(key, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseBool(v string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
73
internal/db/db.go
Normal file
73
internal/db/db.go
Normal file
@ -0,0 +1,73 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go-dy/internal/config"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
conn *sql.DB
|
||||
)
|
||||
|
||||
func getDSN(cfg config.Config) string {
|
||||
if cfg.DBDsn != "" {
|
||||
return cfg.DBDsn
|
||||
}
|
||||
// sensible default for local dev
|
||||
return "root:password@tcp(127.0.0.1:3306)/go-dy?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
}
|
||||
|
||||
// Get returns a shared DB connection. It retries initialization if previous attempts failed.
|
||||
func Get(cfg config.Config) (*sql.DB, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if conn != nil {
|
||||
return conn, nil
|
||||
}
|
||||
dsn := getDSN(cfg)
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(20)
|
||||
db.SetMaxIdleConns(5)
|
||||
db.SetConnMaxLifetime(30 * time.Minute)
|
||||
|
||||
// verify connection first
|
||||
if e := db.Ping(); e != nil {
|
||||
_ = db.Close()
|
||||
return nil, e
|
||||
}
|
||||
|
||||
// init schema after successful ping
|
||||
if e := initSchema(db); e != nil {
|
||||
_ = db.Close()
|
||||
return nil, e
|
||||
}
|
||||
|
||||
conn = db
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func initSchema(db *sql.DB) error {
|
||||
// minimal users table
|
||||
_, err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
name VARCHAR(255) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init schema: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
31
internal/handlers/captcha.go
Normal file
31
internal/handlers/captcha.go
Normal file
@ -0,0 +1,31 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
|
||||
"github.com/dchest/captcha"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"go-dy/internal/resp"
|
||||
)
|
||||
|
||||
// CaptchaNewHandler generates a new captcha and returns id + base64 image.
|
||||
func CaptchaNewHandler() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := captcha.New()
|
||||
var buf bytes.Buffer
|
||||
// generate PNG image of the captcha
|
||||
const width, height = 200, 80
|
||||
if err := captcha.WriteImage(&buf, id, width, height); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "captcha generate failed"})
|
||||
return
|
||||
}
|
||||
b64 := base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||
resp.OK(c, gin.H{
|
||||
"id": id,
|
||||
"image": "data:image/png;base64," + b64,
|
||||
})
|
||||
}
|
||||
}
|
||||
64
internal/handlers/login.go
Normal file
64
internal/handlers/login.go
Normal file
@ -0,0 +1,64 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/dchest/captcha"
|
||||
|
||||
"go-dy/internal/auth"
|
||||
"go-dy/internal/config"
|
||||
dbpkg "go-dy/internal/db"
|
||||
"go-dy/internal/repo"
|
||||
"go-dy/internal/resp"
|
||||
)
|
||||
|
||||
func LoginHandler(cfg config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Expect form data: name, password (+ optional captcha when enabled)
|
||||
name := c.PostForm("name")
|
||||
password := c.PostForm("password")
|
||||
|
||||
if name == "" || password == "" {
|
||||
resp.Error(c, http.StatusBadRequest, "missing name or password")
|
||||
return
|
||||
}
|
||||
|
||||
// captcha gate (if enabled)
|
||||
if cfg.LoginRequireCaptcha {
|
||||
cid := c.PostForm("captcha_id")
|
||||
ccode := c.PostForm("captcha_code")
|
||||
if cid == "" || ccode == "" {
|
||||
resp.Error(c, http.StatusBadRequest, "missing captcha_id or captcha_code")
|
||||
return
|
||||
}
|
||||
if !captcha.VerifyString(cid, ccode) {
|
||||
resp.Error(c, http.StatusUnauthorized, "invalid captcha")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Database-backed login only: always check users table
|
||||
db, err := dbpkg.Get(cfg)
|
||||
if err != nil {
|
||||
resp.Error(c, http.StatusServiceUnavailable, "database unavailable")
|
||||
return
|
||||
}
|
||||
u, err := repo.GetUserByName(db, name)
|
||||
if err != nil {
|
||||
resp.Error(c, http.StatusInternalServerError, "query user failed")
|
||||
return
|
||||
}
|
||||
if u == nil || !repo.CheckPassword(u, password) {
|
||||
resp.Error(c, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
token, err := auth.GenerateToken(name, cfg.JWTSecret, 24*time.Hour)
|
||||
if err != nil {
|
||||
resp.Error(c, http.StatusInternalServerError, "could not generate token")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"token": token})
|
||||
}
|
||||
}
|
||||
68
internal/handlers/register.go
Normal file
68
internal/handlers/register.go
Normal file
@ -0,0 +1,68 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/dchest/captcha"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"go-dy/internal/config"
|
||||
dbpkg "go-dy/internal/db"
|
||||
"go-dy/internal/repo"
|
||||
"go-dy/internal/resp"
|
||||
)
|
||||
|
||||
func RegisterHandler(cfg config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !cfg.AllowRegistration {
|
||||
resp.Error(c, http.StatusForbidden, "registration disabled")
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(c.PostForm("name"))
|
||||
password := c.PostForm("password")
|
||||
confirm := c.PostForm("confirm_password")
|
||||
captchaID := c.PostForm("captcha_id")
|
||||
captchaCode := c.PostForm("captcha_code")
|
||||
if cfg.RegisterRequireCaptcha {
|
||||
if name == "" || password == "" || confirm == "" || captchaID == "" || captchaCode == "" {
|
||||
resp.Error(c, http.StatusBadRequest, "missing fields: name/password/confirm/captcha")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if name == "" || password == "" || confirm == "" {
|
||||
resp.Error(c, http.StatusBadRequest, "missing fields: name/password/confirm")
|
||||
return
|
||||
}
|
||||
}
|
||||
if password != confirm {
|
||||
resp.Error(c, http.StatusBadRequest, "passwords do not match")
|
||||
return
|
||||
}
|
||||
if cfg.RegisterRequireCaptcha {
|
||||
if !captcha.VerifyString(captchaID, captchaCode) {
|
||||
resp.Error(c, http.StatusBadRequest, "invalid captcha")
|
||||
return
|
||||
}
|
||||
}
|
||||
db, err := dbpkg.Get(cfg)
|
||||
if err != nil {
|
||||
resp.Error(c, http.StatusInternalServerError, "database not available")
|
||||
return
|
||||
}
|
||||
existing, err := repo.GetUserByName(db, name)
|
||||
if err != nil {
|
||||
resp.Error(c, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
if existing != nil {
|
||||
resp.Error(c, http.StatusBadRequest, "user already exists")
|
||||
return
|
||||
}
|
||||
if err := repo.CreateUser(db, name, password); err != nil {
|
||||
resp.Error(c, http.StatusInternalServerError, "create user failed")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"username": name})
|
||||
}
|
||||
}
|
||||
69
internal/handlers/upload.go
Normal file
69
internal/handlers/upload.go
Normal file
@ -0,0 +1,69 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"go-dy/internal/config"
|
||||
osspkg "go-dy/internal/oss"
|
||||
"go-dy/internal/resp"
|
||||
)
|
||||
|
||||
func UploadHandler(cfg config.Config) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
resp.Error(c, http.StatusBadRequest, "missing file")
|
||||
return
|
||||
}
|
||||
f, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
log.Printf("file open failed: filename=%s, err=%v", fileHeader.Filename, err)
|
||||
resp.Error(c, http.StatusInternalServerError, "file open failed")
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
prefix := c.PostForm("prefix")
|
||||
prefix = strings.Trim(prefix, "/ ")
|
||||
if prefix == "" {
|
||||
prefix = time.Now().Format("uploads/2006/01/02")
|
||||
}
|
||||
|
||||
base := filepath.Base(fileHeader.Filename)
|
||||
ts := time.Now().UnixNano()
|
||||
objectKey := fmt.Sprintf("%s/%d_%s", prefix, ts, base)
|
||||
|
||||
ct := fileHeader.Header.Get("Content-Type")
|
||||
if ct == "" {
|
||||
ct = mime.TypeByExtension(filepath.Ext(base))
|
||||
}
|
||||
|
||||
// Lazy init OSS client to avoid startup dependency on OSS params
|
||||
oss, err := osspkg.New(cfg)
|
||||
if err != nil {
|
||||
log.Printf("oss init failed: endpoint=%s bucket=%s err=%v", cfg.OSSEndpoint, cfg.OSSBucket, err)
|
||||
resp.Error(c, http.StatusInternalServerError, "upload failed")
|
||||
return
|
||||
}
|
||||
if err := oss.Upload(objectKey, f, ct); err != nil {
|
||||
log.Printf("oss upload failed: key=%s contentType=%s err=%v", objectKey, ct, err)
|
||||
resp.Error(c, http.StatusInternalServerError, "upload failed")
|
||||
return
|
||||
}
|
||||
|
||||
url := oss.PublicURL(objectKey)
|
||||
log.Printf("oss upload success: key=%s url=%s", objectKey, url)
|
||||
resp.OK(c, gin.H{
|
||||
"key": objectKey,
|
||||
"url": url,
|
||||
})
|
||||
}
|
||||
}
|
||||
30
internal/middleware/auth.go
Normal file
30
internal/middleware/auth.go
Normal file
@ -0,0 +1,30 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go-dy/internal/auth"
|
||||
"go-dy/internal/resp"
|
||||
)
|
||||
|
||||
func Auth(secret string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
h := c.GetHeader("Authorization")
|
||||
if h == "" || !strings.HasPrefix(strings.ToLower(h), "bearer ") {
|
||||
resp.Error(c, http.StatusUnauthorized, "missing bearer token")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
token := strings.TrimSpace(h[len("Bearer "):])
|
||||
username, err := auth.ParseToken(token, secret)
|
||||
if err != nil {
|
||||
resp.Error(c, http.StatusUnauthorized, "invalid token")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set("username", username)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
43
internal/oss/oss.go
Normal file
43
internal/oss/oss.go
Normal file
@ -0,0 +1,43 @@
|
||||
package oss
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/aliyun/aliyun-oss-go-sdk/oss"
|
||||
"go-dy/internal/config"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
client *oss.Client
|
||||
bucket *oss.Bucket
|
||||
endpoint string
|
||||
bucketName string
|
||||
}
|
||||
|
||||
func New(cfg config.Config) (*Client, error) {
|
||||
if cfg.OSSEndpoint == "" || cfg.OSSAccessKeyID == "" || cfg.OSSAccessKeySecret == "" || cfg.OSSBucket == "" {
|
||||
return nil, fmt.Errorf("missing OSS config: endpoint/accessKeyID/accessKeySecret/bucket")
|
||||
}
|
||||
c, err := oss.New(cfg.OSSEndpoint, cfg.OSSAccessKeyID, cfg.OSSAccessKeySecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := c.Bucket(cfg.OSSBucket)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Client{client: c, bucket: b, endpoint: cfg.OSSEndpoint, bucketName: cfg.OSSBucket}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Upload(objectKey string, reader io.Reader, contentType string) error {
|
||||
var opts []oss.Option
|
||||
if contentType != "" {
|
||||
opts = append(opts, oss.ContentType(contentType))
|
||||
}
|
||||
return c.bucket.PutObject(objectKey, reader, opts...)
|
||||
}
|
||||
|
||||
func (c *Client) PublicURL(objectKey string) string {
|
||||
return fmt.Sprintf("https://%s.%s/%s", c.bucketName, c.endpoint, objectKey)
|
||||
}
|
||||
39
internal/repo/user.go
Normal file
39
internal/repo/user.go
Normal file
@ -0,0 +1,39 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID int64
|
||||
Name string
|
||||
PasswordHash string
|
||||
}
|
||||
|
||||
func GetUserByName(db *sql.DB, name string) (*User, error) {
|
||||
u := &User{}
|
||||
err := db.QueryRow("SELECT id, name, password_hash FROM users WHERE name = ?", name).Scan(&u.ID, &u.Name, &u.PasswordHash)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func CreateUser(db *sql.DB, name, password string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = db.Exec("INSERT INTO users(name, password_hash) VALUES(?, ?)", name, string(hash))
|
||||
return err
|
||||
}
|
||||
|
||||
func CheckPassword(u *User, password string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil
|
||||
}
|
||||
21
internal/resp/resp.go
Normal file
21
internal/resp/resp.go
Normal file
@ -0,0 +1,21 @@
|
||||
package resp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type APIResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func OK(c *gin.Context, data interface{}) {
|
||||
c.JSON(http.StatusOK, APIResponse{Code: 0, Message: "ok", Data: data})
|
||||
}
|
||||
|
||||
func Error(c *gin.Context, status int, message string) {
|
||||
c.JSON(status, APIResponse{Code: status, Message: message})
|
||||
}
|
||||
57
main.go
Normal file
57
main.go
Normal file
@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"go-dy/internal/config"
|
||||
dbpkg "go-dy/internal/db"
|
||||
"go-dy/internal/handlers"
|
||||
"go-dy/internal/middleware"
|
||||
"go-dy/internal/resp"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
|
||||
// No OSS init at startup; it's lazily initialized in the upload handler.
|
||||
|
||||
if mode := os.Getenv("GIN_MODE"); mode != "" {
|
||||
gin.SetMode(mode)
|
||||
}
|
||||
|
||||
// Startup DB connectivity check
|
||||
if _, err := dbpkg.Get(cfg); err != nil {
|
||||
log.Printf("[startup] database connection failed: %v", err)
|
||||
} else {
|
||||
log.Printf("[startup] database connection ok")
|
||||
}
|
||||
|
||||
r := gin.Default()
|
||||
|
||||
r.GET("/api/health", func(c *gin.Context) {
|
||||
if _, err := dbpkg.Get(cfg); err != nil {
|
||||
resp.OK(c, gin.H{"status": "ok", "db": "error", "error": err.Error()})
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"status": "ok", "db": "ok"})
|
||||
})
|
||||
|
||||
// Captcha endpoint for registration
|
||||
r.GET("/api/captcha/new", handlers.CaptchaNewHandler())
|
||||
|
||||
r.POST("/api/login", handlers.LoginHandler(cfg))
|
||||
// Always expose register endpoint; handler enforces AllowRegistration.
|
||||
r.POST("/api/register", handlers.RegisterHandler(cfg))
|
||||
|
||||
auth := middleware.Auth(cfg.JWTSecret)
|
||||
r.POST("/api/upload", auth, handlers.UploadHandler(cfg))
|
||||
|
||||
addr := ":" + cfg.Port
|
||||
log.Printf("server listening on %s", addr)
|
||||
if err := r.Run(addr); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
41
tools/update_password.go
Normal file
41
tools/update_password.go
Normal file
@ -0,0 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"go-dy/internal/config"
|
||||
dbpkg "go-dy/internal/db"
|
||||
)
|
||||
|
||||
func main() {
|
||||
name := os.Getenv("USER_NAME")
|
||||
pw := os.Getenv("NEW_PASSWORD")
|
||||
if name == "" || pw == "" {
|
||||
fmt.Println("missing USER_NAME or NEW_PASSWORD")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
cfg := config.Load()
|
||||
db, err := dbpkg.Get(cfg)
|
||||
if err != nil {
|
||||
fmt.Println("db error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
fmt.Println("hash error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
res, err := db.Exec("UPDATE users SET password_hash=? WHERE name=?", string(hash), name)
|
||||
if err != nil {
|
||||
fmt.Println("update error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
fmt.Printf("updated %d rows\n", n)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user