Skip to content

Commit 300b6b8

Browse files
committed
feat(issue-60): support uploading content to journeys
1 parent b6133f9 commit 300b6b8

File tree

12 files changed

+722
-13
lines changed

12 files changed

+722
-13
lines changed

services/poc/app/app.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ package app
88
import (
99
"context"
1010

11+
"github.com/z5labs/journeys/services/poc/storage"
1112
"github.com/z5labs/journeys/services/poc/ui"
1213

1314
"github.com/dgraph-io/dgo/v240"
@@ -23,6 +24,14 @@ type Config struct {
2324
Dgraph struct {
2425
Address string `config:"address"`
2526
} `config:"dgraph"`
27+
28+
Minio struct {
29+
Endpoint string `config:"endpoint"`
30+
AccessKey string `config:"access_key"`
31+
SecretKey string `config:"secret_key"`
32+
Bucket string `config:"bucket"`
33+
UseSSL bool `config:"use_ssl"`
34+
} `config:"minio"`
2635
}
2736

2837
func Init(ctx context.Context, cfg Config) (*rest.Api, error) {
@@ -41,6 +50,21 @@ func Init(ctx context.Context, cfg Config) (*rest.Api, error) {
4150
return nil, err
4251
}
4352

53+
minioClient, err := storage.NewMinioClient(
54+
cfg.Minio.Endpoint,
55+
cfg.Minio.AccessKey,
56+
cfg.Minio.SecretKey,
57+
cfg.Minio.Bucket,
58+
cfg.Minio.UseSSL,
59+
)
60+
if err != nil {
61+
return nil, err
62+
}
63+
64+
if err := minioClient.EnsureBucket(ctx); err != nil {
65+
return nil, err
66+
}
67+
4468
api := rest.NewApi(
4569
cfg.OpenApi.Title,
4670
cfg.OpenApi.Version,
@@ -49,6 +73,9 @@ func Init(ctx context.Context, cfg Config) (*rest.Api, error) {
4973
ui.GetJourneyForm(dgraph),
5074
ui.CancelJourneyForm(dgraph),
5175
ui.CreateJourney(dgraph),
76+
ui.GetContentUploadForm(dgraph),
77+
ui.UploadContent(dgraph, minioClient),
78+
ui.GetContentPreview(dgraph, minioClient),
5279
)
5380

5481
return api, nil

services/poc/app/schema.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,30 @@ type Journey {
1010
journey.id: string
1111
journey.title: string
1212
journey.created_at: datetime
13+
journey.content: [uid]
14+
}
15+
16+
type Content {
17+
content.id: string
18+
content.type: string
19+
content.minio_key: string
20+
content.title: string
21+
content.description: string
22+
content.uploaded_at: datetime
23+
content.file_size: int
24+
content.mime_type: string
1325
}
1426
1527
journey.id: string @index(exact) .
1628
journey.title: string .
1729
journey.created_at: datetime .
30+
journey.content: [uid] @reverse .
31+
content.id: string @index(exact) .
32+
content.type: string @index(exact) .
33+
content.minio_key: string .
34+
content.title: string .
35+
content.description: string .
36+
content.uploaded_at: datetime .
37+
content.file_size: int .
38+
content.mime_type: string .
1839
`

services/poc/config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,4 @@ minio:
3737
access_key: {{env "MINIO_ACCESS_KEY" | default "minioadmin"}}
3838
secret_key: {{env "MINIO_SECRET_KEY" | default "minioadmin"}}
3939
bucket: {{env "MINIO_BUCKET" | default "onebrc"}}
40+
use_ssl: {{env "MINIO_USE_SSL" | default "false"}}

services/poc/storage/minio.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Copyright (c) 2025 Z5labs and Contributors
2+
//
3+
// This software is released under the MIT License.
4+
// https://opensource.org/licenses/MIT
5+
6+
package storage
7+
8+
import (
9+
"context"
10+
"io"
11+
"time"
12+
13+
"github.com/minio/minio-go/v7"
14+
"github.com/minio/minio-go/v7/pkg/credentials"
15+
)
16+
17+
type MinioClient struct {
18+
client *minio.Client
19+
bucket string
20+
}
21+
22+
func NewMinioClient(endpoint, accessKey, secretKey, bucket string, useSSL bool) (*MinioClient, error) {
23+
client, err := minio.New(endpoint, &minio.Options{
24+
Creds: credentials.NewStaticV4(accessKey, secretKey, ""),
25+
Secure: useSSL,
26+
})
27+
if err != nil {
28+
return nil, err
29+
}
30+
31+
return &MinioClient{
32+
client: client,
33+
bucket: bucket,
34+
}, nil
35+
}
36+
37+
func (m *MinioClient) EnsureBucket(ctx context.Context) error {
38+
exists, err := m.client.BucketExists(ctx, m.bucket)
39+
if err != nil {
40+
return err
41+
}
42+
43+
if !exists {
44+
err = m.client.MakeBucket(ctx, m.bucket, minio.MakeBucketOptions{})
45+
if err != nil {
46+
return err
47+
}
48+
}
49+
50+
return nil
51+
}
52+
53+
func (m *MinioClient) UploadFile(ctx context.Context, objectKey string, reader io.Reader, size int64, contentType string) error {
54+
_, err := m.client.PutObject(ctx, m.bucket, objectKey, reader, size, minio.PutObjectOptions{
55+
ContentType: contentType,
56+
})
57+
return err
58+
}
59+
60+
func (m *MinioClient) GetFileURL(ctx context.Context, objectKey string, expiry time.Duration) (string, error) {
61+
url, err := m.client.PresignedGetObject(ctx, m.bucket, objectKey, expiry, nil)
62+
if err != nil {
63+
return "", err
64+
}
65+
return url.String(), nil
66+
}
67+
68+
func (m *MinioClient) DeleteFile(ctx context.Context, objectKey string) error {
69+
return m.client.RemoveObject(ctx, m.bucket, objectKey, minio.RemoveObjectOptions{})
70+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Copyright (c) 2025 Z5labs and Contributors
2+
//
3+
// This software is released under the MIT License.
4+
// https://opensource.org/licenses/MIT
5+
6+
package ui
7+
8+
import (
9+
"context"
10+
"encoding/json"
11+
"fmt"
12+
"net/http"
13+
"time"
14+
15+
"github.com/z5labs/journeys/services/poc/storage"
16+
17+
"github.com/dgraph-io/dgo/v240"
18+
"github.com/z5labs/humus/rest"
19+
)
20+
21+
type contentPreviewHandler struct {
22+
dgraph *dgo.Dgraph
23+
minio *storage.MinioClient
24+
}
25+
26+
func GetContentPreview(dgraph *dgo.Dgraph, minio *storage.MinioClient) rest.ApiOption {
27+
h := &contentPreviewHandler{
28+
dgraph: dgraph,
29+
minio: minio,
30+
}
31+
32+
return rest.Operation(
33+
http.MethodGet,
34+
rest.BasePath("/app/content").Param("contentID").Segment("preview"),
35+
h,
36+
)
37+
}
38+
39+
func (h *contentPreviewHandler) Handle(ctx context.Context, req *rest.EmptyRequest) (*HtmlResponse, error) {
40+
contentID := rest.PathParamValue(ctx, "contentID")
41+
if contentID == "" {
42+
return nil, fmt.Errorf("content ID is required")
43+
}
44+
45+
query := `query getContent($contentID: string) {
46+
content(func: eq(content.id, $contentID)) {
47+
content.minio_key
48+
}
49+
}`
50+
51+
vars := map[string]string{"contentID": contentID}
52+
resp, err := h.dgraph.NewReadOnlyTxn().QueryWithVars(ctx, query, vars)
53+
if err != nil {
54+
return nil, fmt.Errorf("failed to query content: %w", err)
55+
}
56+
57+
var result struct {
58+
Content []struct {
59+
MinioKey string `json:"content.minio_key"`
60+
} `json:"content"`
61+
}
62+
if err := json.Unmarshal(resp.Json, &result); err != nil {
63+
return nil, fmt.Errorf("failed to unmarshal content: %w", err)
64+
}
65+
66+
if len(result.Content) == 0 {
67+
return nil, fmt.Errorf("content not found")
68+
}
69+
70+
minioKey := result.Content[0].MinioKey
71+
72+
presignedURL, err := h.minio.GetFileURL(ctx, minioKey, 15*time.Minute)
73+
if err != nil {
74+
return nil, fmt.Errorf("failed to get presigned URL: %w", err)
75+
}
76+
77+
redirectHTML := fmt.Sprintf(`<!DOCTYPE html>
78+
<html>
79+
<head>
80+
<meta http-equiv="refresh" content="0; url=%s">
81+
</head>
82+
<body>
83+
<p>Redirecting...</p>
84+
</body>
85+
</html>`, presignedURL)
86+
87+
return &HtmlResponse{
88+
ContentType: "text/html; charset=utf-8",
89+
Body: []byte(redirectHTML),
90+
}, nil
91+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Copyright (c) 2025 Z5labs and Contributors
2+
//
3+
// This software is released under the MIT License.
4+
// https://opensource.org/licenses/MIT
5+
6+
package ui
7+
8+
import (
9+
"bytes"
10+
"context"
11+
_ "embed"
12+
"fmt"
13+
"html/template"
14+
"net/http"
15+
16+
"github.com/dgraph-io/dgo/v240"
17+
"github.com/z5labs/humus/rest"
18+
)
19+
20+
//go:embed templates/content_upload_form.html
21+
var contentUploadFormTemplate string
22+
23+
type contentUploadFormHandler struct {
24+
dgraph *dgo.Dgraph
25+
template *template.Template
26+
}
27+
28+
func GetContentUploadForm(dgraph *dgo.Dgraph) rest.ApiOption {
29+
tmpl, err := template.New("content_upload_form.html").Parse(contentUploadFormTemplate)
30+
if err != nil {
31+
panic(err)
32+
}
33+
34+
h := &contentUploadFormHandler{
35+
dgraph: dgraph,
36+
template: tmpl,
37+
}
38+
39+
return rest.Operation(
40+
http.MethodGet,
41+
rest.BasePath("/app/journey").Param("journeyID").Segment("content").Segment("form"),
42+
h,
43+
)
44+
}
45+
46+
func (h *contentUploadFormHandler) Handle(ctx context.Context, req *rest.EmptyRequest) (*HtmlResponse, error) {
47+
journeyID := rest.PathParamValue(ctx, "journeyID")
48+
if journeyID == "" {
49+
return nil, fmt.Errorf("journey ID is required")
50+
}
51+
52+
var buf bytes.Buffer
53+
err := h.template.Execute(&buf, map[string]string{
54+
"JourneyID": journeyID,
55+
})
56+
if err != nil {
57+
return nil, fmt.Errorf("failed to render template: %w", err)
58+
}
59+
60+
return &HtmlResponse{
61+
ContentType: "text/html; charset=utf-8",
62+
Body: buf.Bytes(),
63+
}, nil
64+
}

0 commit comments

Comments
 (0)