Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 10 additions & 22 deletions server/handlers/console/buckets/objects.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"github.com/mojatter/s2"
"github.com/mojatter/s2/internal/numconv"
"github.com/mojatter/s2/server"
"github.com/mojatter/s2/server/handlers/console"
"github.com/mojatter/s2/server/middleware"
)

Expand Down Expand Up @@ -63,8 +64,14 @@ func handleObjects(s *server.Server, w http.ResponseWriter, r *http.Request) {
parentPrefix = ""
}

// Non-HTMX requests get redirected to the index page where the
// sidebar navigation triggers the htmx load.
if r.Header.Get("HX-Request") != "true" {
http.Redirect(w, r, "/", http.StatusFound)
return
}

data := struct {
Buckets []string
BucketName string
Objects []s2.Object
Prefixes []string
Expand All @@ -82,27 +89,8 @@ func handleObjects(s *server.Server, w http.ResponseWriter, r *http.Request) {
HasParent: prefix != "" && prefix != "/",
}

// Use a partial template for HTMX requests
if r.Header.Get("HX-Request") == "true" {
var buf bytes.Buffer
if err := s.Template.ExecuteTemplate(&buf, "buckets/objects.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_, _ = buf.WriteTo(w)
return
}

// Fallback to full page if accessed directly
bucketNames, err := s.Buckets.Names()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data.Buckets = bucketNames

var buf bytes.Buffer
if err := s.Template.ExecuteTemplate(&buf, "index.html", data); err != nil {
if err := s.Template.ExecuteTemplate(&buf, "buckets/objects.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
Expand Down Expand Up @@ -220,6 +208,6 @@ func init() {
server.RegisterConsoleHandleFunc("POST /buckets/{name}/folders", middleware.BasicAuth(handleCreateFolder))
server.RegisterConsoleHandleFunc("POST /buckets/{name}/upload", middleware.BasicAuth(handleUploadFile))
server.RegisterConsoleHandleFunc("DELETE /buckets/{name}/objects", middleware.BasicAuth(handleDeleteObject))
server.RegisterTemplate("buckets/objects.html")
console.RegisterTemplateWithScripts("buckets/objects.html")
}

72 changes: 72 additions & 0 deletions server/handlers/console/buckets/objects/view.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package objects

import (
"bytes"
"encoding/json"
"fmt"
"io"
Expand Down Expand Up @@ -106,7 +107,78 @@ func handleMeta(s *server.Server, w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(resp)
}

func handlePreview(s *server.Server, w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
bucketName := r.PathValue("name")
objectName := r.PathValue("object")

strg, err := s.Buckets.Get(ctx, bucketName)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}

obj, err := strg.Get(ctx, objectName)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}

ext := strings.ToLower(path.Ext(objectName))
viewURL := fmt.Sprintf("/buckets/%s/view/%s", bucketName, objectName)
previewType := server.PreviewType(ext)

var textContent string
if previewType == "text" {
rc, err := obj.Open()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer func() { _ = rc.Close() }()

b, err := io.ReadAll(rc)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
textContent = string(b)
}

data := struct {
Filename string
ViewURL string
ContentType string
Size uint64
LastModified string
Metadata map[string]string
PreviewType string
TextContent string
}{
Filename: path.Base(objectName),
ViewURL: viewURL,
ContentType: contentTypeByExt(ext),
Size: obj.Length(),
LastModified: obj.LastModified().Format("2006-01-02 15:04:05"),
PreviewType: previewType,
TextContent: textContent,
}
if md := obj.Metadata(); len(md) > 0 {
data.Metadata = md
}

var buf bytes.Buffer
if err := s.Template.ExecuteTemplate(&buf, "buckets/preview.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = buf.WriteTo(w)
}

func init() {
server.RegisterConsoleHandleFunc("GET /buckets/{name}/view/{object...}", middleware.BasicAuth(handleView))
server.RegisterConsoleHandleFunc("GET /buckets/{name}/meta/{object...}", middleware.BasicAuth(handleMeta))
server.RegisterConsoleHandleFunc("GET /buckets/{name}/preview/{object...}", middleware.BasicAuth(handlePreview))
}
6 changes: 3 additions & 3 deletions server/handlers/console/buckets/objects_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,16 +101,16 @@ func (s *ObjectsTestSuite) TestHandleObjects() {
s.Equal(http.StatusNotFound, w.Code)
})

s.Run("full page without HX-Request", func() {
s.Run("full page without HX-Request redirects to index", func() {
s.createBucket("full")

req := httptest.NewRequest("GET", "/buckets/full", nil)
req.SetPathValue("name", "full")
w := httptest.NewRecorder()
handleObjects(s.server, w, req)

s.Equal(http.StatusOK, w.Code)
s.Contains(w.Body.String(), "<!DOCTYPE html>")
s.Equal(http.StatusFound, w.Code)
s.Equal("/", w.Header().Get("Location"))
})
}

Expand Down
7 changes: 5 additions & 2 deletions server/handlers/console/console_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,9 @@ func (s *IndexTestSuite) TestHandleCreateBucket() {
w := httptest.NewRecorder()
handleCreateBucket(s.server, w, req)

s.Equal(http.StatusFound, w.Code)
s.Equal(http.StatusOK, w.Code)
s.Contains(w.Body.String(), "new-bucket")

exists, err := s.server.Buckets.Exists("new-bucket")
s.Require().NoError(err)
s.True(exists)
Expand Down Expand Up @@ -102,7 +104,8 @@ func (s *IndexTestSuite) TestHandleDeleteBucket() {
handleDeleteBucket(s.server, w, req)

s.Equal(http.StatusOK, w.Code)
s.Equal("/", w.Header().Get("HX-Redirect"))
s.Equal("/", w.Header().Get("HX-Push-Url"))
s.Contains(w.Body.String(), "bucket-list")

exists, err := s.server.Buckets.Exists("to-delete")
s.Require().NoError(err)
Expand Down
50 changes: 45 additions & 5 deletions server/handlers/console/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,7 @@ func handleCreateBucket(s *server.Server, w http.ResponseWriter, r *http.Request
return
}

// For HTMX, just redirect or re-render (usually redirect to index is fine)
http.Redirect(w, r, "/", http.StatusFound)
renderBucketList(s, w)
}

func handleDeleteBucket(s *server.Server, w http.ResponseWriter, r *http.Request) {
Expand All @@ -57,11 +56,52 @@ func handleDeleteBucket(s *server.Server, w http.ResponseWriter, r *http.Request
return
}

// For HTMX DELETE request, redirect to index
w.Header().Set("HX-Redirect", "/")
w.WriteHeader(http.StatusOK)
names, err := s.Buckets.Names()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

data := struct{ Buckets []string }{Buckets: names}

var buf bytes.Buffer
if err := s.Template.ExecuteTemplate(&buf, "buckets/list.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

// OOB swap to reset main content to empty state
buf.WriteString(`<div id="main-content" hx-swap-oob="innerHTML">`)

if err := s.Template.ExecuteTemplate(&buf, "empty.html", nil); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

buf.WriteString(`</div>`)

w.Header().Set("HX-Push-Url", "/")
_, _ = buf.WriteTo(w)
}

// renderBucketList renders the sidebar bucket list fragment.
func renderBucketList(s *server.Server, w http.ResponseWriter) {
names, err := s.Buckets.Names()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

data := struct{ Buckets []string }{Buckets: names}

var buf bytes.Buffer
if err := s.Template.ExecuteTemplate(&buf, "buckets/list.html", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

_, _ = buf.WriteTo(w)
}

func init() {
server.RegisterConsoleHandleFunc("GET /{$}", middleware.BasicAuth(handleIndex))
Expand Down
39 changes: 39 additions & 0 deletions server/handlers/console/scripts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package console

import (
"bytes"
"net/http"

"github.com/mojatter/s2/server"
"github.com/mojatter/s2/server/middleware"
)

// scriptTemplates lists the {{define "scripts:..."}} blocks to render.
// Populated automatically by RegisterTemplateWithScripts.
var scriptTemplates []string

// RegisterTemplateWithScripts registers a template that contains a
// {{define "scripts:<name>"}} block. It registers both the template
// itself (via server.RegisterTemplate) and the scripts block for the
// GET /scripts endpoint.
func RegisterTemplateWithScripts(name string) {
server.RegisterTemplate(name)
scriptTemplates = append(scriptTemplates, "scripts:"+name)
}

func handleScripts(s *server.Server, w http.ResponseWriter, r *http.Request) {
var buf bytes.Buffer
for _, name := range scriptTemplates {
if err := s.Template.ExecuteTemplate(&buf, name, nil); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}

w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = buf.WriteTo(w)
}

func init() {
server.RegisterConsoleHandleFunc("GET /scripts", middleware.BasicAuth(handleScripts))
}
35 changes: 34 additions & 1 deletion server/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@ var templatesFS embed.FS
var (
templatesMux sync.Mutex
templateNames = []string{
"index.html",
"empty.html",
"buckets/list.html",
"buckets/objects.html",
"buckets/preview.html",
"index.html",
}
)

Expand Down Expand Up @@ -61,6 +64,36 @@ var imageExts = map[string]bool{
".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".webp": true, ".svg": true, ".bmp": true, ".ico": true,
}

// videoExts is the set of file extensions recognized as video for preview.
var videoExts = map[string]bool{
".mp4": true, ".webm": true, ".ogg": true,
}

// audioExts is the set of file extensions recognized as audio for preview.
var audioExts = map[string]bool{
".mp3": true, ".wav": true, ".aac": true, ".flac": true,
}

// PreviewType returns the preview category for the given file extension:
// "image", "video", "audio", "pdf", "text", or "" (unsupported).
func PreviewType(ext string) string {
ext = strings.ToLower(ext)
switch {
case imageExts[ext]:
return "image"
case videoExts[ext]:
return "video"
case audioExts[ext]:
return "audio"
case ext == ".pdf":
return "pdf"
case textPreviewExts[ext]:
return "text"
default:
return ""
}
}

// previewableExts is the set of file extensions that can be previewed in the Web Console.
var previewableExts = map[string]bool{
// Images
Expand Down
30 changes: 30 additions & 0 deletions server/templates/buckets/list.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<ul class="bucket-list">
{{range .Buckets}}
<li>
<div class="bucket-item-wrap">
<a hx-get="/buckets/{{.}}" hx-target="#main-content" hx-push-url="true" class="bucket-item"
onclick="document.querySelectorAll('.bucket-item').forEach(el => el.classList.remove('active')); this.classList.add('active')">
<span class="obj-icon">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
</svg>
</span>
{{.}}
</a>
<button class="btn-delete-sm"
hx-delete="/buckets/{{.}}"
hx-confirm="Are you sure you want to delete bucket '{{.}}' and all its contents?"
hx-target="closest .bucket-list"
hx-swap="outerHTML"
title="Delete Bucket">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
</div>
</li>
{{else}}
<li style="padding: 1rem; color: var(--text-muted); font-size: 0.875rem;">No buckets found</li>
{{end}}
</ul>
Loading