web module refactoring (#2725)
* feat(server): moved an http server object into a pkg subdir to be reusable for http, spots, and integrations * feat(web): isolated web module (server, router, middleware, utils) used in spots and new integrations * feat(web): removed possible panic * feat(web): split all handlers from http service into different packages for better management. * feat(web): changed router's method signature * feat(web): added missing handlers interface * feat(web): added health middleware to remove unnecessary checks * feat(web): customizable middleware set for web servers * feat(web): simplified the handler's structure * feat(web): created an unified server.Run method for all web services (http, spot, integrations) * feat(web): fixed a json size limit issue * feat(web): removed Keys and PG connection from router * feat(web): simplified integration's main file * feat(web): simplified spot's main file * feat(web): simplified http's main file (builder) * feat(web): refactored audit trail functionality * feat(web): added ee version of audit trail * feat(web): added ee version of conditions module * feat(web): moved ee version of some web session structs * feat(web): new format of web metrics * feat(web): added new web metrics to all handlers * feat(web): added justExpired feature to web ingest handler * feat(web): added small integrations improvements
This commit is contained in:
parent
d95738bb0d
commit
6830c8879f
62 changed files with 2795 additions and 2528 deletions
|
|
@ -2,30 +2,27 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"openreplay/backend/internal/config/http"
|
"openreplay/backend/internal/config/http"
|
||||||
"openreplay/backend/internal/http/router"
|
|
||||||
"openreplay/backend/internal/http/server"
|
|
||||||
"openreplay/backend/internal/http/services"
|
"openreplay/backend/internal/http/services"
|
||||||
"openreplay/backend/pkg/db/postgres/pool"
|
"openreplay/backend/pkg/db/postgres/pool"
|
||||||
"openreplay/backend/pkg/db/redis"
|
"openreplay/backend/pkg/db/redis"
|
||||||
"openreplay/backend/pkg/logger"
|
"openreplay/backend/pkg/logger"
|
||||||
"openreplay/backend/pkg/metrics"
|
"openreplay/backend/pkg/metrics"
|
||||||
databaseMetrics "openreplay/backend/pkg/metrics/database"
|
databaseMetrics "openreplay/backend/pkg/metrics/database"
|
||||||
httpMetrics "openreplay/backend/pkg/metrics/http"
|
"openreplay/backend/pkg/metrics/web"
|
||||||
"openreplay/backend/pkg/queue"
|
"openreplay/backend/pkg/queue"
|
||||||
|
"openreplay/backend/pkg/server"
|
||||||
|
"openreplay/backend/pkg/server/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
log := logger.New()
|
log := logger.New()
|
||||||
cfg := http.New(log)
|
cfg := http.New(log)
|
||||||
metrics.New(log, append(httpMetrics.List(), databaseMetrics.List()...))
|
webMetrics := web.New("http")
|
||||||
|
metrics.New(log, append(webMetrics.List(), databaseMetrics.List()...))
|
||||||
|
|
||||||
// Connect to queue
|
|
||||||
producer := queue.NewProducer(cfg.MessageSizeLimit, true)
|
producer := queue.NewProducer(cfg.MessageSizeLimit, true)
|
||||||
defer producer.Close(15000)
|
defer producer.Close(15000)
|
||||||
|
|
||||||
|
|
@ -37,38 +34,21 @@ func main() {
|
||||||
|
|
||||||
redisClient, err := redis.New(&cfg.Redis)
|
redisClient, err := redis.New(&cfg.Redis)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn(ctx, "can't init redis connection: %s", err)
|
log.Info(ctx, "no redis cache: %s", err)
|
||||||
}
|
}
|
||||||
defer redisClient.Close()
|
defer redisClient.Close()
|
||||||
|
|
||||||
services, err := services.New(log, cfg, producer, pgConn, redisClient)
|
builder, err := services.New(log, cfg, webMetrics, producer, pgConn, redisClient)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(ctx, "failed while creating services: %s", err)
|
log.Fatal(ctx, "failed while creating services: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
router, err := router.NewRouter(cfg, log, services)
|
router, err := api.NewRouter(&cfg.HTTP, log)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(ctx, "failed while creating router: %s", err)
|
log.Fatal(ctx, "failed while creating router: %s", err)
|
||||||
}
|
}
|
||||||
|
router.AddHandlers(api.NoPrefix, builder.WebAPI, builder.MobileAPI, builder.ConditionsAPI, builder.FeatureFlagsAPI,
|
||||||
|
builder.TagsAPI, builder.UxTestsAPI)
|
||||||
|
|
||||||
server, err := server.New(router.GetHandler(), cfg.HTTPHost, cfg.HTTPPort, cfg.HTTPTimeout)
|
server.Run(ctx, log, &cfg.HTTP, router)
|
||||||
if err != nil {
|
|
||||||
log.Fatal(ctx, "failed while creating server: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run server
|
|
||||||
go func() {
|
|
||||||
if err := server.Start(); err != nil {
|
|
||||||
log.Fatal(ctx, "http server error: %s", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
log.Info(ctx, "server successfully started on port %s", cfg.HTTPPort)
|
|
||||||
|
|
||||||
// Wait stop signal to shut down server gracefully
|
|
||||||
sigchan := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(sigchan, syscall.SIGINT, syscall.SIGTERM)
|
|
||||||
<-sigchan
|
|
||||||
log.Info(ctx, "shutting down the server")
|
|
||||||
server.Stop()
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,24 +2,24 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
|
|
||||||
config "openreplay/backend/internal/config/integrations"
|
config "openreplay/backend/internal/config/integrations"
|
||||||
"openreplay/backend/internal/http/server"
|
|
||||||
"openreplay/backend/pkg/db/postgres/pool"
|
"openreplay/backend/pkg/db/postgres/pool"
|
||||||
integration "openreplay/backend/pkg/integrations"
|
"openreplay/backend/pkg/integrations"
|
||||||
"openreplay/backend/pkg/logger"
|
"openreplay/backend/pkg/logger"
|
||||||
"openreplay/backend/pkg/metrics"
|
"openreplay/backend/pkg/metrics"
|
||||||
"openreplay/backend/pkg/metrics/database"
|
"openreplay/backend/pkg/metrics/database"
|
||||||
|
"openreplay/backend/pkg/metrics/web"
|
||||||
|
"openreplay/backend/pkg/server"
|
||||||
|
"openreplay/backend/pkg/server/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
log := logger.New()
|
log := logger.New()
|
||||||
cfg := config.New(log)
|
cfg := config.New(log)
|
||||||
metrics.New(log, append(database.List()))
|
webMetrics := web.New("integrations")
|
||||||
|
metrics.New(log, append(webMetrics.List(), database.List()...))
|
||||||
|
|
||||||
pgConn, err := pool.New(cfg.Postgres.String())
|
pgConn, err := pool.New(cfg.Postgres.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -27,31 +27,17 @@ func main() {
|
||||||
}
|
}
|
||||||
defer pgConn.Close()
|
defer pgConn.Close()
|
||||||
|
|
||||||
services, err := integration.NewServiceBuilder(log, cfg, pgConn)
|
builder, err := integrations.NewServiceBuilder(log, cfg, webMetrics, pgConn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(ctx, "can't init services: %s", err)
|
log.Fatal(ctx, "can't init services: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
router, err := integration.NewRouter(cfg, log, services)
|
router, err := api.NewRouter(&cfg.HTTP, log)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(ctx, "failed while creating router: %s", err)
|
log.Fatal(ctx, "failed while creating router: %s", err)
|
||||||
}
|
}
|
||||||
|
router.AddHandlers(api.NoPrefix, builder.IntegrationsAPI)
|
||||||
|
router.AddMiddlewares(builder.Auth.Middleware, builder.RateLimiter.Middleware, builder.AuditTrail.Middleware)
|
||||||
|
|
||||||
dataIntegrationServer, err := server.New(router.GetHandler(), cfg.HTTPHost, cfg.HTTPPort, cfg.HTTPTimeout)
|
server.Run(ctx, log, &cfg.HTTP, router)
|
||||||
if err != nil {
|
|
||||||
log.Fatal(ctx, "failed while creating server: %s", err)
|
|
||||||
}
|
|
||||||
go func() {
|
|
||||||
if err := dataIntegrationServer.Start(); err != nil {
|
|
||||||
log.Fatal(ctx, "http server error: %s", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
log.Info(ctx, "server successfully started on port %s", cfg.HTTPPort)
|
|
||||||
|
|
||||||
// Wait stop signal to shut down server gracefully
|
|
||||||
sigchan := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(sigchan, syscall.SIGINT, syscall.SIGTERM)
|
|
||||||
<-sigchan
|
|
||||||
log.Info(ctx, "shutting down the server")
|
|
||||||
dataIntegrationServer.Stop()
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,26 +2,25 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"openreplay/backend/pkg/spot"
|
|
||||||
"openreplay/backend/pkg/spot/api"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
|
|
||||||
spotConfig "openreplay/backend/internal/config/spot"
|
spotConfig "openreplay/backend/internal/config/spot"
|
||||||
"openreplay/backend/internal/http/server"
|
|
||||||
"openreplay/backend/pkg/db/postgres/pool"
|
"openreplay/backend/pkg/db/postgres/pool"
|
||||||
"openreplay/backend/pkg/logger"
|
"openreplay/backend/pkg/logger"
|
||||||
"openreplay/backend/pkg/metrics"
|
"openreplay/backend/pkg/metrics"
|
||||||
databaseMetrics "openreplay/backend/pkg/metrics/database"
|
databaseMetrics "openreplay/backend/pkg/metrics/database"
|
||||||
spotMetrics "openreplay/backend/pkg/metrics/spot"
|
spotMetrics "openreplay/backend/pkg/metrics/spot"
|
||||||
|
"openreplay/backend/pkg/metrics/web"
|
||||||
|
"openreplay/backend/pkg/server"
|
||||||
|
"openreplay/backend/pkg/server/api"
|
||||||
|
"openreplay/backend/pkg/spot"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
log := logger.New()
|
log := logger.New()
|
||||||
cfg := spotConfig.New(log)
|
cfg := spotConfig.New(log)
|
||||||
metrics.New(log, append(spotMetrics.List(), databaseMetrics.List()...))
|
webMetrics := web.New("spot")
|
||||||
|
metrics.New(log, append(webMetrics.List(), append(spotMetrics.List(), databaseMetrics.List()...)...))
|
||||||
|
|
||||||
pgConn, err := pool.New(cfg.Postgres.String())
|
pgConn, err := pool.New(cfg.Postgres.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -29,32 +28,17 @@ func main() {
|
||||||
}
|
}
|
||||||
defer pgConn.Close()
|
defer pgConn.Close()
|
||||||
|
|
||||||
services, err := spot.NewServiceBuilder(log, cfg, pgConn)
|
builder, err := spot.NewServiceBuilder(log, cfg, webMetrics, pgConn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(ctx, "can't init services: %s", err)
|
log.Fatal(ctx, "can't init services: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
router, err := api.NewRouter(cfg, log, services)
|
router, err := api.NewRouter(&cfg.HTTP, log)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(ctx, "failed while creating router: %s", err)
|
log.Fatal(ctx, "failed while creating router: %s", err)
|
||||||
}
|
}
|
||||||
|
router.AddHandlers(api.NoPrefix, builder.SpotsAPI)
|
||||||
|
router.AddMiddlewares(builder.Auth.Middleware, builder.RateLimiter.Middleware, builder.AuditTrail.Middleware)
|
||||||
|
|
||||||
spotServer, err := server.New(router.GetHandler(), cfg.HTTPHost, cfg.HTTPPort, cfg.HTTPTimeout)
|
server.Run(ctx, log, &cfg.HTTP, router)
|
||||||
if err != nil {
|
|
||||||
log.Fatal(ctx, "failed while creating server: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
if err := spotServer.Start(); err != nil {
|
|
||||||
log.Fatal(ctx, "http server error: %s", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
log.Info(ctx, "server successfully started on port %s", cfg.HTTPPort)
|
|
||||||
|
|
||||||
// Wait stop signal to shut down server gracefully
|
|
||||||
sigchan := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(sigchan, syscall.SIGINT, syscall.SIGTERM)
|
|
||||||
<-sigchan
|
|
||||||
log.Info(ctx, "shutting down the server")
|
|
||||||
spotServer.Stop()
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
package common
|
package common
|
||||||
|
|
||||||
import "strings"
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// Common config for all services
|
// Common config for all services
|
||||||
|
|
||||||
|
|
@ -70,3 +73,13 @@ type ElasticSearch struct {
|
||||||
func (cfg *ElasticSearch) GetURLs() []string {
|
func (cfg *ElasticSearch) GetURLs() []string {
|
||||||
return strings.Split(cfg.URLs, ",")
|
return strings.Split(cfg.URLs, ",")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HTTP struct {
|
||||||
|
HTTPHost string `env:"HTTP_HOST,default="`
|
||||||
|
HTTPPort string `env:"HTTP_PORT,required"`
|
||||||
|
HTTPTimeout time.Duration `env:"HTTP_TIMEOUT,default=60s"`
|
||||||
|
JsonSizeLimit int64 `env:"JSON_SIZE_LIMIT,default=131072"` // 128KB, 1000 for HTTP service
|
||||||
|
UseAccessControlHeaders bool `env:"USE_CORS,default=false"`
|
||||||
|
JWTSecret string `env:"JWT_SECRET,required"`
|
||||||
|
JWTSpotSecret string `env:"JWT_SPOT_SECRET,required"`
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,31 +15,27 @@ type Config struct {
|
||||||
common.Postgres
|
common.Postgres
|
||||||
redis.Redis
|
redis.Redis
|
||||||
objectstorage.ObjectsConfig
|
objectstorage.ObjectsConfig
|
||||||
HTTPHost string `env:"HTTP_HOST,default="`
|
common.HTTP
|
||||||
HTTPPort string `env:"HTTP_PORT,required"`
|
TopicRawWeb string `env:"TOPIC_RAW_WEB,required"`
|
||||||
HTTPTimeout time.Duration `env:"HTTP_TIMEOUT,default=60s"`
|
TopicRawMobile string `env:"TOPIC_RAW_IOS,required"`
|
||||||
TopicRawWeb string `env:"TOPIC_RAW_WEB,required"`
|
TopicRawImages string `env:"TOPIC_RAW_IMAGES,required"`
|
||||||
TopicRawMobile string `env:"TOPIC_RAW_IOS,required"`
|
TopicCanvasImages string `env:"TOPIC_CANVAS_IMAGES,required"`
|
||||||
TopicRawImages string `env:"TOPIC_RAW_IMAGES,required"`
|
BeaconSizeLimit int64 `env:"BEACON_SIZE_LIMIT,required"`
|
||||||
TopicCanvasImages string `env:"TOPIC_CANVAS_IMAGES,required"`
|
CompressionThreshold int64 `env:"COMPRESSION_THRESHOLD,default=20000"`
|
||||||
BeaconSizeLimit int64 `env:"BEACON_SIZE_LIMIT,required"`
|
FileSizeLimit int64 `env:"FILE_SIZE_LIMIT,default=10000000"`
|
||||||
CompressionThreshold int64 `env:"COMPRESSION_THRESHOLD,default=20000"`
|
TokenSecret string `env:"TOKEN_SECRET,required"`
|
||||||
JsonSizeLimit int64 `env:"JSON_SIZE_LIMIT,default=1000"`
|
UAParserFile string `env:"UAPARSER_FILE,required"`
|
||||||
FileSizeLimit int64 `env:"FILE_SIZE_LIMIT,default=10000000"`
|
MaxMinDBFile string `env:"MAXMINDDB_FILE,required"`
|
||||||
TokenSecret string `env:"TOKEN_SECRET,required"`
|
UseProfiler bool `env:"PROFILER_ENABLED,default=false"`
|
||||||
UAParserFile string `env:"UAPARSER_FILE,required"`
|
ProjectExpiration time.Duration `env:"PROJECT_EXPIRATION,default=10m"`
|
||||||
MaxMinDBFile string `env:"MAXMINDDB_FILE,required"`
|
RecordCanvas bool `env:"RECORD_CANVAS,default=false"`
|
||||||
UseProfiler bool `env:"PROFILER_ENABLED,default=false"`
|
CanvasQuality string `env:"CANVAS_QUALITY,default=low"`
|
||||||
UseAccessControlHeaders bool `env:"USE_CORS,default=false"`
|
CanvasFps int `env:"CANVAS_FPS,default=1"`
|
||||||
ProjectExpiration time.Duration `env:"PROJECT_EXPIRATION,default=10m"`
|
MobileQuality string `env:"MOBILE_QUALITY,default=low"` // (low, standard, high)
|
||||||
RecordCanvas bool `env:"RECORD_CANVAS,default=false"`
|
MobileFps int `env:"MOBILE_FPS,default=1"`
|
||||||
CanvasQuality string `env:"CANVAS_QUALITY,default=low"`
|
IsFeatureFlagEnabled bool `env:"IS_FEATURE_FLAG_ENABLED,default=false"`
|
||||||
CanvasFps int `env:"CANVAS_FPS,default=1"`
|
IsUsabilityTestEnabled bool `env:"IS_USABILITY_TEST_ENABLED,default=false"`
|
||||||
MobileQuality string `env:"MOBILE_QUALITY,default=low"` // (low, standard, high)
|
WorkerID uint16
|
||||||
MobileFps int `env:"MOBILE_FPS,default=1"`
|
|
||||||
IsFeatureFlagEnabled bool `env:"IS_FEATURE_FLAG_ENABLED,default=false"`
|
|
||||||
IsUsabilityTestEnabled bool `env:"IS_USABILITY_TEST_ENABLED,default=false"`
|
|
||||||
WorkerID uint16
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(log logger.Logger) *Config {
|
func New(log logger.Logger) *Config {
|
||||||
|
|
|
||||||
|
|
@ -16,14 +16,9 @@ type Config struct {
|
||||||
common.Postgres
|
common.Postgres
|
||||||
redis.Redis
|
redis.Redis
|
||||||
objectstorage.ObjectsConfig
|
objectstorage.ObjectsConfig
|
||||||
HTTPHost string `env:"HTTP_HOST,default="`
|
common.HTTP
|
||||||
HTTPPort string `env:"HTTP_PORT,required"`
|
ProjectExpiration time.Duration `env:"PROJECT_EXPIRATION,default=10m"`
|
||||||
HTTPTimeout time.Duration `env:"HTTP_TIMEOUT,default=60s"`
|
WorkerID uint16
|
||||||
JsonSizeLimit int64 `env:"JSON_SIZE_LIMIT,default=131072"` // 128KB
|
|
||||||
UseAccessControlHeaders bool `env:"USE_CORS,default=false"`
|
|
||||||
ProjectExpiration time.Duration `env:"PROJECT_EXPIRATION,default=10m"`
|
|
||||||
JWTSecret string `env:"JWT_SECRET,required"`
|
|
||||||
WorkerID uint16
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(log logger.Logger) *Config {
|
func New(log logger.Logger) *Config {
|
||||||
|
|
|
||||||
|
|
@ -16,18 +16,12 @@ type Config struct {
|
||||||
common.Postgres
|
common.Postgres
|
||||||
redis.Redis
|
redis.Redis
|
||||||
objectstorage.ObjectsConfig
|
objectstorage.ObjectsConfig
|
||||||
FSDir string `env:"FS_DIR,required"`
|
common.HTTP
|
||||||
SpotsDir string `env:"SPOTS_DIR,default=spots"`
|
FSDir string `env:"FS_DIR,required"`
|
||||||
HTTPHost string `env:"HTTP_HOST,default="`
|
SpotsDir string `env:"SPOTS_DIR,default=spots"`
|
||||||
HTTPPort string `env:"HTTP_PORT,required"`
|
ProjectExpiration time.Duration `env:"PROJECT_EXPIRATION,default=10m"`
|
||||||
HTTPTimeout time.Duration `env:"HTTP_TIMEOUT,default=60s"`
|
MinimumStreamDuration int `env:"MINIMUM_STREAM_DURATION,default=15000"` // 15s
|
||||||
JsonSizeLimit int64 `env:"JSON_SIZE_LIMIT,default=131072"` // 128KB
|
WorkerID uint16
|
||||||
UseAccessControlHeaders bool `env:"USE_CORS,default=false"`
|
|
||||||
ProjectExpiration time.Duration `env:"PROJECT_EXPIRATION,default=10m"`
|
|
||||||
JWTSecret string `env:"JWT_SECRET,required"`
|
|
||||||
JWTSpotSecret string `env:"JWT_SPOT_SECRET,required"`
|
|
||||||
MinimumStreamDuration int `env:"MINIMUM_STREAM_DURATION,default=15000"` // 15s
|
|
||||||
WorkerID uint16
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(log logger.Logger) *Config {
|
func New(log logger.Logger) *Config {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,10 @@ package geoip
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"github.com/tomasen/realip"
|
||||||
"net"
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"openreplay/backend/pkg/logger"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/oschwald/maxminddb-golang"
|
"github.com/oschwald/maxminddb-golang"
|
||||||
|
|
@ -46,18 +49,23 @@ func UnpackGeoRecord(pkg string) *GeoRecord {
|
||||||
|
|
||||||
type GeoParser interface {
|
type GeoParser interface {
|
||||||
Parse(ip net.IP) (*GeoRecord, error)
|
Parse(ip net.IP) (*GeoRecord, error)
|
||||||
|
ExtractGeoData(r *http.Request) *GeoRecord
|
||||||
}
|
}
|
||||||
|
|
||||||
type geoParser struct {
|
type geoParser struct {
|
||||||
r *maxminddb.Reader
|
log logger.Logger
|
||||||
|
r *maxminddb.Reader
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(file string) (GeoParser, error) {
|
func New(log logger.Logger, file string) (GeoParser, error) {
|
||||||
r, err := maxminddb.Open(file)
|
r, err := maxminddb.Open(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &geoParser{r}, nil
|
return &geoParser{
|
||||||
|
log: log,
|
||||||
|
r: r,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (geoIP *geoParser) Parse(ip net.IP) (*GeoRecord, error) {
|
func (geoIP *geoParser) Parse(ip net.IP) (*GeoRecord, error) {
|
||||||
|
|
@ -82,3 +90,12 @@ func (geoIP *geoParser) Parse(ip net.IP) (*GeoRecord, error) {
|
||||||
res.City = record.City.Names["en"]
|
res.City = record.City.Names["en"]
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (geoIP *geoParser) ExtractGeoData(r *http.Request) *GeoRecord {
|
||||||
|
ip := net.ParseIP(realip.FromRequest(r))
|
||||||
|
geoRec, err := geoIP.Parse(ip)
|
||||||
|
if err != nil {
|
||||||
|
geoIP.log.Warn(r.Context(), "failed to parse geo data: %v", err)
|
||||||
|
}
|
||||||
|
return geoRec
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
package router
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (e *Router) getConditions(w http.ResponseWriter, r *http.Request) {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusNotImplemented, errors.New("no support"), time.Now(), r.URL.Path, 0)
|
|
||||||
}
|
|
||||||
|
|
@ -1,271 +0,0 @@
|
||||||
package router
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"math/rand"
|
|
||||||
"net/http"
|
|
||||||
"openreplay/backend/internal/http/ios"
|
|
||||||
"openreplay/backend/internal/http/uuid"
|
|
||||||
"openreplay/backend/pkg/db/postgres"
|
|
||||||
"openreplay/backend/pkg/messages"
|
|
||||||
"openreplay/backend/pkg/sessions"
|
|
||||||
"openreplay/backend/pkg/token"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (e *Router) startMobileSessionHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
startTime := time.Now()
|
|
||||||
|
|
||||||
if r.Body == nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, errors.New("request body is empty"), startTime, r.URL.Path, 0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
body := http.MaxBytesReader(w, r.Body, e.cfg.JsonSizeLimit)
|
|
||||||
defer body.Close()
|
|
||||||
|
|
||||||
req := &StartMobileSessionRequest{}
|
|
||||||
if err := json.NewDecoder(body).Decode(req); err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, 0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add tracker version to context
|
|
||||||
r = r.WithContext(context.WithValue(r.Context(), "tracker", req.TrackerVersion))
|
|
||||||
|
|
||||||
if req.ProjectKey == nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusForbidden, errors.New("projectKey value required"), startTime, r.URL.Path, 0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
p, err := e.services.Projects.GetProjectByKey(*req.ProjectKey)
|
|
||||||
if err != nil {
|
|
||||||
if postgres.IsNoRowsErr(err) {
|
|
||||||
logErr := fmt.Errorf("project doesn't exist or is not active, key: %s", *req.ProjectKey)
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusNotFound, logErr, startTime, r.URL.Path, 0)
|
|
||||||
} else {
|
|
||||||
e.log.Error(r.Context(), "failed to get project by key: %s, err: %s", *req.ProjectKey, err)
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, errors.New("can't find a project"), startTime, r.URL.Path, 0)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add projectID to context
|
|
||||||
r = r.WithContext(context.WithValue(r.Context(), "projectID", fmt.Sprintf("%d", p.ProjectID)))
|
|
||||||
|
|
||||||
// Check if the project supports mobile sessions
|
|
||||||
if !p.IsMobile() {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusForbidden, errors.New("project doesn't support mobile sessions"), startTime, r.URL.Path, 0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !checkMobileTrackerVersion(req.TrackerVersion) {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusUpgradeRequired, errors.New("tracker version not supported"), startTime, r.URL.Path, 0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userUUID := uuid.GetUUID(req.UserUUID)
|
|
||||||
tokenData, err := e.services.Tokenizer.Parse(req.Token)
|
|
||||||
|
|
||||||
if err != nil { // Starting the new one
|
|
||||||
dice := byte(rand.Intn(100)) // [0, 100)
|
|
||||||
// Use condition rate if it's set
|
|
||||||
if req.Condition != "" {
|
|
||||||
rate, err := e.services.Conditions.GetRate(p.ProjectID, req.Condition, int(p.SampleRate))
|
|
||||||
if err != nil {
|
|
||||||
e.log.Warn(r.Context(), "can't get condition rate, condition: %s, err: %s", req.Condition, err)
|
|
||||||
} else {
|
|
||||||
p.SampleRate = byte(rate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if dice >= p.SampleRate {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusForbidden, fmt.Errorf("capture rate miss, rate: %d", p.SampleRate), startTime, r.URL.Path, 0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ua := e.services.UaParser.ParseFromHTTPRequest(r)
|
|
||||||
if ua == nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusForbidden, fmt.Errorf("browser not recognized, user-agent: %s", r.Header.Get("User-Agent")), startTime, r.URL.Path, 0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sessionID, err := e.services.Flaker.Compose(uint64(startTime.UnixMilli()))
|
|
||||||
if err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, 0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
expTime := startTime.Add(time.Duration(p.MaxSessionDuration) * time.Millisecond)
|
|
||||||
tokenData = &token.TokenData{sessionID, 0, expTime.UnixMilli()}
|
|
||||||
|
|
||||||
// Add sessionID to context
|
|
||||||
r = r.WithContext(context.WithValue(r.Context(), "sessionID", fmt.Sprintf("%d", sessionID)))
|
|
||||||
|
|
||||||
geoInfo := e.ExtractGeoData(r)
|
|
||||||
deviceType, platform, os := ios.GetIOSDeviceType(req.UserDevice), "ios", "IOS"
|
|
||||||
if req.Platform != "" && req.Platform != "ios" {
|
|
||||||
deviceType = req.UserDeviceType
|
|
||||||
platform = req.Platform
|
|
||||||
os = "Android"
|
|
||||||
}
|
|
||||||
|
|
||||||
if !req.DoNotRecord {
|
|
||||||
if err := e.services.Sessions.Add(&sessions.Session{
|
|
||||||
SessionID: sessionID,
|
|
||||||
Platform: platform,
|
|
||||||
Timestamp: req.Timestamp,
|
|
||||||
Timezone: req.Timezone,
|
|
||||||
ProjectID: p.ProjectID,
|
|
||||||
TrackerVersion: req.TrackerVersion,
|
|
||||||
RevID: req.RevID,
|
|
||||||
UserUUID: userUUID,
|
|
||||||
UserOS: os,
|
|
||||||
UserOSVersion: req.UserOSVersion,
|
|
||||||
UserDevice: ios.MapIOSDevice(req.UserDevice),
|
|
||||||
UserDeviceType: deviceType,
|
|
||||||
UserCountry: geoInfo.Country,
|
|
||||||
UserState: geoInfo.State,
|
|
||||||
UserCity: geoInfo.City,
|
|
||||||
UserDeviceMemorySize: req.DeviceMemory,
|
|
||||||
UserDeviceHeapSize: req.DeviceMemory,
|
|
||||||
ScreenWidth: req.Width,
|
|
||||||
ScreenHeight: req.Height,
|
|
||||||
}); err != nil {
|
|
||||||
e.log.Warn(r.Context(), "failed to add mobile session to DB: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sessStart := &messages.MobileSessionStart{
|
|
||||||
Timestamp: req.Timestamp,
|
|
||||||
ProjectID: uint64(p.ProjectID),
|
|
||||||
TrackerVersion: req.TrackerVersion,
|
|
||||||
RevID: req.RevID,
|
|
||||||
UserUUID: userUUID,
|
|
||||||
UserOS: os,
|
|
||||||
UserOSVersion: req.UserOSVersion,
|
|
||||||
UserDevice: ios.MapIOSDevice(req.UserDevice),
|
|
||||||
UserDeviceType: deviceType,
|
|
||||||
UserCountry: geoInfo.Pack(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := e.services.Producer.Produce(e.cfg.TopicRawMobile, tokenData.ID, sessStart.Encode()); err != nil {
|
|
||||||
e.log.Error(r.Context(), "failed to send mobile sessionStart event to queue: %s", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
e.ResponseWithJSON(r.Context(), w, &StartMobileSessionResponse{
|
|
||||||
Token: e.services.Tokenizer.Compose(*tokenData),
|
|
||||||
UserUUID: userUUID,
|
|
||||||
SessionID: strconv.FormatUint(tokenData.ID, 10),
|
|
||||||
BeaconSizeLimit: e.cfg.BeaconSizeLimit,
|
|
||||||
ImageQuality: e.cfg.MobileQuality,
|
|
||||||
FrameRate: e.cfg.MobileFps,
|
|
||||||
ProjectID: strconv.FormatUint(uint64(p.ProjectID), 10),
|
|
||||||
Features: e.features,
|
|
||||||
}, startTime, r.URL.Path, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) pushMobileMessagesHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
startTime := time.Now()
|
|
||||||
|
|
||||||
sessionData, err := e.services.Tokenizer.ParseFromHTTPRequest(r)
|
|
||||||
if sessionData != nil {
|
|
||||||
r = r.WithContext(context.WithValue(r.Context(), "sessionID", fmt.Sprintf("%d", sessionData.ID)))
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusUnauthorized, err, startTime, r.URL.Path, 0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add sessionID and projectID to context
|
|
||||||
if info, err := e.services.Sessions.Get(sessionData.ID); err == nil {
|
|
||||||
r = r.WithContext(context.WithValue(r.Context(), "projectID", fmt.Sprintf("%d", info.ProjectID)))
|
|
||||||
}
|
|
||||||
|
|
||||||
e.pushMessages(w, r, sessionData.ID, e.cfg.TopicRawMobile)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) pushMobileLateMessagesHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
startTime := time.Now()
|
|
||||||
sessionData, err := e.services.Tokenizer.ParseFromHTTPRequest(r)
|
|
||||||
if sessionData != nil {
|
|
||||||
r = r.WithContext(context.WithValue(r.Context(), "sessionID", fmt.Sprintf("%d", sessionData.ID)))
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil && err != token.EXPIRED {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusUnauthorized, err, startTime, r.URL.Path, 0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Check timestamps here?
|
|
||||||
e.pushMessages(w, r, sessionData.ID, e.cfg.TopicRawMobile)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) mobileImagesUploadHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
startTime := time.Now()
|
|
||||||
|
|
||||||
sessionData, err := e.services.Tokenizer.ParseFromHTTPRequest(r)
|
|
||||||
if sessionData != nil {
|
|
||||||
r = r.WithContext(context.WithValue(r.Context(), "sessionID", fmt.Sprintf("%d", sessionData.ID)))
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusUnauthorized, err, startTime, r.URL.Path, 0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add sessionID and projectID to context
|
|
||||||
if info, err := e.services.Sessions.Get(sessionData.ID); err == nil {
|
|
||||||
r = r.WithContext(context.WithValue(r.Context(), "projectID", fmt.Sprintf("%d", info.ProjectID)))
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.Body == nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, errors.New("request body is empty"), startTime, r.URL.Path, 0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, e.cfg.FileSizeLimit)
|
|
||||||
defer r.Body.Close()
|
|
||||||
|
|
||||||
err = r.ParseMultipartForm(5 * 1e6) // ~5Mb
|
|
||||||
if err == http.ErrNotMultipart || err == http.ErrMissingBoundary {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusUnsupportedMediaType, err, startTime, r.URL.Path, 0)
|
|
||||||
return
|
|
||||||
} else if err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, 0) // TODO: send error here only on staging
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.MultipartForm == nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, errors.New("multipart not parsed"), startTime, r.URL.Path, 0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(r.MultipartForm.Value["projectKey"]) == 0 {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, errors.New("projectKey parameter missing"), startTime, r.URL.Path, 0) // status for missing/wrong parameter?
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, fileHeaderList := range r.MultipartForm.File {
|
|
||||||
for _, fileHeader := range fileHeaderList {
|
|
||||||
file, err := fileHeader.Open()
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := io.ReadAll(file)
|
|
||||||
if err != nil {
|
|
||||||
file.Close()
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, 0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
file.Close()
|
|
||||||
|
|
||||||
if err := e.services.Producer.Produce(e.cfg.TopicRawImages, sessionData.ID, data); err != nil {
|
|
||||||
e.log.Warn(r.Context(), "failed to send image to queue: %s", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
e.ResponseOK(r.Context(), w, startTime, r.URL.Path, 0)
|
|
||||||
}
|
|
||||||
|
|
@ -1,754 +0,0 @@
|
||||||
package router
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"math/rand"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
|
||||||
|
|
||||||
"github.com/Masterminds/semver"
|
|
||||||
"github.com/klauspost/compress/gzip"
|
|
||||||
"openreplay/backend/internal/http/util"
|
|
||||||
"openreplay/backend/internal/http/uuid"
|
|
||||||
"openreplay/backend/pkg/db/postgres"
|
|
||||||
"openreplay/backend/pkg/featureflags"
|
|
||||||
"openreplay/backend/pkg/flakeid"
|
|
||||||
. "openreplay/backend/pkg/messages"
|
|
||||||
"openreplay/backend/pkg/sessions"
|
|
||||||
"openreplay/backend/pkg/token"
|
|
||||||
"openreplay/backend/pkg/uxtesting"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (e *Router) readBody(w http.ResponseWriter, r *http.Request, limit int64) ([]byte, error) {
|
|
||||||
body := http.MaxBytesReader(w, r.Body, limit)
|
|
||||||
var (
|
|
||||||
bodyBytes []byte
|
|
||||||
err error
|
|
||||||
)
|
|
||||||
|
|
||||||
// Check if body is gzipped and decompress it
|
|
||||||
if r.Header.Get("Content-Encoding") == "gzip" {
|
|
||||||
reader, err := gzip.NewReader(body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("can't create gzip reader: %s", err)
|
|
||||||
}
|
|
||||||
bodyBytes, err = io.ReadAll(reader)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("can't read gzip body: %s", err)
|
|
||||||
}
|
|
||||||
if err := reader.Close(); err != nil {
|
|
||||||
e.log.Warn(r.Context(), "can't close gzip reader: %s", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
bodyBytes, err = io.ReadAll(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close body
|
|
||||||
if closeErr := body.Close(); closeErr != nil {
|
|
||||||
e.log.Warn(r.Context(), "error while closing request body: %s", closeErr)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return bodyBytes, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkMobileTrackerVersion(ver string) bool {
|
|
||||||
c, err := semver.NewConstraint(">=1.0.9")
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
// Check for beta version
|
|
||||||
parts := strings.Split(ver, "-")
|
|
||||||
if len(parts) > 1 {
|
|
||||||
ver = parts[0]
|
|
||||||
}
|
|
||||||
v, err := semver.NewVersion(ver)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return c.Check(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getSessionTimestamp(req *StartSessionRequest, startTimeMili int64) (ts uint64) {
|
|
||||||
ts = uint64(req.Timestamp)
|
|
||||||
if req.IsOffline {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c, err := semver.NewConstraint(">=4.1.6")
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ver := req.TrackerVersion
|
|
||||||
parts := strings.Split(ver, "-")
|
|
||||||
if len(parts) > 1 {
|
|
||||||
ver = parts[0]
|
|
||||||
}
|
|
||||||
v, err := semver.NewVersion(ver)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if c.Check(v) {
|
|
||||||
ts = uint64(startTimeMili)
|
|
||||||
if req.BufferDiff > 0 && req.BufferDiff < 5*60*1000 {
|
|
||||||
ts -= req.BufferDiff
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) startSessionHandlerWeb(w http.ResponseWriter, r *http.Request) {
|
|
||||||
startTime := time.Now()
|
|
||||||
bodySize := 0
|
|
||||||
|
|
||||||
// Check request body
|
|
||||||
if r.Body == nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, errors.New("request body is empty"), startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
bodyBytes, err := e.readBody(w, r, e.cfg.JsonSizeLimit)
|
|
||||||
if err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusRequestEntityTooLarge, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
bodySize = len(bodyBytes)
|
|
||||||
|
|
||||||
// Parse request body
|
|
||||||
req := &StartSessionRequest{}
|
|
||||||
if err := json.Unmarshal(bodyBytes, req); err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add tracker version to context
|
|
||||||
r = r.WithContext(context.WithValue(r.Context(), "tracker", req.TrackerVersion))
|
|
||||||
|
|
||||||
// Handler's logic
|
|
||||||
if req.ProjectKey == nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusForbidden, errors.New("ProjectKey value required"), startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
p, err := e.services.Projects.GetProjectByKey(*req.ProjectKey)
|
|
||||||
if err != nil {
|
|
||||||
if postgres.IsNoRowsErr(err) {
|
|
||||||
logErr := fmt.Errorf("project doesn't exist or is not active, key: %s", *req.ProjectKey)
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusNotFound, logErr, startTime, r.URL.Path, bodySize)
|
|
||||||
} else {
|
|
||||||
e.log.Error(r.Context(), "failed to get project by key: %s, err: %s", *req.ProjectKey, err)
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, errors.New("can't find a project"), startTime, r.URL.Path, bodySize)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add projectID to context
|
|
||||||
r = r.WithContext(context.WithValue(r.Context(), "projectID", fmt.Sprintf("%d", p.ProjectID)))
|
|
||||||
|
|
||||||
// Check if the project supports mobile sessions
|
|
||||||
if !p.IsWeb() {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusForbidden, errors.New("project doesn't support web sessions"), startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ua := e.services.UaParser.ParseFromHTTPRequest(r)
|
|
||||||
if ua == nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusForbidden, fmt.Errorf("browser not recognized, user-agent: %s", r.Header.Get("User-Agent")), startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
geoInfo := e.ExtractGeoData(r)
|
|
||||||
|
|
||||||
userUUID := uuid.GetUUID(req.UserUUID)
|
|
||||||
tokenData, err := e.services.Tokenizer.Parse(req.Token)
|
|
||||||
if err != nil || req.Reset { // Starting the new one
|
|
||||||
dice := byte(rand.Intn(100))
|
|
||||||
// Use condition rate if it's set
|
|
||||||
if req.Condition != "" {
|
|
||||||
rate, err := e.services.Conditions.GetRate(p.ProjectID, req.Condition, int(p.SampleRate))
|
|
||||||
if err != nil {
|
|
||||||
e.log.Warn(r.Context(), "can't get condition rate, condition: %s, err: %s", req.Condition, err)
|
|
||||||
} else {
|
|
||||||
p.SampleRate = byte(rate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if dice >= p.SampleRate {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusForbidden, fmt.Errorf("capture rate miss, rate: %d", p.SampleRate), startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
startTimeMili := startTime.UnixMilli()
|
|
||||||
sessionID, err := e.services.Flaker.Compose(uint64(startTimeMili))
|
|
||||||
if err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
expTime := startTime.Add(time.Duration(p.MaxSessionDuration) * time.Millisecond)
|
|
||||||
tokenData = &token.TokenData{
|
|
||||||
ID: sessionID,
|
|
||||||
Delay: startTimeMili - req.Timestamp,
|
|
||||||
ExpTime: expTime.UnixMilli(),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add sessionID to context
|
|
||||||
r = r.WithContext(context.WithValue(r.Context(), "sessionID", fmt.Sprintf("%d", sessionID)))
|
|
||||||
|
|
||||||
if recordSession(req) {
|
|
||||||
sessionStart := &SessionStart{
|
|
||||||
Timestamp: getSessionTimestamp(req, startTimeMili),
|
|
||||||
ProjectID: uint64(p.ProjectID),
|
|
||||||
TrackerVersion: req.TrackerVersion,
|
|
||||||
RevID: req.RevID,
|
|
||||||
UserUUID: userUUID,
|
|
||||||
UserAgent: r.Header.Get("User-Agent"),
|
|
||||||
UserOS: ua.OS,
|
|
||||||
UserOSVersion: ua.OSVersion,
|
|
||||||
UserBrowser: ua.Browser,
|
|
||||||
UserBrowserVersion: ua.BrowserVersion,
|
|
||||||
UserDevice: ua.Device,
|
|
||||||
UserDeviceType: ua.DeviceType,
|
|
||||||
UserCountry: geoInfo.Pack(),
|
|
||||||
UserDeviceMemorySize: req.DeviceMemory,
|
|
||||||
UserDeviceHeapSize: req.JsHeapSizeLimit,
|
|
||||||
UserID: req.UserID,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save sessionStart to db
|
|
||||||
if err := e.services.Sessions.Add(&sessions.Session{
|
|
||||||
SessionID: sessionID,
|
|
||||||
Platform: "web",
|
|
||||||
Timestamp: sessionStart.Timestamp,
|
|
||||||
Timezone: req.Timezone,
|
|
||||||
ProjectID: uint32(sessionStart.ProjectID),
|
|
||||||
TrackerVersion: sessionStart.TrackerVersion,
|
|
||||||
RevID: sessionStart.RevID,
|
|
||||||
UserUUID: sessionStart.UserUUID,
|
|
||||||
UserOS: sessionStart.UserOS,
|
|
||||||
UserOSVersion: sessionStart.UserOSVersion,
|
|
||||||
UserDevice: sessionStart.UserDevice,
|
|
||||||
UserCountry: geoInfo.Country,
|
|
||||||
UserState: geoInfo.State,
|
|
||||||
UserCity: geoInfo.City,
|
|
||||||
UserAgent: sessionStart.UserAgent,
|
|
||||||
UserBrowser: sessionStart.UserBrowser,
|
|
||||||
UserBrowserVersion: sessionStart.UserBrowserVersion,
|
|
||||||
UserDeviceType: sessionStart.UserDeviceType,
|
|
||||||
UserDeviceMemorySize: sessionStart.UserDeviceMemorySize,
|
|
||||||
UserDeviceHeapSize: sessionStart.UserDeviceHeapSize,
|
|
||||||
UserID: &sessionStart.UserID,
|
|
||||||
ScreenWidth: req.Width,
|
|
||||||
ScreenHeight: req.Height,
|
|
||||||
}); err != nil {
|
|
||||||
e.log.Warn(r.Context(), "can't insert sessionStart to DB: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send sessionStart message to kafka
|
|
||||||
if err := e.services.Producer.Produce(e.cfg.TopicRawWeb, tokenData.ID, sessionStart.Encode()); err != nil {
|
|
||||||
e.log.Error(r.Context(), "can't send sessionStart to queue: %s", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
r = r.WithContext(context.WithValue(r.Context(), "sessionID", fmt.Sprintf("%d", tokenData.ID)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save information about session beacon size
|
|
||||||
e.addBeaconSize(tokenData.ID, p.BeaconSize)
|
|
||||||
|
|
||||||
startResponse := &StartSessionResponse{
|
|
||||||
Token: e.services.Tokenizer.Compose(*tokenData),
|
|
||||||
UserUUID: userUUID,
|
|
||||||
UserOS: ua.OS,
|
|
||||||
UserDevice: ua.Device,
|
|
||||||
UserBrowser: ua.Browser,
|
|
||||||
UserCountry: geoInfo.Country,
|
|
||||||
UserState: geoInfo.State,
|
|
||||||
UserCity: geoInfo.City,
|
|
||||||
SessionID: strconv.FormatUint(tokenData.ID, 10),
|
|
||||||
ProjectID: strconv.FormatUint(uint64(p.ProjectID), 10),
|
|
||||||
BeaconSizeLimit: e.getBeaconSize(tokenData.ID),
|
|
||||||
CompressionThreshold: e.getCompressionThreshold(),
|
|
||||||
StartTimestamp: int64(flakeid.ExtractTimestamp(tokenData.ID)),
|
|
||||||
Delay: tokenData.Delay,
|
|
||||||
CanvasEnabled: e.cfg.RecordCanvas,
|
|
||||||
CanvasImageQuality: e.cfg.CanvasQuality,
|
|
||||||
CanvasFrameRate: e.cfg.CanvasFps,
|
|
||||||
Features: e.features,
|
|
||||||
}
|
|
||||||
modifyResponse(req, startResponse)
|
|
||||||
|
|
||||||
e.ResponseWithJSON(r.Context(), w, startResponse, startTime, r.URL.Path, bodySize)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) pushMessagesHandlerWeb(w http.ResponseWriter, r *http.Request) {
|
|
||||||
startTime := time.Now()
|
|
||||||
bodySize := 0
|
|
||||||
|
|
||||||
// Get debug header with batch info
|
|
||||||
if batch := r.URL.Query().Get("batch"); batch != "" {
|
|
||||||
r = r.WithContext(context.WithValue(r.Context(), "batch", batch))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check authorization
|
|
||||||
sessionData, err := e.services.Tokenizer.ParseFromHTTPRequest(r)
|
|
||||||
if sessionData != nil {
|
|
||||||
r = r.WithContext(context.WithValue(r.Context(), "sessionID", fmt.Sprintf("%d", sessionData.ID)))
|
|
||||||
}
|
|
||||||
tokenJustExpired := false
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, token.JUST_EXPIRED) {
|
|
||||||
tokenJustExpired = true
|
|
||||||
} else {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusUnauthorized, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add sessionID and projectID to context
|
|
||||||
if info, err := e.services.Sessions.Get(sessionData.ID); err == nil {
|
|
||||||
r = r.WithContext(context.WithValue(r.Context(), "projectID", fmt.Sprintf("%d", info.ProjectID)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check request body
|
|
||||||
if r.Body == nil {
|
|
||||||
errCode := http.StatusBadRequest
|
|
||||||
if tokenJustExpired {
|
|
||||||
errCode = http.StatusUnauthorized
|
|
||||||
}
|
|
||||||
e.ResponseWithError(r.Context(), w, errCode, errors.New("request body is empty"), startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
bodyBytes, err := e.readBody(w, r, e.getBeaconSize(sessionData.ID))
|
|
||||||
if err != nil {
|
|
||||||
errCode := http.StatusRequestEntityTooLarge
|
|
||||||
if tokenJustExpired {
|
|
||||||
errCode = http.StatusUnauthorized
|
|
||||||
}
|
|
||||||
e.ResponseWithError(r.Context(), w, errCode, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
bodySize = len(bodyBytes)
|
|
||||||
|
|
||||||
// Send processed messages to queue as array of bytes
|
|
||||||
err = e.services.Producer.Produce(e.cfg.TopicRawWeb, sessionData.ID, bodyBytes)
|
|
||||||
if err != nil {
|
|
||||||
e.log.Error(r.Context(), "can't send messages batch to queue: %s", err)
|
|
||||||
errCode := http.StatusInternalServerError
|
|
||||||
if tokenJustExpired {
|
|
||||||
errCode = http.StatusUnauthorized
|
|
||||||
}
|
|
||||||
e.ResponseWithError(r.Context(), w, errCode, errors.New("can't save message, try again"), startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if tokenJustExpired {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusUnauthorized, errors.New("token expired"), startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
e.ResponseOK(r.Context(), w, startTime, r.URL.Path, bodySize)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) notStartedHandlerWeb(w http.ResponseWriter, r *http.Request) {
|
|
||||||
startTime := time.Now()
|
|
||||||
bodySize := 0
|
|
||||||
|
|
||||||
if r.Body == nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, errors.New("request body is empty"), startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
bodyBytes, err := e.readBody(w, r, e.cfg.JsonSizeLimit)
|
|
||||||
if err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusRequestEntityTooLarge, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
bodySize = len(bodyBytes)
|
|
||||||
|
|
||||||
req := &NotStartedRequest{}
|
|
||||||
if err := json.Unmarshal(bodyBytes, req); err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add tracker version to context
|
|
||||||
r = r.WithContext(context.WithValue(r.Context(), "tracker", req.TrackerVersion))
|
|
||||||
|
|
||||||
// Handler's logic
|
|
||||||
if req.ProjectKey == nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusForbidden, errors.New("projectKey value required"), startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
p, err := e.services.Projects.GetProjectByKey(*req.ProjectKey)
|
|
||||||
if err != nil {
|
|
||||||
if postgres.IsNoRowsErr(err) {
|
|
||||||
logErr := fmt.Errorf("project doesn't exist or is not active, key: %s", *req.ProjectKey)
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusNotFound, logErr, startTime, r.URL.Path, bodySize)
|
|
||||||
} else {
|
|
||||||
e.log.Error(r.Context(), "can't find a project: %s", err)
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, errors.New("can't find a project"), startTime, r.URL.Path, bodySize)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add projectID to context
|
|
||||||
r = r.WithContext(context.WithValue(r.Context(), "projectID", fmt.Sprintf("%d", p.ProjectID)))
|
|
||||||
|
|
||||||
ua := e.services.UaParser.ParseFromHTTPRequest(r)
|
|
||||||
if ua == nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusForbidden, fmt.Errorf("browser not recognized, user-agent: %s", r.Header.Get("User-Agent")), startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
geoInfo := e.ExtractGeoData(r)
|
|
||||||
err = e.services.Sessions.AddUnStarted(&sessions.UnStartedSession{
|
|
||||||
ProjectKey: *req.ProjectKey,
|
|
||||||
TrackerVersion: req.TrackerVersion,
|
|
||||||
DoNotTrack: req.DoNotTrack,
|
|
||||||
Platform: "web",
|
|
||||||
UserAgent: r.Header.Get("User-Agent"),
|
|
||||||
UserOS: ua.OS,
|
|
||||||
UserOSVersion: ua.OSVersion,
|
|
||||||
UserBrowser: ua.Browser,
|
|
||||||
UserBrowserVersion: ua.BrowserVersion,
|
|
||||||
UserDevice: ua.Device,
|
|
||||||
UserDeviceType: ua.DeviceType,
|
|
||||||
UserCountry: geoInfo.Country,
|
|
||||||
UserState: geoInfo.State,
|
|
||||||
UserCity: geoInfo.City,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
e.log.Warn(r.Context(), "can't insert un-started session: %s", err)
|
|
||||||
}
|
|
||||||
// response ok anyway
|
|
||||||
e.ResponseOK(r.Context(), w, startTime, r.URL.Path, bodySize)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) featureFlagsHandlerWeb(w http.ResponseWriter, r *http.Request) {
|
|
||||||
startTime := time.Now()
|
|
||||||
bodySize := 0
|
|
||||||
|
|
||||||
// Check authorization
|
|
||||||
sessionData, err := e.services.Tokenizer.ParseFromHTTPRequest(r)
|
|
||||||
if sessionData != nil {
|
|
||||||
r = r.WithContext(context.WithValue(r.Context(), "sessionID", fmt.Sprintf("%d", sessionData.ID)))
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusUnauthorized, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add sessionID and projectID to context
|
|
||||||
if info, err := e.services.Sessions.Get(sessionData.ID); err == nil {
|
|
||||||
r = r.WithContext(context.WithValue(r.Context(), "projectID", fmt.Sprintf("%d", info.ProjectID)))
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.Body == nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, errors.New("request body is empty"), startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
bodyBytes, err := e.readBody(w, r, e.cfg.JsonSizeLimit)
|
|
||||||
if err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusRequestEntityTooLarge, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
bodySize = len(bodyBytes)
|
|
||||||
|
|
||||||
// Parse request body
|
|
||||||
req := &featureflags.FeatureFlagsRequest{}
|
|
||||||
if err := json.Unmarshal(bodyBytes, req); err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
computedFlags, err := e.services.FeatureFlags.ComputeFlagsForSession(req)
|
|
||||||
if err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := &featureflags.FeatureFlagsResponse{
|
|
||||||
Flags: computedFlags,
|
|
||||||
}
|
|
||||||
e.ResponseWithJSON(r.Context(), w, resp, startTime, r.URL.Path, bodySize)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) getUXTestInfo(w http.ResponseWriter, r *http.Request) {
|
|
||||||
startTime := time.Now()
|
|
||||||
bodySize := 0
|
|
||||||
|
|
||||||
// Check authorization
|
|
||||||
sessionData, err := e.services.Tokenizer.ParseFromHTTPRequest(r)
|
|
||||||
if sessionData != nil {
|
|
||||||
r = r.WithContext(context.WithValue(r.Context(), "sessionID", fmt.Sprintf("%d", sessionData.ID)))
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusUnauthorized, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sess, err := e.services.Sessions.Get(sessionData.ID)
|
|
||||||
if err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusForbidden, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add projectID to context
|
|
||||||
r = r.WithContext(context.WithValue(r.Context(), "projectID", fmt.Sprintf("%d", sess.ProjectID)))
|
|
||||||
|
|
||||||
// Get taskID
|
|
||||||
vars := mux.Vars(r)
|
|
||||||
id := vars["id"]
|
|
||||||
|
|
||||||
// Get task info
|
|
||||||
info, err := e.services.UXTesting.GetInfo(id)
|
|
||||||
if err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if sess.ProjectID != info.ProjectID {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusForbidden, errors.New("project mismatch"), startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
type TaskInfoResponse struct {
|
|
||||||
Task *uxtesting.UXTestInfo `json:"test"`
|
|
||||||
}
|
|
||||||
e.ResponseWithJSON(r.Context(), w, &TaskInfoResponse{Task: info}, startTime, r.URL.Path, bodySize)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) sendUXTestSignal(w http.ResponseWriter, r *http.Request) {
|
|
||||||
startTime := time.Now()
|
|
||||||
bodySize := 0
|
|
||||||
|
|
||||||
// Check authorization
|
|
||||||
sessionData, err := e.services.Tokenizer.ParseFromHTTPRequest(r)
|
|
||||||
if sessionData != nil {
|
|
||||||
r = r.WithContext(context.WithValue(r.Context(), "sessionID", fmt.Sprintf("%d", sessionData.ID)))
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusUnauthorized, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add sessionID and projectID to context
|
|
||||||
if info, err := e.services.Sessions.Get(sessionData.ID); err == nil {
|
|
||||||
r = r.WithContext(context.WithValue(r.Context(), "projectID", fmt.Sprintf("%d", info.ProjectID)))
|
|
||||||
}
|
|
||||||
|
|
||||||
bodyBytes, err := e.readBody(w, r, e.cfg.JsonSizeLimit)
|
|
||||||
if err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusRequestEntityTooLarge, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
bodySize = len(bodyBytes)
|
|
||||||
|
|
||||||
// Parse request body
|
|
||||||
req := &uxtesting.TestSignal{}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(bodyBytes, req); err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
req.SessionID = sessionData.ID
|
|
||||||
|
|
||||||
// Save test signal
|
|
||||||
if err := e.services.UXTesting.SetTestSignal(req); err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
e.ResponseOK(r.Context(), w, startTime, r.URL.Path, bodySize)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) sendUXTaskSignal(w http.ResponseWriter, r *http.Request) {
|
|
||||||
startTime := time.Now()
|
|
||||||
bodySize := 0
|
|
||||||
|
|
||||||
// Check authorization
|
|
||||||
sessionData, err := e.services.Tokenizer.ParseFromHTTPRequest(r)
|
|
||||||
if sessionData != nil {
|
|
||||||
r = r.WithContext(context.WithValue(r.Context(), "sessionID", fmt.Sprintf("%d", sessionData.ID)))
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusUnauthorized, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add sessionID and projectID to context
|
|
||||||
if info, err := e.services.Sessions.Get(sessionData.ID); err == nil {
|
|
||||||
r = r.WithContext(context.WithValue(r.Context(), "projectID", fmt.Sprintf("%d", info.ProjectID)))
|
|
||||||
}
|
|
||||||
|
|
||||||
bodyBytes, err := e.readBody(w, r, e.cfg.JsonSizeLimit)
|
|
||||||
if err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusRequestEntityTooLarge, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
bodySize = len(bodyBytes)
|
|
||||||
|
|
||||||
// Parse request body
|
|
||||||
req := &uxtesting.TaskSignal{}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(bodyBytes, req); err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
req.SessionID = sessionData.ID
|
|
||||||
|
|
||||||
// Save test signal
|
|
||||||
if err := e.services.UXTesting.SetTaskSignal(req); err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
e.ResponseOK(r.Context(), w, startTime, r.URL.Path, bodySize)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) getUXUploadUrl(w http.ResponseWriter, r *http.Request) {
|
|
||||||
startTime := time.Now()
|
|
||||||
bodySize := 0
|
|
||||||
|
|
||||||
// Check authorization
|
|
||||||
sessionData, err := e.services.Tokenizer.ParseFromHTTPRequest(r)
|
|
||||||
if sessionData != nil {
|
|
||||||
r = r.WithContext(context.WithValue(r.Context(), "sessionID", fmt.Sprintf("%d", sessionData.ID)))
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusUnauthorized, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add sessionID and projectID to context
|
|
||||||
if info, err := e.services.Sessions.Get(sessionData.ID); err == nil {
|
|
||||||
r = r.WithContext(context.WithValue(r.Context(), "projectID", fmt.Sprintf("%d", info.ProjectID)))
|
|
||||||
}
|
|
||||||
|
|
||||||
key := fmt.Sprintf("%d/ux_webcam_record.webm", sessionData.ID)
|
|
||||||
url, err := e.services.ObjStorage.GetPreSignedUploadUrl(key)
|
|
||||||
if err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
type UrlResponse struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
}
|
|
||||||
e.ResponseWithJSON(r.Context(), w, &UrlResponse{URL: url}, startTime, r.URL.Path, bodySize)
|
|
||||||
}
|
|
||||||
|
|
||||||
type ScreenshotMessage struct {
|
|
||||||
Name string
|
|
||||||
Data []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) imagesUploaderHandlerWeb(w http.ResponseWriter, r *http.Request) {
|
|
||||||
startTime := time.Now()
|
|
||||||
|
|
||||||
sessionData, err := e.services.Tokenizer.ParseFromHTTPRequest(r)
|
|
||||||
if sessionData != nil {
|
|
||||||
r = r.WithContext(context.WithValue(r.Context(), "sessionID", fmt.Sprintf("%d", sessionData.ID)))
|
|
||||||
}
|
|
||||||
if err != nil { // Should accept expired token?
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusUnauthorized, err, startTime, r.URL.Path, 0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add sessionID and projectID to context
|
|
||||||
if info, err := e.services.Sessions.Get(sessionData.ID); err == nil {
|
|
||||||
r = r.WithContext(context.WithValue(r.Context(), "projectID", fmt.Sprintf("%d", info.ProjectID)))
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.Body == nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, errors.New("request body is empty"), startTime, r.URL.Path, 0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, e.cfg.FileSizeLimit)
|
|
||||||
defer r.Body.Close()
|
|
||||||
|
|
||||||
// Parse the multipart form
|
|
||||||
err = r.ParseMultipartForm(10 << 20) // Max upload size 10 MB
|
|
||||||
if err == http.ErrNotMultipart || err == http.ErrMissingBoundary {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusUnsupportedMediaType, err, startTime, r.URL.Path, 0)
|
|
||||||
return
|
|
||||||
} else if err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, 0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Iterate over uploaded files
|
|
||||||
for _, fileHeaderList := range r.MultipartForm.File {
|
|
||||||
for _, fileHeader := range fileHeaderList {
|
|
||||||
file, err := fileHeader.Open()
|
|
||||||
if err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, 0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read the file content
|
|
||||||
fileBytes, err := io.ReadAll(file)
|
|
||||||
if err != nil {
|
|
||||||
file.Close()
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, 0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
file.Close()
|
|
||||||
|
|
||||||
fileName := util.SafeString(fileHeader.Filename)
|
|
||||||
|
|
||||||
// Create a message to send to Kafka
|
|
||||||
msg := ScreenshotMessage{
|
|
||||||
Name: fileName,
|
|
||||||
Data: fileBytes,
|
|
||||||
}
|
|
||||||
data, err := json.Marshal(&msg)
|
|
||||||
if err != nil {
|
|
||||||
e.log.Warn(r.Context(), "can't marshal screenshot message, err: %s", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send the message to queue
|
|
||||||
if err := e.services.Producer.Produce(e.cfg.TopicCanvasImages, sessionData.ID, data); err != nil {
|
|
||||||
e.log.Warn(r.Context(), "can't send screenshot message to queue, err: %s", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
e.ResponseOK(r.Context(), w, startTime, r.URL.Path, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) getTags(w http.ResponseWriter, r *http.Request) {
|
|
||||||
startTime := time.Now()
|
|
||||||
bodySize := 0
|
|
||||||
|
|
||||||
// Check authorization
|
|
||||||
sessionData, err := e.services.Tokenizer.ParseFromHTTPRequest(r)
|
|
||||||
if sessionData != nil {
|
|
||||||
r = r.WithContext(context.WithValue(r.Context(), "sessionID", fmt.Sprintf("%d", sessionData.ID)))
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusUnauthorized, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sessInfo, err := e.services.Sessions.Get(sessionData.ID)
|
|
||||||
if err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusUnauthorized, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add sessionID and projectID to context
|
|
||||||
r = r.WithContext(context.WithValue(r.Context(), "sessionID", fmt.Sprintf("%d", sessionData.ID)))
|
|
||||||
r = r.WithContext(context.WithValue(r.Context(), "projectID", fmt.Sprintf("%d", sessInfo.ProjectID)))
|
|
||||||
|
|
||||||
// Get tags
|
|
||||||
tags, err := e.services.Tags.Get(sessInfo.ProjectID)
|
|
||||||
if err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
type UrlResponse struct {
|
|
||||||
Tags interface{} `json:"tags"`
|
|
||||||
}
|
|
||||||
e.ResponseWithJSON(r.Context(), w, &UrlResponse{Tags: tags}, startTime, r.URL.Path, bodySize)
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
package router
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
gzip "github.com/klauspost/pgzip"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (e *Router) pushMessages(w http.ResponseWriter, r *http.Request, sessionID uint64, topicName string) {
|
|
||||||
start := time.Now()
|
|
||||||
body := http.MaxBytesReader(w, r.Body, e.cfg.BeaconSizeLimit)
|
|
||||||
defer body.Close()
|
|
||||||
|
|
||||||
var reader io.ReadCloser
|
|
||||||
var err error
|
|
||||||
|
|
||||||
switch r.Header.Get("Content-Encoding") {
|
|
||||||
case "gzip":
|
|
||||||
reader, err = gzip.NewReader(body)
|
|
||||||
if err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, start, r.URL.Path, 0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer reader.Close()
|
|
||||||
default:
|
|
||||||
reader = body
|
|
||||||
}
|
|
||||||
buf, err := io.ReadAll(reader)
|
|
||||||
if err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, start, r.URL.Path, 0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := e.services.Producer.Produce(topicName, sessionID, buf); err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, start, r.URL.Path, 0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
e.log.Info(r.Context(), "response ok")
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
package router
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
metrics "openreplay/backend/pkg/metrics/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
func recordMetrics(requestStart time.Time, url string, code, bodySize int) {
|
|
||||||
if bodySize > 0 {
|
|
||||||
metrics.RecordRequestSize(float64(bodySize), url, code)
|
|
||||||
}
|
|
||||||
metrics.IncreaseTotalRequests()
|
|
||||||
metrics.RecordRequestDuration(float64(time.Now().Sub(requestStart).Milliseconds()), url, code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) ResponseOK(ctx context.Context, w http.ResponseWriter, requestStart time.Time, url string, bodySize int) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
e.log.Info(ctx, "response ok")
|
|
||||||
recordMetrics(requestStart, url, http.StatusOK, bodySize)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) ResponseWithJSON(ctx context.Context, w http.ResponseWriter, res interface{}, requestStart time.Time, url string, bodySize int) {
|
|
||||||
e.log.Info(ctx, "response ok")
|
|
||||||
body, err := json.Marshal(res)
|
|
||||||
if err != nil {
|
|
||||||
e.log.Error(ctx, "can't marshal response: %s", err)
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Write(body)
|
|
||||||
recordMetrics(requestStart, url, http.StatusOK, bodySize)
|
|
||||||
}
|
|
||||||
|
|
||||||
type response struct {
|
|
||||||
Error string `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) ResponseWithError(ctx context.Context, w http.ResponseWriter, code int, err error, requestStart time.Time, url string, bodySize int) {
|
|
||||||
e.log.Error(ctx, "response error, code: %d, error: %s", code, err)
|
|
||||||
body, err := json.Marshal(&response{err.Error()})
|
|
||||||
if err != nil {
|
|
||||||
e.log.Error(ctx, "can't marshal response: %s", err)
|
|
||||||
}
|
|
||||||
w.WriteHeader(code)
|
|
||||||
w.Write(body)
|
|
||||||
recordMetrics(requestStart, url, code, bodySize)
|
|
||||||
}
|
|
||||||
|
|
@ -1,178 +0,0 @@
|
||||||
package router
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"github.com/docker/distribution/context"
|
|
||||||
"github.com/tomasen/realip"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"openreplay/backend/internal/http/geoip"
|
|
||||||
"openreplay/backend/pkg/logger"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
|
||||||
http3 "openreplay/backend/internal/config/http"
|
|
||||||
http2 "openreplay/backend/internal/http/services"
|
|
||||||
"openreplay/backend/internal/http/util"
|
|
||||||
)
|
|
||||||
|
|
||||||
type BeaconSize struct {
|
|
||||||
size int64
|
|
||||||
time time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type Router struct {
|
|
||||||
log logger.Logger
|
|
||||||
cfg *http3.Config
|
|
||||||
router *mux.Router
|
|
||||||
mutex *sync.RWMutex
|
|
||||||
services *http2.ServicesBuilder
|
|
||||||
beaconSizeCache map[uint64]*BeaconSize // Cache for session's beaconSize
|
|
||||||
compressionThreshold int64
|
|
||||||
features map[string]bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewRouter(cfg *http3.Config, log logger.Logger, services *http2.ServicesBuilder) (*Router, error) {
|
|
||||||
switch {
|
|
||||||
case cfg == nil:
|
|
||||||
return nil, fmt.Errorf("config is empty")
|
|
||||||
case services == nil:
|
|
||||||
return nil, fmt.Errorf("services is empty")
|
|
||||||
case log == nil:
|
|
||||||
return nil, fmt.Errorf("logger is empty")
|
|
||||||
}
|
|
||||||
e := &Router{
|
|
||||||
log: log,
|
|
||||||
cfg: cfg,
|
|
||||||
mutex: &sync.RWMutex{},
|
|
||||||
services: services,
|
|
||||||
beaconSizeCache: make(map[uint64]*BeaconSize),
|
|
||||||
compressionThreshold: cfg.CompressionThreshold,
|
|
||||||
features: map[string]bool{
|
|
||||||
"feature-flags": cfg.IsFeatureFlagEnabled,
|
|
||||||
"usability-test": cfg.IsUsabilityTestEnabled,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
e.init()
|
|
||||||
go e.clearBeaconSizes()
|
|
||||||
return e, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) addBeaconSize(sessionID uint64, size int64) {
|
|
||||||
if size <= 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
e.mutex.Lock()
|
|
||||||
defer e.mutex.Unlock()
|
|
||||||
e.beaconSizeCache[sessionID] = &BeaconSize{
|
|
||||||
size: size,
|
|
||||||
time: time.Now(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) getBeaconSize(sessionID uint64) int64 {
|
|
||||||
e.mutex.RLock()
|
|
||||||
defer e.mutex.RUnlock()
|
|
||||||
if beaconSize, ok := e.beaconSizeCache[sessionID]; ok {
|
|
||||||
beaconSize.time = time.Now()
|
|
||||||
return beaconSize.size
|
|
||||||
}
|
|
||||||
return e.cfg.BeaconSizeLimit
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) getCompressionThreshold() int64 {
|
|
||||||
return e.compressionThreshold
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) clearBeaconSizes() {
|
|
||||||
for {
|
|
||||||
time.Sleep(time.Minute * 2)
|
|
||||||
now := time.Now()
|
|
||||||
e.mutex.Lock()
|
|
||||||
for sid, bs := range e.beaconSizeCache {
|
|
||||||
if now.Sub(bs.time) > time.Minute*3 {
|
|
||||||
delete(e.beaconSizeCache, sid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
e.mutex.Unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) ExtractGeoData(r *http.Request) *geoip.GeoRecord {
|
|
||||||
ip := net.ParseIP(realip.FromRequest(r))
|
|
||||||
geoRec, err := e.services.GeoIP.Parse(ip)
|
|
||||||
if err != nil {
|
|
||||||
e.log.Warn(r.Context(), "failed to parse geo data: %v", err)
|
|
||||||
}
|
|
||||||
return geoRec
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) init() {
|
|
||||||
e.router = mux.NewRouter()
|
|
||||||
|
|
||||||
// Root path
|
|
||||||
e.router.HandleFunc("/", e.root)
|
|
||||||
|
|
||||||
handlers := map[string]func(http.ResponseWriter, *http.Request){
|
|
||||||
"/v1/web/not-started": e.notStartedHandlerWeb,
|
|
||||||
"/v1/web/start": e.startSessionHandlerWeb,
|
|
||||||
"/v1/web/i": e.pushMessagesHandlerWeb,
|
|
||||||
"/v1/web/feature-flags": e.featureFlagsHandlerWeb,
|
|
||||||
"/v1/web/images": e.imagesUploaderHandlerWeb,
|
|
||||||
"/v1/mobile/start": e.startMobileSessionHandler,
|
|
||||||
"/v1/mobile/i": e.pushMobileMessagesHandler,
|
|
||||||
"/v1/mobile/late": e.pushMobileLateMessagesHandler,
|
|
||||||
"/v1/mobile/images": e.mobileImagesUploadHandler,
|
|
||||||
"/v1/web/uxt/signals/test": e.sendUXTestSignal,
|
|
||||||
"/v1/web/uxt/signals/task": e.sendUXTaskSignal,
|
|
||||||
}
|
|
||||||
getHandlers := map[string]func(http.ResponseWriter, *http.Request){
|
|
||||||
"/v1/web/uxt/test/{id}": e.getUXTestInfo,
|
|
||||||
"/v1/web/uxt/upload-url": e.getUXUploadUrl,
|
|
||||||
"/v1/web/tags": e.getTags,
|
|
||||||
"/v1/web/conditions/{project}": e.getConditions,
|
|
||||||
"/v1/mobile/conditions/{project}": e.getConditions,
|
|
||||||
}
|
|
||||||
prefix := "/ingest"
|
|
||||||
|
|
||||||
for path, handler := range handlers {
|
|
||||||
e.router.HandleFunc(path, handler).Methods("POST", "OPTIONS")
|
|
||||||
e.router.HandleFunc(prefix+path, handler).Methods("POST", "OPTIONS")
|
|
||||||
}
|
|
||||||
for path, handler := range getHandlers {
|
|
||||||
e.router.HandleFunc(path, handler).Methods("GET", "OPTIONS")
|
|
||||||
e.router.HandleFunc(prefix+path, handler).Methods("GET", "OPTIONS")
|
|
||||||
}
|
|
||||||
|
|
||||||
// CORS middleware
|
|
||||||
e.router.Use(e.corsMiddleware)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) root(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) corsMiddleware(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if e.cfg.UseAccessControlHeaders {
|
|
||||||
// Prepare headers for preflight requests
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "POST,GET")
|
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type,Authorization,Content-Encoding")
|
|
||||||
}
|
|
||||||
if r.Method == http.MethodOptions {
|
|
||||||
w.Header().Set("Cache-Control", "max-age=86400")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
r = r.WithContext(context.WithValues(r.Context(), map[string]interface{}{"httpMethod": r.Method, "url": util.SafeString(r.URL.Path)}))
|
|
||||||
|
|
||||||
// Serve request
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) GetHandler() http.Handler {
|
|
||||||
return e.router
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"golang.org/x/net/http2"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Server struct {
|
|
||||||
server *http.Server
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(handler http.Handler, host, port string, timeout time.Duration) (*Server, error) {
|
|
||||||
switch {
|
|
||||||
case port == "":
|
|
||||||
return nil, errors.New("empty server port")
|
|
||||||
case handler == nil:
|
|
||||||
return nil, errors.New("empty handler")
|
|
||||||
case timeout < 1:
|
|
||||||
return nil, fmt.Errorf("invalid timeout %d", timeout)
|
|
||||||
}
|
|
||||||
server := &http.Server{
|
|
||||||
Addr: fmt.Sprintf("%s:%s", host, port),
|
|
||||||
Handler: handler,
|
|
||||||
ReadTimeout: timeout,
|
|
||||||
WriteTimeout: timeout,
|
|
||||||
}
|
|
||||||
http2.ConfigureServer(server, nil)
|
|
||||||
return &Server{
|
|
||||||
server: server,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) Start() error {
|
|
||||||
return s.server.ListenAndServe()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) Stop() {
|
|
||||||
s.server.Shutdown(context.Background())
|
|
||||||
}
|
|
||||||
|
|
@ -5,44 +5,44 @@ import (
|
||||||
"openreplay/backend/internal/http/geoip"
|
"openreplay/backend/internal/http/geoip"
|
||||||
"openreplay/backend/internal/http/uaparser"
|
"openreplay/backend/internal/http/uaparser"
|
||||||
"openreplay/backend/pkg/conditions"
|
"openreplay/backend/pkg/conditions"
|
||||||
|
conditionsAPI "openreplay/backend/pkg/conditions/api"
|
||||||
"openreplay/backend/pkg/db/postgres/pool"
|
"openreplay/backend/pkg/db/postgres/pool"
|
||||||
"openreplay/backend/pkg/db/redis"
|
"openreplay/backend/pkg/db/redis"
|
||||||
"openreplay/backend/pkg/featureflags"
|
"openreplay/backend/pkg/featureflags"
|
||||||
|
featureflagsAPI "openreplay/backend/pkg/featureflags/api"
|
||||||
"openreplay/backend/pkg/flakeid"
|
"openreplay/backend/pkg/flakeid"
|
||||||
"openreplay/backend/pkg/logger"
|
"openreplay/backend/pkg/logger"
|
||||||
"openreplay/backend/pkg/objectstorage"
|
"openreplay/backend/pkg/metrics/web"
|
||||||
"openreplay/backend/pkg/objectstorage/store"
|
"openreplay/backend/pkg/objectstorage/store"
|
||||||
"openreplay/backend/pkg/projects"
|
"openreplay/backend/pkg/projects"
|
||||||
"openreplay/backend/pkg/queue/types"
|
"openreplay/backend/pkg/queue/types"
|
||||||
|
"openreplay/backend/pkg/server/api"
|
||||||
"openreplay/backend/pkg/sessions"
|
"openreplay/backend/pkg/sessions"
|
||||||
|
mobilesessions "openreplay/backend/pkg/sessions/api/mobile"
|
||||||
|
websessions "openreplay/backend/pkg/sessions/api/web"
|
||||||
"openreplay/backend/pkg/tags"
|
"openreplay/backend/pkg/tags"
|
||||||
|
tagsAPI "openreplay/backend/pkg/tags/api"
|
||||||
"openreplay/backend/pkg/token"
|
"openreplay/backend/pkg/token"
|
||||||
"openreplay/backend/pkg/uxtesting"
|
"openreplay/backend/pkg/uxtesting"
|
||||||
|
uxtestingAPI "openreplay/backend/pkg/uxtesting/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ServicesBuilder struct {
|
type ServicesBuilder struct {
|
||||||
Projects projects.Projects
|
WebAPI api.Handlers
|
||||||
Sessions sessions.Sessions
|
MobileAPI api.Handlers
|
||||||
FeatureFlags featureflags.FeatureFlags
|
ConditionsAPI api.Handlers
|
||||||
Producer types.Producer
|
FeatureFlagsAPI api.Handlers
|
||||||
Flaker *flakeid.Flaker
|
TagsAPI api.Handlers
|
||||||
UaParser *uaparser.UAParser
|
UxTestsAPI api.Handlers
|
||||||
GeoIP geoip.GeoParser
|
|
||||||
Tokenizer *token.Tokenizer
|
|
||||||
ObjStorage objectstorage.ObjectStorage
|
|
||||||
UXTesting uxtesting.UXTesting
|
|
||||||
Tags tags.Tags
|
|
||||||
Conditions conditions.Conditions
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(log logger.Logger, cfg *http.Config, producer types.Producer, pgconn pool.Pool, redis *redis.Client) (*ServicesBuilder, error) {
|
func New(log logger.Logger, cfg *http.Config, metrics web.Web, producer types.Producer, pgconn pool.Pool, redis *redis.Client) (*ServicesBuilder, error) {
|
||||||
projs := projects.New(log, pgconn, redis)
|
projs := projects.New(log, pgconn, redis)
|
||||||
// ObjectStorage client to generate pre-signed upload urls
|
|
||||||
objStore, err := store.NewStore(&cfg.ObjectsConfig)
|
objStore, err := store.NewStore(&cfg.ObjectsConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
geoModule, err := geoip.New(cfg.MaxMinDBFile)
|
geoModule, err := geoip.New(log, cfg.MaxMinDBFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -50,18 +50,32 @@ func New(log logger.Logger, cfg *http.Config, producer types.Producer, pgconn po
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &ServicesBuilder{
|
tokenizer := token.NewTokenizer(cfg.TokenSecret)
|
||||||
Projects: projs,
|
conditions := conditions.New(pgconn)
|
||||||
Sessions: sessions.New(log, pgconn, projs, redis),
|
flaker := flakeid.NewFlaker(cfg.WorkerID)
|
||||||
FeatureFlags: featureflags.New(pgconn),
|
sessions := sessions.New(log, pgconn, projs, redis)
|
||||||
Producer: producer,
|
featureFlags := featureflags.New(pgconn)
|
||||||
Tokenizer: token.NewTokenizer(cfg.TokenSecret),
|
tags := tags.New(log, pgconn)
|
||||||
UaParser: uaModule,
|
uxTesting := uxtesting.New(pgconn)
|
||||||
GeoIP: geoModule,
|
responser := api.NewResponser(metrics)
|
||||||
Flaker: flakeid.NewFlaker(cfg.WorkerID),
|
builder := &ServicesBuilder{}
|
||||||
ObjStorage: objStore,
|
if builder.WebAPI, err = websessions.NewHandlers(cfg, log, responser, producer, projs, sessions, uaModule, geoModule, tokenizer, conditions, flaker); err != nil {
|
||||||
UXTesting: uxtesting.New(pgconn),
|
return nil, err
|
||||||
Tags: tags.New(log, pgconn),
|
}
|
||||||
Conditions: conditions.New(pgconn),
|
if builder.MobileAPI, err = mobilesessions.NewHandlers(cfg, log, responser, producer, projs, sessions, uaModule, geoModule, tokenizer, conditions, flaker); err != nil {
|
||||||
}, nil
|
return nil, err
|
||||||
|
}
|
||||||
|
if builder.ConditionsAPI, err = conditionsAPI.NewHandlers(log, responser, tokenizer, conditions); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if builder.FeatureFlagsAPI, err = featureflagsAPI.NewHandlers(log, responser, cfg.JsonSizeLimit, tokenizer, sessions, featureFlags); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if builder.TagsAPI, err = tagsAPI.NewHandlers(log, responser, tokenizer, sessions, tags); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if builder.UxTestsAPI, err = uxtestingAPI.NewHandlers(log, responser, cfg.JsonSizeLimit, tokenizer, sessions, uxTesting, objStore); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return builder, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
34
backend/pkg/conditions/api/handlers.go
Normal file
34
backend/pkg/conditions/api/handlers.go
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"openreplay/backend/pkg/conditions"
|
||||||
|
"openreplay/backend/pkg/logger"
|
||||||
|
"openreplay/backend/pkg/server/api"
|
||||||
|
"openreplay/backend/pkg/token"
|
||||||
|
)
|
||||||
|
|
||||||
|
type handlersImpl struct {
|
||||||
|
log logger.Logger
|
||||||
|
responser *api.Responser
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandlers(log logger.Logger, responser *api.Responser, tokenizer *token.Tokenizer, conditions conditions.Conditions) (api.Handlers, error) {
|
||||||
|
return &handlersImpl{
|
||||||
|
log: log,
|
||||||
|
responser: responser,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *handlersImpl) GetAll() []*api.Description {
|
||||||
|
return []*api.Description{
|
||||||
|
{"/v1/web/conditions/{project}", e.getConditions, "GET"},
|
||||||
|
{"/v1/mobile/conditions/{project}", e.getConditions, "GET"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *handlersImpl) getConditions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusNotImplemented, nil, time.Now(), r.URL.Path, 0)
|
||||||
|
}
|
||||||
|
|
@ -4,9 +4,9 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"openreplay/backend/pkg/db/postgres/pool"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"openreplay/backend/pkg/db/postgres/pool"
|
||||||
"openreplay/backend/pkg/metrics/database"
|
"openreplay/backend/pkg/metrics/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
92
backend/pkg/featureflags/api/handlers.go
Normal file
92
backend/pkg/featureflags/api/handlers.go
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"openreplay/backend/pkg/featureflags"
|
||||||
|
"openreplay/backend/pkg/logger"
|
||||||
|
"openreplay/backend/pkg/server/api"
|
||||||
|
"openreplay/backend/pkg/sessions"
|
||||||
|
"openreplay/backend/pkg/token"
|
||||||
|
)
|
||||||
|
|
||||||
|
type handlersImpl struct {
|
||||||
|
log logger.Logger
|
||||||
|
responser *api.Responser
|
||||||
|
jsonSizeLimit int64
|
||||||
|
tokenizer *token.Tokenizer
|
||||||
|
sessions sessions.Sessions
|
||||||
|
featureFlags featureflags.FeatureFlags
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandlers(log logger.Logger, responser *api.Responser, jsonSizeLimit int64, tokenizer *token.Tokenizer, sessions sessions.Sessions,
|
||||||
|
featureFlags featureflags.FeatureFlags) (api.Handlers, error) {
|
||||||
|
return &handlersImpl{
|
||||||
|
log: log,
|
||||||
|
responser: responser,
|
||||||
|
jsonSizeLimit: jsonSizeLimit,
|
||||||
|
tokenizer: tokenizer,
|
||||||
|
sessions: sessions,
|
||||||
|
featureFlags: featureFlags,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *handlersImpl) GetAll() []*api.Description {
|
||||||
|
return []*api.Description{
|
||||||
|
{"/v1/web/feature-flags", e.featureFlagsHandlerWeb, "POST"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *handlersImpl) featureFlagsHandlerWeb(w http.ResponseWriter, r *http.Request) {
|
||||||
|
startTime := time.Now()
|
||||||
|
bodySize := 0
|
||||||
|
|
||||||
|
// Check authorization
|
||||||
|
sessionData, err := e.tokenizer.ParseFromHTTPRequest(r)
|
||||||
|
if sessionData != nil {
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), "sessionID", fmt.Sprintf("%d", sessionData.ID)))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusUnauthorized, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add sessionID and projectID to context
|
||||||
|
if info, err := e.sessions.Get(sessionData.ID); err == nil {
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), "projectID", fmt.Sprintf("%d", info.ProjectID)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Body == nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, errors.New("request body is empty"), startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bodyBytes, err := api.ReadCompressedBody(e.log, w, r, e.jsonSizeLimit)
|
||||||
|
if err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusRequestEntityTooLarge, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bodySize = len(bodyBytes)
|
||||||
|
|
||||||
|
// Parse request body
|
||||||
|
req := &featureflags.FeatureFlagsRequest{}
|
||||||
|
if err := json.Unmarshal(bodyBytes, req); err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
computedFlags, err := e.featureFlags.ComputeFlagsForSession(req)
|
||||||
|
if err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &featureflags.FeatureFlagsResponse{
|
||||||
|
Flags: computedFlags,
|
||||||
|
}
|
||||||
|
e.responser.ResponseWithJSON(e.log, r.Context(), w, resp, startTime, r.URL.Path, bodySize)
|
||||||
|
}
|
||||||
206
backend/pkg/integrations/api/handlers.go
Normal file
206
backend/pkg/integrations/api/handlers.go
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
|
||||||
|
integrationsCfg "openreplay/backend/internal/config/integrations"
|
||||||
|
"openreplay/backend/pkg/integrations/service"
|
||||||
|
"openreplay/backend/pkg/logger"
|
||||||
|
"openreplay/backend/pkg/server/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
type handlersImpl struct {
|
||||||
|
log logger.Logger
|
||||||
|
responser *api.Responser
|
||||||
|
integrations service.Service
|
||||||
|
jsonSizeLimit int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandlers(log logger.Logger, cfg *integrationsCfg.Config, responser *api.Responser, integrations service.Service) (api.Handlers, error) {
|
||||||
|
return &handlersImpl{
|
||||||
|
log: log,
|
||||||
|
responser: responser,
|
||||||
|
integrations: integrations,
|
||||||
|
jsonSizeLimit: cfg.JsonSizeLimit,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *handlersImpl) GetAll() []*api.Description {
|
||||||
|
return []*api.Description{
|
||||||
|
{"/v1/integrations/{name}/{project}", e.createIntegration, "POST"},
|
||||||
|
{"/v1/integrations/{name}/{project}", e.getIntegration, "GET"},
|
||||||
|
{"/v1/integrations/{name}/{project}", e.updateIntegration, "PATCH"},
|
||||||
|
{"/v1/integrations/{name}/{project}", e.deleteIntegration, "DELETE"},
|
||||||
|
{"/v1/integrations/{name}/{project}/data/{session}", e.getIntegrationData, "GET"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getIntegrationsArgs(r *http.Request) (string, uint64, error) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
name := vars["name"]
|
||||||
|
if name == "" {
|
||||||
|
return "", 0, fmt.Errorf("empty integration name")
|
||||||
|
}
|
||||||
|
project := vars["project"]
|
||||||
|
if project == "" {
|
||||||
|
return "", 0, fmt.Errorf("project id is empty")
|
||||||
|
}
|
||||||
|
projID, err := strconv.ParseUint(project, 10, 64)
|
||||||
|
if err != nil || projID <= 0 {
|
||||||
|
return "", 0, fmt.Errorf("invalid project id")
|
||||||
|
}
|
||||||
|
return name, projID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getIntegrationSession(r *http.Request) (uint64, error) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
session := vars["session"]
|
||||||
|
if session == "" {
|
||||||
|
return 0, fmt.Errorf("session id is empty")
|
||||||
|
}
|
||||||
|
sessID, err := strconv.ParseUint(session, 10, 64)
|
||||||
|
if err != nil || sessID <= 0 {
|
||||||
|
return 0, fmt.Errorf("invalid session id")
|
||||||
|
}
|
||||||
|
return sessID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type IntegrationRequest struct {
|
||||||
|
IntegrationData map[string]string `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *handlersImpl) createIntegration(w http.ResponseWriter, r *http.Request) {
|
||||||
|
startTime := time.Now()
|
||||||
|
bodySize := 0
|
||||||
|
|
||||||
|
bodyBytes, err := api.ReadBody(e.log, w, r, e.jsonSizeLimit)
|
||||||
|
if err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusRequestEntityTooLarge, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bodySize = len(bodyBytes)
|
||||||
|
|
||||||
|
integration, project, err := getIntegrationsArgs(r)
|
||||||
|
if err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &IntegrationRequest{}
|
||||||
|
if err := json.Unmarshal(bodyBytes, req); err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := e.integrations.AddIntegration(project, integration, req.IntegrationData); err != nil {
|
||||||
|
if strings.Contains(err.Error(), "failed to validate") {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusUnprocessableEntity, err, startTime, r.URL.Path, bodySize)
|
||||||
|
} else {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.responser.ResponseOK(e.log, r.Context(), w, startTime, r.URL.Path, bodySize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *handlersImpl) getIntegration(w http.ResponseWriter, r *http.Request) {
|
||||||
|
startTime := time.Now()
|
||||||
|
bodySize := 0
|
||||||
|
|
||||||
|
integration, project, err := getIntegrationsArgs(r)
|
||||||
|
if err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
intParams, err := e.integrations.GetIntegration(project, integration)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "no rows in result set") {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusNotFound, err, startTime, r.URL.Path, bodySize)
|
||||||
|
} else {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.responser.ResponseWithJSON(e.log, r.Context(), w, intParams, startTime, r.URL.Path, bodySize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *handlersImpl) updateIntegration(w http.ResponseWriter, r *http.Request) {
|
||||||
|
startTime := time.Now()
|
||||||
|
bodySize := 0
|
||||||
|
|
||||||
|
bodyBytes, err := api.ReadBody(e.log, w, r, e.jsonSizeLimit)
|
||||||
|
if err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusRequestEntityTooLarge, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bodySize = len(bodyBytes)
|
||||||
|
|
||||||
|
integration, project, err := getIntegrationsArgs(r)
|
||||||
|
if err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &IntegrationRequest{}
|
||||||
|
if err := json.Unmarshal(bodyBytes, req); err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := e.integrations.UpdateIntegration(project, integration, req.IntegrationData); err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.responser.ResponseOK(e.log, r.Context(), w, startTime, r.URL.Path, bodySize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *handlersImpl) deleteIntegration(w http.ResponseWriter, r *http.Request) {
|
||||||
|
startTime := time.Now()
|
||||||
|
bodySize := 0
|
||||||
|
|
||||||
|
integration, project, err := getIntegrationsArgs(r)
|
||||||
|
if err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := e.integrations.DeleteIntegration(project, integration); err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.responser.ResponseOK(e.log, r.Context(), w, startTime, r.URL.Path, bodySize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *handlersImpl) getIntegrationData(w http.ResponseWriter, r *http.Request) {
|
||||||
|
startTime := time.Now()
|
||||||
|
bodySize := 0
|
||||||
|
|
||||||
|
integration, project, err := getIntegrationsArgs(r)
|
||||||
|
if err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := getIntegrationSession(r)
|
||||||
|
if err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
url, err := e.integrations.GetSessionDataURL(project, integration, session)
|
||||||
|
if err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := map[string]string{"url": url}
|
||||||
|
e.responser.ResponseWithJSON(e.log, r.Context(), w, resp, startTime, r.URL.Path, bodySize)
|
||||||
|
}
|
||||||
|
|
@ -1,36 +1,51 @@
|
||||||
package data_integration
|
package integrations
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"openreplay/backend/pkg/integrations/service"
|
||||||
|
"openreplay/backend/pkg/metrics/web"
|
||||||
|
"openreplay/backend/pkg/server/tracer"
|
||||||
|
"time"
|
||||||
|
|
||||||
"openreplay/backend/internal/config/integrations"
|
"openreplay/backend/internal/config/integrations"
|
||||||
"openreplay/backend/pkg/db/postgres/pool"
|
"openreplay/backend/pkg/db/postgres/pool"
|
||||||
"openreplay/backend/pkg/flakeid"
|
integrationsAPI "openreplay/backend/pkg/integrations/api"
|
||||||
"openreplay/backend/pkg/logger"
|
"openreplay/backend/pkg/logger"
|
||||||
"openreplay/backend/pkg/objectstorage"
|
|
||||||
"openreplay/backend/pkg/objectstorage/store"
|
"openreplay/backend/pkg/objectstorage/store"
|
||||||
"openreplay/backend/pkg/spot/auth"
|
"openreplay/backend/pkg/server/api"
|
||||||
|
"openreplay/backend/pkg/server/auth"
|
||||||
|
"openreplay/backend/pkg/server/limiter"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ServiceBuilder struct {
|
type ServiceBuilder struct {
|
||||||
Flaker *flakeid.Flaker
|
Auth auth.Auth
|
||||||
ObjStorage objectstorage.ObjectStorage
|
RateLimiter *limiter.UserRateLimiter
|
||||||
Auth auth.Auth
|
AuditTrail tracer.Tracer
|
||||||
Integrator Service
|
IntegrationsAPI api.Handlers
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServiceBuilder(log logger.Logger, cfg *integrations.Config, pgconn pool.Pool) (*ServiceBuilder, error) {
|
func NewServiceBuilder(log logger.Logger, cfg *integrations.Config, webMetrics web.Web, pgconn pool.Pool) (*ServiceBuilder, error) {
|
||||||
objStore, err := store.NewStore(&cfg.ObjectsConfig)
|
objStore, err := store.NewStore(&cfg.ObjectsConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
integrator, err := NewService(log, pgconn, objStore)
|
integrator, err := service.NewService(log, pgconn, objStore)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
flaker := flakeid.NewFlaker(cfg.WorkerID)
|
responser := api.NewResponser(webMetrics)
|
||||||
return &ServiceBuilder{
|
handlers, err := integrationsAPI.NewHandlers(log, cfg, responser, integrator)
|
||||||
Flaker: flaker,
|
if err != nil {
|
||||||
ObjStorage: objStore,
|
return nil, err
|
||||||
Auth: auth.NewAuth(log, cfg.JWTSecret, "", pgconn),
|
}
|
||||||
Integrator: integrator,
|
auditrail, err := tracer.NewTracer(log, pgconn)
|
||||||
}, nil
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
builder := &ServiceBuilder{
|
||||||
|
Auth: auth.NewAuth(log, cfg.JWTSecret, "", pgconn, nil),
|
||||||
|
RateLimiter: limiter.NewUserRateLimiter(10, 30, 1*time.Minute, 5*time.Minute),
|
||||||
|
AuditTrail: auditrail,
|
||||||
|
IntegrationsAPI: handlers,
|
||||||
|
}
|
||||||
|
return builder, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,233 +0,0 @@
|
||||||
package data_integration
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
|
||||||
|
|
||||||
metrics "openreplay/backend/pkg/metrics/heuristics"
|
|
||||||
)
|
|
||||||
|
|
||||||
func getIntegrationsArgs(r *http.Request) (string, uint64, error) {
|
|
||||||
vars := mux.Vars(r)
|
|
||||||
name := vars["name"]
|
|
||||||
if name == "" {
|
|
||||||
return "", 0, fmt.Errorf("empty integration name")
|
|
||||||
}
|
|
||||||
project := vars["project"]
|
|
||||||
if project == "" {
|
|
||||||
return "", 0, fmt.Errorf("project id is empty")
|
|
||||||
}
|
|
||||||
projID, err := strconv.ParseUint(project, 10, 64)
|
|
||||||
if err != nil || projID <= 0 {
|
|
||||||
return "", 0, fmt.Errorf("invalid project id")
|
|
||||||
}
|
|
||||||
return name, projID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getIntegrationSession(r *http.Request) (uint64, error) {
|
|
||||||
vars := mux.Vars(r)
|
|
||||||
session := vars["session"]
|
|
||||||
if session == "" {
|
|
||||||
return 0, fmt.Errorf("session id is empty")
|
|
||||||
}
|
|
||||||
sessID, err := strconv.ParseUint(session, 10, 64)
|
|
||||||
if err != nil || sessID <= 0 {
|
|
||||||
return 0, fmt.Errorf("invalid session id")
|
|
||||||
}
|
|
||||||
return sessID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type IntegrationRequest struct {
|
|
||||||
IntegrationData map[string]string `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) createIntegration(w http.ResponseWriter, r *http.Request) {
|
|
||||||
startTime := time.Now()
|
|
||||||
bodySize := 0
|
|
||||||
|
|
||||||
bodyBytes, err := e.readBody(w, r, e.cfg.JsonSizeLimit)
|
|
||||||
if err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusRequestEntityTooLarge, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
bodySize = len(bodyBytes)
|
|
||||||
|
|
||||||
integration, project, err := getIntegrationsArgs(r)
|
|
||||||
if err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
req := &IntegrationRequest{}
|
|
||||||
if err := json.Unmarshal(bodyBytes, req); err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := e.services.Integrator.AddIntegration(project, integration, req.IntegrationData); err != nil {
|
|
||||||
if strings.Contains(err.Error(), "failed to validate") {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusUnprocessableEntity, err, startTime, r.URL.Path, bodySize)
|
|
||||||
} else {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
e.ResponseOK(r.Context(), w, startTime, r.URL.Path, bodySize)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) getIntegration(w http.ResponseWriter, r *http.Request) {
|
|
||||||
startTime := time.Now()
|
|
||||||
bodySize := 0
|
|
||||||
|
|
||||||
integration, project, err := getIntegrationsArgs(r)
|
|
||||||
if err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
intParams, err := e.services.Integrator.GetIntegration(project, integration)
|
|
||||||
if err != nil {
|
|
||||||
if strings.Contains(err.Error(), "no rows in result set") {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusNotFound, err, startTime, r.URL.Path, bodySize)
|
|
||||||
} else {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
e.ResponseWithJSON(r.Context(), w, intParams, startTime, r.URL.Path, bodySize)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) updateIntegration(w http.ResponseWriter, r *http.Request) {
|
|
||||||
startTime := time.Now()
|
|
||||||
bodySize := 0
|
|
||||||
|
|
||||||
bodyBytes, err := e.readBody(w, r, e.cfg.JsonSizeLimit)
|
|
||||||
if err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusRequestEntityTooLarge, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
bodySize = len(bodyBytes)
|
|
||||||
|
|
||||||
integration, project, err := getIntegrationsArgs(r)
|
|
||||||
if err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
req := &IntegrationRequest{}
|
|
||||||
if err := json.Unmarshal(bodyBytes, req); err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := e.services.Integrator.UpdateIntegration(project, integration, req.IntegrationData); err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
e.ResponseOK(r.Context(), w, startTime, r.URL.Path, bodySize)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) deleteIntegration(w http.ResponseWriter, r *http.Request) {
|
|
||||||
startTime := time.Now()
|
|
||||||
bodySize := 0
|
|
||||||
|
|
||||||
integration, project, err := getIntegrationsArgs(r)
|
|
||||||
if err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := e.services.Integrator.DeleteIntegration(project, integration); err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
e.ResponseOK(r.Context(), w, startTime, r.URL.Path, bodySize)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) getIntegrationData(w http.ResponseWriter, r *http.Request) {
|
|
||||||
startTime := time.Now()
|
|
||||||
bodySize := 0
|
|
||||||
|
|
||||||
integration, project, err := getIntegrationsArgs(r)
|
|
||||||
if err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
session, err := getIntegrationSession(r)
|
|
||||||
if err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
url, err := e.services.Integrator.GetSessionDataURL(project, integration, session)
|
|
||||||
if err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := map[string]string{"url": url}
|
|
||||||
e.ResponseWithJSON(r.Context(), w, resp, startTime, r.URL.Path, bodySize)
|
|
||||||
}
|
|
||||||
|
|
||||||
func recordMetrics(requestStart time.Time, url string, code, bodySize int) {
|
|
||||||
if bodySize > 0 {
|
|
||||||
metrics.RecordRequestSize(float64(bodySize), url, code)
|
|
||||||
}
|
|
||||||
metrics.IncreaseTotalRequests()
|
|
||||||
metrics.RecordRequestDuration(float64(time.Now().Sub(requestStart).Milliseconds()), url, code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) readBody(w http.ResponseWriter, r *http.Request, limit int64) ([]byte, error) {
|
|
||||||
body := http.MaxBytesReader(w, r.Body, limit)
|
|
||||||
bodyBytes, err := io.ReadAll(body)
|
|
||||||
|
|
||||||
// Close body
|
|
||||||
if closeErr := body.Close(); closeErr != nil {
|
|
||||||
e.log.Warn(r.Context(), "error while closing request body: %s", closeErr)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return bodyBytes, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) ResponseOK(ctx context.Context, w http.ResponseWriter, requestStart time.Time, url string, bodySize int) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
e.log.Info(ctx, "response ok")
|
|
||||||
recordMetrics(requestStart, url, http.StatusOK, bodySize)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) ResponseWithJSON(ctx context.Context, w http.ResponseWriter, res interface{}, requestStart time.Time, url string, bodySize int) {
|
|
||||||
e.log.Info(ctx, "response ok")
|
|
||||||
body, err := json.Marshal(res)
|
|
||||||
if err != nil {
|
|
||||||
e.log.Error(ctx, "can't marshal response: %s", err)
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Write(body)
|
|
||||||
recordMetrics(requestStart, url, http.StatusOK, bodySize)
|
|
||||||
}
|
|
||||||
|
|
||||||
type response struct {
|
|
||||||
Error string `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) ResponseWithError(ctx context.Context, w http.ResponseWriter, code int, err error, requestStart time.Time, url string, bodySize int) {
|
|
||||||
e.log.Error(ctx, "response error, code: %d, error: %s", code, err)
|
|
||||||
body, err := json.Marshal(&response{err.Error()})
|
|
||||||
if err != nil {
|
|
||||||
e.log.Error(ctx, "can't marshal response: %s", err)
|
|
||||||
}
|
|
||||||
w.WriteHeader(code)
|
|
||||||
w.Write(body)
|
|
||||||
recordMetrics(requestStart, url, code, bodySize)
|
|
||||||
}
|
|
||||||
|
|
@ -1,170 +0,0 @@
|
||||||
package data_integration
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/docker/distribution/context"
|
|
||||||
"github.com/gorilla/mux"
|
|
||||||
|
|
||||||
integration "openreplay/backend/internal/config/integrations"
|
|
||||||
"openreplay/backend/internal/http/util"
|
|
||||||
"openreplay/backend/pkg/logger"
|
|
||||||
limiter "openreplay/backend/pkg/spot/api"
|
|
||||||
"openreplay/backend/pkg/spot/auth"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Router struct {
|
|
||||||
log logger.Logger
|
|
||||||
cfg *integration.Config
|
|
||||||
router *mux.Router
|
|
||||||
services *ServiceBuilder
|
|
||||||
limiter *limiter.UserRateLimiter
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewRouter(cfg *integration.Config, log logger.Logger, services *ServiceBuilder) (*Router, error) {
|
|
||||||
switch {
|
|
||||||
case cfg == nil:
|
|
||||||
return nil, fmt.Errorf("config is empty")
|
|
||||||
case services == nil:
|
|
||||||
return nil, fmt.Errorf("services is empty")
|
|
||||||
case log == nil:
|
|
||||||
return nil, fmt.Errorf("logger is empty")
|
|
||||||
}
|
|
||||||
e := &Router{
|
|
||||||
log: log,
|
|
||||||
cfg: cfg,
|
|
||||||
services: services,
|
|
||||||
limiter: limiter.NewUserRateLimiter(10, 30, 1*time.Minute, 5*time.Minute),
|
|
||||||
}
|
|
||||||
e.init()
|
|
||||||
return e, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) init() {
|
|
||||||
e.router = mux.NewRouter()
|
|
||||||
|
|
||||||
// Root route
|
|
||||||
e.router.HandleFunc("/", e.ping)
|
|
||||||
|
|
||||||
e.router.HandleFunc("/v1/integrations/{name}/{project}", e.createIntegration).Methods("POST", "OPTIONS")
|
|
||||||
e.router.HandleFunc("/v1/integrations/{name}/{project}", e.getIntegration).Methods("GET", "OPTIONS")
|
|
||||||
e.router.HandleFunc("/v1/integrations/{name}/{project}", e.updateIntegration).Methods("PATCH", "OPTIONS")
|
|
||||||
e.router.HandleFunc("/v1/integrations/{name}/{project}", e.deleteIntegration).Methods("DELETE", "OPTIONS")
|
|
||||||
e.router.HandleFunc("/v1/integrations/{name}/{project}/data/{session}", e.getIntegrationData).Methods("GET", "OPTIONS")
|
|
||||||
|
|
||||||
// CORS middleware
|
|
||||||
e.router.Use(e.corsMiddleware)
|
|
||||||
e.router.Use(e.authMiddleware)
|
|
||||||
e.router.Use(e.rateLimitMiddleware)
|
|
||||||
e.router.Use(e.actionMiddleware)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) ping(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) corsMiddleware(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.URL.Path == "/" {
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
}
|
|
||||||
if e.cfg.UseAccessControlHeaders {
|
|
||||||
// Prepare headers for preflight requests
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "POST,GET,PATCH,DELETE")
|
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type,Authorization,Content-Encoding")
|
|
||||||
}
|
|
||||||
if r.Method == http.MethodOptions {
|
|
||||||
w.Header().Set("Cache-Control", "max-age=86400")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
r = r.WithContext(context.WithValues(r.Context(), map[string]interface{}{"httpMethod": r.Method, "url": util.SafeString(r.URL.Path)}))
|
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) authMiddleware(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.URL.Path == "/" {
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the request is authorized
|
|
||||||
user, err := e.services.Auth.IsAuthorized(r.Header.Get("Authorization"), nil, false)
|
|
||||||
if err != nil {
|
|
||||||
e.log.Warn(r.Context(), "Unauthorized request: %s", err)
|
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
r = r.WithContext(context.WithValues(r.Context(), map[string]interface{}{"userData": user}))
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) rateLimitMiddleware(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.URL.Path == "/" {
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
}
|
|
||||||
user := r.Context().Value("userData").(*auth.User)
|
|
||||||
rl := e.limiter.GetRateLimiter(user.ID)
|
|
||||||
|
|
||||||
if !rl.Allow() {
|
|
||||||
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type statusWriter struct {
|
|
||||||
http.ResponseWriter
|
|
||||||
statusCode int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *statusWriter) WriteHeader(statusCode int) {
|
|
||||||
w.statusCode = statusCode
|
|
||||||
w.ResponseWriter.WriteHeader(statusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *statusWriter) Write(b []byte) (int, error) {
|
|
||||||
if w.statusCode == 0 {
|
|
||||||
w.statusCode = http.StatusOK
|
|
||||||
}
|
|
||||||
return w.ResponseWriter.Write(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) actionMiddleware(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.URL.Path == "/" {
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
}
|
|
||||||
// Read body and restore the io.ReadCloser to its original state
|
|
||||||
bodyBytes, err := io.ReadAll(r.Body)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "can't read body", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
|
||||||
// Use custom response writer to get the status code
|
|
||||||
sw := &statusWriter{ResponseWriter: w}
|
|
||||||
// Serve the request
|
|
||||||
next.ServeHTTP(sw, r)
|
|
||||||
e.logRequest(r, bodyBytes, sw.statusCode)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) logRequest(r *http.Request, bodyBytes []byte, statusCode int) {
|
|
||||||
e.log.Info(r.Context(), "Request: %s %s %s %d", r.Method, r.URL.Path, bodyBytes, statusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) GetHandler() http.Handler {
|
|
||||||
return e.router
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package data_integration
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
package http
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
"openreplay/backend/pkg/metrics/common"
|
|
||||||
"strconv"
|
|
||||||
)
|
|
||||||
|
|
||||||
var httpRequestSize = prometheus.NewHistogramVec(
|
|
||||||
prometheus.HistogramOpts{
|
|
||||||
Namespace: "http",
|
|
||||||
Name: "request_size_bytes",
|
|
||||||
Help: "A histogram displaying the size of each HTTP request in bytes.",
|
|
||||||
Buckets: common.DefaultSizeBuckets,
|
|
||||||
},
|
|
||||||
[]string{"url", "response_code"},
|
|
||||||
)
|
|
||||||
|
|
||||||
func RecordRequestSize(size float64, url string, code int) {
|
|
||||||
httpRequestSize.WithLabelValues(url, strconv.Itoa(code)).Observe(size)
|
|
||||||
}
|
|
||||||
|
|
||||||
var httpRequestDuration = prometheus.NewHistogramVec(
|
|
||||||
prometheus.HistogramOpts{
|
|
||||||
Namespace: "http",
|
|
||||||
Name: "request_duration_seconds",
|
|
||||||
Help: "A histogram displaying the duration of each HTTP request in seconds.",
|
|
||||||
Buckets: common.DefaultDurationBuckets,
|
|
||||||
},
|
|
||||||
[]string{"url", "response_code"},
|
|
||||||
)
|
|
||||||
|
|
||||||
func RecordRequestDuration(durMillis float64, url string, code int) {
|
|
||||||
httpRequestDuration.WithLabelValues(url, strconv.Itoa(code)).Observe(durMillis / 1000.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
var httpTotalRequests = prometheus.NewCounter(
|
|
||||||
prometheus.CounterOpts{
|
|
||||||
Namespace: "http",
|
|
||||||
Name: "requests_total",
|
|
||||||
Help: "A counter displaying the number all HTTP requests.",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
func IncreaseTotalRequests() {
|
|
||||||
httpTotalRequests.Inc()
|
|
||||||
}
|
|
||||||
|
|
||||||
func List() []prometheus.Collector {
|
|
||||||
return []prometheus.Collector{
|
|
||||||
httpRequestSize,
|
|
||||||
httpRequestDuration,
|
|
||||||
httpTotalRequests,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +1,11 @@
|
||||||
package spot
|
package spot
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
|
||||||
"openreplay/backend/pkg/metrics/common"
|
"openreplay/backend/pkg/metrics/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
var spotRequestSize = prometheus.NewHistogramVec(
|
|
||||||
prometheus.HistogramOpts{
|
|
||||||
Namespace: "spot",
|
|
||||||
Name: "request_size_bytes",
|
|
||||||
Help: "A histogram displaying the size of each HTTP request in bytes.",
|
|
||||||
Buckets: common.DefaultSizeBuckets,
|
|
||||||
},
|
|
||||||
[]string{"url", "response_code"},
|
|
||||||
)
|
|
||||||
|
|
||||||
func RecordRequestSize(size float64, url string, code int) {
|
|
||||||
spotRequestSize.WithLabelValues(url, strconv.Itoa(code)).Observe(size)
|
|
||||||
}
|
|
||||||
|
|
||||||
var spotRequestDuration = prometheus.NewHistogramVec(
|
|
||||||
prometheus.HistogramOpts{
|
|
||||||
Namespace: "spot",
|
|
||||||
Name: "request_duration_seconds",
|
|
||||||
Help: "A histogram displaying the duration of each HTTP request in seconds.",
|
|
||||||
Buckets: common.DefaultDurationBuckets,
|
|
||||||
},
|
|
||||||
[]string{"url", "response_code"},
|
|
||||||
)
|
|
||||||
|
|
||||||
func RecordRequestDuration(durMillis float64, url string, code int) {
|
|
||||||
spotRequestDuration.WithLabelValues(url, strconv.Itoa(code)).Observe(durMillis / 1000.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
var spotTotalRequests = prometheus.NewCounter(
|
|
||||||
prometheus.CounterOpts{
|
|
||||||
Namespace: "spot",
|
|
||||||
Name: "requests_total",
|
|
||||||
Help: "A counter displaying the number all HTTP requests.",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
func IncreaseTotalRequests() {
|
|
||||||
spotTotalRequests.Inc()
|
|
||||||
}
|
|
||||||
|
|
||||||
var spotOriginalVideoSize = prometheus.NewHistogram(
|
var spotOriginalVideoSize = prometheus.NewHistogram(
|
||||||
prometheus.HistogramOpts{
|
prometheus.HistogramOpts{
|
||||||
Namespace: "spot",
|
Namespace: "spot",
|
||||||
|
|
@ -177,9 +135,6 @@ func RecordTranscodedVideoUploadDuration(durMillis float64) {
|
||||||
|
|
||||||
func List() []prometheus.Collector {
|
func List() []prometheus.Collector {
|
||||||
return []prometheus.Collector{
|
return []prometheus.Collector{
|
||||||
spotRequestSize,
|
|
||||||
spotRequestDuration,
|
|
||||||
spotTotalRequests,
|
|
||||||
spotOriginalVideoSize,
|
spotOriginalVideoSize,
|
||||||
spotCroppedVideoSize,
|
spotCroppedVideoSize,
|
||||||
spotVideosTotal,
|
spotVideosTotal,
|
||||||
|
|
|
||||||
84
backend/pkg/metrics/web/metrics.go
Normal file
84
backend/pkg/metrics/web/metrics.go
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
|
||||||
|
"openreplay/backend/pkg/metrics/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Web interface {
|
||||||
|
RecordRequestSize(size float64, url string, code int)
|
||||||
|
RecordRequestDuration(durMillis float64, url string, code int)
|
||||||
|
IncreaseTotalRequests()
|
||||||
|
List() []prometheus.Collector
|
||||||
|
}
|
||||||
|
|
||||||
|
type webImpl struct {
|
||||||
|
httpRequestSize *prometheus.HistogramVec
|
||||||
|
httpRequestDuration *prometheus.HistogramVec
|
||||||
|
httpTotalRequests prometheus.Counter
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(serviceName string) Web {
|
||||||
|
return &webImpl{
|
||||||
|
httpRequestSize: newRequestSizeMetric(serviceName),
|
||||||
|
httpRequestDuration: newRequestDurationMetric(serviceName),
|
||||||
|
httpTotalRequests: newTotalRequestsMetric(serviceName),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *webImpl) List() []prometheus.Collector {
|
||||||
|
return []prometheus.Collector{
|
||||||
|
w.httpRequestSize,
|
||||||
|
w.httpRequestDuration,
|
||||||
|
w.httpTotalRequests,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRequestSizeMetric(serviceName string) *prometheus.HistogramVec {
|
||||||
|
return prometheus.NewHistogramVec(
|
||||||
|
prometheus.HistogramOpts{
|
||||||
|
Namespace: serviceName,
|
||||||
|
Name: "request_size_bytes",
|
||||||
|
Help: "A histogram displaying the size of each HTTP request in bytes.",
|
||||||
|
Buckets: common.DefaultSizeBuckets,
|
||||||
|
},
|
||||||
|
[]string{"url", "response_code"},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *webImpl) RecordRequestSize(size float64, url string, code int) {
|
||||||
|
w.httpRequestSize.WithLabelValues(url, strconv.Itoa(code)).Observe(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRequestDurationMetric(serviceName string) *prometheus.HistogramVec {
|
||||||
|
return prometheus.NewHistogramVec(
|
||||||
|
prometheus.HistogramOpts{
|
||||||
|
Namespace: serviceName,
|
||||||
|
Name: "request_duration_seconds",
|
||||||
|
Help: "A histogram displaying the duration of each HTTP request in seconds.",
|
||||||
|
Buckets: common.DefaultDurationBuckets,
|
||||||
|
},
|
||||||
|
[]string{"url", "response_code"},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *webImpl) RecordRequestDuration(durMillis float64, url string, code int) {
|
||||||
|
w.httpRequestDuration.WithLabelValues(url, strconv.Itoa(code)).Observe(durMillis / 1000.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTotalRequestsMetric(serviceName string) prometheus.Counter {
|
||||||
|
return prometheus.NewCounter(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Namespace: serviceName,
|
||||||
|
Name: "requests_total",
|
||||||
|
Help: "A counter displaying the number all HTTP requests.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *webImpl) IncreaseTotalRequests() {
|
||||||
|
w.httpTotalRequests.Inc()
|
||||||
|
}
|
||||||
59
backend/pkg/server/api/body-reader.go
Normal file
59
backend/pkg/server/api/body-reader.go
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/klauspost/compress/gzip"
|
||||||
|
|
||||||
|
"openreplay/backend/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ReadBody(log logger.Logger, w http.ResponseWriter, r *http.Request, limit int64) ([]byte, error) {
|
||||||
|
body := http.MaxBytesReader(w, r.Body, limit)
|
||||||
|
bodyBytes, err := io.ReadAll(body)
|
||||||
|
|
||||||
|
// Close body
|
||||||
|
if closeErr := body.Close(); closeErr != nil {
|
||||||
|
log.Warn(r.Context(), "error while closing request body: %s", closeErr)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return bodyBytes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadCompressedBody(log logger.Logger, w http.ResponseWriter, r *http.Request, limit int64) ([]byte, error) {
|
||||||
|
body := http.MaxBytesReader(w, r.Body, limit)
|
||||||
|
var (
|
||||||
|
bodyBytes []byte
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check if body is gzipped and decompress it
|
||||||
|
if r.Header.Get("Content-Encoding") == "gzip" {
|
||||||
|
reader, err := gzip.NewReader(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("can't create gzip reader: %s", err)
|
||||||
|
}
|
||||||
|
bodyBytes, err = io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("can't read gzip body: %s", err)
|
||||||
|
}
|
||||||
|
if err := reader.Close(); err != nil {
|
||||||
|
log.Warn(r.Context(), "can't close gzip reader: %s", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bodyBytes, err = io.ReadAll(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close body
|
||||||
|
if closeErr := body.Close(); closeErr != nil {
|
||||||
|
log.Warn(r.Context(), "error while closing request body: %s", closeErr)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return bodyBytes, nil
|
||||||
|
}
|
||||||
13
backend/pkg/server/api/handlers.go
Normal file
13
backend/pkg/server/api/handlers.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
type Description struct {
|
||||||
|
Path string
|
||||||
|
Handler http.HandlerFunc
|
||||||
|
Method string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Handlers interface {
|
||||||
|
GetAll() []*Description
|
||||||
|
}
|
||||||
41
backend/pkg/server/api/middleware.go
Normal file
41
backend/pkg/server/api/middleware.go
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
ctxStore "github.com/docker/distribution/context"
|
||||||
|
"openreplay/backend/internal/http/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (e *routerImpl) health(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *routerImpl) healthMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/" {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *routerImpl) corsMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if e.cfg.UseAccessControlHeaders {
|
||||||
|
// Prepare headers for preflight requests
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "POST,GET,PATCH,DELETE")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type,Authorization,Content-Encoding")
|
||||||
|
}
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.Header().Set("Cache-Control", "max-age=86400")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r = r.WithContext(ctxStore.WithValues(r.Context(), map[string]interface{}{"httpMethod": r.Method, "url": util.SafeString(r.URL.Path)}))
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
61
backend/pkg/server/api/responser.go
Normal file
61
backend/pkg/server/api/responser.go
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"openreplay/backend/pkg/logger"
|
||||||
|
"openreplay/backend/pkg/metrics/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Responser struct {
|
||||||
|
metrics web.Web
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewResponser(webMetrics web.Web) *Responser {
|
||||||
|
return &Responser{
|
||||||
|
metrics: webMetrics,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type response struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Responser) ResponseOK(log logger.Logger, ctx context.Context, w http.ResponseWriter, requestStart time.Time, url string, bodySize int) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
log.Info(ctx, "response ok")
|
||||||
|
r.recordMetrics(requestStart, url, http.StatusOK, bodySize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Responser) ResponseWithJSON(log logger.Logger, ctx context.Context, w http.ResponseWriter, res interface{}, requestStart time.Time, url string, bodySize int) {
|
||||||
|
log.Info(ctx, "response ok")
|
||||||
|
body, err := json.Marshal(res)
|
||||||
|
if err != nil {
|
||||||
|
log.Error(ctx, "can't marshal response: %s", err)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write(body)
|
||||||
|
r.recordMetrics(requestStart, url, http.StatusOK, bodySize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Responser) ResponseWithError(log logger.Logger, ctx context.Context, w http.ResponseWriter, code int, err error, requestStart time.Time, url string, bodySize int) {
|
||||||
|
log.Error(ctx, "response error, code: %d, error: %s", code, err)
|
||||||
|
body, err := json.Marshal(&response{err.Error()})
|
||||||
|
if err != nil {
|
||||||
|
log.Error(ctx, "can't marshal response: %s", err)
|
||||||
|
}
|
||||||
|
w.WriteHeader(code)
|
||||||
|
w.Write(body)
|
||||||
|
r.recordMetrics(requestStart, url, code, bodySize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Responser) recordMetrics(requestStart time.Time, url string, code, bodySize int) {
|
||||||
|
if bodySize > 0 {
|
||||||
|
r.metrics.RecordRequestSize(float64(bodySize), url, code)
|
||||||
|
}
|
||||||
|
r.metrics.IncreaseTotalRequests()
|
||||||
|
r.metrics.RecordRequestDuration(float64(time.Now().Sub(requestStart).Milliseconds()), url, code)
|
||||||
|
}
|
||||||
69
backend/pkg/server/api/router.go
Normal file
69
backend/pkg/server/api/router.go
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
|
||||||
|
"openreplay/backend/internal/config/common"
|
||||||
|
"openreplay/backend/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Router interface {
|
||||||
|
AddHandlers(prefix string, handlers ...Handlers)
|
||||||
|
AddMiddlewares(middlewares ...func(http.Handler) http.Handler)
|
||||||
|
Get() http.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
type routerImpl struct {
|
||||||
|
log logger.Logger
|
||||||
|
cfg *common.HTTP
|
||||||
|
router *mux.Router
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRouter(cfg *common.HTTP, log logger.Logger) (Router, error) {
|
||||||
|
switch {
|
||||||
|
case cfg == nil:
|
||||||
|
return nil, fmt.Errorf("config is empty")
|
||||||
|
case log == nil:
|
||||||
|
return nil, fmt.Errorf("logger is empty")
|
||||||
|
}
|
||||||
|
e := &routerImpl{
|
||||||
|
log: log,
|
||||||
|
cfg: cfg,
|
||||||
|
router: mux.NewRouter(),
|
||||||
|
}
|
||||||
|
e.initRouter()
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *routerImpl) initRouter() {
|
||||||
|
e.router.HandleFunc("/", e.health)
|
||||||
|
// Default middlewares
|
||||||
|
e.router.Use(e.healthMiddleware)
|
||||||
|
e.router.Use(e.corsMiddleware)
|
||||||
|
}
|
||||||
|
|
||||||
|
const NoPrefix = ""
|
||||||
|
|
||||||
|
func (e *routerImpl) AddHandlers(prefix string, handlers ...Handlers) {
|
||||||
|
for _, handlersSet := range handlers {
|
||||||
|
for _, handler := range handlersSet.GetAll() {
|
||||||
|
e.router.HandleFunc(handler.Path, handler.Handler).Methods(handler.Method, "OPTIONS")
|
||||||
|
if prefix != NoPrefix {
|
||||||
|
e.router.HandleFunc(prefix+handler.Path, handler.Handler).Methods(handler.Method, "OPTIONS")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *routerImpl) AddMiddlewares(middlewares ...func(http.Handler) http.Handler) {
|
||||||
|
for _, middleware := range middlewares {
|
||||||
|
e.router.Use(middleware)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *routerImpl) Get() http.Handler {
|
||||||
|
return e.router
|
||||||
|
}
|
||||||
|
|
@ -2,16 +2,20 @@ package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
|
||||||
"openreplay/backend/pkg/db/postgres/pool"
|
"openreplay/backend/pkg/db/postgres/pool"
|
||||||
"openreplay/backend/pkg/logger"
|
"openreplay/backend/pkg/logger"
|
||||||
|
"openreplay/backend/pkg/server/keys"
|
||||||
|
"openreplay/backend/pkg/server/user"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Auth interface {
|
type Auth interface {
|
||||||
IsAuthorized(authHeader string, permissions []string, isExtension bool) (*User, error)
|
IsAuthorized(authHeader string, permissions []string, isExtension bool) (*user.User, error)
|
||||||
|
Middleware(next http.Handler) http.Handler
|
||||||
}
|
}
|
||||||
|
|
||||||
type authImpl struct {
|
type authImpl struct {
|
||||||
|
|
@ -19,18 +23,20 @@ type authImpl struct {
|
||||||
secret string
|
secret string
|
||||||
spotSecret string
|
spotSecret string
|
||||||
pgconn pool.Pool
|
pgconn pool.Pool
|
||||||
|
keys keys.Keys
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuth(log logger.Logger, jwtSecret, jwtSpotSecret string, conn pool.Pool) Auth {
|
func NewAuth(log logger.Logger, jwtSecret, jwtSpotSecret string, conn pool.Pool, keys keys.Keys) Auth {
|
||||||
return &authImpl{
|
return &authImpl{
|
||||||
log: log,
|
log: log,
|
||||||
secret: jwtSecret,
|
secret: jwtSecret,
|
||||||
spotSecret: jwtSpotSecret,
|
spotSecret: jwtSpotSecret,
|
||||||
pgconn: conn,
|
pgconn: conn,
|
||||||
|
keys: keys,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseJWT(authHeader, secret string) (*JWTClaims, error) {
|
func parseJWT(authHeader, secret string) (*user.JWTClaims, error) {
|
||||||
if authHeader == "" {
|
if authHeader == "" {
|
||||||
return nil, fmt.Errorf("authorization header missing")
|
return nil, fmt.Errorf("authorization header missing")
|
||||||
}
|
}
|
||||||
|
|
@ -40,7 +46,7 @@ func parseJWT(authHeader, secret string) (*JWTClaims, error) {
|
||||||
}
|
}
|
||||||
tokenString := tokenParts[1]
|
tokenString := tokenParts[1]
|
||||||
|
|
||||||
claims := &JWTClaims{}
|
claims := &user.JWTClaims{}
|
||||||
token, err := jwt.ParseWithClaims(tokenString, claims,
|
token, err := jwt.ParseWithClaims(tokenString, claims,
|
||||||
func(token *jwt.Token) (interface{}, error) {
|
func(token *jwt.Token) (interface{}, error) {
|
||||||
return []byte(secret), nil
|
return []byte(secret), nil
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
func (a *authImpl) IsAuthorized(authHeader string, permissions []string, isExtension bool) (*User, error) {
|
import "openreplay/backend/pkg/server/user"
|
||||||
|
|
||||||
|
func (a *authImpl) IsAuthorized(authHeader string, permissions []string, isExtension bool) (*user.User, error) {
|
||||||
secret := a.secret
|
secret := a.secret
|
||||||
if isExtension {
|
if isExtension {
|
||||||
secret = a.spotSecret
|
secret = a.spotSecret
|
||||||
65
backend/pkg/server/auth/middleware.go
Normal file
65
backend/pkg/server/auth/middleware.go
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
|
||||||
|
ctxStore "github.com/docker/distribution/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (e *authImpl) Middleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, err := e.IsAuthorized(r.Header.Get("Authorization"), getPermissions(r.URL.Path), e.isExtensionRequest(r))
|
||||||
|
if err != nil {
|
||||||
|
if !e.isSpotWithKeyRequest(r) {
|
||||||
|
e.log.Warn(r.Context(), "Unauthorized request, wrong jwt token: %s", err)
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err = e.keys.IsValid(r.URL.Query().Get("key"))
|
||||||
|
if err != nil {
|
||||||
|
e.log.Warn(r.Context(), "Unauthorized request, wrong public key: %s", err)
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r = r.WithContext(ctxStore.WithValues(r.Context(), map[string]interface{}{"userData": user}))
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *authImpl) isExtensionRequest(r *http.Request) bool {
|
||||||
|
pathTemplate, err := mux.CurrentRoute(r).GetPathTemplate()
|
||||||
|
if err != nil {
|
||||||
|
e.log.Error(r.Context(), "failed to get path template: %s", err)
|
||||||
|
} else {
|
||||||
|
if pathTemplate == "/v1/ping" ||
|
||||||
|
(pathTemplate == "/v1/spots" && r.Method == "POST") ||
|
||||||
|
(pathTemplate == "/v1/spots/{id}/uploaded" && r.Method == "POST") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *authImpl) isSpotWithKeyRequest(r *http.Request) bool {
|
||||||
|
if e.keys == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
pathTemplate, err := mux.CurrentRoute(r).GetPathTemplate()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
getSpotPrefix := "/v1/spots/{id}" // GET
|
||||||
|
addCommentPrefix := "/v1/spots/{id}/comment" // POST
|
||||||
|
getStatusPrefix := "/v1/spots/{id}/status" // GET
|
||||||
|
if (pathTemplate == getSpotPrefix && r.Method == "GET") ||
|
||||||
|
(pathTemplate == addCommentPrefix && r.Method == "POST") ||
|
||||||
|
(pathTemplate == getStatusPrefix && r.Method == "GET") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package api
|
package auth
|
||||||
|
|
||||||
func getPermissions(urlPath string) []string {
|
func getPermissions(urlPath string) []string {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -3,10 +3,11 @@ package auth
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"openreplay/backend/pkg/db/postgres/pool"
|
"openreplay/backend/pkg/db/postgres/pool"
|
||||||
|
"openreplay/backend/pkg/server/user"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func authUser(conn pool.Pool, userID, tenantID, jwtIAT int, isExtension bool) (*User, error) {
|
func authUser(conn pool.Pool, userID, tenantID, jwtIAT int, isExtension bool) (*user.User, error) {
|
||||||
sql := `
|
sql := `
|
||||||
SELECT user_id, name, email, EXTRACT(epoch FROM spot_jwt_iat)::BIGINT AS spot_jwt_iat
|
SELECT user_id, name, email, EXTRACT(epoch FROM spot_jwt_iat)::BIGINT AS spot_jwt_iat
|
||||||
FROM public.users
|
FROM public.users
|
||||||
|
|
@ -15,12 +16,19 @@ func authUser(conn pool.Pool, userID, tenantID, jwtIAT int, isExtension bool) (*
|
||||||
if !isExtension {
|
if !isExtension {
|
||||||
sql = strings.ReplaceAll(sql, "spot_jwt_iat", "jwt_iat")
|
sql = strings.ReplaceAll(sql, "spot_jwt_iat", "jwt_iat")
|
||||||
}
|
}
|
||||||
user := &User{TenantID: 1, AuthMethod: "jwt"}
|
newUser := &user.User{TenantID: 1, AuthMethod: "jwt"}
|
||||||
if err := conn.QueryRow(sql, userID).Scan(&user.ID, &user.Name, &user.Email, &user.JwtIat); err != nil {
|
if err := conn.QueryRow(sql, userID).Scan(&newUser.ID, &newUser.Name, &newUser.Email, &newUser.JwtIat); err != nil {
|
||||||
return nil, fmt.Errorf("user not found")
|
return nil, fmt.Errorf("user not found")
|
||||||
}
|
}
|
||||||
if user.JwtIat == 0 || abs(jwtIAT-user.JwtIat) > 1 {
|
if newUser.JwtIat == 0 || abs(jwtIAT-newUser.JwtIat) > 1 {
|
||||||
return nil, fmt.Errorf("token has been updated")
|
return nil, fmt.Errorf("token has been updated")
|
||||||
}
|
}
|
||||||
return user, nil
|
return newUser, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func abs(x int) int {
|
||||||
|
if x < 0 {
|
||||||
|
return -x
|
||||||
|
}
|
||||||
|
return x
|
||||||
}
|
}
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
package service
|
package keys
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"openreplay/backend/pkg/server/user"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/rs/xid"
|
"github.com/rs/xid"
|
||||||
|
|
||||||
"openreplay/backend/pkg/db/postgres/pool"
|
"openreplay/backend/pkg/db/postgres/pool"
|
||||||
"openreplay/backend/pkg/logger"
|
"openreplay/backend/pkg/logger"
|
||||||
"openreplay/backend/pkg/spot/auth"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Key struct {
|
type Key struct {
|
||||||
|
|
@ -22,9 +22,9 @@ type Key struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Keys interface {
|
type Keys interface {
|
||||||
Set(spotID, expiration uint64, user *auth.User) (*Key, error)
|
Set(spotID, expiration uint64, user *user.User) (*Key, error)
|
||||||
Get(spotID uint64, user *auth.User) (*Key, error)
|
Get(spotID uint64, user *user.User) (*Key, error)
|
||||||
IsValid(key string) (*auth.User, error)
|
IsValid(key string) (*user.User, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type keysImpl struct {
|
type keysImpl struct {
|
||||||
|
|
@ -32,7 +32,7 @@ type keysImpl struct {
|
||||||
conn pool.Pool
|
conn pool.Pool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *keysImpl) Set(spotID, expiration uint64, user *auth.User) (*Key, error) {
|
func (k *keysImpl) Set(spotID, expiration uint64, user *user.User) (*Key, error) {
|
||||||
switch {
|
switch {
|
||||||
case spotID == 0:
|
case spotID == 0:
|
||||||
return nil, fmt.Errorf("spotID is required")
|
return nil, fmt.Errorf("spotID is required")
|
||||||
|
|
@ -89,7 +89,7 @@ func (k *keysImpl) Set(spotID, expiration uint64, user *auth.User) (*Key, error)
|
||||||
return key, nil
|
return key, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *keysImpl) Get(spotID uint64, user *auth.User) (*Key, error) {
|
func (k *keysImpl) Get(spotID uint64, user *user.User) (*Key, error) {
|
||||||
switch {
|
switch {
|
||||||
case spotID == 0:
|
case spotID == 0:
|
||||||
return nil, fmt.Errorf("spotID is required")
|
return nil, fmt.Errorf("spotID is required")
|
||||||
|
|
@ -114,7 +114,7 @@ func (k *keysImpl) Get(spotID uint64, user *auth.User) (*Key, error) {
|
||||||
return key, nil
|
return key, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *keysImpl) IsValid(key string) (*auth.User, error) {
|
func (k *keysImpl) IsValid(key string) (*user.User, error) {
|
||||||
if key == "" {
|
if key == "" {
|
||||||
return nil, fmt.Errorf("key is required")
|
return nil, fmt.Errorf("key is required")
|
||||||
}
|
}
|
||||||
|
|
@ -133,7 +133,7 @@ func (k *keysImpl) IsValid(key string) (*auth.User, error) {
|
||||||
return nil, fmt.Errorf("key is expired")
|
return nil, fmt.Errorf("key is expired")
|
||||||
}
|
}
|
||||||
// Get user info by userID
|
// Get user info by userID
|
||||||
user := &auth.User{ID: userID, AuthMethod: "public-key"}
|
user := &user.User{ID: userID, AuthMethod: "public-key"}
|
||||||
// We don't need tenantID here
|
// We don't need tenantID here
|
||||||
if err := k.conn.QueryRow(getUserSQL, userID).Scan(&user.TenantID, &user.Name, &user.Email); err != nil {
|
if err := k.conn.QueryRow(getUserSQL, userID).Scan(&user.TenantID, &user.Name, &user.Email); err != nil {
|
||||||
k.log.Error(context.Background(), "failed to get user: %v", err)
|
k.log.Error(context.Background(), "failed to get user: %v", err)
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
package service
|
package keys
|
||||||
|
|
||||||
var getUserSQL = `SELECT 1, name, email FROM public.users WHERE user_id = $1 AND deleted_at IS NULL LIMIT 1`
|
var getUserSQL = `SELECT 1, name, email FROM public.users WHERE user_id = $1 AND deleted_at IS NULL LIMIT 1`
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package api
|
package limiter
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sync"
|
"sync"
|
||||||
24
backend/pkg/server/limiter/middleware.go
Normal file
24
backend/pkg/server/limiter/middleware.go
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
package limiter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"openreplay/backend/pkg/server/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (rl *UserRateLimiter) Middleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userContext := r.Context().Value("userData")
|
||||||
|
if userContext == nil {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
authUser := userContext.(*user.User)
|
||||||
|
rl := rl.GetRateLimiter(authUser.ID)
|
||||||
|
|
||||||
|
if !rl.Allow() {
|
||||||
|
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
75
backend/pkg/server/server.go
Normal file
75
backend/pkg/server/server.go
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/net/http2"
|
||||||
|
|
||||||
|
"openreplay/backend/internal/config/common"
|
||||||
|
"openreplay/backend/pkg/logger"
|
||||||
|
"openreplay/backend/pkg/server/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
server *http.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(handler http.Handler, host, port string, timeout time.Duration) (*Server, error) {
|
||||||
|
switch {
|
||||||
|
case port == "":
|
||||||
|
return nil, errors.New("empty server port")
|
||||||
|
case handler == nil:
|
||||||
|
return nil, errors.New("empty handler")
|
||||||
|
case timeout < 1:
|
||||||
|
return nil, fmt.Errorf("invalid timeout %d", timeout)
|
||||||
|
}
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: fmt.Sprintf("%s:%s", host, port),
|
||||||
|
Handler: handler,
|
||||||
|
ReadTimeout: timeout,
|
||||||
|
WriteTimeout: timeout,
|
||||||
|
}
|
||||||
|
if err := http2.ConfigureServer(server, nil); err != nil {
|
||||||
|
return nil, fmt.Errorf("error configuring server: %s", err)
|
||||||
|
}
|
||||||
|
return &Server{
|
||||||
|
server: server,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Start() error {
|
||||||
|
return s.server.ListenAndServe()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Stop() {
|
||||||
|
if err := s.server.Shutdown(context.Background()); err != nil {
|
||||||
|
fmt.Printf("error shutting down server: %s\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Run(ctx context.Context, log logger.Logger, cfg *common.HTTP, router api.Router) {
|
||||||
|
webServer, err := New(router.Get(), cfg.HTTPHost, cfg.HTTPPort, cfg.HTTPTimeout)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(ctx, "failed while creating server: %s", err)
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
if err := webServer.Start(); err != nil {
|
||||||
|
log.Fatal(ctx, "http server error: %s", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
log.Info(ctx, "server successfully started on port %s", cfg.HTTPPort)
|
||||||
|
|
||||||
|
// Wait stop signal to shut down server gracefully
|
||||||
|
sigchan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigchan, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-sigchan
|
||||||
|
log.Info(ctx, "shutting down the server")
|
||||||
|
webServer.Stop()
|
||||||
|
}
|
||||||
11
backend/pkg/server/tracer/middleware.go
Normal file
11
backend/pkg/server/tracer/middleware.go
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
package tracer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (t *tracerImpl) Middleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
23
backend/pkg/server/tracer/tracer.go
Normal file
23
backend/pkg/server/tracer/tracer.go
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
package tracer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
db "openreplay/backend/pkg/db/postgres/pool"
|
||||||
|
"openreplay/backend/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Tracer interface {
|
||||||
|
Middleware(next http.Handler) http.Handler
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
type tracerImpl struct{}
|
||||||
|
|
||||||
|
func NewTracer(log logger.Logger, conn db.Pool) (Tracer, error) {
|
||||||
|
return &tracerImpl{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tracerImpl) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package auth
|
package user
|
||||||
|
|
||||||
import "github.com/golang-jwt/jwt/v5"
|
import "github.com/golang-jwt/jwt/v5"
|
||||||
|
|
||||||
|
|
@ -25,10 +25,3 @@ func (u *User) HasPermission(perm string) bool {
|
||||||
_, ok := u.Permissions[perm]
|
_, ok := u.Permissions[perm]
|
||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
func abs(x int) int {
|
|
||||||
if x < 0 {
|
|
||||||
return -x
|
|
||||||
}
|
|
||||||
return x
|
|
||||||
}
|
|
||||||
63
backend/pkg/sessions/api/beacon-cache.go
Normal file
63
backend/pkg/sessions/api/beacon-cache.go
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BeaconSize struct {
|
||||||
|
size int64
|
||||||
|
time time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type BeaconCache struct {
|
||||||
|
mutex *sync.RWMutex
|
||||||
|
beaconSizeCache map[uint64]*BeaconSize
|
||||||
|
defaultLimit int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBeaconCache(limit int64) *BeaconCache {
|
||||||
|
cache := &BeaconCache{
|
||||||
|
mutex: &sync.RWMutex{},
|
||||||
|
beaconSizeCache: make(map[uint64]*BeaconSize),
|
||||||
|
defaultLimit: limit,
|
||||||
|
}
|
||||||
|
go cache.cleaner()
|
||||||
|
return cache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *BeaconCache) Add(sessionID uint64, size int64) {
|
||||||
|
if size <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.mutex.Lock()
|
||||||
|
defer e.mutex.Unlock()
|
||||||
|
e.beaconSizeCache[sessionID] = &BeaconSize{
|
||||||
|
size: size,
|
||||||
|
time: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *BeaconCache) Get(sessionID uint64) int64 {
|
||||||
|
e.mutex.RLock()
|
||||||
|
defer e.mutex.RUnlock()
|
||||||
|
if beaconSize, ok := e.beaconSizeCache[sessionID]; ok {
|
||||||
|
beaconSize.time = time.Now()
|
||||||
|
return beaconSize.size
|
||||||
|
}
|
||||||
|
return e.defaultLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *BeaconCache) cleaner() {
|
||||||
|
for {
|
||||||
|
time.Sleep(time.Minute * 2)
|
||||||
|
now := time.Now()
|
||||||
|
e.mutex.Lock()
|
||||||
|
for sid, bs := range e.beaconSizeCache {
|
||||||
|
if now.Sub(bs.time) > time.Minute*3 {
|
||||||
|
delete(e.beaconSizeCache, sid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.mutex.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
379
backend/pkg/sessions/api/mobile/handlers.go
Normal file
379
backend/pkg/sessions/api/mobile/handlers.go
Normal file
|
|
@ -0,0 +1,379 @@
|
||||||
|
package mobile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Masterminds/semver"
|
||||||
|
gzip "github.com/klauspost/pgzip"
|
||||||
|
|
||||||
|
httpCfg "openreplay/backend/internal/config/http"
|
||||||
|
"openreplay/backend/internal/http/geoip"
|
||||||
|
"openreplay/backend/internal/http/ios"
|
||||||
|
"openreplay/backend/internal/http/uaparser"
|
||||||
|
"openreplay/backend/internal/http/uuid"
|
||||||
|
"openreplay/backend/pkg/conditions"
|
||||||
|
"openreplay/backend/pkg/db/postgres"
|
||||||
|
"openreplay/backend/pkg/flakeid"
|
||||||
|
"openreplay/backend/pkg/logger"
|
||||||
|
"openreplay/backend/pkg/messages"
|
||||||
|
"openreplay/backend/pkg/projects"
|
||||||
|
"openreplay/backend/pkg/queue/types"
|
||||||
|
"openreplay/backend/pkg/server/api"
|
||||||
|
"openreplay/backend/pkg/sessions"
|
||||||
|
"openreplay/backend/pkg/token"
|
||||||
|
)
|
||||||
|
|
||||||
|
func checkMobileTrackerVersion(ver string) bool {
|
||||||
|
c, err := semver.NewConstraint(">=1.0.9")
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Check for beta version
|
||||||
|
parts := strings.Split(ver, "-")
|
||||||
|
if len(parts) > 1 {
|
||||||
|
ver = parts[0]
|
||||||
|
}
|
||||||
|
v, err := semver.NewVersion(ver)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return c.Check(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
type handlersImpl struct {
|
||||||
|
log logger.Logger
|
||||||
|
cfg *httpCfg.Config
|
||||||
|
responser *api.Responser
|
||||||
|
producer types.Producer
|
||||||
|
projects projects.Projects
|
||||||
|
sessions sessions.Sessions
|
||||||
|
uaParser *uaparser.UAParser
|
||||||
|
geoIP geoip.GeoParser
|
||||||
|
tokenizer *token.Tokenizer
|
||||||
|
conditions conditions.Conditions
|
||||||
|
flaker *flakeid.Flaker
|
||||||
|
features map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandlers(cfg *httpCfg.Config, log logger.Logger, responser *api.Responser, producer types.Producer, projects projects.Projects,
|
||||||
|
sessions sessions.Sessions, uaParser *uaparser.UAParser, geoIP geoip.GeoParser, tokenizer *token.Tokenizer,
|
||||||
|
conditions conditions.Conditions, flaker *flakeid.Flaker) (api.Handlers, error) {
|
||||||
|
return &handlersImpl{
|
||||||
|
log: log,
|
||||||
|
cfg: cfg,
|
||||||
|
responser: responser,
|
||||||
|
producer: producer,
|
||||||
|
projects: projects,
|
||||||
|
sessions: sessions,
|
||||||
|
uaParser: uaParser,
|
||||||
|
geoIP: geoIP,
|
||||||
|
tokenizer: tokenizer,
|
||||||
|
conditions: conditions,
|
||||||
|
flaker: flaker,
|
||||||
|
features: map[string]bool{
|
||||||
|
"feature-flags": cfg.IsFeatureFlagEnabled,
|
||||||
|
"usability-test": cfg.IsUsabilityTestEnabled,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *handlersImpl) GetAll() []*api.Description {
|
||||||
|
return []*api.Description{
|
||||||
|
{"/v1/mobile/start", e.startMobileSessionHandler, "POST"},
|
||||||
|
{"/v1/mobile/i", e.pushMobileMessagesHandler, "POST"},
|
||||||
|
{"/v1/mobile/late", e.pushMobileLateMessagesHandler, "POST"},
|
||||||
|
{"/v1/mobile/images", e.mobileImagesUploadHandler, "POST"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *handlersImpl) startMobileSessionHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
if r.Body == nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, errors.New("request body is empty"), startTime, r.URL.Path, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body := http.MaxBytesReader(w, r.Body, e.cfg.JsonSizeLimit)
|
||||||
|
defer body.Close()
|
||||||
|
|
||||||
|
req := &StartMobileSessionRequest{}
|
||||||
|
if err := json.NewDecoder(body).Decode(req); err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tracker version to context
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), "tracker", req.TrackerVersion))
|
||||||
|
|
||||||
|
if req.ProjectKey == nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusForbidden, errors.New("projectKey value required"), startTime, r.URL.Path, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
p, err := e.projects.GetProjectByKey(*req.ProjectKey)
|
||||||
|
if err != nil {
|
||||||
|
if postgres.IsNoRowsErr(err) {
|
||||||
|
logErr := fmt.Errorf("project doesn't exist or is not active, key: %s", *req.ProjectKey)
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusNotFound, logErr, startTime, r.URL.Path, 0)
|
||||||
|
} else {
|
||||||
|
e.log.Error(r.Context(), "failed to get project by key: %s, err: %s", *req.ProjectKey, err)
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, errors.New("can't find a project"), startTime, r.URL.Path, 0)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add projectID to context
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), "projectID", fmt.Sprintf("%d", p.ProjectID)))
|
||||||
|
|
||||||
|
// Check if the project supports mobile sessions
|
||||||
|
if !p.IsMobile() {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusForbidden, errors.New("project doesn't support mobile sessions"), startTime, r.URL.Path, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !checkMobileTrackerVersion(req.TrackerVersion) {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusUpgradeRequired, errors.New("tracker version not supported"), startTime, r.URL.Path, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userUUID := uuid.GetUUID(req.UserUUID)
|
||||||
|
tokenData, err := e.tokenizer.Parse(req.Token)
|
||||||
|
|
||||||
|
if err != nil { // Starting the new one
|
||||||
|
dice := byte(rand.Intn(100)) // [0, 100)
|
||||||
|
// Use condition rate if it's set
|
||||||
|
if req.Condition != "" {
|
||||||
|
rate, err := e.conditions.GetRate(p.ProjectID, req.Condition, int(p.SampleRate))
|
||||||
|
if err != nil {
|
||||||
|
e.log.Warn(r.Context(), "can't get condition rate, condition: %s, err: %s", req.Condition, err)
|
||||||
|
} else {
|
||||||
|
p.SampleRate = byte(rate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dice >= p.SampleRate {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusForbidden, fmt.Errorf("capture rate miss, rate: %d", p.SampleRate), startTime, r.URL.Path, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ua := e.uaParser.ParseFromHTTPRequest(r)
|
||||||
|
if ua == nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusForbidden, fmt.Errorf("browser not recognized, user-agent: %s", r.Header.Get("User-Agent")), startTime, r.URL.Path, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sessionID, err := e.flaker.Compose(uint64(startTime.UnixMilli()))
|
||||||
|
if err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
expTime := startTime.Add(time.Duration(p.MaxSessionDuration) * time.Millisecond)
|
||||||
|
tokenData = &token.TokenData{sessionID, 0, expTime.UnixMilli()}
|
||||||
|
|
||||||
|
// Add sessionID to context
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), "sessionID", fmt.Sprintf("%d", sessionID)))
|
||||||
|
|
||||||
|
geoInfo := e.geoIP.ExtractGeoData(r)
|
||||||
|
deviceType, platform, os := ios.GetIOSDeviceType(req.UserDevice), "ios", "IOS"
|
||||||
|
if req.Platform != "" && req.Platform != "ios" {
|
||||||
|
deviceType = req.UserDeviceType
|
||||||
|
platform = req.Platform
|
||||||
|
os = "Android"
|
||||||
|
}
|
||||||
|
|
||||||
|
if !req.DoNotRecord {
|
||||||
|
if err := e.sessions.Add(&sessions.Session{
|
||||||
|
SessionID: sessionID,
|
||||||
|
Platform: platform,
|
||||||
|
Timestamp: req.Timestamp,
|
||||||
|
Timezone: req.Timezone,
|
||||||
|
ProjectID: p.ProjectID,
|
||||||
|
TrackerVersion: req.TrackerVersion,
|
||||||
|
RevID: req.RevID,
|
||||||
|
UserUUID: userUUID,
|
||||||
|
UserOS: os,
|
||||||
|
UserOSVersion: req.UserOSVersion,
|
||||||
|
UserDevice: ios.MapIOSDevice(req.UserDevice),
|
||||||
|
UserDeviceType: deviceType,
|
||||||
|
UserCountry: geoInfo.Country,
|
||||||
|
UserState: geoInfo.State,
|
||||||
|
UserCity: geoInfo.City,
|
||||||
|
UserDeviceMemorySize: req.DeviceMemory,
|
||||||
|
UserDeviceHeapSize: req.DeviceMemory,
|
||||||
|
ScreenWidth: req.Width,
|
||||||
|
ScreenHeight: req.Height,
|
||||||
|
}); err != nil {
|
||||||
|
e.log.Warn(r.Context(), "failed to add mobile session to DB: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessStart := &messages.MobileSessionStart{
|
||||||
|
Timestamp: req.Timestamp,
|
||||||
|
ProjectID: uint64(p.ProjectID),
|
||||||
|
TrackerVersion: req.TrackerVersion,
|
||||||
|
RevID: req.RevID,
|
||||||
|
UserUUID: userUUID,
|
||||||
|
UserOS: os,
|
||||||
|
UserOSVersion: req.UserOSVersion,
|
||||||
|
UserDevice: ios.MapIOSDevice(req.UserDevice),
|
||||||
|
UserDeviceType: deviceType,
|
||||||
|
UserCountry: geoInfo.Pack(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := e.producer.Produce(e.cfg.TopicRawMobile, tokenData.ID, sessStart.Encode()); err != nil {
|
||||||
|
e.log.Error(r.Context(), "failed to send mobile sessionStart event to queue: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
e.responser.ResponseWithJSON(e.log, r.Context(), w, &StartMobileSessionResponse{
|
||||||
|
Token: e.tokenizer.Compose(*tokenData),
|
||||||
|
UserUUID: userUUID,
|
||||||
|
SessionID: strconv.FormatUint(tokenData.ID, 10),
|
||||||
|
BeaconSizeLimit: e.cfg.BeaconSizeLimit,
|
||||||
|
ImageQuality: e.cfg.MobileQuality,
|
||||||
|
FrameRate: e.cfg.MobileFps,
|
||||||
|
ProjectID: strconv.FormatUint(uint64(p.ProjectID), 10),
|
||||||
|
Features: e.features,
|
||||||
|
}, startTime, r.URL.Path, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *handlersImpl) pushMobileMessagesHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
sessionData, err := e.tokenizer.ParseFromHTTPRequest(r)
|
||||||
|
if sessionData != nil {
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), "sessionID", fmt.Sprintf("%d", sessionData.ID)))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusUnauthorized, err, startTime, r.URL.Path, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add sessionID and projectID to context
|
||||||
|
if info, err := e.sessions.Get(sessionData.ID); err == nil {
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), "projectID", fmt.Sprintf("%d", info.ProjectID)))
|
||||||
|
}
|
||||||
|
|
||||||
|
e.pushMessages(w, r, sessionData.ID, e.cfg.TopicRawMobile)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *handlersImpl) pushMobileLateMessagesHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
startTime := time.Now()
|
||||||
|
sessionData, err := e.tokenizer.ParseFromHTTPRequest(r)
|
||||||
|
if sessionData != nil {
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), "sessionID", fmt.Sprintf("%d", sessionData.ID)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil && err != token.EXPIRED {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusUnauthorized, err, startTime, r.URL.Path, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Check timestamps here?
|
||||||
|
e.pushMessages(w, r, sessionData.ID, e.cfg.TopicRawMobile)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *handlersImpl) mobileImagesUploadHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
sessionData, err := e.tokenizer.ParseFromHTTPRequest(r)
|
||||||
|
if sessionData != nil {
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), "sessionID", fmt.Sprintf("%d", sessionData.ID)))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusUnauthorized, err, startTime, r.URL.Path, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add sessionID and projectID to context
|
||||||
|
if info, err := e.sessions.Get(sessionData.ID); err == nil {
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), "projectID", fmt.Sprintf("%d", info.ProjectID)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Body == nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, errors.New("request body is empty"), startTime, r.URL.Path, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, e.cfg.FileSizeLimit)
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
err = r.ParseMultipartForm(5 * 1e6) // ~5Mb
|
||||||
|
if err == http.ErrNotMultipart || err == http.ErrMissingBoundary {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusUnsupportedMediaType, err, startTime, r.URL.Path, 0)
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, 0) // TODO: send error here only on staging
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.MultipartForm == nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, errors.New("multipart not parsed"), startTime, r.URL.Path, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(r.MultipartForm.Value["projectKey"]) == 0 {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, errors.New("projectKey parameter missing"), startTime, r.URL.Path, 0) // status for missing/wrong parameter?
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, fileHeaderList := range r.MultipartForm.File {
|
||||||
|
for _, fileHeader := range fileHeaderList {
|
||||||
|
file, err := fileHeader.Open()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
file.Close()
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file.Close()
|
||||||
|
|
||||||
|
if err := e.producer.Produce(e.cfg.TopicRawImages, sessionData.ID, data); err != nil {
|
||||||
|
e.log.Warn(r.Context(), "failed to send image to queue: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.responser.ResponseOK(e.log, r.Context(), w, startTime, r.URL.Path, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *handlersImpl) pushMessages(w http.ResponseWriter, r *http.Request, sessionID uint64, topicName string) {
|
||||||
|
start := time.Now()
|
||||||
|
body := http.MaxBytesReader(w, r.Body, e.cfg.BeaconSizeLimit)
|
||||||
|
defer body.Close()
|
||||||
|
|
||||||
|
var reader io.ReadCloser
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch r.Header.Get("Content-Encoding") {
|
||||||
|
case "gzip":
|
||||||
|
reader, err = gzip.NewReader(body)
|
||||||
|
if err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, start, r.URL.Path, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
default:
|
||||||
|
reader = body
|
||||||
|
}
|
||||||
|
buf, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, start, r.URL.Path, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := e.producer.Produce(topicName, sessionID, buf); err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, start, r.URL.Path, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
e.log.Info(r.Context(), "response ok")
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,4 @@
|
||||||
package router
|
package mobile
|
||||||
|
|
||||||
type NotStartedRequest struct {
|
|
||||||
ProjectKey *string `json:"projectKey"`
|
|
||||||
TrackerVersion string `json:"trackerVersion"`
|
|
||||||
DoNotTrack bool `json:"DoNotTrack"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type StartMobileSessionRequest struct {
|
type StartMobileSessionRequest struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
512
backend/pkg/sessions/api/web/handlers.go
Normal file
512
backend/pkg/sessions/api/web/handlers.go
Normal file
|
|
@ -0,0 +1,512 @@
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Masterminds/semver"
|
||||||
|
|
||||||
|
httpCfg "openreplay/backend/internal/config/http"
|
||||||
|
"openreplay/backend/internal/http/geoip"
|
||||||
|
"openreplay/backend/internal/http/uaparser"
|
||||||
|
"openreplay/backend/internal/http/util"
|
||||||
|
"openreplay/backend/internal/http/uuid"
|
||||||
|
"openreplay/backend/pkg/conditions"
|
||||||
|
"openreplay/backend/pkg/db/postgres"
|
||||||
|
"openreplay/backend/pkg/flakeid"
|
||||||
|
"openreplay/backend/pkg/logger"
|
||||||
|
. "openreplay/backend/pkg/messages"
|
||||||
|
"openreplay/backend/pkg/projects"
|
||||||
|
"openreplay/backend/pkg/queue/types"
|
||||||
|
"openreplay/backend/pkg/server/api"
|
||||||
|
"openreplay/backend/pkg/sessions"
|
||||||
|
beacons "openreplay/backend/pkg/sessions/api"
|
||||||
|
"openreplay/backend/pkg/token"
|
||||||
|
)
|
||||||
|
|
||||||
|
type handlersImpl struct {
|
||||||
|
log logger.Logger
|
||||||
|
cfg *httpCfg.Config
|
||||||
|
responser *api.Responser
|
||||||
|
producer types.Producer
|
||||||
|
projects projects.Projects
|
||||||
|
sessions sessions.Sessions
|
||||||
|
uaParser *uaparser.UAParser
|
||||||
|
geoIP geoip.GeoParser
|
||||||
|
tokenizer *token.Tokenizer
|
||||||
|
conditions conditions.Conditions
|
||||||
|
flaker *flakeid.Flaker
|
||||||
|
beaconSizeCache *beacons.BeaconCache
|
||||||
|
features map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandlers(cfg *httpCfg.Config, log logger.Logger, responser *api.Responser, producer types.Producer, projects projects.Projects,
|
||||||
|
sessions sessions.Sessions, uaParser *uaparser.UAParser, geoIP geoip.GeoParser, tokenizer *token.Tokenizer,
|
||||||
|
conditions conditions.Conditions, flaker *flakeid.Flaker) (api.Handlers, error) {
|
||||||
|
return &handlersImpl{
|
||||||
|
log: log,
|
||||||
|
cfg: cfg,
|
||||||
|
responser: responser,
|
||||||
|
producer: producer,
|
||||||
|
projects: projects,
|
||||||
|
sessions: sessions,
|
||||||
|
uaParser: uaParser,
|
||||||
|
geoIP: geoIP,
|
||||||
|
tokenizer: tokenizer,
|
||||||
|
conditions: conditions,
|
||||||
|
flaker: flaker,
|
||||||
|
beaconSizeCache: beacons.NewBeaconCache(cfg.BeaconSizeLimit),
|
||||||
|
features: map[string]bool{
|
||||||
|
"feature-flags": cfg.IsFeatureFlagEnabled,
|
||||||
|
"usability-test": cfg.IsUsabilityTestEnabled,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *handlersImpl) GetAll() []*api.Description {
|
||||||
|
return []*api.Description{
|
||||||
|
{"/v1/web/not-started", e.notStartedHandlerWeb, "POST"},
|
||||||
|
{"/v1/web/start", e.startSessionHandlerWeb, "POST"},
|
||||||
|
{"/v1/web/i", e.pushMessagesHandlerWeb, "POST"},
|
||||||
|
{"/v1/web/images", e.imagesUploaderHandlerWeb, "POST"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSessionTimestamp(req *StartSessionRequest, startTimeMili int64) (ts uint64) {
|
||||||
|
ts = uint64(req.Timestamp)
|
||||||
|
if req.IsOffline {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c, err := semver.NewConstraint(">=4.1.6")
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ver := req.TrackerVersion
|
||||||
|
parts := strings.Split(ver, "-")
|
||||||
|
if len(parts) > 1 {
|
||||||
|
ver = parts[0]
|
||||||
|
}
|
||||||
|
v, err := semver.NewVersion(ver)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.Check(v) {
|
||||||
|
ts = uint64(startTimeMili)
|
||||||
|
if req.BufferDiff > 0 && req.BufferDiff < 5*60*1000 {
|
||||||
|
ts -= req.BufferDiff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *handlersImpl) startSessionHandlerWeb(w http.ResponseWriter, r *http.Request) {
|
||||||
|
startTime := time.Now()
|
||||||
|
bodySize := 0
|
||||||
|
|
||||||
|
// Check request body
|
||||||
|
if r.Body == nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, errors.New("request body is empty"), startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := api.ReadCompressedBody(e.log, w, r, e.cfg.JsonSizeLimit)
|
||||||
|
if err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusRequestEntityTooLarge, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bodySize = len(bodyBytes)
|
||||||
|
|
||||||
|
// Parse request body
|
||||||
|
req := &StartSessionRequest{}
|
||||||
|
if err := json.Unmarshal(bodyBytes, req); err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tracker version to context
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), "tracker", req.TrackerVersion))
|
||||||
|
|
||||||
|
// Handler's logic
|
||||||
|
if req.ProjectKey == nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusForbidden, errors.New("ProjectKey value required"), startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
p, err := e.projects.GetProjectByKey(*req.ProjectKey)
|
||||||
|
if err != nil {
|
||||||
|
if postgres.IsNoRowsErr(err) {
|
||||||
|
logErr := fmt.Errorf("project doesn't exist or is not active, key: %s", *req.ProjectKey)
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusNotFound, logErr, startTime, r.URL.Path, bodySize)
|
||||||
|
} else {
|
||||||
|
e.log.Error(r.Context(), "failed to get project by key: %s, err: %s", *req.ProjectKey, err)
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, errors.New("can't find a project"), startTime, r.URL.Path, bodySize)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add projectID to context
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), "projectID", fmt.Sprintf("%d", p.ProjectID)))
|
||||||
|
|
||||||
|
// Check if the project supports mobile sessions
|
||||||
|
if !p.IsWeb() {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusForbidden, errors.New("project doesn't support web sessions"), startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ua := e.uaParser.ParseFromHTTPRequest(r)
|
||||||
|
if ua == nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusForbidden, fmt.Errorf("browser not recognized, user-agent: %s", r.Header.Get("User-Agent")), startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
geoInfo := e.geoIP.ExtractGeoData(r)
|
||||||
|
|
||||||
|
userUUID := uuid.GetUUID(req.UserUUID)
|
||||||
|
tokenData, err := e.tokenizer.Parse(req.Token)
|
||||||
|
if err != nil || req.Reset { // Starting the new one
|
||||||
|
dice := byte(rand.Intn(100))
|
||||||
|
// Use condition rate if it's set
|
||||||
|
if req.Condition != "" {
|
||||||
|
rate, err := e.conditions.GetRate(p.ProjectID, req.Condition, int(p.SampleRate))
|
||||||
|
if err != nil {
|
||||||
|
e.log.Warn(r.Context(), "can't get condition rate, condition: %s, err: %s", req.Condition, err)
|
||||||
|
} else {
|
||||||
|
p.SampleRate = byte(rate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dice >= p.SampleRate {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusForbidden, fmt.Errorf("capture rate miss, rate: %d", p.SampleRate), startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
startTimeMili := startTime.UnixMilli()
|
||||||
|
sessionID, err := e.flaker.Compose(uint64(startTimeMili))
|
||||||
|
if err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
expTime := startTime.Add(time.Duration(p.MaxSessionDuration) * time.Millisecond)
|
||||||
|
tokenData = &token.TokenData{
|
||||||
|
ID: sessionID,
|
||||||
|
Delay: startTimeMili - req.Timestamp,
|
||||||
|
ExpTime: expTime.UnixMilli(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add sessionID to context
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), "sessionID", fmt.Sprintf("%d", sessionID)))
|
||||||
|
|
||||||
|
if recordSession(req) {
|
||||||
|
sessionStart := &SessionStart{
|
||||||
|
Timestamp: getSessionTimestamp(req, startTimeMili),
|
||||||
|
ProjectID: uint64(p.ProjectID),
|
||||||
|
TrackerVersion: req.TrackerVersion,
|
||||||
|
RevID: req.RevID,
|
||||||
|
UserUUID: userUUID,
|
||||||
|
UserAgent: r.Header.Get("User-Agent"),
|
||||||
|
UserOS: ua.OS,
|
||||||
|
UserOSVersion: ua.OSVersion,
|
||||||
|
UserBrowser: ua.Browser,
|
||||||
|
UserBrowserVersion: ua.BrowserVersion,
|
||||||
|
UserDevice: ua.Device,
|
||||||
|
UserDeviceType: ua.DeviceType,
|
||||||
|
UserCountry: geoInfo.Pack(),
|
||||||
|
UserDeviceMemorySize: req.DeviceMemory,
|
||||||
|
UserDeviceHeapSize: req.JsHeapSizeLimit,
|
||||||
|
UserID: req.UserID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save sessionStart to db
|
||||||
|
if err := e.sessions.Add(&sessions.Session{
|
||||||
|
SessionID: sessionID,
|
||||||
|
Platform: "web",
|
||||||
|
Timestamp: sessionStart.Timestamp,
|
||||||
|
Timezone: req.Timezone,
|
||||||
|
ProjectID: uint32(sessionStart.ProjectID),
|
||||||
|
TrackerVersion: sessionStart.TrackerVersion,
|
||||||
|
RevID: sessionStart.RevID,
|
||||||
|
UserUUID: sessionStart.UserUUID,
|
||||||
|
UserOS: sessionStart.UserOS,
|
||||||
|
UserOSVersion: sessionStart.UserOSVersion,
|
||||||
|
UserDevice: sessionStart.UserDevice,
|
||||||
|
UserCountry: geoInfo.Country,
|
||||||
|
UserState: geoInfo.State,
|
||||||
|
UserCity: geoInfo.City,
|
||||||
|
UserAgent: sessionStart.UserAgent,
|
||||||
|
UserBrowser: sessionStart.UserBrowser,
|
||||||
|
UserBrowserVersion: sessionStart.UserBrowserVersion,
|
||||||
|
UserDeviceType: sessionStart.UserDeviceType,
|
||||||
|
UserDeviceMemorySize: sessionStart.UserDeviceMemorySize,
|
||||||
|
UserDeviceHeapSize: sessionStart.UserDeviceHeapSize,
|
||||||
|
UserID: &sessionStart.UserID,
|
||||||
|
ScreenWidth: req.Width,
|
||||||
|
ScreenHeight: req.Height,
|
||||||
|
}); err != nil {
|
||||||
|
e.log.Warn(r.Context(), "can't insert sessionStart to DB: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send sessionStart message to kafka
|
||||||
|
if err := e.producer.Produce(e.cfg.TopicRawWeb, tokenData.ID, sessionStart.Encode()); err != nil {
|
||||||
|
e.log.Error(r.Context(), "can't send sessionStart to queue: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), "sessionID", fmt.Sprintf("%d", tokenData.ID)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save information about session beacon size
|
||||||
|
e.beaconSizeCache.Add(tokenData.ID, p.BeaconSize)
|
||||||
|
|
||||||
|
startResponse := &StartSessionResponse{
|
||||||
|
Token: e.tokenizer.Compose(*tokenData),
|
||||||
|
UserUUID: userUUID,
|
||||||
|
UserOS: ua.OS,
|
||||||
|
UserDevice: ua.Device,
|
||||||
|
UserBrowser: ua.Browser,
|
||||||
|
UserCountry: geoInfo.Country,
|
||||||
|
UserState: geoInfo.State,
|
||||||
|
UserCity: geoInfo.City,
|
||||||
|
SessionID: strconv.FormatUint(tokenData.ID, 10),
|
||||||
|
ProjectID: strconv.FormatUint(uint64(p.ProjectID), 10),
|
||||||
|
BeaconSizeLimit: e.beaconSizeCache.Get(tokenData.ID),
|
||||||
|
CompressionThreshold: e.cfg.CompressionThreshold,
|
||||||
|
StartTimestamp: int64(flakeid.ExtractTimestamp(tokenData.ID)),
|
||||||
|
Delay: tokenData.Delay,
|
||||||
|
CanvasEnabled: e.cfg.RecordCanvas,
|
||||||
|
CanvasImageQuality: e.cfg.CanvasQuality,
|
||||||
|
CanvasFrameRate: e.cfg.CanvasFps,
|
||||||
|
Features: e.features,
|
||||||
|
}
|
||||||
|
modifyResponse(req, startResponse)
|
||||||
|
|
||||||
|
e.responser.ResponseWithJSON(e.log, r.Context(), w, startResponse, startTime, r.URL.Path, bodySize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *handlersImpl) pushMessagesHandlerWeb(w http.ResponseWriter, r *http.Request) {
|
||||||
|
startTime := time.Now()
|
||||||
|
bodySize := 0
|
||||||
|
|
||||||
|
// Get debug header with batch info
|
||||||
|
if batch := r.URL.Query().Get("batch"); batch != "" {
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), "batch", batch))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check authorization
|
||||||
|
sessionData, err := e.tokenizer.ParseFromHTTPRequest(r)
|
||||||
|
if sessionData != nil {
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), "sessionID", fmt.Sprintf("%d", sessionData.ID)))
|
||||||
|
}
|
||||||
|
tokenJustExpired := false
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, token.JUST_EXPIRED) {
|
||||||
|
tokenJustExpired = true
|
||||||
|
} else {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusUnauthorized, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add sessionID and projectID to context
|
||||||
|
if info, err := e.sessions.Get(sessionData.ID); err == nil {
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), "projectID", fmt.Sprintf("%d", info.ProjectID)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check request body
|
||||||
|
if r.Body == nil {
|
||||||
|
errCode := http.StatusBadRequest
|
||||||
|
if tokenJustExpired {
|
||||||
|
errCode = http.StatusUnauthorized
|
||||||
|
}
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, errCode, errors.New("request body is empty"), startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := api.ReadCompressedBody(e.log, w, r, e.beaconSizeCache.Get(sessionData.ID))
|
||||||
|
if err != nil {
|
||||||
|
errCode := http.StatusRequestEntityTooLarge
|
||||||
|
if tokenJustExpired {
|
||||||
|
errCode = http.StatusUnauthorized
|
||||||
|
}
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, errCode, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bodySize = len(bodyBytes)
|
||||||
|
|
||||||
|
// Send processed messages to queue as array of bytes
|
||||||
|
err = e.producer.Produce(e.cfg.TopicRawWeb, sessionData.ID, bodyBytes)
|
||||||
|
if err != nil {
|
||||||
|
e.log.Error(r.Context(), "can't send messages batch to queue: %s", err)
|
||||||
|
errCode := http.StatusInternalServerError
|
||||||
|
if tokenJustExpired {
|
||||||
|
errCode = http.StatusUnauthorized
|
||||||
|
}
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, errCode, errors.New("can't save message, try again"), startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokenJustExpired {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusUnauthorized, errors.New("token expired"), startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.responser.ResponseOK(e.log, r.Context(), w, startTime, r.URL.Path, bodySize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *handlersImpl) notStartedHandlerWeb(w http.ResponseWriter, r *http.Request) {
|
||||||
|
startTime := time.Now()
|
||||||
|
bodySize := 0
|
||||||
|
|
||||||
|
if r.Body == nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, errors.New("request body is empty"), startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bodyBytes, err := api.ReadCompressedBody(e.log, w, r, e.cfg.JsonSizeLimit)
|
||||||
|
if err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusRequestEntityTooLarge, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bodySize = len(bodyBytes)
|
||||||
|
|
||||||
|
req := &NotStartedRequest{}
|
||||||
|
if err := json.Unmarshal(bodyBytes, req); err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tracker version to context
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), "tracker", req.TrackerVersion))
|
||||||
|
|
||||||
|
// Handler's logic
|
||||||
|
if req.ProjectKey == nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusForbidden, errors.New("projectKey value required"), startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p, err := e.projects.GetProjectByKey(*req.ProjectKey)
|
||||||
|
if err != nil {
|
||||||
|
if postgres.IsNoRowsErr(err) {
|
||||||
|
logErr := fmt.Errorf("project doesn't exist or is not active, key: %s", *req.ProjectKey)
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusNotFound, logErr, startTime, r.URL.Path, bodySize)
|
||||||
|
} else {
|
||||||
|
e.log.Error(r.Context(), "can't find a project: %s", err)
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, errors.New("can't find a project"), startTime, r.URL.Path, bodySize)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add projectID to context
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), "projectID", fmt.Sprintf("%d", p.ProjectID)))
|
||||||
|
|
||||||
|
ua := e.uaParser.ParseFromHTTPRequest(r)
|
||||||
|
if ua == nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusForbidden, fmt.Errorf("browser not recognized, user-agent: %s", r.Header.Get("User-Agent")), startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
geoInfo := e.geoIP.ExtractGeoData(r)
|
||||||
|
err = e.sessions.AddUnStarted(&sessions.UnStartedSession{
|
||||||
|
ProjectKey: *req.ProjectKey,
|
||||||
|
TrackerVersion: req.TrackerVersion,
|
||||||
|
DoNotTrack: req.DoNotTrack,
|
||||||
|
Platform: "web",
|
||||||
|
UserAgent: r.Header.Get("User-Agent"),
|
||||||
|
UserOS: ua.OS,
|
||||||
|
UserOSVersion: ua.OSVersion,
|
||||||
|
UserBrowser: ua.Browser,
|
||||||
|
UserBrowserVersion: ua.BrowserVersion,
|
||||||
|
UserDevice: ua.Device,
|
||||||
|
UserDeviceType: ua.DeviceType,
|
||||||
|
UserCountry: geoInfo.Country,
|
||||||
|
UserState: geoInfo.State,
|
||||||
|
UserCity: geoInfo.City,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
e.log.Warn(r.Context(), "can't insert un-started session: %s", err)
|
||||||
|
}
|
||||||
|
// response ok anyway
|
||||||
|
e.responser.ResponseOK(e.log, r.Context(), w, startTime, r.URL.Path, bodySize)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScreenshotMessage struct {
|
||||||
|
Name string
|
||||||
|
Data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *handlersImpl) imagesUploaderHandlerWeb(w http.ResponseWriter, r *http.Request) {
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
sessionData, err := e.tokenizer.ParseFromHTTPRequest(r)
|
||||||
|
if sessionData != nil {
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), "sessionID", fmt.Sprintf("%d", sessionData.ID)))
|
||||||
|
}
|
||||||
|
if err != nil { // Should accept expired token?
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusUnauthorized, err, startTime, r.URL.Path, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add sessionID and projectID to context
|
||||||
|
if info, err := e.sessions.Get(sessionData.ID); err == nil {
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), "projectID", fmt.Sprintf("%d", info.ProjectID)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Body == nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, errors.New("request body is empty"), startTime, r.URL.Path, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, e.cfg.FileSizeLimit)
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
// Parse the multipart form
|
||||||
|
err = r.ParseMultipartForm(10 << 20) // Max upload size 10 MB
|
||||||
|
if err == http.ErrNotMultipart || err == http.ErrMissingBoundary {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusUnsupportedMediaType, err, startTime, r.URL.Path, 0)
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterate over uploaded files
|
||||||
|
for _, fileHeaderList := range r.MultipartForm.File {
|
||||||
|
for _, fileHeader := range fileHeaderList {
|
||||||
|
file, err := fileHeader.Open()
|
||||||
|
if err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the file content
|
||||||
|
fileBytes, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
file.Close()
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file.Close()
|
||||||
|
|
||||||
|
fileName := util.SafeString(fileHeader.Filename)
|
||||||
|
|
||||||
|
// Create a message to send to Kafka
|
||||||
|
msg := ScreenshotMessage{
|
||||||
|
Name: fileName,
|
||||||
|
Data: fileBytes,
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(&msg)
|
||||||
|
if err != nil {
|
||||||
|
e.log.Warn(r.Context(), "can't marshal screenshot message, err: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the message to queue
|
||||||
|
if err := e.producer.Produce(e.cfg.TopicCanvasImages, sessionData.ID, data); err != nil {
|
||||||
|
e.log.Warn(r.Context(), "can't send screenshot message to queue, err: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.responser.ResponseOK(e.log, r.Context(), w, startTime, r.URL.Path, 0)
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
package router
|
package web
|
||||||
|
|
||||||
|
type NotStartedRequest struct {
|
||||||
|
ProjectKey *string `json:"projectKey"`
|
||||||
|
TrackerVersion string `json:"trackerVersion"`
|
||||||
|
DoNotTrack bool `json:"DoNotTrack"`
|
||||||
|
}
|
||||||
|
|
||||||
type StartSessionRequest struct {
|
type StartSessionRequest struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
|
|
@ -2,12 +2,10 @@ package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -15,59 +13,106 @@ import (
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
|
|
||||||
metrics "openreplay/backend/pkg/metrics/spot"
|
spotConfig "openreplay/backend/internal/config/spot"
|
||||||
|
"openreplay/backend/pkg/logger"
|
||||||
"openreplay/backend/pkg/objectstorage"
|
"openreplay/backend/pkg/objectstorage"
|
||||||
"openreplay/backend/pkg/spot/auth"
|
"openreplay/backend/pkg/server/api"
|
||||||
|
"openreplay/backend/pkg/server/keys"
|
||||||
|
"openreplay/backend/pkg/server/user"
|
||||||
"openreplay/backend/pkg/spot/service"
|
"openreplay/backend/pkg/spot/service"
|
||||||
|
"openreplay/backend/pkg/spot/transcoder"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (e *Router) createSpot(w http.ResponseWriter, r *http.Request) {
|
type handlersImpl struct {
|
||||||
|
log logger.Logger
|
||||||
|
responser *api.Responser
|
||||||
|
jsonSizeLimit int64
|
||||||
|
spots service.Spots
|
||||||
|
objStorage objectstorage.ObjectStorage
|
||||||
|
transcoder transcoder.Transcoder
|
||||||
|
keys keys.Keys
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandlers(log logger.Logger, cfg *spotConfig.Config, responser *api.Responser, spots service.Spots, objStore objectstorage.ObjectStorage, transcoder transcoder.Transcoder, keys keys.Keys) (api.Handlers, error) {
|
||||||
|
return &handlersImpl{
|
||||||
|
log: log,
|
||||||
|
responser: responser,
|
||||||
|
jsonSizeLimit: cfg.JsonSizeLimit,
|
||||||
|
spots: spots,
|
||||||
|
objStorage: objStore,
|
||||||
|
transcoder: transcoder,
|
||||||
|
keys: keys,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *handlersImpl) GetAll() []*api.Description {
|
||||||
|
return []*api.Description{
|
||||||
|
{"/v1/spots", e.createSpot, "POST"},
|
||||||
|
{"/v1/spots/{id}", e.getSpot, "GET"},
|
||||||
|
{"/v1/spots/{id}", e.updateSpot, "PATCH"},
|
||||||
|
{"/v1/spots", e.getSpots, "GET"},
|
||||||
|
{"/v1/spots", e.deleteSpots, "DELETE"},
|
||||||
|
{"/v1/spots/{id}/comment", e.addComment, "POST"},
|
||||||
|
{"/v1/spots/{id}/uploaded", e.uploadedSpot, "POST"},
|
||||||
|
{"/v1/spots/{id}/video", e.getSpotVideo, "GET"},
|
||||||
|
{"/v1/spots/{id}/public-key", e.getPublicKey, "GET"},
|
||||||
|
{"/v1/spots/{id}/public-key", e.updatePublicKey, "PATCH"},
|
||||||
|
{"/v1/spots/{id}/status", e.spotStatus, "GET"},
|
||||||
|
{"/v1/ping", e.ping, "GET"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *handlersImpl) ping(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *handlersImpl) createSpot(w http.ResponseWriter, r *http.Request) {
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
bodySize := 0
|
bodySize := 0
|
||||||
|
|
||||||
bodyBytes, err := e.readBody(w, r, e.cfg.JsonSizeLimit)
|
bodyBytes, err := api.ReadBody(e.log, w, r, e.jsonSizeLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusRequestEntityTooLarge, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusRequestEntityTooLarge, err, startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
bodySize = len(bodyBytes)
|
bodySize = len(bodyBytes)
|
||||||
|
|
||||||
req := &CreateSpotRequest{}
|
req := &CreateSpotRequest{}
|
||||||
if err := json.Unmarshal(bodyBytes, req); err != nil {
|
if err := json.Unmarshal(bodyBytes, req); err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Creat a spot
|
// Creat a spot
|
||||||
currUser := r.Context().Value("userData").(*auth.User)
|
currUser := r.Context().Value("userData").(*user.User)
|
||||||
newSpot, err := e.services.Spots.Add(currUser, req.Name, req.Comment, req.Duration, req.Crop)
|
newSpot, err := e.spots.Add(currUser, req.Name, req.Comment, req.Duration, req.Crop)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse and upload preview image
|
// Parse and upload preview image
|
||||||
previewImage, err := getSpotPreview(req.Preview)
|
previewImage, err := getSpotPreview(req.Preview)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
previewName := fmt.Sprintf("%d/preview.jpeg", newSpot.ID)
|
previewName := fmt.Sprintf("%d/preview.jpeg", newSpot.ID)
|
||||||
if err = e.services.ObjStorage.Upload(bytes.NewReader(previewImage), previewName, "image/jpeg", objectstorage.NoCompression); err != nil {
|
if err = e.objStorage.Upload(bytes.NewReader(previewImage), previewName, "image/jpeg", objectstorage.NoCompression); err != nil {
|
||||||
e.log.Error(r.Context(), "can't upload preview image: %s", err)
|
e.log.Error(r.Context(), "can't upload preview image: %s", err)
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, errors.New("can't upload preview image"), startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, errors.New("can't upload preview image"), startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
mobURL, err := e.getUploadMobURL(newSpot.ID)
|
mobURL, err := e.getUploadMobURL(newSpot.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
videoURL, err := e.getUploadVideoURL(newSpot.ID)
|
videoURL, err := e.getUploadVideoURL(newSpot.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,7 +121,7 @@ func (e *Router) createSpot(w http.ResponseWriter, r *http.Request) {
|
||||||
MobURL: mobURL,
|
MobURL: mobURL,
|
||||||
VideoURL: videoURL,
|
VideoURL: videoURL,
|
||||||
}
|
}
|
||||||
e.ResponseWithJSON(r.Context(), w, resp, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithJSON(e.log, r.Context(), w, resp, startTime, r.URL.Path, bodySize)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getSpotPreview(preview string) ([]byte, error) {
|
func getSpotPreview(preview string) ([]byte, error) {
|
||||||
|
|
@ -93,18 +138,18 @@ func getSpotPreview(preview string) ([]byte, error) {
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Router) getUploadMobURL(spotID uint64) (string, error) {
|
func (e *handlersImpl) getUploadMobURL(spotID uint64) (string, error) {
|
||||||
mobKey := fmt.Sprintf("%d/events.mob", spotID)
|
mobKey := fmt.Sprintf("%d/events.mob", spotID)
|
||||||
mobURL, err := e.services.ObjStorage.GetPreSignedUploadUrl(mobKey)
|
mobURL, err := e.objStorage.GetPreSignedUploadUrl(mobKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("can't get mob URL: %s", err)
|
return "", fmt.Errorf("can't get mob URL: %s", err)
|
||||||
}
|
}
|
||||||
return mobURL, nil
|
return mobURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Router) getUploadVideoURL(spotID uint64) (string, error) {
|
func (e *handlersImpl) getUploadVideoURL(spotID uint64) (string, error) {
|
||||||
mobKey := fmt.Sprintf("%d/video.webm", spotID)
|
mobKey := fmt.Sprintf("%d/video.webm", spotID)
|
||||||
mobURL, err := e.services.ObjStorage.GetPreSignedUploadUrl(mobKey)
|
mobURL, err := e.objStorage.GetPreSignedUploadUrl(mobKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("can't get video URL: %s", err)
|
return "", fmt.Errorf("can't get video URL: %s", err)
|
||||||
}
|
}
|
||||||
|
|
@ -143,51 +188,51 @@ func getSpotsRequest(r *http.Request) (*GetSpotsRequest, error) {
|
||||||
return req, nil
|
return req, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Router) getPreviewURL(spotID uint64) (string, error) {
|
func (e *handlersImpl) getPreviewURL(spotID uint64) (string, error) {
|
||||||
previewKey := fmt.Sprintf("%d/preview.jpeg", spotID)
|
previewKey := fmt.Sprintf("%d/preview.jpeg", spotID)
|
||||||
previewURL, err := e.services.ObjStorage.GetPreSignedDownloadUrl(previewKey)
|
previewURL, err := e.objStorage.GetPreSignedDownloadUrl(previewKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("can't get preview URL: %s", err)
|
return "", fmt.Errorf("can't get preview URL: %s", err)
|
||||||
}
|
}
|
||||||
return previewURL, nil
|
return previewURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Router) getMobURL(spotID uint64) (string, error) {
|
func (e *handlersImpl) getMobURL(spotID uint64) (string, error) {
|
||||||
mobKey := fmt.Sprintf("%d/events.mob", spotID)
|
mobKey := fmt.Sprintf("%d/events.mob", spotID)
|
||||||
mobURL, err := e.services.ObjStorage.GetPreSignedDownloadUrl(mobKey)
|
mobURL, err := e.objStorage.GetPreSignedDownloadUrl(mobKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("can't get mob URL: %s", err)
|
return "", fmt.Errorf("can't get mob URL: %s", err)
|
||||||
}
|
}
|
||||||
return mobURL, nil
|
return mobURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Router) getVideoURL(spotID uint64) (string, error) {
|
func (e *handlersImpl) getVideoURL(spotID uint64) (string, error) {
|
||||||
mobKey := fmt.Sprintf("%d/video.webm", spotID) // TODO: later return url to m3u8 file
|
mobKey := fmt.Sprintf("%d/video.webm", spotID) // TODO: later return url to m3u8 file
|
||||||
mobURL, err := e.services.ObjStorage.GetPreSignedDownloadUrl(mobKey)
|
mobURL, err := e.objStorage.GetPreSignedDownloadUrl(mobKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("can't get video URL: %s", err)
|
return "", fmt.Errorf("can't get video URL: %s", err)
|
||||||
}
|
}
|
||||||
return mobURL, nil
|
return mobURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Router) getSpot(w http.ResponseWriter, r *http.Request) {
|
func (e *handlersImpl) getSpot(w http.ResponseWriter, r *http.Request) {
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
bodySize := 0
|
bodySize := 0
|
||||||
|
|
||||||
id, err := getSpotID(r)
|
id, err := getSpotID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user := r.Context().Value("userData").(*auth.User)
|
user := r.Context().Value("userData").(*user.User)
|
||||||
res, err := e.services.Spots.GetByID(user, id)
|
res, err := e.spots.GetByID(user, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if res == nil {
|
if res == nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusNotFound, fmt.Errorf("spot not found"), startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusNotFound, fmt.Errorf("spot not found"), startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -197,12 +242,12 @@ func (e *Router) getSpot(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
mobURL, err := e.getMobURL(id)
|
mobURL, err := e.getMobURL(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
videoURL, err := e.getVideoURL(id)
|
videoURL, err := e.getVideoURL(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -216,60 +261,60 @@ func (e *Router) getSpot(w http.ResponseWriter, r *http.Request) {
|
||||||
MobURL: mobURL,
|
MobURL: mobURL,
|
||||||
VideoURL: videoURL,
|
VideoURL: videoURL,
|
||||||
}
|
}
|
||||||
playlist, err := e.services.Transcoder.GetSpotStreamPlaylist(id)
|
playlist, err := e.transcoder.GetSpotStreamPlaylist(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.log.Warn(r.Context(), "can't get stream playlist: %s", err)
|
e.log.Warn(r.Context(), "can't get stream playlist: %s", err)
|
||||||
} else {
|
} else {
|
||||||
spotInfo.StreamFile = base64.StdEncoding.EncodeToString(playlist)
|
spotInfo.StreamFile = base64.StdEncoding.EncodeToString(playlist)
|
||||||
}
|
}
|
||||||
|
|
||||||
e.ResponseWithJSON(r.Context(), w, &GetSpotResponse{Spot: spotInfo}, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithJSON(e.log, r.Context(), w, &GetSpotResponse{Spot: spotInfo}, startTime, r.URL.Path, bodySize)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Router) updateSpot(w http.ResponseWriter, r *http.Request) {
|
func (e *handlersImpl) updateSpot(w http.ResponseWriter, r *http.Request) {
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
bodySize := 0
|
bodySize := 0
|
||||||
|
|
||||||
id, err := getSpotID(r)
|
id, err := getSpotID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
bodyBytes, err := e.readBody(w, r, e.cfg.JsonSizeLimit)
|
bodyBytes, err := api.ReadBody(e.log, w, r, e.jsonSizeLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusRequestEntityTooLarge, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusRequestEntityTooLarge, err, startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
bodySize = len(bodyBytes)
|
bodySize = len(bodyBytes)
|
||||||
|
|
||||||
req := &UpdateSpotRequest{}
|
req := &UpdateSpotRequest{}
|
||||||
if err := json.Unmarshal(bodyBytes, req); err != nil {
|
if err := json.Unmarshal(bodyBytes, req); err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user := r.Context().Value("userData").(*auth.User)
|
user := r.Context().Value("userData").(*user.User)
|
||||||
_, err = e.services.Spots.UpdateName(user, id, req.Name)
|
_, err = e.spots.UpdateName(user, id, req.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
e.ResponseOK(r.Context(), w, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseOK(e.log, r.Context(), w, startTime, r.URL.Path, bodySize)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Router) getSpots(w http.ResponseWriter, r *http.Request) {
|
func (e *handlersImpl) getSpots(w http.ResponseWriter, r *http.Request) {
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
bodySize := 0
|
bodySize := 0
|
||||||
|
|
||||||
req, err := getSpotsRequest(r)
|
req, err := getSpotsRequest(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user := r.Context().Value("userData").(*auth.User)
|
user := r.Context().Value("userData").(*user.User)
|
||||||
opts := &service.GetOpts{
|
opts := &service.GetOpts{
|
||||||
NameFilter: req.Query, Order: req.Order, Page: req.Page, Limit: req.Limit}
|
NameFilter: req.Query, Order: req.Order, Page: req.Page, Limit: req.Limit}
|
||||||
switch req.FilterBy {
|
switch req.FilterBy {
|
||||||
|
|
@ -278,9 +323,9 @@ func (e *Router) getSpots(w http.ResponseWriter, r *http.Request) {
|
||||||
default:
|
default:
|
||||||
opts.TenantID = user.TenantID
|
opts.TenantID = user.TenantID
|
||||||
}
|
}
|
||||||
spots, total, tenantHasSpots, err := e.services.Spots.Get(user, opts)
|
spots, total, tenantHasSpots, err := e.spots.Get(user, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
res := make([]ShortInfo, 0, len(spots))
|
res := make([]ShortInfo, 0, len(spots))
|
||||||
|
|
@ -298,82 +343,82 @@ func (e *Router) getSpots(w http.ResponseWriter, r *http.Request) {
|
||||||
PreviewURL: previewUrl,
|
PreviewURL: previewUrl,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
e.ResponseWithJSON(r.Context(), w, &GetSpotsResponse{Spots: res, Total: total, TenantHasSpots: tenantHasSpots}, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithJSON(e.log, r.Context(), w, &GetSpotsResponse{Spots: res, Total: total, TenantHasSpots: tenantHasSpots}, startTime, r.URL.Path, bodySize)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Router) deleteSpots(w http.ResponseWriter, r *http.Request) {
|
func (e *handlersImpl) deleteSpots(w http.ResponseWriter, r *http.Request) {
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
bodySize := 0
|
bodySize := 0
|
||||||
|
|
||||||
bodyBytes, err := e.readBody(w, r, e.cfg.JsonSizeLimit)
|
bodyBytes, err := api.ReadBody(e.log, w, r, e.jsonSizeLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusRequestEntityTooLarge, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusRequestEntityTooLarge, err, startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
bodySize = len(bodyBytes)
|
bodySize = len(bodyBytes)
|
||||||
|
|
||||||
req := &DeleteSpotRequest{}
|
req := &DeleteSpotRequest{}
|
||||||
if err := json.Unmarshal(bodyBytes, req); err != nil {
|
if err := json.Unmarshal(bodyBytes, req); err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
spotsToDelete := make([]uint64, 0, len(req.SpotIDs))
|
spotsToDelete := make([]uint64, 0, len(req.SpotIDs))
|
||||||
for _, idStr := range req.SpotIDs {
|
for _, idStr := range req.SpotIDs {
|
||||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, fmt.Errorf("invalid spot id: %s", idStr), startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, fmt.Errorf("invalid spot id: %s", idStr), startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
spotsToDelete = append(spotsToDelete, id)
|
spotsToDelete = append(spotsToDelete, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
user := r.Context().Value("userData").(*auth.User)
|
user := r.Context().Value("userData").(*user.User)
|
||||||
if err := e.services.Spots.Delete(user, spotsToDelete); err != nil {
|
if err := e.spots.Delete(user, spotsToDelete); err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
e.ResponseOK(r.Context(), w, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseOK(e.log, r.Context(), w, startTime, r.URL.Path, bodySize)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Router) addComment(w http.ResponseWriter, r *http.Request) {
|
func (e *handlersImpl) addComment(w http.ResponseWriter, r *http.Request) {
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
bodySize := 0
|
bodySize := 0
|
||||||
|
|
||||||
id, err := getSpotID(r)
|
id, err := getSpotID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
bodyBytes, err := e.readBody(w, r, e.cfg.JsonSizeLimit)
|
bodyBytes, err := api.ReadBody(e.log, w, r, e.jsonSizeLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusRequestEntityTooLarge, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusRequestEntityTooLarge, err, startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
bodySize = len(bodyBytes)
|
bodySize = len(bodyBytes)
|
||||||
|
|
||||||
req := &AddCommentRequest{}
|
req := &AddCommentRequest{}
|
||||||
if err := json.Unmarshal(bodyBytes, req); err != nil {
|
if err := json.Unmarshal(bodyBytes, req); err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user := r.Context().Value("userData").(*auth.User)
|
user := r.Context().Value("userData").(*user.User)
|
||||||
updatedSpot, err := e.services.Spots.AddComment(user, id, &service.Comment{UserName: req.UserName, Text: req.Comment})
|
updatedSpot, err := e.spots.AddComment(user, id, &service.Comment{UserName: req.UserName, Text: req.Comment})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
mobURL, err := e.getMobURL(id)
|
mobURL, err := e.getMobURL(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
videoURL, err := e.getVideoURL(id)
|
videoURL, err := e.getVideoURL(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -385,70 +430,70 @@ func (e *Router) addComment(w http.ResponseWriter, r *http.Request) {
|
||||||
MobURL: mobURL,
|
MobURL: mobURL,
|
||||||
VideoURL: videoURL,
|
VideoURL: videoURL,
|
||||||
}
|
}
|
||||||
e.ResponseWithJSON(r.Context(), w, &GetSpotResponse{Spot: spotInfo}, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithJSON(e.log, r.Context(), w, &GetSpotResponse{Spot: spotInfo}, startTime, r.URL.Path, bodySize)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Router) uploadedSpot(w http.ResponseWriter, r *http.Request) {
|
func (e *handlersImpl) uploadedSpot(w http.ResponseWriter, r *http.Request) {
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
bodySize := 0
|
bodySize := 0
|
||||||
|
|
||||||
id, err := getSpotID(r)
|
id, err := getSpotID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user := r.Context().Value("userData").(*auth.User)
|
user := r.Context().Value("userData").(*user.User)
|
||||||
spot, err := e.services.Spots.GetByID(user, id) // check if spot exists
|
spot, err := e.spots.GetByID(user, id) // check if spot exists
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
e.log.Info(r.Context(), "uploaded spot %+v, from user: %+v", spot, user)
|
e.log.Info(r.Context(), "uploaded spot %+v, from user: %+v", spot, user)
|
||||||
if err := e.services.Transcoder.Process(spot); err != nil {
|
if err := e.transcoder.Process(spot); err != nil {
|
||||||
e.log.Error(r.Context(), "can't add transcoding task: %s", err)
|
e.log.Error(r.Context(), "can't add transcoding task: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
e.ResponseOK(r.Context(), w, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseOK(e.log, r.Context(), w, startTime, r.URL.Path, bodySize)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Router) getSpotVideo(w http.ResponseWriter, r *http.Request) {
|
func (e *handlersImpl) getSpotVideo(w http.ResponseWriter, r *http.Request) {
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
bodySize := 0
|
bodySize := 0
|
||||||
|
|
||||||
id, err := getSpotID(r)
|
id, err := getSpotID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
key := fmt.Sprintf("%d/video.webm", id)
|
key := fmt.Sprintf("%d/video.webm", id)
|
||||||
videoURL, err := e.services.ObjStorage.GetPreSignedDownloadUrl(key)
|
videoURL, err := e.objStorage.GetPreSignedDownloadUrl(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := map[string]interface{}{
|
resp := map[string]interface{}{
|
||||||
"url": videoURL,
|
"url": videoURL,
|
||||||
}
|
}
|
||||||
e.ResponseWithJSON(r.Context(), w, resp, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithJSON(e.log, r.Context(), w, resp, startTime, r.URL.Path, bodySize)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Router) getSpotStream(w http.ResponseWriter, r *http.Request) {
|
func (e *handlersImpl) getSpotStream(w http.ResponseWriter, r *http.Request) {
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
bodySize := 0
|
bodySize := 0
|
||||||
|
|
||||||
id, err := getSpotID(r)
|
id, err := getSpotID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Example data to serve as the file content
|
// Example data to serve as the file content
|
||||||
streamPlaylist, err := e.services.Transcoder.GetSpotStreamPlaylist(id)
|
streamPlaylist, err := e.transcoder.GetSpotStreamPlaylist(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -462,144 +507,90 @@ func (e *Router) getSpotStream(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
// Write the content of the buffer to the response writer
|
// Write the content of the buffer to the response writer
|
||||||
if _, err := buffer.WriteTo(w); err != nil {
|
if _, err := buffer.WriteTo(w); err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Router) getPublicKey(w http.ResponseWriter, r *http.Request) {
|
func (e *handlersImpl) getPublicKey(w http.ResponseWriter, r *http.Request) {
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
bodySize := 0
|
bodySize := 0
|
||||||
|
|
||||||
id, err := getSpotID(r)
|
id, err := getSpotID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user := r.Context().Value("userData").(*auth.User)
|
user := r.Context().Value("userData").(*user.User)
|
||||||
key, err := e.services.Keys.Get(id, user)
|
key, err := e.keys.Get(id, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "not found") {
|
if strings.Contains(err.Error(), "not found") {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusNotFound, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusNotFound, err, startTime, r.URL.Path, bodySize)
|
||||||
} else {
|
} else {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resp := map[string]interface{}{
|
resp := map[string]interface{}{
|
||||||
"key": key,
|
"key": key,
|
||||||
}
|
}
|
||||||
e.ResponseWithJSON(r.Context(), w, resp, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithJSON(e.log, r.Context(), w, resp, startTime, r.URL.Path, bodySize)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Router) updatePublicKey(w http.ResponseWriter, r *http.Request) {
|
func (e *handlersImpl) updatePublicKey(w http.ResponseWriter, r *http.Request) {
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
bodySize := 0
|
bodySize := 0
|
||||||
|
|
||||||
id, err := getSpotID(r)
|
id, err := getSpotID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
bodyBytes, err := e.readBody(w, r, e.cfg.JsonSizeLimit)
|
bodyBytes, err := api.ReadBody(e.log, w, r, e.jsonSizeLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusRequestEntityTooLarge, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusRequestEntityTooLarge, err, startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
bodySize = len(bodyBytes)
|
bodySize = len(bodyBytes)
|
||||||
|
|
||||||
req := &UpdateSpotPublicKeyRequest{}
|
req := &UpdateSpotPublicKeyRequest{}
|
||||||
if err := json.Unmarshal(bodyBytes, req); err != nil {
|
if err := json.Unmarshal(bodyBytes, req); err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user := r.Context().Value("userData").(*auth.User)
|
user := r.Context().Value("userData").(*user.User)
|
||||||
key, err := e.services.Keys.Set(id, req.Expiration, user)
|
key, err := e.keys.Set(id, req.Expiration, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resp := map[string]interface{}{
|
resp := map[string]interface{}{
|
||||||
"key": key,
|
"key": key,
|
||||||
}
|
}
|
||||||
e.ResponseWithJSON(r.Context(), w, resp, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithJSON(e.log, r.Context(), w, resp, startTime, r.URL.Path, bodySize)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Router) spotStatus(w http.ResponseWriter, r *http.Request) {
|
func (e *handlersImpl) spotStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
bodySize := 0
|
bodySize := 0
|
||||||
|
|
||||||
id, err := getSpotID(r)
|
id, err := getSpotID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user := r.Context().Value("userData").(*auth.User)
|
user := r.Context().Value("userData").(*user.User)
|
||||||
status, err := e.services.Spots.GetStatus(user, id)
|
status, err := e.spots.GetStatus(user, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resp := map[string]interface{}{
|
resp := map[string]interface{}{
|
||||||
"status": status,
|
"status": status,
|
||||||
}
|
}
|
||||||
e.ResponseWithJSON(r.Context(), w, resp, startTime, r.URL.Path, bodySize)
|
e.responser.ResponseWithJSON(e.log, r.Context(), w, resp, startTime, r.URL.Path, bodySize)
|
||||||
}
|
|
||||||
|
|
||||||
func recordMetrics(requestStart time.Time, url string, code, bodySize int) {
|
|
||||||
if bodySize > 0 {
|
|
||||||
metrics.RecordRequestSize(float64(bodySize), url, code)
|
|
||||||
}
|
|
||||||
metrics.IncreaseTotalRequests()
|
|
||||||
metrics.RecordRequestDuration(float64(time.Now().Sub(requestStart).Milliseconds()), url, code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) readBody(w http.ResponseWriter, r *http.Request, limit int64) ([]byte, error) {
|
|
||||||
body := http.MaxBytesReader(w, r.Body, limit)
|
|
||||||
bodyBytes, err := io.ReadAll(body)
|
|
||||||
|
|
||||||
// Close body
|
|
||||||
if closeErr := body.Close(); closeErr != nil {
|
|
||||||
e.log.Warn(r.Context(), "error while closing request body: %s", closeErr)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return bodyBytes, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) ResponseOK(ctx context.Context, w http.ResponseWriter, requestStart time.Time, url string, bodySize int) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
e.log.Info(ctx, "response ok")
|
|
||||||
recordMetrics(requestStart, url, http.StatusOK, bodySize)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) ResponseWithJSON(ctx context.Context, w http.ResponseWriter, res interface{}, requestStart time.Time, url string, bodySize int) {
|
|
||||||
e.log.Info(ctx, "response ok")
|
|
||||||
body, err := json.Marshal(res)
|
|
||||||
if err != nil {
|
|
||||||
e.log.Error(ctx, "can't marshal response: %s", err)
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Write(body)
|
|
||||||
recordMetrics(requestStart, url, http.StatusOK, bodySize)
|
|
||||||
}
|
|
||||||
|
|
||||||
type response struct {
|
|
||||||
Error string `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) ResponseWithError(ctx context.Context, w http.ResponseWriter, code int, err error, requestStart time.Time, url string, bodySize int) {
|
|
||||||
e.log.Error(ctx, "response error, code: %d, error: %s", code, err)
|
|
||||||
body, err := json.Marshal(&response{err.Error()})
|
|
||||||
if err != nil {
|
|
||||||
e.log.Error(ctx, "can't marshal response: %s", err)
|
|
||||||
}
|
|
||||||
w.WriteHeader(code)
|
|
||||||
w.Write(body)
|
|
||||||
recordMetrics(requestStart, url, code, bodySize)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,213 +0,0 @@
|
||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"openreplay/backend/pkg/spot"
|
|
||||||
"openreplay/backend/pkg/spot/auth"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/docker/distribution/context"
|
|
||||||
"github.com/gorilla/mux"
|
|
||||||
|
|
||||||
spotConfig "openreplay/backend/internal/config/spot"
|
|
||||||
"openreplay/backend/internal/http/util"
|
|
||||||
"openreplay/backend/pkg/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Router struct {
|
|
||||||
log logger.Logger
|
|
||||||
cfg *spotConfig.Config
|
|
||||||
router *mux.Router
|
|
||||||
mutex *sync.RWMutex
|
|
||||||
services *spot.ServicesBuilder
|
|
||||||
limiter *UserRateLimiter
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewRouter(cfg *spotConfig.Config, log logger.Logger, services *spot.ServicesBuilder) (*Router, error) {
|
|
||||||
switch {
|
|
||||||
case cfg == nil:
|
|
||||||
return nil, fmt.Errorf("config is empty")
|
|
||||||
case services == nil:
|
|
||||||
return nil, fmt.Errorf("services is empty")
|
|
||||||
case log == nil:
|
|
||||||
return nil, fmt.Errorf("logger is empty")
|
|
||||||
}
|
|
||||||
e := &Router{
|
|
||||||
log: log,
|
|
||||||
cfg: cfg,
|
|
||||||
mutex: &sync.RWMutex{},
|
|
||||||
services: services,
|
|
||||||
limiter: NewUserRateLimiter(10, 30, 1*time.Minute, 5*time.Minute),
|
|
||||||
}
|
|
||||||
e.init()
|
|
||||||
return e, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) init() {
|
|
||||||
e.router = mux.NewRouter()
|
|
||||||
|
|
||||||
// Root route
|
|
||||||
e.router.HandleFunc("/", e.ping)
|
|
||||||
|
|
||||||
// Spot routes
|
|
||||||
e.router.HandleFunc("/v1/spots", e.createSpot).Methods("POST", "OPTIONS")
|
|
||||||
e.router.HandleFunc("/v1/spots/{id}", e.getSpot).Methods("GET", "OPTIONS")
|
|
||||||
e.router.HandleFunc("/v1/spots/{id}", e.updateSpot).Methods("PATCH", "OPTIONS")
|
|
||||||
e.router.HandleFunc("/v1/spots", e.getSpots).Methods("GET", "OPTIONS")
|
|
||||||
e.router.HandleFunc("/v1/spots", e.deleteSpots).Methods("DELETE", "OPTIONS")
|
|
||||||
e.router.HandleFunc("/v1/spots/{id}/comment", e.addComment).Methods("POST", "OPTIONS")
|
|
||||||
e.router.HandleFunc("/v1/spots/{id}/uploaded", e.uploadedSpot).Methods("POST", "OPTIONS")
|
|
||||||
e.router.HandleFunc("/v1/spots/{id}/video", e.getSpotVideo).Methods("GET", "OPTIONS")
|
|
||||||
e.router.HandleFunc("/v1/spots/{id}/public-key", e.getPublicKey).Methods("GET", "OPTIONS")
|
|
||||||
e.router.HandleFunc("/v1/spots/{id}/public-key", e.updatePublicKey).Methods("PATCH", "OPTIONS")
|
|
||||||
e.router.HandleFunc("/v1/spots/{id}/status", e.spotStatus).Methods("GET", "OPTIONS")
|
|
||||||
e.router.HandleFunc("/v1/ping", e.ping).Methods("GET", "OPTIONS")
|
|
||||||
|
|
||||||
// CORS middleware
|
|
||||||
e.router.Use(e.corsMiddleware)
|
|
||||||
e.router.Use(e.authMiddleware)
|
|
||||||
e.router.Use(e.rateLimitMiddleware)
|
|
||||||
e.router.Use(e.actionMiddleware)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) ping(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) corsMiddleware(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.URL.Path == "/" {
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
}
|
|
||||||
if e.cfg.UseAccessControlHeaders {
|
|
||||||
// Prepare headers for preflight requests
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "POST,GET,PATCH,DELETE")
|
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type,Authorization,Content-Encoding")
|
|
||||||
}
|
|
||||||
if r.Method == http.MethodOptions {
|
|
||||||
w.Header().Set("Cache-Control", "max-age=86400")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
r = r.WithContext(context.WithValues(r.Context(), map[string]interface{}{"httpMethod": r.Method, "url": util.SafeString(r.URL.Path)}))
|
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) authMiddleware(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.URL.Path == "/" {
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
}
|
|
||||||
isExtension := false
|
|
||||||
pathTemplate, err := mux.CurrentRoute(r).GetPathTemplate()
|
|
||||||
if err != nil {
|
|
||||||
e.log.Error(r.Context(), "failed to get path template: %s", err)
|
|
||||||
} else {
|
|
||||||
if pathTemplate == "/v1/ping" ||
|
|
||||||
(pathTemplate == "/v1/spots" && r.Method == "POST") ||
|
|
||||||
(pathTemplate == "/v1/spots/{id}/uploaded" && r.Method == "POST") {
|
|
||||||
isExtension = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the request is authorized
|
|
||||||
user, err := e.services.Auth.IsAuthorized(r.Header.Get("Authorization"), getPermissions(r.URL.Path), isExtension)
|
|
||||||
if err != nil {
|
|
||||||
e.log.Warn(r.Context(), "Unauthorized request: %s", err)
|
|
||||||
if !isSpotWithKeyRequest(r) {
|
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err = e.services.Keys.IsValid(r.URL.Query().Get("key"))
|
|
||||||
if err != nil {
|
|
||||||
e.log.Warn(r.Context(), "Wrong public key: %s", err)
|
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
r = r.WithContext(context.WithValues(r.Context(), map[string]interface{}{"userData": user}))
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func isSpotWithKeyRequest(r *http.Request) bool {
|
|
||||||
pathTemplate, err := mux.CurrentRoute(r).GetPathTemplate()
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
getSpotPrefix := "/v1/spots/{id}" // GET
|
|
||||||
addCommentPrefix := "/v1/spots/{id}/comment" // POST
|
|
||||||
getStatusPrefix := "/v1/spots/{id}/status" // GET
|
|
||||||
if (pathTemplate == getSpotPrefix && r.Method == "GET") ||
|
|
||||||
(pathTemplate == addCommentPrefix && r.Method == "POST") ||
|
|
||||||
(pathTemplate == getStatusPrefix && r.Method == "GET") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) rateLimitMiddleware(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.URL.Path == "/" {
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
}
|
|
||||||
user := r.Context().Value("userData").(*auth.User)
|
|
||||||
rl := e.limiter.GetRateLimiter(user.ID)
|
|
||||||
|
|
||||||
if !rl.Allow() {
|
|
||||||
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type statusWriter struct {
|
|
||||||
http.ResponseWriter
|
|
||||||
statusCode int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *statusWriter) WriteHeader(statusCode int) {
|
|
||||||
w.statusCode = statusCode
|
|
||||||
w.ResponseWriter.WriteHeader(statusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *statusWriter) Write(b []byte) (int, error) {
|
|
||||||
if w.statusCode == 0 {
|
|
||||||
w.statusCode = http.StatusOK // Default status code is 200
|
|
||||||
}
|
|
||||||
return w.ResponseWriter.Write(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) actionMiddleware(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.URL.Path == "/" {
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
}
|
|
||||||
// Read body and restore the io.ReadCloser to its original state
|
|
||||||
bodyBytes, err := io.ReadAll(r.Body)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "can't read body", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
|
||||||
// Use custom response writer to get the status code
|
|
||||||
sw := &statusWriter{ResponseWriter: w}
|
|
||||||
// Serve the request
|
|
||||||
next.ServeHTTP(sw, r)
|
|
||||||
e.logRequest(r, bodyBytes, sw.statusCode)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Router) GetHandler() http.Handler {
|
|
||||||
return e.router
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (e *Router) logRequest(r *http.Request, bodyBytes []byte, statusCode int) {}
|
|
||||||
|
|
@ -1,39 +1,53 @@
|
||||||
package spot
|
package spot
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"openreplay/backend/pkg/metrics/web"
|
||||||
|
"openreplay/backend/pkg/server/tracer"
|
||||||
|
"time"
|
||||||
|
|
||||||
"openreplay/backend/internal/config/spot"
|
"openreplay/backend/internal/config/spot"
|
||||||
"openreplay/backend/pkg/db/postgres/pool"
|
"openreplay/backend/pkg/db/postgres/pool"
|
||||||
"openreplay/backend/pkg/flakeid"
|
"openreplay/backend/pkg/flakeid"
|
||||||
"openreplay/backend/pkg/logger"
|
"openreplay/backend/pkg/logger"
|
||||||
"openreplay/backend/pkg/objectstorage"
|
|
||||||
"openreplay/backend/pkg/objectstorage/store"
|
"openreplay/backend/pkg/objectstorage/store"
|
||||||
"openreplay/backend/pkg/spot/auth"
|
"openreplay/backend/pkg/server/api"
|
||||||
|
"openreplay/backend/pkg/server/auth"
|
||||||
|
"openreplay/backend/pkg/server/keys"
|
||||||
|
"openreplay/backend/pkg/server/limiter"
|
||||||
|
spotAPI "openreplay/backend/pkg/spot/api"
|
||||||
"openreplay/backend/pkg/spot/service"
|
"openreplay/backend/pkg/spot/service"
|
||||||
"openreplay/backend/pkg/spot/transcoder"
|
"openreplay/backend/pkg/spot/transcoder"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ServicesBuilder struct {
|
type ServicesBuilder struct {
|
||||||
Flaker *flakeid.Flaker
|
Auth auth.Auth
|
||||||
ObjStorage objectstorage.ObjectStorage
|
RateLimiter *limiter.UserRateLimiter
|
||||||
Auth auth.Auth
|
AuditTrail tracer.Tracer
|
||||||
Spots service.Spots
|
SpotsAPI api.Handlers
|
||||||
Keys service.Keys
|
|
||||||
Transcoder transcoder.Transcoder
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServiceBuilder(log logger.Logger, cfg *spot.Config, pgconn pool.Pool) (*ServicesBuilder, error) {
|
func NewServiceBuilder(log logger.Logger, cfg *spot.Config, webMetrics web.Web, pgconn pool.Pool) (*ServicesBuilder, error) {
|
||||||
objStore, err := store.NewStore(&cfg.ObjectsConfig)
|
objStore, err := store.NewStore(&cfg.ObjectsConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
flaker := flakeid.NewFlaker(cfg.WorkerID)
|
flaker := flakeid.NewFlaker(cfg.WorkerID)
|
||||||
spots := service.NewSpots(log, pgconn, flaker)
|
spots := service.NewSpots(log, pgconn, flaker)
|
||||||
|
transcoder := transcoder.NewTranscoder(cfg, log, objStore, pgconn, spots)
|
||||||
|
keys := keys.NewKeys(log, pgconn)
|
||||||
|
auditrail, err := tracer.NewTracer(log, pgconn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
responser := api.NewResponser(webMetrics)
|
||||||
|
handlers, err := spotAPI.NewHandlers(log, cfg, responser, spots, objStore, transcoder, keys)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return &ServicesBuilder{
|
return &ServicesBuilder{
|
||||||
Flaker: flaker,
|
Auth: auth.NewAuth(log, cfg.JWTSecret, cfg.JWTSpotSecret, pgconn, keys),
|
||||||
ObjStorage: objStore,
|
RateLimiter: limiter.NewUserRateLimiter(10, 30, 1*time.Minute, 5*time.Minute),
|
||||||
Auth: auth.NewAuth(log, cfg.JWTSecret, cfg.JWTSpotSecret, pgconn),
|
AuditTrail: auditrail,
|
||||||
Spots: spots,
|
SpotsAPI: handlers,
|
||||||
Keys: service.NewKeys(log, pgconn),
|
|
||||||
Transcoder: transcoder.NewTranscoder(cfg, log, objStore, pgconn, spots),
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,12 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"openreplay/backend/pkg/server/user"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"openreplay/backend/pkg/db/postgres/pool"
|
"openreplay/backend/pkg/db/postgres/pool"
|
||||||
"openreplay/backend/pkg/flakeid"
|
"openreplay/backend/pkg/flakeid"
|
||||||
"openreplay/backend/pkg/logger"
|
"openreplay/backend/pkg/logger"
|
||||||
"openreplay/backend/pkg/spot/auth"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const MaxNameLength = 64
|
const MaxNameLength = 64
|
||||||
|
|
@ -58,14 +58,14 @@ type Update struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Spots interface {
|
type Spots interface {
|
||||||
Add(user *auth.User, name, comment string, duration int, crop []int) (*Spot, error)
|
Add(user *user.User, name, comment string, duration int, crop []int) (*Spot, error)
|
||||||
GetByID(user *auth.User, spotID uint64) (*Spot, error)
|
GetByID(user *user.User, spotID uint64) (*Spot, error)
|
||||||
Get(user *auth.User, opts *GetOpts) ([]*Spot, uint64, bool, error)
|
Get(user *user.User, opts *GetOpts) ([]*Spot, uint64, bool, error)
|
||||||
UpdateName(user *auth.User, spotID uint64, newName string) (*Spot, error)
|
UpdateName(user *user.User, spotID uint64, newName string) (*Spot, error)
|
||||||
AddComment(user *auth.User, spotID uint64, comment *Comment) (*Spot, error)
|
AddComment(user *user.User, spotID uint64, comment *Comment) (*Spot, error)
|
||||||
Delete(user *auth.User, spotIds []uint64) error
|
Delete(user *user.User, spotIds []uint64) error
|
||||||
SetStatus(spotID uint64, status string) error
|
SetStatus(spotID uint64, status string) error
|
||||||
GetStatus(user *auth.User, spotID uint64) (string, error)
|
GetStatus(user *user.User, spotID uint64) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSpots(log logger.Logger, pgconn pool.Pool, flaker *flakeid.Flaker) Spots {
|
func NewSpots(log logger.Logger, pgconn pool.Pool, flaker *flakeid.Flaker) Spots {
|
||||||
|
|
@ -76,7 +76,7 @@ func NewSpots(log logger.Logger, pgconn pool.Pool, flaker *flakeid.Flaker) Spots
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *spotsImpl) Add(user *auth.User, name, comment string, duration int, crop []int) (*Spot, error) {
|
func (s *spotsImpl) Add(user *user.User, name, comment string, duration int, crop []int) (*Spot, error) {
|
||||||
switch {
|
switch {
|
||||||
case user == nil:
|
case user == nil:
|
||||||
return nil, fmt.Errorf("user is required")
|
return nil, fmt.Errorf("user is required")
|
||||||
|
|
@ -142,7 +142,7 @@ func (s *spotsImpl) add(spot *Spot) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *spotsImpl) GetByID(user *auth.User, spotID uint64) (*Spot, error) {
|
func (s *spotsImpl) GetByID(user *user.User, spotID uint64) (*Spot, error) {
|
||||||
switch {
|
switch {
|
||||||
case user == nil:
|
case user == nil:
|
||||||
return nil, fmt.Errorf("user is required")
|
return nil, fmt.Errorf("user is required")
|
||||||
|
|
@ -152,7 +152,7 @@ func (s *spotsImpl) GetByID(user *auth.User, spotID uint64) (*Spot, error) {
|
||||||
return s.getByID(spotID, user)
|
return s.getByID(spotID, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *spotsImpl) getByID(spotID uint64, user *auth.User) (*Spot, error) {
|
func (s *spotsImpl) getByID(spotID uint64, user *user.User) (*Spot, error) {
|
||||||
sql := `SELECT s.name, u.email, s.duration, s.crop, s.comments, s.created_at
|
sql := `SELECT s.name, u.email, s.duration, s.crop, s.comments, s.created_at
|
||||||
FROM spots.spots s
|
FROM spots.spots s
|
||||||
JOIN public.users u ON s.user_id = u.user_id
|
JOIN public.users u ON s.user_id = u.user_id
|
||||||
|
|
@ -176,7 +176,7 @@ func (s *spotsImpl) getByID(spotID uint64, user *auth.User) (*Spot, error) {
|
||||||
return spot, nil
|
return spot, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *spotsImpl) Get(user *auth.User, opts *GetOpts) ([]*Spot, uint64, bool, error) {
|
func (s *spotsImpl) Get(user *user.User, opts *GetOpts) ([]*Spot, uint64, bool, error) {
|
||||||
switch {
|
switch {
|
||||||
case user == nil:
|
case user == nil:
|
||||||
return nil, 0, false, fmt.Errorf("user is required")
|
return nil, 0, false, fmt.Errorf("user is required")
|
||||||
|
|
@ -200,7 +200,7 @@ func (s *spotsImpl) Get(user *auth.User, opts *GetOpts) ([]*Spot, uint64, bool,
|
||||||
return s.getAll(user, opts)
|
return s.getAll(user, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *spotsImpl) getAll(user *auth.User, opts *GetOpts) ([]*Spot, uint64, bool, error) {
|
func (s *spotsImpl) getAll(user *user.User, opts *GetOpts) ([]*Spot, uint64, bool, error) {
|
||||||
sql := `SELECT COUNT(1) OVER () AS total, s.spot_id, s.name, u.email, s.duration, s.created_at
|
sql := `SELECT COUNT(1) OVER () AS total, s.spot_id, s.name, u.email, s.duration, s.created_at
|
||||||
FROM spots.spots s
|
FROM spots.spots s
|
||||||
JOIN public.users u ON s.user_id = u.user_id
|
JOIN public.users u ON s.user_id = u.user_id
|
||||||
|
|
@ -261,7 +261,7 @@ func (s *spotsImpl) doesTenantHasSpots(tenantID uint64) bool {
|
||||||
return count > 0
|
return count > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *spotsImpl) UpdateName(user *auth.User, spotID uint64, newName string) (*Spot, error) {
|
func (s *spotsImpl) UpdateName(user *user.User, spotID uint64, newName string) (*Spot, error) {
|
||||||
switch {
|
switch {
|
||||||
case user == nil:
|
case user == nil:
|
||||||
return nil, fmt.Errorf("user is required")
|
return nil, fmt.Errorf("user is required")
|
||||||
|
|
@ -276,7 +276,7 @@ func (s *spotsImpl) UpdateName(user *auth.User, spotID uint64, newName string) (
|
||||||
return s.updateName(spotID, newName, user)
|
return s.updateName(spotID, newName, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *spotsImpl) updateName(spotID uint64, newName string, user *auth.User) (*Spot, error) {
|
func (s *spotsImpl) updateName(spotID uint64, newName string, user *user.User) (*Spot, error) {
|
||||||
sql := `WITH updated AS (
|
sql := `WITH updated AS (
|
||||||
UPDATE spots.spots SET name = $1, updated_at = $2
|
UPDATE spots.spots SET name = $1, updated_at = $2
|
||||||
WHERE spot_id = $3 AND tenant_id = $4 AND deleted_at IS NULL RETURNING *)
|
WHERE spot_id = $3 AND tenant_id = $4 AND deleted_at IS NULL RETURNING *)
|
||||||
|
|
@ -291,7 +291,7 @@ func (s *spotsImpl) updateName(spotID uint64, newName string, user *auth.User) (
|
||||||
return &Spot{ID: spotID, Name: newName}, nil
|
return &Spot{ID: spotID, Name: newName}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *spotsImpl) AddComment(user *auth.User, spotID uint64, comment *Comment) (*Spot, error) {
|
func (s *spotsImpl) AddComment(user *user.User, spotID uint64, comment *Comment) (*Spot, error) {
|
||||||
switch {
|
switch {
|
||||||
case user == nil:
|
case user == nil:
|
||||||
return nil, fmt.Errorf("user is required")
|
return nil, fmt.Errorf("user is required")
|
||||||
|
|
@ -311,7 +311,7 @@ func (s *spotsImpl) AddComment(user *auth.User, spotID uint64, comment *Comment)
|
||||||
return s.addComment(spotID, comment, user)
|
return s.addComment(spotID, comment, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *spotsImpl) addComment(spotID uint64, newComment *Comment, user *auth.User) (*Spot, error) {
|
func (s *spotsImpl) addComment(spotID uint64, newComment *Comment, user *user.User) (*Spot, error) {
|
||||||
sql := `WITH updated AS (
|
sql := `WITH updated AS (
|
||||||
UPDATE spots.spots
|
UPDATE spots.spots
|
||||||
SET comments = array_append(comments, $1), updated_at = $2
|
SET comments = array_append(comments, $1), updated_at = $2
|
||||||
|
|
@ -332,7 +332,7 @@ func (s *spotsImpl) addComment(spotID uint64, newComment *Comment, user *auth.Us
|
||||||
return &Spot{ID: spotID}, nil
|
return &Spot{ID: spotID}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *spotsImpl) Delete(user *auth.User, spotIds []uint64) error {
|
func (s *spotsImpl) Delete(user *user.User, spotIds []uint64) error {
|
||||||
switch {
|
switch {
|
||||||
case user == nil:
|
case user == nil:
|
||||||
return fmt.Errorf("user is required")
|
return fmt.Errorf("user is required")
|
||||||
|
|
@ -342,7 +342,7 @@ func (s *spotsImpl) Delete(user *auth.User, spotIds []uint64) error {
|
||||||
return s.deleteSpots(spotIds, user)
|
return s.deleteSpots(spotIds, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *spotsImpl) deleteSpots(spotIds []uint64, user *auth.User) error {
|
func (s *spotsImpl) deleteSpots(spotIds []uint64, user *user.User) error {
|
||||||
sql := `WITH updated AS (UPDATE spots.spots SET deleted_at = NOW() WHERE tenant_id = $1 AND spot_id IN (`
|
sql := `WITH updated AS (UPDATE spots.spots SET deleted_at = NOW() WHERE tenant_id = $1 AND spot_id IN (`
|
||||||
args := []interface{}{user.TenantID}
|
args := []interface{}{user.TenantID}
|
||||||
for i, spotID := range spotIds {
|
for i, spotID := range spotIds {
|
||||||
|
|
@ -378,7 +378,7 @@ func (s *spotsImpl) SetStatus(spotID uint64, status string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *spotsImpl) GetStatus(user *auth.User, spotID uint64) (string, error) {
|
func (s *spotsImpl) GetStatus(user *user.User, spotID uint64) (string, error) {
|
||||||
switch {
|
switch {
|
||||||
case user == nil:
|
case user == nil:
|
||||||
return "", fmt.Errorf("user is required")
|
return "", fmt.Errorf("user is required")
|
||||||
|
|
|
||||||
73
backend/pkg/tags/api/handlers.go
Normal file
73
backend/pkg/tags/api/handlers.go
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"openreplay/backend/pkg/logger"
|
||||||
|
"openreplay/backend/pkg/server/api"
|
||||||
|
"openreplay/backend/pkg/sessions"
|
||||||
|
"openreplay/backend/pkg/tags"
|
||||||
|
"openreplay/backend/pkg/token"
|
||||||
|
)
|
||||||
|
|
||||||
|
type handlersImpl struct {
|
||||||
|
log logger.Logger
|
||||||
|
responser *api.Responser
|
||||||
|
tokenizer *token.Tokenizer
|
||||||
|
sessions sessions.Sessions
|
||||||
|
tags tags.Tags
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandlers(log logger.Logger, responser *api.Responser, tokenizer *token.Tokenizer, sessions sessions.Sessions, tags tags.Tags) (api.Handlers, error) {
|
||||||
|
return &handlersImpl{
|
||||||
|
log: log,
|
||||||
|
responser: responser,
|
||||||
|
tokenizer: tokenizer,
|
||||||
|
sessions: sessions,
|
||||||
|
tags: tags,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *handlersImpl) GetAll() []*api.Description {
|
||||||
|
return []*api.Description{
|
||||||
|
{"/v1/tags", e.getTags, "GET"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *handlersImpl) getTags(w http.ResponseWriter, r *http.Request) {
|
||||||
|
startTime := time.Now()
|
||||||
|
bodySize := 0
|
||||||
|
|
||||||
|
// TODO: move check authorization into middleware (we gonna have 2 different auth middlewares)
|
||||||
|
sessionData, err := e.tokenizer.ParseFromHTTPRequest(r)
|
||||||
|
if sessionData != nil {
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), "sessionID", fmt.Sprintf("%d", sessionData.ID)))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusUnauthorized, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sessInfo, err := e.sessions.Get(sessionData.ID)
|
||||||
|
if err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusUnauthorized, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add sessionID and projectID to context
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), "sessionID", fmt.Sprintf("%d", sessionData.ID)))
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), "projectID", fmt.Sprintf("%d", sessInfo.ProjectID)))
|
||||||
|
|
||||||
|
// Get tags
|
||||||
|
tags, err := e.tags.Get(sessInfo.ProjectID)
|
||||||
|
if err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type UrlResponse struct {
|
||||||
|
Tags interface{} `json:"tags"`
|
||||||
|
}
|
||||||
|
e.responser.ResponseWithJSON(e.log, r.Context(), w, &UrlResponse{Tags: tags}, startTime, r.URL.Path, bodySize)
|
||||||
|
}
|
||||||
211
backend/pkg/uxtesting/api/handlers.go
Normal file
211
backend/pkg/uxtesting/api/handlers.go
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
|
||||||
|
"openreplay/backend/pkg/logger"
|
||||||
|
"openreplay/backend/pkg/objectstorage"
|
||||||
|
"openreplay/backend/pkg/server/api"
|
||||||
|
"openreplay/backend/pkg/sessions"
|
||||||
|
"openreplay/backend/pkg/token"
|
||||||
|
"openreplay/backend/pkg/uxtesting"
|
||||||
|
)
|
||||||
|
|
||||||
|
type handlersImpl struct {
|
||||||
|
log logger.Logger
|
||||||
|
responser *api.Responser
|
||||||
|
jsonSizeLimit int64
|
||||||
|
tokenizer *token.Tokenizer
|
||||||
|
sessions sessions.Sessions
|
||||||
|
uxTesting uxtesting.UXTesting
|
||||||
|
objStorage objectstorage.ObjectStorage
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandlers(log logger.Logger, responser *api.Responser, jsonSizeLimit int64, tokenizer *token.Tokenizer, sessions sessions.Sessions,
|
||||||
|
uxTesting uxtesting.UXTesting, objStorage objectstorage.ObjectStorage) (api.Handlers, error) {
|
||||||
|
return &handlersImpl{
|
||||||
|
log: log,
|
||||||
|
responser: responser,
|
||||||
|
jsonSizeLimit: jsonSizeLimit,
|
||||||
|
tokenizer: tokenizer,
|
||||||
|
sessions: sessions,
|
||||||
|
uxTesting: uxTesting,
|
||||||
|
objStorage: objStorage,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *handlersImpl) GetAll() []*api.Description {
|
||||||
|
return []*api.Description{
|
||||||
|
{"/v1/web/uxt/signals/test", e.sendUXTestSignal, "POST"},
|
||||||
|
{"/v1/web/uxt/signals/task", e.sendUXTaskSignal, "POST"},
|
||||||
|
{"/v1/web/uxt/test/{id}", e.getUXTestInfo, "GET"},
|
||||||
|
{"/v1/web/uxt/upload-url", e.getUXUploadUrl, "GET"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *handlersImpl) getUXTestInfo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
startTime := time.Now()
|
||||||
|
bodySize := 0
|
||||||
|
|
||||||
|
// Check authorization
|
||||||
|
sessionData, err := e.tokenizer.ParseFromHTTPRequest(r)
|
||||||
|
if sessionData != nil {
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), "sessionID", fmt.Sprintf("%d", sessionData.ID)))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusUnauthorized, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sess, err := e.sessions.Get(sessionData.ID)
|
||||||
|
if err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusForbidden, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add projectID to context
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), "projectID", fmt.Sprintf("%d", sess.ProjectID)))
|
||||||
|
|
||||||
|
// Get taskID
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
id := vars["id"]
|
||||||
|
|
||||||
|
// Get task info
|
||||||
|
info, err := e.uxTesting.GetInfo(id)
|
||||||
|
if err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sess.ProjectID != info.ProjectID {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusForbidden, errors.New("project mismatch"), startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type TaskInfoResponse struct {
|
||||||
|
Task *uxtesting.UXTestInfo `json:"test"`
|
||||||
|
}
|
||||||
|
e.responser.ResponseWithJSON(e.log, r.Context(), w, &TaskInfoResponse{Task: info}, startTime, r.URL.Path, bodySize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *handlersImpl) sendUXTestSignal(w http.ResponseWriter, r *http.Request) {
|
||||||
|
startTime := time.Now()
|
||||||
|
bodySize := 0
|
||||||
|
|
||||||
|
// Check authorization
|
||||||
|
sessionData, err := e.tokenizer.ParseFromHTTPRequest(r)
|
||||||
|
if sessionData != nil {
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), "sessionID", fmt.Sprintf("%d", sessionData.ID)))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusUnauthorized, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add sessionID and projectID to context
|
||||||
|
if info, err := e.sessions.Get(sessionData.ID); err == nil {
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), "projectID", fmt.Sprintf("%d", info.ProjectID)))
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := api.ReadBody(e.log, w, r, e.jsonSizeLimit)
|
||||||
|
if err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusRequestEntityTooLarge, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bodySize = len(bodyBytes)
|
||||||
|
|
||||||
|
// Parse request body
|
||||||
|
req := &uxtesting.TestSignal{}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(bodyBytes, req); err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.SessionID = sessionData.ID
|
||||||
|
|
||||||
|
// Save test signal
|
||||||
|
if err := e.uxTesting.SetTestSignal(req); err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.responser.ResponseOK(e.log, r.Context(), w, startTime, r.URL.Path, bodySize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *handlersImpl) sendUXTaskSignal(w http.ResponseWriter, r *http.Request) {
|
||||||
|
startTime := time.Now()
|
||||||
|
bodySize := 0
|
||||||
|
|
||||||
|
// Check authorization
|
||||||
|
sessionData, err := e.tokenizer.ParseFromHTTPRequest(r)
|
||||||
|
if sessionData != nil {
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), "sessionID", fmt.Sprintf("%d", sessionData.ID)))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusUnauthorized, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add sessionID and projectID to context
|
||||||
|
if info, err := e.sessions.Get(sessionData.ID); err == nil {
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), "projectID", fmt.Sprintf("%d", info.ProjectID)))
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := api.ReadBody(e.log, w, r, e.jsonSizeLimit)
|
||||||
|
if err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusRequestEntityTooLarge, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bodySize = len(bodyBytes)
|
||||||
|
|
||||||
|
// Parse request body
|
||||||
|
req := &uxtesting.TaskSignal{}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(bodyBytes, req); err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.SessionID = sessionData.ID
|
||||||
|
|
||||||
|
// Save test signal
|
||||||
|
if err := e.uxTesting.SetTaskSignal(req); err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.responser.ResponseOK(e.log, r.Context(), w, startTime, r.URL.Path, bodySize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *handlersImpl) getUXUploadUrl(w http.ResponseWriter, r *http.Request) {
|
||||||
|
startTime := time.Now()
|
||||||
|
bodySize := 0
|
||||||
|
|
||||||
|
// Check authorization
|
||||||
|
sessionData, err := e.tokenizer.ParseFromHTTPRequest(r)
|
||||||
|
if sessionData != nil {
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), "sessionID", fmt.Sprintf("%d", sessionData.ID)))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusUnauthorized, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add sessionID and projectID to context
|
||||||
|
if info, err := e.sessions.Get(sessionData.ID); err == nil {
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), "projectID", fmt.Sprintf("%d", info.ProjectID)))
|
||||||
|
}
|
||||||
|
|
||||||
|
key := fmt.Sprintf("%d/ux_webcam_record.webm", sessionData.ID)
|
||||||
|
url, err := e.objStorage.GetPreSignedUploadUrl(key)
|
||||||
|
if err != nil {
|
||||||
|
e.responser.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type UrlResponse struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
e.responser.ResponseWithJSON(e.log, r.Context(), w, &UrlResponse{URL: url}, startTime, r.URL.Path, bodySize)
|
||||||
|
}
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
package router
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/gorilla/mux"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (e *Router) getConditions(w http.ResponseWriter, r *http.Request) {
|
|
||||||
startTime := time.Now()
|
|
||||||
bodySize := 0
|
|
||||||
|
|
||||||
// Check authorization
|
|
||||||
_, err := e.services.Tokenizer.ParseFromHTTPRequest(r)
|
|
||||||
if err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusUnauthorized, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get taskID
|
|
||||||
vars := mux.Vars(r)
|
|
||||||
projID := vars["project"]
|
|
||||||
projectID, err := strconv.Atoi(projID)
|
|
||||||
if err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get task info
|
|
||||||
info, err := e.services.Conditions.Get(uint32(projectID))
|
|
||||||
if err != nil {
|
|
||||||
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
e.ResponseWithJSON(r.Context(), w, info, startTime, r.URL.Path, bodySize)
|
|
||||||
}
|
|
||||||
64
ee/backend/pkg/conditions/api/handlers.go
Normal file
64
ee/backend/pkg/conditions/api/handlers.go
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
|
||||||
|
"openreplay/backend/pkg/conditions"
|
||||||
|
"openreplay/backend/pkg/logger"
|
||||||
|
"openreplay/backend/pkg/server/api"
|
||||||
|
"openreplay/backend/pkg/token"
|
||||||
|
)
|
||||||
|
|
||||||
|
type handlersImpl struct {
|
||||||
|
log logger.Logger
|
||||||
|
tokenizer *token.Tokenizer
|
||||||
|
conditions conditions.Conditions
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandlers(log logger.Logger, tokenizer *token.Tokenizer, conditions conditions.Conditions) (api.Handlers, error) {
|
||||||
|
return &handlersImpl{
|
||||||
|
log: log,
|
||||||
|
tokenizer: tokenizer,
|
||||||
|
conditions: conditions,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *handlersImpl) GetAll() []*api.Description {
|
||||||
|
return []*api.Description{
|
||||||
|
{"/v1/web/conditions/{project}", e.getConditions, "GET"},
|
||||||
|
{"/v1/mobile/conditions/{project}", e.getConditions, "GET"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *handlersImpl) getConditions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
startTime := time.Now()
|
||||||
|
bodySize := 0
|
||||||
|
|
||||||
|
// Check authorization
|
||||||
|
_, err := e.tokenizer.ParseFromHTTPRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
api.ResponseWithError(e.log, r.Context(), w, http.StatusUnauthorized, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get taskID
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
projID := vars["project"]
|
||||||
|
projectID, err := strconv.Atoi(projID)
|
||||||
|
if err != nil {
|
||||||
|
api.ResponseWithError(e.log, r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get task info
|
||||||
|
info, err := e.conditions.Get(uint32(projectID))
|
||||||
|
if err != nil {
|
||||||
|
api.ResponseWithError(e.log, r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api.ResponseWithJSON(e.log, r.Context(), w, info, startTime, r.URL.Path, bodySize)
|
||||||
|
}
|
||||||
96
ee/backend/pkg/server/tracer/middleware.go
Normal file
96
ee/backend/pkg/server/tracer/middleware.go
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
package tracer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
|
||||||
|
"openreplay/backend/pkg/server/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
type statusWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
statusCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *statusWriter) WriteHeader(statusCode int) {
|
||||||
|
w.statusCode = statusCode
|
||||||
|
w.ResponseWriter.WriteHeader(statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *statusWriter) Write(b []byte) (int, error) {
|
||||||
|
if w.statusCode == 0 {
|
||||||
|
w.statusCode = http.StatusOK
|
||||||
|
}
|
||||||
|
return w.ResponseWriter.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tracerImpl) Middleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Read body and restore the io.ReadCloser to its original state
|
||||||
|
bodyBytes, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "can't read body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||||
|
// Use custom response writer to get the status code
|
||||||
|
sw := &statusWriter{ResponseWriter: w}
|
||||||
|
// Serve the request
|
||||||
|
next.ServeHTTP(sw, r)
|
||||||
|
t.logRequest(r, bodyBytes, sw.statusCode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var routeMatch = map[string]string{
|
||||||
|
"POST" + "/spot/v1/spots": "createSpot",
|
||||||
|
"GET" + "/spot/v1/spots/{id}": "getSpot",
|
||||||
|
"PATCH" + "/spot/v1/spots/{id}": "updateSpot",
|
||||||
|
"GET" + "/spot/v1/spots": "getSpots",
|
||||||
|
"DELETE" + "/spot/v1/spots": "deleteSpots",
|
||||||
|
"POST" + "/spot/v1/spots/{id}/comment": "addComment",
|
||||||
|
"GET" + "/spot/v1/spots/{id}/video": "getSpotVideo",
|
||||||
|
"PATCH" + "/spot/v1/spots/{id}/public-key": "updatePublicKey",
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tracerImpl) logRequest(r *http.Request, bodyBytes []byte, statusCode int) {
|
||||||
|
pathTemplate, err := mux.CurrentRoute(r).GetPathTemplate()
|
||||||
|
if err != nil {
|
||||||
|
t.log.Error(r.Context(), "failed to get path template: %s", err)
|
||||||
|
}
|
||||||
|
t.log.Debug(r.Context(), "path template: %s", pathTemplate)
|
||||||
|
if _, ok := routeMatch[r.Method+pathTemplate]; !ok {
|
||||||
|
t.log.Debug(r.Context(), "no match for route: %s %s", r.Method, pathTemplate)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Convert the parameters to json
|
||||||
|
query := r.URL.Query()
|
||||||
|
params := make(map[string]interface{})
|
||||||
|
for key, values := range query {
|
||||||
|
if len(values) > 1 {
|
||||||
|
params[key] = values
|
||||||
|
} else {
|
||||||
|
params[key] = values[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jsonData, err := json.Marshal(params)
|
||||||
|
if err != nil {
|
||||||
|
t.log.Error(r.Context(), "failed to marshal query parameters: %s", err)
|
||||||
|
}
|
||||||
|
requestData := &RequestData{
|
||||||
|
Action: routeMatch[r.Method+pathTemplate],
|
||||||
|
Method: r.Method,
|
||||||
|
PathFormat: pathTemplate,
|
||||||
|
Endpoint: r.URL.Path,
|
||||||
|
Payload: bodyBytes,
|
||||||
|
Parameters: jsonData,
|
||||||
|
Status: statusCode,
|
||||||
|
}
|
||||||
|
userData := r.Context().Value("userData").(*user.User)
|
||||||
|
t.trace(userData, requestData)
|
||||||
|
// DEBUG
|
||||||
|
t.log.Debug(r.Context(), "request data: %v", requestData)
|
||||||
|
}
|
||||||
106
ee/backend/pkg/server/tracer/tracer.go
Normal file
106
ee/backend/pkg/server/tracer/tracer.go
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
package tracer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"openreplay/backend/pkg/db/postgres"
|
||||||
|
db "openreplay/backend/pkg/db/postgres/pool"
|
||||||
|
"openreplay/backend/pkg/logger"
|
||||||
|
"openreplay/backend/pkg/pool"
|
||||||
|
"openreplay/backend/pkg/server/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Tracer interface {
|
||||||
|
Middleware(next http.Handler) http.Handler
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
type tracerImpl struct {
|
||||||
|
log logger.Logger
|
||||||
|
conn db.Pool
|
||||||
|
traces postgres.Bulk
|
||||||
|
saver pool.WorkerPool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTracer(log logger.Logger, conn db.Pool) (Tracer, error) {
|
||||||
|
switch {
|
||||||
|
case log == nil:
|
||||||
|
return nil, errors.New("logger is required")
|
||||||
|
case conn == nil:
|
||||||
|
return nil, errors.New("connection is required")
|
||||||
|
}
|
||||||
|
tracer := &tracerImpl{
|
||||||
|
log: log,
|
||||||
|
conn: conn,
|
||||||
|
}
|
||||||
|
if err := tracer.initBulk(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tracer.saver = pool.NewPool(1, 200, tracer.sendTraces)
|
||||||
|
return tracer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tracerImpl) initBulk() (err error) {
|
||||||
|
t.traces, err = postgres.NewBulk(t.conn,
|
||||||
|
"traces",
|
||||||
|
"(user_id, tenant_id, auth, action, method, path_format, endpoint, payload, parameters, status)",
|
||||||
|
"($%d, $%d, $%d, $%d, $%d, $%d, $%d, $%d, $%d, $%d)",
|
||||||
|
10, 50)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Task struct {
|
||||||
|
UserID *uint64
|
||||||
|
TenantID uint64
|
||||||
|
Auth *string
|
||||||
|
Data *RequestData
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tracerImpl) sendTraces(payload interface{}) {
|
||||||
|
rec := payload.(*Task)
|
||||||
|
t.log.Debug(context.Background(), "Sending traces, %v", rec)
|
||||||
|
if err := t.traces.Append(rec.UserID, rec.TenantID, rec.Auth, rec.Data.Action, rec.Data.Method, rec.Data.PathFormat,
|
||||||
|
rec.Data.Endpoint, rec.Data.Payload, rec.Data.Parameters, rec.Data.Status); err != nil {
|
||||||
|
t.log.Error(context.Background(), "can't append trace: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type RequestData struct {
|
||||||
|
Action string
|
||||||
|
Method string
|
||||||
|
PathFormat string
|
||||||
|
Endpoint string
|
||||||
|
Payload []byte
|
||||||
|
Parameters []byte
|
||||||
|
Status int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tracerImpl) trace(user *user.User, data *RequestData) error {
|
||||||
|
switch {
|
||||||
|
case user == nil:
|
||||||
|
return errors.New("user is required")
|
||||||
|
case data == nil:
|
||||||
|
return errors.New("request is required")
|
||||||
|
}
|
||||||
|
trace := &Task{
|
||||||
|
UserID: &user.ID,
|
||||||
|
TenantID: user.TenantID,
|
||||||
|
Auth: &user.AuthMethod,
|
||||||
|
Data: data,
|
||||||
|
}
|
||||||
|
t.saver.Submit(trace)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tracerImpl) Close() error {
|
||||||
|
t.saver.Stop()
|
||||||
|
if err := t.traces.Send(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
package router
|
package web
|
||||||
|
|
||||||
|
type NotStartedRequest struct {
|
||||||
|
ProjectKey *string `json:"projectKey"`
|
||||||
|
TrackerVersion string `json:"trackerVersion"`
|
||||||
|
DoNotTrack bool `json:"DoNotTrack"`
|
||||||
|
}
|
||||||
|
|
||||||
type StartSessionRequest struct {
|
type StartSessionRequest struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
Loading…
Add table
Reference in a new issue