initial media service

This commit is contained in:
Sergey Krylov 2026-03-29 07:47:46 +03:00
commit af2e675cbb
21 changed files with 1242 additions and 0 deletions

16
.air.toml Normal file
View File

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

21
.env.example Normal file
View File

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

36
.gitignore vendored Normal file
View File

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

102
README.md Normal file
View File

@ -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 под свои задачи.
Главная цель сервиса в рамках курса - показать **интеграцию и архитектурную роль**, а не конкретную реализацию.

82
cmd/main.go Normal file
View File

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

70
go.mod Normal file
View File

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

167
go.sum Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

76
internal/config/config.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

53
pkg/logger/logger.go Normal file
View File

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

10
scripts/run_dev.sh Normal file
View File

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