diff --git a/.env b/.env new file mode 100644 index 0000000..4eeda1a --- /dev/null +++ b/.env @@ -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 \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..688a7cd --- /dev/null +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/build/go-dy-linux-amd64 b/build/go-dy-linux-amd64 new file mode 100644 index 0000000..4a61a04 Binary files /dev/null and b/build/go-dy-linux-amd64 differ diff --git a/build/go-dy-linux-arm64 b/build/go-dy-linux-arm64 new file mode 100644 index 0000000..82acc6b Binary files /dev/null and b/build/go-dy-linux-arm64 differ diff --git a/build/go-dy-windows-amd64.exe b/build/go-dy-windows-amd64.exe new file mode 100644 index 0000000..e06e526 Binary files /dev/null and b/build/go-dy-windows-amd64.exe differ diff --git a/captcha.png b/captcha.png new file mode 100644 index 0000000..e69de29 diff --git a/deploy/README_DEPLOY.md b/deploy/README_DEPLOY.md new file mode 100644 index 0000000..8ff7c64 --- /dev/null +++ b/deploy/README_DEPLOY.md @@ -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 密码。 \ No newline at end of file diff --git a/deploy/systemd/go-dy.service b/deploy/systemd/go-dy.service new file mode 100644 index 0000000..29e0936 --- /dev/null +++ b/deploy/systemd/go-dy.service @@ -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 \ No newline at end of file diff --git a/dist/.env.example b/dist/.env.example new file mode 100644 index 0000000..688a7cd --- /dev/null +++ b/dist/.env.example @@ -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 \ No newline at end of file diff --git a/dist/deploy/README_DEPLOY.md b/dist/deploy/README_DEPLOY.md new file mode 100644 index 0000000..8ff7c64 --- /dev/null +++ b/dist/deploy/README_DEPLOY.md @@ -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 密码。 \ No newline at end of file diff --git a/dist/deploy/deploy/README_DEPLOY.md b/dist/deploy/deploy/README_DEPLOY.md new file mode 100644 index 0000000..8ff7c64 --- /dev/null +++ b/dist/deploy/deploy/README_DEPLOY.md @@ -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 密码。 \ No newline at end of file diff --git a/dist/deploy/deploy/systemd/go-dy.service b/dist/deploy/deploy/systemd/go-dy.service new file mode 100644 index 0000000..29e0936 --- /dev/null +++ b/dist/deploy/deploy/systemd/go-dy.service @@ -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 \ No newline at end of file diff --git a/dist/deploy/systemd/go-dy.service b/dist/deploy/systemd/go-dy.service new file mode 100644 index 0000000..29e0936 --- /dev/null +++ b/dist/deploy/systemd/go-dy.service @@ -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 \ No newline at end of file diff --git a/dist/go-dy-linux-amd64 b/dist/go-dy-linux-amd64 new file mode 100644 index 0000000..4a61a04 Binary files /dev/null and b/dist/go-dy-linux-amd64 differ diff --git a/dist/go-dy-linux-arm64 b/dist/go-dy-linux-arm64 new file mode 100644 index 0000000..82acc6b Binary files /dev/null and b/dist/go-dy-linux-arm64 differ diff --git a/dist/go-dy-release.zip b/dist/go-dy-release.zip new file mode 100644 index 0000000..be8d632 Binary files /dev/null and b/dist/go-dy-release.zip differ diff --git a/dist/go-dy-windows-amd64.exe b/dist/go-dy-windows-amd64.exe new file mode 100644 index 0000000..e06e526 Binary files /dev/null and b/dist/go-dy-windows-amd64.exe differ diff --git a/go-dy b/go-dy new file mode 100644 index 0000000..e06e526 Binary files /dev/null and b/go-dy differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..85cb07f --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7b39c34 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go new file mode 100644 index 0000000..e5428d9 --- /dev/null +++ b/internal/auth/jwt.go @@ -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 +} \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..08020d5 --- /dev/null +++ b/internal/config/config.go @@ -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 + } +} \ No newline at end of file diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..c740e0e --- /dev/null +++ b/internal/db/db.go @@ -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 +} \ No newline at end of file diff --git a/internal/handlers/captcha.go b/internal/handlers/captcha.go new file mode 100644 index 0000000..24cef95 --- /dev/null +++ b/internal/handlers/captcha.go @@ -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, + }) + } +} \ No newline at end of file diff --git a/internal/handlers/login.go b/internal/handlers/login.go new file mode 100644 index 0000000..b4884d6 --- /dev/null +++ b/internal/handlers/login.go @@ -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}) + } +} \ No newline at end of file diff --git a/internal/handlers/register.go b/internal/handlers/register.go new file mode 100644 index 0000000..7a6ce6e --- /dev/null +++ b/internal/handlers/register.go @@ -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}) + } +} \ No newline at end of file diff --git a/internal/handlers/upload.go b/internal/handlers/upload.go new file mode 100644 index 0000000..1df7176 --- /dev/null +++ b/internal/handlers/upload.go @@ -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, + }) + } +} \ No newline at end of file diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..142d718 --- /dev/null +++ b/internal/middleware/auth.go @@ -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() + } +} diff --git a/internal/oss/oss.go b/internal/oss/oss.go new file mode 100644 index 0000000..aec4b07 --- /dev/null +++ b/internal/oss/oss.go @@ -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) +} \ No newline at end of file diff --git a/internal/repo/user.go b/internal/repo/user.go new file mode 100644 index 0000000..084f3f7 --- /dev/null +++ b/internal/repo/user.go @@ -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 +} \ No newline at end of file diff --git a/internal/resp/resp.go b/internal/resp/resp.go new file mode 100644 index 0000000..73ddce0 --- /dev/null +++ b/internal/resp/resp.go @@ -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}) +} \ No newline at end of file diff --git a/main.go b/main.go new file mode 100644 index 0000000..32f4fdf --- /dev/null +++ b/main.go @@ -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) + } +} \ No newline at end of file diff --git a/test.txt b/test.txt new file mode 100644 index 0000000..ce01362 --- /dev/null +++ b/test.txt @@ -0,0 +1 @@ +hello diff --git a/tools/update_password.go b/tools/update_password.go new file mode 100644 index 0000000..e59f682 --- /dev/null +++ b/tools/update_password.go @@ -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) +} \ No newline at end of file