feat: complete API-driven projects with infrastructure and business acceptance
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
# GoOmni service
|
||||
|
||||
This service was generated with `go-omni new`.
|
||||
{{if .Modules}}
|
||||
**本项目启用了模块化配置,启动步骤和环境变量请先看 [MODULES.md](MODULES.md)。** 配置来自 `etc/config.yaml`,文件模块需要配置环境密钥后启动。
|
||||
{{end}}
|
||||
|
||||
Framework dependency: `git.yuntuo.hk/k2safe/go-omni {{.FrameworkVersion}}`.
|
||||
|
||||
Storage: `{{.Database}}`.
|
||||
Database: `{{.Database}}`.
|
||||
|
||||
{{if eq .Database "mysql"}}
|
||||
先创建业务数据库,再执行项目的建表脚本(替换示例数据库名、账号和地址):
|
||||
|
||||
69
cmd/go-omni/scaffold/templates/api-context.go.tmpl
Normal file
69
cmd/go-omni/scaffold/templates/api-context.go.tmpl
Normal file
@@ -0,0 +1,69 @@
|
||||
package servicecontext
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
{{if ne .Storage "none"}}"net/http"
|
||||
"git.yuntuo.hk/k2safe/go-omni/storage"{{end}}
|
||||
{{if eq .Database "mysql"}}"time"
|
||||
"git.yuntuo.hk/k2safe/go-omni/database"
|
||||
"github.com/go-sql-driver/mysql"{{end}}
|
||||
"git.yuntuo.hk/k2safe/go-omni/health"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"{{.Module}}/internal/settings"
|
||||
"{{.Module}}/internal/middleware"
|
||||
)
|
||||
// Context owns infrastructure, while Logger is borrowed from main.
|
||||
// Add business repositories here; HTTP contracts do not define database tables.
|
||||
type Context struct {
|
||||
Logger *logx.Logger
|
||||
Health *health.Checker
|
||||
Middlewares map[string]httpx.Middleware
|
||||
{{if eq .Database "mysql"}}DB *database.DB{{end}}
|
||||
{{if ne .Storage "none"}}
|
||||
Files storage.Store
|
||||
Uploads *storage.Uploader
|
||||
Downloads storage.Presigner
|
||||
UploadLinks storage.UploadPresigner
|
||||
FileHTTP http.Handler
|
||||
storageClose func()error
|
||||
{{end}}
|
||||
closeMu sync.Mutex
|
||||
closed bool
|
||||
closeOnce sync.Once
|
||||
closeErr error
|
||||
}
|
||||
// Pass the settings loaded by main. The optional form supports lightweight
|
||||
// tests without infrastructure; selected adapters still require valid settings.
|
||||
func New(ctx context.Context,logger *logx.Logger,configs ...settings.Config)(result *Context,err error){
|
||||
if ctx==nil{ctx=context.Background()}
|
||||
if err=ctx.Err();err!=nil{return nil,err}
|
||||
if len(configs)>1{return nil,errors.New("expected at most one configuration")}
|
||||
cfg:=settings.Default();if len(configs)==1{cfg=configs[0]}
|
||||
_=cfg
|
||||
checker,err:=health.New();if err!=nil{return nil,err}
|
||||
c:=&Context{Logger:logger,Health:checker,Middlewares:middleware.Defaults()}
|
||||
defer func(){if err!=nil{err=errors.Join(err,c.Close())}}()
|
||||
{{if eq .Database "mysql"}}
|
||||
if err=cfg.Validate();err!=nil{return nil,err}
|
||||
dsn,parseErr:=mysql.ParseDSN(cfg.Database.DSN)
|
||||
if parseErr!=nil || dsn.DBName=="" {return nil,errors.New("GO_OMNI_DB_DSN must contain a valid MySQL database name")}
|
||||
dsn.ParseTime=true;dsn.Loc=time.UTC
|
||||
cfg.Database.DSN=dsn.FormatDSN()
|
||||
c.DB,err=database.Open(ctx,cfg.Database);if err!=nil{return nil,err}
|
||||
if err=checker.Add(health.Probe{Name:"database",Timeout:cfg.Database.PingTimeout,Check:c.DB.Health});err!=nil{return nil,err}
|
||||
{{end}}
|
||||
{{if ne .Storage "none"}}if err=c.InitStorage(ctx,cfg.Storage);err!=nil{return nil,err}{{end}}
|
||||
return c,nil
|
||||
}
|
||||
// Close is idempotent, including startup failure and graceful shutdown.
|
||||
func(c *Context)Close()error{
|
||||
if c==nil{return nil}
|
||||
c.closeMu.Lock();defer c.closeMu.Unlock();c.closed=true
|
||||
c.closeOnce.Do(func(){
|
||||
{{if ne .Storage "none"}}if c.storageClose!=nil{c.closeErr=errors.Join(c.closeErr,c.storageClose())}{{end}}
|
||||
{{if eq .Database "mysql"}}if c.DB!=nil{c.closeErr=errors.Join(c.closeErr,c.DB.Close())}{{end}}
|
||||
})
|
||||
return c.closeErr
|
||||
}
|
||||
@@ -23,7 +23,10 @@ func (s *Users) Create(ctx context.Context, input model.UserInput) (model.User,
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
return s.services.Users.Create(ctx, input)
|
||||
user, err := s.services.Users.Create(ctx, input)
|
||||
if err != nil { s.services.Logger.ErrorContext(ctx,"user.create.failed","error",err); return model.User{},err }
|
||||
s.services.Logger.InfoContext(ctx,"user.created","userId",user.ID)
|
||||
return user,nil
|
||||
}
|
||||
|
||||
func (s *Users) Get(ctx context.Context, id int64) (model.User, error) {
|
||||
|
||||
@@ -2,6 +2,10 @@ package servicecontext
|
||||
|
||||
import (
|
||||
"context"
|
||||
{{if and .Modules (ne .Storage "none")}}"errors"
|
||||
"net/http"
|
||||
"sync"
|
||||
"git.yuntuo.hk/k2safe/go-omni/storage"{{end}}
|
||||
{{if eq .Database "mysql"}}"fmt"
|
||||
"time"
|
||||
"git.yuntuo.hk/k2safe/go-omni/database"
|
||||
@@ -17,6 +21,18 @@ type Context struct {
|
||||
Logger *logx.Logger
|
||||
Users repository.UserRepository
|
||||
Health *health.Checker
|
||||
{{if and .Modules (ne .Storage "none")}}
|
||||
Files storage.Store
|
||||
Uploads *storage.Uploader
|
||||
Downloads storage.Presigner
|
||||
UploadLinks storage.UploadPresigner
|
||||
FileHTTP http.Handler
|
||||
storageClose func() error
|
||||
closeMu sync.Mutex
|
||||
closed bool
|
||||
closeOnce sync.Once
|
||||
closeErr error
|
||||
{{end}}
|
||||
{{if eq .Database "mysql"}}DB *database.DB{{end}}
|
||||
}
|
||||
|
||||
@@ -61,6 +77,15 @@ func New(ctx context.Context, logger *logx.Logger) (*Context, error) {
|
||||
|
||||
// Close is safe to call on startup failure and again during application exit.
|
||||
func (c *Context) Close() error {
|
||||
{{if eq .Database "mysql"}}if c != nil && c.DB != nil { return c.DB.Close() }{{end}}
|
||||
return nil
|
||||
{{if and .Modules (ne .Storage "none")}}
|
||||
if c==nil{return nil}
|
||||
c.closeMu.Lock();defer c.closeMu.Unlock()
|
||||
c.closed=true
|
||||
c.closeOnce.Do(func(){
|
||||
if c.storageClose!=nil {c.closeErr=errors.Join(c.closeErr,c.storageClose())}
|
||||
{{if eq .Database "mysql"}}if c.DB!=nil{c.closeErr=errors.Join(c.closeErr,c.DB.Close())}{{end}}
|
||||
})
|
||||
return c.closeErr
|
||||
{{else}}{{if eq .Database "mysql"}}if c != nil && c.DB != nil { return c.DB.Close() }{{end}}{{end}}
|
||||
{{if not (and .Modules (ne .Storage "none"))}}return nil{{end}}
|
||||
}
|
||||
|
||||
97
cmd/go-omni/scaffold/templates/modules-README.md.tmpl
Normal file
97
cmd/go-omni/scaffold/templates/modules-README.md.tmpl
Normal file
@@ -0,0 +1,97 @@
|
||||
# 按需装配的日志与文件模块
|
||||
|
||||
本项目选用日志输出 `{{.LogOutput}}`、格式 `{{.LogFormat}}`、对象存储 `{{.Storage}}`。数据库选择 `{{.Database}}` 独立于对象存储。
|
||||
|
||||
配置入口为 `etc/config.yaml`,通过 `go run ./cmd/server -config /path/to/config.yaml` 指定其他 JSON/YAML 文件;`-config ''` 只用代码默认值和环境变量。优先级:默认值 → 文件 → 非空环境变量。未知字段、无效参数会在监听前拒绝。
|
||||
|
||||
```sh
|
||||
go mod tidy
|
||||
{{if ne .Storage "none"}}# 示例文件接口使用单一共享 Token,不是用户/租户权限系统。
|
||||
export GO_OMNI_FILES_TOKEN="$(openssl rand -hex 32)"
|
||||
{{if eq .Storage "local"}}# 本地签名密钥需要稳定保存;换密钥后旧链接失效。
|
||||
export GO_OMNI_STORAGE_SIGNING_KEY="$(openssl rand -hex 32)"
|
||||
{{else}}# 配置当前存储平台已有的桶和凭据,不会自动创建桶。
|
||||
export GO_OMNI_STORAGE_BUCKET='your-bucket'
|
||||
export GO_OMNI_STORAGE_ACCESS_KEY='your-access-key'
|
||||
export GO_OMNI_STORAGE_SECRET_KEY='your-secret-key'
|
||||
{{if eq .Storage "s3"}}export GO_OMNI_STORAGE_ENDPOINT='s3.us-east-1.amazonaws.com'
|
||||
export GO_OMNI_STORAGE_REGION='us-east-1'
|
||||
{{else if eq .Storage "r2"}}export GO_OMNI_STORAGE_ACCOUNT_ID='your-32-character-account-id'
|
||||
{{else}}export GO_OMNI_STORAGE_REGION='cn-hangzhou'
|
||||
{{end}}{{end}}{{end}}
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
{{if eq .Database "mysql"}}还需按 README 配置独立 MySQL 数据库并应用 schema.sql;应用不自动迁移。{{end}}
|
||||
|
||||
默认仅监听 `127.0.0.1:8080`。`GO_OMNI_HTTP_ADDR` 覆盖地址;本地签名 URL 的外部地址通过 `GO_OMNI_STORAGE_PUBLIC_URL` 单独配置,始终以 `/storage/object` 结尾,不使用请求 Host 自动拼接可信链接。
|
||||
|
||||
## 日志
|
||||
|
||||
业务示例在创建用户/上传文件成功时通过 logx 写结构化日志。Context 原样传递 requestId,框架不自动增加用户、租户等业务字段。文件模式已配置轮转:100 MiB、10 份、7 天、压缩。调整 `server.log` 即可更改;`redactKeys` 可增加业务敏感键。
|
||||
|
||||
```sh
|
||||
curl -H 'Content-Type: application/json' -H 'X-Request-ID: example-request' \
|
||||
-d '{"name":"Example","email":"example@example.com"}' http://127.0.0.1:8080/users
|
||||
```
|
||||
|
||||
`GO_OMNI_LOG_LEVEL`、`GO_OMNI_LOG_FORMAT`、`GO_OMNI_LOG_FILE` 覆盖对应项;显式设置 `GO_OMNI_LOG_OUTPUT=stdout` 且未设置 LOG_FILE 时,切换到控制台并关闭文件轮转。慢请求阈值为 500ms,可通过 `GO_OMNI_SLOW_REQUEST_THRESHOLD` 调整。字段脱敏不扫描 msg/error 中的自由文本;不要把凭据或签名 URL 拼到日志中。
|
||||
|
||||
{{if ne .Storage "none"}}## 文件示例
|
||||
|
||||
Handler → Service → storage 接口;底层构造与连接池集中在 `internal/servicecontext/storage.go`。示例接口要求 `Authorization: Bearer $GO_OMNI_FILES_TOKEN`,它授权这个示例的全部文件,实际业务应替换认证和对象归属检查。存储适配器不承担业务授权。
|
||||
|
||||
```sh
|
||||
printf 'hello go-omni' > /tmp/goOmniExample.txt
|
||||
curl -H "Authorization: Bearer $GO_OMNI_FILES_TOKEN" \
|
||||
-H 'Content-Type: text/plain' -H 'X-Request-ID: file-example' \
|
||||
--data-binary @/tmp/goOmniExample.txt http://127.0.0.1:8080/files
|
||||
```
|
||||
|
||||
保存响应中 `data.key`,再请求下载链接(把示例 key 替换为返回值):
|
||||
|
||||
```sh
|
||||
curl -H "Authorization: Bearer $GO_OMNI_FILES_TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"key":"uploads/returned-key"}' http://127.0.0.1:8080/files/downloadLink
|
||||
```
|
||||
|
||||
返回 `data.url`,在有效期内 GET 即可下载,无需再携带 Token。签发前会检查对象存在;URL 默认 5 分钟有效,不写日志,响应设置 no-store。
|
||||
|
||||
需要直传时先申请链接:
|
||||
|
||||
```sh
|
||||
curl -H "Authorization: Bearer $GO_OMNI_FILES_TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"size":13,"contentType":"text/plain"}' http://127.0.0.1:8080/files/uploadLink
|
||||
```
|
||||
|
||||
响应包括随机 `data.key` 和 `data.request`(method/url/headers/expiresAt)。按返回的 method 和 headers 用原始文件字节上传到 URL;不能额外包 FormData。浏览器自动填写 Content-Length,其余签名 Header 原样发送;云桶需配置允许业务域名的 CORS。云端直传只绑定声明类型,实际内容需上传后验证;本地和 API 中转上传会检查允许类型及前缀嗅探结果。未完成内容验证前不要把暂存文件当作可信文件公开。
|
||||
|
||||
| 参数 | 默认值 / 环境变量 |
|
||||
| --- | --- |
|
||||
| 大小 | 32 MiB / GO_OMNI_STORAGE_MAX_BYTES |
|
||||
| 类型 | text/plain、image/png、image/jpeg、application/pdf / GO_OMNI_STORAGE_CONTENT_TYPES(逗号分隔) |
|
||||
| 链接有效期 | 5m / GO_OMNI_STORAGE_LINK_TTL(1s..1h) |
|
||||
| 请求超时 | 30s / GO_OMNI_STORAGE_REQUEST_TIMEOUT(>0 且 <=5m) |
|
||||
| 本地目录 | var/files / GO_OMNI_STORAGE_ROOT |
|
||||
| 本地外部 URL | http://127.0.0.1:8080/storage/object / GO_OMNI_STORAGE_PUBLIC_URL |
|
||||
| 云配置 | GO_OMNI_STORAGE_ENDPOINT、REGION、BUCKET、ACCOUNT_ID、SECURE |
|
||||
| 环境密钥 | GO_OMNI_FILES_TOKEN、GO_OMNI_STORAGE_SIGNING_KEY、GO_OMNI_STORAGE_ACCESS_KEY、SECRET_KEY、SESSION_TOKEN |
|
||||
|
||||
全部 JSON/YAML 字段使用 lowerCamelCase。密钥字段仅允许环境变量,配置文件中写入会报未知字段;不要把运行环境文件提交到仓库。S3 的 endpoint 为 host[:port],OSS 的可选 endpoint 为 HTTPS origin;R2 从 accountId 派生官方 endpoint,region 为 auto。仅 S3/MinIO 的本地测试可设置 SECURE=false。
|
||||
|
||||
错误响应:未授权 401、无 Content-Length 411、超限 413、类型不允许 415、长度不符/非法 key 400、对象不存在 404、存储超时 504,其余内部错误统一隐藏细节。底层签名入口遵循存储协议返回 201/403 等状态,流式传输且访问日志保留实际状态。
|
||||
|
||||
云客户端只在组合处创建,使用专用有界连接池;配置正确不代表桶和网络已经可用,首次真实调用仍可能失败。不创建桶、不修改桶权限、不初始化任何业务数据库。更换平台时替换组合处适配器,业务 Service 接口保持不变。
|
||||
{{end}}
|
||||
|
||||
## 生命周期与验证
|
||||
|
||||
启动失败会清理已创建资源;SIGINT/SIGTERM 后先等待 HTTP 请求退出,再关闭服务依赖,最后关闭日志。只关闭项目自己创建的资源,不关闭全局默认 HTTP Transport。
|
||||
|
||||
```sh
|
||||
go test -race ./...
|
||||
go build -o server ./cmd/server
|
||||
./server
|
||||
```
|
||||
|
||||
生成的测试覆盖配置优先级/校验{{if ne .Storage "none"}}、资源关闭幂等性和借用日志所有权、上传与临时链接、签名篡改、限额/类型错误。业务测试使用临时 Local,不访问云账号;云端配置需另做实网验收{{end}}。基础用户 CRUD 示例仍未启用认证,部署前按业务需要替换。
|
||||
46
cmd/go-omni/scaffold/templates/modules-config.yaml.tmpl
Normal file
46
cmd/go-omni/scaffold/templates/modules-config.yaml.tmpl
Normal file
@@ -0,0 +1,46 @@
|
||||
# Non-secret settings only. Environment variables override these values.
|
||||
server:
|
||||
address: '127.0.0.1:8080'
|
||||
enableLogger: true
|
||||
slowRequestThreshold: 500ms
|
||||
shutdownTimeout: 10s
|
||||
log:
|
||||
service: {{printf "%q" .Module}}
|
||||
level: info
|
||||
format: {{.LogFormat}}
|
||||
{{if eq .LogOutput "file"}} file: './var/log/server.log'
|
||||
rotation:
|
||||
enabled: true
|
||||
maxSizeMb: 100
|
||||
maxBackups: 10
|
||||
maxAgeDays: 7
|
||||
compress: true
|
||||
{{else}} output: {{.LogOutput}}
|
||||
{{end}} redactKeys: []
|
||||
{{if ne .Storage "none"}}storage:
|
||||
provider: {{.Storage}}
|
||||
maxBytes: 33554432
|
||||
contentTypes: ['text/plain', 'image/png', 'image/jpeg', 'application/pdf']
|
||||
linkTtl: 5m
|
||||
requestTimeout: 30s
|
||||
{{if eq .Storage "local"}} root: './var/files'
|
||||
publicUrl: 'http://127.0.0.1:8080/storage/object'
|
||||
{{else}} bucket: '' # set GO_OMNI_STORAGE_BUCKET
|
||||
secure: true
|
||||
{{if eq .Storage "r2"}} accountId: '' # set GO_OMNI_STORAGE_ACCOUNT_ID
|
||||
region: auto
|
||||
{{else if eq .Storage "s3"}} endpoint: '' # set GO_OMNI_STORAGE_ENDPOINT (host[:port])
|
||||
region: us-east-1
|
||||
{{else}} region: cn-hangzhou
|
||||
endpoint: '' # empty uses the official regional endpoint
|
||||
{{end}}{{end}}{{end}}
|
||||
|
||||
{{if and .API (eq .Database "mysql")}}database:
|
||||
driver: mysql
|
||||
maxOpenConns: 10
|
||||
maxIdleConns: 5
|
||||
connMaxLifetime: 30m
|
||||
connMaxIdleTime: 5m
|
||||
pingTimeout: 5s
|
||||
# GO_OMNI_DB_DSN supplies the secret connection string.
|
||||
{{end}}
|
||||
86
cmd/go-omni/scaffold/templates/modules-filesHandler.go.tmpl
Normal file
86
cmd/go-omni/scaffold/templates/modules-filesHandler.go.tmpl
Normal file
@@ -0,0 +1,86 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"git.yuntuo.hk/k2safe/go-omni/storage"
|
||||
"{{.Module}}/internal/service"
|
||||
)
|
||||
|
||||
type Files struct {service *service.Files; maxBytes int64; timeout time.Duration}
|
||||
func NewFiles(service *service.Files,maxBytes int64,timeout time.Duration)*Files{return &Files{service:service,maxBytes:maxBytes,timeout:timeout}}
|
||||
|
||||
// RegisterRoutes protects example control endpoints with one shared demo token.
|
||||
// Replace this gate with project authentication and object ownership checks.
|
||||
// Signed object URLs carry their own short-lived authorization.
|
||||
func (h *Files) RegisterRoutes(application *omni.App,token string,signed http.Handler)error {
|
||||
if len(token)<32{return errors.New("file example requires a token of at least 32 bytes")}
|
||||
bounded:=func(next omni.Handler)omni.Handler{return func(c *omni.Context)error{
|
||||
ctx,cancel:=context.WithTimeout(c.Context(),h.timeout);defer cancel();c.WithContext(ctx);return next(c)
|
||||
}}
|
||||
authorized:=func(next omni.Handler)omni.Handler{return func(c *omni.Context)error{
|
||||
if len(c.Request().Header.Values("Authorization"))!=1 || subtle.ConstantTimeCompare([]byte(c.Header("Authorization")),[]byte("Bearer "+token))!=1{return omni.Unauthorized("UNAUTHORIZED","invalid file access token")}
|
||||
return next(c)
|
||||
}}
|
||||
group:=application.Group("/files",authorized,bounded)
|
||||
group.POST("",h.Upload)
|
||||
group.POST("/uploadLink",h.UploadLink)
|
||||
group.POST("/downloadLink",h.DownloadLink)
|
||||
if signed!=nil {application.Any("/storage/object",func(c *omni.Context)error{
|
||||
signed.ServeHTTP(contextWriter{c},c.Request());return nil
|
||||
},bounded)}
|
||||
return nil
|
||||
}
|
||||
func (h *Files) Upload(c *omni.Context)error {
|
||||
size:=c.Request().ContentLength
|
||||
if size<0{return omni.NewError(http.StatusLengthRequired,"LENGTH_REQUIRED","Content-Length is required")}
|
||||
if size>h.maxBytes{return omni.NewError(http.StatusRequestEntityTooLarge,"FILE_TOO_LARGE","file exceeds upload limit")}
|
||||
body:=http.MaxBytesReader(c.Raw(),c.Request().Body,h.maxBytes)
|
||||
defer body.Close()
|
||||
info,err:=h.service.Upload(c.Context(),body,size,c.Header("Content-Type"))
|
||||
if err!=nil{return fileError(err)}
|
||||
return c.Created(info)
|
||||
}
|
||||
func (h *Files) UploadLink(c *omni.Context)error {
|
||||
var input struct{Size int64 `json:"size" binding:"gte=0"`;ContentType string `json:"contentType" binding:"required,max=256"`}
|
||||
if err:=c.BindJSON(&input);err!=nil{return err}
|
||||
link,err:=h.service.UploadLink(c.Context(),input.Size,input.ContentType)
|
||||
if err!=nil{return fileError(err)}
|
||||
c.SetResponseHeader("Cache-Control","no-store")
|
||||
return c.OK(link)
|
||||
}
|
||||
func (h *Files) DownloadLink(c *omni.Context)error {
|
||||
var input struct{Key string `json:"key" binding:"required,max=1024"`}
|
||||
if err:=c.BindJSON(&input);err!=nil{return err}
|
||||
if err:=storage.Check(c.Context(),input.Key);err!=nil{return omni.BadRequest("INVALID_KEY","invalid object key")}
|
||||
link,err:=h.service.DownloadLink(c.Context(),input.Key)
|
||||
if err!=nil{return fileError(err)}
|
||||
c.SetResponseHeader("Cache-Control","no-store")
|
||||
return c.OK(map[string]string{"url":link})
|
||||
}
|
||||
func fileError(err error)error {
|
||||
var limit *http.MaxBytesError
|
||||
switch {
|
||||
case errors.Is(err,storage.ErrTooLarge),errors.As(err,&limit):return omni.NewError(413,"FILE_TOO_LARGE","file exceeds upload limit").WithCause(err)
|
||||
case errors.Is(err,storage.ErrContentType):return omni.NewError(415,"CONTENT_TYPE_NOT_ALLOWED","file content type is not allowed").WithCause(err)
|
||||
case errors.Is(err,storage.ErrSizeMismatch):return omni.BadRequest("SIZE_MISMATCH","file size does not match Content-Length").WithCause(err)
|
||||
case errors.Is(err,storage.ErrNotFound):return omni.NotFound("FILE_NOT_FOUND","file was not found").WithCause(err)
|
||||
case errors.Is(err,context.DeadlineExceeded):return omni.NewError(504,"STORAGE_TIMEOUT","storage operation timed out").WithCause(err)
|
||||
default:return err
|
||||
}
|
||||
}
|
||||
|
||||
// Track raw streaming handler responses through Omni so access logs retain the
|
||||
// actual status (including failed signatures), without buffering object bodies.
|
||||
type contextWriter struct{ctx *omni.Context}
|
||||
func (w contextWriter) Header()http.Header{return w.ctx.Raw().Header()}
|
||||
func (w contextWriter) WriteHeader(status int){_ = w.ctx.WriteResponse(status,nil,nil)}
|
||||
func (w contextWriter) Write(data []byte)(int,error){
|
||||
if !w.ctx.Responded(){if err:=w.ctx.WriteResponse(http.StatusOK,nil,nil);err!=nil{return 0,err}}
|
||||
return w.ctx.Raw().Write(data)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package handler_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/storage"
|
||||
"git.yuntuo.hk/k2safe/go-omni/storage/local"
|
||||
"{{.Module}}/internal/handler"
|
||||
"{{.Module}}/internal/service"
|
||||
)
|
||||
|
||||
// Exercise business code using Local even when production selects a cloud
|
||||
// adapter. These tests do not use cloud accounts or network credentials.
|
||||
func TestFileUploadAndLinks(t *testing.T){
|
||||
files,err:=local.New(t.TempDir(),64);if err!=nil{t.Fatal(err)}
|
||||
policy:=storage.UploadPolicy{MaxBytes:64,ContentTypes:[]string{"text/plain"}}
|
||||
signer,err:=storage.NewUploadSigner(files,"http://localhost/storage/object",bytes.Repeat([]byte{1},32),policy);if err!=nil{t.Fatal(err)}
|
||||
uploader,err:=storage.NewUploader(files,policy);if err!=nil{t.Fatal(err)}
|
||||
var logs bytes.Buffer
|
||||
logger,err:=logx.New(logx.Config{Writer:&logs});if err!=nil{t.Fatal(err)}
|
||||
logx.SetDefault(logger);defer logx.SetDefault(nil)
|
||||
app:=omni.New(omni.WithLogger(true))
|
||||
token:=strings.Repeat("t",32)
|
||||
svc:=service.NewFiles(files,uploader,signer,signer,logger,time.Minute)
|
||||
if err=handler.NewFiles(svc,64,time.Second).RegisterRoutes(app,token,signer);err!=nil{t.Fatal(err)}
|
||||
request:=func(method,path,body,contentType string,authorized bool)*httptest.ResponseRecorder{
|
||||
r:=httptest.NewRequest(method,path,strings.NewReader(body));r.Header.Set("Content-Type",contentType);r.Header.Set("X-Request-ID","upload-test")
|
||||
if authorized{r.Header.Set("Authorization","Bearer "+token)}
|
||||
w:=httptest.NewRecorder();app.ServeHTTP(w,r);return w
|
||||
}
|
||||
if w:=request("POST","/files","hello","text/plain",false);w.Code!=401{t.Fatal(w.Code)}
|
||||
w:=request("POST","/files","hello","text/plain",true)
|
||||
var saved struct{Data storage.Info}
|
||||
if w.Code!=201 || json.Unmarshal(w.Body.Bytes(),&saved)!=nil || saved.Data.Size!=5{t.Fatal(w.Code,w.Body.String())}
|
||||
raw,_:=json.Marshal(map[string]string{"key":saved.Data.Key})
|
||||
w=request("POST","/files/downloadLink",string(raw),"application/json",true)
|
||||
var download struct{Data struct{URL string}}
|
||||
if w.Code!=200 || json.Unmarshal(w.Body.Bytes(),&download)!=nil{t.Fatal(w.Code,w.Body.String())}
|
||||
w=request("GET",download.Data.URL,"","",false)
|
||||
if w.Code!=200 || w.Body.String()!="hello"{t.Fatal(w.Code,w.Body.String())}
|
||||
u,_:=url.Parse(download.Data.URL);q:=u.Query();q.Set("signature","tampered");u.RawQuery=q.Encode()
|
||||
if w=request("GET",u.String(),"","",false);w.Code!=403{t.Fatal(w.Code)}
|
||||
for _,tc:=range []struct{path,body,contentType string;status int}{
|
||||
{"/files",strings.Repeat("x",65),"text/plain",413},
|
||||
{"/files","hello","image/png",415},
|
||||
{"/files","<html>hello</html>","text/plain",415},
|
||||
{"/files/downloadLink",`{"key":"../bad"}`,"application/json",400},
|
||||
{"/files/downloadLink",`{"key":"missing"}`,"application/json",404},
|
||||
{"/files/uploadLink",`{"size":65,"contentType":"text/plain"}`,"application/json",413},
|
||||
{"/files/uploadLink",`{"size":5,"contentType":"image/png"}`,"application/json",415},
|
||||
}{if w=request("POST",tc.path,tc.body,tc.contentType,true);w.Code!=tc.status{t.Fatal(tc.path,w.Code,w.Body.String())}}
|
||||
w=request("POST","/files/uploadLink",`{"size":5,"contentType":"text/plain"}`,"application/json",true)
|
||||
var direct struct{Data service.FileUploadLink}
|
||||
if w.Code!=200 || json.Unmarshal(w.Body.Bytes(),&direct)!=nil{t.Fatal(w.Code,w.Body.String())}
|
||||
if w=request("PUT",direct.Data.Request.URL,"world","text/plain",false);w.Code!=201{t.Fatal(w.Code,w.Body.String())}
|
||||
object,err:=files.Get(context.Background(),direct.Data.Key);if err!=nil{t.Fatal(err)};body,err:=io.ReadAll(object.Body);object.Body.Close();if err!=nil || string(body)!="world"{t.Fatal(err,string(body))}
|
||||
decoder:=json.NewDecoder(&logs);sawBusiness,sawDenied:=false,false
|
||||
for {var entry map[string]any;err:=decoder.Decode(&entry);if err==io.EOF{break};if err!=nil{t.Fatal(err)}
|
||||
if entry["requestId"]!="upload-test"{t.Fatal("lost request correlation")}
|
||||
if entry["msg"]=="file.uploaded"{sawBusiness=true}
|
||||
if entry["msg"]=="http.request" && entry["path"]=="/storage/object" && entry["status"]==float64(http.StatusForbidden){sawDenied=true}
|
||||
}
|
||||
if !sawBusiness || !sawDenied{t.Fatal("business log or signed-handler status missing")}
|
||||
}
|
||||
61
cmd/go-omni/scaffold/templates/modules-filesService.go.tmpl
Normal file
61
cmd/go-omni/scaffold/templates/modules-filesService.go.tmpl
Normal file
@@ -0,0 +1,61 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/storage"
|
||||
)
|
||||
|
||||
// Files borrows provider-neutral capabilities; it never creates or closes clients.
|
||||
// Authentication/ownership rules belong to this project, outside storage adapters.
|
||||
type Files struct {
|
||||
store storage.Store
|
||||
uploader *storage.Uploader
|
||||
downloads storage.Presigner
|
||||
uploadLinks storage.UploadPresigner
|
||||
logger *logx.Logger
|
||||
ttl time.Duration
|
||||
}
|
||||
func NewFiles(store storage.Store,uploader *storage.Uploader,downloads storage.Presigner,uploadLinks storage.UploadPresigner,logger *logx.Logger,ttl time.Duration)*Files{
|
||||
return &Files{store:store,uploader:uploader,downloads:downloads,uploadLinks:uploadLinks,logger:logger.With("component","files"),ttl:ttl}
|
||||
}
|
||||
func newFileKey()(string,error){
|
||||
var id [16]byte
|
||||
if _,err:=rand.Read(id[:]);err!=nil{return "",err}
|
||||
return "uploads/"+hex.EncodeToString(id[:]),nil
|
||||
}
|
||||
func (s *Files) Upload(ctx context.Context,body io.Reader,size int64,contentType string)(storage.Info,error){
|
||||
key,err:=newFileKey();if err!=nil{return storage.Info{},err}
|
||||
info,err:=s.uploader.Put(ctx,key,body,size,contentType)
|
||||
if err!=nil{s.logger.WarnContext(ctx,"file.upload.failed","error",err);return storage.Info{},err}
|
||||
s.logger.InfoContext(ctx,"file.uploaded","key",info.Key,"size",info.Size,"contentType",info.ContentType)
|
||||
return info,nil
|
||||
}
|
||||
type FileUploadLink struct {
|
||||
Key string `json:"key"`
|
||||
Request storage.UploadRequest `json:"request"`
|
||||
}
|
||||
func (s *Files) UploadLink(ctx context.Context,size int64,contentType string)(FileUploadLink,error){
|
||||
key,err:=newFileKey();if err!=nil{return FileUploadLink{},err}
|
||||
// Check the same policy before issuing a local or cloud capability.
|
||||
// Cloud byte-level checks must happen in a post-upload validation workflow.
|
||||
request,err:=s.uploadLinks.PresignPut(ctx,key,size,contentType,s.ttl)
|
||||
if err!=nil{return FileUploadLink{},err}
|
||||
s.logger.InfoContext(ctx,"file.uploadLink.issued","key",key)
|
||||
return FileUploadLink{Key:key,Request:request},nil
|
||||
}
|
||||
func (s *Files) DownloadLink(ctx context.Context,key string)(string,error){
|
||||
if err:=storage.Check(ctx,key);err!=nil{return "",err}
|
||||
// Confirm the object exists without buffering its contents.
|
||||
object,err:=s.store.Get(ctx,key);if err!=nil{return "",err}
|
||||
if err=object.Body.Close();err!=nil{return "",err}
|
||||
link,err:=s.downloads.PresignGet(ctx,key,s.ttl)
|
||||
if err!=nil{return "",err}
|
||||
s.logger.InfoContext(ctx,"file.downloadLink.issued","key",key)
|
||||
return link.String(),nil
|
||||
}
|
||||
6
cmd/go-omni/scaffold/templates/modules-gitignore.tmpl
Normal file
6
cmd/go-omni/scaffold/templates/modules-gitignore.tmpl
Normal file
@@ -0,0 +1,6 @@
|
||||
/var/
|
||||
/server
|
||||
.env
|
||||
.env.*
|
||||
*.local.yaml
|
||||
*.local.json
|
||||
50
cmd/go-omni/scaffold/templates/modules-main.go.tmpl
Normal file
50
cmd/go-omni/scaffold/templates/modules-main.go.tmpl
Normal file
@@ -0,0 +1,50 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"{{.Module}}/internal/handler"
|
||||
"{{.Module}}/internal/service"
|
||||
"{{.Module}}/internal/servicecontext"
|
||||
"{{.Module}}/internal/settings"
|
||||
)
|
||||
|
||||
func main(){
|
||||
path:=flag.String("config","etc/config.yaml","JSON/YAML settings; empty uses defaults and environment")
|
||||
flag.Parse()
|
||||
if err:=run(*path);err!=nil{log.Fatal(err)}
|
||||
}
|
||||
func run(path string)(runErr error){
|
||||
ctx,stop:=signal.NotifyContext(context.Background(),os.Interrupt,syscall.SIGTERM)
|
||||
defer stop()
|
||||
cfg,err:=settings.Load(path)
|
||||
if err!=nil{return err}
|
||||
logger,err:=logx.New(cfg.Server.Log)
|
||||
if err!=nil{return err}
|
||||
defer func(){runErr=errors.Join(runErr,logger.Close())}()
|
||||
logx.SetDefault(logger)
|
||||
services,err:=servicecontext.New(ctx,logger)
|
||||
if err!=nil{return err}
|
||||
defer func(){runErr=errors.Join(runErr,services.Close())}()
|
||||
{{if ne .Storage "none"}}if err=services.InitStorage(ctx,cfg.Storage);err!=nil{return err}{{end}}
|
||||
application:=omni.New(omni.WithConfig(cfg.Server))
|
||||
application.OnShutdown(logger.Close)
|
||||
application.OnShutdown(services.Close)
|
||||
application.GET("/health/live",services.Health.LivenessHandler())
|
||||
application.GET("/health/ready",services.Health.ReadinessHandler())
|
||||
handler.NewUsers(service.NewUsers(services)).RegisterRoutes(application)
|
||||
{{if ne .Storage "none"}}
|
||||
files:=service.NewFiles(services.Files,services.Uploads,services.Downloads,services.UploadLinks,logger,cfg.Storage.LinkTTL)
|
||||
if err=handler.NewFiles(files,cfg.Storage.MaxBytes,cfg.Storage.RequestTimeout).RegisterRoutes(application,cfg.Storage.FilesToken,services.FileHTTP);err!=nil{return err}
|
||||
{{end}}
|
||||
logger.InfoContext(ctx,"service.starting","address",cfg.Server.Address)
|
||||
return application.RunContext(ctx,cfg.Server.Address)
|
||||
}
|
||||
140
cmd/go-omni/scaffold/templates/modules-settings.go.tmpl
Normal file
140
cmd/go-omni/scaffold/templates/modules-settings.go.tmpl
Normal file
@@ -0,0 +1,140 @@
|
||||
// Package settings owns this project's configuration; it adds no business identity model.
|
||||
package settings
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
{{if ne .Storage "none"}}"fmt"
|
||||
{{if ne .Storage "r2"}}"net/url"{{else}}"regexp"{{end}}
|
||||
"strconv"
|
||||
"git.yuntuo.hk/k2safe/go-omni/storage"{{end}}
|
||||
{{if and .API (eq .Database "mysql")}}"git.yuntuo.hk/k2safe/go-omni/database"{{end}}
|
||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server config.Config `json:"server"`
|
||||
{{if and .API (eq .Database "mysql")}}Database database.Config `json:"database"`{{end}}
|
||||
{{if ne .Storage "none"}}Storage StorageConfig `json:"storage"`{{end}}
|
||||
}
|
||||
|
||||
func Default() Config {
|
||||
server:=config.Default()
|
||||
server.Address="127.0.0.1:8080"
|
||||
server.EnableLogger=true
|
||||
server.SlowRequestThreshold=500*time.Millisecond
|
||||
server.Log.Service={{printf "%q" .Module}}
|
||||
server.Log.Format={{printf "%q" .LogFormat}}
|
||||
{{if eq .LogOutput "file"}}server.Log.File="./var/log/server.log"
|
||||
server.Log.Rotation=logx.RotationConfig{Enabled:true,MaxSizeMB:100,MaxBackups:10,MaxAgeDays:7,Compress:true}
|
||||
{{else}}server.Log.Output={{printf "%q" .LogOutput}}{{end}}
|
||||
return Config{Server:server{{if and .API (eq .Database "mysql")}},Database:database.DefaultConfig(){{end}}{{if ne .Storage "none"}},Storage:StorageConfig{
|
||||
Provider:{{printf "%q" .Storage}}, Root:"./var/files", PublicURL:"http://127.0.0.1:8080/storage/object",
|
||||
Secure:true, MaxBytes:32<<20, ContentTypes:[]string{"text/plain","image/png","image/jpeg","application/pdf"},
|
||||
LinkTTL:5*time.Minute, RequestTimeout:30*time.Second,
|
||||
{{if eq .Storage "s3"}}Region:"us-east-1",{{end}}
|
||||
{{if eq .Storage "r2"}}Region:"auto",{{end}}
|
||||
{{if eq .Storage "oss"}}Region:"cn-hangzhou",{{end}}
|
||||
}{{end}}}
|
||||
}
|
||||
|
||||
// Load uses defaults -> strict YAML/JSON -> nonempty environment variables.
|
||||
// Pass an empty path to use only defaults and environment.
|
||||
func Load(path string)(Config,error) {
|
||||
cfg:=Default()
|
||||
var err error
|
||||
if path!="" { cfg,err=config.DecodeFile(path,cfg);if err!=nil{return Config{},err} }
|
||||
cfg.Server,err=config.ApplyEnv(cfg.Server,"")
|
||||
if err!=nil{return Config{},err}
|
||||
// A stream explicitly selected by environment overrides a file profile.
|
||||
if output:=strings.TrimSpace(os.Getenv("GO_OMNI_LOG_OUTPUT"));output!="" && strings.TrimSpace(os.Getenv("GO_OMNI_LOG_FILE"))=="" {
|
||||
cfg.Server.Log.File="";cfg.Server.Log.Rotation=logx.RotationConfig{}
|
||||
}
|
||||
{{if ne .Storage "none"}}
|
||||
if err=cfg.Storage.applyEnv();err!=nil{return Config{},err}
|
||||
{{end}}
|
||||
{{if and .API (eq .Database "mysql")}}cfg.Database,err=database.ApplyEnv(cfg.Database,"");if err!=nil{return Config{},err}{{end}}
|
||||
if err=cfg.Validate();err!=nil{return Config{},err}
|
||||
return cfg,nil
|
||||
}
|
||||
func (c Config) Validate()error {
|
||||
{{if and .API (eq .Database "mysql")}}
|
||||
if err:=c.Database.Validate();err!=nil{return err}
|
||||
if c.Database.Driver!="mysql" || c.Database.MaxOpenConns<1 {return errors.New("database requires driver mysql and a positive maxOpenConns")}
|
||||
{{end}}
|
||||
if err:=c.Server.Validate();err!=nil{return err}
|
||||
if c.Server.Log.Format!="json" && c.Server.Log.Format!="text" {return errors.New("log.format must be json or text")}
|
||||
switch strings.ToLower(c.Server.Log.Level) {case "debug","info","warn","warning","error":default:return errors.New("invalid log.level")}
|
||||
{{if ne .Storage "none"}}return c.Storage.Validate(){{else}}return nil{{end}}
|
||||
}
|
||||
|
||||
{{if ne .Storage "none"}}
|
||||
type StorageConfig struct {
|
||||
Provider string `json:"provider"`
|
||||
Root string `json:"root"`
|
||||
PublicURL string `json:"publicUrl"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Region string `json:"region"`
|
||||
Bucket string `json:"bucket"`
|
||||
AccountID string `json:"accountId"`
|
||||
Secure bool `json:"secure"`
|
||||
MaxBytes int64 `json:"maxBytes"`
|
||||
ContentTypes []string `json:"contentTypes"`
|
||||
LinkTTL time.Duration `json:"linkTtl"`
|
||||
RequestTimeout time.Duration `json:"requestTimeout"`
|
||||
// Secrets are environment-only and cannot be populated by config files.
|
||||
FilesToken string `json:"-"`
|
||||
SigningKey string `json:"-"`
|
||||
AccessKey string `json:"-"`
|
||||
SecretKey string `json:"-"`
|
||||
SessionToken string `json:"-"`
|
||||
}
|
||||
func (s StorageConfig) Policy()storage.UploadPolicy{return storage.UploadPolicy{MaxBytes:s.MaxBytes,ContentTypes:s.ContentTypes}}
|
||||
func (s *StorageConfig) applyEnv()error {
|
||||
values:=map[string]*string{
|
||||
"GO_OMNI_STORAGE_ROOT":&s.Root,"GO_OMNI_STORAGE_PUBLIC_URL":&s.PublicURL,
|
||||
"GO_OMNI_STORAGE_ENDPOINT":&s.Endpoint,"GO_OMNI_STORAGE_REGION":&s.Region,
|
||||
"GO_OMNI_STORAGE_BUCKET":&s.Bucket,"GO_OMNI_STORAGE_ACCOUNT_ID":&s.AccountID,
|
||||
"GO_OMNI_STORAGE_SIGNING_KEY":&s.SigningKey,"GO_OMNI_FILES_TOKEN":&s.FilesToken,
|
||||
"GO_OMNI_STORAGE_ACCESS_KEY":&s.AccessKey,"GO_OMNI_STORAGE_SECRET_KEY":&s.SecretKey,
|
||||
"GO_OMNI_STORAGE_SESSION_TOKEN":&s.SessionToken,
|
||||
}
|
||||
for name,target:=range values {if value:=strings.TrimSpace(os.Getenv(name));value!=""{*target=value}}
|
||||
for name,target:=range map[string]*time.Duration{"GO_OMNI_STORAGE_LINK_TTL":&s.LinkTTL,"GO_OMNI_STORAGE_REQUEST_TIMEOUT":&s.RequestTimeout} {
|
||||
if value:=strings.TrimSpace(os.Getenv(name));value!="" {v,err:=time.ParseDuration(value);if err!=nil{return fmt.Errorf("%s must be a duration",name)};*target=v}
|
||||
}
|
||||
if value:=os.Getenv("GO_OMNI_STORAGE_MAX_BYTES");value!=""{v,err:=strconv.ParseInt(value,10,64);if err!=nil{return errors.New("GO_OMNI_STORAGE_MAX_BYTES must be an integer")};s.MaxBytes=v}
|
||||
if value:=os.Getenv("GO_OMNI_STORAGE_SECURE");value!=""{v,err:=strconv.ParseBool(value);if err!=nil{return errors.New("GO_OMNI_STORAGE_SECURE must be a boolean")};s.Secure=v}
|
||||
if value:=strings.TrimSpace(os.Getenv("GO_OMNI_STORAGE_CONTENT_TYPES"));value!="" {s.ContentTypes=strings.Split(value,",");for i:=range s.ContentTypes{s.ContentTypes[i]=strings.TrimSpace(s.ContentTypes[i])}}
|
||||
return nil
|
||||
}
|
||||
func (s StorageConfig) Validate()error {
|
||||
if s.Provider!={{printf "%q" .Storage}} {return errors.New("storage.provider must match the adapter selected at generation")}
|
||||
if err:=s.Policy().Validate();err!=nil{return err}
|
||||
if s.LinkTTL<time.Second || s.LinkTTL>time.Hour{return errors.New("storage.linkTtl must be 1s..1h")}
|
||||
if s.RequestTimeout<=0 || s.RequestTimeout>5*time.Minute{return errors.New("storage.requestTimeout must be >0 and <=5m")}
|
||||
{{if not .API}}if len(s.FilesToken)<32 || strings.ContainsAny(s.FilesToken," \t\r\n") {return errors.New("set GO_OMNI_FILES_TOKEN to a token of at least 32 bytes without whitespace")}{{end}}
|
||||
{{if eq .Storage "local"}}
|
||||
if s.Root=="" {return errors.New("storage.root is required")}
|
||||
u,err:=url.Parse(s.PublicURL)
|
||||
if err!=nil || u.Host=="" || u.Hostname()=="" || (u.Scheme!="http" && u.Scheme!="https") || u.User!=nil || u.RawQuery!="" || u.Fragment!="" || u.Path!="/storage/object" {return errors.New("storage.publicUrl must be an absolute HTTP(S) URL ending in /storage/object")}
|
||||
if len(s.SigningKey)<32 {return errors.New("set GO_OMNI_STORAGE_SIGNING_KEY to a stable secret of at least 32 bytes")}
|
||||
{{else}}
|
||||
if s.Bucket=="" || s.Region=="" || s.AccessKey=="" || s.SecretKey=="" {return errors.New("storage bucket, region and GO_OMNI_STORAGE_ACCESS_KEY/SECRET_KEY are required")}
|
||||
{{if eq .Storage "r2"}}
|
||||
if !regexp.MustCompile(`^[a-fA-F0-9]{32}$`).MatchString(s.AccountID) || s.Region!="auto" || !s.Secure {return errors.New("R2 requires a 32-hex accountId, region auto and secure=true")}
|
||||
if s.Endpoint!=""{return errors.New("this R2 profile derives endpoint from accountId; customize servicecontext for a jurisdiction-specific endpoint")}
|
||||
{{else if eq .Storage "s3"}}
|
||||
u,err:=url.Parse("https://"+s.Endpoint)
|
||||
if err!=nil || s.Endpoint=="" || u.Hostname()=="" || u.User!=nil || u.Path!="" || u.RawQuery!="" || u.Fragment!="" {return errors.New("S3 endpoint must be host[:port] without scheme or path")}
|
||||
{{else if eq .Storage "oss"}}
|
||||
if s.Endpoint!="" {u,err:=url.Parse(s.Endpoint);if err!=nil || u.Scheme!="https" || u.Hostname()=="" || u.User!=nil || u.Path!="" || u.RawQuery!="" || u.Fragment!="" {return errors.New("OSS endpoint must be an HTTPS origin")}}
|
||||
if !s.Secure{return errors.New("OSS requires secure=true")}
|
||||
{{end}}
|
||||
{{end}}
|
||||
return nil
|
||||
}
|
||||
{{end}}
|
||||
38
cmd/go-omni/scaffold/templates/modules-settings_test.go.tmpl
Normal file
38
cmd/go-omni/scaffold/templates/modules-settings_test.go.tmpl
Normal file
@@ -0,0 +1,38 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
func environment(t *testing.T){
|
||||
t.Helper()
|
||||
for _,entry:=range os.Environ(){key,_,_:=strings.Cut(entry,"=");if strings.HasPrefix(key,"GO_OMNI_"){t.Setenv(key,"")}}
|
||||
{{if ne .Storage "none"}}
|
||||
t.Setenv("GO_OMNI_FILES_TOKEN",strings.Repeat("t",32))
|
||||
t.Setenv("GO_OMNI_STORAGE_SIGNING_KEY",strings.Repeat("s",32))
|
||||
t.Setenv("GO_OMNI_STORAGE_ACCESS_KEY","fixtureAccess")
|
||||
t.Setenv("GO_OMNI_STORAGE_SECRET_KEY","fixtureSecret")
|
||||
t.Setenv("GO_OMNI_STORAGE_BUCKET","testbucket")
|
||||
{{if eq .Storage "s3"}}t.Setenv("GO_OMNI_STORAGE_ENDPOINT","127.0.0.1:9000"){{end}}
|
||||
{{if eq .Storage "r2"}}t.Setenv("GO_OMNI_STORAGE_ACCOUNT_ID",strings.Repeat("a",32)){{end}}
|
||||
{{end}}
|
||||
}
|
||||
func TestSettingsPrecedenceAndStrictness(t *testing.T){
|
||||
environment(t)
|
||||
path:=filepath.Join(t.TempDir(),"config.yaml")
|
||||
if err:=os.WriteFile(path,[]byte("server:\n address: '127.0.0.1:8123'\n log:\n level: debug\n"),0600);err!=nil{t.Fatal(err)}
|
||||
t.Setenv("GO_OMNI_HTTP_ADDR","127.0.0.1:9123")
|
||||
t.Setenv("GO_OMNI_LOG_OUTPUT","stdout")
|
||||
cfg,err:=Load(path);if err!=nil{t.Fatal(err)}
|
||||
if cfg.Server.Address!="127.0.0.1:9123" || cfg.Server.Log.Level!="debug" || cfg.Server.Log.Output!="stdout" || cfg.Server.Log.File!="" || cfg.Server.Log.Rotation.Enabled {t.Fatal("configuration precedence failed")}
|
||||
for _,body:=range []string{"unexpected: true","server:\n slowRequestThreshold: -1s","server:\n log:\n format: invalid"{{if ne .Storage "none"}},"storage:\n filesToken: forbidden","storage:\n maxBytes: -1","storage:\n provider: unsupported"{{end}}}{
|
||||
if err:=os.WriteFile(path,[]byte(body),0600);err!=nil{t.Fatal(err)}
|
||||
if _,err:=Load(path);err==nil{t.Fatal("invalid configuration accepted")}
|
||||
}
|
||||
{{if ne .Storage "none"}}
|
||||
t.Setenv("GO_OMNI_FILES_TOKEN","")
|
||||
if _,err:=Load("");err==nil{t.Fatal("missing file token accepted")}
|
||||
{{end}}
|
||||
}
|
||||
79
cmd/go-omni/scaffold/templates/modules-storage.go.tmpl
Normal file
79
cmd/go-omni/scaffold/templates/modules-storage.go.tmpl
Normal file
@@ -0,0 +1,79 @@
|
||||
package servicecontext
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
{{if eq .Storage "local"}}
|
||||
"git.yuntuo.hk/k2safe/go-omni/storage/local"
|
||||
{{else}}
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
{{if eq .Storage "oss"}}
|
||||
ossStore "git.yuntuo.hk/k2safe/go-omni/storage/oss"
|
||||
ossSDK "github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss"
|
||||
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss/credentials"
|
||||
{{else}}
|
||||
"git.yuntuo.hk/k2safe/go-omni/storage/s3"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
{{end}}{{end}}
|
||||
"git.yuntuo.hk/k2safe/go-omni/storage"
|
||||
"{{.Module}}/internal/settings"
|
||||
)
|
||||
|
||||
// InitStorage is called once, before serving requests. The composition root
|
||||
// owns provider clients; business services receive only capability interfaces.
|
||||
func (c *Context) InitStorage(ctx context.Context,cfg settings.StorageConfig)error {
|
||||
if c==nil || ctx==nil{return errors.New("storage initialization requires context")}
|
||||
c.closeMu.Lock();defer c.closeMu.Unlock()
|
||||
if c.closed{return errors.New("service dependencies are closed")}
|
||||
if err:=ctx.Err();err!=nil{return err}
|
||||
if c.Files!=nil{return errors.New("storage already initialized")}
|
||||
if err:=cfg.Validate();err!=nil{return err}
|
||||
{{if eq .Storage "local"}}
|
||||
files,err:=local.New(cfg.Root,cfg.MaxBytes)
|
||||
if err!=nil{return err}
|
||||
signer,err:=storage.NewUploadSigner(files,cfg.PublicURL,[]byte(cfg.SigningKey),cfg.Policy())
|
||||
if err!=nil{return err}
|
||||
uploader,err:=storage.NewUploader(files,cfg.Policy())
|
||||
if err!=nil{return err}
|
||||
c.Files,c.Uploads,c.Downloads,c.UploadLinks,c.FileHTTP=files,uploader,signer,signer,signer
|
||||
{{else}}
|
||||
// Dedicated bounded pool, never the process-wide default transport.
|
||||
transport:=&http.Transport{
|
||||
Proxy:http.ProxyFromEnvironment,
|
||||
DialContext:(&net.Dialer{Timeout:5*time.Second,KeepAlive:30*time.Second}).DialContext,
|
||||
ForceAttemptHTTP2:true, MaxIdleConns:32,MaxIdleConnsPerHost:8,MaxConnsPerHost:32,
|
||||
IdleConnTimeout:90*time.Second,TLSHandshakeTimeout:5*time.Second,
|
||||
ResponseHeaderTimeout:cfg.RequestTimeout,
|
||||
}
|
||||
initialized:=false
|
||||
defer func(){if !initialized{transport.CloseIdleConnections()}}()
|
||||
{{if eq .Storage "oss"}}
|
||||
sdkConfig:=ossSDK.LoadDefaultConfig().WithRegion(cfg.Region).
|
||||
WithSignatureVersion(ossSDK.SignatureVersionV4).
|
||||
WithAdditionalHeaders([]string{"content-length"}).
|
||||
WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.AccessKey,cfg.SecretKey,cfg.SessionToken)).
|
||||
WithHttpClient(&http.Client{Transport:transport,Timeout:cfg.RequestTimeout})
|
||||
if cfg.Endpoint!=""{sdkConfig.WithEndpoint(cfg.Endpoint)}
|
||||
files,err:=ossStore.New(ossSDK.NewClient(sdkConfig),cfg.Bucket,cfg.MaxBytes)
|
||||
{{else}}
|
||||
creds:=credentials.NewStaticV4(cfg.AccessKey,cfg.SecretKey,cfg.SessionToken)
|
||||
{{if eq .Storage "r2"}}
|
||||
options,err:=s3.R2Config(cfg.AccountID,cfg.Bucket,creds)
|
||||
if err!=nil{return err}
|
||||
options.MaxBytes,options.Transport=cfg.MaxBytes,transport
|
||||
{{else}}
|
||||
options:=s3.Config{Endpoint:cfg.Endpoint,Region:cfg.Region,Bucket:cfg.Bucket,Secure:cfg.Secure,MaxBytes:cfg.MaxBytes,Credentials:creds,Transport:transport}
|
||||
{{end}}
|
||||
files,err:=s3.Open(options)
|
||||
{{end}}
|
||||
if err!=nil{return err}
|
||||
uploader,err:=storage.NewUploader(files,cfg.Policy())
|
||||
if err!=nil{return err}
|
||||
c.Files,c.Uploads,c.Downloads,c.UploadLinks=files,uploader,files,uploader
|
||||
c.storageClose=func()error{transport.CloseIdleConnections();return nil}
|
||||
initialized=true
|
||||
{{end}}
|
||||
return nil
|
||||
}
|
||||
38
cmd/go-omni/scaffold/templates/modules-storage_test.go.tmpl
Normal file
38
cmd/go-omni/scaffold/templates/modules-storage_test.go.tmpl
Normal file
@@ -0,0 +1,38 @@
|
||||
package servicecontext
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"{{.Module}}/internal/settings"
|
||||
)
|
||||
func TestStorageInitializationAndOwnership(t *testing.T){
|
||||
cfg:=settings.Default().Storage
|
||||
cfg.FilesToken=strings.Repeat("t",32)
|
||||
cfg.SigningKey=strings.Repeat("s",32)
|
||||
cfg.Root=t.TempDir()
|
||||
cfg.AccessKey,cfg.SecretKey,cfg.Bucket="fixtureAccess","fixtureSecret","testbucket"
|
||||
{{if eq .Storage "s3"}}cfg.Endpoint="127.0.0.1:9000"{{end}}
|
||||
{{if eq .Storage "r2"}}cfg.AccountID=strings.Repeat("a",32){{end}}
|
||||
var output bytes.Buffer
|
||||
logger,err:=logx.New(logx.Config{Writer:&output});if err!=nil{t.Fatal(err)};defer logger.Close()
|
||||
c:=&Context{Logger:logger}
|
||||
canceled,cancel:=context.WithCancel(context.Background());cancel()
|
||||
if err=c.InitStorage(canceled,cfg);err==nil || c.Files!=nil{t.Fatal("canceled startup initialized storage")}
|
||||
invalid:=cfg;invalid.FilesToken=""
|
||||
if err=c.InitStorage(context.Background(),invalid);err==nil || c.Files!=nil{t.Fatal("invalid settings initialized storage")}
|
||||
if err=c.InitStorage(context.Background(),cfg);err!=nil{t.Fatal(err)}
|
||||
if c.Files==nil || c.Uploads==nil || c.Downloads==nil || c.UploadLinks==nil{t.Fatal("dependencies not assembled")}
|
||||
if err=c.InitStorage(context.Background(),cfg);err==nil{t.Fatal("duplicate initialization accepted")}
|
||||
cleanup:=c.storageClose;count:=0
|
||||
c.storageClose=func()error{count++;if cleanup!=nil{return cleanup()};return nil}
|
||||
if err=c.Close();err!=nil{t.Fatal(err)};if err=c.Close();err!=nil{t.Fatal(err)}
|
||||
logger.Info("still usable")
|
||||
if count!=1 || !strings.Contains(output.String(),"still usable"){t.Fatal("resource ownership violated")}
|
||||
closed:=&Context{Logger:logger}
|
||||
if err=closed.Close();err!=nil{t.Fatal(err)}
|
||||
if err=closed.InitStorage(context.Background(),cfg);err==nil || closed.Files!=nil{t.Fatal("closed context reopened resources")}
|
||||
}
|
||||
Reference in New Issue
Block a user