commit af2e675cbb399feb5b0b731f39439533bf706f18 Author: Sergey Krylov Date: Sun Mar 29 07:47:46 2026 +0300 initial media service diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..7a77cb8 --- /dev/null +++ b/.air.toml @@ -0,0 +1,16 @@ +root = "." +tmp_dir = "tmp" + +[build] +cmd = "go build -o ./tmp/cmd.exe ./cmd" +bin = "./tmp/cmd.exe" +delay = 500 +exclude_dir = ["tmp", "vendor"] +include_ext = ["go"] +exclude_file = ["*.md"] + +[log] +time = true + +[env] +APP_ENV="development" diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8793054 --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# General +APP_ENV='development' + +# HTTP server +HTTP_PORT=4200 +HTTP_HOST='localhost:4200' + +# gRPC server +GRPC_PORT=50059 +GRPC_HOST='localhost' + +# S3 Storage +S3_BUCKET='' +S3_REGION='' +S3_ENDPOINT='' +S3_ACCESS_KEY='' +S3_SECRET_KEY='' +S3_PUBLIC_URL='' + +# Logging +LOG_LEVEL='debug' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b4d184e --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Go binaries +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Go test binaries +*.test + +# Go coverage output +*.out + +# Vendor (если не используешь vendoring) +vendor/ + +# IDE +.idea/ +.vscode/ + +# OS +.DS_Store + +# Logs +logs/ +*.log + +# Environment +.env + +# Build artifacts +bin/ +dist/ + +# Temporary files +tmp/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..2125e41 --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# Media Service + +Этот репозиторий содержит **media-service**, используемый в проекте TeaCinema - сервис для хранения и раздачи медиа-файлов (постеры, баннеры фильмов и другие ассеты). + +Сервис написан на **Golang** и используется в рамках курса по микросервисной архитектуре. + +--- + +## Контекст курса + +В видео-курсе media-service **не разрабатывается с нуля**. +Вместо этого: + +- показано, как сервис подключается к системе; +- как он интегрируется через **gRPC contracts (proto)**; +- как его запускать и использовать в общей инфраструктуре. + +Поэтому данный репозиторий предоставляется в готовом виде - как часть учебных материалов. + +--- + +## Proto-контракты + +Media-service работает на основе **gRPC proto-контрактов**. + +В рамках курса: + +- proto-файлы **пишутся и объясняются в видео**; +- media-service просто подключается к этим контрактам. + +Если вы хотите повторить проект самостоятельно, вам необходимо: + +1. Создать proto-контракты, ориентируясь на видео; +2. Сгенерировать код для Go; +3. Подключить их к media-service. + +Таймкод в видео, где разбираются proto-контракты и их подключение: https://youtube.com/watch?v=EFXFVNDk2xM&t=83886s + +--- + +## Запуск + +Media-service запускается как отдельный микросервис и предполагает: + +- наличие gRPC-контрактов; +- корректные настройки окружения; + +Подробная схема запуска всей системы разбирается в видеокурсе. + +## Установка зависимостей + +Для установки зависимостей используйте стандартные команды Go: + +```bash +# Инициализация модулей +go mod init github.com/yourusername/media-service + +# Установка зависимостей +go mod tidy +``` + +> [!Важно] +> Перед запуском сервиса переименуйте файл .env.example в .env и заполните реальные значения для подключения к S3. + +## Запуск сервиса + +Для запуска media-service есть два варианта: + +### Стандартный запуск через Go + +```bash +# Запуск сервиса напрямую +go run cmd/main.go + +# Или сборка бинарника и запуск +go build -o media-service cmd/main.go +./media-service +``` + +### Разработка с hot reload через Air + +Сервис можно запускать в режиме разработки с авто-перезапуском при изменениях кода, используя скрипт `run_dev.sh`: + +```bash +./scripts/run_dev.sh +``` + +Скрипт автоматически: + +- загружает переменные окружения из .env; +- запускает media-service с hot reload через Air. + +--- + +Media-service предоставляется **в учебных целях**. + +Вы можете: + +- изменить способ хранения файлов; +- адаптировать API под свои задачи. + +Главная цель сервиса в рамках курса - показать **интеграцию и архитектурную роль**, а не конкретную реализацию. diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..c8f6d07 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,82 @@ +package main + +import ( + "context" + "fmt" + "net" + "os" + "os/signal" + "syscall" + "time" + + "github.com/teacinema/media-service/internal/config" + "github.com/teacinema/media-service/internal/infrastructure/grpc" + httpserver "github.com/teacinema/media-service/internal/infrastructure/http" + "github.com/teacinema/media-service/internal/infrastructure/storage" + "github.com/teacinema/media-service/pkg/logger" +) + +func main() { + cfg := config.Load() + + logger.Init(cfg.Logging.Level) + + logger.Info("🚀 Starting media-service in %s mode", cfg.App.Env) + + var mediaStorage storage.Storage + var err error + + mediaStorage, err = storage.NewS3Storage(cfg) + if err != nil { + logger.Fatal("failed to init S3 storage: %v", err) + } + logger.Info("✅ S3 storage connected (bucket: %s)", cfg.Storage.Bucket) + + grpcServer := grpc.NewServer(mediaStorage, cfg) + grpcListener, err := net.Listen("tcp", fmt.Sprintf(":%s", cfg.GRPC.Port)) + + if err != nil { + logger.Fatal("failed to listen gRPC: %v", err) + } + + httpSrv := httpserver.NewServer(mediaStorage, cfg) + + go func() { + logger.Info("gRPC listening on :%s", cfg.GRPC.Port) + if err := grpcServer.Serve(grpcListener); err != nil { + logger.Fatal("gRPC serve error: %v", err) + } + }() + + go func() { + logger.Info("HTTP listening on :%s", cfg.HTTP.Port) + if err := httpSrv.Start(); err != nil { + logger.Fatal("HTTP server error: %v", err) + } + }() + + go func() { + logger.Info("📦 Background queue workers started") + }() + + waitForShutdown(func() { + logger.Warn("🛑 Graceful shutdown started...") + grpcServer.GracefulStop() + mediaStorage.Close() + httpSrv.Stop(context.Background()) + logger.Info("✅ Shutdown complete") + }) +} + +func waitForShutdown(cleanup func()) { + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cleanup() + + <-ctx.Done() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b84748a --- /dev/null +++ b/go.mod @@ -0,0 +1,70 @@ +module github.com/teacinema/media-service + +go 1.24.0 + +toolchain go1.24.10 + +require ( + github.com/aws/aws-sdk-go-v2 v1.39.6 + github.com/aws/aws-sdk-go-v2/config v1.31.17 + github.com/aws/aws-sdk-go-v2/credentials v1.18.21 + github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.4 + github.com/aws/aws-sdk-go-v2/service/s3 v1.90.0 + github.com/disintegration/imaging v1.6.2 + github.com/gabriel-vasile/mimetype v1.4.11 + github.com/gin-gonic/gin v1.11.0 + github.com/google/uuid v1.6.0 + github.com/kolesa-team/go-webp v1.0.5 + github.com/teacinema/contracts v1.0.2 + google.golang.org/grpc v1.77.0 +) + +require ( + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.13 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.13 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 // indirect + github.com/aws/smithy-go v1.23.2 // indirect + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/chai2010/webp v1.4.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.54.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + go.uber.org/mock v0.5.0 // indirect + golang.org/x/arch v0.20.0 // indirect + golang.org/x/crypto v0.43.0 // indirect + golang.org/x/image v0.0.0-20211028202545-6944b10bf410 // indirect + golang.org/x/mod v0.28.0 // indirect + golang.org/x/net v0.46.1-0.20251013234738-63d1a5100f82 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect + golang.org/x/tools v0.37.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..52a6c7b --- /dev/null +++ b/go.sum @@ -0,0 +1,167 @@ +github.com/aws/aws-sdk-go-v2 v1.39.6 h1:2JrPCVgWJm7bm83BDwY5z8ietmeJUbh3O2ACnn+Xsqk= +github.com/aws/aws-sdk-go-v2 v1.39.6/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 h1:DHctwEM8P8iTXFxC/QK0MRjwEpWQeM9yzidCRjldUz0= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3/go.mod h1:xdCzcZEtnSTKVDOmUZs4l/j3pSV6rpo1WXl5ugNsL8Y= +github.com/aws/aws-sdk-go-v2/config v1.31.17 h1:QFl8lL6RgakNK86vusim14P2k8BFSxjvUkcWLDjgz9Y= +github.com/aws/aws-sdk-go-v2/config v1.31.17/go.mod h1:V8P7ILjp/Uef/aX8TjGk6OHZN6IKPM5YW6S78QnRD5c= +github.com/aws/aws-sdk-go-v2/credentials v1.18.21 h1:56HGpsgnmD+2/KpG0ikvvR8+3v3COCwaF4r+oWwOeNA= +github.com/aws/aws-sdk-go-v2/credentials v1.18.21/go.mod h1:3YELwedmQbw7cXNaII2Wywd+YY58AmLPwX4LzARgmmA= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 h1:T1brd5dR3/fzNFAQch/iBKeX07/ffu/cLu+q+RuzEWk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13/go.mod h1:Peg/GBAQ6JDt+RoBf4meB1wylmAipb7Kg2ZFakZTlwk= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.4 h1:2fjfz3/G9BRvIKuNZ655GwzpklC2kEH0cowZQGO7uBg= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.4/go.mod h1:Ymws824lvMypLFPwyyUXM52SXuGgxpu0+DISLfKvB+c= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13 h1:a+8/MLcWlIxo1lF9xaGt3J/u3yOZx+CdSveSNwjhD40= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13/go.mod h1:oGnKwIYZ4XttyU2JWxFrwvhF6YKiK/9/wmE3v3Iu9K8= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13 h1:HBSI2kDkMdWz4ZM7FjwE7e/pWDEZ+nR95x8Ztet1ooY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13/go.mod h1:YE94ZoDArI7awZqJzBAZ3PDD2zSfuP7w6P2knOzIn8M= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.13 h1:eg/WYAa12vqTphzIdWMzqYRVKKnCboVPRlvaybNCqPA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.13/go.mod h1:/FDdxWhz1486obGrKKC1HONd7krpk38LBt+dutLcN9k= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.4 h1:NvMjwvv8hpGUILarKw7Z4Q0w1H9anXKsesMxtw++MA4= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.4/go.mod h1:455WPHSwaGj2waRSpQp7TsnpOnBfw8iDfPfbwl7KPJE= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 h1:kDqdFvMY4AtKoACfzIGD8A0+hbT41KTKF//gq7jITfM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13/go.mod h1:lmKuogqSU3HzQCwZ9ZtcqOc5XGMqtDK7OIc2+DxiUEg= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.13 h1:zhBJXdhWIFZ1acfDYIhu4+LCzdUS2Vbcum7D01dXlHQ= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.13/go.mod h1:JaaOeCE368qn2Hzi3sEzY6FgAZVCIYcC2nwbro2QCh8= +github.com/aws/aws-sdk-go-v2/service/s3 v1.90.0 h1:ef6gIJR+xv/JQWwpa5FYirzoQctfSJm7tuDe3SZsUf8= +github.com/aws/aws-sdk-go-v2/service/s3 v1.90.0/go.mod h1:+wArOOrcHUevqdto9k1tKOF5++YTe9JEcPSc9Tx2ZSw= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 h1:0JPwLz1J+5lEOfy/g0SURC9cxhbQ1lIMHMa+AHZSzz0= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.1/go.mod h1:fKvyjJcz63iL/ftA6RaM8sRCtN4r4zl4tjL3qw5ec7k= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 h1:OWs0/j2UYR5LOGi88sD5/lhN6TDLG6SfA7CqsQO9zF0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5/go.mod h1:klO+ejMvYsB4QATfEOIXk8WAEwN4N0aBfJpvC+5SZBo= +github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 h1:mLlUgHn02ue8whiR4BmxxGJLR2gwU6s6ZzJ5wDamBUs= +github.com/aws/aws-sdk-go-v2/service/sts v1.39.1/go.mod h1:E19xDjpzPZC7LS2knI9E6BaRFDK43Eul7vd6rSq2HWk= +github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM= +github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/chai2010/webp v1.4.0 h1:6DA2pkkRUPnbOHvvsmGI3He1hBKf/bkRlniAiSGuEko= +github.com/chai2010/webp v1.4.0/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik= +github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kolesa-team/go-webp v1.0.5 h1:GZQHJBaE8dsNKZltfwqsL0qVJ7vqHXsfA+4AHrQW3pE= +github.com/kolesa-team/go-webp v1.0.5/go.mod h1:QmJu0YHXT3ex+4SgUvs+a+1SFCDcCqyZg+LbIuNNTnE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= +github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/teacinema/contracts v1.0.2 h1:ZA4CupsMjjjNQMrwSbGjtf9cWodDZgGHcmt0cRxlLto= +github.com/teacinema/contracts v1.0.2/go.mod h1:oYc1RkjWhR4czmX7uZt4GaP6C6omGyZEdTkuWV5OIf8= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +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/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20211028202545-6944b10bf410 h1:hTftEOvwiOq2+O8k2D5/Q7COC7k5Qcrgc2TFURJYnvQ= +golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= +golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= +golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= +golang.org/x/net v0.46.1-0.20251013234738-63d1a5100f82 h1:6/3JGEh1C88g7m+qzzTbl3A0FtsLguXieqofVLU/JAo= +golang.org/x/net v0.46.1-0.20251013234738-63d1a5100f82/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= +golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/application/dto/media.go b/internal/application/dto/media.go new file mode 100644 index 0000000..404b516 --- /dev/null +++ b/internal/application/dto/media.go @@ -0,0 +1,35 @@ +package dto + +import "io" + +type UploadMediaRequest struct { + FileName string + Folder string + ContentType string + ResizeWidth int32 + ResizeHeight int32 + Preset string + Reader io.Reader + Size int64 +} + +type UploadMediaResponse struct { + Key string +} + +type GetMediaRequest struct { + Key string +} + +type GetMediaResponse struct { + Reader io.ReadCloser + ContentType string +} + +type DeleteMediaRequest struct { + Key string +} + +type DeleteMediaResponse struct { + OK bool +} diff --git a/internal/application/usecases/delete.go b/internal/application/usecases/delete.go new file mode 100644 index 0000000..0fb9c72 --- /dev/null +++ b/internal/application/usecases/delete.go @@ -0,0 +1,24 @@ +package usecases + +import ( + "context" + + "github.com/teacinema/media-service/internal/application/dto" + "github.com/teacinema/media-service/internal/infrastructure/storage" +) + +type DeleteUseCase struct { + storage storage.Storage +} + +func NewDeleteUseCase(s storage.Storage) *DeleteUseCase { + return &DeleteUseCase{storage: s} +} + +func (u *DeleteUseCase) Execute(ctx context.Context, input dto.DeleteMediaRequest) (*dto.DeleteMediaResponse, error) { + err := u.storage.Delete(ctx, input.Key) + if err != nil { + return nil, err + } + return &dto.DeleteMediaResponse{OK: true}, nil +} diff --git a/internal/application/usecases/fetch.go b/internal/application/usecases/fetch.go new file mode 100644 index 0000000..c4000cd --- /dev/null +++ b/internal/application/usecases/fetch.go @@ -0,0 +1,32 @@ +package usecases + +import ( + "context" + + "github.com/teacinema/media-service/internal/application/dto" + "github.com/teacinema/media-service/internal/infrastructure/storage" +) + +type GetUseCase struct { + storage storage.Storage +} + +func NewGetUseCase(s storage.Storage) *GetUseCase { + return &GetUseCase{storage: s} +} + +func (u *GetUseCase) Execute( + ctx context.Context, + input dto.GetMediaRequest, +) (*dto.GetMediaResponse, error) { + + reader, contentType, err := u.storage.GetStream(ctx, input.Key) + if err != nil { + return nil, err + } + + return &dto.GetMediaResponse{ + Reader: reader, + ContentType: contentType, + }, nil +} diff --git a/internal/application/usecases/upload.go b/internal/application/usecases/upload.go new file mode 100644 index 0000000..52657d9 --- /dev/null +++ b/internal/application/usecases/upload.go @@ -0,0 +1,42 @@ +package usecases + +import ( + "context" + "fmt" + + "github.com/teacinema/media-service/internal/application/dto" + "github.com/teacinema/media-service/internal/infrastructure/images" + "github.com/teacinema/media-service/internal/infrastructure/storage" +) + +type UploadUseCase struct { + storage storage.Storage + processor images.Processor +} + +func NewUploadUseCase(s storage.Storage, p images.Processor) *UploadUseCase { + return &UploadUseCase{ + storage: s, + processor: p, + } +} + +func (u *UploadUseCase) Execute( + ctx context.Context, + input dto.UploadMediaRequest, +) (*dto.UploadMediaResponse, error) { + key := fmt.Sprintf("%s/%s", input.Folder, input.FileName) + + if err := u.storage.UploadStream( + ctx, + key, + input.Reader, + input.ContentType, + ); err != nil { + return nil, err + } + + return &dto.UploadMediaResponse{ + Key: key, + }, nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..3ee8976 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,76 @@ +package config + +import ( + "os" + "strings" +) + +type Config struct { + App struct { + Env string + } + + HTTP struct { + Port string + Host string + } + + GRPC struct { + Port string + Host string + } + + Storage struct { + Driver string + Bucket string + Region string + Endpoint string + AccessKey string + SecretKey string + PublicURL string + } + + Image struct { + MaxSizeMB int + AllowedTypes []string + } + + Logging struct { + Level string + } +} + +func Load() *Config { + var cfg Config + loadFromEnv(&cfg) + return &cfg +} + +func loadFromEnv(cfg *Config) { + get := func(key string) string { + return strings.TrimSpace(os.Getenv(key)) + } + + // General + cfg.App.Env = get("APP_ENV") + + // HTTP server + cfg.HTTP.Port = get("HTTP_PORT") + cfg.HTTP.Host = get("HTTP_HOST") + + // gRPC server + cfg.GRPC.Port = get("GRPC_PORT") + cfg.GRPC.Host = get("GRPC_HOST") + + // Storage + cfg.Storage.Driver = get("S3_DRIVER") + cfg.Storage.Bucket = get("S3_BUCKET") + cfg.Storage.Region = get("S3_REGION") + cfg.Storage.Endpoint = get("S3_ENDPOINT") + cfg.Storage.AccessKey = get("S3_ACCESS_KEY") + cfg.Storage.SecretKey = get("S3_SECRET_KEY") + cfg.Storage.PublicURL = get("S3_PUBLIC_URL") + + // Logging + cfg.Logging.Level = get("LOG_LEVEL") +} diff --git a/internal/infrastructure/grpc/interceptor.go b/internal/infrastructure/grpc/interceptor.go new file mode 100644 index 0000000..587d47e --- /dev/null +++ b/internal/infrastructure/grpc/interceptor.go @@ -0,0 +1,56 @@ +package grpc + +import ( + "context" + "time" + + "github.com/google/uuid" + "github.com/teacinema/media-service/pkg/logger" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" +) + +func RequestLoggerInterceptor( + ctx context.Context, + req interface{}, + info *grpc.UnaryServerInfo, + handler grpc.UnaryHandler, +) (interface{}, error) { + + start := time.Now() + resp, err := handler(ctx, req) + + status := "✅" + if err != nil { + status = "❌" + } + + logger.Info("%s %s %v", status, info.FullMethod, time.Since(start)) + return resp, err +} + +func TraceIDInterceptor( + ctx context.Context, + req interface{}, + info *grpc.UnaryServerInfo, + handler grpc.UnaryHandler, +) (interface{}, error) { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + md = metadata.New(nil) + } + + ids := md.Get("x-trace-id") + var traceID string + if len(ids) == 0 { + traceID = uuid.New().String() + md.Set("x-trace-id", traceID) + } else { + traceID = ids[0] + } + + ctx = metadata.NewIncomingContext(ctx, md) + ctx = context.WithValue(ctx, "traceID", traceID) + + return handler(ctx, req) +} diff --git a/internal/infrastructure/grpc/server.go b/internal/infrastructure/grpc/server.go new file mode 100644 index 0000000..ec516ec --- /dev/null +++ b/internal/infrastructure/grpc/server.go @@ -0,0 +1,45 @@ +package grpc + +import ( + "fmt" + "net" + + pb "github.com/teacinema/contracts/gen/go/media" + "github.com/teacinema/media-service/internal/application/usecases" + "github.com/teacinema/media-service/internal/config" + "github.com/teacinema/media-service/internal/infrastructure/images" + "github.com/teacinema/media-service/internal/infrastructure/storage" + handler "github.com/teacinema/media-service/internal/interfaces/grpc" + "github.com/teacinema/media-service/pkg/logger" + "google.golang.org/grpc" +) + +func NewServer(storage storage.Storage, cfg *config.Config) *grpc.Server { + server := grpc.NewServer( + grpc.ChainUnaryInterceptor( + RequestLoggerInterceptor, + TraceIDInterceptor, + ), + ) + + uploadUC := usecases.NewUploadUseCase(storage, images.NewImageProcessor()) + getUC := usecases.NewGetUseCase(storage) + deleteUC := usecases.NewDeleteUseCase(storage) + + h := handler.NewMediaHandler(uploadUC, getUC, deleteUC) + + pb.RegisterMediaServiceServer(server, h) + + return server +} + +func StartGRPC(server *grpc.Server, port int) error { + addr := fmt.Sprintf(":%d", port) + lis, err := net.Listen("tcp", addr) + if err != nil { + return err + } + + logger.Info("gRPC server listening on %s", addr) + return server.Serve(lis) +} diff --git a/internal/infrastructure/http/server.go b/internal/infrastructure/http/server.go new file mode 100644 index 0000000..be4c541 --- /dev/null +++ b/internal/infrastructure/http/server.go @@ -0,0 +1,81 @@ +package http + +import ( + "context" + "fmt" + "io" + "net/http" + + "github.com/gabriel-vasile/mimetype" + "github.com/gin-gonic/gin" + "github.com/teacinema/media-service/internal/config" + "github.com/teacinema/media-service/internal/infrastructure/images" + "github.com/teacinema/media-service/internal/infrastructure/storage" + "github.com/teacinema/media-service/pkg/logger" +) + +type Server struct { + engine *gin.Engine + storage storage.Storage + cfg *config.Config + processor images.Processor + httpSrv *http.Server +} + +func NewServer(s storage.Storage, cfg *config.Config) *Server { + if cfg.App.Env == "production" { + gin.SetMode(gin.ReleaseMode) + } + + r := gin.New() + r.Use(gin.Logger()) + r.Use(gin.Recovery()) + + srv := &Server{ + engine: r, + storage: s, + cfg: cfg, + } + + r.GET("/*key", srv.getMediaHandler) + + srv.httpSrv = &http.Server{ + Addr: fmt.Sprintf(":%s", cfg.HTTP.Port), + Handler: r, + } + + return srv +} + +func (s *Server) Start() error { + return s.httpSrv.ListenAndServe() +} + +func (s *Server) Stop(ctx context.Context) error { + return s.httpSrv.Shutdown(ctx) +} + +func (s *Server) getMediaHandler(c *gin.Context) { + key := c.Param("key")[1:] + + obj, contentType, err := s.storage.GetStream(c, key) + if err != nil { + c.String(http.StatusNotFound, "file not found") + return + } + defer obj.Close() + + img, err := io.ReadAll(obj) + if err != nil { + logger.Error("failed read: %v", err) + c.String(http.StatusInternalServerError, "error reading file") + return + } + + mime := mimetype.Detect(img) + + c.Header("Content-Type", contentType) + c.Header("Cache-Control", "public, max-age=86400") + + c.Data(http.StatusOK, mime.String(), img) +} diff --git a/internal/infrastructure/images/processor.go b/internal/infrastructure/images/processor.go new file mode 100644 index 0000000..eaa2cbe --- /dev/null +++ b/internal/infrastructure/images/processor.go @@ -0,0 +1,28 @@ +package images + +import ( + "io" +) + +type ResizeOptions struct { + Width int + Height int + Crop bool +} + +type Processor interface { + Process(input io.Reader, opts *ResizeOptions) (io.Reader, error) +} + +type NoopProcessor struct{} + +func NewImageProcessor() *NoopProcessor { + return &NoopProcessor{} +} + +func (p *NoopProcessor) Process( + input io.Reader, + opts *ResizeOptions, +) (io.Reader, error) { + return input, nil +} diff --git a/internal/infrastructure/storage/s3.go b/internal/infrastructure/storage/s3.go new file mode 100644 index 0000000..1ff3d86 --- /dev/null +++ b/internal/infrastructure/storage/s3.go @@ -0,0 +1,187 @@ +package storage + +import ( + "context" + "fmt" + "io" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsConfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/feature/s3/manager" + "github.com/aws/aws-sdk-go-v2/service/s3" + s3Types "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/teacinema/media-service/internal/config" + "github.com/teacinema/media-service/pkg/logger" +) + +type S3Storage struct { + client *s3.Client + uploader *manager.Uploader + downloader *manager.Downloader + bucket string + cfg *config.Config + presigner *s3.PresignClient +} + +func NewS3Storage(c *config.Config) (*S3Storage, error) { + var loadOpts []func(*awsConfig.LoadOptions) error + if c.Storage.Region != "" { + loadOpts = append(loadOpts, awsConfig.WithRegion(c.Storage.Region)) + } + if c.Storage.AccessKey != "" && c.Storage.SecretKey != "" { + loadOpts = append(loadOpts, awsConfig.WithCredentialsProvider( + credentials.NewStaticCredentialsProvider(c.Storage.AccessKey, c.Storage.SecretKey, ""), + )) + } + + awsCfg, err := awsConfig.LoadDefaultConfig(context.Background(), loadOpts...) + if err != nil { + return nil, fmt.Errorf("load aws config: %w", err) + } + + var clientOpts []func(*s3.Options) + if strings.TrimSpace(c.Storage.Endpoint) != "" { + ep := c.Storage.Endpoint + + clientOpts = append(clientOpts, func(o *s3.Options) { + o.UsePathStyle = true + o.BaseEndpoint = aws.String(ep) + }) + } + + client := s3.NewFromConfig(awsCfg, clientOpts...) + uploader := manager.NewUploader(client) + downloader := manager.NewDownloader(client) + presigner := s3.NewPresignClient(client) + + s := &S3Storage{ + client: client, + uploader: uploader, + downloader: downloader, + bucket: c.Storage.Bucket, + cfg: c, + presigner: presigner, + } + + ctx := context.Background() + _, headErr := s.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(s.bucket)}) + if headErr != nil { + _, createErr := s.client.CreateBucket(ctx, &s3.CreateBucketInput{ + Bucket: aws.String(s.bucket), + + CreateBucketConfiguration: &s3Types.CreateBucketConfiguration{ + LocationConstraint: s3Types.BucketLocationConstraint(aws.ToString(&c.Storage.Region)), + }, + }) + if createErr != nil { + return nil, fmt.Errorf("create bucket: %w (head err: %v)", createErr, headErr) + } + logger.Info("🪣 Created S3 bucket: %s", s.bucket) + } + + logger.Info("✅ Connected to S3 bucket: %s (region=%s)", s.bucket, c.Storage.Region) + return s, nil +} + +func (s *S3Storage) UploadStream( + ctx context.Context, + key string, + reader io.Reader, + contentType string, +) error { + _, err := s.uploader.Upload(ctx, &s3.PutObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + Body: reader, + ContentType: aws.String(contentType), + }) + if err != nil { + return fmt.Errorf("s3 upload: %w", err) + } + + return nil +} + +func (s *S3Storage) GetStream( + ctx context.Context, + key string, +) (io.ReadCloser, string, error) { + out, err := s.client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + }) + if err != nil { + return nil, "", fmt.Errorf("s3 get: %w", err) + } + + contentType := "" + if out.ContentType != nil { + contentType = *out.ContentType + } + + return out.Body, contentType, nil +} + +func (s *S3Storage) Delete(ctx context.Context, key string) error { + _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + }) + if err != nil { + return fmt.Errorf("s3 delete: %w", err) + } + return nil +} + +func (s *S3Storage) GetPublicURL(key string) string { + host := s.cfg.HTTP.Host + + if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") { + host = "http://" + host + } + + return fmt.Sprintf("%s/%s", strings.TrimRight(host, "/"), key) +} + +func (s *S3Storage) GetPresignedURL(ctx context.Context, key string, expire time.Duration, method string) (string, error) { + switch strings.ToUpper(method) { + case "GET": + ps, err := s.presigner.PresignGetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + }, s3.WithPresignExpires(expire)) + if err != nil { + return "", fmt.Errorf("presign GET: %w", err) + } + return ps.URL, nil + case "PUT": + ps, err := s.presigner.PresignPutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + }, s3.WithPresignExpires(expire)) + if err != nil { + return "", fmt.Errorf("presign PUT: %w", err) + } + return ps.URL, nil + default: + return "", fmt.Errorf("unsupported method for presign: %s", method) + } +} + +func (s *S3Storage) Close() error { + return nil +} + +func ensureEndpointURL(ep string, ssl bool) string { + ep = strings.TrimSpace(ep) + if strings.HasPrefix(ep, "http://") || strings.HasPrefix(ep, "https://") { + return ep + } + if ssl { + return "https://" + ep + } + return "http://" + ep +} diff --git a/internal/infrastructure/storage/storage.go b/internal/infrastructure/storage/storage.go new file mode 100644 index 0000000..eda0c6f --- /dev/null +++ b/internal/infrastructure/storage/storage.go @@ -0,0 +1,29 @@ +package storage + +import ( + "context" + "io" +) + +type FileInfo struct { + URL string + Size int64 + MIMEType string +} + +type Storage interface { + UploadStream( + ctx context.Context, + key string, + reader io.Reader, + contentType string, + ) error + + GetStream( + ctx context.Context, + key string, + ) (io.ReadCloser, string, error) + + Delete(ctx context.Context, key string) error + Close() error +} diff --git a/internal/interfaces/grpc/media_handler.go b/internal/interfaces/grpc/media_handler.go new file mode 100644 index 0000000..6d34202 --- /dev/null +++ b/internal/interfaces/grpc/media_handler.go @@ -0,0 +1,50 @@ +package grpc + +import ( + "bytes" + "context" + + pb "github.com/teacinema/contracts/gen/go/media" + "github.com/teacinema/media-service/internal/application/dto" + "github.com/teacinema/media-service/internal/application/usecases" +) + +type MediaHandler struct { + pb.UnimplementedMediaServiceServer + uploadUC *usecases.UploadUseCase + getUC *usecases.GetUseCase + deleteUC *usecases.DeleteUseCase +} + +func NewMediaHandler( + u *usecases.UploadUseCase, + g *usecases.GetUseCase, + d *usecases.DeleteUseCase, +) *MediaHandler { + return &MediaHandler{ + uploadUC: u, + getUC: g, + deleteUC: d, + } +} + +func (h *MediaHandler) Upload( + ctx context.Context, + req *pb.UploadRequest, +) (*pb.UploadResponse, error) { + res, err := h.uploadUC.Execute(ctx, dto.UploadMediaRequest{ + FileName: req.FileName, + Folder: req.Folder, + ContentType: req.ContentType, + Reader: bytes.NewReader(req.Data), + Size: int64(len(req.Data)), + }) + + if err != nil { + return nil, err + } + + return &pb.UploadResponse{ + Key: res.Key, + }, nil +} diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go new file mode 100644 index 0000000..c10c974 --- /dev/null +++ b/pkg/logger/logger.go @@ -0,0 +1,53 @@ +package logger + +import ( + "log" + "os" + "strings" +) + +var level = "info" + +func Init(lvl string) { + level = strings.ToLower(lvl) + + log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile) + log.Printf("[LOGGER] initialized with level: %s", level) +} + +func Info(format string, v ...any) { + if levelAllowed("info") { + log.Printf("[INFO] "+format, v...) + } +} + +func Warn(format string, v ...any) { + if levelAllowed("warn") { + log.Printf("[WARN] "+format, v...) + } +} + +func Error(format string, v ...any) { + if levelAllowed("error") { + log.Printf("[ERROR] "+format, v...) + } +} + +func Fatal(format string, v ...any) { + log.Printf("[FATAL] "+format, v...) + os.Exit(1) +} + +// helpers + +func levelAllowed(l string) bool { + levels := map[string]int{ + "debug": 1, + "info": 2, + "warn": 3, + "error": 4, + "fatal": 5, + } + + return levels[strings.ToLower(level)] <= levels[l] +} diff --git a/scripts/run_dev.sh b/scripts/run_dev.sh new file mode 100644 index 0000000..3e7ef6a --- /dev/null +++ b/scripts/run_dev.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +echo "Starting media-service in dev mode..." + +# Source .env if exists +if [ -f ".env" ]; then + export $(grep -v '^#' .env | xargs) +fi + +air