- 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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ce4023ca59
commit
f83724b995
@@ -0,0 +1,197 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user