diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 0000000..c1fd1ee --- /dev/null +++ b/.mise.toml @@ -0,0 +1,2 @@ +[tools] +go = "1.24.6" diff --git a/deck.go b/deck.go index e28e018..c265f62 100644 --- a/deck.go +++ b/deck.go @@ -35,6 +35,7 @@ type Deck struct { tableStyle *TableStyle logger *slog.Logger fresh bool + imageUploader ImageUploader } type Option func(*Deck) error @@ -70,6 +71,13 @@ func WithFolderID(folderID string) Option { } } +func WithImageUploader(uploader ImageUploader) Option { + return func(d *Deck) error { + d.imageUploader = uploader + return nil + } +} + type placeholder struct { objectID string x float64 @@ -345,9 +353,34 @@ func (d *Deck) initialize(ctx context.Context) (err error) { } driveSrv.UserAgent = userAgent d.driveSrv = driveSrv + + // Initialize image uploader if not already set + if d.imageUploader == nil { + uploader, err := d.createImageUploader(ctx) + if err != nil { + return fmt.Errorf("failed to create image uploader: %w", err) + } + d.imageUploader = uploader + } + return nil } +// createImageUploader creates an ImageUploader based on environment variables. +func (d *Deck) createImageUploader(ctx context.Context) (ImageUploader, error) { + switch GetImageStorageType() { + case "gcs": + d.logger.Debug("using GCS image storage") + return NewGCSUploader(ctx) + case "s3": + d.logger.Debug("using S3 image storage") + return NewS3Uploader(ctx) + default: + d.logger.Debug("using Google Drive image storage") + return NewGoogleDriveUploader(d.driveSrv, d.folderID), nil + } +} + func (d *Deck) createPage(ctx context.Context, index int, slide *Slide) (err error) { defer func() { err = errors.WithStack(err) diff --git a/go.mod b/go.mod index 104cbf7..19a6f21 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,12 @@ module github.com/k1LoW/deck go 1.24.6 require ( + cloud.google.com/go/storage v1.58.0 github.com/Songmu/prompter v0.5.1 + github.com/aws/aws-sdk-go-v2 v1.41.0 + github.com/aws/aws-sdk-go-v2/config v1.32.6 + github.com/aws/aws-sdk-go-v2/credentials v1.19.6 + github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0 github.com/briandowns/spinner v1.23.2 github.com/chromedp/chromedp v0.14.2 github.com/corona10/goimagehash v1.1.0 @@ -32,14 +37,39 @@ require ( require ( cel.dev/expr v0.24.0 // indirect + cloud.google.com/go v0.123.0 // indirect cloud.google.com/go/auth v0.18.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect + cloud.google.com/go/iam v1.5.3 // indirect + cloud.google.com/go/monitoring v1.24.3 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.0 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // 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.16 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect + github.com/aws/smithy-go v1.24.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 // indirect github.com/chromedp/sysutil v1.1.0 // indirect + github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f // indirect + github.com/envoyproxy/go-control-plane/envoy v1.35.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -56,21 +86,29 @@ require ( github.com/lestrrat-go/option v1.0.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect + github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/samber/lo v1.52.0 // indirect github.com/samber/slog-common v0.19.0 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/stoewer/go-strcase v1.2.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.38.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect go.opentelemetry.io/otel v1.39.0 // indirect go.opentelemetry.io/otel/metric v1.39.0 // indirect + go.opentelemetry.io/otel/sdk v1.39.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect go.opentelemetry.io/otel/trace v1.39.0 // indirect golang.org/x/crypto v0.46.0 // indirect golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/term v0.38.0 // indirect golang.org/x/text v0.32.0 // indirect + golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.39.0 // indirect + google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect google.golang.org/grpc v1.77.0 // indirect diff --git a/go.sum b/go.sum index 385966b..d525ef7 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,75 @@ cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= cloud.google.com/go/auth v0.18.0 h1:wnqy5hrv7p3k7cShwAU/Br3nzod7fxoqG+k0VZ+/Pk0= cloud.google.com/go/auth v0.18.0/go.mod h1:wwkPM1AgE1f2u6dG443MiWoD8C3BtOywNsUMcUTVDRo= 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.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= +cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= +cloud.google.com/go/logging v1.13.1 h1:O7LvmO0kGLaHY/gq8cV7T0dyp6zJhYAOtZPX4TF3QtY= +cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw= +cloud.google.com/go/longrunning v0.7.0 h1:FV0+SYF1RIj59gyoWDRi45GiYUMM3K1qO51qoboQT1E= +cloud.google.com/go/longrunning v0.7.0/go.mod h1:ySn2yXmjbK9Ba0zsQqunhDkYi0+9rlXIwnoAf+h+TPY= +cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= +cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= +cloud.google.com/go/storage v1.58.0 h1:PflFXlmFJjG/nBeR9B7pKddLQWaFaRWx4uUi/LyNxxo= +cloud.google.com/go/storage v1.58.0/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI= +cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= +cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 h1:lhhYARPUu3LmHysQ/igznQphfzynnqI3D75oUyw1HXk= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0/go.mod h1:l9rva3ApbBpEJxSNYnwT9N4CDLrWgtq3u8736C5hyJw= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0 h1:xfK3bbi6F2RDtaZFtUdKO3osOBIhNb+xTs8lFW6yx9o= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= github.com/Songmu/prompter v0.5.1 h1:IAsttKsOZWSDw7bV1mtGn9TAmLFAjXbp9I/eYmUUogo= github.com/Songmu/prompter v0.5.1/go.mod h1:CS3jEPD6h9IaLaG6afrl1orTgII9+uDWuw95dr6xHSw= github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= +github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4= +github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= +github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8= +github.com/aws/aws-sdk-go-v2/config v1.32.6/go.mod h1:lcUL/gcd8WyjCrMnxez5OXkO3/rwcNmvfno62tnXNcI= +github.com/aws/aws-sdk-go-v2/credentials v1.19.6 h1:F9vWao2TwjV2MyiyVS+duza0NIRtAslgLUM0vTA1ZaE= +github.com/aws/aws-sdk-go-v2/credentials v1.19.6/go.mod h1:SgHzKjEVsdQr6Opor0ihgWtkWdfRAIwxYzSJ8O85VHY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc= +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.16 h1:CjMzUs78RDDv4ROu3JnJn/Ig1r6ZD7/T2DXLLRpejic= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16/go.mod h1:uVW4OLBqbJXSHJYA9svT9BluSvvwbzLQ2Crf6UPzR3c= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 h1:DIBqIrJ7hv+e4CmIk2z3pyKT+3B6qVMgRsawHiR3qso= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7/go.mod h1:vLm00xmBke75UmpNvOcZQ/Q30ZFjbczeLFqGx5urmGo= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A= +github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0 h1:MIWra+MSq53CFaXXAywB2qg9YvVZifkk6vEGl/1Qor0= +github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= +github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= +github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -20,18 +80,30 @@ github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZ github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo= github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= +github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f h1:Y8xYupdHxryycyPlc9Y+bSQAYZnetRJ70VMVKm5CKI0= +github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4= github.com/corona10/goimagehash v1.1.0 h1:teNMX/1e+Wn/AYSbLHX8mj+mF9r60R1kBeqE9MkoYwI= github.com/corona10/goimagehash v1.1.0/go.mod h1:VkvE0mLn84L4aF8vCb6mafVajEb6QYMHl2ZJLn0mOGI= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 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/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= +github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329 h1:K+fnvUM0VZ7ZFJf0n4L/BRlnsb9pL/GuDG6FqaH+PwM= +github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329/go.mod h1:Alz8LEClvR7xKsrq3qzoc4N0guvVNSS8KmSChGYr9hs= +github.com/envoyproxy/go-control-plane/envoy v1.35.0 h1:ixjkELDE+ru6idPxcHLj8LBVc2bFP7iBytj353BoHUo= +github.com/envoyproxy/go-control-plane/envoy v1.35.0/go.mod h1:09qwbGVuSWWAyN5t/b3iyVfz5+z8QWGrzkoqm/8SbEs= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= +github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= +github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs= github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -53,6 +125,8 @@ github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ= github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= 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/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= +github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -95,8 +169,11 @@ github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhA github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= @@ -108,6 +185,8 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -122,10 +201,16 @@ github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 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/detectors/gcp v1.38.0 h1:ZoYbqX7OaA/TAikspPl3ozPI6iY6LiIY9I8cUfm+pJs= +go.opentelemetry.io/contrib/detectors/gcp v1.38.0/go.mod h1:SU+iU7nu5ud4oCb3LQOhIZ3nRLj6FNVrKgtflbaf2ts= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 h1:wm/Q0GAAykXv83wzcKzGGqAnnfLFyFe7RslekZuv+VI= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0/go.mod h1:ra3Pa40+oKjvYh+ZD3EdxFZZB0xdMfuileHAm4nNN7w= go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= @@ -168,6 +253,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= diff --git a/preload.go b/preload.go index 626feda..66cee37 100644 --- a/preload.go +++ b/preload.go @@ -1,18 +1,14 @@ package deck import ( - "bytes" "context" "fmt" "log/slog" "slices" "sync" - "time" - "github.com/k1LoW/errors" "golang.org/x/sync/errgroup" "golang.org/x/sync/semaphore" - "google.golang.org/api/drive/v3" "google.golang.org/api/slides/v1" ) @@ -153,12 +149,6 @@ func (d *Deck) preloadCurrentImages(ctx context.Context, actions []*action) (map return result, nil } -// uploadedImageInfo holds information about uploaded images for cleanup. -type uploadedImageInfo struct { - uploadedID string - image *Image -} - // startUploadingImages starts uploading new images asynchronously and returns a channel for cleanup. func (d *Deck) startUploadingImages( ctx context.Context, actions []*action, currentImages map[int]*currentImageData) <-chan uploadedImageInfo { @@ -193,7 +183,7 @@ func (d *Deck) startUploadingImages( close(uploadedCh) return uploadedCh } - d.logger.Info("starting image upload", slog.Int("count", len(imagesToUpload))) + d.logger.Info("starting image upload", slog.Int("count", len(imagesToUpload)), slog.String("storage", GetImageStorageType())) // Mark all images as upload in progress for _, image := range imagesToUpload { @@ -215,51 +205,18 @@ func (d *Deck) startUploadingImages( } defer sem.Release(1) - // Upload image to Google Drive - df := &drive.File{ - Name: fmt.Sprintf("________tmp-for-deck-%s", time.Now().Format(time.RFC3339)), - MimeType: string(image.mimeType), - } - if d.folderID != "" { - df.Parents = []string{d.folderID} - } - uploaded, err := d.driveSrv.Files.Create(df).Media(bytes.NewBuffer(image.Bytes())).SupportsAllDrives(true).Do() + // Upload image using the configured uploader + filename := generateTempFilename() + publicURL, resourceID, err := d.imageUploader.Upload(ctx, image.Bytes(), string(image.mimeType), filename) if err != nil { image.SetUploadResult("", fmt.Errorf("failed to upload image: %w", err)) return err } - defer func() { - if err != nil { - // Clean up uploaded file on error - if deleteErr := d.deleteOrTrashFile(ctx, uploaded.Id); deleteErr != nil { - err = errors.Join(err, deleteErr) - } - } - }() - - // To specify a URL for CreateImageRequest, we must make the webContentURL readable to anyone - // and configure the necessary permissions for this purpose. - if err := d.AllowReadingByAnyone(ctx, uploaded.Id); err != nil { - image.SetUploadResult("", fmt.Errorf("failed to set permission for image: %w", err)) - return err - } - - // Get webContentLink - f, err := d.driveSrv.Files.Get(uploaded.Id).Fields("webContentLink").SupportsAllDrives(true).Do() - if err != nil { - image.SetUploadResult("", fmt.Errorf("failed to get webContentLink for image: %w", err)) - return err - } - - if f.WebContentLink == "" { - image.SetUploadResult("", fmt.Errorf("webContentLink is empty for image: %s", uploaded.Id)) - return err - } // Set successful upload result - image.SetUploadResult(f.WebContentLink, nil) + image.SetUploadResult(publicURL, nil) - uploadedCh <- uploadedImageInfo{uploadedID: uploaded.Id, image: image} + uploadedCh <- uploadedImageInfo{resourceID: resourceID, image: image} return nil }) } @@ -300,13 +257,13 @@ func (d *Deck) cleanupUploadedImages(ctx context.Context, uploadedCh <-chan uplo wg.Done() }() - // Delete uploaded image from Google Drive + // Delete uploaded image using the configured uploader // Note: We only log errors here instead of returning them to ensure // all images are attempted to be deleted. A single deletion failure // should not prevent cleanup of other successfully uploaded images. - if err := d.deleteOrTrashFile(ctx, info.uploadedID); err != nil { + if err := d.imageUploader.Delete(ctx, info.resourceID); err != nil { d.logger.Error("failed to delete uploaded image", - slog.String("id", info.uploadedID), + slog.String("id", info.resourceID), slog.Any("error", err)) } }(info) diff --git a/uploader.go b/uploader.go new file mode 100644 index 0000000..4eca03a --- /dev/null +++ b/uploader.go @@ -0,0 +1,39 @@ +package deck + +import ( + "context" + "os" +) + +// Environment variables for image storage selection. +const ( + // EnvImageStorage specifies the image storage backend. + // Valid values: "gdrive" (default), "gcs", "s3" + EnvImageStorage = "DECK_IMAGE_STORAGE" +) + +// ImageUploader is the interface for uploading images to various storage backends. +type ImageUploader interface { + // Upload uploads an image and returns the public URL and resource ID. + // The public URL is used by Google Slides API to fetch the image. + // The resource ID is used for cleanup (deletion) after the image is fetched. + Upload(ctx context.Context, data []byte, mimeType, filename string) (publicURL, resourceID string, err error) + + // Delete deletes an uploaded image by its resource ID. + Delete(ctx context.Context, resourceID string) error +} + +// uploadedImageInfo holds information about uploaded images for cleanup. +type uploadedImageInfo struct { + resourceID string + image *Image +} + +// GetImageStorageType returns the configured image storage type. +func GetImageStorageType() string { + storage := os.Getenv(EnvImageStorage) + if storage == "" { + return "gdrive" + } + return storage +} diff --git a/uploader_gcs.go b/uploader_gcs.go new file mode 100644 index 0000000..43548b6 --- /dev/null +++ b/uploader_gcs.go @@ -0,0 +1,96 @@ +package deck + +import ( + "context" + "fmt" + "os" + "time" + + "cloud.google.com/go/storage" + "google.golang.org/api/option" +) + +// Environment variables for GCS configuration. +const ( + // EnvGCSServiceAccountKey - JSON content of a service account key for GCS. + EnvGCSServiceAccountKey = "DECK_GCS_SERVICE_ACCOUNT_KEY" + + // EnvGCSBucket - The GCS bucket name to upload images to. + EnvGCSBucket = "DECK_GCS_BUCKET" + + // EnvGCSPrefix - Optional prefix (folder path) for uploaded images. + EnvGCSPrefix = "DECK_GCS_PREFIX" +) + +// GCSUploader implements ImageUploader for Google Cloud Storage. +type GCSUploader struct { + client *storage.Client + bucketName string + prefix string +} + +// NewGCSUploader creates a new GCSUploader from environment variables. +func NewGCSUploader(ctx context.Context) (*GCSUploader, error) { + bucketName := os.Getenv(EnvGCSBucket) + if bucketName == "" { + return nil, fmt.Errorf("%s is required for GCS storage", EnvGCSBucket) + } + + var client *storage.Client + var err error + + if credsJSON := os.Getenv(EnvGCSServiceAccountKey); credsJSON != "" { + // Use service account key from environment variable + client, err = storage.NewClient(ctx, option.WithCredentialsJSON([]byte(credsJSON))) + } else { + // Fall back to Application Default Credentials + client, err = storage.NewClient(ctx) + } + if err != nil { + return nil, fmt.Errorf("failed to create GCS client: %w", err) + } + + return &GCSUploader{ + client: client, + bucketName: bucketName, + prefix: os.Getenv(EnvGCSPrefix), + }, nil +} + +// Upload uploads an image to GCS and returns a signed URL. +func (u *GCSUploader) Upload(ctx context.Context, data []byte, mimeType, filename string) (string, string, error) { + objectName := u.prefix + filename + + // Upload the image + obj := u.client.Bucket(u.bucketName).Object(objectName) + w := obj.NewWriter(ctx) + w.ContentType = mimeType + + if _, err := w.Write(data); err != nil { + return "", "", fmt.Errorf("failed to write image to GCS: %w", err) + } + if err := w.Close(); err != nil { + return "", "", fmt.Errorf("failed to close GCS writer: %w", err) + } + + // Generate signed URL (valid for 1 hour) + signedURL, err := u.client.Bucket(u.bucketName).SignedURL(objectName, &storage.SignedURLOptions{ + Method: "GET", + Expires: time.Now().Add(1 * time.Hour), + }) + if err != nil { + // Clean up on error + _ = u.Delete(ctx, objectName) + return "", "", fmt.Errorf("failed to generate signed URL: %w", err) + } + + return signedURL, objectName, nil +} + +// Delete deletes an uploaded image from GCS. +func (u *GCSUploader) Delete(ctx context.Context, resourceID string) error { + if err := u.client.Bucket(u.bucketName).Object(resourceID).Delete(ctx); err != nil { + return fmt.Errorf("failed to delete object from GCS: %w", err) + } + return nil +} diff --git a/uploader_gdrive.go b/uploader_gdrive.go new file mode 100644 index 0000000..73606fc --- /dev/null +++ b/uploader_gdrive.go @@ -0,0 +1,91 @@ +package deck + +import ( + "bytes" + "context" + "fmt" + "time" + + "google.golang.org/api/drive/v3" +) + +// GoogleDriveUploader implements ImageUploader for Google Drive. +type GoogleDriveUploader struct { + driveSrv *drive.Service + folderID string +} + +// NewGoogleDriveUploader creates a new GoogleDriveUploader. +func NewGoogleDriveUploader(driveSrv *drive.Service, folderID string) *GoogleDriveUploader { + return &GoogleDriveUploader{ + driveSrv: driveSrv, + folderID: folderID, + } +} + +// Upload uploads an image to Google Drive and returns the public URL and file ID. +func (u *GoogleDriveUploader) Upload(ctx context.Context, data []byte, mimeType, filename string) (string, string, error) { + df := &drive.File{ + Name: filename, + MimeType: mimeType, + } + if u.folderID != "" { + df.Parents = []string{u.folderID} + } + + uploaded, err := u.driveSrv.Files.Create(df).Media(bytes.NewBuffer(data)).SupportsAllDrives(true).Do() + if err != nil { + return "", "", fmt.Errorf("failed to upload image to Google Drive: %w", err) + } + + // Set permission to allow anyone to read + permission := &drive.Permission{ + Type: "anyone", + Role: "reader", + } + if _, err := u.driveSrv.Permissions.Create(uploaded.Id, permission).SupportsAllDrives(true).Context(ctx).Do(); err != nil { + // Clean up uploaded file on permission error + _ = u.Delete(ctx, uploaded.Id) + return "", "", fmt.Errorf("failed to set permission for image: %w", err) + } + + // Get webContentLink + f, err := u.driveSrv.Files.Get(uploaded.Id).Fields("webContentLink").SupportsAllDrives(true).Do() + if err != nil { + _ = u.Delete(ctx, uploaded.Id) + return "", "", fmt.Errorf("failed to get webContentLink for image: %w", err) + } + + if f.WebContentLink == "" { + _ = u.Delete(ctx, uploaded.Id) + return "", "", fmt.Errorf("webContentLink is empty for image: %s", uploaded.Id) + } + + return f.WebContentLink, uploaded.Id, nil +} + +// Delete deletes an uploaded image from Google Drive. +func (u *GoogleDriveUploader) Delete(ctx context.Context, resourceID string) error { + file, err := u.driveSrv.Files.Get(resourceID).SupportsAllDrives(true).Fields("capabilities").Context(ctx).Do() + if err != nil { + return fmt.Errorf("file not found or not accessible before deletion (file ID: %s): %w", resourceID, err) + } + + if file.Capabilities == nil || file.Capabilities.CanDelete { + return u.driveSrv.Files.Delete(resourceID).SupportsAllDrives(true).Context(ctx).Do() + } + if file.Capabilities.CanTrash { + updateRequest := &drive.File{Trashed: true} + _, err := u.driveSrv.Files.Update(resourceID, updateRequest).SupportsAllDrives(true).Context(ctx).Do() + if err != nil { + return fmt.Errorf("failed to trash file: %w", err) + } + return nil + } + return fmt.Errorf("file cannot be deleted or trashed (file ID: %s)", resourceID) +} + +// generateTempFilename generates a temporary filename for uploaded images. +func generateTempFilename() string { + return fmt.Sprintf("________tmp-for-deck-%s", time.Now().Format(time.RFC3339)) +} diff --git a/uploader_s3.go b/uploader_s3.go new file mode 100644 index 0000000..6df45ec --- /dev/null +++ b/uploader_s3.go @@ -0,0 +1,137 @@ +package deck + +import ( + "bytes" + "context" + "fmt" + "os" + "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" +) + +// Environment variables for S3 configuration. +const ( + // EnvS3AccessKeyID - AWS access key ID for S3 (optional, falls back to AWS SDK default chain). + EnvS3AccessKeyID = "DECK_S3_ACCESS_KEY_ID" + + // EnvS3SecretAccessKey - AWS secret access key for S3 (optional, falls back to AWS SDK default chain). + EnvS3SecretAccessKey = "DECK_S3_SECRET_ACCESS_KEY" + + // EnvS3Bucket - The S3 bucket name to upload images to. + EnvS3Bucket = "DECK_S3_BUCKET" + + // EnvS3Prefix - Optional prefix (folder path) for uploaded images. + EnvS3Prefix = "DECK_S3_PREFIX" + + // EnvS3Region - AWS region for the S3 bucket. + EnvS3Region = "DECK_S3_REGION" + + // EnvS3Endpoint - Custom S3 endpoint (for S3-compatible services like MinIO, Cloudflare R2). + EnvS3Endpoint = "DECK_S3_ENDPOINT" +) + +// S3Uploader implements ImageUploader for AWS S3. +type S3Uploader struct { + client *s3.Client + presignClient *s3.PresignClient + bucketName string + prefix string +} + +// NewS3Uploader creates a new S3Uploader from environment variables. +func NewS3Uploader(ctx context.Context) (*S3Uploader, error) { + bucketName := os.Getenv(EnvS3Bucket) + if bucketName == "" { + return nil, fmt.Errorf("%s is required for S3 storage", EnvS3Bucket) + } + + var cfg aws.Config + var err error + + // Check for deck-specific credentials first + accessKeyID := os.Getenv(EnvS3AccessKeyID) + secretAccessKey := os.Getenv(EnvS3SecretAccessKey) + + opts := []func(*config.LoadOptions) error{} + + // Set region if specified + if region := os.Getenv(EnvS3Region); region != "" { + opts = append(opts, config.WithRegion(region)) + } + + // Use deck-specific credentials if provided + if accessKeyID != "" && secretAccessKey != "" { + opts = append(opts, config.WithCredentialsProvider( + credentials.NewStaticCredentialsProvider(accessKeyID, secretAccessKey, ""), + )) + } + + cfg, err = config.LoadDefaultConfig(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("failed to load AWS config: %w", err) + } + + // Create S3 client with optional custom endpoint + var clientOpts []func(*s3.Options) + if endpoint := os.Getenv(EnvS3Endpoint); endpoint != "" { + clientOpts = append(clientOpts, func(o *s3.Options) { + o.BaseEndpoint = aws.String(endpoint) + o.UsePathStyle = true // Required for most S3-compatible services + }) + } + + client := s3.NewFromConfig(cfg, clientOpts...) + presignClient := s3.NewPresignClient(client) + + return &S3Uploader{ + client: client, + presignClient: presignClient, + bucketName: bucketName, + prefix: os.Getenv(EnvS3Prefix), + }, nil +} + +// Upload uploads an image to S3 and returns a presigned URL. +func (u *S3Uploader) Upload(ctx context.Context, data []byte, mimeType, filename string) (string, string, error) { + key := u.prefix + filename + + // Upload the image (private by default) + _, err := u.client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: &u.bucketName, + Key: &key, + Body: bytes.NewReader(data), + ContentType: &mimeType, + }) + if err != nil { + return "", "", fmt.Errorf("failed to upload image to S3: %w", err) + } + + // Generate presigned URL (valid for 1 hour) + presignedReq, err := u.presignClient.PresignGetObject(ctx, &s3.GetObjectInput{ + Bucket: &u.bucketName, + Key: &key, + }, s3.WithPresignExpires(1*time.Hour)) + if err != nil { + // Clean up on error + _ = u.Delete(ctx, key) + return "", "", fmt.Errorf("failed to generate presigned URL: %w", err) + } + + return presignedReq.URL, key, nil +} + +// Delete deletes an uploaded image from S3. +func (u *S3Uploader) Delete(ctx context.Context, resourceID string) error { + _, err := u.client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: &u.bucketName, + Key: &resourceID, + }) + if err != nil { + return fmt.Errorf("failed to delete object from S3: %w", err) + } + return nil +}