diff --git a/go.mod b/go.mod index 33e1445..b68cbba 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26.5 require ( github.com/disintegration/imaging v1.6.2 + github.com/gin-gonic/gin v1.12.0 github.com/go-sql-driver/mysql v1.10.0 golang.org/x/crypto v0.55.0 gorm.io/driver/mysql v1.6.0 @@ -12,8 +13,35 @@ require ( require ( filippo.io/edwards25519 v1.2.0 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + golang.org/x/arch v0.22.0 // indirect golang.org/x/image v0.41.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect ) diff --git a/go.sum b/go.sum index 15a246d..3f37d55 100644 --- a/go.sum +++ b/go.sum @@ -1,23 +1,108 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +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/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= diff --git a/internal/config/config.go b/internal/config/config.go index 8d47734..405263b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,10 +1,14 @@ package config import ( + "crypto/rand" + "encoding/base64" "errors" "fmt" "os" + "strconv" "strings" + "time" ) type Environment string @@ -16,12 +20,21 @@ const ( ) type Config struct { - Environment Environment - DBDSN string - ListenAddress string - StorageRoot string - SessionKey string - MasterKey string + Environment Environment + DBDSN string + ListenAddress string + StorageRoot string + SessionKey string + MasterKey string + SessionTTL time.Duration + LoginAttempts int + LoginWindow time.Duration + MaxPromptBytes int + MaxImages int + MaxImageBytes int64 + MaxUploadBytes int64 + MaxImagePixels uint64 + HistoryLimit int } func Load() (Config, error) { @@ -50,6 +63,18 @@ func LoadFromLookup(lookup func(string) (string, bool)) (Config, error) { SessionKey: read("CHORUS_SESSION_KEY"), MasterKey: read("CHORUS_MASTER_KEY"), } + if environment != Production { + if cfg.StorageRoot == "" { + cfg.StorageRoot = "var/storage" + } + if cfg.SessionKey == "" { + generated := make([]byte, 32) + if _, err := rand.Read(generated); err != nil { + return Config{}, fmt.Errorf("generate development session key") + } + cfg.SessionKey = base64.RawURLEncoding.EncodeToString(generated) + } + } if cfg.ListenAddress == "" { cfg.ListenAddress = "127.0.0.1:8080" } @@ -68,10 +93,52 @@ func LoadFromLookup(lookup func(string) (string, bool)) (Config, error) { missing = append(missing, name) } } + for _, name := range []string{"CHORUS_SESSION_TTL_MINUTES", "CHORUS_LOGIN_ATTEMPTS", "CHORUS_LOGIN_WINDOW_SECONDS", "CHORUS_MAX_PROMPT_BYTES", "CHORUS_MAX_IMAGES", "CHORUS_MAX_IMAGE_BYTES", "CHORUS_MAX_UPLOAD_BYTES", "CHORUS_MAX_IMAGE_PIXELS", "CHORUS_HISTORY_LIMIT"} { + if read(name) == "" { + missing = append(missing, name) + } + } } if len(missing) > 0 { return Config{}, fmt.Errorf("missing required configuration: %s", strings.Join(missing, ", ")) } + var err error + if cfg.SessionTTL, err = durationConfig(read("CHORUS_SESSION_TTL_MINUTES"), 480, time.Minute); err != nil { + return Config{}, fmt.Errorf("CHORUS_SESSION_TTL_MINUTES is invalid") + } + if cfg.LoginWindow, err = durationConfig(read("CHORUS_LOGIN_WINDOW_SECONDS"), 60, time.Second); err != nil { + return Config{}, fmt.Errorf("CHORUS_LOGIN_WINDOW_SECONDS is invalid") + } + if cfg.LoginAttempts, err = intConfig(read("CHORUS_LOGIN_ATTEMPTS"), 5); err != nil { + return Config{}, fmt.Errorf("CHORUS_LOGIN_ATTEMPTS is invalid") + } + if cfg.MaxPromptBytes, err = intConfig(read("CHORUS_MAX_PROMPT_BYTES"), 8000); err != nil { + return Config{}, fmt.Errorf("CHORUS_MAX_PROMPT_BYTES is invalid") + } + if cfg.MaxImages, err = intConfig(read("CHORUS_MAX_IMAGES"), 8); err != nil { + return Config{}, fmt.Errorf("CHORUS_MAX_IMAGES is invalid") + } + maxImageBytes, err := intConfig(read("CHORUS_MAX_IMAGE_BYTES"), 10<<20) + if err != nil { + return Config{}, fmt.Errorf("CHORUS_MAX_IMAGE_BYTES is invalid") + } + cfg.MaxImageBytes = int64(maxImageBytes) + maxUploadBytes, err := intConfig(read("CHORUS_MAX_UPLOAD_BYTES"), 32<<20) + if err != nil { + return Config{}, fmt.Errorf("CHORUS_MAX_UPLOAD_BYTES is invalid") + } + cfg.MaxUploadBytes = int64(maxUploadBytes) + maxPixels, err := intConfig(read("CHORUS_MAX_IMAGE_PIXELS"), 40_000_000) + if err != nil { + return Config{}, fmt.Errorf("CHORUS_MAX_IMAGE_PIXELS is invalid") + } + cfg.MaxImagePixels = uint64(maxPixels) + if cfg.HistoryLimit, err = intConfig(read("CHORUS_HISTORY_LIMIT"), 50); err != nil { + return Config{}, fmt.Errorf("CHORUS_HISTORY_LIMIT is invalid") + } + if cfg.MaxUploadBytes < cfg.MaxImageBytes || cfg.MaxImages > 32 || cfg.HistoryLimit > 200 { + return Config{}, fmt.Errorf("portal limits are inconsistent") + } return cfg, nil } @@ -79,8 +146,26 @@ func (c Config) ValidateProduction() error { if c.Environment != Production { return nil } - if c.DBDSN == "" || c.MasterKey == "" || c.SessionKey == "" || c.StorageRoot == "" { + if c.DBDSN == "" || c.MasterKey == "" || len(c.SessionKey) < 32 || c.StorageRoot == "" || c.SessionTTL <= 0 || c.MaxImageBytes <= 0 || c.MaxUploadBytes < c.MaxImageBytes { return errors.New("production configuration is incomplete") } return nil } + +func intConfig(value string, fallback int) (int, error) { + if value == "" { + return fallback, nil + } + parsed, err := strconv.Atoi(value) + if err != nil || parsed <= 0 { + return 0, errors.New("invalid positive integer") + } + return parsed, nil +} +func durationConfig(value string, fallback int, unit time.Duration) (time.Duration, error) { + parsed, err := intConfig(value, fallback) + if err != nil { + return 0, err + } + return time.Duration(parsed) * unit, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 90b1528..0efb53e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -50,3 +50,24 @@ func TestLoadRejectsUnknownEnvironment(t *testing.T) { t.Fatal("LoadFromLookup() expected an error") } } + +func TestLoadProductionPortalLimits(t *testing.T) { + values := map[string]string{ + "CHORUS_ENV": "production", "CHORUS_DSN": "sensitive-dsn", "CHORUS_MASTER_KEY": strings.Repeat("m", 32), + "CHORUS_SESSION_KEY": strings.Repeat("s", 32), "CHORUS_STORAGE_ROOT": "/var/lib/chorus", + "CHORUS_SESSION_TTL_MINUTES": "480", "CHORUS_LOGIN_ATTEMPTS": "5", "CHORUS_LOGIN_WINDOW_SECONDS": "60", + "CHORUS_MAX_PROMPT_BYTES": "8000", "CHORUS_MAX_IMAGES": "8", "CHORUS_MAX_IMAGE_BYTES": "10485760", + "CHORUS_MAX_UPLOAD_BYTES": "33554432", "CHORUS_MAX_IMAGE_PIXELS": "40000000", "CHORUS_HISTORY_LIMIT": "50", + } + cfg, err := LoadFromLookup(lookup(values)) + if err != nil { + t.Fatal(err) + } + if err := cfg.ValidateProduction(); err != nil { + t.Fatal(err) + } + values["CHORUS_MAX_UPLOAD_BYTES"] = "100" + if _, err := LoadFromLookup(lookup(values)); err == nil { + t.Fatal("inconsistent upload limits accepted") + } +} diff --git a/internal/core/queue/mysql_integration_test.go b/internal/core/queue/mysql_integration_test.go index a057e01..7ddee7c 100644 --- a/internal/core/queue/mysql_integration_test.go +++ b/internal/core/queue/mysql_integration_test.go @@ -3,6 +3,7 @@ package queue import ( "context" "encoding/json" + "errors" "fmt" "os" "strings" @@ -141,6 +142,38 @@ func TestMySQLIdempotencyIsScopedToUser(t *testing.T) { } } +func TestMySQLPreparedInputsRollbackAndReplay(t *testing.T) { + repository, db := integrationRepository(t) + user := createIntegrationUser(t, db, "prepared") + generation := pendingGeneration(user.ID, "prepared-error") + callbackError := fmt.Errorf("synthetic prepare failure") + created, err := repository.CreateIdempotentPrepared(context.Background(), &generation, func(uint64) ([]model.GenerationInput, error) { + return nil, callbackError + }) + if created || !errors.Is(err, callbackError) { + t.Fatalf("prepared failure = created %v error %v", created, err) + } + var count int64 + if err := db.Model(&model.Generation{}).Where("user_id=? AND idempotency_key=?", user.ID, "prepared-error").Count(&count).Error; err != nil || count != 0 { + t.Fatalf("failed prepared create persisted generation: count=%d error=%v", count, err) + } + + generation = pendingGeneration(user.ID, "prepared-replay") + callbackCalls := 0 + created, err = repository.CreateIdempotentPrepared(context.Background(), &generation, func(id uint64) ([]model.GenerationInput, error) { + callbackCalls++ + return []model.GenerationInput{{GenerationID: id, Position: 0, Role: model.RolePrimary, OriginalName: "input.png", MIMEType: "image/png", StorageKey: "synthetic/input", SizeBytes: 10}}, nil + }) + if err != nil || !created || callbackCalls != 1 { + t.Fatalf("prepared create=%v calls=%d err=%v", created, callbackCalls, err) + } + replay := pendingGeneration(user.ID, "prepared-replay") + created, err = repository.CreateIdempotentPrepared(context.Background(), &replay, func(uint64) ([]model.GenerationInput, error) { callbackCalls++; return nil, nil }) + if err != nil || created || replay.ID != generation.ID || callbackCalls != 1 { + t.Fatalf("prepared replay=%v id=%d calls=%d err=%v", created, replay.ID, callbackCalls, err) + } +} + func TestMySQLConcurrentClaimAndStaleTokenCAS(t *testing.T) { repository, db := integrationRepository(t) user := createIntegrationUser(t, db, "claim") diff --git a/internal/core/queue/mysql_repository.go b/internal/core/queue/mysql_repository.go index cabff17..535ee90 100644 --- a/internal/core/queue/mysql_repository.go +++ b/internal/core/queue/mysql_repository.go @@ -25,6 +25,8 @@ type MySQLRepository struct { db *gorm.DB } +type PrepareInputs func(generationID uint64) ([]model.GenerationInput, error) + func NewMySQLRepository(db *gorm.DB) (*MySQLRepository, error) { if db == nil { return nil, fmt.Errorf("queue database is required") @@ -33,9 +35,18 @@ func NewMySQLRepository(db *gorm.DB) (*MySQLRepository, error) { } func (r *MySQLRepository) CreateIdempotent(ctx context.Context, generation *model.Generation, inputs []model.GenerationInput) (bool, error) { + return r.CreateIdempotentPrepared(ctx, generation, func(uint64) ([]model.GenerationInput, error) { + return inputs, nil + }) +} + +func (r *MySQLRepository) CreateIdempotentPrepared(ctx context.Context, generation *model.Generation, prepare PrepareInputs) (bool, error) { if generation == nil || generation.UserID == 0 || strings.TrimSpace(generation.IdempotencyKey) == "" || !generation.Kind.Valid() || strings.TrimSpace(generation.RenderedPrompt) == "" { return false, ErrInvalidGeneration } + if prepare == nil { + return false, ErrInvalidGeneration + } var created bool err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var user model.User @@ -62,6 +73,10 @@ func (r *MySQLRepository) CreateIdempotent(ctx context.Context, generation *mode if err := tx.Create(generation).Error; err != nil { return fmt.Errorf("create generation: %w", err) } + inputs, err := prepare(generation.ID) + if err != nil { + return fmt.Errorf("prepare generation inputs: %w", err) + } for index := range inputs { inputs[index].GenerationID = generation.ID } diff --git a/internal/platform/password/password.go b/internal/platform/password/password.go new file mode 100644 index 0000000..ce5bf63 --- /dev/null +++ b/internal/platform/password/password.go @@ -0,0 +1,30 @@ +package password + +import ( + "errors" + "strings" + + "golang.org/x/crypto/bcrypt" +) + +const prefix = "bcrypt:v1:" + +var ErrInvalidEncoding = errors.New("password hash encoding is invalid") + +func Encode(value string) (string, error) { + if len(value) < 12 || len(value) > 1024 { + return "", ErrInvalidEncoding + } + hash, err := bcrypt.GenerateFromPassword([]byte(value), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return prefix + string(hash), nil +} + +func Verify(encoded, value string) bool { + if !strings.HasPrefix(encoded, prefix) || len(value) > 1024 { + return false + } + return bcrypt.CompareHashAndPassword([]byte(strings.TrimPrefix(encoded, prefix)), []byte(value)) == nil +} diff --git a/internal/platform/password/password_test.go b/internal/platform/password/password_test.go new file mode 100644 index 0000000..24edc8d --- /dev/null +++ b/internal/platform/password/password_test.go @@ -0,0 +1,24 @@ +package password + +import ( + "strings" + "testing" +) + +func TestVersionedPasswordEncoding(t *testing.T) { + encoded, err := Encode("correct-horse-battery-staple") + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(encoded, "bcrypt:v1:$2") || !Verify(encoded, "correct-horse-battery-staple") || Verify(encoded, "wrong-password") || Verify(strings.TrimPrefix(encoded, "bcrypt:v1:"), "correct-horse-battery-staple") { + t.Fatalf("versioned password verification failed") + } +} +func TestPasswordLength(t *testing.T) { + if _, err := Encode("short"); err == nil { + t.Fatal("short password accepted") + } + if Verify("bcrypt:v2:anything", "value") { + t.Fatal("unknown version accepted") + } +} diff --git a/internal/seed/seed.go b/internal/seed/seed.go index f140d4b..6c990b5 100644 --- a/internal/seed/seed.go +++ b/internal/seed/seed.go @@ -7,7 +7,7 @@ import ( "net/url" "strings" - "golang.org/x/crypto/bcrypt" + passwordpkg "git.ilapage.cn/OPC/chorus/internal/platform/password" ) type Options struct { @@ -31,7 +31,7 @@ func Run(ctx context.Context, db *sql.DB, options Options) error { if err != nil { return err } - passwordHash, err := bcrypt.GenerateFromPassword([]byte(options.UserPassword), bcrypt.DefaultCost) + passwordHash, err := passwordpkg.Encode(options.UserPassword) if err != nil { return fmt.Errorf("hash seed user password: %w", err) } @@ -45,8 +45,9 @@ func Run(ctx context.Context, db *sql.DB, options Options) error { if _, err := tx.ExecContext(ctx, ` INSERT INTO users (email, password_hash, display_name, status) VALUES (?, ?, ?, 'active') - ON DUPLICATE KEY UPDATE email = VALUES(email)`, - options.UserEmail, string(passwordHash), "MVP-0 Test User"); err != nil { + ON DUPLICATE KEY UPDATE + password_hash = VALUES(password_hash), display_name = VALUES(display_name), status = 'active'`, + options.UserEmail, passwordHash, "MVP-0 Test User"); err != nil { return fmt.Errorf("seed user: %w", err) } diff --git a/portal/auth/service.go b/portal/auth/service.go new file mode 100644 index 0000000..14b16e1 --- /dev/null +++ b/portal/auth/service.go @@ -0,0 +1,81 @@ +package auth + +import ( + "context" + "errors" + "strings" + "sync" + "time" + + "git.ilapage.cn/OPC/chorus/internal/core/model" + passwordpkg "git.ilapage.cn/OPC/chorus/internal/platform/password" + "gorm.io/gorm" +) + +var ErrInvalidCredentials = errors.New("invalid credentials") +var ErrUnavailable = errors.New("authentication service is unavailable") +var dummyHash, _ = passwordpkg.Encode("dummy-password-not-used") + +type Service struct { + db *gorm.DB + limiter *Limiter +} + +func NewService(db *gorm.DB, attempts int, window time.Duration) (*Service, error) { + if db == nil || attempts <= 0 || window <= 0 { + return nil, errors.New("authentication configuration is invalid") + } + return &Service{db: db, limiter: NewLimiter(attempts, window)}, nil +} + +func (s *Service) Login(ctx context.Context, remoteKey, email, password string) (model.User, error) { + if !s.limiter.Allow(remoteKey, time.Now()) { + _ = passwordpkg.Verify(dummyHash, password) + return model.User{}, ErrInvalidCredentials + } + normalized := strings.ToLower(strings.TrimSpace(email)) + var user model.User + err := s.db.WithContext(ctx).Where("email = ?", normalized).First(&user).Error + encoded := dummyHash + if err == nil { + encoded = user.PasswordHash + } + valid := passwordpkg.Verify(encoded, password) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return model.User{}, ErrUnavailable + } + if err != nil || !valid || user.Status != "active" { + return model.User{}, ErrInvalidCredentials + } + s.limiter.Reset(remoteKey) + return user, nil +} + +type Limiter struct { + mutex sync.Mutex + attempts int + window time.Duration + entries map[string][]time.Time +} + +func NewLimiter(attempts int, window time.Duration) *Limiter { + return &Limiter{attempts: attempts, window: window, entries: map[string][]time.Time{}} +} +func (l *Limiter) Allow(key string, now time.Time) bool { + l.mutex.Lock() + defer l.mutex.Unlock() + cutoff := now.Add(-l.window) + current := l.entries[key][:0] + for _, stamp := range l.entries[key] { + if stamp.After(cutoff) { + current = append(current, stamp) + } + } + if len(current) >= l.attempts { + l.entries[key] = current + return false + } + l.entries[key] = append(current, now) + return true +} +func (l *Limiter) Reset(key string) { l.mutex.Lock(); delete(l.entries, key); l.mutex.Unlock() } diff --git a/portal/auth/service_test.go b/portal/auth/service_test.go new file mode 100644 index 0000000..d59fe97 --- /dev/null +++ b/portal/auth/service_test.go @@ -0,0 +1,21 @@ +package auth + +import ( + "testing" + "time" +) + +func TestLimiterWindowAndReset(t *testing.T) { + limiter := NewLimiter(2, time.Minute) + now := time.Now() + if !limiter.Allow("client", now) || !limiter.Allow("client", now) || limiter.Allow("client", now) { + t.Fatal("attempt limit was not enforced") + } + if !limiter.Allow("client", now.Add(2*time.Minute)) { + t.Fatal("expired attempts were not removed") + } + limiter.Reset("client") + if !limiter.Allow("client", now) { + t.Fatal("reset did not clear attempts") + } +} diff --git a/portal/handler/mysql_integration_test.go b/portal/handler/mysql_integration_test.go new file mode 100644 index 0000000..0ab787c --- /dev/null +++ b/portal/handler/mysql_integration_test.go @@ -0,0 +1,298 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "image" + "image/color" + "image/png" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/textproto" + "os" + "strconv" + "strings" + "testing" + "time" + + "git.ilapage.cn/OPC/chorus/internal/core/model" + "git.ilapage.cn/OPC/chorus/internal/core/queue" + corestorage "git.ilapage.cn/OPC/chorus/internal/core/storage" + passwordpkg "git.ilapage.cn/OPC/chorus/internal/platform/password" + platformstorage "git.ilapage.cn/OPC/chorus/internal/platform/storage" + "git.ilapage.cn/OPC/chorus/portal/auth" + "git.ilapage.cn/OPC/chorus/portal/service" + "git.ilapage.cn/OPC/chorus/portal/session" + "github.com/gin-gonic/gin" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +type apiClient struct { + router http.Handler + cookie *http.Cookie + csrf string +} + +func (c *apiClient) do(method, path string, body []byte, contentType string) *httptest.ResponseRecorder { + request := httptest.NewRequest(method, path, bytes.NewReader(body)) + request.RemoteAddr = "192.0.2.10:1234" + if contentType != "" { + request.Header.Set("Content-Type", contentType) + } + if c.cookie != nil { + request.AddCookie(c.cookie) + } + if c.csrf != "" { + request.Header.Set("X-CSRF-Token", c.csrf) + } + response := httptest.NewRecorder() + c.router.ServeHTTP(response, request) + for _, cookie := range response.Result().Cookies() { + if cookie.Name == "chorus_session" { + c.cookie = cookie + } + } + return response +} +func (c *apiClient) start(t *testing.T) { + t.Helper() + response := c.do(http.MethodGet, "/api/session", nil, "") + var body struct { + CSRF string `json:"csrf_token"` + } + if response.Code != 200 || json.Unmarshal(response.Body.Bytes(), &body) != nil || body.CSRF == "" { + t.Fatalf("session response=%d %s", response.Code, response.Body.String()) + } + c.csrf = body.CSRF +} +func (c *apiClient) login(t *testing.T, email, password string) *httptest.ResponseRecorder { + t.Helper() + body, _ := json.Marshal(map[string]string{"email": email, "password": password}) + response := c.do(http.MethodPost, "/api/session/login", body, "application/json") + if response.Code == 200 { + var result struct { + CSRF string `json:"csrf_token"` + } + if json.Unmarshal(response.Body.Bytes(), &result) != nil || result.CSRF == "" { + t.Fatalf("login body=%s", response.Body.String()) + } + c.csrf = result.CSRF + } + return response +} + +func TestPortalAuthenticationSubmissionAndAuthorization(t *testing.T) { + dsn := os.Getenv("CHORUS_TEST_DSN") + if dsn == "" { + t.Skip("CHORUS_TEST_DSN is not set") + } + gin.SetMode(gin.TestMode) + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + sqlDB, _ := db.DB() + defer sqlDB.Close() + suffix := strconv.FormatInt(time.Now().UnixNano(), 10) + password := "test-password-" + suffix + encoded, err := passwordpkg.Encode(password) + if err != nil { + t.Fatal(err) + } + users := []model.User{{Email: "portal-a-" + suffix + "@example.invalid", PasswordHash: encoded, DisplayName: "User A", Status: "active"}, {Email: "portal-b-" + suffix + "@example.invalid", PasswordHash: encoded, DisplayName: "User B", Status: "active"}} + if err := db.Create(&users).Error; err != nil { + t.Fatal(err) + } + defer func() { + db.Where("user_id IN ?", []uint64{users[0].ID, users[1].ID}).Delete(&model.Generation{}) + db.Where("id IN ?", []uint64{users[0].ID, users[1].ID}).Delete(&model.User{}) + }() + queueRepository, _ := queue.NewMySQLRepository(db) + storageRoot := t.TempDir() + local, err := platformstorage.NewLocal(platformstorage.Config{Root: storageRoot, MaxObjectBytes: 1 << 20, MaxImagePixels: 10000, ThumbnailMaxSide: 32, AllowedImageMIME: map[string]bool{"image/png": true, "image/jpeg": true}}) + if err != nil { + t.Fatal(err) + } + generationService, err := service.New(db, queueRepository, local, service.Config{MaxPromptBytes: 1000, MaxImages: 3, MaxImageBytes: 200 << 10, MaxUploadBytes: 1 << 20, MaxImagePixels: 10000, HistoryLimit: 20}) + if err != nil { + t.Fatal(err) + } + sessions, _ := session.New([]byte(strings.Repeat("s", 32)), time.Hour, false) + authService, _ := auth.NewService(db, 20, time.Minute) + router, err := NewRouter(sessions, authService, generationService, 1<<20) + if err != nil { + t.Fatal(err) + } + + unknown := &apiClient{router: router} + unknown.start(t) + unknownResponse := unknown.login(t, "missing-"+suffix+"@example.invalid", "wrong-password-value") + wrong := &apiClient{router: router} + wrong.start(t) + wrongResponse := wrong.login(t, users[0].Email, "wrong-password-value") + if unknownResponse.Code != 401 || wrongResponse.Code != 401 || unknownResponse.Body.String() != wrongResponse.Body.String() { + t.Fatalf("credential responses differ: %d %s / %d %s", unknownResponse.Code, unknownResponse.Body.String(), wrongResponse.Code, wrongResponse.Body.String()) + } + + clientA := &apiClient{router: router} + clientA.start(t) + oldCookie := *clientA.cookie + if response := clientA.login(t, users[0].Email, password); response.Code != 200 { + t.Fatalf("login=%d %s", response.Code, response.Body.String()) + } + oldRequest := httptest.NewRequest(http.MethodGet, "/api/generations", nil) + oldRequest.AddCookie(&oldCookie) + oldResponse := httptest.NewRecorder() + router.ServeHTTP(oldResponse, oldRequest) + if oldResponse.Code != 401 { + t.Fatalf("old session code=%d", oldResponse.Code) + } + + textBody, _ := json.Marshal(map[string]string{"idempotency_key": "text-key-" + suffix, "prompt": "write a title"}) + textResponse := clientA.do(http.MethodPost, "/api/generations/text", textBody, "application/json") + if textResponse.Code != 202 { + t.Fatalf("text submit=%d %s", textResponse.Code, textResponse.Body.String()) + } + textID := responseID(t, textResponse) + replay := clientA.do(http.MethodPost, "/api/generations/text", textBody, "application/json") + if replay.Code != 200 || responseID(t, replay) != textID { + t.Fatalf("text replay=%d %s", replay.Code, replay.Body.String()) + } + var generation model.Generation + if err := db.First(&generation, textID).Error; err != nil { + t.Fatal(err) + } + if generation.Status != model.StatusPending || generation.ProviderModelID != nil || generation.AttemptCount != 0 { + t.Fatalf("synchronous submission touched worker fields: %#v", generation) + } + + imageKey := "image-key-" + suffix + imageResponse := clientA.do(http.MethodPost, "/api/generations/image", multipartBody(t, imageKey, "edit image", []uploadPart{{"first.png", "image/png", testPNG(4, 4)}}), lastMultipartType) + if imageResponse.Code != 202 { + t.Fatalf("image submit=%d %s", imageResponse.Code, imageResponse.Body.String()) + } + imageID := responseID(t, imageResponse) + var inputs []model.GenerationInput + if err := db.Where("generation_id=?", imageID).Find(&inputs).Error; err != nil || len(inputs) != 1 || inputs[0].Role != model.RolePrimary { + t.Fatalf("inputs=%#v err=%v", inputs, err) + } + imageReplay := clientA.do(http.MethodPost, "/api/generations/image", multipartBody(t, imageKey, "edit image", []uploadPart{{"second.png", "image/png", testPNG(4, 4)}}), lastMultipartType) + if imageReplay.Code != 200 || responseID(t, imageReplay) != imageID { + t.Fatalf("image replay=%d %s", imageReplay.Code, imageReplay.Body.String()) + } + var inputCount int64 + db.Model(&model.GenerationInput{}).Where("generation_id=?", imageID).Count(&inputCount) + if inputCount != 1 { + t.Fatalf("replay wrote %d inputs", inputCount) + } + invalidResponse := clientA.do(http.MethodPost, "/api/generations/image", multipartBody(t, "invalid-key-"+suffix, "edit image", []uploadPart{{"fake.txt", "image/png", testPNG(4, 4)}}), lastMultipartType) + if invalidResponse.Code != 400 || !strings.Contains(invalidResponse.Body.String(), `"code":"invalid_image_extension"`) || !strings.Contains(invalidResponse.Body.String(), `"index":0`) || !strings.Contains(invalidResponse.Body.String(), `"name":"fake.txt"`) { + t.Fatalf("invalid image response=%d %s", invalidResponse.Code, invalidResponse.Body.String()) + } + + clientB := &apiClient{router: router} + clientB.start(t) + if response := clientB.login(t, users[1].Email, password); response.Code != 200 { + t.Fatalf("login B=%d", response.Code) + } + if response := clientB.do(http.MethodGet, fmt.Sprintf("/api/generations/%d", imageID), nil, ""); response.Code != 404 { + t.Fatalf("cross-user detail=%d", response.Code) + } + if response := clientB.do(http.MethodGet, fmt.Sprintf("/api/generations/%d/inputs/%d", imageID, inputs[0].ID), nil, ""); response.Code != 404 { + t.Fatalf("cross-user input=%d", response.Code) + } + if response := clientA.do(http.MethodGet, fmt.Sprintf("/api/generations/%d/inputs/%d", imageID, inputs[0].ID), nil, ""); response.Code != 200 || response.Header().Get("Content-Type") != "image/png" { + t.Fatalf("own input=%d %s", response.Code, response.Header().Get("Content-Type")) + } + + outputData := testPNG(8, 8) + original, _ := local.Put(context.Background(), corestorage.PutRequest{Key: fmt.Sprintf("test-output/%d/original", imageID), OwnerID: users[0].ID, GenerationID: imageID, ContentType: "image/png", Source: bytes.NewReader(outputData)}) + thumbnail, _ := local.Put(context.Background(), corestorage.PutRequest{Key: fmt.Sprintf("test-output/%d/thumbnail", imageID), OwnerID: users[0].ID, GenerationID: imageID, ContentType: "image/png", Source: bytes.NewReader(testPNG(2, 2))}) + mimeType := "image/png" + size := uint64(original.Size) + output := model.GenerationOutput{GenerationID: imageID, Kind: model.KindImage, StorageKey: &original.Key, ThumbnailStorageKey: &thumbnail.Key, MIMEType: &mimeType, SizeBytes: &size} + if err := db.Create(&output).Error; err != nil { + t.Fatal(err) + } + if err := db.Model(&model.Generation{}).Where("id=?", imageID).Updates(map[string]any{"status": model.StatusSucceeded, "completed_at": time.Now()}).Error; err != nil { + t.Fatal(err) + } + detail := clientA.do(http.MethodGet, fmt.Sprintf("/api/generations/%d", imageID), nil, "") + if detail.Code != 200 || !strings.Contains(detail.Body.String(), `"terminal":true`) || strings.Contains(detail.Body.String(), storageRoot) || strings.Contains(detail.Body.String(), inputs[0].StorageKey) { + t.Fatalf("terminal detail=%d %s", detail.Code, detail.Body.String()) + } + if response := clientB.do(http.MethodGet, fmt.Sprintf("/api/generations/%d/outputs/%d", imageID, output.ID), nil, ""); response.Code != 404 { + t.Fatalf("cross-user output=%d", response.Code) + } + if response := clientA.do(http.MethodGet, fmt.Sprintf("/api/generations/%d/outputs/%d/thumbnail", imageID, output.ID), nil, ""); response.Code != 200 { + t.Fatalf("own thumbnail=%d", response.Code) + } + + anonymous := &apiClient{router: router} + anonymous.start(t) + request := httptest.NewRequest(http.MethodGet, "/api/generations/1/card", nil) + request.Header.Set("HX-Request", "true") + request.AddCookie(anonymous.cookie) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != 401 || response.Header().Get("HX-Redirect") != "/login" { + t.Fatalf("expired HTMX=%d redirect=%q", response.Code, response.Header().Get("HX-Redirect")) + } + logout := clientA.do(http.MethodPost, "/api/session/logout", nil, "") + if logout.Code != 200 { + t.Fatalf("logout=%d %s", logout.Code, logout.Body.String()) + } + if response := clientA.do(http.MethodGet, "/api/generations", nil, ""); response.Code != 401 { + t.Fatalf("post-logout access=%d", response.Code) + } +} + +type uploadPart struct { + name, mime string + data []byte +} + +var lastMultipartType string + +func multipartBody(t *testing.T, key, prompt string, parts []uploadPart) []byte { + t.Helper() + var body bytes.Buffer + writer := multipart.NewWriter(&body) + _ = writer.WriteField("idempotency_key", key) + _ = writer.WriteField("prompt", prompt) + for _, item := range parts { + header := make(textproto.MIMEHeader) + header.Set("Content-Disposition", fmt.Sprintf(`form-data; name="images"; filename="%s"`, item.name)) + header.Set("Content-Type", item.mime) + part, _ := writer.CreatePart(header) + _, _ = part.Write(item.data) + } + writer.Close() + lastMultipartType = writer.FormDataContentType() + return body.Bytes() +} +func responseID(t *testing.T, response *httptest.ResponseRecorder) uint64 { + t.Helper() + var body struct { + ID uint64 `json:"id"` + } + if json.Unmarshal(response.Body.Bytes(), &body) != nil || body.ID == 0 { + t.Fatalf("response id missing: %s", response.Body.String()) + } + return body.ID +} +func testPNG(width, height int) []byte { + img := image.NewRGBA(image.Rect(0, 0, width, height)) + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + img.Set(x, y, color.RGBA{R: 20, G: 100, B: 180, A: 255}) + } + } + var body bytes.Buffer + _ = png.Encode(&body, img) + return body.Bytes() +} diff --git a/portal/handler/router.go b/portal/handler/router.go new file mode 100644 index 0000000..2ccf401 --- /dev/null +++ b/portal/handler/router.go @@ -0,0 +1,352 @@ +package handler + +import ( + "errors" + "fmt" + "html/template" + "io" + "mime" + "net" + "net/http" + "path" + "strconv" + "strings" + + "git.ilapage.cn/OPC/chorus/internal/core/model" + "git.ilapage.cn/OPC/chorus/portal/auth" + "git.ilapage.cn/OPC/chorus/portal/service" + "git.ilapage.cn/OPC/chorus/portal/session" + "github.com/gin-gonic/gin" +) + +type Handler struct { + sessions *session.Manager + auth *auth.Service + service *service.Service + maxUploadBytes int64 +} + +func NewRouter(sessions *session.Manager, authService *auth.Service, generationService *service.Service, maxUploadBytes int64) (*gin.Engine, error) { + if sessions == nil || authService == nil || generationService == nil || maxUploadBytes <= 0 { + return nil, errors.New("portal handler configuration is invalid") + } + handler := &Handler{sessions: sessions, auth: authService, service: generationService, maxUploadBytes: maxUploadBytes} + router := gin.New() + router.Use(gin.RecoveryWithWriter(io.Discard)) + router.Use(securityHeaders) + _ = router.SetTrustedProxies(nil) + router.Use(handler.sessionMiddleware) + api := router.Group("/api") + api.GET("/session", handler.sessionState) + api.POST("/session/login", handler.csrf, handler.login) + api.POST("/session/logout", handler.requireAuth, handler.csrf, handler.logout) + generations := api.Group("/generations", handler.requireAuth) + generations.GET("", handler.history) + generations.POST("/text", handler.csrf, handler.submitText) + generations.POST("/image", handler.csrf, handler.submitImages) + generations.GET("/:id", handler.detail) + generations.GET("/:id/card", handler.card) + generations.GET("/:id/inputs/:inputID", handler.input) + generations.GET("/:id/outputs/:outputID", handler.output) + generations.GET("/:id/outputs/:outputID/thumbnail", handler.thumbnail) + return router, nil +} + +func (h *Handler) sessionMiddleware(c *gin.Context) { + state, err := h.sessions.Ensure(c.Writer, c.Request) + if err != nil { + writeError(c, http.StatusInternalServerError, "internal_error", "request could not be completed") + c.Abort() + return + } + c.Set("session", state) + c.Next() +} +func (h *Handler) requireAuth(c *gin.Context) { + state := currentSession(c) + if state.UserID == 0 { + if isHTMX(c) { + c.Header("HX-Redirect", "/login") + } + writeError(c, http.StatusUnauthorized, "authentication_required", "sign in is required") + c.Abort() + return + } + c.Next() +} +func (h *Handler) csrf(c *gin.Context) { + if !h.sessions.ValidateCSRF(c.Request, currentSession(c)) { + writeError(c, http.StatusForbidden, "csrf_invalid", "request token is invalid") + c.Abort() + return + } + c.Next() +} +func currentSession(c *gin.Context) session.State { + value, _ := c.Get("session") + state, _ := value.(session.State) + return state +} + +func (h *Handler) sessionState(c *gin.Context) { + state := currentSession(c) + c.JSON(http.StatusOK, gin.H{"authenticated": state.UserID != 0, "csrf_token": state.CSRFToken}) +} +func (h *Handler) login(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) + var input struct { + Email string `json:"email" binding:"required"` + Password string `json:"password" binding:"required"` + } + if err := c.ShouldBindJSON(&input); err != nil { + writeError(c, 400, "invalid_request", "email and password are required") + return + } + user, err := h.auth.Login(c.Request.Context(), remoteKey(c.Request), input.Email, input.Password) + if err != nil { + if !errors.Is(err, auth.ErrInvalidCredentials) { + writeError(c, http.StatusInternalServerError, "internal_error", "request could not be completed") + return + } + writeError(c, http.StatusUnauthorized, "invalid_credentials", "email or password is incorrect") + return + } + state, err := h.sessions.Authenticate(c.Writer, c.Request, user.ID) + if err != nil { + writeError(c, 500, "internal_error", "request could not be completed") + return + } + c.JSON(http.StatusOK, gin.H{"authenticated": true, "csrf_token": state.CSRFToken, "user": gin.H{"id": user.ID, "display_name": user.DisplayName}}) +} +func (h *Handler) logout(c *gin.Context) { + state, err := h.sessions.Logout(c.Writer, c.Request) + if err != nil { + writeError(c, 500, "internal_error", "request could not be completed") + return + } + c.JSON(http.StatusOK, gin.H{"authenticated": false, "csrf_token": state.CSRFToken}) +} + +func (h *Handler) submitText(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) + var input struct { + IdempotencyKey string `json:"idempotency_key"` + Prompt string `json:"prompt"` + } + if c.ShouldBindJSON(&input) != nil { + writeError(c, 400, "invalid_request", "request body is invalid") + return + } + generation, created, err := h.service.SubmitText(c.Request.Context(), currentSession(c).UserID, input.IdempotencyKey, input.Prompt) + if err != nil { + h.serviceError(c, err) + return + } + status := http.StatusAccepted + if !created { + status = http.StatusOK + } + c.JSON(status, generationResponse(generation)) +} +func (h *Handler) submitImages(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, h.maxUploadBytes+(1<<20)) + if err := c.Request.ParseMultipartForm(h.maxUploadBytes); err != nil { + writeError(c, 400, "upload_too_large", "upload exceeds configured limit") + return + } + defer c.Request.MultipartForm.RemoveAll() + headers := c.Request.MultipartForm.File["images"] + uploads := make([]service.Upload, 0, len(headers)) + for index, header := range headers { + file, err := header.Open() + if err != nil { + writeFileError(c, index, safeName(header.Filename), "invalid_image_content") + return + } + content, readErr := io.ReadAll(io.LimitReader(file, h.maxUploadBytes+1)) + file.Close() + if readErr != nil { + writeFileError(c, index, safeName(header.Filename), "invalid_image_content") + return + } + uploads = append(uploads, service.Upload{Name: header.Filename, DeclaredMIME: header.Header.Get("Content-Type"), Content: content}) + } + generation, created, err := h.service.SubmitImages(c.Request.Context(), currentSession(c).UserID, c.PostForm("idempotency_key"), c.PostForm("prompt"), uploads) + if err != nil { + h.serviceError(c, err) + return + } + status := http.StatusAccepted + if !created { + status = http.StatusOK + } + c.JSON(status, generationResponse(generation)) +} + +func (h *Handler) history(c *gin.Context) { + rows, err := h.service.History(c.Request.Context(), currentSession(c).UserID) + if err != nil { + writeError(c, 500, "internal_error", "request could not be completed") + return + } + items := make([]gin.H, 0, len(rows)) + for _, row := range rows { + items = append(items, generationResponse(row)) + } + c.JSON(200, gin.H{"items": items}) +} +func (h *Handler) detail(c *gin.Context) { + id, ok := uintParam(c, "id") + if !ok { + return + } + detail, err := h.service.ByID(c.Request.Context(), currentSession(c).UserID, id) + if err != nil { + h.serviceError(c, err) + return + } + outputs := make([]gin.H, 0, len(detail.Outputs)) + for _, output := range detail.Outputs { + item := gin.H{"id": output.ID, "kind": output.Kind} + if output.TextContent != nil { + item["text"] = *output.TextContent + } + if output.StorageKey != nil { + item["url"] = fmt.Sprintf("/api/generations/%d/outputs/%d", id, output.ID) + item["thumbnail_url"] = fmt.Sprintf("/api/generations/%d/outputs/%d/thumbnail", id, output.ID) + } + outputs = append(outputs, item) + } + inputs := make([]gin.H, 0, len(detail.Inputs)) + for _, input := range detail.Inputs { + inputs = append(inputs, gin.H{"id": input.ID, "position": input.Position, "role": input.Role, "name": input.OriginalName, "mime_type": input.MIMEType, "size_bytes": input.SizeBytes, "url": fmt.Sprintf("/api/generations/%d/inputs/%d", id, input.ID)}) + } + response := generationResponse(detail.Generation) + response["inputs"] = inputs + response["outputs"] = outputs + c.JSON(200, response) +} +func (h *Handler) card(c *gin.Context) { + id, ok := uintParam(c, "id") + if !ok { + return + } + detail, err := h.service.ByID(c.Request.Context(), currentSession(c).UserID, id) + if err != nil { + h.serviceError(c, err) + return + } + data := struct { + ID uint64 + Status model.GenerationStatus + Terminal bool + }{id, detail.Generation.Status, detail.Generation.Status.Terminal()} + const markup = `
{{.Status}}
` + tmpl := template.Must(template.New("card").Parse(markup)) + c.Header("Content-Type", "text/html; charset=utf-8") + _ = tmpl.Execute(c.Writer, data) +} + +func (h *Handler) input(c *gin.Context) { + generationID, ok := uintParam(c, "id") + if !ok { + return + } + inputID, ok := uintParam(c, "inputID") + if !ok { + return + } + reader, object, name, err := h.service.OpenInput(c.Request.Context(), currentSession(c).UserID, generationID, inputID) + if err != nil { + h.serviceError(c, err) + return + } + defer reader.Close() + c.Header("Content-Type", object.ContentType) + c.Header("Content-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": name})) + c.Status(200) + _, _ = io.Copy(c.Writer, reader) +} +func (h *Handler) output(c *gin.Context) { h.writeOutput(c, false) } +func (h *Handler) thumbnail(c *gin.Context) { h.writeOutput(c, true) } +func (h *Handler) writeOutput(c *gin.Context, thumbnail bool) { + generationID, ok := uintParam(c, "id") + if !ok { + return + } + outputID, ok := uintParam(c, "outputID") + if !ok { + return + } + reader, object, err := h.service.OpenOutput(c.Request.Context(), currentSession(c).UserID, generationID, outputID, thumbnail) + if err != nil { + h.serviceError(c, err) + return + } + defer reader.Close() + c.Header("Content-Type", object.ContentType) + c.Header("Content-Disposition", "inline") + c.Status(200) + _, _ = io.Copy(c.Writer, reader) +} + +func generationResponse(generation model.Generation) gin.H { + return gin.H{"id": generation.ID, "kind": generation.Kind, "status": generation.Status, "terminal": generation.Status.Terminal(), "user_prompt": generation.UserPrompt, "error_code": generation.ErrorCode, "error_message": generation.ErrorMessage, "created_at": generation.CreatedAt, "completed_at": generation.CompletedAt} +} +func (h *Handler) serviceError(c *gin.Context, err error) { + var fileError *service.FileError + if errors.As(err, &fileError) { + writeFileError(c, fileError.Index, fileError.Name, fileError.Code.Error()) + return + } + switch { + case errors.Is(err, service.ErrInvalidPrompt): + writeError(c, 400, "invalid_prompt", "prompt is invalid") + case errors.Is(err, service.ErrInvalidIdempotencyKey): + writeError(c, 400, "invalid_idempotency_key", "idempotency key is invalid") + case errors.Is(err, service.ErrImageCount): + writeError(c, 400, "invalid_image_count", "image count is invalid") + case errors.Is(err, service.ErrNotFound): + writeError(c, 404, "not_found", "resource was not found") + default: + writeError(c, 500, "internal_error", "request could not be completed") + } +} +func writeFileError(c *gin.Context, index int, name, code string) { + c.JSON(400, gin.H{"error": gin.H{"code": code, "message": "image is invalid", "file": gin.H{"index": index, "name": name}}}) +} +func writeError(c *gin.Context, status int, code, message string) { + c.JSON(status, gin.H{"error": gin.H{"code": code, "message": message}}) +} +func uintParam(c *gin.Context, name string) (uint64, bool) { + value, err := strconv.ParseUint(c.Param(name), 10, 64) + if err != nil || value == 0 { + writeError(c, 400, "invalid_id", "resource id is invalid") + return 0, false + } + return value, true +} +func remoteKey(request *http.Request) string { + host, _, err := net.SplitHostPort(request.RemoteAddr) + if err == nil { + return host + } + return strings.TrimSpace(request.RemoteAddr) +} +func isHTMX(c *gin.Context) bool { return strings.EqualFold(c.GetHeader("HX-Request"), "true") } + +func safeName(value string) string { + name := path.Base(strings.ReplaceAll(strings.TrimSpace(value), "\\", "/")) + if name == "." || name == "" { + return "image" + } + return name +} + +func securityHeaders(c *gin.Context) { + c.Header("Content-Security-Policy", "default-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'") + c.Header("Referrer-Policy", "no-referrer") + c.Header("X-Content-Type-Options", "nosniff") + c.Header("X-Frame-Options", "DENY") + c.Next() +} diff --git a/portal/main.go b/portal/main.go index e67bf83..8135e94 100644 --- a/portal/main.go +++ b/portal/main.go @@ -1,16 +1,31 @@ package main import ( - "fmt" + "context" + "errors" "log" + "net/http" "os" + "os/signal" + "syscall" + "time" "git.ilapage.cn/OPC/chorus/internal/config" + "git.ilapage.cn/OPC/chorus/internal/core/queue" + platformstorage "git.ilapage.cn/OPC/chorus/internal/platform/storage" + "git.ilapage.cn/OPC/chorus/portal/auth" + "git.ilapage.cn/OPC/chorus/portal/handler" + "git.ilapage.cn/OPC/chorus/portal/service" + "git.ilapage.cn/OPC/chorus/portal/session" + "github.com/gin-gonic/gin" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" ) func main() { if err := run(); err != nil { - log.Printf("chorus portal configuration error: %v", err) + log.Printf("chorus portal stopped: %v", err) os.Exit(1) } } @@ -23,6 +38,60 @@ func run() error { if err := cfg.ValidateProduction(); err != nil { return err } - fmt.Printf("chorus portal skeleton configured for %s on %s\n", cfg.Environment, cfg.ListenAddress) - return nil + if cfg.Environment == config.Production { + gin.SetMode(gin.ReleaseMode) + } + dbLogger := logger.New(log.New(os.Stderr, "chorus db: ", log.LstdFlags), logger.Config{LogLevel: logger.Error, IgnoreRecordNotFoundError: true, ParameterizedQueries: true}) + db, err := gorm.Open(mysql.Open(cfg.DBDSN), &gorm.Config{Logger: dbLogger}) + if err != nil { + return errors.New("connect portal database") + } + sqlDB, err := db.DB() + if err != nil { + return errors.New("configure portal database") + } + defer sqlDB.Close() + storage, err := platformstorage.NewLocal(platformstorage.Config{Root: cfg.StorageRoot, MaxObjectBytes: cfg.MaxImageBytes, MaxImagePixels: cfg.MaxImagePixels, ThumbnailMaxSide: 256, AllowedImageMIME: map[string]bool{"image/png": true, "image/jpeg": true}}) + if err != nil { + return err + } + queueRepository, err := queue.NewMySQLRepository(db) + if err != nil { + return err + } + sessions, err := session.New([]byte(cfg.SessionKey), cfg.SessionTTL, cfg.Environment == config.Production) + if err != nil { + return err + } + authService, err := auth.NewService(db, cfg.LoginAttempts, cfg.LoginWindow) + if err != nil { + return err + } + generationService, err := service.New(db, queueRepository, storage, service.Config{MaxPromptBytes: cfg.MaxPromptBytes, MaxImages: cfg.MaxImages, MaxImageBytes: cfg.MaxImageBytes, MaxUploadBytes: cfg.MaxUploadBytes, MaxImagePixels: cfg.MaxImagePixels, HistoryLimit: cfg.HistoryLimit}) + if err != nil { + return err + } + router, err := handler.NewRouter(sessions, authService, generationService, cfg.MaxUploadBytes) + if err != nil { + return err + } + server := &http.Server{Addr: cfg.ListenAddress, Handler: router, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second} + shutdown, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + errorsChannel := make(chan error, 1) + go func() { + log.Printf("chorus portal listening on %s", cfg.ListenAddress) + errorsChannel <- server.ListenAndServe() + }() + select { + case err := <-errorsChannel: + if !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil + case <-shutdown.Done(): + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + return server.Shutdown(ctx) + } } diff --git a/portal/service/service.go b/portal/service/service.go new file mode 100644 index 0000000..86f8bc2 --- /dev/null +++ b/portal/service/service.go @@ -0,0 +1,263 @@ +package service + +import ( + "bytes" + "context" + "errors" + "fmt" + "image" + "io" + "net/http" + "path" + "strings" + "unicode/utf8" + + "git.ilapage.cn/OPC/chorus/internal/core/generate" + "git.ilapage.cn/OPC/chorus/internal/core/model" + "git.ilapage.cn/OPC/chorus/internal/core/queue" + corestorage "git.ilapage.cn/OPC/chorus/internal/core/storage" + "gorm.io/gorm" +) + +var ( + ErrInvalidPrompt = errors.New("invalid_prompt") + ErrInvalidIdempotencyKey = errors.New("invalid_idempotency_key") + ErrImageCount = errors.New("invalid_image_count") + ErrImageTooLarge = errors.New("image_too_large") + ErrUploadTooLarge = errors.New("upload_too_large") + ErrImageMIME = errors.New("invalid_image_mime") + ErrImageExtension = errors.New("invalid_image_extension") + ErrImageDecode = errors.New("invalid_image_content") + ErrImagePixels = errors.New("image_pixels_exceeded") + ErrNotFound = errors.New("not_found") +) + +type PreparedCreator interface { + CreateIdempotentPrepared(context.Context, *model.Generation, queue.PrepareInputs) (bool, error) +} +type Config struct { + MaxPromptBytes, MaxImages int + MaxImageBytes, MaxUploadBytes int64 + MaxImagePixels uint64 + HistoryLimit int +} +type Service struct { + db *gorm.DB + creator PreparedCreator + storage corestorage.Store + config Config +} +type Upload struct { + Name, DeclaredMIME string + Content []byte +} +type FileError struct { + Index int + Name string + Code error +} + +func (e *FileError) Error() string { return e.Code.Error() } +func (e *FileError) Unwrap() error { return e.Code } + +func New(db *gorm.DB, creator PreparedCreator, storage corestorage.Store, config Config) (*Service, error) { + if db == nil || creator == nil || storage == nil || config.MaxPromptBytes <= 0 || config.MaxImages <= 0 || config.MaxImageBytes <= 0 || config.MaxUploadBytes < config.MaxImageBytes || config.MaxImagePixels == 0 || config.HistoryLimit <= 0 { + return nil, errors.New("portal service configuration is invalid") + } + return &Service{db: db, creator: creator, storage: storage, config: config}, nil +} + +func (s *Service) SubmitText(ctx context.Context, userID uint64, idempotencyKey, prompt string) (model.Generation, bool, error) { + if err := s.validateCommon(userID, idempotencyKey, prompt); err != nil { + return model.Generation{}, false, err + } + rendered, err := s.render(ctx, model.KindText, prompt) + if err != nil { + return model.Generation{}, false, err + } + generation := model.Generation{UserID: userID, Kind: model.KindText, IdempotencyKey: idempotencyKey, UserPrompt: prompt, RenderedPrompt: rendered} + created, err := s.creator.CreateIdempotentPrepared(ctx, &generation, func(uint64) ([]model.GenerationInput, error) { return nil, nil }) + return generation, created, err +} + +func (s *Service) SubmitImages(ctx context.Context, userID uint64, idempotencyKey, prompt string, uploads []Upload) (model.Generation, bool, error) { + if err := s.validateCommon(userID, idempotencyKey, prompt); err != nil { + return model.Generation{}, false, err + } + validated, err := s.validateImages(uploads) + if err != nil { + return model.Generation{}, false, err + } + rendered, err := s.render(ctx, model.KindImage, prompt) + if err != nil { + return model.Generation{}, false, err + } + generation := model.Generation{UserID: userID, Kind: model.KindImage, IdempotencyKey: idempotencyKey, UserPrompt: prompt, RenderedPrompt: rendered} + var saved []string + created, err := s.creator.CreateIdempotentPrepared(ctx, &generation, func(generationID uint64) ([]model.GenerationInput, error) { + rows := make([]model.GenerationInput, 0, len(validated)) + for index, item := range validated { + key := fmt.Sprintf("inputs/%d/%d/%d", userID, generationID, index+1) + object, putErr := s.storage.Put(ctx, corestorage.PutRequest{Key: key, OwnerID: userID, GenerationID: generationID, ContentType: item.MIME, Source: bytes.NewReader(item.Content)}) + if putErr != nil { + return nil, putErr + } + saved = append(saved, key) + width, height := uint32(item.Width), uint32(item.Height) + role := model.RoleReference + if index == 0 { + role = model.RolePrimary + } + rows = append(rows, model.GenerationInput{Position: uint32(index), Role: role, OriginalName: item.Name, MIMEType: item.MIME, StorageKey: key, SizeBytes: uint64(object.Size), Width: &width, Height: &height}) + } + return rows, nil + }) + if err != nil { + for _, key := range saved { + _ = s.storage.Delete(context.Background(), key) + } + } + return generation, created, err +} + +type validatedImage struct { + Name, MIME string + Content []byte + Width, Height int +} + +func (s *Service) validateImages(uploads []Upload) ([]validatedImage, error) { + if len(uploads) == 0 || len(uploads) > s.config.MaxImages { + return nil, ErrImageCount + } + result := make([]validatedImage, 0, len(uploads)) + var total int64 + for index, upload := range uploads { + name := path.Base(strings.ReplaceAll(strings.TrimSpace(upload.Name), "\\", "/")) + if name == "." || name == "" { + name = "image" + } + size := int64(len(upload.Content)) + if size == 0 { + return nil, &FileError{index, name, ErrImageDecode} + } + if size > s.config.MaxImageBytes { + return nil, &FileError{index, name, ErrImageTooLarge} + } + total += size + if total > s.config.MaxUploadBytes { + return nil, &FileError{index, name, ErrUploadTooLarge} + } + detected := http.DetectContentType(upload.Content) + if separator := strings.Index(detected, ";"); separator >= 0 { + detected = detected[:separator] + } + declared := strings.TrimSpace(strings.Split(upload.DeclaredMIME, ";")[0]) + if (detected != "image/png" && detected != "image/jpeg") || declared != detected { + return nil, &FileError{index, name, ErrImageMIME} + } + extension := strings.ToLower(path.Ext(name)) + if (detected == "image/png" && extension != ".png") || (detected == "image/jpeg" && extension != ".jpg" && extension != ".jpeg") { + return nil, &FileError{index, name, ErrImageExtension} + } + config, _, err := image.DecodeConfig(bytes.NewReader(upload.Content)) + if err != nil || config.Width <= 0 || config.Height <= 0 { + return nil, &FileError{index, name, ErrImageDecode} + } + if uint64(config.Width)*uint64(config.Height) > s.config.MaxImagePixels { + return nil, &FileError{index, name, ErrImagePixels} + } + result = append(result, validatedImage{Name: name, MIME: detected, Content: upload.Content, Width: config.Width, Height: config.Height}) + } + return result, nil +} +func (s *Service) validateCommon(userID uint64, key, prompt string) error { + if userID == 0 { + return ErrNotFound + } + if len(key) < 8 || len(key) > 128 || strings.TrimSpace(key) != key { + return ErrInvalidIdempotencyKey + } + if strings.TrimSpace(prompt) == "" || len(prompt) > s.config.MaxPromptBytes || !utf8.ValidString(prompt) { + return ErrInvalidPrompt + } + return nil +} +func (s *Service) render(ctx context.Context, kind model.GenerationKind, prompt string) (string, error) { + var template model.PromptTemplate + err := s.db.WithContext(ctx).Where("kind=? AND enabled=TRUE", kind).Order("version DESC, id DESC").First(&template).Error + if err != nil { + return "", fmt.Errorf("load prompt template: %w", err) + } + return generate.RenderPrompt(template.TemplateText, prompt) +} + +type Detail struct { + Generation model.Generation + Inputs []model.GenerationInput + Outputs []model.GenerationOutput +} + +func (s *Service) ByID(ctx context.Context, userID, generationID uint64) (Detail, error) { + var generation model.Generation + if err := s.db.WithContext(ctx).Where("id=? AND user_id=?", generationID, userID).First(&generation).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return Detail{}, ErrNotFound + } + return Detail{}, err + } + var inputs []model.GenerationInput + var outputs []model.GenerationOutput + if err := s.db.WithContext(ctx).Where("generation_id=?", generationID).Order("position,id").Find(&inputs).Error; err != nil { + return Detail{}, err + } + if err := s.db.WithContext(ctx).Where("generation_id=?", generationID).Order("id").Find(&outputs).Error; err != nil { + return Detail{}, err + } + return Detail{Generation: generation, Inputs: inputs, Outputs: outputs}, nil +} +func (s *Service) History(ctx context.Context, userID uint64) ([]model.Generation, error) { + var rows []model.Generation + err := s.db.WithContext(ctx).Where("user_id=?", userID).Order("created_at DESC,id DESC").Limit(s.config.HistoryLimit).Find(&rows).Error + return rows, err +} + +func (s *Service) OpenInput(ctx context.Context, userID, generationID, inputID uint64) (io.ReadCloser, corestorage.Object, string, error) { + var input model.GenerationInput + err := s.db.WithContext(ctx).Table("generation_inputs gi").Select("gi.*").Joins("JOIN generations g ON g.id=gi.generation_id").Where("gi.id=? AND gi.generation_id=? AND g.user_id=?", inputID, generationID, userID).First(&input).Error + if err != nil { + return nil, corestorage.Object{}, "", ErrNotFound + } + reader, object, err := s.storage.Open(ctx, input.StorageKey) + if err != nil { + return nil, corestorage.Object{}, "", ErrNotFound + } + if object.OwnerID != userID || object.GenerationID != generationID { + reader.Close() + return nil, corestorage.Object{}, "", ErrNotFound + } + return reader, object, input.OriginalName, nil +} +func (s *Service) OpenOutput(ctx context.Context, userID, generationID, outputID uint64, thumbnail bool) (io.ReadCloser, corestorage.Object, error) { + var output model.GenerationOutput + err := s.db.WithContext(ctx).Table("generation_outputs AS o").Select("o.*").Joins("JOIN generations g ON g.id=o.generation_id").Where("o.id=? AND o.generation_id=? AND g.user_id=?", outputID, generationID, userID).First(&output).Error + if err != nil { + return nil, corestorage.Object{}, ErrNotFound + } + key := output.StorageKey + if thumbnail { + key = output.ThumbnailStorageKey + } + if key == nil { + return nil, corestorage.Object{}, ErrNotFound + } + reader, object, err := s.storage.Open(ctx, *key) + if err != nil { + return nil, corestorage.Object{}, ErrNotFound + } + if object.OwnerID != userID || object.GenerationID != generationID { + reader.Close() + return nil, corestorage.Object{}, ErrNotFound + } + return reader, object, nil +} diff --git a/portal/service/validation_test.go b/portal/service/validation_test.go new file mode 100644 index 0000000..e859ae5 --- /dev/null +++ b/portal/service/validation_test.go @@ -0,0 +1,66 @@ +package service + +import ( + "bytes" + "errors" + "image" + "image/color" + "image/png" + "testing" +) + +func TestUploadValidationErrors(t *testing.T) { + service := &Service{config: Config{MaxImages: 2, MaxImageBytes: 100, MaxUploadBytes: 200, MaxImagePixels: 16}} + tests := []struct { + name, mime string + data []byte + want error + }{ + {"mime", "text/plain", smallPNG(2, 2), ErrImageMIME}, + {"extension", "image/png", smallPNG(2, 2), ErrImageExtension}, + {"decode", "image/png", append([]byte{137, 80, 78, 71, 13, 10, 26, 10}, []byte("broken")...), ErrImageDecode}, + {"bytes", "image/png", bytes.Repeat([]byte{1}, 101), ErrImageTooLarge}, + {"pixels", "image/png", smallPNG(5, 5), ErrImagePixels}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + name := "file.png" + if test.name == "extension" { + name = "file.jpg" + } + _, err := service.validateImages([]Upload{{Name: name, DeclaredMIME: test.mime, Content: test.data}}) + if !errors.Is(err, test.want) { + t.Fatalf("error=%v want=%v", err, test.want) + } + }) + } +} + +func TestImageCountAndTotalSize(t *testing.T) { + service := &Service{config: Config{MaxImages: 1, MaxImageBytes: 1000, MaxUploadBytes: 100, MaxImagePixels: 100}} + if _, err := service.validateImages(nil); !errors.Is(err, ErrImageCount) { + t.Fatalf("empty error=%v", err) + } + data := smallPNG(2, 2) + images := []Upload{{Name: "a.png", DeclaredMIME: "image/png", Content: data}, {Name: "b.png", DeclaredMIME: "image/png", Content: data}} + if _, err := service.validateImages(images); !errors.Is(err, ErrImageCount) { + t.Fatalf("count error=%v", err) + } + service.config.MaxImages = 2 + service.config.MaxUploadBytes = int64(len(data)*2 - 1) + if _, err := service.validateImages(images); !errors.Is(err, ErrUploadTooLarge) { + t.Fatalf("total error=%v", err) + } +} + +func smallPNG(width, height int) []byte { + img := image.NewRGBA(image.Rect(0, 0, width, height)) + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + img.Set(x, y, color.RGBA{R: 1, G: 2, B: 3, A: 255}) + } + } + var output bytes.Buffer + _ = png.Encode(&output, img) + return output.Bytes() +} diff --git a/portal/session/manager.go b/portal/session/manager.go new file mode 100644 index 0000000..2361805 --- /dev/null +++ b/portal/session/manager.go @@ -0,0 +1,136 @@ +package session + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "net/http" + "strings" + "sync" + "time" +) + +const cookieName = "chorus_session" +const maxSessions = 10000 + +var ErrInvalidConfig = errors.New("session configuration is invalid") +var ErrCapacity = errors.New("session capacity is exhausted") + +type State struct { + ID string + UserID uint64 + CSRFToken string + ExpiresAt time.Time +} +type Manager struct { + mutex sync.RWMutex + sessions map[string]State + key []byte + ttl time.Duration + secure bool + now func() time.Time +} + +func New(key []byte, ttl time.Duration, secure bool) (*Manager, error) { + if len(key) < 32 || ttl <= 0 { + return nil, ErrInvalidConfig + } + return &Manager{sessions: map[string]State{}, key: append([]byte(nil), key...), ttl: ttl, secure: secure, now: time.Now}, nil +} + +func (m *Manager) Ensure(response http.ResponseWriter, request *http.Request) (State, error) { + if state, ok := m.Get(request); ok { + return state, nil + } + return m.rotate(response, "", 0) +} +func (m *Manager) Authenticate(response http.ResponseWriter, request *http.Request, userID uint64) (State, error) { + old, _ := m.Get(request) + return m.rotate(response, old.ID, userID) +} +func (m *Manager) Logout(response http.ResponseWriter, request *http.Request) (State, error) { + old, _ := m.Get(request) + return m.rotate(response, old.ID, 0) +} +func (m *Manager) Get(request *http.Request) (State, bool) { + cookie, err := request.Cookie(cookieName) + if err != nil { + return State{}, false + } + id, ok := m.verifyCookie(cookie.Value) + if !ok { + return State{}, false + } + m.mutex.RLock() + state, ok := m.sessions[id] + m.mutex.RUnlock() + if !ok || !state.ExpiresAt.After(m.now()) { + if ok { + m.delete(id) + } + return State{}, false + } + return state, true +} +func (m *Manager) ValidateCSRF(request *http.Request, state State) bool { + provided := request.Header.Get("X-CSRF-Token") + return state.CSRFToken != "" && hmac.Equal([]byte(state.CSRFToken), []byte(provided)) +} + +func (m *Manager) rotate(response http.ResponseWriter, oldID string, userID uint64) (State, error) { + id, err := randomToken(32) + if err != nil { + return State{}, err + } + csrf, err := randomToken(32) + if err != nil { + return State{}, err + } + state := State{ID: id, UserID: userID, CSRFToken: csrf, ExpiresAt: m.now().Add(m.ttl)} + m.mutex.Lock() + for id, existing := range m.sessions { + if !existing.ExpiresAt.After(m.now()) { + delete(m.sessions, id) + } + } + if oldID != "" { + delete(m.sessions, oldID) + } + if len(m.sessions) >= maxSessions { + m.mutex.Unlock() + return State{}, ErrCapacity + } + m.sessions[id] = state + m.mutex.Unlock() + http.SetCookie(response, &http.Cookie{Name: cookieName, Value: m.signCookie(id), Path: "/", HttpOnly: true, Secure: m.secure, SameSite: http.SameSiteLaxMode, Expires: state.ExpiresAt, MaxAge: int(m.ttl.Seconds())}) + return state, nil +} +func (m *Manager) delete(id string) { m.mutex.Lock(); delete(m.sessions, id); m.mutex.Unlock() } +func (m *Manager) signCookie(id string) string { + return id + "." + base64.RawURLEncoding.EncodeToString(m.signature(id)) +} +func (m *Manager) verifyCookie(value string) (string, bool) { + parts := strings.Split(value, ".") + if len(parts) != 2 { + return "", false + } + signature, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil || !hmac.Equal(signature, m.signature(parts[0])) { + return "", false + } + return parts[0], true +} +func (m *Manager) signature(value string) []byte { + mac := hmac.New(sha256.New, m.key) + mac.Write([]byte(value)) + return mac.Sum(nil) +} +func randomToken(size int) (string, error) { + value := make([]byte, size) + if _, err := rand.Read(value); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(value), nil +} diff --git a/portal/session/manager_test.go b/portal/session/manager_test.go new file mode 100644 index 0000000..8cc4a85 --- /dev/null +++ b/portal/session/manager_test.go @@ -0,0 +1,68 @@ +package session + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestSessionRotationCookieAndCSRF(t *testing.T) { + manager, err := New([]byte(strings.Repeat("k", 32)), time.Hour, true) + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodGet, "https://chorus.test/api/session", nil) + response := httptest.NewRecorder() + anonymous, err := manager.Ensure(response, request) + if err != nil { + t.Fatal(err) + } + cookie := response.Result().Cookies()[0] + if !cookie.HttpOnly || !cookie.Secure || cookie.SameSite != http.SameSiteLaxMode || anonymous.UserID != 0 { + t.Fatalf("cookie/state = %#v %#v", cookie, anonymous) + } + request.AddCookie(cookie) + loginResponse := httptest.NewRecorder() + authenticated, err := manager.Authenticate(loginResponse, request, 42) + if err != nil { + t.Fatal(err) + } + if authenticated.ID == anonymous.ID || authenticated.CSRFToken == anonymous.CSRFToken { + t.Fatal("login did not rotate session and CSRF") + } + if _, ok := manager.Get(request); ok { + t.Fatal("old session remained valid") + } + newCookie := loginResponse.Result().Cookies()[0] + csrfRequest := httptest.NewRequest(http.MethodPost, "https://chorus.test/api", nil) + csrfRequest.AddCookie(newCookie) + csrfRequest.Header.Set("X-CSRF-Token", authenticated.CSRFToken) + if state, ok := manager.Get(csrfRequest); !ok || !manager.ValidateCSRF(csrfRequest, state) { + t.Fatal("new session CSRF was rejected") + } + csrfRequest.Header.Set("X-CSRF-Token", "tampered") + if manager.ValidateCSRF(csrfRequest, authenticated) { + t.Fatal("tampered CSRF was accepted") + } +} +func TestTamperedAndExpiredCookie(t *testing.T) { + manager, _ := New([]byte(strings.Repeat("k", 32)), time.Millisecond, false) + response := httptest.NewRecorder() + state, _ := manager.Ensure(response, httptest.NewRequest(http.MethodGet, "http://test/", nil)) + cookie := response.Result().Cookies()[0] + cookie.Value += "x" + request := httptest.NewRequest(http.MethodGet, "http://test/", nil) + request.AddCookie(cookie) + if _, ok := manager.Get(request); ok { + t.Fatal("tampered cookie accepted") + } + manager.now = func() time.Time { return state.ExpiresAt.Add(time.Second) } + validCookie := response.Result().Cookies()[0] + request = httptest.NewRequest(http.MethodGet, "http://test/", nil) + request.AddCookie(validCookie) + if _, ok := manager.Get(request); ok { + t.Fatal("expired session accepted") + } +}