diff --git a/api/server/.air.toml b/api/server/.air.toml new file mode 100644 index 0000000..c0d65bf --- /dev/null +++ b/api/server/.air.toml @@ -0,0 +1,57 @@ +# Config file for [Air](https://github.com/air-verse/air) in TOML format + +# Working directory +# . or absolute path, please note that the directories following must be under root. +root = "./.." +tmp_dir = "tmp" + +[build] +# Just plain old shell command. You could use `make` as well. +cmd = "go build -o app -ldflags \"-X 'github.com/tech/sendico/server/internal/appversion.BuildUser=$(whoami)' -X 'github.com/tech/sendico/server/internal/appversion.Version=$APP_V' -X 'github.com/tech/sendico/server/internal/appversion.Branch=$BUILD_BRANCH' -X 'github.com/tech/sendico/server/internal/appversion.Revision=$GIT_REV' -X 'github.com/tech/sendico/server/internal/appversion.BuildDate=$(date)' -X 'github.com/tech/sendico/server/internal/mutil/ampli.Version=$APP_V'\"" +# Binary file yields from `cmd`. +bin = "./app" +# Customize binary, can setup environment variables when run your app. +full_bin = "./app --debug" +# Watch these filename extensions. +include_ext = ["go"] +# Ignore these filename extensions or directories. +exclude_dir = ["server/.git", "pkg/.git", "server/tmp", "server/storage", "server/resources", "server/env"] +# Watch these directories if you specified. +include_dir = [] +# Watch these files. +include_file = [] +# Exclude files. +exclude_file = [] +# Exclude specific regular expressions. +exclude_regex = ["_test\\.go"] +# Exclude unchanged files. +exclude_unchanged = true +# Follow symlink for directories +follow_symlink = true +# This log file places in your tmp_dir. +log = "air.log" +# It's not necessary to trigger build each time file changes if it's too frequent. +delay = 0 # ms +# Stop running old binary when build errors occur. +stop_on_error = true +# Send Interrupt signal before killing process (windows does not support this feature) +send_interrupt = true +# Delay after sending Interrupt signal +kill_delay = 500 # ms +# Add additional arguments when running binary (bin/full_bin). Will run './tmp/main hello world'. +args_bin = [] + +[log] +# Show log time +time = false + +[color] +# Customize each part's color. If no color found, use the raw app log. +main = "magenta" +watcher = "cyan" +build = "yellow" +runner = "green" + +[misc] +# Delete tmp directory on exit +clean_on_exit = true \ No newline at end of file diff --git a/api/server/.gitignore b/api/server/.gitignore new file mode 100644 index 0000000..5d252d7 --- /dev/null +++ b/api/server/.gitignore @@ -0,0 +1 @@ +storage \ No newline at end of file diff --git a/api/server/ampli.json b/api/server/ampli.json new file mode 100644 index 0000000..2635e30 --- /dev/null +++ b/api/server/ampli.json @@ -0,0 +1,14 @@ +{ + "Zone": "eu", + "OrgId": "100001828", + "WorkspaceId": "c75043a3-1fad-45ec-bd71-c807a99c650d", + "SourceId": "81b24ac7-e285-4519-9e82-bb575601120c", + "Branch": "main", + "Version": "2.0.0", + "VersionId": "4fa6851a-4ff0-42f1-b440-8b39f07870e4", + "Runtime": "go:go-ampli", + "Platform": "Go", + "Language": "Go", + "SDK": "analytics-go", + "Path": "./internal/ampli" +} \ No newline at end of file diff --git a/api/server/app b/api/server/app new file mode 100755 index 0000000..9ac4ac8 Binary files /dev/null and b/api/server/app differ diff --git a/api/server/assets/assets.go b/api/server/assets/assets.go new file mode 100644 index 0000000..c5fcdc2 --- /dev/null +++ b/api/server/assets/assets.go @@ -0,0 +1,6 @@ +package assets + +import _ "embed" + +//go:embed resources/logo.png +var MailLogo []byte diff --git a/api/server/assets/resources/logo.png b/api/server/assets/resources/logo.png new file mode 100644 index 0000000..90b3760 Binary files /dev/null and b/api/server/assets/resources/logo.png differ diff --git a/api/server/config.yml b/api/server/config.yml new file mode 100755 index 0000000..6b401da --- /dev/null +++ b/api/server/config.yml @@ -0,0 +1,97 @@ +http_server: + listen_address: :8081 + read_header_timeout: 60 + shutdown_timeout: 5 + +api: + amplitude: + ampli_environment_env: AMPLI_ENVIRONMENT + middleware: + api_protocol_env: API_PROTOCOL + domain_env: SERVICE_HOST + api_endpoint_env: API_ENDPOINT + signature: + secret_key_env: API_ENDPOINT_SECRET + algorithm: HS256 + CORS: + max_age: 300 + allowed_origins: + - "http://*" + - "https://*" + allowed_methods: + - "GET" + - "POST" + - "PUT" + - "PATCH" + - "DELETE" + - "OPTIONS" + allowed_headers: + - "Accept" + - "Authorization" + - "Content-Type" + - "X-Requested-With" + exposed_headers: + allow_credentials: false + websocket: + endpoint_env: WS_ENDPOINT + timeout: 60 + message_broker: + driver: NATS + settings: + host_env: NATS_HOST + port_env: NATS_PORT + username_env: NATS_USER + password_env: NATS_PASSWORD + broker_name: Sendico Backend server + max_reconnects: 10 + reconnect_wait: 5 + # type: in-process + # settings: + # buffer_size: 10 + token: + expiration_hours: + account: 24 + refresh: 720 + length: 32 + password: + token_length: 32 + checks: + min_length: 8 + digit: true + upper: true + lower: true + special: true + + + storage: + # driver: aws_s3 + # settings: + # access_key_id_env: S3_ACCESS_KEY_ID + # secret_access_key_env: S3_ACCESS_KEY_SECRET + # region_env: S3_REGION + # bucket_name_env: S3_BUCKET_NAME + driver: local_fs + settings: + root_path: ./storage + +app: + +database: + driver: mongodb + settings: + host_env: MONGO_HOST + port_env: MONGO_PORT + database_env: MONGO_DATABASE + user_env: MONGO_USER + password_env: MONGO_PASSWORD + auth_source_env: MONGO_AUTH_SOURCE + replica_set_env: MONGO_REPLICA_SET + enforcer: + driver: native + settings: + model_path_env: PERMISSION_MODEL + adapter: + collection_name_env: PERMISSION_COLLECTION + database_name_env: MONGO_DATABASE + timeout_seconds_env: PERMISSION_TIMEOUT + is_filtered_env: PERMISSION_IS_FILTERED \ No newline at end of file diff --git a/api/server/env/.gitignore b/api/server/env/.gitignore new file mode 100644 index 0000000..d71ab6c --- /dev/null +++ b/api/server/env/.gitignore @@ -0,0 +1 @@ +.env.api \ No newline at end of file diff --git a/api/server/go.mod b/api/server/go.mod new file mode 100644 index 0000000..a879b45 --- /dev/null +++ b/api/server/go.mod @@ -0,0 +1,133 @@ +module github.com/tech/sendico/server + +go 1.25.3 + +replace github.com/tech/sendico/pkg => ../pkg + +require ( + github.com/aws/aws-sdk-go-v2 v1.39.6 + github.com/aws/aws-sdk-go-v2/config v1.31.17 + github.com/aws/aws-sdk-go-v2/credentials v1.18.21 + github.com/aws/aws-sdk-go-v2/service/s3 v1.90.0 + github.com/go-chi/chi/v5 v5.2.3 + github.com/go-chi/cors v1.2.2 + github.com/go-chi/jwtauth/v5 v5.3.3 + github.com/go-chi/metrics v0.1.1 + github.com/mitchellh/mapstructure v1.5.0 + github.com/stretchr/testify v1.11.1 + github.com/tech/sendico/pkg v0.1.0 + github.com/testcontainers/testcontainers-go v0.33.0 + github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 + go.mongodb.org/mongo-driver v1.17.6 + go.uber.org/zap v1.27.0 + golang.org/x/net v0.46.0 + gopkg.in/yaml.v3 v3.0.1 + moul.io/chizap v1.0.3 +) + +require ( + github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect + github.com/casbin/casbin/v2 v2.132.0 // indirect + github.com/casbin/govaluate v1.10.0 // indirect +) + +require ( + dario.cat/mergo v1.0.1 // indirect + github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.13 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.13 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 // indirect + github.com/aws/smithy-go v1.23.2 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker v27.3.1+incompatible // indirect + github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-chi/chi v1.5.5 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/snappy v1.0.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.18.1 // indirect + github.com/lestrrat-go/blackmagic v1.0.4 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc v1.0.6 // indirect + github.com/lestrrat-go/iter v1.0.2 // indirect + github.com/lestrrat-go/jwx/v2 v2.1.6 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect + github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.3.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.0 // indirect + github.com/montanaflynn/stats v0.7.1 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/nats-io/nats.go v1.47.0 // indirect + github.com/nats-io/nkeys v0.4.11 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.2 // indirect + github.com/prometheus/procfs v0.19.2 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/shirou/gopsutil/v3 v3.24.5 // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/tklauser/go-sysconf v0.3.15 // indirect + github.com/tklauser/numcpus v0.10.0 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.1.2 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 // indirect + go.opentelemetry.io/otel v1.37.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect + go.opentelemetry.io/otel/metric v1.37.0 // indirect + go.opentelemetry.io/otel/trace v1.37.0 // indirect + go.opentelemetry.io/proto/otlp v1.7.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/crypto v0.43.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 // indirect + google.golang.org/grpc v1.76.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect +) diff --git a/api/server/go.sum b/api/server/go.sum new file mode 100644 index 0000000..dfea05c --- /dev/null +++ b/api/server/go.sum @@ -0,0 +1,379 @@ +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/aws/aws-sdk-go-v2 v1.39.6 h1:2JrPCVgWJm7bm83BDwY5z8ietmeJUbh3O2ACnn+Xsqk= +github.com/aws/aws-sdk-go-v2 v1.39.6/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 h1:DHctwEM8P8iTXFxC/QK0MRjwEpWQeM9yzidCRjldUz0= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3/go.mod h1:xdCzcZEtnSTKVDOmUZs4l/j3pSV6rpo1WXl5ugNsL8Y= +github.com/aws/aws-sdk-go-v2/config v1.31.17 h1:QFl8lL6RgakNK86vusim14P2k8BFSxjvUkcWLDjgz9Y= +github.com/aws/aws-sdk-go-v2/config v1.31.17/go.mod h1:V8P7ILjp/Uef/aX8TjGk6OHZN6IKPM5YW6S78QnRD5c= +github.com/aws/aws-sdk-go-v2/credentials v1.18.21 h1:56HGpsgnmD+2/KpG0ikvvR8+3v3COCwaF4r+oWwOeNA= +github.com/aws/aws-sdk-go-v2/credentials v1.18.21/go.mod h1:3YELwedmQbw7cXNaII2Wywd+YY58AmLPwX4LzARgmmA= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 h1:T1brd5dR3/fzNFAQch/iBKeX07/ffu/cLu+q+RuzEWk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13/go.mod h1:Peg/GBAQ6JDt+RoBf4meB1wylmAipb7Kg2ZFakZTlwk= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13 h1:a+8/MLcWlIxo1lF9xaGt3J/u3yOZx+CdSveSNwjhD40= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13/go.mod h1:oGnKwIYZ4XttyU2JWxFrwvhF6YKiK/9/wmE3v3Iu9K8= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13 h1:HBSI2kDkMdWz4ZM7FjwE7e/pWDEZ+nR95x8Ztet1ooY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13/go.mod h1:YE94ZoDArI7awZqJzBAZ3PDD2zSfuP7w6P2knOzIn8M= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.13 h1:eg/WYAa12vqTphzIdWMzqYRVKKnCboVPRlvaybNCqPA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.13/go.mod h1:/FDdxWhz1486obGrKKC1HONd7krpk38LBt+dutLcN9k= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.4 h1:NvMjwvv8hpGUILarKw7Z4Q0w1H9anXKsesMxtw++MA4= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.4/go.mod h1:455WPHSwaGj2waRSpQp7TsnpOnBfw8iDfPfbwl7KPJE= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 h1:kDqdFvMY4AtKoACfzIGD8A0+hbT41KTKF//gq7jITfM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13/go.mod h1:lmKuogqSU3HzQCwZ9ZtcqOc5XGMqtDK7OIc2+DxiUEg= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.13 h1:zhBJXdhWIFZ1acfDYIhu4+LCzdUS2Vbcum7D01dXlHQ= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.13/go.mod h1:JaaOeCE368qn2Hzi3sEzY6FgAZVCIYcC2nwbro2QCh8= +github.com/aws/aws-sdk-go-v2/service/s3 v1.90.0 h1:ef6gIJR+xv/JQWwpa5FYirzoQctfSJm7tuDe3SZsUf8= +github.com/aws/aws-sdk-go-v2/service/s3 v1.90.0/go.mod h1:+wArOOrcHUevqdto9k1tKOF5++YTe9JEcPSc9Tx2ZSw= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 h1:0JPwLz1J+5lEOfy/g0SURC9cxhbQ1lIMHMa+AHZSzz0= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.1/go.mod h1:fKvyjJcz63iL/ftA6RaM8sRCtN4r4zl4tjL3qw5ec7k= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 h1:OWs0/j2UYR5LOGi88sD5/lhN6TDLG6SfA7CqsQO9zF0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5/go.mod h1:klO+ejMvYsB4QATfEOIXk8WAEwN4N0aBfJpvC+5SZBo= +github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 h1:mLlUgHn02ue8whiR4BmxxGJLR2gwU6s6ZzJ5wDamBUs= +github.com/aws/aws-sdk-go-v2/service/sts v1.39.1/go.mod h1:E19xDjpzPZC7LS2knI9E6BaRFDK43Eul7vd6rSq2HWk= +github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM= +github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= +github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/casbin/casbin/v2 v2.132.0 h1:73hGmOszGSL3hTVquwkAi98XLl3gPJ+BxB6D7G9Fxtk= +github.com/casbin/casbin/v2 v2.132.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18= +github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= +github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0= +github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= +github.com/casbin/mongodb-adapter/v3 v3.7.0 h1:w9c3bea1BGK4eZTAmk17JkY52yv/xSZDSHKji8q+z6E= +github.com/casbin/mongodb-adapter/v3 v3.7.0/go.mod h1:F1mu4ojoJVE/8VhIMxMedhjfwRDdIXgANYs6Sd0MgVA= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI= +github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-chi/chi v1.5.4/go.mod h1:uaf8YgoFazUOkPBG7fxPftUylNumIev9awIWOENIuEg= +github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE= +github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw= +github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= +github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= +github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-chi/jwtauth/v5 v5.3.3 h1:50Uzmacu35/ZP9ER2Ht6SazwPsnLQ9LRJy6zTZJpHEo= +github.com/go-chi/jwtauth/v5 v5.3.3/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/QY5vUz7aQ= +github.com/go-chi/metrics v0.1.1 h1:CXhbnkAVVjb0k73EBRQ6Z2YdWFnbXZgNtg1Mboguibk= +github.com/go-chi/metrics v0.1.1/go.mod h1:mcGTM1pPalP7WCtb+akNYFO/lwNwBBLCuedepqjoPn4= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= +github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= +github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= +github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= +github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= +github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= +github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA= +github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= +github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg= +github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= +github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= +github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM= +github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= +github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0= +github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pkg/diff v0.0.0-20200914180035-5b29258ca4f7/go.mod h1:zO8QMzTeZd5cpnIkz/Gn6iK0jDfGicM1nynOkkPIl28= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= +github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= +github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= +github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502/go.mod h1:p9lPsd+cx33L3H9nNoecRRxPssFKUwwI50I3pZ0yT+8= +github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw= +github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8= +github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 h1:iXVA84s5hKMS5gn01GWOYHE3ymy/2b+0YkpFeTxB2XY= +github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0/go.mod h1:R6tMjTojRiaoo89fh/hf7tOmfzohdqSU17R9DwSVSog= +github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= +github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= +github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= +github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= +github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss= +go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= +go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os= +go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.8.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20201211185031-d93e913c1a58/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b h1:ULiyYQ0FdsJhwwZUwbaXpZF5yUE3h+RA+gxvBu37ucc= +google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:oDOGiMSXHL4sDTJvFvIB9nRQCGdLP1o/iVaqQK8zB+M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 h1:tRPGkdGHuewF4UisLzzHHr1spKw92qLM98nIzxbC0wY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= +google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= +gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= +moul.io/chizap v1.0.3 h1:mliXvvuS5HVo3QP8qPXczWtRM5dQ9UmK3bBVIkZo6ek= +moul.io/chizap v1.0.3/go.mod h1:pq4R9kGLwz4XjBc4hodQYuoE7Yc9RUabLBFyyi2uErk= diff --git a/api/server/interface/accountservice/internal/service.go b/api/server/interface/accountservice/internal/service.go new file mode 100644 index 0000000..07b6da9 --- /dev/null +++ b/api/server/interface/accountservice/internal/service.go @@ -0,0 +1,409 @@ +package accountserviceimp + +import ( + "context" + "errors" + "fmt" + "slices" + "unicode" + + "github.com/tech/sendico/pkg/auth" + "github.com/tech/sendico/pkg/auth/management" + "github.com/tech/sendico/pkg/db" + "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/transaction" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/mutil/mzap" + "github.com/tech/sendico/server/interface/middleware" + "github.com/tech/sendico/server/internal/mutil/flrstring" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.uber.org/zap" +) + +type service struct { + logger mlogger.Logger + accountDB account.DB + orgDB organization.DB + enforcer auth.Enforcer + roleManager management.Role + config *middleware.PasswordConfig + tf transaction.Factory + + policyDB policy.DB +} + +func validateUserRequest(u *model.Account) error { + if u.Name == "" { + return merrors.InvalidArgument("Name must not be empty") + } + if u.Login == "" { + return merrors.InvalidArgument("Login must not be empty") + } + if u.Password == "" { + return merrors.InvalidArgument("Password must not be empty") + } + return nil +} + +func (s *service) ValidatePassword( + password string, + oldPassword *string, +) error { + var hasDigit, hasUpper, hasLower, hasSpecial bool + + if oldPassword != nil { + if *oldPassword == password { + return merrors.InvalidArgument("New password cannot be the same as the old password") + } + } + + if len(password) < s.config.Check.MinLength { + return merrors.InvalidArgument(fmt.Sprintf("Password must be at least %d characters long", s.config.Check.MinLength)) + } + + // Check for digit, uppercase, lowercase, and special character + for _, char := range password { + switch { + case unicode.IsDigit(char): + hasDigit = true + case unicode.IsUpper(char): + hasUpper = true + case unicode.IsLower(char): + hasLower = true + case unicode.IsPunct(char) || unicode.IsSymbol(char): + hasSpecial = true + } + } + + if s.config.Check.Digit && !hasDigit { + return merrors.InvalidArgument("Password must contain at least one digit") + } + if s.config.Check.Upper && !hasUpper { + return merrors.InvalidArgument("Password must contain at least one uppercase letter") + } + if s.config.Check.Lower && !hasLower { + return merrors.InvalidArgument("Password must contain at least one lowercase letter") + } + if s.config.Check.Special && !hasSpecial { + return merrors.InvalidArgument("Password must contain at least one special character") + } + + // If all checks pass, return nil (no error) + return nil +} + +func (s *service) ValidateAccount(acct *model.Account) error { + if err := validateUserRequest(acct); err != nil { + s.logger.Warn("Invalid signup acccount received", zap.Error(err), zap.String("account", acct.Login)) + return err + } + + if err := s.ValidatePassword(acct.Password, nil); err != nil { + s.logger.Warn("Password validation failed", zap.Error(err), zap.String("account", acct.Login)) + return err + } + + if err := acct.HashPassword(); err != nil { + s.logger.Warn("Failed to hash password", zap.Error(err), zap.String("account", acct.Login)) + return err + } + + acct.VerifyToken = flrstring.CreateRandString(s.config.TokenLength) + return nil +} + +func (s *service) CreateAccount( + ctx context.Context, + org *model.Organization, + acct *model.Account, + roleDescID primitive.ObjectID, +) error { + if org == nil { + return merrors.InvalidArgument("Organization must not be nil") + } + if acct == nil || len(acct.Login) == 0 { + return merrors.InvalidArgument("Account must have a non-empty login") + } + if roleDescID == primitive.NilObjectID { + return merrors.InvalidArgument("Role description must be provided") + } + // 1) Create the account + if err := s.accountDB.Create(ctx, acct); err != nil { + if errors.Is(err, merrors.ErrDataConflict) { + s.logger.Info("Username is already taken", zap.String("login", acct.Login)) + } else { + s.logger.Warn("Failed to signup a user", zap.Error(err), zap.String("login", acct.Login)) + } + return err + } + + // 2) Add to organization + if err := s.JoinOrganization(ctx, org, acct, roleDescID); err != nil { + s.logger.Warn("Failed to register new organization member", zap.Error(err), mzap.StorableRef(acct)) + return err + } + return nil +} + +func (s *service) DeleteAccount( + ctx context.Context, + org *model.Organization, + accountRef primitive.ObjectID, +) error { + // Check if this is the only member in the organization + if len(org.Members) <= 1 { + s.logger.Warn("Cannot delete account - it's the only member in the organization", + mzap.ObjRef("account_ref", accountRef), mzap.StorableRef(org)) + return merrors.InvalidArgument("Cannot delete the only member of an organization") + } + + // 1) Remove from organization + if err := s.RemoveAccountFromOrganization(ctx, org, accountRef); err != nil { + s.logger.Warn("Failed to revoke account role", zap.Error(err), mzap.ObjRef("account_ref", accountRef)) + return err + } + + // 2) Delete the account document + if err := s.accountDB.Delete(ctx, accountRef); err != nil { + s.logger.Warn("Failed to delete account", zap.Error(err), mzap.ObjRef("account_ref", accountRef)) + return err + } + return nil +} + +func (s *service) RemoveAccountFromOrganization( + ctx context.Context, + org *model.Organization, + accountRef primitive.ObjectID, +) error { + if org == nil { + return merrors.InvalidArgument("Organization must not be nil") + } + roles, err := s.enforcer.GetRoles(ctx, accountRef, org.ID) + if err != nil { + s.logger.Warn("Failed to fetch account permissions", zap.Error(err), mzap.StorableRef(org), + mzap.ObjRef("account_ref", accountRef)) + return err + } + for _, role := range roles { + if err := s.roleManager.Revoke(ctx, role.DescriptionRef, accountRef, org.ID); err != nil { + s.logger.Warn("Failed to revoke account role", zap.Error(err), + mzap.ObjRef("account_ref", accountRef), mzap.ObjRef("role_ref", role.DescriptionRef)) + return err + } + } + for i, member := range org.Members { + if member == accountRef { + // Remove the member by slicing it out + org.Members = append(org.Members[:i], org.Members[i+1:]...) + if err := s.orgDB.Update(ctx, accountRef, org); err != nil { + s.logger.Warn("Failed to remove member from organization", zap.Error(err), mzap.ObjRef("account_ref", accountRef)) + return err + } + break + } + } + return nil +} + +func (s *service) ResetPassword( + ctx context.Context, + acct *model.Account, +) error { + acct.ResetPasswordToken = flrstring.CreateRandString(s.config.TokenLength) + return s.accountDB.Update(ctx, acct) +} + +func (s *service) UpdateLogin( + ctx context.Context, + acct *model.Account, + newLogin string, +) error { + acct.EmailBackup = acct.Login + acct.Login = newLogin + acct.VerifyToken = flrstring.CreateRandString(s.config.TokenLength) + return s.accountDB.Update(ctx, acct) +} + +func (s *service) JoinOrganization( + ctx context.Context, + org *model.Organization, + account *model.Account, + roleDescID primitive.ObjectID, +) error { + if slices.Contains(org.Members, account.ID) { + s.logger.Debug("Account is already a member", mzap.StorableRef(org), mzap.StorableRef(account)) + return nil + } + org.Members = append(org.Members, account.ID) + if err := s.orgDB.Update(ctx, *account.GetID(), org); err != nil { + s.logger.Warn("Failed to update organization members list", zap.Error(err), mzap.StorableRef(account)) + return err + } + + role := &model.Role{ + DescriptionRef: roleDescID, + OrganizationRef: org.ID, + AccountRef: account.ID, + } + if err := s.roleManager.Assign(ctx, role); err != nil { + s.logger.Warn("Failed to assign role to account", zap.Error(err), mzap.StorableRef(account)) + return err + } + return nil +} + +func (s *service) deleteOrganizationRoles(ctx context.Context, orgRef primitive.ObjectID) error { + s.logger.Info("Deleting roles for organization", mzap.ObjRef("organization_ref", orgRef)) + + // Get all roles for the organization + roles, err := s.roleManager.List(ctx, orgRef) + if err != nil && !errors.Is(err, merrors.ErrNoData) { + s.logger.Warn("Failed to fetch roles for deletion", zap.Error(err), mzap.ObjRef("organization_ref", orgRef)) + return err + } + + // Delete each role + for _, role := range roles { + if err := s.roleManager.Delete(ctx, role.ID); err != nil { + s.logger.Warn("Failed to delete role", zap.Error(err), mzap.ObjRef("role_ref", role.ID)) + return err + } + } + + s.logger.Info("Successfully deleted roles", zap.Int("count", len(roles)), mzap.ObjRef("organization_ref", orgRef)) + return nil +} + +func (s *service) deleteOrganizationPolicies(ctx context.Context, orgRef primitive.ObjectID) error { + s.logger.Info("Deleting policies for organization", mzap.ObjRef("organization_ref", orgRef)) + + // Get all policies for the organization + policies, err := s.policyDB.All(ctx, orgRef) + if err != nil && !errors.Is(err, merrors.ErrNoData) { + s.logger.Warn("Failed to fetch policies for deletion", zap.Error(err), mzap.ObjRef("organization_ref", orgRef)) + return err + } + + // Delete each policy + for _, policy := range policies { + if err := s.policyDB.Delete(ctx, policy.ID); err != nil { + s.logger.Warn("Failed to delete policy", zap.Error(err), mzap.ObjRef("policy_ref", policy.ID)) + return err + } + } + + s.logger.Info("Successfully deleted policies", zap.Int("count", len(policies)), mzap.ObjRef("organization_ref", orgRef)) + return nil +} + +func (s *service) DeleteOrganization( + ctx context.Context, + org *model.Organization, +) error { + s.logger.Info("Starting organization deletion", mzap.StorableRef(org)) + + // Use transaction to ensure atomicity + _, err := s.tf.CreateTransaction().Execute(ctx, func(ctx context.Context) (any, error) { + // 8. Delete all roles and role descriptions in the organization + if err := s.deleteOrganizationRoles(ctx, org.ID); err != nil { + return nil, err + } + + // 9. Delete all policies in the organization + if err := s.deleteOrganizationPolicies(ctx, org.ID); err != nil { + return nil, err + } + + // 10. Finally, delete the organization itself + if err := s.orgDB.Delete(ctx, primitive.NilObjectID, org.ID); err != nil { + s.logger.Warn("Failed to delete organization", zap.Error(err), mzap.StorableRef(org)) + return nil, err + } + + return nil, nil + }) + if err != nil { + s.logger.Error("Failed to delete organization", zap.Error(err), mzap.StorableRef(org)) + return err + } + + s.logger.Info("Organization deleted successfully", mzap.StorableRef(org)) + return nil +} + +func (s *service) DeleteAll( + ctx context.Context, + org *model.Organization, + accountRef primitive.ObjectID, +) error { + s.logger.Info("Starting complete deletion (organization + account)", + mzap.StorableRef(org), mzap.ObjRef("account_ref", accountRef)) + + // Use transaction to ensure atomicity + _, err := s.tf.CreateTransaction().Execute(ctx, func(ctx context.Context) (any, error) { + // 1. First delete the organization and all its data + if err := s.DeleteOrganization(ctx, org); err != nil { + return nil, err + } + + // 2. Then delete the account + if err := s.accountDB.Delete(ctx, accountRef); err != nil { + s.logger.Warn("Failed to delete account", zap.Error(err), mzap.ObjRef("account_ref", accountRef)) + return nil, err + } + + return nil, nil + }) + if err != nil { + s.logger.Error("Failed to delete all data", zap.Error(err), mzap.StorableRef(org), mzap.ObjRef("account_ref", accountRef)) + return err + } + + s.logger.Info("Complete deletion successful", mzap.StorableRef(org), mzap.ObjRef("account_ref", accountRef)) + return nil +} + +// NewAccountService wires in your logger plus the three dependencies. +func NewAccountService( + l mlogger.Logger, + dbf db.Factory, + enforcer auth.Enforcer, + ra management.Role, + config *middleware.PasswordConfig, +) (*service, error) { + logger := l.Named("account_service") + + if config == nil { + return nil, merrors.Internal("Invalid account service configuration provides") + } + + res := &service{ + logger: logger, + enforcer: enforcer, + roleManager: ra, + config: config, + tf: dbf.TransactionFactory(), + } + var err error + if res.accountDB, err = dbf.NewAccountDB(); err != nil { + logger.Warn("Failed to create accounts database", zap.Error(err)) + return nil, err + } + if res.orgDB, err = dbf.NewOrganizationDB(); err != nil { + logger.Warn("Failed to create organizations database", zap.Error(err)) + return nil, err + } + + // Initialize database dependencies for cascade deletion + if res.policyDB, err = dbf.NewPoliciesDB(); err != nil { + logger.Warn("Failed to create policies database", zap.Error(err)) + return nil, err + } + + return res, nil +} diff --git a/api/server/interface/accountservice/internal/service_deletion_test.go b/api/server/interface/accountservice/internal/service_deletion_test.go new file mode 100644 index 0000000..881c329 --- /dev/null +++ b/api/server/interface/accountservice/internal/service_deletion_test.go @@ -0,0 +1,156 @@ +package accountserviceimp + +import ( + "testing" + + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +func TestDeleteAccount_Validation(t *testing.T) { + t.Run("DeleteAccount_LastMemberFails", func(t *testing.T) { + orgID := primitive.NewObjectID() + accountID := primitive.NewObjectID() + + org := &model.Organization{ + OrganizationBase: model.OrganizationBase{ + Describable: model.Describable{Name: "Single Member Org"}, + }, + Members: []primitive.ObjectID{accountID}, // Only one member + } + org.ID = orgID + + // This should fail because it's the only member + err := validateDeleteAccount(org) + require.Error(t, err) + assert.Contains(t, err.Error(), "Cannot delete the only member") + }) + + t.Run("DeleteAccount_MultipleMembersSuccess", func(t *testing.T) { + orgID := primitive.NewObjectID() + accountID := primitive.NewObjectID() + otherAccountID := primitive.NewObjectID() + + org := &model.Organization{ + OrganizationBase: model.OrganizationBase{ + Describable: model.Describable{Name: "Multi Member Org"}, + }, + Members: []primitive.ObjectID{accountID, otherAccountID}, // Multiple members + } + org.ID = orgID + + // This should succeed because there are multiple members + err := validateDeleteAccount(org) + require.NoError(t, err) + }) + + t.Run("DeleteAccount_EmptyMembersList", func(t *testing.T) { + orgID := primitive.NewObjectID() + + org := &model.Organization{ + OrganizationBase: model.OrganizationBase{ + Describable: model.Describable{Name: "Empty Org"}, + }, + Members: []primitive.ObjectID{}, // No members + } + org.ID = orgID + + // This should fail because there are no members + err := validateDeleteAccount(org) + require.Error(t, err) + assert.Contains(t, err.Error(), "Cannot delete the only member") + }) +} + +func TestDeleteOrganization_Validation(t *testing.T) { + t.Run("DeleteOrganization_NilOrganization", func(t *testing.T) { + err := validateDeleteOrganization(nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "organization cannot be nil") + }) + + t.Run("DeleteOrganization_EmptyOrganization", func(t *testing.T) { + org := &model.Organization{} + err := validateDeleteOrganization(org) + require.Error(t, err) + assert.Contains(t, err.Error(), "organization ID cannot be empty") + }) + + t.Run("DeleteOrganization_ValidOrganization", func(t *testing.T) { + org := &model.Organization{ + OrganizationBase: model.OrganizationBase{ + Describable: model.Describable{Name: "Valid Organization"}, + }, + } + org.ID = primitive.NewObjectID() + + err := validateDeleteOrganization(org) + require.NoError(t, err) + }) +} + +func TestDeleteAll_Validation(t *testing.T) { + t.Run("DeleteAll_NilOrganization", func(t *testing.T) { + accountID := primitive.NewObjectID() + err := validateDeleteAll(nil, accountID) + require.Error(t, err) + assert.Contains(t, err.Error(), "organization cannot be nil") + }) + + t.Run("DeleteAll_EmptyAccountID", func(t *testing.T) { + org := &model.Organization{ + OrganizationBase: model.OrganizationBase{ + Describable: model.Describable{Name: "Valid Organization"}, + }, + } + org.ID = primitive.NewObjectID() + + err := validateDeleteAll(org, primitive.NilObjectID) + require.Error(t, err) + assert.Contains(t, err.Error(), "account ID cannot be empty") + }) + + t.Run("DeleteAll_ValidInput", func(t *testing.T) { + org := &model.Organization{ + OrganizationBase: model.OrganizationBase{ + Describable: model.Describable{Name: "Valid Organization"}, + }, + } + org.ID = primitive.NewObjectID() + accountID := primitive.NewObjectID() + + err := validateDeleteAll(org, accountID) + require.NoError(t, err) + }) +} + +// Helper functions that implement the validation logic from the service +func validateDeleteAccount(org *model.Organization) error { + if len(org.Members) <= 1 { + return merrors.InvalidArgument("Cannot delete the only member of an organization") + } + return nil +} + +func validateDeleteOrganization(org *model.Organization) error { + if org == nil { + return merrors.InvalidArgument("organization cannot be nil") + } + if org.ID == primitive.NilObjectID { + return merrors.InvalidArgument("organization ID cannot be empty") + } + return nil +} + +func validateDeleteAll(org *model.Organization, accountRef primitive.ObjectID) error { + if org == nil { + return merrors.InvalidArgument("organization cannot be nil") + } + if accountRef == primitive.NilObjectID { + return merrors.InvalidArgument("account ID cannot be empty") + } + return nil +} diff --git a/api/server/interface/accountservice/internal/service_test.go b/api/server/interface/accountservice/internal/service_test.go new file mode 100644 index 0000000..5570867 --- /dev/null +++ b/api/server/interface/accountservice/internal/service_test.go @@ -0,0 +1,298 @@ +package accountserviceimp + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tech/sendico/pkg/model" + apiconfig "github.com/tech/sendico/server/internal/api/config" + "go.uber.org/zap" +) + +// TestValidatePassword tests the password validation logic directly +func TestValidatePassword(t *testing.T) { + config := &apiconfig.PasswordConfig{ + Check: apiconfig.PasswordChecks{ + MinLength: 8, + Digit: true, + Upper: true, + Lower: true, + Special: true, + }, + TokenLength: 32, + } + + // Create a minimal service for testing password validation + logger := zap.NewNop() // Use no-op logger for tests + service := &service{ + config: config, + logger: logger, + } + + t.Run("ValidPassword", func(t *testing.T) { + err := service.ValidatePassword("TestPassword123!", nil) + assert.NoError(t, err) + }) + + t.Run("PasswordTooShort", func(t *testing.T) { + err := service.ValidatePassword("Test1!", nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "at least 8 characters") + }) + + t.Run("PasswordMissingDigit", func(t *testing.T) { + err := service.ValidatePassword("TestPassword!", nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "at least one digit") + }) + + t.Run("PasswordMissingUppercase", func(t *testing.T) { + err := service.ValidatePassword("testpassword123!", nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "at least one uppercase") + }) + + t.Run("PasswordMissingLowercase", func(t *testing.T) { + err := service.ValidatePassword("TESTPASSWORD123!", nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "at least one lowercase") + }) + + t.Run("PasswordMissingSpecialCharacter", func(t *testing.T) { + err := service.ValidatePassword("TestPassword123", nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "at least one special character") + }) + + t.Run("PasswordSameAsOld", func(t *testing.T) { + oldPassword := "TestPassword123!" + err := service.ValidatePassword("TestPassword123!", &oldPassword) + assert.Error(t, err) + assert.Contains(t, err.Error(), "cannot be the same as the old password") + }) +} + +// TestValidateAccount tests the account validation logic directly +func TestValidateAccount(t *testing.T) { + config := &apiconfig.PasswordConfig{ + Check: apiconfig.PasswordChecks{ + MinLength: 8, + Digit: true, + Upper: true, + Lower: true, + Special: true, + }, + TokenLength: 32, + } + + logger := zap.NewNop() // Use no-op logger for tests + service := &service{ + config: config, + logger: logger, + } + + t.Run("ValidAccount", 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", + }, + }, + Password: "TestPassword123!", + } + + originalPassword := account.Password + err := service.ValidateAccount(account) + require.NoError(t, err) + + // Password should be hashed after validation + assert.NotEqual(t, originalPassword, account.Password) + assert.NotEmpty(t, account.VerifyToken) + assert.Equal(t, config.TokenLength, len(account.VerifyToken)) + }) + + t.Run("AccountMissingName", func(t *testing.T) { + account := &model.Account{ + AccountPublic: model.AccountPublic{ + UserDataBase: model.UserDataBase{ + Login: "test@example.com", + }, + }, + Password: "TestPassword123!", + } + + err := service.ValidateAccount(account) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Name must not be empty") + }) + + t.Run("AccountMissingLogin", func(t *testing.T) { + account := &model.Account{ + AccountPublic: model.AccountPublic{ + AccountBase: model.AccountBase{ + Describable: model.Describable{ + Name: "Test User", + }, + }, + }, + Password: "TestPassword123!", + } + + err := service.ValidateAccount(account) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Login must not be empty") + }) + + t.Run("AccountMissingPassword", 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", + }, + }, + Password: "", + } + + err := service.ValidateAccount(account) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Password must not be empty") + }) + + t.Run("AccountInvalidPassword", 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", + }, + }, + Password: "weak", // Should fail validation + } + + err := service.ValidateAccount(account) + assert.Error(t, err) + // Should fail on password validation + assert.Contains(t, err.Error(), "at least 8 characters") + }) +} + +// TestPasswordConfiguration verifies different password rule configurations +func TestPasswordConfiguration(t *testing.T) { + t.Run("MinimalRequirements", func(t *testing.T) { + config := &apiconfig.PasswordConfig{ + Check: apiconfig.PasswordChecks{ + MinLength: 4, + Digit: false, + Upper: false, + Lower: false, + Special: false, + }, + TokenLength: 16, + } + + logger := zap.NewNop() // Use no-op logger for tests + service := &service{ + config: config, + logger: logger, + } + + // Should pass with minimal requirements + err := service.ValidatePassword("test", nil) + assert.NoError(t, err) + }) + + t.Run("StrictRequirements", func(t *testing.T) { + config := &apiconfig.PasswordConfig{ + Check: apiconfig.PasswordChecks{ + MinLength: 12, + Digit: true, + Upper: true, + Lower: true, + Special: true, + }, + TokenLength: 64, + } + + logger := zap.NewNop() // Use no-op logger for tests + service := &service{ + config: config, + logger: logger, + } + + // Should fail with shorter password + err := service.ValidatePassword("Test123!", nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "at least 12 characters") + + // Should pass with longer password + err = service.ValidatePassword("TestPassword123!", nil) + assert.NoError(t, err) + }) +} + +// TestTokenGeneration verifies that verification tokens are generated with correct length +func TestTokenGeneration(t *testing.T) { + testCases := []struct { + name string + tokenLength int + }{ + {"Short", 8}, + {"Medium", 32}, + {"Long", 64}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + config := &apiconfig.PasswordConfig{ + Check: apiconfig.PasswordChecks{ + MinLength: 8, + Digit: true, + Upper: true, + Lower: true, + Special: true, + }, + TokenLength: tc.tokenLength, + } + + logger := zap.NewNop() // Use no-op logger for tests + service := &service{ + config: config, + logger: logger, + } + + account := &model.Account{ + AccountPublic: model.AccountPublic{ + AccountBase: model.AccountBase{ + Describable: model.Describable{ + Name: "Test User", + }, + }, + UserDataBase: model.UserDataBase{ + Login: "test@example.com", + }, + }, + Password: "TestPassword123!", + } + + err := service.ValidateAccount(account) + require.NoError(t, err) + + assert.Equal(t, tc.tokenLength, len(account.VerifyToken)) + }) + } +} diff --git a/api/server/interface/accountservice/types.go b/api/server/interface/accountservice/types.go new file mode 100644 index 0000000..173c8cb --- /dev/null +++ b/api/server/interface/accountservice/types.go @@ -0,0 +1,99 @@ +package accountservice + +import ( + "context" + + "github.com/tech/sendico/pkg/auth" + "github.com/tech/sendico/pkg/auth/management" + "github.com/tech/sendico/pkg/db" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/model" + accountserviceimp "github.com/tech/sendico/server/interface/accountservice/internal" + "github.com/tech/sendico/server/interface/middleware" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// AccountService defines all account-related workflows. +type AccountService interface { + // ValidateAccount will: + // 1) check it's completeness + // 2) hash password + // 3) prepare verification token + ValidateAccount( + acct *model.Account, + ) error + + // ValidatePassword will: + // 1) check passsword conformance + ValidatePassword( + password string, + oldPassword *string, + ) error + + // ResetPassword will: + // 1) generate reset password token + ResetPassword( + ctx context.Context, + acct *model.Account, + ) error + + // CreateAccount will: + // 1) create the account + // 2) add it to the org’s member list + // 3) assign the given role description to it + CreateAccount( + ctx context.Context, + org *model.Organization, + acct *model.Account, + roleDescID primitive.ObjectID, + ) error + + JoinOrganization( + ctx context.Context, + org *model.Organization, + acct *model.Account, + roleDescID primitive.ObjectID, + ) error + + UpdateLogin( + ctx context.Context, + acct *model.Account, + newLogin string, + ) error + + // DeleteAccount deletes the account and removes it from the org. + DeleteAccount( + ctx context.Context, + org *model.Organization, + accountRef primitive.ObjectID, + ) error + + // RemoveAccountFromOrganization just drops it from the member slice. + RemoveAccountFromOrganization( + ctx context.Context, + org *model.Organization, + accountRef primitive.ObjectID, + ) error + + DeleteOrganization( + ctx context.Context, + org *model.Organization, + ) error + + // DeleteAll deletes both the organization and the account. + DeleteAll( + ctx context.Context, + org *model.Organization, + accountRef primitive.ObjectID, + ) error +} + +func NewAccountService( + logger mlogger.Logger, + dbf db.Factory, + enforcer auth.Enforcer, + roleManeger management.Role, + config *middleware.PasswordConfig, +) (AccountService, error) { + return accountserviceimp.NewAccountService(logger, dbf, enforcer, roleManeger, config) +} diff --git a/api/server/interface/api/api.go b/api/server/interface/api/api.go new file mode 100644 index 0000000..a15147b --- /dev/null +++ b/api/server/interface/api/api.go @@ -0,0 +1,20 @@ +package api + +import ( + "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" +) + +type API interface { + Logger() mlogger.Logger + DomainProvider() domainprovider.DomainProvider + Config() *Config + DBFactory() db.Factory + Permissions() auth.Provider + Register() Register +} + +type MicroServiceFactoryT = func(API) (mservice.MicroService, error) diff --git a/api/server/interface/api/config.go b/api/server/interface/api/config.go new file mode 100644 index 0000000..bee2ca9 --- /dev/null +++ b/api/server/interface/api/config.go @@ -0,0 +1,11 @@ +package api + +import ( + mwa "github.com/tech/sendico/server/interface/middleware" + fsc "github.com/tech/sendico/server/interface/services/fileservice/config" +) + +type Config struct { + Mw *mwa.Config `yaml:"middleware"` + Storage *fsc.Config `yaml:"storage"` +} diff --git a/api/server/interface/api/permissions/deny.go b/api/server/interface/api/permissions/deny.go new file mode 100644 index 0000000..f19ddf2 --- /dev/null +++ b/api/server/interface/api/permissions/deny.go @@ -0,0 +1,10 @@ +package permissions + +import ( + "github.com/tech/sendico/pkg/auth" + "github.com/tech/sendico/pkg/model" +) + +func Deny(_ *model.Account, _ *auth.Enforcer) (bool, error) { + return true, nil +} diff --git a/api/server/interface/api/permissions/donotcheck.go b/api/server/interface/api/permissions/donotcheck.go new file mode 100644 index 0000000..b24e3f5 --- /dev/null +++ b/api/server/interface/api/permissions/donotcheck.go @@ -0,0 +1,10 @@ +package permissions + +import ( + "github.com/tech/sendico/pkg/auth" + "github.com/tech/sendico/pkg/model" +) + +func DoNotCheck(_ *model.Account, _ *auth.Enforcer) (bool, error) { + return true, nil +} diff --git a/api/server/interface/api/register.go b/api/server/interface/api/register.go new file mode 100644 index 0000000..4a46514 --- /dev/null +++ b/api/server/interface/api/register.go @@ -0,0 +1,17 @@ +package api + +import ( + api "github.com/tech/sendico/pkg/api/http" + "github.com/tech/sendico/pkg/messaging" + "github.com/tech/sendico/pkg/mservice" + "github.com/tech/sendico/server/interface/api/sresponse" + "github.com/tech/sendico/server/interface/api/ws" +) + +type Register interface { + Handler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) + AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc) + WSHandler(messageType string, handler ws.HandlerFunc) + + Messaging() messaging.Register +} diff --git a/api/server/interface/api/srequest/acceptinvitation.go b/api/server/interface/api/srequest/acceptinvitation.go new file mode 100644 index 0000000..9e8fae1 --- /dev/null +++ b/api/server/interface/api/srequest/acceptinvitation.go @@ -0,0 +1,7 @@ +package srequest + +import "github.com/tech/sendico/pkg/model" + +type AcceptInvitation struct { + Account *model.AccountData `json:"account,omitempty"` +} diff --git a/api/server/interface/api/srequest/changepolicies.go b/api/server/interface/api/srequest/changepolicies.go new file mode 100644 index 0000000..4195643 --- /dev/null +++ b/api/server/interface/api/srequest/changepolicies.go @@ -0,0 +1,12 @@ +package srequest + +import ( + "github.com/tech/sendico/pkg/model" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +type ChangePolicies struct { + RoleRef primitive.ObjectID `json:"roleRef"` + Add *[]model.RolePolicy `json:"add,omitempty"` + Remove *[]model.RolePolicy `json:"remove,omitempty"` +} diff --git a/api/server/interface/api/srequest/changerole.go b/api/server/interface/api/srequest/changerole.go new file mode 100644 index 0000000..6b74e9a --- /dev/null +++ b/api/server/interface/api/srequest/changerole.go @@ -0,0 +1,8 @@ +package srequest + +import "go.mongodb.org/mongo-driver/bson/primitive" + +type ChangeRole struct { + AccountRef primitive.ObjectID `json:"accountRef"` + NewRoleDescriptionRef primitive.ObjectID `json:"newRoleDescriptionRef"` +} diff --git a/api/server/interface/api/srequest/file.go b/api/server/interface/api/srequest/file.go new file mode 100644 index 0000000..0e5ede2 --- /dev/null +++ b/api/server/interface/api/srequest/file.go @@ -0,0 +1,7 @@ +package srequest + +import "go.mongodb.org/mongo-driver/bson/primitive" + +type FileUpload struct { + ObjRef primitive.ObjectID `json:"objRef"` +} diff --git a/api/server/interface/api/srequest/invitation.go b/api/server/interface/api/srequest/invitation.go new file mode 100644 index 0000000..635523c --- /dev/null +++ b/api/server/interface/api/srequest/invitation.go @@ -0,0 +1,7 @@ +package srequest + +import ( + "github.com/tech/sendico/pkg/model" +) + +type CreateInvitation = model.Invitation diff --git a/api/server/interface/api/srequest/login.go b/api/server/interface/api/srequest/login.go new file mode 100644 index 0000000..4b91f50 --- /dev/null +++ b/api/server/interface/api/srequest/login.go @@ -0,0 +1,8 @@ +package srequest + +import "github.com/tech/sendico/pkg/model" + +type Login struct { + model.SessionIdentifier `json:",inline"` + model.LoginData `json:"login"` +} diff --git a/api/server/interface/api/srequest/password.go b/api/server/interface/api/srequest/password.go new file mode 100644 index 0000000..bffee11 --- /dev/null +++ b/api/server/interface/api/srequest/password.go @@ -0,0 +1,15 @@ +package srequest + +type ChangePassword struct { + Old string `json:"old"` + New string `json:"new"` + DeviceID string `json:"deviceId"` +} + +type ResetPassword struct { + Password string `json:"password"` +} + +type ForgotPassword struct { + Login string `json:"login"` +} diff --git a/api/server/interface/api/srequest/priority.go b/api/server/interface/api/srequest/priority.go new file mode 100644 index 0000000..2939ebe --- /dev/null +++ b/api/server/interface/api/srequest/priority.go @@ -0,0 +1,8 @@ +package srequest + +import "github.com/tech/sendico/pkg/model" + +type CreatePriorityGroup struct { + Description model.Describable `json:"description"` + Priorities []model.Colorable `json:"priorities"` +} diff --git a/api/server/interface/api/srequest/project.go b/api/server/interface/api/srequest/project.go new file mode 100644 index 0000000..09f082c --- /dev/null +++ b/api/server/interface/api/srequest/project.go @@ -0,0 +1,31 @@ +package srequest + +import ( + "github.com/tech/sendico/pkg/model" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +type CreateProject struct { + Project model.Describable `json:"project"` + LogoURI *string `json:"logoUrl,omitempty"` + PrioriyGroupRef primitive.ObjectID `json:"priorityGroupRef"` + StatusGroupRef primitive.ObjectID `json:"statusGroupRef"` + Mnemonic string `json:"mnemonic"` +} + +type ProjectPreview struct { + Projects []primitive.ObjectID `json:"projects"` +} + +type TagFilterMode string + +const ( + TagFilterModeNone TagFilterMode = "none" + TagFilterModePresent TagFilterMode = "present" + TagFilterModeMissing TagFilterMode = "missing" + TagFilterModeIncludeAny TagFilterMode = "includeAny" + TagFilterModeIncludeAll TagFilterMode = "includeAll" + TagFilterModeExcludeAny TagFilterMode = "excludeAny" +) + +type ProjectsFilter = model.ProjectFilterBase diff --git a/api/server/interface/api/srequest/project_delete.go b/api/server/interface/api/srequest/project_delete.go new file mode 100644 index 0000000..386dbeb --- /dev/null +++ b/api/server/interface/api/srequest/project_delete.go @@ -0,0 +1,11 @@ +package srequest + +import ( + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// DeleteProject represents a request to delete a project +type DeleteProject struct { + OrganizationRef primitive.ObjectID `json:"organizationRef"` // If provided, move tasks to this project. If null, delete all tasks + MoveTasksToProjectRef *primitive.ObjectID `json:"moveTasksToProjectRef,omitempty"` // If provided, move tasks to this project. If null, delete all tasks +} diff --git a/api/server/interface/api/srequest/refresh.go b/api/server/interface/api/srequest/refresh.go new file mode 100644 index 0000000..f19edff --- /dev/null +++ b/api/server/interface/api/srequest/refresh.go @@ -0,0 +1,5 @@ +package srequest + +import "github.com/tech/sendico/pkg/model" + +type AccessTokenRefresh = model.ClientRefreshToken diff --git a/api/server/interface/api/srequest/reorder.go b/api/server/interface/api/srequest/reorder.go new file mode 100644 index 0000000..5ba3763 --- /dev/null +++ b/api/server/interface/api/srequest/reorder.go @@ -0,0 +1,19 @@ +package srequest + +import "go.mongodb.org/mongo-driver/bson/primitive" + +type Reorder struct { + ParentRef primitive.ObjectID `json:"parentRef"` + From int `json:"from"` + To int `json:"to"` +} + +type ReorderX struct { + ObjectRef primitive.ObjectID `json:"objectRef"` + To int `json:"to"` +} + +type ReorderXDefault struct { + ReorderX `json:",inline"` + ParentRef primitive.ObjectID `json:"parentRef"` +} diff --git a/api/server/interface/api/srequest/rotate.go b/api/server/interface/api/srequest/rotate.go new file mode 100644 index 0000000..6315c79 --- /dev/null +++ b/api/server/interface/api/srequest/rotate.go @@ -0,0 +1,5 @@ +package srequest + +import "github.com/tech/sendico/pkg/model" + +type TokenRefreshRotate = model.ClientRefreshToken diff --git a/api/server/interface/api/srequest/sgchange.go b/api/server/interface/api/srequest/sgchange.go new file mode 100644 index 0000000..11048dc --- /dev/null +++ b/api/server/interface/api/srequest/sgchange.go @@ -0,0 +1,13 @@ +package srequest + +import "go.mongodb.org/mongo-driver/bson/primitive" + +type GroupItemChange struct { + GroupRef primitive.ObjectID `json:"groupRef"` + ItemRef primitive.ObjectID `json:"itemRef"` +} + +type RemoveItemFromGroup struct { + GroupItemChange `json:",inline"` + TargetItemRef primitive.ObjectID `json:"targetItemRef"` +} diff --git a/api/server/interface/api/srequest/signup.go b/api/server/interface/api/srequest/signup.go new file mode 100644 index 0000000..e9147be --- /dev/null +++ b/api/server/interface/api/srequest/signup.go @@ -0,0 +1,14 @@ +package srequest + +import "github.com/tech/sendico/pkg/model" + +type Signup struct { + Account model.AccountData `json:"account"` + OrganizationName string `json:"organizationName"` + OrganizationTimeZone string `json:"organizationTimeZone"` + DefaultPriorityGroup CreatePriorityGroup `json:"defaultPriorityGroup"` + DefaultStatusGroup CreateStatusGroup `json:"defaultStatusGroup"` + AnonymousUser model.Describable `json:"anonymousUser"` + OwnerRole model.Describable `json:"ownerRole"` + AnonymousRole model.Describable `json:"anonymousRole"` +} diff --git a/api/server/interface/api/srequest/signup_test.go b/api/server/interface/api/srequest/signup_test.go new file mode 100644 index 0000000..f53e063 --- /dev/null +++ b/api/server/interface/api/srequest/signup_test.go @@ -0,0 +1,312 @@ +package srequest_test + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/server/interface/api/srequest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Helper function to create string pointers +func stringPtr(s string) *string { + return &s +} + +func TestSignupRequest_JSONSerialization(t *testing.T) { + signup := 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", + }, + } + + // Test JSON marshaling + jsonData, err := json.Marshal(signup) + require.NoError(t, err) + assert.NotEmpty(t, jsonData) + + // Test JSON unmarshaling + var unmarshaled srequest.Signup + err = json.Unmarshal(jsonData, &unmarshaled) + require.NoError(t, err) + + // Verify all fields are properly serialized/deserialized + assert.Equal(t, signup.Account.Name, unmarshaled.Account.Name) + assert.Equal(t, signup.Account.Login, unmarshaled.Account.Login) + assert.Equal(t, signup.Account.Password, unmarshaled.Account.Password) + assert.Equal(t, signup.OrganizationName, unmarshaled.OrganizationName) + assert.Equal(t, signup.OrganizationTimeZone, unmarshaled.OrganizationTimeZone) + assert.Equal(t, signup.DefaultPriorityGroup.Description.Name, unmarshaled.DefaultPriorityGroup.Description.Name) + assert.Equal(t, len(signup.DefaultPriorityGroup.Priorities), len(unmarshaled.DefaultPriorityGroup.Priorities)) + assert.Equal(t, signup.AnonymousUser.Name, unmarshaled.AnonymousUser.Name) + assert.Equal(t, signup.OwnerRole.Name, unmarshaled.OwnerRole.Name) + assert.Equal(t, signup.AnonymousRole.Name, unmarshaled.AnonymousRole.Name) + + // Verify priorities + for i, priority := range signup.DefaultPriorityGroup.Priorities { + assert.Equal(t, priority.Name, unmarshaled.DefaultPriorityGroup.Priorities[i].Name) + if priority.Color != nil && unmarshaled.DefaultPriorityGroup.Priorities[i].Color != nil { + assert.Equal(t, *priority.Color, *unmarshaled.DefaultPriorityGroup.Priorities[i].Color) + } + } +} + +func TestSignupRequest_MinimalValidRequest(t *testing.T) { + signup := 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", + }, + Priorities: []model.Colorable{ + { + Describable: model.Describable{Name: "Normal"}, + Color: stringPtr("#000000"), + }, + }, + }, + AnonymousUser: model.Describable{ + Name: "Anonymous", + }, + OwnerRole: model.Describable{ + Name: "Owner", + }, + AnonymousRole: model.Describable{ + Name: "Anonymous", + }, + } + + // Test JSON marshaling + jsonData, err := json.Marshal(signup) + require.NoError(t, err) + assert.NotEmpty(t, jsonData) + + // Test JSON unmarshaling + var unmarshaled srequest.Signup + err = json.Unmarshal(jsonData, &unmarshaled) + require.NoError(t, err) + + // Verify minimal request is valid + assert.Equal(t, signup.Account.Name, unmarshaled.Account.Name) + assert.Equal(t, signup.Account.Login, unmarshaled.Account.Login) + assert.Equal(t, signup.OrganizationName, unmarshaled.OrganizationName) + assert.Len(t, unmarshaled.DefaultPriorityGroup.Priorities, 1) +} + +func TestSignupRequest_InvalidJSON(t *testing.T) { + invalidJSONs := []string{ + `{"account": invalid}`, + `{"organizationName": 123}`, + `{"organizationTimeZone": true}`, + `{"defaultPriorityGroup": "not_an_object"}`, + `{"anonymousUser": []}`, + `{"anonymousRole": 456}`, + `{invalid json}`, + } + + for i, invalidJSON := range invalidJSONs { + t.Run(fmt.Sprintf("Invalid JSON %d", i), func(t *testing.T) { + var signup srequest.Signup + err := json.Unmarshal([]byte(invalidJSON), &signup) + require.Error(t, err) + }) + } +} + +func TestSignupRequest_UnicodeCharacters(t *testing.T) { + signup := srequest.Signup{ + Account: model.AccountData{ + LoginData: model.LoginData{ + UserDataBase: model.UserDataBase{ + Login: "测试@example.com", + }, + Password: "TestPassword123!", + }, + Name: "Test 用户 Üser", + }, + OrganizationName: "测试 Organization", + OrganizationTimeZone: "UTC", + DefaultPriorityGroup: srequest.CreatePriorityGroup{ + Description: model.Describable{ + Name: "默认 Priority Group", + }, + Priorities: []model.Colorable{ + { + Describable: model.Describable{Name: "高"}, + Color: stringPtr("#FF0000"), + }, + }, + }, + AnonymousUser: model.Describable{ + Name: "匿名 User", + }, + OwnerRole: model.Describable{ + Name: "所有者", + }, + AnonymousRole: model.Describable{ + Name: "匿名", + }, + } + + // Test JSON marshaling + jsonData, err := json.Marshal(signup) + require.NoError(t, err) + assert.NotEmpty(t, jsonData) + + // Test JSON unmarshaling + var unmarshaled srequest.Signup + err = json.Unmarshal(jsonData, &unmarshaled) + require.NoError(t, err) + + // Verify unicode characters are properly handled + assert.Equal(t, "测试@example.com", unmarshaled.Account.Login) + assert.Equal(t, "Test 用户 Üser", unmarshaled.Account.Name) + assert.Equal(t, "测试 Organization", unmarshaled.OrganizationName) + assert.Equal(t, "默认 Priority Group", unmarshaled.DefaultPriorityGroup.Description.Name) + assert.Equal(t, "高", unmarshaled.DefaultPriorityGroup.Priorities[0].Name) + assert.Equal(t, "匿名 User", unmarshaled.AnonymousUser.Name) + assert.Equal(t, "所有者", unmarshaled.OwnerRole.Name) + assert.Equal(t, "匿名", unmarshaled.AnonymousRole.Name) +} + +func TestCreatePriorityGroup_JSONSerialization(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"), + }, + }, + } + + // Test JSON marshaling + jsonData, err := json.Marshal(priorityGroup) + require.NoError(t, err) + assert.NotEmpty(t, jsonData) + + // Test JSON unmarshaling + var unmarshaled srequest.CreatePriorityGroup + err = json.Unmarshal(jsonData, &unmarshaled) + require.NoError(t, err) + + // Verify all fields are properly serialized/deserialized + assert.Equal(t, priorityGroup.Description.Name, unmarshaled.Description.Name) + assert.Equal(t, len(priorityGroup.Priorities), len(unmarshaled.Priorities)) + + for i, priority := range priorityGroup.Priorities { + assert.Equal(t, priority.Name, unmarshaled.Priorities[i].Name) + if priority.Color != nil && unmarshaled.Priorities[i].Color != nil { + assert.Equal(t, *priority.Color, *unmarshaled.Priorities[i].Color) + } + } +} + +func TestCreatePriorityGroup_EmptyPriorities(t *testing.T) { + priorityGroup := srequest.CreatePriorityGroup{ + Description: model.Describable{ + Name: "Empty Priority Group", + }, + Priorities: []model.Colorable{}, + } + + // Test JSON marshaling + jsonData, err := json.Marshal(priorityGroup) + require.NoError(t, err) + assert.NotEmpty(t, jsonData) + + // Test JSON unmarshaling + var unmarshaled srequest.CreatePriorityGroup + err = json.Unmarshal(jsonData, &unmarshaled) + require.NoError(t, err) + + // Verify empty priorities array is handled correctly + assert.Equal(t, priorityGroup.Description.Name, unmarshaled.Description.Name) + assert.Empty(t, unmarshaled.Priorities) +} + +func TestCreatePriorityGroup_NilPriorities(t *testing.T) { + priorityGroup := srequest.CreatePriorityGroup{ + Description: model.Describable{ + Name: "Nil Priority Group", + }, + Priorities: nil, + } + + // Test JSON marshaling + jsonData, err := json.Marshal(priorityGroup) + require.NoError(t, err) + assert.NotEmpty(t, jsonData) + + // Test JSON unmarshaling + var unmarshaled srequest.CreatePriorityGroup + err = json.Unmarshal(jsonData, &unmarshaled) + require.NoError(t, err) + + // Verify nil priorities is handled correctly + assert.Equal(t, priorityGroup.Description.Name, unmarshaled.Description.Name) + assert.Nil(t, unmarshaled.Priorities) +} diff --git a/api/server/interface/api/srequest/status.go b/api/server/interface/api/srequest/status.go new file mode 100644 index 0000000..5264f8c --- /dev/null +++ b/api/server/interface/api/srequest/status.go @@ -0,0 +1,16 @@ +package srequest + +import ( + "github.com/tech/sendico/pkg/model" +) + +type CreateStatus struct { + model.Colorable `json:"description"` + Icon string `json:"icon"` + IsFinal bool `json:"isFinal"` +} + +type CreateStatusGroup struct { + Description model.Describable `json:"description"` + Statuses []CreateStatus `json:"statuses"` +} diff --git a/api/server/interface/api/srequest/taggable.go b/api/server/interface/api/srequest/taggable.go new file mode 100644 index 0000000..c762aea --- /dev/null +++ b/api/server/interface/api/srequest/taggable.go @@ -0,0 +1,20 @@ +package srequest + +import "go.mongodb.org/mongo-driver/bson/primitive" + +// TaggableSingle is used for single tag operations (add/remove tag) +type TaggableSingle struct { + ObjectRef primitive.ObjectID `json:"objectRef"` + TagRef primitive.ObjectID `json:"tagRef"` +} + +// TaggableMultiple is used for multiple tag operations (add tags, set tags) +type TaggableMultiple struct { + ObjectRef primitive.ObjectID `json:"objectRef"` + TagRefs []primitive.ObjectID `json:"tagRefs"` +} + +// TaggableObject is used for object-only operations (remove all tags, get tags) +type TaggableObject struct { + ObjectRef primitive.ObjectID `json:"objectRef"` +} diff --git a/api/server/interface/api/sresponse/account.go b/api/server/interface/api/sresponse/account.go new file mode 100644 index 0000000..2dfc58e --- /dev/null +++ b/api/server/interface/api/sresponse/account.go @@ -0,0 +1,62 @@ +package sresponse + +import ( + "net/http" + + "github.com/tech/sendico/pkg/api/http/response" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/model" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +type accountData struct { + model.AccountPublic `json:",inline"` + IsAnonymous bool `json:"isAnonymous"` +} + +type accountResponse struct { + authResponse `json:",inline"` + Account accountData `json:"account"` +} + +func _createAccount(account *model.Account, isAnonymous bool) *accountData { + return &accountData{ + AccountPublic: account.AccountPublic, + IsAnonymous: isAnonymous, + } +} + +func _toAccount(account *model.Account, orgRef primitive.ObjectID) *accountData { + return _createAccount(account, model.AccountIsAnonymous(&account.UserDataBase, orgRef)) +} + +func Account(logger mlogger.Logger, account *model.Account, accessToken *TokenData) http.HandlerFunc { + return response.Ok( + logger, + &accountResponse{ + Account: *_createAccount(account, false), + authResponse: authResponse{AccessToken: *accessToken}, + }, + ) +} + +type accountsResponse struct { + authResponse `json:",inline"` + Accounts []accountData `json:"accounts"` +} + +func Accounts(logger mlogger.Logger, accounts []model.Account, orgRef primitive.ObjectID, accessToken *TokenData) http.HandlerFunc { + // Convert each account to its public representation. + publicAccounts := make([]accountData, len(accounts)) + for i, a := range accounts { + publicAccounts[i] = *_toAccount(&a, orgRef) + } + + return response.Ok( + logger, + &accountsResponse{ + Accounts: publicAccounts, + authResponse: authResponse{AccessToken: *accessToken}, + }, + ) +} diff --git a/api/server/interface/api/sresponse/authresp.go b/api/server/interface/api/sresponse/authresp.go new file mode 100644 index 0000000..7679a5a --- /dev/null +++ b/api/server/interface/api/sresponse/authresp.go @@ -0,0 +1,5 @@ +package sresponse + +type authResponse struct { + AccessToken TokenData `json:"accessToken"` +} diff --git a/api/server/interface/api/sresponse/badpassword.go b/api/server/interface/api/sresponse/badpassword.go new file mode 100644 index 0000000..17cd294 --- /dev/null +++ b/api/server/interface/api/sresponse/badpassword.go @@ -0,0 +1,15 @@ +package sresponse + +import ( + "net/http" + + "github.com/tech/sendico/pkg/api/http/response" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/mservice" + "go.uber.org/zap" +) + +func BadRPassword(logger mlogger.Logger, source mservice.Type, err error) http.HandlerFunc { + logger.Info("Failed password validation check", zap.Error(err)) + return response.BadRequest(logger, source, "invalid_request", err.Error()) +} diff --git a/api/server/interface/api/sresponse/commentp.go b/api/server/interface/api/sresponse/commentp.go new file mode 100644 index 0000000..bce9fc9 --- /dev/null +++ b/api/server/interface/api/sresponse/commentp.go @@ -0,0 +1,24 @@ +package sresponse + +import ( + "net/http" + + "github.com/tech/sendico/pkg/api/http/response" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/model" +) + +type commentPreviewResponse struct { + authResponse `json:",inline"` + Comments []model.CommentPreview `json:"comments"` +} + +func CommentPreview(logger mlogger.Logger, accessToken *TokenData, comments []model.CommentPreview) http.HandlerFunc { + return response.Ok( + logger, + &commentPreviewResponse{ + Comments: comments, + authResponse: authResponse{AccessToken: *accessToken}, + }, + ) +} diff --git a/api/server/interface/api/sresponse/dzone.go b/api/server/interface/api/sresponse/dzone.go new file mode 100644 index 0000000..21932fe --- /dev/null +++ b/api/server/interface/api/sresponse/dzone.go @@ -0,0 +1,24 @@ +package sresponse + +import ( + "net/http" + + "github.com/tech/sendico/pkg/api/http/response" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/model" +) + +type dzoneResponse struct { + authResponse `json:",inline"` + DZone model.DZone `json:"dzone"` +} + +func DZone(logger mlogger.Logger, dzone *model.DZone, accessToken *TokenData) http.HandlerFunc { + return response.Ok( + logger, + &dzoneResponse{ + DZone: *dzone, + authResponse: authResponse{AccessToken: *accessToken}, + }, + ) +} diff --git a/api/server/interface/api/sresponse/file.go b/api/server/interface/api/sresponse/file.go new file mode 100644 index 0000000..2e58bbb --- /dev/null +++ b/api/server/interface/api/sresponse/file.go @@ -0,0 +1,16 @@ +package sresponse + +import ( + "net/http" + + "github.com/tech/sendico/pkg/api/http/response" + "github.com/tech/sendico/pkg/mlogger" +) + +type fileUpladed struct { + URL string `json:"url"` +} + +func FileUploaded(logger mlogger.Logger, url string) http.HandlerFunc { + return response.Ok(logger, &fileUpladed{URL: url}) +} diff --git a/api/server/interface/api/sresponse/invitation.go b/api/server/interface/api/sresponse/invitation.go new file mode 100644 index 0000000..9148076 --- /dev/null +++ b/api/server/interface/api/sresponse/invitation.go @@ -0,0 +1,21 @@ +package sresponse + +import ( + "net/http" + + "github.com/tech/sendico/pkg/api/http/response" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/model" +) + +type invitationResp struct { + Invitation model.PublicInvitation `json:"invitation"` +} + +func Invitation(logger mlogger.Logger, invitation *model.PublicInvitation) http.HandlerFunc { + return response.Ok(logger, &invitationResp{Invitation: *invitation}) +} + +func Invitations(logger mlogger.Logger, invitations []model.Invitation) http.HandlerFunc { + return response.Ok(logger, invitations) +} diff --git a/api/server/interface/api/sresponse/login.go b/api/server/interface/api/sresponse/login.go new file mode 100644 index 0000000..1f22a10 --- /dev/null +++ b/api/server/interface/api/sresponse/login.go @@ -0,0 +1,27 @@ +package sresponse + +import ( + "net/http" + + "github.com/tech/sendico/pkg/api/http/response" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/model" +) + +type loginResponse struct { + accountResponse + RefreshToken TokenData `json:"refreshToken"` +} + +func Login(logger mlogger.Logger, account *model.Account, accessToken, refreshToken *TokenData) http.HandlerFunc { + return response.Ok( + logger, + &loginResponse{ + accountResponse: accountResponse{ + Account: *_createAccount(account, false), + authResponse: authResponse{AccessToken: *accessToken}, + }, + RefreshToken: *refreshToken, + }, + ) +} diff --git a/api/server/interface/api/sresponse/objects.go b/api/server/interface/api/sresponse/objects.go new file mode 100644 index 0000000..f0720c3 --- /dev/null +++ b/api/server/interface/api/sresponse/objects.go @@ -0,0 +1,49 @@ +package sresponse + +import ( + "encoding/json" + "net/http" + + "github.com/tech/sendico/pkg/api/http/response" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/mservice" +) + +type DynamicResponse[T any] struct { + authResponse `json:",inline"` + Items []T + // FieldName is the JSON key to use for the items. + FieldName string +} + +func (dr DynamicResponse[T]) MarshalJSON() ([]byte, error) { + // Create a temporary map to hold the keys and values. + m := map[string]any{ + dr.FieldName: dr.Items, + "accessToken": dr.AccessToken, + } + return json.Marshal(m) +} + +type handler = func(logger mlogger.Logger, data any) http.HandlerFunc + +func objectsAuth[T any](logger mlogger.Logger, items []T, accessToken *TokenData, resource mservice.Type, handler handler) http.HandlerFunc { + resp := &DynamicResponse[T]{ + Items: items, + authResponse: authResponse{AccessToken: *accessToken}, + FieldName: resource, + } + return handler(logger, resp) +} + +func ObjectsAuth[T any](logger mlogger.Logger, items []T, accessToken *TokenData, resource mservice.Type) http.HandlerFunc { + return objectsAuth(logger, items, accessToken, resource, response.Ok) +} + +func ObjectAuth[T any](logger mlogger.Logger, item *T, accessToken *TokenData, resource mservice.Type) http.HandlerFunc { + return ObjectsAuth(logger, []T{*item}, accessToken, resource) +} + +func ObjectAuthCreated[T any](logger mlogger.Logger, item *T, accessToken *TokenData, resource mservice.Type) http.HandlerFunc { + return objectsAuth(logger, []T{*item}, accessToken, resource, response.Created) +} diff --git a/api/server/interface/api/sresponse/orgnization.go b/api/server/interface/api/sresponse/orgnization.go new file mode 100644 index 0000000..5be5d44 --- /dev/null +++ b/api/server/interface/api/sresponse/orgnization.go @@ -0,0 +1,35 @@ +package sresponse + +import ( + "net/http" + + "github.com/tech/sendico/pkg/api/http/response" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/model" +) + +type organizationsResponse struct { + authResponse `json:",inline"` + Organizations []model.Organization `json:"organizations"` +} + +func Organization(logger mlogger.Logger, organization *model.Organization, accessToken *TokenData) http.HandlerFunc { + return Organizations(logger, []model.Organization{*organization}, accessToken) +} + +func Organizations(logger mlogger.Logger, organizations []model.Organization, accessToken *TokenData) http.HandlerFunc { + return response.Ok(logger, organizationsResponse{ + Organizations: organizations, + authResponse: authResponse{AccessToken: *accessToken}, + }) +} + +type organizationPublicResponse struct { + Organizations []model.OrganizationBase `json:"organizations"` +} + +func OrganizationPublic(logger mlogger.Logger, organization *model.OrganizationBase) http.HandlerFunc { + return response.Ok(logger, organizationPublicResponse{ + []model.OrganizationBase{*organization}, + }) +} diff --git a/api/server/interface/api/sresponse/permissions.go b/api/server/interface/api/sresponse/permissions.go new file mode 100644 index 0000000..a56a2e9 --- /dev/null +++ b/api/server/interface/api/sresponse/permissions.go @@ -0,0 +1,45 @@ +package sresponse + +import ( + "net/http" + + "github.com/tech/sendico/pkg/api/http/response" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/model" +) + +type permissionsDescription struct { + Roles []model.RoleDescription `json:"roles"` + Policies []model.PolicyDescription `json:"policies"` +} + +type permissionsData struct { + Roles []model.Role `json:"roles"` + Policies []model.RolePolicy `json:"policies"` + Permissions []model.Permission `json:"permissions"` +} + +type permissionsResponse struct { + authResponse `json:",inline"` + Descriptions permissionsDescription `json:"descriptions"` + Permissions permissionsData `json:"permissions"` +} + +func Permisssions(logger mlogger.Logger, + rolesDescs []model.RoleDescription, policiesDescs []model.PolicyDescription, + roles []model.Role, policies []model.RolePolicy, permissions []model.Permission, + accessToken *TokenData, +) http.HandlerFunc { + return response.Ok(logger, permissionsResponse{ + Descriptions: permissionsDescription{ + Roles: rolesDescs, + Policies: policiesDescs, + }, + Permissions: permissionsData{ + Roles: roles, + Policies: policies, + Permissions: permissions, + }, + authResponse: authResponse{AccessToken: *accessToken}, + }) +} diff --git a/api/server/interface/api/sresponse/projects.go b/api/server/interface/api/sresponse/projects.go new file mode 100644 index 0000000..965aba7 --- /dev/null +++ b/api/server/interface/api/sresponse/projects.go @@ -0,0 +1,37 @@ +package sresponse + +import ( + "net/http" + + "github.com/tech/sendico/pkg/api/http/response" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/model" +) + +type projectsResponse struct { + authResponse `json:",inline"` + Projects []model.Project `json:"projects"` +} + +func Projects(logger mlogger.Logger, projects []model.Project, accessToken *TokenData) http.HandlerFunc { + return response.Ok(logger, projectsResponse{ + Projects: projects, + authResponse: authResponse{AccessToken: *accessToken}, + }) +} + +func Project(logger mlogger.Logger, project *model.Project, accessToken *TokenData) http.HandlerFunc { + return Projects(logger, []model.Project{*project}, accessToken) +} + +type projectPreviewsResponse struct { + authResponse `json:",inline"` + Previews []model.ProjectPreview `json:"previews"` +} + +func ProjectsPreviews(logger mlogger.Logger, previews []model.ProjectPreview, accessToken *TokenData) http.HandlerFunc { + return response.Ok(logger, &projectPreviewsResponse{ + authResponse: authResponse{AccessToken: *accessToken}, + Previews: previews, + }) +} diff --git a/api/server/interface/api/sresponse/response.go b/api/server/interface/api/sresponse/response.go new file mode 100644 index 0000000..04a6cf8 --- /dev/null +++ b/api/server/interface/api/sresponse/response.go @@ -0,0 +1,12 @@ +package sresponse + +import ( + "net/http" + + "github.com/tech/sendico/pkg/model" +) + +type ( + HandlerFunc = func(r *http.Request) http.HandlerFunc + AccountHandlerFunc = func(r *http.Request, account *model.Account, accessToken *TokenData) http.HandlerFunc +) diff --git a/api/server/interface/api/sresponse/result.go b/api/server/interface/api/sresponse/result.go new file mode 100644 index 0000000..64671a5 --- /dev/null +++ b/api/server/interface/api/sresponse/result.go @@ -0,0 +1,27 @@ +package sresponse + +import ( + "net/http" + + "github.com/tech/sendico/pkg/api/http/response" + "github.com/tech/sendico/pkg/mlogger" +) + +type resultAuth struct { + authResponse `json:",inline"` + response.Result `json:",inline"` +} + +func Success(logger mlogger.Logger, accessToken *TokenData) http.HandlerFunc { + return response.Ok(logger, &resultAuth{ + Result: response.Result{Result: true}, + authResponse: authResponse{AccessToken: *accessToken}, + }) +} + +func Failed(logger mlogger.Logger, accessToken *TokenData) http.HandlerFunc { + return response.Accepted(logger, &resultAuth{ + Result: response.Result{Result: false}, + authResponse: authResponse{AccessToken: *accessToken}, + }) +} diff --git a/api/server/interface/api/sresponse/signup.go b/api/server/interface/api/sresponse/signup.go new file mode 100644 index 0000000..418ddc6 --- /dev/null +++ b/api/server/interface/api/sresponse/signup.go @@ -0,0 +1,16 @@ +package sresponse + +import ( + "net/http" + + "github.com/tech/sendico/pkg/api/http/response" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/model" +) + +func SignUp(logger mlogger.Logger, account *model.Account) http.HandlerFunc { + return response.Ok( + logger, + &account.AccountBase, + ) +} diff --git a/api/server/interface/api/sresponse/statuses.go b/api/server/interface/api/sresponse/statuses.go new file mode 100644 index 0000000..6b6ae0d --- /dev/null +++ b/api/server/interface/api/sresponse/statuses.go @@ -0,0 +1,25 @@ +package sresponse + +import ( + "net/http" + + "github.com/tech/sendico/pkg/api/http/response" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/model" +) + +type statusesResponse struct { + authResponse `json:",inline"` + Statuses []model.Status `json:"statuses"` +} + +func Statuses(logger mlogger.Logger, statuses []model.Status, accessToken *TokenData) http.HandlerFunc { + return response.Ok(logger, statusesResponse{ + Statuses: statuses, + authResponse: authResponse{AccessToken: *accessToken}, + }) +} + +func Status(logger mlogger.Logger, status *model.Status, accessToken *TokenData) http.HandlerFunc { + return Statuses(logger, []model.Status{*status}, accessToken) +} diff --git a/api/server/interface/api/sresponse/token.go b/api/server/interface/api/sresponse/token.go new file mode 100644 index 0000000..3bbf9df --- /dev/null +++ b/api/server/interface/api/sresponse/token.go @@ -0,0 +1,8 @@ +package sresponse + +import "time" + +type TokenData struct { + Token string `json:"token"` + Expiration time.Time `json:"expiration"` +} diff --git a/api/server/interface/api/sresponse/ws/response.go b/api/server/interface/api/sresponse/ws/response.go new file mode 100644 index 0000000..582e153 --- /dev/null +++ b/api/server/interface/api/sresponse/ws/response.go @@ -0,0 +1,57 @@ +package ws + +import ( + "net/http" + + api "github.com/tech/sendico/pkg/api/http" + r "github.com/tech/sendico/pkg/api/http/response" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/server/interface/api/ws" + "go.uber.org/zap" + "golang.org/x/net/websocket" +) + +func respond(logger mlogger.Logger, conn *websocket.Conn, messageType, apiStatus, requestID string, data any) { + message := ws.Message{ + BaseResponse: r.BaseResponse{ + Status: apiStatus, + Data: data, + }, + ID: requestID, + MessageType: messageType, + } + + if err := websocket.JSON.Send(conn, message); err != nil { + logger.Warn("Failed to send error message", zap.Error(err), zap.Any("message", message)) + } +} + +func errorf(logger mlogger.Logger, messageType, requestID string, conn *websocket.Conn, resp r.ErrorResponse) { + logger.Debug( + "Writing error sresponse", + zap.String("error", resp.Error), + zap.String("details", resp.Details), + zap.Int("code", resp.Code), + ) + respond(logger, conn, messageType, api.MSError, requestID, &resp) +} + +func Ok(logger mlogger.Logger, requestID string, data any) ws.ResponseHandler { + res := func(messageType string, conn *websocket.Conn) { + logger.Debug("Successfully executed request", zap.Any("sresponse", data)) + respond(logger, conn, messageType, api.MSSuccess, requestID, data) + } + return res +} + +func Internal(logger mlogger.Logger, requestID string, err error) ws.ResponseHandler { + res := func(messageType string, conn *websocket.Conn) { + errorf(logger, messageType, requestID, conn, + r.ErrorResponse{ + Error: "internal_error", + Details: err.Error(), + Code: http.StatusInternalServerError, + }) + } + return res +} diff --git a/api/server/interface/api/ws/config.go b/api/server/interface/api/ws/config.go new file mode 100644 index 0000000..82a5bf7 --- /dev/null +++ b/api/server/interface/api/ws/config.go @@ -0,0 +1,9 @@ +package ws + +import ( + ac "github.com/tech/sendico/server/internal/api/config" +) + +type ( + Config = ac.WebSocketConfig +) diff --git a/api/server/interface/api/ws/handler.go b/api/server/interface/api/ws/handler.go new file mode 100644 index 0000000..34979bb --- /dev/null +++ b/api/server/interface/api/ws/handler.go @@ -0,0 +1,12 @@ +package ws + +import ( + "context" + + "golang.org/x/net/websocket" +) + +type ( + ResponseHandler func(messageType string, conn *websocket.Conn) + HandlerFunc func(ctx context.Context, msg Message) ResponseHandler +) diff --git a/api/server/interface/api/ws/message.go b/api/server/interface/api/ws/message.go new file mode 100644 index 0000000..66544fc --- /dev/null +++ b/api/server/interface/api/ws/message.go @@ -0,0 +1,9 @@ +package ws + +import "github.com/tech/sendico/pkg/api/http/response" + +type Message struct { + response.BaseResponse + ID string `json:"id"` + MessageType string `json:"messageType"` +} diff --git a/api/server/interface/middleware/middleware.go b/api/server/interface/middleware/middleware.go new file mode 100644 index 0000000..57ca8fe --- /dev/null +++ b/api/server/interface/middleware/middleware.go @@ -0,0 +1,31 @@ +package middleware + +import ( + "os" + + ai "github.com/tech/sendico/server/internal/api/config" +) + +type ( + TokenConfig = ai.TokenConfig + Config = ai.Config + Signature = ai.SignatureConf + PasswordConfig = ai.PasswordConfig +) + +type MapClaims = ai.MapClaims + +func getKey(osEnv string) any { + if len(osEnv) == 0 { + return nil + } + return []byte(os.Getenv(osEnv)) +} + +func SignatureConf(conf *Config) Signature { + return Signature{ + PrivateKey: []byte(os.Getenv(conf.Signature.PrivateKeyEnv)), + PublicKey: getKey(conf.Signature.PublicKeyEnv), + Algorithm: conf.Signature.Algorithm, + } +} diff --git a/api/server/interface/model/token.go b/api/server/interface/model/token.go new file mode 100644 index 0000000..7f51ba6 --- /dev/null +++ b/api/server/interface/model/token.go @@ -0,0 +1,94 @@ +package model + +import ( + "fmt" + "time" + + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/model" + mduration "github.com/tech/sendico/pkg/mutil/duration" + "github.com/tech/sendico/server/interface/middleware" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +type AccountToken struct { + AccountRef primitive.ObjectID + Login string + Name string + Locale string + Expiration time.Time +} + +func createAccountToken(a *model.Account, expiration int) AccountToken { + return AccountToken{ + AccountRef: *a.GetID(), + Login: a.Login, + Name: a.Name, + Locale: a.Locale, + Expiration: time.Now().Add(mduration.Param2Duration(expiration, time.Hour)), + } +} + +func getTokenParam(claims middleware.MapClaims, param string) (string, error) { + id, ok := claims[param].(string) + if !ok { + return "", merrors.NoData(fmt.Sprintf("param '%s' not found", param)) + } + + return id, nil +} + +const ( + paramNameID = "id" + paramNameName = "name" + paramNameLocale = "locale" + paramNameLogin = "login" + paramNameExpiration = "exp" +) + +func Claims2Token(claims middleware.MapClaims) (*AccountToken, error) { + var at AccountToken + var err error + var account string + if account, err = getTokenParam(claims, paramNameID); err != nil { + return nil, err + } + if at.AccountRef, err = primitive.ObjectIDFromHex(account); err != nil { + return nil, err + } + if at.Login, err = getTokenParam(claims, paramNameLogin); err != nil { + return nil, err + } + if at.Name, err = getTokenParam(claims, paramNameName); err != nil { + return nil, err + } + if at.Locale, err = getTokenParam(claims, paramNameLocale); err != nil { + return nil, err + } + if expValue, ok := claims[paramNameExpiration]; ok { + switch exp := expValue.(type) { + case time.Time: + at.Expiration = exp + case float64: + at.Expiration = time.Unix(int64(exp), 0) + case int64: + at.Expiration = time.Unix(exp, 0) + default: + return nil, merrors.InvalidDataType(fmt.Sprintf("expiration param is of invalid type: %T", expValue)) + } + } else { + return nil, merrors.InvalidDataType(fmt.Sprintf("expiration param is of invalid type: %T", expValue)) + } + return &at, nil +} + +func Account2Claims(a *model.Account, expiration int) middleware.MapClaims { + t := createAccountToken(a, expiration) + return middleware.MapClaims{ + paramNameID: t.AccountRef.Hex(), + paramNameLogin: t.Login, + paramNameName: t.Name, + paramNameLocale: t.Locale, + paramNameExpiration: int64(t.Expiration.Unix()), + } +} diff --git a/api/server/interface/services/account/account.go b/api/server/interface/services/account/account.go new file mode 100644 index 0000000..c169c25 --- /dev/null +++ b/api/server/interface/services/account/account.go @@ -0,0 +1,11 @@ +package account + +import ( + "github.com/tech/sendico/pkg/mservice" + "github.com/tech/sendico/server/interface/api" + "github.com/tech/sendico/server/internal/server/accountapiimp" +) + +func Create(a api.API) (mservice.MicroService, error) { + return accountapiimp.CreateAPI(a) +} diff --git a/api/server/interface/services/fileservice/config/config.go b/api/server/interface/services/fileservice/config/config.go new file mode 100644 index 0000000..492cbb4 --- /dev/null +++ b/api/server/interface/services/fileservice/config/config.go @@ -0,0 +1,12 @@ +package fileservice + +import "github.com/tech/sendico/pkg/model" + +type StorageType string + +const ( + LocalFS StorageType = "local_fs" + AwsS3 StorageType = "aws_s3" +) + +type Config = model.DriverConfig[StorageType] diff --git a/api/server/interface/services/fileservice/fileservice.go b/api/server/interface/services/fileservice/fileservice.go new file mode 100644 index 0000000..f467ca3 --- /dev/null +++ b/api/server/interface/services/fileservice/fileservice.go @@ -0,0 +1,11 @@ +package fileservice + +import ( + "github.com/tech/sendico/pkg/mservice" + "github.com/tech/sendico/server/interface/api" + "github.com/tech/sendico/server/internal/server/fileserviceimp" +) + +func CreateAPI(a api.API, directory string) (mservice.MicroService, error) { + return fileserviceimp.CreateAPI(a, directory) +} diff --git a/api/server/interface/services/invitation/invitation.go b/api/server/interface/services/invitation/invitation.go new file mode 100644 index 0000000..d235575 --- /dev/null +++ b/api/server/interface/services/invitation/invitation.go @@ -0,0 +1,11 @@ +package invitation + +import ( + "github.com/tech/sendico/pkg/mservice" + "github.com/tech/sendico/server/interface/api" + "github.com/tech/sendico/server/internal/server/invitationimp" +) + +func Create(a api.API) (mservice.MicroService, error) { + return invitationimp.CreateAPI(a) +} diff --git a/api/server/interface/services/logo/logo.go b/api/server/interface/services/logo/logo.go new file mode 100644 index 0000000..c99952b --- /dev/null +++ b/api/server/interface/services/logo/logo.go @@ -0,0 +1,11 @@ +package logo + +import ( + "github.com/tech/sendico/pkg/mservice" + "github.com/tech/sendico/server/interface/api" + "github.com/tech/sendico/server/internal/server/logoimp" +) + +func Create(a api.API) (mservice.MicroService, error) { + return logoimp.CreateAPI(a) +} diff --git a/api/server/interface/services/organization/organization.go b/api/server/interface/services/organization/organization.go new file mode 100644 index 0000000..04b0fe4 --- /dev/null +++ b/api/server/interface/services/organization/organization.go @@ -0,0 +1,11 @@ +package organization + +import ( + "github.com/tech/sendico/pkg/mservice" + "github.com/tech/sendico/server/interface/api" + "github.com/tech/sendico/server/internal/server/organizationimp" +) + +func Create(a api.API) (mservice.MicroService, error) { + return organizationimp.CreateAPI(a) +} diff --git a/api/server/interface/services/permission/permission.go b/api/server/interface/services/permission/permission.go new file mode 100644 index 0000000..282dd7c --- /dev/null +++ b/api/server/interface/services/permission/permission.go @@ -0,0 +1,11 @@ +package permission + +import ( + "github.com/tech/sendico/pkg/mservice" + "github.com/tech/sendico/server/interface/api" + "github.com/tech/sendico/server/internal/server/permissionsimp" +) + +func Create(a api.API) (mservice.MicroService, error) { + return permissionsimp.CreateAPI(a) +} diff --git a/api/server/internal/api/api.go b/api/server/internal/api/api.go new file mode 100644 index 0000000..6a255ec --- /dev/null +++ b/api/server/internal/api/api.go @@ -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 +} diff --git a/api/server/internal/api/config/config.go b/api/server/internal/api/config/config.go new file mode 100755 index 0000000..c3c8fc0 --- /dev/null +++ b/api/server/internal/api/config/config.go @@ -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 diff --git a/api/server/internal/api/middleware.go b/api/server/internal/api/middleware.go new file mode 100644 index 0000000..3642cca --- /dev/null +++ b/api/server/internal/api/middleware.go @@ -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 +} diff --git a/api/server/internal/api/routers/authorized/handler.go b/api/server/internal/api/routers/authorized/handler.go new file mode 100644 index 0000000..b0a3580 --- /dev/null +++ b/api/server/internal/api/routers/authorized/handler.go @@ -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) +} diff --git a/api/server/internal/api/routers/authorized/router.go b/api/server/internal/api/routers/authorized/router.go new file mode 100644 index 0000000..7bd9596 --- /dev/null +++ b/api/server/internal/api/routers/authorized/router.go @@ -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 +} diff --git a/api/server/internal/api/routers/dispatcher.go b/api/server/internal/api/routers/dispatcher.go new file mode 100644 index 0000000..6f6ebb0 --- /dev/null +++ b/api/server/internal/api/routers/dispatcher.go @@ -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 +} diff --git a/api/server/internal/api/routers/endpoint/endpoint.go b/api/server/internal/api/routers/endpoint/endpoint.go new file mode 100644 index 0000000..4710f2b --- /dev/null +++ b/api/server/internal/api/routers/endpoint/endpoint.go @@ -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 +} diff --git a/api/server/internal/api/routers/endpoint/install.go b/api/server/internal/api/routers/endpoint/install.go new file mode 100644 index 0000000..a5ecd8e --- /dev/null +++ b/api/server/internal/api/routers/endpoint/install.go @@ -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))) +} diff --git a/api/server/internal/api/routers/endpoint/token.go b/api/server/internal/api/routers/endpoint/token.go new file mode 100644 index 0000000..bb01eb2 --- /dev/null +++ b/api/server/internal/api/routers/endpoint/token.go @@ -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 +} diff --git a/api/server/internal/api/routers/metrics/handler.go b/api/server/internal/api/routers/metrics/handler.go new file mode 100644 index 0000000..a182686 --- /dev/null +++ b/api/server/internal/api/routers/metrics/handler.go @@ -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 +} diff --git a/api/server/internal/api/routers/metrics/router.go b/api/server/internal/api/routers/metrics/router.go new file mode 100644 index 0000000..e66a15b --- /dev/null +++ b/api/server/internal/api/routers/metrics/router.go @@ -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 +} diff --git a/api/server/internal/api/routers/public/login.go b/api/server/internal/api/routers/public/login.go new file mode 100644 index 0000000..b668b9e --- /dev/null +++ b/api/server/internal/api/routers/public/login.go @@ -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) +} diff --git a/api/server/internal/api/routers/public/refresh.go b/api/server/internal/api/routers/public/refresh.go new file mode 100644 index 0000000..7c7be60 --- /dev/null +++ b/api/server/internal/api/routers/public/refresh.go @@ -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) +} diff --git a/api/server/internal/api/routers/public/respond.go b/api/server/internal/api/routers/public/respond.go new file mode 100644 index 0000000..002c51f --- /dev/null +++ b/api/server/internal/api/routers/public/respond.go @@ -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) +} diff --git a/api/server/internal/api/routers/public/rotate.go b/api/server/internal/api/routers/public/rotate.go new file mode 100644 index 0000000..cf93394 --- /dev/null +++ b/api/server/internal/api/routers/public/rotate.go @@ -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) +} diff --git a/api/server/internal/api/routers/public/router.go b/api/server/internal/api/routers/public/router.go new file mode 100644 index 0000000..a2fcae3 --- /dev/null +++ b/api/server/internal/api/routers/public/router.go @@ -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 +} diff --git a/api/server/internal/api/routers/public/validate.go b/api/server/internal/api/routers/public/validate.go new file mode 100644 index 0000000..222e3e6 --- /dev/null +++ b/api/server/internal/api/routers/public/validate.go @@ -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 +} diff --git a/api/server/internal/api/routers/router.go b/api/server/internal/api/routers/router.go new file mode 100644 index 0000000..ef95937 --- /dev/null +++ b/api/server/internal/api/routers/router.go @@ -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) +} diff --git a/api/server/internal/api/ws/dispimp.go b/api/server/internal/api/ws/dispimp.go new file mode 100644 index 0000000..fb2b20f --- /dev/null +++ b/api/server/internal/api/ws/dispimp.go @@ -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 +} diff --git a/api/server/internal/api/ws/router.go b/api/server/internal/api/ws/router.go new file mode 100644 index 0000000..6485ec8 --- /dev/null +++ b/api/server/internal/api/ws/router.go @@ -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) +} diff --git a/api/server/internal/appversion/version.go b/api/server/internal/appversion/version.go new file mode 100755 index 0000000..688b0c4 --- /dev/null +++ b/api/server/internal/appversion/version.go @@ -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) +} diff --git a/api/server/internal/mutil/flrstring/flrstring.go b/api/server/internal/mutil/flrstring/flrstring.go new file mode 100644 index 0000000..cccdb9a --- /dev/null +++ b/api/server/internal/mutil/flrstring/flrstring.go @@ -0,0 +1,35 @@ +package flrstring + +import ( + "math/rand" + "time" +) + +// Constants and variables for random string generation +const ( + letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + letterIdxBits = 6 + letterIdxMask = 1<= 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) +} diff --git a/api/server/internal/mutil/imagewriter/imagewriter.go b/api/server/internal/mutil/imagewriter/imagewriter.go new file mode 100644 index 0000000..ff853a9 --- /dev/null +++ b/api/server/internal/mutil/imagewriter/imagewriter.go @@ -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 +} diff --git a/api/server/internal/mutil/param/endpoint.go b/api/server/internal/mutil/param/endpoint.go new file mode 100644 index 0000000..f016f2e --- /dev/null +++ b/api/server/internal/mutil/param/endpoint.go @@ -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()) +} diff --git a/api/server/internal/mutil/param/getter.go b/api/server/internal/mutil/param/getter.go new file mode 100644 index 0000000..7c65029 --- /dev/null +++ b/api/server/internal/mutil/param/getter.go @@ -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 +} diff --git a/api/server/internal/mutil/param/getter_test.go b/api/server/internal/mutil/param/getter_test.go new file mode 100644 index 0000000..ea26f78 --- /dev/null +++ b/api/server/internal/mutil/param/getter_test.go @@ -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 +} diff --git a/api/server/internal/mutil/param/helper.go b/api/server/internal/mutil/param/helper.go new file mode 100644 index 0000000..92d10c5 --- /dev/null +++ b/api/server/internal/mutil/param/helper.go @@ -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), + } +} diff --git a/api/server/internal/mutil/param/internal/helper.go b/api/server/internal/mutil/param/internal/helper.go new file mode 100644 index 0000000..2a9f9e0 --- /dev/null +++ b/api/server/internal/mutil/param/internal/helper.go @@ -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} +} diff --git a/api/server/internal/mutil/param/logger.go b/api/server/internal/mutil/param/logger.go new file mode 100644 index 0000000..e0e1a21 --- /dev/null +++ b/api/server/internal/mutil/param/logger.go @@ -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()) +} diff --git a/api/server/internal/mutil/param/names.go b/api/server/internal/mutil/param/names.go new file mode 100644 index 0000000..a13bbe0 --- /dev/null +++ b/api/server/internal/mutil/param/names.go @@ -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" +} diff --git a/api/server/internal/mutil/param/ref.go b/api/server/internal/mutil/param/ref.go new file mode 100644 index 0000000..040bd77 --- /dev/null +++ b/api/server/internal/mutil/param/ref.go @@ -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)) +} diff --git a/api/server/internal/mutil/time/go/gotime.go b/api/server/internal/mutil/time/go/gotime.go new file mode 100644 index 0000000..3bfeffb --- /dev/null +++ b/api/server/internal/mutil/time/go/gotime.go @@ -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) +} diff --git a/api/server/internal/server/aapitemplate/config.go b/api/server/internal/server/aapitemplate/config.go new file mode 100644 index 0000000..8bf2e64 --- /dev/null +++ b/api/server/internal/server/aapitemplate/config.go @@ -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, + } +} diff --git a/api/server/internal/server/aapitemplate/create.go b/api/server/internal/server/aapitemplate/create.go new file mode 100644 index 0000000..ceb184d --- /dev/null +++ b/api/server/internal/server/aapitemplate/create.go @@ -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) +} diff --git a/api/server/internal/server/aapitemplate/db.go b/api/server/internal/server/aapitemplate/db.go new file mode 100644 index 0000000..e1dcc84 --- /dev/null +++ b/api/server/internal/server/aapitemplate/db.go @@ -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 +} diff --git a/api/server/internal/server/aapitemplate/delete.go b/api/server/internal/server/aapitemplate/delete.go new file mode 100644 index 0000000..2d4ee0e --- /dev/null +++ b/api/server/internal/server/aapitemplate/delete.go @@ -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) +} diff --git a/api/server/internal/server/aapitemplate/get.go b/api/server/internal/server/aapitemplate/get.go new file mode 100644 index 0000000..5c2fc13 --- /dev/null +++ b/api/server/internal/server/aapitemplate/get.go @@ -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) +} diff --git a/api/server/internal/server/aapitemplate/list.go b/api/server/internal/server/aapitemplate/list.go new file mode 100644 index 0000000..b88dcbc --- /dev/null +++ b/api/server/internal/server/aapitemplate/list.go @@ -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) +} diff --git a/api/server/internal/server/aapitemplate/nconfig.go b/api/server/internal/server/aapitemplate/nconfig.go new file mode 100644 index 0000000..5a5516e --- /dev/null +++ b/api/server/internal/server/aapitemplate/nconfig.go @@ -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 +} diff --git a/api/server/internal/server/aapitemplate/rconfig.go b/api/server/internal/server/aapitemplate/rconfig.go new file mode 100644 index 0000000..992a0ce --- /dev/null +++ b/api/server/internal/server/aapitemplate/rconfig.go @@ -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 +} diff --git a/api/server/internal/server/aapitemplate/reorder.go b/api/server/internal/server/aapitemplate/reorder.go new file mode 100644 index 0000000..7f7265c --- /dev/null +++ b/api/server/internal/server/aapitemplate/reorder.go @@ -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) +} diff --git a/api/server/internal/server/aapitemplate/responses.go b/api/server/internal/server/aapitemplate/responses.go new file mode 100644 index 0000000..7e45ef1 --- /dev/null +++ b/api/server/internal/server/aapitemplate/responses.go @@ -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()) +} diff --git a/api/server/internal/server/aapitemplate/service.go b/api/server/internal/server/aapitemplate/service.go new file mode 100644 index 0000000..aa8b569 --- /dev/null +++ b/api/server/internal/server/aapitemplate/service.go @@ -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 +} diff --git a/api/server/internal/server/aapitemplate/update.go b/api/server/internal/server/aapitemplate/update.go new file mode 100644 index 0000000..916d5d2 --- /dev/null +++ b/api/server/internal/server/aapitemplate/update.go @@ -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) +} diff --git a/api/server/internal/server/accountapiimp/account.go b/api/server/internal/server/accountapiimp/account.go new file mode 100755 index 0000000..df623b4 --- /dev/null +++ b/api/server/internal/server/accountapiimp/account.go @@ -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") +} diff --git a/api/server/internal/server/accountapiimp/delete.go b/api/server/internal/server/accountapiimp/delete.go new file mode 100644 index 0000000..5ab2a61 --- /dev/null +++ b/api/server/internal/server/accountapiimp/delete.go @@ -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) +} diff --git a/api/server/internal/server/accountapiimp/dzone.go b/api/server/internal/server/accountapiimp/dzone.go new file mode 100644 index 0000000..7d4824c --- /dev/null +++ b/api/server/internal/server/accountapiimp/dzone.go @@ -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, + ) +} diff --git a/api/server/internal/server/accountapiimp/email.go b/api/server/internal/server/accountapiimp/email.go new file mode 100644 index 0000000..ca2beab --- /dev/null +++ b/api/server/internal/server/accountapiimp/email.go @@ -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) +} diff --git a/api/server/internal/server/accountapiimp/employees.go b/api/server/internal/server/accountapiimp/employees.go new file mode 100644 index 0000000..66b138b --- /dev/null +++ b/api/server/internal/server/accountapiimp/employees.go @@ -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) +} diff --git a/api/server/internal/server/accountapiimp/empupdate.go b/api/server/internal/server/accountapiimp/empupdate.go new file mode 100644 index 0000000..a0f32c0 --- /dev/null +++ b/api/server/internal/server/accountapiimp/empupdate.go @@ -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) +} diff --git a/api/server/internal/server/accountapiimp/password.go b/api/server/internal/server/accountapiimp/password.go new file mode 100644 index 0000000..601b7f3 --- /dev/null +++ b/api/server/internal/server/accountapiimp/password.go @@ -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 +} diff --git a/api/server/internal/server/accountapiimp/password_test.go b/api/server/internal/server/accountapiimp/password_test.go new file mode 100644 index 0000000..ae1f806 --- /dev/null +++ b/api/server/internal/server/accountapiimp/password_test.go @@ -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") + }) + } + }) +} diff --git a/api/server/internal/server/accountapiimp/service.go b/api/server/internal/server/accountapiimp/service.go new file mode 100644 index 0000000..7425b03 --- /dev/null +++ b/api/server/internal/server/accountapiimp/service.go @@ -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 +} diff --git a/api/server/internal/server/accountapiimp/signup.go b/api/server/internal/server/accountapiimp/signup.go new file mode 100644 index 0000000..a770d12 --- /dev/null +++ b/api/server/internal/server/accountapiimp/signup.go @@ -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 +} diff --git a/api/server/internal/server/accountapiimp/signup_integration_test.go b/api/server/internal/server/accountapiimp/signup_integration_test.go new file mode 100644 index 0000000..515f3f9 --- /dev/null +++ b/api/server/internal/server/accountapiimp/signup_integration_test.go @@ -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) + }) +} diff --git a/api/server/internal/server/accountapiimp/signup_test.go b/api/server/internal/server/accountapiimp/signup_test.go new file mode 100644 index 0000000..91b2918 --- /dev/null +++ b/api/server/internal/server/accountapiimp/signup_test.go @@ -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 #") + }) + } +} diff --git a/api/server/internal/server/accountapiimp/update.go b/api/server/internal/server/accountapiimp/update.go new file mode 100644 index 0000000..6236ab3 --- /dev/null +++ b/api/server/internal/server/accountapiimp/update.go @@ -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) +} diff --git a/api/server/internal/server/fileserviceimp/fileserver.go b/api/server/internal/server/fileserviceimp/fileserver.go new file mode 100644 index 0000000..d2766d1 --- /dev/null +++ b/api/server/internal/server/fileserviceimp/fileserver.go @@ -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) +} diff --git a/api/server/internal/server/fileserviceimp/service.go b/api/server/internal/server/fileserviceimp/service.go new file mode 100644 index 0000000..ba6d804 --- /dev/null +++ b/api/server/internal/server/fileserviceimp/service.go @@ -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 +} diff --git a/api/server/internal/server/internal/serverimp.go b/api/server/internal/server/internal/serverimp.go new file mode 100644 index 0000000..b48ab19 --- /dev/null +++ b/api/server/internal/server/internal/serverimp.go @@ -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 +} diff --git a/api/server/internal/server/invitationimp/accept.go b/api/server/internal/server/invitationimp/accept.go new file mode 100644 index 0000000..c429288 --- /dev/null +++ b/api/server/internal/server/invitationimp/accept.go @@ -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) +} diff --git a/api/server/internal/server/invitationimp/decline.go b/api/server/internal/server/invitationimp/decline.go new file mode 100644 index 0000000..9ac9be0 --- /dev/null +++ b/api/server/internal/server/invitationimp/decline.go @@ -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) +} diff --git a/api/server/internal/server/invitationimp/notifications.go b/api/server/internal/server/invitationimp/notifications.go new file mode 100644 index 0000000..42ddb71 --- /dev/null +++ b/api/server/internal/server/invitationimp/notifications.go @@ -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) +} diff --git a/api/server/internal/server/invitationimp/public.go b/api/server/internal/server/invitationimp/public.go new file mode 100644 index 0000000..3347c77 --- /dev/null +++ b/api/server/internal/server/invitationimp/public.go @@ -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) +} diff --git a/api/server/internal/server/invitationimp/response.go b/api/server/internal/server/invitationimp/response.go new file mode 100644 index 0000000..ef671c0 --- /dev/null +++ b/api/server/internal/server/invitationimp/response.go @@ -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) +} diff --git a/api/server/internal/server/invitationimp/service.go b/api/server/internal/server/invitationimp/service.go new file mode 100644 index 0000000..ec755ce --- /dev/null +++ b/api/server/internal/server/invitationimp/service.go @@ -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 +} diff --git a/api/server/internal/server/logoimp/logo.go b/api/server/internal/server/logoimp/logo.go new file mode 100644 index 0000000..a4297d7 --- /dev/null +++ b/api/server/internal/server/logoimp/logo.go @@ -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 +} diff --git a/api/server/internal/server/logoimp/service.go b/api/server/internal/server/logoimp/service.go new file mode 100644 index 0000000..4b685aa --- /dev/null +++ b/api/server/internal/server/logoimp/service.go @@ -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 +} diff --git a/api/server/internal/server/organizationimp/crud.go b/api/server/internal/server/organizationimp/crud.go new file mode 100644 index 0000000..96adc74 --- /dev/null +++ b/api/server/internal/server/organizationimp/crud.go @@ -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) +} diff --git a/api/server/internal/server/organizationimp/invitation.go b/api/server/internal/server/organizationimp/invitation.go new file mode 100644 index 0000000..43ea8e9 --- /dev/null +++ b/api/server/internal/server/organizationimp/invitation.go @@ -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) +} diff --git a/api/server/internal/server/organizationimp/service.go b/api/server/internal/server/organizationimp/service.go new file mode 100644 index 0000000..54e5857 --- /dev/null +++ b/api/server/internal/server/organizationimp/service.go @@ -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 +} diff --git a/api/server/internal/server/papitemplate/archive.go b/api/server/internal/server/papitemplate/archive.go new file mode 100644 index 0000000..667c1b9 --- /dev/null +++ b/api/server/internal/server/papitemplate/archive.go @@ -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) +} diff --git a/api/server/internal/server/papitemplate/config.go b/api/server/internal/server/papitemplate/config.go new file mode 100644 index 0000000..384fe62 --- /dev/null +++ b/api/server/internal/server/papitemplate/config.go @@ -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, + } +} diff --git a/api/server/internal/server/papitemplate/create.go b/api/server/internal/server/papitemplate/create.go new file mode 100644 index 0000000..827e86e --- /dev/null +++ b/api/server/internal/server/papitemplate/create.go @@ -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) +} diff --git a/api/server/internal/server/papitemplate/db.go b/api/server/internal/server/papitemplate/db.go new file mode 100644 index 0000000..118663b --- /dev/null +++ b/api/server/internal/server/papitemplate/db.go @@ -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 +} diff --git a/api/server/internal/server/papitemplate/delete.go b/api/server/internal/server/papitemplate/delete.go new file mode 100644 index 0000000..6616d9e --- /dev/null +++ b/api/server/internal/server/papitemplate/delete.go @@ -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) +} diff --git a/api/server/internal/server/papitemplate/get.go b/api/server/internal/server/papitemplate/get.go new file mode 100644 index 0000000..feb5ae3 --- /dev/null +++ b/api/server/internal/server/papitemplate/get.go @@ -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) +} diff --git a/api/server/internal/server/papitemplate/list.go b/api/server/internal/server/papitemplate/list.go new file mode 100644 index 0000000..c3bfff3 --- /dev/null +++ b/api/server/internal/server/papitemplate/list.go @@ -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) +} diff --git a/api/server/internal/server/papitemplate/nconfig.go b/api/server/internal/server/papitemplate/nconfig.go new file mode 100644 index 0000000..a6949f5 --- /dev/null +++ b/api/server/internal/server/papitemplate/nconfig.go @@ -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 +} diff --git a/api/server/internal/server/papitemplate/rconfig.go b/api/server/internal/server/papitemplate/rconfig.go new file mode 100644 index 0000000..feeea66 --- /dev/null +++ b/api/server/internal/server/papitemplate/rconfig.go @@ -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 +} diff --git a/api/server/internal/server/papitemplate/reorder.go b/api/server/internal/server/papitemplate/reorder.go new file mode 100644 index 0000000..7360077 --- /dev/null +++ b/api/server/internal/server/papitemplate/reorder.go @@ -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) +} diff --git a/api/server/internal/server/papitemplate/responses.go b/api/server/internal/server/papitemplate/responses.go new file mode 100644 index 0000000..7917876 --- /dev/null +++ b/api/server/internal/server/papitemplate/responses.go @@ -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()) +} diff --git a/api/server/internal/server/papitemplate/service.go b/api/server/internal/server/papitemplate/service.go new file mode 100644 index 0000000..1f85c13 --- /dev/null +++ b/api/server/internal/server/papitemplate/service.go @@ -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 +} diff --git a/api/server/internal/server/papitemplate/taggable.go b/api/server/internal/server/papitemplate/taggable.go new file mode 100644 index 0000000..c8f2ee3 --- /dev/null +++ b/api/server/internal/server/papitemplate/taggable.go @@ -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, + }) +} diff --git a/api/server/internal/server/papitemplate/tconfig.go b/api/server/internal/server/papitemplate/tconfig.go new file mode 100644 index 0000000..ae711af --- /dev/null +++ b/api/server/internal/server/papitemplate/tconfig.go @@ -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 +} diff --git a/api/server/internal/server/papitemplate/update.go b/api/server/internal/server/papitemplate/update.go new file mode 100644 index 0000000..0628e3e --- /dev/null +++ b/api/server/internal/server/papitemplate/update.go @@ -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) +} diff --git a/api/server/internal/server/permissionsimp/changepolicies.go b/api/server/internal/server/permissionsimp/changepolicies.go new file mode 100644 index 0000000..24c30a7 --- /dev/null +++ b/api/server/internal/server/permissionsimp/changepolicies.go @@ -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 +} diff --git a/api/server/internal/server/permissionsimp/changerole.go b/api/server/internal/server/permissionsimp/changerole.go new file mode 100644 index 0000000..5d33f3e --- /dev/null +++ b/api/server/internal/server/permissionsimp/changerole.go @@ -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) +} diff --git a/api/server/internal/server/permissionsimp/createrole.go b/api/server/internal/server/permissionsimp/createrole.go new file mode 100644 index 0000000..2c80aee --- /dev/null +++ b/api/server/internal/server/permissionsimp/createrole.go @@ -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) +} diff --git a/api/server/internal/server/permissionsimp/deleterole.go b/api/server/internal/server/permissionsimp/deleterole.go new file mode 100644 index 0000000..4168a9b --- /dev/null +++ b/api/server/internal/server/permissionsimp/deleterole.go @@ -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) +} diff --git a/api/server/internal/server/permissionsimp/get.go b/api/server/internal/server/permissionsimp/get.go new file mode 100644 index 0000000..f6a956d --- /dev/null +++ b/api/server/internal/server/permissionsimp/get.go @@ -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, + ) +} diff --git a/api/server/internal/server/permissionsimp/get_all.go b/api/server/internal/server/permissionsimp/get_all.go new file mode 100644 index 0000000..f7e388e --- /dev/null +++ b/api/server/internal/server/permissionsimp/get_all.go @@ -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) +} diff --git a/api/server/internal/server/permissionsimp/permissions.go b/api/server/internal/server/permissionsimp/permissions.go new file mode 100644 index 0000000..88ee50d --- /dev/null +++ b/api/server/internal/server/permissionsimp/permissions.go @@ -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) +} diff --git a/api/server/internal/server/permissionsimp/service.go b/api/server/internal/server/permissionsimp/service.go new file mode 100644 index 0000000..6de6c73 --- /dev/null +++ b/api/server/internal/server/permissionsimp/service.go @@ -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 +} diff --git a/api/server/internal/server/server.go b/api/server/internal/server/server.go new file mode 100755 index 0000000..92caa6b --- /dev/null +++ b/api/server/internal/server/server.go @@ -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) +} diff --git a/api/server/main.go b/api/server/main.go new file mode 100644 index 0000000..3574002 --- /dev/null +++ b/api/server/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/server" + smain "github.com/tech/sendico/pkg/server/main" + "github.com/tech/sendico/server/internal/appversion" + si "github.com/tech/sendico/server/internal/server" +) + +// generate translations +// go:generate Users/stephandeshevikh/go/bin/go18n extract +// go:generate Users/stephandeshevikh/go/bin/go18n merge + +// lint go code +// docker run -t --rm -v $(pwd):/app -w /app golangci/golangci-lint:latest golangci-lint run -v --timeout 10m0s --enable-all -D ireturn -D wrapcheck -D varnamelen -D tagliatelle -D nosnakecase -D gochecknoglobals -D nlreturn -D stylecheck -D lll -D wsl -D scopelint -D varcheck -D exhaustivestruct -D golint -D maligned -D interfacer -D ifshort -D structcheck -D deadcode -D godot -D depguard -D tagalign + +// gofumpt source files +//go:generate /Users/stephandeshevikh/go/bin/gofumpt -w . + +// gci source files +//go:generate /Users/stephandeshevikh/go/bin/gci write . + +// get new ampli events +//go:generate ampli pull backend --path ./internal/ampli + +func factory(logger mlogger.Logger, file string, debug bool) (server.Application, error) { + return si.Create(logger, file, debug) +} + +func main() { + smain.RunServer("server", appversion.Create(), factory) +} diff --git a/api/server/server b/api/server/server new file mode 100755 index 0000000..cb7b499 Binary files /dev/null and b/api/server/server differ diff --git a/ci/pipelines/fx/build.sh b/ci/pipelines/fx/build.sh index a87867c..5f568f3 100755 --- a/ci/pipelines/fx/build.sh +++ b/ci/pipelines/fx/build.sh @@ -56,7 +56,13 @@ cat </kaniko/.docker/config.json } EOF +BUILD_CONTEXT="${FX_BUILD_CONTEXT:-${WOODPECKER_WORKSPACE:-${CI_WORKSPACE:-${PWD:-/workspace}}}}" +if [ ! -d "${BUILD_CONTEXT}" ]; then + BUILD_CONTEXT="/workspace" +fi + /kaniko/executor \ + --context "${BUILD_CONTEXT}" \ --dockerfile "${FX_DOCKERFILE}" \ --destination "${REGISTRY_URL}/${FX_IMAGE_PATH}:${APP_V}" \ --build-arg APP_VERSION="${APP_V}" \