UPDATE: Add super admin

This commit is contained in:
2026-03-30 23:48:37 +07:00
parent 44d0d0c973
commit 62d4b889d1
18 changed files with 1061 additions and 16 deletions

View File

@@ -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())

View File

@@ -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(),

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS medias;

View File

@@ -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);

39
db/query/files.sql Normal file
View File

@@ -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;

View File

@@ -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()
);
);
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()
);

View File

@@ -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:

24
go.mod
View File

@@ -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

53
go.sum
View File

@@ -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=

View File

@@ -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"`
}

View File

@@ -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
}

View File

@@ -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"`

36
internal/models/media.go Normal file
View File

@@ -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,
}
}

View File

@@ -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
}

View File

@@ -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")
}

View File

@@ -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
}
return nil, err
}

75
pkg/database/seed.go Normal file
View File

@@ -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
}

139
pkg/storage/rustfs.go Normal file
View File

@@ -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
}