fx build fix
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline failed

This commit is contained in:
Stephan D
2025-11-08 00:30:29 +01:00
parent 590fad0071
commit 49b86efecb
165 changed files with 9466 additions and 0 deletions

View File

@@ -0,0 +1,141 @@
package apiimp
import (
"context"
"github.com/go-chi/chi/v5"
"github.com/tech/sendico/pkg/api/routers/health"
"github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/domainprovider"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api"
"github.com/tech/sendico/server/interface/services/account"
"github.com/tech/sendico/server/interface/services/invitation"
"github.com/tech/sendico/server/interface/services/logo"
"github.com/tech/sendico/server/interface/services/organization"
"github.com/tech/sendico/server/interface/services/permission"
"go.uber.org/zap"
)
type Microservices = []mservice.MicroService
// APIImp represents the structure of the APIImp
type APIImp struct {
logger mlogger.Logger
db db.Factory
domain domainprovider.DomainProvider
config *api.Config
services Microservices
mw *Middleware
}
func (a *APIImp) installMicroservice(srv mservice.MicroService) {
a.services = append(a.services, srv)
a.logger.Info("Microservice installed", zap.String("service", srv.Name()))
}
func (a *APIImp) addMicroservice(srvf api.MicroServiceFactoryT) error {
srv, err := srvf(a)
if err != nil {
a.logger.Error("Failed to install a microservice", zap.Error(err))
return err
}
a.installMicroservice(srv)
return nil
}
func (a *APIImp) Logger() mlogger.Logger {
return a.logger
}
func (a *APIImp) Config() *api.Config {
return a.config
}
func (a *APIImp) DBFactory() db.Factory {
return a.db
}
func (a *APIImp) DomainProvider() domainprovider.DomainProvider {
return a.domain
}
func (a *APIImp) Register() api.Register {
return a.mw
}
func (a *APIImp) Permissions() auth.Provider {
return a.db.Permissions()
}
func (a *APIImp) installServices() error {
srvf := make([]api.MicroServiceFactoryT, 0)
srvf = append(srvf, account.Create)
srvf = append(srvf, organization.Create)
srvf = append(srvf, invitation.Create)
srvf = append(srvf, logo.Create)
srvf = append(srvf, permission.Create)
for _, v := range srvf {
if err := a.addMicroservice(v); err != nil {
return err
}
}
a.mw.SetStatus(health.SSRunning)
return nil
}
func (a *APIImp) Finish(ctx context.Context) error {
a.mw.SetStatus(health.SSTerminating)
a.mw.Finish()
var lastError error
for i := len(a.services) - 1; i >= 0; i-- {
if err := (a.services[i]).Finish(ctx); err != nil {
lastError = err
a.logger.Warn("Error occurred when finishing service",
zap.Error(err), zap.String("service_name", (a.services[i]).Name()))
} else {
a.logger.Info("Microservice is down", zap.String("service_name", (a.services[i]).Name()))
}
}
return lastError
}
func (a *APIImp) Name() string {
return "api"
}
func CreateAPI(logger mlogger.Logger, config *api.Config, db db.Factory, router *chi.Mux, debug bool) (mservice.MicroService, error) {
p := &APIImp{
logger: logger.Named("api"),
config: config,
db: db,
}
var err error
if p.domain, err = domainprovider.CreateDomainProvider(p.logger, config.Mw.DomainEnv, config.Mw.APIProtocolEnv, config.Mw.EndPointEnv); err != nil {
p.logger.Error("Failed to initizlize domain provider")
return nil, err
}
p.logger.Info("Domain provider installed")
if p.mw, err = CreateMiddleware(logger, db, p.db.Permissions().Enforcer(), router, config.Mw, debug); err != nil {
p.logger.Error("Failed to create middleware", zap.Error(err))
return nil, err
}
p.logger.Info("Middleware installed", zap.Bool("debug_mode", debug))
p.logger.Info("Installing microservices...")
if err := p.installServices(); err != nil {
p.logger.Error("Failed to install a microservice", zap.Error(err))
return nil, err
}
p.logger.Info("Microservices installation complete", zap.Int("microservices", len(p.services)))
return p, nil
}

View File

@@ -0,0 +1,66 @@
package apiimp
import "github.com/tech/sendico/pkg/messaging"
type CORSSettings struct {
MaxAge int `yaml:"max_age"`
AllowedOrigins []string `yaml:"allowed_origins"`
AllowedMethods []string `yaml:"allowed_methods"`
AllowedHeaders []string `yaml:"allowed_headers"`
ExposedHeaders []string `yaml:"exposed_headers"`
AllowCredentials bool `yaml:"allow_credentials"`
}
type SignatureConf struct {
PublicKey any
PrivateKey []byte
Algorithm string
}
type Signature struct {
PublicKeyEnv string `yaml:"public_key_env,omitempty"`
PrivateKeyEnv string `yaml:"secret_key_env"`
Algorithm string `yaml:"algorithm"`
}
type TokenExpiration struct {
Account int `yaml:"account"`
Refresh int `yaml:"refresh"`
}
type TokenConfig struct {
Expiration TokenExpiration `yaml:"expiration_hours"`
Length int `yaml:"length"`
}
type WebSocketConfig struct {
EndpointEnv string `yaml:"endpoint_env"`
Timeout int `yaml:"timeout"`
}
type PasswordChecks struct {
Digit bool `yaml:"digit"`
Upper bool `yaml:"upper"`
Lower bool `yaml:"lower"`
Special bool `yaml:"special"`
MinLength int `yaml:"min_length"`
}
type PasswordConfig struct {
TokenLength int `yaml:"token_length"`
Check PasswordChecks `yaml:"check"`
}
type Config struct {
DomainEnv string `yaml:"domain_env"`
EndPointEnv string `yaml:"api_endpoint_env"`
APIProtocolEnv string `yaml:"api_protocol_env"`
Signature Signature `yaml:"signature"`
CORS CORSSettings `yaml:"CORS"`
WebSocket WebSocketConfig `yaml:"websocket"`
Messaging messaging.Config `yaml:"message_broker"`
Token TokenConfig `yaml:"token"`
Password PasswordConfig `yaml:"password"`
}
type MapClaims = map[string]any

View File

@@ -0,0 +1,138 @@
package apiimp
import (
"os"
"github.com/go-chi/chi/v5"
cm "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/go-chi/metrics"
api "github.com/tech/sendico/pkg/api/http"
amr "github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/api/routers/health"
"github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/messaging"
notifications "github.com/tech/sendico/pkg/messaging/notifications/processor"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api/sresponse"
wsh "github.com/tech/sendico/server/interface/api/ws"
"github.com/tech/sendico/server/interface/middleware"
"github.com/tech/sendico/server/internal/api/routers"
mr "github.com/tech/sendico/server/internal/api/routers/metrics"
"github.com/tech/sendico/server/internal/api/ws"
"go.uber.org/zap"
"moul.io/chizap"
)
type Middleware struct {
logger mlogger.Logger
router *chi.Mux
apiEndpoint string
health amr.Health
metrics mr.Metrics
wshandler ws.Router
messaging amr.Messaging
epdispatcher *routers.Dispatcher
}
func (mw *Middleware) Handler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) {
mw.epdispatcher.Handler(service, endpoint, method, handler)
}
func (mw *Middleware) AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc) {
mw.epdispatcher.AccountHandler(service, endpoint, method, handler)
}
func (mw *Middleware) WSHandler(messageType string, handler wsh.HandlerFunc) {
mw.wshandler.InstallHandler(messageType, handler)
}
func (mw *Middleware) Consumer(processor notifications.EnvelopeProcessor) error {
return mw.messaging.Consumer(processor)
}
func (mw *Middleware) Producer() messaging.Producer {
return mw.messaging.Producer()
}
func (mw *Middleware) Messaging() messaging.Register {
return mw
}
func (mw *Middleware) Finish() {
mw.messaging.Finish()
mw.health.Finish()
}
func (mw *Middleware) SetStatus(status health.ServiceStatus) {
mw.health.SetStatus(status)
}
func (mw *Middleware) installMiddleware(config *middleware.Config, debug bool) {
mw.logger.Debug("Installing middleware stack...")
// Collect metrics for all incoming HTTP requests
mw.router.Use(metrics.Collector(metrics.CollectorOpts{
Host: false, // avoid high-cardinality "host" label
Proto: true, // include HTTP protocol label
}))
mw.router.Use(cm.RequestID)
mw.router.Use(cm.RealIP)
if debug {
mw.router.Use(chizap.New(mw.logger.Named("http_trace"), &chizap.Opts{
WithReferer: true,
WithUserAgent: true,
}))
}
mw.router.Use(cors.Handler(cors.Options{
AllowedOrigins: config.CORS.AllowedOrigins,
AllowedMethods: config.CORS.AllowedMethods,
AllowedHeaders: config.CORS.AllowedHeaders,
ExposedHeaders: config.CORS.ExposedHeaders,
AllowCredentials: config.CORS.AllowCredentials,
MaxAge: config.CORS.MaxAge,
OptionsPassthrough: false,
Debug: debug,
}))
mw.router.Use(cm.Recoverer)
mw.router.Handle("/metrics", metrics.Handler())
}
func CreateMiddleware(logger mlogger.Logger, db db.Factory, enforcer auth.Enforcer, router *chi.Mux, config *middleware.Config, debug bool) (*Middleware, error) {
p := &Middleware{
logger: logger.Named("middleware"),
router: router,
apiEndpoint: os.Getenv(config.EndPointEnv),
}
p.logger.Info("Set endpoint", zap.String("endpoint", p.apiEndpoint))
p.installMiddleware(config, debug)
var err error
if p.messaging, err = amr.NewMessagingRouter(p.logger, &config.Messaging); err != nil {
p.logger.Error("Failed to create messaging router", zap.Error(err))
return nil, err
}
if p.health, err = amr.NewHealthRouter(p.logger, p.router, p.apiEndpoint); err != nil {
p.logger.Error("Failed to create healthcheck router", zap.Error(err), zap.String("api_endpoint", p.apiEndpoint))
return nil, err
}
if p.metrics, err = mr.NewMetricsRouter(p.logger, p.router, p.apiEndpoint); err != nil {
p.logger.Error("Failed to create metrics router", zap.Error(err), zap.String("api_endpoint", p.apiEndpoint))
return nil, err
}
adb, err := db.NewAccountDB()
if err != nil {
p.logger.Error("Faild to create account database", zap.Error(err))
return nil, err
}
rtdb, err := db.NewRefreshTokensDB()
if err != nil {
p.logger.Error("Faild to create refresh token management database", zap.Error(err))
return nil, err
}
p.epdispatcher = routers.NewDispatcher(p.logger, p.router, adb, rtdb, enforcer, config)
p.wshandler = ws.NewRouter(p.logger, p.router, &config.WebSocket, p.apiEndpoint)
return p, nil
}

View File

@@ -0,0 +1,56 @@
package routers
import (
"errors"
"net/http"
"github.com/go-chi/jwtauth/v5"
api "github.com/tech/sendico/pkg/api/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/sresponse"
emodel "github.com/tech/sendico/server/interface/model"
"go.uber.org/zap"
)
type tokenHandlerFunc = func(r *http.Request, t *emodel.AccountToken) http.HandlerFunc
func (ar *AuthorizedRouter) tokenHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler tokenHandlerFunc) {
hndlr := func(r *http.Request) http.HandlerFunc {
_, claims, err := jwtauth.FromContext(r.Context())
if err != nil {
ar.logger.Debug("Authorization failed", zap.Error(err), zap.String("request", r.URL.Path))
return response.Unauthorized(ar.logger, ar.service, "credentials required")
}
t, err := emodel.Claims2Token(claims)
if err != nil {
ar.logger.Debug("Failed to decode account token", zap.Error(err))
return response.BadRequest(ar.logger, ar.service, "credentials_unreadable", "faild to parse credentials")
}
return handler(r, t)
}
ar.imp.InstallHandler(service, endpoint, method, hndlr)
}
func (ar *AuthorizedRouter) AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc) {
hndlr := func(r *http.Request, t *emodel.AccountToken) http.HandlerFunc {
var a model.Account
if err := ar.db.Get(r.Context(), t.AccountRef, &a); err != nil {
if errors.Is(err, merrors.ErrNoData) {
ar.logger.Debug("Failed to find related user", zap.Error(err), mzap.ObjRef("account_ref", t.AccountRef))
return response.NotFound(ar.logger, ar.service, err.Error())
}
return response.Internal(ar.logger, ar.service, err)
}
accessToken, err := ar.imp.CreateAccessToken(&a)
if err != nil {
ar.logger.Warn("Failed to generate access token", zap.Error(err))
return response.Internal(ar.logger, ar.service, err)
}
return handler(r, &a, &accessToken)
}
ar.tokenHandler(service, endpoint, method, hndlr)
}

View File

@@ -0,0 +1,34 @@
package routers
import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
"github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/db/account"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/middleware"
re "github.com/tech/sendico/server/internal/api/routers/endpoint"
)
type AuthorizedRouter struct {
logger mlogger.Logger
db account.DB
imp *re.HttpEndpointRouter
service mservice.Type
}
func NewRouter(logger mlogger.Logger, apiEndpoint string, router chi.Router, db account.DB, enforcer auth.Enforcer, config *middleware.TokenConfig, signature *middleware.Signature) *AuthorizedRouter {
ja := jwtauth.New(signature.Algorithm, signature.PrivateKey, signature.PublicKey)
router.Use(jwtauth.Verifier(ja))
router.Use(jwtauth.Authenticator(ja))
l := logger.Named("authorized")
ar := AuthorizedRouter{
logger: l,
db: db,
imp: re.NewHttpEndpointRouter(l, apiEndpoint, router, config, signature),
service: mservice.Accounts,
}
return &ar
}

View File

@@ -0,0 +1,50 @@
package routers
import (
"os"
"github.com/go-chi/chi/v5"
api "github.com/tech/sendico/pkg/api/http"
"github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/db/account"
"github.com/tech/sendico/pkg/db/refreshtokens"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api/sresponse"
"github.com/tech/sendico/server/interface/middleware"
rauthorized "github.com/tech/sendico/server/internal/api/routers/authorized"
rpublic "github.com/tech/sendico/server/internal/api/routers/public"
)
type Dispatcher struct {
logger mlogger.Logger
public APIRouter
protected ProtectedAPIRouter
}
func (d *Dispatcher) Handler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) {
d.public.InstallHandler(service, endpoint, method, handler)
}
func (d *Dispatcher) AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc) {
d.protected.AccountHandler(service, endpoint, method, handler)
}
func NewDispatcher(logger mlogger.Logger, router chi.Router, db account.DB, rtdb refreshtokens.DB, enforcer auth.Enforcer, config *middleware.Config) *Dispatcher {
d := &Dispatcher{
logger: logger.Named("api_dispatcher"),
}
d.logger.Debug("Installing endpoints middleware...")
endpoint := os.Getenv(config.EndPointEnv)
signature := middleware.SignatureConf(config)
router.Group(func(r chi.Router) {
d.public = rpublic.NewRouter(d.logger, endpoint, db, rtdb, r, &config.Token, &signature)
})
router.Group(func(r chi.Router) {
d.protected = rauthorized.NewRouter(d.logger, endpoint, r, db, enforcer, &config.Token, &signature)
})
return d
}

View File

@@ -0,0 +1,36 @@
package routers
import (
"context"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/server/interface/api/sresponse"
"github.com/tech/sendico/server/interface/middleware"
)
type (
RegistratorT = func(chi.Router, string, http.HandlerFunc)
ResponderFunc = func(ctx context.Context, r *http.Request, session *model.SessionIdentifier, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc
)
type HttpEndpointRouter struct {
logger mlogger.Logger
apiEndpoint string
router chi.Router
config middleware.TokenConfig
signature middleware.Signature
}
func NewHttpEndpointRouter(logger mlogger.Logger, apiEndpoint string, router chi.Router, config *middleware.TokenConfig, signature *middleware.Signature) *HttpEndpointRouter {
er := HttpEndpointRouter{
logger: logger.Named("http"),
apiEndpoint: apiEndpoint,
router: router,
signature: *signature,
config: *config,
}
return &er
}

View File

@@ -0,0 +1,50 @@
package routers
import (
"fmt"
"net/http"
"path"
"github.com/go-chi/chi/v5"
api "github.com/tech/sendico/pkg/api/http"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api/sresponse"
"go.uber.org/zap"
)
func (er *HttpEndpointRouter) chooseMethod(method api.HTTPMethod) RegistratorT {
switch method {
case api.Get:
return func(r chi.Router, p string, h http.HandlerFunc) { r.Get(p, h) }
case api.Post:
return func(r chi.Router, p string, h http.HandlerFunc) { r.Post(p, h) }
case api.Put:
return func(r chi.Router, p string, h http.HandlerFunc) { r.Put(p, h) }
case api.Delete:
return func(r chi.Router, p string, h http.HandlerFunc) { r.Delete(p, h) }
case api.Patch:
return func(r chi.Router, p string, h http.HandlerFunc) { r.Patch(p, h) }
case api.Options:
return func(r chi.Router, p string, h http.HandlerFunc) { r.Options(p, h) }
case api.Head:
return func(r chi.Router, p string, h http.HandlerFunc) { r.Head(p, h) }
default:
}
er.logger.Error("Unknown method provided", zap.String("method", api.HTTPMethod2String(method)))
panic(fmt.Sprintf("Unknown method provided: %d", method))
}
func (er *HttpEndpointRouter) endpoint(service mservice.Type, handler string) string {
return path.Join(er.apiEndpoint, service, handler)
}
func (er *HttpEndpointRouter) InstallHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) {
ep := er.endpoint(service, endpoint)
hm := er.chooseMethod(method)
hndlr := func(w http.ResponseWriter, r *http.Request) {
res := handler(r)
res(w, r)
}
hm(er.router, ep, hndlr)
er.logger.Info("Handler installed", zap.String("endpoint", ep), zap.String("method", api.HTTPMethod2String(method)))
}

View File

@@ -0,0 +1,20 @@
package routers
import (
"time"
"github.com/go-chi/jwtauth/v5"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/server/interface/api/sresponse"
emodel "github.com/tech/sendico/server/interface/model"
)
func (er *HttpEndpointRouter) CreateAccessToken(user *model.Account) (sresponse.TokenData, error) {
ja := jwtauth.New(er.signature.Algorithm, er.signature.PrivateKey, er.signature.PublicKey)
_, res, err := ja.Encode(emodel.Account2Claims(user, er.config.Expiration.Account))
token := sresponse.TokenData{
Token: res,
Expiration: time.Now().Add(time.Duration(er.config.Expiration.Account) * time.Hour),
}
return token, err
}

View File

@@ -0,0 +1,40 @@
package routers
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/metrics"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
type metricsRouter struct {
logger mlogger.Logger
handler http.Handler
}
func (mr *metricsRouter) Finish() {
mr.logger.Debug("Stopped")
}
func (mr *metricsRouter) handle(w http.ResponseWriter, r *http.Request) {
mr.logger.Debug("Serving metrics request...")
mr.handler.ServeHTTP(w, r)
}
func newMetricsRouter(logger mlogger.Logger, router chi.Router, endpoint string) *metricsRouter {
mr := metricsRouter{
logger: logger.Named("metrics"),
handler: metrics.Handler(),
}
logger.Debug("Installing Prometheus middleware...")
router.Group(func(r chi.Router) {
ep := endpoint + "/metrics"
r.Get(ep, mr.handle)
logger.Info("Prometheus handler installed", zap.String("endpoint", ep))
})
return &mr
}

View File

@@ -0,0 +1,14 @@
package routers
import (
"github.com/go-chi/chi/v5"
"github.com/tech/sendico/pkg/mlogger"
)
type Metrics interface {
Finish()
}
func NewMetricsRouter(logger mlogger.Logger, router chi.Router, endpoint string) (Metrics, error) {
return newMetricsRouter(logger, router, endpoint), nil
}

View File

@@ -0,0 +1,63 @@
package routers
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api/srequest"
"go.uber.org/zap"
)
func (pr *PublicRouter) logUserIn(ctx context.Context, r *http.Request, req *srequest.Login) http.HandlerFunc {
// Get the account database entry
trimmedLogin := strings.TrimSpace(req.Login)
account, err := pr.db.GetByEmail(ctx, strings.ToLower(trimmedLogin))
if errors.Is(err, merrors.ErrNoData) || (account == nil) {
pr.logger.Debug("User not found while logging in", zap.Error(err), zap.String("login", req.Login))
return response.Unauthorized(pr.logger, pr.service, "user not found")
}
if err != nil {
pr.logger.Warn("Failed to query user with email", zap.Error(err), zap.String("login", req.Login))
return response.Internal(pr.logger, pr.service, err)
}
if account.VerifyToken != "" {
return response.Forbidden(pr.logger, pr.service, "account_not_verified", "Account verification required")
}
if !account.MatchPassword(req.Password) {
return response.Unauthorized(pr.logger, pr.service, "password does not match")
}
accessToken, err := pr.imp.CreateAccessToken(account)
if err != nil {
pr.logger.Warn("Failed to generate access token", zap.Error(err))
return response.Internal(pr.logger, pr.service, err)
}
return pr.refreshAndRespondLogin(ctx, r, &req.SessionIdentifier, account, &accessToken)
}
func (a *PublicRouter) login(r *http.Request) http.HandlerFunc {
// TODO: add rate check
var req srequest.Login
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
a.logger.Info("Failed to decode login request", zap.Error(err))
return response.BadPayload(a.logger, mservice.Accounts, err)
}
req.Login = strings.TrimSpace(req.Login)
req.Password = strings.TrimSpace(req.Password)
if req.Login == "" {
return response.BadRequest(a.logger, mservice.Accounts, "email_missing", "login request has no user name")
}
if req.Password == "" {
return response.BadRequest(a.logger, mservice.Accounts, "password_missing", "login request has no password")
}
return a.logUserIn(r.Context(), r, &req)
}

View File

@@ -0,0 +1,29 @@
package routers
import (
"encoding/json"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api/srequest"
"github.com/tech/sendico/server/interface/api/sresponse"
"go.uber.org/zap"
)
func (pr *PublicRouter) refreshAccessToken(r *http.Request) http.HandlerFunc {
pr.logger.Debug("Processing access token refresh request")
var req srequest.AccessTokenRefresh
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
pr.logger.Info("Failed to decode token rotation request", zap.Error(err))
return response.BadPayload(pr.logger, mservice.RefreshTokens, err)
}
account, token, err := pr.validateRefreshToken(r.Context(), r, &req)
if err != nil {
pr.logger.Warn("Failed to process access token refreshment request", zap.Error(err))
return response.Auto(pr.logger, pr.service, err)
}
return sresponse.Account(pr.logger, account, token)
}

View File

@@ -0,0 +1,77 @@
package routers
import (
"context"
"crypto/rand"
"encoding/base64"
"io"
"net/http"
"time"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/sresponse"
"go.uber.org/zap"
)
func generateRefreshTokenData(length int) (string, error) {
randomBytes := make([]byte, length)
if _, err := io.ReadFull(rand.Reader, randomBytes); err != nil {
return "", merrors.Internal("failed to generate secure random bytes: " + err.Error())
}
return base64.URLEncoding.EncodeToString(randomBytes), nil
}
func (er *PublicRouter) prepareRefreshToken(ctx context.Context, r *http.Request, session *model.SessionIdentifier, account *model.Account) (*model.RefreshToken, error) {
refreshToken, err := generateRefreshTokenData(er.config.Length)
if err != nil {
er.logger.Warn("Failed to generate refresh token", zap.Error(err), mzap.StorableRef(account))
return nil, err
}
token := &model.RefreshToken{
AccountBoundBase: model.AccountBoundBase{
AccountRef: account.GetID(),
},
ClientRefreshToken: model.ClientRefreshToken{
SessionIdentifier: *session,
RefreshToken: refreshToken,
},
ExpiresAt: time.Now().Add(time.Duration(er.config.Expiration.Refresh) * time.Hour),
IsRevoked: false,
UserAgent: r.UserAgent(),
IPAddress: r.RemoteAddr,
}
if err = er.rtdb.Create(ctx, token); err != nil {
er.logger.Warn("Failed to store a refresh token", zap.Error(err), mzap.StorableRef(account),
zap.String("client_id", token.ClientID), zap.String("device_id", token.DeviceID))
return nil, err
}
return token, nil
}
func (pr *PublicRouter) refreshAndRespondLogin(
ctx context.Context,
r *http.Request,
session *model.SessionIdentifier,
account *model.Account,
accessToken *sresponse.TokenData,
) http.HandlerFunc {
refreshToken, err := pr.prepareRefreshToken(ctx, r, session, account)
if err != nil {
pr.logger.Warn("Failed to create refresh token", zap.Error(err), mzap.StorableRef(account),
zap.String("client_id", session.ClientID), zap.String("device_id", session.DeviceID))
return response.Internal(pr.logger, pr.service, err)
}
token := sresponse.TokenData{
Token: refreshToken.RefreshToken,
Expiration: refreshToken.ExpiresAt,
}
return sresponse.Login(pr.logger, account, accessToken, &token)
}

View File

@@ -0,0 +1,28 @@
package routers
import (
"encoding/json"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api/srequest"
"go.uber.org/zap"
)
func (pr *PublicRouter) rotateRefreshToken(r *http.Request) http.HandlerFunc {
pr.logger.Debug("Processing token rotation request...")
var req srequest.TokenRefreshRotate
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
pr.logger.Info("Failed to decode token rotation request", zap.Error(err))
return response.BadPayload(pr.logger, mservice.RefreshTokens, err)
}
account, token, err := pr.validateRefreshToken(r.Context(), r, &req)
if err != nil {
pr.logger.Warn("Failed to validate refresh token", zap.Error(err))
return response.Auto(pr.logger, pr.service, err)
}
return pr.refreshAndRespondLogin(r.Context(), r, &req.SessionIdentifier, account, token)
}

View File

@@ -0,0 +1,46 @@
package routers
import (
"github.com/go-chi/chi/v5"
api "github.com/tech/sendico/pkg/api/http"
"github.com/tech/sendico/pkg/db/account"
"github.com/tech/sendico/pkg/db/refreshtokens"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api/sresponse"
"github.com/tech/sendico/server/interface/middleware"
re "github.com/tech/sendico/server/internal/api/routers/endpoint"
)
type PublicRouter struct {
logger mlogger.Logger
db account.DB
imp *re.HttpEndpointRouter
rtdb refreshtokens.DB
config middleware.TokenConfig
signature middleware.Signature
service mservice.Type
}
func (pr *PublicRouter) InstallHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) {
pr.imp.InstallHandler(service, endpoint, method, handler)
}
func NewRouter(logger mlogger.Logger, apiEndpoint string, db account.DB, rtdb refreshtokens.DB, router chi.Router, config *middleware.TokenConfig, signature *middleware.Signature) *PublicRouter {
l := logger.Named("public")
hr := PublicRouter{
logger: l,
db: db,
rtdb: rtdb,
config: *config,
signature: *signature,
imp: re.NewHttpEndpointRouter(l, apiEndpoint, router, config, signature),
service: mservice.Accounts,
}
hr.InstallHandler(hr.service, "/login", api.Post, hr.login)
hr.InstallHandler(hr.service, "/rotate", api.Post, hr.rotateRefreshToken)
hr.InstallHandler(hr.service, "/refresh", api.Post, hr.refreshAccessToken)
return &hr
}

View File

@@ -0,0 +1,59 @@
package routers
import (
"context"
"errors"
"net/http"
"time"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/srequest"
"github.com/tech/sendico/server/interface/api/sresponse"
"go.uber.org/zap"
)
func validateToken(token string, rt *model.RefreshToken) string {
if rt.AccountRef == nil {
return "missing account reference"
}
if token != rt.RefreshToken {
return "tokens do not match"
}
if rt.ExpiresAt.Before(time.Now()) {
return "token expired"
}
if rt.IsRevoked {
return "token has been revoked"
}
return ""
}
func (pr *PublicRouter) validateRefreshToken(ctx context.Context, _ *http.Request, req *srequest.TokenRefreshRotate) (*model.Account, *sresponse.TokenData, error) {
rt, err := pr.rtdb.GetByCRT(ctx, req)
if errors.Is(err, merrors.ErrNoData) {
pr.logger.Info("Refresh token not found", zap.String("client_id", req.ClientID), zap.String("device_id", req.DeviceID))
return nil, nil, err
}
if reason := validateToken(req.RefreshToken, rt); len(reason) > 0 {
pr.logger.Info("Token validation failed", zap.String("reason", reason))
return nil, nil, merrors.Unauthorized(reason)
}
var account model.Account
if err := pr.db.Get(ctx, *rt.AccountRef, &account); errors.Is(err, merrors.ErrNoData) {
pr.logger.Info("User not found while rotating refresh token", zap.Error(err), mzap.ObjRef("account_ref", *rt.AccountRef))
return nil, nil, merrors.Unauthorized("user not found")
}
accessToken, err := pr.imp.CreateAccessToken(&account)
if err != nil {
pr.logger.Warn("Failed to generate access token", zap.Error(err))
return nil, nil, err
}
return &account, &accessToken, nil
}

View File

@@ -0,0 +1,15 @@
package routers
import (
api "github.com/tech/sendico/pkg/api/http"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api/sresponse"
)
type APIRouter interface {
InstallHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc)
}
type ProtectedAPIRouter interface {
AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc)
}

View File

@@ -0,0 +1,68 @@
package ws
import (
"context"
"fmt"
"net/http"
"os"
"time"
"github.com/go-chi/chi/v5"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/server/interface/api/ws"
ac "github.com/tech/sendico/server/internal/api/config"
"go.uber.org/zap"
"golang.org/x/net/websocket"
)
type DispatcherImpl struct {
logger mlogger.Logger
handlers map[string]ws.HandlerFunc
timeout int
}
func (d *DispatcherImpl) InstallHandler(messageType string, handler ws.HandlerFunc) {
d.handlers[messageType] = handler
d.logger.Info("Handler installed", zap.String("message_type", messageType))
}
func (d *DispatcherImpl) dispatchMessage(ctx context.Context, conn *websocket.Conn) {
var msg ws.Message
err := websocket.JSON.Receive(conn, &msg)
if err != nil {
d.logger.Warn("Failed to read websocket message", zap.Error(err))
return
}
if handler, exists := d.handlers[msg.MessageType]; exists {
responseHandler := handler(ctx, msg)
responseHandler(msg.MessageType, conn)
} else {
d.logger.Warn("Unknown websocket message type", zap.String("message_type", msg.MessageType), zap.Any("message", &msg))
}
}
func (d *DispatcherImpl) handle(w http.ResponseWriter, r *http.Request) {
websocket.Handler(func(conn *websocket.Conn) {
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(d.timeout)*time.Second)
defer cancel()
d.dispatchMessage(ctx, conn)
}).ServeHTTP(w, r)
}
func NewDispatcher(logger mlogger.Logger, router chi.Router, config *ac.WebSocketConfig, apiEndpoint string) *DispatcherImpl {
d := &DispatcherImpl{
logger: logger.Named("websocket"),
handlers: make(map[string]ws.HandlerFunc),
timeout: config.Timeout,
}
d.logger.Debug("Installing websocket middleware...")
router.Group(func(r chi.Router) {
ep := fmt.Sprintf("%s%s", apiEndpoint, os.Getenv(config.EndpointEnv))
d.logger.Info("Installing websockets handler", zap.String("endpoint", ep))
r.Get(ep, d.handle)
})
return d
}

View File

@@ -0,0 +1,15 @@
package ws
import (
"github.com/go-chi/chi/v5"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/server/interface/api/ws"
)
type Router interface {
InstallHandler(messageType string, handler ws.HandlerFunc)
}
func NewRouter(logger mlogger.Logger, router chi.Router, config *ws.Config, apiEndpoint string) Router {
return NewDispatcher(logger, router, config, apiEndpoint)
}