1
0
Fork 0

Compare commits

...

4 Commits

Author SHA1 Message Date
Ambrose Chua 32d5b985b4 Add Tilt 2021-11-17 18:37:38 +08:00
Ambrose Chua aeddf1c9d6 Add uploader 2021-11-17 11:06:37 +08:00
Ambrose Chua ff4646598f Switch gallery layout engine into a single function 2020-10-11 22:22:29 +08:00
Ambrose Chua 0e54187b15 Get web interface into working state 2020-08-23 00:21:56 +08:00
47 changed files with 981 additions and 1258 deletions

11
.dockerignore Normal file
View File

@ -0,0 +1,11 @@
cmd/admin/admin
cmd/control/control
cmd/web/web
cmd/preview/preview
cmd/proxy/proxy
# dist is required for Dockerfile.local
env
tilt_modules
build
deployments

3
.gitignore vendored
View File

@ -3,5 +3,6 @@ cmd/control/control
cmd/web/web
cmd/preview/preview
cmd/proxy/proxy
dist
env
tilt_modules

104
Tiltfile Normal file
View File

@ -0,0 +1,104 @@
load('ext://restart_process', 'docker_build_with_restart')
local_resource(
'control-go-compile',
'CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o dist/control ./cmd/control',
deps=['cmd/control', 'pkg', 'internal'],
allow_parallel=True,
)
docker_build_with_restart(
'registry.makerforce.io/photos/control',
'.',
entrypoint=['/control'],
dockerfile='build/control/Dockerfile.local',
only=['dist/control'],
live_update=[
sync('dist/control', '/control'),
],
)
docker_build_with_restart(
'registry.makerforce.io/photos/preview',
'.',
entrypoint=['/preview'],
dockerfile='build/preview/Dockerfile',
target='build',
only=['cmd/preview', 'pkg', 'internal', 'go.mod', 'go.sum'],
live_update=[
sync('cmd/preview', '/src/cmd/preview'),
sync('pkg', '/src/pkg'),
sync('internal', '/src/internal'),
run('go build -o /preview'),
],
)
local_resource(
'proxy-go-compile',
'CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o dist/proxy ./cmd/proxy',
deps=['cmd/proxy'],
allow_parallel=True,
)
docker_build_with_restart(
'registry.makerforce.io/photos/proxy',
'.',
entrypoint=['/proxy'],
dockerfile='build/proxy/Dockerfile.local',
only=['dist/proxy'],
live_update=[
sync('dist/proxy', '/proxy'),
],
)
local_resource(
'web-go-compile',
'CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o dist/web ./cmd/web',
deps=['cmd/web', 'pkg', 'internal', 'web'],
allow_parallel=True,
)
docker_build_with_restart(
'registry.makerforce.io/photos/web',
'.',
entrypoint=['/web'],
dockerfile='build/web/Dockerfile.local',
only=['dist/web'],
live_update=[
sync('dist/web', '/web'),
],
)
k8s_yaml('deployments/secrets.yml')
k8s_yaml('deployments/minio-basic.yml')
k8s_resource(
'minio',
port_forwards=[
'9000:9000',
'9001:9001',
],
objects=['minio:secret', 'minio:service'],
)
k8s_yaml('deployments/services.yml')
k8s_resource(
'proxy',
port_forwards=[
'8101',
],
)
k8s_resource(
'control',
port_forwards=[
'8100',
],
)
k8s_resource(
'preview',
port_forwards=[
'8103',
],
)
k8s_resource(
'web',
port_forwards=[
'8104',
],
)

View File

@ -1,17 +1,15 @@
FROM golang:alpine AS build
RUN mkdir /src /dist
FROM golang:1.16-alpine AS build
WORKDIR /src
COPY . ./
WORKDIR /src/cmd/control/
ENV CGO_ENABLED=0
RUN go build -o /dist/control
RUN go build -o /control
FROM scratch
COPY --from=build /dist/control /control
COPY --from=build /control /control
ENTRYPOINT ["/control"]

View File

@ -0,0 +1,3 @@
FROM alpine:latest
COPY ./dist/control /control
ENTRYPOINT ["/control"]

View File

@ -1,23 +1,21 @@
FROM golang:buster AS build
FROM golang:1.16-bullseye AS build
RUN apt-get update
RUN apt-get install -y libvips-dev
RUN mkdir /src /dist
RUN apt-get update \
&& apt-get install -y libvips-dev
WORKDIR /src
COPY . ./
WORKDIR /src/cmd/preview/
RUN go build -o /dist/preview
RUN go build -o /preview
FROM debian:buster
FROM debian:bullseye
RUN apt-get update \
&& apt-get install -y libvips42 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /dist/preview /preview
COPY --from=build /preview /preview
ENTRYPOINT ["/preview"]

View File

@ -1,17 +1,15 @@
FROM golang:alpine AS build
RUN mkdir /src /dist
FROM golang:1.16-alpine AS build
WORKDIR /src
COPY . ./
WORKDIR /src/cmd/proxy/
ENV CGO_ENABLED=0
RUN go build -o /dist/proxy
RUN go build -o /proxy
FROM scratch
COPY --from=build /dist/proxy /proxy
COPY --from=build /proxy /proxy
ENTRYPOINT ["/proxy"]

View File

@ -0,0 +1,3 @@
FROM alpine:latest
COPY ./dist/proxy /proxy
ENTRYPOINT ["/proxy"]

View File

@ -1,17 +1,15 @@
FROM golang:buster AS build
RUN mkdir /src /dist
FROM golang:1.16-alpine AS build
WORKDIR /src
COPY . ./
WORKDIR /src/cmd/web/
ENV CGO_ENABLED=0
RUN go build -o /dist/web
RUN go build -o /web
FROM debian:buster
FROM scratch
COPY --from=build /dist/web /web
COPY --from=build /web /web
ENTRYPOINT ["/web"]

View File

@ -0,0 +1,3 @@
FROM alpine:latest
COPY ./dist/web /web
ENTRYPOINT ["/web"]

View File

@ -27,6 +27,17 @@ func main() {
panic(err)
}
transport := &http.Transport{
MaxIdleConns: 4,
MaxIdleConnsPerHost: 4,
IdleConnTimeout: 60 * time.Second,
DisableCompression: true,
}
http.DefaultClient = &http.Client{
Transport: transport,
Timeout: 60 * time.Second,
}
server := &http.Server{
Addr: ":8000",
ReadTimeout: 5 * time.Second,
@ -44,7 +55,7 @@ func sign(w http.ResponseWriter, req *http.Request) {
bucket := lib.Bucket(req.FormValue("bucket"))
token := req.FormValue("token")
request := SafePathable(req.FormValue("request"))
resource := SafePathable(req.FormValue("resource"))
err = bucket.Validate()
if err != nil {
@ -52,7 +63,7 @@ func sign(w http.ResponseWriter, req *http.Request) {
httphelpers.ErrorResponse(w, err)
return
}
err = request.Validate()
err = resource.Validate()
if err != nil {
err := fmt.Errorf("%w: %v", httphelpers.ErrorBadRequest, err.Error())
httphelpers.ErrorResponse(w, err)
@ -70,7 +81,7 @@ func sign(w http.ResponseWriter, req *http.Request) {
}
var signedReq *http.Request
unsignedReq, err := http.NewRequest(req.Method, bucket.URL(request).String(), nil)
unsignedReq, err := http.NewRequest(req.Method, bucket.URL(resource).String(), nil)
if err != nil {
err := fmt.Errorf("%w: error creating request: %v", httphelpers.ErrorBadRequest, err.Error())
httphelpers.ErrorResponse(w, err)
@ -80,13 +91,14 @@ func sign(w http.ResponseWriter, req *http.Request) {
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)
signedReq = sig.PreSignWrite(unsignedReq, cred)
} else {
err := fmt.Errorf("%w: %v", httphelpers.ErrorMethodNotAllowed, req.Method)
httphelpers.ErrorResponse(w, err)
return
}
w.Header().Add("Access-Control-Allow-Origin", "*")
w.Header().Add("Location", signedReq.URL.String())
w.WriteHeader(http.StatusTemporaryRedirect)
}

View File

@ -17,11 +17,11 @@ func (s SafePathable) Validate() error {
if strings.HasPrefix(string(s), "internal/") {
return ErrorInvalidRequest
}
return nil
_, err := url.Parse(string(s))
return err
}
func (s SafePathable) Path() *url.URL {
return &url.URL{
Path: string(s),
}
u, _ := url.Parse(string(s))
return u
}

View File

@ -38,10 +38,6 @@ func main() {
panic(err)
}
// Setup vips
vips.Startup(nil)
defer vips.Shutdown()
transport := &http.Transport{
MaxIdleConns: 4,
MaxIdleConnsPerHost: 4,
@ -53,6 +49,10 @@ func main() {
Timeout: 60 * time.Second,
}
// Setup vips
vips.Startup(nil)
defer vips.Shutdown()
server := &http.Server{
Addr: ":8003",
ReadTimeout: 5 * time.Second,

View File

@ -45,6 +45,7 @@ func putPhotoMetadataSize(bucket lib.Bucket, cred credentials.Credential, photo
if err != nil {
return err
}
unsignedReq.Header.Set("Content-Type", "application/json")
signedReq := sig.Sign(unsignedReq, cred)
resp, err := http.DefaultClient.Do(signedReq)
@ -61,6 +62,7 @@ func putPreview(bucket lib.Bucket, cred credentials.Credential, preview lib.Prev
if err != nil {
return err
}
unsignedReq.Header.Set("Content-Type", string(preview.Format()))
signedReq := sig.Sign(unsignedReq, cred)
resp, err := http.DefaultClient.Do(signedReq)

View File

@ -1,9 +0,0 @@
# Creating embedded data
```bash
go get -u github.com/go-bindata/go-bindata/v3/...
go generate
```
<!-- vim: set conceallevel=2 et ts=2 sw=2: -->

File diff suppressed because one or more lines are too long

147
cmd/web/req.go Normal file
View File

@ -0,0 +1,147 @@
package main
import (
"bytes"
"errors"
"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 getPhotos(bucket lib.Bucket, cred credentials.Credential) ([]lib.Photo, error) {
unsignedReq, err := http.NewRequest(http.MethodGet, bucket.ListURL(10000, "").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 lib.BucketListUnmarshal(buf)
}
func getPhotoMetadataSize(bucket lib.Bucket, cred credentials.Credential, photo lib.Photo) (lib.PhotoMetadataSize, error) {
buf, err := getPhotoMetadata(bucket, cred, photo.MetadataSize())
if err != nil {
return lib.PhotoMetadataSize{}, err
}
return lib.PhotoMetadataSizeUnmarshal(buf)
}
func getPhotoMetadataTitle(bucket lib.Bucket, cred credentials.Credential, photo lib.Photo) (lib.PhotoMetadataTitle, error) {
buf, err := getPhotoMetadata(bucket, cred, photo.MetadataTitle())
if errors.Is(err, httphelpers.ErrorNotFound) {
return lib.PhotoMetadataTitle(""), nil
}
if err != nil {
return "", err
}
return lib.PhotoMetadataTitle(buf), nil
}
func getPhotoMetadataTags(bucket lib.Bucket, cred credentials.Credential, photo lib.Photo) (lib.PhotoMetadataTags, error) {
buf, err := getPhotoMetadata(bucket, cred, photo.MetadataTags())
if errors.Is(err, httphelpers.ErrorNotFound) {
return lib.PhotoMetadataTags([]string{}), nil
}
if err != nil {
return nil, err
}
return lib.PhotoMetadataTagsUnmarshal(buf)
}
func getPhotoMetadata(bucket lib.Bucket, cred credentials.Credential, photoMetadata lib.PhotoMetadata) ([]byte, error) {
unsignedReq, err := http.NewRequest(http.MethodGet, bucket.URL(photoMetadata).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)
}
return ioutil.ReadAll(resp.Body)
}
func getBucketMetadataTitle(bucket lib.Bucket, cred credentials.Credential) (lib.BucketMetadataTitle, error) {
buf, err := getBucketMetadata(bucket, cred, bucket.MetadataTitle())
return lib.BucketMetadataTitle(buf), err
}
func getBucketMetadata(bucket lib.Bucket, cred credentials.Credential, bucketMetadata lib.BucketMetadata) ([]byte, error) {
unsignedReq, err := http.NewRequest(http.MethodGet, bucket.URL(bucketMetadata).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)
}
return ioutil.ReadAll(resp.Body)
}
func putBucketView(bucket lib.Bucket, cred credentials.Credential, buf []byte) error {
unsignedReq, err := http.NewRequest(http.MethodPut, bucket.ViewURL().String(), bytes.NewBuffer(buf))
if err != nil {
return err
}
unsignedReq.Header.Set("Content-Type", "text/html")
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)
}
func putBucketManage(bucket lib.Bucket, cred credentials.Credential, buf []byte) error {
unsignedReq, err := http.NewRequest(http.MethodPut, bucket.ManageURL().String(), bytes.NewBuffer(buf))
if err != nil {
return err
}
unsignedReq.Header.Set("Content-Type", "text/html")
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)
}

View File

@ -5,31 +5,41 @@ import (
"strings"
lib "git.makerforce.io/photos/photos/pkg/bucket"
"git.makerforce.io/photos/photos/web"
)
var funcs = template.FuncMap{
"ar": func(p Photo) float64 { return float64(p.Width) / float64(p.Height) },
"mul": func(a, b float64) float64 { return a * b },
"photo": photo,
"preview": preview,
"ar": func(p Photo) float64 { return float64(p.Size.Width) / float64(p.Size.Height) },
"mul": func(a, b float64) float64 { return a * b },
"bucketmetadatatitle": func(t lib.BucketMetadataTitle) string { return strings.TrimSpace(string(t)) },
"photometadatatitle": func(t lib.PhotoMetadataTitle) string { return strings.TrimSpace(string(t)) },
"photo": photo,
"preview": preview,
}
type IndexTemplateData struct {
Assets string
Bucket lib.Bucket
Metadata lib.BucketMetadata
Photos []Photo
Assets string
Control string
Bucket lib.Bucket
Title lib.BucketMetadataTitle
Photos []Photo
Development bool
}
type Photo struct {
lib.Photo
lib.PhotoMetadata
Size lib.PhotoMetadataSize
Title lib.PhotoMetadataTitle
Tags lib.PhotoMetadataTags
}
var indexTemplate = mustTemplateAsset("index")
var viewTemplate = mustTemplateAsset("view/index.tmpl")
var manageTemplate = mustTemplateAsset("manage/index.tmpl")
func mustTemplateAsset(name string) *template.Template {
buf, err := Asset("view/" + name + ".tmpl")
buf, err := web.FS.ReadFile(name)
if err != nil {
panic(err)
}
@ -39,7 +49,7 @@ func mustTemplateAsset(name string) *template.Template {
}
func photo(p Photo) template.URL {
return template.URL(p.Path())
return template.URL(p.Path().String())
}
func preview(p Photo, height int, format lib.PhotoFormat, quality int) template.Srcset {
@ -48,6 +58,6 @@ func preview(p Photo, height int, format lib.PhotoFormat, quality int) template.
Format: format,
Quality: quality,
})
path := strings.ReplaceAll(preview.Path(), ",", "%2C")
path := strings.ReplaceAll(preview.Path().String(), ",", "%2C")
return template.Srcset(path)
}

View File

@ -1,7 +1,5 @@
package main
//go:generate go-bindata -fs -prefix "../../web/" "../../web/..."
import (
"bytes"
"fmt"
@ -11,25 +9,48 @@ import (
"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"
"git.makerforce.io/photos/photos/web"
)
var client *lib.Client
var creds *credentials.Client
var sig *signer.Signer
var sharedFileServer http.Handler
var endpoint string
var controlEndpoint string
func main() {
// Read configuration
endpoint = os.Getenv("WEB_ENDPOINT")
// 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)
}
sharedFileServer = http.FileServer(AssetFile())
transport := &http.Transport{
MaxIdleConns: 4,
MaxIdleConnsPerHost: 4,
IdleConnTimeout: 60 * time.Second,
DisableCompression: true,
}
http.DefaultClient = &http.Client{
Transport: transport,
Timeout: 60 * time.Second,
}
// Read configuration
endpoint = os.Getenv("WEB_ENDPOINT")
controlEndpoint = os.Getenv("CONTROL_ENDPOINT")
sharedFileServer = http.FileServer(http.FS(web.FS))
server := &http.Server{
Addr: ":8004",
@ -38,6 +59,7 @@ func main() {
}
http.HandleFunc("/shared/", shared)
http.HandleFunc("/update", update)
err = server.ListenAndServe()
if err != nil {
panic(err)
@ -49,10 +71,11 @@ func shared(w http.ResponseWriter, req *http.Request) {
w.Header().Add("Access-Control-Allow-Origin", "*")
sharedFileServer.ServeHTTP(w, req)
return
}
func update(w http.ResponseWriter, req *http.Request) {
var err error
if req.Method != http.MethodPost {
err := fmt.Errorf("%w: %v", httphelpers.ErrorMethodNotAllowed, req.Method)
httphelpers.ErrorResponse(w, err)
@ -60,43 +83,91 @@ func update(w http.ResponseWriter, req *http.Request) {
}
bucket := lib.Bucket(req.FormValue("bucket"))
/*
bucketMetadata, err := client.GetBucketMetadata(bucket)
previewOptions := bucketMetadata.PreviewOptions
*/
metadata, err := client.GetBucketMetadata(bucket)
photos, err := client.GetPhotos(bucket)
err = bucket.Validate()
if err != nil {
err := fmt.Errorf("%w: %v", httphelpers.ErrorBadRequest, err.Error())
httphelpers.ErrorResponse(w, err)
return
}
var cred credentials.Credential
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
}
title, err := getBucketMetadataTitle(bucket, cred)
if err != nil {
/*
err := fmt.Errorf("bucket title: %w", err)
httphelpers.ErrorResponse(w, err)
return
*/
title = ""
}
photos, err := getPhotos(bucket, cred)
if err != nil {
err := fmt.Errorf("cannot list bucket: %w", err)
httphelpers.ErrorResponse(w, err)
return
}
detailedPhotos := make([]Photo, 0)
for _, photo := range photos {
photoMetadata, err := client.GetPhotoMetadata(bucket, photo)
size, err := getPhotoMetadataSize(bucket, cred, photo)
if err != nil {
err := fmt.Errorf("%w: %v", err, photo)
err := fmt.Errorf("photo size: %w: %v", err, photo)
httphelpers.ErrorResponse(w, err)
return
}
detailedPhotos = append(detailedPhotos, Photo{photo, photoMetadata})
title, err := getPhotoMetadataTitle(bucket, cred, photo)
if err != nil {
err := fmt.Errorf("photo title: %w: %v", err, photo)
httphelpers.ErrorResponse(w, err)
return
}
tags, err := getPhotoMetadataTags(bucket, cred, photo)
if err != nil {
err := fmt.Errorf("photo tags: %w: %v", err, photo)
httphelpers.ErrorResponse(w, err)
return
}
detailedPhotos = append(detailedPhotos, Photo{photo, size, title, tags})
}
data := IndexTemplateData{
Assets: endpoint + "/shared/",
Bucket: bucket,
Metadata: metadata,
Photos: detailedPhotos,
Assets: endpoint + "/shared/",
Control: controlEndpoint,
Bucket: bucket,
Title: title,
Photos: detailedPhotos,
}
indexBuf := new(bytes.Buffer)
err = indexTemplate.Execute(indexBuf, data)
buf := new(bytes.Buffer)
err = viewTemplate.Execute(buf, data)
if err != nil {
httphelpers.ErrorResponse(w, err)
return
}
err = client.PutBucketWeb(bucket, indexBuf.Bytes())
err = putBucketView(bucket, cred, buf.Bytes())
if err != nil {
httphelpers.ErrorResponse(w, err)
return
}
buf.Reset()
err = manageTemplate.Execute(buf, data)
if err != nil {
httphelpers.ErrorResponse(w, err)
return
}
err = putBucketManage(bucket, cred, buf.Bytes())
if err != nil {
httphelpers.ErrorResponse(w, err)
return

View File

@ -0,0 +1,40 @@
apiVersion: v1
kind: Pod
metadata:
name: minio
labels:
app: minio
spec:
containers:
- name: minio
image: minio/minio:RELEASE.2021-11-09T03-21-45Z
command: ["minio", "server", "/data", "--console-address", ":9001"]
env:
- name: MINIO_ROOT_USER
valueFrom:
secretKeyRef:
key: accesskey
name: minio
- name: MINIO_ROOT_PASSWORD
valueFrom:
secretKeyRef:
key: secretkey
name: minio
ports:
- containerPort: 9000
- containerPort: 9001
---
apiVersion: v1
kind: Service
metadata:
name: minio
spec:
selector:
app: minio
ports:
- name: api
protocol: TCP
port: 9000
- name: console
protocol: TCP
port: 9001

9
deployments/secrets.yml Normal file
View File

@ -0,0 +1,9 @@
apiVersion: v1
kind: Secret
metadata:
name: minio
labels:
app: minio
data:
accesskey: bWluaW9hZG1pbg==
secretkey: bWluaW9hZG1pbg==

View File

@ -1,18 +1,18 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: photos-proxy
name: proxy
labels:
app: photos-proxy
app: proxy
spec:
replicas: 1
selector:
matchLabels:
app: photos-proxy
app: proxy
template:
metadata:
labels:
app: photos-proxy
app: proxy
spec:
containers:
- name: proxy
@ -20,7 +20,7 @@ spec:
imagePullPolicy: Always
env:
- name: MINIO_ENDPOINT
value: api.ambrose.photos:9000
value: minio:9000
- name: MINIO_DOMAIN
value: ""
- name: MINIO_ENDPOINT_SECURE
@ -29,35 +29,33 @@ spec:
value: "false"
ports:
- containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
name: photos-proxy
name: proxy
spec:
selector:
app: photos-proxy
app: proxy
ports:
- protocol: TCP
port: 80
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: photos-control
name: control
labels:
app: photos-control
app: control
spec:
replicas: 1
replicas: 2
selector:
matchLabels:
app: photos-control
app: control
template:
metadata:
labels:
app: photos-control
app: control
spec:
containers:
- name: control
@ -65,7 +63,7 @@ spec:
imagePullPolicy: Always
env:
- name: MINIO_ENDPOINT
value: api.ambrose.photos:9000
value: minio:9000
- name: MINIO_ACCESS_KEY
valueFrom:
secretKeyRef:
@ -82,38 +80,40 @@ spec:
value: "false"
- name: MINIO_CREDENTIALS_BUCKET
value: "credentials"
- name: EXPIRATION_READ
value: "5m"
- name: EXPIRATION_WRITE
value: "1m"
ports:
- containerPort: 8000
---
apiVersion: v1
kind: Service
metadata:
name: photos-control
name: control
spec:
selector:
app: photos-control
app: control
ports:
- protocol: TCP
port: 80
targetPort: 8000
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: photos-preview
name: preview
labels:
app: photos-preview
app: preview
spec:
replicas: 1
selector:
matchLabels:
app: photos-preview
app: preview
template:
metadata:
labels:
app: photos-preview
app: preview
spec:
containers:
- name: preview
@ -121,7 +121,7 @@ spec:
imagePullPolicy: Always
env:
- name: MINIO_ENDPOINT
value: api.ambrose.photos:9000
value: minio:9000
- name: MINIO_ACCESS_KEY
valueFrom:
secretKeyRef:
@ -138,38 +138,40 @@ spec:
value: "false"
- name: MINIO_CREDENTIALS_BUCKET
value: "credentials"
- name: EXPIRATION_READ
value: "5m"
- name: EXPIRATION_WRITE
value: "1m"
ports:
- containerPort: 8003
---
apiVersion: v1
kind: Service
metadata:
name: photos-preview
name: preview
spec:
selector:
app: photos-preview
app: preview
ports:
- protocol: TCP
port: 80
targetPort: 8003
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: photos-web
name: web
labels:
app: photos-web
app: web
spec:
replicas: 1
selector:
matchLabels:
app: photos-web
app: web
template:
metadata:
labels:
app: photos-web
app: web
spec:
containers:
- name: web
@ -177,7 +179,7 @@ spec:
imagePullPolicy: Always
env:
- name: MINIO_ENDPOINT
value: api.ambrose.photos:9000
value: minio:9000
- name: MINIO_ACCESS_KEY
valueFrom:
secretKeyRef:
@ -195,18 +197,21 @@ spec:
- name: MINIO_CREDENTIALS_BUCKET
value: "credentials"
- name: WEB_ENDPOINT
value: "http://web.ambrose.photos"
value: "http://localhost:8104"
- name: EXPIRATION_READ
value: "5m"
- name: EXPIRATION_WRITE
value: "1m"
ports:
- containerPort: 8004
---
apiVersion: v1
kind: Service
metadata:
name: photos-web
name: web
spec:
selector:
app: photos-web
app: web
ports:
- protocol: TCP
port: 80

8
go.mod
View File

@ -1,16 +1,18 @@
module git.makerforce.io/photos/photos
go 1.14
go 1.16
require (
github.com/davidbyttow/govips v0.0.0-20200412130214-cbefdd8c639a
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.1.2+incompatible // indirect
github.com/go-bindata/go-bindata/v3 v3.1.3 // indirect
github.com/go-ini/ini v1.57.0 // indirect
github.com/kisielk/errcheck v1.4.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
golang.org/x/tools v0.0.0-20200821200730-1e23e48ab93b // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
)

19
go.sum
View File

@ -8,6 +8,8 @@ github.com/elazarl/go-bindata-assetfs v1.0.0 h1:G/bYguwHIzWq9ZoyUQqrjTmJbbYn3j3C
github.com/elazarl/go-bindata-assetfs v1.0.0/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4=
github.com/go-bindata/go-bindata v1.0.0 h1:DZ34txDXWn1DyWa+vQf7V9ANc2ILTtrEjtlsdJRF26M=
github.com/go-bindata/go-bindata v1.0.0/go.mod h1:xK8Dsgwmeed+BBsSy2XTopBn/8uK2HWuGSnA11C3Joo=
github.com/go-bindata/go-bindata v3.1.2+incompatible h1:5vjJMVhowQdPzjE1LdxyFF7YFTXg5IgGVW4gBr5IbvE=
github.com/go-bindata/go-bindata v3.1.2+incompatible/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=
@ -21,6 +23,8 @@ github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kisielk/errcheck v1.2.0 h1:reN85Pxc5larApoH1keMBiu2GWtPqXQ1nc9gx+jOU+E=
github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00=
github.com/kisielk/errcheck v1.4.0 h1:ueN6QYA+c7eDQo7ebpNdYR8mUJZThiGz9PEoJEMGPzA=
github.com/kisielk/errcheck v1.4.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
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=
@ -49,17 +53,22 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190513172903-22d7a77e9e5f h1:R423Cnkcp5JABoeemiGEPlt9tHXFfw5kvc0yqlxRPWo=
golang.org/x/crypto v0.0.0-20190513172903-22d7a77e9e5f/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f h1:J5lckAjkw6qYlOZNj90mLYNTEKDvWeuc1yieZ8qUzUE=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b h1:Wh+f8QHJXR411sJR8/vRBTZ7YapZaRvUcLFFJhusH0k=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190522155817-f3200d17e092 h1:4QSRKanuywn15aTZvI/mIDEgPQpswuFndXpOj3rKEco=
@ -69,15 +78,20 @@ golang.org/x/net v0.0.0-20190923162816-aa69164e4478 h1:l5EDrHhldLYb3ZRHDUhXF7Om7
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b h1:0mm1VjtFUOIlE1SbDlwjYaDxZVDP2S5ou6y0gSgXHu8=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200625001655-4c5254603344 h1:vGXIOMxbNfDTk/aXCmfdLgkrSV+Z2tcbze+pEc3v5W4=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e h1:vcxGaoTs7kV8m5Np9uUNQin4BrLOthgV7252N8V+FwY=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe h1:6fAMxZRR6sl1Uq8U61gxU+kPTs2tR8uOySCbBP7BN/M=
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@ -89,9 +103,14 @@ golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapK
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200530233709-52effbd89c51 h1:Wec8/IO8hAraBf0it7/dPQYOslIrgM938wZYNkLnOYc=
golang.org/x/tools v0.0.0-20200530233709-52effbd89c51/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200821200730-1e23e48ab93b h1:ob2Rprc4uPVPGKaYKm9lrGewYQJRu7KtuzGTICCM1X4=
golang.org/x/tools v0.0.0-20200821200730-1e23e48ab93b/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/ini.v1 v1.42.0 h1:7N3gPTt50s8GuLortA00n8AqRTk75qOP98+mTPpgzRk=
gopkg.in/ini.v1 v1.42.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=

View File

@ -11,6 +11,7 @@ import (
var ErrorBadRequest = errors.New("bad request")
var ErrorMethodNotAllowed = errors.New("method not allowed")
var ErrorNotFound = errors.New("not found")
var ErrorUnauthorized = errors.New("unauthorized")
func ErrorResponse(w http.ResponseWriter, err error) {
if err == nil {
@ -49,5 +50,14 @@ func ErrorFromStatus(code int) error {
if code == http.StatusOK {
return nil
}
if code == http.StatusNotFound {
return ErrorNotFound
}
if code == http.StatusBadRequest {
return ErrorBadRequest
}
if code == http.StatusUnauthorized {
return ErrorUnauthorized
}
return fmt.Errorf("%s", http.StatusText(code))
}

View File

@ -40,6 +40,11 @@ func (b Bucket) URL(p Pathable) *url.URL {
return u
}
func (b Bucket) ListURL(maxKeys int, startAfter string) *url.URL {
list := newList(photoPrefix, maxKeys, startAfter)
return b.URL(list)
}
func pathJoin(paths ...string) string {
cleanedPaths := make([]string, 0, len(paths))
for i, path := range paths {
@ -84,3 +89,18 @@ func (b Bucket) MetadataDescription() BucketMetadata {
func (b Bucket) MetadataOrdering() BucketMetadata {
return b.Metadata("ordering")
}
type index string
func (i index) Path() *url.URL {
return &url.URL{
Path: "/" + string(i) + "index.html",
}
}
func (b Bucket) ViewURL() *url.URL {
return b.URL(index(""))
}
func (b Bucket) ManageURL() *url.URL {
return b.URL(index("manage/"))
}

View File

@ -1,27 +1,29 @@
package bucket
import (
"encoding/xml"
"fmt"
"net/url"
"github.com/minio/minio-go/v6"
"github.com/minio/minio-go/v6/pkg/s3utils"
)
type List struct {
type list struct {
prefix string
maxKeys int
startAfter string
}
func NewList(prefix string, maxKeys int, startAfter string) List {
return List{
func newList(prefix string, maxKeys int, startAfter string) list {
return list{
prefix: prefix,
maxKeys: maxKeys,
startAfter: startAfter,
}
}
func (l List) Path() *url.URL {
func (l list) Path() *url.URL {
params := make(url.Values)
params.Set("list-type", "2")
params.Set("metadata", "true")
@ -35,3 +37,16 @@ func (l List) Path() *url.URL {
RawQuery: s3utils.QueryEncode(params),
}
}
func BucketListUnmarshal(buf []byte) ([]Photo, error) {
list := minio.ListBucketV2Result{}
err := xml.Unmarshal(buf, &list)
if err != nil {
return nil, err
}
photos := make([]Photo, 0, len(list.Contents))
for _, o := range list.Contents {
photos = append(photos, Photo(o.Key))
}
return photos, nil
}

22
pkg/bucket/list_test.go Normal file
View File

@ -0,0 +1,22 @@
package bucket
import (
"testing"
)
func TestListUnmarshal(t *testing.T) {
xml := []byte(`
<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Name>screenshot</Name><Prefix></Prefix><Marker></Marker><MaxKeys>10000</MaxKeys><Delimiter></Delimiter><IsTruncated>false</IsTruncated><Contents><Key>Screenshot_2020-08-02 [Filebeat Cisco] ASA Firewall - Elastic.png</Key><LastModified>2020-08-22T13:44:33.034Z</LastModified><ETag>&#34;94aef50ebfceadd0cefee92b14b7e73a&#34;</ETag><Size>190116</Size><Owner><ID>02d6176db174dc93cb1b899f7c6078f08654445fe8cf1b6ce98d8855f66bdbf4</ID><DisplayName></DisplayName></Owner><StorageClass>STANDARD</StorageClass></Contents><Contents><Key>grim.png</Key><LastModified>2020-08-20T06:37:30.595Z</LastModified><ETag>&#34;7078589d69baa9434ab28729449ed7a9&#34;</ETag><Size>5166601</Size><Owner><ID>02d6176db174dc93cb1b899f7c6078f08654445fe8cf1b6ce98d8855f66bdbf4</ID><DisplayName></DisplayName></Owner><StorageClass>STANDARD</StorageClass></Contents></ListBucketResult>
`)
l, err := ListUnmarshal(xml)
if err != nil {
t.Errorf("%v", err)
}
if string(l[0]) != "Screenshot_2020-08-02 [Filebeat Cisco] ASA Firewall - Elastic.png" {
t.Errorf("Wrong filename")
}
if string(l[1]) != "grim.png" {
t.Errorf("Wrong filename")
}
}

View File

@ -1 +1,3 @@
package bucket
type BucketMetadataTitle string

View File

@ -2,6 +2,7 @@ package bucket
import (
"encoding/json"
"strings"
)
type PhotoMetadataSize struct {
@ -14,7 +15,15 @@ func PhotoMetadataSizeUnmarshal(buf []byte) (PhotoMetadataSize, error) {
err := json.Unmarshal(buf, &s)
return s, err
}
func PhotoMetadataSizeMarshal(s PhotoMetadataSize) ([]byte, error) {
return json.MarshalIndent(s, "", "\t")
}
type PhotoMetadataTitle string
type PhotoMetadataTags []string
func PhotoMetadataTagsUnmarshal(buf []byte) (PhotoMetadataTags, error) {
tags := string(buf)
return PhotoMetadataTags(strings.Split(tags, ",")), nil
}

View File

@ -4,7 +4,6 @@ import (
"errors"
"net/http"
"os"
"strconv"
"time"
"git.makerforce.io/photos/photos/pkg/credentials"
@ -40,9 +39,15 @@ func NewSigner(expirations Expirations) (*Signer, error) {
}
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)}
expirationRead, err := time.ParseDuration(os.Getenv("EXPIRATION_READ"))
if err != nil {
panic(err)
}
expirationWrite, err := time.ParseDuration(os.Getenv("EXPIRATION_WRITE"))
if err != nil {
panic(err)
}
expirations := Expirations{Read: expirationRead, Write: expirationWrite}
return NewSigner(expirations)
}

6
web/assets.go Normal file
View File

@ -0,0 +1,6 @@
package web
import "embed"
//go:embed manage shared view
var FS embed.FS

View File

@ -42,7 +42,8 @@ container: .containertest .container
touch $@
DEV_BUCKET ?= http://api.ambrose.photos:9000/test.ambrose.photos
DEV_BUCKET ?= https://bad-boy-bucket.makerforce.io/test1/
CONTROL_ENDPOINT ?= http://localhost:8000
.PHONY: dev
# Start a development server
dev: $(SOURCE)/* $(OUTPUT)/index.html $(shared_copy_files) | container
@ -52,7 +53,7 @@ dev: $(SOURCE)/* $(OUTPUT)/index.html $(shared_copy_files) | container
$(OUTPUT)/index.html: index.tmpl indextmpl.go
mkdir -p $(@D)
$(GO) run indextmpl.go -t $< -o $@ -b $(DEV_BUCKET) -a $(subst $(OUTPUT)/,,$(SHARED_COPY))/ -mt "[dev] Manage"
$(GO) run indextmpl.go -t $< -o $@ -a $(subst $(OUTPUT)/,,$(SHARED_COPY))/ -c $(CONTROL_ENDPOINT) -b $(DEV_BUCKET) -title "[dev] Manage"
.SECONDEXPANSION:
$(shared_copy_files): $$(subst $$(SHARED_COPY)/,$$(SHARED)/,$$@)

View File

@ -3,12 +3,12 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ .Metadata.Title }}</title>
<title>{{ .Title }}</title>
<link rel="stylesheet" href="{{ .Assets }}css/base.css">
<link rel="stylesheet" href="{{if .Development}}{{else}}{{ .Assets }}css/{{end}}manage.css">
<!-- Svelte! -->
<script src="{{if .Development}}{{else}}{{ .Assets }}js/{{end}}manage.js" defer async></script>
</head>
<body data-bucket="{{ .Bucket }}">
<body data-bucket="{{ .Bucket }}" data-control="{{ .Control }}">
</body>
</html>

View File

@ -10,13 +10,12 @@ import (
type IndexData struct {
Assets string
Bucket string
Development bool
Metadata Metadata
}
Control string
type Metadata struct {
Bucket string
Title string
Development bool
}
var data = IndexData{
@ -28,9 +27,10 @@ var out string
func main() {
flag.StringVar(&tmpl, "t", "", "template file")
flag.StringVar(&out, "o", "", "output file")
flag.StringVar(&data.Bucket, "b", "", ".Bucket")
flag.StringVar(&data.Assets, "a", "", ".Assets")
flag.StringVar(&data.Metadata.Title, "mt", "", ".Metadata.Title")
flag.StringVar(&data.Control, "c", "", ".Control")
flag.StringVar(&data.Bucket, "b", "", ".Bucket")
flag.StringVar(&data.Title, "title", "", ".Title")
flag.Parse()
f, err := os.Open(tmpl)

View File

@ -1,24 +1,62 @@
<script>
export let bucket;
import { fetcher, request, listPhotos } from './http.js';
import Gallery from './Gallery.svelte';
import Uploader from './Uploader.svelte';
let photos = [
"photo/Screenshot from 2020-04-23 19-27-53.png",
"photo/Screenshot_2020-04-13 Looking Glass - Hurricane Electric (AS6939).png",
];
async function getMetadataTitle() {
const resp = await fetcher(request('GET', 'metadata/title'));
return resp.text();
}
let title = getMetadataTitle();
async function getPhotos() {
const resp = await fetcher(request('GET', listPhotos(10000)));
const text = await resp.text();
const xml = new window.DOMParser().parseFromString(text, "text/xml");
const contents = xml.querySelectorAll('ListBucketResult > Contents');
const photos = [];
contents.forEach(c => {
photos.push(c.querySelector('Key').innerHTML);
});
return photos;
}
let photos = getPhotos();
</script>
<svelte:head>
{#await title then title}
<title>{title}</title>
{:catch error}
<title>Error</title>
{/await}
</svelte:head>
<div>
<nav>
<ul>
<li><a href="..">Gallery</a></li>
</ul>
</nav>
<header>
<h1>{bucket}</h1>
{#await title}
<h1 class="dim">Loading...</h1>
{:then title}
<h1>{title}</h1>
{:catch error}
<h1 class="dim" title="{error}">Error getting title</h1>
{/await}
</header>
<Gallery {photos} />
<Uploader />
{#await photos then photos}
<Gallery {photos} />
{:catch error}
<main>Unable to load photos: {error}</main>
{/await}
</div>
<!-- vim: set ft=html: -->

View File

@ -1,12 +1,21 @@
<script>
import { onMount, afterUpdate } from 'svelte';
import Gallery from '../build/shared/js/gallery.js';
import Photo from './Photo.svelte';
export let photos;
import Photo from './Photo.svelte';
let galleryEle;
let gallery;
onMount(() => {
gallery = new Gallery(galleryEle);
});
</script>
<main>
<main class="gallery" bind:this={galleryEle}>
{#each photos as photo}
<Photo {photo} />
<Photo {photo} on:sizechange={gallery.recompute()} />
{/each}
</main>

View File

@ -1,38 +1,97 @@
<script>
import { createEventDispatcher, onMount } from 'svelte';
import { fetcher, request } from './http.js';
const dispatch = createEventDispatcher();
export let photo;
$: raw = request('GET', photo).url;
async function ar() {
let title = '';
}
async function preview() {
let size = { width: 1, height: 1 };
$: ar = size.width / size.height;
}
let tags = [];
async function getPhotoMetadataTitle() {
const objectBase = photo.replace('photo/', 'photometadata/');
const resp = await fetcher(request('GET', objectBase + '/title'));
return resp.text();
}
onMount(async () => {
try {
title = await getPhotoMetadataTitle();
} catch (e) {
// We can ignore missing titles
}
});
async function getPhotoMetadataSize() {
const objectBase = photo.replace('photo/', 'photometadata/');
const resp = await fetcher(request('GET', objectBase + '/size'));
return resp.json();
}
onMount(async () => {
try {
size = await getPhotoMetadataSize();
dispatch('sizechange');
} catch (e) {
// TODO: Emit error
}
});
async function getPhotoMetadataTags() {
const objectBase = photo.replace('photo/', 'photometadata/');
const resp = await fetcher(request('GET', objectBase + '/tags'));
const str = await resp.text();
return str.split(",");
}
onMount(async () => {
try {
tags = await getPhotoMetadataTags();
} catch (e) {
// We can ignore missing tags
}
});
function preview(height, format, quality) {
const objectBase = photo.replace('photo/', 'preview/');
const extIndex = objectBase.lastIndexOf(".");
const base = objectBase.substring(0, extIndex);
const res = base + `_h${height}q${quality}`;
const ext = extensionByType(format)
return request('GET', res + ext).url;
}
function extensionByType(format) {
return {
"image/webp": ".webp",
"image/jpeg": ".jpg",
}[format];
}
</script>
<div class="gallery-item preloaded-thumbnail" data-ar="{ar(photo)}">
<a href="{photo}" data-enlarge="{photo}">
<div class="gallery-item preloaded-thumbnail" title="{title}" data-ar="{ar}">
<picture style="display: none;">
<source srcset="{preview(photo, 320, 'image/webp', 70)} 2x, {preview(photo, 160, 'image/webp', 70)} 1x" type="image/webp" media="(max-width: 900px)">
<source srcset="{preview(photo, 640, 'image/webp', 70)} 2x, {preview(photo, 320, 'image/webp', 70)} 1x" type="image/webp">
<source srcset="{preview(photo, 320, 'image/jpeg', 70)} 2x, {preview(photo, 160, 'image/jpeg', 70)} 1x" media="(max-width: 900px)">
<source srcset="{preview(photo, 640, 'image/jpeg', 70)} 2x, {preview(photo, 320, 'image/jpeg', 70)} 1x">
<img src="{preview(photo, 320, 'image/jpeg', 70)}"
<source srcset="{preview(320, 'image/webp', 70)} 2x, {preview(160, 'image/webp', 70)} 1x" type="image/webp" media="(max-width: 900px)">
<source srcset="{preview(640, 'image/webp', 70)} 2x, {preview(320, 'image/webp', 70)} 1x" type="image/webp">
<source srcset="{preview(320, 'image/jpeg', 70)} 2x, {preview(160, 'image/jpeg', 70)} 1x" media="(max-width: 900px)">
<source srcset="{preview(640, 'image/jpeg', 70)} 2x, {preview(320, 'image/jpeg', 70)} 1x">
<img src="{preview(320, 'image/jpeg', 70)}"
alt=""
width="{ar(photo) * 320}"
width="{ar * 320}"
height="320"
loading="lazy"
data-enlarge-preload-for="{photo}">
</picture>
<picture class="preloaded-thumbnail-image">
<source srcset="{preview(photo, 30, 'image/webp', 34)} 1x" type="image/webp" media="(max-width: 900px)">
<source srcset="{preview(photo, 60, 'image/webp', 34)} 1x" type="image/webp">
<source srcset="{preview(photo, 30, 'image/jpeg', 34)} 1x" media="(max-width: 900px)">
<source srcset="{preview(photo, 60, 'image/jpeg', 34)} 1x">
<img src="{preview(photo, 30, 'image/jpeg', 34)}"
<source srcset="{preview(30, 'image/webp', 34)} 1x" type="image/webp" media="(max-width: 900px)">
<source srcset="{preview(60, 'image/webp', 34)} 1x" type="image/webp">
<source srcset="{preview(30, 'image/jpeg', 34)} 1x" media="(max-width: 900px)">
<source srcset="{preview(60, 'image/jpeg', 34)} 1x">
<img src="{preview(30, 'image/jpeg', 34)}"
alt=""
width="{ar(photo) * 320}"
width="{ar * 320}"
height="320">
</picture>
</a>
</div>
<!-- vim: set ft=html: -->

View File

@ -0,0 +1,42 @@
<script>
let isdragenter = false;
function dragenter(event) {
isdragenter = true;
}
function dragleave(event) {
isdragenter = false;
}
function dragover(event) {
// Do nothing
}
function drop(event) {
console.log(event.dataTransfer.items);
}
</script>
<div on:dragenter={dragenter} on:dragleave={dragleave} on:dragover|preventDefault={dragover} on:drop|preventDefault={drop}>
<div class="instructions" class:isdragenter>
Drop photos here
</div>
</div>
<style>
.instructions {
margin: calc(var(--spacing) * 2);
padding: 1rem;
border: 2px dashed #000;
border-radius: 0.5rem;
opacity: 0.333;
text-align: center;
font-weight: 550;
pointer-events: none;
}
.instructions.isdragenter {
opacity: 0.666;
}
</style>
<!-- vim: set ft=html: -->

54
web/manage/src/http.js Normal file
View File

@ -0,0 +1,54 @@
import { control, bucket, token } from './stores.js';
let control_value;
control.subscribe(value => {
control_value = value;
});
let bucket_value;
bucket.subscribe(value => {
bucket_value = value;
});
let token_value;
token.subscribe(value => {
token_value = value;
});
class RequestError extends Error {
constructor(res) {
super(`Request returned status ${res.status} ${res.statusText}`);
this.res = res;
}
}
export async function fetcher(request) {
const resp = await fetch(request);
if (resp.status != 200) {
throw new RequestError(resp);
}
return resp;
}
export function request(method, resource, body=null) {
const sign = new URL(control_value + '/sign');
sign.searchParams.append('bucket', bucket_value);
sign.searchParams.append('token', token_value);
sign.searchParams.append('resource', resource);
const req = new Request(sign.href, {
method,
body,
});
return req;
}
export function listPhotos(maxKeys=1000, startAfter='') {
const prefix = "photo/";
const params = new URLSearchParams();
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", maxKeys);
params.set("start-after", startAfter);
return '?' + params.toString();
}

View File

@ -2,9 +2,6 @@ import App from './App.svelte';
const app = new App({
target: document.body,
props: {
bucket: document.body.dataset.bucket,
}
});
export default app;

6
web/manage/src/stores.js Normal file
View File

@ -0,0 +1,6 @@
import { readable, writable } from 'svelte/store';
export const control = readable(document.body.dataset.control);
export const bucket = readable(document.body.dataset.bucket);
export const token = writable('todo');

View File

@ -48,6 +48,10 @@ h4, h5, h6 {
font-size: 1.272em;
}
.dim {
opacity: 0.333;
}
/*
* Molecules
*/

View File

@ -1,3 +1,2 @@
main.svelte-1tky8bj{text-align:center;padding:1em;max-width:240px;margin:0 auto}h1.svelte-1tky8bj{color:#ff3e00;text-transform:uppercase;font-size:4em;font-weight:100}@media(min-width: 640px){main.svelte-1tky8bj{max-width:none}}
/*# sourceMappingURL=manage.css.map */

View File

@ -1,48 +1,90 @@
class LayoutEngine {
/*
rects = [];
export default class Gallery {
constructor(ele) {
this.ele = ele;
gap = 12;
baseHeight = 100;
// maxHeight = 320;
viewportWidth = 1024;
*/
constructor() {
this.rects = [];
this.gap = 12;
this.baseHeight = 100;
this.viewportWidth = 1024;
this.grabGap();
this.grabBaseHeight();
this.grabViewportWidth();
this.registerViewport();
this.draw();
}
// Register a new rectangle with an aspect ratio of ar at index
insert(ar=1, index=-1) {
const rect = {
ar: ar,
};
if (index == -1) {
this.rects.push(rect);
} else {
this.rects.splice(index, 0, rect);
// Extract gap values
grabGap() {
// Simple implementation to guess from padding values
const px = window.getComputedStyle(this.ele).getPropertyValue('padding-left').replace('px', '');
this.gap = parseInt(px) * 2 || 0;
}
// Extract baseHeight
grabBaseHeight() {
const px = window.getComputedStyle(this.ele).getPropertyValue('--gallery-base-height').replace('px', '');
this.baseHeight = parseInt(px) || 0;
}
// Extract viewport width
grabViewportWidth() {
this.viewportWidth = this.ele.clientWidth;
}
// Inform our engine of viewport width
registerViewport() {
window.addEventListener('resize', () => {
if (this.viewportWidth == window.innerWidth) {
return;
}
this.recompute();
});
window.addEventListener('load', () => {
this.recompute();
});
setInterval(this.recompute.bind(this), 500);
}
recompute() {
this.grabGap();
this.grabBaseHeight();
this.grabViewportWidth();
this.draw();
}
// Calculate dimensions and draw them
draw() {
const elements = this.ele.querySelectorAll('.gallery-item');
const rects = Array.from(elements).map(element => parseFloat(element.dataset.ar));
const dimensions = this.layout(rects);
const galleryItemEles = this.ele.querySelectorAll('.gallery-item');
for (const [index, ele] of galleryItemEles.entries()) {
const dimension = dimensions[index];
if (!dimension) {
console.error(`Missing dimensions for element at ${index}`);
return;
}
for (const imgEle of ele.querySelectorAll('img')) {
imgEle.style.height = `${Math.floor(dimension.h)}px`;
imgEle.style.width = `${dimension.w}px`;
}
}
}
// Unregister a new rectangle at index
pop(index=-1) {
// TODO
}
// Generate a list of dimensions for each rectangle
calculate() {
// Perform layout
layout(rects) {
let dimensions = [];
let currentWidth = this.gap;
let startIndex = 0;
// Behave like a browser: try to fit as many in a row with baseHeight
for (let index = 0; index < this.rects.length; index++) {
for (let index = 0; index < rects.length; index++) {
// Add this rectangle width
const currentRectWidth = (this.baseHeight * this.rects[index].ar);
const currentRectWidth = (this.baseHeight * rects[index]);
currentWidth += currentRectWidth + this.gap;
if (currentWidth > this.viewportWidth) {
@ -51,8 +93,8 @@ class LayoutEngine {
}
// Get the next width to add
const hasNextRect = index + 1 < this.rects.length;
const nextRectWidth = hasNextRect ? (this.baseHeight * this.rects[index+1].ar) : 0;
const hasNextRect = index + 1 < rects.length;
const nextRectWidth = hasNextRect ? (this.baseHeight * rects[index+1]) : 0;
const nextRectOverflow = currentWidth + nextRectWidth + this.gap > this.viewportWidth;
// If the next width is too wide, resolve the current rectangles
@ -68,7 +110,7 @@ class LayoutEngine {
for (; startIndex <= index; startIndex++) {
dimensions.push({
h: rectHeight,
w: rectHeight * this.rects[startIndex].ar,
w: rectHeight * rects[startIndex],
})
}
currentWidth = this.gap;
@ -78,104 +120,4 @@ class LayoutEngine {
return dimensions;
}
/*
* Code for a future implementation that emits events
update() {
console.debug('updating layout');
}
// Schedule an update at maximum 10Hz
scheduleUpdate() {
if (this.scheduledUpdate) {
return;
}
this.scheduledUpdate = setTimeout(() => {
this.scheduledUpdate = null;
this.update();
}, 100);
}
*/
}
export default class Gallery {
constructor(ele) {
this.ele = ele;
this.layoutEngine = new LayoutEngine();
this.grabGap();
this.grabBaseHeight();
this.grabViewportWidth();
this.registerViewport();
this.registerInitial();
this.draw();
}
// Extract gap values
grabGap() {
// Simple implementation to guess from padding values
const px = window.getComputedStyle(this.ele).getPropertyValue('padding-left').replace('px', '');
this.layoutEngine.gap = parseInt(px) * 2 || 0;
}
// Extract baseHeight
grabBaseHeight() {
const px = window.getComputedStyle(this.ele).getPropertyValue('--gallery-base-height').replace('px', '');
this.layoutEngine.baseHeight = parseInt(px) || 0;
}
// Extract viewport width
grabViewportWidth() {
this.layoutEngine.viewportWidth = this.ele.clientWidth;
}
// Inform our engine of viewport width
registerViewport() {
window.addEventListener('resize', () => {
if (this.layoutEngine.viewportWidth == window.innerWidth) {
return;
}
this.recompute();
});
window.addEventListener('load', () => {
this.recompute();
});
setInterval(this.recompute.bind(this), 500);
}
recompute() {
this.grabGap();
this.grabBaseHeight();
this.grabViewportWidth();
//this.layoutEngine.scheduleUpdate();
this.draw();
}
// Register initial elements
registerInitial() {
const galleryItemEles = this.ele.querySelectorAll('.gallery-item');
for (const ele of galleryItemEles) {
const ar = ele.dataset.ar;
this.layoutEngine.insert(ar);
}
}
// Calculate dimensions and draw them
draw() {
const dimensions = this.layoutEngine.calculate();
const galleryItemEles = this.ele.querySelectorAll('.gallery-item');
for (const [index, ele] of galleryItemEles.entries()) {
const dimension = dimensions[index];
if (!dimension) {
console.error(`Missing dimensions for element at ${index}`);
return;
}
for (const imgEle of ele.querySelectorAll('img')) {
imgEle.style.height = `${Math.floor(dimension.h)}px`;
imgEle.style.width = `${dimension.w}px`;
}
}
}
}

File diff suppressed because one or more lines are too long

View File

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ .Metadata.Title }}</title>
<title>{{ bucketmetadatatitle .Title }}</title>
<link rel="stylesheet" href="{{ .Assets }}css/base.css">
<link rel="stylesheet" href="{{ .Assets }}css/gallery.css">
<link rel="stylesheet" href="{{ .Assets }}css/enlarge.css">
@ -18,11 +18,11 @@
</ul>
</nav>
<header>
<h1>{{ .Metadata.Title }}</h1>
<h1>{{ bucketmetadatatitle .Title }}</h1>
</header>
<main class="gallery">
{{ range .Photos }}
<div class="gallery-item preloaded-thumbnail" data-ar="{{ ar . }}">
<div class="gallery-item preloaded-thumbnail" data-ar="{{ ar . }}" title="{{ photometadatatitle .Title }}" data-tags="{{ .Tags }}">
<a href="{{ photo . }}" data-enlarge="{{ photo . }}">
<picture style="display: none;">
<source srcset="{{ preview . 320 "image/webp" 70 }} 2x, {{ preview . 160 "image/webp" 70 }} 1x" type="image/webp" media="(max-width: 900px)">