simpleserve/main.go

383 lines
9.8 KiB
Go
Executable File

package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"log"
"net/http"
"net/http/httptest"
"os"
"path"
"regexp"
"strings"
"time"
"gogs.inhome.blapointe.com/local/args"
"gogs.inhome.blapointe.com/local/gziphttp"
"gogs.inhome.blapointe.com/local/notes-server/notes/md"
)
const (
ENDPOINT_UPLOAD = "__upload__"
ENDPOINT_DELETE = "__delete__"
)
var (
fs *args.ArgSet
)
func main() {
fs = args.NewArgSet()
fs.Append(args.STRING, "p", "port to serve", "8100")
fs.Append(args.STRING, "ip", "ip to serve", "")
fs.Append(args.STRING, "u", "user:pass for basic auth", "")
fs.Append(args.BOOL, "md", "whether to render markdown as html", true)
fs.Append(args.BOOL, "log", "emit access logs", false)
fs.Append(args.BOOL, "ro", "read only mode", false)
fs.Append(args.BOOL, "https", "https only", false)
fs.Append(args.STRING, "md-css", "css to load for md", "/dev/null")
fs.Append(args.STRING, "md-class", "class to wrap md", "phb")
fs.Append(args.STRING, "d", "static path to serve", "./public")
if err := fs.Parse(); err != nil {
panic(err)
}
d := fs.Get("d").GetString()
userPass := fs.Get("u").GetString()
md := fs.Get("md").GetBool()
ro := fs.Get("ro").GetBool()
https := fs.Get("https").GetBool()
mdCss := fs.Get("md-css").GetString()
mdClass := fs.Get("md-class").GetString()
accessLogging := fs.GetBool("log")
if mdCss != "" {
b, err := ioutil.ReadFile(mdCss)
if err != nil {
panic(err)
}
mdCss = fmt.Sprintf(`
<style>
body > div {
margin: auto;
overflow: auto;
}
.%s {
width: auto !important;
/*height: auto !important;*/
/*height: 100%% !important;
column-count: auto !important;
-webkit-column-count: auto !important;*/
overflow: auto !important;
background-repeat: repeat-x;
}
.%s h1 {
/*
column-span: none !important;
-webkit-column-span: none !important;
*/
}
.%s hr+table {
margin-top: 40px !important;
}
.%s nav {
/*
background: none !important;
*/
display: none;
}
%s
</style>`,
mdClass,
mdClass,
mdClass,
mdClass,
b,
)
}
ip := fs.Get("ip").GetString()
p := strings.TrimPrefix(fs.Get("p").GetString(), ":")
http.Handle("/", http.HandlerFunc(handler(userPass, https, ro, d, md, mdCss, mdClass, accessLogging)))
log.Printf("Serving %s on HTTP port: %s\n", d, p)
log.Fatal(http.ListenAndServe(ip+":"+p, nil))
}
func handler(userPass string, https, ro bool, d string, md bool, mdCss, mdClass string, accessLogging bool) http.HandlerFunc {
return withAccessLogging(accessLogging, httpsOnly(https, gzip(basicAuth(userPass, endpoints(ro, withDel(ro, withMD(d, md, mdCss, mdClass, fserve(d))))))))
}
func withAccessLogging(enabled bool, h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.URL.Host == "" {
r.URL.Host = r.Host
}
b, _ := json.Marshal(map[string]any{
"_ts": time.Now(),
"client": map[string]any{
"ip": r.RemoteAddr,
"forwarded-for": r.Header.Get("X-Forwarded-For"),
"user-agent": r.UserAgent(),
},
"request": map[string]any{
"method": r.Method,
"url": r.URL.String(),
"headers": r.Header,
},
})
fmt.Printf("%s\n", b)
h(w, r)
}
}
func writeMeta(w http.ResponseWriter) {
fmt.Fprintf(w, `
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
</head>
`)
}
func writeForm(w http.ResponseWriter) {
fmt.Fprintf(w, `
<form enctype="multipart/form-data" action="./%s" method="post">
<input type="file" name="file" required/>
<input type="submit" value="upload"/>
</form>
`, ENDPOINT_UPLOAD)
}
func basicAuth(userPass string, foo http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if userPass != "" {
u, p, ok := r.BasicAuth()
if !ok || u+":"+p != userPass {
w.Header().Set("WWW-Authenticate", "Basic")
w.WriteHeader(401)
return
}
}
foo(w, r)
}
}
func httpsOnly(https bool, foo http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if https && r.URL.Scheme != "https" {
log.Printf("redirecting: %+v", r.URL)
r.URL.Scheme = "https"
http.Redirect(w, r, r.URL.String(), http.StatusSeeOther)
return
}
foo(w, r)
}
}
func gzip(foo http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if gziphttp.Can(r) {
gz := gziphttp.New(w)
defer gz.Close()
w = gz
}
foo(w, r)
}
}
func endpoints(ro bool, foo http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if isDir(r) {
writeMeta(w)
if !ro {
writeForm(w)
}
}
if !ro && isUploaded(r) {
if err := upload(w, r); err != nil {
fmt.Fprintln(w, err.Error())
}
} else if !ro && isDeleted(r) {
if err := del(w, r); err != nil {
fmt.Fprintln(w, err.Error())
}
} else {
gziphttp.SetContentTypeIfMedia(w, r)
foo(w, r)
}
}
}
func isUploaded(r *http.Request) bool {
return path.Base(r.URL.Path) == ENDPOINT_UPLOAD
}
func isDeleted(r *http.Request) bool {
return path.Base(r.URL.Path) == ENDPOINT_DELETE
}
func isDir(r *http.Request) bool {
d := toRealPath(r.URL.Path)
fi, err := os.Stat(d)
if err != nil {
return false
}
if !fi.IsDir() {
return false
}
if _, err := os.Stat(path.Join(d, "index.html")); err == nil {
return false
}
return true
}
func withMD(dir string, enabled bool, mdCss, mdClass string, foo http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
realpath := toRealPath(r.URL.Path)
if enabled && !isDir(r) && path.Ext(realpath) == ".md" {
b, err := ioutil.ReadFile(realpath)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s, err := md.Gomarkdown(b, nil)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
fmt.Fprintln(w, mdCss)
fmt.Fprintf(w, "<div class=%q>", mdClass)
s = strings.ReplaceAll(s, "<nav>", `<nav class="toc">`)
fmt.Fprintln(w, s)
fmt.Fprintf(w, "</div>")
} else {
foo(w, r)
}
}
}
func withDel(ro bool, foo http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !isDir(r) {
foo(w, r)
return
}
fmt.Fprintln(w, `<a href=".."><input type="button" style="padding: .15em 4em .35em 4em" value=".."/></a>`)
w2 := httptest.NewRecorder()
foo(w2, r)
b := bytes.Split(w2.Body.Bytes(), []byte("\n"))
buff := bytes.NewBuffer(nil)
for i := range b {
if !ro && bytes.Contains(b[i], []byte("<a href=")) {
re := regexp.MustCompile(`href="[^"]*"`)
match := re.Find(b[i])
if len(match) > 0 {
match = bytes.Split(match, []byte(`href="`))[1]
match = match[:len(match)-1]
b[i] = []byte(fmt.Sprintf(`<a href="%s/%s"><input type="button" value="&#10060;" style="padding: .40em 1em .10em 1em; margin-right: .5em" onclick='return confirm("Delete "+%q+"?");'></input></a> %s`, match, ENDPOINT_DELETE, match, b[i]))
}
}
buff.Write(b[i])
buff.Write([]byte("\n"))
}
io.Copy(w, buff)
}
}
func fserve(d string) http.HandlerFunc {
h := http.FileServer(http.Dir(d))
return h.ServeHTTP
}
func upload(w http.ResponseWriter, r *http.Request) error {
r.ParseMultipartForm(100 << 20)
file, handler, err := r.FormFile("file")
if err != nil {
return err
}
defer file.Close()
p := toRealPath(path.Join(path.Dir(r.URL.Path), handler.Filename))
if fi, err := os.Stat(p); err == nil && !fi.IsDir() {
return errors.New("already exists")
}
f, err := os.Create(p)
if err != nil {
return err
}
defer f.Close()
if _, err := io.Copy(f, file); err != nil {
return err
}
http.Redirect(w, r, path.Dir(r.URL.Path)+"/", http.StatusSeeOther)
return nil
}
func del(w http.ResponseWriter, r *http.Request) error {
p := toRealPath(path.Dir(r.URL.Path))
_, err := os.Stat(p)
if err != nil {
return err
}
err = os.RemoveAll(p)
if err != nil {
return err
}
http.Redirect(w, r, path.Dir(path.Dir(r.URL.Path))+"/", http.StatusSeeOther)
return nil
}
func toRealPath(p string) string {
d := path.Join(fs.Get("d").GetString())
return path.Join(d, p)
}
func setContentTypeIfMedia(w http.ResponseWriter, r *http.Request) {
switch strings.ToLower(path.Ext(r.URL.Path)) {
case ".mp4":
w.Header().Set("Content-Type", "video/mp4")
case ".mkv":
w.Header().Set("Content-Type", "video/x-matroska")
case ".mp3":
w.Header().Set("Content-Type", "audio/mpeg3")
case ".epub", ".mobi":
w.Header().Set("Content-Disposition", "attachment")
case ".jpg", ".jpeg":
w.Header().Set("Content-Type", "image/jpeg")
case ".gif":
w.Header().Set("Content-Type", "image/gif")
case ".png":
w.Header().Set("Content-Type", "image/png")
case ".ico":
w.Header().Set("Content-Type", "image/x-icon")
case ".svg":
w.Header().Set("Content-Type", "image/svg+xml")
case ".css":
w.Header().Set("Content-Type", "text/css")
case ".js":
w.Header().Set("Content-Type", "text/javascript")
case ".json":
w.Header().Set("Content-Type", "application/json")
case ".html", ".htm":
w.Header().Set("Content-Type", "text/html")
case ".pdf":
w.Header().Set("Content-Type", "application/pdf")
case ".webm":
w.Header().Set("Content-Type", "video/webm")
case ".weba":
w.Header().Set("Content-Type", "audio/webm")
case ".webp":
w.Header().Set("Content-Type", "image/webp")
case ".zip":
w.Header().Set("Content-Type", "application/zip")
case ".7z":
w.Header().Set("Content-Type", "application/x-7z-compressed")
case ".tar":
w.Header().Set("Content-Type", "application/x-tar")
}
}