Spots ⚡⚡⚡ (#2305)
* feat(spot): first version to test http endpoints * fix(helm): changed nginx path prefix * fix(spots): added missing BUCKET_NAME env var * fix(spots): added services init check * feat(spots): removed geo module * feat(spots): removed uaparser * feat(spots): added more detailed authorization error log * feat(spots): changed the authorization middleware * feat(spots): extended http body size limit to 128kb * feat(spots): added s3 error log * feat(spots): added new handler for uploaded event * feat(backend): small api changes in spot service * feat(backend): rewrote request parameters grabber for getSpot handler * feat(backend): added tenantID to auth struct * feat(backend): added pre-signed download urls for preview, mob et video files * feat(backend): added user's email to spots table, and getSpot responses * feat(backend): returning spotID as a string * feat(spot): added transcoder pipeline * fix(spot): return spotID as a string * feat(spot): added volume mount to spot service * feat(spot): fixed volume mounting * feat(spot): helm fix * feat(spot): helm another fix * fix(spot): correct video.webm path * fix(spot): correct pre-signed url for download original video * feat(spot): added PATCH and DELETE methods to CORS * feat(spot): use string format for spotIDs in delete method * feat(spot): added public key implemented * fix(spot): correct public-key parser * fix(spot): fixed query params issue + user's tenantID * fix(spot): use 1 as a default tenant * feat(spot): added correct total spots calculation * fix(spot): fixed offset calculation * feat(spot): added extra check in auth method * fix(spot): removed / from video file name * fix(spot): devided codec flag into 2 parts * feat(spot): use fixed tenantID = 1 for oss users * feat(spot): return 404 for public key not found issue * feat(spots): added spots folder to minio path rule * feat(spot): added spot video streaming support * fix(spot): fixed an sql request for spot streams * feat(spot): return playlist file in getSpot responce * feat(spot): try to use aac audio codec * feat(spot): added permissions support (oss/ee) * feat(spot): added authorizer method * feat(spot): added license check * feat(spot): added spot preview for get response * fix(spot): fixed a problem with permissions * feat(spot): added crop feature * feat(spot): upload cropped video back to s3 * feat(spot): manage expired modified playlist file * feat(backend): hack with video formats * feat(backend): removed space * feat(spot): req tracing * feat(spot): manual method's name mapping * feat(spot): added a second method to public key auth support * feat(spot): metrics * feat(spot): added rate limiter per user * feat(spot): added ping endpoint for spot jwt token check * feat(spot): getStatus endpoint * feat(spot): added missing import * feat(spot): transcoding issue fix * feat(spot): temp remove tasks * feat(spot): better error log message * feat(spot): set default jwt_secret value * feat(spot): debug auth * feat(spot): 2 diff jwt tokens support * feat(spot): pg tasks with process status * feat(spot): more logs * feat(spot): improved defer for GetTask method * feat(spot): keep only failed tasks * feat(spot): removing temp dir with spot files * feat(spot): added several workers for transcoding module * feat(spot): fixed spot path for temp video files * feat(spot): use custom statusWriter to track response code in middleware * feat(spot): added body and parameter parser for auditrail feature * feat(spot): fixed IsAuth method signature * feat(spot): fixed ee service builder * feat(spot): added import * feat(spot): fix data type for payload and parameters jsonb fields * feat(spot): typo fix * feat(spot): moved out consts * feat(spot): new table's name * feat(spot): added missing imports in go.mod * feat(spot): added a check for the number of comments (20 by default)
This commit is contained in:
parent
7c52b83a63
commit
345f316b27
47 changed files with 3365 additions and 15 deletions
|
|
@ -103,7 +103,10 @@ ENV TZ=UTC \
|
||||||
COMPRESSION_THRESHOLD="20000" \
|
COMPRESSION_THRESHOLD="20000" \
|
||||||
# Set Access-Control-* headers for tracker requests if true
|
# Set Access-Control-* headers for tracker requests if true
|
||||||
USE_CORS=false \
|
USE_CORS=false \
|
||||||
RECORD_CANVAS=true
|
RECORD_CANVAS=true \
|
||||||
|
JWT_SECRET="SECRET" \
|
||||||
|
JWT_SPOT_SECRET="SECRET" \
|
||||||
|
BUCKET_NAME="spots"
|
||||||
|
|
||||||
|
|
||||||
RUN if [ "$SERVICE_NAME" = "http" ]; then \
|
RUN if [ "$SERVICE_NAME" = "http" ]; then \
|
||||||
|
|
@ -113,7 +116,7 @@ RUN if [ "$SERVICE_NAME" = "http" ]; then \
|
||||||
apk add --no-cache zstd; \
|
apk add --no-cache zstd; \
|
||||||
elif [ "$SERVICE_NAME" = "canvas-handler" ]; then \
|
elif [ "$SERVICE_NAME" = "canvas-handler" ]; then \
|
||||||
apk add --no-cache zstd; \
|
apk add --no-cache zstd; \
|
||||||
elif [ "$SERVICE_NAME" = "canvas-maker" ]; then \
|
elif [ "$SERVICE_NAME" = "spot" ]; then \
|
||||||
apk add --no-cache ffmpeg; \
|
apk add --no-cache ffmpeg; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
|
||||||
60
backend/cmd/spot/main.go
Normal file
60
backend/cmd/spot/main.go
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"openreplay/backend/pkg/spot"
|
||||||
|
"openreplay/backend/pkg/spot/api"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
spotConfig "openreplay/backend/internal/config/spot"
|
||||||
|
"openreplay/backend/internal/http/server"
|
||||||
|
"openreplay/backend/pkg/db/postgres/pool"
|
||||||
|
"openreplay/backend/pkg/logger"
|
||||||
|
"openreplay/backend/pkg/metrics"
|
||||||
|
databaseMetrics "openreplay/backend/pkg/metrics/database"
|
||||||
|
spotMetrics "openreplay/backend/pkg/metrics/spot"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
ctx := context.Background()
|
||||||
|
log := logger.New()
|
||||||
|
cfg := spotConfig.New(log)
|
||||||
|
metrics.New(log, append(spotMetrics.List(), databaseMetrics.List()...))
|
||||||
|
|
||||||
|
pgConn, err := pool.New(cfg.Postgres.String())
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(ctx, "can't init postgres connection: %s", err)
|
||||||
|
}
|
||||||
|
defer pgConn.Close()
|
||||||
|
|
||||||
|
services, err := spot.NewServiceBuilder(log, cfg, pgConn)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(ctx, "can't init services: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
router, err := api.NewRouter(cfg, log, services)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(ctx, "failed while creating router: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
spotServer, err := server.New(router.GetHandler(), cfg.HTTPHost, cfg.HTTPPort, cfg.HTTPTimeout)
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@ require (
|
||||||
github.com/elastic/go-elasticsearch/v7 v7.13.1
|
github.com/elastic/go-elasticsearch/v7 v7.13.1
|
||||||
github.com/elastic/go-elasticsearch/v8 v8.13.0
|
github.com/elastic/go-elasticsearch/v8 v8.13.0
|
||||||
github.com/go-redis/redis v6.15.9+incompatible
|
github.com/go-redis/redis v6.15.9+incompatible
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/mux v1.8.1
|
github.com/gorilla/mux v1.8.1
|
||||||
github.com/jackc/pgconn v1.14.3
|
github.com/jackc/pgconn v1.14.3
|
||||||
|
|
@ -29,6 +30,7 @@ require (
|
||||||
github.com/oschwald/maxminddb-golang v1.7.0
|
github.com/oschwald/maxminddb-golang v1.7.0
|
||||||
github.com/pkg/errors v0.9.1
|
github.com/pkg/errors v0.9.1
|
||||||
github.com/prometheus/client_golang v1.16.0
|
github.com/prometheus/client_golang v1.16.0
|
||||||
|
github.com/rs/xid v1.2.1
|
||||||
github.com/sethvargo/go-envconfig v0.7.0
|
github.com/sethvargo/go-envconfig v0.7.0
|
||||||
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce
|
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce
|
||||||
github.com/ua-parser/uap-go v0.0.0-20200325213135-e1c09f13e2fe
|
github.com/ua-parser/uap-go v0.0.0-20200325213135-e1c09f13e2fe
|
||||||
|
|
|
||||||
|
|
@ -199,6 +199,8 @@ github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69
|
||||||
github.com/golang-jwt/jwt v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfEP4Q1lOd9Z/+c=
|
github.com/golang-jwt/jwt v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfEP4Q1lOd9Z/+c=
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
|
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||||
|
|
@ -460,6 +462,7 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
|
||||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||||
|
github.com/rs/xid v1.2.1 h1:mhH9Nq+C1fY2l1XIpgxIiUOfNpRBYH1kKcr+qfKgjRc=
|
||||||
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
|
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
|
||||||
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
|
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
|
||||||
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
|
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
|
||||||
|
|
|
||||||
37
backend/internal/config/spot/config.go
Normal file
37
backend/internal/config/spot/config.go
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
package spot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"openreplay/backend/internal/config/common"
|
||||||
|
"openreplay/backend/internal/config/configurator"
|
||||||
|
"openreplay/backend/internal/config/objectstorage"
|
||||||
|
"openreplay/backend/internal/config/redis"
|
||||||
|
"openreplay/backend/pkg/env"
|
||||||
|
"openreplay/backend/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
common.Config
|
||||||
|
common.Postgres
|
||||||
|
redis.Redis
|
||||||
|
objectstorage.ObjectsConfig
|
||||||
|
FSDir string `env:"FS_DIR,required"`
|
||||||
|
SpotsDir string `env:"SPOTS_DIR,default=spots"`
|
||||||
|
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
|
||||||
|
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 {
|
||||||
|
cfg := &Config{WorkerID: env.WorkerID()}
|
||||||
|
configurator.Process(log, cfg)
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
@ -18,7 +18,7 @@ type Pool interface {
|
||||||
QueryRow(sql string, args ...interface{}) pgx.Row
|
QueryRow(sql string, args ...interface{}) pgx.Row
|
||||||
Exec(sql string, arguments ...interface{}) error
|
Exec(sql string, arguments ...interface{}) error
|
||||||
SendBatch(b *pgx.Batch) pgx.BatchResults
|
SendBatch(b *pgx.Batch) pgx.BatchResults
|
||||||
Begin() (*_Tx, error)
|
Begin() (*Tx, error)
|
||||||
Close()
|
Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,12 +62,12 @@ func (p *poolImpl) SendBatch(b *pgx.Batch) pgx.BatchResults {
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *poolImpl) Begin() (*_Tx, error) {
|
func (p *poolImpl) Begin() (*Tx, error) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
tx, err := p.conn.Begin(context.Background())
|
tx, err := p.conn.Begin(context.Background())
|
||||||
database.RecordRequestDuration(float64(time.Now().Sub(start).Milliseconds()), "begin", "")
|
database.RecordRequestDuration(float64(time.Now().Sub(start).Milliseconds()), "begin", "")
|
||||||
database.IncreaseTotalRequests("begin", "")
|
database.IncreaseTotalRequests("begin", "")
|
||||||
return &_Tx{tx}, err
|
return &Tx{tx}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *poolImpl) Close() {
|
func (p *poolImpl) Close() {
|
||||||
|
|
@ -91,11 +91,11 @@ func New(url string) (Pool, error) {
|
||||||
|
|
||||||
// TX - start
|
// TX - start
|
||||||
|
|
||||||
type _Tx struct {
|
type Tx struct {
|
||||||
pgx.Tx
|
pgx.Tx
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tx *_Tx) exec(sql string, args ...interface{}) error {
|
func (tx *Tx) TxExec(sql string, args ...interface{}) error {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
_, err := tx.Exec(context.Background(), sql, args...)
|
_, err := tx.Exec(context.Background(), sql, args...)
|
||||||
method, table := methodName(sql)
|
method, table := methodName(sql)
|
||||||
|
|
@ -104,7 +104,16 @@ func (tx *_Tx) exec(sql string, args ...interface{}) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tx *_Tx) rollback() error {
|
func (tx *Tx) TxQueryRow(sql string, args ...interface{}) pgx.Row {
|
||||||
|
start := time.Now()
|
||||||
|
res := tx.QueryRow(context.Background(), sql, args...)
|
||||||
|
method, table := methodName(sql)
|
||||||
|
database.RecordRequestDuration(float64(time.Now().Sub(start).Milliseconds()), method, table)
|
||||||
|
database.IncreaseTotalRequests(method, table)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tx *Tx) TxRollback() error {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
err := tx.Rollback(context.Background())
|
err := tx.Rollback(context.Background())
|
||||||
database.RecordRequestDuration(float64(time.Now().Sub(start).Milliseconds()), "rollback", "")
|
database.RecordRequestDuration(float64(time.Now().Sub(start).Milliseconds()), "rollback", "")
|
||||||
|
|
@ -112,7 +121,7 @@ func (tx *_Tx) rollback() error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tx *_Tx) commit() error {
|
func (tx *Tx) TxCommit() error {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
err := tx.Commit(context.Background())
|
err := tx.Commit(context.Background())
|
||||||
database.RecordRequestDuration(float64(time.Now().Sub(start).Milliseconds()), "commit", "")
|
database.RecordRequestDuration(float64(time.Now().Sub(start).Milliseconds()), "commit", "")
|
||||||
|
|
|
||||||
|
|
@ -9,3 +9,7 @@ var DefaultSizeBuckets = []float64{1, 10, 50, 100, 250, 500, 1000, 2500, 5000, 1
|
||||||
|
|
||||||
// DefaultBuckets is a set of buckets from 1 to 1_000_000 elements
|
// DefaultBuckets is a set of buckets from 1 to 1_000_000 elements
|
||||||
var DefaultBuckets = []float64{1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10_000, 50_000, 100_000, 1_000_000}
|
var DefaultBuckets = []float64{1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10_000, 50_000, 100_000, 1_000_000}
|
||||||
|
|
||||||
|
// VideoSizeBuckets is a set of buckets from 1_000 bytes (~1 Kb) to 500_000_000 bytes (~500 Mb)
|
||||||
|
var VideoSizeBuckets = []float64{1_000, 10_000, 100_000, 1_000_000, 5_000_000, 10_000_000, 20_000_000, 30_000_000,
|
||||||
|
40_000_000, 50_000_000, 75_000_000, 100_000_000, 250_000_000, 500_000_000}
|
||||||
|
|
|
||||||
194
backend/pkg/metrics/spot/spot.go
Normal file
194
backend/pkg/metrics/spot/spot.go
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
package spot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
|
||||||
|
"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(
|
||||||
|
prometheus.HistogramOpts{
|
||||||
|
Namespace: "spot",
|
||||||
|
Name: "original_video_size_bytes",
|
||||||
|
Help: "A histogram displaying the size of each original video in bytes.",
|
||||||
|
Buckets: common.VideoSizeBuckets,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
func RecordOriginalVideoSize(size float64) {
|
||||||
|
spotOriginalVideoSize.Observe(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
var spotCroppedVideoSize = prometheus.NewHistogram(
|
||||||
|
prometheus.HistogramOpts{
|
||||||
|
Namespace: "spot",
|
||||||
|
Name: "cropped_video_size_bytes",
|
||||||
|
Help: "A histogram displaying the size of each cropped video in bytes.",
|
||||||
|
Buckets: common.VideoSizeBuckets,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
func RecordCroppedVideoSize(size float64) {
|
||||||
|
spotCroppedVideoSize.Observe(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
var spotVideosTotal = prometheus.NewCounter(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Namespace: "spot",
|
||||||
|
Name: "videos_total",
|
||||||
|
Help: "A counter displaying the total number of all processed videos.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
func IncreaseVideosTotal() {
|
||||||
|
spotVideosTotal.Inc()
|
||||||
|
}
|
||||||
|
|
||||||
|
var spotVideosCropped = prometheus.NewCounter(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Namespace: "spot",
|
||||||
|
Name: "videos_cropped_total",
|
||||||
|
Help: "A counter displaying the total number of all cropped videos.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
func IncreaseVideosCropped() {
|
||||||
|
spotVideosCropped.Inc()
|
||||||
|
}
|
||||||
|
|
||||||
|
var spotVideosTranscoded = prometheus.NewCounter(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Namespace: "spot",
|
||||||
|
Name: "videos_transcoded_total",
|
||||||
|
Help: "A counter displaying the total number of all transcoded videos.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
func IncreaseVideosTranscoded() {
|
||||||
|
spotVideosTranscoded.Inc()
|
||||||
|
}
|
||||||
|
|
||||||
|
var spotOriginalVideoDownloadDuration = prometheus.NewHistogram(
|
||||||
|
prometheus.HistogramOpts{
|
||||||
|
Namespace: "spot",
|
||||||
|
Name: "original_video_download_duration_seconds",
|
||||||
|
Help: "A histogram displaying the duration of downloading each original video in seconds.",
|
||||||
|
Buckets: common.DefaultDurationBuckets,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
func RecordOriginalVideoDownloadDuration(durMillis float64) {
|
||||||
|
spotOriginalVideoDownloadDuration.Observe(durMillis / 1000.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var spotCroppingDuration = prometheus.NewHistogram(
|
||||||
|
prometheus.HistogramOpts{
|
||||||
|
Namespace: "spot",
|
||||||
|
Name: "cropping_duration_seconds",
|
||||||
|
Help: "A histogram displaying the duration of cropping each video in seconds.",
|
||||||
|
Buckets: common.DefaultDurationBuckets,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
func RecordCroppingDuration(durMillis float64) {
|
||||||
|
spotCroppingDuration.Observe(durMillis / 1000.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var spotCroppedVideoUploadDuration = prometheus.NewHistogram(
|
||||||
|
prometheus.HistogramOpts{
|
||||||
|
Namespace: "spot",
|
||||||
|
Name: "cropped_video_upload_duration_seconds",
|
||||||
|
Help: "A histogram displaying the duration of uploading each cropped video in seconds.",
|
||||||
|
Buckets: common.DefaultDurationBuckets,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
func RecordCroppedVideoUploadDuration(durMillis float64) {
|
||||||
|
spotCroppedVideoUploadDuration.Observe(durMillis / 1000.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var spotTranscodingDuration = prometheus.NewHistogram(
|
||||||
|
prometheus.HistogramOpts{
|
||||||
|
Namespace: "spot",
|
||||||
|
Name: "transcoding_duration_seconds",
|
||||||
|
Help: "A histogram displaying the duration of transcoding each video in seconds.",
|
||||||
|
Buckets: common.DefaultDurationBuckets,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
func RecordTranscodingDuration(durMillis float64) {
|
||||||
|
spotTranscodingDuration.Observe(durMillis / 1000.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var spotTranscodedVideoUploadDuration = prometheus.NewHistogram(
|
||||||
|
prometheus.HistogramOpts{
|
||||||
|
Namespace: "spot",
|
||||||
|
Name: "transcoded_video_upload_duration_seconds",
|
||||||
|
Help: "A histogram displaying the duration of uploading each transcoded video in seconds.",
|
||||||
|
Buckets: common.DefaultDurationBuckets,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
func RecordTranscodedVideoUploadDuration(durMillis float64) {
|
||||||
|
spotTranscodedVideoUploadDuration.Observe(durMillis / 1000.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func List() []prometheus.Collector {
|
||||||
|
return []prometheus.Collector{
|
||||||
|
spotRequestSize,
|
||||||
|
spotRequestDuration,
|
||||||
|
spotTotalRequests,
|
||||||
|
spotOriginalVideoSize,
|
||||||
|
spotCroppedVideoSize,
|
||||||
|
spotVideosTotal,
|
||||||
|
spotVideosCropped,
|
||||||
|
spotVideosTranscoded,
|
||||||
|
spotOriginalVideoDownloadDuration,
|
||||||
|
spotCroppingDuration,
|
||||||
|
spotCroppedVideoUploadDuration,
|
||||||
|
spotTranscodingDuration,
|
||||||
|
spotTranscodedVideoUploadDuration,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,4 +20,5 @@ type ObjectStorage interface {
|
||||||
Exists(key string) bool
|
Exists(key string) bool
|
||||||
GetCreationTime(key string) *time.Time
|
GetCreationTime(key string) *time.Time
|
||||||
GetPreSignedUploadUrl(key string) (string, error)
|
GetPreSignedUploadUrl(key string) (string, error)
|
||||||
|
GetPreSignedDownloadUrl(key string) (string, error)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -206,3 +206,15 @@ func (s *storageImpl) GetPreSignedUploadUrl(key string) (string, error) {
|
||||||
}
|
}
|
||||||
return urlStr, nil
|
return urlStr, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *storageImpl) GetPreSignedDownloadUrl(key string) (string, error) {
|
||||||
|
req, _ := s.svc.GetObjectRequest(&s3.GetObjectInput{
|
||||||
|
Bucket: aws.String(*s.bucket),
|
||||||
|
Key: aws.String(key),
|
||||||
|
})
|
||||||
|
urlStr, err := req.Presign(15 * time.Minute)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return urlStr, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,11 @@ type task struct {
|
||||||
toStop bool
|
toStop bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTask(payload interface{}) *task {
|
func newTask(payload interface{}) *task {
|
||||||
return &task{Payload: payload}
|
return &task{Payload: payload}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewStopSignal() *task {
|
func newStopSignal() *task {
|
||||||
return &task{toStop: true}
|
return &task{toStop: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -28,7 +28,7 @@ type WorkerPool interface {
|
||||||
Stop()
|
Stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPool(numberOfWorkers, queueSize int, handler func(payload interface{})) *workerPoolImpl {
|
func NewPool(numberOfWorkers, queueSize int, handler func(payload interface{})) WorkerPool {
|
||||||
pool := &workerPoolImpl{
|
pool := &workerPoolImpl{
|
||||||
wg: &sync.WaitGroup{},
|
wg: &sync.WaitGroup{},
|
||||||
tasks: make(chan *task, queueSize),
|
tasks: make(chan *task, queueSize),
|
||||||
|
|
@ -47,12 +47,12 @@ func (p *workerPoolImpl) runWorkers() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *workerPoolImpl) Submit(payload interface{}) {
|
func (p *workerPoolImpl) Submit(payload interface{}) {
|
||||||
p.tasks <- NewTask(payload)
|
p.tasks <- newTask(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *workerPoolImpl) stop() {
|
func (p *workerPoolImpl) stop() {
|
||||||
for i := 0; i < p.numberOfWorkers; i++ {
|
for i := 0; i < p.numberOfWorkers; i++ {
|
||||||
p.tasks <- NewStopSignal()
|
p.tasks <- newStopSignal()
|
||||||
}
|
}
|
||||||
p.wg.Wait()
|
p.wg.Wait()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
605
backend/pkg/spot/api/handlers.go
Normal file
605
backend/pkg/spot/api/handlers.go
Normal file
|
|
@ -0,0 +1,605 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
|
||||||
|
metrics "openreplay/backend/pkg/metrics/spot"
|
||||||
|
"openreplay/backend/pkg/objectstorage"
|
||||||
|
"openreplay/backend/pkg/spot/auth"
|
||||||
|
"openreplay/backend/pkg/spot/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (e *Router) createSpot(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)
|
||||||
|
|
||||||
|
req := &CreateSpotRequest{}
|
||||||
|
if err := json.Unmarshal(bodyBytes, req); err != nil {
|
||||||
|
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creat a spot
|
||||||
|
currUser := r.Context().Value("userData").(*auth.User)
|
||||||
|
newSpot, err := e.services.Spots.Add(currUser, req.Name, req.Comment, req.Duration, req.Crop)
|
||||||
|
if err != nil {
|
||||||
|
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and upload preview image
|
||||||
|
previewImage, err := getSpotPreview(req.Preview)
|
||||||
|
if err != nil {
|
||||||
|
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
previewName := fmt.Sprintf("%d/preview.jpeg", newSpot.ID)
|
||||||
|
if err = e.services.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.ResponseWithError(r.Context(), w, http.StatusInternalServerError, errors.New("can't upload preview image"), startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mobURL, err := e.getUploadMobURL(newSpot.ID)
|
||||||
|
if err != nil {
|
||||||
|
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
videoURL, err := e.getUploadVideoURL(newSpot.ID)
|
||||||
|
if err != nil {
|
||||||
|
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &CreateSpotResponse{
|
||||||
|
ID: strconv.Itoa(int(newSpot.ID)),
|
||||||
|
MobURL: mobURL,
|
||||||
|
VideoURL: videoURL,
|
||||||
|
}
|
||||||
|
e.ResponseWithJSON(r.Context(), w, resp, startTime, r.URL.Path, bodySize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSpotPreview(preview string) ([]byte, error) {
|
||||||
|
parts := strings.Split(preview, ",")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return nil, fmt.Errorf("invalid preview format")
|
||||||
|
}
|
||||||
|
base64Str := parts[1]
|
||||||
|
|
||||||
|
data, err := base64.StdEncoding.DecodeString(base64Str)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("can't decode base64 preview: %s", err)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Router) getUploadMobURL(spotID uint64) (string, error) {
|
||||||
|
mobKey := fmt.Sprintf("%d/events.mob", spotID)
|
||||||
|
mobURL, err := e.services.ObjStorage.GetPreSignedUploadUrl(mobKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("can't get mob URL: %s", err)
|
||||||
|
}
|
||||||
|
return mobURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Router) getUploadVideoURL(spotID uint64) (string, error) {
|
||||||
|
mobKey := fmt.Sprintf("%d/video.webm", spotID)
|
||||||
|
mobURL, err := e.services.ObjStorage.GetPreSignedUploadUrl(mobKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("can't get video URL: %s", err)
|
||||||
|
}
|
||||||
|
return mobURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSpotID(r *http.Request) (uint64, error) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
idStr := vars["id"]
|
||||||
|
if idStr == "" {
|
||||||
|
return 0, fmt.Errorf("empty spot id")
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("invalid spot id")
|
||||||
|
}
|
||||||
|
if id <= 0 {
|
||||||
|
return 0, fmt.Errorf("invalid spot id")
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSpotsRequest(r *http.Request) (*GetSpotsRequest, error) {
|
||||||
|
params := r.URL.Query()
|
||||||
|
page := params.Get("page")
|
||||||
|
limit := params.Get("limit")
|
||||||
|
pageNum, _ := strconv.ParseUint(page, 10, 64)
|
||||||
|
limitNum, _ := strconv.ParseUint(limit, 10, 64)
|
||||||
|
req := &GetSpotsRequest{
|
||||||
|
Query: params.Get("query"),
|
||||||
|
FilterBy: params.Get("filterBy"),
|
||||||
|
Order: params.Get("order"),
|
||||||
|
Page: pageNum,
|
||||||
|
Limit: limitNum,
|
||||||
|
}
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Router) getPreviewURL(spotID uint64) (string, error) {
|
||||||
|
previewKey := fmt.Sprintf("%d/preview.jpeg", spotID)
|
||||||
|
previewURL, err := e.services.ObjStorage.GetPreSignedDownloadUrl(previewKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("can't get preview URL: %s", err)
|
||||||
|
}
|
||||||
|
return previewURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Router) getMobURL(spotID uint64) (string, error) {
|
||||||
|
mobKey := fmt.Sprintf("%d/events.mob", spotID)
|
||||||
|
mobURL, err := e.services.ObjStorage.GetPreSignedDownloadUrl(mobKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("can't get mob URL: %s", err)
|
||||||
|
}
|
||||||
|
return mobURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Router) getVideoURL(spotID uint64) (string, error) {
|
||||||
|
mobKey := fmt.Sprintf("%d/video.webm", spotID) // TODO: later return url to m3u8 file
|
||||||
|
mobURL, err := e.services.ObjStorage.GetPreSignedDownloadUrl(mobKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("can't get video URL: %s", err)
|
||||||
|
}
|
||||||
|
return mobURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Router) getSpot(w http.ResponseWriter, r *http.Request) {
|
||||||
|
startTime := time.Now()
|
||||||
|
bodySize := 0
|
||||||
|
|
||||||
|
id, err := getSpotID(r)
|
||||||
|
if err != nil {
|
||||||
|
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := r.Context().Value("userData").(*auth.User)
|
||||||
|
res, err := e.services.Spots.GetByID(user, id)
|
||||||
|
if err != nil {
|
||||||
|
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if res == nil {
|
||||||
|
e.ResponseWithError(r.Context(), w, http.StatusNotFound, fmt.Errorf("spot not found"), startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
previewUrl, err := e.getPreviewURL(id)
|
||||||
|
if err != nil {
|
||||||
|
e.log.Error(r.Context(), "can't get preview URL: %s", err)
|
||||||
|
}
|
||||||
|
mobURL, err := e.getMobURL(id)
|
||||||
|
if err != nil {
|
||||||
|
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
videoURL, err := e.getVideoURL(id)
|
||||||
|
if err != nil {
|
||||||
|
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
spotInfo := &Info{
|
||||||
|
Name: res.Name,
|
||||||
|
UserEmail: res.UserEmail,
|
||||||
|
Duration: res.Duration,
|
||||||
|
Comments: res.Comments,
|
||||||
|
CreatedAt: res.CreatedAt,
|
||||||
|
PreviewURL: previewUrl,
|
||||||
|
MobURL: mobURL,
|
||||||
|
VideoURL: videoURL,
|
||||||
|
}
|
||||||
|
playlist, err := e.services.Transcoder.GetSpotStreamPlaylist(id)
|
||||||
|
if err != nil {
|
||||||
|
e.log.Warn(r.Context(), "can't get stream playlist: %s", err)
|
||||||
|
} else {
|
||||||
|
spotInfo.StreamFile = base64.StdEncoding.EncodeToString(playlist)
|
||||||
|
}
|
||||||
|
|
||||||
|
e.ResponseWithJSON(r.Context(), w, &GetSpotResponse{Spot: spotInfo}, startTime, r.URL.Path, bodySize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Router) updateSpot(w http.ResponseWriter, r *http.Request) {
|
||||||
|
startTime := time.Now()
|
||||||
|
bodySize := 0
|
||||||
|
|
||||||
|
id, err := getSpotID(r)
|
||||||
|
if err != nil {
|
||||||
|
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, 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 := &UpdateSpotRequest{}
|
||||||
|
if err := json.Unmarshal(bodyBytes, req); err != nil {
|
||||||
|
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := r.Context().Value("userData").(*auth.User)
|
||||||
|
_, err = e.services.Spots.UpdateName(user, id, req.Name)
|
||||||
|
if 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) getSpots(w http.ResponseWriter, r *http.Request) {
|
||||||
|
startTime := time.Now()
|
||||||
|
bodySize := 0
|
||||||
|
|
||||||
|
req, err := getSpotsRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := r.Context().Value("userData").(*auth.User)
|
||||||
|
opts := &service.GetOpts{
|
||||||
|
NameFilter: req.Query, Order: req.Order, Page: req.Page, Limit: req.Limit}
|
||||||
|
switch req.FilterBy {
|
||||||
|
case "own":
|
||||||
|
opts.UserID = user.ID
|
||||||
|
default:
|
||||||
|
opts.TenantID = user.TenantID
|
||||||
|
}
|
||||||
|
spots, total, err := e.services.Spots.Get(user, opts)
|
||||||
|
if err != nil {
|
||||||
|
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res := make([]ShortInfo, 0, len(spots))
|
||||||
|
for _, spot := range spots {
|
||||||
|
previewUrl, err := e.getPreviewURL(spot.ID)
|
||||||
|
if err != nil {
|
||||||
|
e.log.Error(r.Context(), "can't get preview URL: %s", err)
|
||||||
|
}
|
||||||
|
res = append(res, ShortInfo{
|
||||||
|
ID: strconv.Itoa(int(spot.ID)),
|
||||||
|
Name: spot.Name,
|
||||||
|
UserEmail: spot.UserEmail,
|
||||||
|
Duration: spot.Duration,
|
||||||
|
CreatedAt: spot.CreatedAt,
|
||||||
|
PreviewURL: previewUrl,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
e.ResponseWithJSON(r.Context(), w, &GetSpotsResponse{Spots: res, Total: total}, startTime, r.URL.Path, bodySize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Router) deleteSpots(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)
|
||||||
|
|
||||||
|
req := &DeleteSpotRequest{}
|
||||||
|
if err := json.Unmarshal(bodyBytes, req); err != nil {
|
||||||
|
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
spotsToDelete := make([]uint64, 0, len(req.SpotIDs))
|
||||||
|
for _, idStr := range req.SpotIDs {
|
||||||
|
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, fmt.Errorf("invalid spot id: %s", idStr), startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
spotsToDelete = append(spotsToDelete, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
user := r.Context().Value("userData").(*auth.User)
|
||||||
|
if err := e.services.Spots.Delete(user, spotsToDelete); 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) addComment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
startTime := time.Now()
|
||||||
|
bodySize := 0
|
||||||
|
|
||||||
|
id, err := getSpotID(r)
|
||||||
|
if err != nil {
|
||||||
|
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, 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 := &AddCommentRequest{}
|
||||||
|
if err := json.Unmarshal(bodyBytes, req); err != nil {
|
||||||
|
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := r.Context().Value("userData").(*auth.User)
|
||||||
|
updatedSpot, err := e.services.Spots.AddComment(user, id, &service.Comment{UserName: req.UserName, Text: req.Comment})
|
||||||
|
if err != nil {
|
||||||
|
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mobURL, err := e.getMobURL(id)
|
||||||
|
if err != nil {
|
||||||
|
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
videoURL, err := e.getVideoURL(id)
|
||||||
|
if err != nil {
|
||||||
|
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
spotInfo := &Info{
|
||||||
|
Name: updatedSpot.Name,
|
||||||
|
Duration: updatedSpot.Duration,
|
||||||
|
Comments: updatedSpot.Comments,
|
||||||
|
CreatedAt: updatedSpot.CreatedAt,
|
||||||
|
MobURL: mobURL,
|
||||||
|
VideoURL: videoURL,
|
||||||
|
}
|
||||||
|
e.ResponseWithJSON(r.Context(), w, &GetSpotResponse{Spot: spotInfo}, startTime, r.URL.Path, bodySize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Router) uploadedSpot(w http.ResponseWriter, r *http.Request) {
|
||||||
|
startTime := time.Now()
|
||||||
|
bodySize := 0
|
||||||
|
|
||||||
|
id, err := getSpotID(r)
|
||||||
|
if err != nil {
|
||||||
|
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := r.Context().Value("userData").(*auth.User)
|
||||||
|
spot, err := e.services.Spots.GetByID(user, id) // check if spot exists
|
||||||
|
if err != nil {
|
||||||
|
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.log.Info(r.Context(), "uploaded spot %+v, from user: %+v", spot, user)
|
||||||
|
if err := e.services.Transcoder.Process(spot); err != nil {
|
||||||
|
e.log.Error(r.Context(), "can't add transcoding task: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
e.ResponseOK(r.Context(), w, startTime, r.URL.Path, bodySize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Router) getSpotVideo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
startTime := time.Now()
|
||||||
|
bodySize := 0
|
||||||
|
|
||||||
|
id, err := getSpotID(r)
|
||||||
|
if err != nil {
|
||||||
|
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
key := fmt.Sprintf("%d/video.webm", id)
|
||||||
|
videoURL, err := e.services.ObjStorage.GetPreSignedDownloadUrl(key)
|
||||||
|
if err != nil {
|
||||||
|
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := map[string]interface{}{
|
||||||
|
"url": videoURL,
|
||||||
|
}
|
||||||
|
e.ResponseWithJSON(r.Context(), w, resp, startTime, r.URL.Path, bodySize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Router) getSpotStream(w http.ResponseWriter, r *http.Request) {
|
||||||
|
startTime := time.Now()
|
||||||
|
bodySize := 0
|
||||||
|
|
||||||
|
id, err := getSpotID(r)
|
||||||
|
if err != nil {
|
||||||
|
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example data to serve as the file content
|
||||||
|
streamPlaylist, err := e.services.Transcoder.GetSpotStreamPlaylist(id)
|
||||||
|
if err != nil {
|
||||||
|
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a buffer with the file content
|
||||||
|
buffer := bytes.NewBuffer(streamPlaylist)
|
||||||
|
|
||||||
|
// Set the headers for the response
|
||||||
|
w.Header().Set("Content-Disposition", "attachment; filename=index.m3u8")
|
||||||
|
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl") //"application/octet-stream")
|
||||||
|
w.Header().Set("Content-Length", string(len(streamPlaylist)))
|
||||||
|
|
||||||
|
// Write the content of the buffer to the response writer
|
||||||
|
if _, err := buffer.WriteTo(w); err != nil {
|
||||||
|
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Router) getPublicKey(w http.ResponseWriter, r *http.Request) {
|
||||||
|
startTime := time.Now()
|
||||||
|
bodySize := 0
|
||||||
|
|
||||||
|
id, err := getSpotID(r)
|
||||||
|
if err != nil {
|
||||||
|
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := r.Context().Value("userData").(*auth.User)
|
||||||
|
key, err := e.services.Keys.Get(id, user)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "not found") {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
resp := map[string]interface{}{
|
||||||
|
"key": key,
|
||||||
|
}
|
||||||
|
e.ResponseWithJSON(r.Context(), w, resp, startTime, r.URL.Path, bodySize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Router) updatePublicKey(w http.ResponseWriter, r *http.Request) {
|
||||||
|
startTime := time.Now()
|
||||||
|
bodySize := 0
|
||||||
|
|
||||||
|
id, err := getSpotID(r)
|
||||||
|
if err != nil {
|
||||||
|
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, 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 := &UpdateSpotPublicKeyRequest{}
|
||||||
|
if err := json.Unmarshal(bodyBytes, req); err != nil {
|
||||||
|
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := r.Context().Value("userData").(*auth.User)
|
||||||
|
key, err := e.services.Keys.Set(id, req.Expiration, user)
|
||||||
|
if err != nil {
|
||||||
|
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp := map[string]interface{}{
|
||||||
|
"key": key,
|
||||||
|
}
|
||||||
|
e.ResponseWithJSON(r.Context(), w, resp, startTime, r.URL.Path, bodySize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Router) spotStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
startTime := time.Now()
|
||||||
|
bodySize := 0
|
||||||
|
|
||||||
|
id, err := getSpotID(r)
|
||||||
|
if err != nil {
|
||||||
|
e.ResponseWithError(r.Context(), w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := r.Context().Value("userData").(*auth.User)
|
||||||
|
status, err := e.services.Spots.GetStatus(user, id)
|
||||||
|
if err != nil {
|
||||||
|
e.ResponseWithError(r.Context(), w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp := map[string]interface{}{
|
||||||
|
"status": status,
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
88
backend/pkg/spot/api/limiter.go
Normal file
88
backend/pkg/spot/api/limiter.go
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RateLimiter struct {
|
||||||
|
rate int
|
||||||
|
burst int
|
||||||
|
tokens int
|
||||||
|
lastToken time.Time
|
||||||
|
lastUsed time.Time
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRateLimiter(rate int, burst int) *RateLimiter {
|
||||||
|
return &RateLimiter{
|
||||||
|
rate: rate,
|
||||||
|
burst: burst,
|
||||||
|
tokens: burst,
|
||||||
|
lastToken: time.Now(),
|
||||||
|
lastUsed: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rl *RateLimiter) Allow() bool {
|
||||||
|
rl.mu.Lock()
|
||||||
|
defer rl.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
elapsed := now.Sub(rl.lastToken)
|
||||||
|
|
||||||
|
rl.tokens += int(elapsed.Seconds()) * rl.rate
|
||||||
|
if rl.tokens > rl.burst {
|
||||||
|
rl.tokens = rl.burst
|
||||||
|
}
|
||||||
|
|
||||||
|
rl.lastToken = now
|
||||||
|
rl.lastUsed = now
|
||||||
|
|
||||||
|
if rl.tokens > 0 {
|
||||||
|
rl.tokens--
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserRateLimiter struct {
|
||||||
|
rateLimiters sync.Map
|
||||||
|
rate int
|
||||||
|
burst int
|
||||||
|
cleanupInterval time.Duration
|
||||||
|
maxIdleTime time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserRateLimiter(rate int, burst int, cleanupInterval time.Duration, maxIdleTime time.Duration) *UserRateLimiter {
|
||||||
|
url := &UserRateLimiter{
|
||||||
|
rate: rate,
|
||||||
|
burst: burst,
|
||||||
|
cleanupInterval: cleanupInterval,
|
||||||
|
maxIdleTime: maxIdleTime,
|
||||||
|
}
|
||||||
|
go url.cleanup()
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
func (url *UserRateLimiter) GetRateLimiter(user uint64) *RateLimiter {
|
||||||
|
value, _ := url.rateLimiters.LoadOrStore(user, NewRateLimiter(url.rate, url.burst))
|
||||||
|
return value.(*RateLimiter)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (url *UserRateLimiter) cleanup() {
|
||||||
|
for {
|
||||||
|
time.Sleep(url.cleanupInterval)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
url.rateLimiters.Range(func(key, value interface{}) bool {
|
||||||
|
rl := value.(*RateLimiter)
|
||||||
|
rl.mu.Lock()
|
||||||
|
if now.Sub(rl.lastUsed) > url.maxIdleTime {
|
||||||
|
url.rateLimiters.Delete(key)
|
||||||
|
}
|
||||||
|
rl.mu.Unlock()
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
75
backend/pkg/spot/api/model.go
Normal file
75
backend/pkg/spot/api/model.go
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"openreplay/backend/pkg/spot/service"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CreateSpotRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Comment string `json:"comment"`
|
||||||
|
Duration int `json:"duration"`
|
||||||
|
Crop []int `json:"crop"`
|
||||||
|
Preview string `json:"preview"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateSpotResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
MobURL string `json:"mobURL"`
|
||||||
|
VideoURL string `json:"videoURL"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Info struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
UserEmail string `json:"userEmail"`
|
||||||
|
Duration int `json:"duration"`
|
||||||
|
Comments []service.Comment `json:"comments"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
MobURL string `json:"mobURL"`
|
||||||
|
PreviewURL string `json:"previewURL"`
|
||||||
|
VideoURL string `json:"videoURL"`
|
||||||
|
StreamFile string `json:"streamFile"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetSpotResponse struct {
|
||||||
|
Spot *Info `json:"spot"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetSpotsRequest struct {
|
||||||
|
Query string `json:"query"` // for search by name (optional)
|
||||||
|
FilterBy string `json:"filterBy"` // "own", "all", "shared"
|
||||||
|
Order string `json:"order"`
|
||||||
|
Page uint64 `json:"page"`
|
||||||
|
Limit uint64 `json:"limit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShortInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
UserEmail string `json:"userEmail"`
|
||||||
|
Duration int `json:"duration"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
PreviewURL string `json:"previewURL"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetSpotsResponse struct {
|
||||||
|
Spots []ShortInfo `json:"spots"`
|
||||||
|
Total uint64 `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateSpotRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddCommentRequest struct {
|
||||||
|
UserName string `json:"userName"`
|
||||||
|
Comment string `json:"comment"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeleteSpotRequest struct {
|
||||||
|
SpotIDs []string `json:"spotIDs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateSpotPublicKeyRequest struct {
|
||||||
|
Expiration uint64 `json:"expiration"` // in seconds
|
||||||
|
}
|
||||||
5
backend/pkg/spot/api/permissions.go
Normal file
5
backend/pkg/spot/api/permissions.go
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
func getPermissions(urlPath string) []string {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
205
backend/pkg/spot/api/router.go
Normal file
205
backend/pkg/spot/api/router.go
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
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.root)
|
||||||
|
|
||||||
|
// 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) root(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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
|
||||||
|
if (pathTemplate == getSpotPrefix && r.Method == "GET") || (pathTemplate == addCommentPrefix && r.Method == "POST") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Router) rateLimitMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
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) {
|
||||||
|
// 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
|
||||||
|
}
|
||||||
7
backend/pkg/spot/api/tracer.go
Normal file
7
backend/pkg/spot/api/tracer.go
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (e *Router) logRequest(r *http.Request, bodyBytes []byte, statusCode int) {}
|
||||||
53
backend/pkg/spot/auth/auth.go
Normal file
53
backend/pkg/spot/auth/auth.go
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
|
||||||
|
"openreplay/backend/pkg/db/postgres/pool"
|
||||||
|
"openreplay/backend/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Auth interface {
|
||||||
|
IsAuthorized(authHeader string, permissions []string, isExtension bool) (*User, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type authImpl struct {
|
||||||
|
log logger.Logger
|
||||||
|
secret string
|
||||||
|
spotSecret string
|
||||||
|
pgconn pool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuth(log logger.Logger, jwtSecret, jwtSpotSecret string, conn pool.Pool) Auth {
|
||||||
|
return &authImpl{
|
||||||
|
log: log,
|
||||||
|
secret: jwtSecret,
|
||||||
|
spotSecret: jwtSpotSecret,
|
||||||
|
pgconn: conn,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseJWT(authHeader, secret string) (*JWTClaims, error) {
|
||||||
|
if authHeader == "" {
|
||||||
|
return nil, fmt.Errorf("authorization header missing")
|
||||||
|
}
|
||||||
|
tokenParts := strings.Split(authHeader, "Bearer ")
|
||||||
|
if len(tokenParts) != 2 {
|
||||||
|
return nil, fmt.Errorf("invalid authorization header")
|
||||||
|
}
|
||||||
|
tokenString := tokenParts[1]
|
||||||
|
|
||||||
|
claims := &JWTClaims{}
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, claims,
|
||||||
|
func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(secret), nil
|
||||||
|
})
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
fmt.Printf("token err: %v\n", err)
|
||||||
|
return nil, fmt.Errorf("invalid token")
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
13
backend/pkg/spot/auth/authorizer.go
Normal file
13
backend/pkg/spot/auth/authorizer.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
func (a *authImpl) IsAuthorized(authHeader string, permissions []string, isExtension bool) (*User, error) {
|
||||||
|
secret := a.secret
|
||||||
|
if isExtension {
|
||||||
|
secret = a.spotSecret
|
||||||
|
}
|
||||||
|
jwtInfo, err := parseJWT(authHeader, secret)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return authUser(a.pgconn, jwtInfo.UserId, jwtInfo.TenantID, int(jwtInfo.IssuedAt.Unix()))
|
||||||
|
}
|
||||||
34
backend/pkg/spot/auth/model.go
Normal file
34
backend/pkg/spot/auth/model.go
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import "github.com/golang-jwt/jwt/v5"
|
||||||
|
|
||||||
|
type JWTClaims struct {
|
||||||
|
UserId int `json:"userId"`
|
||||||
|
TenantID int `json:"tenantId"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
TenantID uint64 `json:"tenantId"`
|
||||||
|
JwtIat int `json:"jwtIat"`
|
||||||
|
Permissions map[string]bool `json:"permissions"`
|
||||||
|
AuthMethod string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *User) HasPermission(perm string) bool {
|
||||||
|
if u.Permissions == nil {
|
||||||
|
return true // no permissions
|
||||||
|
}
|
||||||
|
_, ok := u.Permissions[perm]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func abs(x int) int {
|
||||||
|
if x < 0 {
|
||||||
|
return -x
|
||||||
|
}
|
||||||
|
return x
|
||||||
|
}
|
||||||
23
backend/pkg/spot/auth/storage.go
Normal file
23
backend/pkg/spot/auth/storage.go
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"openreplay/backend/pkg/db/postgres/pool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func authUser(conn pool.Pool, userID, tenantID, jwtIAT int) (*User, error) {
|
||||||
|
sql := `
|
||||||
|
SELECT user_id, name, email, EXTRACT(epoch FROM spot_jwt_iat)::BIGINT AS spot_jwt_iat
|
||||||
|
FROM public.users
|
||||||
|
WHERE user_id = $1 AND deleted_at IS NULL
|
||||||
|
LIMIT 1;`
|
||||||
|
|
||||||
|
user := &User{TenantID: 1, AuthMethod: "jwt"}
|
||||||
|
if err := conn.QueryRow(sql, userID).Scan(&user.ID, &user.Name, &user.Email, &user.JwtIat); err != nil {
|
||||||
|
return nil, fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
if user.JwtIat == 0 || abs(jwtIAT-user.JwtIat) > 1 {
|
||||||
|
return nil, fmt.Errorf("token expired")
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
39
backend/pkg/spot/builder.go
Normal file
39
backend/pkg/spot/builder.go
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
package spot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"openreplay/backend/internal/config/spot"
|
||||||
|
"openreplay/backend/pkg/db/postgres/pool"
|
||||||
|
"openreplay/backend/pkg/flakeid"
|
||||||
|
"openreplay/backend/pkg/logger"
|
||||||
|
"openreplay/backend/pkg/objectstorage"
|
||||||
|
"openreplay/backend/pkg/objectstorage/store"
|
||||||
|
"openreplay/backend/pkg/spot/auth"
|
||||||
|
"openreplay/backend/pkg/spot/service"
|
||||||
|
"openreplay/backend/pkg/spot/transcoder"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServicesBuilder struct {
|
||||||
|
Flaker *flakeid.Flaker
|
||||||
|
ObjStorage objectstorage.ObjectStorage
|
||||||
|
Auth auth.Auth
|
||||||
|
Spots service.Spots
|
||||||
|
Keys service.Keys
|
||||||
|
Transcoder transcoder.Transcoder
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServiceBuilder(log logger.Logger, cfg *spot.Config, pgconn pool.Pool) (*ServicesBuilder, error) {
|
||||||
|
objStore, err := store.NewStore(&cfg.ObjectsConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
flaker := flakeid.NewFlaker(cfg.WorkerID)
|
||||||
|
spots := service.NewSpots(log, pgconn, flaker)
|
||||||
|
return &ServicesBuilder{
|
||||||
|
Flaker: flaker,
|
||||||
|
ObjStorage: objStore,
|
||||||
|
Auth: auth.NewAuth(log, cfg.JWTSecret, cfg.JWTSpotSecret, pgconn),
|
||||||
|
Spots: spots,
|
||||||
|
Keys: service.NewKeys(log, pgconn),
|
||||||
|
Transcoder: transcoder.NewTranscoder(cfg, log, objStore, pgconn, spots),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
146
backend/pkg/spot/service/public_key.go
Normal file
146
backend/pkg/spot/service/public_key.go
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"github.com/rs/xid"
|
||||||
|
"openreplay/backend/pkg/spot/auth"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"openreplay/backend/pkg/db/postgres/pool"
|
||||||
|
"openreplay/backend/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Key struct {
|
||||||
|
SpotID uint64 `json:"-"`
|
||||||
|
UserID uint64 `json:"-"` // to track who generated the key
|
||||||
|
TenantID uint64 `json:"-"` // to check availability
|
||||||
|
Value string `json:"value"`
|
||||||
|
Expiration uint64 `json:"expiration"` // in seconds
|
||||||
|
ExpiredAt time.Time `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Keys interface {
|
||||||
|
Set(spotID, expiration uint64, user *auth.User) (*Key, error)
|
||||||
|
Get(spotID uint64, user *auth.User) (*Key, error)
|
||||||
|
IsValid(key string) (*auth.User, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type keysImpl struct {
|
||||||
|
log logger.Logger
|
||||||
|
conn pool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *keysImpl) Set(spotID, expiration uint64, user *auth.User) (*Key, error) {
|
||||||
|
switch {
|
||||||
|
case spotID == 0:
|
||||||
|
return nil, fmt.Errorf("spotID is required")
|
||||||
|
case expiration > 604800:
|
||||||
|
return nil, fmt.Errorf("expiration should be less than 7 days")
|
||||||
|
case user == nil:
|
||||||
|
return nil, fmt.Errorf("user is required")
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if expiration == 0 {
|
||||||
|
sql := `UPDATE spots_keys SET expired_at = $1, expiration = 0 WHERE spot_id = $2`
|
||||||
|
if err := k.conn.Exec(sql, now, spotID); err != nil {
|
||||||
|
k.log.Error(context.Background(), "failed to set key: %v", err)
|
||||||
|
return nil, fmt.Errorf("key not updated")
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
newKey := xid.New().String()
|
||||||
|
expiredAt := now.Add(time.Duration(expiration) * time.Second)
|
||||||
|
sql := `
|
||||||
|
WITH updated AS (
|
||||||
|
UPDATE spots_keys
|
||||||
|
SET
|
||||||
|
spot_key = CASE
|
||||||
|
WHEN expired_at < $1 THEN $2
|
||||||
|
ELSE spot_key
|
||||||
|
END,
|
||||||
|
user_id = $3,
|
||||||
|
expiration = $4,
|
||||||
|
expired_at = $5,
|
||||||
|
updated_at = $1
|
||||||
|
WHERE spot_id = $6
|
||||||
|
RETURNING spot_key, expiration, expired_at
|
||||||
|
),
|
||||||
|
|
||||||
|
inserted AS (
|
||||||
|
INSERT INTO spots_keys (spot_key, spot_id, user_id, tenant_id, expiration, created_at, expired_at)
|
||||||
|
SELECT $2, $6, $3, $7, $4, $1, $5
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM updated)
|
||||||
|
RETURNING spot_key, expiration, expired_at
|
||||||
|
)
|
||||||
|
|
||||||
|
SELECT spot_key, expiration, expired_at FROM updated
|
||||||
|
UNION ALL
|
||||||
|
SELECT spot_key, expiration, expired_at FROM inserted;
|
||||||
|
`
|
||||||
|
key := &Key{}
|
||||||
|
if err := k.conn.QueryRow(sql, now, newKey, user.ID, expiration, expiredAt, spotID, user.TenantID).
|
||||||
|
Scan(&key.Value, &key.Expiration, &key.ExpiredAt); err != nil {
|
||||||
|
k.log.Error(context.Background(), "failed to set key: %v", err)
|
||||||
|
return nil, fmt.Errorf("key not updated")
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *keysImpl) Get(spotID uint64, user *auth.User) (*Key, error) {
|
||||||
|
switch {
|
||||||
|
case spotID == 0:
|
||||||
|
return nil, fmt.Errorf("spotID is required")
|
||||||
|
case user == nil:
|
||||||
|
return nil, fmt.Errorf("user is required")
|
||||||
|
}
|
||||||
|
//
|
||||||
|
key := &Key{}
|
||||||
|
sql := `SELECT spot_key, expiration, expired_at FROM spots_keys WHERE spot_id = $1 AND tenant_id = $2`
|
||||||
|
if err := k.conn.QueryRow(sql, spotID, user.TenantID).Scan(&key.Value, &key.Expiration, &key.ExpiredAt); err != nil {
|
||||||
|
k.log.Error(context.Background(), "failed to get key: %v", err)
|
||||||
|
return nil, fmt.Errorf("key not found")
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if key.ExpiredAt.Before(now) {
|
||||||
|
return nil, fmt.Errorf("key is expired")
|
||||||
|
}
|
||||||
|
key.Expiration = uint64(key.ExpiredAt.Sub(now).Seconds())
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *keysImpl) IsValid(key string) (*auth.User, error) {
|
||||||
|
if key == "" {
|
||||||
|
return nil, fmt.Errorf("key is required")
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
userID uint64
|
||||||
|
expiredAt time.Time
|
||||||
|
)
|
||||||
|
// Get userID if key is valid
|
||||||
|
sql := `SELECT user_id, expired_at FROM spots_keys WHERE spot_key = $1`
|
||||||
|
if err := k.conn.QueryRow(sql, key).Scan(&userID, &expiredAt); err != nil {
|
||||||
|
k.log.Error(context.Background(), "failed to get key: %v", err)
|
||||||
|
return nil, fmt.Errorf("key not found")
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if expiredAt.Before(now) {
|
||||||
|
return nil, fmt.Errorf("key is expired")
|
||||||
|
}
|
||||||
|
// Get user info by userID
|
||||||
|
user := &auth.User{ID: userID, AuthMethod: "public-key"}
|
||||||
|
// We don't need tenantID here
|
||||||
|
sql = `SELECT 1, name, email FROM public.users WHERE user_id = $1 AND deleted_at IS NULL LIMIT 1`
|
||||||
|
if err := k.conn.QueryRow(sql, userID).Scan(&user.TenantID, &user.Name, &user.Email); err != nil {
|
||||||
|
k.log.Error(context.Background(), "failed to get user: %v", err)
|
||||||
|
return nil, fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewKeys(log logger.Logger, conn pool.Pool) Keys {
|
||||||
|
return &keysImpl{
|
||||||
|
log: log,
|
||||||
|
conn: conn,
|
||||||
|
}
|
||||||
|
}
|
||||||
366
backend/pkg/spot/service/spot.go
Normal file
366
backend/pkg/spot/service/spot.go
Normal file
|
|
@ -0,0 +1,366 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"openreplay/backend/pkg/db/postgres/pool"
|
||||||
|
"openreplay/backend/pkg/flakeid"
|
||||||
|
"openreplay/backend/pkg/logger"
|
||||||
|
"openreplay/backend/pkg/spot/auth"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const MaxCommentLength = 120
|
||||||
|
const MaxNumberOfComments = 20
|
||||||
|
|
||||||
|
type Spot struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
UserID uint64 `json:"userID"`
|
||||||
|
UserEmail string `json:"userEmail"`
|
||||||
|
TenantID uint64 `json:"tenantID"`
|
||||||
|
Duration int `json:"duration"`
|
||||||
|
Crop []int `json:"crop"`
|
||||||
|
Comments []Comment `json:"comments"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Comment struct {
|
||||||
|
UserName string `json:"user"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetOpts struct {
|
||||||
|
SpotID uint64 // grab particular spot by ID
|
||||||
|
UserID uint64 // for filtering by user
|
||||||
|
TenantID uint64 // for filtering by all users in tenant
|
||||||
|
NameFilter string // for filtering by name (substring)
|
||||||
|
Order string // sorting ("asc" or "desc")
|
||||||
|
Limit uint64 // pagination (limit for page)
|
||||||
|
Offset uint64 // pagination (offset for page)
|
||||||
|
Page uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
type spotsImpl struct {
|
||||||
|
log logger.Logger
|
||||||
|
pgconn pool.Pool
|
||||||
|
flaker *flakeid.Flaker
|
||||||
|
}
|
||||||
|
|
||||||
|
type Update struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
NewName string `json:"newName"`
|
||||||
|
NewComment *Comment `json:"newComment"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Spots interface {
|
||||||
|
Add(user *auth.User, name, comment string, duration int, crop []int) (*Spot, error)
|
||||||
|
GetByID(user *auth.User, spotID uint64) (*Spot, error)
|
||||||
|
Get(user *auth.User, opts *GetOpts) ([]*Spot, uint64, error)
|
||||||
|
UpdateName(user *auth.User, spotID uint64, newName string) (*Spot, error)
|
||||||
|
AddComment(user *auth.User, spotID uint64, comment *Comment) (*Spot, error)
|
||||||
|
Delete(user *auth.User, spotIds []uint64) error
|
||||||
|
SetStatus(spotID uint64, status string) error
|
||||||
|
GetStatus(user *auth.User, spotID uint64) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSpots(log logger.Logger, pgconn pool.Pool, flaker *flakeid.Flaker) Spots {
|
||||||
|
return &spotsImpl{
|
||||||
|
log: log,
|
||||||
|
pgconn: pgconn,
|
||||||
|
flaker: flaker,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *spotsImpl) Add(user *auth.User, name, comment string, duration int, crop []int) (*Spot, error) {
|
||||||
|
switch {
|
||||||
|
case user == nil:
|
||||||
|
return nil, fmt.Errorf("user is required")
|
||||||
|
case name == "":
|
||||||
|
return nil, fmt.Errorf("name is required")
|
||||||
|
case duration <= 0:
|
||||||
|
return nil, fmt.Errorf("duration should be greater than 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
createdAt := time.Now()
|
||||||
|
spotID, err := s.flaker.Compose(uint64(createdAt.UnixMilli()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
newSpot := &Spot{
|
||||||
|
ID: spotID,
|
||||||
|
Name: name,
|
||||||
|
UserID: user.ID,
|
||||||
|
UserEmail: user.Email,
|
||||||
|
TenantID: user.TenantID,
|
||||||
|
Duration: duration,
|
||||||
|
Crop: crop,
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
}
|
||||||
|
if comment != "" {
|
||||||
|
newSpot.Comments = append(newSpot.Comments, Comment{
|
||||||
|
UserName: user.Name,
|
||||||
|
Text: comment,
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err = s.add(newSpot); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return newSpot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *spotsImpl) encodeComment(comment *Comment) string {
|
||||||
|
encodedComment, err := json.Marshal(comment)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn(context.Background(), "failed to encode comment: %v, err: %s", comment, err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(encodedComment)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *spotsImpl) add(spot *Spot) error {
|
||||||
|
sql := `INSERT INTO spots (spot_id, name, user_id, user_email, tenant_id, duration, crop, comments, status, created_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`
|
||||||
|
var comments []string
|
||||||
|
for _, comment := range spot.Comments {
|
||||||
|
if encodedComment := s.encodeComment(&comment); encodedComment != "" {
|
||||||
|
comments = append(comments, encodedComment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err := s.pgconn.Exec(sql, spot.ID, spot.Name, spot.UserID, spot.UserEmail, spot.TenantID, spot.Duration, spot.Crop,
|
||||||
|
comments, "pending", spot.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *spotsImpl) GetByID(user *auth.User, spotID uint64) (*Spot, error) {
|
||||||
|
switch {
|
||||||
|
case user == nil:
|
||||||
|
return nil, fmt.Errorf("user is required")
|
||||||
|
case spotID == 0:
|
||||||
|
return nil, fmt.Errorf("spot id is required")
|
||||||
|
}
|
||||||
|
return s.getByID(spotID, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *spotsImpl) getByID(spotID uint64, user *auth.User) (*Spot, error) {
|
||||||
|
sql := `SELECT name, user_email, duration, crop, comments, created_at FROM spots
|
||||||
|
WHERE spot_id = $1 AND tenant_id = $2 AND deleted_at IS NULL`
|
||||||
|
spot := &Spot{ID: spotID}
|
||||||
|
var comments []string
|
||||||
|
err := s.pgconn.QueryRow(sql, spotID, user.TenantID).Scan(&spot.Name, &spot.UserEmail, &spot.Duration, &spot.Crop,
|
||||||
|
&comments, &spot.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, comment := range comments {
|
||||||
|
var decodedComment Comment
|
||||||
|
if err = json.Unmarshal([]byte(comment), &decodedComment); err != nil {
|
||||||
|
s.log.Warn(context.Background(), "failed to decode comment: %s", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
spot.Comments = append(spot.Comments, decodedComment)
|
||||||
|
|
||||||
|
}
|
||||||
|
return spot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *spotsImpl) Get(user *auth.User, opts *GetOpts) ([]*Spot, uint64, error) {
|
||||||
|
switch {
|
||||||
|
case user == nil:
|
||||||
|
return nil, 0, fmt.Errorf("user is required")
|
||||||
|
case opts == nil:
|
||||||
|
return nil, 0, fmt.Errorf("get options are required")
|
||||||
|
case user.TenantID == 0: // Tenant ID is required even for public get functions
|
||||||
|
return nil, 0, fmt.Errorf("tenant id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the latest spots first by default
|
||||||
|
if opts.Order != "asc" && opts.Order != "desc" {
|
||||||
|
opts.Order = "desc"
|
||||||
|
}
|
||||||
|
if opts.Limit <= 0 || opts.Limit > 10 {
|
||||||
|
opts.Limit = 9
|
||||||
|
}
|
||||||
|
if opts.Page < 1 {
|
||||||
|
opts.Page = 1
|
||||||
|
}
|
||||||
|
opts.Offset = (opts.Page - 1) * opts.Limit
|
||||||
|
return s.getAll(user, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *spotsImpl) getAll(user *auth.User, opts *GetOpts) ([]*Spot, uint64, error) {
|
||||||
|
sql := `SELECT COUNT(1) OVER () AS total, spot_id, name, user_email, duration, created_at FROM spots
|
||||||
|
WHERE tenant_id = $1 AND deleted_at IS NULL`
|
||||||
|
args := []interface{}{user.TenantID}
|
||||||
|
if opts.UserID != 0 {
|
||||||
|
sql += ` AND user_id = ` + fmt.Sprintf("$%d", len(args)+1)
|
||||||
|
args = append(args, opts.UserID)
|
||||||
|
}
|
||||||
|
if opts.NameFilter != "" {
|
||||||
|
sql += ` AND name ILIKE ` + fmt.Sprintf("$%d", len(args)+1)
|
||||||
|
args = append(args, "%"+opts.NameFilter+"%")
|
||||||
|
}
|
||||||
|
if opts.Order != "" {
|
||||||
|
sql += ` ORDER BY created_at ` + opts.Order
|
||||||
|
}
|
||||||
|
if opts.Limit != 0 {
|
||||||
|
sql += ` LIMIT ` + fmt.Sprintf("$%d", len(args)+1)
|
||||||
|
args = append(args, opts.Limit)
|
||||||
|
}
|
||||||
|
if opts.Offset != 0 {
|
||||||
|
sql += ` OFFSET ` + fmt.Sprintf("$%d", len(args)+1)
|
||||||
|
args = append(args, opts.Offset)
|
||||||
|
}
|
||||||
|
//s.log.Info(context.Background(), "sql: %s, args: %v", sql, args)
|
||||||
|
rows, err := s.pgconn.Query(sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var total uint64
|
||||||
|
var spots []*Spot
|
||||||
|
for rows.Next() {
|
||||||
|
spot := &Spot{}
|
||||||
|
if err = rows.Scan(&total, &spot.ID, &spot.Name, &spot.UserEmail, &spot.Duration, &spot.CreatedAt); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
spots = append(spots, spot)
|
||||||
|
}
|
||||||
|
return spots, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *spotsImpl) UpdateName(user *auth.User, spotID uint64, newName string) (*Spot, error) {
|
||||||
|
switch {
|
||||||
|
case user == nil:
|
||||||
|
return nil, fmt.Errorf("user is required")
|
||||||
|
case spotID == 0:
|
||||||
|
return nil, fmt.Errorf("spot id is required")
|
||||||
|
case newName == "":
|
||||||
|
return nil, fmt.Errorf("new name is required")
|
||||||
|
}
|
||||||
|
return s.updateName(spotID, newName, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *spotsImpl) updateName(spotID uint64, newName string, user *auth.User) (*Spot, error) {
|
||||||
|
sql := `WITH updated AS (
|
||||||
|
UPDATE spots SET name = $1, updated_at = $2
|
||||||
|
WHERE spot_id = $3 AND tenant_id = $4 AND deleted_at IS NULL RETURNING *)
|
||||||
|
SELECT COUNT(*) FROM updated`
|
||||||
|
updated := 0
|
||||||
|
if err := s.pgconn.QueryRow(sql, newName, time.Now(), spotID, user.TenantID).Scan(&updated); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if updated == 0 {
|
||||||
|
return nil, fmt.Errorf("not allowed to update name")
|
||||||
|
}
|
||||||
|
return &Spot{ID: spotID, Name: newName}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *spotsImpl) AddComment(user *auth.User, spotID uint64, comment *Comment) (*Spot, error) {
|
||||||
|
switch {
|
||||||
|
case user == nil:
|
||||||
|
return nil, fmt.Errorf("user is required")
|
||||||
|
case spotID == 0:
|
||||||
|
return nil, fmt.Errorf("spot id is required")
|
||||||
|
case comment == nil:
|
||||||
|
return nil, fmt.Errorf("comment is required")
|
||||||
|
case comment.UserName == "":
|
||||||
|
return nil, fmt.Errorf("user name is required")
|
||||||
|
case comment.Text == "":
|
||||||
|
return nil, fmt.Errorf("comment text is required")
|
||||||
|
}
|
||||||
|
if len(comment.Text) > MaxCommentLength {
|
||||||
|
comment.Text = comment.Text[:MaxCommentLength]
|
||||||
|
}
|
||||||
|
comment.CreatedAt = time.Now()
|
||||||
|
return s.addComment(spotID, comment, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *spotsImpl) addComment(spotID uint64, newComment *Comment, user *auth.User) (*Spot, error) {
|
||||||
|
sql := `WITH updated AS (
|
||||||
|
UPDATE spots
|
||||||
|
SET comments = array_append(comments, $1), updated_at = $2
|
||||||
|
WHERE spot_id = $3 AND tenant_id = $4 AND deleted_at IS NULL AND array_length(comments, 1) < $5
|
||||||
|
RETURNING *)
|
||||||
|
SELECT COUNT(*) FROM updated`
|
||||||
|
encodedComment := s.encodeComment(newComment)
|
||||||
|
if encodedComment == "" {
|
||||||
|
return nil, fmt.Errorf("failed to encode comment")
|
||||||
|
}
|
||||||
|
updated := 0
|
||||||
|
if err := s.pgconn.QueryRow(sql, encodedComment, time.Now(), spotID, user.TenantID, MaxNumberOfComments).Scan(&updated); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if updated == 0 {
|
||||||
|
return nil, fmt.Errorf("not allowed to add comment")
|
||||||
|
}
|
||||||
|
return &Spot{ID: spotID}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *spotsImpl) Delete(user *auth.User, spotIds []uint64) error {
|
||||||
|
switch {
|
||||||
|
case user == nil:
|
||||||
|
return fmt.Errorf("user is required")
|
||||||
|
case len(spotIds) == 0:
|
||||||
|
return fmt.Errorf("spot ids are required")
|
||||||
|
}
|
||||||
|
return s.deleteSpots(spotIds, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *spotsImpl) deleteSpots(spotIds []uint64, user *auth.User) error {
|
||||||
|
sql := `WITH updated AS (UPDATE spots SET deleted_at = NOW() WHERE tenant_id = $1 AND spot_id IN (`
|
||||||
|
args := []interface{}{user.TenantID}
|
||||||
|
for i, spotID := range spotIds {
|
||||||
|
sql += fmt.Sprintf("$%d,", i+2)
|
||||||
|
args = append(args, spotID)
|
||||||
|
}
|
||||||
|
sql = sql[:len(sql)-1] + `) RETURNING *) SELECT COUNT(*) FROM updated`
|
||||||
|
count := 0
|
||||||
|
if err := s.pgconn.QueryRow(sql, args...).Scan(&count); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
return fmt.Errorf("not allowed to delete spots")
|
||||||
|
}
|
||||||
|
if count != len(spotIds) {
|
||||||
|
s.log.Warn(context.Background(), "deleted %d spots, but expected to delete %d", count, len(spotIds))
|
||||||
|
return fmt.Errorf("failed to delete all requested spots")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *spotsImpl) SetStatus(spotID uint64, status string) error {
|
||||||
|
switch {
|
||||||
|
case spotID == 0:
|
||||||
|
return fmt.Errorf("spot id is required")
|
||||||
|
case status == "":
|
||||||
|
return fmt.Errorf("status is required")
|
||||||
|
}
|
||||||
|
sql := `UPDATE spots SET status = $1, updated_at = $2 WHERE spot_id = $3 AND deleted_at IS NULL`
|
||||||
|
if err := s.pgconn.Exec(sql, status, time.Now(), spotID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *spotsImpl) GetStatus(user *auth.User, spotID uint64) (string, error) {
|
||||||
|
switch {
|
||||||
|
case user == nil:
|
||||||
|
return "", fmt.Errorf("user is required")
|
||||||
|
case spotID == 0:
|
||||||
|
return "", fmt.Errorf("spot id is required")
|
||||||
|
}
|
||||||
|
sql := `SELECT status FROM spots WHERE spot_id = $1 AND tenant_id = $2 AND deleted_at IS NULL`
|
||||||
|
var status string
|
||||||
|
if err := s.pgconn.QueryRow(sql, spotID, user.TenantID).Scan(&status); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
106
backend/pkg/spot/transcoder/streams.go
Normal file
106
backend/pkg/spot/transcoder/streams.go
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
package transcoder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"openreplay/backend/pkg/db/postgres/pool"
|
||||||
|
"openreplay/backend/pkg/logger"
|
||||||
|
"openreplay/backend/pkg/objectstorage"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Streams interface {
|
||||||
|
Add(spotID uint64, originalStream string) error
|
||||||
|
Get(spotID uint64) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type streamsImpl struct {
|
||||||
|
log logger.Logger
|
||||||
|
conn pool.Pool
|
||||||
|
storage objectstorage.ObjectStorage
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *streamsImpl) Add(spotID uint64, originalStream string) error {
|
||||||
|
lines := strings.Split(originalStream, "\n")
|
||||||
|
|
||||||
|
// Replace indexN.ts with pre-signed URLs
|
||||||
|
for i, line := range lines {
|
||||||
|
if strings.HasPrefix(line, "index") && strings.HasSuffix(line, ".ts") {
|
||||||
|
key := fmt.Sprintf("%d/%s", spotID, line)
|
||||||
|
presignedURL, err := s.storage.GetPreSignedDownloadUrl(key)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Error generating pre-signed URL:", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
lines[i] = presignedURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
modifiedContent := strings.Join(lines, "\n")
|
||||||
|
now := time.Now()
|
||||||
|
// Insert playlist to DB
|
||||||
|
sql := `INSERT INTO spots_streams (spot_id, original_playlist, modified_playlist, created_at, expired_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5) ON CONFLICT (spot_id) DO UPDATE SET original_playlist = $2, modified_playlist = $3,
|
||||||
|
created_at = $4, expired_at = $5`
|
||||||
|
if err := s.conn.Exec(sql, spotID, originalStream, modifiedContent, now, now.Add(10*time.Minute)); err != nil {
|
||||||
|
fmt.Println("Error inserting playlist to DB:", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *streamsImpl) Get(spotID uint64) ([]byte, error) {
|
||||||
|
// Get modified playlist from DB
|
||||||
|
sql := `
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN expired_at > $2 THEN modified_playlist
|
||||||
|
ELSE original_playlist
|
||||||
|
END AS playlist,
|
||||||
|
CASE
|
||||||
|
WHEN expired_at > $2 THEN 'modified'
|
||||||
|
ELSE 'original'
|
||||||
|
END AS playlist_type
|
||||||
|
FROM spots_streams
|
||||||
|
WHERE spot_id = $1`
|
||||||
|
var playlist, flag string
|
||||||
|
if err := s.conn.QueryRow(sql, spotID, time.Now()).Scan(&playlist, &flag); err != nil {
|
||||||
|
s.log.Error(context.Background(), "Error getting spot stream playlist: %v", err)
|
||||||
|
return []byte(""), err
|
||||||
|
}
|
||||||
|
if flag == "modified" {
|
||||||
|
return []byte(playlist), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Have to generate a new modified playlist with updated pre-signed URLs for chunks
|
||||||
|
lines := strings.Split(playlist, "\n")
|
||||||
|
for i, line := range lines {
|
||||||
|
if strings.HasPrefix(line, "index") && strings.HasSuffix(line, ".ts") {
|
||||||
|
key := fmt.Sprintf("%d/%s", spotID, line)
|
||||||
|
presignedURL, err := s.storage.GetPreSignedDownloadUrl(key)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error(context.Background(), "Error generating pre-signed URL: %v", err)
|
||||||
|
return []byte(""), err
|
||||||
|
}
|
||||||
|
lines[i] = presignedURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
modifiedPlaylist := strings.Join(lines, "\n")
|
||||||
|
|
||||||
|
// Save modified playlist to DB
|
||||||
|
sql = `UPDATE spots_streams SET modified_playlist = $1, expired_at = $2 WHERE spot_id = $3`
|
||||||
|
if err := s.conn.Exec(sql, modifiedPlaylist, time.Now().Add(10*time.Minute), spotID); err != nil {
|
||||||
|
s.log.Warn(context.Background(), "Error updating modified playlist: %v", err)
|
||||||
|
}
|
||||||
|
return []byte(modifiedPlaylist), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStreams(log logger.Logger, conn pool.Pool, storage objectstorage.ObjectStorage) Streams {
|
||||||
|
return &streamsImpl{
|
||||||
|
log: log,
|
||||||
|
conn: conn,
|
||||||
|
storage: storage,
|
||||||
|
}
|
||||||
|
}
|
||||||
100
backend/pkg/spot/transcoder/tasks.go
Normal file
100
backend/pkg/spot/transcoder/tasks.go
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
package transcoder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v4"
|
||||||
|
"openreplay/backend/pkg/db/postgres/pool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Tasks interface {
|
||||||
|
Add(spotID uint64, crop []int, duration int) error
|
||||||
|
Get() (*Task, error)
|
||||||
|
Done(task *Task) error
|
||||||
|
Failed(task *Task, taskErr error) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type tasksImpl struct {
|
||||||
|
conn pool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTasks(conn pool.Pool) Tasks {
|
||||||
|
return &tasksImpl{conn: conn}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Task struct {
|
||||||
|
SpotID uint64
|
||||||
|
Crop []int
|
||||||
|
Duration int
|
||||||
|
Status string
|
||||||
|
Path string
|
||||||
|
tx pool.Tx
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Task) HasToTrim() bool {
|
||||||
|
return t.Crop != nil && len(t.Crop) == 2
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Task) HasToTranscode() bool {
|
||||||
|
return t.Duration > 15000
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tasksImpl) Add(spotID uint64, crop []int, duration int) error {
|
||||||
|
sql := `INSERT INTO spot_tasks (id, crop, duration, status, added_time) VALUES ($1, $2, $3, $4, $5)`
|
||||||
|
if err := t.conn.Exec(sql, spotID, crop, duration, "pending", time.Now()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type NoTasksError struct{}
|
||||||
|
|
||||||
|
func (NoTasksError) Error() string {
|
||||||
|
return "no tasks"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tasksImpl) Get() (task *Task, err error) {
|
||||||
|
tx, err := t.conn.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
tx.TxRollback()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
task = &Task{tx: pool.Tx{Tx: tx}}
|
||||||
|
sql := `SELECT id, crop, duration FROM spots_tasks WHERE status = 'pending' ORDER BY added_time FOR UPDATE SKIP LOCKED LIMIT 1`
|
||||||
|
err = tx.TxQueryRow(sql).Scan(&task.SpotID, &task.Crop, &task.Duration)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, NoTasksError{}
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return task, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tasksImpl) Done(task *Task) error {
|
||||||
|
sql := `DELETE FROM spots_tasks WHERE id = $1`
|
||||||
|
err := task.tx.TxExec(sql, task.SpotID)
|
||||||
|
if err != nil {
|
||||||
|
task.tx.TxRollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return task.tx.TxCommit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tasksImpl) Failed(task *Task, taskErr error) error {
|
||||||
|
sql := `UPDATE spots_tasks SET status = 'failed', error = $2 WHERE id = $1`
|
||||||
|
err := task.tx.TxExec(sql, task.SpotID, taskErr.Error())
|
||||||
|
if err != nil {
|
||||||
|
task.tx.TxRollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return task.tx.TxCommit()
|
||||||
|
}
|
||||||
349
backend/pkg/spot/transcoder/transcoder.go
Normal file
349
backend/pkg/spot/transcoder/transcoder.go
Normal file
|
|
@ -0,0 +1,349 @@
|
||||||
|
package transcoder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"openreplay/backend/internal/config/spot"
|
||||||
|
"openreplay/backend/pkg/db/postgres/pool"
|
||||||
|
"openreplay/backend/pkg/logger"
|
||||||
|
metrics "openreplay/backend/pkg/metrics/spot"
|
||||||
|
"openreplay/backend/pkg/objectstorage"
|
||||||
|
workers "openreplay/backend/pkg/pool"
|
||||||
|
"openreplay/backend/pkg/spot/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Transcoder interface {
|
||||||
|
Process(spot *service.Spot) error
|
||||||
|
GetSpotStreamPlaylist(spotID uint64) ([]byte, error)
|
||||||
|
Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
type transcoderImpl struct {
|
||||||
|
cfg *spot.Config
|
||||||
|
log logger.Logger
|
||||||
|
close chan interface{}
|
||||||
|
objStorage objectstorage.ObjectStorage
|
||||||
|
conn pool.Pool
|
||||||
|
tasks Tasks
|
||||||
|
streams Streams
|
||||||
|
spots service.Spots
|
||||||
|
prepareWorkers workers.WorkerPool
|
||||||
|
transcodeWorkers workers.WorkerPool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTranscoder(cfg *spot.Config, log logger.Logger, objStorage objectstorage.ObjectStorage, conn pool.Pool, spots service.Spots) Transcoder {
|
||||||
|
tnsc := &transcoderImpl{
|
||||||
|
cfg: cfg,
|
||||||
|
log: log,
|
||||||
|
close: make(chan interface{}, 1),
|
||||||
|
objStorage: objStorage,
|
||||||
|
conn: conn,
|
||||||
|
tasks: NewTasks(conn),
|
||||||
|
streams: NewStreams(log, conn, objStorage),
|
||||||
|
spots: spots,
|
||||||
|
}
|
||||||
|
tnsc.prepareWorkers = workers.NewPool(2, 4, tnsc.prepare)
|
||||||
|
tnsc.transcodeWorkers = workers.NewPool(2, 4, tnsc.transcode)
|
||||||
|
go tnsc.mainLoop()
|
||||||
|
return tnsc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *transcoderImpl) Process(spot *service.Spot) error {
|
||||||
|
if spot.Crop == nil && spot.Duration < t.cfg.MinimumStreamDuration {
|
||||||
|
// Skip this spot and set processed status
|
||||||
|
t.log.Info(context.Background(), "Spot video %+v is too short for transcoding and without crop values", spot)
|
||||||
|
if err := t.spots.SetStatus(spot.ID, "processed"); err != nil {
|
||||||
|
t.log.Error(context.Background(), "Error updating spot status: %v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return t.tasks.Add(spot.ID, spot.Crop, spot.Duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *transcoderImpl) mainLoop() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case closeEvent := <-t.close:
|
||||||
|
t.log.Info(context.Background(), "Transcoder is closing: %v", closeEvent)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
task, err := t.tasks.Get()
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, NoTasksError{}) {
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
} else {
|
||||||
|
t.log.Error(context.Background(), "Error getting task: %v", err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t.process(task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *transcoderImpl) failedTask(task *Task, err error) {
|
||||||
|
t.log.Error(context.Background(), "Task failed: %v", err)
|
||||||
|
if err := t.tasks.Failed(task, err); err != nil {
|
||||||
|
t.log.Error(context.Background(), "Error marking task as failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(task.Path); err != nil {
|
||||||
|
t.log.Error(context.Background(), "Error removing directory: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *transcoderImpl) doneTask(task *Task) {
|
||||||
|
if err := t.tasks.Done(task); err != nil {
|
||||||
|
t.log.Error(context.Background(), "Error marking task as done: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(task.Path); err != nil {
|
||||||
|
t.log.Error(context.Background(), "Error removing directory: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *transcoderImpl) process(task *Task) {
|
||||||
|
metrics.IncreaseVideosTotal()
|
||||||
|
//spotID := task.SpotID
|
||||||
|
t.log.Info(context.Background(), "Processing spot %s", task.SpotID)
|
||||||
|
|
||||||
|
// Prepare path for spot video
|
||||||
|
path := t.cfg.FSDir + "/"
|
||||||
|
if t.cfg.SpotsDir != "" {
|
||||||
|
path += t.cfg.SpotsDir + "/"
|
||||||
|
}
|
||||||
|
task.Path = path + strconv.FormatUint(task.SpotID, 10) + "/"
|
||||||
|
|
||||||
|
t.prepareWorkers.Submit(task)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download original video, crop if needed (and upload cropped).
|
||||||
|
func (t *transcoderImpl) prepare(payload interface{}) {
|
||||||
|
task := payload.(*Task)
|
||||||
|
|
||||||
|
// Download video from S3
|
||||||
|
if err := t.downloadSpotVideo(task.SpotID, task.Path); err != nil {
|
||||||
|
t.failedTask(task, fmt.Errorf("can't download video, spot: %d, err: %s", task.SpotID, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if task.HasToTrim() {
|
||||||
|
if err := t.cropSpotVideo(task.SpotID, task.Crop, task.Path); err != nil {
|
||||||
|
t.failedTask(task, fmt.Errorf("can't crop video, spot: %d, err: %s", task.SpotID, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !task.HasToTranscode() {
|
||||||
|
t.log.Info(context.Background(), "Spot video %d is too short for transcoding", task.SpotID)
|
||||||
|
t.doneTask(task)
|
||||||
|
} else {
|
||||||
|
t.transcodeWorkers.Submit(task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transcode video, upload to S3, save playlist to DB, delete local files.
|
||||||
|
func (t *transcoderImpl) transcode(payload interface{}) {
|
||||||
|
task := payload.(*Task)
|
||||||
|
|
||||||
|
// Transcode spot video to HLS format
|
||||||
|
streamPlaylist, err := t.transcodeSpotVideo(task.SpotID, task.Path)
|
||||||
|
if err != nil {
|
||||||
|
t.failedTask(task, fmt.Errorf("can't transcode video, spot: %d, err: %s", task.SpotID, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save stream playlist to DB
|
||||||
|
if err := t.streams.Add(task.SpotID, streamPlaylist); err != nil {
|
||||||
|
t.failedTask(task, fmt.Errorf("can't insert playlist to DB, spot: %d, err: %s", task.SpotID, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := t.spots.SetStatus(task.SpotID, "processed"); err != nil {
|
||||||
|
t.log.Error(context.Background(), "Error updating spot status: %v", err)
|
||||||
|
}
|
||||||
|
t.doneTask(task)
|
||||||
|
|
||||||
|
t.log.Info(context.Background(), "Transcoded spot %d, have to upload chunks to S3", task.SpotID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *transcoderImpl) downloadSpotVideo(spotID uint64, path string) error {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
// Ensure the directory exists
|
||||||
|
if err := os.MkdirAll(path, 0755); err != nil {
|
||||||
|
t.log.Fatal(context.Background(), "Error creating directories: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
video, err := t.objStorage.Get(fmt.Sprintf("%d/video.webm", spotID))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer video.Close()
|
||||||
|
|
||||||
|
// Save file to disk
|
||||||
|
originVideo, err := os.Create(path + "origin.webm")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("can't create file: %s", err.Error())
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(originVideo, video); err != nil {
|
||||||
|
return fmt.Errorf("can't copy file: %s", err.Error())
|
||||||
|
}
|
||||||
|
if fileInfo, err := originVideo.Stat(); err != nil {
|
||||||
|
t.log.Error(context.Background(), "Failed to get file info: %v", err)
|
||||||
|
} else {
|
||||||
|
metrics.RecordOriginalVideoSize(float64(fileInfo.Size()))
|
||||||
|
}
|
||||||
|
originVideo.Close()
|
||||||
|
|
||||||
|
metrics.RecordOriginalVideoDownloadDuration(time.Since(start).Seconds())
|
||||||
|
|
||||||
|
t.log.Info(context.Background(), "Saved origin video to disk, spot: %d in %v sec", spotID, time.Since(start).Seconds())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *transcoderImpl) cropSpotVideo(spotID uint64, crop []int, path string) error {
|
||||||
|
// Crop video
|
||||||
|
// ffmpeg -i input.webm -ss 5 -to 20 -c copy output.webm
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
cmd := exec.Command("ffmpeg", "-i", path+"origin.webm",
|
||||||
|
"-ss", fmt.Sprintf("%.2f", float64(crop[0])/1000.0),
|
||||||
|
"-to", fmt.Sprintf("%.2f", float64(crop[1])/1000.0),
|
||||||
|
"-c", "copy", path+"cropped.mp4")
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
err := cmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to execute command: %v, stderr: %v", err, stderr.String())
|
||||||
|
}
|
||||||
|
metrics.IncreaseVideosCropped()
|
||||||
|
metrics.RecordCroppingDuration(time.Since(start).Seconds())
|
||||||
|
|
||||||
|
t.log.Info(context.Background(), "Cropped spot %d in %v", spotID, time.Since(start).Seconds())
|
||||||
|
|
||||||
|
// mv cropped.webm origin.webm
|
||||||
|
err = os.Rename(path+"cropped.mp4", path+"origin.webm")
|
||||||
|
|
||||||
|
// upload cropped video back to s3
|
||||||
|
start = time.Now()
|
||||||
|
video, err := os.Open(path + "origin.webm")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open cropped video: %v", err)
|
||||||
|
}
|
||||||
|
defer video.Close()
|
||||||
|
|
||||||
|
if fileInfo, err := video.Stat(); err != nil {
|
||||||
|
t.log.Error(context.Background(), "Failed to get file info: %v", err)
|
||||||
|
} else {
|
||||||
|
metrics.RecordCroppedVideoSize(float64(fileInfo.Size()))
|
||||||
|
}
|
||||||
|
|
||||||
|
err = t.objStorage.Upload(video, fmt.Sprintf("%d/video.webm", spotID), "video/webm", objectstorage.NoCompression)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to upload cropped video: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
metrics.RecordCroppedVideoUploadDuration(time.Since(start).Seconds())
|
||||||
|
|
||||||
|
t.log.Info(context.Background(), "Uploaded cropped spot %d in %v", spotID, time.Since(start).Seconds())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *transcoderImpl) transcodeSpotVideo(spotID uint64, path string) (string, error) {
|
||||||
|
// Transcode video tp HLS format
|
||||||
|
// ffmpeg -i origin.webm -c:v copy -c:a aac -b:a 128k -start_number 0 -hls_time 10 -hls_list_size 0 -f hls index.m3u8
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
videoPath := path + "origin.webm"
|
||||||
|
playlistPath := path + "index.m3u8"
|
||||||
|
cmd := exec.Command("ffmpeg", "-i", videoPath, "-c:v", "copy", "-c:a", "aac", "-b:a", "96k",
|
||||||
|
"-start_number", "0", "-hls_time", "10", "-hls_list_size", "0", "-f", "hls", playlistPath)
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
err := cmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
t.log.Error(context.Background(), "Failed to execute command: %v, stderr: %v", err, stderr.String())
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
metrics.IncreaseVideosTranscoded()
|
||||||
|
metrics.RecordTranscodingDuration(time.Since(start).Seconds())
|
||||||
|
t.log.Info(context.Background(), "Transcoded spot %d in %v", spotID, time.Since(start).Seconds())
|
||||||
|
|
||||||
|
start = time.Now()
|
||||||
|
// Read the M3U8 file
|
||||||
|
file, err := os.Open(playlistPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Error opening file:", err)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
var originalLines []string
|
||||||
|
var lines []string
|
||||||
|
var chunks []string
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
lines = append(lines, line)
|
||||||
|
if strings.HasPrefix(line, "index") && strings.HasSuffix(line, ".ts") {
|
||||||
|
chunks = append(chunks, line)
|
||||||
|
}
|
||||||
|
originalLines = append(originalLines, line)
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
fmt.Println("Error reading file:", err)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert stream chunks to s3
|
||||||
|
for _, chunk := range chunks {
|
||||||
|
chunkPath := path + chunk
|
||||||
|
chunkFile, err := os.Open(chunkPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Error opening file:", err)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer chunkFile.Close()
|
||||||
|
|
||||||
|
key := fmt.Sprintf("%d/%s", spotID, chunk)
|
||||||
|
err = t.objStorage.Upload(chunkFile, key, "video/mp2t", objectstorage.NoCompression)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Error uploading file:", err)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
metrics.RecordTranscodedVideoUploadDuration(time.Since(start).Seconds())
|
||||||
|
|
||||||
|
t.log.Info(context.Background(), "Uploaded chunks for spot %d in %v", spotID, time.Since(start).Seconds())
|
||||||
|
return strings.Join(lines, "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *transcoderImpl) GetSpotStreamPlaylist(spotID uint64) ([]byte, error) {
|
||||||
|
return t.streams.Get(spotID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *transcoderImpl) Wait() {
|
||||||
|
t.prepareWorkers.Pause()
|
||||||
|
t.transcodeWorkers.Pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *transcoderImpl) Close() {
|
||||||
|
t.close <- nil
|
||||||
|
t.prepareWorkers.Stop()
|
||||||
|
t.transcodeWorkers.Stop()
|
||||||
|
}
|
||||||
|
|
@ -140,6 +140,10 @@ func (s *storageImpl) GetPreSignedUploadUrl(key string) (string, error) {
|
||||||
return sasURL, nil
|
return sasURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *storageImpl) GetPreSignedDownloadUrl(key string) (string, error) {
|
||||||
|
return "", errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
func loadFileTag() map[string]string {
|
func loadFileTag() map[string]string {
|
||||||
// Load file tag from env
|
// Load file tag from env
|
||||||
key := "retention"
|
key := "retention"
|
||||||
|
|
|
||||||
11
ee/backend/pkg/spot/api/permissions.go
Normal file
11
ee/backend/pkg/spot/api/permissions.go
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
func getPermissions(urlPath string) []string {
|
||||||
|
res := []string{"SPOT"}
|
||||||
|
if strings.Contains(urlPath, "public-key") {
|
||||||
|
res = append(res, "SPOT_PUBLIC")
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
57
ee/backend/pkg/spot/api/tracer.go
Normal file
57
ee/backend/pkg/spot/api/tracer.go
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
|
||||||
|
"openreplay/backend/pkg/spot/auth"
|
||||||
|
"openreplay/backend/pkg/spot/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
var routeMatch = map[string]string{
|
||||||
|
"POST" + "/v1/spots": "createSpot",
|
||||||
|
"GET" + "/v1/spots/{id}": "getSpot",
|
||||||
|
"PATCH" + "/v1/spots/{id}": "updateSpot",
|
||||||
|
"GET" + "/v1/spots": "getSpots",
|
||||||
|
"DELETE" + "/v1/spots": "deleteSpots",
|
||||||
|
"POST" + "/v1/spots/{id}/comment": "addComment",
|
||||||
|
"GET" + "/v1/spots/{id}/video": "getSpotVideo",
|
||||||
|
"PATCH" + "/v1/spots/{id}/public-key": "updatePublicKey",
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Router) logRequest(r *http.Request, bodyBytes []byte, statusCode int) {
|
||||||
|
pathTemplate, err := mux.CurrentRoute(r).GetPathTemplate()
|
||||||
|
if err != nil {
|
||||||
|
e.log.Error(r.Context(), "failed to get path template: %s", err)
|
||||||
|
}
|
||||||
|
e.log.Info(r.Context(), "path template: %s", pathTemplate)
|
||||||
|
// 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 {
|
||||||
|
e.log.Error(r.Context(), "failed to marshal query parameters: %s", err)
|
||||||
|
}
|
||||||
|
requestData := &service.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").(*auth.User)
|
||||||
|
e.services.Tracer.Trace(userData, requestData)
|
||||||
|
// DEBUG
|
||||||
|
e.log.Info(r.Context(), "request data: %v", requestData)
|
||||||
|
}
|
||||||
25
ee/backend/pkg/spot/auth/authorizer.go
Normal file
25
ee/backend/pkg/spot/auth/authorizer.go
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
func (a *authImpl) IsAuthorized(authHeader string, permissions []string, isExtension bool) (*User, error) {
|
||||||
|
secret := a.secret
|
||||||
|
if isExtension {
|
||||||
|
secret = a.spotSecret
|
||||||
|
}
|
||||||
|
jwtInfo, err := parseJWT(authHeader, secret)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := authUser(a.pgconn, jwtInfo.UserId, jwtInfo.TenantID, int(jwtInfo.IssuedAt.Unix()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, perm := range permissions {
|
||||||
|
if !user.HasPermission(perm) {
|
||||||
|
return nil, fmt.Errorf("user has no permission")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
32
ee/backend/pkg/spot/auth/storage.go
Normal file
32
ee/backend/pkg/spot/auth/storage.go
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"openreplay/backend/pkg/db/postgres/pool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func authUser(conn pool.Pool, userID, tenantID, jwtIAT int) (*User, error) {
|
||||||
|
sql := `SELECT user_id, users.tenant_id, users.name, email, EXTRACT(epoch FROM spot_jwt_iat)::BIGINT AS spot_jwt_iat, roles.permissions
|
||||||
|
FROM users
|
||||||
|
JOIN tenants on users.tenant_id = tenants.tenant_id
|
||||||
|
JOIN roles on users.role_id = roles.role_id
|
||||||
|
WHERE users.user_id = $1 AND users.tenant_id = $2 AND users.deleted_at IS NULL ;`
|
||||||
|
|
||||||
|
user := &User{}
|
||||||
|
var permissions []string
|
||||||
|
if err := conn.QueryRow(sql, userID, tenantID).
|
||||||
|
Scan(&user.ID, &user.TenantID, &user.Name, &user.Email, &user.JwtIat, &permissions); err != nil {
|
||||||
|
return nil, fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
if user.JwtIat == 0 || abs(jwtIAT-user.JwtIat) > 1 {
|
||||||
|
return nil, fmt.Errorf("token expired")
|
||||||
|
}
|
||||||
|
user.Permissions = make(map[string]bool)
|
||||||
|
for _, perm := range permissions {
|
||||||
|
user.Permissions[perm] = true
|
||||||
|
}
|
||||||
|
if _, ok := user.Permissions["SPOT"]; !ok {
|
||||||
|
return nil, fmt.Errorf("user has no permissions")
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
45
ee/backend/pkg/spot/builder.go
Normal file
45
ee/backend/pkg/spot/builder.go
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
package spot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"openreplay/backend/internal/config/spot"
|
||||||
|
"openreplay/backend/pkg/db/postgres/pool"
|
||||||
|
"openreplay/backend/pkg/flakeid"
|
||||||
|
"openreplay/backend/pkg/logger"
|
||||||
|
"openreplay/backend/pkg/objectstorage"
|
||||||
|
"openreplay/backend/pkg/objectstorage/store"
|
||||||
|
"openreplay/backend/pkg/spot/auth"
|
||||||
|
"openreplay/backend/pkg/spot/service"
|
||||||
|
"openreplay/backend/pkg/spot/transcoder"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServicesBuilder struct {
|
||||||
|
Flaker *flakeid.Flaker
|
||||||
|
ObjStorage objectstorage.ObjectStorage
|
||||||
|
Auth auth.Auth
|
||||||
|
Spots service.Spots
|
||||||
|
Keys service.Keys
|
||||||
|
Transcoder transcoder.Transcoder
|
||||||
|
Tracer service.Tracer
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServiceBuilder(log logger.Logger, cfg *spot.Config, pgconn pool.Pool) (*ServicesBuilder, error) {
|
||||||
|
objStore, err := store.NewStore(&cfg.ObjectsConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
flaker := flakeid.NewFlaker(cfg.WorkerID)
|
||||||
|
tracer, err := service.NewTracer(log, pgconn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
spots := service.NewSpots(log, pgconn, flaker)
|
||||||
|
return &ServicesBuilder{
|
||||||
|
Flaker: flaker,
|
||||||
|
ObjStorage: objStore,
|
||||||
|
Auth: auth.NewAuth(log, cfg.JWTSecret, cfg.JWTSpotSecret, pgconn),
|
||||||
|
Spots: spots,
|
||||||
|
Keys: service.NewKeys(log, pgconn),
|
||||||
|
Transcoder: transcoder.NewTranscoder(cfg, log, objStore, pgconn, spots),
|
||||||
|
Tracer: tracer,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
104
ee/backend/pkg/spot/service/tracer.go
Normal file
104
ee/backend/pkg/spot/service/tracer.go
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"openreplay/backend/pkg/db/postgres"
|
||||||
|
db "openreplay/backend/pkg/db/postgres/pool"
|
||||||
|
"openreplay/backend/pkg/logger"
|
||||||
|
"openreplay/backend/pkg/pool"
|
||||||
|
"openreplay/backend/pkg/spot/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Tracer interface {
|
||||||
|
Trace(user *auth.User, data *RequestData) error
|
||||||
|
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.Info(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 *auth.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
|
||||||
|
}
|
||||||
|
|
@ -57,7 +57,7 @@ spec:
|
||||||
name: minio
|
name: minio
|
||||||
port:
|
port:
|
||||||
number: 9000
|
number: 9000
|
||||||
path: /(minio|mobs|sessions-assets|frontend|static|sourcemaps|ios-images|records|uxtesting-records)/
|
path: /(minio|mobs|sessions-assets|frontend|static|sourcemaps|ios-images|records|uxtesting-records|spots)/
|
||||||
tls:
|
tls:
|
||||||
- hosts:
|
- hosts:
|
||||||
- {{ .Values.global.domainName }}
|
- {{ .Values.global.domainName }}
|
||||||
|
|
|
||||||
23
scripts/helmcharts/openreplay/charts/spot/.helmignore
Normal file
23
scripts/helmcharts/openreplay/charts/spot/.helmignore
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Patterns to ignore when building packages.
|
||||||
|
# This supports shell glob matching, relative path matching, and
|
||||||
|
# negation (prefixed with !). Only one pattern per line.
|
||||||
|
.DS_Store
|
||||||
|
# Common VCS dirs
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
.bzr/
|
||||||
|
.bzrignore
|
||||||
|
.hg/
|
||||||
|
.hgignore
|
||||||
|
.svn/
|
||||||
|
# Common backup files
|
||||||
|
*.swp
|
||||||
|
*.bak
|
||||||
|
*.tmp
|
||||||
|
*.orig
|
||||||
|
*~
|
||||||
|
# Various IDEs
|
||||||
|
.project
|
||||||
|
.idea/
|
||||||
|
*.tmproj
|
||||||
|
.vscode/
|
||||||
24
scripts/helmcharts/openreplay/charts/spot/Chart.yaml
Normal file
24
scripts/helmcharts/openreplay/charts/spot/Chart.yaml
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
apiVersion: v2
|
||||||
|
name: spot
|
||||||
|
description: A Helm chart for Kubernetes
|
||||||
|
|
||||||
|
# A chart can be either an 'application' or a 'library' chart.
|
||||||
|
#
|
||||||
|
# Application charts are a collection of templates that can be packaged into versioned archives
|
||||||
|
# to be deployed.
|
||||||
|
#
|
||||||
|
# Library charts provide useful utilities or functions for the chart developer. They're included as
|
||||||
|
# a dependency of application charts to inject those utilities and functions into the rendering
|
||||||
|
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
|
||||||
|
type: application
|
||||||
|
|
||||||
|
# This is the chart version. This version number should be incremented each time you make changes
|
||||||
|
# to the chart and its templates, including the app version.
|
||||||
|
# Versions are expected to follow Semantic Versioning (https://semver.org/)
|
||||||
|
version: 0.1.1
|
||||||
|
|
||||||
|
# This is the version number of the application being deployed. This version number should be
|
||||||
|
# incremented each time you make changes to the application. Versions are not expected to
|
||||||
|
# follow Semantic Versioning. They should reflect the version the application is using.
|
||||||
|
# It is recommended to use it with quotes.
|
||||||
|
AppVersion: "v1.18.0"
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
1. Get the application URL by running these commands:
|
||||||
|
{{- if .Values.ingress.enabled }}
|
||||||
|
{{- range $host := .Values.ingress.hosts }}
|
||||||
|
{{- range .paths }}
|
||||||
|
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- else if contains "NodePort" .Values.service.type }}
|
||||||
|
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "http.fullname" . }})
|
||||||
|
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
|
||||||
|
echo http://$NODE_IP:$NODE_PORT
|
||||||
|
{{- else if contains "LoadBalancer" .Values.service.type }}
|
||||||
|
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
|
||||||
|
You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "spot.fullname" . }}'
|
||||||
|
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "spot.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
|
||||||
|
echo http://$SERVICE_IP:{{ .Values.service.port }}
|
||||||
|
{{- else if contains "ClusterIP" .Values.service.type }}
|
||||||
|
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "spot.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
|
||||||
|
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
|
||||||
|
echo "Visit http://127.0.0.1:8080 to use your application"
|
||||||
|
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
|
||||||
|
{{- end }}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
{{/*
|
||||||
|
Expand the name of the chart.
|
||||||
|
*/}}
|
||||||
|
{{- define "spot.name" -}}
|
||||||
|
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Create a default fully qualified app name.
|
||||||
|
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
|
||||||
|
If release name contains chart name it will be used as a full name.
|
||||||
|
*/}}
|
||||||
|
{{- define "spot.fullname" -}}
|
||||||
|
{{- if .Values.fullnameOverride }}
|
||||||
|
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- else }}
|
||||||
|
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||||
|
{{- if contains $name .Release.Name }}
|
||||||
|
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- else }}
|
||||||
|
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Create chart name and version as used by the chart label.
|
||||||
|
*/}}
|
||||||
|
{{- define "spot.chart" -}}
|
||||||
|
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Common labels
|
||||||
|
*/}}
|
||||||
|
{{- define "spot.labels" -}}
|
||||||
|
helm.sh/chart: {{ include "spot.chart" . }}
|
||||||
|
{{ include "spot.selectorLabels" . }}
|
||||||
|
{{- if .Chart.AppVersion }}
|
||||||
|
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||||
|
{{- end }}
|
||||||
|
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Selector labels
|
||||||
|
*/}}
|
||||||
|
{{- define "spot.selectorLabels" -}}
|
||||||
|
app.kubernetes.io/name: {{ include "spot.name" . }}
|
||||||
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Create the name of the service account to use
|
||||||
|
*/}}
|
||||||
|
{{- define "spot.serviceAccountName" -}}
|
||||||
|
{{- if .Values.serviceAccount.create }}
|
||||||
|
{{- default (include "spot.fullname" .) .Values.serviceAccount.name }}
|
||||||
|
{{- else }}
|
||||||
|
{{- default "default" .Values.serviceAccount.name }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: {{ include "spot.fullname" . }}
|
||||||
|
namespace: {{ .Release.Namespace }}
|
||||||
|
labels:
|
||||||
|
{{- include "spot.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
{{- if not .Values.autoscaling.enabled }}
|
||||||
|
replicas: {{ .Values.replicaCount }}
|
||||||
|
{{- end }}
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
{{- include "spot.selectorLabels" . | nindent 6 }}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
{{- with .Values.podAnnotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
labels:
|
||||||
|
{{- include "spot.selectorLabels" . | nindent 8 }}
|
||||||
|
spec:
|
||||||
|
{{- with .Values.imagePullSecrets }}
|
||||||
|
imagePullSecrets:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
serviceAccountName: {{ include "spot.serviceAccountName" . }}
|
||||||
|
securityContext:
|
||||||
|
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||||
|
shareProcessNamespace: true
|
||||||
|
containers:
|
||||||
|
- name: {{ .Chart.Name }}
|
||||||
|
securityContext:
|
||||||
|
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||||
|
{{- if .Values.global.enterpriseEditionLicense }}
|
||||||
|
image: "{{ tpl .Values.image.repository . }}:{{ .Values.image.tag | default .Chart.AppVersion }}-ee"
|
||||||
|
{{- else }}
|
||||||
|
image: "{{ tpl .Values.image.repository . }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||||
|
{{- end }}
|
||||||
|
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||||
|
{{- if .Values.healthCheck}}
|
||||||
|
{{- .Values.healthCheck | toYaml | nindent 10}}
|
||||||
|
{{- end}}
|
||||||
|
env:
|
||||||
|
{{- range $key, $val := .Values.env }}
|
||||||
|
- name: {{ $key }}
|
||||||
|
value: '{{ $val }}'
|
||||||
|
{{- end}}
|
||||||
|
{{- range $key, $val := .Values.global.env }}
|
||||||
|
- name: {{ $key }}
|
||||||
|
value: '{{ $val }}'
|
||||||
|
{{- end }}
|
||||||
|
- name: AWS_ACCESS_KEY_ID
|
||||||
|
{{- if .Values.global.s3.existingSecret }}
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ .Values.global.s3.existingSecret }}
|
||||||
|
key: access-key
|
||||||
|
{{- else }}
|
||||||
|
value: {{ .Values.global.s3.accessKey }}
|
||||||
|
{{- end }}
|
||||||
|
- name: AWS_SECRET_ACCESS_KEY
|
||||||
|
{{- if .Values.global.s3.existingSecret }}
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ .Values.global.s3.existingSecret }}
|
||||||
|
key: secret-key
|
||||||
|
{{- else }}
|
||||||
|
value: {{ .Values.global.s3.secretKey }}
|
||||||
|
{{- end }}
|
||||||
|
- name: AWS_REGION
|
||||||
|
value: '{{ .Values.global.s3.region }}'
|
||||||
|
- name: AWS_ENDPOINT
|
||||||
|
value: '{{- include "openreplay.s3Endpoint" . }}'
|
||||||
|
- name: LICENSE_KEY
|
||||||
|
value: '{{ .Values.global.enterpriseEditionLicense }}'
|
||||||
|
- name: KAFKA_SERVERS
|
||||||
|
value: '{{ .Values.global.kafka.kafkaHost }}:{{ .Values.global.kafka.kafkaPort }}'
|
||||||
|
- name: KAFKA_USE_SSL
|
||||||
|
value: '{{ .Values.global.kafka.kafkaUseSsl }}'
|
||||||
|
- name: pg_password
|
||||||
|
{{- if .Values.global.postgresql.existingSecret }}
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ .Values.global.postgresql.existingSecret }}
|
||||||
|
key: postgresql-postgres-password
|
||||||
|
{{- else }}
|
||||||
|
value: '{{ .Values.global.postgresql.postgresqlPassword }}'
|
||||||
|
{{- end}}
|
||||||
|
- name: POSTGRES_STRING
|
||||||
|
value: 'postgres://{{ .Values.global.postgresql.postgresqlUser }}:$(pg_password)@{{ .Values.global.postgresql.postgresqlHost }}:{{ .Values.global.postgresql.postgresqlPort }}/{{ .Values.global.postgresql.postgresqlDatabase }}'
|
||||||
|
{{- include "openreplay.env.redis_string" .Values.global.redis | nindent 12 }}
|
||||||
|
ports:
|
||||||
|
{{- range $key, $val := .Values.service.ports }}
|
||||||
|
- name: {{ $key }}
|
||||||
|
containerPort: {{ $val }}
|
||||||
|
protocol: TCP
|
||||||
|
{{- end }}
|
||||||
|
volumeMounts:
|
||||||
|
- name: datadir
|
||||||
|
mountPath: /mnt/efs
|
||||||
|
{{- include "openreplay.volume.redis_ca_certificate.mount" .Values.global.redis | nindent 12 }}
|
||||||
|
{{- with .Values.persistence.mounts }}
|
||||||
|
{{- toYaml . | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
|
resources:
|
||||||
|
{{- toYaml .Values.resources | nindent 12 }}
|
||||||
|
{{- if eq (tpl .Values.pvc.name . ) "hostPath" }}
|
||||||
|
volumes:
|
||||||
|
{{- with .Values.persistence.volumes }}
|
||||||
|
{{- toYaml . | nindent 6 }}
|
||||||
|
{{- end }}
|
||||||
|
- name: datadir
|
||||||
|
hostPath:
|
||||||
|
# Ensure the file directory is created.
|
||||||
|
path: {{ tpl .Values.pvc.hostMountPath . }}
|
||||||
|
type: DirectoryOrCreate
|
||||||
|
{{- else }}
|
||||||
|
volumes:
|
||||||
|
{{- with .Values.persistence.volumes }}
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
- name: datadir
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: "{{ tpl .Values.pvc.name . }}"
|
||||||
|
{{- end }}
|
||||||
|
{{- include "openreplay.volume.redis_ca_certificate" .Values.global.redis | nindent 6 }}
|
||||||
|
{{- with .Values.nodeSelector }}
|
||||||
|
nodeSelector:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.affinity }}
|
||||||
|
affinity:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.tolerations }}
|
||||||
|
tolerations:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
29
scripts/helmcharts/openreplay/charts/spot/templates/hpa.yaml
Normal file
29
scripts/helmcharts/openreplay/charts/spot/templates/hpa.yaml
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{{- if .Values.autoscaling.enabled }}
|
||||||
|
apiVersion: autoscaling/v2beta1
|
||||||
|
kind: HorizontalPodAutoscaler
|
||||||
|
metadata:
|
||||||
|
name: {{ include "spot.fullname" . }}
|
||||||
|
namespace: {{ .Release.Namespace }}
|
||||||
|
labels:
|
||||||
|
{{- include "spot.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
scaleTargetRef:
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
name: {{ include "spot.fullname" . }}
|
||||||
|
minReplicas: {{ .Values.autoscaling.minReplicas }}
|
||||||
|
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
|
||||||
|
metrics:
|
||||||
|
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
|
||||||
|
- type: Resource
|
||||||
|
resource:
|
||||||
|
name: cpu
|
||||||
|
targetAverageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
|
||||||
|
- type: Resource
|
||||||
|
resource:
|
||||||
|
name: memory
|
||||||
|
targetAverageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
{{- if .Values.ingress.enabled }}
|
||||||
|
{{- $fullName := include "spot.fullname" . -}}
|
||||||
|
{{- $svcPort := .Values.service.ports.http -}}
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: {{ $fullName }}
|
||||||
|
namespace: {{ .Release.Namespace }}
|
||||||
|
labels:
|
||||||
|
{{- include "spot.labels" . | nindent 4 }}
|
||||||
|
annotations:
|
||||||
|
{{- with .Values.ingress.annotations }}
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
nginx.ingress.kubernetes.io/rewrite-target: /$1
|
||||||
|
nginx.ingress.kubernetes.io/upstream-hash-by: $http_x_forwarded_for
|
||||||
|
spec:
|
||||||
|
ingressClassName: "{{ tpl .Values.ingress.className . }}"
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- {{ .Values.global.domainName }}
|
||||||
|
{{- if .Values.ingress.tls.secretName}}
|
||||||
|
secretName: {{ .Values.ingress.tls.secretName }}
|
||||||
|
{{- end}}
|
||||||
|
rules:
|
||||||
|
- host: {{ .Values.global.domainName }}
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: {{ $fullName }}
|
||||||
|
port:
|
||||||
|
number: {{ $svcPort }}
|
||||||
|
path: /spot/(.*)
|
||||||
|
{{- end }}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: {{ include "spot.fullname" . }}
|
||||||
|
namespace: {{ .Release.Namespace }}
|
||||||
|
labels:
|
||||||
|
{{- include "spot.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
type: {{ .Values.service.type }}
|
||||||
|
ports:
|
||||||
|
{{- range $key, $val := .Values.service.ports }}
|
||||||
|
- port: {{ $val }}
|
||||||
|
targetPort: {{ $key }}
|
||||||
|
protocol: TCP
|
||||||
|
name: {{ $key }}
|
||||||
|
{{- end}}
|
||||||
|
selector:
|
||||||
|
{{- include "spot.selectorLabels" . | nindent 4 }}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
{{- if and ( .Capabilities.APIVersions.Has "monitoring.coreos.com/v1" ) ( .Values.serviceMonitor.enabled ) }}
|
||||||
|
apiVersion: monitoring.coreos.com/v1
|
||||||
|
kind: ServiceMonitor
|
||||||
|
metadata:
|
||||||
|
name: {{ include "spot.fullname" . }}
|
||||||
|
namespace: {{ .Release.Namespace }}
|
||||||
|
labels:
|
||||||
|
{{- include "spot.labels" . | nindent 4 }}
|
||||||
|
{{- if .Values.serviceMonitor.additionalLabels }}
|
||||||
|
{{- toYaml .Values.serviceMonitor.additionalLabels | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
endpoints:
|
||||||
|
{{- .Values.serviceMonitor.scrapeConfigs | toYaml | nindent 4 }}
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
{{- include "spot.selectorLabels" . | nindent 6 }}
|
||||||
|
{{- end }}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
{{- if .Values.serviceAccount.create -}}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: {{ include "spot.serviceAccountName" . }}
|
||||||
|
namespace: {{ .Release.Namespace }}
|
||||||
|
labels:
|
||||||
|
{{- include "spot.labels" . | nindent 4 }}
|
||||||
|
{{- with .Values.serviceAccount.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Pod
|
||||||
|
metadata:
|
||||||
|
name: "{{ include "spot.fullname" . }}-test-connection"
|
||||||
|
labels:
|
||||||
|
{{- include "spot.labels" . | nindent 4 }}
|
||||||
|
annotations:
|
||||||
|
"helm.sh/hook": test
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: wget
|
||||||
|
image: busybox
|
||||||
|
command: ['wget']
|
||||||
|
args: ['{{ include "spot.fullname" . }}:{{ .Values.service.port }}']
|
||||||
|
restartPolicy: Never
|
||||||
133
scripts/helmcharts/openreplay/charts/spot/values.yaml
Normal file
133
scripts/helmcharts/openreplay/charts/spot/values.yaml
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
# Default values for openreplay.
|
||||||
|
# This is a YAML-formatted file.
|
||||||
|
# Declare variables to be passed into your templates.
|
||||||
|
|
||||||
|
replicaCount: 1
|
||||||
|
|
||||||
|
image:
|
||||||
|
repository: "{{ .Values.global.openReplayContainerRegistry }}/spot"
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
# Overrides the image tag whose default is the chart appVersion.
|
||||||
|
tag: ""
|
||||||
|
|
||||||
|
imagePullSecrets: []
|
||||||
|
nameOverride: "spot"
|
||||||
|
fullnameOverride: "spot-openreplay"
|
||||||
|
|
||||||
|
serviceAccount:
|
||||||
|
# Specifies whether a service account should be created
|
||||||
|
create: true
|
||||||
|
# Annotations to add to the service account
|
||||||
|
annotations: {}
|
||||||
|
# The name of the service account to use.
|
||||||
|
# If not set and create is true, a name is generated using the fullname template
|
||||||
|
name: ""
|
||||||
|
|
||||||
|
podAnnotations: {}
|
||||||
|
|
||||||
|
securityContext:
|
||||||
|
runAsUser: 1001
|
||||||
|
runAsGroup: 1001
|
||||||
|
podSecurityContext:
|
||||||
|
runAsUser: 1001
|
||||||
|
runAsGroup: 1001
|
||||||
|
fsGroup: 1001
|
||||||
|
fsGroupChangePolicy: "OnRootMismatch"
|
||||||
|
# podSecurityContext: {}
|
||||||
|
# fsGroup: 2000
|
||||||
|
|
||||||
|
# securityContext: {}
|
||||||
|
# capabilities:
|
||||||
|
# drop:
|
||||||
|
# - ALL
|
||||||
|
# readOnlyRootFilesystem: true
|
||||||
|
# runAsNonRoot: true
|
||||||
|
# runAsUser: 1000
|
||||||
|
|
||||||
|
service:
|
||||||
|
type: ClusterIP
|
||||||
|
ports:
|
||||||
|
http: 8080
|
||||||
|
metrics: 8888
|
||||||
|
|
||||||
|
serviceMonitor:
|
||||||
|
enabled: true
|
||||||
|
additionalLabels:
|
||||||
|
release: observability
|
||||||
|
scrapeConfigs:
|
||||||
|
- port: metrics
|
||||||
|
honorLabels: true
|
||||||
|
interval: 15s
|
||||||
|
path: /metrics
|
||||||
|
scheme: http
|
||||||
|
scrapeTimeout: 10s
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
enabled: true
|
||||||
|
className: "{{ .Values.global.ingress.controller.ingressClassResource.name }}"
|
||||||
|
annotations:
|
||||||
|
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||||
|
nginx.ingress.kubernetes.io/proxy-connect-timeout: "120"
|
||||||
|
nginx.ingress.kubernetes.io/proxy-send-timeout: "300"
|
||||||
|
nginx.ingress.kubernetes.io/proxy-read-timeout: "300"
|
||||||
|
nginx.ingress.kubernetes.io/cors-allow-methods: POST,PATCH,DELETE
|
||||||
|
nginx.ingress.kubernetes.io/cors-allow-headers: Content-Type,Authorization,Content-Encoding,X-Openreplay-Batch
|
||||||
|
nginx.ingress.kubernetes.io/cors-allow-origin: '*'
|
||||||
|
nginx.ingress.kubernetes.io/enable-cors: "true"
|
||||||
|
nginx.ingress.kubernetes.io/cors-expose-headers: "Content-Length"
|
||||||
|
# kubernetes.io/ingress.class: nginx
|
||||||
|
# kubernetes.io/tls-acme: "true"
|
||||||
|
tls:
|
||||||
|
secretName: openreplay-ssl
|
||||||
|
|
||||||
|
resources: {}
|
||||||
|
# We usually recommend not to specify default resources and to leave this as a conscious
|
||||||
|
# choice for the user. This also increases chances charts run on environments with little
|
||||||
|
# resources, such as Minikube. If you do want to specify resources, uncomment the following
|
||||||
|
# lines, adjust them as necessary, and remove the curly braces after 'resources:'.
|
||||||
|
# limits:
|
||||||
|
# cpu: 100m
|
||||||
|
# memory: 128Mi
|
||||||
|
# requests:
|
||||||
|
# cpu: 100m
|
||||||
|
# memory: 128Mi
|
||||||
|
|
||||||
|
autoscaling:
|
||||||
|
enabled: false
|
||||||
|
minReplicas: 1
|
||||||
|
maxReplicas: 5
|
||||||
|
targetCPUUtilizationPercentage: 80
|
||||||
|
# targetMemoryUtilizationPercentage: 80
|
||||||
|
|
||||||
|
env:
|
||||||
|
TOKEN_SECRET: secret_token_string # TODO: generate on build
|
||||||
|
CACHE_ASSETS: true
|
||||||
|
FS_CLEAN_HRS: 24
|
||||||
|
|
||||||
|
|
||||||
|
pvc:
|
||||||
|
# This can be either persistentVolumeClaim or hostPath.
|
||||||
|
# In case of pvc, you'll have to provide the pvc name.
|
||||||
|
# For example
|
||||||
|
# name: openreplay-efs
|
||||||
|
name: "{{ .Values.global.pvcRWXName }}"
|
||||||
|
hostMountPath: "{{ .Values.global.orTmpDir }}"
|
||||||
|
|
||||||
|
|
||||||
|
nodeSelector: {}
|
||||||
|
|
||||||
|
tolerations: []
|
||||||
|
|
||||||
|
affinity: {}
|
||||||
|
|
||||||
|
|
||||||
|
persistence: {}
|
||||||
|
# # Spec of spec.template.spec.containers[*].volumeMounts
|
||||||
|
# mounts:
|
||||||
|
# - name: kafka-ssl
|
||||||
|
# mountPath: /opt/kafka/ssl
|
||||||
|
# # Spec of spec.template.spec.volumes
|
||||||
|
# volumes:
|
||||||
|
# - name: kafka-ssl
|
||||||
|
# secret:
|
||||||
|
# secretName: kafka-ssl
|
||||||
Loading…
Add table
Reference in a new issue