Add asset file upload form, plus related necessary refactors

This commit is contained in:
Brian Picciano 2022-05-17 15:54:20 -06:00
parent e406ad6e7c
commit 69de76cb32
9 changed files with 152 additions and 56 deletions

View File

@ -11,6 +11,7 @@ import (
"net/url" "net/url"
"os" "os"
"github.com/mediocregopher/blog.mediocregopher.com/srv/api/apiutil"
"github.com/mediocregopher/blog.mediocregopher.com/srv/cfg" "github.com/mediocregopher/blog.mediocregopher.com/srv/cfg"
"github.com/mediocregopher/blog.mediocregopher.com/srv/chat" "github.com/mediocregopher/blog.mediocregopher.com/srv/chat"
"github.com/mediocregopher/blog.mediocregopher.com/srv/mailinglist" "github.com/mediocregopher/blog.mediocregopher.com/srv/mailinglist"
@ -158,9 +159,9 @@ func (a *api) handler() http.Handler {
return a.requirePowMiddleware(h) return a.requirePowMiddleware(h)
} }
postFormMiddleware := func(h http.Handler) http.Handler { formMiddleware := func(h http.Handler) http.Handler {
h = checkCSRFMiddleware(h) h = checkCSRFMiddleware(h)
h = postOnlyMiddleware(h) h = disallowGetMiddleware(h)
h = logReqMiddleware(h) h = logReqMiddleware(h)
h = addResponseHeaders(map[string]string{ h = addResponseHeaders(map[string]string{
"Cache-Control": "no-store, max-age=0", "Cache-Control": "no-store, max-age=0",
@ -193,14 +194,17 @@ func (a *api) handler() http.Handler {
a.requirePowMiddleware, a.requirePowMiddleware,
))) )))
mux.Handle("/api/", http.StripPrefix("/api", postFormMiddleware(apiMux))) mux.Handle("/api/", http.StripPrefix("/api", formMiddleware(apiMux)))
} }
{ {
v2Mux := http.NewServeMux() v2Mux := http.NewServeMux()
v2Mux.Handle("/follow.html", a.renderDumbHandler("follow.html")) v2Mux.Handle("/follow.html", a.renderDumbHandler("follow.html"))
v2Mux.Handle("/posts/", a.renderPostHandler()) v2Mux.Handle("/posts/", a.renderPostHandler())
v2Mux.Handle("/assets", a.renderPostAssetsIndexHandler()) v2Mux.Handle("/assets", apiutil.MethodMux(map[string]http.Handler{
"GET": a.renderPostAssetsIndexHandler(),
"POST": formMiddleware(a.uploadPostAssetHandler()),
}))
v2Mux.Handle("/assets/", a.servePostAssetHandler()) v2Mux.Handle("/assets/", a.servePostAssetHandler())
v2Mux.Handle("/", a.renderIndexHandler()) v2Mux.Handle("/", a.renderIndexHandler())

View File

@ -11,6 +11,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"github.com/mediocregopher/mediocre-go-lib/v2/mlog" "github.com/mediocregopher/mediocre-go-lib/v2/mlog"
) )
@ -110,3 +111,23 @@ func RandStr(numBytes int) string {
} }
return hex.EncodeToString(b) return hex.EncodeToString(b)
} }
// MethodMux will take the request method (GET, POST, etc...) and handle the
// request using the corresponding Handler in the given map.
//
// If no Handler is defined for a method then a 405 Method Not Allowed error is
// returned.
func MethodMux(handlers map[string]http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
handler, ok := handlers[strings.ToUpper(r.Method)]
if !ok {
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
return
}
handler.ServeHTTP(rw, r)
})
}

View File

@ -111,3 +111,31 @@ func (a *api) servePostAssetHandler() http.Handler {
}) })
} }
func (a *api) uploadPostAssetHandler() http.Handler {
renderIndex := a.renderPostAssetsIndexHandler()
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
id := r.PostFormValue("id")
if id == "" {
apiutil.BadRequest(rw, r, errors.New("id is required"))
return
}
file, _, err := r.FormFile("file")
if err != nil {
apiutil.BadRequest(rw, r, fmt.Errorf("reading multipart file: %w", err))
return
}
defer file.Close()
if err := a.params.PostAssetStore.Set(id, file); err != nil {
apiutil.InternalServerError(rw, r, fmt.Errorf("storing file: %w", err))
return
}
renderIndex.ServeHTTP(rw, r)
})
}

View File

@ -10,6 +10,7 @@ import (
const ( const (
csrfTokenCookieName = "csrf_token" csrfTokenCookieName = "csrf_token"
csrfTokenHeaderName = "X-CSRF-Token" csrfTokenHeaderName = "X-CSRF-Token"
csrfTokenFormName = "csrfToken"
) )
func setCSRFMiddleware(h http.Handler) http.Handler { func setCSRFMiddleware(h http.Handler) http.Handler {
@ -45,7 +46,7 @@ func checkCSRFMiddleware(h http.Handler) http.Handler {
givenCSRFTok := r.Header.Get(csrfTokenHeaderName) givenCSRFTok := r.Header.Get(csrfTokenHeaderName)
if givenCSRFTok == "" { if givenCSRFTok == "" {
givenCSRFTok = r.FormValue("csrfToken") givenCSRFTok = r.FormValue(csrfTokenFormName)
} }
if csrfTok == "" || givenCSRFTok != csrfTok { if csrfTok == "" || givenCSRFTok != csrfTok {

View File

@ -80,11 +80,11 @@ func logReqMiddleware(h http.Handler) http.Handler {
}) })
} }
func postOnlyMiddleware(h http.Handler) http.Handler { func disallowGetMiddleware(h http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
// we allow websockets to not be POSTs because, well, they can't be // we allow websockets to be GETs because, well, they must be
if r.Method == "POST" || r.Header.Get("Upgrade") == "websocket" { if r.Method != "GET" || r.Header.Get("Upgrade") == "websocket" {
h.ServeHTTP(rw, r) h.ServeHTTP(rw, r)
return return
} }

View File

@ -51,6 +51,39 @@ func (a *api) mustParseTpl(name string) *template.Template {
return tpl return tpl
} }
type tplData struct {
Payload interface{}
CSRFToken string
}
func (t tplData) CSRFFormInput() template.HTML {
return template.HTML(fmt.Sprintf(
`<input type="hidden" name="%s" value="%s" />`,
csrfTokenFormName, t.CSRFToken,
))
}
// executeTemplate expects to be the final action in an http.Handler
func executeTemplate(
rw http.ResponseWriter, r *http.Request,
tpl *template.Template, payload interface{},
) {
csrfToken, _ := apiutil.GetCookie(r, csrfTokenCookieName, "")
tplData := tplData{
Payload: payload,
CSRFToken: csrfToken,
}
if err := tpl.Execute(rw, tplData); err != nil {
apiutil.InternalServerError(
rw, r, fmt.Errorf("rendering template: %w", err),
)
return
}
}
func (a *api) renderIndexHandler() http.Handler { func (a *api) renderIndexHandler() http.Handler {
tpl := a.mustParseTpl("index.html") tpl := a.mustParseTpl("index.html")
@ -79,7 +112,7 @@ func (a *api) renderIndexHandler() http.Handler {
return return
} }
tplData := struct { tplPayload := struct {
Posts []post.StoredPost Posts []post.StoredPost
PrevPage, NextPage int PrevPage, NextPage int
}{ }{
@ -89,19 +122,14 @@ func (a *api) renderIndexHandler() http.Handler {
} }
if page > 0 { if page > 0 {
tplData.PrevPage = page - 1 tplPayload.PrevPage = page - 1
} }
if hasMore { if hasMore {
tplData.NextPage = page + 1 tplPayload.NextPage = page + 1
} }
if err := tpl.Execute(rw, tplData); err != nil { executeTemplate(rw, r, tpl, tplPayload)
apiutil.InternalServerError(
rw, r, fmt.Errorf("rendering index: %w", err),
)
return
}
}) })
} }
@ -133,7 +161,7 @@ func (a *api) renderPostHandler() http.Handler {
renderedBody := markdown.ToHTML([]byte(storedPost.Body), parser, htmlRenderer) renderedBody := markdown.ToHTML([]byte(storedPost.Body), parser, htmlRenderer)
tplData := struct { tplPayload := struct {
post.StoredPost post.StoredPost
SeriesPrevious, SeriesNext *post.StoredPost SeriesPrevious, SeriesNext *post.StoredPost
Body template.HTML Body template.HTML
@ -165,21 +193,16 @@ func (a *api) renderPostHandler() http.Handler {
} }
if !foundThis { if !foundThis {
tplData.SeriesPrevious = &seriesPost tplPayload.SeriesPrevious = &seriesPost
continue continue
} }
tplData.SeriesNext = &seriesPost tplPayload.SeriesNext = &seriesPost
break break
} }
} }
if err := tpl.Execute(rw, tplData); err != nil { executeTemplate(rw, r, tpl, tplPayload)
apiutil.InternalServerError(
rw, r, fmt.Errorf("rendering post with id %q: %w", id, err),
)
return
}
}) })
} }
@ -212,17 +235,12 @@ func (a *api) renderPostAssetsIndexHandler() http.Handler {
return return
} }
tplData := struct { tplPayload := struct {
IDs []string IDs []string
}{ }{
IDs: ids, IDs: ids,
} }
if err := tpl.Execute(rw, tplData); err != nil { executeTemplate(rw, r, tpl, tplPayload)
apiutil.InternalServerError(
rw, r, fmt.Errorf("rendering: %w", err),
)
return
}
}) })
} }

View File

@ -1,8 +1,32 @@
{{ define "body" }} {{ define "body" }}
<h2>Upload Asset</h2>
<p>
If the given ID is the same as an existing asset's ID, then that asset will be
overwritten.
</p>
<form action={{ BlogURL "assets" }} method="POST" enctype="multipart/form-data">
{{ .CSRFFormInput }}
<div class="row">
<div class="four columns">
<input type="text" placeholder="Unique ID" name="id" />
</div>
<div class="four columns">
<input type="file" name="file" /><br/>
</div>
<div class="four columns">
<input type="submit" value="Upload" />
</div>
</div>
</form>
<h2>Existing Assets</h2>
<table> <table>
{{ range .IDs }} {{ range .Payload.IDs }}
<tr> <tr>
<td><a href="{{ AssetURL . }}" target="_blank">{{ . }}</a></td> <td><a href="{{ AssetURL . }}" target="_blank">{{ . }}</a></td>
<td> <td>

View File

@ -2,7 +2,7 @@
<ul id="posts-list"> <ul id="posts-list">
{{ range .Posts }} {{ range .Payload.Posts }}
<li> <li>
<h2> <h2>
<a href="posts/{{ .HTTPPath }}">{{ .Title }}</a> <a href="posts/{{ .HTTPPath }}">{{ .Title }}</a>
@ -17,15 +17,15 @@
</ul> </ul>
{{ if or (ge .PrevPage 0) (ge .NextPage 0) }} {{ if or (ge .Payload.PrevPage 0) (ge .Payload.NextPage 0) }}
<div id="page-turner"> <div id="page-turner">
{{ if ge .PrevPage 0 }} {{ if ge .Payload.PrevPage 0 }}
<a style="float: left;" href="?p={{ .PrevPage}}">Newer</a> <a style="float: left;" href="?p={{ .Payload.PrevPage}}">Newer</a>
{{ end }} {{ end }}
{{ if ge .NextPage 0 }} {{ if ge .Payload.NextPage 0 }}
<a style="float:right;" href="?p={{ .NextPage}}">Older</a> <a style="float:right;" href="?p={{ .Payload.NextPage}}">Older</a>
{{ end }} {{ end }}
</div> </div>

View File

@ -2,43 +2,43 @@
<header id="post-header"> <header id="post-header">
<h1 id="post-headline"> <h1 id="post-headline">
{{ .Title }} {{ .Payload.Title }}
</h1> </h1>
<div class="light"> <div class="light">
{{ .PublishedAt.Format "2006-01-02" }} {{ .Payload.PublishedAt.Format "2006-01-02" }}
&nbsp;&nbsp; &nbsp;&nbsp;
{{ if not .LastUpdatedAt.IsZero }} {{ if not .Payload.LastUpdatedAt.IsZero }}
(Updated {{ .LastUpdatedAt.Format "2006-01-02" }}) (Updated {{ .Payload.LastUpdatedAt.Format "2006-01-02" }})
&nbsp;&nbsp; &nbsp;&nbsp;
{{ end }} {{ end }}
<em>{{ .Description }}</em> <em>{{ .Payload.Description }}</em>
</div> </div>
</header> </header>
{{ if (or .SeriesPrevious .SeriesNext) }} {{ if (or .Payload.SeriesPrevious .Payload.SeriesNext) }}
<p class="light"><em> <p class="light"><em>
This post is part of a series:<br/> This post is part of a series:<br/>
{{ if .SeriesPrevious }} {{ if .Payload.SeriesPrevious }}
Previously: <a href="{{ .SeriesPrevious.HTTPPath }}">{{ .SeriesPrevious.Title }}</a></br> Previously: <a href="{{ .Payload.SeriesPrevious.HTTPPath }}">{{ .Payload.SeriesPrevious.Title }}</a></br>
{{ end }} {{ end }}
{{ if .SeriesNext }} {{ if .Payload.SeriesNext }}
Next: <a href="{{ .SeriesNext.HTTPPath }}">{{ .SeriesNext.Title }}</a></br> Next: <a href="{{ .Payload.SeriesNext.HTTPPath }}">{{ .Payload.SeriesNext.Title }}</a></br>
{{ end }} {{ end }}
</em></p> </em></p>
{{ end }} {{ end }}
<div id="post-content"> <div id="post-content">
{{ .Body }} {{ .Payload.Body }}
</div> </div>
{{ if (or .SeriesPrevious .SeriesNext) }} {{ if (or .Payload.SeriesPrevious .Payload.SeriesNext) }}
<p class="light"><em> <p class="light"><em>
If you liked this post, consider checking out other posts in the series:<br/> If you liked this post, consider checking out other posts in the series:<br/>
{{ if .SeriesPrevious }} {{ if .Payload.SeriesPrevious }}
Previously: <a href="{{ .SeriesPrevious.HTTPPath }}">{{ .SeriesPrevious.Title }}</a></br> Previously: <a href="{{ .Payload.SeriesPrevious.HTTPPath }}">{{ .Payload.SeriesPrevious.Title }}</a></br>
{{ end }} {{ end }}
{{ if .SeriesNext }} {{ if .Payload.SeriesNext }}
Next: <a href="{{ .SeriesNext.HTTPPath }}">{{ .SeriesNext.Title }}</a></br> Next: <a href="{{ .Payload.SeriesNext.HTTPPath }}">{{ .Payload.SeriesNext.Title }}</a></br>
{{ end }} {{ end }}
</em></p> </em></p>
{{ end }} {{ end }}