All checks were successful
build-and-push / build (push) Successful in 39s
- config/db/storage/auth/router/perms: eQMS 규약 미러링, 권한 2-tier (관리자 전체 / 구성원 본인·신청만), oauth2-proxy 헤더 인증 + DEV_AUTH mock - 모델: 구성원/부서, 근무(출퇴근·휴가·공가·초과), 프로젝트(회사/제품/버전· 작업자portion·담당자·태스크·계약·첨부·분할입금), 인센티브(설정·단계· 유저배분·분기정산), 회계(거래·세금) - internal/worktime: 근로기준법 월 집계 엔진 - internal/incentive: BE/non-BE × 계약금/중도금/잔금 3단계 계산 + 시뮬레이션 - 시드 데이터, Go 멀티스테이지 Dockerfile - ADMIN_GROUPS 기본값 'admin' (전 내부 앱 공통 그룹) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
198 lines
5.5 KiB
Go
198 lines
5.5 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"time"
|
|
|
|
"spin/internal/config"
|
|
|
|
awscfg "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"
|
|
s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
|
|
)
|
|
|
|
// ObjectInfo describes a single S3 object as listed by the file-admin tool.
|
|
type ObjectInfo struct {
|
|
Key string `json:"key"`
|
|
Size int64 `json:"size"`
|
|
LastModified time.Time `json:"lastModified"`
|
|
}
|
|
|
|
// Storage wraps two S3 clients: one for in-cluster operations (S3_ENDPOINT) and
|
|
// one used only to mint presigned URLs that the browser can reach
|
|
// (S3_PUBLIC_ENDPOINT). Mirrors the sister eQMS service.
|
|
type Storage struct {
|
|
bucket string
|
|
prefix string // S3_PREFIX — app namespace within a shared bucket
|
|
client *s3.Client
|
|
presign *s3.PresignClient
|
|
}
|
|
|
|
// k applies the configured key prefix. DB stores logical keys; the physical
|
|
// object location is prefix + key (e.g. "spin-backend/contracts/...").
|
|
func (s *Storage) k(key string) string { return s.prefix + key }
|
|
|
|
// New constructs the storage clients. MinIO requires path-style addressing.
|
|
func New(ctx context.Context, cfg config.Config) (*Storage, error) {
|
|
creds := credentials.NewStaticCredentialsProvider(cfg.S3AccessKey, cfg.S3SecretKey, "")
|
|
|
|
base, err := awscfg.LoadDefaultConfig(ctx,
|
|
awscfg.WithRegion(cfg.S3Region),
|
|
awscfg.WithCredentialsProvider(creds),
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
client := s3.NewFromConfig(base, func(o *s3.Options) {
|
|
o.BaseEndpoint = strPtr(cfg.S3Endpoint)
|
|
o.UsePathStyle = true
|
|
})
|
|
|
|
// Separate client whose endpoint is the browser-reachable host.
|
|
publicClient := s3.NewFromConfig(base, func(o *s3.Options) {
|
|
o.BaseEndpoint = strPtr(cfg.S3PublicEndpoint)
|
|
o.UsePathStyle = true
|
|
})
|
|
|
|
return &Storage{
|
|
bucket: cfg.S3Bucket,
|
|
prefix: cfg.S3Prefix,
|
|
client: client,
|
|
presign: s3.NewPresignClient(publicClient),
|
|
}, nil
|
|
}
|
|
|
|
func strPtr(s string) *string { return &s }
|
|
|
|
// EnsureBucket creates the bucket if it does not exist. Best-effort.
|
|
func (s *Storage) EnsureBucket(ctx context.Context) error {
|
|
_, err := s.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: &s.bucket})
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
_, err = s.client.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: &s.bucket})
|
|
return err
|
|
}
|
|
|
|
// Upload stores an object.
|
|
func (s *Storage) Upload(ctx context.Context, key, contentType string, body io.Reader, size int64) error {
|
|
full := s.k(key)
|
|
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
|
|
Bucket: &s.bucket,
|
|
Key: &full,
|
|
Body: body,
|
|
ContentType: &contentType,
|
|
ContentLength: &size,
|
|
})
|
|
return err
|
|
}
|
|
|
|
// Get streams an object's bytes from S3. The caller must close the returned
|
|
// reader. The second return value is the content length (-1 if unknown).
|
|
func (s *Storage) Get(ctx context.Context, key string) (io.ReadCloser, int64, error) {
|
|
full := s.k(key)
|
|
out, err := s.client.GetObject(ctx, &s3.GetObjectInput{
|
|
Bucket: &s.bucket,
|
|
Key: &full,
|
|
})
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
var size int64 = -1
|
|
if out.ContentLength != nil {
|
|
size = *out.ContentLength
|
|
}
|
|
return out.Body, size, nil
|
|
}
|
|
|
|
// Delete removes a single logical object (prefix applied).
|
|
func (s *Storage) Delete(ctx context.Context, key string) error {
|
|
full := s.k(key)
|
|
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
|
Bucket: &s.bucket,
|
|
Key: &full,
|
|
})
|
|
return err
|
|
}
|
|
|
|
// Prefix returns the configured S3 key prefix (app namespace). Used by the
|
|
// file-admin tool as the default listing prefix.
|
|
func (s *Storage) Prefix() string { return s.prefix }
|
|
|
|
// List enumerates RAW S3 keys (no app-prefix rewrite) under the given prefix,
|
|
// following pagination. The admin file tool operates on physical keys directly.
|
|
func (s *Storage) List(ctx context.Context, prefix string) ([]ObjectInfo, error) {
|
|
var out []ObjectInfo
|
|
var token *string
|
|
for {
|
|
page, err := s.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
|
|
Bucket: &s.bucket,
|
|
Prefix: &prefix,
|
|
ContinuationToken: token,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, obj := range page.Contents {
|
|
info := ObjectInfo{}
|
|
if obj.Key != nil {
|
|
info.Key = *obj.Key
|
|
}
|
|
if obj.Size != nil {
|
|
info.Size = *obj.Size
|
|
}
|
|
if obj.LastModified != nil {
|
|
info.LastModified = *obj.LastModified
|
|
}
|
|
out = append(out, info)
|
|
}
|
|
if page.IsTruncated == nil || !*page.IsTruncated {
|
|
break
|
|
}
|
|
token = page.NextContinuationToken
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// DeleteKeys bulk-deletes RAW S3 keys (no app-prefix rewrite) and returns the
|
|
// number of objects deleted. Batches in groups of 1000 (S3 DeleteObjects limit).
|
|
func (s *Storage) DeleteKeys(ctx context.Context, keys []string) (int, error) {
|
|
deleted := 0
|
|
for i := 0; i < len(keys); i += 1000 {
|
|
end := i + 1000
|
|
if end > len(keys) {
|
|
end = len(keys)
|
|
}
|
|
ids := make([]s3types.ObjectIdentifier, 0, end-i)
|
|
for _, k := range keys[i:end] {
|
|
kk := k
|
|
ids = append(ids, s3types.ObjectIdentifier{Key: &kk})
|
|
}
|
|
out, err := s.client.DeleteObjects(ctx, &s3.DeleteObjectsInput{
|
|
Bucket: &s.bucket,
|
|
Delete: &s3types.Delete{Objects: ids},
|
|
})
|
|
if err != nil {
|
|
return deleted, err
|
|
}
|
|
deleted += len(out.Deleted)
|
|
}
|
|
return deleted, nil
|
|
}
|
|
|
|
// PresignGet returns a browser-usable presigned GET URL valid for 10 minutes.
|
|
func (s *Storage) PresignGet(ctx context.Context, key string) (string, error) {
|
|
full := s.k(key)
|
|
out, err := s.presign.PresignGetObject(ctx, &s3.GetObjectInput{
|
|
Bucket: &s.bucket,
|
|
Key: &full,
|
|
}, s3.WithPresignExpires(10*time.Minute))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return out.URL, nil
|
|
}
|