Skip to content

Commit

Permalink
Handle most upload JSON
Browse files Browse the repository at this point in the history
  • Loading branch information
chriskuehl committed Sep 8, 2024
1 parent 871fde9 commit 4ef6628
Show file tree
Hide file tree
Showing 9 changed files with 212 additions and 61 deletions.
4 changes: 1 addition & 3 deletions server/assets/assets.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,7 @@ func AssetURL(conf *config.Config, path string) (string, error) {
if !ok {
return "", fmt.Errorf("asset not found: %s", path)
}
url := conf.ObjectURLPattern
url.Path = strings.Replace(url.Path, ":path:", assetObjectPath(path, hash), -1)
return url.String(), nil
return conf.ObjectURL(assetObjectPath(path, hash)).String(), nil
}

// AssetAsString returns the contents of the asset as a string.
Expand Down
8 changes: 7 additions & 1 deletion server/assets/assets_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package assets_test

import (
"net/url"
"testing"

"github.com/chriskuehl/fluffy/server/assets"
Expand All @@ -23,13 +24,18 @@ func TestAssetURLDev(t *testing.T) {

func TestAssetURLProd(t *testing.T) {
conf := testfunc.NewConfig()
url, err := url.ParseRequestURI("https://fancy-cdn.com/:path:")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
conf.ObjectURLPattern = *url
conf.DevMode = false

got, err := assets.AssetURL(conf, "img/favicon.ico")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := "http://localhost:8080/dev/object/static/5b707398fe549635b8794ac8e73db6938dd7b6b7a28b339296bde1b0fdec764b/img/favicon.ico"
want := "https://fancy-cdn.com/static/5b707398fe549635b8794ac8e73db6938dd7b6b7a28b339296bde1b0fdec764b/img/favicon.ico"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
Expand Down
6 changes: 6 additions & 0 deletions server/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,9 @@ func (conf *Config) Validate() []string {
}
return errs
}

func (conf *Config) ObjectURL(path string) *url.URL {
url := conf.ObjectURLPattern
url.Path = strings.Replace(url.Path, ":path:", path, -1)
return &url
}
6 changes: 3 additions & 3 deletions server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ func NewConfig() *config.Config {
MaxUploadBytes: 1024 * 1024 * 10, // 10 MiB
MaxMultipartMemoryBytes: 1024 * 1024 * 10, // 10 MiB
HomeURL: url.URL{Scheme: "http", Host: "localhost:8080"},
ObjectURLPattern: url.URL{Scheme: "http", Host: "localhost:8080", Path: "/dev/object/:path:"},
HTMLURLPattern: url.URL{Scheme: "http", Host: "localhost:8080", Path: "/dev/html/:path:"},
ObjectURLPattern: url.URL{Scheme: "http", Host: "localhost:8080", Path: "/dev/storage/object/:path:"},
HTMLURLPattern: url.URL{Scheme: "http", Host: "localhost:8080", Path: "/dev/storage/html/:path:"},
ForbiddenFileExtensions: make(map[string]struct{}),
Host: "127.0.0.1",
Port: 8080,
Expand Down Expand Up @@ -59,7 +59,7 @@ func newCSPMiddleware(conf *config.Config, next http.Handler) http.Handler {
nonce := hex.EncodeToString(nonceBytes)
ctx = context.WithValue(ctx, cspNonceKey{}, nonce)
csp := fmt.Sprintf(
"default-src %s; script-src https://ajax.googleapis.com 'nonce-%s' %[1]s; style-src https://fonts.googleapis.com %[1]s; font-src https://fonts.gstatic.com %[1]s",
"default-src 'self' %s; script-src https://ajax.googleapis.com 'nonce-%s' %[1]s; style-src 'self' https://fonts.googleapis.com %[1]s; font-src https://fonts.gstatic.com %[1]s",
objectURLBase.String(),
nonce,
)
Expand Down
1 change: 1 addition & 0 deletions server/storage/storagedata/storagedata.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ type Object struct {
Links []string
MetadataURL string
Reader io.Reader
Bytes int64
}
2 changes: 1 addition & 1 deletion server/templates/include/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<body>
<div id="container">
<div id="content">
<h1 class="site-header"><a href="{{.Meta.Conf.HomeURL}}">{{.Meta.Conf.Branding}}</a></h1>
<h1 class="site-header"><a href="{{.Meta.Conf.HomeURL.String}}">{{.Meta.Conf.Branding}}</a></h1>
{{template "content" .}}
</div>
</div>
Expand Down
35 changes: 35 additions & 0 deletions server/utils/utils.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package utils

import (
"fmt"
)

func Pluralize(s string, n int64) string {
if n == 1 {
return s
}
return s + "s"
}

func FormatBytes(bytes int64) string {
const (
kb = 1024
mb = 1024 * kb
gb = 1024 * mb
)
switch {
case bytes >= gb:
// Note: Using this pattern instead of printf with %.1f to avoid rounding up which can lead
// to weird results like 1024.0 MiB instead of 1.0 GiB.
n, rem := bytes/gb, bytes%gb
return fmt.Sprintf("%d.%d GiB", n, rem*10/gb)
case bytes >= mb:
n, rem := bytes/mb, bytes%mb
return fmt.Sprintf("%d.%d MiB", n, rem*10/mb)
case bytes >= kb:
n, rem := bytes/kb, bytes%kb
return fmt.Sprintf("%d.%d KiB", n, rem*10/kb)
default:
return fmt.Sprintf("%d %s", bytes, Pluralize("byte", bytes))
}
}
44 changes: 44 additions & 0 deletions server/utils/utils_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package utils_test

import (
"strconv"
"testing"

"github.com/chriskuehl/fluffy/server/utils"
)

func TestPluralize(t *testing.T) {
tests := map[int64]string{
0: "things",
1: "thing",
2: "things",
}
for count, want := range tests {
t.Run(strconv.FormatInt(count, 10), func(t *testing.T) {
if got := utils.Pluralize("thing", count); got != want {
t.Errorf("got Pluralize(%q, %d) = %v, want %v", "thing", count, got, want)
}
})
}
}

func TestFormatBytes(t *testing.T) {
tests := map[int64]string{
0: "0 bytes",
1: "1 byte",
1023: "1023 bytes",
1024: "1.0 KiB",
1024 * 1024: "1.0 MiB",
1024*1024 - 1: "1023.9 KiB",
1024*1024*1024 - 1: "1023.9 MiB",
1024 * 1024 * 1024: "1.0 GiB",
3*1024*1024*1024 + 717*1024*1024: "3.7 GiB",
}
for bytes, want := range tests {
t.Run(strconv.FormatInt(bytes, 10), func(t *testing.T) {
if got := utils.FormatBytes(bytes); got != want {
t.Errorf("got FormatBytes(%d) = %v, want %v", bytes, got, want)
}
})
}
}
167 changes: 114 additions & 53 deletions server/views.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ package server

import (
"bytes"
"context"
"embed"
"encoding/json"
"errors"
"fmt"
"html/template"
"mime/multipart"
"net/http"
"strings"

Expand All @@ -16,6 +18,7 @@ import (
"github.com/chriskuehl/fluffy/server/logging"
"github.com/chriskuehl/fluffy/server/storage/storagedata"
"github.com/chriskuehl/fluffy/server/uploads"
"github.com/chriskuehl/fluffy/server/utils"
)

//go:embed templates/*
Expand Down Expand Up @@ -121,93 +124,151 @@ func handleUploadHistory(conf *config.Config, logger logging.Logger) (http.Handl
}, nil
}

type UploadResponse struct {
type errorResponse struct {
Success bool `json:"success"`
Error string `json:"error"`
Error string `json:"error,omitempty"`
}

func handleUpload(conf *config.Config, logger logging.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
jsonError := func(statusCode int, msg string) {
w.WriteHeader(statusCode)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(UploadResponse{
Success: false,
Error: msg,
})
type userError struct {
code int
message string
}

func (e userError) Error() string {
return e.message
}

func (e userError) output(w http.ResponseWriter) {
w.WriteHeader(e.code)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(errorResponse{
Success: false,
Error: e.message,
})
}

type uploadedFile struct {
Bytes int64 `json:"bytes"`
Raw string `json:"raw"`
Paste string `json:"paste,omitempty"`
}

type uploadResponse struct {
errorResponse
Redirect string `json:"redirect"`
Metadata string `json:"metadata"`
UploadedFiles map[string]uploadedFile `json:"uploadedFiles"`
}

func objectFromFileHeader(
ctx context.Context,
conf *config.Config,
logger logging.Logger,
fileHeader *multipart.FileHeader,
) (*storagedata.Object, error) {
file, err := fileHeader.Open()
if err != nil {
logger.Error(ctx, "opening file", "error", err)
return nil, userError{http.StatusBadRequest, "Could not open file."}
}
defer file.Close()

if fileHeader.Size > conf.MaxUploadBytes {
logger.Info(ctx, "file too large", "size", fileHeader.Size)
return nil, userError{
http.StatusBadRequest,
fmt.Sprintf("File is too large; max size is %s.", utils.FormatBytes(conf.MaxUploadBytes)),
}
}

key, err := uploads.SanitizeUploadName(fileHeader.Filename, conf.ForbiddenFileExtensions)
if err != nil {
if errors.Is(err, uploads.ErrForbiddenExtension) {
logger.Info(ctx, "forbidden extension", "filename", fileHeader.Filename)
return nil, userError{http.StatusBadRequest, fmt.Sprintf("Sorry, %q has a forbidden file extension.", fileHeader.Filename)}
}
logger.Error(ctx, "sanitizing upload name", "error", err)
return nil, userError{http.StatusInternalServerError, "Failed to sanitize upload name."}
}

return &storagedata.Object{
Key: key.String(),
Reader: file,
Bytes: fileHeader.Size,
}, nil
}

func handleUpload(conf *config.Config, logger logging.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
err := r.ParseMultipartForm(conf.MaxMultipartMemoryBytes)
if err != nil {
logger.Error(r.Context(), "parsing multipart form", "error", err)
jsonError(http.StatusBadRequest, "Could not parse multipart form.")
userError{http.StatusBadRequest, "Could not parse multipart form."}.output(w)
return
}

_, json := r.URL.Query()["json"]
_, jsonResponse := r.URL.Query()["json"]
if _, ok := r.MultipartForm.Value["json"]; ok {
json = true
jsonResponse = true
}
fmt.Printf("json: %v\n", json)

objs := []storagedata.Object{}

fmt.Printf("files: %v\n", r.MultipartForm.File["file"])

for _, fileHeader := range r.MultipartForm.File["file"] {
fmt.Printf("file: %v\n", fileHeader.Filename)
file, err := fileHeader.Open()
obj, err := objectFromFileHeader(r.Context(), conf, logger, fileHeader)
if err != nil {
logger.Error(r.Context(), "opening file", "error", err)
jsonError(http.StatusInternalServerError, "Could not open file.")
return
}
defer file.Close()

// TODO: check file size (keep in mind fileHeader.Size might be a lie?)
// -- but maybe not? since Go buffers it first?
key, err := uploads.SanitizeUploadName(fileHeader.Filename, conf.ForbiddenFileExtensions)
if err != nil {
if errors.Is(err, uploads.ErrForbiddenExtension) {
logger.Info(r.Context(), "forbidden extension", "filename", fileHeader.Filename)
jsonError(
http.StatusBadRequest,
fmt.Sprintf("Sorry, %q has a forbidden file extension.", fileHeader.Filename),
)
return
userErr, ok := err.(userError)
if !ok {
logger.Error(r.Context(), "unexpected error", "error", err)
userErr = userError{http.StatusInternalServerError, "An unexpected error occurred."}
}
logger.Error(r.Context(), "sanitizing upload name", "error", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Failed to sanitize upload name.\n"))
userErr.output(w)
return
}

obj := storagedata.Object{
Key: key.String(),
Reader: file,
}
objs = append(objs, obj)
objs = append(objs, *obj)
}

fmt.Printf("objs: %v\n", objs)

if len(objs) == 0 {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("No files uploaded.\n"))
logger.Info(r.Context(), "no files uploaded")
userError{http.StatusBadRequest, "No files uploaded."}.output(w)
return
}

errs := uploads.UploadObjects(r.Context(), logger, conf, objs)

if len(errs) > 0 {
logger.Error(r.Context(), "uploading objects failed", "errors", errs)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Failed to store object.\n"))
userError{http.StatusInternalServerError, "Failed to store object."}.output(w)
return
}

logger.Info(r.Context(), "uploaded", "objects", len(objs))
w.WriteHeader(http.StatusOK)
w.Write([]byte("Uploaded.\n"))

redirect := conf.ObjectURL(objs[0].Key).String()

if jsonResponse {
uploadedFiles := make(map[string]uploadedFile, len(objs))
for _, obj := range objs {
uploadedFiles[obj.Key] = uploadedFile{
Bytes: obj.Bytes,
Raw: conf.ObjectURL(obj.Key).String(),
// TODO: Paste for text files
}
}

resp := uploadResponse{
errorResponse: errorResponse{
Success: true,
},
Redirect: redirect,
Metadata: "TODO",
UploadedFiles: uploadedFiles,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(resp)
} else {
http.Redirect(w, r, redirect, http.StatusSeeOther)
}
}
}

0 comments on commit 4ef6628

Please sign in to comment.