diff --git a/cmd/api/main.go b/cmd/api/main.go index 30f9b40..0b3c333 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -53,6 +53,12 @@ func StartServer() { } defer poolPg.Close() + err = database.SeedSuperAdmin(poolPg) + if err != nil { + log.Error().Msg(err.Error()) + panic(err) + } + sqlTile, err := mbtiles.NewMBTilesDB("data/map.mbtiles") if err != nil { log.Error().Msg(err.Error()) diff --git a/db/migrations/000001_users.up.sql b/db/migrations/000001_users.up.sql index 16852de..671e1b3 100644 --- a/db/migrations/000001_users.up.sql +++ b/db/migrations/000001_users.up.sql @@ -1,4 +1,6 @@ CREATE EXTENSION IF NOT EXISTS postgis; +CREATE EXTENSION IF NOT EXISTS pg_trgm; +CREATE EXTENSION IF NOT EXISTS btree_gist; CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT uuidv7(), diff --git a/db/migrations/000008_files.down.sql b/db/migrations/000008_files.down.sql new file mode 100644 index 0000000..03d2bbf --- /dev/null +++ b/db/migrations/000008_files.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS medias; \ No newline at end of file diff --git a/db/migrations/000008_files.up.sql b/db/migrations/000008_files.up.sql new file mode 100644 index 0000000..2c54e00 --- /dev/null +++ b/db/migrations/000008_files.up.sql @@ -0,0 +1,20 @@ +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE TABLE medias ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + storage_key VARCHAR(255) UNIQUE NOT NULL, + original_name VARCHAR(255) NOT NULL, + mime_type VARCHAR(100) NOT NULL, + size BIGINT NOT NULL, + target_type VARCHAR(50) NOT NULL, + target_id UUID NOT NULL, + file_metadata JSONB DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX idx_medias_target ON medias (target_type, target_id); +CREATE INDEX idx_medias_user_created ON medias (user_id, created_at DESC); +CREATE INDEX idx_medias_original_name_trgm ON medias USING GIN (original_name gin_trgm_ops); +CREATE INDEX idx_medias_storage_key_trgm ON medias USING GIN (storage_key gin_trgm_ops); \ No newline at end of file diff --git a/db/query/files.sql b/db/query/files.sql new file mode 100644 index 0000000..ef99b9f --- /dev/null +++ b/db/query/files.sql @@ -0,0 +1,39 @@ +-- name: CreateMedia :one +INSERT INTO medias ( + user_id, storage_key, original_name, mime_type, size, target_type, target_id, file_metadata +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8 +) +RETURNING *; + +-- name: GetMediasByTarget :many +SELECT * FROM medias +WHERE target_type = $1 AND target_id = $2 +ORDER BY created_at DESC; + +-- name: DeleteMedia :exec +DELETE FROM medias +WHERE id = $1; + +-- name: SearchMedias :many +SELECT * +FROM medias +WHERE + (sqlc.narg('cursor')::uuid IS NULL OR id > sqlc.narg('cursor')::uuid) + AND (sqlc.narg('target_types')::varchar[] IS NULL OR target_type = ANY(sqlc.narg('target_types')::varchar[])) + AND ( + sqlc.narg('search_text')::text IS NULL OR + original_name ILIKE '%' || sqlc.narg('search_text')::text || '%' OR + storage_key ILIKE '%' || sqlc.narg('search_text')::text || '%' + ) +ORDER BY id ASC +LIMIT sqlc.arg('limit'); + +-- name: GetMediasByUserID :many +SELECT * FROM medias +WHERE user_id = $1 +ORDER BY created_at DESC; + +-- name: GetMediaByID :one +SELECT * FROM medias +WHERE id = $1; \ No newline at end of file diff --git a/db/schema.sql b/db/schema.sql index d748acf..d0cf1c5 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -48,4 +48,19 @@ CREATE TABLE IF NOT EXISTS user_verifications ( reviewed_by UUID REFERENCES users(id), reviewed_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT now() -); \ No newline at end of file +); + + +CREATE TABLE medias ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + storage_key VARCHAR(255) UNIQUE NOT NULL, + original_name VARCHAR(255) NOT NULL, + mime_type VARCHAR(100) NOT NULL, + size BIGINT NOT NULL, + target_type VARCHAR(50) NOT NULL, + target_id UUID NOT NULL, + file_metadata JSONB DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); diff --git a/docker-compose.yml b/docker-compose.yml index c74744d..50b9861 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,8 @@ services: - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - POSTGRES_DB=${POSTGRES_DB} - PGDATA=/var/lib/postgresql/data + ports: + - "5432:5432" volumes: - pg_data:/var/lib/postgresql/data healthcheck: diff --git a/go.mod b/go.mod index 77732d8..8868406 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,10 @@ module history-api go 1.26.1 require ( + github.com/aws/aws-sdk-go-v2 v1.41.5 + github.com/aws/aws-sdk-go-v2/config v1.32.13 + github.com/aws/aws-sdk-go-v2/credentials v1.19.13 + github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 github.com/glebarez/go-sqlite v1.22.0 github.com/go-playground/validator/v10 v10.30.1 github.com/gofiber/contrib/v3/jwt v1.1.0 @@ -18,6 +22,8 @@ require ( github.com/swaggo/swag v1.16.6 github.com/wneessen/go-mail v0.7.2 golang.org/x/crypto v0.49.0 + golang.org/x/oauth2 v0.36.0 + google.golang.org/api v0.273.0 ) require ( @@ -27,6 +33,21 @@ require ( github.com/KyleBanks/depth v1.2.1 // indirect github.com/MicahParks/keyfunc/v2 v2.1.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.14 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect + github.com/aws/smithy-go v1.24.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dustin/go-humanize v1.0.1 // indirect @@ -64,7 +85,6 @@ require ( github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/compress v1.18.5 // indirect - github.com/kr/text v0.2.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -84,12 +104,10 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/mod v0.33.0 // indirect golang.org/x/net v0.52.0 // indirect - golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect golang.org/x/tools v0.42.0 // indirect - google.golang.org/api v0.273.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/grpc v1.79.3 // indirect google.golang.org/protobuf v1.36.11 // indirect diff --git a/go.sum b/go.sum index 1d53e0c..4deae4e 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,6 @@ cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= -cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= -cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= @@ -12,6 +10,44 @@ github.com/MicahParks/keyfunc/v2 v2.1.0 h1:6ZXKb9Rp6qp1bDbJefnG7cTH8yMN1IC/4nf+G github.com/MicahParks/keyfunc/v2 v2.1.0/go.mod h1:rW42fi+xgLJ2FRRXAfNx9ZA8WpD4OeE/yHVMteCkw9k= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= +github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= +github.com/aws/aws-sdk-go-v2/config v1.32.13 h1:5KgbxMaS2coSWRrx9TX/QtWbqzgQkOdEa3sZPhBhCSg= +github.com/aws/aws-sdk-go-v2/config v1.32.13/go.mod h1:8zz7wedqtCbw5e9Mi2doEwDyEgHcEE9YOJp6a8jdSMY= +github.com/aws/aws-sdk-go-v2/credentials v1.19.13 h1:mA59E3fokBvyEGHKFdnpNNrvaR351cqiHgRg+JzOSRI= +github.com/aws/aws-sdk-go-v2/credentials v1.19.13/go.mod h1:yoTXOQKea18nrM69wGF9jBdG4WocSZA1h38A+t/MAsk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.14 h1:GcLE9ba5ehAQma6wlopUesYg/hbcOhFNWTjELkiWkh4= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.14/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18 h1:mP49nTpfKtpXLt5SLn8Uv8z6W+03jYVoOSAl/c02nog= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw= +github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= +github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -19,7 +55,6 @@ github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -108,6 +143,8 @@ github.com/gofiber/utils/v2 v2.0.2 h1:ShRRssz0F3AhTlAQcuEj54OEDtWF7+HJDwEi/aa6QL github.com/gofiber/utils/v2 v2.0.2/go.mod h1:+9Ub4NqQ+IaJoTliq5LfdmOJAA/Hzwf4pXOxOa3RrJ0= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= @@ -191,12 +228,18 @@ go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5 go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= +go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= +go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= +go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= +go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= @@ -220,8 +263,12 @@ golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.273.0 h1:r/Bcv36Xa/te1ugaN1kdJ5LoA5Wj/cL+a4gj6FiPBjQ= google.golang.org/api v0.273.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew= google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w= diff --git a/internal/dtos/response/media.go b/internal/dtos/response/media.go index 560a9c3..49b1092 100644 --- a/internal/dtos/response/media.go +++ b/internal/dtos/response/media.go @@ -1,5 +1,7 @@ package response +import "time" + type PreSignedResponse struct { UploadUrl string `json:"uploadUrl"` PublicUrl string `json:"publicUrl"` @@ -7,3 +9,17 @@ type PreSignedResponse struct { MediaId string `json:"mediaId"` SignedHeaders map[string]string `json:"signedHeaders"` } + +type MediaResponse struct { + ID string `json:"id"` + UserID string `json:"user_id"` + StorageKey string `json:"storage_key"` + OriginalName string `json:"original_name"` + MimeType string `json:"mime_type"` + Size int64 `json:"size"` + TargetType string `json:"target_type"` + TargetID string `json:"target_id"` + FileMetadata []byte `json:"file_metadata"` + CreatedAt *time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at"` +} diff --git a/internal/gen/sqlc/files.sql.go b/internal/gen/sqlc/files.sql.go new file mode 100644 index 0000000..3c0a22c --- /dev/null +++ b/internal/gen/sqlc/files.sql.go @@ -0,0 +1,234 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: files.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createMedia = `-- name: CreateMedia :one +INSERT INTO medias ( + user_id, storage_key, original_name, mime_type, size, target_type, target_id, file_metadata +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8 +) +RETURNING id, user_id, storage_key, original_name, mime_type, size, target_type, target_id, file_metadata, created_at, updated_at +` + +type CreateMediaParams struct { + UserID pgtype.UUID `json:"user_id"` + StorageKey string `json:"storage_key"` + OriginalName string `json:"original_name"` + MimeType string `json:"mime_type"` + Size int64 `json:"size"` + TargetType string `json:"target_type"` + TargetID pgtype.UUID `json:"target_id"` + FileMetadata []byte `json:"file_metadata"` +} + +func (q *Queries) CreateMedia(ctx context.Context, arg CreateMediaParams) (Media, error) { + row := q.db.QueryRow(ctx, createMedia, + arg.UserID, + arg.StorageKey, + arg.OriginalName, + arg.MimeType, + arg.Size, + arg.TargetType, + arg.TargetID, + arg.FileMetadata, + ) + var i Media + err := row.Scan( + &i.ID, + &i.UserID, + &i.StorageKey, + &i.OriginalName, + &i.MimeType, + &i.Size, + &i.TargetType, + &i.TargetID, + &i.FileMetadata, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteMedia = `-- name: DeleteMedia :exec +DELETE FROM medias +WHERE id = $1 +` + +func (q *Queries) DeleteMedia(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteMedia, id) + return err +} + +const getMediaByID = `-- name: GetMediaByID :one +SELECT id, user_id, storage_key, original_name, mime_type, size, target_type, target_id, file_metadata, created_at, updated_at FROM medias +WHERE id = $1 +` + +func (q *Queries) GetMediaByID(ctx context.Context, id pgtype.UUID) (Media, error) { + row := q.db.QueryRow(ctx, getMediaByID, id) + var i Media + err := row.Scan( + &i.ID, + &i.UserID, + &i.StorageKey, + &i.OriginalName, + &i.MimeType, + &i.Size, + &i.TargetType, + &i.TargetID, + &i.FileMetadata, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getMediasByTarget = `-- name: GetMediasByTarget :many +SELECT id, user_id, storage_key, original_name, mime_type, size, target_type, target_id, file_metadata, created_at, updated_at FROM medias +WHERE target_type = $1 AND target_id = $2 +ORDER BY created_at DESC +` + +type GetMediasByTargetParams struct { + TargetType string `json:"target_type"` + TargetID pgtype.UUID `json:"target_id"` +} + +func (q *Queries) GetMediasByTarget(ctx context.Context, arg GetMediasByTargetParams) ([]Media, error) { + rows, err := q.db.Query(ctx, getMediasByTarget, arg.TargetType, arg.TargetID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Media{} + for rows.Next() { + var i Media + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.StorageKey, + &i.OriginalName, + &i.MimeType, + &i.Size, + &i.TargetType, + &i.TargetID, + &i.FileMetadata, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getMediasByUserID = `-- name: GetMediasByUserID :many +SELECT id, user_id, storage_key, original_name, mime_type, size, target_type, target_id, file_metadata, created_at, updated_at FROM medias +WHERE user_id = $1 +ORDER BY created_at DESC +` + +func (q *Queries) GetMediasByUserID(ctx context.Context, userID pgtype.UUID) ([]Media, error) { + rows, err := q.db.Query(ctx, getMediasByUserID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Media{} + for rows.Next() { + var i Media + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.StorageKey, + &i.OriginalName, + &i.MimeType, + &i.Size, + &i.TargetType, + &i.TargetID, + &i.FileMetadata, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const searchMedias = `-- name: SearchMedias :many +SELECT id, user_id, storage_key, original_name, mime_type, size, target_type, target_id, file_metadata, created_at, updated_at +FROM medias +WHERE + ($1::uuid IS NULL OR id > $1::uuid) + AND ($2::varchar[] IS NULL OR target_type = ANY($2::varchar[])) + AND ( + $3::text IS NULL OR + original_name ILIKE '%' || $3::text || '%' OR + storage_key ILIKE '%' || $3::text || '%' + ) +ORDER BY id ASC +LIMIT $4 +` + +type SearchMediasParams struct { + Cursor pgtype.UUID `json:"cursor"` + TargetTypes []string `json:"target_types"` + SearchText pgtype.Text `json:"search_text"` + Limit int32 `json:"limit"` +} + +func (q *Queries) SearchMedias(ctx context.Context, arg SearchMediasParams) ([]Media, error) { + rows, err := q.db.Query(ctx, searchMedias, + arg.Cursor, + arg.TargetTypes, + arg.SearchText, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Media{} + for rows.Next() { + var i Media + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.StorageKey, + &i.OriginalName, + &i.MimeType, + &i.Size, + &i.TargetType, + &i.TargetID, + &i.FileMetadata, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/gen/sqlc/models.go b/internal/gen/sqlc/models.go index dcfd4e6..c6efbaa 100644 --- a/internal/gen/sqlc/models.go +++ b/internal/gen/sqlc/models.go @@ -8,6 +8,20 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +type Media struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + StorageKey string `json:"storage_key"` + OriginalName string `json:"original_name"` + MimeType string `json:"mime_type"` + Size int64 `json:"size"` + TargetType string `json:"target_type"` + TargetID pgtype.UUID `json:"target_id"` + FileMetadata []byte `json:"file_metadata"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type Role struct { ID pgtype.UUID `json:"id"` Name string `json:"name"` diff --git a/internal/models/media.go b/internal/models/media.go new file mode 100644 index 0000000..c1dedc1 --- /dev/null +++ b/internal/models/media.go @@ -0,0 +1,36 @@ +package models + +import ( + "history-api/internal/dtos/response" + "time" +) + +type MediaEntity struct { + ID string `json:"id"` + UserID string `json:"user_id"` + StorageKey string `json:"storage_key"` + OriginalName string `json:"original_name"` + MimeType string `json:"mime_type"` + Size int64 `json:"size"` + TargetType string `json:"target_type"` + TargetID string `json:"target_id"` + FileMetadata []byte `json:"file_metadata"` + CreatedAt *time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at"` +} + +func (e *MediaEntity) ToResponse() *response.MediaResponse { + return &response.MediaResponse{ + ID: e.ID, + UserID: e.UserID, + StorageKey: e.StorageKey, + OriginalName: e.OriginalName, + MimeType: e.MimeType, + Size: e.Size, + TargetType: e.TargetType, + TargetID: e.TargetID, + FileMetadata: e.FileMetadata, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + } +} diff --git a/internal/repositories/mediaRepository.go b/internal/repositories/mediaRepository.go new file mode 100644 index 0000000..3c9ad39 --- /dev/null +++ b/internal/repositories/mediaRepository.go @@ -0,0 +1,295 @@ +package repositories + +import ( + "context" + "crypto/md5" + "encoding/json" + "fmt" + "history-api/internal/gen/sqlc" + "history-api/internal/models" + "history-api/pkg/cache" + "history-api/pkg/constants" + "history-api/pkg/convert" + + "github.com/jackc/pgx/v5/pgtype" +) + +type MediaRepository interface { + GetByID(ctx context.Context, id pgtype.UUID) (*models.MediaEntity, error) + GetByUserID(ctx context.Context, userId pgtype.UUID) ([]*models.MediaEntity, error) + Search(ctx context.Context, params sqlc.SearchMediasParams) ([]*models.MediaEntity, error) + Delete(ctx context.Context, id pgtype.UUID) error + GetByTarget(ctx context.Context, params sqlc.GetMediasByTargetParams) ([]*models.MediaEntity, error) + Create(ctx context.Context, params sqlc.CreateMediaParams) (*models.MediaEntity, error) +} + +type mediaRepository struct { + q *sqlc.Queries + c cache.Cache +} + +func NewMediaRepository(db sqlc.DBTX, c cache.Cache) MediaRepository { + return &mediaRepository{ + q: sqlc.New(db), + c: c, + } +} + +func (r *mediaRepository) generateQueryKey(prefix string, params any) string { + b, _ := json.Marshal(params) + hash := fmt.Sprintf("%x", md5.Sum(b)) + return fmt.Sprintf("%s:%s", prefix, hash) +} + +func (r *mediaRepository) getByIDsWithFallback(ctx context.Context, ids []string) ([]*models.MediaEntity, error) { + if len(ids) == 0 { + return []*models.MediaEntity{}, nil + } + keys := make([]string, len(ids)) + for i, id := range ids { + keys[i] = fmt.Sprintf("media:id:%s", id) + } + raws := r.c.MGet(ctx, keys...) + + var medias []*models.MediaEntity + missingMediasToCache := make(map[string]any) + + for i, b := range raws { + if len(b) > 0 { + var m models.MediaEntity + if err := json.Unmarshal(b, &m); err == nil { + medias = append(medias, &m) + } + } else { + pgId := pgtype.UUID{} + err := pgId.Scan(ids[i]) + if err != nil { + continue + } + dbMedia, err := r.GetByID(ctx, pgId) + if err == nil && dbMedia != nil { + medias = append(medias, dbMedia) + missingMediasToCache[keys[i]] = dbMedia + } + } + } + + if len(missingMediasToCache) > 0 { + _ = r.c.MSet(ctx, missingMediasToCache, constants.NormalCacheDuration) + } + + return medias, nil +} + +func (r *mediaRepository) GetByID(ctx context.Context, id pgtype.UUID) (*models.MediaEntity, error) { + cacheId := fmt.Sprintf("media:id:%s", convert.UUIDToString(id)) + var media models.MediaEntity + err := r.c.Get(ctx, cacheId, &media) + if err == nil { + return &media, nil + } + + row, err := r.q.GetMediaByID(ctx, id) + if err != nil { + return nil, err + } + + media = models.MediaEntity{ + ID: convert.UUIDToString(row.ID), + UserID: convert.UUIDToString(row.UserID), + StorageKey: row.StorageKey, + OriginalName: row.OriginalName, + MimeType: row.MimeType, + Size: row.Size, + TargetType: row.TargetType, + TargetID: convert.UUIDToString(row.TargetID), + FileMetadata: row.FileMetadata, + CreatedAt: convert.TimeToPtr(row.CreatedAt), + UpdatedAt: convert.TimeToPtr(row.UpdatedAt), + } + + _ = r.c.Set(ctx, cacheId, media, constants.NormalCacheDuration) + + return &media, nil +} + +func (r *mediaRepository) Create(ctx context.Context, params sqlc.CreateMediaParams) (*models.MediaEntity, error) { + row, err := r.q.CreateMedia(ctx, params) + if err != nil { + return nil, err + } + + go func() { + bgCtx := context.Background() + + _ = r.c.DelByPattern(bgCtx, "media:target*") + _ = r.c.DelByPattern(bgCtx, "media:userId:*") + _ = r.c.DelByPattern(bgCtx, "media:search*") + }() + media := models.MediaEntity{ + ID: convert.UUIDToString(row.ID), + UserID: convert.UUIDToString(row.UserID), + StorageKey: row.StorageKey, + OriginalName: row.OriginalName, + MimeType: row.MimeType, + Size: row.Size, + TargetType: row.TargetType, + TargetID: convert.UUIDToString(row.TargetID), + FileMetadata: row.FileMetadata, + CreatedAt: convert.TimeToPtr(row.CreatedAt), + UpdatedAt: convert.TimeToPtr(row.UpdatedAt), + } + cacheId := fmt.Sprintf("media:id:%s", media.ID) + _ = r.c.Set(ctx, cacheId, media, constants.NormalCacheDuration) + return &media, nil +} + +func (r *mediaRepository) Delete(ctx context.Context, id pgtype.UUID) error { + err := r.q.DeleteMedia(ctx, id) + if err != nil { + return err + } + + cacheId := fmt.Sprintf("media:id:%s", convert.UUIDToString(id)) + _ = r.c.Del(ctx, cacheId) + + return nil +} + +func (r *mediaRepository) GetByTarget(ctx context.Context, params sqlc.GetMediasByTargetParams) ([]*models.MediaEntity, error) { + queryKey := r.generateQueryKey("media:target", params) + var cachedIDs []string + if err := r.c.Get(ctx, queryKey, &cachedIDs); err == nil && len(cachedIDs) > 0 { + return r.getByIDsWithFallback(ctx, cachedIDs) + } + + rows, err := r.q.GetMediasByTarget(ctx, params) + if err != nil { + return nil, err + } + var medias []*models.MediaEntity + var ids []string + mediasToCache := make(map[string]any) + + for _, row := range rows { + media := &models.MediaEntity{ + ID: convert.UUIDToString(row.ID), + UserID: convert.UUIDToString(row.UserID), + StorageKey: row.StorageKey, + OriginalName: row.OriginalName, + MimeType: row.MimeType, + Size: row.Size, + TargetType: row.TargetType, + TargetID: convert.UUIDToString(row.TargetID), + FileMetadata: row.FileMetadata, + CreatedAt: convert.TimeToPtr(row.CreatedAt), + UpdatedAt: convert.TimeToPtr(row.UpdatedAt), + } + ids = append(ids, media.ID) + medias = append(medias, media) + + mediasToCache[fmt.Sprintf("media:id:%s", media.ID)] = media + } + + if len(mediasToCache) > 0 { + _ = r.c.MSet(ctx, mediasToCache, constants.NormalCacheDuration) + } + + if len(ids) > 0 { + _ = r.c.Set(ctx, queryKey, ids, constants.ListCacheDuration) + } + + return medias, nil +} + +func (r *mediaRepository) Search(ctx context.Context, params sqlc.SearchMediasParams) ([]*models.MediaEntity, error) { + queryKey := r.generateQueryKey("media:search", params) + var cachedIDs []string + if err := r.c.Get(ctx, queryKey, &cachedIDs); err == nil && len(cachedIDs) > 0 { + return r.getByIDsWithFallback(ctx, cachedIDs) + } + + rows, err := r.q.SearchMedias(ctx, params) + if err != nil { + return nil, err + } + var medias []*models.MediaEntity + var ids []string + mediasToCache := make(map[string]any) + + for _, row := range rows { + media := &models.MediaEntity{ + ID: convert.UUIDToString(row.ID), + UserID: convert.UUIDToString(row.UserID), + StorageKey: row.StorageKey, + OriginalName: row.OriginalName, + MimeType: row.MimeType, + Size: row.Size, + TargetType: row.TargetType, + TargetID: convert.UUIDToString(row.TargetID), + FileMetadata: row.FileMetadata, + CreatedAt: convert.TimeToPtr(row.CreatedAt), + UpdatedAt: convert.TimeToPtr(row.UpdatedAt), + } + ids = append(ids, media.ID) + medias = append(medias, media) + + mediasToCache[fmt.Sprintf("media:id:%s", media.ID)] = media + } + + if len(mediasToCache) > 0 { + _ = r.c.MSet(ctx, mediasToCache, constants.NormalCacheDuration) + } + + if len(ids) > 0 { + _ = r.c.Set(ctx, queryKey, ids, constants.ListCacheDuration) + } + + return medias, nil +} + +func (r *mediaRepository) GetByUserID(ctx context.Context, userId pgtype.UUID) ([]*models.MediaEntity, error) { + queryKey := fmt.Sprintf("media:userId:%s", convert.UUIDToString(userId)) + var cachedIDs []string + if err := r.c.Get(ctx, queryKey, &cachedIDs); err == nil && len(cachedIDs) > 0 { + return r.getByIDsWithFallback(ctx, cachedIDs) + } + + rows, err := r.q.GetMediasByUserID(ctx, userId) + if err != nil { + return nil, err + } + var medias []*models.MediaEntity + var ids []string + mediasToCache := make(map[string]any) + + for _, row := range rows { + media := &models.MediaEntity{ + ID: convert.UUIDToString(row.ID), + UserID: convert.UUIDToString(row.UserID), + StorageKey: row.StorageKey, + OriginalName: row.OriginalName, + MimeType: row.MimeType, + Size: row.Size, + TargetType: row.TargetType, + TargetID: convert.UUIDToString(row.TargetID), + FileMetadata: row.FileMetadata, + CreatedAt: convert.TimeToPtr(row.CreatedAt), + UpdatedAt: convert.TimeToPtr(row.UpdatedAt), + } + ids = append(ids, media.ID) + medias = append(medias, media) + + mediasToCache[fmt.Sprintf("media:id:%s", media.ID)] = media + } + + if len(mediasToCache) > 0 { + _ = r.c.MSet(ctx, mediasToCache, constants.NormalCacheDuration) + } + + if len(ids) > 0 { + _ = r.c.Set(ctx, queryKey, ids, constants.ListCacheDuration) + } + + return medias, nil +} diff --git a/internal/services/mediaService.go b/internal/services/mediaService.go new file mode 100644 index 0000000..5a1de4d --- /dev/null +++ b/internal/services/mediaService.go @@ -0,0 +1,77 @@ +package services + +import ( + "context" + "history-api/internal/dtos/request" + "history-api/internal/dtos/response" + "history-api/internal/repositories" + "history-api/pkg/storage" + "mime/multipart" +) + +type MediaService interface { + GetMediaByID(ctx context.Context, mediaId string) (*response.MediaResponse, error) + GetMediaByUserID(ctx context.Context, userId string) ([]*response.MediaResponse, error) + SearchMedia(ctx context.Context, dto *request.SearchMediaDto) (*response.PaginatedResponse, error) + DeleteMedia(ctx context.Context, mediaId string) error + GetMediaByTarget(ctx context.Context, targetType string, targetId string) ([]*response.MediaResponse, error) + UploadServerSide(ctx context.Context, userId string, fileHeader *multipart.FileHeader) (*response.MediaResponse, error) + GeneratePresignedURL(ctx context.Context, userId string, dto *request.PreSignedDto) (*response.PreSignedResponse, error) + PreSignedCompleted(ctx context.Context, userId string, dto *request.PreSignedCompleteDto) (*response.MediaResponse, error) +} + +type mediaService struct { + mediaRepo repositories.MediaRepository + s storage.Storage +} + +func NewMediaService( + mediaRepo repositories.MediaRepository, + s storage.Storage, +) MediaService { + return &mediaService{ + mediaRepo: mediaRepo, + s: s, + } +} + +// DeleteMedia implements [MediaService]. +func (m *mediaService) DeleteMedia(ctx context.Context, mediaId string) error { + panic("unimplemented") +} + +// GeneratePresignedURL implements [MediaService]. +func (m *mediaService) GeneratePresignedURL(ctx context.Context, userId string, dto *request.PreSignedDto) (*response.PreSignedResponse, error) { + panic("unimplemented") +} + +// GetMediaByID implements [MediaService]. +func (m *mediaService) GetMediaByID(ctx context.Context, mediaId string) (*response.MediaResponse, error) { + panic("unimplemented") +} + +// GetMediaByTarget implements [MediaService]. +func (m *mediaService) GetMediaByTarget(ctx context.Context, targetType string, targetId string) ([]*response.MediaResponse, error) { + panic("unimplemented") +} + +// GetMediaByUserID implements [MediaService]. +func (m *mediaService) GetMediaByUserID(ctx context.Context, userId string) ([]*response.MediaResponse, error) { + panic("unimplemented") +} + +// PreSignedCompleted implements [MediaService]. +func (m *mediaService) PreSignedCompleted(ctx context.Context, userId string, dto *request.PreSignedCompleteDto) (*response.MediaResponse, error) { + panic("unimplemented") +} + +// SearchMedia implements [MediaService]. +func (m *mediaService) SearchMedia(ctx context.Context, dto *request.SearchMediaDto) (*response.PaginatedResponse, error) { + panic("unimplemented") +} + +// UploadServerSide implements [MediaService]. +func (m *mediaService) UploadServerSide(ctx context.Context, userId string, fileHeader *multipart.FileHeader) (*response.MediaResponse, error) { + panic("unimplemented") +} + diff --git a/pkg/database/db.go b/pkg/database/db.go index 571d5c4..6c6f7e9 100644 --- a/pkg/database/db.go +++ b/pkg/database/db.go @@ -3,12 +3,14 @@ package database import ( "context" "history-api/pkg/config" + "time" "github.com/jackc/pgx/v5/pgxpool" ) func NewPostgresqlDB() (*pgxpool.Pool, error) { ctx := context.Background() + connectionURI, err := config.GetConfig("PGX_CONNECTION_URI") if err != nil { return nil, err @@ -19,14 +21,21 @@ func NewPostgresqlDB() (*pgxpool.Pool, error) { return nil, err } - pool, err := pgxpool.NewWithConfig(ctx, poolConfig) - if err != nil { - return nil, err + var pool *pgxpool.Pool + + for i := 0; i < 5; i++ { + pool, err = pgxpool.NewWithConfig(ctx, poolConfig) + if err != nil { + time.Sleep(2 * time.Second) + continue + } + + if err = pool.Ping(ctx); err == nil { + return pool, nil + } + + time.Sleep(2 * time.Second) } - if err := pool.Ping(ctx); err != nil { - return nil, err - } - - return pool, nil -} \ No newline at end of file + return nil, err +} diff --git a/pkg/database/seed.go b/pkg/database/seed.go new file mode 100644 index 0000000..14a93b0 --- /dev/null +++ b/pkg/database/seed.go @@ -0,0 +1,75 @@ +package database + +import ( + "context" + "database/sql" + "errors" + "history-api/internal/gen/sqlc" + "history-api/pkg/config" + "history-api/pkg/constants" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + "golang.org/x/crypto/bcrypt" +) + +func SeedSuperAdmin(pool *pgxpool.Pool) error { + ctx := context.Background() + + displayName, err := config.GetConfig("ADMIN_DISPLAY_NAME") + if err != nil { + return err + } + + email, err := config.GetConfig("ADMIN_EMAIL") + if err != nil { + return err + } + + password, err := config.GetConfig("ADMIN_PASSWORD") + if err != nil { + return err + } + + q := sqlc.New(pool) + + _, err = q.GetUserByEmail(ctx, email) + if err == nil { + return nil + } + + if !errors.Is(err, sql.ErrNoRows) { + return err + } + + hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + + user, err := q.UpsertUser(ctx, sqlc.UpsertUserParams{ + Email: email, + PasswordHash: pgtype.Text{ + String: string(hashed), + Valid: len(hashed) != 0, + }, + AuthProvider: constants.LocalProvider.String(), + }) + if err != nil { + return err + } + + _, err = q.CreateUserProfile(ctx, sqlc.CreateUserProfileParams{ + UserID: user.ID, + DisplayName: pgtype.Text{ + String: displayName, + Valid: displayName != "", + }, + }) + if err != nil { + return err + } + + return nil + +} diff --git a/pkg/storage/rustfs.go b/pkg/storage/rustfs.go new file mode 100644 index 0000000..de63689 --- /dev/null +++ b/pkg/storage/rustfs.go @@ -0,0 +1,139 @@ +package storage + +import ( + "context" + "io" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/rs/zerolog/log" + + ffconfig "history-api/pkg/config" +) + +type UploadOptions struct { + ContentType string + ContentDisposition string + Metadata map[string]string +} + +type Storage interface { + Upload(ctx context.Context, key string, body io.Reader, size int64, opts UploadOptions) error + PresignUpload(ctx context.Context, key string, expire time.Duration, opts UploadOptions) (string, error) + GetURL(ctx context.Context, key string, expire time.Duration) (string, error) + Delete(ctx context.Context, key string) error +} + +type s3Storage struct { + client *s3.Client + ps *s3.PresignClient + bucket string + endPoint string +} + +func NewS3Storage() (Storage, error) { + accessKey, err := ffconfig.GetConfig("STORAGE_ACCESS_KEY") + if err != nil { + return nil, err + } + + secretAccessKey, err := ffconfig.GetConfig("STORAGE_SECRET_KEY") + if err != nil { + return nil, err + } + + bucketName, err := ffconfig.GetConfig("STORAGE_BUCKET_NAME") + if err != nil { + return nil, err + } + + endpoint, err := ffconfig.GetConfig("STORAGE_ENDPOINT") + if err != nil { + return nil, err + } + + cfg, err := config.LoadDefaultConfig(context.TODO(), + config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretAccessKey, "")), + config.WithRegion("auto"), + ) + if err != nil { + log.Error().Msgf("unable to load AWS SDK config, %v", err) + return nil, err + } + + client := s3.NewFromConfig(cfg, func(o *s3.Options) { + o.BaseEndpoint = aws.String(endpoint) + }) + + return &s3Storage{ + client: client, + ps: s3.NewPresignClient(client), + bucket: bucketName, + endPoint: endpoint, + }, nil +} + +func (s *s3Storage) Upload(ctx context.Context, key string, body io.Reader, size int64, opts UploadOptions) error { + input := &s3.PutObjectInput{ + Bucket: &s.bucket, + Key: &key, + Body: body, + } + + if opts.ContentType != "" { + input.ContentType = aws.String(opts.ContentType) + } + if opts.ContentDisposition != "" { + input.ContentDisposition = aws.String(opts.ContentDisposition) + } + if len(opts.Metadata) > 0 { + input.Metadata = opts.Metadata + } + + _, err := s.client.PutObject(ctx, input) + return err +} + +func (s *s3Storage) PresignUpload(ctx context.Context, key string, expire time.Duration, opts UploadOptions) (string, error) { + input := &s3.PutObjectInput{ + Bucket: &s.bucket, + Key: &key, + } + + if opts.ContentType != "" { + input.ContentType = aws.String(opts.ContentType) + } + if opts.ContentDisposition != "" { + input.ContentDisposition = aws.String(opts.ContentDisposition) + } + if len(opts.Metadata) > 0 { + input.Metadata = opts.Metadata + } + + req, err := s.ps.PresignPutObject(ctx, input, s3.WithPresignExpires(expire)) + if err != nil { + return "", err + } + return req.URL, nil +} +func (s *s3Storage) GetURL(ctx context.Context, key string, expire time.Duration) (string, error) { + req, err := s.ps.PresignGetObject(ctx, &s3.GetObjectInput{ + Bucket: &s.bucket, + Key: &key, + }, s3.WithPresignExpires(expire)) + if err != nil { + return "", err + } + return req.URL, nil +} + +func (s *s3Storage) Delete(ctx context.Context, key string) error { + _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: &s.bucket, + Key: &key, + }) + return err +}