fx build fix
This commit is contained in:
141
api/server/internal/api/api.go
Normal file
141
api/server/internal/api/api.go
Normal 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
|
||||
}
|
||||
66
api/server/internal/api/config/config.go
Executable file
66
api/server/internal/api/config/config.go
Executable 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
|
||||
138
api/server/internal/api/middleware.go
Normal file
138
api/server/internal/api/middleware.go
Normal 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
|
||||
}
|
||||
56
api/server/internal/api/routers/authorized/handler.go
Normal file
56
api/server/internal/api/routers/authorized/handler.go
Normal 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)
|
||||
}
|
||||
34
api/server/internal/api/routers/authorized/router.go
Normal file
34
api/server/internal/api/routers/authorized/router.go
Normal 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
|
||||
}
|
||||
50
api/server/internal/api/routers/dispatcher.go
Normal file
50
api/server/internal/api/routers/dispatcher.go
Normal 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
|
||||
}
|
||||
36
api/server/internal/api/routers/endpoint/endpoint.go
Normal file
36
api/server/internal/api/routers/endpoint/endpoint.go
Normal 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
|
||||
}
|
||||
50
api/server/internal/api/routers/endpoint/install.go
Normal file
50
api/server/internal/api/routers/endpoint/install.go
Normal 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)))
|
||||
}
|
||||
20
api/server/internal/api/routers/endpoint/token.go
Normal file
20
api/server/internal/api/routers/endpoint/token.go
Normal 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
|
||||
}
|
||||
40
api/server/internal/api/routers/metrics/handler.go
Normal file
40
api/server/internal/api/routers/metrics/handler.go
Normal 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
|
||||
}
|
||||
14
api/server/internal/api/routers/metrics/router.go
Normal file
14
api/server/internal/api/routers/metrics/router.go
Normal 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
|
||||
}
|
||||
63
api/server/internal/api/routers/public/login.go
Normal file
63
api/server/internal/api/routers/public/login.go
Normal 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)
|
||||
}
|
||||
29
api/server/internal/api/routers/public/refresh.go
Normal file
29
api/server/internal/api/routers/public/refresh.go
Normal 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)
|
||||
}
|
||||
77
api/server/internal/api/routers/public/respond.go
Normal file
77
api/server/internal/api/routers/public/respond.go
Normal 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)
|
||||
}
|
||||
28
api/server/internal/api/routers/public/rotate.go
Normal file
28
api/server/internal/api/routers/public/rotate.go
Normal 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)
|
||||
}
|
||||
46
api/server/internal/api/routers/public/router.go
Normal file
46
api/server/internal/api/routers/public/router.go
Normal 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
|
||||
}
|
||||
59
api/server/internal/api/routers/public/validate.go
Normal file
59
api/server/internal/api/routers/public/validate.go
Normal 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
|
||||
}
|
||||
15
api/server/internal/api/routers/router.go
Normal file
15
api/server/internal/api/routers/router.go
Normal 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)
|
||||
}
|
||||
68
api/server/internal/api/ws/dispimp.go
Normal file
68
api/server/internal/api/ws/dispimp.go
Normal 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
|
||||
}
|
||||
15
api/server/internal/api/ws/router.go
Normal file
15
api/server/internal/api/ws/router.go
Normal 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)
|
||||
}
|
||||
27
api/server/internal/appversion/version.go
Executable file
27
api/server/internal/appversion/version.go
Executable file
@@ -0,0 +1,27 @@
|
||||
package appversion
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/version"
|
||||
vf "github.com/tech/sendico/pkg/version/factory"
|
||||
)
|
||||
|
||||
// Build information. Populated at build-time.
|
||||
var (
|
||||
Version string
|
||||
Revision string
|
||||
Branch string
|
||||
BuildUser string
|
||||
BuildDate string
|
||||
)
|
||||
|
||||
func Create() version.Printer {
|
||||
vi := version.Info{
|
||||
Program: "MeetX Connectica Backend Service",
|
||||
Revision: Revision,
|
||||
Branch: Branch,
|
||||
BuildUser: BuildUser,
|
||||
BuildDate: BuildDate,
|
||||
Version: Version,
|
||||
}
|
||||
return vf.Create(&vi)
|
||||
}
|
||||
35
api/server/internal/mutil/flrstring/flrstring.go
Normal file
35
api/server/internal/mutil/flrstring/flrstring.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package flrstring
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Constants and variables for random string generation
|
||||
const (
|
||||
letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
letterIdxBits = 6
|
||||
letterIdxMask = 1<<letterIdxBits - 1
|
||||
letterIdxMax = 63 / letterIdxBits
|
||||
)
|
||||
|
||||
var src = rand.NewSource(time.Now().UnixNano())
|
||||
|
||||
// createRandString creates a random string with the size of n
|
||||
// See: http://stackoverflow.com/questions/22892120/how-to-generate-a-random-string-of-a-fixed-length-in-golang
|
||||
func CreateRandString(n int) string {
|
||||
b := make([]byte, n)
|
||||
for i, cache, remain := n-1, src.Int63(), letterIdxMax; i >= 0; {
|
||||
if remain == 0 {
|
||||
cache, remain = src.Int63(), letterIdxMax
|
||||
}
|
||||
if idx := int(cache & letterIdxMask); idx < len(letterBytes) {
|
||||
b[i] = letterBytes[idx]
|
||||
i--
|
||||
}
|
||||
cache >>= letterIdxBits
|
||||
remain--
|
||||
}
|
||||
|
||||
return string(b)
|
||||
}
|
||||
15
api/server/internal/mutil/imagewriter/imagewriter.go
Normal file
15
api/server/internal/mutil/imagewriter/imagewriter.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package imagewriter
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func WriteImage(w http.ResponseWriter, buffer *[]byte, fileType string) error {
|
||||
w.Header().Set("Content-Type", fileType)
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(*buffer)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
_, err := w.Write(*buffer)
|
||||
return err
|
||||
}
|
||||
39
api/server/internal/mutil/param/endpoint.go
Normal file
39
api/server/internal/mutil/param/endpoint.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package mutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func AddParam(base string, param string) string {
|
||||
base = strings.TrimSuffix(base, "/")
|
||||
return fmt.Sprintf("%s/{%s}", base, param)
|
||||
}
|
||||
|
||||
func AddAccountRef(base string) string {
|
||||
return AddParam(base, AccountRefName())
|
||||
}
|
||||
|
||||
func AddObjRef(base string) string {
|
||||
return AddParam(base, ObjRefName())
|
||||
}
|
||||
|
||||
func AddOrganizaztionRef(base string) string {
|
||||
return AddParam(base, OrganizationRefName())
|
||||
}
|
||||
|
||||
func AddStatusRef(base string) string {
|
||||
return AddParam(base, StatusRefName())
|
||||
}
|
||||
|
||||
func AddProjectRef(base string) string {
|
||||
return AddParam(base, ProjectRefName())
|
||||
}
|
||||
|
||||
func AddInvitationRef(base string) string {
|
||||
return AddParam(base, InvitationRefName())
|
||||
}
|
||||
|
||||
func AddToken(base string) string {
|
||||
return AddParam(base, TokenName())
|
||||
}
|
||||
135
api/server/internal/mutil/param/getter.go
Normal file
135
api/server/internal/mutil/param/getter.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package mutil
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func GetParam(r *http.Request, paramName string) string {
|
||||
return chi.URLParam(r, paramName)
|
||||
}
|
||||
|
||||
func GetID(r *http.Request) string {
|
||||
return GetParam(r, "id")
|
||||
}
|
||||
|
||||
func GetAccountID(r *http.Request) string {
|
||||
return GetParam(r, AccountRefName())
|
||||
}
|
||||
|
||||
func GetObjRef(r *http.Request) string {
|
||||
return GetParam(r, ObjRefName())
|
||||
}
|
||||
|
||||
func GetOrganizationID(r *http.Request) string {
|
||||
return GetParam(r, OrganizationRefName())
|
||||
}
|
||||
|
||||
func GetOrganizationRef(r *http.Request) (primitive.ObjectID, error) {
|
||||
return primitive.ObjectIDFromHex(GetOrganizationID(r))
|
||||
}
|
||||
|
||||
func GetStatusID(r *http.Request) string {
|
||||
return GetParam(r, OrganizationRefName())
|
||||
}
|
||||
|
||||
func GetStatusRef(r *http.Request) (primitive.ObjectID, error) {
|
||||
return primitive.ObjectIDFromHex(GetStatusID(r))
|
||||
}
|
||||
|
||||
func GetProjectID(r *http.Request) string {
|
||||
return GetParam(r, ProjectRefName())
|
||||
}
|
||||
|
||||
func GetProjectRef(r *http.Request) (primitive.ObjectID, error) {
|
||||
return primitive.ObjectIDFromHex(GetProjectID(r))
|
||||
}
|
||||
|
||||
func GetInvitationID(r *http.Request) string {
|
||||
return GetParam(r, InvitationRefName())
|
||||
}
|
||||
|
||||
func GetInvitationRef(r *http.Request) (primitive.ObjectID, error) {
|
||||
return primitive.ObjectIDFromHex(GetOrganizationID(r))
|
||||
}
|
||||
|
||||
func GetToken(r *http.Request) string {
|
||||
return GetParam(r, TokenName())
|
||||
}
|
||||
|
||||
// parseFunc is a function type that parses a string to a specific type
|
||||
type parseFunc[T any] func(string) (T, error)
|
||||
|
||||
// getOptionalParam is a generic function that handles optional query parameters
|
||||
func GetOptionalParam[T any](logger mlogger.Logger, r *http.Request, key string, parse parseFunc[T]) (*T, error) {
|
||||
vals := r.URL.Query()
|
||||
s := vals.Get(key)
|
||||
if s == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
val, err := parse(s)
|
||||
if err != nil {
|
||||
logger.Debug("Malformed query parameter", zap.Error(err), zap.String(key, s))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &val, nil
|
||||
}
|
||||
|
||||
// getOptionalInt64Param gets an optional int64 query parameter
|
||||
func GetOptionalInt64Param(logger mlogger.Logger, r *http.Request, key string) (*int64, error) {
|
||||
return GetOptionalParam(logger, r, key, func(s string) (int64, error) {
|
||||
return strconv.ParseInt(s, 10, 64)
|
||||
})
|
||||
}
|
||||
|
||||
func GetLimit(logger mlogger.Logger, r *http.Request) (*int64, error) {
|
||||
return GetOptionalInt64Param(logger, r, "limit")
|
||||
}
|
||||
|
||||
func GetOffset(logger mlogger.Logger, r *http.Request) (*int64, error) {
|
||||
return GetOptionalInt64Param(logger, r, "offset")
|
||||
}
|
||||
|
||||
func GetLimitAndOffset(logger mlogger.Logger, r *http.Request) (*int64, *int64, error) {
|
||||
limit, err := GetLimit(logger, r)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
offset, err := GetOffset(logger, r)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return limit, offset, nil
|
||||
}
|
||||
|
||||
func GetOptionalBoolParam(logger mlogger.Logger, r *http.Request, key string) (*bool, error) {
|
||||
return GetOptionalParam(logger, r, key, strconv.ParseBool)
|
||||
}
|
||||
|
||||
func GetCascadeParam(logger mlogger.Logger, r *http.Request) (*bool, error) {
|
||||
return GetOptionalBoolParam(logger, r, "cascade")
|
||||
}
|
||||
|
||||
func GetArchiveParam(logger mlogger.Logger, r *http.Request) (*bool, error) {
|
||||
return GetOptionalBoolParam(logger, r, "archived")
|
||||
}
|
||||
|
||||
func GetViewCursor(logger mlogger.Logger, r *http.Request) (*model.ViewCursor, error) {
|
||||
var res model.ViewCursor
|
||||
var err error
|
||||
if res.Limit, res.Offset, err = GetLimitAndOffset(logger, r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.IsArchived, err = GetArchiveParam(logger, r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &res, nil
|
||||
}
|
||||
142
api/server/internal/mutil/param/getter_test.go
Normal file
142
api/server/internal/mutil/param/getter_test.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package mutil
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestGetOptionalBoolParam(t *testing.T) {
|
||||
logger := mlogger.Logger(zap.NewNop())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
expected *bool
|
||||
hasError bool
|
||||
}{
|
||||
{
|
||||
name: "valid true",
|
||||
query: "?param=true",
|
||||
expected: boolPtr(true),
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
name: "valid false",
|
||||
query: "?param=false",
|
||||
expected: boolPtr(false),
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
name: "missing parameter",
|
||||
query: "?other=value",
|
||||
expected: nil,
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid value",
|
||||
query: "?param=invalid",
|
||||
expected: nil,
|
||||
hasError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", "http://example.com"+tt.query, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := GetOptionalBoolParam(logger, req, "param")
|
||||
|
||||
if tt.hasError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
if tt.expected == nil {
|
||||
assert.Nil(t, result)
|
||||
} else {
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, *tt.expected, *result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOptionalInt64Param(t *testing.T) {
|
||||
logger := mlogger.Logger(zap.NewNop())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
expected *int64
|
||||
hasError bool
|
||||
}{
|
||||
{
|
||||
name: "valid positive number",
|
||||
query: "?param=123",
|
||||
expected: int64Ptr(123),
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
name: "valid negative number",
|
||||
query: "?param=-456",
|
||||
expected: int64Ptr(-456),
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
name: "valid zero",
|
||||
query: "?param=0",
|
||||
expected: int64Ptr(0),
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
name: "missing parameter",
|
||||
query: "?other=value",
|
||||
expected: nil,
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid value",
|
||||
query: "?param=invalid",
|
||||
expected: nil,
|
||||
hasError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", "http://example.com"+tt.query, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := GetOptionalInt64Param(logger, req, "param")
|
||||
|
||||
if tt.hasError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
if tt.expected == nil {
|
||||
assert.Nil(t, result)
|
||||
} else {
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, *tt.expected, *result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for creating pointers to values
|
||||
func boolPtr(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
|
||||
func int64Ptr(i int64) *int64 {
|
||||
return &i
|
||||
}
|
||||
44
api/server/internal/mutil/param/helper.go
Normal file
44
api/server/internal/mutil/param/helper.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package mutil
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
mutilimp "github.com/tech/sendico/server/internal/mutil/param/internal"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
type ParamHelper interface {
|
||||
Name() string
|
||||
RefName() string
|
||||
GetID(r *http.Request) string
|
||||
GetRef(r *http.Request) (primitive.ObjectID, error)
|
||||
AddRef(base string) string
|
||||
}
|
||||
|
||||
func CreatePH(resource string) ParamHelper {
|
||||
return mutilimp.CreateImp(resource)
|
||||
}
|
||||
|
||||
type DependentParamHelper struct {
|
||||
p ParamHelper
|
||||
c ParamHelper
|
||||
}
|
||||
|
||||
func (ph *DependentParamHelper) Parent() ParamHelper {
|
||||
return ph.p
|
||||
}
|
||||
|
||||
func (ph *DependentParamHelper) Child() ParamHelper {
|
||||
return ph.c
|
||||
}
|
||||
|
||||
func (ph *DependentParamHelper) AddRef(base string) string {
|
||||
return ph.Parent().AddRef(ph.Child().AddRef(base))
|
||||
}
|
||||
|
||||
func CreateDPH(pRes, cRes string) *DependentParamHelper {
|
||||
return &DependentParamHelper{
|
||||
p: CreatePH(pRes),
|
||||
c: CreatePH(cRes),
|
||||
}
|
||||
}
|
||||
51
api/server/internal/mutil/param/internal/helper.go
Normal file
51
api/server/internal/mutil/param/internal/helper.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package mutilimp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
func addParam(base string, param string) string {
|
||||
base = strings.TrimSuffix(base, "/")
|
||||
return fmt.Sprintf("%s/{%s}", base, param)
|
||||
}
|
||||
|
||||
func _ref(param string) string {
|
||||
return param + "_ref"
|
||||
}
|
||||
|
||||
func getParam(r *http.Request, paramName string) string {
|
||||
return chi.URLParam(r, paramName)
|
||||
}
|
||||
|
||||
type ParamHelper struct {
|
||||
t string
|
||||
}
|
||||
|
||||
func (ph *ParamHelper) Name() string {
|
||||
return ph.t
|
||||
}
|
||||
|
||||
func (ph *ParamHelper) RefName() string {
|
||||
return _ref(ph.Name())
|
||||
}
|
||||
|
||||
func (ph *ParamHelper) GetID(r *http.Request) string {
|
||||
return getParam(r, ph.RefName())
|
||||
}
|
||||
|
||||
func (ph *ParamHelper) GetRef(r *http.Request) (primitive.ObjectID, error) {
|
||||
return primitive.ObjectIDFromHex(ph.GetID(r))
|
||||
}
|
||||
|
||||
func (ph *ParamHelper) AddRef(base string) string {
|
||||
return addParam(base, ph.RefName())
|
||||
}
|
||||
|
||||
func CreateImp(resource string) *ParamHelper {
|
||||
return &ParamHelper{t: resource}
|
||||
}
|
||||
15
api/server/internal/mutil/param/logger.go
Normal file
15
api/server/internal/mutil/param/logger.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package mutil
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func PLog(ph ParamHelper, r *http.Request) zap.Field {
|
||||
return zap.String(ph.Name(), ph.GetID(r))
|
||||
}
|
||||
|
||||
func PLogType(ph ParamHelper) zap.Field {
|
||||
return zap.String("object", ph.Name())
|
||||
}
|
||||
33
api/server/internal/mutil/param/names.go
Normal file
33
api/server/internal/mutil/param/names.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package mutil
|
||||
|
||||
func _ref(param string) string {
|
||||
return param + "_ref"
|
||||
}
|
||||
|
||||
func AccountRefName() string {
|
||||
return _ref("account")
|
||||
}
|
||||
|
||||
func ObjRefName() string {
|
||||
return _ref("obj")
|
||||
}
|
||||
|
||||
func OrganizationRefName() string {
|
||||
return _ref("org")
|
||||
}
|
||||
|
||||
func StatusRefName() string {
|
||||
return _ref("status")
|
||||
}
|
||||
|
||||
func ProjectRefName() string {
|
||||
return _ref("project")
|
||||
}
|
||||
|
||||
func InvitationRefName() string {
|
||||
return _ref("invitation")
|
||||
}
|
||||
|
||||
func TokenName() string {
|
||||
return "token"
|
||||
}
|
||||
11
api/server/internal/mutil/param/ref.go
Normal file
11
api/server/internal/mutil/param/ref.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package mutil
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
func GetAccountRef(r *http.Request) (primitive.ObjectID, error) {
|
||||
return primitive.ObjectIDFromHex(GetAccountID(r))
|
||||
}
|
||||
15
api/server/internal/mutil/time/go/gotime.go
Normal file
15
api/server/internal/mutil/time/go/gotime.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package mutil
|
||||
|
||||
import "time"
|
||||
|
||||
func ToDate(t time.Time) string {
|
||||
return t.Format(time.DateOnly)
|
||||
}
|
||||
|
||||
func ToTime(t time.Time) string {
|
||||
return t.Format(time.TimeOnly)
|
||||
}
|
||||
|
||||
func ToDateTime(t time.Time) string {
|
||||
return t.Format(time.DateTime)
|
||||
}
|
||||
130
api/server/internal/server/aapitemplate/config.go
Normal file
130
api/server/internal/server/aapitemplate/config.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package aapitemplate
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
)
|
||||
|
||||
type HandlerResolver func(sresponse.AccountHandlerFunc) sresponse.AccountHandlerFunc
|
||||
|
||||
type Config interface {
|
||||
WithNoCreate() Config
|
||||
WithCreateHandler(handler sresponse.AccountHandlerFunc) Config
|
||||
WithNoList() Config
|
||||
WithListHandler(handler sresponse.AccountHandlerFunc) Config
|
||||
WithNoGet() Config
|
||||
WithGetHandler(handler sresponse.AccountHandlerFunc) Config
|
||||
WithNoUpdate() Config
|
||||
WithUpdateHandler(handler sresponse.AccountHandlerFunc) Config
|
||||
WithNoDelete() Config
|
||||
WithDeleteHandler(handler sresponse.AccountHandlerFunc) Config
|
||||
WithReorderHandler(reorder ReorderConfig) Config
|
||||
}
|
||||
|
||||
type AAPIConfig struct {
|
||||
CreateResolver HandlerResolver
|
||||
ListResolver HandlerResolver
|
||||
GetResolver HandlerResolver
|
||||
UpdateResolver HandlerResolver
|
||||
DeleteResolver HandlerResolver
|
||||
ArchiveResolver HandlerResolver
|
||||
Reorder *ReorderConfig
|
||||
}
|
||||
|
||||
// WithNoCreate disables the create endpoint by replacing its resolver.
|
||||
func (cfg *AAPIConfig) WithNoCreate() *AAPIConfig {
|
||||
cfg.CreateResolver = disableResolver
|
||||
return cfg
|
||||
}
|
||||
|
||||
// WithCreateHandler overrides the create endpoint by replacing its resolver.
|
||||
func (cfg *AAPIConfig) WithCreateHandler(handler sresponse.AccountHandlerFunc) *AAPIConfig {
|
||||
cfg.CreateResolver = overrideResolver(handler)
|
||||
return cfg
|
||||
}
|
||||
|
||||
// WithNoList disables the list endpoint.
|
||||
func (cfg *AAPIConfig) WithNoList() *AAPIConfig {
|
||||
cfg.ListResolver = disableResolver
|
||||
return cfg
|
||||
}
|
||||
|
||||
// WithListHandler overrides the list endpoint.
|
||||
func (cfg *AAPIConfig) WithListHandler(handler sresponse.AccountHandlerFunc) *AAPIConfig {
|
||||
cfg.ListResolver = overrideResolver(handler)
|
||||
return cfg
|
||||
}
|
||||
|
||||
// WithNoGet disables the get endpoint.
|
||||
func (cfg *AAPIConfig) WithNoGet() *AAPIConfig {
|
||||
cfg.GetResolver = disableResolver
|
||||
return cfg
|
||||
}
|
||||
|
||||
// WithGetHandler overrides the get endpoint.
|
||||
func (cfg *AAPIConfig) WithGetHandler(handler sresponse.AccountHandlerFunc) *AAPIConfig {
|
||||
cfg.GetResolver = overrideResolver(handler)
|
||||
return cfg
|
||||
}
|
||||
|
||||
// WithNoUpdate disables the update endpoint.
|
||||
func (cfg *AAPIConfig) WithNoUpdate() *AAPIConfig {
|
||||
cfg.UpdateResolver = disableResolver
|
||||
return cfg
|
||||
}
|
||||
|
||||
// WithUpdateHandler overrides the update endpoint.
|
||||
func (cfg *AAPIConfig) WithUpdateHandler(handler sresponse.AccountHandlerFunc) *AAPIConfig {
|
||||
cfg.UpdateResolver = overrideResolver(handler)
|
||||
return cfg
|
||||
}
|
||||
|
||||
// WithNoDelete disables the delete endpoint.
|
||||
func (cfg *AAPIConfig) WithNoDelete() *AAPIConfig {
|
||||
cfg.DeleteResolver = disableResolver
|
||||
return cfg
|
||||
}
|
||||
|
||||
// WithDeleteHandler overrides the delete endpoint.
|
||||
func (cfg *AAPIConfig) WithDeleteHandler(handler sresponse.AccountHandlerFunc) *AAPIConfig {
|
||||
cfg.DeleteResolver = overrideResolver(handler)
|
||||
return cfg
|
||||
}
|
||||
|
||||
func (cfg *AAPIConfig) WithNoArchive() *AAPIConfig {
|
||||
cfg.ArchiveResolver = disableResolver
|
||||
return cfg
|
||||
}
|
||||
|
||||
func (cfg *AAPIConfig) WithArchiveHandler(handler sresponse.AccountHandlerFunc) *AAPIConfig {
|
||||
cfg.ArchiveResolver = overrideResolver(handler)
|
||||
return cfg
|
||||
}
|
||||
|
||||
// defaultResolver returns the default handler unchanged.
|
||||
func defaultResolver(defaultHandler sresponse.AccountHandlerFunc) sresponse.AccountHandlerFunc {
|
||||
return defaultHandler
|
||||
}
|
||||
|
||||
// disableResolver always returns nil, disabling the endpoint.
|
||||
func disableResolver(_ sresponse.AccountHandlerFunc) sresponse.AccountHandlerFunc {
|
||||
return nil
|
||||
}
|
||||
|
||||
// overrideResolver returns a resolver that always returns the given custom handler.
|
||||
func overrideResolver(custom sresponse.AccountHandlerFunc) HandlerResolver {
|
||||
return func(_ sresponse.AccountHandlerFunc) sresponse.AccountHandlerFunc {
|
||||
return custom
|
||||
}
|
||||
}
|
||||
|
||||
func NewConfig() *AAPIConfig {
|
||||
return &AAPIConfig{
|
||||
CreateResolver: defaultResolver,
|
||||
ListResolver: defaultResolver,
|
||||
GetResolver: defaultResolver,
|
||||
UpdateResolver: defaultResolver,
|
||||
DeleteResolver: defaultResolver,
|
||||
ArchiveResolver: defaultResolver,
|
||||
Reorder: nil,
|
||||
}
|
||||
}
|
||||
31
api/server/internal/server/aapitemplate/create.go
Normal file
31
api/server/internal/server/aapitemplate/create.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package aapitemplate
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"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 (a *AccountAPI[T]) create(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
|
||||
var object T
|
||||
if err := json.NewDecoder(r.Body).Decode(&object); err != nil {
|
||||
a.Logger.Warn("Failed to decode object when creating", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.BadPayload(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
if err := a.DB.Create(r.Context(), *account.GetID(), &object); err != nil {
|
||||
a.Logger.Warn("Error creating object", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.Auto(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
if err := a.nconfig.CreateNotification(&object, *account.GetID()); err != nil {
|
||||
a.Logger.Warn("Failed to send creation notification", zap.Error(err), mzap.StorableRef(account))
|
||||
}
|
||||
|
||||
return a.ObjectCreated(&object, accessToken)
|
||||
}
|
||||
21
api/server/internal/server/aapitemplate/db.go
Normal file
21
api/server/internal/server/aapitemplate/db.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package aapitemplate
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/pkg/db/repository/builder"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
type DB[T any] interface {
|
||||
Create(ctx context.Context, accountRef primitive.ObjectID, object *T) error
|
||||
Get(ctx context.Context, accountRef, objectRef primitive.ObjectID, result *T) error
|
||||
Update(ctx context.Context, accountRef primitive.ObjectID, object *T) error
|
||||
Patch(ctx context.Context, accountRef, objectRef primitive.ObjectID, patch builder.Patch) error
|
||||
Delete(ctx context.Context, accountRef, objectRef primitive.ObjectID) error
|
||||
List(ctx context.Context, accountRef, organizationRef primitive.ObjectID) ([]T, error)
|
||||
}
|
||||
|
||||
type ReorderDB interface {
|
||||
Reorder(ctx context.Context, accountRef, objectRef primitive.ObjectID, newIndex int, filter builder.Query) error
|
||||
}
|
||||
53
api/server/internal/server/aapitemplate/delete.go
Normal file
53
api/server/internal/server/aapitemplate/delete.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package aapitemplate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *AccountAPI[T]) deleteImp(ctx context.Context, account *model.Account, objectRef primitive.ObjectID) error {
|
||||
if err := a.DB.Delete(ctx, *account.GetID(), objectRef); err != nil {
|
||||
a.Logger.Warn("Error deleting object", zap.Error(err), mzap.StorableRef(account), mzap.ObjRef("object_ref", objectRef))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AccountAPI[T]) delete(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
|
||||
objectRef, err := a.Oph.GetRef(r)
|
||||
if err != nil {
|
||||
a.Logger.Warn("Failed to restore object reference", zap.Error(err), mutil.PLog(a.Oph, r))
|
||||
return response.BadReference(a.Logger, a.Name(), a.Oph.Name(), a.Oph.GetID(r), err)
|
||||
}
|
||||
|
||||
var objPtr *T
|
||||
if a.nconfig.NeedDeleteNotification {
|
||||
var object T
|
||||
if err := a.DB.Get(r.Context(), *account.GetID(), objectRef, &object); err != nil {
|
||||
a.Logger.Warn("Failed to fetch object for notification", zap.Error(err), mzap.StorableRef(account), mutil.PLog(a.Oph, r))
|
||||
} else {
|
||||
objPtr = &object
|
||||
}
|
||||
}
|
||||
|
||||
if err := a.deleteImp(r.Context(), account, objectRef); err != nil {
|
||||
a.Logger.Warn("Error deleting object", zap.Error(err), mzap.StorableRef(account), mutil.PLog(a.Oph, r))
|
||||
return response.Auto(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
if objPtr != nil {
|
||||
if err := a.nconfig.DeleteNotification(objPtr, *account.GetID()); err != nil {
|
||||
a.Logger.Warn("Failed to send deletion notification", zap.Error(err), mzap.StorableRef(account), mutil.PLog(a.Oph, r))
|
||||
}
|
||||
}
|
||||
|
||||
return a.Objects([]T{}, accessToken)
|
||||
}
|
||||
29
api/server/internal/server/aapitemplate/get.go
Normal file
29
api/server/internal/server/aapitemplate/get.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package aapitemplate
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *AccountAPI[T]) get(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
|
||||
ctx := r.Context()
|
||||
objectRef, err := a.Oph.GetRef(r)
|
||||
if err != nil {
|
||||
a.Logger.Warn("Failed to restore object reference", zap.Error(err), mutil.PLog(a.Oph, r))
|
||||
return response.BadReference(a.Logger, a.Name(), a.Oph.Name(), a.Oph.GetID(r), err)
|
||||
}
|
||||
|
||||
var object T
|
||||
if err := a.DB.Get(ctx, *account.GetID(), objectRef, &object); err != nil {
|
||||
a.Logger.Warn("Failed to fetch object", zap.Error(err), mzap.StorableRef(account), mutil.PLog(a.Oph, r))
|
||||
return response.Auto(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
return a.Object(&object, accessToken)
|
||||
}
|
||||
33
api/server/internal/server/aapitemplate/list.go
Normal file
33
api/server/internal/server/aapitemplate/list.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package aapitemplate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/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/mutil/mzap"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *AccountAPI[T]) list(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
|
||||
ctx := r.Context()
|
||||
organizationRef, err := a.Orgph.GetRef(r)
|
||||
if err != nil {
|
||||
a.Logger.Warn("Failed to restore organization reference", zap.Error(err), mutil.PLog(a.Orgph, r))
|
||||
return response.BadReference(a.Logger, a.Name(), a.Orgph.Name(), a.Orgph.GetID(r), err)
|
||||
}
|
||||
objects, err := a.DB.List(ctx, *account.GetID(), organizationRef)
|
||||
if err != nil {
|
||||
if !errors.Is(err, merrors.ErrNoData) {
|
||||
a.Logger.Warn("Failed to list objects", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.Auto(a.Logger, a.Name(), err)
|
||||
} else {
|
||||
a.Logger.Debug("No objects available", zap.Error(err), mzap.StorableRef(account))
|
||||
}
|
||||
}
|
||||
return a.Objects(objects, accessToken)
|
||||
}
|
||||
88
api/server/internal/server/aapitemplate/nconfig.go
Normal file
88
api/server/internal/server/aapitemplate/nconfig.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package aapitemplate
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/messaging"
|
||||
notifications "github.com/tech/sendico/pkg/messaging/envelope"
|
||||
model "github.com/tech/sendico/pkg/model/notification"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
// NotificationHandler is a function that processes an object of type T and returns an error.
|
||||
type NotificationHandler[T any] func(template T, actorAccountRef primitive.ObjectID) error
|
||||
|
||||
// sinkNotification is the default no-op strategy.
|
||||
func sinkNotification[T any](_ T, _ primitive.ObjectID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// NotificationConfig manages notifications for Create, Update, and Delete operations.
|
||||
type NotificationConfig[T any] struct {
|
||||
producer messaging.Producer
|
||||
// The factory now receives a NotificationAction so it knows which event is being processed.
|
||||
factory func(template T, actorAccountRef primitive.ObjectID, t model.NotificationAction) notifications.Envelope
|
||||
CreateNotification NotificationHandler[T]
|
||||
UpdateNotification NotificationHandler[T]
|
||||
NeedArchiveNotification bool
|
||||
ArchiveNotification NotificationHandler[T]
|
||||
NeedDeleteNotification bool
|
||||
DeleteNotification NotificationHandler[T]
|
||||
}
|
||||
|
||||
// NewNotificationConfig creates a new NotificationConfig with default (no-op) strategies.
|
||||
func NewNotificationConfig[T any](producer messaging.Producer) *NotificationConfig[T] {
|
||||
return &NotificationConfig[T]{
|
||||
producer: producer,
|
||||
factory: nil, // no factory by default
|
||||
CreateNotification: sinkNotification[T],
|
||||
UpdateNotification: sinkNotification[T],
|
||||
ArchiveNotification: sinkNotification[T],
|
||||
NeedArchiveNotification: false,
|
||||
DeleteNotification: sinkNotification[T],
|
||||
NeedDeleteNotification: false,
|
||||
}
|
||||
}
|
||||
|
||||
// WithNotifications sets the notification factory and switches all endpoints to the sending strategy.
|
||||
func (nc *NotificationConfig[T]) WithNotifications(factory func(template T, actorAccountRef primitive.ObjectID, typ model.NotificationAction) notifications.Envelope) *NotificationConfig[T] {
|
||||
nc.factory = factory
|
||||
// Build sending functions for each notification type.
|
||||
nc.CreateNotification = func(template T, actorAccountRef primitive.ObjectID) error {
|
||||
return nc.producer.SendMessage(factory(template, actorAccountRef, model.NACreated))
|
||||
}
|
||||
nc.UpdateNotification = func(template T, actorAccountRef primitive.ObjectID) error {
|
||||
return nc.producer.SendMessage(factory(template, actorAccountRef, model.NAUpdated))
|
||||
}
|
||||
nc.ArchiveNotification = func(template T, actorAccountRef primitive.ObjectID) error {
|
||||
return nc.producer.SendMessage(factory(template, actorAccountRef, model.NAArchived))
|
||||
}
|
||||
nc.NeedArchiveNotification = true
|
||||
nc.DeleteNotification = func(template T, actorAccountRef primitive.ObjectID) error {
|
||||
return nc.producer.SendMessage(factory(template, actorAccountRef, model.NADeleted))
|
||||
}
|
||||
nc.NeedDeleteNotification = true
|
||||
return nc
|
||||
}
|
||||
|
||||
// WithNoCreateNotification disables the create notification.
|
||||
func (nc *NotificationConfig[T]) WithNoCreateNotification() *NotificationConfig[T] {
|
||||
nc.CreateNotification = sinkNotification[T]
|
||||
return nc
|
||||
}
|
||||
|
||||
// WithNoUpdateNotification disables the update notification.
|
||||
func (nc *NotificationConfig[T]) WithNoUpdateNotification() *NotificationConfig[T] {
|
||||
nc.UpdateNotification = sinkNotification[T]
|
||||
return nc
|
||||
}
|
||||
|
||||
func (nc *NotificationConfig[T]) WithNoArchiveNotification() *NotificationConfig[T] {
|
||||
nc.ArchiveNotification = sinkNotification[T]
|
||||
return nc
|
||||
}
|
||||
|
||||
// WithNoDeleteNotification disables the delete notification.
|
||||
func (nc *NotificationConfig[T]) WithNoDeleteNotification() *NotificationConfig[T] {
|
||||
nc.DeleteNotification = sinkNotification[T]
|
||||
nc.NeedDeleteNotification = false
|
||||
return nc
|
||||
}
|
||||
33
api/server/internal/server/aapitemplate/rconfig.go
Normal file
33
api/server/internal/server/aapitemplate/rconfig.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package aapitemplate
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/db/repository"
|
||||
"github.com/tech/sendico/pkg/db/repository/builder"
|
||||
"github.com/tech/sendico/server/interface/api/srequest"
|
||||
)
|
||||
|
||||
type ReorderRequestProcessor func(r *http.Request) (*srequest.ReorderX, builder.Query, error)
|
||||
|
||||
type ReorderConfig struct {
|
||||
DB ReorderDB
|
||||
ReqProcessor ReorderRequestProcessor
|
||||
}
|
||||
|
||||
func (cfg *AAPIConfig) WithReorderHandler(reorder ReorderConfig) *AAPIConfig {
|
||||
cfg.Reorder = &reorder
|
||||
if cfg.Reorder.ReqProcessor == nil {
|
||||
cfg.Reorder.ReqProcessor = defaultRequestProcessor
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func defaultRequestProcessor(r *http.Request) (*srequest.ReorderX, builder.Query, error) {
|
||||
var req srequest.ReorderXDefault
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &req.ReorderX, repository.OrgFilter(req.ParentRef), nil
|
||||
}
|
||||
33
api/server/internal/server/aapitemplate/reorder.go
Normal file
33
api/server/internal/server/aapitemplate/reorder.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package aapitemplate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"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 (a *AccountAPI[T]) reorder(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
a.Logger.Debug("Processing reorder request...")
|
||||
req, filter, err := a.config.Reorder.ReqProcessor(r)
|
||||
if err != nil {
|
||||
a.Logger.Warn("Failed to decode tasks reorder request", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.BadPayload(a.Logger, a.Name(), err)
|
||||
}
|
||||
a.Logger.Debug("Moving objects", mzap.ObjRef("object_ref", req.ObjectRef), zap.Int("new_index", req.To))
|
||||
|
||||
if _, err := a.a.DBFactory().TransactionFactory().CreateTransaction().Execute(r.Context(), func(ctx context.Context) (any, error) {
|
||||
// reorder is not atomic, so wrappping into transaction
|
||||
return nil, a.config.Reorder.DB.Reorder(ctx, account.ID, req.ObjectRef, req.To, filter)
|
||||
}); err != nil {
|
||||
a.Logger.Warn("Failed to reorder tasks", zap.Error(err), mzap.ObjRef("object_ref", req.ObjectRef), zap.Int("to", req.To))
|
||||
return response.Auto(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
a.Logger.Debug("Reorder request processing complete")
|
||||
return response.Success(a.Logger)
|
||||
}
|
||||
19
api/server/internal/server/aapitemplate/responses.go
Normal file
19
api/server/internal/server/aapitemplate/responses.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package aapitemplate
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
)
|
||||
|
||||
func (a *AccountAPI[T]) Objects(items []T, accessToken *sresponse.TokenData) http.HandlerFunc {
|
||||
return sresponse.ObjectsAuth(a.Logger, items, accessToken, a.Name())
|
||||
}
|
||||
|
||||
func (a *AccountAPI[T]) Object(item *T, accessToken *sresponse.TokenData) http.HandlerFunc {
|
||||
return sresponse.ObjectAuth(a.Logger, item, accessToken, a.Name())
|
||||
}
|
||||
|
||||
func (a *AccountAPI[T]) ObjectCreated(item *T, accessToken *sresponse.TokenData) http.HandlerFunc {
|
||||
return sresponse.ObjectAuthCreated(a.Logger, item, accessToken, a.Name())
|
||||
}
|
||||
181
api/server/internal/server/aapitemplate/service.go
Normal file
181
api/server/internal/server/aapitemplate/service.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package aapitemplate
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
api "github.com/tech/sendico/pkg/api/http"
|
||||
notifications "github.com/tech/sendico/pkg/messaging/envelope"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
model "github.com/tech/sendico/pkg/model/notification"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
eapi "github.com/tech/sendico/server/interface/api"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type AccountAPI[T any] struct {
|
||||
Logger mlogger.Logger
|
||||
DB DB[T]
|
||||
Oph mutil.ParamHelper // object param handler
|
||||
Orgph mutil.ParamHelper // organization param handler
|
||||
a eapi.API
|
||||
config *AAPIConfig
|
||||
nconfig *NotificationConfig[*T]
|
||||
resource mservice.Type
|
||||
}
|
||||
|
||||
func (a *AccountAPI[_]) Name() mservice.Type {
|
||||
return a.resource
|
||||
}
|
||||
|
||||
func (_ *AccountAPI[_]) Finish(_ context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AccountAPI[T]) Build() *AccountAPI[T] {
|
||||
createHandler := a.config.CreateResolver(a.create)
|
||||
if createHandler != nil {
|
||||
a.a.Register().AccountHandler(a.Name(), "/", api.Post, createHandler)
|
||||
}
|
||||
|
||||
listHandler := a.config.ListResolver(a.list)
|
||||
if listHandler != nil {
|
||||
a.a.Register().AccountHandler(a.Name(), a.Orgph.AddRef("/list"), api.Get, listHandler)
|
||||
}
|
||||
|
||||
getHandler := a.config.GetResolver(a.get)
|
||||
if getHandler != nil {
|
||||
a.a.Register().AccountHandler(a.Name(), a.Oph.AddRef("/"), api.Get, getHandler)
|
||||
}
|
||||
|
||||
updateHandler := a.config.UpdateResolver(a.update)
|
||||
if updateHandler != nil {
|
||||
a.a.Register().AccountHandler(a.Name(), "/", api.Put, updateHandler)
|
||||
}
|
||||
|
||||
deleteHandler := a.config.DeleteResolver(a.delete)
|
||||
if deleteHandler != nil {
|
||||
a.a.Register().AccountHandler(a.Name(), a.Oph.AddRef("/"), api.Delete, deleteHandler)
|
||||
}
|
||||
|
||||
if a.config.Reorder != nil {
|
||||
a.a.Register().AccountHandler(a.Name(), "/reorder", api.Post, a.reorder)
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *AccountAPI[T]) WithNotifications(factory func(template *T, actorAccountRef primitive.ObjectID, t model.NotificationAction) notifications.Envelope) *AccountAPI[T] {
|
||||
a.nconfig.WithNotifications(factory)
|
||||
a.Logger.Info("Notificatons handler installed")
|
||||
return a
|
||||
}
|
||||
|
||||
// WithNoCreateNotification disables the create notification.
|
||||
func (a *AccountAPI[T]) WithNoCreateNotification() *AccountAPI[T] {
|
||||
a.nconfig.WithNoCreateNotification()
|
||||
a.Logger.Info("Object creation notificaton disabled")
|
||||
return a
|
||||
}
|
||||
|
||||
// WithNoUpdateNotification disables the update notification.
|
||||
func (a *AccountAPI[T]) WithNoUpdateNotification() *AccountAPI[T] {
|
||||
a.nconfig.WithNoUpdateNotification()
|
||||
a.Logger.Info("Object update notificaton disabled")
|
||||
return a
|
||||
}
|
||||
|
||||
// WithNoDeleteNotification disables the delete notification.
|
||||
func (a *AccountAPI[T]) WithNoDeleteNotification() *AccountAPI[T] {
|
||||
a.nconfig.WithNoDeleteNotification()
|
||||
a.Logger.Info("Object deletion notificaton disabled")
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *AccountAPI[T]) WithNoCreate() *AccountAPI[T] {
|
||||
a.config.WithNoCreate()
|
||||
a.Logger.Info("Create handler disabled")
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *AccountAPI[T]) WithCreateHandler(handler sresponse.AccountHandlerFunc) *AccountAPI[T] {
|
||||
a.config.WithCreateHandler(handler)
|
||||
a.Logger.Info("Create handler overridden")
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *AccountAPI[T]) WithNoList() *AccountAPI[T] {
|
||||
a.config.WithNoList()
|
||||
a.Logger.Info("List handler disabled")
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *AccountAPI[T]) WithListHandler(handler sresponse.AccountHandlerFunc) *AccountAPI[T] {
|
||||
a.config.WithListHandler(handler)
|
||||
a.Logger.Info("List handler overridden")
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *AccountAPI[T]) WithNoGet() *AccountAPI[T] {
|
||||
a.config.WithNoGet()
|
||||
a.Logger.Info("Get handler disabled")
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *AccountAPI[T]) WithGetHandler(handler sresponse.AccountHandlerFunc) *AccountAPI[T] {
|
||||
a.config.WithGetHandler(handler)
|
||||
a.Logger.Info("Get handler overridden")
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *AccountAPI[T]) WithReorderHandler(reorder ReorderConfig) *AccountAPI[T] {
|
||||
a.config.WithReorderHandler(reorder)
|
||||
a.Logger.Info("Reorder handler installed")
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *AccountAPI[T]) WithNoUpdate() *AccountAPI[T] {
|
||||
a.config.WithNoUpdate()
|
||||
a.Logger.Info("Update handler disabled")
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *AccountAPI[T]) WithUpdateHandler(handler sresponse.AccountHandlerFunc) *AccountAPI[T] {
|
||||
a.config.WithUpdateHandler(handler)
|
||||
a.Logger.Info("Update handler overridden")
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *AccountAPI[T]) WithNoDelete() *AccountAPI[T] {
|
||||
a.config.WithNoDelete()
|
||||
a.Logger.Info("Delete handler disabled")
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *AccountAPI[T]) WithDeleteHandler(handler sresponse.AccountHandlerFunc) *AccountAPI[T] {
|
||||
a.config.WithDeleteHandler(handler)
|
||||
a.Logger.Info("Delete handler overriden")
|
||||
return a
|
||||
}
|
||||
|
||||
func CreateAPI[T any](a eapi.API, dbFactory func() (DB[T], error), resource mservice.Type) (*AccountAPI[T], error) {
|
||||
p := &AccountAPI[T]{
|
||||
Logger: a.Logger().Named(resource),
|
||||
Oph: mutil.CreatePH("obj"), // to avoid collision with object_ref
|
||||
Orgph: mutil.CreatePH("org"), // to avoid collision with organizaitons_ref
|
||||
a: a,
|
||||
config: NewConfig(),
|
||||
resource: resource,
|
||||
nconfig: NewNotificationConfig[*T](a.Register().Messaging().Producer()),
|
||||
}
|
||||
|
||||
var err error
|
||||
if p.DB, err = dbFactory(); err != nil {
|
||||
p.Logger.Error("Failed to create protected database", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
31
api/server/internal/server/aapitemplate/update.go
Normal file
31
api/server/internal/server/aapitemplate/update.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package aapitemplate
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"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 (a *AccountAPI[T]) update(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
|
||||
var object T
|
||||
if err := json.NewDecoder(r.Body).Decode(&object); err != nil {
|
||||
a.Logger.Warn("Failed to decode object when updating settings", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.BadPayload(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
if err := a.DB.Update(r.Context(), *account.GetID(), &object); err != nil {
|
||||
a.Logger.Warn("Error updating object", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.Auto(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
if err := a.nconfig.UpdateNotification(&object, *account.GetID()); err != nil {
|
||||
a.Logger.Warn("Failed to send creation notification", zap.Error(err))
|
||||
}
|
||||
|
||||
return a.Object(&object, accessToken)
|
||||
}
|
||||
103
api/server/internal/server/accountapiimp/account.go
Executable file
103
api/server/internal/server/accountapiimp/account.go
Executable file
@@ -0,0 +1,103 @@
|
||||
package accountapiimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/db/account"
|
||||
an "github.com/tech/sendico/pkg/messaging/notifications/account"
|
||||
"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 (a *AccountAPI) attemptDecodeAccount(r *http.Request) (*model.Account, error) {
|
||||
var u model.Account
|
||||
return &u, json.NewDecoder(r.Body).Decode(&u)
|
||||
}
|
||||
|
||||
func (a *AccountAPI) reportUnauthorized(hint string) http.HandlerFunc {
|
||||
return response.Unauthorized(a.logger, a.Name(), hint)
|
||||
}
|
||||
|
||||
func (a *AccountAPI) reportDuplicateEmail() http.HandlerFunc {
|
||||
return response.Forbidden(a.logger, a.Name(), "duplicate_email", "email has already been registered")
|
||||
}
|
||||
|
||||
func (a *AccountAPI) reportEmailMissing() http.HandlerFunc {
|
||||
return response.BadRequest(a.logger, a.Name(), "email_missing", "email is required")
|
||||
}
|
||||
|
||||
func (a *AccountAPI) sendPasswordResetEmail(account *model.Account, resetToken string) error {
|
||||
if err := a.producer.SendMessage(an.PasswordResetRequested(a.Name(), *account.GetID(), resetToken)); err != nil {
|
||||
a.logger.Warn("Failed to send password reset notification", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AccountAPI) getProfile(_ *http.Request, u *model.Account, token *sresponse.TokenData) http.HandlerFunc {
|
||||
return sresponse.Account(a.logger, u, token)
|
||||
}
|
||||
|
||||
func (a *AccountAPI) reportTokenNotFound() http.HandlerFunc {
|
||||
return response.NotFound(a.logger, a.Name(), "No account found associated with given verifcation token")
|
||||
}
|
||||
|
||||
func (a *AccountAPI) sendWelcomeEmail(account *model.Account) error {
|
||||
if err := a.producer.SendMessage(an.AccountCreated(a.Name(), *account.GetID())); err != nil {
|
||||
a.logger.Warn("Failed to send account creation notification", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AccountAPI) sendVerificationMail(r *http.Request, paramGetter func(ctx context.Context, db account.DB, user *model.Account) (*model.Account, error)) http.HandlerFunc {
|
||||
// Validate user input
|
||||
u, err := a.attemptDecodeAccount(r)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to decide profile update", zap.Error(err))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
// Get the account
|
||||
// accnt, err := a.db.GetByEmail(ctx, paramGetter(u))
|
||||
// if err != nil || accnt == nil {
|
||||
// a.logger.Warn("Failed to ger user from db with", zap.Error(err), mzap.StorableRef(u))
|
||||
// return response.Internal(a.logger, a.Name(), err)
|
||||
// }
|
||||
accnt, err := paramGetter(r.Context(), a.db, u)
|
||||
if err != nil || accnt == nil {
|
||||
a.logger.Warn("Failed to ger user from db with", zap.Error(err), mzap.StorableRef(u))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
if accnt.VerifyToken == "" {
|
||||
a.logger.Debug("Verification token is empty", zap.Error(err), mzap.StorableRef(u))
|
||||
return a.reportTokenNotFound()
|
||||
}
|
||||
|
||||
// Send welcome email
|
||||
if err = a.sendWelcomeEmail(accnt); err != nil {
|
||||
a.logger.Warn("Failed to send verification email",
|
||||
zap.Error(err), mzap.StorableRef(u), zap.String("email", accnt.Login))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
return response.Success(a.logger)
|
||||
}
|
||||
|
||||
func getID(ctx context.Context, db account.DB, u *model.Account) (*model.Account, error) {
|
||||
var res model.Account
|
||||
return &res, db.Get(ctx, *u.GetID(), &res)
|
||||
}
|
||||
|
||||
func getEmail(ctx context.Context, db account.DB, u *model.Account) (*model.Account, error) {
|
||||
return db.GetByEmail(ctx, u.Login)
|
||||
}
|
||||
|
||||
func (a *AccountAPI) reportNoEmailRegistered() http.HandlerFunc {
|
||||
return response.BadRequest(a.logger, a.Name(), "email_not_registered", "no account registered with this email")
|
||||
}
|
||||
123
api/server/internal/server/accountapiimp/delete.go
Normal file
123
api/server/internal/server/accountapiimp/delete.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package accountapiimp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/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"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *AccountAPI) deleteProfile(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
|
||||
ctx := r.Context()
|
||||
|
||||
// Get the current organization from the request context
|
||||
orgRef, err := a.getCurrentOrganizationRef(r)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to get current organization", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.BadRequest(a.logger, a.Name(), "invalid_organization_context", "Invalid organization context")
|
||||
}
|
||||
|
||||
// Get the organization
|
||||
var org model.Organization
|
||||
if err := a.odb.Get(ctx, *account.GetID(), orgRef, &org); err != nil {
|
||||
a.logger.Error("Failed to fetch organization", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
// Delete the account (this will check if it's the only member)
|
||||
if err := a.accService.DeleteAccount(ctx, &org, account.ID); err != nil {
|
||||
if errors.Is(err, merrors.ErrInvalidArg) {
|
||||
a.logger.Warn("Cannot delete account - validation failed", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.BadRequest(a.logger, a.Name(), "validation_failed", err.Error())
|
||||
}
|
||||
a.logger.Error("Failed to delete account", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
a.logger.Info("Account deleted successfully", mzap.StorableRef(account))
|
||||
return response.Success(a.logger)
|
||||
}
|
||||
|
||||
func (a *AccountAPI) deleteOrganization(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
|
||||
ctx := r.Context()
|
||||
|
||||
// Get the current organization from the request context
|
||||
orgRef, err := a.getCurrentOrganizationRef(r)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to get current organization", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.BadRequest(a.logger, a.Name(), "invalid_organization_context", "Invalid organization context")
|
||||
}
|
||||
|
||||
// Get the organization
|
||||
var org model.Organization
|
||||
if err := a.odb.Get(ctx, *account.GetID(), orgRef, &org); err != nil {
|
||||
a.logger.Error("Failed to fetch organization", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
// Delete the organization and all its data
|
||||
if err := a.accService.DeleteOrganization(ctx, &org); err != nil {
|
||||
a.logger.Error("Failed to delete organization", zap.Error(err), mzap.StorableRef(&org))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
a.logger.Info("Organization deleted successfully", mzap.StorableRef(&org))
|
||||
return response.Success(a.logger)
|
||||
}
|
||||
|
||||
func (a *AccountAPI) deleteAll(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
|
||||
ctx := r.Context()
|
||||
|
||||
// Get the current organization from the request context
|
||||
orgRef, err := a.getCurrentOrganizationRef(r)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to get current organization", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.BadRequest(a.logger, a.Name(), "invalid_organization_context", "Invalid organization context")
|
||||
}
|
||||
|
||||
// Get the organization
|
||||
var org model.Organization
|
||||
if err := a.odb.Get(ctx, *account.GetID(), orgRef, &org); err != nil {
|
||||
a.logger.Error("Failed to fetch organization", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
// Get organization permission reference
|
||||
var orgPolicy model.PolicyDescription
|
||||
if err := a.plcdb.GetBuiltInPolicy(ctx, mservice.Organizations, &orgPolicy); err != nil {
|
||||
a.logger.Error("Failed to fetch organization policy", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
// Check if user has permission to delete the organization
|
||||
canDelete, err := a.enf.Enforce(ctx, orgPolicy.ID, account.ID, orgRef, primitive.NilObjectID, model.ActionDelete)
|
||||
if err != nil {
|
||||
a.logger.Error("Failed to check delete permission", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
if !canDelete {
|
||||
a.logger.Warn("User does not have permission to delete organization", mzap.StorableRef(account), mzap.StorableRef(&org))
|
||||
return response.AccessDenied(a.logger, a.Name(), "Insufficient permissions to delete organization")
|
||||
}
|
||||
|
||||
// Delete everything (organization + account)
|
||||
if err := a.accService.DeleteAll(ctx, &org, account.ID); err != nil {
|
||||
a.logger.Error("Failed to delete all data", zap.Error(err), mzap.StorableRef(&org), mzap.StorableRef(account))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
a.logger.Info("All data deleted successfully", mzap.StorableRef(&org), mzap.StorableRef(account))
|
||||
return response.Success(a.logger)
|
||||
}
|
||||
|
||||
// Helper method to get current organization reference from request context
|
||||
func (a *AccountAPI) getCurrentOrganizationRef(r *http.Request) (primitive.ObjectID, error) {
|
||||
return a.oph.GetRef(r)
|
||||
}
|
||||
49
api/server/internal/server/accountapiimp/dzone.go
Normal file
49
api/server/internal/server/accountapiimp/dzone.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package accountapiimp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"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 (a *AccountAPI) dzone(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
|
||||
ctx := r.Context()
|
||||
orgs, err := a.odb.List(ctx, account.ID, nil)
|
||||
if err != nil {
|
||||
a.logger.Error("Failed to list owned organizations", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
orgsPBS := make([]model.PermissionBoundStorable, len(orgs))
|
||||
for i, org := range orgs {
|
||||
orgsPBS[i] = &org
|
||||
}
|
||||
res, err := a.enf.EnforceBatch(ctx, orgsPBS, account.ID, model.ActionDelete)
|
||||
if err != nil {
|
||||
a.logger.Error("Failed to enforce permissions", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
validOrgs := make([]model.Organization, 0, len(orgs))
|
||||
for _, org := range orgs {
|
||||
if res[org.ID] {
|
||||
validOrgs = append(validOrgs, org)
|
||||
a.logger.Debug("Organization can be deleted", mzap.StorableRef(&org), mzap.StorableRef(account))
|
||||
} else {
|
||||
a.logger.Debug("Organization does not have delete permission for account", mzap.StorableRef(&org), mzap.StorableRef(account))
|
||||
}
|
||||
}
|
||||
|
||||
return sresponse.DZone(
|
||||
a.logger,
|
||||
&model.DZone{
|
||||
CanDeleteAccount: true,
|
||||
CanDeleteCascade: len(validOrgs) > 0,
|
||||
Organizations: validOrgs,
|
||||
},
|
||||
token,
|
||||
)
|
||||
}
|
||||
45
api/server/internal/server/accountapiimp/email.go
Normal file
45
api/server/internal/server/accountapiimp/email.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package accountapiimp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *AccountAPI) verify(r *http.Request) http.HandlerFunc {
|
||||
// Validate user input
|
||||
token := mutil.GetToken(r)
|
||||
// Get user
|
||||
ctx := r.Context()
|
||||
user, err := a.db.GetByToken(ctx, token)
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
a.logger.Debug("Verification token not found", zap.Error(err))
|
||||
return a.reportTokenNotFound()
|
||||
}
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to fetch account", zap.Error(err))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
// Delete verification token to confirm account
|
||||
user.VerifyToken = ""
|
||||
if err = a.db.Update(ctx, user); err != nil {
|
||||
a.logger.Warn("Failed to save account while verifying account", zap.Error(err))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
// TODO: Send verification confirmation email
|
||||
return response.Success(a.logger)
|
||||
}
|
||||
|
||||
func (a *AccountAPI) resendVerificationMail(r *http.Request) http.HandlerFunc {
|
||||
return a.sendVerificationMail(r, getID)
|
||||
}
|
||||
|
||||
func (a *AccountAPI) resendVerification(r *http.Request) http.HandlerFunc {
|
||||
return a.sendVerificationMail(r, getEmail)
|
||||
}
|
||||
43
api/server/internal/server/accountapiimp/employees.go
Normal file
43
api/server/internal/server/accountapiimp/employees.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package accountapiimp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *AccountAPI) getEmployees(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
|
||||
orgRef, err := a.oph.GetRef(r)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to fetch organizaiton reference", zap.Error(err), zap.String(a.oph.Name(), a.oph.GetID(r)))
|
||||
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
|
||||
}
|
||||
ctx := r.Context()
|
||||
res, err := a.enf.Enforce(ctx, a.accountsPermissionRef, account.ID, orgRef, primitive.NilObjectID, model.ActionRead)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to check accounts access permissions", zap.Error(err), mzap.ObjRef("organization_ref", orgRef), mzap.StorableRef(account))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
if !res {
|
||||
a.logger.Debug("Access denied when reading organization employees", mzap.StorableRef(account))
|
||||
return response.AccessDenied(a.logger, a.Name(), "orgnizations employees read permission denied")
|
||||
}
|
||||
var org model.Organization
|
||||
if err := a.odb.Get(ctx, *account.GetID(), orgRef, &org); err != nil {
|
||||
a.logger.Warn("Failed to fetch organization", zap.Error(err), mzap.ObjRef("organization_ref", orgRef), mzap.StorableRef(account))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
emps, err := a.db.GetAccountsByRefs(ctx, orgRef, org.Members)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to fetch organization emplpyees", zap.Error(err), mzap.ObjRef("organization_ref", orgRef), mzap.StorableRef(account))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
return sresponse.Accounts(a.logger, emps, orgRef, token)
|
||||
}
|
||||
82
api/server/internal/server/accountapiimp/empupdate.go
Normal file
82
api/server/internal/server/accountapiimp/empupdate.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package accountapiimp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/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"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *AccountAPI) updateEmployee(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
|
||||
orgRef, err := a.getCurrentOrganizationRef(r)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to get current organization", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.BadRequest(a.logger, a.Name(), "invalid_organization_context", "Invalid organization context")
|
||||
}
|
||||
|
||||
// Validate user input
|
||||
var u model.AccountPublic
|
||||
if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
|
||||
a.logger.Warn("Failed to decide profile update", zap.Error(err))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
res, err := a.enf.Enforce(ctx, a.accountsPermissionRef, account.ID, orgRef, u.ID, model.ActionUpdate)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to check employee update permission", zap.Error(err), mzap.StorableRef(account), mzap.ObjRef("employee_ref", u.ID))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
if !res {
|
||||
a.logger.Debug("Permission deined for employee update", mzap.StorableRef(account), mzap.ObjRef("employee_ref", u.ID))
|
||||
return response.Auto(a.logger, a.Name(), merrors.AccessDenied(mservice.Accounts, string(model.ActionUpdate), u.ID))
|
||||
}
|
||||
|
||||
if u.Login == "" {
|
||||
a.logger.Debug("No email in request")
|
||||
return a.reportEmailMissing()
|
||||
}
|
||||
if u.Name == "" {
|
||||
a.logger.Debug("No name in request")
|
||||
return response.BadRequest(a.logger, a.Name(), "name_missing", "name is required")
|
||||
}
|
||||
|
||||
var acc model.Account
|
||||
if err := a.db.Get(ctx, u.ID, &acc); err != nil {
|
||||
a.logger.Warn("Failed to fetch employee account", zap.Error(err), mzap.ObjRef("employee_ref", u.ID))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
if acc.Login != u.Login {
|
||||
// Change email address
|
||||
if err := a.accService.UpdateLogin(ctx, &acc, u.Login); err != nil {
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
a.logger.Debug("Duplicate login, denying change...", zap.Error(err), mzap.ObjRef("employee_ref", u.ID))
|
||||
return a.reportDuplicateEmail()
|
||||
}
|
||||
a.logger.Warn("Error while updating login", zap.Error(err), mzap.ObjRef("employee_ref", u.ID))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
// Send verification email
|
||||
if err = a.sendWelcomeEmail(&acc); err != nil {
|
||||
a.logger.Warn("Failed to send verification email", zap.Error(err), mzap.StorableRef(&acc))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
} else {
|
||||
// Save the user
|
||||
acc.AccountPublic = u
|
||||
if err = a.db.Update(ctx, &acc); err != nil {
|
||||
a.logger.Warn("Failed to save account", zap.Error(err), mzap.StorableRef(&acc))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
return sresponse.Account(a.logger, &acc, token)
|
||||
}
|
||||
196
api/server/internal/server/accountapiimp/password.go
Normal file
196
api/server/internal/server/accountapiimp/password.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package accountapiimp
|
||||
|
||||
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/model"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
"github.com/tech/sendico/server/interface/api/srequest"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *AccountAPI) checkPassword(_ *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
|
||||
return sresponse.Account(a.logger, account, accessToken)
|
||||
}
|
||||
|
||||
func (a *AccountAPI) changePassword(r *http.Request, user *model.Account, token *sresponse.TokenData) http.HandlerFunc {
|
||||
// TODO: add rate check
|
||||
var pcr srequest.ChangePassword
|
||||
if err := json.NewDecoder(r.Body).Decode(&pcr); err != nil {
|
||||
a.logger.Warn("Failed to decode password change request", zap.Error(err), mzap.StorableRef(user))
|
||||
return response.BadPayload(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
if err := a.accService.ValidatePassword(pcr.New, &pcr.Old); err != nil {
|
||||
a.logger.Debug("Password validation failed", zap.Error(err), mzap.StorableRef(user))
|
||||
return sresponse.BadRPassword(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
if !user.MatchPassword(pcr.Old) {
|
||||
a.logger.Info("Old password does not match", mzap.StorableRef(user))
|
||||
return a.reportUnauthorized("old password does not match")
|
||||
}
|
||||
user.Password = pcr.New
|
||||
if err := user.HashPassword(); err != nil {
|
||||
a.logger.Warn("Failed to hash new password", zap.Error(err), mzap.StorableRef(user))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
if err := a.db.Update(ctx, user); err != nil {
|
||||
a.logger.Warn("Failed to save account", zap.Error(err), mzap.StorableRef(user))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
if err := a.rtdb.RevokeAll(ctx, *user.GetID(), pcr.DeviceID); err != nil {
|
||||
a.logger.Warn("Failed to revoke refresh tokens",
|
||||
zap.Error(err), mzap.StorableRef(user), zap.String("device_id", pcr.DeviceID))
|
||||
}
|
||||
|
||||
return sresponse.Account(a.logger, user, token)
|
||||
}
|
||||
|
||||
func (a *AccountAPI) forgotPassword(r *http.Request) http.HandlerFunc {
|
||||
var req srequest.ForgotPassword
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
a.logger.Warn("Failed to decode password change request", zap.Error(err))
|
||||
return response.BadPayload(a.logger, a.Name(), err)
|
||||
}
|
||||
if req.Login == "" {
|
||||
a.logger.Debug("Email is missing in the request")
|
||||
return a.reportEmailMissing()
|
||||
}
|
||||
|
||||
// Always use the lower case email address
|
||||
req.Login = strings.ToLower(req.Login)
|
||||
|
||||
// Get user
|
||||
ctx := r.Context()
|
||||
user, err := a.db.GetByEmail(ctx, req.Login)
|
||||
if (errors.Is(err, merrors.ErrNoData)) || (user == nil) {
|
||||
a.logger.Debug("User not found while recovering password", zap.Error(err), zap.String("email", req.Login))
|
||||
return a.reportNoEmailRegistered()
|
||||
}
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to fetch user", zap.Error(err), zap.String("email", req.Login))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
// Generate reset password token
|
||||
if err := a.accService.ResetPassword(ctx, user); err != nil {
|
||||
a.logger.Warn("Failed to generate reset password token", zap.Error(err), mzap.StorableRef(user))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
// Send reset password email
|
||||
if err = a.sendPasswordResetEmail(user, user.ResetPasswordToken); err != nil {
|
||||
a.logger.Warn("Failed to send reset password email", zap.Error(err), mzap.StorableRef(user))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
a.logger.Info("Reset password email sent successfully", zap.String("email", user.Login))
|
||||
return response.Success(a.logger)
|
||||
}
|
||||
|
||||
func (a *AccountAPI) resetPassword(r *http.Request) http.HandlerFunc {
|
||||
ctx := r.Context()
|
||||
|
||||
// Get account reference and token from URL parameters using parameter helpers
|
||||
accountRef, err := a.aph.GetRef(r)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to get account reference", zap.Error(err), mutil.PLog(a.aph, r))
|
||||
return response.BadReference(a.logger, a.Name(), a.aph.Name(), a.aph.GetID(r), err)
|
||||
}
|
||||
|
||||
token := a.tph.GetID(r)
|
||||
if token == "" {
|
||||
a.logger.Warn("Missing token in reset password request")
|
||||
return response.BadRequest(a.logger, a.Name(), "missing_parameters", "token is required")
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
var user model.Account
|
||||
err = a.db.Get(ctx, accountRef, &user)
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
a.logger.Info("User not found for password reset", zap.String("account_ref", accountRef.Hex()))
|
||||
return response.NotFound(a.logger, a.Name(), "User not found")
|
||||
}
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to get user for password reset", zap.Error(err), zap.String("account_ref", accountRef.Hex()))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
// Validate reset token
|
||||
if user.ResetPasswordToken == "" {
|
||||
a.logger.Debug("No reset token found for user", mzap.StorableRef(&user))
|
||||
return response.BadRequest(a.logger, a.Name(), "no_reset_token", "No password reset token found for this user")
|
||||
}
|
||||
|
||||
if user.ResetPasswordToken != token {
|
||||
a.logger.Debug("Reset token mismatch", mzap.StorableRef(&user))
|
||||
return response.BadRequest(a.logger, a.Name(), "invalid_token", "Invalid or expired reset token")
|
||||
}
|
||||
|
||||
// Parse new password from request body
|
||||
var req srequest.ResetPassword
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
a.logger.Warn("Failed to decode reset password request", zap.Error(err))
|
||||
return response.BadPayload(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
if req.Password == "" {
|
||||
a.logger.Debug("New password is empty")
|
||||
return response.BadRequest(a.logger, a.Name(), "empty_password", "New password cannot be empty")
|
||||
}
|
||||
|
||||
// Validate new password
|
||||
if err := a.accService.ValidatePassword(req.Password, nil); err != nil {
|
||||
a.logger.Debug("Password validation failed", zap.Error(err), mzap.StorableRef(&user))
|
||||
return sresponse.BadRPassword(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
// Execute password reset in transaction to ensure atomicity
|
||||
if _, err := a.tf.CreateTransaction().Execute(ctx, func(ctx context.Context) (any, error) {
|
||||
return a.resetPasswordTransactionBody(ctx, &user, req.Password)
|
||||
}); err != nil {
|
||||
a.logger.Warn("Failed to execute password reset transaction", zap.Error(err), mzap.StorableRef(&user))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
a.logger.Info("Password reset successful", mzap.StorableRef(&user))
|
||||
return response.Success(a.logger)
|
||||
}
|
||||
|
||||
// resetPasswordTransactionBody contains the transaction logic for password reset
|
||||
func (a *AccountAPI) resetPasswordTransactionBody(ctx context.Context, user *model.Account, newPassword string) (any, error) {
|
||||
// Update user with new password and clear reset token
|
||||
user.Password = newPassword
|
||||
user.ResetPasswordToken = "" // Clear the token after use
|
||||
|
||||
// Hash the new password
|
||||
if err := user.HashPassword(); err != nil {
|
||||
a.logger.Warn("Failed to hash new password", zap.Error(err), mzap.StorableRef(user))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Save the updated user
|
||||
if err := a.db.Update(ctx, user); err != nil {
|
||||
a.logger.Warn("Failed to save user with new password", zap.Error(err), mzap.StorableRef(user))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Revoke all refresh tokens for this user (force re-login)
|
||||
if err := a.rtdb.RevokeAll(ctx, user.ID, ""); err != nil {
|
||||
a.logger.Warn("Failed to revoke refresh tokens after password reset", zap.Error(err), mzap.StorableRef(user))
|
||||
// Don't fail the transaction if token revocation fails, but log it
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
361
api/server/internal/server/accountapiimp/password_test.go
Normal file
361
api/server/internal/server/accountapiimp/password_test.go
Normal file
@@ -0,0 +1,361 @@
|
||||
package accountapiimp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
// TestPasswordResetTokenGeneration tests the token generation logic
|
||||
func TestPasswordResetTokenGeneration(t *testing.T) {
|
||||
// Test that ResetPassword service method generates a token
|
||||
account := &model.Account{
|
||||
AccountPublic: model.AccountPublic{
|
||||
AccountBase: model.AccountBase{
|
||||
Describable: model.Describable{
|
||||
Name: "Test User",
|
||||
},
|
||||
},
|
||||
UserDataBase: model.UserDataBase{
|
||||
Login: "test@example.com",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Initially no reset token
|
||||
assert.Empty(t, account.ResetPasswordToken, "Account should not have reset token initially")
|
||||
|
||||
// Simulate what ResetPassword service method does
|
||||
account.ResetPasswordToken = "generated-token-123"
|
||||
assert.NotEmpty(t, account.ResetPasswordToken, "Reset token should be generated")
|
||||
assert.Equal(t, "generated-token-123", account.ResetPasswordToken, "Reset token should match generated value")
|
||||
}
|
||||
|
||||
// TestPasswordResetTokenValidation tests token validation logic
|
||||
func TestPasswordResetTokenValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
storedToken string
|
||||
providedToken string
|
||||
shouldBeValid bool
|
||||
}{
|
||||
{
|
||||
name: "ValidToken_ShouldMatch",
|
||||
storedToken: "valid-token-123",
|
||||
providedToken: "valid-token-123",
|
||||
shouldBeValid: true,
|
||||
},
|
||||
{
|
||||
name: "InvalidToken_ShouldNotMatch",
|
||||
storedToken: "valid-token-123",
|
||||
providedToken: "invalid-token-456",
|
||||
shouldBeValid: false,
|
||||
},
|
||||
{
|
||||
name: "EmptyStoredToken_ShouldBeInvalid",
|
||||
storedToken: "",
|
||||
providedToken: "any-token",
|
||||
shouldBeValid: false,
|
||||
},
|
||||
{
|
||||
name: "EmptyProvidedToken_ShouldBeInvalid",
|
||||
storedToken: "valid-token-123",
|
||||
providedToken: "",
|
||||
shouldBeValid: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
account := &model.Account{
|
||||
AccountPublic: model.AccountPublic{
|
||||
AccountBase: model.AccountBase{
|
||||
Describable: model.Describable{
|
||||
Name: "Test User",
|
||||
},
|
||||
},
|
||||
UserDataBase: model.UserDataBase{
|
||||
Login: "test@example.com",
|
||||
},
|
||||
},
|
||||
ResetPasswordToken: tt.storedToken,
|
||||
}
|
||||
|
||||
// Test token validation logic (what the resetPassword handler does)
|
||||
isValid := account.ResetPasswordToken != "" && account.ResetPasswordToken == tt.providedToken
|
||||
assert.Equal(t, tt.shouldBeValid, isValid, "Token validation should match expected result")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPasswordResetFlowLogic tests the logical flow without database dependencies
|
||||
func TestPasswordResetFlowLogic(t *testing.T) {
|
||||
t.Run("CompleteFlow", func(t *testing.T) {
|
||||
// Step 1: User requests password reset
|
||||
userEmail := "test@example.com"
|
||||
assert.NotEmpty(t, userEmail, "Email should not be empty")
|
||||
assert.Contains(t, userEmail, "@", "Email should contain @ symbol")
|
||||
|
||||
// Step 2: System generates reset token
|
||||
originalToken := ""
|
||||
resetToken := "generated-reset-token-123"
|
||||
assert.NotEmpty(t, resetToken, "Reset token should be generated")
|
||||
assert.NotEqual(t, originalToken, resetToken, "Reset token should be different from empty")
|
||||
|
||||
// Step 3: User clicks reset link with token
|
||||
userID := primitive.NewObjectID()
|
||||
assert.NotEqual(t, primitive.NilObjectID, userID, "User ID should be valid")
|
||||
|
||||
// Step 4: System validates token and updates password
|
||||
storedToken := resetToken
|
||||
providedToken := resetToken
|
||||
tokenValid := storedToken == providedToken
|
||||
assert.True(t, tokenValid, "Token should be valid")
|
||||
|
||||
// Step 5: Password gets updated and token cleared
|
||||
oldPassword := "old-password"
|
||||
newPassword := "new-password-123!"
|
||||
clearedToken := ""
|
||||
|
||||
assert.NotEqual(t, oldPassword, newPassword, "Password should be changed")
|
||||
assert.Empty(t, clearedToken, "Token should be cleared after use")
|
||||
assert.NotEqual(t, storedToken, clearedToken, "Token should be different after clearing")
|
||||
})
|
||||
|
||||
t.Run("TokenSecurity", func(t *testing.T) {
|
||||
// Test that tokens are single-use
|
||||
originalToken := "valid-token-123"
|
||||
usedToken := "" // After use, token should be cleared
|
||||
|
||||
assert.NotEmpty(t, originalToken, "Original token should exist")
|
||||
assert.Empty(t, usedToken, "Used token should be cleared")
|
||||
assert.NotEqual(t, originalToken, usedToken, "Token should be cleared after use")
|
||||
|
||||
// Test that different tokens are not equal
|
||||
token1 := "token-123"
|
||||
token2 := "token-456"
|
||||
assert.NotEqual(t, token1, token2, "Different tokens should not be equal")
|
||||
})
|
||||
}
|
||||
|
||||
// TestPasswordValidationLogic tests password complexity requirements
|
||||
func TestPasswordValidationLogic(t *testing.T) {
|
||||
t.Run("ValidPasswords", func(t *testing.T) {
|
||||
validPasswords := []string{
|
||||
"Password123!",
|
||||
"MySecurePass1@",
|
||||
"ComplexP@ssw0rd",
|
||||
}
|
||||
|
||||
for _, password := range validPasswords {
|
||||
t.Run(password, func(t *testing.T) {
|
||||
// Test minimum length
|
||||
assert.True(t, len(password) >= 8, "Password should be at least 8 characters")
|
||||
|
||||
// Test for at least one digit
|
||||
hasDigit := false
|
||||
for _, char := range password {
|
||||
if char >= '0' && char <= '9' {
|
||||
hasDigit = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, hasDigit, "Password should contain at least one digit")
|
||||
|
||||
// Test for at least one uppercase letter
|
||||
hasUpper := false
|
||||
for _, char := range password {
|
||||
if char >= 'A' && char <= 'Z' {
|
||||
hasUpper = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, hasUpper, "Password should contain at least one uppercase letter")
|
||||
|
||||
// Test for at least one lowercase letter
|
||||
hasLower := false
|
||||
for _, char := range password {
|
||||
if char >= 'a' && char <= 'z' {
|
||||
hasLower = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, hasLower, "Password should contain at least one lowercase letter")
|
||||
|
||||
// Test for at least one special character
|
||||
hasSpecial := false
|
||||
specialChars := "!@#$%^&*()_+-=[]{}|;:,.<>?"
|
||||
for _, char := range password {
|
||||
for _, special := range specialChars {
|
||||
if char == special {
|
||||
hasSpecial = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasSpecial {
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, hasSpecial, "Password should contain at least one special character")
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("InvalidPasswords", func(t *testing.T) {
|
||||
invalidPasswords := []string{
|
||||
"", // Empty
|
||||
"short", // Too short
|
||||
"nouppercase1!", // No uppercase
|
||||
"NOLOWERCASE1!", // No lowercase
|
||||
"NoNumbers!", // No numbers
|
||||
"NoSpecial1", // No special characters
|
||||
}
|
||||
|
||||
for _, password := range invalidPasswords {
|
||||
t.Run(password, func(t *testing.T) {
|
||||
// Test that invalid passwords fail at least one requirement
|
||||
isValid := true
|
||||
|
||||
// Check length
|
||||
if len(password) < 8 {
|
||||
isValid = false
|
||||
}
|
||||
|
||||
// Check for digit
|
||||
hasDigit := false
|
||||
for _, char := range password {
|
||||
if char >= '0' && char <= '9' {
|
||||
hasDigit = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasDigit {
|
||||
isValid = false
|
||||
}
|
||||
|
||||
// Check for uppercase
|
||||
hasUpper := false
|
||||
for _, char := range password {
|
||||
if char >= 'A' && char <= 'Z' {
|
||||
hasUpper = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasUpper {
|
||||
isValid = false
|
||||
}
|
||||
|
||||
// Check for lowercase
|
||||
hasLower := false
|
||||
for _, char := range password {
|
||||
if char >= 'a' && char <= 'z' {
|
||||
hasLower = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasLower {
|
||||
isValid = false
|
||||
}
|
||||
|
||||
// Check for special character
|
||||
hasSpecial := false
|
||||
specialChars := "!@#$%^&*()_+-=[]{}|;:,.<>?"
|
||||
for _, char := range password {
|
||||
for _, special := range specialChars {
|
||||
if char == special {
|
||||
hasSpecial = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasSpecial {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasSpecial {
|
||||
isValid = false
|
||||
}
|
||||
|
||||
assert.False(t, isValid, "Invalid password should fail validation")
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestEmailValidationLogic tests email format validation
|
||||
func TestEmailValidationLogic(t *testing.T) {
|
||||
t.Run("ValidEmails", func(t *testing.T) {
|
||||
validEmails := []string{
|
||||
"test@example.com",
|
||||
"user.name@domain.org",
|
||||
"user+tag@example.co.uk",
|
||||
"test123@domain.com",
|
||||
}
|
||||
|
||||
for _, email := range validEmails {
|
||||
t.Run(email, func(t *testing.T) {
|
||||
// Basic email validation logic
|
||||
hasAt := false
|
||||
hasDot := false
|
||||
atIndex := -1
|
||||
dotIndex := -1
|
||||
|
||||
for i, char := range email {
|
||||
if char == '@' {
|
||||
hasAt = true
|
||||
atIndex = i
|
||||
}
|
||||
if char == '.' {
|
||||
hasDot = true
|
||||
dotIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, hasAt, "Valid email should contain @")
|
||||
assert.True(t, hasDot, "Valid email should contain .")
|
||||
assert.True(t, atIndex > 0, "Valid email should have @ not at start")
|
||||
assert.True(t, dotIndex > atIndex, "Valid email should have . after @")
|
||||
assert.True(t, len(email) > atIndex+1, "Valid email should have domain after @")
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("InvalidEmails", func(t *testing.T) {
|
||||
invalidEmails := []string{
|
||||
"", // Empty
|
||||
"noat.com", // No @
|
||||
"test@nodot", // No .
|
||||
"@nodomain.com", // No local part
|
||||
"test@.com", // No domain
|
||||
"test.com@", // No domain after @
|
||||
}
|
||||
|
||||
for _, email := range invalidEmails {
|
||||
t.Run(email, func(t *testing.T) {
|
||||
// Basic email validation logic
|
||||
hasAt := false
|
||||
hasDot := false
|
||||
atIndex := -1
|
||||
dotIndex := -1
|
||||
|
||||
for i, char := range email {
|
||||
if char == '@' {
|
||||
hasAt = true
|
||||
atIndex = i
|
||||
}
|
||||
if char == '.' {
|
||||
hasDot = true
|
||||
dotIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
// Invalid emails should fail at least one requirement
|
||||
domainAfterDot := len(email) > dotIndex+1
|
||||
domainAfterAt := len(email) > atIndex+1
|
||||
isValid := hasAt && hasDot && atIndex > 0 && dotIndex > atIndex && domainAfterAt && domainAfterDot && (dotIndex-atIndex) > 1
|
||||
assert.False(t, isValid, "Invalid email should fail validation")
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
124
api/server/internal/server/accountapiimp/service.go
Normal file
124
api/server/internal/server/accountapiimp/service.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package accountapiimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
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/organization"
|
||||
"github.com/tech/sendico/pkg/db/policy"
|
||||
"github.com/tech/sendico/pkg/db/refreshtokens"
|
||||
"github.com/tech/sendico/pkg/db/transaction"
|
||||
"github.com/tech/sendico/pkg/domainprovider"
|
||||
"github.com/tech/sendico/pkg/messaging"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"github.com/tech/sendico/server/interface/accountservice"
|
||||
eapi "github.com/tech/sendico/server/interface/api"
|
||||
"github.com/tech/sendico/server/interface/services/fileservice"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type AccountAPI struct {
|
||||
logger mlogger.Logger
|
||||
db account.DB
|
||||
odb organization.DB
|
||||
tf transaction.Factory
|
||||
rtdb refreshtokens.DB
|
||||
plcdb policy.DB
|
||||
domain domainprovider.DomainProvider
|
||||
avatars mservice.MicroService
|
||||
producer messaging.Producer
|
||||
pmanager auth.Manager
|
||||
enf auth.Enforcer
|
||||
oph mutil.ParamHelper
|
||||
aph mutil.ParamHelper
|
||||
tph mutil.ParamHelper
|
||||
accountsPermissionRef primitive.ObjectID
|
||||
accService accountservice.AccountService
|
||||
}
|
||||
|
||||
func (a *AccountAPI) Name() mservice.Type {
|
||||
return mservice.Accounts
|
||||
}
|
||||
|
||||
func (a *AccountAPI) Finish(ctx context.Context) error {
|
||||
return a.avatars.Finish(ctx)
|
||||
}
|
||||
|
||||
func CreateAPI(a eapi.API) (*AccountAPI, error) {
|
||||
p := new(AccountAPI)
|
||||
p.logger = a.Logger().Named(p.Name())
|
||||
var err error
|
||||
if p.db, err = a.DBFactory().NewAccountDB(); err != nil {
|
||||
p.logger.Error("Failed to create accounts database", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
if p.rtdb, err = a.DBFactory().NewRefreshTokensDB(); err != nil {
|
||||
p.logger.Error("Failed to create refresh tokens database", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
if p.odb, err = a.DBFactory().NewOrganizationDB(); err != nil {
|
||||
p.logger.Error("Failed to create organizations database", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
if p.plcdb, err = a.DBFactory().NewPoliciesDB(); err != nil {
|
||||
p.logger.Error("Failed to create policies database", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p.domain = a.DomainProvider()
|
||||
p.producer = a.Register().Messaging().Producer()
|
||||
p.tf = a.DBFactory().TransactionFactory()
|
||||
p.pmanager = a.Permissions().Manager()
|
||||
p.enf = a.Permissions().Enforcer()
|
||||
p.oph = mutil.CreatePH(mservice.Organizations)
|
||||
p.aph = mutil.CreatePH(mservice.Accounts)
|
||||
p.tph = mutil.CreatePH("token")
|
||||
|
||||
if p.accService, err = accountservice.NewAccountService(p.logger, a.DBFactory(), p.enf, p.pmanager.Role(), &a.Config().Mw.Password); err != nil {
|
||||
p.logger.Error("Failed to create account manager", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Account related api endpoints
|
||||
a.Register().Handler(mservice.Accounts, "/signup", api.Post, p.signup)
|
||||
|
||||
a.Register().AccountHandler(mservice.Accounts, "", api.Put, p.updateProfile)
|
||||
a.Register().AccountHandler(mservice.Accounts, "", api.Get, p.getProfile)
|
||||
|
||||
a.Register().AccountHandler(mservice.Accounts, p.oph.AddRef("/employee"), api.Put, p.updateEmployee)
|
||||
|
||||
a.Register().AccountHandler(mservice.Accounts, "/dzone", api.Get, p.dzone)
|
||||
a.Register().AccountHandler(mservice.Accounts, p.oph.AddRef("/profile"), api.Delete, p.deleteProfile)
|
||||
a.Register().AccountHandler(mservice.Accounts, p.oph.AddRef("/organization"), api.Delete, p.deleteOrganization)
|
||||
a.Register().AccountHandler(mservice.Accounts, p.oph.AddRef("/all"), api.Delete, p.deleteAll)
|
||||
|
||||
a.Register().AccountHandler(mservice.Accounts, p.oph.AddRef("/list"), api.Get, p.getEmployees)
|
||||
|
||||
a.Register().AccountHandler(mservice.Accounts, "/password", api.Post, p.checkPassword)
|
||||
a.Register().AccountHandler(mservice.Accounts, "/password", api.Patch, p.changePassword)
|
||||
a.Register().Handler(mservice.Accounts, "/password", api.Put, p.forgotPassword)
|
||||
a.Register().Handler(mservice.Accounts, p.tph.AddRef(p.aph.AddRef("/password/reset")), api.Post, p.resetPassword)
|
||||
|
||||
a.Register().Handler(mservice.Accounts, mutil.AddToken("/verify"), api.Get, p.verify)
|
||||
a.Register().Handler(mservice.Accounts, "/email", api.Post, p.resendVerificationMail)
|
||||
a.Register().Handler(mservice.Accounts, "/email", api.Put, p.resendVerification)
|
||||
|
||||
if p.avatars, err = fileservice.CreateAPI(a, p.Name()); err != nil {
|
||||
p.logger.Error("Failed to create image server", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
accountsPolicy, err := a.Permissions().GetPolicyDescription(context.Background(), mservice.Accounts)
|
||||
if err != nil {
|
||||
p.logger.Warn("Failed to fetch account permission policy description", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
p.accountsPermissionRef = accountsPolicy.ID
|
||||
|
||||
return p, nil
|
||||
}
|
||||
176
api/server/internal/server/accountapiimp/signup.go
Normal file
176
api/server/internal/server/accountapiimp/signup.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package accountapiimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"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/mservice"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
"github.com/tech/sendico/server/interface/api/srequest"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *AccountAPI) createAnonymousAccount(ctx context.Context, org *model.Organization, sr *srequest.Signup) error {
|
||||
anonymousUser := &model.Account{
|
||||
AccountPublic: model.AccountPublic{
|
||||
AccountBase: model.AccountBase{
|
||||
Describable: sr.AnonymousUser,
|
||||
},
|
||||
UserDataBase: sr.Account.UserDataBase,
|
||||
},
|
||||
}
|
||||
r, err := a.pmanager.Role().Create(ctx, org.ID, &sr.AnonymousRole)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to create anonymous role", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
if err := a.accService.CreateAccount(ctx, org, anonymousUser, r.ID); err != nil {
|
||||
a.logger.Warn("Failed to create account", zap.Error(err), zap.String("login", anonymousUser.Login))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AccountAPI) createOrg(ctx context.Context, sr *srequest.Signup, permissionRef primitive.ObjectID) (*model.Organization, error) {
|
||||
if _, err := time.LoadLocation(sr.OrganizationTimeZone); err != nil {
|
||||
return nil, merrors.DataConflict(fmt.Sprintf("invalid time zone '%s' provided, error %s", sr.OrganizationTimeZone, err.Error()))
|
||||
}
|
||||
|
||||
org := &model.Organization{
|
||||
OrganizationBase: model.OrganizationBase{
|
||||
PermissionBound: model.PermissionBound{
|
||||
PermissionRef: permissionRef,
|
||||
},
|
||||
Describable: model.Describable{
|
||||
Name: sr.OrganizationName,
|
||||
},
|
||||
TimeZone: sr.OrganizationTimeZone,
|
||||
},
|
||||
Members: []primitive.ObjectID{},
|
||||
}
|
||||
if err := a.odb.Unprotected().Create(ctx, org); err != nil {
|
||||
a.logger.Warn("Failed to create organization", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return org, nil
|
||||
}
|
||||
|
||||
// signupHandler handles user sign up
|
||||
func (a *AccountAPI) signup(r *http.Request) http.HandlerFunc {
|
||||
// Validate user input
|
||||
var sr srequest.Signup
|
||||
if err := json.NewDecoder(r.Body).Decode(&sr); err != nil {
|
||||
a.logger.Warn("Failed to decode signup request", zap.Error(err))
|
||||
return response.BadRequest(a.logger, a.Name(), "", err.Error())
|
||||
}
|
||||
|
||||
newAccount := sr.Account.ToAccount()
|
||||
if res := a.accService.ValidateAccount(newAccount); res != nil {
|
||||
a.logger.Warn("Invalid signup account received", zap.Error(res), zap.String("account", newAccount.Login))
|
||||
return response.BadPayload(a.logger, a.Name(), res)
|
||||
}
|
||||
|
||||
if err := a.executeSignupTransaction(r.Context(), &sr, newAccount); err != nil {
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
a.logger.Info("Failed to register account", zap.Error(err), zap.String("login", newAccount.Login))
|
||||
return response.DataConflict(a.logger, "user_already_registered", "User has already been registered")
|
||||
}
|
||||
a.logger.Info("Failed to create new user", zap.Error(err), zap.String("login", newAccount.Login))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
if err := a.sendWelcomeEmail(newAccount); err != nil {
|
||||
a.logger.Warn("Failed to send welcome email", zap.Error(err), mzap.StorableRef(newAccount))
|
||||
}
|
||||
|
||||
return sresponse.SignUp(a.logger, newAccount)
|
||||
}
|
||||
|
||||
func (a *AccountAPI) executeSignupTransaction(ctxt context.Context, sr *srequest.Signup, newAccount *model.Account) error {
|
||||
_, err := a.tf.CreateTransaction().Execute(ctxt, func(ctx context.Context) (any, error) {
|
||||
return a.signupTransactionBody(ctx, sr, newAccount)
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *AccountAPI) signupTransactionBody(ctx context.Context, sr *srequest.Signup, newAccount *model.Account) (any, error) {
|
||||
var orgPolicy model.PolicyDescription
|
||||
if err := a.plcdb.GetBuiltInPolicy(ctx, mservice.Organizations, &orgPolicy); err != nil {
|
||||
a.logger.Warn("Failed to fetch built-in organization policy", zap.Error(err), zap.String("login", newAccount.Login))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
org, err := a.createOrg(ctx, sr, orgPolicy.ID)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to create organization", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
roleDescription, err := a.pmanager.Role().Create(ctx, org.ID, &sr.OwnerRole)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to create owner role", zap.Error(err), zap.String("login", newAccount.Login))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := a.grantAllPermissions(ctx, org.ID, roleDescription.ID, newAccount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := a.accService.CreateAccount(ctx, org, newAccount, roleDescription.ID); err != nil {
|
||||
a.logger.Warn("Failed to create account", zap.Error(err), zap.String("login", newAccount.Login))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := a.createAnonymousAccount(ctx, org, sr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (a *AccountAPI) grantAllPermissions(ctx context.Context, organizationRef primitive.ObjectID, roleID primitive.ObjectID, newAccount *model.Account) error {
|
||||
om := a.pmanager.Permission()
|
||||
policies, err := a.plcdb.All(ctx, organizationRef)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to fetch permissions", zap.Error(err), mzap.StorableRef(newAccount))
|
||||
return err
|
||||
}
|
||||
|
||||
actions := []model.Action{model.ActionCreate, model.ActionRead, model.ActionUpdate, model.ActionDelete}
|
||||
for _, policy := range policies {
|
||||
for _, action := range actions {
|
||||
a.logger.Debug("Adding permission", mzap.StorableRef(&policy), zap.String("action", string(action)),
|
||||
mzap.ObjRef("role_ref", roleID), mzap.ObjRef("policy_ref", policy.ID), mzap.ObjRef("organization_ref", organizationRef))
|
||||
policy := model.RolePolicy{
|
||||
Policy: model.Policy{
|
||||
OrganizationRef: organizationRef,
|
||||
DescriptionRef: policy.ID,
|
||||
ObjectRef: nil, // all objects are affected
|
||||
Effect: model.ActionEffect{Action: action, Effect: model.EffectAllow},
|
||||
},
|
||||
RoleDescriptionRef: roleID,
|
||||
}
|
||||
if err := om.GrantToRole(ctx, &policy); err != nil {
|
||||
a.logger.Warn("Failed to grant permission", zap.Error(err), mzap.StorableRef(newAccount))
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := om.Save(); err != nil {
|
||||
a.logger.Warn("Failed to save permissions", zap.Error(err), mzap.StorableRef(newAccount))
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package accountapiimp_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/server/interface/api/srequest"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
// Helper function to create string pointers
|
||||
func stringPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
// TestSignupRequestSerialization tests JSON marshaling/unmarshaling with real MongoDB
|
||||
func TestSignupRequestSerialization(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
mongoContainer, err := mongodb.Run(ctx,
|
||||
"mongo:latest",
|
||||
mongodb.WithUsername("root"),
|
||||
mongodb.WithPassword("password"),
|
||||
testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")),
|
||||
)
|
||||
require.NoError(t, err, "failed to start MongoDB container")
|
||||
defer func() {
|
||||
err := mongoContainer.Terminate(ctx)
|
||||
require.NoError(t, err, "failed to terminate MongoDB container")
|
||||
}()
|
||||
|
||||
mongoURI, err := mongoContainer.ConnectionString(ctx)
|
||||
require.NoError(t, err, "failed to get MongoDB connection string")
|
||||
|
||||
clientOptions := options.Client().ApplyURI(mongoURI)
|
||||
client, err := mongo.Connect(ctx, clientOptions)
|
||||
require.NoError(t, err, "failed to connect to MongoDB")
|
||||
defer func() {
|
||||
err := client.Disconnect(ctx)
|
||||
require.NoError(t, err, "failed to disconnect from MongoDB")
|
||||
}()
|
||||
|
||||
db := client.Database("test_signup")
|
||||
collection := db.Collection("signup_requests")
|
||||
|
||||
t.Run("StoreAndRetrieveSignupRequest", func(t *testing.T) {
|
||||
signupRequest := srequest.Signup{
|
||||
Account: model.AccountData{
|
||||
LoginData: model.LoginData{
|
||||
UserDataBase: model.UserDataBase{
|
||||
Login: "test@example.com",
|
||||
},
|
||||
Password: "TestPassword123!",
|
||||
},
|
||||
Name: "Test User",
|
||||
},
|
||||
OrganizationName: "Test Organization",
|
||||
OrganizationTimeZone: "UTC",
|
||||
DefaultPriorityGroup: srequest.CreatePriorityGroup{
|
||||
Description: model.Describable{
|
||||
Name: "Default Priority Group",
|
||||
},
|
||||
Priorities: []model.Colorable{
|
||||
{
|
||||
Describable: model.Describable{Name: "High"},
|
||||
Color: stringPtr("#FF0000"),
|
||||
},
|
||||
{
|
||||
Describable: model.Describable{Name: "Medium"},
|
||||
Color: stringPtr("#FFFF00"),
|
||||
},
|
||||
{
|
||||
Describable: model.Describable{Name: "Low"},
|
||||
Color: stringPtr("#00FF00"),
|
||||
},
|
||||
},
|
||||
},
|
||||
AnonymousUser: model.Describable{
|
||||
Name: "Anonymous User",
|
||||
},
|
||||
OwnerRole: model.Describable{
|
||||
Name: "Owner",
|
||||
},
|
||||
AnonymousRole: model.Describable{
|
||||
Name: "Anonymous",
|
||||
},
|
||||
}
|
||||
|
||||
// Store in MongoDB
|
||||
result, err := collection.InsertOne(ctx, signupRequest)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result.InsertedID)
|
||||
|
||||
// Retrieve from MongoDB
|
||||
var retrieved srequest.Signup
|
||||
err = collection.FindOne(ctx, map[string]interface{}{"_id": result.InsertedID}).Decode(&retrieved)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify data integrity
|
||||
assert.Equal(t, signupRequest.Account.Login, retrieved.Account.Login)
|
||||
assert.Equal(t, signupRequest.Account.Name, retrieved.Account.Name)
|
||||
assert.Equal(t, signupRequest.OrganizationName, retrieved.OrganizationName)
|
||||
assert.Equal(t, signupRequest.OrganizationTimeZone, retrieved.OrganizationTimeZone)
|
||||
assert.Equal(t, len(signupRequest.DefaultPriorityGroup.Priorities), len(retrieved.DefaultPriorityGroup.Priorities))
|
||||
|
||||
// Verify priorities
|
||||
for i, priority := range signupRequest.DefaultPriorityGroup.Priorities {
|
||||
assert.Equal(t, priority.Name, retrieved.DefaultPriorityGroup.Priorities[i].Name)
|
||||
if priority.Color != nil && retrieved.DefaultPriorityGroup.Priorities[i].Color != nil {
|
||||
assert.Equal(t, *priority.Color, *retrieved.DefaultPriorityGroup.Priorities[i].Color)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestSignupHTTPSerialization tests HTTP request/response serialization
|
||||
func TestSignupHTTPSerialization(t *testing.T) {
|
||||
signupRequest := srequest.Signup{
|
||||
Account: model.AccountData{
|
||||
LoginData: model.LoginData{
|
||||
UserDataBase: model.UserDataBase{
|
||||
Login: "test@example.com",
|
||||
},
|
||||
Password: "TestPassword123!",
|
||||
},
|
||||
Name: "Test User",
|
||||
},
|
||||
OrganizationName: "Test Organization",
|
||||
OrganizationTimeZone: "UTC",
|
||||
DefaultPriorityGroup: srequest.CreatePriorityGroup{
|
||||
Description: model.Describable{
|
||||
Name: "Default Priority Group",
|
||||
},
|
||||
Priorities: []model.Colorable{
|
||||
{
|
||||
Describable: model.Describable{Name: "High"},
|
||||
Color: stringPtr("#FF0000"),
|
||||
},
|
||||
},
|
||||
},
|
||||
AnonymousUser: model.Describable{
|
||||
Name: "Anonymous User",
|
||||
},
|
||||
OwnerRole: model.Describable{
|
||||
Name: "Owner",
|
||||
},
|
||||
AnonymousRole: model.Describable{
|
||||
Name: "Anonymous",
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("ValidJSONRequest", func(t *testing.T) {
|
||||
// Serialize to JSON
|
||||
reqBody, err := json.Marshal(signupRequest)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create HTTP request
|
||||
req := httptest.NewRequest(http.MethodPost, "/signup", bytes.NewBuffer(reqBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Parse the request body
|
||||
var parsedRequest srequest.Signup
|
||||
err = json.NewDecoder(req.Body).Decode(&parsedRequest)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify parsing
|
||||
assert.Equal(t, signupRequest.Account.Login, parsedRequest.Account.Login)
|
||||
assert.Equal(t, signupRequest.Account.Name, parsedRequest.Account.Name)
|
||||
assert.Equal(t, signupRequest.OrganizationName, parsedRequest.OrganizationName)
|
||||
})
|
||||
|
||||
t.Run("UnicodeCharacters", func(t *testing.T) {
|
||||
unicodeRequest := signupRequest
|
||||
unicodeRequest.Account.Name = "Test 用户 Üser"
|
||||
unicodeRequest.OrganizationName = "测试 Organization"
|
||||
|
||||
// Serialize to JSON
|
||||
reqBody, err := json.Marshal(unicodeRequest)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Parse back
|
||||
var parsedRequest srequest.Signup
|
||||
err = json.Unmarshal(reqBody, &parsedRequest)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify unicode characters are preserved
|
||||
assert.Equal(t, "Test 用户 Üser", parsedRequest.Account.Name)
|
||||
assert.Equal(t, "测试 Organization", parsedRequest.OrganizationName)
|
||||
})
|
||||
|
||||
t.Run("InvalidJSONRequest", func(t *testing.T) {
|
||||
invalidJSON := `{"account": {"login": "test@example.com", "password": "invalid json structure`
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/signup", bytes.NewBufferString(invalidJSON))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
var parsedRequest srequest.Signup
|
||||
err := json.NewDecoder(req.Body).Decode(&parsedRequest)
|
||||
assert.Error(t, err, "Should fail to parse invalid JSON")
|
||||
})
|
||||
}
|
||||
|
||||
// TestAccountDataConversion tests conversion between request and model types
|
||||
func TestAccountDataConversion(t *testing.T) {
|
||||
accountData := model.AccountData{
|
||||
LoginData: model.LoginData{
|
||||
UserDataBase: model.UserDataBase{
|
||||
Login: "test@example.com",
|
||||
},
|
||||
Password: "TestPassword123!",
|
||||
},
|
||||
Name: "Test User",
|
||||
}
|
||||
|
||||
t.Run("ToAccount", func(t *testing.T) {
|
||||
account := accountData.ToAccount()
|
||||
|
||||
assert.Equal(t, accountData.Login, account.Login)
|
||||
assert.Equal(t, accountData.Password, account.Password)
|
||||
assert.Equal(t, accountData.Name, account.Name)
|
||||
|
||||
// Verify the account has proper structure
|
||||
assert.NotNil(t, account)
|
||||
assert.IsType(t, &model.Account{}, account)
|
||||
})
|
||||
|
||||
t.Run("PasswordHandling", func(t *testing.T) {
|
||||
account := accountData.ToAccount()
|
||||
|
||||
// Original password should be preserved before validation
|
||||
assert.Equal(t, "TestPassword123!", account.Password)
|
||||
|
||||
// Verify password is not empty
|
||||
assert.NotEmpty(t, account.Password)
|
||||
})
|
||||
}
|
||||
311
api/server/internal/server/accountapiimp/signup_test.go
Normal file
311
api/server/internal/server/accountapiimp/signup_test.go
Normal file
@@ -0,0 +1,311 @@
|
||||
package accountapiimp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/server/interface/api/srequest"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// Helper function to create string pointers
|
||||
func stringPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
// TestTimezoneValidation tests timezone validation logic separately
|
||||
func TestTimezoneValidation(t *testing.T) {
|
||||
t.Run("ValidTimezones", func(t *testing.T) {
|
||||
validTimezones := []string{
|
||||
"UTC",
|
||||
"America/New_York",
|
||||
"Europe/London",
|
||||
"Asia/Tokyo",
|
||||
"Australia/Sydney",
|
||||
}
|
||||
|
||||
for _, tz := range validTimezones {
|
||||
t.Run(tz, func(t *testing.T) {
|
||||
_, err := time.LoadLocation(tz)
|
||||
assert.NoError(t, err, "Timezone %s should be valid", tz)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("InvalidTimezones", func(t *testing.T) {
|
||||
invalidTimezones := []string{
|
||||
"Invalid/Timezone",
|
||||
"Not/A/Timezone",
|
||||
"BadTimezone",
|
||||
"America/NotACity",
|
||||
}
|
||||
|
||||
for _, tz := range invalidTimezones {
|
||||
t.Run(tz, func(t *testing.T) {
|
||||
_, err := time.LoadLocation(tz)
|
||||
assert.Error(t, err, "Timezone %s should be invalid", tz)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCreateValidSignupRequest tests the helper function for creating valid requests
|
||||
func TestCreateValidSignupRequest(t *testing.T) {
|
||||
request := srequest.Signup{
|
||||
Account: model.AccountData{
|
||||
LoginData: model.LoginData{
|
||||
UserDataBase: model.UserDataBase{
|
||||
Login: "test@example.com",
|
||||
},
|
||||
Password: "TestPassword123!",
|
||||
},
|
||||
Name: "Test User",
|
||||
},
|
||||
OrganizationName: "Test Organization",
|
||||
OrganizationTimeZone: "UTC",
|
||||
DefaultPriorityGroup: srequest.CreatePriorityGroup{
|
||||
Description: model.Describable{
|
||||
Name: "Default Priority Group",
|
||||
},
|
||||
Priorities: []model.Colorable{
|
||||
{
|
||||
Describable: model.Describable{Name: "High"},
|
||||
Color: stringPtr("#FF0000"),
|
||||
},
|
||||
{
|
||||
Describable: model.Describable{Name: "Medium"},
|
||||
Color: stringPtr("#FFFF00"),
|
||||
},
|
||||
{
|
||||
Describable: model.Describable{Name: "Low"},
|
||||
Color: stringPtr("#00FF00"),
|
||||
},
|
||||
},
|
||||
},
|
||||
AnonymousUser: model.Describable{
|
||||
Name: "Anonymous User",
|
||||
},
|
||||
OwnerRole: model.Describable{
|
||||
Name: "Owner",
|
||||
},
|
||||
AnonymousRole: model.Describable{
|
||||
Name: "Anonymous",
|
||||
},
|
||||
}
|
||||
|
||||
// Validate the request structure
|
||||
assert.Equal(t, "test@example.com", request.Account.Login)
|
||||
assert.Equal(t, "TestPassword123!", request.Account.Password)
|
||||
assert.Equal(t, "Test User", request.Account.Name)
|
||||
assert.Equal(t, "Test Organization", request.OrganizationName)
|
||||
assert.Equal(t, "UTC", request.OrganizationTimeZone)
|
||||
assert.Equal(t, "Default Priority Group", request.DefaultPriorityGroup.Description.Name)
|
||||
assert.Len(t, request.DefaultPriorityGroup.Priorities, 3)
|
||||
assert.Equal(t, "High", request.DefaultPriorityGroup.Priorities[0].Name)
|
||||
assert.Equal(t, "#FF0000", *request.DefaultPriorityGroup.Priorities[0].Color)
|
||||
}
|
||||
|
||||
// TestSignupRequestValidation tests various signup request validation scenarios
|
||||
func TestSignupRequestValidation(t *testing.T) {
|
||||
t.Run("ValidRequest", func(t *testing.T) {
|
||||
request := srequest.Signup{
|
||||
Account: model.AccountData{
|
||||
LoginData: model.LoginData{
|
||||
UserDataBase: model.UserDataBase{
|
||||
Login: "test@example.com",
|
||||
},
|
||||
Password: "TestPassword123!",
|
||||
},
|
||||
Name: "Test User",
|
||||
},
|
||||
OrganizationName: "Test Organization",
|
||||
OrganizationTimeZone: "UTC",
|
||||
}
|
||||
|
||||
// Basic validation - all required fields present
|
||||
assert.NotEmpty(t, request.Account.Login)
|
||||
assert.NotEmpty(t, request.Account.Password)
|
||||
assert.NotEmpty(t, request.Account.Name)
|
||||
assert.NotEmpty(t, request.OrganizationName)
|
||||
assert.NotEmpty(t, request.OrganizationTimeZone)
|
||||
})
|
||||
|
||||
t.Run("EmailFormats", func(t *testing.T) {
|
||||
validEmails := []string{
|
||||
"test@example.com",
|
||||
"user.name@example.com",
|
||||
"user+tag@example.org",
|
||||
"test123@domain.co.uk",
|
||||
}
|
||||
|
||||
for _, email := range validEmails {
|
||||
t.Run(email, func(t *testing.T) {
|
||||
request := srequest.Signup{
|
||||
Account: model.AccountData{
|
||||
LoginData: model.LoginData{
|
||||
UserDataBase: model.UserDataBase{
|
||||
Login: email,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
assert.Equal(t, email, request.Account.Login)
|
||||
assert.Contains(t, email, "@")
|
||||
assert.Contains(t, email, ".")
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PasswordComplexity", func(t *testing.T) {
|
||||
passwordTests := []struct {
|
||||
name string
|
||||
password string
|
||||
valid bool
|
||||
}{
|
||||
{"Strong", "TestPassword123!", true},
|
||||
{"WithNumbers", "MyPass123!", true},
|
||||
{"WithSymbols", "Complex@Pass1", true},
|
||||
{"TooShort", "Test1!", false},
|
||||
{"NoNumbers", "TestPassword!", false},
|
||||
{"NoSymbols", "TestPassword123", false},
|
||||
{"NoUppercase", "testpassword123!", false},
|
||||
{"NoLowercase", "TESTPASSWORD123!", false},
|
||||
}
|
||||
|
||||
for _, tt := range passwordTests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
request := srequest.Signup{
|
||||
Account: model.AccountData{
|
||||
LoginData: model.LoginData{
|
||||
Password: tt.password,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Basic structure validation
|
||||
assert.Equal(t, tt.password, request.Account.Password)
|
||||
|
||||
if tt.valid {
|
||||
assert.True(t, len(tt.password) >= 8, "Password should be at least 8 characters")
|
||||
} else {
|
||||
// For invalid passwords, at least one condition should fail
|
||||
hasDigit := false
|
||||
hasUpper := false
|
||||
hasLower := false
|
||||
hasSpecial := false
|
||||
|
||||
for _, char := range tt.password {
|
||||
switch {
|
||||
case char >= '0' && char <= '9':
|
||||
hasDigit = true
|
||||
case char >= 'A' && char <= 'Z':
|
||||
hasUpper = true
|
||||
case char >= 'a' && char <= 'z':
|
||||
hasLower = true
|
||||
case char >= '!' && char <= '/' || char >= ':' && char <= '@':
|
||||
hasSpecial = true
|
||||
}
|
||||
}
|
||||
|
||||
// At least one requirement should fail for invalid passwords
|
||||
if len(tt.password) >= 8 {
|
||||
assert.False(t, hasDigit && hasUpper && hasLower && hasSpecial,
|
||||
"Password %s should fail at least one requirement", tt.password)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestPriorityGroupCreation tests the priority group structure
|
||||
func TestPriorityGroupCreation(t *testing.T) {
|
||||
priorityGroup := srequest.CreatePriorityGroup{
|
||||
Description: model.Describable{
|
||||
Name: "Test Priority Group",
|
||||
},
|
||||
Priorities: []model.Colorable{
|
||||
{
|
||||
Describable: model.Describable{Name: "Critical"},
|
||||
Color: stringPtr("#FF0000"),
|
||||
},
|
||||
{
|
||||
Describable: model.Describable{Name: "High"},
|
||||
Color: stringPtr("#FF8000"),
|
||||
},
|
||||
{
|
||||
Describable: model.Describable{Name: "Medium"},
|
||||
Color: stringPtr("#FFFF00"),
|
||||
},
|
||||
{
|
||||
Describable: model.Describable{Name: "Low"},
|
||||
Color: stringPtr("#00FF00"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t, "Test Priority Group", priorityGroup.Description.Name)
|
||||
assert.Len(t, priorityGroup.Priorities, 4)
|
||||
|
||||
// Test each priority
|
||||
expectedPriorities := []struct {
|
||||
name string
|
||||
color string
|
||||
}{
|
||||
{"Critical", "#FF0000"},
|
||||
{"High", "#FF8000"},
|
||||
{"Medium", "#FFFF00"},
|
||||
{"Low", "#00FF00"},
|
||||
}
|
||||
|
||||
for i, expected := range expectedPriorities {
|
||||
assert.Equal(t, expected.name, priorityGroup.Priorities[i].Name)
|
||||
assert.Equal(t, expected.color, *priorityGroup.Priorities[i].Color)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountDataToAccount tests the ToAccount method
|
||||
func TestAccountDataToAccount(t *testing.T) {
|
||||
accountData := model.AccountData{
|
||||
LoginData: model.LoginData{
|
||||
UserDataBase: model.UserDataBase{
|
||||
Login: "test@example.com",
|
||||
},
|
||||
Password: "TestPassword123!",
|
||||
},
|
||||
Name: "Test User",
|
||||
}
|
||||
|
||||
account := accountData.ToAccount()
|
||||
|
||||
assert.Equal(t, accountData.Login, account.Login)
|
||||
assert.Equal(t, accountData.Password, account.Password)
|
||||
assert.Equal(t, accountData.Name, account.Name)
|
||||
|
||||
// Verify the account has proper structure
|
||||
assert.NotNil(t, account)
|
||||
assert.IsType(t, &model.Account{}, account)
|
||||
}
|
||||
|
||||
// TestColorValidation tests that colors are properly formatted
|
||||
func TestColorValidation(t *testing.T) {
|
||||
validColors := []string{
|
||||
"#FF0000", // Red
|
||||
"#00FF00", // Green
|
||||
"#0000FF", // Blue
|
||||
"#FFFFFF", // White
|
||||
"#000000", // Black
|
||||
"#FF8000", // Orange
|
||||
}
|
||||
|
||||
for _, color := range validColors {
|
||||
t.Run(color, func(t *testing.T) {
|
||||
colorPtr := stringPtr(color)
|
||||
assert.NotNil(t, colorPtr)
|
||||
assert.Equal(t, color, *colorPtr)
|
||||
assert.True(t, len(color) == 7, "Color should be 7 characters long")
|
||||
assert.True(t, color[0] == '#', "Color should start with #")
|
||||
})
|
||||
}
|
||||
}
|
||||
61
api/server/internal/server/accountapiimp/update.go
Normal file
61
api/server/internal/server/accountapiimp/update.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package accountapiimp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/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/mutil/mzap"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *AccountAPI) updateProfile(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
|
||||
ctx := r.Context()
|
||||
// Validate user input
|
||||
u, err := a.attemptDecodeAccount(r)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to decide profile update", zap.Error(err))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
if u.Login == "" {
|
||||
a.logger.Debug("No email in request")
|
||||
return a.reportEmailMissing()
|
||||
}
|
||||
if u.Name == "" {
|
||||
a.logger.Debug("No name in request")
|
||||
return response.BadRequest(a.logger, a.Name(), "name_missing", "name is required")
|
||||
}
|
||||
|
||||
if account.Login != u.Login {
|
||||
// Change email address
|
||||
if err := a.accService.UpdateLogin(ctx, account, u.Login); err != nil {
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
a.logger.Debug("Duplicate login, denying change...", zap.Error(err), mzap.StorableRef(u))
|
||||
return a.reportDuplicateEmail()
|
||||
}
|
||||
a.logger.Warn("Error while updating login", zap.Error(err), mzap.StorableRef(u))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
// Send verification email
|
||||
if err = a.sendWelcomeEmail(account); err != nil {
|
||||
a.logger.Warn("Failed to send verification email", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
} else {
|
||||
// Save the user
|
||||
u.Password = account.Password
|
||||
u.ResetPasswordToken = account.ResetPasswordToken
|
||||
u.VerifyToken = account.VerifyToken
|
||||
if err = a.db.Update(ctx, u); err != nil {
|
||||
a.logger.Warn("Failed to save account", zap.Error(err), mzap.StorableRef(u))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
return sresponse.Account(a.logger, u, token)
|
||||
}
|
||||
42
api/server/internal/server/fileserviceimp/fileserver.go
Normal file
42
api/server/internal/server/fileserviceimp/fileserver.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package fileserviceimp
|
||||
|
||||
import (
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *API) getFile(r *http.Request) http.HandlerFunc {
|
||||
fileName := mutil.GetObjRef(r)
|
||||
return a.fileManager.Get(r.Context(), fileName)
|
||||
}
|
||||
|
||||
func (a *API) closeFile(file multipart.File) {
|
||||
if err := file.Close(); err != nil {
|
||||
a.logger.Warn("Failed to close file", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) uploadFile(r *http.Request, _ *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
fileName := mutil.GetObjRef(r)
|
||||
|
||||
file, _, err := r.FormFile(a.subDir)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to read form request", zap.Error(err), zap.String("field_name", a.subDir))
|
||||
return response.BadRequest(a.logger, a.Name(), a.subDir+"_broken", err.Error())
|
||||
}
|
||||
defer a.closeFile(file)
|
||||
|
||||
url, err := a.fileManager.Save(r.Context(), file, fileName)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to store file", zap.Error(err), zap.String(mutil.ObjRefName(), fileName), zap.String("field_name", a.subDir))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
return sresponse.FileUploaded(a.logger, url)
|
||||
}
|
||||
46
api/server/internal/server/fileserviceimp/service.go
Normal file
46
api/server/internal/server/fileserviceimp/service.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package fileserviceimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path"
|
||||
|
||||
api "github.com/tech/sendico/pkg/api/http"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
eapi "github.com/tech/sendico/server/interface/api"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"github.com/tech/sendico/server/internal/server/fileserviceimp/storage"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
logger mlogger.Logger
|
||||
fileManager storage.FileManager
|
||||
subDir string
|
||||
}
|
||||
|
||||
func (a *API) Name() mservice.Type {
|
||||
return "storage"
|
||||
}
|
||||
|
||||
func (a *API) Finish(_ context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreateAPI(a eapi.API, service mservice.Type) (*API, error) {
|
||||
p := new(API)
|
||||
p.logger = a.Logger().Named(p.Name())
|
||||
p.subDir = "image"
|
||||
var err error
|
||||
if p.fileManager, err = storage.Create(p.logger, a, service, service, p.subDir); err != nil {
|
||||
p.logger.Warn("Failed to create storage manager", zap.String("directory", service), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
p.logger.Info("Storage connected", zap.String("driver", string(a.Config().Storage.Driver)))
|
||||
|
||||
handler := path.Join(p.subDir, mutil.AddObjRef("/"))
|
||||
a.Register().Handler(service, handler, api.Get, p.getFile)
|
||||
a.Register().AccountHandler(service, handler, api.Post, p.uploadFile)
|
||||
|
||||
return p, nil
|
||||
}
|
||||
120
api/server/internal/server/internal/serverimp.go
Normal file
120
api/server/internal/server/internal/serverimp.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package serverimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/tech/sendico/pkg/db"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
mduration "github.com/tech/sendico/pkg/mutil/duration"
|
||||
ac "github.com/tech/sendico/server/interface/api"
|
||||
apiimip "github.com/tech/sendico/server/internal/api"
|
||||
"go.uber.org/zap"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type httpServerConf struct {
|
||||
ListenAddress string `yaml:"listen_address"`
|
||||
ReadHeaderTimeout int `yaml:"read_header_timeout"`
|
||||
ShutdownTimeout int `yaml:"shutdown_timeout"`
|
||||
}
|
||||
|
||||
// Config represents the server configuration
|
||||
type Config struct {
|
||||
API *ac.Config `yaml:"api"`
|
||||
DB *db.Config `yaml:"database"`
|
||||
HTTPServer *httpServerConf `yaml:"http_server"`
|
||||
}
|
||||
|
||||
// Instance represents an instance of the server
|
||||
type Imp struct {
|
||||
logger mlogger.Logger
|
||||
api mservice.MicroService
|
||||
config *Config
|
||||
db db.Factory
|
||||
httpServer *http.Server
|
||||
debug bool
|
||||
file string
|
||||
}
|
||||
|
||||
// Shutdown stops the server
|
||||
func (i *Imp) Shutdown() {
|
||||
// Shutdown HTTP server
|
||||
ctx, cancel := context.WithTimeout(context.Background(), mduration.Param2Duration(i.config.HTTPServer.ShutdownTimeout, time.Second))
|
||||
i.logger.Info("Shutting HTTP server down...")
|
||||
if err := i.httpServer.Shutdown(ctx); err != nil {
|
||||
if !errors.Is(err, http.ErrServerClosed) {
|
||||
i.logger.Warn("Failed to shutdown HTTP server gracefully", zap.Error(err))
|
||||
cancel()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
|
||||
func (i *Imp) Run() error {
|
||||
if err := i.httpServer.ListenAndServe(); err != nil {
|
||||
if !errors.Is(err, http.ErrServerClosed) {
|
||||
i.logger.Error("HTTP Server stopped unexpectedly", zap.Error(err))
|
||||
}
|
||||
}
|
||||
i.logger.Info("HTTP Server stopped")
|
||||
|
||||
if err := i.api.Finish(context.Background()); err != nil {
|
||||
i.logger.Warn("Error when finishing service", zap.Error(err))
|
||||
}
|
||||
|
||||
i.db.CloseConnection()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start starts the server
|
||||
func (i *Imp) Start() error {
|
||||
i.logger.Info("Starting...", zap.String("config_file", i.file), zap.Bool("debug_mode", i.debug))
|
||||
// Load configuration file
|
||||
data, err := os.ReadFile(i.file)
|
||||
if err != nil {
|
||||
i.logger.Error("Could not load configuration", zap.Error(err), zap.String("config_file", i.file))
|
||||
return err
|
||||
}
|
||||
|
||||
if err = yaml.Unmarshal(data, &i.config); err != nil {
|
||||
i.logger.Error("Failed to parse configuration", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
if i.db, err = db.NewConnection(i.logger, i.config.DB); err != nil {
|
||||
i.logger.Error("Could not open database connection", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
router := chi.NewRouter()
|
||||
if i.api, err = apiimip.CreateAPI(i.logger, i.config.API, i.db, router, i.debug); err != nil {
|
||||
i.logger.Error("Failed to create API instance", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
// Startup the HTTP Server in a way that we can gracefully shut it down again
|
||||
i.httpServer = &http.Server{
|
||||
Addr: i.config.HTTPServer.ListenAddress,
|
||||
Handler: router,
|
||||
ReadHeaderTimeout: mduration.Param2Duration(i.config.HTTPServer.ReadHeaderTimeout, time.Second),
|
||||
}
|
||||
|
||||
return i.Run()
|
||||
}
|
||||
|
||||
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
|
||||
srv := &Imp{
|
||||
logger: logger,
|
||||
debug: debug,
|
||||
file: file,
|
||||
}
|
||||
return srv, nil
|
||||
}
|
||||
120
api/server/internal/server/invitationimp/accept.go
Normal file
120
api/server/internal/server/invitationimp/accept.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package invitationimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/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/mutil/mzap"
|
||||
"github.com/tech/sendico/server/interface/api/srequest"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *InvitationAPI) doAccept(ctx context.Context, invitationRef primitive.ObjectID, accData *model.AccountData) error {
|
||||
inv, err := a.getPendingInvitation(ctx, invitationRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
org, err := a.getOrganization(ctx, inv.OrganizationRef, inv.Content.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := a.fetchOrCreateAccount(ctx, org, inv, accData); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := a.db.Accept(ctx, invitationRef); err != nil {
|
||||
a.Logger.Warn("Failed to accept invitation", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef))
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *InvitationAPI) getPendingInvitation(ctx context.Context, invitationRef primitive.ObjectID) (*model.Invitation, error) {
|
||||
a.Logger.Debug("Fetching invitation", mzap.ObjRef("invitation_ref", invitationRef))
|
||||
var inv model.Invitation
|
||||
if err := a.db.Unprotected().Get(ctx, invitationRef, &inv); err != nil {
|
||||
a.Logger.Warn("Failed to fetch invitation", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef))
|
||||
return nil, err
|
||||
}
|
||||
if inv.Status != model.InvitationCreated {
|
||||
a.Logger.Warn("Invitation is not pending", mzap.StorableRef(&inv))
|
||||
return nil, merrors.InvalidArgument("Invitation is not pending")
|
||||
}
|
||||
return &inv, nil
|
||||
}
|
||||
|
||||
func (a *InvitationAPI) getOrganization(ctx context.Context, orgRef primitive.ObjectID, email string) (*model.Organization, error) {
|
||||
a.Logger.Debug("Fetching organization", mzap.ObjRef("organization_ref", orgRef), zap.String("email", email))
|
||||
var org model.Organization
|
||||
if err := a.odb.Unprotected().Get(ctx, orgRef, &org); err != nil {
|
||||
a.Logger.Warn("Failed to fetch organization when processing invitation", zap.Error(err),
|
||||
mzap.ObjRef("organization_ref", orgRef), zap.String("email", email))
|
||||
return nil, err
|
||||
}
|
||||
return &org, nil
|
||||
}
|
||||
|
||||
func (a *InvitationAPI) fetchOrCreateAccount(ctx context.Context, org *model.Organization, inv *model.Invitation, accData *model.AccountData) (*model.Account, error) {
|
||||
account, err := a.adb.GetByEmail(ctx, inv.Content.Email)
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
a.Logger.Debug("Account is not registered, creating", zap.String("email", inv.Content.Email))
|
||||
if accData == nil {
|
||||
a.Logger.Warn("Account data missing for unregistered invitation acceptance",
|
||||
zap.String("email", inv.Content.Email), mzap.StorableRef(inv))
|
||||
return nil, merrors.InvalidArgument("No account data provided for invitation acceptance")
|
||||
}
|
||||
|
||||
account = accData.ToAccount()
|
||||
if err := a.accService.ValidateAccount(account); err != nil {
|
||||
a.Logger.Info("Account validation failed", zap.Error(err), zap.String("email", inv.Content.Email))
|
||||
return nil, err
|
||||
}
|
||||
// creates account and joins organization
|
||||
if err := a.accService.CreateAccount(ctx, org, account, inv.RoleRef); err != nil {
|
||||
a.Logger.Warn("Failed to create account", zap.Error(err), zap.String("email", inv.Content.Email))
|
||||
return nil, err
|
||||
}
|
||||
return account, nil
|
||||
} else if err != nil {
|
||||
a.Logger.Warn("Failed to fetch account by email", zap.Error(err), zap.String("email", inv.Content.Email))
|
||||
return nil, err
|
||||
} else {
|
||||
// If account already exists, then just join organization
|
||||
if err := a.accService.JoinOrganization(ctx, org, account, inv.RoleRef); err != nil {
|
||||
a.Logger.Warn("Failed to join organization", zap.Error(err), mzap.StorableRef(account), mzap.StorableRef(org))
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return account, nil
|
||||
}
|
||||
|
||||
func (a *InvitationAPI) accept(r *http.Request) http.HandlerFunc {
|
||||
invitationRef, err := a.irh.GetRef(r)
|
||||
if err != nil {
|
||||
return a.respondBadReference(r, err)
|
||||
}
|
||||
var req srequest.AcceptInvitation
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
a.Logger.Warn("Failed to decode request body", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef))
|
||||
return response.BadPayload(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
if _, err := a.tf.CreateTransaction().Execute(r.Context(), func(ctx context.Context) (any, error) {
|
||||
return nil, a.doAccept(ctx, invitationRef, req.Account)
|
||||
}); err != nil {
|
||||
a.Logger.Warn("Failed to accept invitation", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef))
|
||||
return response.Auto(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
return response.Success(a.Logger)
|
||||
}
|
||||
24
api/server/internal/server/invitationimp/decline.go
Normal file
24
api/server/internal/server/invitationimp/decline.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package invitationimp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *InvitationAPI) decline(r *http.Request) http.HandlerFunc {
|
||||
invitationRef, err := a.irh.GetRef(r)
|
||||
if err != nil {
|
||||
return a.respondBadReference(r, err)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
if err := a.db.Decline(ctx, invitationRef); err != nil {
|
||||
a.Logger.Warn("Failed to decline invitation", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef))
|
||||
return response.Auto(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
return response.Success(a.Logger)
|
||||
}
|
||||
19
api/server/internal/server/invitationimp/notifications.go
Normal file
19
api/server/internal/server/invitationimp/notifications.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package invitationimp
|
||||
|
||||
import (
|
||||
messaging "github.com/tech/sendico/pkg/messaging/envelope"
|
||||
in "github.com/tech/sendico/pkg/messaging/notifications/invitation"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
nm "github.com/tech/sendico/pkg/model/notification"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
func (a *InvitationAPI) notification(
|
||||
invitation *model.Invitation,
|
||||
actorAccountRef primitive.ObjectID,
|
||||
t nm.NotificationAction,
|
||||
) messaging.Envelope {
|
||||
a.Logger.Debug("Sending notification of new invitation created", mzap.StorableRef(invitation))
|
||||
return in.Invitation(a.Name(), actorAccountRef, invitation.ID, t)
|
||||
}
|
||||
26
api/server/internal/server/invitationimp/public.go
Normal file
26
api/server/internal/server/invitationimp/public.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package invitationimp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *InvitationAPI) public(r *http.Request) http.HandlerFunc {
|
||||
invitationRef, err := a.irh.GetRef(r)
|
||||
if err != nil {
|
||||
return a.respondBadReference(r, err)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
inv, err := a.db.GetPublic(ctx, invitationRef)
|
||||
if err != nil {
|
||||
a.Logger.Warn("Failed to get public invitation", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef))
|
||||
return response.Auto(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
return sresponse.Invitation(a.Logger, inv)
|
||||
}
|
||||
13
api/server/internal/server/invitationimp/response.go
Normal file
13
api/server/internal/server/invitationimp/response.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package invitationimp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
)
|
||||
|
||||
func (a *InvitationAPI) respondBadReference(r *http.Request, err error) http.HandlerFunc {
|
||||
a.Logger.Warn("Failed to fetch invitation reference", mutil.PLog(a.irh, r))
|
||||
return response.BadReference(a.Logger, a.Name(), a.irh.Name(), a.irh.GetID(r), err)
|
||||
}
|
||||
81
api/server/internal/server/invitationimp/service.go
Normal file
81
api/server/internal/server/invitationimp/service.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package invitationimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
api "github.com/tech/sendico/pkg/api/http"
|
||||
"github.com/tech/sendico/pkg/db/account"
|
||||
"github.com/tech/sendico/pkg/db/invitation"
|
||||
"github.com/tech/sendico/pkg/db/organization"
|
||||
"github.com/tech/sendico/pkg/db/transaction"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"github.com/tech/sendico/server/interface/accountservice"
|
||||
eapi "github.com/tech/sendico/server/interface/api"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"github.com/tech/sendico/server/internal/server/papitemplate"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type InvitationAPI struct {
|
||||
papitemplate.ProtectedAPI[model.Invitation]
|
||||
db invitation.DB
|
||||
irh mutil.ParamHelper
|
||||
tf transaction.Factory
|
||||
adb account.DB
|
||||
odb organization.DB
|
||||
accService accountservice.AccountService
|
||||
}
|
||||
|
||||
func (a *InvitationAPI) Name() mservice.Type {
|
||||
return mservice.Invitations
|
||||
}
|
||||
|
||||
func (a *InvitationAPI) Finish(_ context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreateAPI(a eapi.API) (*InvitationAPI, error) {
|
||||
dbFactory := func() (papitemplate.ProtectedDB[model.Invitation], error) {
|
||||
return a.DBFactory().NewInvitationsDB()
|
||||
}
|
||||
|
||||
res := &InvitationAPI{
|
||||
irh: mutil.CreatePH("invitation"),
|
||||
tf: a.DBFactory().TransactionFactory(),
|
||||
}
|
||||
|
||||
p, err := papitemplate.CreateAPI(a, dbFactory, mservice.Organizations, mservice.Invitations)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.ProtectedAPI = *p.WithNotifications(res.notification).Build()
|
||||
|
||||
if res.db, err = a.DBFactory().NewInvitationsDB(); err != nil {
|
||||
res.Logger.Warn("Failed to create invitation database", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
if res.adb, err = a.DBFactory().NewAccountDB(); err != nil {
|
||||
res.Logger.Warn("Failed to create accounts database", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
if res.odb, err = a.DBFactory().NewOrganizationDB(); err != nil {
|
||||
res.Logger.Warn("Failed to create organizations database", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
if res.accService, err = accountservice.NewAccountService(
|
||||
res.Logger,
|
||||
a.DBFactory(),
|
||||
a.Permissions().Enforcer(),
|
||||
a.Permissions().Manager().Role(),
|
||||
&a.Config().Mw.Password); err != nil {
|
||||
res.Logger.Warn("Failed to create account service", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.Register().Handler(mservice.Invitations, res.irh.AddRef("/public"), api.Get, res.public)
|
||||
a.Register().Handler(mservice.Invitations, res.irh.AddRef("/accept"), api.Put, res.accept)
|
||||
a.Register().Handler(mservice.Invitations, res.irh.AddRef("/decline"), api.Delete, res.decline)
|
||||
|
||||
return res, nil
|
||||
}
|
||||
40
api/server/internal/server/logoimp/logo.go
Normal file
40
api/server/internal/server/logoimp/logo.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package logoimp
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"github.com/tech/sendico/server/assets"
|
||||
"github.com/tech/sendico/server/internal/mutil/imagewriter"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *LogoAPI) getLogo(r *http.Request) http.HandlerFunc {
|
||||
accountRef, err := mutil.GetAccountRef(r)
|
||||
if err != nil {
|
||||
a.logger.Warn("Invalid account reference", zap.Error(err))
|
||||
return response.BadReference(a.logger, a.Name(), mutil.AccountRefName(), mutil.GetAccountID(r), err)
|
||||
}
|
||||
// new context for execution
|
||||
var account model.Account
|
||||
if err := a.adb.Get(r.Context(), accountRef, &account); err != nil {
|
||||
a.logger.Warn("Failed to fetch account data", zap.Error(err))
|
||||
return response.NotFound(a.logger, mservice.Accounts, err.Error())
|
||||
}
|
||||
|
||||
res := func(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: delayed response due to context expiration after writing reposnse :(
|
||||
if err = imagewriter.WriteImage(w, &assets.MailLogo, "image/png"); err != nil {
|
||||
a.logger.Error("Failed to send logo", zap.Error(err))
|
||||
}
|
||||
|
||||
// ma.Identify(acc.Email)
|
||||
// ampli.Instance.EmailOpened(acc.Email,
|
||||
// ampli.EmailOpened.Builder().EmailType(mutil.GetParam(r, "email_type")).Build())
|
||||
}
|
||||
return res
|
||||
}
|
||||
40
api/server/internal/server/logoimp/service.go
Normal file
40
api/server/internal/server/logoimp/service.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package logoimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
api "github.com/tech/sendico/pkg/api/http"
|
||||
"github.com/tech/sendico/pkg/db/account"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
eapi "github.com/tech/sendico/server/interface/api"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type LogoAPI struct {
|
||||
logger mlogger.Logger
|
||||
adb account.DB
|
||||
}
|
||||
|
||||
func (a *LogoAPI) Name() mservice.Type {
|
||||
return mservice.Logo
|
||||
}
|
||||
|
||||
func (a *LogoAPI) Finish(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreateAPI(a eapi.API) (*LogoAPI, error) {
|
||||
p := new(LogoAPI)
|
||||
p.logger = a.Logger().Named(p.Name())
|
||||
var err error
|
||||
|
||||
if p.adb, err = a.DBFactory().NewAccountDB(); err != nil {
|
||||
p.logger.Error("Failed to create account database", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
a.Register().Handler(mservice.Logo, mutil.AddAccountRef("/")+"/{email_type}", api.Get, p.getLogo)
|
||||
|
||||
return p, nil
|
||||
}
|
||||
71
api/server/internal/server/organizationimp/crud.go
Normal file
71
api/server/internal/server/organizationimp/crud.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package organizationimp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/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/mutil/mzap"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *OrganizationAPI) list(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
|
||||
orgs, err := a.db.List(r.Context(), *account.GetID(), nil)
|
||||
if errors.Is(err, merrors.ErrNoData) || (len(orgs) == 0) {
|
||||
a.logger.Debug("Organizations not found", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.NotFound(a.logger, a.Name(), "orgnizations not found")
|
||||
}
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to fetch organizations", zap.Error(err))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
return sresponse.Organizations(a.logger, orgs, accessToken)
|
||||
}
|
||||
|
||||
func (a *OrganizationAPI) get(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
|
||||
orgRef, err := mutil.GetOrganizationRef(r)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to parse organization id", zap.Error(err), zap.String("organization_ref", mutil.GetOrganizationID(r)))
|
||||
return response.BadReference(a.logger, a.Name(), mutil.OrganizationRefName(), mutil.GetOrganizationID(r), err)
|
||||
}
|
||||
|
||||
var org model.Organization
|
||||
if err := a.db.Get(r.Context(), *account.GetID(), orgRef, &org); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
a.logger.Debug("Organization not found", mzap.ObjRef("organization_ref", orgRef), zap.Error(err))
|
||||
return response.NotFound(a.logger, a.Name(), "organization with given id not found")
|
||||
}
|
||||
a.logger.Error("Error fetching organization", mzap.ObjRef("organization_ref", orgRef), zap.Error(err))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
return sresponse.Organization(a.logger, &org, accessToken)
|
||||
}
|
||||
|
||||
func (a *OrganizationAPI) update(r *http.Request, acccount *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
|
||||
var org model.OrganizationBase
|
||||
if err := json.NewDecoder(r.Body).Decode(&org); err != nil {
|
||||
a.logger.Warn("Failed to decode organization when updating settings", zap.Error(err))
|
||||
return response.BadPayload(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
var orgStored model.Organization
|
||||
if err := a.db.Get(ctx, *acccount.GetID(), *org.GetID(), &orgStored); err != nil {
|
||||
a.logger.Warn("Failed to fetch organization for update", zap.Error(err), mzap.StorableRef(&org), mzap.StorableRef(acccount))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
orgStored.OrganizationBase = org
|
||||
if err := a.db.Update(r.Context(), *acccount.GetID(), &orgStored); err != nil {
|
||||
a.logger.Warn("Error fetching organization", mzap.StorableRef(&org), zap.Error(err))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
return sresponse.Organization(a.logger, &orgStored, accessToken)
|
||||
}
|
||||
37
api/server/internal/server/organizationimp/invitation.go
Normal file
37
api/server/internal/server/organizationimp/invitation.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package organizationimp
|
||||
|
||||
import (
|
||||
"net/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/mutil/mzap"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *OrganizationAPI) invitation(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
|
||||
invitationRef, err := mutil.GetInvitationRef(r)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to parse organization id", zap.Error(err), zap.String("invitation_ref", mutil.GetOrganizationID(r)))
|
||||
return response.Auto(a.logger, a.Name(), merrors.InvalidArgument("bad invitation reference"))
|
||||
}
|
||||
ctx := r.Context()
|
||||
|
||||
var invitation model.Invitation
|
||||
if err := a.idb.Get(ctx, *account.GetID(), invitationRef, &invitation); err != nil {
|
||||
a.logger.Warn("Failed to fetch invitation", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
var org model.Organization
|
||||
if err := a.db.Get(ctx, *account.GetID(), invitation.OrganizationRef, &org); err != nil {
|
||||
a.logger.Error("Error fetching organization", zap.Error(err),
|
||||
mzap.StorableRef(&invitation), mzap.ObjRef("organization_ref", invitation.OrganizationRef))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
return sresponse.OrganizationPublic(a.logger, &org.OrganizationBase)
|
||||
}
|
||||
59
api/server/internal/server/organizationimp/service.go
Normal file
59
api/server/internal/server/organizationimp/service.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package organizationimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
api "github.com/tech/sendico/pkg/api/http"
|
||||
"github.com/tech/sendico/pkg/auth"
|
||||
"github.com/tech/sendico/pkg/db/invitation"
|
||||
"github.com/tech/sendico/pkg/db/organization"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
eapi "github.com/tech/sendico/server/interface/api"
|
||||
"github.com/tech/sendico/server/interface/services/fileservice"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type OrganizationAPI struct {
|
||||
logger mlogger.Logger
|
||||
db organization.DB
|
||||
idb invitation.DB
|
||||
images mservice.MicroService
|
||||
enforcer auth.Enforcer
|
||||
}
|
||||
|
||||
func (a *OrganizationAPI) Name() mservice.Type {
|
||||
return mservice.Organizations
|
||||
}
|
||||
|
||||
func (a *OrganizationAPI) Finish(ctx context.Context) error {
|
||||
return a.images.Finish(ctx)
|
||||
}
|
||||
|
||||
func CreateAPI(a eapi.API) (*OrganizationAPI, error) {
|
||||
p := new(OrganizationAPI)
|
||||
p.logger = a.Logger().Named(p.Name())
|
||||
p.enforcer = a.Permissions().Enforcer()
|
||||
var err error
|
||||
if p.db, err = a.DBFactory().NewOrganizationDB(); err != nil {
|
||||
p.logger.Error("Failed to create organizations database", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
if p.idb, err = a.DBFactory().NewInvitationsDB(); err != nil {
|
||||
p.logger.Error("Failed to create invitations database", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.Register().AccountHandler(mservice.Organizations, "", api.Get, p.list)
|
||||
a.Register().AccountHandler(mservice.Organizations, mutil.AddOrganizaztionRef("/"), api.Get, p.get)
|
||||
a.Register().AccountHandler(mservice.Organizations, "", api.Put, p.update)
|
||||
a.Register().AccountHandler(mservice.Organizations, mutil.AddInvitationRef("/invitation"), api.Get, p.invitation)
|
||||
|
||||
if p.images, err = fileservice.CreateAPI(a, p.Name()); err != nil {
|
||||
p.logger.Error("Failed to create image server", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
69
api/server/internal/server/papitemplate/archive.go
Normal file
69
api/server/internal/server/papitemplate/archive.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package papitemplate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *ProtectedAPI[T]) archive(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
|
||||
objectRef, err := a.Cph.GetRef(r)
|
||||
if err != nil {
|
||||
a.Logger.Warn("Failed to restore object reference", zap.Error(err), mutil.PLog(a.Cph, r))
|
||||
return response.BadReference(a.Logger, a.Name(), a.Cph.Name(), a.Cph.GetID(r), err)
|
||||
}
|
||||
organizationRef, err := a.Oph.GetRef(r)
|
||||
if err != nil {
|
||||
a.Logger.Warn("Failed to restore organization reference", zap.Error(err), mutil.PLog(a.Oph, r))
|
||||
return response.BadReference(a.Logger, a.Name(), a.Oph.Name(), a.Oph.GetID(r), err)
|
||||
}
|
||||
|
||||
archived, err := mutil.GetArchiveParam(a.Logger, r)
|
||||
if err != nil {
|
||||
a.Logger.Warn("Failed to read optional 'archived' param", zap.Error(err))
|
||||
return response.Auto(a.Logger, a.resource, err)
|
||||
}
|
||||
if archived == nil {
|
||||
a.Logger.Warn("No archivation setting provided")
|
||||
return response.BadRequest(a.Logger, a.resource, "invalid_query_parameter", "'archived' pram must be present")
|
||||
}
|
||||
cascade, err := mutil.GetCascadeParam(a.Logger, r)
|
||||
if err != nil {
|
||||
a.Logger.Warn("Failed to read optional 'cascade' param", zap.Error(err))
|
||||
return response.Auto(a.Logger, a.resource, err)
|
||||
}
|
||||
if cascade == nil {
|
||||
a.Logger.Warn("Cascade property not specified, defaulting to false")
|
||||
csc := false
|
||||
cascade = &csc
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
_, err = a.a.DBFactory().TransactionFactory().CreateTransaction().Execute(ctx, func(ctx context.Context) (any, error) {
|
||||
return nil, a.DB.SetArchived(r.Context(), *account.GetID(), organizationRef, objectRef, *archived, *cascade)
|
||||
})
|
||||
if err != nil {
|
||||
a.Logger.Warn("Failed to change archive property", zap.Error(err), mzap.StorableRef(account), mutil.PLog(a.Cph, r),
|
||||
zap.Bool("archived", *archived), zap.Bool("cascade", *cascade))
|
||||
return response.Auto(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
if a.nconfig.NeedArchiveNotification {
|
||||
var object T
|
||||
if err := a.DB.Get(ctx, *account.GetID(), objectRef, &object); err != nil {
|
||||
a.Logger.Warn("Failed to fetch object for notification", zap.Error(err), mzap.StorableRef(account), mutil.PLog(a.Cph, r))
|
||||
} else {
|
||||
if err := a.nconfig.ArchiveNotification(&object, *account.GetID()); err != nil {
|
||||
a.Logger.Warn("Failed to send archivation notification", zap.Error(err), mzap.StorableRef(account), mutil.PLog(a.Cph, r))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return a.Objects([]T{}, accessToken)
|
||||
}
|
||||
133
api/server/internal/server/papitemplate/config.go
Normal file
133
api/server/internal/server/papitemplate/config.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package papitemplate
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
)
|
||||
|
||||
type HandlerResolver func(sresponse.AccountHandlerFunc) sresponse.AccountHandlerFunc
|
||||
|
||||
type Config interface {
|
||||
WithNoCreate() Config
|
||||
WithCreateHandler(handler sresponse.AccountHandlerFunc) Config
|
||||
WithNoList() Config
|
||||
WithListHandler(handler sresponse.AccountHandlerFunc) Config
|
||||
WithNoGet() Config
|
||||
WithGetHandler(handler sresponse.AccountHandlerFunc) Config
|
||||
WithNoUpdate() Config
|
||||
WithUpdateHandler(handler sresponse.AccountHandlerFunc) Config
|
||||
WithNoDelete() Config
|
||||
WithDeleteHandler(handler sresponse.AccountHandlerFunc) Config
|
||||
WithReorderHandler(reorder ReorderConfig) Config
|
||||
WithTaggableHandler(taggable TaggableConfig) Config
|
||||
}
|
||||
|
||||
type PAPIConfig struct {
|
||||
CreateResolver HandlerResolver
|
||||
ListResolver HandlerResolver
|
||||
GetResolver HandlerResolver
|
||||
UpdateResolver HandlerResolver
|
||||
DeleteResolver HandlerResolver
|
||||
ArchiveResolver HandlerResolver
|
||||
Reorder *ReorderConfig
|
||||
Taggable *TaggableConfig
|
||||
}
|
||||
|
||||
// WithNoCreate disables the create endpoint by replacing its resolver.
|
||||
func (cfg *PAPIConfig) WithNoCreate() *PAPIConfig {
|
||||
cfg.CreateResolver = disableResolver
|
||||
return cfg
|
||||
}
|
||||
|
||||
// WithCreateHandler overrides the create endpoint by replacing its resolver.
|
||||
func (cfg *PAPIConfig) WithCreateHandler(handler sresponse.AccountHandlerFunc) *PAPIConfig {
|
||||
cfg.CreateResolver = overrideResolver(handler)
|
||||
return cfg
|
||||
}
|
||||
|
||||
// WithNoList disables the list endpoint.
|
||||
func (cfg *PAPIConfig) WithNoList() *PAPIConfig {
|
||||
cfg.ListResolver = disableResolver
|
||||
return cfg
|
||||
}
|
||||
|
||||
// WithListHandler overrides the list endpoint.
|
||||
func (cfg *PAPIConfig) WithListHandler(handler sresponse.AccountHandlerFunc) *PAPIConfig {
|
||||
cfg.ListResolver = overrideResolver(handler)
|
||||
return cfg
|
||||
}
|
||||
|
||||
// WithNoGet disables the get endpoint.
|
||||
func (cfg *PAPIConfig) WithNoGet() *PAPIConfig {
|
||||
cfg.GetResolver = disableResolver
|
||||
return cfg
|
||||
}
|
||||
|
||||
// WithGetHandler overrides the get endpoint.
|
||||
func (cfg *PAPIConfig) WithGetHandler(handler sresponse.AccountHandlerFunc) *PAPIConfig {
|
||||
cfg.GetResolver = overrideResolver(handler)
|
||||
return cfg
|
||||
}
|
||||
|
||||
// WithNoUpdate disables the update endpoint.
|
||||
func (cfg *PAPIConfig) WithNoUpdate() *PAPIConfig {
|
||||
cfg.UpdateResolver = disableResolver
|
||||
return cfg
|
||||
}
|
||||
|
||||
// WithUpdateHandler overrides the update endpoint.
|
||||
func (cfg *PAPIConfig) WithUpdateHandler(handler sresponse.AccountHandlerFunc) *PAPIConfig {
|
||||
cfg.UpdateResolver = overrideResolver(handler)
|
||||
return cfg
|
||||
}
|
||||
|
||||
// WithNoDelete disables the delete endpoint.
|
||||
func (cfg *PAPIConfig) WithNoDelete() *PAPIConfig {
|
||||
cfg.DeleteResolver = disableResolver
|
||||
return cfg
|
||||
}
|
||||
|
||||
// WithDeleteHandler overrides the delete endpoint.
|
||||
func (cfg *PAPIConfig) WithDeleteHandler(handler sresponse.AccountHandlerFunc) *PAPIConfig {
|
||||
cfg.DeleteResolver = overrideResolver(handler)
|
||||
return cfg
|
||||
}
|
||||
|
||||
func (cfg *PAPIConfig) WithNoArchive() *PAPIConfig {
|
||||
cfg.ArchiveResolver = disableResolver
|
||||
return cfg
|
||||
}
|
||||
|
||||
func (cfg *PAPIConfig) WithArchiveHandler(handler sresponse.AccountHandlerFunc) *PAPIConfig {
|
||||
cfg.ArchiveResolver = overrideResolver(handler)
|
||||
return cfg
|
||||
}
|
||||
|
||||
// defaultResolver returns the default handler unchanged.
|
||||
func defaultResolver(defaultHandler sresponse.AccountHandlerFunc) sresponse.AccountHandlerFunc {
|
||||
return defaultHandler
|
||||
}
|
||||
|
||||
// disableResolver always returns nil, disabling the endpoint.
|
||||
func disableResolver(_ sresponse.AccountHandlerFunc) sresponse.AccountHandlerFunc {
|
||||
return nil
|
||||
}
|
||||
|
||||
// overrideResolver returns a resolver that always returns the given custom handler.
|
||||
func overrideResolver(custom sresponse.AccountHandlerFunc) HandlerResolver {
|
||||
return func(_ sresponse.AccountHandlerFunc) sresponse.AccountHandlerFunc {
|
||||
return custom
|
||||
}
|
||||
}
|
||||
|
||||
func NewConfig() *PAPIConfig {
|
||||
return &PAPIConfig{
|
||||
CreateResolver: defaultResolver,
|
||||
ListResolver: defaultResolver,
|
||||
GetResolver: defaultResolver,
|
||||
UpdateResolver: defaultResolver,
|
||||
DeleteResolver: defaultResolver,
|
||||
ArchiveResolver: defaultResolver,
|
||||
Reorder: nil,
|
||||
Taggable: nil,
|
||||
}
|
||||
}
|
||||
38
api/server/internal/server/papitemplate/create.go
Normal file
38
api/server/internal/server/papitemplate/create.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package papitemplate
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *ProtectedAPI[T]) create(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
|
||||
organizationRef, err := a.Oph.GetRef(r)
|
||||
if err != nil {
|
||||
a.Logger.Warn("Failed to parse parent object reference", zap.Error(err), mutil.PLog(a.Oph, r))
|
||||
return response.BadReference(a.Logger, a.Name(), a.Oph.Name(), a.Oph.GetID(r), err)
|
||||
}
|
||||
|
||||
var object T
|
||||
if err := json.NewDecoder(r.Body).Decode(&object); err != nil {
|
||||
a.Logger.Warn("Failed to decode object when creating", zap.Error(err), mzap.StorableRef(account), mutil.PLog(a.Oph, r))
|
||||
return response.BadPayload(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
if err := a.DB.Create(r.Context(), *account.GetID(), organizationRef, &object); err != nil {
|
||||
a.Logger.Warn("Error creating object", zap.Error(err), mzap.StorableRef(account), mutil.PLog(a.Oph, r), mutil.PLog(a.Cph, r))
|
||||
return response.Auto(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
if err := a.nconfig.CreateNotification(&object, *account.GetID()); err != nil {
|
||||
a.Logger.Warn("Failed to send creation notification", zap.Error(err), mzap.StorableRef(account), mutil.PLog(a.Oph, r), mutil.PLog(a.Cph, r))
|
||||
}
|
||||
|
||||
return a.ObjectCreated(&object, accessToken)
|
||||
}
|
||||
23
api/server/internal/server/papitemplate/db.go
Normal file
23
api/server/internal/server/papitemplate/db.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package papitemplate
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/pkg/db/repository/builder"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
type ProtectedDB[T any] interface {
|
||||
Create(ctx context.Context, accountRef, organizationRef primitive.ObjectID, object *T) error
|
||||
Get(ctx context.Context, accountRef, objectRef primitive.ObjectID, result *T) error
|
||||
Update(ctx context.Context, accountRef primitive.ObjectID, object *T) error
|
||||
Delete(ctx context.Context, accountRef, objectRef primitive.ObjectID) error
|
||||
DeleteCascadeAuth(ctx context.Context, accountRef, objectRef primitive.ObjectID) error
|
||||
SetArchived(ctx context.Context, accountRef, organizationRef, objectRef primitive.ObjectID, isArchived, cascade bool) error
|
||||
List(ctx context.Context, accountRef, organizationRef, parentRef primitive.ObjectID, cursor *model.ViewCursor) ([]T, error)
|
||||
}
|
||||
|
||||
type ReorderDB interface {
|
||||
Reorder(ctx context.Context, accountRef, objectRef primitive.ObjectID, newIndex int, filter builder.Query) error
|
||||
}
|
||||
67
api/server/internal/server/papitemplate/delete.go
Normal file
67
api/server/internal/server/papitemplate/delete.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package papitemplate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *ProtectedAPI[T]) deleteImp(ctx context.Context, account *model.Account, objectRef primitive.ObjectID, cascade *bool) error {
|
||||
var err error
|
||||
if (cascade != nil) && (*cascade) {
|
||||
_, err = a.a.DBFactory().TransactionFactory().CreateTransaction().Execute(ctx, func(ctx context.Context) (any, error) {
|
||||
return nil, a.DB.DeleteCascadeAuth(ctx, *account.GetID(), objectRef)
|
||||
})
|
||||
} else {
|
||||
err = a.DB.Delete(ctx, *account.GetID(), objectRef)
|
||||
}
|
||||
if err != nil {
|
||||
a.Logger.Warn("Error deleting object", zap.Error(err), mzap.StorableRef(account), mzap.ObjRef("object_ref", objectRef))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *ProtectedAPI[T]) delete(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
|
||||
objectRef, err := a.Cph.GetRef(r)
|
||||
if err != nil {
|
||||
a.Logger.Warn("Failed to restore object reference", zap.Error(err), mutil.PLog(a.Cph, r))
|
||||
return response.BadReference(a.Logger, a.Name(), a.Cph.Name(), a.Cph.GetID(r), err)
|
||||
}
|
||||
|
||||
cascade, err := mutil.GetCascadeParam(a.Logger, r)
|
||||
if err != nil {
|
||||
a.Logger.Warn("Failed to read optional 'cascade' param", zap.Error(err))
|
||||
return response.Auto(a.Logger, a.resource, err)
|
||||
}
|
||||
|
||||
var objPtr *T
|
||||
if a.nconfig.NeedDeleteNotification {
|
||||
var object T
|
||||
if err := a.DB.Get(r.Context(), *account.GetID(), objectRef, &object); err != nil {
|
||||
a.Logger.Warn("Failed to fetch object for notification", zap.Error(err), mzap.StorableRef(account), mutil.PLog(a.Cph, r))
|
||||
} else {
|
||||
objPtr = &object
|
||||
}
|
||||
}
|
||||
|
||||
if err := a.deleteImp(r.Context(), account, objectRef, cascade); err != nil {
|
||||
a.Logger.Warn("Error deleting object", zap.Error(err), mzap.StorableRef(account), mutil.PLog(a.Cph, r))
|
||||
return response.Auto(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
if objPtr != nil {
|
||||
if err := a.nconfig.DeleteNotification(objPtr, *account.GetID()); err != nil {
|
||||
a.Logger.Warn("Failed to send deletion notification", zap.Error(err), mzap.StorableRef(account), mutil.PLog(a.Cph, r))
|
||||
}
|
||||
}
|
||||
|
||||
return a.Objects([]T{}, accessToken)
|
||||
}
|
||||
29
api/server/internal/server/papitemplate/get.go
Normal file
29
api/server/internal/server/papitemplate/get.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package papitemplate
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *ProtectedAPI[T]) get(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
|
||||
ctx := r.Context()
|
||||
objectRef, err := a.Cph.GetRef(r)
|
||||
if err != nil {
|
||||
a.Logger.Warn("Failed to restore object reference", zap.Error(err), mutil.PLog(a.Cph, r))
|
||||
return response.BadReference(a.Logger, a.Name(), a.Cph.Name(), a.Cph.GetID(r), err)
|
||||
}
|
||||
|
||||
var object T
|
||||
if err := a.DB.Get(ctx, *account.GetID(), objectRef, &object); err != nil {
|
||||
a.Logger.Warn("Failed to fetch object", zap.Error(err), mzap.StorableRef(account), mutil.PLog(a.Cph, r))
|
||||
return response.Auto(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
return a.Object(&object, accessToken)
|
||||
}
|
||||
42
api/server/internal/server/papitemplate/list.go
Normal file
42
api/server/internal/server/papitemplate/list.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package papitemplate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/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/server/interface/api/sresponse"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *ProtectedAPI[T]) list(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
|
||||
organizationRef, err := a.Oph.GetRef(r)
|
||||
if err != nil {
|
||||
a.Logger.Warn("Failed to restore organization reference", zap.Error(err), mutil.PLog(a.Oph, r))
|
||||
return response.BadReference(a.Logger, a.Name(), a.Oph.Name(), a.Oph.GetID(r), err)
|
||||
}
|
||||
parentRef, err := a.Pph.GetRef(r)
|
||||
if err != nil {
|
||||
a.Logger.Warn("Failed to restore parent reference", zap.Error(err), mutil.PLog(a.Pph, r))
|
||||
return response.BadReference(a.Logger, a.Name(), a.Pph.Name(), a.Pph.GetID(r), err)
|
||||
}
|
||||
|
||||
cursor, err := mutil.GetViewCursor(a.Logger, r)
|
||||
if err != nil {
|
||||
a.Logger.Warn("Failed to decode view cursor", zap.Error(err))
|
||||
return response.Auto(a.Logger, a.Name(), err)
|
||||
}
|
||||
objects, err := a.DB.List(r.Context(), *account.GetID(), organizationRef, parentRef, cursor)
|
||||
if err != nil {
|
||||
if !errors.Is(err, merrors.ErrNoData) {
|
||||
a.Logger.Warn("Failed to list objects", zap.Error(err), mutil.PLog(a.Pph, r))
|
||||
return response.Auto(a.Logger, a.Name(), err)
|
||||
} else {
|
||||
a.Logger.Debug("No objects available", zap.Error(err), mutil.PLog(a.Pph, r))
|
||||
}
|
||||
}
|
||||
return a.Objects(objects, accessToken)
|
||||
}
|
||||
88
api/server/internal/server/papitemplate/nconfig.go
Normal file
88
api/server/internal/server/papitemplate/nconfig.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package papitemplate
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/messaging"
|
||||
notifications "github.com/tech/sendico/pkg/messaging/envelope"
|
||||
model "github.com/tech/sendico/pkg/model/notification"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
// NotificationHandler is a function that processes an object of type T and returns an error.
|
||||
type NotificationHandler[T any] func(template T, actorAccountRef primitive.ObjectID) error
|
||||
|
||||
// sinkNotification is the default no-op strategy.
|
||||
func sinkNotification[T any](_ T, _ primitive.ObjectID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// NotificationConfig manages notifications for Create, Update, and Delete operations.
|
||||
type NotificationConfig[T any] struct {
|
||||
producer messaging.Producer
|
||||
// The factory now receives a NotificationAction so it knows which event is being processed.
|
||||
factory func(template T, actorAccountRef primitive.ObjectID, t model.NotificationAction) notifications.Envelope
|
||||
CreateNotification NotificationHandler[T]
|
||||
UpdateNotification NotificationHandler[T]
|
||||
NeedArchiveNotification bool
|
||||
ArchiveNotification NotificationHandler[T]
|
||||
NeedDeleteNotification bool
|
||||
DeleteNotification NotificationHandler[T]
|
||||
}
|
||||
|
||||
// NewNotificationConfig creates a new NotificationConfig with default (no-op) strategies.
|
||||
func NewNotificationConfig[T any](producer messaging.Producer) *NotificationConfig[T] {
|
||||
return &NotificationConfig[T]{
|
||||
producer: producer,
|
||||
factory: nil, // no factory by default
|
||||
CreateNotification: sinkNotification[T],
|
||||
UpdateNotification: sinkNotification[T],
|
||||
ArchiveNotification: sinkNotification[T],
|
||||
NeedArchiveNotification: false,
|
||||
DeleteNotification: sinkNotification[T],
|
||||
NeedDeleteNotification: false,
|
||||
}
|
||||
}
|
||||
|
||||
// WithNotifications sets the notification factory and switches all endpoints to the sending strategy.
|
||||
func (nc *NotificationConfig[T]) WithNotifications(factory func(template T, actorAccountRef primitive.ObjectID, typ model.NotificationAction) notifications.Envelope) *NotificationConfig[T] {
|
||||
nc.factory = factory
|
||||
// Build sending functions for each notification type.
|
||||
nc.CreateNotification = func(template T, actorAccountRef primitive.ObjectID) error {
|
||||
return nc.producer.SendMessage(factory(template, actorAccountRef, model.NACreated))
|
||||
}
|
||||
nc.UpdateNotification = func(template T, actorAccountRef primitive.ObjectID) error {
|
||||
return nc.producer.SendMessage(factory(template, actorAccountRef, model.NAUpdated))
|
||||
}
|
||||
nc.ArchiveNotification = func(template T, actorAccountRef primitive.ObjectID) error {
|
||||
return nc.producer.SendMessage(factory(template, actorAccountRef, model.NAArchived))
|
||||
}
|
||||
nc.NeedArchiveNotification = true
|
||||
nc.DeleteNotification = func(template T, actorAccountRef primitive.ObjectID) error {
|
||||
return nc.producer.SendMessage(factory(template, actorAccountRef, model.NADeleted))
|
||||
}
|
||||
nc.NeedDeleteNotification = true
|
||||
return nc
|
||||
}
|
||||
|
||||
// WithNoCreateNotification disables the create notification.
|
||||
func (nc *NotificationConfig[T]) WithNoCreateNotification() *NotificationConfig[T] {
|
||||
nc.CreateNotification = sinkNotification[T]
|
||||
return nc
|
||||
}
|
||||
|
||||
// WithNoUpdateNotification disables the update notification.
|
||||
func (nc *NotificationConfig[T]) WithNoUpdateNotification() *NotificationConfig[T] {
|
||||
nc.UpdateNotification = sinkNotification[T]
|
||||
return nc
|
||||
}
|
||||
|
||||
func (nc *NotificationConfig[T]) WithNoArchiveNotification() *NotificationConfig[T] {
|
||||
nc.ArchiveNotification = sinkNotification[T]
|
||||
return nc
|
||||
}
|
||||
|
||||
// WithNoDeleteNotification disables the delete notification.
|
||||
func (nc *NotificationConfig[T]) WithNoDeleteNotification() *NotificationConfig[T] {
|
||||
nc.DeleteNotification = sinkNotification[T]
|
||||
nc.NeedDeleteNotification = false
|
||||
return nc
|
||||
}
|
||||
33
api/server/internal/server/papitemplate/rconfig.go
Normal file
33
api/server/internal/server/papitemplate/rconfig.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package papitemplate
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/db/repository"
|
||||
"github.com/tech/sendico/pkg/db/repository/builder"
|
||||
"github.com/tech/sendico/server/interface/api/srequest"
|
||||
)
|
||||
|
||||
type ReorderRequestProcessor func(r *http.Request) (*srequest.ReorderX, builder.Query, error)
|
||||
|
||||
type ReorderConfig struct {
|
||||
DB ReorderDB
|
||||
ReqProcessor ReorderRequestProcessor
|
||||
}
|
||||
|
||||
func (cfg *PAPIConfig) WithReorderHandler(reorder ReorderConfig) *PAPIConfig {
|
||||
cfg.Reorder = &reorder
|
||||
if cfg.Reorder.ReqProcessor == nil {
|
||||
cfg.Reorder.ReqProcessor = defaultRequestProcessor
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func defaultRequestProcessor(r *http.Request) (*srequest.ReorderX, builder.Query, error) {
|
||||
var req srequest.ReorderXDefault
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &req.ReorderX, repository.OrgFilter(req.ParentRef), nil
|
||||
}
|
||||
33
api/server/internal/server/papitemplate/reorder.go
Normal file
33
api/server/internal/server/papitemplate/reorder.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package papitemplate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"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 (a *ProtectedAPI[T]) reorder(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
a.Logger.Debug("Processing reorder request...")
|
||||
req, filter, err := a.config.Reorder.ReqProcessor(r)
|
||||
if err != nil {
|
||||
a.Logger.Warn("Failed to decode tasks reorder request", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.BadPayload(a.Logger, a.Name(), err)
|
||||
}
|
||||
a.Logger.Debug("Moving objects", mzap.ObjRef("object_ref", req.ObjectRef), zap.Int("new_index", req.To))
|
||||
|
||||
if _, err := a.a.DBFactory().TransactionFactory().CreateTransaction().Execute(r.Context(), func(ctx context.Context) (any, error) {
|
||||
// reorder is not atomic, so wrappping into transaction
|
||||
return nil, a.config.Reorder.DB.Reorder(ctx, account.ID, req.ObjectRef, req.To, filter)
|
||||
}); err != nil {
|
||||
a.Logger.Warn("Failed to reorder tasks", zap.Error(err), mzap.ObjRef("object_ref", req.ObjectRef), zap.Int("to", req.To))
|
||||
return response.Auto(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
a.Logger.Debug("Reorder request processing complete")
|
||||
return response.Success(a.Logger)
|
||||
}
|
||||
19
api/server/internal/server/papitemplate/responses.go
Normal file
19
api/server/internal/server/papitemplate/responses.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package papitemplate
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
)
|
||||
|
||||
func (a *ProtectedAPI[T]) Objects(items []T, accessToken *sresponse.TokenData) http.HandlerFunc {
|
||||
return sresponse.ObjectsAuth(a.Logger, items, accessToken, a.Name())
|
||||
}
|
||||
|
||||
func (a *ProtectedAPI[T]) Object(item *T, accessToken *sresponse.TokenData) http.HandlerFunc {
|
||||
return sresponse.ObjectAuth(a.Logger, item, accessToken, a.Name())
|
||||
}
|
||||
|
||||
func (a *ProtectedAPI[T]) ObjectCreated(item *T, accessToken *sresponse.TokenData) http.HandlerFunc {
|
||||
return sresponse.ObjectAuthCreated(a.Logger, item, accessToken, a.Name())
|
||||
}
|
||||
203
api/server/internal/server/papitemplate/service.go
Normal file
203
api/server/internal/server/papitemplate/service.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package papitemplate
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
api "github.com/tech/sendico/pkg/api/http"
|
||||
notifications "github.com/tech/sendico/pkg/messaging/envelope"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
model "github.com/tech/sendico/pkg/model/notification"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
eapi "github.com/tech/sendico/server/interface/api"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type ProtectedAPI[T any] struct {
|
||||
Logger mlogger.Logger
|
||||
DB ProtectedDB[T]
|
||||
Oph mutil.ParamHelper // org param handler
|
||||
Pph mutil.ParamHelper // parent object param handler
|
||||
Cph mutil.ParamHelper // child object param handler
|
||||
resource mservice.Type
|
||||
a eapi.API
|
||||
config *PAPIConfig
|
||||
nconfig *NotificationConfig[*T]
|
||||
}
|
||||
|
||||
func (a *ProtectedAPI[_]) Name() mservice.Type {
|
||||
return a.resource
|
||||
}
|
||||
|
||||
func (_ *ProtectedAPI[_]) Finish(_ context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *ProtectedAPI[T]) Build() *ProtectedAPI[T] {
|
||||
createHandler := a.config.CreateResolver(a.create)
|
||||
if createHandler != nil {
|
||||
a.a.Register().AccountHandler(a.Name(), a.Oph.AddRef("/"), api.Post, createHandler)
|
||||
}
|
||||
|
||||
listHandler := a.config.ListResolver(a.list)
|
||||
if listHandler != nil {
|
||||
a.a.Register().AccountHandler(a.Name(), a.Pph.AddRef(a.Oph.AddRef("/list")), api.Get, listHandler)
|
||||
}
|
||||
|
||||
getHandler := a.config.GetResolver(a.get)
|
||||
if getHandler != nil {
|
||||
a.a.Register().AccountHandler(a.Name(), a.Cph.AddRef("/"), api.Get, getHandler)
|
||||
}
|
||||
|
||||
updateHandler := a.config.UpdateResolver(a.update)
|
||||
if updateHandler != nil {
|
||||
a.a.Register().AccountHandler(a.Name(), "/", api.Put, updateHandler)
|
||||
}
|
||||
|
||||
deleteHandler := a.config.DeleteResolver(a.delete)
|
||||
if deleteHandler != nil {
|
||||
a.a.Register().AccountHandler(a.Name(), a.Cph.AddRef("/"), api.Delete, deleteHandler)
|
||||
}
|
||||
|
||||
archiveHandler := a.config.ArchiveResolver(a.archive)
|
||||
if archiveHandler != nil {
|
||||
a.a.Register().AccountHandler(a.Name(), a.Cph.AddRef(a.Oph.AddRef("/archive")), api.Get, archiveHandler)
|
||||
}
|
||||
|
||||
if a.config.Reorder != nil {
|
||||
a.a.Register().AccountHandler(a.Name(), "/reorder", api.Post, a.reorder)
|
||||
}
|
||||
|
||||
if a.config.Taggable != nil {
|
||||
a.a.Register().AccountHandler(a.Name(), "/tags/add", api.Put, a.addTag)
|
||||
a.a.Register().AccountHandler(a.Name(), "/tags/add", api.Post, a.addTags)
|
||||
a.a.Register().AccountHandler(a.Name(), "/tags", api.Delete, a.removeTag)
|
||||
a.a.Register().AccountHandler(a.Name(), "/tags/all", api.Delete, a.removeAllTags)
|
||||
a.a.Register().AccountHandler(a.Name(), "/tags/set", api.Post, a.setTags)
|
||||
a.a.Register().AccountHandler(a.Name(), "/tags", api.Get, a.getTags)
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *ProtectedAPI[T]) WithNotifications(factory func(template *T, actorAccountRef primitive.ObjectID, t model.NotificationAction) notifications.Envelope) *ProtectedAPI[T] {
|
||||
a.nconfig.WithNotifications(factory)
|
||||
a.Logger.Info("Notificatons handler installed")
|
||||
return a
|
||||
}
|
||||
|
||||
// WithNoCreateNotification disables the create notification.
|
||||
func (a *ProtectedAPI[T]) WithNoCreateNotification() *ProtectedAPI[T] {
|
||||
a.nconfig.WithNoCreateNotification()
|
||||
a.Logger.Info("Object creation notificaton disabled")
|
||||
return a
|
||||
}
|
||||
|
||||
// WithNoUpdateNotification disables the update notification.
|
||||
func (a *ProtectedAPI[T]) WithNoUpdateNotification() *ProtectedAPI[T] {
|
||||
a.nconfig.WithNoUpdateNotification()
|
||||
a.Logger.Info("Object update notificaton disabled")
|
||||
return a
|
||||
}
|
||||
|
||||
// WithNoDeleteNotification disables the delete notification.
|
||||
func (a *ProtectedAPI[T]) WithNoDeleteNotification() *ProtectedAPI[T] {
|
||||
a.nconfig.WithNoDeleteNotification()
|
||||
a.Logger.Info("Object deletion notificaton disabled")
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *ProtectedAPI[T]) WithNoCreate() *ProtectedAPI[T] {
|
||||
a.config.WithNoCreate()
|
||||
a.Logger.Info("Create handler disabled")
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *ProtectedAPI[T]) WithCreateHandler(handler sresponse.AccountHandlerFunc) *ProtectedAPI[T] {
|
||||
a.config.WithCreateHandler(handler)
|
||||
a.Logger.Info("Create handler overridden")
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *ProtectedAPI[T]) WithNoList() *ProtectedAPI[T] {
|
||||
a.config.WithNoList()
|
||||
a.Logger.Info("List handler disabled")
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *ProtectedAPI[T]) WithListHandler(handler sresponse.AccountHandlerFunc) *ProtectedAPI[T] {
|
||||
a.config.WithListHandler(handler)
|
||||
a.Logger.Info("List handler overridden")
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *ProtectedAPI[T]) WithNoGet() *ProtectedAPI[T] {
|
||||
a.config.WithNoGet()
|
||||
a.Logger.Info("Get handler disabled")
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *ProtectedAPI[T]) WithGetHandler(handler sresponse.AccountHandlerFunc) *ProtectedAPI[T] {
|
||||
a.config.WithGetHandler(handler)
|
||||
a.Logger.Info("Get handler overridden")
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *ProtectedAPI[T]) WithReorderHandler(reorder ReorderConfig) *ProtectedAPI[T] {
|
||||
a.config.WithReorderHandler(reorder)
|
||||
a.Logger.Info("Reorder handler installed")
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *ProtectedAPI[T]) WithTaggableHandler(taggable TaggableConfig) *ProtectedAPI[T] {
|
||||
a.config.WithTaggableHandler(taggable)
|
||||
a.Logger.Info("Taggable handlers installed")
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *ProtectedAPI[T]) WithNoUpdate() *ProtectedAPI[T] {
|
||||
a.config.WithNoUpdate()
|
||||
a.Logger.Info("Update handler disabled")
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *ProtectedAPI[T]) WithUpdateHandler(handler sresponse.AccountHandlerFunc) *ProtectedAPI[T] {
|
||||
a.config.WithUpdateHandler(handler)
|
||||
a.Logger.Info("Update handler overridden")
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *ProtectedAPI[T]) WithNoDelete() *ProtectedAPI[T] {
|
||||
a.config.WithNoDelete()
|
||||
a.Logger.Info("Delete handler disabled")
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *ProtectedAPI[T]) WithDeleteHandler(handler sresponse.AccountHandlerFunc) *ProtectedAPI[T] {
|
||||
a.config.WithDeleteHandler(handler)
|
||||
a.Logger.Info("Delete handler overriden")
|
||||
return a
|
||||
}
|
||||
|
||||
func CreateAPI[T any](a eapi.API, dbFactory func() (ProtectedDB[T], error), parent, resource mservice.Type) (*ProtectedAPI[T], error) {
|
||||
p := &ProtectedAPI[T]{
|
||||
Logger: a.Logger().Named(resource),
|
||||
Oph: mutil.CreatePH("org"), // to avoid collision with organizaitons_ref when
|
||||
Pph: mutil.CreatePH(parent),
|
||||
resource: resource,
|
||||
Cph: mutil.CreatePH(resource),
|
||||
a: a,
|
||||
config: NewConfig(),
|
||||
nconfig: NewNotificationConfig[*T](a.Register().Messaging().Producer()),
|
||||
}
|
||||
|
||||
var err error
|
||||
if p.DB, err = dbFactory(); err != nil {
|
||||
p.Logger.Error("Failed to create protected database", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
122
api/server/internal/server/papitemplate/taggable.go
Normal file
122
api/server/internal/server/papitemplate/taggable.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package papitemplate
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"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 (a *ProtectedAPI[T]) addTag(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
a.Logger.Debug("Processing add tag request...")
|
||||
req, err := a.config.Taggable.AddTagReqProcessor(r)
|
||||
if err != nil {
|
||||
a.Logger.Warn("Failed to decode add tag request", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.BadPayload(a.Logger, a.Name(), err)
|
||||
}
|
||||
a.Logger.Debug("Adding tag to object", mzap.ObjRef("object_ref", req.ObjectRef), mzap.ObjRef("tag_ref", req.TagRef))
|
||||
|
||||
if err := a.config.Taggable.DB.AddTag(r.Context(), account.ID, req.ObjectRef, req.TagRef); err != nil {
|
||||
a.Logger.Warn("Failed to add tag to object", zap.Error(err), mzap.ObjRef("object_ref", req.ObjectRef), mzap.ObjRef("tag_ref", req.TagRef))
|
||||
return response.Auto(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
a.Logger.Debug("Add tag request processing complete")
|
||||
return response.Success(a.Logger)
|
||||
}
|
||||
|
||||
func (a *ProtectedAPI[T]) addTags(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
a.Logger.Debug("Processing add tags request...")
|
||||
req, err := a.config.Taggable.AddTagsReqProcessor(r)
|
||||
if err != nil {
|
||||
a.Logger.Warn("Failed to decode add tags request", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.BadPayload(a.Logger, a.Name(), err)
|
||||
}
|
||||
a.Logger.Debug("Adding tags to object", mzap.ObjRef("object_ref", req.ObjectRef), zap.Int("tag_count", len(req.TagRefs)))
|
||||
|
||||
if err := a.config.Taggable.DB.AddTags(r.Context(), account.ID, req.ObjectRef, req.TagRefs); err != nil {
|
||||
a.Logger.Warn("Failed to add tags to object", zap.Error(err), mzap.ObjRef("object_ref", req.ObjectRef))
|
||||
return response.Auto(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
a.Logger.Debug("Add tags request processing complete")
|
||||
return response.Success(a.Logger)
|
||||
}
|
||||
|
||||
func (a *ProtectedAPI[T]) removeTag(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
a.Logger.Debug("Processing remove tag request...")
|
||||
req, err := a.config.Taggable.RemoveTagReqProcessor(r)
|
||||
if err != nil {
|
||||
a.Logger.Warn("Failed to decode remove tag request", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.BadPayload(a.Logger, a.Name(), err)
|
||||
}
|
||||
a.Logger.Debug("Removing tag from object", mzap.ObjRef("object_ref", req.ObjectRef), mzap.ObjRef("tag_ref", req.TagRef))
|
||||
|
||||
if err := a.config.Taggable.DB.RemoveTag(r.Context(), account.ID, req.ObjectRef, req.TagRef); err != nil {
|
||||
a.Logger.Warn("Failed to remove tag from object", zap.Error(err), mzap.ObjRef("object_ref", req.ObjectRef), mzap.ObjRef("tag_ref", req.TagRef))
|
||||
return response.Auto(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
a.Logger.Debug("Remove tag request processing complete")
|
||||
return response.Success(a.Logger)
|
||||
}
|
||||
|
||||
func (a *ProtectedAPI[T]) removeAllTags(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
a.Logger.Debug("Processing remove all tags request...")
|
||||
req, err := a.config.Taggable.RemoveAllTagsReqProcessor(r)
|
||||
if err != nil {
|
||||
a.Logger.Warn("Failed to decode remove all tags request", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.BadPayload(a.Logger, a.Name(), err)
|
||||
}
|
||||
a.Logger.Debug("Removing all tags from object", mzap.ObjRef("object_ref", req.ObjectRef))
|
||||
|
||||
if err := a.config.Taggable.DB.RemoveAllTags(r.Context(), account.ID, req.ObjectRef); err != nil {
|
||||
a.Logger.Warn("Failed to remove all tags from object", zap.Error(err), mzap.ObjRef("object_ref", req.ObjectRef))
|
||||
return response.Auto(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
a.Logger.Debug("Remove all tags request processing complete")
|
||||
return response.Success(a.Logger)
|
||||
}
|
||||
|
||||
func (a *ProtectedAPI[T]) setTags(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
a.Logger.Debug("Processing set tags request...")
|
||||
req, err := a.config.Taggable.SetTagsReqProcessor(r)
|
||||
if err != nil {
|
||||
a.Logger.Warn("Failed to decode set tags request", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.BadPayload(a.Logger, a.Name(), err)
|
||||
}
|
||||
a.Logger.Debug("Setting tags for object", mzap.ObjRef("object_ref", req.ObjectRef), zap.Int("tag_count", len(req.TagRefs)))
|
||||
|
||||
if err := a.config.Taggable.DB.SetTags(r.Context(), account.ID, req.ObjectRef, req.TagRefs); err != nil {
|
||||
a.Logger.Warn("Failed to set tags for object", zap.Error(err), mzap.ObjRef("object_ref", req.ObjectRef))
|
||||
return response.Auto(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
a.Logger.Debug("Set tags request processing complete")
|
||||
return response.Success(a.Logger)
|
||||
}
|
||||
|
||||
func (a *ProtectedAPI[T]) getTags(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
a.Logger.Debug("Processing get tags request...")
|
||||
req, err := a.config.Taggable.GetTagsReqProcessor(r)
|
||||
if err != nil {
|
||||
a.Logger.Warn("Failed to decode get tags request", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.BadPayload(a.Logger, a.Name(), err)
|
||||
}
|
||||
a.Logger.Debug("Getting tags for object", mzap.ObjRef("object_ref", req.ObjectRef))
|
||||
|
||||
tagRefs, err := a.config.Taggable.DB.GetTags(r.Context(), account.ID, req.ObjectRef)
|
||||
if err != nil {
|
||||
a.Logger.Warn("Failed to get tags for object", zap.Error(err), mzap.ObjRef("object_ref", req.ObjectRef))
|
||||
return response.Auto(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
a.Logger.Debug("Get tags request processing complete", zap.Int("tag_count", len(tagRefs)))
|
||||
return response.Ok(a.Logger, map[string]interface{}{
|
||||
"tagRefs": tagRefs,
|
||||
})
|
||||
}
|
||||
80
api/server/internal/server/papitemplate/tconfig.go
Normal file
80
api/server/internal/server/papitemplate/tconfig.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package papitemplate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/server/interface/api/srequest"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
type (
|
||||
TaggableSingleRequestProcessor func(r *http.Request) (*srequest.TaggableSingle, error)
|
||||
TaggableMultipleRequestProcessor func(r *http.Request) (*srequest.TaggableMultiple, error)
|
||||
TaggableObjectRequestProcessor func(r *http.Request) (*srequest.TaggableObject, error)
|
||||
)
|
||||
|
||||
// TaggableDB interface defines the required methods for tag operations
|
||||
type TaggableDB interface {
|
||||
AddTag(ctx context.Context, accountRef, objectRef, tagRef primitive.ObjectID) error
|
||||
AddTags(ctx context.Context, accountRef, objectRef primitive.ObjectID, tagRefs []primitive.ObjectID) error
|
||||
RemoveTag(ctx context.Context, accountRef, objectRef, tagRef primitive.ObjectID) error
|
||||
RemoveAllTags(ctx context.Context, accountRef, objectRef primitive.ObjectID) error
|
||||
SetTags(ctx context.Context, accountRef, objectRef primitive.ObjectID, tagRefs []primitive.ObjectID) error
|
||||
GetTags(ctx context.Context, accountRef, objectRef primitive.ObjectID) ([]primitive.ObjectID, error)
|
||||
}
|
||||
|
||||
type TaggableConfig struct {
|
||||
DB TaggableDB
|
||||
AddTagReqProcessor TaggableSingleRequestProcessor
|
||||
AddTagsReqProcessor TaggableMultipleRequestProcessor
|
||||
RemoveTagReqProcessor TaggableSingleRequestProcessor
|
||||
RemoveAllTagsReqProcessor TaggableObjectRequestProcessor
|
||||
SetTagsReqProcessor TaggableMultipleRequestProcessor
|
||||
GetTagsReqProcessor TaggableObjectRequestProcessor
|
||||
}
|
||||
|
||||
func (cfg *PAPIConfig) WithTaggableHandler(taggable TaggableConfig) *PAPIConfig {
|
||||
cfg.Taggable = &taggable
|
||||
if cfg.Taggable.AddTagReqProcessor == nil {
|
||||
cfg.Taggable.AddTagReqProcessor = defaultTaggableSingleRequestProcessor
|
||||
}
|
||||
if cfg.Taggable.AddTagsReqProcessor == nil {
|
||||
cfg.Taggable.AddTagsReqProcessor = defaultTaggableMultipleRequestProcessor
|
||||
}
|
||||
if cfg.Taggable.RemoveTagReqProcessor == nil {
|
||||
cfg.Taggable.RemoveTagReqProcessor = defaultTaggableSingleRequestProcessor
|
||||
}
|
||||
if cfg.Taggable.RemoveAllTagsReqProcessor == nil {
|
||||
cfg.Taggable.RemoveAllTagsReqProcessor = defaultTaggableObjectRequestProcessor
|
||||
}
|
||||
if cfg.Taggable.SetTagsReqProcessor == nil {
|
||||
cfg.Taggable.SetTagsReqProcessor = defaultTaggableMultipleRequestProcessor
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func defaultTaggableSingleRequestProcessor(r *http.Request) (*srequest.TaggableSingle, error) {
|
||||
var req srequest.TaggableSingle
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &req, nil
|
||||
}
|
||||
|
||||
func defaultTaggableMultipleRequestProcessor(r *http.Request) (*srequest.TaggableMultiple, error) {
|
||||
var req srequest.TaggableMultiple
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &req, nil
|
||||
}
|
||||
|
||||
func defaultTaggableObjectRequestProcessor(r *http.Request) (*srequest.TaggableObject, error) {
|
||||
var req srequest.TaggableObject
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &req, nil
|
||||
}
|
||||
31
api/server/internal/server/papitemplate/update.go
Normal file
31
api/server/internal/server/papitemplate/update.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package papitemplate
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"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 (a *ProtectedAPI[T]) update(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
|
||||
var object T
|
||||
if err := json.NewDecoder(r.Body).Decode(&object); err != nil {
|
||||
a.Logger.Warn("Failed to decode object when updating settings", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.BadPayload(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
if err := a.DB.Update(r.Context(), *account.GetID(), &object); err != nil {
|
||||
a.Logger.Warn("Error updating object", zap.Error(err), mzap.StorableRef(account))
|
||||
return response.Auto(a.Logger, a.Name(), err)
|
||||
}
|
||||
|
||||
if err := a.nconfig.UpdateNotification(&object, *account.GetID()); err != nil {
|
||||
a.Logger.Warn("Failed to send creation notification", zap.Error(err))
|
||||
}
|
||||
|
||||
return a.Object(&object, accessToken)
|
||||
}
|
||||
94
api/server/internal/server/permissionsimp/changepolicies.go
Normal file
94
api/server/internal/server/permissionsimp/changepolicies.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package permissionsimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/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/server/interface/api/srequest"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *PermissionsAPI) changePolicies(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
var req srequest.ChangePolicies
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
a.logger.Warn("Failed to decode role policies change request", zap.Error(err))
|
||||
return response.BadPayload(a.logger, mservice.Roles, err)
|
||||
}
|
||||
if req.Add != nil && req.Remove != nil {
|
||||
for _, addItem := range *req.Add {
|
||||
for _, removeItem := range *req.Remove {
|
||||
if addItem == removeItem {
|
||||
a.logger.Debug("Duplicate policies found, rejecting policies update request", zap.Any("add", &addItem), zap.Any("remove", &removeItem))
|
||||
return response.BadRequest(a.logger, a.Name(), "invalid_policies_change_request", "duplicate policies found in 'add' and 'remove' fields")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := a.tf.CreateTransaction().Execute(r.Context(), func(ctx context.Context) (any, error) {
|
||||
return a.changePoliciesImp(ctx, account, &req)
|
||||
}); err != nil {
|
||||
a.logger.Debug("Rolling policies changes back", zap.Error(err))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
return response.Success(a.logger)
|
||||
}
|
||||
|
||||
func (a *PermissionsAPI) changePoliciesImp(
|
||||
ctx context.Context,
|
||||
account *model.Account,
|
||||
req *srequest.ChangePolicies,
|
||||
) (any, error) {
|
||||
// helper that runs through each change-item, enforces the right action,
|
||||
// and then calls apply(item) if enforcement passes.
|
||||
handle := func(items *[]model.RolePolicy, action model.Action, opName string, apply func(context.Context, *model.RolePolicy) error) error {
|
||||
for _, it := range *items {
|
||||
// 1) permission check
|
||||
ok, err := a.enforcer.Enforce(ctx, a.policiesPermissionRef, account.ID, it.OrganizationRef, primitive.NilObjectID, action)
|
||||
if err != nil {
|
||||
a.logger.Warn(fmt.Sprintf("failed to enforce permission while %s policy", opName), zap.Error(err), zap.Any(opName, &it))
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
a.logger.Debug(fmt.Sprintf("policy %s denied", opName))
|
||||
return merrors.AccessDenied(mservice.Policies, string(action), primitive.NilObjectID)
|
||||
}
|
||||
|
||||
// 2) perform the add/remove
|
||||
if err := apply(ctx, &it); err != nil {
|
||||
a.logger.Warn(fmt.Sprintf("failed to %s role policy", opName), zap.Error(err), zap.Any("policy", &it))
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// REMOVE
|
||||
if req.Remove != nil {
|
||||
if err := handle(req.Remove, model.ActionDelete, "remove", func(ctx context.Context, it *model.RolePolicy) error {
|
||||
return a.auth.Permission().RevokeFromRole(ctx, it)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// ADD
|
||||
if req.Add != nil {
|
||||
if err := handle(req.Add, model.ActionCreate, "add", func(ctx context.Context, it *model.RolePolicy) error {
|
||||
return a.auth.Permission().GrantToRole(ctx, it)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
85
api/server/internal/server/permissionsimp/changerole.go
Normal file
85
api/server/internal/server/permissionsimp/changerole.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package permissionsimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"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/srequest"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *PermissionsAPI) changeRole(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
orgRef, err := mutil.GetOrganizationRef(r)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to restore organization reference", zap.Error(err), zap.String("organization_ref", mutil.GetOrganizationID(r)))
|
||||
return response.BadReference(a.logger, a.Name(), mutil.OrganizationRefName(), mutil.GetOrganizationID(r), err)
|
||||
}
|
||||
|
||||
var req srequest.ChangeRole
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
a.logger.Warn("Failed to decode change role request", zap.Error(err))
|
||||
return response.BadPayload(a.logger, mservice.Roles, err)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
res, err := a.enforcer.Enforce(ctx, a.rolesPermissionRef, account.ID, orgRef, req.AccountRef, model.ActionUpdate)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to check permissions while assigning new role", zap.Error(err),
|
||||
mzap.ObjRef("requesting_account_ref", account.ID), mzap.ObjRef("account_ref", req.AccountRef),
|
||||
mzap.ObjRef("role_description_ref", req.NewRoleDescriptionRef))
|
||||
}
|
||||
if !res {
|
||||
a.logger.Debug("Permission denied to set new role", mzap.ObjRef("requesting_account_ref", account.ID),
|
||||
mzap.ObjRef("account_ref", req.AccountRef), mzap.ObjRef("role_description_ref", req.NewRoleDescriptionRef))
|
||||
return response.AccessDenied(a.logger, a.Name(), "no permission to change user roles")
|
||||
}
|
||||
|
||||
var roleDescription model.RoleDescription
|
||||
if err := a.rdb.Get(ctx, req.NewRoleDescriptionRef, &roleDescription); err != nil {
|
||||
a.logger.Warn("Failed to fetch and validate role description", zap.Error(err), mzap.ObjRef("requesting_account_ref", account.ID),
|
||||
mzap.ObjRef("account_ref", req.AccountRef), mzap.ObjRef("role_description_ref", req.NewRoleDescriptionRef))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
return a.changeRoleImp(ctx, &req, orgRef, account)
|
||||
}
|
||||
|
||||
func (a *PermissionsAPI) changeRoleImp(ctx context.Context, req *srequest.ChangeRole, organizationRef primitive.ObjectID, account *model.Account) http.HandlerFunc {
|
||||
roles, err := a.enforcer.GetRoles(ctx, req.AccountRef, organizationRef)
|
||||
// TODO: add check that role revocation won't leave venue without the owner
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to fetch account roles", zap.Error(err), mzap.ObjRef("requesting_account_ref", account.ID),
|
||||
mzap.ObjRef("account_ref", req.AccountRef), mzap.ObjRef("role_description_ref", req.NewRoleDescriptionRef))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
for _, role := range roles {
|
||||
if err := a.manager.Role().Revoke(ctx, role.DescriptionRef, req.AccountRef, organizationRef); err != nil {
|
||||
a.logger.Warn("Failed to revoke old role", zap.Error(err), mzap.ObjRef("requesting_account_ref", account.ID),
|
||||
mzap.ObjRef("account_ref", req.AccountRef), mzap.ObjRef("role_description_ref", req.NewRoleDescriptionRef),
|
||||
mzap.ObjRef("role_ref", role.DescriptionRef))
|
||||
// continue...
|
||||
}
|
||||
}
|
||||
|
||||
role := model.Role{
|
||||
AccountRef: req.AccountRef,
|
||||
OrganizationRef: organizationRef,
|
||||
DescriptionRef: req.NewRoleDescriptionRef,
|
||||
}
|
||||
if err := a.manager.Role().Assign(ctx, &role); err != nil {
|
||||
a.logger.Warn("Failed to assign new role", zap.Error(err), mzap.ObjRef("requesting_account_ref", account.ID),
|
||||
mzap.ObjRef("account_ref", req.AccountRef), mzap.ObjRef("role_description_ref", req.NewRoleDescriptionRef),
|
||||
mzap.ObjRef("role_ref", req.NewRoleDescriptionRef))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
return response.Success(a.logger)
|
||||
}
|
||||
29
api/server/internal/server/permissionsimp/createrole.go
Normal file
29
api/server/internal/server/permissionsimp/createrole.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package permissionsimp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"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"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *PermissionsAPI) createRoleDescription(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
var req model.RoleDescription
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
a.logger.Warn("Failed to decode role creation request", zap.Error(err))
|
||||
return response.BadPayload(a.logger, mservice.Roles, err)
|
||||
}
|
||||
|
||||
if err := a.rdb.Create(r.Context(), &req); err != nil {
|
||||
a.logger.Warn("Failed to create role description", zap.Error(err),
|
||||
mzap.ObjRef("requesting_account_ref", account.ID), zap.String("role_name", req.Name))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
return response.Success(a.logger)
|
||||
}
|
||||
28
api/server/internal/server/permissionsimp/deleterole.go
Normal file
28
api/server/internal/server/permissionsimp/deleterole.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package permissionsimp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *PermissionsAPI) deleteRoleDescription(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
roleDescriptionRef, err := a.Rph.GetRef(r)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to restore object reference", zap.Error(err), mutil.PLog(a.Rph, r))
|
||||
return response.BadReference(a.logger, a.Name(), a.Rph.Name(), a.Rph.GetID(r), err)
|
||||
}
|
||||
|
||||
if err := a.rdb.Delete(r.Context(), roleDescriptionRef); err != nil {
|
||||
a.logger.Warn("Failed to delete role description", zap.Error(err),
|
||||
mzap.ObjRef("requesting_account_ref", account.ID), mzap.ObjRef("role_ref", roleDescriptionRef))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
return response.Success(a.logger)
|
||||
}
|
||||
51
api/server/internal/server/permissionsimp/get.go
Normal file
51
api/server/internal/server/permissionsimp/get.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package permissionsimp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *PermissionsAPI) get(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
|
||||
orgRef, err := mutil.GetOrganizationRef(r)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to restore organization reference", zap.Error(err), zap.String("organization_ref", mutil.GetOrganizationID(r)))
|
||||
return response.BadReference(a.logger, a.Name(), mutil.OrganizationRefName(), mutil.GetOrganizationID(r), err)
|
||||
}
|
||||
ctx := r.Context()
|
||||
roles, permissions, err := a.enforcer.GetPermissions(ctx, *account.GetID(), orgRef)
|
||||
if len(roles) == 0 {
|
||||
a.logger.Warn("No roles defined for account", mzap.StorableRef(account), mzap.ObjRef("organization_ref", orgRef))
|
||||
return response.AccessDenied(a.logger, a.Name(), "User has no roles assigned")
|
||||
}
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to fetch account policies", zap.Error(err), mzap.ObjRef("organization_ref", orgRef))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
roleDescs, err := a.rdb.List(ctx, orgRef, nil)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to fetch organization roles", mzap.ObjRef("organization_ref", orgRef))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
policies, err := a.getRolePolicies(ctx, roleDescs)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to fetch roles policies", zap.Error(err))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
permDescs, err := a.pdb.All(ctx, orgRef)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to fetch organization permissions", mzap.ObjRef("organization_ref", orgRef))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
return sresponse.Permisssions(a.logger,
|
||||
roleDescs, permDescs,
|
||||
roles, policies, permissions,
|
||||
accessToken,
|
||||
)
|
||||
}
|
||||
75
api/server/internal/server/permissionsimp/get_all.go
Normal file
75
api/server/internal/server/permissionsimp/get_all.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package permissionsimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *PermissionsAPI) getRolePolicies(ctx context.Context, roles []model.RoleDescription) ([]model.RolePolicy, error) {
|
||||
policies := make([]model.RolePolicy, 0)
|
||||
|
||||
uniqueRefs := make(map[primitive.ObjectID]struct{})
|
||||
for _, role := range roles {
|
||||
uniqueRefs[*role.GetID()] = struct{}{}
|
||||
}
|
||||
|
||||
for ref := range uniqueRefs {
|
||||
plcs, err := a.auth.Permission().GetPolicies(ctx, ref)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to fetch role permissions", zap.Error(err), mzap.ObjRef("role_ref", ref))
|
||||
return nil, err
|
||||
}
|
||||
policies = append(policies, plcs...)
|
||||
}
|
||||
return policies, nil
|
||||
}
|
||||
|
||||
func (a *PermissionsAPI) getAll(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
|
||||
orgRef, err := mutil.GetOrganizationRef(r)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to restore organization reference", zap.Error(err), zap.String("organization_ref", mutil.GetOrganizationID(r)))
|
||||
return response.BadReference(a.logger, a.Name(), mutil.ObjRefName(), mutil.GetOrganizationID(r), err)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
res, err := a.enforcer.Enforce(ctx, a.rolesPermissionRef, account.ID, orgRef, primitive.NilObjectID, model.ActionRead)
|
||||
if err != nil {
|
||||
a.logger.Debug("Error occurred", zap.Error(err))
|
||||
response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
if !res {
|
||||
a.logger.Debug("Access to permissions denied")
|
||||
response.AccessDenied(a.logger, a.Name(), "no required permissiosn to read account permissions data")
|
||||
}
|
||||
|
||||
var org model.Organization
|
||||
if err := a.db.Get(ctx, account.ID, orgRef, &org); err != nil {
|
||||
a.logger.Warn("Failed to fetch venue", zap.Error(err), mzap.ObjRef("organization_ref", orgRef))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
roles := make([]model.Role, 0)
|
||||
permissions := make([]model.Permission, 0)
|
||||
for _, employee := range org.Members {
|
||||
rls, prms, err := a.enforcer.GetPermissions(ctx, employee, orgRef)
|
||||
if len(rls) == 0 {
|
||||
a.logger.Warn("No roles defined for account", mzap.ObjRef("employee_ref", employee), mzap.ObjRef("organization_ref", orgRef))
|
||||
return response.NotFound(a.logger, a.Name(), "User has no roles assigned")
|
||||
}
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to fetch account policies", zap.Error(err), mzap.ObjRef("organization_ref", orgRef))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
roles = append(roles, rls...)
|
||||
permissions = append(permissions, prms...)
|
||||
}
|
||||
|
||||
return a.permissions(ctx, orgRef, roles, permissions, accessToken)
|
||||
}
|
||||
33
api/server/internal/server/permissionsimp/permissions.go
Normal file
33
api/server/internal/server/permissionsimp/permissions.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package permissionsimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *PermissionsAPI) permissions(ctx context.Context, organizationRef primitive.ObjectID, roles []model.Role, permissions []model.Permission, accessToken *sresponse.TokenData) http.HandlerFunc {
|
||||
roleDescs, err := a.rdb.List(ctx, organizationRef, nil)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to fetch organization roles", zap.Error(err), mzap.ObjRef("organization_ref", organizationRef))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
permDescs, err := a.pdb.All(ctx, organizationRef)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to fetch organization permissions", zap.Error(err), mzap.ObjRef("organization_ref", organizationRef))
|
||||
return response.Internal(a.logger, a.Name(), err)
|
||||
}
|
||||
policies, err := a.getRolePolicies(ctx, roleDescs)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to fetch roles policies", zap.Error(err))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
return sresponse.Permisssions(a.logger, roleDescs, permDescs, roles, policies, permissions, accessToken)
|
||||
}
|
||||
87
api/server/internal/server/permissionsimp/service.go
Normal file
87
api/server/internal/server/permissionsimp/service.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package permissionsimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
api "github.com/tech/sendico/pkg/api/http"
|
||||
"github.com/tech/sendico/pkg/auth"
|
||||
"github.com/tech/sendico/pkg/db/organization"
|
||||
"github.com/tech/sendico/pkg/db/policy"
|
||||
"github.com/tech/sendico/pkg/db/role"
|
||||
"github.com/tech/sendico/pkg/db/transaction"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
eapi "github.com/tech/sendico/server/interface/api"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type PermissionsAPI struct {
|
||||
logger mlogger.Logger
|
||||
db organization.DB
|
||||
pdb policy.DB
|
||||
rdb role.DB
|
||||
enforcer auth.Enforcer
|
||||
manager auth.Manager
|
||||
rolesPermissionRef primitive.ObjectID
|
||||
policiesPermissionRef primitive.ObjectID
|
||||
Rph mutil.ParamHelper
|
||||
tf transaction.Factory
|
||||
auth auth.Manager
|
||||
}
|
||||
|
||||
func (a *PermissionsAPI) Name() mservice.Type {
|
||||
return mservice.Permissions
|
||||
}
|
||||
|
||||
func (a *PermissionsAPI) Finish(_ context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreateAPI(a eapi.API) (*PermissionsAPI, error) {
|
||||
p := &PermissionsAPI{
|
||||
enforcer: a.Permissions().Enforcer(),
|
||||
manager: a.Permissions().Manager(),
|
||||
Rph: mutil.CreatePH("role"),
|
||||
tf: a.DBFactory().TransactionFactory(),
|
||||
auth: a.Permissions().Manager(),
|
||||
}
|
||||
p.logger = a.Logger().Named(p.Name())
|
||||
|
||||
var err error
|
||||
if p.db, err = a.DBFactory().NewOrganizationDB(); err != nil {
|
||||
p.logger.Error("Failed to create organizations database", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
if p.rdb, err = a.DBFactory().NewRolesDB(); err != nil {
|
||||
p.logger.Error("Failed to create roles database", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
if p.pdb, err = a.DBFactory().NewPoliciesDB(); err != nil {
|
||||
p.logger.Error("Failed to create policies database", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var pdesc model.PolicyDescription
|
||||
if err := p.pdb.GetBuiltInPolicy(context.Background(), mservice.Roles, &pdesc); err != nil {
|
||||
p.logger.Warn("Failed to fetch roles management permission description", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
p.rolesPermissionRef = pdesc.ID
|
||||
if err := p.pdb.GetBuiltInPolicy(context.Background(), mservice.Policies, &pdesc); err != nil {
|
||||
p.logger.Warn("Failed to fetch policies management permission description", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
p.policiesPermissionRef = pdesc.ID
|
||||
|
||||
a.Register().AccountHandler(p.Name(), mutil.AddOrganizaztionRef("/"), api.Get, p.get)
|
||||
a.Register().AccountHandler(p.Name(), mutil.AddOrganizaztionRef("/all"), api.Get, p.getAll)
|
||||
a.Register().AccountHandler(p.Name(), mutil.AddOrganizaztionRef("/change_role"), api.Post, p.changeRole)
|
||||
a.Register().AccountHandler(p.Name(), "/policies", api.Put, p.changePolicies)
|
||||
a.Register().AccountHandler(p.Name(), "/role", api.Post, p.createRoleDescription)
|
||||
a.Register().AccountHandler(p.Name(), p.Rph.AddRef("/role"), api.Delete, p.deleteRoleDescription)
|
||||
|
||||
return p, nil
|
||||
}
|
||||
11
api/server/internal/server/server.go
Executable file
11
api/server/internal/server/server.go
Executable file
@@ -0,0 +1,11 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/server"
|
||||
serverimp "github.com/tech/sendico/server/internal/server/internal"
|
||||
)
|
||||
|
||||
func Create(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
|
||||
return serverimp.Create(logger, file, debug)
|
||||
}
|
||||
Reference in New Issue
Block a user