mirror of
https://github.com/caddyserver/caddy.git
synced 2025-02-24 00:38:53 +01:00
Migrate remaining middleware packages
This commit is contained in:
parent
416af05a00
commit
a762dde145
47 changed files with 6455 additions and 0 deletions
432
caddyhttp/browse/browse.go
Normal file
432
caddyhttp/browse/browse.go
Normal file
|
@ -0,0 +1,432 @@
|
||||||
|
// Package browse provides middleware for listing files in a directory
|
||||||
|
// when directory path is requested instead of a specific file.
|
||||||
|
package browse
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
|
"github.com/mholt/caddy/caddyhttp/httpserver"
|
||||||
|
"github.com/mholt/caddy/caddyhttp/staticfiles"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Browse is an http.Handler that can show a file listing when
|
||||||
|
// directories in the given paths are specified.
|
||||||
|
type Browse struct {
|
||||||
|
Next httpserver.Handler
|
||||||
|
Configs []Config
|
||||||
|
IgnoreIndexes bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config is a configuration for browsing in a particular path.
|
||||||
|
type Config struct {
|
||||||
|
PathScope string
|
||||||
|
Root http.FileSystem
|
||||||
|
Variables interface{}
|
||||||
|
Template *template.Template
|
||||||
|
}
|
||||||
|
|
||||||
|
// A Listing is the context used to fill out a template.
|
||||||
|
type Listing struct {
|
||||||
|
// The name of the directory (the last element of the path)
|
||||||
|
Name string
|
||||||
|
|
||||||
|
// The full path of the request
|
||||||
|
Path string
|
||||||
|
|
||||||
|
// Whether the parent directory is browsable
|
||||||
|
CanGoUp bool
|
||||||
|
|
||||||
|
// The items (files and folders) in the path
|
||||||
|
Items []FileInfo
|
||||||
|
|
||||||
|
// The number of directories in the listing
|
||||||
|
NumDirs int
|
||||||
|
|
||||||
|
// The number of files (items that aren't directories) in the listing
|
||||||
|
NumFiles int
|
||||||
|
|
||||||
|
// Which sorting order is used
|
||||||
|
Sort string
|
||||||
|
|
||||||
|
// And which order
|
||||||
|
Order string
|
||||||
|
|
||||||
|
// If ≠0 then Items have been limited to that many elements
|
||||||
|
ItemsLimitedTo int
|
||||||
|
|
||||||
|
// Optional custom variables for use in browse templates
|
||||||
|
User interface{}
|
||||||
|
|
||||||
|
httpserver.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
// BreadcrumbMap returns l.Path where every element is a map
|
||||||
|
// of URLs and path segment names.
|
||||||
|
func (l Listing) BreadcrumbMap() map[string]string {
|
||||||
|
result := map[string]string{}
|
||||||
|
|
||||||
|
if len(l.Path) == 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// skip trailing slash
|
||||||
|
lpath := l.Path
|
||||||
|
if lpath[len(lpath)-1] == '/' {
|
||||||
|
lpath = lpath[:len(lpath)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(lpath, "/")
|
||||||
|
for i, part := range parts {
|
||||||
|
if i == 0 && part == "" {
|
||||||
|
// Leading slash (root)
|
||||||
|
result["/"] = "/"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result[strings.Join(parts[:i+1], "/")] = part
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileInfo is the info about a particular file or directory
|
||||||
|
type FileInfo struct {
|
||||||
|
IsDir bool
|
||||||
|
Name string
|
||||||
|
Size int64
|
||||||
|
URL string
|
||||||
|
ModTime time.Time
|
||||||
|
Mode os.FileMode
|
||||||
|
}
|
||||||
|
|
||||||
|
// HumanSize returns the size of the file as a human-readable string
|
||||||
|
// in IEC format (i.e. power of 2 or base 1024).
|
||||||
|
func (fi FileInfo) HumanSize() string {
|
||||||
|
return humanize.IBytes(uint64(fi.Size))
|
||||||
|
}
|
||||||
|
|
||||||
|
// HumanModTime returns the modified time of the file as a human-readable string.
|
||||||
|
func (fi FileInfo) HumanModTime(format string) string {
|
||||||
|
return fi.ModTime.Format(format)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Implement sorting for Listing
|
||||||
|
type byName Listing
|
||||||
|
type bySize Listing
|
||||||
|
type byTime Listing
|
||||||
|
|
||||||
|
// By Name
|
||||||
|
func (l byName) Len() int { return len(l.Items) }
|
||||||
|
func (l byName) Swap(i, j int) { l.Items[i], l.Items[j] = l.Items[j], l.Items[i] }
|
||||||
|
|
||||||
|
// Treat upper and lower case equally
|
||||||
|
func (l byName) Less(i, j int) bool {
|
||||||
|
return strings.ToLower(l.Items[i].Name) < strings.ToLower(l.Items[j].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// By Size
|
||||||
|
func (l bySize) Len() int { return len(l.Items) }
|
||||||
|
func (l bySize) Swap(i, j int) { l.Items[i], l.Items[j] = l.Items[j], l.Items[i] }
|
||||||
|
|
||||||
|
const directoryOffset = -1 << 31 // = math.MinInt32
|
||||||
|
func (l bySize) Less(i, j int) bool {
|
||||||
|
iSize, jSize := l.Items[i].Size, l.Items[j].Size
|
||||||
|
if l.Items[i].IsDir {
|
||||||
|
iSize = directoryOffset + iSize
|
||||||
|
}
|
||||||
|
if l.Items[j].IsDir {
|
||||||
|
jSize = directoryOffset + jSize
|
||||||
|
}
|
||||||
|
return iSize < jSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// By Time
|
||||||
|
func (l byTime) Len() int { return len(l.Items) }
|
||||||
|
func (l byTime) Swap(i, j int) { l.Items[i], l.Items[j] = l.Items[j], l.Items[i] }
|
||||||
|
func (l byTime) Less(i, j int) bool { return l.Items[i].ModTime.Before(l.Items[j].ModTime) }
|
||||||
|
|
||||||
|
// Add sorting method to "Listing"
|
||||||
|
// it will apply what's in ".Sort" and ".Order"
|
||||||
|
func (l Listing) applySort() {
|
||||||
|
// Check '.Order' to know how to sort
|
||||||
|
if l.Order == "desc" {
|
||||||
|
switch l.Sort {
|
||||||
|
case "name":
|
||||||
|
sort.Sort(sort.Reverse(byName(l)))
|
||||||
|
case "size":
|
||||||
|
sort.Sort(sort.Reverse(bySize(l)))
|
||||||
|
case "time":
|
||||||
|
sort.Sort(sort.Reverse(byTime(l)))
|
||||||
|
default:
|
||||||
|
// If not one of the above, do nothing
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else { // If we had more Orderings we could add them here
|
||||||
|
switch l.Sort {
|
||||||
|
case "name":
|
||||||
|
sort.Sort(byName(l))
|
||||||
|
case "size":
|
||||||
|
sort.Sort(bySize(l))
|
||||||
|
case "time":
|
||||||
|
sort.Sort(byTime(l))
|
||||||
|
default:
|
||||||
|
// If not one of the above, do nothing
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func directoryListing(files []os.FileInfo, canGoUp bool, urlPath string) (Listing, bool) {
|
||||||
|
var (
|
||||||
|
fileinfos []FileInfo
|
||||||
|
dirCount, fileCount int
|
||||||
|
hasIndexFile bool
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, f := range files {
|
||||||
|
name := f.Name()
|
||||||
|
|
||||||
|
for _, indexName := range staticfiles.IndexPages {
|
||||||
|
if name == indexName {
|
||||||
|
hasIndexFile = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.IsDir() {
|
||||||
|
name += "/"
|
||||||
|
dirCount++
|
||||||
|
} else {
|
||||||
|
fileCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
url := url.URL{Path: "./" + name} // prepend with "./" to fix paths with ':' in the name
|
||||||
|
|
||||||
|
fileinfos = append(fileinfos, FileInfo{
|
||||||
|
IsDir: f.IsDir(),
|
||||||
|
Name: f.Name(),
|
||||||
|
Size: f.Size(),
|
||||||
|
URL: url.String(),
|
||||||
|
ModTime: f.ModTime().UTC(),
|
||||||
|
Mode: f.Mode(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return Listing{
|
||||||
|
Name: path.Base(urlPath),
|
||||||
|
Path: urlPath,
|
||||||
|
CanGoUp: canGoUp,
|
||||||
|
Items: fileinfos,
|
||||||
|
NumDirs: dirCount,
|
||||||
|
NumFiles: fileCount,
|
||||||
|
}, hasIndexFile
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP determines if the request is for this plugin, and if all prerequisites are met.
|
||||||
|
// If so, control is handed over to ServeListing.
|
||||||
|
func (b Browse) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error) {
|
||||||
|
var bc *Config
|
||||||
|
// See if there's a browse configuration to match the path
|
||||||
|
for i := range b.Configs {
|
||||||
|
if httpserver.Path(r.URL.Path).Matches(b.Configs[i].PathScope) {
|
||||||
|
bc = &b.Configs[i]
|
||||||
|
goto inScope
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.Next.ServeHTTP(w, r)
|
||||||
|
inScope:
|
||||||
|
|
||||||
|
// Browse works on existing directories; delegate everything else
|
||||||
|
requestedFilepath, err := bc.Root.Open(r.URL.Path)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case os.IsPermission(err):
|
||||||
|
return http.StatusForbidden, err
|
||||||
|
case os.IsExist(err):
|
||||||
|
return http.StatusNotFound, err
|
||||||
|
default:
|
||||||
|
return b.Next.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer requestedFilepath.Close()
|
||||||
|
|
||||||
|
info, err := requestedFilepath.Stat()
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case os.IsPermission(err):
|
||||||
|
return http.StatusForbidden, err
|
||||||
|
case os.IsExist(err):
|
||||||
|
return http.StatusGone, err
|
||||||
|
default:
|
||||||
|
return b.Next.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return b.Next.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do not reply to anything else because it might be nonsensical
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet, http.MethodHead:
|
||||||
|
// proceed, noop
|
||||||
|
case "PROPFIND", http.MethodOptions:
|
||||||
|
return http.StatusNotImplemented, nil
|
||||||
|
default:
|
||||||
|
return b.Next.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Browsing navigation gets messed up if browsing a directory
|
||||||
|
// that doesn't end in "/" (which it should, anyway)
|
||||||
|
if !strings.HasSuffix(r.URL.Path, "/") {
|
||||||
|
http.Redirect(w, r, r.URL.Path+"/", http.StatusTemporaryRedirect)
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.ServeListing(w, r, requestedFilepath, bc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b Browse) loadDirectoryContents(requestedFilepath http.File, urlPath string) (*Listing, bool, error) {
|
||||||
|
files, err := requestedFilepath.Readdir(-1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if user can browse up another folder
|
||||||
|
var canGoUp bool
|
||||||
|
curPathDir := path.Dir(strings.TrimSuffix(urlPath, "/"))
|
||||||
|
for _, other := range b.Configs {
|
||||||
|
if strings.HasPrefix(curPathDir, other.PathScope) {
|
||||||
|
canGoUp = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assemble listing of directory contents
|
||||||
|
listing, hasIndex := directoryListing(files, canGoUp, urlPath)
|
||||||
|
|
||||||
|
return &listing, hasIndex, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSortOrder gets and stores for a Listing the 'sort' and 'order',
|
||||||
|
// and reads 'limit' if given. The latter is 0 if not given.
|
||||||
|
//
|
||||||
|
// This sets Cookies.
|
||||||
|
func (b Browse) handleSortOrder(w http.ResponseWriter, r *http.Request, scope string) (sort string, order string, limit int, err error) {
|
||||||
|
sort, order, limitQuery := r.URL.Query().Get("sort"), r.URL.Query().Get("order"), r.URL.Query().Get("limit")
|
||||||
|
|
||||||
|
// If the query 'sort' or 'order' is empty, use defaults or any values previously saved in Cookies
|
||||||
|
switch sort {
|
||||||
|
case "":
|
||||||
|
sort = "name"
|
||||||
|
if sortCookie, sortErr := r.Cookie("sort"); sortErr == nil {
|
||||||
|
sort = sortCookie.Value
|
||||||
|
}
|
||||||
|
case "name", "size", "type":
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: "sort", Value: sort, Path: scope, Secure: r.TLS != nil})
|
||||||
|
}
|
||||||
|
|
||||||
|
switch order {
|
||||||
|
case "":
|
||||||
|
order = "asc"
|
||||||
|
if orderCookie, orderErr := r.Cookie("order"); orderErr == nil {
|
||||||
|
order = orderCookie.Value
|
||||||
|
}
|
||||||
|
case "asc", "desc":
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: "order", Value: order, Path: scope, Secure: r.TLS != nil})
|
||||||
|
}
|
||||||
|
|
||||||
|
if limitQuery != "" {
|
||||||
|
limit, err = strconv.Atoi(limitQuery)
|
||||||
|
if err != nil { // if the 'limit' query can't be interpreted as a number, return err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeListing returns a formatted view of 'requestedFilepath' contents'.
|
||||||
|
func (b Browse) ServeListing(w http.ResponseWriter, r *http.Request, requestedFilepath http.File, bc *Config) (int, error) {
|
||||||
|
listing, containsIndex, err := b.loadDirectoryContents(requestedFilepath, r.URL.Path)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case os.IsPermission(err):
|
||||||
|
return http.StatusForbidden, err
|
||||||
|
case os.IsExist(err):
|
||||||
|
return http.StatusGone, err
|
||||||
|
default:
|
||||||
|
return http.StatusInternalServerError, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if containsIndex && !b.IgnoreIndexes { // directory isn't browsable
|
||||||
|
return b.Next.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
listing.Context = httpserver.Context{
|
||||||
|
Root: bc.Root,
|
||||||
|
Req: r,
|
||||||
|
URL: r.URL,
|
||||||
|
}
|
||||||
|
listing.User = bc.Variables
|
||||||
|
|
||||||
|
// Copy the query values into the Listing struct
|
||||||
|
var limit int
|
||||||
|
listing.Sort, listing.Order, limit, err = b.handleSortOrder(w, r, bc.PathScope)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusBadRequest, err
|
||||||
|
}
|
||||||
|
|
||||||
|
listing.applySort()
|
||||||
|
|
||||||
|
if limit > 0 && limit <= len(listing.Items) {
|
||||||
|
listing.Items = listing.Items[:limit]
|
||||||
|
listing.ItemsLimitedTo = limit
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf *bytes.Buffer
|
||||||
|
acceptHeader := strings.ToLower(strings.Join(r.Header["Accept"], ","))
|
||||||
|
switch {
|
||||||
|
case strings.Contains(acceptHeader, "application/json"):
|
||||||
|
if buf, err = b.formatAsJSON(listing, bc); err != nil {
|
||||||
|
return http.StatusInternalServerError, err
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
|
||||||
|
default: // There's no 'application/json' in the 'Accept' header; browse normally
|
||||||
|
if buf, err = b.formatAsHTML(listing, bc); err != nil {
|
||||||
|
return http.StatusInternalServerError, err
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
buf.WriteTo(w)
|
||||||
|
|
||||||
|
return http.StatusOK, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b Browse) formatAsJSON(listing *Listing, bc *Config) (*bytes.Buffer, error) {
|
||||||
|
marsh, err := json.Marshal(listing.Items)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
_, err = buf.Write(marsh)
|
||||||
|
return buf, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b Browse) formatAsHTML(listing *Listing, bc *Config) (*bytes.Buffer, error) {
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
err := bc.Template.Execute(buf, listing)
|
||||||
|
return buf, err
|
||||||
|
}
|
355
caddyhttp/browse/browse_test.go
Normal file
355
caddyhttp/browse/browse_test.go
Normal file
|
@ -0,0 +1,355 @@
|
||||||
|
package browse
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"testing"
|
||||||
|
"text/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mholt/caddy/caddyhttp/httpserver"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSort(t *testing.T) {
|
||||||
|
// making up []fileInfo with bogus values;
|
||||||
|
// to be used to make up our "listing"
|
||||||
|
fileInfos := []FileInfo{
|
||||||
|
{
|
||||||
|
Name: "fizz",
|
||||||
|
Size: 4,
|
||||||
|
ModTime: time.Now().AddDate(-1, 1, 0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "buzz",
|
||||||
|
Size: 2,
|
||||||
|
ModTime: time.Now().AddDate(0, -3, 3),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "bazz",
|
||||||
|
Size: 1,
|
||||||
|
ModTime: time.Now().AddDate(0, -2, -23),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "jazz",
|
||||||
|
Size: 3,
|
||||||
|
ModTime: time.Now(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
listing := Listing{
|
||||||
|
Name: "foobar",
|
||||||
|
Path: "/fizz/buzz",
|
||||||
|
CanGoUp: false,
|
||||||
|
Items: fileInfos,
|
||||||
|
}
|
||||||
|
|
||||||
|
// sort by name
|
||||||
|
listing.Sort = "name"
|
||||||
|
listing.applySort()
|
||||||
|
if !sort.IsSorted(byName(listing)) {
|
||||||
|
t.Errorf("The listing isn't name sorted: %v", listing.Items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sort by size
|
||||||
|
listing.Sort = "size"
|
||||||
|
listing.applySort()
|
||||||
|
if !sort.IsSorted(bySize(listing)) {
|
||||||
|
t.Errorf("The listing isn't size sorted: %v", listing.Items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sort by Time
|
||||||
|
listing.Sort = "time"
|
||||||
|
listing.applySort()
|
||||||
|
if !sort.IsSorted(byTime(listing)) {
|
||||||
|
t.Errorf("The listing isn't time sorted: %v", listing.Items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reverse by name
|
||||||
|
listing.Sort = "name"
|
||||||
|
listing.Order = "desc"
|
||||||
|
listing.applySort()
|
||||||
|
if !isReversed(byName(listing)) {
|
||||||
|
t.Errorf("The listing isn't reversed by name: %v", listing.Items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reverse by size
|
||||||
|
listing.Sort = "size"
|
||||||
|
listing.Order = "desc"
|
||||||
|
listing.applySort()
|
||||||
|
if !isReversed(bySize(listing)) {
|
||||||
|
t.Errorf("The listing isn't reversed by size: %v", listing.Items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reverse by time
|
||||||
|
listing.Sort = "time"
|
||||||
|
listing.Order = "desc"
|
||||||
|
listing.applySort()
|
||||||
|
if !isReversed(byTime(listing)) {
|
||||||
|
t.Errorf("The listing isn't reversed by time: %v", listing.Items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrowseHTTPMethods(t *testing.T) {
|
||||||
|
tmpl, err := template.ParseFiles("testdata/photos.tpl")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("An error occured while parsing the template: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
b := Browse{
|
||||||
|
Next: httpserver.HandlerFunc(func(w http.ResponseWriter, r *http.Request) (int, error) {
|
||||||
|
return http.StatusTeapot, nil // not t.Fatalf, or we will not see what other methods yield
|
||||||
|
}),
|
||||||
|
Configs: []Config{
|
||||||
|
{
|
||||||
|
PathScope: "/photos",
|
||||||
|
Root: http.Dir("./testdata"),
|
||||||
|
Template: tmpl,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
for method, expected := range map[string]int{
|
||||||
|
http.MethodGet: http.StatusOK,
|
||||||
|
http.MethodHead: http.StatusOK,
|
||||||
|
http.MethodOptions: http.StatusNotImplemented,
|
||||||
|
"PROPFIND": http.StatusNotImplemented,
|
||||||
|
} {
|
||||||
|
req, err := http.NewRequest(method, "/photos/", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Test: Could not create HTTP request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, _ := b.ServeHTTP(rec, req)
|
||||||
|
if code != expected {
|
||||||
|
t.Errorf("Wrong status with HTTP Method %s: expected %d, got %d", method, expected, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrowseTemplate(t *testing.T) {
|
||||||
|
tmpl, err := template.ParseFiles("testdata/photos.tpl")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("An error occured while parsing the template: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
b := Browse{
|
||||||
|
Next: httpserver.HandlerFunc(func(w http.ResponseWriter, r *http.Request) (int, error) {
|
||||||
|
t.Fatalf("Next shouldn't be called")
|
||||||
|
return 0, nil
|
||||||
|
}),
|
||||||
|
Configs: []Config{
|
||||||
|
{
|
||||||
|
PathScope: "/photos",
|
||||||
|
Root: http.Dir("./testdata"),
|
||||||
|
Template: tmpl,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", "/photos/", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Test: Could not create HTTP request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
code, _ := b.ServeHTTP(rec, req)
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("Wrong status, expected %d, got %d", http.StatusOK, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
respBody := rec.Body.String()
|
||||||
|
expectedBody := `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Template</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Header</h1>
|
||||||
|
|
||||||
|
<h1>/photos/</h1>
|
||||||
|
|
||||||
|
<a href="./test.html">test.html</a><br>
|
||||||
|
|
||||||
|
<a href="./test2.html">test2.html</a><br>
|
||||||
|
|
||||||
|
<a href="./test3.html">test3.html</a><br>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
|
||||||
|
if respBody != expectedBody {
|
||||||
|
t.Fatalf("Expected body: %v got: %v", expectedBody, respBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrowseJson(t *testing.T) {
|
||||||
|
b := Browse{
|
||||||
|
Next: httpserver.HandlerFunc(func(w http.ResponseWriter, r *http.Request) (int, error) {
|
||||||
|
t.Fatalf("Next shouldn't be called")
|
||||||
|
return 0, nil
|
||||||
|
}),
|
||||||
|
Configs: []Config{
|
||||||
|
{
|
||||||
|
PathScope: "/photos/",
|
||||||
|
Root: http.Dir("./testdata"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
//Getting the listing from the ./testdata/photos, the listing returned will be used to validate test results
|
||||||
|
testDataPath := filepath.Join("./testdata", "photos")
|
||||||
|
file, err := os.Open(testDataPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsPermission(err) {
|
||||||
|
t.Fatalf("Os Permission Error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
files, err := file.Readdir(-1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Unable to Read Contents of the directory")
|
||||||
|
}
|
||||||
|
var fileinfos []FileInfo
|
||||||
|
|
||||||
|
for i, f := range files {
|
||||||
|
name := f.Name()
|
||||||
|
|
||||||
|
// Tests fail in CI environment because all file mod times are the same for
|
||||||
|
// some reason, making the sorting unpredictable. To hack around this,
|
||||||
|
// we ensure here that each file has a different mod time.
|
||||||
|
chTime := f.ModTime().UTC().Add(-(time.Duration(i) * time.Second))
|
||||||
|
if err := os.Chtimes(filepath.Join(testDataPath, name), chTime, chTime); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.IsDir() {
|
||||||
|
name += "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
url := url.URL{Path: "./" + name}
|
||||||
|
|
||||||
|
fileinfos = append(fileinfos, FileInfo{
|
||||||
|
IsDir: f.IsDir(),
|
||||||
|
Name: f.Name(),
|
||||||
|
Size: f.Size(),
|
||||||
|
URL: url.String(),
|
||||||
|
ModTime: chTime,
|
||||||
|
Mode: f.Mode(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
listing := Listing{Items: fileinfos} // this listing will be used for validation inside the tests
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
QueryURL string
|
||||||
|
SortBy string
|
||||||
|
OrderBy string
|
||||||
|
Limit int
|
||||||
|
shouldErr bool
|
||||||
|
expectedResult []FileInfo
|
||||||
|
}{
|
||||||
|
//test case 1: testing for default sort and order and without the limit parameter, default sort is by name and the default order is ascending
|
||||||
|
//without the limit query entire listing will be produced
|
||||||
|
{"/", "", "", -1, false, listing.Items},
|
||||||
|
//test case 2: limit is set to 1, orderBy and sortBy is default
|
||||||
|
{"/?limit=1", "", "", 1, false, listing.Items[:1]},
|
||||||
|
//test case 3 : if the listing request is bigger than total size of listing then it should return everything
|
||||||
|
{"/?limit=100000000", "", "", 100000000, false, listing.Items},
|
||||||
|
//test case 4 : testing for negative limit
|
||||||
|
{"/?limit=-1", "", "", -1, false, listing.Items},
|
||||||
|
//test case 5 : testing with limit set to -1 and order set to descending
|
||||||
|
{"/?limit=-1&order=desc", "", "desc", -1, false, listing.Items},
|
||||||
|
//test case 6 : testing with limit set to 2 and order set to descending
|
||||||
|
{"/?limit=2&order=desc", "", "desc", 2, false, listing.Items},
|
||||||
|
//test case 7 : testing with limit set to 3 and order set to descending
|
||||||
|
{"/?limit=3&order=desc", "", "desc", 3, false, listing.Items},
|
||||||
|
//test case 8 : testing with limit set to 3 and order set to ascending
|
||||||
|
{"/?limit=3&order=asc", "", "asc", 3, false, listing.Items},
|
||||||
|
//test case 9 : testing with limit set to 1111111 and order set to ascending
|
||||||
|
{"/?limit=1111111&order=asc", "", "asc", 1111111, false, listing.Items},
|
||||||
|
//test case 10 : testing with limit set to default and order set to ascending and sorting by size
|
||||||
|
{"/?order=asc&sort=size", "size", "asc", -1, false, listing.Items},
|
||||||
|
//test case 11 : testing with limit set to default and order set to ascending and sorting by last modified
|
||||||
|
{"/?order=asc&sort=time", "time", "asc", -1, false, listing.Items},
|
||||||
|
//test case 12 : testing with limit set to 1 and order set to ascending and sorting by last modified
|
||||||
|
{"/?order=asc&sort=time&limit=1", "time", "asc", 1, false, listing.Items},
|
||||||
|
//test case 13 : testing with limit set to -100 and order set to ascending and sorting by last modified
|
||||||
|
{"/?order=asc&sort=time&limit=-100", "time", "asc", -100, false, listing.Items},
|
||||||
|
//test case 14 : testing with limit set to -100 and order set to ascending and sorting by size
|
||||||
|
{"/?order=asc&sort=size&limit=-100", "size", "asc", -100, false, listing.Items},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, test := range tests {
|
||||||
|
var marsh []byte
|
||||||
|
req, err := http.NewRequest("GET", "/photos"+test.QueryURL, nil)
|
||||||
|
|
||||||
|
if err == nil && test.shouldErr {
|
||||||
|
t.Errorf("Test %d didn't error, but it should have", i)
|
||||||
|
} else if err != nil && !test.shouldErr {
|
||||||
|
t.Errorf("Test %d errored, but it shouldn't have; got '%v'", i, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
code, err := b.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("In test %d: Wrong status, expected %d, got %d", i, http.StatusOK, code)
|
||||||
|
}
|
||||||
|
if rec.HeaderMap.Get("Content-Type") != "application/json; charset=utf-8" {
|
||||||
|
t.Fatalf("Expected Content type to be application/json; charset=utf-8, but got %s ", rec.HeaderMap.Get("Content-Type"))
|
||||||
|
}
|
||||||
|
|
||||||
|
actualJSONResponse := rec.Body.String()
|
||||||
|
copyOflisting := listing
|
||||||
|
if test.SortBy == "" {
|
||||||
|
copyOflisting.Sort = "name"
|
||||||
|
} else {
|
||||||
|
copyOflisting.Sort = test.SortBy
|
||||||
|
}
|
||||||
|
if test.OrderBy == "" {
|
||||||
|
copyOflisting.Order = "asc"
|
||||||
|
} else {
|
||||||
|
copyOflisting.Order = test.OrderBy
|
||||||
|
}
|
||||||
|
|
||||||
|
copyOflisting.applySort()
|
||||||
|
|
||||||
|
limit := test.Limit
|
||||||
|
if limit <= len(copyOflisting.Items) && limit > 0 {
|
||||||
|
marsh, err = json.Marshal(copyOflisting.Items[:limit])
|
||||||
|
} else { // if the 'limit' query is empty, or has the wrong value, list everything
|
||||||
|
marsh, err = json.Marshal(copyOflisting.Items)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Unable to Marshal the listing ")
|
||||||
|
}
|
||||||
|
expectedJSON := string(marsh)
|
||||||
|
|
||||||
|
if actualJSONResponse != expectedJSON {
|
||||||
|
t.Errorf("JSON response doesn't match the expected for test number %d with sort=%s, order=%s\nExpected response %s\nActual response = %s\n",
|
||||||
|
i+1, test.SortBy, test.OrderBy, expectedJSON, actualJSONResponse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "sort" package has "IsSorted" function, but no "IsReversed";
|
||||||
|
func isReversed(data sort.Interface) bool {
|
||||||
|
n := data.Len()
|
||||||
|
for i := n - 1; i > 0; i-- {
|
||||||
|
if !data.Less(i, i-1) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
428
caddyhttp/browse/setup.go
Normal file
428
caddyhttp/browse/setup.go
Normal file
|
@ -0,0 +1,428 @@
|
||||||
|
package browse
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
|
"github.com/mholt/caddy"
|
||||||
|
"github.com/mholt/caddy/caddyhttp/httpserver"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
caddy.RegisterPlugin(caddy.Plugin{
|
||||||
|
Name: "browse",
|
||||||
|
ServerType: "http",
|
||||||
|
Action: setup,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// setup configures a new Browse middleware instance.
|
||||||
|
func setup(c *caddy.Controller) error {
|
||||||
|
configs, err := browseParse(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
b := Browse{
|
||||||
|
Configs: configs,
|
||||||
|
IgnoreIndexes: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
httpserver.GetConfig(c.Key).AddMiddleware(func(next httpserver.Handler) httpserver.Handler {
|
||||||
|
b.Next = next
|
||||||
|
return b
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func browseParse(c *caddy.Controller) ([]Config, error) {
|
||||||
|
var configs []Config
|
||||||
|
|
||||||
|
cfg := httpserver.GetConfig(c.Key)
|
||||||
|
|
||||||
|
appendCfg := func(bc Config) error {
|
||||||
|
for _, c := range configs {
|
||||||
|
if c.PathScope == bc.PathScope {
|
||||||
|
return fmt.Errorf("duplicate browsing config for %s", c.PathScope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
configs = append(configs, bc)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for c.Next() {
|
||||||
|
var bc Config
|
||||||
|
|
||||||
|
// First argument is directory to allow browsing; default is site root
|
||||||
|
if c.NextArg() {
|
||||||
|
bc.PathScope = c.Val()
|
||||||
|
} else {
|
||||||
|
bc.PathScope = "/"
|
||||||
|
}
|
||||||
|
bc.Root = http.Dir(cfg.Root)
|
||||||
|
theRoot, err := bc.Root.Open("/") // catch a missing path early
|
||||||
|
if err != nil {
|
||||||
|
return configs, err
|
||||||
|
}
|
||||||
|
defer theRoot.Close()
|
||||||
|
_, err = theRoot.Readdir(-1)
|
||||||
|
if err != nil {
|
||||||
|
return configs, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second argument would be the template file to use
|
||||||
|
var tplText string
|
||||||
|
if c.NextArg() {
|
||||||
|
tplBytes, err := ioutil.ReadFile(c.Val())
|
||||||
|
if err != nil {
|
||||||
|
return configs, err
|
||||||
|
}
|
||||||
|
tplText = string(tplBytes)
|
||||||
|
} else {
|
||||||
|
tplText = defaultTemplate
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the template
|
||||||
|
tpl, err := template.New("listing").Parse(tplText)
|
||||||
|
if err != nil {
|
||||||
|
return configs, err
|
||||||
|
}
|
||||||
|
bc.Template = tpl
|
||||||
|
|
||||||
|
// Save configuration
|
||||||
|
err = appendCfg(bc)
|
||||||
|
if err != nil {
|
||||||
|
return configs, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return configs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The default template to use when serving up directory listings
|
||||||
|
const defaultTemplate = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>{{.Name}}</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<style>
|
||||||
|
* { padding: 0; margin: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: sans-serif;
|
||||||
|
text-rendering: optimizespeed;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #006ed3;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover,
|
||||||
|
h1 a:hover {
|
||||||
|
color: #319cff;
|
||||||
|
}
|
||||||
|
|
||||||
|
header,
|
||||||
|
#summary {
|
||||||
|
padding-left: 5%;
|
||||||
|
padding-right: 5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:first-child,
|
||||||
|
td:first-child {
|
||||||
|
padding-left: 5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:last-child,
|
||||||
|
td:last-child {
|
||||||
|
padding-right: 5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
padding-top: 25px;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
background-color: #f2f2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: normal;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow-x: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: Verdana, sans-serif;
|
||||||
|
border-bottom: 1px solid #9C9C9C;
|
||||||
|
padding-top: 15px;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
margin-right: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr {
|
||||||
|
border-bottom: 1px dashed #dadada;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:not(:first-child):hover {
|
||||||
|
background-color: #ffffec;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
padding-top: 15px;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
font-size: 16px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
th a {
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
th svg {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
td:first-child {
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:last-child,
|
||||||
|
td:last-child {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
td:first-child svg {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
td .name,
|
||||||
|
td .goup {
|
||||||
|
margin-left: 1.75em;
|
||||||
|
word-break: break-all;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
padding: 40px 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.hideable {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
td:first-child {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:nth-child(2),
|
||||||
|
td:nth-child(2) {
|
||||||
|
padding-right: 5%;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="0" width="0" style="position: absolute;">
|
||||||
|
<defs>
|
||||||
|
<!-- Folder -->
|
||||||
|
<linearGradient id="f" y2="640" gradientUnits="userSpaceOnUse" x2="244.84" gradientTransform="matrix(.97319 0 0 1.0135 -.50695 -13.679)" y1="415.75" x1="244.84">
|
||||||
|
<stop stop-color="#b3ddfd" offset="0"/>
|
||||||
|
<stop stop-color="#69c" offset="1"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="e" y2="571.06" gradientUnits="userSpaceOnUse" x2="238.03" gradientTransform="translate(0,2)" y1="346.05" x1="236.26">
|
||||||
|
<stop stop-color="#ace" offset="0"/>
|
||||||
|
<stop stop-color="#369" offset="1"/>
|
||||||
|
</linearGradient>
|
||||||
|
<g id="folder" transform="translate(-266.06 -193.36)">
|
||||||
|
<g transform="matrix(.066019 0 0 .066019 264.2 170.93)">
|
||||||
|
<g transform="matrix(1.4738 0 0 1.4738 -52.053 -166.93)">
|
||||||
|
<path fill="#69c" d="m98.424 343.78c-11.08 0-20 8.92-20 20v48.5 33.719 105.06c0 11.08 8.92 20 20 20h279.22c11.08 0 20-8.92 20-20v-138.78c0-11.08-8.92-20-20-20h-117.12c-7.5478-1.1844-9.7958-6.8483-10.375-11.312v-5.625-11.562c0-11.08-8.92-20-20-20h-131.72z"/>
|
||||||
|
<rect rx="12.885" ry="12.199" height="227.28" width="366.69" y="409.69" x="54.428" fill="#369"/>
|
||||||
|
<path fill="url(#e)" d="m98.424 345.78c-11.08 0-20 8.92-20 20v48.5 33.719 105.06c0 11.08 8.92 20 20 20h279.22c11.08 0 20-8.92 20-20v-138.78c0-11.08-8.92-20-20-20h-117.12c-7.5478-1.1844-9.7958-6.8483-10.375-11.312v-5.625-11.562c0-11.08-8.92-20-20-20h-131.72z"/>
|
||||||
|
<rect rx="12.885" ry="12.199" height="227.28" width="366.69" y="407.69" x="54.428" fill="url(#f)"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- File -->
|
||||||
|
<linearGradient id="a">
|
||||||
|
<stop stop-color="#cbcbcb" offset="0"/>
|
||||||
|
<stop stop-color="#f0f0f0" offset=".34923"/>
|
||||||
|
<stop stop-color="#e2e2e2" offset="1"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="d" y2="686.15" xlink:href="#a" gradientUnits="userSpaceOnUse" y1="207.83" gradientTransform="matrix(.28346 0 0 .31053 -608.52 485.11)" x2="380.1" x1="749.25"/>
|
||||||
|
<linearGradient id="c" y2="287.74" xlink:href="#a" gradientUnits="userSpaceOnUse" y1="169.44" gradientTransform="matrix(.28342 0 0 .31057 -608.52 485.11)" x2="622.33" x1="741.64"/>
|
||||||
|
<linearGradient id="b" y2="418.54" gradientUnits="userSpaceOnUse" y1="236.13" gradientTransform="matrix(.29343 0 0 .29999 -608.52 485.11)" x2="330.88" x1="687.96">
|
||||||
|
<stop stop-color="#fff" offset="0"/>
|
||||||
|
<stop stop-color="#fff" stop-opacity="0" offset="1"/>
|
||||||
|
</linearGradient>
|
||||||
|
<g id="file" transform="translate(-278.15 -216.59)">
|
||||||
|
<g fill-rule="evenodd" transform="matrix(.19775 0 0 .19775 381.05 112.68)">
|
||||||
|
<path d="m-520.17 525.5v36.739 36.739 36.739 36.739h33.528 33.528 33.528 33.528v-36.739-36.739-36.739l-33.528-36.739h-33.528-33.528-33.528z" stroke-opacity=".36478" stroke-width=".42649" fill="#fff"/>
|
||||||
|
<g>
|
||||||
|
<path d="m-520.11 525.68v36.739 36.739 36.739 36.739h33.528 33.528 33.528 33.528v-36.739-36.739-36.739l-33.528-36.739h-33.528-33.528-33.528z" stroke-opacity=".36478" stroke="#000" stroke-width=".42649" fill="url(#d)"/>
|
||||||
|
<path d="m-386 562.42c-10.108-2.9925-23.206-2.5682-33.101-0.86253 1.7084-10.962 1.922-24.701-0.4271-35.877l33.528 36.739z" stroke-width=".95407pt" fill="url(#c)"/>
|
||||||
|
<path d="m-519.13 537-0.60402 134.7h131.68l0.0755-33.296c-2.9446 1.1325-32.692-40.998-70.141-39.186-37.483 1.8137-27.785-56.777-61.006-62.214z" stroke-width="1pt" fill="url(#b)"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Up arrow -->
|
||||||
|
<g id="up-arrow" transform="translate(-279.22 -208.12)">
|
||||||
|
<path transform="matrix(.22413 0 0 .12089 335.67 164.35)" stroke-width="0" d="m-194.17 412.01h-28.827-28.827l14.414-24.965 14.414-24.965 14.414 24.965z"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Down arrow -->
|
||||||
|
<g id="down-arrow" transform="translate(-279.22 -208.12)">
|
||||||
|
<path transform="matrix(.22413 0 0 -.12089 335.67 257.93)" stroke-width="0" d="m-194.17 412.01h-28.827-28.827l14.414-24.965 14.414-24.965 14.414 24.965z"/>
|
||||||
|
</g>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1>
|
||||||
|
{{range $url, $name := .BreadcrumbMap}}<a href="{{$url}}">{{$name}}</a>{{if ne $url "/"}}/{{end}}{{end}}
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<div class="meta">
|
||||||
|
<div id="summary">
|
||||||
|
<span class="meta-item"><b>{{.NumDirs}}</b> director{{if eq 1 .NumDirs}}y{{else}}ies{{end}}</span>
|
||||||
|
<span class="meta-item"><b>{{.NumFiles}}</b> file{{if ne 1 .NumFiles}}s{{end}}</span>
|
||||||
|
{{- if ne 0 .ItemsLimitedTo}}
|
||||||
|
<span class="meta-item">(of which only <b>{{.ItemsLimitedTo}}</b> are displayed)</span>
|
||||||
|
{{- end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="listing">
|
||||||
|
<table aria-describedby="summary">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
{{- if and (eq .Sort "name") (ne .Order "desc")}}
|
||||||
|
<a href="?sort=name&order=desc{{if ne 0 .ItemsLimitedTo}}&limit={{.ItemsLimitedTo}}{{end}}">Name <svg width="1em" height=".4em" version="1.1" viewBox="0 0 12.922194 6.0358899"><use xlink:href="#up-arrow"></use></svg></a>
|
||||||
|
{{- else if and (eq .Sort "name") (ne .Order "asc")}}
|
||||||
|
<a href="?sort=name&order=asc{{if ne 0 .ItemsLimitedTo}}&limit={{.ItemsLimitedTo}}{{end}}">Name <svg width="1em" height=".4em" version="1.1" viewBox="0 0 12.922194 6.0358899"><use xlink:href="#down-arrow"></use></svg></a>
|
||||||
|
{{- else}}
|
||||||
|
<a href="?sort=name&order=asc{{if ne 0 .ItemsLimitedTo}}&limit={{.ItemsLimitedTo}}{{end}}">Name</a>
|
||||||
|
{{- end}}
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
{{- if and (eq .Sort "size") (ne .Order "desc")}}
|
||||||
|
<a href="?sort=size&order=desc{{if ne 0 .ItemsLimitedTo}}&limit={{.ItemsLimitedTo}}{{end}}">Size <svg width="1em" height=".4em" version="1.1" viewBox="0 0 12.922194 6.0358899"><use xlink:href="#up-arrow"></use></svg></a>
|
||||||
|
{{- else if and (eq .Sort "size") (ne .Order "asc")}}
|
||||||
|
<a href="?sort=size&order=asc{{if ne 0 .ItemsLimitedTo}}&limit={{.ItemsLimitedTo}}{{end}}">Size <svg width="1em" height=".4em" version="1.1" viewBox="0 0 12.922194 6.0358899"><use xlink:href="#down-arrow"></use></svg></a>
|
||||||
|
{{- else}}
|
||||||
|
<a href="?sort=size&order=asc{{if ne 0 .ItemsLimitedTo}}&limit={{.ItemsLimitedTo}}{{end}}">Size</a>
|
||||||
|
{{- end}}
|
||||||
|
</th>
|
||||||
|
<th class="hideable">
|
||||||
|
{{- if and (eq .Sort "time") (ne .Order "desc")}}
|
||||||
|
<a href="?sort=time&order=desc{{if ne 0 .ItemsLimitedTo}}&limit={{.ItemsLimitedTo}}{{end}}">Modified <svg width="1em" height=".4em" version="1.1" viewBox="0 0 12.922194 6.0358899"><use xlink:href="#up-arrow"></use></svg></a>
|
||||||
|
{{- else if and (eq .Sort "time") (ne .Order "asc")}}
|
||||||
|
<a href="?sort=time&order=asc{{if ne 0 .ItemsLimitedTo}}&limit={{.ItemsLimitedTo}}{{end}}">Modified <svg width="1em" height=".4em" version="1.1" viewBox="0 0 12.922194 6.0358899"><use xlink:href="#down-arrow"></use></svg></a>
|
||||||
|
{{- else}}
|
||||||
|
<a href="?sort=time&order=asc{{if ne 0 .ItemsLimitedTo}}&limit={{.ItemsLimitedTo}}{{end}}">Modified</a>
|
||||||
|
{{- end}}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{- if .CanGoUp}}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="..">
|
||||||
|
<span class="goup">Go up</span>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td class="hideable">—</td>
|
||||||
|
</tr>
|
||||||
|
{{- end}}
|
||||||
|
{{- range .Items}}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{.URL}}">
|
||||||
|
{{- if .IsDir}}
|
||||||
|
<svg width="1.5em" height="1em" version="1.1" viewBox="0 0 35.678803 28.527945"><use xlink:href="#folder"></use></svg>
|
||||||
|
{{- else}}
|
||||||
|
<svg width="1.5em" height="1em" version="1.1" viewBox="0 0 26.604381 29.144726"><use xlink:href="#file"></use></svg>
|
||||||
|
{{- end}}
|
||||||
|
<span class="name">{{.Name}}</span>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
{{- if .IsDir}}
|
||||||
|
<td data-order="-1">—</td>
|
||||||
|
{{- else}}
|
||||||
|
<td data-order="{{.Size}}">{{.HumanSize}}</td>
|
||||||
|
{{- end}}
|
||||||
|
<td class="hideable"><time datetime="{{.HumanModTime "2006-01-02T15:04:05Z"}}">{{.HumanModTime "01/02/2006 03:04:05 PM -07:00"}}</time></td>
|
||||||
|
</tr>
|
||||||
|
{{- end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<footer>
|
||||||
|
Served with <a rel="noopener noreferrer" href="https://caddyserver.com">Caddy</a>.
|
||||||
|
</footer>
|
||||||
|
<script type="text/javascript">
|
||||||
|
function localizeDatetime(e, index, ar) {
|
||||||
|
if (e.textContent === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var d = new Date(e.getAttribute('datetime'));
|
||||||
|
if (isNaN(d)) {
|
||||||
|
d = new Date(e.textContent);
|
||||||
|
if (isNaN(d)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.textContent = d.toLocaleString();
|
||||||
|
}
|
||||||
|
var timeList = Array.prototype.slice.call(document.getElementsByTagName("time"));
|
||||||
|
timeList.forEach(localizeDatetime);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
68
caddyhttp/browse/setup_test.go
Normal file
68
caddyhttp/browse/setup_test.go
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
package browse
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mholt/caddy"
|
||||||
|
"github.com/mholt/caddy/caddyhttp/httpserver"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSetup(t *testing.T) {
|
||||||
|
tempDirPath := os.TempDir()
|
||||||
|
_, err := os.Stat(tempDirPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BeforeTest: Failed to find an existing directory for testing! Error was: %v", err)
|
||||||
|
}
|
||||||
|
nonExistantDirPath := filepath.Join(tempDirPath, strconv.Itoa(int(time.Now().UnixNano())))
|
||||||
|
|
||||||
|
tempTemplate, err := ioutil.TempFile(".", "tempTemplate")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BeforeTest: Failed to create a temporary file in the working directory! Error was: %v", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tempTemplate.Name())
|
||||||
|
|
||||||
|
tempTemplatePath := filepath.Join(".", tempTemplate.Name())
|
||||||
|
|
||||||
|
for i, test := range []struct {
|
||||||
|
input string
|
||||||
|
expectedPathScope []string
|
||||||
|
shouldErr bool
|
||||||
|
}{
|
||||||
|
// test case #0 tests handling of multiple pathscopes
|
||||||
|
{"browse " + tempDirPath + "\n browse .", []string{tempDirPath, "."}, false},
|
||||||
|
|
||||||
|
// test case #1 tests instantiation of Config with default values
|
||||||
|
{"browse /", []string{"/"}, false},
|
||||||
|
|
||||||
|
// test case #2 tests detectaction of custom template
|
||||||
|
{"browse . " + tempTemplatePath, []string{"."}, false},
|
||||||
|
|
||||||
|
// test case #3 tests detection of non-existent template
|
||||||
|
{"browse . " + nonExistantDirPath, nil, true},
|
||||||
|
|
||||||
|
// test case #4 tests detection of duplicate pathscopes
|
||||||
|
{"browse " + tempDirPath + "\n browse " + tempDirPath, nil, true},
|
||||||
|
} {
|
||||||
|
|
||||||
|
err := setup(caddy.NewTestController(test.input))
|
||||||
|
if err != nil && !test.shouldErr {
|
||||||
|
t.Errorf("Test case #%d recieved an error of %v", i, err)
|
||||||
|
}
|
||||||
|
if test.expectedPathScope == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mids := httpserver.GetConfig("").Middleware()
|
||||||
|
mid := mids[len(mids)-1]
|
||||||
|
recievedConfigs := mid(nil).(Browse).Configs
|
||||||
|
for j, config := range recievedConfigs {
|
||||||
|
if config.PathScope != test.expectedPathScope[j] {
|
||||||
|
t.Errorf("Test case #%d expected a pathscope of %v, but got %v", i, test.expectedPathScope, config.PathScope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
1
caddyhttp/browse/testdata/header.html
vendored
Normal file
1
caddyhttp/browse/testdata/header.html
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<h1>Header</h1>
|
13
caddyhttp/browse/testdata/photos.tpl
vendored
Normal file
13
caddyhttp/browse/testdata/photos.tpl
vendored
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Template</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{.Include "header.html"}}
|
||||||
|
<h1>{{.Path}}</h1>
|
||||||
|
{{range .Items}}
|
||||||
|
<a href="{{.URL}}">{{.Name}}</a><br>
|
||||||
|
{{end}}
|
||||||
|
</body>
|
||||||
|
</html>
|
8
caddyhttp/browse/testdata/photos/test.html
vendored
Normal file
8
caddyhttp/browse/testdata/photos/test.html
vendored
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Test</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
</body>
|
||||||
|
</html>
|
8
caddyhttp/browse/testdata/photos/test2.html
vendored
Normal file
8
caddyhttp/browse/testdata/photos/test2.html
vendored
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Test 2</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
</body>
|
||||||
|
</html>
|
3
caddyhttp/browse/testdata/photos/test3.html
vendored
Normal file
3
caddyhttp/browse/testdata/photos/test3.html
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
</html>
|
|
@ -5,10 +5,25 @@ import (
|
||||||
_ "github.com/mholt/caddy/caddyhttp/httpserver"
|
_ "github.com/mholt/caddy/caddyhttp/httpserver"
|
||||||
|
|
||||||
// plug in the standard directives
|
// plug in the standard directives
|
||||||
|
_ "github.com/mholt/caddy/caddyhttp/basicauth"
|
||||||
_ "github.com/mholt/caddy/caddyhttp/bind"
|
_ "github.com/mholt/caddy/caddyhttp/bind"
|
||||||
|
_ "github.com/mholt/caddy/caddyhttp/browse"
|
||||||
_ "github.com/mholt/caddy/caddyhttp/errors"
|
_ "github.com/mholt/caddy/caddyhttp/errors"
|
||||||
|
_ "github.com/mholt/caddy/caddyhttp/expvar"
|
||||||
|
_ "github.com/mholt/caddy/caddyhttp/extensions"
|
||||||
|
_ "github.com/mholt/caddy/caddyhttp/fastcgi"
|
||||||
_ "github.com/mholt/caddy/caddyhttp/gzip"
|
_ "github.com/mholt/caddy/caddyhttp/gzip"
|
||||||
|
_ "github.com/mholt/caddy/caddyhttp/header"
|
||||||
|
_ "github.com/mholt/caddy/caddyhttp/internalsrv"
|
||||||
_ "github.com/mholt/caddy/caddyhttp/log"
|
_ "github.com/mholt/caddy/caddyhttp/log"
|
||||||
|
_ "github.com/mholt/caddy/caddyhttp/markdown"
|
||||||
|
_ "github.com/mholt/caddy/caddyhttp/mime"
|
||||||
|
_ "github.com/mholt/caddy/caddyhttp/pprof"
|
||||||
|
_ "github.com/mholt/caddy/caddyhttp/proxy"
|
||||||
|
_ "github.com/mholt/caddy/caddyhttp/redirect"
|
||||||
|
_ "github.com/mholt/caddy/caddyhttp/rewrite"
|
||||||
_ "github.com/mholt/caddy/caddyhttp/root"
|
_ "github.com/mholt/caddy/caddyhttp/root"
|
||||||
|
_ "github.com/mholt/caddy/caddyhttp/templates"
|
||||||
|
_ "github.com/mholt/caddy/caddyhttp/websocket"
|
||||||
_ "github.com/mholt/caddy/startupshutdown"
|
_ "github.com/mholt/caddy/startupshutdown"
|
||||||
)
|
)
|
||||||
|
|
336
caddyhttp/fastcgi/fastcgi.go
Normal file
336
caddyhttp/fastcgi/fastcgi.go
Normal file
|
@ -0,0 +1,336 @@
|
||||||
|
// Package fastcgi has middleware that acts as a FastCGI client. Requests
|
||||||
|
// that get forwarded to FastCGI stop the middleware execution chain.
|
||||||
|
// The most common use for this package is to serve PHP websites via php-fpm.
|
||||||
|
package fastcgi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mholt/caddy/caddyhttp/httpserver"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler is a middleware type that can handle requests as a FastCGI client.
|
||||||
|
type Handler struct {
|
||||||
|
Next httpserver.Handler
|
||||||
|
Rules []Rule
|
||||||
|
Root string
|
||||||
|
AbsRoot string // same as root, but absolute path
|
||||||
|
FileSys http.FileSystem
|
||||||
|
|
||||||
|
// These are sent to CGI scripts in env variables
|
||||||
|
SoftwareName string
|
||||||
|
SoftwareVersion string
|
||||||
|
ServerName string
|
||||||
|
ServerPort string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP satisfies the httpserver.Handler interface.
|
||||||
|
func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error) {
|
||||||
|
for _, rule := range h.Rules {
|
||||||
|
|
||||||
|
// First requirement: Base path must match and the path must be allowed.
|
||||||
|
if !httpserver.Path(r.URL.Path).Matches(rule.Path) || !rule.AllowedPath(r.URL.Path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// In addition to matching the path, a request must meet some
|
||||||
|
// other criteria before being proxied as FastCGI. For example,
|
||||||
|
// we probably want to exclude static assets (CSS, JS, images...)
|
||||||
|
// but we also want to be flexible for the script we proxy to.
|
||||||
|
|
||||||
|
fpath := r.URL.Path
|
||||||
|
|
||||||
|
if idx, ok := httpserver.IndexFile(h.FileSys, fpath, rule.IndexFiles); ok {
|
||||||
|
fpath = idx
|
||||||
|
// Index file present.
|
||||||
|
// If request path cannot be split, return error.
|
||||||
|
if !rule.canSplit(fpath) {
|
||||||
|
return http.StatusInternalServerError, ErrIndexMissingSplit
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No index file present.
|
||||||
|
// If request path cannot be split, ignore request.
|
||||||
|
if !rule.canSplit(fpath) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// These criteria work well in this order for PHP sites
|
||||||
|
if !h.exists(fpath) || fpath[len(fpath)-1] == '/' || strings.HasSuffix(fpath, rule.Ext) {
|
||||||
|
|
||||||
|
// Create environment for CGI script
|
||||||
|
env, err := h.buildEnv(r, rule, fpath)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusInternalServerError, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to FastCGI gateway
|
||||||
|
network, address := rule.parseAddress()
|
||||||
|
fcgiBackend, err := Dial(network, address)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusBadGateway, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp *http.Response
|
||||||
|
contentLength, _ := strconv.Atoi(r.Header.Get("Content-Length"))
|
||||||
|
switch r.Method {
|
||||||
|
case "HEAD":
|
||||||
|
resp, err = fcgiBackend.Head(env)
|
||||||
|
case "GET":
|
||||||
|
resp, err = fcgiBackend.Get(env)
|
||||||
|
case "OPTIONS":
|
||||||
|
resp, err = fcgiBackend.Options(env)
|
||||||
|
default:
|
||||||
|
resp, err = fcgiBackend.Post(env, r.Method, r.Header.Get("Content-Type"), r.Body, contentLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Body != nil {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return http.StatusBadGateway, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write response header
|
||||||
|
writeHeader(w, resp)
|
||||||
|
|
||||||
|
// Write the response body
|
||||||
|
_, err = io.Copy(w, resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusBadGateway, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log any stderr output from upstream
|
||||||
|
if fcgiBackend.stderr.Len() != 0 {
|
||||||
|
// Remove trailing newline, error logger already does this.
|
||||||
|
err = LogError(strings.TrimSuffix(fcgiBackend.stderr.String(), "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normally we would return the status code if it is an error status (>= 400),
|
||||||
|
// however, upstream FastCGI apps don't know about our contract and have
|
||||||
|
// probably already written an error page. So we just return 0, indicating
|
||||||
|
// that the response body is already written. However, we do return any
|
||||||
|
// error value so it can be logged.
|
||||||
|
// Note that the proxy middleware works the same way, returning status=0.
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return h.Next.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAddress returns the network and address of r.
|
||||||
|
// The first string is the network, "tcp" or "unix", implied from the scheme and address.
|
||||||
|
// The second string is r.Address, with scheme prefixes removed.
|
||||||
|
// The two returned strings can be used as parameters to the Dial() function.
|
||||||
|
func (r Rule) parseAddress() (string, string) {
|
||||||
|
// check if address has tcp scheme explicitly set
|
||||||
|
if strings.HasPrefix(r.Address, "tcp://") {
|
||||||
|
return "tcp", r.Address[len("tcp://"):]
|
||||||
|
}
|
||||||
|
// check if address has fastcgi scheme explicitly set
|
||||||
|
if strings.HasPrefix(r.Address, "fastcgi://") {
|
||||||
|
return "tcp", r.Address[len("fastcgi://"):]
|
||||||
|
}
|
||||||
|
// check if unix socket
|
||||||
|
if trim := strings.HasPrefix(r.Address, "unix"); strings.HasPrefix(r.Address, "/") || trim {
|
||||||
|
if trim {
|
||||||
|
return "unix", r.Address[len("unix:"):]
|
||||||
|
}
|
||||||
|
return "unix", r.Address
|
||||||
|
}
|
||||||
|
// default case, a plain tcp address with no scheme
|
||||||
|
return "tcp", r.Address
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeHeader(w http.ResponseWriter, r *http.Response) {
|
||||||
|
for key, vals := range r.Header {
|
||||||
|
for _, val := range vals {
|
||||||
|
w.Header().Add(key, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.WriteHeader(r.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h Handler) exists(path string) bool {
|
||||||
|
if _, err := os.Stat(h.Root + path); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildEnv returns a set of CGI environment variables for the request.
|
||||||
|
func (h Handler) buildEnv(r *http.Request, rule Rule, fpath string) (map[string]string, error) {
|
||||||
|
var env map[string]string
|
||||||
|
|
||||||
|
// Get absolute path of requested resource
|
||||||
|
absPath := filepath.Join(h.AbsRoot, fpath)
|
||||||
|
|
||||||
|
// Separate remote IP and port; more lenient than net.SplitHostPort
|
||||||
|
var ip, port string
|
||||||
|
if idx := strings.LastIndex(r.RemoteAddr, ":"); idx > -1 {
|
||||||
|
ip = r.RemoteAddr[:idx]
|
||||||
|
port = r.RemoteAddr[idx+1:]
|
||||||
|
} else {
|
||||||
|
ip = r.RemoteAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove [] from IPv6 addresses
|
||||||
|
ip = strings.Replace(ip, "[", "", 1)
|
||||||
|
ip = strings.Replace(ip, "]", "", 1)
|
||||||
|
|
||||||
|
// Split path in preparation for env variables.
|
||||||
|
// Previous rule.canSplit checks ensure this can never be -1.
|
||||||
|
splitPos := rule.splitPos(fpath)
|
||||||
|
|
||||||
|
// Request has the extension; path was split successfully
|
||||||
|
docURI := fpath[:splitPos+len(rule.SplitPath)]
|
||||||
|
pathInfo := fpath[splitPos+len(rule.SplitPath):]
|
||||||
|
scriptName := fpath
|
||||||
|
scriptFilename := absPath
|
||||||
|
|
||||||
|
// Strip PATH_INFO from SCRIPT_NAME
|
||||||
|
scriptName = strings.TrimSuffix(scriptName, pathInfo)
|
||||||
|
|
||||||
|
// Get the request URI. The request URI might be as it came in over the wire,
|
||||||
|
// or it might have been rewritten internally by the rewrite middleware (see issue #256).
|
||||||
|
// If it was rewritten, there will be a header indicating the original URL,
|
||||||
|
// which is needed to get the correct RequestURI value for PHP apps.
|
||||||
|
const internalRewriteFieldName = "Caddy-Rewrite-Original-URI"
|
||||||
|
reqURI := r.URL.RequestURI()
|
||||||
|
if origURI := r.Header.Get(internalRewriteFieldName); origURI != "" {
|
||||||
|
reqURI = origURI
|
||||||
|
r.Header.Del(internalRewriteFieldName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some variables are unused but cleared explicitly to prevent
|
||||||
|
// the parent environment from interfering.
|
||||||
|
env = map[string]string{
|
||||||
|
|
||||||
|
// Variables defined in CGI 1.1 spec
|
||||||
|
"AUTH_TYPE": "", // Not used
|
||||||
|
"CONTENT_LENGTH": r.Header.Get("Content-Length"),
|
||||||
|
"CONTENT_TYPE": r.Header.Get("Content-Type"),
|
||||||
|
"GATEWAY_INTERFACE": "CGI/1.1",
|
||||||
|
"PATH_INFO": pathInfo,
|
||||||
|
"QUERY_STRING": r.URL.RawQuery,
|
||||||
|
"REMOTE_ADDR": ip,
|
||||||
|
"REMOTE_HOST": ip, // For speed, remote host lookups disabled
|
||||||
|
"REMOTE_PORT": port,
|
||||||
|
"REMOTE_IDENT": "", // Not used
|
||||||
|
"REMOTE_USER": "", // Not used
|
||||||
|
"REQUEST_METHOD": r.Method,
|
||||||
|
"SERVER_NAME": h.ServerName,
|
||||||
|
"SERVER_PORT": h.ServerPort,
|
||||||
|
"SERVER_PROTOCOL": r.Proto,
|
||||||
|
"SERVER_SOFTWARE": h.SoftwareName + "/" + h.SoftwareVersion,
|
||||||
|
|
||||||
|
// Other variables
|
||||||
|
"DOCUMENT_ROOT": h.AbsRoot,
|
||||||
|
"DOCUMENT_URI": docURI,
|
||||||
|
"HTTP_HOST": r.Host, // added here, since not always part of headers
|
||||||
|
"REQUEST_URI": reqURI,
|
||||||
|
"SCRIPT_FILENAME": scriptFilename,
|
||||||
|
"SCRIPT_NAME": scriptName,
|
||||||
|
}
|
||||||
|
|
||||||
|
// compliance with the CGI specification that PATH_TRANSLATED
|
||||||
|
// should only exist if PATH_INFO is defined.
|
||||||
|
// Info: https://www.ietf.org/rfc/rfc3875 Page 14
|
||||||
|
if env["PATH_INFO"] != "" {
|
||||||
|
env["PATH_TRANSLATED"] = filepath.Join(h.AbsRoot, pathInfo) // Info: http://www.oreilly.com/openbook/cgi/ch02_04.html
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some web apps rely on knowing HTTPS or not
|
||||||
|
if r.TLS != nil {
|
||||||
|
env["HTTPS"] = "on"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add env variables from config
|
||||||
|
for _, envVar := range rule.EnvVars {
|
||||||
|
env[envVar[0]] = envVar[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add all HTTP headers to env variables
|
||||||
|
for field, val := range r.Header {
|
||||||
|
header := strings.ToUpper(field)
|
||||||
|
header = headerNameReplacer.Replace(header)
|
||||||
|
env["HTTP_"+header] = strings.Join(val, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
return env, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule represents a FastCGI handling rule.
|
||||||
|
type Rule struct {
|
||||||
|
// The base path to match. Required.
|
||||||
|
Path string
|
||||||
|
|
||||||
|
// The address of the FastCGI server. Required.
|
||||||
|
Address string
|
||||||
|
|
||||||
|
// Always process files with this extension with fastcgi.
|
||||||
|
Ext string
|
||||||
|
|
||||||
|
// The path in the URL will be split into two, with the first piece ending
|
||||||
|
// with the value of SplitPath. The first piece will be assumed as the
|
||||||
|
// actual resource (CGI script) name, and the second piece will be set to
|
||||||
|
// PATH_INFO for the CGI script to use.
|
||||||
|
SplitPath string
|
||||||
|
|
||||||
|
// If the URL ends with '/' (which indicates a directory), these index
|
||||||
|
// files will be tried instead.
|
||||||
|
IndexFiles []string
|
||||||
|
|
||||||
|
// Environment Variables
|
||||||
|
EnvVars [][2]string
|
||||||
|
|
||||||
|
// Ignored paths
|
||||||
|
IgnoredSubPaths []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// canSplit checks if path can split into two based on rule.SplitPath.
|
||||||
|
func (r Rule) canSplit(path string) bool {
|
||||||
|
return r.splitPos(path) >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitPos returns the index where path should be split
|
||||||
|
// based on rule.SplitPath.
|
||||||
|
func (r Rule) splitPos(path string) int {
|
||||||
|
if httpserver.CaseSensitivePath {
|
||||||
|
return strings.Index(path, r.SplitPath)
|
||||||
|
}
|
||||||
|
return strings.Index(strings.ToLower(path), strings.ToLower(r.SplitPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowedPath checks if requestPath is not an ignored path.
|
||||||
|
func (r Rule) AllowedPath(requestPath string) bool {
|
||||||
|
for _, ignoredSubPath := range r.IgnoredSubPaths {
|
||||||
|
if httpserver.Path(path.Clean(requestPath)).Matches(path.Join(r.Path, ignoredSubPath)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
headerNameReplacer = strings.NewReplacer(" ", "_", "-", "_")
|
||||||
|
// ErrIndexMissingSplit describes an index configuration error.
|
||||||
|
ErrIndexMissingSplit = errors.New("configured index file(s) must include split value")
|
||||||
|
)
|
||||||
|
|
||||||
|
// LogError is a non fatal error that allows requests to go through.
|
||||||
|
type LogError string
|
||||||
|
|
||||||
|
// Error satisfies error interface.
|
||||||
|
func (l LogError) Error() string {
|
||||||
|
return string(l)
|
||||||
|
}
|
160
caddyhttp/fastcgi/fastcgi_test.go
Normal file
160
caddyhttp/fastcgi/fastcgi_test.go
Normal file
|
@ -0,0 +1,160 @@
|
||||||
|
package fastcgi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/fcgi"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestServeHTTP(t *testing.T) {
|
||||||
|
body := "This is some test body content"
|
||||||
|
|
||||||
|
bodyLenStr := strconv.Itoa(len(body))
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Unable to create listener for test: %v", err)
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
go fcgi.Serve(listener, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Length", bodyLenStr)
|
||||||
|
w.Write([]byte(body))
|
||||||
|
}))
|
||||||
|
|
||||||
|
handler := Handler{
|
||||||
|
Next: nil,
|
||||||
|
Rules: []Rule{{Path: "/", Address: listener.Addr().String()}},
|
||||||
|
}
|
||||||
|
r, err := http.NewRequest("GET", "/", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Unable to create request: %v", err)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
status, err := handler.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
if got, want := status, 0; got != want {
|
||||||
|
t.Errorf("Expected returned status code to be %d, got %d", want, got)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Expected nil error, got: %v", err)
|
||||||
|
}
|
||||||
|
if got, want := w.Header().Get("Content-Length"), bodyLenStr; got != want {
|
||||||
|
t.Errorf("Expected Content-Length to be '%s', got: '%s'", want, got)
|
||||||
|
}
|
||||||
|
if got, want := w.Body.String(), body; got != want {
|
||||||
|
t.Errorf("Expected response body to be '%s', got: '%s'", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuleParseAddress(t *testing.T) {
|
||||||
|
getClientTestTable := []struct {
|
||||||
|
rule *Rule
|
||||||
|
expectednetwork string
|
||||||
|
expectedaddress string
|
||||||
|
}{
|
||||||
|
{&Rule{Address: "tcp://172.17.0.1:9000"}, "tcp", "172.17.0.1:9000"},
|
||||||
|
{&Rule{Address: "fastcgi://localhost:9000"}, "tcp", "localhost:9000"},
|
||||||
|
{&Rule{Address: "172.17.0.15"}, "tcp", "172.17.0.15"},
|
||||||
|
{&Rule{Address: "/my/unix/socket"}, "unix", "/my/unix/socket"},
|
||||||
|
{&Rule{Address: "unix:/second/unix/socket"}, "unix", "/second/unix/socket"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range getClientTestTable {
|
||||||
|
if actualnetwork, _ := entry.rule.parseAddress(); actualnetwork != entry.expectednetwork {
|
||||||
|
t.Errorf("Unexpected network for address string %v. Got %v, expected %v", entry.rule.Address, actualnetwork, entry.expectednetwork)
|
||||||
|
}
|
||||||
|
if _, actualaddress := entry.rule.parseAddress(); actualaddress != entry.expectedaddress {
|
||||||
|
t.Errorf("Unexpected parsed address for address string %v. Got %v, expected %v", entry.rule.Address, actualaddress, entry.expectedaddress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuleIgnoredPath(t *testing.T) {
|
||||||
|
rule := &Rule{
|
||||||
|
Path: "/fastcgi",
|
||||||
|
IgnoredSubPaths: []string{"/download", "/static"},
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
url string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{"/fastcgi", true},
|
||||||
|
{"/fastcgi/dl", true},
|
||||||
|
{"/fastcgi/download", false},
|
||||||
|
{"/fastcgi/download/static", false},
|
||||||
|
{"/fastcgi/static", false},
|
||||||
|
{"/fastcgi/static/download", false},
|
||||||
|
{"/fastcgi/something/download", true},
|
||||||
|
{"/fastcgi/something/static", true},
|
||||||
|
{"/fastcgi//static", false},
|
||||||
|
{"/fastcgi//static//download", false},
|
||||||
|
{"/fastcgi//download", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, test := range tests {
|
||||||
|
allowed := rule.AllowedPath(test.url)
|
||||||
|
if test.expected != allowed {
|
||||||
|
t.Errorf("Test %d: expected %v found %v", i, test.expected, allowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildEnv(t *testing.T) {
|
||||||
|
testBuildEnv := func(r *http.Request, rule Rule, fpath string, envExpected map[string]string) {
|
||||||
|
var h Handler
|
||||||
|
env, err := h.buildEnv(r, rule, fpath)
|
||||||
|
if err != nil {
|
||||||
|
t.Error("Unexpected error:", err.Error())
|
||||||
|
}
|
||||||
|
for k, v := range envExpected {
|
||||||
|
if env[k] != v {
|
||||||
|
t.Errorf("Unexpected %v. Got %v, expected %v", k, env[k], v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rule := Rule{}
|
||||||
|
url, err := url.Parse("http://localhost:2015/fgci_test.php?test=blabla")
|
||||||
|
if err != nil {
|
||||||
|
t.Error("Unexpected error:", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
r := http.Request{
|
||||||
|
Method: "GET",
|
||||||
|
URL: url,
|
||||||
|
Proto: "HTTP/1.1",
|
||||||
|
ProtoMajor: 1,
|
||||||
|
ProtoMinor: 1,
|
||||||
|
Host: "localhost:2015",
|
||||||
|
RemoteAddr: "[2b02:1810:4f2d:9400:70ab:f822:be8a:9093]:51688",
|
||||||
|
RequestURI: "/fgci_test.php",
|
||||||
|
}
|
||||||
|
|
||||||
|
fpath := "/fgci_test.php"
|
||||||
|
|
||||||
|
var envExpected = map[string]string{
|
||||||
|
"REMOTE_ADDR": "2b02:1810:4f2d:9400:70ab:f822:be8a:9093",
|
||||||
|
"REMOTE_PORT": "51688",
|
||||||
|
"SERVER_PROTOCOL": "HTTP/1.1",
|
||||||
|
"QUERY_STRING": "test=blabla",
|
||||||
|
"REQUEST_METHOD": "GET",
|
||||||
|
"HTTP_HOST": "localhost:2015",
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Test for full canonical IPv6 address
|
||||||
|
testBuildEnv(&r, rule, fpath, envExpected)
|
||||||
|
|
||||||
|
// 2. Test for shorthand notation of IPv6 address
|
||||||
|
r.RemoteAddr = "[::1]:51688"
|
||||||
|
envExpected["REMOTE_ADDR"] = "::1"
|
||||||
|
testBuildEnv(&r, rule, fpath, envExpected)
|
||||||
|
|
||||||
|
// 3. Test for IPv4 address
|
||||||
|
r.RemoteAddr = "192.168.0.10:51688"
|
||||||
|
envExpected["REMOTE_ADDR"] = "192.168.0.10"
|
||||||
|
testBuildEnv(&r, rule, fpath, envExpected)
|
||||||
|
}
|
79
caddyhttp/fastcgi/fcgi_test.php
Normal file
79
caddyhttp/fastcgi/fcgi_test.php
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
ini_set("display_errors",1);
|
||||||
|
|
||||||
|
echo "resp: start\n";//.print_r($GLOBALS,1)."\n".print_r($_SERVER,1)."\n";
|
||||||
|
|
||||||
|
//echo print_r($_SERVER,1)."\n";
|
||||||
|
|
||||||
|
$length = 0;
|
||||||
|
$stat = "PASSED";
|
||||||
|
|
||||||
|
$ret = "[";
|
||||||
|
|
||||||
|
if (count($_POST) || count($_FILES)) {
|
||||||
|
foreach($_POST as $key => $val) {
|
||||||
|
$md5 = md5($val);
|
||||||
|
|
||||||
|
if ($key != $md5) {
|
||||||
|
$stat = "FAILED";
|
||||||
|
echo "server:err ".$md5." != ".$key."\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$length += strlen($key) + strlen($val);
|
||||||
|
|
||||||
|
$ret .= $key."(".strlen($key).") ";
|
||||||
|
}
|
||||||
|
$ret .= "] [";
|
||||||
|
foreach ($_FILES as $k0 => $val) {
|
||||||
|
|
||||||
|
$error = $val["error"];
|
||||||
|
if ($error == UPLOAD_ERR_OK) {
|
||||||
|
$tmp_name = $val["tmp_name"];
|
||||||
|
$name = $val["name"];
|
||||||
|
$datafile = "/tmp/test.go";
|
||||||
|
move_uploaded_file($tmp_name, $datafile);
|
||||||
|
$md5 = md5_file($datafile);
|
||||||
|
|
||||||
|
if ($k0 != $md5) {
|
||||||
|
$stat = "FAILED";
|
||||||
|
echo "server:err ".$md5." != ".$key."\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$length += strlen($k0) + filesize($datafile);
|
||||||
|
|
||||||
|
unlink($datafile);
|
||||||
|
$ret .= $k0."(".strlen($k0).") ";
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
$stat = "FAILED";
|
||||||
|
echo "server:file err ".file_upload_error_message($error)."\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$ret .= "]";
|
||||||
|
echo "server:got data length " .$length."\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
echo "-{$stat}-POST(".count($_POST).") FILE(".count($_FILES).")\n";
|
||||||
|
|
||||||
|
function file_upload_error_message($error_code) {
|
||||||
|
switch ($error_code) {
|
||||||
|
case UPLOAD_ERR_INI_SIZE:
|
||||||
|
return 'The uploaded file exceeds the upload_max_filesize directive in php.ini';
|
||||||
|
case UPLOAD_ERR_FORM_SIZE:
|
||||||
|
return 'The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form';
|
||||||
|
case UPLOAD_ERR_PARTIAL:
|
||||||
|
return 'The uploaded file was only partially uploaded';
|
||||||
|
case UPLOAD_ERR_NO_FILE:
|
||||||
|
return 'No file was uploaded';
|
||||||
|
case UPLOAD_ERR_NO_TMP_DIR:
|
||||||
|
return 'Missing a temporary folder';
|
||||||
|
case UPLOAD_ERR_CANT_WRITE:
|
||||||
|
return 'Failed to write file to disk';
|
||||||
|
case UPLOAD_ERR_EXTENSION:
|
||||||
|
return 'File upload stopped by extension';
|
||||||
|
default:
|
||||||
|
return 'Unknown upload error';
|
||||||
|
}
|
||||||
|
}
|
560
caddyhttp/fastcgi/fcgiclient.go
Normal file
560
caddyhttp/fastcgi/fcgiclient.go
Normal file
|
@ -0,0 +1,560 @@
|
||||||
|
// Forked Jan. 2015 from http://bitbucket.org/PinIdea/fcgi_client
|
||||||
|
// (which is forked from https://code.google.com/p/go-fastcgi-client/)
|
||||||
|
|
||||||
|
// This fork contains several fixes and improvements by Matt Holt and
|
||||||
|
// other contributors to this project.
|
||||||
|
|
||||||
|
// Copyright 2012 Junqing Tan <ivan@mysqlab.net> and The Go Authors
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// Part of source code is from Go fcgi package
|
||||||
|
|
||||||
|
package fastcgi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"mime/multipart"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/textproto"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FCGIListenSockFileno describes listen socket file number.
|
||||||
|
const FCGIListenSockFileno uint8 = 0
|
||||||
|
|
||||||
|
// FCGIHeaderLen describes header length.
|
||||||
|
const FCGIHeaderLen uint8 = 8
|
||||||
|
|
||||||
|
// Version1 describes the version.
|
||||||
|
const Version1 uint8 = 1
|
||||||
|
|
||||||
|
// FCGINullRequestID describes the null request ID.
|
||||||
|
const FCGINullRequestID uint8 = 0
|
||||||
|
|
||||||
|
// FCGIKeepConn describes keep connection mode.
|
||||||
|
const FCGIKeepConn uint8 = 1
|
||||||
|
const doubleCRLF = "\r\n\r\n"
|
||||||
|
|
||||||
|
const (
|
||||||
|
// BeginRequest is the begin request flag.
|
||||||
|
BeginRequest uint8 = iota + 1
|
||||||
|
// AbortRequest is the abort request flag.
|
||||||
|
AbortRequest
|
||||||
|
// EndRequest is the end request flag.
|
||||||
|
EndRequest
|
||||||
|
// Params is the parameters flag.
|
||||||
|
Params
|
||||||
|
// Stdin is the standard input flag.
|
||||||
|
Stdin
|
||||||
|
// Stdout is the standard output flag.
|
||||||
|
Stdout
|
||||||
|
// Stderr is the standard error flag.
|
||||||
|
Stderr
|
||||||
|
// Data is the data flag.
|
||||||
|
Data
|
||||||
|
// GetValues is the get values flag.
|
||||||
|
GetValues
|
||||||
|
// GetValuesResult is the get values result flag.
|
||||||
|
GetValuesResult
|
||||||
|
// UnknownType is the unknown type flag.
|
||||||
|
UnknownType
|
||||||
|
// MaxType is the maximum type flag.
|
||||||
|
MaxType = UnknownType
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Responder is the responder flag.
|
||||||
|
Responder uint8 = iota + 1
|
||||||
|
// Authorizer is the authorizer flag.
|
||||||
|
Authorizer
|
||||||
|
// Filter is the filter flag.
|
||||||
|
Filter
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// RequestComplete is the completed request flag.
|
||||||
|
RequestComplete uint8 = iota
|
||||||
|
// CantMultiplexConns is the multiplexed connections flag.
|
||||||
|
CantMultiplexConns
|
||||||
|
// Overloaded is the overloaded flag.
|
||||||
|
Overloaded
|
||||||
|
// UnknownRole is the unknown role flag.
|
||||||
|
UnknownRole
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// MaxConns is the maximum connections flag.
|
||||||
|
MaxConns string = "MAX_CONNS"
|
||||||
|
// MaxRequests is the maximum requests flag.
|
||||||
|
MaxRequests string = "MAX_REQS"
|
||||||
|
// MultiplexConns is the multiplex connections flag.
|
||||||
|
MultiplexConns string = "MPXS_CONNS"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxWrite = 65500 // 65530 may work, but for compatibility
|
||||||
|
maxPad = 255
|
||||||
|
)
|
||||||
|
|
||||||
|
type header struct {
|
||||||
|
Version uint8
|
||||||
|
Type uint8
|
||||||
|
ID uint16
|
||||||
|
ContentLength uint16
|
||||||
|
PaddingLength uint8
|
||||||
|
Reserved uint8
|
||||||
|
}
|
||||||
|
|
||||||
|
// for padding so we don't have to allocate all the time
|
||||||
|
// not synchronized because we don't care what the contents are
|
||||||
|
var pad [maxPad]byte
|
||||||
|
|
||||||
|
func (h *header) init(recType uint8, reqID uint16, contentLength int) {
|
||||||
|
h.Version = 1
|
||||||
|
h.Type = recType
|
||||||
|
h.ID = reqID
|
||||||
|
h.ContentLength = uint16(contentLength)
|
||||||
|
h.PaddingLength = uint8(-contentLength & 7)
|
||||||
|
}
|
||||||
|
|
||||||
|
type record struct {
|
||||||
|
h header
|
||||||
|
rbuf []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rec *record) read(r io.Reader) (buf []byte, err error) {
|
||||||
|
if err = binary.Read(r, binary.BigEndian, &rec.h); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rec.h.Version != 1 {
|
||||||
|
err = errors.New("fcgi: invalid header version")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rec.h.Type == EndRequest {
|
||||||
|
err = io.EOF
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n := int(rec.h.ContentLength) + int(rec.h.PaddingLength)
|
||||||
|
if len(rec.rbuf) < n {
|
||||||
|
rec.rbuf = make([]byte, n)
|
||||||
|
}
|
||||||
|
if _, err = io.ReadFull(r, rec.rbuf[:n]); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
buf = rec.rbuf[:int(rec.h.ContentLength)]
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// FCGIClient implements a FastCGI client, which is a standard for
|
||||||
|
// interfacing external applications with Web servers.
|
||||||
|
type FCGIClient struct {
|
||||||
|
mutex sync.Mutex
|
||||||
|
rwc io.ReadWriteCloser
|
||||||
|
h header
|
||||||
|
buf bytes.Buffer
|
||||||
|
stderr bytes.Buffer
|
||||||
|
keepAlive bool
|
||||||
|
reqID uint16
|
||||||
|
}
|
||||||
|
|
||||||
|
// DialWithDialer connects to the fcgi responder at the specified network address, using custom net.Dialer.
|
||||||
|
// See func net.Dial for a description of the network and address parameters.
|
||||||
|
func DialWithDialer(network, address string, dialer net.Dialer) (fcgi *FCGIClient, err error) {
|
||||||
|
var conn net.Conn
|
||||||
|
conn, err = dialer.Dial(network, address)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fcgi = &FCGIClient{
|
||||||
|
rwc: conn,
|
||||||
|
keepAlive: false,
|
||||||
|
reqID: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dial connects to the fcgi responder at the specified network address, using default net.Dialer.
|
||||||
|
// See func net.Dial for a description of the network and address parameters.
|
||||||
|
func Dial(network, address string) (fcgi *FCGIClient, err error) {
|
||||||
|
return DialWithDialer(network, address, net.Dialer{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes fcgi connnection
|
||||||
|
func (c *FCGIClient) Close() {
|
||||||
|
c.rwc.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FCGIClient) writeRecord(recType uint8, content []byte) (err error) {
|
||||||
|
c.mutex.Lock()
|
||||||
|
defer c.mutex.Unlock()
|
||||||
|
c.buf.Reset()
|
||||||
|
c.h.init(recType, c.reqID, len(content))
|
||||||
|
if err := binary.Write(&c.buf, binary.BigEndian, c.h); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := c.buf.Write(content); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := c.buf.Write(pad[:c.h.PaddingLength]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = c.rwc.Write(c.buf.Bytes())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FCGIClient) writeBeginRequest(role uint16, flags uint8) error {
|
||||||
|
b := [8]byte{byte(role >> 8), byte(role), flags}
|
||||||
|
return c.writeRecord(BeginRequest, b[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FCGIClient) writeEndRequest(appStatus int, protocolStatus uint8) error {
|
||||||
|
b := make([]byte, 8)
|
||||||
|
binary.BigEndian.PutUint32(b, uint32(appStatus))
|
||||||
|
b[4] = protocolStatus
|
||||||
|
return c.writeRecord(EndRequest, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FCGIClient) writePairs(recType uint8, pairs map[string]string) error {
|
||||||
|
w := newWriter(c, recType)
|
||||||
|
b := make([]byte, 8)
|
||||||
|
nn := 0
|
||||||
|
for k, v := range pairs {
|
||||||
|
m := 8 + len(k) + len(v)
|
||||||
|
if m > maxWrite {
|
||||||
|
// param data size exceed 65535 bytes"
|
||||||
|
vl := maxWrite - 8 - len(k)
|
||||||
|
v = v[:vl]
|
||||||
|
}
|
||||||
|
n := encodeSize(b, uint32(len(k)))
|
||||||
|
n += encodeSize(b[n:], uint32(len(v)))
|
||||||
|
m = n + len(k) + len(v)
|
||||||
|
if (nn + m) > maxWrite {
|
||||||
|
w.Flush()
|
||||||
|
nn = 0
|
||||||
|
}
|
||||||
|
nn += m
|
||||||
|
if _, err := w.Write(b[:n]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := w.WriteString(k); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := w.WriteString(v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readSize(s []byte) (uint32, int) {
|
||||||
|
if len(s) == 0 {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
size, n := uint32(s[0]), 1
|
||||||
|
if size&(1<<7) != 0 {
|
||||||
|
if len(s) < 4 {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
n = 4
|
||||||
|
size = binary.BigEndian.Uint32(s)
|
||||||
|
size &^= 1 << 31
|
||||||
|
}
|
||||||
|
return size, n
|
||||||
|
}
|
||||||
|
|
||||||
|
func readString(s []byte, size uint32) string {
|
||||||
|
if size > uint32(len(s)) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(s[:size])
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeSize(b []byte, size uint32) int {
|
||||||
|
if size > 127 {
|
||||||
|
size |= 1 << 31
|
||||||
|
binary.BigEndian.PutUint32(b, size)
|
||||||
|
return 4
|
||||||
|
}
|
||||||
|
b[0] = byte(size)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// bufWriter encapsulates bufio.Writer but also closes the underlying stream when
|
||||||
|
// Closed.
|
||||||
|
type bufWriter struct {
|
||||||
|
closer io.Closer
|
||||||
|
*bufio.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *bufWriter) Close() error {
|
||||||
|
if err := w.Writer.Flush(); err != nil {
|
||||||
|
w.closer.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return w.closer.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func newWriter(c *FCGIClient, recType uint8) *bufWriter {
|
||||||
|
s := &streamWriter{c: c, recType: recType}
|
||||||
|
w := bufio.NewWriterSize(s, maxWrite)
|
||||||
|
return &bufWriter{s, w}
|
||||||
|
}
|
||||||
|
|
||||||
|
// streamWriter abstracts out the separation of a stream into discrete records.
|
||||||
|
// It only writes maxWrite bytes at a time.
|
||||||
|
type streamWriter struct {
|
||||||
|
c *FCGIClient
|
||||||
|
recType uint8
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *streamWriter) Write(p []byte) (int, error) {
|
||||||
|
nn := 0
|
||||||
|
for len(p) > 0 {
|
||||||
|
n := len(p)
|
||||||
|
if n > maxWrite {
|
||||||
|
n = maxWrite
|
||||||
|
}
|
||||||
|
if err := w.c.writeRecord(w.recType, p[:n]); err != nil {
|
||||||
|
return nn, err
|
||||||
|
}
|
||||||
|
nn += n
|
||||||
|
p = p[n:]
|
||||||
|
}
|
||||||
|
return nn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *streamWriter) Close() error {
|
||||||
|
// send empty record to close the stream
|
||||||
|
return w.c.writeRecord(w.recType, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
type streamReader struct {
|
||||||
|
c *FCGIClient
|
||||||
|
buf []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *streamReader) Read(p []byte) (n int, err error) {
|
||||||
|
|
||||||
|
if len(p) > 0 {
|
||||||
|
if len(w.buf) == 0 {
|
||||||
|
|
||||||
|
// filter outputs for error log
|
||||||
|
for {
|
||||||
|
rec := &record{}
|
||||||
|
var buf []byte
|
||||||
|
buf, err = rec.read(w.c.rwc)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// standard error output
|
||||||
|
if rec.h.Type == Stderr {
|
||||||
|
w.c.stderr.Write(buf)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
w.buf = buf
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
n = len(p)
|
||||||
|
if n > len(w.buf) {
|
||||||
|
n = len(w.buf)
|
||||||
|
}
|
||||||
|
copy(p, w.buf[:n])
|
||||||
|
w.buf = w.buf[n:]
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do made the request and returns a io.Reader that translates the data read
|
||||||
|
// from fcgi responder out of fcgi packet before returning it.
|
||||||
|
func (c *FCGIClient) Do(p map[string]string, req io.Reader) (r io.Reader, err error) {
|
||||||
|
err = c.writeBeginRequest(uint16(Responder), 0)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = c.writePairs(Params, p)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body := newWriter(c, Stdin)
|
||||||
|
if req != nil {
|
||||||
|
io.Copy(body, req)
|
||||||
|
}
|
||||||
|
body.Close()
|
||||||
|
|
||||||
|
r = &streamReader{c: c}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientCloser is a io.ReadCloser. It wraps a io.Reader with a Closer
|
||||||
|
// that closes FCGIClient connection.
|
||||||
|
type clientCloser struct {
|
||||||
|
*FCGIClient
|
||||||
|
io.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f clientCloser) Close() error { return f.rwc.Close() }
|
||||||
|
|
||||||
|
// Request returns a HTTP Response with Header and Body
|
||||||
|
// from fcgi responder
|
||||||
|
func (c *FCGIClient) Request(p map[string]string, req io.Reader) (resp *http.Response, err error) {
|
||||||
|
|
||||||
|
r, err := c.Do(p, req)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rb := bufio.NewReader(r)
|
||||||
|
tp := textproto.NewReader(rb)
|
||||||
|
resp = new(http.Response)
|
||||||
|
|
||||||
|
// Parse the response headers.
|
||||||
|
mimeHeader, err := tp.ReadMIMEHeader()
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp.Header = http.Header(mimeHeader)
|
||||||
|
|
||||||
|
if resp.Header.Get("Status") != "" {
|
||||||
|
statusParts := strings.SplitN(resp.Header.Get("Status"), " ", 2)
|
||||||
|
resp.StatusCode, err = strconv.Atoi(statusParts[0])
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(statusParts) > 1 {
|
||||||
|
resp.Status = statusParts[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
resp.StatusCode = http.StatusOK
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: fixTransferEncoding ?
|
||||||
|
resp.TransferEncoding = resp.Header["Transfer-Encoding"]
|
||||||
|
resp.ContentLength, _ = strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 64)
|
||||||
|
|
||||||
|
if chunked(resp.TransferEncoding) {
|
||||||
|
resp.Body = clientCloser{c, httputil.NewChunkedReader(rb)}
|
||||||
|
} else {
|
||||||
|
resp.Body = clientCloser{c, ioutil.NopCloser(rb)}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get issues a GET request to the fcgi responder.
|
||||||
|
func (c *FCGIClient) Get(p map[string]string) (resp *http.Response, err error) {
|
||||||
|
|
||||||
|
p["REQUEST_METHOD"] = "GET"
|
||||||
|
p["CONTENT_LENGTH"] = "0"
|
||||||
|
|
||||||
|
return c.Request(p, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Head issues a HEAD request to the fcgi responder.
|
||||||
|
func (c *FCGIClient) Head(p map[string]string) (resp *http.Response, err error) {
|
||||||
|
|
||||||
|
p["REQUEST_METHOD"] = "HEAD"
|
||||||
|
p["CONTENT_LENGTH"] = "0"
|
||||||
|
|
||||||
|
return c.Request(p, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options issues an OPTIONS request to the fcgi responder.
|
||||||
|
func (c *FCGIClient) Options(p map[string]string) (resp *http.Response, err error) {
|
||||||
|
|
||||||
|
p["REQUEST_METHOD"] = "OPTIONS"
|
||||||
|
p["CONTENT_LENGTH"] = "0"
|
||||||
|
|
||||||
|
return c.Request(p, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post issues a POST request to the fcgi responder. with request body
|
||||||
|
// in the format that bodyType specified
|
||||||
|
func (c *FCGIClient) Post(p map[string]string, method string, bodyType string, body io.Reader, l int) (resp *http.Response, err error) {
|
||||||
|
if p == nil {
|
||||||
|
p = make(map[string]string)
|
||||||
|
}
|
||||||
|
|
||||||
|
p["REQUEST_METHOD"] = strings.ToUpper(method)
|
||||||
|
|
||||||
|
if len(p["REQUEST_METHOD"]) == 0 || p["REQUEST_METHOD"] == "GET" {
|
||||||
|
p["REQUEST_METHOD"] = "POST"
|
||||||
|
}
|
||||||
|
|
||||||
|
p["CONTENT_LENGTH"] = strconv.Itoa(l)
|
||||||
|
if len(bodyType) > 0 {
|
||||||
|
p["CONTENT_TYPE"] = bodyType
|
||||||
|
} else {
|
||||||
|
p["CONTENT_TYPE"] = "application/x-www-form-urlencoded"
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Request(p, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostForm issues a POST to the fcgi responder, with form
|
||||||
|
// as a string key to a list values (url.Values)
|
||||||
|
func (c *FCGIClient) PostForm(p map[string]string, data url.Values) (resp *http.Response, err error) {
|
||||||
|
body := bytes.NewReader([]byte(data.Encode()))
|
||||||
|
return c.Post(p, "POST", "application/x-www-form-urlencoded", body, body.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostFile issues a POST to the fcgi responder in multipart(RFC 2046) standard,
|
||||||
|
// with form as a string key to a list values (url.Values),
|
||||||
|
// and/or with file as a string key to a list file path.
|
||||||
|
func (c *FCGIClient) PostFile(p map[string]string, data url.Values, file map[string]string) (resp *http.Response, err error) {
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
writer := multipart.NewWriter(buf)
|
||||||
|
bodyType := writer.FormDataContentType()
|
||||||
|
|
||||||
|
for key, val := range data {
|
||||||
|
for _, v0 := range val {
|
||||||
|
err = writer.WriteField(key, v0)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, val := range file {
|
||||||
|
fd, e := os.Open(val)
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
defer fd.Close()
|
||||||
|
|
||||||
|
part, e := writer.CreateFormFile(key, filepath.Base(val))
|
||||||
|
if e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
_, err = io.Copy(part, fd)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = writer.Close()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Post(p, "POST", bodyType, buf, buf.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checks whether chunked is part of the encodings stack
|
||||||
|
func chunked(te []string) bool { return len(te) > 0 && te[0] == "chunked" }
|
275
caddyhttp/fastcgi/fcgiclient_test.go
Normal file
275
caddyhttp/fastcgi/fcgiclient_test.go
Normal file
|
@ -0,0 +1,275 @@
|
||||||
|
// NOTE: These tests were adapted from the original
|
||||||
|
// repository from which this package was forked.
|
||||||
|
// The tests are slow (~10s) and in dire need of rewriting.
|
||||||
|
// As such, the tests have been disabled to speed up
|
||||||
|
// automated builds until they can be properly written.
|
||||||
|
|
||||||
|
package fastcgi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"log"
|
||||||
|
"math/rand"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/fcgi"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// test fcgi protocol includes:
|
||||||
|
// Get, Post, Post in multipart/form-data, and Post with files
|
||||||
|
// each key should be the md5 of the value or the file uploaded
|
||||||
|
// sepicify remote fcgi responer ip:port to test with php
|
||||||
|
// test failed if the remote fcgi(script) failed md5 verification
|
||||||
|
// and output "FAILED" in response
|
||||||
|
const (
|
||||||
|
scriptFile = "/tank/www/fcgic_test.php"
|
||||||
|
//ipPort = "remote-php-serv:59000"
|
||||||
|
ipPort = "127.0.0.1:59000"
|
||||||
|
)
|
||||||
|
|
||||||
|
var globalt *testing.T
|
||||||
|
|
||||||
|
type FastCGIServer struct{}
|
||||||
|
|
||||||
|
func (s FastCGIServer) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
|
||||||
|
|
||||||
|
req.ParseMultipartForm(100000000)
|
||||||
|
|
||||||
|
stat := "PASSED"
|
||||||
|
fmt.Fprintln(resp, "-")
|
||||||
|
fileNum := 0
|
||||||
|
{
|
||||||
|
length := 0
|
||||||
|
for k0, v0 := range req.Form {
|
||||||
|
h := md5.New()
|
||||||
|
io.WriteString(h, v0[0])
|
||||||
|
md5 := fmt.Sprintf("%x", h.Sum(nil))
|
||||||
|
|
||||||
|
length += len(k0)
|
||||||
|
length += len(v0[0])
|
||||||
|
|
||||||
|
// echo error when key != md5(val)
|
||||||
|
if md5 != k0 {
|
||||||
|
fmt.Fprintln(resp, "server:err ", md5, k0)
|
||||||
|
stat = "FAILED"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.MultipartForm != nil {
|
||||||
|
fileNum = len(req.MultipartForm.File)
|
||||||
|
for kn, fns := range req.MultipartForm.File {
|
||||||
|
//fmt.Fprintln(resp, "server:filekey ", kn )
|
||||||
|
length += len(kn)
|
||||||
|
for _, f := range fns {
|
||||||
|
fd, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
log.Println("server:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h := md5.New()
|
||||||
|
l0, err := io.Copy(h, fd)
|
||||||
|
if err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
length += int(l0)
|
||||||
|
defer fd.Close()
|
||||||
|
md5 := fmt.Sprintf("%x", h.Sum(nil))
|
||||||
|
//fmt.Fprintln(resp, "server:filemd5 ", md5 )
|
||||||
|
|
||||||
|
if kn != md5 {
|
||||||
|
fmt.Fprintln(resp, "server:err ", md5, kn)
|
||||||
|
stat = "FAILED"
|
||||||
|
}
|
||||||
|
//fmt.Fprintln(resp, "server:filename ", f.Filename )
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(resp, "server:got data length", length)
|
||||||
|
}
|
||||||
|
fmt.Fprintln(resp, "-"+stat+"-POST(", len(req.Form), ")-FILE(", fileNum, ")--")
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendFcgi(reqType int, fcgiParams map[string]string, data []byte, posts map[string]string, files map[string]string) (content []byte) {
|
||||||
|
fcgi, err := Dial("tcp", ipPort)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("err:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
length := 0
|
||||||
|
|
||||||
|
var resp *http.Response
|
||||||
|
switch reqType {
|
||||||
|
case 0:
|
||||||
|
if len(data) > 0 {
|
||||||
|
length = len(data)
|
||||||
|
rd := bytes.NewReader(data)
|
||||||
|
resp, err = fcgi.Post(fcgiParams, "", "", rd, rd.Len())
|
||||||
|
} else if len(posts) > 0 {
|
||||||
|
values := url.Values{}
|
||||||
|
for k, v := range posts {
|
||||||
|
values.Set(k, v)
|
||||||
|
length += len(k) + 2 + len(v)
|
||||||
|
}
|
||||||
|
resp, err = fcgi.PostForm(fcgiParams, values)
|
||||||
|
} else {
|
||||||
|
resp, err = fcgi.Get(fcgiParams)
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
values := url.Values{}
|
||||||
|
for k, v := range posts {
|
||||||
|
values.Set(k, v)
|
||||||
|
length += len(k) + 2 + len(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range files {
|
||||||
|
fi, _ := os.Lstat(v)
|
||||||
|
length += len(k) + int(fi.Size())
|
||||||
|
}
|
||||||
|
resp, err = fcgi.PostFile(fcgiParams, values, files)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Println("err:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
defer resp.Body.Close()
|
||||||
|
content, _ = ioutil.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
log.Println("c: send data length ≈", length, string(content))
|
||||||
|
fcgi.Close()
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
|
if bytes.Index(content, []byte("FAILED")) >= 0 {
|
||||||
|
globalt.Error("Server return failed message")
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateRandFile(size int) (p string, m string) {
|
||||||
|
|
||||||
|
p = filepath.Join(os.TempDir(), "fcgict"+strconv.Itoa(rand.Int()))
|
||||||
|
|
||||||
|
// open output file
|
||||||
|
fo, err := os.Create(p)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
// close fo on exit and check for its returned error
|
||||||
|
defer func() {
|
||||||
|
if err := fo.Close(); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
h := md5.New()
|
||||||
|
for i := 0; i < size/16; i++ {
|
||||||
|
buf := make([]byte, 16)
|
||||||
|
binary.PutVarint(buf, rand.Int63())
|
||||||
|
fo.Write(buf)
|
||||||
|
h.Write(buf)
|
||||||
|
}
|
||||||
|
m = fmt.Sprintf("%x", h.Sum(nil))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func DisabledTest(t *testing.T) {
|
||||||
|
// TODO: test chunked reader
|
||||||
|
globalt = t
|
||||||
|
|
||||||
|
rand.Seed(time.Now().UTC().UnixNano())
|
||||||
|
|
||||||
|
// server
|
||||||
|
go func() {
|
||||||
|
listener, err := net.Listen("tcp", ipPort)
|
||||||
|
if err != nil {
|
||||||
|
// handle error
|
||||||
|
log.Println("listener creation failed: ", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := new(FastCGIServer)
|
||||||
|
fcgi.Serve(listener, srv)
|
||||||
|
}()
|
||||||
|
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
|
// init
|
||||||
|
fcgiParams := make(map[string]string)
|
||||||
|
fcgiParams["REQUEST_METHOD"] = "GET"
|
||||||
|
fcgiParams["SERVER_PROTOCOL"] = "HTTP/1.1"
|
||||||
|
//fcgi_params["GATEWAY_INTERFACE"] = "CGI/1.1"
|
||||||
|
fcgiParams["SCRIPT_FILENAME"] = scriptFile
|
||||||
|
|
||||||
|
// simple GET
|
||||||
|
log.Println("test:", "get")
|
||||||
|
sendFcgi(0, fcgiParams, nil, nil, nil)
|
||||||
|
|
||||||
|
// simple post data
|
||||||
|
log.Println("test:", "post")
|
||||||
|
sendFcgi(0, fcgiParams, []byte("c4ca4238a0b923820dcc509a6f75849b=1&7b8b965ad4bca0e41ab51de7b31363a1=n"), nil, nil)
|
||||||
|
|
||||||
|
log.Println("test:", "post data (more than 60KB)")
|
||||||
|
data := ""
|
||||||
|
for i := 0x00; i < 0xff; i++ {
|
||||||
|
v0 := strings.Repeat(string(i), 256)
|
||||||
|
h := md5.New()
|
||||||
|
io.WriteString(h, v0)
|
||||||
|
k0 := fmt.Sprintf("%x", h.Sum(nil))
|
||||||
|
data += k0 + "=" + url.QueryEscape(v0) + "&"
|
||||||
|
}
|
||||||
|
sendFcgi(0, fcgiParams, []byte(data), nil, nil)
|
||||||
|
|
||||||
|
log.Println("test:", "post form (use url.Values)")
|
||||||
|
p0 := make(map[string]string, 1)
|
||||||
|
p0["c4ca4238a0b923820dcc509a6f75849b"] = "1"
|
||||||
|
p0["7b8b965ad4bca0e41ab51de7b31363a1"] = "n"
|
||||||
|
sendFcgi(1, fcgiParams, nil, p0, nil)
|
||||||
|
|
||||||
|
log.Println("test:", "post forms (256 keys, more than 1MB)")
|
||||||
|
p1 := make(map[string]string, 1)
|
||||||
|
for i := 0x00; i < 0xff; i++ {
|
||||||
|
v0 := strings.Repeat(string(i), 4096)
|
||||||
|
h := md5.New()
|
||||||
|
io.WriteString(h, v0)
|
||||||
|
k0 := fmt.Sprintf("%x", h.Sum(nil))
|
||||||
|
p1[k0] = v0
|
||||||
|
}
|
||||||
|
sendFcgi(1, fcgiParams, nil, p1, nil)
|
||||||
|
|
||||||
|
log.Println("test:", "post file (1 file, 500KB)) ")
|
||||||
|
f0 := make(map[string]string, 1)
|
||||||
|
path0, m0 := generateRandFile(500000)
|
||||||
|
f0[m0] = path0
|
||||||
|
sendFcgi(1, fcgiParams, nil, p1, f0)
|
||||||
|
|
||||||
|
log.Println("test:", "post multiple files (2 files, 5M each) and forms (256 keys, more than 1MB data")
|
||||||
|
path1, m1 := generateRandFile(5000000)
|
||||||
|
f0[m1] = path1
|
||||||
|
sendFcgi(1, fcgiParams, nil, p1, f0)
|
||||||
|
|
||||||
|
log.Println("test:", "post only files (2 files, 5M each)")
|
||||||
|
sendFcgi(1, fcgiParams, nil, nil, f0)
|
||||||
|
|
||||||
|
log.Println("test:", "post only 1 file")
|
||||||
|
delete(f0, "m0")
|
||||||
|
sendFcgi(1, fcgiParams, nil, nil, f0)
|
||||||
|
|
||||||
|
os.Remove(path0)
|
||||||
|
os.Remove(path1)
|
||||||
|
}
|
127
caddyhttp/fastcgi/setup.go
Normal file
127
caddyhttp/fastcgi/setup.go
Normal file
|
@ -0,0 +1,127 @@
|
||||||
|
package fastcgi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/mholt/caddy"
|
||||||
|
"github.com/mholt/caddy/caddyhttp/httpserver"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
caddy.RegisterPlugin(caddy.Plugin{
|
||||||
|
Name: "fastcgi",
|
||||||
|
ServerType: "http",
|
||||||
|
Action: setup,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// setup configures a new FastCGI middleware instance.
|
||||||
|
func setup(c *caddy.Controller) error {
|
||||||
|
cfg := httpserver.GetConfig(c.Key)
|
||||||
|
absRoot, err := filepath.Abs(cfg.Root)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rules, err := fastcgiParse(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.AddMiddleware(func(next httpserver.Handler) httpserver.Handler {
|
||||||
|
return Handler{
|
||||||
|
Next: next,
|
||||||
|
Rules: rules,
|
||||||
|
Root: cfg.Root,
|
||||||
|
AbsRoot: absRoot,
|
||||||
|
FileSys: http.Dir(cfg.Root),
|
||||||
|
SoftwareName: caddy.AppName,
|
||||||
|
SoftwareVersion: caddy.AppVersion,
|
||||||
|
ServerName: cfg.Addr.Host,
|
||||||
|
ServerPort: cfg.Addr.Port,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fastcgiParse(c *caddy.Controller) ([]Rule, error) {
|
||||||
|
var rules []Rule
|
||||||
|
|
||||||
|
for c.Next() {
|
||||||
|
var rule Rule
|
||||||
|
|
||||||
|
args := c.RemainingArgs()
|
||||||
|
|
||||||
|
switch len(args) {
|
||||||
|
case 0:
|
||||||
|
return rules, c.ArgErr()
|
||||||
|
case 1:
|
||||||
|
rule.Path = "/"
|
||||||
|
rule.Address = args[0]
|
||||||
|
case 2:
|
||||||
|
rule.Path = args[0]
|
||||||
|
rule.Address = args[1]
|
||||||
|
case 3:
|
||||||
|
rule.Path = args[0]
|
||||||
|
rule.Address = args[1]
|
||||||
|
err := fastcgiPreset(args[2], &rule)
|
||||||
|
if err != nil {
|
||||||
|
return rules, c.Err("Invalid fastcgi rule preset '" + args[2] + "'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for c.NextBlock() {
|
||||||
|
switch c.Val() {
|
||||||
|
case "ext":
|
||||||
|
if !c.NextArg() {
|
||||||
|
return rules, c.ArgErr()
|
||||||
|
}
|
||||||
|
rule.Ext = c.Val()
|
||||||
|
case "split":
|
||||||
|
if !c.NextArg() {
|
||||||
|
return rules, c.ArgErr()
|
||||||
|
}
|
||||||
|
rule.SplitPath = c.Val()
|
||||||
|
case "index":
|
||||||
|
args := c.RemainingArgs()
|
||||||
|
if len(args) == 0 {
|
||||||
|
return rules, c.ArgErr()
|
||||||
|
}
|
||||||
|
rule.IndexFiles = args
|
||||||
|
case "env":
|
||||||
|
envArgs := c.RemainingArgs()
|
||||||
|
if len(envArgs) < 2 {
|
||||||
|
return rules, c.ArgErr()
|
||||||
|
}
|
||||||
|
rule.EnvVars = append(rule.EnvVars, [2]string{envArgs[0], envArgs[1]})
|
||||||
|
case "except":
|
||||||
|
ignoredPaths := c.RemainingArgs()
|
||||||
|
if len(ignoredPaths) == 0 {
|
||||||
|
return rules, c.ArgErr()
|
||||||
|
}
|
||||||
|
rule.IgnoredSubPaths = ignoredPaths
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rules = append(rules, rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rules, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fastcgiPreset configures rule according to name. It returns an error if
|
||||||
|
// name is not a recognized preset name.
|
||||||
|
func fastcgiPreset(name string, rule *Rule) error {
|
||||||
|
switch name {
|
||||||
|
case "php":
|
||||||
|
rule.Ext = ".php"
|
||||||
|
rule.SplitPath = ".php"
|
||||||
|
rule.IndexFiles = []string{"index.php"}
|
||||||
|
default:
|
||||||
|
return errors.New(name + " is not a valid preset name")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
121
caddyhttp/fastcgi/setup_test.go
Normal file
121
caddyhttp/fastcgi/setup_test.go
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
package fastcgi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mholt/caddy"
|
||||||
|
"github.com/mholt/caddy/caddyhttp/httpserver"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSetup(t *testing.T) {
|
||||||
|
err := setup(caddy.NewTestController(`fastcgi / 127.0.0.1:9000`))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Expected no errors, got: %v", err)
|
||||||
|
}
|
||||||
|
mids := httpserver.GetConfig("").Middleware()
|
||||||
|
if len(mids) == 0 {
|
||||||
|
t.Fatal("Expected middleware, got 0 instead")
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := mids[0](httpserver.EmptyNext)
|
||||||
|
myHandler, ok := handler.(Handler)
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Expected handler to be type , got: %#v", handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
if myHandler.Rules[0].Path != "/" {
|
||||||
|
t.Errorf("Expected / as the Path")
|
||||||
|
}
|
||||||
|
if myHandler.Rules[0].Address != "127.0.0.1:9000" {
|
||||||
|
t.Errorf("Expected 127.0.0.1:9000 as the Address")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFastcgiParse(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
inputFastcgiConfig string
|
||||||
|
shouldErr bool
|
||||||
|
expectedFastcgiConfig []Rule
|
||||||
|
}{
|
||||||
|
|
||||||
|
{`fastcgi /blog 127.0.0.1:9000 php`,
|
||||||
|
false, []Rule{{
|
||||||
|
Path: "/blog",
|
||||||
|
Address: "127.0.0.1:9000",
|
||||||
|
Ext: ".php",
|
||||||
|
SplitPath: ".php",
|
||||||
|
IndexFiles: []string{"index.php"},
|
||||||
|
}}},
|
||||||
|
{`fastcgi / 127.0.0.1:9001 {
|
||||||
|
split .html
|
||||||
|
}`,
|
||||||
|
false, []Rule{{
|
||||||
|
Path: "/",
|
||||||
|
Address: "127.0.0.1:9001",
|
||||||
|
Ext: "",
|
||||||
|
SplitPath: ".html",
|
||||||
|
IndexFiles: []string{},
|
||||||
|
}}},
|
||||||
|
{`fastcgi / 127.0.0.1:9001 {
|
||||||
|
split .html
|
||||||
|
except /admin /user
|
||||||
|
}`,
|
||||||
|
false, []Rule{{
|
||||||
|
Path: "/",
|
||||||
|
Address: "127.0.0.1:9001",
|
||||||
|
Ext: "",
|
||||||
|
SplitPath: ".html",
|
||||||
|
IndexFiles: []string{},
|
||||||
|
IgnoredSubPaths: []string{"/admin", "/user"},
|
||||||
|
}}},
|
||||||
|
}
|
||||||
|
for i, test := range tests {
|
||||||
|
actualFastcgiConfigs, err := fastcgiParse(caddy.NewTestController(test.inputFastcgiConfig))
|
||||||
|
|
||||||
|
if err == nil && test.shouldErr {
|
||||||
|
t.Errorf("Test %d didn't error, but it should have", i)
|
||||||
|
} else if err != nil && !test.shouldErr {
|
||||||
|
t.Errorf("Test %d errored, but it shouldn't have; got '%v'", i, err)
|
||||||
|
}
|
||||||
|
if len(actualFastcgiConfigs) != len(test.expectedFastcgiConfig) {
|
||||||
|
t.Fatalf("Test %d expected %d no of FastCGI configs, but got %d ",
|
||||||
|
i, len(test.expectedFastcgiConfig), len(actualFastcgiConfigs))
|
||||||
|
}
|
||||||
|
for j, actualFastcgiConfig := range actualFastcgiConfigs {
|
||||||
|
|
||||||
|
if actualFastcgiConfig.Path != test.expectedFastcgiConfig[j].Path {
|
||||||
|
t.Errorf("Test %d expected %dth FastCGI Path to be %s , but got %s",
|
||||||
|
i, j, test.expectedFastcgiConfig[j].Path, actualFastcgiConfig.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
if actualFastcgiConfig.Address != test.expectedFastcgiConfig[j].Address {
|
||||||
|
t.Errorf("Test %d expected %dth FastCGI Address to be %s , but got %s",
|
||||||
|
i, j, test.expectedFastcgiConfig[j].Address, actualFastcgiConfig.Address)
|
||||||
|
}
|
||||||
|
|
||||||
|
if actualFastcgiConfig.Ext != test.expectedFastcgiConfig[j].Ext {
|
||||||
|
t.Errorf("Test %d expected %dth FastCGI Ext to be %s , but got %s",
|
||||||
|
i, j, test.expectedFastcgiConfig[j].Ext, actualFastcgiConfig.Ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
if actualFastcgiConfig.SplitPath != test.expectedFastcgiConfig[j].SplitPath {
|
||||||
|
t.Errorf("Test %d expected %dth FastCGI SplitPath to be %s , but got %s",
|
||||||
|
i, j, test.expectedFastcgiConfig[j].SplitPath, actualFastcgiConfig.SplitPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fmt.Sprint(actualFastcgiConfig.IndexFiles) != fmt.Sprint(test.expectedFastcgiConfig[j].IndexFiles) {
|
||||||
|
t.Errorf("Test %d expected %dth FastCGI IndexFiles to be %s , but got %s",
|
||||||
|
i, j, test.expectedFastcgiConfig[j].IndexFiles, actualFastcgiConfig.IndexFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fmt.Sprint(actualFastcgiConfig.IgnoredSubPaths) != fmt.Sprint(test.expectedFastcgiConfig[j].IgnoredSubPaths) {
|
||||||
|
t.Errorf("Test %d expected %dth FastCGI IgnoredSubPaths to be %s , but got %s",
|
||||||
|
i, j, test.expectedFastcgiConfig[j].IgnoredSubPaths, actualFastcgiConfig.IgnoredSubPaths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
263
caddyhttp/httpserver/context.go
Normal file
263
caddyhttp/httpserver/context.go
Normal file
|
@ -0,0 +1,263 @@
|
||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/russross/blackfriday"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This file contains the context and functions available for
|
||||||
|
// use in the templates.
|
||||||
|
|
||||||
|
// Context is the context with which Caddy templates are executed.
|
||||||
|
type Context struct {
|
||||||
|
Root http.FileSystem
|
||||||
|
Req *http.Request
|
||||||
|
URL *url.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include returns the contents of filename relative to the site root.
|
||||||
|
func (c Context) Include(filename string) (string, error) {
|
||||||
|
return ContextInclude(filename, c, c.Root)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now returns the current timestamp in the specified format.
|
||||||
|
func (c Context) Now(format string) string {
|
||||||
|
return time.Now().Format(format)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NowDate returns the current date/time that can be used
|
||||||
|
// in other time functions.
|
||||||
|
func (c Context) NowDate() time.Time {
|
||||||
|
return time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cookie gets the value of a cookie with name name.
|
||||||
|
func (c Context) Cookie(name string) string {
|
||||||
|
cookies := c.Req.Cookies()
|
||||||
|
for _, cookie := range cookies {
|
||||||
|
if cookie.Name == name {
|
||||||
|
return cookie.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header gets the value of a request header with field name.
|
||||||
|
func (c Context) Header(name string) string {
|
||||||
|
return c.Req.Header.Get(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IP gets the (remote) IP address of the client making the request.
|
||||||
|
func (c Context) IP() string {
|
||||||
|
ip, _, err := net.SplitHostPort(c.Req.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return c.Req.RemoteAddr
|
||||||
|
}
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
|
||||||
|
// URI returns the raw, unprocessed request URI (including query
|
||||||
|
// string and hash) obtained directly from the Request-Line of
|
||||||
|
// the HTTP request.
|
||||||
|
func (c Context) URI() string {
|
||||||
|
return c.Req.RequestURI
|
||||||
|
}
|
||||||
|
|
||||||
|
// Host returns the hostname portion of the Host header
|
||||||
|
// from the HTTP request.
|
||||||
|
func (c Context) Host() (string, error) {
|
||||||
|
host, _, err := net.SplitHostPort(c.Req.Host)
|
||||||
|
if err != nil {
|
||||||
|
if !strings.Contains(c.Req.Host, ":") {
|
||||||
|
// common with sites served on the default port 80
|
||||||
|
return c.Req.Host, nil
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return host, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Port returns the port portion of the Host header if specified.
|
||||||
|
func (c Context) Port() (string, error) {
|
||||||
|
_, port, err := net.SplitHostPort(c.Req.Host)
|
||||||
|
if err != nil {
|
||||||
|
if !strings.Contains(c.Req.Host, ":") {
|
||||||
|
// common with sites served on the default port 80
|
||||||
|
return "80", nil
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return port, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method returns the method (GET, POST, etc.) of the request.
|
||||||
|
func (c Context) Method() string {
|
||||||
|
return c.Req.Method
|
||||||
|
}
|
||||||
|
|
||||||
|
// PathMatches returns true if the path portion of the request
|
||||||
|
// URL matches pattern.
|
||||||
|
func (c Context) PathMatches(pattern string) bool {
|
||||||
|
return Path(c.Req.URL.Path).Matches(pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate truncates the input string to the given length.
|
||||||
|
// If length is negative, it returns that many characters
|
||||||
|
// starting from the end of the string. If the absolute value
|
||||||
|
// of length is greater than len(input), the whole input is
|
||||||
|
// returned.
|
||||||
|
func (c Context) Truncate(input string, length int) string {
|
||||||
|
if length < 0 && len(input)+length > 0 {
|
||||||
|
return input[len(input)+length:]
|
||||||
|
}
|
||||||
|
if length >= 0 && len(input) > length {
|
||||||
|
return input[:length]
|
||||||
|
}
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
// StripHTML returns s without HTML tags. It is fairly naive
|
||||||
|
// but works with most valid HTML inputs.
|
||||||
|
func (c Context) StripHTML(s string) string {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
var inTag, inQuotes bool
|
||||||
|
var tagStart int
|
||||||
|
for i, ch := range s {
|
||||||
|
if inTag {
|
||||||
|
if ch == '>' && !inQuotes {
|
||||||
|
inTag = false
|
||||||
|
} else if ch == '<' && !inQuotes {
|
||||||
|
// false start
|
||||||
|
buf.WriteString(s[tagStart:i])
|
||||||
|
tagStart = i
|
||||||
|
} else if ch == '"' {
|
||||||
|
inQuotes = !inQuotes
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ch == '<' {
|
||||||
|
inTag = true
|
||||||
|
tagStart = i
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
buf.WriteRune(ch)
|
||||||
|
}
|
||||||
|
if inTag {
|
||||||
|
// false start
|
||||||
|
buf.WriteString(s[tagStart:])
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// StripExt returns the input string without the extension,
|
||||||
|
// which is the suffix starting with the final '.' character
|
||||||
|
// but not before the final path separator ('/') character.
|
||||||
|
// If there is no extension, the whole input is returned.
|
||||||
|
func (c Context) StripExt(path string) string {
|
||||||
|
for i := len(path) - 1; i >= 0 && path[i] != '/'; i-- {
|
||||||
|
if path[i] == '.' {
|
||||||
|
return path[:i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace replaces instances of find in input with replacement.
|
||||||
|
func (c Context) Replace(input, find, replacement string) string {
|
||||||
|
return strings.Replace(input, find, replacement, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Markdown returns the HTML contents of the markdown contained in filename
|
||||||
|
// (relative to the site root).
|
||||||
|
func (c Context) Markdown(filename string) (string, error) {
|
||||||
|
body, err := c.Include(filename)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
renderer := blackfriday.HtmlRenderer(0, "", "")
|
||||||
|
extns := 0
|
||||||
|
extns |= blackfriday.EXTENSION_TABLES
|
||||||
|
extns |= blackfriday.EXTENSION_FENCED_CODE
|
||||||
|
extns |= blackfriday.EXTENSION_STRIKETHROUGH
|
||||||
|
extns |= blackfriday.EXTENSION_DEFINITION_LISTS
|
||||||
|
markdown := blackfriday.Markdown([]byte(body), renderer, extns)
|
||||||
|
|
||||||
|
return string(markdown), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContextInclude opens filename using fs and executes a template with the context ctx.
|
||||||
|
// This does the same thing that Context.Include() does, but with the ability to provide
|
||||||
|
// your own context so that the included files can have access to additional fields your
|
||||||
|
// type may provide. You can embed Context in your type, then override its Include method
|
||||||
|
// to call this function with ctx being the instance of your type, and fs being Context.Root.
|
||||||
|
func ContextInclude(filename string, ctx interface{}, fs http.FileSystem) (string, error) {
|
||||||
|
file, err := fs.Open(filename)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
body, err := ioutil.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
tpl, err := template.New(filename).Parse(string(body))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err = tpl.Execute(&buf, ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToLower will convert the given string to lower case.
|
||||||
|
func (c Context) ToLower(s string) string {
|
||||||
|
return strings.ToLower(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToUpper will convert the given string to upper case.
|
||||||
|
func (c Context) ToUpper(s string) string {
|
||||||
|
return strings.ToUpper(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split is a passthrough to strings.Split. It will split the first argument at each instance of the separator and return a slice of strings.
|
||||||
|
func (c Context) Split(s string, sep string) []string {
|
||||||
|
return strings.Split(s, sep)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slice will convert the given arguments into a slice.
|
||||||
|
func (c Context) Slice(elems ...interface{}) []interface{} {
|
||||||
|
return elems
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map will convert the arguments into a map. It expects alternating string keys and values. This is useful for building more complicated data structures
|
||||||
|
// if you are using subtemplates or things like that.
|
||||||
|
func (c Context) Map(values ...interface{}) (map[string]interface{}, error) {
|
||||||
|
if len(values)%2 != 0 {
|
||||||
|
return nil, fmt.Errorf("Map expects an even number of arguments")
|
||||||
|
}
|
||||||
|
dict := make(map[string]interface{}, len(values)/2)
|
||||||
|
for i := 0; i < len(values); i += 2 {
|
||||||
|
key, ok := values[i].(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("Map keys must be strings")
|
||||||
|
}
|
||||||
|
dict[key] = values[i+1]
|
||||||
|
}
|
||||||
|
return dict, nil
|
||||||
|
}
|
650
caddyhttp/httpserver/context_test.go
Normal file
650
caddyhttp/httpserver/context_test.go
Normal file
|
@ -0,0 +1,650 @@
|
||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"text/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInclude(t *testing.T) {
|
||||||
|
context := getContextOrFail(t)
|
||||||
|
|
||||||
|
inputFilename := "test_file"
|
||||||
|
absInFilePath := filepath.Join(fmt.Sprintf("%s", context.Root), inputFilename)
|
||||||
|
defer func() {
|
||||||
|
err := os.Remove(absInFilePath)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("Failed to clean test file!")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
fileContent string
|
||||||
|
expectedContent string
|
||||||
|
shouldErr bool
|
||||||
|
expectedErrorContent string
|
||||||
|
}{
|
||||||
|
// Test 0 - all good
|
||||||
|
{
|
||||||
|
fileContent: `str1 {{ .Root }} str2`,
|
||||||
|
expectedContent: fmt.Sprintf("str1 %s str2", context.Root),
|
||||||
|
shouldErr: false,
|
||||||
|
expectedErrorContent: "",
|
||||||
|
},
|
||||||
|
// Test 1 - failure on template.Parse
|
||||||
|
{
|
||||||
|
fileContent: `str1 {{ .Root } str2`,
|
||||||
|
expectedContent: "",
|
||||||
|
shouldErr: true,
|
||||||
|
expectedErrorContent: `unexpected "}" in operand`,
|
||||||
|
},
|
||||||
|
// Test 3 - failure on template.Execute
|
||||||
|
{
|
||||||
|
fileContent: `str1 {{ .InvalidField }} str2`,
|
||||||
|
expectedContent: "",
|
||||||
|
shouldErr: true,
|
||||||
|
expectedErrorContent: `InvalidField`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fileContent: `str1 {{ .InvalidField }} str2`,
|
||||||
|
expectedContent: "",
|
||||||
|
shouldErr: true,
|
||||||
|
expectedErrorContent: `type httpserver.Context`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, test := range tests {
|
||||||
|
testPrefix := getTestPrefix(i)
|
||||||
|
|
||||||
|
// WriteFile truncates the contentt
|
||||||
|
err := ioutil.WriteFile(absInFilePath, []byte(test.fileContent), os.ModePerm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(testPrefix+"Failed to create test file. Error was: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := context.Include(inputFilename)
|
||||||
|
if err != nil {
|
||||||
|
if !test.shouldErr {
|
||||||
|
t.Errorf(testPrefix+"Expected no error, found [%s]", test.expectedErrorContent, err.Error())
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), test.expectedErrorContent) {
|
||||||
|
t.Errorf(testPrefix+"Expected error content [%s], found [%s]", test.expectedErrorContent, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil && test.shouldErr {
|
||||||
|
t.Errorf(testPrefix+"Expected error [%s] but found nil. Input file was: %s", test.expectedErrorContent, inputFilename)
|
||||||
|
}
|
||||||
|
|
||||||
|
if content != test.expectedContent {
|
||||||
|
t.Errorf(testPrefix+"Expected content [%s] but found [%s]. Input file was: %s", test.expectedContent, content, inputFilename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIncludeNotExisting(t *testing.T) {
|
||||||
|
context := getContextOrFail(t)
|
||||||
|
|
||||||
|
_, err := context.Include("not_existing")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("Expected error but found nil!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkdown(t *testing.T) {
|
||||||
|
context := getContextOrFail(t)
|
||||||
|
|
||||||
|
inputFilename := "test_file"
|
||||||
|
absInFilePath := filepath.Join(fmt.Sprintf("%s", context.Root), inputFilename)
|
||||||
|
defer func() {
|
||||||
|
err := os.Remove(absInFilePath)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("Failed to clean test file!")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
fileContent string
|
||||||
|
expectedContent string
|
||||||
|
}{
|
||||||
|
// Test 0 - test parsing of markdown
|
||||||
|
{
|
||||||
|
fileContent: "* str1\n* str2\n",
|
||||||
|
expectedContent: "<ul>\n<li>str1</li>\n<li>str2</li>\n</ul>\n",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, test := range tests {
|
||||||
|
testPrefix := getTestPrefix(i)
|
||||||
|
|
||||||
|
// WriteFile truncates the contentt
|
||||||
|
err := ioutil.WriteFile(absInFilePath, []byte(test.fileContent), os.ModePerm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(testPrefix+"Failed to create test file. Error was: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, _ := context.Markdown(inputFilename)
|
||||||
|
if content != test.expectedContent {
|
||||||
|
t.Errorf(testPrefix+"Expected content [%s] but found [%s]. Input file was: %s", test.expectedContent, content, inputFilename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCookie(t *testing.T) {
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
cookie *http.Cookie
|
||||||
|
cookieName string
|
||||||
|
expectedValue string
|
||||||
|
}{
|
||||||
|
// Test 0 - happy path
|
||||||
|
{
|
||||||
|
cookie: &http.Cookie{Name: "cookieName", Value: "cookieValue"},
|
||||||
|
cookieName: "cookieName",
|
||||||
|
expectedValue: "cookieValue",
|
||||||
|
},
|
||||||
|
// Test 1 - try to get a non-existing cookie
|
||||||
|
{
|
||||||
|
cookie: &http.Cookie{Name: "cookieName", Value: "cookieValue"},
|
||||||
|
cookieName: "notExisting",
|
||||||
|
expectedValue: "",
|
||||||
|
},
|
||||||
|
// Test 2 - partial name match
|
||||||
|
{
|
||||||
|
cookie: &http.Cookie{Name: "cookie", Value: "cookieValue"},
|
||||||
|
cookieName: "cook",
|
||||||
|
expectedValue: "",
|
||||||
|
},
|
||||||
|
// Test 3 - cookie with optional fields
|
||||||
|
{
|
||||||
|
cookie: &http.Cookie{Name: "cookie", Value: "cookieValue", Path: "/path", Domain: "https://localhost", Expires: (time.Now().Add(10 * time.Minute)), MaxAge: 120},
|
||||||
|
cookieName: "cookie",
|
||||||
|
expectedValue: "cookieValue",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, test := range tests {
|
||||||
|
testPrefix := getTestPrefix(i)
|
||||||
|
|
||||||
|
// reinitialize the context for each test
|
||||||
|
context := getContextOrFail(t)
|
||||||
|
|
||||||
|
context.Req.AddCookie(test.cookie)
|
||||||
|
|
||||||
|
actualCookieVal := context.Cookie(test.cookieName)
|
||||||
|
|
||||||
|
if actualCookieVal != test.expectedValue {
|
||||||
|
t.Errorf(testPrefix+"Expected cookie value [%s] but found [%s] for cookie with name %s", test.expectedValue, actualCookieVal, test.cookieName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCookieMultipleCookies(t *testing.T) {
|
||||||
|
context := getContextOrFail(t)
|
||||||
|
|
||||||
|
cookieNameBase, cookieValueBase := "cookieName", "cookieValue"
|
||||||
|
|
||||||
|
// make sure that there's no state and multiple requests for different cookies return the correct result
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
context.Req.AddCookie(&http.Cookie{Name: fmt.Sprintf("%s%d", cookieNameBase, i), Value: fmt.Sprintf("%s%d", cookieValueBase, i)})
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
expectedCookieVal := fmt.Sprintf("%s%d", cookieValueBase, i)
|
||||||
|
actualCookieVal := context.Cookie(fmt.Sprintf("%s%d", cookieNameBase, i))
|
||||||
|
if actualCookieVal != expectedCookieVal {
|
||||||
|
t.Fatalf("Expected cookie value %s, found %s", expectedCookieVal, actualCookieVal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeader(t *testing.T) {
|
||||||
|
context := getContextOrFail(t)
|
||||||
|
|
||||||
|
headerKey, headerVal := "Header1", "HeaderVal1"
|
||||||
|
context.Req.Header.Add(headerKey, headerVal)
|
||||||
|
|
||||||
|
actualHeaderVal := context.Header(headerKey)
|
||||||
|
if actualHeaderVal != headerVal {
|
||||||
|
t.Errorf("Expected header %s, found %s", headerVal, actualHeaderVal)
|
||||||
|
}
|
||||||
|
|
||||||
|
missingHeaderVal := context.Header("not-existing")
|
||||||
|
if missingHeaderVal != "" {
|
||||||
|
t.Errorf("Expected empty header value, found %s", missingHeaderVal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIP(t *testing.T) {
|
||||||
|
context := getContextOrFail(t)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
inputRemoteAddr string
|
||||||
|
expectedIP string
|
||||||
|
}{
|
||||||
|
// Test 0 - ipv4 with port
|
||||||
|
{"1.1.1.1:1111", "1.1.1.1"},
|
||||||
|
// Test 1 - ipv4 without port
|
||||||
|
{"1.1.1.1", "1.1.1.1"},
|
||||||
|
// Test 2 - ipv6 with port
|
||||||
|
{"[::1]:11", "::1"},
|
||||||
|
// Test 3 - ipv6 without port and brackets
|
||||||
|
{"[2001:db8:a0b:12f0::1]", "[2001:db8:a0b:12f0::1]"},
|
||||||
|
// Test 4 - ipv6 with zone and port
|
||||||
|
{`[fe80:1::3%eth0]:44`, `fe80:1::3%eth0`},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, test := range tests {
|
||||||
|
testPrefix := getTestPrefix(i)
|
||||||
|
|
||||||
|
context.Req.RemoteAddr = test.inputRemoteAddr
|
||||||
|
actualIP := context.IP()
|
||||||
|
|
||||||
|
if actualIP != test.expectedIP {
|
||||||
|
t.Errorf(testPrefix+"Expected IP %s, found %s", test.expectedIP, actualIP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestURL(t *testing.T) {
|
||||||
|
context := getContextOrFail(t)
|
||||||
|
|
||||||
|
inputURL := "http://localhost"
|
||||||
|
context.Req.RequestURI = inputURL
|
||||||
|
|
||||||
|
if inputURL != context.URI() {
|
||||||
|
t.Errorf("Expected url %s, found %s", inputURL, context.URI())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHost(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
expectedHost string
|
||||||
|
shouldErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
input: "localhost:123",
|
||||||
|
expectedHost: "localhost",
|
||||||
|
shouldErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "localhost",
|
||||||
|
expectedHost: "localhost",
|
||||||
|
shouldErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "[::]",
|
||||||
|
expectedHost: "",
|
||||||
|
shouldErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
testHostOrPort(t, true, test.input, test.expectedHost, test.shouldErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPort(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
expectedPort string
|
||||||
|
shouldErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
input: "localhost:123",
|
||||||
|
expectedPort: "123",
|
||||||
|
shouldErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "localhost",
|
||||||
|
expectedPort: "80", // assuming 80 is the default port
|
||||||
|
shouldErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: ":8080",
|
||||||
|
expectedPort: "8080",
|
||||||
|
shouldErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "[::]",
|
||||||
|
expectedPort: "",
|
||||||
|
shouldErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
testHostOrPort(t, false, test.input, test.expectedPort, test.shouldErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHostOrPort(t *testing.T, isTestingHost bool, input, expectedResult string, shouldErr bool) {
|
||||||
|
context := getContextOrFail(t)
|
||||||
|
|
||||||
|
context.Req.Host = input
|
||||||
|
var actualResult, testedObject string
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if isTestingHost {
|
||||||
|
actualResult, err = context.Host()
|
||||||
|
testedObject = "host"
|
||||||
|
} else {
|
||||||
|
actualResult, err = context.Port()
|
||||||
|
testedObject = "port"
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldErr && err == nil {
|
||||||
|
t.Errorf("Expected error, found nil!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !shouldErr && err != nil {
|
||||||
|
t.Errorf("Expected no error, found %s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if actualResult != expectedResult {
|
||||||
|
t.Errorf("Expected %s %s, found %s", testedObject, expectedResult, actualResult)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMethod(t *testing.T) {
|
||||||
|
context := getContextOrFail(t)
|
||||||
|
|
||||||
|
method := "POST"
|
||||||
|
context.Req.Method = method
|
||||||
|
|
||||||
|
if method != context.Method() {
|
||||||
|
t.Errorf("Expected method %s, found %s", method, context.Method())
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPathMatches(t *testing.T) {
|
||||||
|
context := getContextOrFail(t)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
urlStr string
|
||||||
|
pattern string
|
||||||
|
shouldMatch bool
|
||||||
|
}{
|
||||||
|
// Test 0
|
||||||
|
{
|
||||||
|
urlStr: "http://localhost/",
|
||||||
|
pattern: "",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
// Test 1
|
||||||
|
{
|
||||||
|
urlStr: "http://localhost",
|
||||||
|
pattern: "",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
// Test 1
|
||||||
|
{
|
||||||
|
urlStr: "http://localhost/",
|
||||||
|
pattern: "/",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
// Test 3
|
||||||
|
{
|
||||||
|
urlStr: "http://localhost/?param=val",
|
||||||
|
pattern: "/",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
// Test 4
|
||||||
|
{
|
||||||
|
urlStr: "http://localhost/dir1/dir2",
|
||||||
|
pattern: "/dir2",
|
||||||
|
shouldMatch: false,
|
||||||
|
},
|
||||||
|
// Test 5
|
||||||
|
{
|
||||||
|
urlStr: "http://localhost/dir1/dir2",
|
||||||
|
pattern: "/dir1",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
// Test 6
|
||||||
|
{
|
||||||
|
urlStr: "http://localhost:444/dir1/dir2",
|
||||||
|
pattern: "/dir1",
|
||||||
|
shouldMatch: true,
|
||||||
|
},
|
||||||
|
// Test 7
|
||||||
|
{
|
||||||
|
urlStr: "http://localhost/dir1/dir2",
|
||||||
|
pattern: "*/dir2",
|
||||||
|
shouldMatch: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, test := range tests {
|
||||||
|
testPrefix := getTestPrefix(i)
|
||||||
|
var err error
|
||||||
|
context.Req.URL, err = url.Parse(test.urlStr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to prepare test URL from string %s! Error was: %s", test.urlStr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
matches := context.PathMatches(test.pattern)
|
||||||
|
if matches != test.shouldMatch {
|
||||||
|
t.Errorf(testPrefix+"Expected and actual result differ: expected to match [%t], actual matches [%t]", test.shouldMatch, matches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncate(t *testing.T) {
|
||||||
|
context := getContextOrFail(t)
|
||||||
|
tests := []struct {
|
||||||
|
inputString string
|
||||||
|
inputLength int
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
// Test 0 - small length
|
||||||
|
{
|
||||||
|
inputString: "string",
|
||||||
|
inputLength: 1,
|
||||||
|
expected: "s",
|
||||||
|
},
|
||||||
|
// Test 1 - exact length
|
||||||
|
{
|
||||||
|
inputString: "string",
|
||||||
|
inputLength: 6,
|
||||||
|
expected: "string",
|
||||||
|
},
|
||||||
|
// Test 2 - bigger length
|
||||||
|
{
|
||||||
|
inputString: "string",
|
||||||
|
inputLength: 10,
|
||||||
|
expected: "string",
|
||||||
|
},
|
||||||
|
// Test 3 - zero length
|
||||||
|
{
|
||||||
|
inputString: "string",
|
||||||
|
inputLength: 0,
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
// Test 4 - negative, smaller length
|
||||||
|
{
|
||||||
|
inputString: "string",
|
||||||
|
inputLength: -5,
|
||||||
|
expected: "tring",
|
||||||
|
},
|
||||||
|
// Test 5 - negative, exact length
|
||||||
|
{
|
||||||
|
inputString: "string",
|
||||||
|
inputLength: -6,
|
||||||
|
expected: "string",
|
||||||
|
},
|
||||||
|
// Test 6 - negative, bigger length
|
||||||
|
{
|
||||||
|
inputString: "string",
|
||||||
|
inputLength: -7,
|
||||||
|
expected: "string",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, test := range tests {
|
||||||
|
actual := context.Truncate(test.inputString, test.inputLength)
|
||||||
|
if actual != test.expected {
|
||||||
|
t.Errorf(getTestPrefix(i)+"Expected '%s', found '%s'. Input was Truncate(%q, %d)", test.expected, actual, test.inputString, test.inputLength)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripHTML(t *testing.T) {
|
||||||
|
context := getContextOrFail(t)
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
// Test 0 - no tags
|
||||||
|
{
|
||||||
|
input: `h1`,
|
||||||
|
expected: `h1`,
|
||||||
|
},
|
||||||
|
// Test 1 - happy path
|
||||||
|
{
|
||||||
|
input: `<h1>h1</h1>`,
|
||||||
|
expected: `h1`,
|
||||||
|
},
|
||||||
|
// Test 2 - tag in quotes
|
||||||
|
{
|
||||||
|
input: `<h1">">h1</h1>`,
|
||||||
|
expected: `h1`,
|
||||||
|
},
|
||||||
|
// Test 3 - multiple tags
|
||||||
|
{
|
||||||
|
input: `<h1><b>h1</b></h1>`,
|
||||||
|
expected: `h1`,
|
||||||
|
},
|
||||||
|
// Test 4 - tags not closed
|
||||||
|
{
|
||||||
|
input: `<h1`,
|
||||||
|
expected: `<h1`,
|
||||||
|
},
|
||||||
|
// Test 5 - false start
|
||||||
|
{
|
||||||
|
input: `<h1<b>hi`,
|
||||||
|
expected: `<h1hi`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, test := range tests {
|
||||||
|
actual := context.StripHTML(test.input)
|
||||||
|
if actual != test.expected {
|
||||||
|
t.Errorf(getTestPrefix(i)+"Expected %s, found %s. Input was StripHTML(%s)", test.expected, actual, test.input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripExt(t *testing.T) {
|
||||||
|
context := getContextOrFail(t)
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
// Test 0 - empty input
|
||||||
|
{
|
||||||
|
input: "",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
// Test 1 - relative file with ext
|
||||||
|
{
|
||||||
|
input: "file.ext",
|
||||||
|
expected: "file",
|
||||||
|
},
|
||||||
|
// Test 2 - relative file without ext
|
||||||
|
{
|
||||||
|
input: "file",
|
||||||
|
expected: "file",
|
||||||
|
},
|
||||||
|
// Test 3 - absolute file without ext
|
||||||
|
{
|
||||||
|
input: "/file",
|
||||||
|
expected: "/file",
|
||||||
|
},
|
||||||
|
// Test 4 - absolute file with ext
|
||||||
|
{
|
||||||
|
input: "/file.ext",
|
||||||
|
expected: "/file",
|
||||||
|
},
|
||||||
|
// Test 5 - with ext but ends with /
|
||||||
|
{
|
||||||
|
input: "/dir.ext/",
|
||||||
|
expected: "/dir.ext/",
|
||||||
|
},
|
||||||
|
// Test 6 - file with ext under dir with ext
|
||||||
|
{
|
||||||
|
input: "/dir.ext/file.ext",
|
||||||
|
expected: "/dir.ext/file",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, test := range tests {
|
||||||
|
actual := context.StripExt(test.input)
|
||||||
|
if actual != test.expected {
|
||||||
|
t.Errorf(getTestPrefix(i)+"Expected %s, found %s. Input was StripExt(%q)", test.expected, actual, test.input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func initTestContext() (Context, error) {
|
||||||
|
body := bytes.NewBufferString("request body")
|
||||||
|
request, err := http.NewRequest("GET", "https://localhost", body)
|
||||||
|
if err != nil {
|
||||||
|
return Context{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return Context{Root: http.Dir(os.TempDir()), Req: request}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getContextOrFail(t *testing.T) Context {
|
||||||
|
context, err := initTestContext()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to prepare test context")
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTestPrefix(testN int) string {
|
||||||
|
return fmt.Sprintf("Test [%d]: ", testN)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTemplates(t *testing.T) {
|
||||||
|
tests := []struct{ tmpl, expected string }{
|
||||||
|
{`{{.ToUpper "aAA"}}`, "AAA"},
|
||||||
|
{`{{"bbb" | .ToUpper}}`, "BBB"},
|
||||||
|
{`{{.ToLower "CCc"}}`, "ccc"},
|
||||||
|
{`{{range (.Split "a,b,c" ",")}}{{.}}{{end}}`, "abc"},
|
||||||
|
{`{{range .Split "a,b,c" ","}}{{.}}{{end}}`, "abc"},
|
||||||
|
{`{{range .Slice "a" "b" "c"}}{{.}}{{end}}`, "abc"},
|
||||||
|
{`{{with .Map "A" "a" "B" "b" "c" "d"}}{{.A}}{{.B}}{{.c}}{{end}}`, "abd"},
|
||||||
|
}
|
||||||
|
for i, test := range tests {
|
||||||
|
ctx := getContextOrFail(t)
|
||||||
|
tmpl, err := template.New("").Parse(test.tmpl)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Test %d: %s", i, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
err = tmpl.Execute(buf, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Test %d: %s", i, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if buf.String() != test.expected {
|
||||||
|
t.Errorf("Test %d: Results do not match. '%s' != '%s'", i, buf.String(), test.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
171
caddyhttp/markdown/markdown.go
Normal file
171
caddyhttp/markdown/markdown.go
Normal file
|
@ -0,0 +1,171 @@
|
||||||
|
// Package markdown is middleware to render markdown files as HTML
|
||||||
|
// on-the-fly.
|
||||||
|
package markdown
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mholt/caddy/caddyhttp/httpserver"
|
||||||
|
"github.com/russross/blackfriday"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Markdown implements a layer of middleware that serves
|
||||||
|
// markdown as HTML.
|
||||||
|
type Markdown struct {
|
||||||
|
// Server root
|
||||||
|
Root string
|
||||||
|
|
||||||
|
// Jail the requests to site root with a mock file system
|
||||||
|
FileSys http.FileSystem
|
||||||
|
|
||||||
|
// Next HTTP handler in the chain
|
||||||
|
Next httpserver.Handler
|
||||||
|
|
||||||
|
// The list of markdown configurations
|
||||||
|
Configs []*Config
|
||||||
|
|
||||||
|
// The list of index files to try
|
||||||
|
IndexFiles []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config stores markdown middleware configurations.
|
||||||
|
type Config struct {
|
||||||
|
// Markdown renderer
|
||||||
|
Renderer blackfriday.Renderer
|
||||||
|
|
||||||
|
// Base path to match
|
||||||
|
PathScope string
|
||||||
|
|
||||||
|
// List of extensions to consider as markdown files
|
||||||
|
Extensions map[string]struct{}
|
||||||
|
|
||||||
|
// List of style sheets to load for each markdown file
|
||||||
|
Styles []string
|
||||||
|
|
||||||
|
// List of JavaScript files to load for each markdown file
|
||||||
|
Scripts []string
|
||||||
|
|
||||||
|
// Template(s) to render with
|
||||||
|
Template *template.Template
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP implements the http.Handler interface.
|
||||||
|
func (md Markdown) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error) {
|
||||||
|
var cfg *Config
|
||||||
|
for _, c := range md.Configs {
|
||||||
|
if httpserver.Path(r.URL.Path).Matches(c.PathScope) { // not negated
|
||||||
|
cfg = c
|
||||||
|
break // or goto
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg == nil {
|
||||||
|
return md.Next.ServeHTTP(w, r) // exit early
|
||||||
|
}
|
||||||
|
|
||||||
|
// We only deal with HEAD/GET
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet, http.MethodHead:
|
||||||
|
default:
|
||||||
|
return http.StatusMethodNotAllowed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var dirents []os.FileInfo
|
||||||
|
var lastModTime time.Time
|
||||||
|
fpath := r.URL.Path
|
||||||
|
if idx, ok := httpserver.IndexFile(md.FileSys, fpath, md.IndexFiles); ok {
|
||||||
|
// We're serving a directory index file, which may be a markdown
|
||||||
|
// file with a template. Let's grab a list of files this directory
|
||||||
|
// URL points to, and pass that in to any possible template invocations,
|
||||||
|
// so that templates can customize the look and feel of a directory.
|
||||||
|
fdp, err := md.FileSys.Open(fpath)
|
||||||
|
switch {
|
||||||
|
case err == nil: // nop
|
||||||
|
case os.IsPermission(err):
|
||||||
|
return http.StatusForbidden, err
|
||||||
|
case os.IsExist(err):
|
||||||
|
return http.StatusNotFound, nil
|
||||||
|
default: // did we run out of FD?
|
||||||
|
return http.StatusInternalServerError, err
|
||||||
|
}
|
||||||
|
defer fdp.Close()
|
||||||
|
|
||||||
|
// Grab a possible set of directory entries. Note, we do not check
|
||||||
|
// for errors here (unreadable directory, for example). It may
|
||||||
|
// still be useful to have a directory template file, without the
|
||||||
|
// directory contents being present. Note, the directory's last
|
||||||
|
// modification is also present here (entry ".").
|
||||||
|
dirents, _ = fdp.Readdir(-1)
|
||||||
|
for _, d := range dirents {
|
||||||
|
lastModTime = latest(lastModTime, d.ModTime())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set path to found index file
|
||||||
|
fpath = idx
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not supported extension, pass on it
|
||||||
|
if _, ok := cfg.Extensions[path.Ext(fpath)]; !ok {
|
||||||
|
return md.Next.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// At this point we have a supported extension/markdown
|
||||||
|
f, err := md.FileSys.Open(fpath)
|
||||||
|
switch {
|
||||||
|
case err == nil: // nop
|
||||||
|
case os.IsPermission(err):
|
||||||
|
return http.StatusForbidden, err
|
||||||
|
case os.IsExist(err):
|
||||||
|
return http.StatusNotFound, nil
|
||||||
|
default: // did we run out of FD?
|
||||||
|
return http.StatusInternalServerError, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
if fs, err := f.Stat(); err != nil {
|
||||||
|
return http.StatusGone, nil
|
||||||
|
} else {
|
||||||
|
lastModTime = latest(lastModTime, fs.ModTime())
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := httpserver.Context{
|
||||||
|
Root: md.FileSys,
|
||||||
|
Req: r,
|
||||||
|
URL: r.URL,
|
||||||
|
}
|
||||||
|
html, err := cfg.Markdown(title(fpath), f, dirents, ctx)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusInternalServerError, err
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Header().Set("Content-Length", strconv.FormatInt(int64(len(html)), 10))
|
||||||
|
httpserver.SetLastModifiedHeader(w, lastModTime)
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
w.Write(html)
|
||||||
|
}
|
||||||
|
return http.StatusOK, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// latest returns the latest time.Time
|
||||||
|
func latest(t ...time.Time) time.Time {
|
||||||
|
var last time.Time
|
||||||
|
|
||||||
|
for _, tt := range t {
|
||||||
|
if tt.After(last) {
|
||||||
|
last = tt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return last
|
||||||
|
}
|
||||||
|
|
||||||
|
// title gives a backup generated title for a page
|
||||||
|
func title(p string) string {
|
||||||
|
return strings.TrimRight(path.Base(p), path.Ext(p))
|
||||||
|
}
|
230
caddyhttp/markdown/markdown_test.go
Normal file
230
caddyhttp/markdown/markdown_test.go
Normal file
|
@ -0,0 +1,230 @@
|
||||||
|
package markdown
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"text/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mholt/caddy/caddyhttp/httpserver"
|
||||||
|
"github.com/russross/blackfriday"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMarkdown(t *testing.T) {
|
||||||
|
rootDir := "./testdata"
|
||||||
|
|
||||||
|
f := func(filename string) string {
|
||||||
|
return filepath.ToSlash(rootDir + string(filepath.Separator) + filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
md := Markdown{
|
||||||
|
Root: rootDir,
|
||||||
|
FileSys: http.Dir(rootDir),
|
||||||
|
Configs: []*Config{
|
||||||
|
{
|
||||||
|
Renderer: blackfriday.HtmlRenderer(0, "", ""),
|
||||||
|
PathScope: "/blog",
|
||||||
|
Extensions: map[string]struct{}{
|
||||||
|
".md": {},
|
||||||
|
},
|
||||||
|
Styles: []string{},
|
||||||
|
Scripts: []string{},
|
||||||
|
Template: setDefaultTemplate(f("markdown_tpl.html")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Renderer: blackfriday.HtmlRenderer(0, "", ""),
|
||||||
|
PathScope: "/docflags",
|
||||||
|
Extensions: map[string]struct{}{
|
||||||
|
".md": {},
|
||||||
|
},
|
||||||
|
Styles: []string{},
|
||||||
|
Scripts: []string{},
|
||||||
|
Template: setDefaultTemplate(f("docflags/template.txt")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Renderer: blackfriday.HtmlRenderer(0, "", ""),
|
||||||
|
PathScope: "/log",
|
||||||
|
Extensions: map[string]struct{}{
|
||||||
|
".md": {},
|
||||||
|
},
|
||||||
|
Styles: []string{"/resources/css/log.css", "/resources/css/default.css"},
|
||||||
|
Scripts: []string{"/resources/js/log.js", "/resources/js/default.js"},
|
||||||
|
Template: GetDefaultTemplate(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Renderer: blackfriday.HtmlRenderer(0, "", ""),
|
||||||
|
PathScope: "/og",
|
||||||
|
Extensions: map[string]struct{}{
|
||||||
|
".md": {},
|
||||||
|
},
|
||||||
|
Styles: []string{},
|
||||||
|
Scripts: []string{},
|
||||||
|
Template: setDefaultTemplate(f("markdown_tpl.html")),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
IndexFiles: []string{"index.html"},
|
||||||
|
Next: httpserver.HandlerFunc(func(w http.ResponseWriter, r *http.Request) (int, error) {
|
||||||
|
t.Fatalf("Next shouldn't be called")
|
||||||
|
return 0, nil
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", "/blog/test.md", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Could not create HTTP request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
md.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("Wrong status, expected: %d and got %d", http.StatusOK, rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
respBody := rec.Body.String()
|
||||||
|
expectedBody := `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Markdown test 1</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Header for: Markdown test 1</h1>
|
||||||
|
|
||||||
|
Welcome to A Caddy website!
|
||||||
|
<h2>Welcome on the blog</h2>
|
||||||
|
|
||||||
|
<p>Body</p>
|
||||||
|
|
||||||
|
<pre><code class="language-go">func getTrue() bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
</code></pre>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
if !equalStrings(respBody, expectedBody) {
|
||||||
|
t.Fatalf("Expected body: %v got: %v", expectedBody, respBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err = http.NewRequest("GET", "/docflags/test.md", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Could not create HTTP request: %v", err)
|
||||||
|
}
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
|
||||||
|
md.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("Wrong status, expected: %d and got %d", http.StatusOK, rec.Code)
|
||||||
|
}
|
||||||
|
respBody = rec.Body.String()
|
||||||
|
expectedBody = `Doc.var_string hello
|
||||||
|
Doc.var_bool <no value>
|
||||||
|
DocFlags.var_string <no value>
|
||||||
|
DocFlags.var_bool true`
|
||||||
|
|
||||||
|
if !equalStrings(respBody, expectedBody) {
|
||||||
|
t.Fatalf("Expected body: %v got: %v", expectedBody, respBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err = http.NewRequest("GET", "/log/test.md", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Could not create HTTP request: %v", err)
|
||||||
|
}
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
|
||||||
|
md.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("Wrong status, expected: %d and got %d", http.StatusOK, rec.Code)
|
||||||
|
}
|
||||||
|
respBody = rec.Body.String()
|
||||||
|
expectedBody = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Markdown test 2</title>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<link rel="stylesheet" href="/resources/css/log.css">
|
||||||
|
<link rel="stylesheet" href="/resources/css/default.css">
|
||||||
|
<script src="/resources/js/log.js"></script>
|
||||||
|
<script src="/resources/js/default.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h2>Welcome on the blog</h2>
|
||||||
|
|
||||||
|
<p>Body</p>
|
||||||
|
|
||||||
|
<pre><code class="language-go">func getTrue() bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
</code></pre>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
if !equalStrings(respBody, expectedBody) {
|
||||||
|
t.Fatalf("Expected body: %v got: %v", expectedBody, respBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err = http.NewRequest("GET", "/og/first.md", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Could not create HTTP request: %v", err)
|
||||||
|
}
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
currenttime := time.Now().Local().Add(-time.Second)
|
||||||
|
_ = os.Chtimes("testdata/og/first.md", currenttime, currenttime)
|
||||||
|
currenttime = time.Now().Local()
|
||||||
|
_ = os.Chtimes("testdata/og_static/og/first.md/index.html", currenttime, currenttime)
|
||||||
|
time.Sleep(time.Millisecond * 200)
|
||||||
|
|
||||||
|
md.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("Wrong status, expected: %d and got %d", http.StatusOK, rec.Code)
|
||||||
|
}
|
||||||
|
respBody = rec.Body.String()
|
||||||
|
expectedBody = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>first_post</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Header for: first_post</h1>
|
||||||
|
|
||||||
|
Welcome to title!
|
||||||
|
<h1>Test h1</h1>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
if !equalStrings(respBody, expectedBody) {
|
||||||
|
t.Fatalf("Expected body: %v got: %v", expectedBody, respBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func equalStrings(s1, s2 string) bool {
|
||||||
|
s1 = strings.TrimSpace(s1)
|
||||||
|
s2 = strings.TrimSpace(s2)
|
||||||
|
in := bufio.NewScanner(strings.NewReader(s1))
|
||||||
|
for in.Scan() {
|
||||||
|
txt := strings.TrimSpace(in.Text())
|
||||||
|
if !strings.HasPrefix(strings.TrimSpace(s2), txt) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s2 = strings.Replace(s2, txt, "", 1)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func setDefaultTemplate(filename string) *template.Template {
|
||||||
|
buf, err := ioutil.ReadFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return template.Must(GetDefaultTemplate().Parse(string(buf)))
|
||||||
|
}
|
158
caddyhttp/markdown/metadata/metadata.go
Normal file
158
caddyhttp/markdown/metadata/metadata.go
Normal file
|
@ -0,0 +1,158 @@
|
||||||
|
package metadata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Date format YYYY-MM-DD HH:MM:SS or YYYY-MM-DD
|
||||||
|
timeLayout = []string{
|
||||||
|
`2006-01-02 15:04:05-0700`,
|
||||||
|
`2006-01-02 15:04:05`,
|
||||||
|
`2006-01-02`,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Metadata stores a page's metadata
|
||||||
|
type Metadata struct {
|
||||||
|
// Page title
|
||||||
|
Title string
|
||||||
|
|
||||||
|
// Page template
|
||||||
|
Template string
|
||||||
|
|
||||||
|
// Publish date
|
||||||
|
Date time.Time
|
||||||
|
|
||||||
|
// Variables to be used with Template
|
||||||
|
Variables map[string]string
|
||||||
|
|
||||||
|
// Flags to be used with Template
|
||||||
|
Flags map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMetadata() returns a new Metadata struct, loaded with the given map
|
||||||
|
func NewMetadata(parsedMap map[string]interface{}) Metadata {
|
||||||
|
md := Metadata{
|
||||||
|
Variables: make(map[string]string),
|
||||||
|
Flags: make(map[string]bool),
|
||||||
|
}
|
||||||
|
md.load(parsedMap)
|
||||||
|
|
||||||
|
return md
|
||||||
|
}
|
||||||
|
|
||||||
|
// load loads parsed values in parsedMap into Metadata
|
||||||
|
func (m *Metadata) load(parsedMap map[string]interface{}) {
|
||||||
|
|
||||||
|
// Pull top level things out
|
||||||
|
if title, ok := parsedMap["title"]; ok {
|
||||||
|
m.Title, _ = title.(string)
|
||||||
|
}
|
||||||
|
if template, ok := parsedMap["template"]; ok {
|
||||||
|
m.Template, _ = template.(string)
|
||||||
|
}
|
||||||
|
if date, ok := parsedMap["date"].(string); ok {
|
||||||
|
for _, layout := range timeLayout {
|
||||||
|
if t, err := time.Parse(layout, date); err == nil {
|
||||||
|
m.Date = t
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store everything as a flag or variable
|
||||||
|
for key, val := range parsedMap {
|
||||||
|
switch v := val.(type) {
|
||||||
|
case bool:
|
||||||
|
m.Flags[key] = v
|
||||||
|
case string:
|
||||||
|
m.Variables[key] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetadataParser is a an interface that must be satisfied by each parser
|
||||||
|
type MetadataParser interface {
|
||||||
|
// Initialize a parser
|
||||||
|
Init(b *bytes.Buffer) bool
|
||||||
|
|
||||||
|
// Type of metadata
|
||||||
|
Type() string
|
||||||
|
|
||||||
|
// Parsed metadata.
|
||||||
|
Metadata() Metadata
|
||||||
|
|
||||||
|
// Raw markdown.
|
||||||
|
Markdown() []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetParser returns a parser for the given data
|
||||||
|
func GetParser(buf []byte) MetadataParser {
|
||||||
|
for _, p := range parsers() {
|
||||||
|
b := bytes.NewBuffer(buf)
|
||||||
|
if p.Init(b) {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsers returns all available parsers
|
||||||
|
func parsers() []MetadataParser {
|
||||||
|
return []MetadataParser{
|
||||||
|
&TOMLMetadataParser{},
|
||||||
|
&YAMLMetadataParser{},
|
||||||
|
&JSONMetadataParser{},
|
||||||
|
|
||||||
|
// This one must be last
|
||||||
|
&NoneMetadataParser{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split out prefixed/suffixed metadata with given delimiter
|
||||||
|
func splitBuffer(b *bytes.Buffer, delim string) (*bytes.Buffer, *bytes.Buffer) {
|
||||||
|
scanner := bufio.NewScanner(b)
|
||||||
|
|
||||||
|
// Read and check first line
|
||||||
|
if !scanner.Scan() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if string(bytes.TrimSpace(scanner.Bytes())) != delim {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accumulate metadata, until delimiter
|
||||||
|
meta := bytes.NewBuffer(nil)
|
||||||
|
for scanner.Scan() {
|
||||||
|
if string(bytes.TrimSpace(scanner.Bytes())) == delim {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if _, err := meta.Write(scanner.Bytes()); err != nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if _, err := meta.WriteRune('\n'); err != nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Make sure we saw closing delimiter
|
||||||
|
if string(bytes.TrimSpace(scanner.Bytes())) != delim {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rest is markdown
|
||||||
|
markdown := new(bytes.Buffer)
|
||||||
|
for scanner.Scan() {
|
||||||
|
if _, err := markdown.Write(scanner.Bytes()); err != nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if _, err := markdown.WriteRune('\n'); err != nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return meta, markdown
|
||||||
|
}
|
53
caddyhttp/markdown/metadata/metadata_json.go
Normal file
53
caddyhttp/markdown/metadata/metadata_json.go
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
package metadata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JSONMetadataParser is the MetadataParser for JSON
|
||||||
|
type JSONMetadataParser struct {
|
||||||
|
metadata Metadata
|
||||||
|
markdown *bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *JSONMetadataParser) Type() string {
|
||||||
|
return "JSON"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse metadata/markdown file
|
||||||
|
func (j *JSONMetadataParser) Init(b *bytes.Buffer) bool {
|
||||||
|
m := make(map[string]interface{})
|
||||||
|
|
||||||
|
err := json.Unmarshal(b.Bytes(), &m)
|
||||||
|
if err != nil {
|
||||||
|
var offset int
|
||||||
|
|
||||||
|
if jerr, ok := err.(*json.SyntaxError); !ok {
|
||||||
|
return false
|
||||||
|
} else {
|
||||||
|
offset = int(jerr.Offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
m = make(map[string]interface{})
|
||||||
|
err = json.Unmarshal(b.Next(offset-1), &m)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
j.metadata = NewMetadata(m)
|
||||||
|
j.markdown = bytes.NewBuffer(b.Bytes())
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metadata returns parsed metadata. It should be called
|
||||||
|
// only after a call to Parse returns without error.
|
||||||
|
func (j *JSONMetadataParser) Metadata() Metadata {
|
||||||
|
return j.metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *JSONMetadataParser) Markdown() []byte {
|
||||||
|
return j.markdown.Bytes()
|
||||||
|
}
|
39
caddyhttp/markdown/metadata/metadata_none.go
Normal file
39
caddyhttp/markdown/metadata/metadata_none.go
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
package metadata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TOMLMetadataParser is the MetadataParser for TOML
|
||||||
|
type NoneMetadataParser struct {
|
||||||
|
metadata Metadata
|
||||||
|
markdown *bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *NoneMetadataParser) Type() string {
|
||||||
|
return "None"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse metadata/markdown file
|
||||||
|
func (n *NoneMetadataParser) Init(b *bytes.Buffer) bool {
|
||||||
|
m := make(map[string]interface{})
|
||||||
|
n.metadata = NewMetadata(m)
|
||||||
|
n.markdown = bytes.NewBuffer(b.Bytes())
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the metadata
|
||||||
|
func (n *NoneMetadataParser) Parse(b []byte) ([]byte, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metadata returns parsed metadata. It should be called
|
||||||
|
// only after a call to Parse returns without error.
|
||||||
|
func (n *NoneMetadataParser) Metadata() Metadata {
|
||||||
|
return n.metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *NoneMetadataParser) Markdown() []byte {
|
||||||
|
return n.markdown.Bytes()
|
||||||
|
}
|
261
caddyhttp/markdown/metadata/metadata_test.go
Normal file
261
caddyhttp/markdown/metadata/metadata_test.go
Normal file
|
@ -0,0 +1,261 @@
|
||||||
|
package metadata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func check(t *testing.T, err error) {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var TOML = [5]string{`
|
||||||
|
title = "A title"
|
||||||
|
template = "default"
|
||||||
|
name = "value"
|
||||||
|
positive = true
|
||||||
|
negative = false
|
||||||
|
`,
|
||||||
|
`+++
|
||||||
|
title = "A title"
|
||||||
|
template = "default"
|
||||||
|
name = "value"
|
||||||
|
positive = true
|
||||||
|
negative = false
|
||||||
|
+++
|
||||||
|
Page content
|
||||||
|
`,
|
||||||
|
`+++
|
||||||
|
title = "A title"
|
||||||
|
template = "default"
|
||||||
|
name = "value"
|
||||||
|
positive = true
|
||||||
|
negative = false
|
||||||
|
`,
|
||||||
|
`title = "A title" template = "default" [variables] name = "value"`,
|
||||||
|
`+++
|
||||||
|
title = "A title"
|
||||||
|
template = "default"
|
||||||
|
name = "value"
|
||||||
|
positive = true
|
||||||
|
negative = false
|
||||||
|
+++
|
||||||
|
`,
|
||||||
|
}
|
||||||
|
|
||||||
|
var YAML = [5]string{`
|
||||||
|
title : A title
|
||||||
|
template : default
|
||||||
|
name : value
|
||||||
|
positive : true
|
||||||
|
negative : false
|
||||||
|
`,
|
||||||
|
`---
|
||||||
|
title : A title
|
||||||
|
template : default
|
||||||
|
name : value
|
||||||
|
positive : true
|
||||||
|
negative : false
|
||||||
|
---
|
||||||
|
Page content
|
||||||
|
`,
|
||||||
|
`---
|
||||||
|
title : A title
|
||||||
|
template : default
|
||||||
|
name : value
|
||||||
|
`,
|
||||||
|
`title : A title template : default variables : name : value : positive : true : negative : false`,
|
||||||
|
`---
|
||||||
|
title : A title
|
||||||
|
template : default
|
||||||
|
name : value
|
||||||
|
positive : true
|
||||||
|
negative : false
|
||||||
|
---
|
||||||
|
`,
|
||||||
|
}
|
||||||
|
|
||||||
|
var JSON = [5]string{`
|
||||||
|
"title" : "A title",
|
||||||
|
"template" : "default",
|
||||||
|
"name" : "value",
|
||||||
|
"positive" : true,
|
||||||
|
"negative" : false
|
||||||
|
`,
|
||||||
|
`{
|
||||||
|
"title" : "A title",
|
||||||
|
"template" : "default",
|
||||||
|
"name" : "value",
|
||||||
|
"positive" : true,
|
||||||
|
"negative" : false
|
||||||
|
}
|
||||||
|
Page content
|
||||||
|
`,
|
||||||
|
`
|
||||||
|
{
|
||||||
|
"title" : "A title",
|
||||||
|
"template" : "default",
|
||||||
|
"name" : "value",
|
||||||
|
"positive" : true,
|
||||||
|
"negative" : false
|
||||||
|
`,
|
||||||
|
`
|
||||||
|
{
|
||||||
|
"title" :: "A title",
|
||||||
|
"template" : "default",
|
||||||
|
"name" : "value",
|
||||||
|
"positive" : true,
|
||||||
|
"negative" : false
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
`{
|
||||||
|
"title" : "A title",
|
||||||
|
"template" : "default",
|
||||||
|
"name" : "value",
|
||||||
|
"positive" : true,
|
||||||
|
"negative" : false
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParsers(t *testing.T) {
|
||||||
|
expected := Metadata{
|
||||||
|
Title: "A title",
|
||||||
|
Template: "default",
|
||||||
|
Variables: map[string]string{
|
||||||
|
"name": "value",
|
||||||
|
"title": "A title",
|
||||||
|
"template": "default",
|
||||||
|
},
|
||||||
|
Flags: map[string]bool{
|
||||||
|
"positive": true,
|
||||||
|
"negative": false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
compare := func(m Metadata) bool {
|
||||||
|
if m.Title != expected.Title {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if m.Template != expected.Template {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for k, v := range m.Variables {
|
||||||
|
if v != expected.Variables[k] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k, v := range m.Flags {
|
||||||
|
if v != expected.Flags[k] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
varLenOK := len(m.Variables) == len(expected.Variables)
|
||||||
|
flagLenOK := len(m.Flags) == len(expected.Flags)
|
||||||
|
return varLenOK && flagLenOK
|
||||||
|
}
|
||||||
|
|
||||||
|
data := []struct {
|
||||||
|
parser MetadataParser
|
||||||
|
testData [5]string
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{&JSONMetadataParser{}, JSON, "JSON"},
|
||||||
|
{&YAMLMetadataParser{}, YAML, "YAML"},
|
||||||
|
{&TOMLMetadataParser{}, TOML, "TOML"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, v := range data {
|
||||||
|
// metadata without identifiers
|
||||||
|
if v.parser.Init(bytes.NewBufferString(v.testData[0])) {
|
||||||
|
t.Fatalf("Expected error for invalid metadata for %v", v.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// metadata with identifiers
|
||||||
|
if !v.parser.Init(bytes.NewBufferString(v.testData[1])) {
|
||||||
|
t.Fatalf("Metadata failed to initialize, type %v", v.parser.Type())
|
||||||
|
}
|
||||||
|
md := v.parser.Markdown()
|
||||||
|
if !compare(v.parser.Metadata()) {
|
||||||
|
t.Fatalf("Expected %v, found %v for %v", expected, v.parser.Metadata(), v.name)
|
||||||
|
}
|
||||||
|
if "Page content" != strings.TrimSpace(string(md)) {
|
||||||
|
t.Fatalf("Expected %v, found %v for %v", "Page content", string(md), v.name)
|
||||||
|
}
|
||||||
|
// Check that we find the correct metadata parser type
|
||||||
|
if p := GetParser([]byte(v.testData[1])); p.Type() != v.name {
|
||||||
|
t.Fatalf("Wrong parser found, expected %v, found %v", v.name, p.Type())
|
||||||
|
}
|
||||||
|
|
||||||
|
// metadata without closing identifier
|
||||||
|
if v.parser.Init(bytes.NewBufferString(v.testData[2])) {
|
||||||
|
t.Fatalf("Expected error for missing closing identifier for %v parser", v.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// invalid metadata
|
||||||
|
if v.parser.Init(bytes.NewBufferString(v.testData[3])) {
|
||||||
|
t.Fatalf("Expected error for invalid metadata for %v", v.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// front matter but no body
|
||||||
|
if !v.parser.Init(bytes.NewBufferString(v.testData[4])) {
|
||||||
|
t.Fatalf("Unexpected error for valid metadata but no body for %v", v.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLargeBody(t *testing.T) {
|
||||||
|
|
||||||
|
var JSON = `{
|
||||||
|
"template": "chapter"
|
||||||
|
}
|
||||||
|
|
||||||
|
Mycket olika byggnader har man i de nordiska rikena: pyramidformiga, kilformiga, välvda, runda och fyrkantiga. De pyramidformiga består helt enkelt av träribbor, som upptill löper samman och nedtill bildar en vidare krets; de är avsedda att användas av hantverkarna under sommaren, för att de inte ska plågas av solen, på samma gång som de besväras av rök och eld. De kilformiga husen är i regel försedda med höga tak, för att de täta och tunga snömassorna fortare ska kunna blåsa av och inte tynga ned taken. Dessa är täckta av björknäver, tegel eller kluvet spån av furu - för kådans skull -, gran, ek eller bok; taken på de förmögnas hus däremot med plåtar av koppar eller bly, i likhet med kyrktaken. Valvbyggnaderna uppförs ganska konstnärligt till skydd mot våldsamma vindar och snöfall, görs av sten eller trä, och är avsedda för olika alldagliga viktiga ändamål. Liknande byggnader kan finnas i stormännens gårdar där de används som förvaringsrum för husgeråd och jordbruksredskap. De runda byggnaderna - som för övrigt är de högst sällsynta - används av konstnärer, som vid sitt arbete behöver ett jämnt fördelat ljus från taket. Vanligast är de fyrkantiga husen, vars grova bjälkar är synnerligen väl hopfogade i hörnen - ett sant mästerverk av byggnadskonst; även dessa har fönster högt uppe i taken, för att dagsljuset skall kunna strömma in och ge alla därinne full belysning. Stenhusen har dörröppningar i förhållande till byggnadens storlek, men smala fönstergluggar, som skydd mot den stränga kölden, frosten och snön. Vore de större och vidare, såsom fönstren i Italien, skulle husen i följd av den fint yrande snön, som röres upp av den starka blåsten, precis som dammet av virvelvinden, snart nog fyllas med massor av snö och inte kunna stå emot dess tryck, utan störta samman.
|
||||||
|
|
||||||
|
`
|
||||||
|
var TOML = `+++
|
||||||
|
template = "chapter"
|
||||||
|
+++
|
||||||
|
|
||||||
|
Mycket olika byggnader har man i de nordiska rikena: pyramidformiga, kilformiga, välvda, runda och fyrkantiga. De pyramidformiga består helt enkelt av träribbor, som upptill löper samman och nedtill bildar en vidare krets; de är avsedda att användas av hantverkarna under sommaren, för att de inte ska plågas av solen, på samma gång som de besväras av rök och eld. De kilformiga husen är i regel försedda med höga tak, för att de täta och tunga snömassorna fortare ska kunna blåsa av och inte tynga ned taken. Dessa är täckta av björknäver, tegel eller kluvet spån av furu - för kådans skull -, gran, ek eller bok; taken på de förmögnas hus däremot med plåtar av koppar eller bly, i likhet med kyrktaken. Valvbyggnaderna uppförs ganska konstnärligt till skydd mot våldsamma vindar och snöfall, görs av sten eller trä, och är avsedda för olika alldagliga viktiga ändamål. Liknande byggnader kan finnas i stormännens gårdar där de används som förvaringsrum för husgeråd och jordbruksredskap. De runda byggnaderna - som för övrigt är de högst sällsynta - används av konstnärer, som vid sitt arbete behöver ett jämnt fördelat ljus från taket. Vanligast är de fyrkantiga husen, vars grova bjälkar är synnerligen väl hopfogade i hörnen - ett sant mästerverk av byggnadskonst; även dessa har fönster högt uppe i taken, för att dagsljuset skall kunna strömma in och ge alla därinne full belysning. Stenhusen har dörröppningar i förhållande till byggnadens storlek, men smala fönstergluggar, som skydd mot den stränga kölden, frosten och snön. Vore de större och vidare, såsom fönstren i Italien, skulle husen i följd av den fint yrande snön, som röres upp av den starka blåsten, precis som dammet av virvelvinden, snart nog fyllas med massor av snö och inte kunna stå emot dess tryck, utan störta samman.
|
||||||
|
|
||||||
|
`
|
||||||
|
var YAML = `---
|
||||||
|
template : chapter
|
||||||
|
---
|
||||||
|
|
||||||
|
Mycket olika byggnader har man i de nordiska rikena: pyramidformiga, kilformiga, välvda, runda och fyrkantiga. De pyramidformiga består helt enkelt av träribbor, som upptill löper samman och nedtill bildar en vidare krets; de är avsedda att användas av hantverkarna under sommaren, för att de inte ska plågas av solen, på samma gång som de besväras av rök och eld. De kilformiga husen är i regel försedda med höga tak, för att de täta och tunga snömassorna fortare ska kunna blåsa av och inte tynga ned taken. Dessa är täckta av björknäver, tegel eller kluvet spån av furu - för kådans skull -, gran, ek eller bok; taken på de förmögnas hus däremot med plåtar av koppar eller bly, i likhet med kyrktaken. Valvbyggnaderna uppförs ganska konstnärligt till skydd mot våldsamma vindar och snöfall, görs av sten eller trä, och är avsedda för olika alldagliga viktiga ändamål. Liknande byggnader kan finnas i stormännens gårdar där de används som förvaringsrum för husgeråd och jordbruksredskap. De runda byggnaderna - som för övrigt är de högst sällsynta - används av konstnärer, som vid sitt arbete behöver ett jämnt fördelat ljus från taket. Vanligast är de fyrkantiga husen, vars grova bjälkar är synnerligen väl hopfogade i hörnen - ett sant mästerverk av byggnadskonst; även dessa har fönster högt uppe i taken, för att dagsljuset skall kunna strömma in och ge alla därinne full belysning. Stenhusen har dörröppningar i förhållande till byggnadens storlek, men smala fönstergluggar, som skydd mot den stränga kölden, frosten och snön. Vore de större och vidare, såsom fönstren i Italien, skulle husen i följd av den fint yrande snön, som röres upp av den starka blåsten, precis som dammet av virvelvinden, snart nog fyllas med massor av snö och inte kunna stå emot dess tryck, utan störta samman.
|
||||||
|
|
||||||
|
`
|
||||||
|
var NONE = `
|
||||||
|
|
||||||
|
Mycket olika byggnader har man i de nordiska rikena: pyramidformiga, kilformiga, välvda, runda och fyrkantiga. De pyramidformiga består helt enkelt av träribbor, som upptill löper samman och nedtill bildar en vidare krets; de är avsedda att användas av hantverkarna under sommaren, för att de inte ska plågas av solen, på samma gång som de besväras av rök och eld. De kilformiga husen är i regel försedda med höga tak, för att de täta och tunga snömassorna fortare ska kunna blåsa av och inte tynga ned taken. Dessa är täckta av björknäver, tegel eller kluvet spån av furu - för kådans skull -, gran, ek eller bok; taken på de förmögnas hus däremot med plåtar av koppar eller bly, i likhet med kyrktaken. Valvbyggnaderna uppförs ganska konstnärligt till skydd mot våldsamma vindar och snöfall, görs av sten eller trä, och är avsedda för olika alldagliga viktiga ändamål. Liknande byggnader kan finnas i stormännens gårdar där de används som förvaringsrum för husgeråd och jordbruksredskap. De runda byggnaderna - som för övrigt är de högst sällsynta - används av konstnärer, som vid sitt arbete behöver ett jämnt fördelat ljus från taket. Vanligast är de fyrkantiga husen, vars grova bjälkar är synnerligen väl hopfogade i hörnen - ett sant mästerverk av byggnadskonst; även dessa har fönster högt uppe i taken, för att dagsljuset skall kunna strömma in och ge alla därinne full belysning. Stenhusen har dörröppningar i förhållande till byggnadens storlek, men smala fönstergluggar, som skydd mot den stränga kölden, frosten och snön. Vore de större och vidare, såsom fönstren i Italien, skulle husen i följd av den fint yrande snön, som röres upp av den starka blåsten, precis som dammet av virvelvinden, snart nog fyllas med massor av snö och inte kunna stå emot dess tryck, utan störta samman.
|
||||||
|
|
||||||
|
`
|
||||||
|
var expectedBody = `Mycket olika byggnader har man i de nordiska rikena: pyramidformiga, kilformiga, välvda, runda och fyrkantiga. De pyramidformiga består helt enkelt av träribbor, som upptill löper samman och nedtill bildar en vidare krets; de är avsedda att användas av hantverkarna under sommaren, för att de inte ska plågas av solen, på samma gång som de besväras av rök och eld. De kilformiga husen är i regel försedda med höga tak, för att de täta och tunga snömassorna fortare ska kunna blåsa av och inte tynga ned taken. Dessa är täckta av björknäver, tegel eller kluvet spån av furu - för kådans skull -, gran, ek eller bok; taken på de förmögnas hus däremot med plåtar av koppar eller bly, i likhet med kyrktaken. Valvbyggnaderna uppförs ganska konstnärligt till skydd mot våldsamma vindar och snöfall, görs av sten eller trä, och är avsedda för olika alldagliga viktiga ändamål. Liknande byggnader kan finnas i stormännens gårdar där de används som förvaringsrum för husgeråd och jordbruksredskap. De runda byggnaderna - som för övrigt är de högst sällsynta - används av konstnärer, som vid sitt arbete behöver ett jämnt fördelat ljus från taket. Vanligast är de fyrkantiga husen, vars grova bjälkar är synnerligen väl hopfogade i hörnen - ett sant mästerverk av byggnadskonst; även dessa har fönster högt uppe i taken, för att dagsljuset skall kunna strömma in och ge alla därinne full belysning. Stenhusen har dörröppningar i förhållande till byggnadens storlek, men smala fönstergluggar, som skydd mot den stränga kölden, frosten och snön. Vore de större och vidare, såsom fönstren i Italien, skulle husen i följd av den fint yrande snön, som röres upp av den starka blåsten, precis som dammet av virvelvinden, snart nog fyllas med massor av snö och inte kunna stå emot dess tryck, utan störta samman.
|
||||||
|
`
|
||||||
|
|
||||||
|
data := []struct {
|
||||||
|
pType string
|
||||||
|
testData string
|
||||||
|
}{
|
||||||
|
{"JSON", JSON},
|
||||||
|
{"TOML", TOML},
|
||||||
|
{"YAML", YAML},
|
||||||
|
{"None", NONE},
|
||||||
|
}
|
||||||
|
for _, v := range data {
|
||||||
|
p := GetParser([]byte(v.testData))
|
||||||
|
if v.pType != p.Type() {
|
||||||
|
t.Fatalf("Wrong parser type, expected %v, got %v", v.pType, p.Type())
|
||||||
|
}
|
||||||
|
md := p.Markdown()
|
||||||
|
if strings.TrimSpace(string(md)) != strings.TrimSpace(expectedBody) {
|
||||||
|
t.Log("Provided:", v.testData)
|
||||||
|
t.Log("Returned:", p.Markdown())
|
||||||
|
t.Fatalf("Error, mismatched body in expected type %v, matched type %v", v.pType, p.Type())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
44
caddyhttp/markdown/metadata/metadata_toml.go
Normal file
44
caddyhttp/markdown/metadata/metadata_toml.go
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
package metadata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
|
||||||
|
"github.com/BurntSushi/toml"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TOMLMetadataParser is the MetadataParser for TOML
|
||||||
|
type TOMLMetadataParser struct {
|
||||||
|
metadata Metadata
|
||||||
|
markdown *bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TOMLMetadataParser) Type() string {
|
||||||
|
return "TOML"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse metadata/markdown file
|
||||||
|
func (t *TOMLMetadataParser) Init(b *bytes.Buffer) bool {
|
||||||
|
meta, data := splitBuffer(b, "+++")
|
||||||
|
if meta == nil || data == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
t.markdown = data
|
||||||
|
|
||||||
|
m := make(map[string]interface{})
|
||||||
|
if err := toml.Unmarshal(meta.Bytes(), &m); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
t.metadata = NewMetadata(m)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metadata returns parsed metadata. It should be called
|
||||||
|
// only after a call to Parse returns without error.
|
||||||
|
func (t *TOMLMetadataParser) Metadata() Metadata {
|
||||||
|
return t.metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TOMLMetadataParser) Markdown() []byte {
|
||||||
|
return t.markdown.Bytes()
|
||||||
|
}
|
43
caddyhttp/markdown/metadata/metadata_yaml.go
Normal file
43
caddyhttp/markdown/metadata/metadata_yaml.go
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
package metadata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// YAMLMetadataParser is the MetadataParser for YAML
|
||||||
|
type YAMLMetadataParser struct {
|
||||||
|
metadata Metadata
|
||||||
|
markdown *bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (y *YAMLMetadataParser) Type() string {
|
||||||
|
return "YAML"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (y *YAMLMetadataParser) Init(b *bytes.Buffer) bool {
|
||||||
|
meta, data := splitBuffer(b, "---")
|
||||||
|
if meta == nil || data == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
y.markdown = data
|
||||||
|
|
||||||
|
m := make(map[string]interface{})
|
||||||
|
if err := yaml.Unmarshal(meta.Bytes(), &m); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
y.metadata = NewMetadata(m)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metadata returns parsed metadata. It should be called
|
||||||
|
// only after a call to Parse returns without error.
|
||||||
|
func (y *YAMLMetadataParser) Metadata() Metadata {
|
||||||
|
return y.metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
func (y *YAMLMetadataParser) Markdown() []byte {
|
||||||
|
return y.markdown.Bytes()
|
||||||
|
}
|
74
caddyhttp/markdown/process.go
Normal file
74
caddyhttp/markdown/process.go
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
package markdown
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/mholt/caddy/caddyhttp/httpserver"
|
||||||
|
"github.com/mholt/caddy/caddyhttp/markdown/metadata"
|
||||||
|
"github.com/mholt/caddy/caddyhttp/markdown/summary"
|
||||||
|
"github.com/russross/blackfriday"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FileInfo struct {
|
||||||
|
os.FileInfo
|
||||||
|
ctx httpserver.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f FileInfo) Summarize(wordcount int) (string, error) {
|
||||||
|
fp, err := f.ctx.Root.Open(f.Name())
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer fp.Close()
|
||||||
|
|
||||||
|
buf, err := ioutil.ReadAll(fp)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(summary.Markdown(buf, wordcount)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Markdown processes the contents of a page in b. It parses the metadata
|
||||||
|
// (if any) and uses the template (if found).
|
||||||
|
func (c *Config) Markdown(title string, r io.Reader, dirents []os.FileInfo, ctx httpserver.Context) ([]byte, error) {
|
||||||
|
body, err := ioutil.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
parser := metadata.GetParser(body)
|
||||||
|
markdown := parser.Markdown()
|
||||||
|
mdata := parser.Metadata()
|
||||||
|
|
||||||
|
// process markdown
|
||||||
|
extns := 0
|
||||||
|
extns |= blackfriday.EXTENSION_TABLES
|
||||||
|
extns |= blackfriday.EXTENSION_FENCED_CODE
|
||||||
|
extns |= blackfriday.EXTENSION_STRIKETHROUGH
|
||||||
|
extns |= blackfriday.EXTENSION_DEFINITION_LISTS
|
||||||
|
html := blackfriday.Markdown(markdown, c.Renderer, extns)
|
||||||
|
|
||||||
|
// set it as body for template
|
||||||
|
mdata.Variables["body"] = string(html)
|
||||||
|
|
||||||
|
// fixup title
|
||||||
|
mdata.Variables["title"] = mdata.Title
|
||||||
|
if mdata.Variables["title"] == "" {
|
||||||
|
mdata.Variables["title"] = title
|
||||||
|
}
|
||||||
|
|
||||||
|
// massage possible files
|
||||||
|
files := []FileInfo{}
|
||||||
|
for _, ent := range dirents {
|
||||||
|
file := FileInfo{
|
||||||
|
FileInfo: ent,
|
||||||
|
ctx: ctx,
|
||||||
|
}
|
||||||
|
files = append(files, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
return execTemplate(c, mdata, files, ctx)
|
||||||
|
}
|
141
caddyhttp/markdown/setup.go
Normal file
141
caddyhttp/markdown/setup.go
Normal file
|
@ -0,0 +1,141 @@
|
||||||
|
package markdown
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/mholt/caddy"
|
||||||
|
"github.com/mholt/caddy/caddyhttp/httpserver"
|
||||||
|
"github.com/russross/blackfriday"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
caddy.RegisterPlugin(caddy.Plugin{
|
||||||
|
Name: "markdown",
|
||||||
|
ServerType: "http",
|
||||||
|
Action: setup,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// setup configures a new Markdown middleware instance.
|
||||||
|
func setup(c *caddy.Controller) error {
|
||||||
|
mdconfigs, err := markdownParse(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := httpserver.GetConfig(c.Key)
|
||||||
|
|
||||||
|
md := Markdown{
|
||||||
|
Root: cfg.Root,
|
||||||
|
FileSys: http.Dir(cfg.Root),
|
||||||
|
Configs: mdconfigs,
|
||||||
|
IndexFiles: []string{"index.md"},
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.AddMiddleware(func(next httpserver.Handler) httpserver.Handler {
|
||||||
|
md.Next = next
|
||||||
|
return md
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func markdownParse(c *caddy.Controller) ([]*Config, error) {
|
||||||
|
var mdconfigs []*Config
|
||||||
|
|
||||||
|
for c.Next() {
|
||||||
|
md := &Config{
|
||||||
|
Renderer: blackfriday.HtmlRenderer(0, "", ""),
|
||||||
|
Extensions: make(map[string]struct{}),
|
||||||
|
Template: GetDefaultTemplate(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the path scope
|
||||||
|
args := c.RemainingArgs()
|
||||||
|
switch len(args) {
|
||||||
|
case 0:
|
||||||
|
md.PathScope = "/"
|
||||||
|
case 1:
|
||||||
|
md.PathScope = args[0]
|
||||||
|
default:
|
||||||
|
return mdconfigs, c.ArgErr()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load any other configuration parameters
|
||||||
|
for c.NextBlock() {
|
||||||
|
if err := loadParams(c, md); err != nil {
|
||||||
|
return mdconfigs, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no extensions were specified, assume some defaults
|
||||||
|
if len(md.Extensions) == 0 {
|
||||||
|
md.Extensions[".md"] = struct{}{}
|
||||||
|
md.Extensions[".markdown"] = struct{}{}
|
||||||
|
md.Extensions[".mdown"] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
mdconfigs = append(mdconfigs, md)
|
||||||
|
}
|
||||||
|
|
||||||
|
return mdconfigs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadParams(c *caddy.Controller, mdc *Config) error {
|
||||||
|
cfg := httpserver.GetConfig(c.Key)
|
||||||
|
|
||||||
|
switch c.Val() {
|
||||||
|
case "ext":
|
||||||
|
for _, ext := range c.RemainingArgs() {
|
||||||
|
mdc.Extensions[ext] = struct{}{}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case "css":
|
||||||
|
if !c.NextArg() {
|
||||||
|
return c.ArgErr()
|
||||||
|
}
|
||||||
|
mdc.Styles = append(mdc.Styles, c.Val())
|
||||||
|
return nil
|
||||||
|
case "js":
|
||||||
|
if !c.NextArg() {
|
||||||
|
return c.ArgErr()
|
||||||
|
}
|
||||||
|
mdc.Scripts = append(mdc.Scripts, c.Val())
|
||||||
|
return nil
|
||||||
|
case "template":
|
||||||
|
tArgs := c.RemainingArgs()
|
||||||
|
switch len(tArgs) {
|
||||||
|
default:
|
||||||
|
return c.ArgErr()
|
||||||
|
case 1:
|
||||||
|
fpath := filepath.ToSlash(filepath.Clean(cfg.Root + string(filepath.Separator) + tArgs[0]))
|
||||||
|
|
||||||
|
if err := SetTemplate(mdc.Template, "", fpath); err != nil {
|
||||||
|
c.Errf("default template parse error: %v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case 2:
|
||||||
|
fpath := filepath.ToSlash(filepath.Clean(cfg.Root + string(filepath.Separator) + tArgs[1]))
|
||||||
|
|
||||||
|
if err := SetTemplate(mdc.Template, tArgs[0], fpath); err != nil {
|
||||||
|
c.Errf("template parse error: %v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
case "templatedir":
|
||||||
|
if !c.NextArg() {
|
||||||
|
return c.ArgErr()
|
||||||
|
}
|
||||||
|
_, err := mdc.Template.ParseGlob(c.Val())
|
||||||
|
if err != nil {
|
||||||
|
c.Errf("template load error: %v", err)
|
||||||
|
}
|
||||||
|
if c.NextArg() {
|
||||||
|
return c.ArgErr()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return c.Err("Expected valid markdown configuration property")
|
||||||
|
}
|
||||||
|
}
|
152
caddyhttp/markdown/setup_test.go
Normal file
152
caddyhttp/markdown/setup_test.go
Normal file
|
@ -0,0 +1,152 @@
|
||||||
|
package markdown
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
|
"github.com/mholt/caddy"
|
||||||
|
"github.com/mholt/caddy/caddyhttp/httpserver"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSetup(t *testing.T) {
|
||||||
|
err := setup(caddy.NewTestController(`markdown /blog`))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Expected no errors, got: %v", err)
|
||||||
|
}
|
||||||
|
mids := httpserver.GetConfig("").Middleware()
|
||||||
|
if len(mids) == 0 {
|
||||||
|
t.Fatal("Expected middleware, got 0 instead")
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := mids[0](httpserver.EmptyNext)
|
||||||
|
myHandler, ok := handler.(Markdown)
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Expected handler to be type Markdown, got: %#v", handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
if myHandler.Configs[0].PathScope != "/blog" {
|
||||||
|
t.Errorf("Expected /blog as the Path Scope")
|
||||||
|
}
|
||||||
|
if len(myHandler.Configs[0].Extensions) != 3 {
|
||||||
|
t.Error("Expected 3 markdown extensions")
|
||||||
|
}
|
||||||
|
for _, key := range []string{".md", ".markdown", ".mdown"} {
|
||||||
|
if ext, ok := myHandler.Configs[0].Extensions[key]; !ok {
|
||||||
|
t.Errorf("Expected extensions to contain %v", ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkdownParse(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
inputMarkdownConfig string
|
||||||
|
shouldErr bool
|
||||||
|
expectedMarkdownConfig []Config
|
||||||
|
}{
|
||||||
|
|
||||||
|
{`markdown /blog {
|
||||||
|
ext .md .txt
|
||||||
|
css /resources/css/blog.css
|
||||||
|
js /resources/js/blog.js
|
||||||
|
}`, false, []Config{{
|
||||||
|
PathScope: "/blog",
|
||||||
|
Extensions: map[string]struct{}{
|
||||||
|
".md": {},
|
||||||
|
".txt": {},
|
||||||
|
},
|
||||||
|
Styles: []string{"/resources/css/blog.css"},
|
||||||
|
Scripts: []string{"/resources/js/blog.js"},
|
||||||
|
Template: GetDefaultTemplate(),
|
||||||
|
}}},
|
||||||
|
{`markdown /blog {
|
||||||
|
ext .md
|
||||||
|
template tpl_with_include.html
|
||||||
|
}`, false, []Config{{
|
||||||
|
PathScope: "/blog",
|
||||||
|
Extensions: map[string]struct{}{
|
||||||
|
".md": {},
|
||||||
|
},
|
||||||
|
Template: GetDefaultTemplate(),
|
||||||
|
}}},
|
||||||
|
}
|
||||||
|
// Setup the extra template
|
||||||
|
tmpl := tests[1].expectedMarkdownConfig[0].Template
|
||||||
|
SetTemplate(tmpl, "", "./testdata/tpl_with_include.html")
|
||||||
|
|
||||||
|
for i, test := range tests {
|
||||||
|
c := caddy.NewTestController(test.inputMarkdownConfig)
|
||||||
|
httpserver.GetConfig("").Root = "./testdata"
|
||||||
|
actualMarkdownConfigs, err := markdownParse(c)
|
||||||
|
|
||||||
|
if err == nil && test.shouldErr {
|
||||||
|
t.Errorf("Test %d didn't error, but it should have", i)
|
||||||
|
} else if err != nil && !test.shouldErr {
|
||||||
|
t.Errorf("Test %d errored, but it shouldn't have; got '%v'", i, err)
|
||||||
|
}
|
||||||
|
if len(actualMarkdownConfigs) != len(test.expectedMarkdownConfig) {
|
||||||
|
t.Fatalf("Test %d expected %d no of WebSocket configs, but got %d ",
|
||||||
|
i, len(test.expectedMarkdownConfig), len(actualMarkdownConfigs))
|
||||||
|
}
|
||||||
|
for j, actualMarkdownConfig := range actualMarkdownConfigs {
|
||||||
|
|
||||||
|
if actualMarkdownConfig.PathScope != test.expectedMarkdownConfig[j].PathScope {
|
||||||
|
t.Errorf("Test %d expected %dth Markdown PathScope to be %s , but got %s",
|
||||||
|
i, j, test.expectedMarkdownConfig[j].PathScope, actualMarkdownConfig.PathScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fmt.Sprint(actualMarkdownConfig.Styles) != fmt.Sprint(test.expectedMarkdownConfig[j].Styles) {
|
||||||
|
t.Errorf("Test %d expected %dth Markdown Config Styles to be %s , but got %s",
|
||||||
|
i, j, fmt.Sprint(test.expectedMarkdownConfig[j].Styles), fmt.Sprint(actualMarkdownConfig.Styles))
|
||||||
|
}
|
||||||
|
if fmt.Sprint(actualMarkdownConfig.Scripts) != fmt.Sprint(test.expectedMarkdownConfig[j].Scripts) {
|
||||||
|
t.Errorf("Test %d expected %dth Markdown Config Scripts to be %s , but got %s",
|
||||||
|
i, j, fmt.Sprint(test.expectedMarkdownConfig[j].Scripts), fmt.Sprint(actualMarkdownConfig.Scripts))
|
||||||
|
}
|
||||||
|
if ok, tx, ty := equalTemplates(actualMarkdownConfig.Template, test.expectedMarkdownConfig[j].Template); !ok {
|
||||||
|
t.Errorf("Test %d the %dth Markdown Config Templates did not match, expected %s to be %s", i, j, tx, ty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func equalTemplates(i, j *template.Template) (bool, string, string) {
|
||||||
|
// Just in case :)
|
||||||
|
if i == j {
|
||||||
|
return true, "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// We can't do much here, templates can't really be compared. However,
|
||||||
|
// we can execute the templates and compare their outputs to be reasonably
|
||||||
|
// sure that they're the same.
|
||||||
|
|
||||||
|
// This is exceedingly ugly.
|
||||||
|
ctx := httpserver.Context{
|
||||||
|
Root: http.Dir("./testdata"),
|
||||||
|
}
|
||||||
|
|
||||||
|
md := Data{
|
||||||
|
Context: ctx,
|
||||||
|
Doc: make(map[string]string),
|
||||||
|
DocFlags: make(map[string]bool),
|
||||||
|
Styles: []string{"style1"},
|
||||||
|
Scripts: []string{"js1"},
|
||||||
|
}
|
||||||
|
md.Doc["title"] = "some title"
|
||||||
|
md.Doc["body"] = "some body"
|
||||||
|
|
||||||
|
bufi := new(bytes.Buffer)
|
||||||
|
bufj := new(bytes.Buffer)
|
||||||
|
|
||||||
|
if err := i.Execute(bufi, md); err != nil {
|
||||||
|
return false, fmt.Sprintf("%v", err), ""
|
||||||
|
}
|
||||||
|
if err := j.Execute(bufj, md); err != nil {
|
||||||
|
return false, "", fmt.Sprintf("%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes.Equal(bufi.Bytes(), bufj.Bytes()), string(bufi.Bytes()), string(bufj.Bytes())
|
||||||
|
}
|
153
caddyhttp/markdown/summary/render.go
Normal file
153
caddyhttp/markdown/summary/render.go
Normal file
|
@ -0,0 +1,153 @@
|
||||||
|
package summary
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
|
||||||
|
"github.com/russross/blackfriday"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ensure we implement the Blackfriday Markdown Renderer interface
|
||||||
|
var _ blackfriday.Renderer = (*renderer)(nil)
|
||||||
|
|
||||||
|
// renderer renders Markdown to plain-text meant for listings and excerpts,
|
||||||
|
// and implements the blackfriday.Renderer interface.
|
||||||
|
//
|
||||||
|
// Many of the methods are stubs with no output to prevent output of HTML markup.
|
||||||
|
type renderer struct{}
|
||||||
|
|
||||||
|
// Blocklevel callbacks
|
||||||
|
|
||||||
|
// BlockCode is the code tag callback.
|
||||||
|
func (r renderer) BlockCode(out *bytes.Buffer, text []byte, land string) {}
|
||||||
|
|
||||||
|
// BlockQuote is the quote tag callback.
|
||||||
|
func (r renderer) BlockQuote(out *bytes.Buffer, text []byte) {}
|
||||||
|
|
||||||
|
// BlockHtml is the HTML tag callback.
|
||||||
|
func (r renderer) BlockHtml(out *bytes.Buffer, text []byte) {}
|
||||||
|
|
||||||
|
// Header is the header tag callback.
|
||||||
|
func (r renderer) Header(out *bytes.Buffer, text func() bool, level int, id string) {}
|
||||||
|
|
||||||
|
// HRule is the horizontal rule tag callback.
|
||||||
|
func (r renderer) HRule(out *bytes.Buffer) {}
|
||||||
|
|
||||||
|
// List is the list tag callback.
|
||||||
|
func (r renderer) List(out *bytes.Buffer, text func() bool, flags int) {
|
||||||
|
// TODO: This is not desired (we'd rather not write lists as part of summary),
|
||||||
|
// but see this issue: https://github.com/russross/blackfriday/issues/189
|
||||||
|
marker := out.Len()
|
||||||
|
if !text() {
|
||||||
|
out.Truncate(marker)
|
||||||
|
}
|
||||||
|
out.Write([]byte{' '})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListItem is the list item tag callback.
|
||||||
|
func (r renderer) ListItem(out *bytes.Buffer, text []byte, flags int) {}
|
||||||
|
|
||||||
|
// Paragraph is the paragraph tag callback. This renders simple paragraph text
|
||||||
|
// into plain text, such that summaries can be easily generated.
|
||||||
|
func (r renderer) Paragraph(out *bytes.Buffer, text func() bool) {
|
||||||
|
marker := out.Len()
|
||||||
|
if !text() {
|
||||||
|
out.Truncate(marker)
|
||||||
|
}
|
||||||
|
out.Write([]byte{' '})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table is the table tag callback.
|
||||||
|
func (r renderer) Table(out *bytes.Buffer, header []byte, body []byte, columnData []int) {}
|
||||||
|
|
||||||
|
// TableRow is the table row tag callback.
|
||||||
|
func (r renderer) TableRow(out *bytes.Buffer, text []byte) {}
|
||||||
|
|
||||||
|
// TableHeaderCell is the table header cell tag callback.
|
||||||
|
func (r renderer) TableHeaderCell(out *bytes.Buffer, text []byte, flags int) {}
|
||||||
|
|
||||||
|
// TableCell is the table cell tag callback.
|
||||||
|
func (r renderer) TableCell(out *bytes.Buffer, text []byte, flags int) {}
|
||||||
|
|
||||||
|
// Footnotes is the foot notes tag callback.
|
||||||
|
func (r renderer) Footnotes(out *bytes.Buffer, text func() bool) {}
|
||||||
|
|
||||||
|
// FootnoteItem is the footnote item tag callback.
|
||||||
|
func (r renderer) FootnoteItem(out *bytes.Buffer, name, text []byte, flags int) {}
|
||||||
|
|
||||||
|
// TitleBlock is the title tag callback.
|
||||||
|
func (r renderer) TitleBlock(out *bytes.Buffer, text []byte) {}
|
||||||
|
|
||||||
|
// Spanlevel callbacks
|
||||||
|
|
||||||
|
// AutoLink is the autolink tag callback.
|
||||||
|
func (r renderer) AutoLink(out *bytes.Buffer, link []byte, kind int) {}
|
||||||
|
|
||||||
|
// CodeSpan is the code span tag callback. Outputs a simple Markdown version
|
||||||
|
// of the code span.
|
||||||
|
func (r renderer) CodeSpan(out *bytes.Buffer, text []byte) {
|
||||||
|
out.Write([]byte("`"))
|
||||||
|
out.Write(text)
|
||||||
|
out.Write([]byte("`"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DoubleEmphasis is the double emphasis tag callback. Outputs a simple
|
||||||
|
// plain-text version of the input.
|
||||||
|
func (r renderer) DoubleEmphasis(out *bytes.Buffer, text []byte) {
|
||||||
|
out.Write(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emphasis is the emphasis tag callback. Outputs a simple plain-text
|
||||||
|
// version of the input.
|
||||||
|
func (r renderer) Emphasis(out *bytes.Buffer, text []byte) {
|
||||||
|
out.Write(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image is the image tag callback.
|
||||||
|
func (r renderer) Image(out *bytes.Buffer, link []byte, title []byte, alt []byte) {}
|
||||||
|
|
||||||
|
// LineBreak is the line break tag callback.
|
||||||
|
func (r renderer) LineBreak(out *bytes.Buffer) {}
|
||||||
|
|
||||||
|
// Link is the link tag callback. Outputs a sipmle plain-text version
|
||||||
|
// of the input.
|
||||||
|
func (r renderer) Link(out *bytes.Buffer, link []byte, title []byte, content []byte) {
|
||||||
|
out.Write(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RawHtmlTag is the raw HTML tag callback.
|
||||||
|
func (r renderer) RawHtmlTag(out *bytes.Buffer, tag []byte) {}
|
||||||
|
|
||||||
|
// TripleEmphasis is the triple emphasis tag callback. Outputs a simple plain-text
|
||||||
|
// version of the input.
|
||||||
|
func (r renderer) TripleEmphasis(out *bytes.Buffer, text []byte) {
|
||||||
|
out.Write(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StrikeThrough is the strikethrough tag callback.
|
||||||
|
func (r renderer) StrikeThrough(out *bytes.Buffer, text []byte) {}
|
||||||
|
|
||||||
|
// FootnoteRef is the footnote ref tag callback.
|
||||||
|
func (r renderer) FootnoteRef(out *bytes.Buffer, ref []byte, id int) {}
|
||||||
|
|
||||||
|
// Lowlevel callbacks
|
||||||
|
|
||||||
|
// Entity callback. Outputs a simple plain-text version of the input.
|
||||||
|
func (r renderer) Entity(out *bytes.Buffer, entity []byte) {
|
||||||
|
out.Write(entity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalText callback. Outputs a simple plain-text version of the input.
|
||||||
|
func (r renderer) NormalText(out *bytes.Buffer, text []byte) {
|
||||||
|
out.Write(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header and footer
|
||||||
|
|
||||||
|
// DocumentHeader callback.
|
||||||
|
func (r renderer) DocumentHeader(out *bytes.Buffer) {}
|
||||||
|
|
||||||
|
// DocumentFooter callback.
|
||||||
|
func (r renderer) DocumentFooter(out *bytes.Buffer) {}
|
||||||
|
|
||||||
|
// GetFlags returns zero.
|
||||||
|
func (r renderer) GetFlags() int { return 0 }
|
17
caddyhttp/markdown/summary/summary.go
Normal file
17
caddyhttp/markdown/summary/summary.go
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
package summary
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
|
||||||
|
"github.com/russross/blackfriday"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Markdown formats input using a plain-text renderer, and
|
||||||
|
// then returns up to the first `wordcount` words as a summary.
|
||||||
|
func Markdown(input []byte, wordcount int) []byte {
|
||||||
|
words := bytes.Fields(blackfriday.Markdown(input, renderer{}, 0))
|
||||||
|
if wordcount > len(words) {
|
||||||
|
wordcount = len(words)
|
||||||
|
}
|
||||||
|
return bytes.Join(words[0:wordcount], []byte{' '})
|
||||||
|
}
|
88
caddyhttp/markdown/template.go
Normal file
88
caddyhttp/markdown/template.go
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
package markdown
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io/ioutil"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
|
"github.com/mholt/caddy/caddyhttp/httpserver"
|
||||||
|
"github.com/mholt/caddy/caddyhttp/markdown/metadata"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Data represents a markdown document.
|
||||||
|
type Data struct {
|
||||||
|
httpserver.Context
|
||||||
|
Doc map[string]string
|
||||||
|
DocFlags map[string]bool
|
||||||
|
Styles []string
|
||||||
|
Scripts []string
|
||||||
|
Files []FileInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include "overrides" the embedded httpserver.Context's Include()
|
||||||
|
// method so that included files have access to d's fields.
|
||||||
|
// Note: using {{template 'template-name' .}} instead might be better.
|
||||||
|
func (d Data) Include(filename string) (string, error) {
|
||||||
|
return httpserver.ContextInclude(filename, d, d.Root)
|
||||||
|
}
|
||||||
|
|
||||||
|
// execTemplate executes a template given a requestPath, template, and metadata
|
||||||
|
func execTemplate(c *Config, mdata metadata.Metadata, files []FileInfo, ctx httpserver.Context) ([]byte, error) {
|
||||||
|
mdData := Data{
|
||||||
|
Context: ctx,
|
||||||
|
Doc: mdata.Variables,
|
||||||
|
DocFlags: mdata.Flags,
|
||||||
|
Styles: c.Styles,
|
||||||
|
Scripts: c.Scripts,
|
||||||
|
Files: files,
|
||||||
|
}
|
||||||
|
|
||||||
|
b := new(bytes.Buffer)
|
||||||
|
if err := c.Template.ExecuteTemplate(b, mdata.Template, mdData); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetTemplate(t *template.Template, name, filename string) error {
|
||||||
|
|
||||||
|
// Read template
|
||||||
|
buf, err := ioutil.ReadFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update if exists
|
||||||
|
if tt := t.Lookup(name); tt != nil {
|
||||||
|
_, err = tt.Parse(string(buf))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allocate new name if not
|
||||||
|
_, err = t.New(name).Parse(string(buf))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetDefaultTemplate() *template.Template {
|
||||||
|
return template.Must(template.New("").Parse(defaultTemplate))
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultTemplate = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>{{.Doc.title}}</title>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
{{- range .Styles}}
|
||||||
|
<link rel="stylesheet" href="{{.}}">
|
||||||
|
{{- end}}
|
||||||
|
{{- range .Scripts}}
|
||||||
|
<script src="{{.}}"></script>
|
||||||
|
{{- end}}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{.Doc.body}}
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
)
|
102
caddyhttp/templates/setup.go
Normal file
102
caddyhttp/templates/setup.go
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/mholt/caddy"
|
||||||
|
"github.com/mholt/caddy/caddyhttp/httpserver"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
caddy.RegisterPlugin(caddy.Plugin{
|
||||||
|
Name: "templates",
|
||||||
|
ServerType: "http",
|
||||||
|
Action: setup,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// setup configures a new Templates middleware instance.
|
||||||
|
func setup(c *caddy.Controller) error {
|
||||||
|
rules, err := templatesParse(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := httpserver.GetConfig(c.Key)
|
||||||
|
|
||||||
|
tmpls := Templates{
|
||||||
|
Rules: rules,
|
||||||
|
Root: cfg.Root,
|
||||||
|
FileSys: http.Dir(cfg.Root),
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.AddMiddleware(func(next httpserver.Handler) httpserver.Handler {
|
||||||
|
tmpls.Next = next
|
||||||
|
return tmpls
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func templatesParse(c *caddy.Controller) ([]Rule, error) {
|
||||||
|
var rules []Rule
|
||||||
|
|
||||||
|
for c.Next() {
|
||||||
|
var rule Rule
|
||||||
|
|
||||||
|
rule.Path = defaultTemplatePath
|
||||||
|
rule.Extensions = defaultTemplateExtensions
|
||||||
|
|
||||||
|
args := c.RemainingArgs()
|
||||||
|
|
||||||
|
switch len(args) {
|
||||||
|
case 0:
|
||||||
|
// Optional block
|
||||||
|
for c.NextBlock() {
|
||||||
|
switch c.Val() {
|
||||||
|
case "path":
|
||||||
|
args := c.RemainingArgs()
|
||||||
|
if len(args) != 1 {
|
||||||
|
return nil, c.ArgErr()
|
||||||
|
}
|
||||||
|
rule.Path = args[0]
|
||||||
|
|
||||||
|
case "ext":
|
||||||
|
args := c.RemainingArgs()
|
||||||
|
if len(args) == 0 {
|
||||||
|
return nil, c.ArgErr()
|
||||||
|
}
|
||||||
|
rule.Extensions = args
|
||||||
|
|
||||||
|
case "between":
|
||||||
|
args := c.RemainingArgs()
|
||||||
|
if len(args) != 2 {
|
||||||
|
return nil, c.ArgErr()
|
||||||
|
}
|
||||||
|
rule.Delims[0] = args[0]
|
||||||
|
rule.Delims[1] = args[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// First argument would be the path
|
||||||
|
rule.Path = args[0]
|
||||||
|
|
||||||
|
// Any remaining arguments are extensions
|
||||||
|
rule.Extensions = args[1:]
|
||||||
|
if len(rule.Extensions) == 0 {
|
||||||
|
rule.Extensions = defaultTemplateExtensions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ext := range rule.Extensions {
|
||||||
|
rule.IndexFiles = append(rule.IndexFiles, "index"+ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
rules = append(rules, rule)
|
||||||
|
}
|
||||||
|
return rules, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultTemplatePath = "/"
|
||||||
|
|
||||||
|
var defaultTemplateExtensions = []string{".html", ".htm", ".tmpl", ".tpl", ".txt"}
|
107
caddyhttp/templates/setup_test.go
Normal file
107
caddyhttp/templates/setup_test.go
Normal file
|
@ -0,0 +1,107 @@
|
||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mholt/caddy"
|
||||||
|
"github.com/mholt/caddy/caddyhttp/httpserver"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSetup(t *testing.T) {
|
||||||
|
err := setup(caddy.NewTestController(`templates`))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Expected no errors, got: %v", err)
|
||||||
|
}
|
||||||
|
mids := httpserver.GetConfig("").Middleware()
|
||||||
|
if len(mids) == 0 {
|
||||||
|
t.Fatal("Expected middleware, got 0 instead")
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := mids[0](httpserver.EmptyNext)
|
||||||
|
myHandler, ok := handler.(Templates)
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Expected handler to be type Templates, got: %#v", handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
if myHandler.Rules[0].Path != defaultTemplatePath {
|
||||||
|
t.Errorf("Expected / as the default Path")
|
||||||
|
}
|
||||||
|
if fmt.Sprint(myHandler.Rules[0].Extensions) != fmt.Sprint(defaultTemplateExtensions) {
|
||||||
|
t.Errorf("Expected %v to be the Default Extensions", defaultTemplateExtensions)
|
||||||
|
}
|
||||||
|
var indexFiles []string
|
||||||
|
for _, extension := range defaultTemplateExtensions {
|
||||||
|
indexFiles = append(indexFiles, "index"+extension)
|
||||||
|
}
|
||||||
|
if fmt.Sprint(myHandler.Rules[0].IndexFiles) != fmt.Sprint(indexFiles) {
|
||||||
|
t.Errorf("Expected %v to be the Default Index files", indexFiles)
|
||||||
|
}
|
||||||
|
if myHandler.Rules[0].Delims != [2]string{} {
|
||||||
|
t.Errorf("Expected %v to be the Default Delims", [2]string{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTemplatesParse(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
inputTemplateConfig string
|
||||||
|
shouldErr bool
|
||||||
|
expectedTemplateConfig []Rule
|
||||||
|
}{
|
||||||
|
{`templates /api1`, false, []Rule{{
|
||||||
|
Path: "/api1",
|
||||||
|
Extensions: defaultTemplateExtensions,
|
||||||
|
Delims: [2]string{},
|
||||||
|
}}},
|
||||||
|
{`templates /api2 .txt .htm`, false, []Rule{{
|
||||||
|
Path: "/api2",
|
||||||
|
Extensions: []string{".txt", ".htm"},
|
||||||
|
Delims: [2]string{},
|
||||||
|
}}},
|
||||||
|
|
||||||
|
{`templates /api3 .htm .html
|
||||||
|
templates /api4 .txt .tpl `, false, []Rule{{
|
||||||
|
Path: "/api3",
|
||||||
|
Extensions: []string{".htm", ".html"},
|
||||||
|
Delims: [2]string{},
|
||||||
|
}, {
|
||||||
|
Path: "/api4",
|
||||||
|
Extensions: []string{".txt", ".tpl"},
|
||||||
|
Delims: [2]string{},
|
||||||
|
}}},
|
||||||
|
{`templates {
|
||||||
|
path /api5
|
||||||
|
ext .html
|
||||||
|
between {% %}
|
||||||
|
}`, false, []Rule{{
|
||||||
|
Path: "/api5",
|
||||||
|
Extensions: []string{".html"},
|
||||||
|
Delims: [2]string{"{%", "%}"},
|
||||||
|
}}},
|
||||||
|
}
|
||||||
|
for i, test := range tests {
|
||||||
|
c := caddy.NewTestController(test.inputTemplateConfig)
|
||||||
|
actualTemplateConfigs, err := templatesParse(c)
|
||||||
|
|
||||||
|
if err == nil && test.shouldErr {
|
||||||
|
t.Errorf("Test %d didn't error, but it should have", i)
|
||||||
|
} else if err != nil && !test.shouldErr {
|
||||||
|
t.Errorf("Test %d errored, but it shouldn't have; got '%v'", i, err)
|
||||||
|
}
|
||||||
|
if len(actualTemplateConfigs) != len(test.expectedTemplateConfig) {
|
||||||
|
t.Fatalf("Test %d expected %d no of Template configs, but got %d ",
|
||||||
|
i, len(test.expectedTemplateConfig), len(actualTemplateConfigs))
|
||||||
|
}
|
||||||
|
for j, actualTemplateConfig := range actualTemplateConfigs {
|
||||||
|
if actualTemplateConfig.Path != test.expectedTemplateConfig[j].Path {
|
||||||
|
t.Errorf("Test %d expected %dth Template Config Path to be %s , but got %s",
|
||||||
|
i, j, test.expectedTemplateConfig[j].Path, actualTemplateConfig.Path)
|
||||||
|
}
|
||||||
|
if fmt.Sprint(actualTemplateConfig.Extensions) != fmt.Sprint(test.expectedTemplateConfig[j].Extensions) {
|
||||||
|
t.Errorf("Expected %v to be the Extensions , but got %v instead", test.expectedTemplateConfig[j].Extensions, actualTemplateConfig.Extensions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
96
caddyhttp/templates/templates.go
Normal file
96
caddyhttp/templates/templates.go
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
// Package templates implements template execution for files to be
|
||||||
|
// dynamically rendered for the client.
|
||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
|
"github.com/mholt/caddy/caddyhttp/httpserver"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServeHTTP implements the httpserver.Handler interface.
|
||||||
|
func (t Templates) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error) {
|
||||||
|
for _, rule := range t.Rules {
|
||||||
|
if !httpserver.Path(r.URL.Path).Matches(rule.Path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for index files
|
||||||
|
fpath := r.URL.Path
|
||||||
|
if idx, ok := httpserver.IndexFile(t.FileSys, fpath, rule.IndexFiles); ok {
|
||||||
|
fpath = idx
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the extension
|
||||||
|
reqExt := path.Ext(fpath)
|
||||||
|
|
||||||
|
for _, ext := range rule.Extensions {
|
||||||
|
if reqExt == ext {
|
||||||
|
// Create execution context
|
||||||
|
ctx := httpserver.Context{Root: t.FileSys, Req: r, URL: r.URL}
|
||||||
|
|
||||||
|
// New template
|
||||||
|
templateName := filepath.Base(fpath)
|
||||||
|
tpl := template.New(templateName)
|
||||||
|
|
||||||
|
// Set delims
|
||||||
|
if rule.Delims != [2]string{} {
|
||||||
|
tpl.Delims(rule.Delims[0], rule.Delims[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the template
|
||||||
|
templatePath := filepath.Join(t.Root, fpath)
|
||||||
|
tpl, err := tpl.ParseFiles(templatePath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return http.StatusNotFound, nil
|
||||||
|
} else if os.IsPermission(err) {
|
||||||
|
return http.StatusForbidden, nil
|
||||||
|
}
|
||||||
|
return http.StatusInternalServerError, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute it
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err = tpl.Execute(&buf, ctx)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusInternalServerError, err
|
||||||
|
}
|
||||||
|
|
||||||
|
templateInfo, err := os.Stat(templatePath)
|
||||||
|
if err == nil {
|
||||||
|
// add the Last-Modified header if we were able to read the stamp
|
||||||
|
httpserver.SetLastModifiedHeader(w, templateInfo.ModTime())
|
||||||
|
}
|
||||||
|
buf.WriteTo(w)
|
||||||
|
|
||||||
|
return http.StatusOK, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return t.Next.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Templates is middleware to render templated files as the HTTP response.
|
||||||
|
type Templates struct {
|
||||||
|
Next httpserver.Handler
|
||||||
|
Rules []Rule
|
||||||
|
Root string
|
||||||
|
FileSys http.FileSystem
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule represents a template rule. A template will only execute
|
||||||
|
// with this rule if the request path matches the Path specified
|
||||||
|
// and requests a resource with one of the extensions specified.
|
||||||
|
type Rule struct {
|
||||||
|
Path string
|
||||||
|
Extensions []string
|
||||||
|
IndexFiles []string
|
||||||
|
Delims [2]string
|
||||||
|
}
|
138
caddyhttp/templates/templates_test.go
Normal file
138
caddyhttp/templates/templates_test.go
Normal file
|
@ -0,0 +1,138 @@
|
||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mholt/caddy/caddyhttp/httpserver"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTemplates(t *testing.T) {
|
||||||
|
tmpl := Templates{
|
||||||
|
Next: httpserver.HandlerFunc(func(w http.ResponseWriter, r *http.Request) (int, error) {
|
||||||
|
return 0, nil
|
||||||
|
}),
|
||||||
|
Rules: []Rule{
|
||||||
|
{
|
||||||
|
Extensions: []string{".html"},
|
||||||
|
IndexFiles: []string{"index.html"},
|
||||||
|
Path: "/photos",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Extensions: []string{".html", ".htm"},
|
||||||
|
IndexFiles: []string{"index.html", "index.htm"},
|
||||||
|
Path: "/images",
|
||||||
|
Delims: [2]string{"{%", "%}"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Root: "./testdata",
|
||||||
|
FileSys: http.Dir("./testdata"),
|
||||||
|
}
|
||||||
|
|
||||||
|
tmplroot := Templates{
|
||||||
|
Next: httpserver.HandlerFunc(func(w http.ResponseWriter, r *http.Request) (int, error) {
|
||||||
|
return 0, nil
|
||||||
|
}),
|
||||||
|
Rules: []Rule{
|
||||||
|
{
|
||||||
|
Extensions: []string{".html"},
|
||||||
|
IndexFiles: []string{"index.html"},
|
||||||
|
Path: "/",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Root: "./testdata",
|
||||||
|
FileSys: http.Dir("./testdata"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test tmpl on /photos/test.html
|
||||||
|
req, err := http.NewRequest("GET", "/photos/test.html", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Test: Could not create HTTP request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
tmpl.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("Test: Wrong response code: %d, should be %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
respBody := rec.Body.String()
|
||||||
|
expectedBody := `<!DOCTYPE html><html><head><title>test page</title></head><body><h1>Header title</h1>
|
||||||
|
</body></html>
|
||||||
|
`
|
||||||
|
|
||||||
|
if respBody != expectedBody {
|
||||||
|
t.Fatalf("Test: the expected body %v is different from the response one: %v", expectedBody, respBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test tmpl on /images/img.htm
|
||||||
|
req, err = http.NewRequest("GET", "/images/img.htm", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Could not create HTTP request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
|
||||||
|
tmpl.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("Test: Wrong response code: %d, should be %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
respBody = rec.Body.String()
|
||||||
|
expectedBody = `<!DOCTYPE html><html><head><title>img</title></head><body><h1>Header title</h1>
|
||||||
|
</body></html>
|
||||||
|
`
|
||||||
|
|
||||||
|
if respBody != expectedBody {
|
||||||
|
t.Fatalf("Test: the expected body %v is different from the response one: %v", expectedBody, respBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test tmpl on /images/img2.htm
|
||||||
|
req, err = http.NewRequest("GET", "/images/img2.htm", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Could not create HTTP request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
|
||||||
|
tmpl.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("Test: Wrong response code: %d, should be %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
respBody = rec.Body.String()
|
||||||
|
expectedBody = `<!DOCTYPE html><html><head><title>img</title></head><body>{{.Include "header.html"}}</body></html>
|
||||||
|
`
|
||||||
|
|
||||||
|
if respBody != expectedBody {
|
||||||
|
t.Fatalf("Test: the expected body %v is different from the response one: %v", expectedBody, respBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test tmplroot on /root.html
|
||||||
|
req, err = http.NewRequest("GET", "/root.html", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Could not create HTTP request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
|
||||||
|
tmplroot.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("Test: Wrong response code: %d, should be %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
respBody = rec.Body.String()
|
||||||
|
expectedBody = `<!DOCTYPE html><html><head><title>root</title></head><body><h1>Header title</h1>
|
||||||
|
</body></html>
|
||||||
|
`
|
||||||
|
|
||||||
|
if respBody != expectedBody {
|
||||||
|
t.Fatalf("Test: the expected body %v is different from the response one: %v", expectedBody, respBody)
|
||||||
|
}
|
||||||
|
}
|
1
caddyhttp/templates/testdata/header.html
vendored
Normal file
1
caddyhttp/templates/testdata/header.html
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<h1>Header title</h1>
|
1
caddyhttp/templates/testdata/images/header.html
vendored
Normal file
1
caddyhttp/templates/testdata/images/header.html
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<h1>Header title</h1>
|
1
caddyhttp/templates/testdata/images/img.htm
vendored
Normal file
1
caddyhttp/templates/testdata/images/img.htm
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<!DOCTYPE html><html><head><title>img</title></head><body>{%.Include "header.html"%}</body></html>
|
1
caddyhttp/templates/testdata/images/img2.htm
vendored
Normal file
1
caddyhttp/templates/testdata/images/img2.htm
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<!DOCTYPE html><html><head><title>img</title></head><body>{{.Include "header.html"}}</body></html>
|
1
caddyhttp/templates/testdata/photos/test.html
vendored
Normal file
1
caddyhttp/templates/testdata/photos/test.html
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<!DOCTYPE html><html><head><title>test page</title></head><body>{{.Include "../header.html"}}</body></html>
|
1
caddyhttp/templates/testdata/root.html
vendored
Normal file
1
caddyhttp/templates/testdata/root.html
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<!DOCTYPE html><html><head><title>root</title></head><body>{{.Include "header.html"}}</body></html>
|
97
caddyhttp/websocket/setup.go
Normal file
97
caddyhttp/websocket/setup.go
Normal file
|
@ -0,0 +1,97 @@
|
||||||
|
package websocket
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/mholt/caddy"
|
||||||
|
"github.com/mholt/caddy/caddyhttp/httpserver"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
caddy.RegisterPlugin(caddy.Plugin{
|
||||||
|
Name: "websocket",
|
||||||
|
ServerType: "http",
|
||||||
|
Action: setup,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// setup configures a new WebSocket middleware instance.
|
||||||
|
func setup(c *caddy.Controller) error {
|
||||||
|
websocks, err := webSocketParse(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
GatewayInterface = caddy.AppName + "-CGI/1.1"
|
||||||
|
ServerSoftware = caddy.AppName + "/" + caddy.AppVersion
|
||||||
|
|
||||||
|
httpserver.GetConfig(c.Key).AddMiddleware(func(next httpserver.Handler) httpserver.Handler {
|
||||||
|
return WebSocket{Next: next, Sockets: websocks}
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func webSocketParse(c *caddy.Controller) ([]Config, error) {
|
||||||
|
var websocks []Config
|
||||||
|
var respawn bool
|
||||||
|
|
||||||
|
optionalBlock := func() (hadBlock bool, err error) {
|
||||||
|
for c.NextBlock() {
|
||||||
|
hadBlock = true
|
||||||
|
if c.Val() == "respawn" {
|
||||||
|
respawn = true
|
||||||
|
} else {
|
||||||
|
return true, c.Err("Expected websocket configuration parameter in block")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for c.Next() {
|
||||||
|
var val, path, command string
|
||||||
|
|
||||||
|
// Path or command; not sure which yet
|
||||||
|
if !c.NextArg() {
|
||||||
|
return nil, c.ArgErr()
|
||||||
|
}
|
||||||
|
val = c.Val()
|
||||||
|
|
||||||
|
// Extra configuration may be in a block
|
||||||
|
hadBlock, err := optionalBlock()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hadBlock {
|
||||||
|
// The next argument on this line will be the command or an open curly brace
|
||||||
|
if c.NextArg() {
|
||||||
|
path = val
|
||||||
|
command = c.Val()
|
||||||
|
} else {
|
||||||
|
path = "/"
|
||||||
|
command = val
|
||||||
|
}
|
||||||
|
|
||||||
|
// Okay, check again for optional block
|
||||||
|
_, err = optionalBlock()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split command into the actual command and its arguments
|
||||||
|
cmd, args, err := caddy.SplitCommandAndArgs(command)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
websocks = append(websocks, Config{
|
||||||
|
Path: path,
|
||||||
|
Command: cmd,
|
||||||
|
Arguments: args,
|
||||||
|
Respawn: respawn, // TODO: This isn't used currently
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return websocks, nil
|
||||||
|
|
||||||
|
}
|
102
caddyhttp/websocket/setup_test.go
Normal file
102
caddyhttp/websocket/setup_test.go
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
package websocket
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mholt/caddy"
|
||||||
|
"github.com/mholt/caddy/caddyhttp/httpserver"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWebSocket(t *testing.T) {
|
||||||
|
err := setup(caddy.NewTestController(`websocket cat`))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Expected no errors, got: %v", err)
|
||||||
|
}
|
||||||
|
mids := httpserver.GetConfig("").Middleware()
|
||||||
|
if len(mids) == 0 {
|
||||||
|
t.Fatal("Expected middleware, got 0 instead")
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := mids[0](httpserver.EmptyNext)
|
||||||
|
myHandler, ok := handler.(WebSocket)
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Expected handler to be type WebSocket, got: %#v", handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
if myHandler.Sockets[0].Path != "/" {
|
||||||
|
t.Errorf("Expected / as the default Path")
|
||||||
|
}
|
||||||
|
if myHandler.Sockets[0].Command != "cat" {
|
||||||
|
t.Errorf("Expected %s as the command", "cat")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
func TestWebSocketParse(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
inputWebSocketConfig string
|
||||||
|
shouldErr bool
|
||||||
|
expectedWebSocketConfig []Config
|
||||||
|
}{
|
||||||
|
{`websocket /api1 cat`, false, []Config{{
|
||||||
|
Path: "/api1",
|
||||||
|
Command: "cat",
|
||||||
|
}}},
|
||||||
|
|
||||||
|
{`websocket /api3 cat
|
||||||
|
websocket /api4 cat `, false, []Config{{
|
||||||
|
Path: "/api3",
|
||||||
|
Command: "cat",
|
||||||
|
}, {
|
||||||
|
Path: "/api4",
|
||||||
|
Command: "cat",
|
||||||
|
}}},
|
||||||
|
|
||||||
|
{`websocket /api5 "cmd arg1 arg2 arg3"`, false, []Config{{
|
||||||
|
Path: "/api5",
|
||||||
|
Command: "cmd",
|
||||||
|
Arguments: []string{"arg1", "arg2", "arg3"},
|
||||||
|
}}},
|
||||||
|
|
||||||
|
// accept respawn
|
||||||
|
{`websocket /api6 cat {
|
||||||
|
respawn
|
||||||
|
}`, false, []Config{{
|
||||||
|
Path: "/api6",
|
||||||
|
Command: "cat",
|
||||||
|
}}},
|
||||||
|
|
||||||
|
// invalid configuration
|
||||||
|
{`websocket /api7 cat {
|
||||||
|
invalid
|
||||||
|
}`, true, []Config{}},
|
||||||
|
}
|
||||||
|
for i, test := range tests {
|
||||||
|
c := caddy.NewTestController(test.inputWebSocketConfig)
|
||||||
|
actualWebSocketConfigs, err := webSocketParse(c)
|
||||||
|
|
||||||
|
if err == nil && test.shouldErr {
|
||||||
|
t.Errorf("Test %d didn't error, but it should have", i)
|
||||||
|
} else if err != nil && !test.shouldErr {
|
||||||
|
t.Errorf("Test %d errored, but it shouldn't have; got '%v'", i, err)
|
||||||
|
}
|
||||||
|
if len(actualWebSocketConfigs) != len(test.expectedWebSocketConfig) {
|
||||||
|
t.Fatalf("Test %d expected %d no of WebSocket configs, but got %d ",
|
||||||
|
i, len(test.expectedWebSocketConfig), len(actualWebSocketConfigs))
|
||||||
|
}
|
||||||
|
for j, actualWebSocketConfig := range actualWebSocketConfigs {
|
||||||
|
|
||||||
|
if actualWebSocketConfig.Path != test.expectedWebSocketConfig[j].Path {
|
||||||
|
t.Errorf("Test %d expected %dth WebSocket Config Path to be %s , but got %s",
|
||||||
|
i, j, test.expectedWebSocketConfig[j].Path, actualWebSocketConfig.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
if actualWebSocketConfig.Command != test.expectedWebSocketConfig[j].Command {
|
||||||
|
t.Errorf("Test %d expected %dth WebSocket Config Command to be %s , but got %s",
|
||||||
|
i, j, test.expectedWebSocketConfig[j].Command, actualWebSocketConfig.Command)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
259
caddyhttp/websocket/websocket.go
Normal file
259
caddyhttp/websocket/websocket.go
Normal file
|
@ -0,0 +1,259 @@
|
||||||
|
// Package websocket implements a WebSocket server by executing
|
||||||
|
// a command and piping its input and output through the WebSocket
|
||||||
|
// connection.
|
||||||
|
package websocket
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/mholt/caddy/caddyhttp/httpserver"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Time allowed to write a message to the peer.
|
||||||
|
writeWait = 10 * time.Second
|
||||||
|
|
||||||
|
// Time allowed to read the next pong message from the peer.
|
||||||
|
pongWait = 60 * time.Second
|
||||||
|
|
||||||
|
// Send pings to peer with this period. Must be less than pongWait.
|
||||||
|
pingPeriod = (pongWait * 9) / 10
|
||||||
|
|
||||||
|
// Maximum message size allowed from peer.
|
||||||
|
maxMessageSize = 1024 * 1024 * 10 // 10 MB default.
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// GatewayInterface is the dialect of CGI being used by the server
|
||||||
|
// to communicate with the script. See CGI spec, 4.1.4
|
||||||
|
GatewayInterface string
|
||||||
|
|
||||||
|
// ServerSoftware is the name and version of the information server
|
||||||
|
// software making the CGI request. See CGI spec, 4.1.17
|
||||||
|
ServerSoftware string
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
// WebSocket is a type that holds configuration for the
|
||||||
|
// websocket middleware generally, like a list of all the
|
||||||
|
// websocket endpoints.
|
||||||
|
WebSocket struct {
|
||||||
|
// Next is the next HTTP handler in the chain for when the path doesn't match
|
||||||
|
Next httpserver.Handler
|
||||||
|
|
||||||
|
// Sockets holds all the web socket endpoint configurations
|
||||||
|
Sockets []Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config holds the configuration for a single websocket
|
||||||
|
// endpoint which may serve multiple websocket connections.
|
||||||
|
Config struct {
|
||||||
|
Path string
|
||||||
|
Command string
|
||||||
|
Arguments []string
|
||||||
|
Respawn bool // TODO: Not used, but parser supports it until we decide on it
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServeHTTP converts the HTTP request to a WebSocket connection and serves it up.
|
||||||
|
func (ws WebSocket) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error) {
|
||||||
|
for _, sockconfig := range ws.Sockets {
|
||||||
|
if httpserver.Path(r.URL.Path).Matches(sockconfig.Path) {
|
||||||
|
return serveWS(w, r, &sockconfig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Didn't match a websocket path, so pass-thru
|
||||||
|
return ws.Next.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveWS is used for setting and upgrading the HTTP connection to a websocket connection.
|
||||||
|
// It also spawns the child process that is associated with matched HTTP path/url.
|
||||||
|
func serveWS(w http.ResponseWriter, r *http.Request, config *Config) (int, error) {
|
||||||
|
upgrader := websocket.Upgrader{
|
||||||
|
ReadBufferSize: 1024,
|
||||||
|
WriteBufferSize: 1024,
|
||||||
|
CheckOrigin: func(r *http.Request) bool { return true },
|
||||||
|
}
|
||||||
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusBadRequest, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
cmd := exec.Command(config.Command, config.Arguments...)
|
||||||
|
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusBadGateway, err
|
||||||
|
}
|
||||||
|
defer stdout.Close()
|
||||||
|
|
||||||
|
stdin, err := cmd.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusBadGateway, err
|
||||||
|
}
|
||||||
|
defer stdin.Close()
|
||||||
|
|
||||||
|
metavars, err := buildEnv(cmd.Path, r)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusBadGateway, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Env = metavars
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return http.StatusBadGateway, err
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go pumpStdout(conn, stdout, done)
|
||||||
|
pumpStdin(conn, stdin)
|
||||||
|
|
||||||
|
stdin.Close() // close stdin to end the process
|
||||||
|
|
||||||
|
if err := cmd.Process.Signal(os.Interrupt); err != nil { // signal an interrupt to kill the process
|
||||||
|
return http.StatusInternalServerError, err
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
// terminate with extreme prejudice.
|
||||||
|
if err := cmd.Process.Signal(os.Kill); err != nil {
|
||||||
|
return http.StatusInternalServerError, err
|
||||||
|
}
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
|
||||||
|
// not sure what we want to do here.
|
||||||
|
// status for an "exited" process is greater
|
||||||
|
// than 0, but isn't really an error per se.
|
||||||
|
// just going to ignore it for now.
|
||||||
|
cmd.Wait()
|
||||||
|
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildEnv creates the meta-variables for the child process according
|
||||||
|
// to the CGI 1.1 specification: http://tools.ietf.org/html/rfc3875#section-4.1
|
||||||
|
// cmdPath should be the path of the command being run.
|
||||||
|
// The returned string slice can be set to the command's Env property.
|
||||||
|
func buildEnv(cmdPath string, r *http.Request) (metavars []string, err error) {
|
||||||
|
if !strings.Contains(r.RemoteAddr, ":") {
|
||||||
|
r.RemoteAddr += ":"
|
||||||
|
}
|
||||||
|
remoteHost, remotePort, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(r.Host, ":") {
|
||||||
|
r.Host += ":"
|
||||||
|
}
|
||||||
|
serverHost, serverPort, err := net.SplitHostPort(r.Host)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
metavars = []string{
|
||||||
|
`AUTH_TYPE=`, // Not used
|
||||||
|
`CONTENT_LENGTH=`, // Not used
|
||||||
|
`CONTENT_TYPE=`, // Not used
|
||||||
|
`GATEWAY_INTERFACE=` + GatewayInterface,
|
||||||
|
`PATH_INFO=`, // TODO
|
||||||
|
`PATH_TRANSLATED=`, // TODO
|
||||||
|
`QUERY_STRING=` + r.URL.RawQuery,
|
||||||
|
`REMOTE_ADDR=` + remoteHost,
|
||||||
|
`REMOTE_HOST=` + remoteHost, // Host lookups are slow - don't do them
|
||||||
|
`REMOTE_IDENT=`, // Not used
|
||||||
|
`REMOTE_PORT=` + remotePort,
|
||||||
|
`REMOTE_USER=`, // Not used,
|
||||||
|
`REQUEST_METHOD=` + r.Method,
|
||||||
|
`REQUEST_URI=` + r.RequestURI,
|
||||||
|
`SCRIPT_NAME=` + cmdPath, // path of the program being executed
|
||||||
|
`SERVER_NAME=` + serverHost,
|
||||||
|
`SERVER_PORT=` + serverPort,
|
||||||
|
`SERVER_PROTOCOL=` + r.Proto,
|
||||||
|
`SERVER_SOFTWARE=` + ServerSoftware,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add each HTTP header to the environment as well
|
||||||
|
for header, values := range r.Header {
|
||||||
|
value := strings.Join(values, ", ")
|
||||||
|
header = strings.ToUpper(header)
|
||||||
|
header = strings.Replace(header, "-", "_", -1)
|
||||||
|
value = strings.Replace(value, "\n", " ", -1)
|
||||||
|
metavars = append(metavars, "HTTP_"+header+"="+value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// pumpStdin handles reading data from the websocket connection and writing
|
||||||
|
// it to stdin of the process.
|
||||||
|
func pumpStdin(conn *websocket.Conn, stdin io.WriteCloser) {
|
||||||
|
// Setup our connection's websocket ping/pong handlers from our const values.
|
||||||
|
defer conn.Close()
|
||||||
|
conn.SetReadLimit(maxMessageSize)
|
||||||
|
conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||||
|
conn.SetPongHandler(func(string) error { conn.SetReadDeadline(time.Now().Add(pongWait)); return nil })
|
||||||
|
for {
|
||||||
|
_, message, err := conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
message = append(message, '\n')
|
||||||
|
if _, err := stdin.Write(message); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pumpStdout handles reading data from stdout of the process and writing
|
||||||
|
// it to websocket connection.
|
||||||
|
func pumpStdout(conn *websocket.Conn, stdout io.Reader, done chan struct{}) {
|
||||||
|
go pinger(conn, done)
|
||||||
|
defer func() {
|
||||||
|
conn.Close()
|
||||||
|
close(done) // make sure to close the pinger when we are done.
|
||||||
|
}()
|
||||||
|
|
||||||
|
s := bufio.NewScanner(stdout)
|
||||||
|
for s.Scan() {
|
||||||
|
conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||||
|
if err := conn.WriteMessage(websocket.TextMessage, bytes.TrimSpace(s.Bytes())); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.Err() != nil {
|
||||||
|
conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseGoingAway, s.Err().Error()), time.Time{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pinger simulates the websocket to keep it alive with ping messages.
|
||||||
|
func pinger(conn *websocket.Conn, done chan struct{}) {
|
||||||
|
ticker := time.NewTicker(pingPeriod)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for { // blocking loop with select to wait for stimulation.
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(writeWait)); err != nil {
|
||||||
|
conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseGoingAway, err.Error()), time.Time{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-done:
|
||||||
|
return // clean up this routine.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
22
caddyhttp/websocket/websocket_test.go
Normal file
22
caddyhttp/websocket/websocket_test.go
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
package websocket
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildEnv(t *testing.T) {
|
||||||
|
req, err := http.NewRequest("GET", "http://localhost", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("Error setting up request:", err)
|
||||||
|
}
|
||||||
|
req.RemoteAddr = "localhost:50302"
|
||||||
|
|
||||||
|
env, err := buildEnv("/bin/command", req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("Didn't expect an error:", err)
|
||||||
|
}
|
||||||
|
if len(env) == 0 {
|
||||||
|
t.Fatalf("Expected non-empty environment; got %#v", env)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue