1
0
Fork 0

Compare commits

...

2 Commits

Author SHA1 Message Date
Ambrose Chua 8a313c2b28
WIP: new bucket layout with preview
continuous-integration/drone/push Build is failing Details
2020-06-14 18:40:55 +08:00
Ambrose Chua 7b6b92e32e
New bucket layout and API, control migration 2020-06-14 17:31:37 +08:00
27 changed files with 903 additions and 612 deletions

119
README.md
View File

@ -3,48 +3,106 @@
A photo bucket management suite.
There are two modes of operation:
- Domain
- Buckets are exactly equal to their domain names
- `unset MINIO_DOMAIN`
- Subdomain
- Buckets are named after subdomains
- `export MINIO_DOMAIN=your.domain`
## Bucket Format
- `photo/[filename]`
- Original photo. Name conflict resolution (excluding extension) must be handled client-side.
- `preview/[filename]_h[height]q[quality].[format]`
- Preview photos at lower resolutions and quality.
- Generator: preview
- `photometadata/[filename]/size`
- Original photo size, JSON
- Generator: preview
- `photometadata/[filename]/date`
- Original photo date taken, unix timestamp
- Generator: preview
- `photometadata/[filename]/title`
- Photo title
- `photometadata/[filename]/tags`
- Photo tags
- `metadata/title`
- Photo album title
- `metadata/description`
- Photo album description, markdown
- `metadata/ordering`
- Manual sort ordering, newline separated
- `internal/control`
- Access control settings, JSON
## `admin`
Create new buckets. Standalone tool.
## `control`
Implement access controls by signing or proxying requests.
- Standalone tool to manage an S3-like account
- In-browser handling of:
- ListBuckets
- MakeBucket
- RemoveBucket
- Setup photo bucket
- Uploads initial metadata
- SetBucketPolicy that matches
- Adds credentials to database.
- Disconnect photo bucket
- Removes credentials from database
### Operations
#### `GET /list?bucket=BUCKET&auth=TOKEN`
#### `PUT /credential?bucket=BUCKET`
1. Consult the bucket for metadata.json
2. Get list access method for the bucket
3. Validate the token against the access method
4. Return ListObjectsV2 for prefix `photo/`
- Can also 307 redirect to the bucket read URL, if is public readable
1. Ensure credentials work on BUCKET
2. Write credentials to KV store
#### `GET /read?bucket=BUCKET&auth=TOKEN&object=OBJECTNAME`
#### `DELETE /credential?bucket=BUCKET`
1. Consult the bucket for metadata.json
1. Ensure credentials work on BUCKET
2. Write credentials to KV store
### Data
Credentials must be passed as:
```
{
"access_key": string,
"secret_key": string,
"region": string
}
```
## `control`
Implement access controls by signing requests, using credentials in database.
### Operations
#### `GET /sign?bucket=BUCKET&token=TOKEN&request=REQUEST`
1. Consult the bucket for `metadata/control` with credentials
2. Get read access method for the bucket
3. Validate the token against the access method
4. Validate that OBJECTNAME starts with `photo/`
4. Validate REQUEST is an acceptable request
- ListObjectsV2, GetObject
- Not `internal/`
5. If necessary, presign an object URL for 4 days
- Cache presigned URLs for 2 days in memory/Redis
- Cache presigned URLs for 2 days in memory/Redis
6. 307 redirect to presigned URL
#### `PUT /write?bucket=BUCKET&auth=TOKEN&object=OBJECTNAME`
#### `PUT /sign?bucket=BUCKET&token=TOKEN&request=REQUEST`
1. Consult the bucket for metadata.json
1. Consult the bucket for `metadata/control` with credentials
2. Get write access method for the bucket
3. Validate the token against the access method
4. Validate that OBJECTNAME starts with `photo/`
4. Validate REQUEST is an acceptable request
- PutObject
- Not `internal/`
5. If necessary, presign an object URL for 30 minutes
6. 307 redirect to presigned URL
#### `DELETE /sign?bucket=BUCKET&token=TOKEN&request=REQUEST`
1. Consult the bucket for `metadata/control` with credentials
2. Get write access method for the bucket
3. Validate the token against the access method
4. Validate REQUEST is an acceptable request
- RemoveObject
- Not `internal/`
5. If necessary, presign an object URL for 30 minutes
6. 307 redirect to presigned URL
@ -58,27 +116,24 @@ The read/write token is checked against a simple string defined in the bucket.
Recommended IDP: [dex](https://github.com/dexidp/dex)
The read/write operation is gated by a signed key corresponding to allowed
users defined in the bucket.
The read/write operation is gated by a signed key corresponding to allowed users defined in the bucket.
## `web`
Generates the web interface for a photo bucket. Also updates the shared asset bucket on start.
Generates the web interface for a photo bucket. Also serves up the shared assets.
### Operations
#### `POST /webhook`
#### `POST /update?bucket=BUCKET`
Regenerate and upload `index.html` and `manage/index.html` to bucket.
## `preview`
Generate previews from photo buckets. Registers webhooks.
Generate previews from photo buckets.
### Operations
#### `POST /webhook`
#### `POST /update?bucket=BUCKET&photo=OBJECT`
1. Perform preview generation using libvips (maybe limit?)
@ -86,7 +141,7 @@ Generate previews from photo buckets. Registers webhooks.
## `proxy`
Reverse proxies buckets to the minio endpoint, as a substitute for the AWS S3 website hosting features. Serves up `index.html` when URLs end in a slash.
Reverse proxies buckets to the MinIO endpoint, as a substitute for AWS S3 website hosting features. Serves up `index.html` when URLs end in a slash.
In production, [replace this with Nginx](https://docs.min.io/docs/setup-nginx-proxy-with-minio).

View File

@ -1,4 +1,4 @@
FROM golang:alpine AS build
FROM golang:buster AS build
RUN mkdir /src /dist
@ -10,7 +10,7 @@ ENV CGO_ENABLED=0
RUN go build -o /dist/web
FROM scratch
FROM debian:buster
COPY --from=build /dist/web /web

View File

@ -3,114 +3,90 @@ package main
import (
"fmt"
"net/http"
"strconv"
"time"
"git.makerforce.io/photos/photos/internal/httphelpers"
lib "git.makerforce.io/photos/photos/pkg/bucket"
"git.makerforce.io/photos/photos/pkg/credentials"
"git.makerforce.io/photos/photos/pkg/signer"
)
var signer *lib.Signer
var creds *credentials.Client
var sig *signer.Signer
func main() {
// Setup bucket signer
var err error
signer, err = lib.NewSignerFromEnv()
// Setup bucket credential source
creds, err = credentials.NewClientFromEnv()
if err != nil {
panic(err)
}
// Setup bucket signer
sig, err = signer.NewSignerFromEnv()
if err != nil {
panic(err)
}
server := &http.Server{
Addr: ":8000",
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
}
http.HandleFunc("/list", list)
http.HandleFunc("/read", read)
http.HandleFunc("/write", write)
http.HandleFunc("/sign", sign)
err = server.ListenAndServe()
if err != nil {
panic(err)
}
}
func list(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
func sign(w http.ResponseWriter, req *http.Request) {
var err error
bucket := lib.Bucket(req.FormValue("bucket"))
token := req.FormValue("token")
request := SafePathable(req.FormValue("request"))
err = bucket.Validate()
if err != nil {
err := fmt.Errorf("%w: %v", httphelpers.ErrorBadRequest, err.Error())
httphelpers.ErrorResponse(w, err)
return
}
err = request.Validate()
if err != nil {
err := fmt.Errorf("%w: %v", httphelpers.ErrorBadRequest, err.Error())
httphelpers.ErrorResponse(w, err)
return
}
var cred credentials.Credential
if token == "todo" {
cred, err = creds.Get(bucket)
if err != nil {
err := fmt.Errorf("%w: error getting credentials: %v", httphelpers.ErrorBadRequest, err.Error())
httphelpers.ErrorResponse(w, err)
return
}
}
var signedReq *http.Request
unsignedReq, err := http.NewRequest(req.Method, bucket.URL(request).String(), nil)
if err != nil {
err := fmt.Errorf("%w: error creating request: %v", httphelpers.ErrorBadRequest, err.Error())
httphelpers.ErrorResponse(w, err)
return
}
if req.Method == http.MethodGet {
signedReq = sig.PreSignRead(unsignedReq, cred)
} else if req.Method == http.MethodPost || req.Method == http.MethodPut || req.Method == http.MethodDelete {
signedReq = sig.PreSignRead(unsignedReq, cred)
} else {
err := fmt.Errorf("%w: %v", httphelpers.ErrorMethodNotAllowed, req.Method)
httphelpers.ErrorResponse(w, err)
return
}
bucket := lib.Bucket(req.FormValue("bucket"))
startAfter := req.FormValue("start-after")
maxKeysString := req.FormValue("max-keys")
if maxKeysString == "" {
maxKeysString = "4000"
}
maxKeys, err := strconv.Atoi(maxKeysString) // Let minio handle error
url, err := signer.GetBucketPhotos(bucket, startAfter, maxKeys)
if err != nil {
httphelpers.ErrorResponse(w, err)
return
}
w.Header().Add("Location", url)
w.Header().Add("Location", signedReq.URL.String())
w.WriteHeader(http.StatusTemporaryRedirect)
}
func read(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
err := fmt.Errorf("%w: %v", httphelpers.ErrorMethodNotAllowed, req.Method)
httphelpers.ErrorResponse(w, err)
return
}
bucket := lib.Bucket(req.FormValue("bucket"))
photo := lib.Photo(req.FormValue("object"))
preview := lib.Preview(req.FormValue("object"))
if photo.Validate() == nil {
url, err := signer.GetPhoto(bucket, photo)
if err != nil {
httphelpers.ErrorResponse(w, err)
return
}
w.Header().Add("Location", url)
w.WriteHeader(http.StatusTemporaryRedirect)
return
} else if preview.Validate() == nil {
url, err := signer.GetPreview(bucket, preview)
if err != nil {
httphelpers.ErrorResponse(w, err)
return
}
w.Header().Add("Location", url)
w.WriteHeader(http.StatusTemporaryRedirect)
return
}
httphelpers.ErrorResponse(w, httphelpers.ErrorBadRequest)
}
func write(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPut {
err := fmt.Errorf("%w: %v", httphelpers.ErrorMethodNotAllowed, req.Method)
httphelpers.ErrorResponse(w, err)
return
}
bucket := lib.Bucket(req.FormValue("bucket"))
photo := lib.Photo(req.FormValue("object"))
if photo.Validate() == nil {
url, err := signer.PutPhoto(bucket, photo)
if err != nil {
httphelpers.ErrorResponse(w, err)
return
}
w.Header().Add("Location", url)
w.WriteHeader(http.StatusTemporaryRedirect)
return
}
httphelpers.ErrorResponse(w, httphelpers.ErrorBadRequest)
}

27
cmd/control/pathable.go Normal file
View File

@ -0,0 +1,27 @@
package main
import (
"errors"
"net/url"
"strings"
)
type SafePathable string
var ErrorInvalidRequest = errors.New("invalid request")
func (s SafePathable) Validate() error {
if strings.HasPrefix(string(s), "/") {
return ErrorInvalidRequest
}
if strings.HasPrefix(string(s), "internal/") {
return ErrorInvalidRequest
}
return nil
}
func (s SafePathable) Path() *url.URL {
return &url.URL{
Path: string(s),
}
}

View File

@ -4,15 +4,17 @@ import (
"fmt"
"log"
"net/http"
"net/url"
"sync"
"time"
"git.makerforce.io/photos/photos/internal/httphelpers"
lib "git.makerforce.io/photos/photos/pkg/bucket"
"github.com/davidbyttow/govips/pkg/vips"
"github.com/minio/minio-go"
)
var client *lib.Client
var creds *credentials.Cred
var sig *signer.Signer
type resizeOperation struct {
input []byte
@ -22,12 +24,15 @@ type resizeOperation struct {
took time.Duration
}
const maxAspectRatio = 6
func main() {
// Setup bucket client
var err error
client, err = lib.NewClientFromEnv()
// Setup bucket credential source
creds, err = credentials.NewClientFromEnv()
if err != nil {
panic(err)
}
// Setup bucket signer
sig, err = signer.NewSignerFromEnv()
if err != nil {
panic(err)
}
@ -36,6 +41,17 @@ func main() {
vips.Startup(nil)
defer vips.Shutdown()
transport := &http.Transport{
MaxIdleConns: 4,
MaxIdleConnsPerHost: 4,
IdleConnTimeout: 30 * time.Second,
DisableCompression: true,
}
http.DefaultClient = &http.Client{
Transport: transport,
Timeout: 5 * time.Second,
}
server := &http.Server{
Addr: ":8003",
ReadTimeout: 5 * time.Second,
@ -57,32 +73,59 @@ func update(w http.ResponseWriter, req *http.Request) {
bucket := lib.Bucket(req.FormValue("bucket"))
photo := lib.Photo(req.FormValue("photo"))
err = bucket.Validate()
if err != nil {
err := fmt.Errorf("%w: %v", httphelpers.ErrorBadRequest, err.Error())
httphelpers.ErrorResponse(w, err)
return
}
err = photo.Validate()
if err != nil {
err := fmt.Errorf("%w: %v", httphelpers.ErrorBadRequest, err.Error())
httphelpers.ErrorResponse(w, err)
return
}
// Obtain bucket credentials
cred, err := creds.Get(bucket)
if err != nil {
err := fmt.Errorf("%w: error getting credentials: %v", httphelpers.ErrorBadRequest, err.Error())
httphelpers.ErrorResponse(w, err)
return
}
// Obtain preview options
/*
bucketMetadata, err := client.GetBucketMetadata(bucket)
previewOptions := bucketMetadata.PreviewOptions
*/
previewOptions := lib.DefaultPreviewOptions()
original, err := client.GetPhoto(bucket, photo)
// Get photo
original, err := getPhoto(bucket, cred, photo)
if err != nil {
httphelpers.ErrorResponse(w, err)
return
}
// First, extract photo size
width, height, err := readsize(original)
if err != nil {
httphelpers.ErrorResponse(w, err)
return
}
metadata, _ := client.GetPhotoMetadata(bucket, photo)
metadata.Width = width
metadata.Height = height
err = client.PutPhotoMetadata(bucket, photo, metadata)
s := bucket.PhotoMetadataSize{
Width: width,
Height: height,
}
err = putPhotoMetadataSize(bucket, cred, photo, s)
if err != nil {
httphelpers.ErrorResponse(w, err)
return
}
// Do other resizes
resizes := make([]resizeOperation, len(previewOptions))
for i := range resizes {
resizes[i] = resizeOperation{
@ -151,40 +194,3 @@ func update(w http.ResponseWriter, req *http.Request) {
}
w.Header().Add("X-Debug-Resize-Timings", fmt.Sprintf("%v", timings))
}
func readsize(buf []byte) (int, int, error) {
i, err := vips.NewImageFromBuffer(buf)
if err != nil {
return 0, 0, err
}
return i.Width(), i.Height(), nil
}
func resize(buf []byte, height int, format vips.ImageType, quality int) ([]byte, error) {
i, err := vips.NewImageFromBuffer(buf)
if err != nil {
return nil, err
}
defer i.Close()
scale := float64(height) / float64(i.Height())
err = i.Resize(scale)
if err != nil {
return nil, err
}
outBuf, _, err := i.Export(vips.ExportParams{
Format: format,
Quality: quality,
})
return outBuf, err
}
func toVipsImageType(format lib.PhotoFormat) vips.ImageType {
if format == lib.PhotoFormatWEBP {
return vips.ImageTypeWEBP
}
if format == lib.PhotoFormatJPEG {
return vips.ImageTypeJPEG
}
return vips.ImageTypeUnknown
}

56
cmd/preview/req.go Normal file
View File

@ -0,0 +1,56 @@
package main
import (
"io/ioutil"
"net/http"
"git.makerforce.io/photos/photos/internal/httphelpers"
lib "git.makerforce.io/photos/photos/pkg/bucket"
"git.makerforce.io/photos/photos/pkg/credentials"
)
// TODO: handle errors nicer
func getPhoto(bucket lib.Bucket, cred credentials.Credential, photo lib.Photo) ([]byte, error) {
unsignedReq, err := http.NewRequest(http.MethodGET, bucket.URL(photo).String(), nil)
if err != nil {
return nil, err
}
signedReq := sig.Sign(unsignedReq, cred)
resp, err := http.DefaultClient.Do(signedReq)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, httphelpers.ErrorFromStatus(resp.StatusCode)
}
buf, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return buf, nil
}
func putPhotoMetadataSize(bucket lib.Bucket, cred credentials.Credential, photo lib.Photo, s lib.PhotoMetadataSize) error {
buf, err := lib.PhotoMetadataSizeMarshal(s)
if err != nil {
return err
}
unsignedReq, err := http.NewRequest(http.MethodPUT, bucket.URL(photo.MetadataSize()).String(), bytes.NewBuffer(buf))
if err != nil {
return err
}
signedReq := sig.Sign(unsignedReq, cred)
resp, err := http.DefaultClient.Do(signedReq)
if err != nil {
return err
}
defer resp.Body.Close()
return httphelpers.ErrorFromStatus(resp.StatusCode)
}

43
cmd/preview/vips.go Normal file
View File

@ -0,0 +1,43 @@
package main
import (
lib "git.makerforce.io/photos/photos/pkg/bucket"
"github.com/davidbyttow/govips/pkg/vips"
)
func readsize(buf []byte) (int, int, error) {
i, err := vips.NewImageFromBuffer(buf)
if err != nil {
return 0, 0, err
}
return i.Width(), i.Height(), nil
}
func resize(buf []byte, height int, format vips.ImageType, quality int) ([]byte, error) {
i, err := vips.NewImageFromBuffer(buf)
if err != nil {
return nil, err
}
defer i.Close()
scale := float64(height) / float64(i.Height())
err = i.Resize(scale)
if err != nil {
return nil, err
}
outBuf, _, err := i.Export(vips.ExportParams{
Format: format,
Quality: quality,
})
return outBuf, err
}
func toVipsImageType(format lib.PhotoFormat) vips.ImageType {
if format == lib.PhotoFormatWEBP {
return vips.ImageTypeWEBP
}
if format == lib.PhotoFormatJPEG {
return vips.ImageTypeJPEG
}
return vips.ImageTypeUnknown
}

View File

@ -1,4 +1,4 @@
// This is honestly terrible. It only exists to replace the AWS S3 website hosting feature missing in minio
// This is honestly terrible. It only exists to replace the AWS S3 website hosting feature missing in MinIO
package main
import (
@ -10,7 +10,6 @@ import (
"strings"
"time"
internal_s3utils "git.makerforce.io/photos/photos/internal/s3utils"
"github.com/miekg/dns"
)
@ -26,6 +25,17 @@ func main() {
domain = os.Getenv("MINIO_DOMAIN")
behindProxy = os.Getenv("BEHIND_PROXY") == "true"
transport := &http.Transport{
MaxIdleConns: 4,
MaxIdleConnsPerHost: 4,
IdleConnTimeout: 30 * time.Second,
DisableCompression: true,
}
http.DefaultClient = &http.Client{
Transport: transport,
Timeout: 5 * time.Second,
}
server := &http.Server{
Addr: ":80",
ReadTimeout: 5 * time.Second,
@ -56,14 +66,7 @@ func director(req *http.Request) {
req.URL.Scheme = "https"
}
req.URL.Host = endpoint
if len(domain) < 1 {
// If we are using domains, rewrite the host into path
req.URL.Path = internal_s3utils.PathFromHost(mapPath(*req), host)
} else {
// If we are using subdomains, set the host header
req.URL.Path = mapPath(*req)
req.Header.Set("Host", host)
}
req.URL.Path = pathFromHost(mapPath(*req), host)
// Prevent MINIO from issuing redirects
userAgent := req.Header.Get("User-Agent")
@ -94,3 +97,10 @@ func mapPath(req http.Request) string {
}
return path
}
func pathFromHost(path, host string) string {
if len(host) > 0 {
return "/" + host + path
}
return path
}

View File

@ -42,6 +42,118 @@ spec:
- protocol: TCP
port: 80
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: photos-control
labels:
app: photos-control
spec:
replicas: 1
selector:
matchLabels:
app: photos-control
template:
metadata:
labels:
app: photos-control
spec:
containers:
- name: control
image: registry.makerforce.io/photos/control:dev
imagePullPolicy: Always
env:
- name: MINIO_ENDPOINT
value: api.ambrose.photos:9000
- name: MINIO_ACCESS_KEY
valueFrom:
secretKeyRef:
key: accesskey
name: minio
- name: MINIO_SECRET_KEY
valueFrom:
secretKeyRef:
key: secretkey
name: minio
- name: MINIO_REGION_NAME
value: "sgp1"
- name: MINIO_ENDPOINT_SECURE
value: "false"
- name: MINIO_CREDENTIALS_BUCKET
value: "credentials"
ports:
- containerPort: 8000
---
apiVersion: v1
kind: Service
metadata:
name: photos-control
spec:
selector:
app: photos-control
ports:
- protocol: TCP
port: 80
targetPort: 8000
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: photos-preview
labels:
app: photos-preview
spec:
replicas: 1
selector:
matchLabels:
app: photos-preview
template:
metadata:
labels:
app: photos-preview
spec:
containers:
- name: preview
image: registry.makerforce.io/photos/preview:dev
imagePullPolicy: Always
env:
- name: MINIO_ENDPOINT
value: api.ambrose.photos:9000
- name: MINIO_ACCESS_KEY
valueFrom:
secretKeyRef:
key: accesskey
name: minio
- name: MINIO_SECRET_KEY
valueFrom:
secretKeyRef:
key: secretkey
name: minio
- name: MINIO_REGION_NAME
value: "sgp1"
- name: MINIO_ENDPOINT_SECURE
value: "false"
- name: MINIO_CREDENTIALS_BUCKET
value: "credentials"
ports:
- containerPort: 8003
---
apiVersion: v1
kind: Service
metadata:
name: photos-preview
spec:
selector:
app: photos-preview
ports:
- protocol: TCP
port: 80
targetPort: 8003
---
apiVersion: apps/v1
kind: Deployment
@ -66,10 +178,6 @@ spec:
env:
- name: MINIO_ENDPOINT
value: api.ambrose.photos:9000
- name: MINIO_DOMAIN
value: ""
- name: MINIO_ENDPOINT_SECURE
value: "false"
- name: MINIO_ACCESS_KEY
valueFrom:
secretKeyRef:
@ -80,6 +188,12 @@ spec:
secretKeyRef:
key: secretkey
name: minio
- name: MINIO_REGION_NAME
value: "sgp1"
- name: MINIO_ENDPOINT_SECURE
value: "false"
- name: MINIO_CREDENTIALS_BUCKET
value: "credentials"
- name: WEB_ENDPOINT
value: "http://web.ambrose.photos"
ports:

2
go.mod
View File

@ -7,7 +7,9 @@ require (
github.com/elazarl/go-bindata-assetfs v1.0.0
github.com/go-bindata/go-bindata v1.0.0 // indirect
github.com/go-bindata/go-bindata/v3 v3.1.3 // indirect
github.com/go-ini/ini v1.57.0 // indirect
github.com/miekg/dns v1.1.29
github.com/minio/minio-go v6.0.14+incompatible
github.com/minio/minio-go/v6 v6.0.55
golang.org/x/lint v0.0.0-20200302205851-738671d3881b // indirect
golang.org/x/tools v0.0.0-20200530233709-52effbd89c51 // indirect

5
go.sum
View File

@ -10,6 +10,8 @@ github.com/go-bindata/go-bindata v1.0.0 h1:DZ34txDXWn1DyWa+vQf7V9ANc2ILTtrEjtlsd
github.com/go-bindata/go-bindata v1.0.0/go.mod h1:xK8Dsgwmeed+BBsSy2XTopBn/8uK2HWuGSnA11C3Joo=
github.com/go-bindata/go-bindata/v3 v3.1.3 h1:F0nVttLC3ws0ojc7p60veTurcOm//D4QBODNM7EGrCI=
github.com/go-bindata/go-bindata/v3 v3.1.3/go.mod h1:1/zrpXsLD8YDIbhZRqXzm1Ghc7NhEvIN9+Z6R5/xH4I=
github.com/go-ini/ini v1.57.0 h1:Qwzj3wZQW+Plax5Ntj+GYe07DfGj1OH+aL1nMTMaNow=
github.com/go-ini/ini v1.57.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
@ -22,6 +24,9 @@ github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQL
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/miekg/dns v1.1.29 h1:xHBEhR+t5RzcFJjBLJlax2daXOrTYtr9z4WdKEfWFzg=
github.com/miekg/dns v1.1.29/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
github.com/minio/minio-go v1.0.0 h1:ooSujki+Z1PRGZsYffJw5jnF5eMBvzMVV86TLAlM0UM=
github.com/minio/minio-go v6.0.14+incompatible h1:fnV+GD28LeqdN6vT2XdGKW8Qe/IfjJDswNVuni6km9o=
github.com/minio/minio-go v6.0.14+incompatible/go.mod h1:7guKYtitv8dktvNUGrhzmNlA5wrAABTQXCoesZdFQO8=
github.com/minio/minio-go/v6 v6.0.55 h1:Hqm41952DdRNKXM+6hCnPXCsHCYSgLf03iuYoxJG2Wk=
github.com/minio/minio-go/v6 v6.0.55/go.mod h1:KQMM+/44DSlSGSQWSfRrAZ12FVMmpWNuX37i2AX0jfI=
github.com/minio/sha256-simd v0.1.1 h1:5QHSlgo3nt5yKOJrC7W8w7X+NFl8cMPZm96iu8kKUJU=

View File

@ -2,6 +2,7 @@ package httphelpers
import (
"errors"
"fmt"
"net/http"
"github.com/minio/minio-go/v6"
@ -9,6 +10,7 @@ import (
var ErrorBadRequest = errors.New("bad request")
var ErrorMethodNotAllowed = errors.New("method not allowed")
var ErrorNotFound = errors.New("not found")
func ErrorResponse(w http.ResponseWriter, err error) {
if err == nil {
@ -23,6 +25,9 @@ func ErrorResponse(w http.ResponseWriter, err error) {
if errors.Is(err, ErrorMethodNotAllowed) {
errorStatus = http.StatusMethodNotAllowed
}
if errors.Is(err, ErrorNotFound) {
errorStatus = http.StatusNotFound
}
var minioErrorResponse minio.ErrorResponse
if errors.As(err, &minioErrorResponse) {
@ -32,10 +37,17 @@ func ErrorResponse(w http.ResponseWriter, err error) {
if minioErrorResponse.Code == "NoSuchBucket" {
errorStatus = http.StatusNotFound
}
// Do not handle minio AccessDenied: that's a server error. Our credentials should always be correct
// Do not handle MinIO AccessDenied: that's a server error. Our credentials should always be correct
}
// Let Header.Write() handle escaping
w.Header().Add("X-Debug-Error", errorMessage)
w.WriteHeader(errorStatus)
}
func ErrorFromStatus(code int) error {
if code == http.StatusOK {
return nil
}
return fmt.Errorf("%s", http.StatusText(code))
}

View File

@ -1,8 +0,0 @@
package s3utils
func PathFromHost(path, host string) string {
if len(host) > 0 {
return "/" + host + path
}
return path
}

View File

@ -3,16 +3,26 @@ package bucket
import (
"errors"
"fmt"
"net/url"
"strings"
)
type Bucket string
const bucketMetadataObject = "metadata.json"
const bucketMetadataPrefix = "metadata/"
var ErrorInvalidBucket = errors.New("invalid bucket")
func (b Bucket) Validate() error {
if len(b) < 1 || b == "meta" {
// Ensure is URL
u, err := url.ParseRequestURI(string(b))
if err != nil {
return fmt.Errorf("%w: %w: %v", ErrorInvalidBucket, err, b)
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("%w: %v", ErrorInvalidBucket, b)
}
if u.Host == "" || !strings.HasSuffix(u.Path, "/") {
return fmt.Errorf("%w: %v", ErrorInvalidBucket, b)
}
return nil
@ -22,7 +32,55 @@ func (b Bucket) String() string {
return string(b)
}
type BucketMetadata struct {
Title string `json:"title"`
PreviewOptions []PreviewOption `json:"preview_options"`
func (b Bucket) URL(p Pathable) *url.URL {
u, _ := url.ParseRequestURI(string(b))
path := p.Path()
u.Path = pathJoin(u.Path, path.Path)
u.RawQuery = path.RawQuery
return u
}
func pathJoin(paths ...string) string {
cleanedPaths := make([]string, 0, len(paths))
for i, path := range paths {
cleanedPath := strings.TrimPrefix(path, "/")
if i != len(paths)-1 {
cleanedPath = strings.TrimSuffix(cleanedPath, "/")
}
if i == 0 && len(cleanedPath) == 0 {
continue
}
cleanedPaths = append(cleanedPaths, cleanedPath)
}
return "/" + strings.Join(cleanedPaths, "/")
}
type Pathable interface {
Path() *url.URL
}
type BucketMetadata string
func (b Bucket) Metadata(name string) BucketMetadata {
return BucketMetadata(bucketMetadataPrefix + name)
}
func (m BucketMetadata) String() string {
return string(m)
}
func (m BucketMetadata) Path() *url.URL {
return &url.URL{
Path: string(m),
}
}
func (b Bucket) MetadataTitle() BucketMetadata {
return b.Metadata("title")
}
func (b Bucket) MetadataDescription() BucketMetadata {
return b.Metadata("description")
}
func (b Bucket) MetadataOrdering() BucketMetadata {
return b.Metadata("ordering")
}

79
pkg/bucket/bucket_test.go Normal file
View File

@ -0,0 +1,79 @@
package bucket
import (
"testing"
)
func TestBucket_Validate(t *testing.T) {
var got error
got = Bucket("http://api.url").Validate()
if got == nil {
t.Errorf("Bucket.Validate() = %v; want non-nil", got)
}
got = Bucket("http://api.url/").Validate()
if got != nil {
t.Errorf("Bucket.Validate() = %v; want nil", got)
}
got = Bucket("http://api.url/bucket").Validate()
if got == nil {
t.Errorf("Bucket.Validate() = %v; want non-nil", got)
}
got = Bucket("http://api.url/bucket/").Validate()
if got != nil {
t.Errorf("Bucket.Validate() = %v; want nil", got)
}
got = Bucket("http://api.url/bucket/path/").Validate()
if got != nil {
t.Errorf("Bucket.Validate() = %v; want nil", got)
}
}
func TestBucket_URL(t *testing.T) {
var got, want string
var b Bucket
b = Bucket("http://api.url/bucket/")
got = b.URL(Photo("/photo/hello")).String()
want = "http://api.url/bucket/photo/hello"
if got != want {
t.Errorf("Bucket.URL(Photo) = %v; want %v", got, want)
}
got = b.URL(Preview("preview/hello")).String()
want = "http://api.url/bucket/preview/hello"
if got != want {
t.Errorf("Bucket.URL(Photo) = %v; want %v", got, want)
}
got = b.URL(NewList("photo/", 1000, "name")).String()
want = "http://api.url/bucket/?delimiter=&encoding-type=url&list-type=2&max-keys=1000&metadata=true&prefix=photo%2F&start-after=name"
if got != want {
t.Errorf("Bucket.URL(Photo) = %v; want %v", got, want)
}
b = Bucket("http://bucket.api.url")
got = b.URL(Photo("/photo/hello")).String()
want = "http://bucket.api.url/photo/hello"
if got != want {
t.Errorf("Bucket.URL(Photo) = %v; want %v", got, want)
}
got = b.URL(Preview("preview/hello")).String()
want = "http://bucket.api.url/preview/hello"
if got != want {
t.Errorf("Bucket.URL(Photo) = %v; want %v", got, want)
}
got = b.URL(NewList("photo/", 1000, "name")).String()
want = "http://bucket.api.url" + NewList("photo/", 1000, "name").Path().String()
if got != want {
t.Errorf("Bucket.URL(Photo) = %v; want %v", got, want)
}
}

View File

@ -1,205 +0,0 @@
package bucket
import (
"bytes"
"encoding/json"
"io/ioutil"
"os"
"github.com/minio/minio-go/v6"
)
type Client struct {
*minio.Client
}
func NewClient(endpoint, accessKey, secretKey, regionName string, endpointSecure bool) (*Client, error) {
m, err := minio.NewWithRegion(endpoint, accessKey, secretKey, endpointSecure, regionName)
if err != nil {
return nil, err
}
client := &Client{
Client: m,
}
return client, nil
}
func NewClientFromEnv() (*Client, error) {
accessKey := os.Getenv("MINIO_ACCESS_KEY")
secretKey := os.Getenv("MINIO_SECRET_KEY")
regionName := os.Getenv("MINIO_REGION_NAME")
endpoint := os.Getenv("MINIO_ENDPOINT")
endpointSecure := os.Getenv("MINIO_ENDPOINT_SECURE") == "true"
return NewClient(endpoint, accessKey, secretKey, regionName, endpointSecure)
}
func (c *Client) GetBucketMetadata(b Bucket) (BucketMetadata, error) {
err := b.Validate()
if err != nil {
return BucketMetadata{}, err
}
o, err := c.GetObject(b.String(), bucketMetadataObject, minio.GetObjectOptions{})
if err != nil {
return BucketMetadata{}, err
}
defer o.Close()
buf, err := ioutil.ReadAll(o)
if err != nil {
return BucketMetadata{}, err
}
m := BucketMetadata{}
err = json.Unmarshal(buf, &m)
if err != nil {
return BucketMetadata{}, err
}
return m, nil
}
func (c *Client) PutBucketMetadata(b Bucket, m BucketMetadata) error {
err := b.Validate()
if err != nil {
return err
}
buf, err := json.MarshalIndent(m, "", "\t")
if err != nil {
return err
}
_, err = c.PutObject(b.String(), bucketMetadataObject, bytes.NewBuffer(buf), int64(len(buf)), minio.PutObjectOptions{
ContentType: "application/json",
})
if err != nil {
return err
}
return nil
}
func (c *Client) PutBucketWeb(b Bucket, buf []byte) error {
err := b.Validate()
if err != nil {
return err
}
_, err = c.PutObject(b.String(), "index.html", bytes.NewBuffer(buf), int64(len(buf)), minio.PutObjectOptions{
ContentType: "text/html",
StorageClass: "REDUCED_REDUNDANCY",
})
if err != nil {
return err
}
return nil
}
func (c *Client) GetPhotos(b Bucket) ([]Photo, error) {
err := b.Validate()
if err != nil {
return nil, err
}
keys := make([]Photo, 0)
doneCh := make(chan struct{})
objCh := c.ListObjectsV2(b.String(), photoPrefix, true, doneCh)
for obj := range objCh {
if obj.Err != nil {
return nil, obj.Err
}
keys = append(keys, Photo(obj.Key))
}
return keys, nil
}
func (c *Client) GetPhoto(b Bucket, p Photo) ([]byte, error) {
err := b.Validate()
if err != nil {
return nil, err
}
err = p.Validate()
if err != nil {
return nil, err
}
o, err := c.GetObject(b.String(), p.String(), minio.GetObjectOptions{})
if err != nil {
return nil, err
}
defer o.Close()
return ioutil.ReadAll(o)
}
func (c *Client) GetPhotoMetadata(b Bucket, p Photo) (PhotoMetadata, error) {
err := b.Validate()
if err != nil {
return PhotoMetadata{}, err
}
err = p.Validate()
if err != nil {
return PhotoMetadata{}, err
}
o, err := c.GetObject(b.String(), p.MetadataString(), minio.GetObjectOptions{})
if err != nil {
return PhotoMetadata{}, err
}
defer o.Close()
buf, err := ioutil.ReadAll(o)
if err != nil {
return PhotoMetadata{}, err
}
m := PhotoMetadata{}
err = json.Unmarshal(buf, &m)
if err != nil {
return PhotoMetadata{}, err
}
return m, nil
}
func (c *Client) PutPhotoMetadata(b Bucket, p Photo, m PhotoMetadata) error {
err := b.Validate()
if err != nil {
return err
}
err = p.Validate()
if err != nil {
return err
}
buf, err := json.MarshalIndent(m, "", "\t")
if err != nil {
return err
}
_, err = c.PutObject(b.String(), p.MetadataString(), bytes.NewBuffer(buf), int64(len(buf)), minio.PutObjectOptions{
ContentType: "application/json",
})
if err != nil {
return err
}
return nil
}
func (c *Client) PutPreview(b Bucket, p Preview, buf []byte) error {
err := b.Validate()
if err != nil {
return err
}
err = p.Validate()
if err != nil {
return err
}
_, err = c.PutObject(b.String(), p.String(), bytes.NewBuffer(buf), int64(len(buf)), minio.PutObjectOptions{
ContentType: p.Format().ContentType(),
StorageClass: "REDUCED_REDUNDANCY",
})
if err != nil {
return err
}
return nil
}

37
pkg/bucket/list.go Normal file
View File

@ -0,0 +1,37 @@
package bucket
import (
"fmt"
"net/url"
"github.com/minio/minio-go/v6/pkg/s3utils"
)
type List struct {
prefix string
maxKeys int
startAfter string
}
func NewList(prefix string, maxKeys int, startAfter string) List {
return List{
prefix: prefix,
maxKeys: maxKeys,
startAfter: startAfter,
}
}
func (l List) Path() *url.URL {
params := make(url.Values)
params.Set("list-type", "2")
params.Set("metadata", "true")
params.Set("encoding-type", "url")
params.Set("prefix", l.prefix)
params.Set("delimiter", "")
params.Set("max-keys", fmt.Sprintf("%d", l.maxKeys))
params.Set("start-after", l.startAfter)
return &url.URL{
Path: "/",
RawQuery: s3utils.QueryEncode(params),
}
}

1
pkg/bucket/metadata.go Normal file
View File

@ -0,0 +1 @@
package bucket

View File

@ -10,6 +10,7 @@ import (
type Photo string
const photoPrefix = "photo/"
const photoMetadataPrefix = "photometadata/"
var ErrorInvalidPhoto = errors.New("invalid photo")
@ -17,6 +18,9 @@ func (p Photo) Validate() error {
if !strings.HasPrefix(string(p), photoPrefix) {
return fmt.Errorf("%w: %v", ErrorInvalidPhoto, p)
}
if strings.HasSuffix(string(p), "/") {
return fmt.Errorf("%w: %v", ErrorInvalidPhoto, p)
}
return nil
}
@ -24,23 +28,38 @@ func (p Photo) String() string {
return string(p)
}
func (p Photo) Path() string {
u := url.URL{
func (p Photo) Path() *url.URL {
return &url.URL{
Path: string(p),
}
return u.String()
}
const photoMetadataPrefix = "photometa/"
type PhotoMetadata string
func (p Photo) MetadataString() string {
func (p Photo) Metadata(name string) PhotoMetadata {
objectBase := strings.Replace(string(p), photoPrefix, photoMetadataPrefix, 1)
objectBase += ".metadata.json"
return objectBase
return PhotoMetadata(objectBase + "/" + name)
}
type PhotoMetadata struct {
Title string `json:"title"`
Width int `json:"width"`
Height int `json:"height"`
func (m PhotoMetadata) String() string {
return string(m)
}
func (m PhotoMetadata) Path() *url.URL {
return &url.URL{
Path: string(m),
}
}
func (p Photo) MetadataSize() PhotoMetadata {
return p.Metadata("size")
}
func (p Photo) MetadataDate() PhotoMetadata {
return p.Metadata("date")
}
func (p Photo) MetadataTitle() PhotoMetadata {
return p.Metadata("title")
}
func (p Photo) MetadataTags() PhotoMetadata {
return p.Metadata("tags")
}

View File

@ -0,0 +1,20 @@
package bucket
import (
"encoding/json"
)
type PhotoMetadataSize struct {
Width int `json:"width"`
Height int `json:"height"`
}
func PhotoMetadataSizeUnmarshal(buf []byte) (PhotoMetadataSize, error) {
s := PhotoMetadataSize{}
err := json.Unmarshal(buf, &s)
return s, err
}
func PhotoMetadataSizeMarshal(s PhotoMetadataSize) ([]byte, error) {
return json.MarshalIndent(s, "", "\t")
}

View File

@ -19,6 +19,9 @@ func (p Preview) Validate() error {
if !strings.HasPrefix(string(p), previewPrefix) {
return fmt.Errorf("%w: %v", ErrorInvalidPreview, p)
}
if strings.HasSuffix(string(p), "/") {
return fmt.Errorf("%w: %v", ErrorInvalidPreview, p)
}
return nil
}
@ -26,11 +29,10 @@ func (p Preview) String() string {
return string(p)
}
func (p Preview) Path() string {
u := url.URL{
func (p Preview) Path() *url.URL {
return &url.URL{
Path: string(p),
}
return u.String()
}
func (p Preview) Format() PhotoFormat {
@ -93,7 +95,7 @@ func (p Photo) GetPreview(option PreviewOption) Preview {
extIndex := strings.LastIndex(objectBase, ".")
base := objectBase[:extIndex]
res := fmt.Sprintf("_h%dq%d", option.Height, option.Quality)
exts, err := mime.ExtensionsByType(option.Format.ContentType())
exts, err := mime.ExtensionsByType(option.Format.ContentType()) // TODO: replace with hardcoded list
if err != nil {
panic(err)
}

View File

@ -1,198 +0,0 @@
package bucket
import (
"errors"
"net"
"net/http"
"net/url"
"os"
"strconv"
"time"
internal_s3utils "git.makerforce.io/photos/photos/internal/s3utils"
"github.com/minio/minio-go/v6/pkg/s3utils"
"github.com/minio/minio-go/v6/pkg/signer"
)
type Signer struct {
accessKey string
secretKey string
regionName string
bucketSecure bool
expirations Expirations
}
type Expirations struct {
// Expiration time for list and read in time.Duration
Read time.Duration
// Expiration time for write in time.Duration
Write time.Duration
}
var ErrorExpirationTooLow = errors.New("expiration time too low")
func NewSigner(accessKey, secretKey, regionName string, bucketSecure bool, expirations Expirations) (*Signer, error) {
if expirations.Read == 0 {
expirations.Read = 30 * time.Minute
}
if expirations.Write == 0 {
expirations.Write = 5 * time.Minute
}
if expirations.Read < time.Second || expirations.Write < time.Second {
return nil, ErrorExpirationTooLow
}
signer := &Signer{
accessKey: accessKey,
secretKey: secretKey,
regionName: regionName,
bucketSecure: bucketSecure,
expirations: expirations,
}
return signer, nil
}
func NewSignerFromEnv() (*Signer, error) {
accessKey := os.Getenv("MINIO_ACCESS_KEY")
secretKey := os.Getenv("MINIO_SECRET_KEY")
regionName := os.Getenv("MINIO_REGION_NAME")
bucketSecure := os.Getenv("BUCKET_SECURE") == "true"
expirationRead, _ := strconv.Atoi(os.Getenv("EXPIRATION_READ"))
expirationWrite, _ := strconv.Atoi(os.Getenv("EXPIRATION_WRITE"))
expirations := Expirations{Read: time.Duration(expirationRead), Write: time.Duration(expirationWrite)}
return NewSigner(accessKey, secretKey, regionName, bucketSecure, expirations)
}
func (s *Signer) baseBucket(b Bucket) url.URL {
url := url.URL{
Scheme: "http",
Host: b.String(),
Path: "/",
}
if s.bucketSecure {
url.Scheme = "https"
}
return url
}
func (s *Signer) preSignV4(req http.Request) http.Request {
domain := os.Getenv("MINIO_DOMAIN")
// Validate host
host, _, err := net.SplitHostPort(req.Host)
if err != nil {
host = req.Host
}
originalPath := req.URL.Path
if len(domain) < 1 {
// If we are using domains, rewrite the host into path
req.URL.Path = internal_s3utils.PathFromHost(req.URL.Path, host)
}
// If we are using subdomains, sign the direct URL
signedReq := signer.PreSignV4(
req,
s.accessKey, s.secretKey, "",
"sgp1",
int64(s.expirations.Read/time.Second),
)
signedReq.URL.Path = originalPath
return *signedReq
}
func listBucketParams(prefix, startAfter string, maxKeys int) url.Values {
params := make(url.Values)
params.Set("list-type", "2")
params.Set("metadata", "true")
params.Set("encoding-type", "url")
params.Set("prefix", prefix)
params.Set("delimiter", "")
params.Set("max-keys", strconv.Itoa(maxKeys))
params.Set("start-after", startAfter)
return params
}
func (s *Signer) GetBucketPhotos(b Bucket, startAfter string, maxKeys int) (string, error) {
err := b.Validate()
if err != nil {
return "", err
}
url := s.baseBucket(b)
url.Path += "/"
params := listBucketParams(photoPrefix, startAfter, maxKeys)
url.RawQuery = s3utils.QueryEncode(params)
req, err := http.NewRequest("GET", url.String(), nil)
if err != nil {
return "", err
}
signedReq := s.preSignV4(*req)
return signedReq.URL.String(), nil
}
func (s *Signer) GetPhoto(b Bucket, p Photo) (string, error) {
err := b.Validate()
if err != nil {
return "", err
}
err = p.Validate()
if err != nil {
return "", err
}
url := s.baseBucket(b)
url.Path += p.Path()
req, err := http.NewRequest("GET", url.String(), nil)
if err != nil {
return "", err
}
signedReq := signer.PreSignV4(*req, s.accessKey, s.secretKey, "", "sgp1", int64(s.expirations.Read/time.Second))
return signedReq.URL.String(), nil
}
func (s *Signer) PutPhoto(b Bucket, p Photo) (string, error) {
err := b.Validate()
if err != nil {
return "", err
}
err = p.Validate()
if err != nil {
return "", err
}
url := s.baseBucket(b)
url.Path += p.Path()
req, err := http.NewRequest("PUT", url.String(), nil)
if err != nil {
return "", err
}
signedReq := signer.PreSignV4(*req, s.accessKey, s.secretKey, "", "sgp1", int64(s.expirations.Write/time.Second))
return signedReq.URL.String(), nil
}
func (s *Signer) GetPreview(b Bucket, p Preview) (string, error) {
err := b.Validate()
if err != nil {
return "", err
}
err = p.Validate()
if err != nil {
return "", err
}
url := s.baseBucket(b)
url.Path += p.Path()
req, err := http.NewRequest("GET", url.String(), nil)
if err != nil {
return "", err
}
signedReq := signer.PreSignV4(*req, s.accessKey, s.secretKey, "", "sgp1", int64(s.expirations.Read/time.Second))
return signedReq.URL.String(), nil
}

87
pkg/credentials/client.go Normal file
View File

@ -0,0 +1,87 @@
package credentials
import (
"bytes"
"encoding/json"
"io/ioutil"
"log"
"os"
"strings"
"git.makerforce.io/photos/photos/pkg/bucket"
"github.com/minio/minio-go/v6"
)
type Client struct {
*minio.Client
Bucket string
}
func NewClient(endpoint, accessKey, secretKey, regionName string, endpointSecure bool, bucket string) (*Client, error) {
m, err := minio.NewWithRegion(endpoint, accessKey, secretKey, endpointSecure, regionName)
if err != nil {
return nil, err
}
client := &Client{
Client: m,
Bucket: bucket,
}
return client, nil
}
func NewClientFromEnv() (*Client, error) {
accessKey := os.Getenv("MINIO_ACCESS_KEY")
secretKey := os.Getenv("MINIO_SECRET_KEY")
regionName := os.Getenv("MINIO_REGION_NAME")
endpoint := os.Getenv("MINIO_ENDPOINT")
endpointSecure := os.Getenv("MINIO_ENDPOINT_SECURE") == "true"
bucket := os.Getenv("MINIO_CREDENTIALS_BUCKET")
return NewClient(endpoint, accessKey, secretKey, regionName, endpointSecure, bucket)
}
func (c *Client) Get(bucket bucket.Bucket) (Credential, error) {
normalizedBucket := normalize(bucket.String())
log.Printf("Getting credentials for %s using %s %s", normalizedBucket, c.EndpointURL(), c.Bucket)
o, err := c.GetObject(c.Bucket, normalizedBucket, minio.GetObjectOptions{})
if err != nil {
return Credential{}, err
}
defer o.Close()
buf, err := ioutil.ReadAll(o)
if err != nil {
return Credential{}, err
}
cred := Credential{}
err = json.Unmarshal(buf, &cred)
if err != nil {
return Credential{}, err
}
return cred, nil
}
func (c *Client) Set(bucket bucket.Bucket, cred Credential) error {
normalizedBucket := normalize(bucket.String())
log.Printf("Setting credentials for %s using %s %s", normalizedBucket, c.EndpointURL(), c.Bucket)
buf, err := json.MarshalIndent(cred, "", "\t")
if err != nil {
return err
}
_, err = c.PutObject(c.Bucket, normalizedBucket, bytes.NewBuffer(buf), int64(len(buf)), minio.PutObjectOptions{
ContentType: "application/json",
})
if err != nil {
return err
}
return nil
}
func normalize(bucket string) string {
nodouble := strings.Replace(bucket, "//", "", 1)
nolastslash := strings.TrimSuffix(nodouble, "/")
return nolastslash
}

7
pkg/credentials/types.go Normal file
View File

@ -0,0 +1,7 @@
package credentials
type Credential struct {
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
Region string `json:"region"`
}

76
pkg/signer/signer.go Normal file
View File

@ -0,0 +1,76 @@
package signer
import (
"errors"
"net/http"
"os"
"strconv"
"time"
"git.makerforce.io/photos/photos/pkg/credentials"
"github.com/minio/minio-go/v6/pkg/signer"
)
type Signer struct {
expirations Expirations
}
type Expirations struct {
// Expiration time for list and read in time.Duration
Read time.Duration
// Expiration time for write in time.Duration
Write time.Duration
}
var ErrorExpirationTooLow = errors.New("expiration time too low")
func NewSigner(expirations Expirations) (*Signer, error) {
if expirations.Read == 0 {
expirations.Read = 30 * time.Minute
}
if expirations.Write == 0 {
expirations.Write = 5 * time.Minute
}
if expirations.Read < time.Second || expirations.Write < time.Second {
return nil, ErrorExpirationTooLow
}
return &Signer{
expirations: expirations,
}, nil
}
func NewSignerFromEnv() (*Signer, error) {
expirationRead, _ := strconv.Atoi(os.Getenv("EXPIRATION_READ"))
expirationWrite, _ := strconv.Atoi(os.Getenv("EXPIRATION_WRITE"))
expirations := Expirations{Read: time.Duration(expirationRead), Write: time.Duration(expirationWrite)}
return NewSigner(expirations)
}
func (s *Signer) PreSignRead(req *http.Request, cred credentials.Credential) *http.Request {
signedReq := signer.PreSignV4(
*req,
cred.AccessKey, cred.SecretKey, "",
cred.Region,
int64(s.expirations.Read/time.Second),
)
return signedReq
}
func (s *Signer) PreSignWrite(req *http.Request, cred credentials.Credential) *http.Request {
signedReq := signer.PreSignV4(
*req,
cred.AccessKey, cred.SecretKey, "",
cred.Region,
int64(s.expirations.Write/time.Second),
)
return signedReq
}
func (s *Signer) Sign(req *http.Request, cred credentials.Credential) *http.Request {
signedReq := signer.SignV4(
*req,
cred.AccessKey, cred.SecretKey, "",
cred.Region,
)
return signedReq
}

5
test.ambrose.photos Normal file
View File

@ -0,0 +1,5 @@
{
"access_key": "azCdtns6MMayxqEWu0UrdDZp",
"secret_key": "W3sJ65RPgr3VzrOeH03nFGvUI5T8ZpESY5OUnvJz6c8Uv3SaIZOA22MboAf4NbEz",
"region": "sgp1"
}

View File

@ -1,7 +1,12 @@
<script>
export let photo;
async function
async function ar() {
}
async function preview() {
}
</script>
<div class="gallery-item preloaded-thumbnail" data-ar="{ar(photo)}">