summaryrefslogtreecommitdiffstats
path: root/main.go
diff options
context:
space:
mode:
Diffstat (limited to 'main.go')
-rw-r--r--main.go373
1 files changed, 373 insertions, 0 deletions
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..050bb0e
--- /dev/null
+++ b/main.go
@@ -0,0 +1,373 @@
+package main
+
+import (
+ "crypto/subtle"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "git.clsr.net/gomf/storage"
+ "html/template"
+ "io"
+ "io/ioutil"
+ "net/http"
+ "os"
+ "path"
+ "strings"
+ "sync"
+ "time"
+)
+
+var (
+ gomfRoot string
+ access map[string]string
+ prefix string
+ uploadUrl string
+ deletionPath string
+ uploads *storage.Storage
+ deleted *json.Encoder
+ deletedLog *os.File
+ deletedLock sync.RWMutex
+
+ templates = template.Must(template.ParseGlob("templates/*.html"))
+)
+
+func getLogNames() ([]string, error) {
+ logs, err := ioutil.ReadDir(path.Join(gomfRoot, "log"))
+ if err != nil {
+ return nil, err
+ }
+ var logNames []string
+ for _, log := range logs {
+ logNames = append(logNames, strings.Split(log.Name(), ".")[0])
+ }
+ return logNames, nil
+}
+
+func getLog(log string) ([]map[string]interface{}, error) {
+ f, err := os.Open(path.Join(gomfRoot, "log", log+".log.json"))
+ if err != nil {
+ return nil, err
+ }
+ dec := json.NewDecoder(f)
+ var entries []map[string]interface{}
+ for err == nil {
+ entry := make(map[string]interface{})
+ err = dec.Decode(&entry)
+ if err == nil {
+ entries = append(entries, entry)
+ }
+ }
+ if err == io.EOF {
+ err = nil
+ }
+ for i := len(entries)/2-1; i >= 0; i-- {
+ j := len(entries)-1-i
+ entries[i], entries[j] = entries[j], entries[i]
+ }
+ return entries, err
+}
+
+func logDeletion(typ, id, hash, by, reason string) {
+ deletedLock.Lock()
+ defer deletedLock.Unlock()
+ deleted.Encode(map[string]string{
+ "type": typ,
+ "by": by,
+ "id": id,
+ "hash": hash,
+ "timestamp": time.Now().UTC().Format(time.RFC3339),
+ "reason": reason,
+ })
+}
+
+func getDeletions() ([]map[string]string, error) {
+ deletedLock.RLock()
+ defer deletedLock.RUnlock()
+ f, err := os.Open(deletedLog.Name())
+ if err != nil {
+ return nil, err
+ }
+ dec := json.NewDecoder(f)
+ var dels []map[string]string
+ for err == nil {
+ del := make(map[string]string)
+ err = dec.Decode(&del)
+ if err == nil {
+ dels = append(dels, del)
+ }
+ }
+ if err == io.EOF {
+ err = nil
+ }
+ return dels, err
+}
+
+func checkAccess(user, pass string) bool {
+ pw, ok := access[user]
+ ok = ok && subtle.ConstantTimeCompare([]byte(pw), []byte(pass)) != 0
+ return ok
+}
+
+func cleanPath(p string) string {
+ abs := strings.HasPrefix(p, "/")
+ dir := strings.HasSuffix(p, "/")
+ p = strings.Trim(path.Clean("/"+p+"/"), "/")
+ if abs {
+ p = "/" + p
+ }
+ if dir {
+ p = p + "/"
+ }
+ return p
+}
+
+func idToFolder(subfolder, id string) string { // gomf/storage/storage.go
+ name := id
+ for len(name) < 4 {
+ name = "_" + name
+ }
+ return path.Join(uploads.Folder, subfolder, name[0:2], name[2:4], id)
+}
+
+func remove(subfolder, id string) error {
+ ext := path.Ext(id)
+ id = id[:len(id)-len(ext)]
+ folder := idToFolder(subfolder, id)
+ err := os.Remove(path.Join(folder, "file"+ext))
+ if err != nil {
+ return err
+ }
+ for err == nil {
+ err = os.Remove(folder)
+ folder = path.Dir(folder)
+ }
+ return nil
+}
+
+func jsonResponse(w http.ResponseWriter, code int, data interface{}) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(code)
+ err := json.NewEncoder(w).Encode(data)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ }
+}
+
+func errorResponse(w http.ResponseWriter, code int, errs string) {
+ fmt.Fprintln(os.Stderr, errs)
+ jsonResponse(w, code, map[string]string{"error": errs})
+}
+
+func handlePanel(w http.ResponseWriter, r *http.Request) {
+ log := cleanPath(r.URL.Path)
+ if strings.Contains(log, "/") {
+ errorResponse(w, http.StatusNotFound, http.StatusText(http.StatusNotFound))
+ return
+ }
+
+ var err error
+ var entries []map[string]interface{}
+ if log != "" {
+ entries, err = getLog(log)
+ if err != nil {
+ errorResponse(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ }
+ logNames, err := getLogNames()
+ if err != nil {
+ errorResponse(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ ctx := struct {
+ Prefix string
+ UploadUrl string
+ Current string
+ Logs []string
+ Entries []map[string]interface{}
+ }{prefix, uploadUrl, log, logNames, entries}
+ err = templates.ExecuteTemplate(w, "panel.html", ctx)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ }
+}
+
+func handleFile(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, uploadUrl+cleanPath(r.URL.Path), http.StatusFound)
+}
+
+func handleRemoval(w http.ResponseWriter, r *http.Request) {
+ if r.Method == "POST" {
+ typ := r.FormValue("type")
+ reason := r.FormValue("reason")
+
+ if reason == "" {
+ errorResponse(w, http.StatusNotFound, "missing deletion reason")
+ return
+ }
+
+ id := r.FormValue("id")
+ hash := r.FormValue("file")
+
+ var target string
+ switch typ {
+ case "id":
+ target = id
+
+ case "file":
+ bhash, err := hex.DecodeString(hash)
+ if err != nil {
+ errorResponse(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ target = base64.RawURLEncoding.EncodeToString(bhash)
+
+ default:
+ errorResponse(w, http.StatusNotFound, "invalid deletion type")
+ return
+ }
+
+ err := remove(typ+"s", target)
+ if err != nil {
+ errorResponse(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ user, _, _ := r.BasicAuth()
+ logDeletion(typ, target, hash, user, r.FormValue("reason"))
+ jsonResponse(w, http.StatusOK, map[string]string{"deleted": target})
+ } else {
+ errorResponse(w, http.StatusMethodNotAllowed, http.StatusText(http.StatusMethodNotAllowed))
+ }
+}
+
+func handleLog(w http.ResponseWriter, r *http.Request) {
+ logFile := cleanPath(r.URL.Path)
+ if strings.Contains(logFile, "/") {
+ errorResponse(w, http.StatusNotFound, http.StatusText(http.StatusNotFound))
+ return
+ }
+ if logFile == "" {
+ handleLogNames(w, r)
+ return
+ }
+ f, err := os.Open(path.Join(gomfRoot, "log", logFile+".log.json"))
+ if err != nil {
+ errorResponse(w, http.StatusNotFound, err.Error())
+ return
+ }
+ defer f.Close()
+ w.Header().Set("Content-Type", "application/json")
+ io.Copy(w, f)
+}
+
+func handleLogNames(w http.ResponseWriter, r *http.Request) {
+ logNames, err := getLogNames()
+ if err != nil {
+ errorResponse(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ jsonResponse(w, 200, logNames)
+}
+
+func handleDeletionLog(w http.ResponseWriter, r *http.Request) {
+ dels, err := getDeletions()
+ if err != nil {
+ errorResponse(w, http.StatusInternalServerError, err.Error())
+ } else {
+ ctx := struct {
+ Prefix string
+ UploadUrl string
+ Deletions []map[string]string
+ }{prefix, uploadUrl, dels}
+ err := templates.ExecuteTemplate(w, "deletion_log.html", ctx)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ }
+ }
+}
+
+func handle(w http.ResponseWriter, r *http.Request) {
+ if !strings.HasPrefix(r.URL.Path, prefix) && r.URL.Path != deletionPath {
+ errorResponse(w, http.StatusNotFound, http.StatusText(http.StatusNotFound))
+ return
+ }
+
+ user, pass, ok := r.BasicAuth()
+ if !ok || !checkAccess(user, pass) {
+ w.Header().Set("WWW-Authenticate", `Basic realm="gomf-modpanel"`)
+ errorResponse(w, http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized))
+ return
+ }
+
+ http.DefaultServeMux.ServeHTTP(w, r)
+}
+
+func main() {
+ listenHttp := flag.String("http", "localhost:8080", "address to listen on for HTTP")
+ listenHttps := flag.String("https", "", "address to listen on for HTTPS")
+ cert := flag.String("cert", "", "path to TLS certificate (for HTTPS)")
+ key := flag.String("key", "", "path to TLS key (for HTTPS)")
+ accessFlag := flag.String("access", "", "comma-separated list of user:password entries for mod panel auth")
+ flag.StringVar(&uploadUrl, "upload-url", "/u/", "URL prefix for uploaded files")
+ flag.StringVar(&deletionPath, "deletion-log", "/deleted", "(URL) path to deletion log (blank to disable)")
+ flag.StringVar(&prefix, "prefix", "mod", "prefix (folder) to serve mod panel under")
+ flag.StringVar(&gomfRoot, "gomf-root", "", "path to Gomf (gomf-web) root (requires files/ and log/)")
+
+ flag.Parse()
+
+ access = make(map[string]string)
+ for _, s := range strings.Split(*accessFlag, ",") {
+ up := strings.Split(s, ":")
+ if len(up) != 2 {
+ panic(fmt.Sprintf("%q is not a valid user:password entry", up))
+ }
+ access[up[0]] = up[1]
+ }
+ prefix = cleanPath("/" + prefix + "/")
+ uploads = storage.NewStorage(path.Join(gomfRoot, "upload"))
+
+ var err error
+ deletedLog, err = os.OpenFile("deleted.log.json", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
+ if err != nil {
+ panic(err)
+ }
+ deleted = json.NewEncoder(deletedLog)
+
+ http.Handle(prefix+"delete", http.StripPrefix(prefix+"delete", http.HandlerFunc(handleRemoval)))
+ http.Handle(prefix+"file/", http.StripPrefix(prefix+"file/", http.HandlerFunc(handleFile)))
+ http.Handle(prefix+"log/", http.StripPrefix(prefix+"log/", http.HandlerFunc(handleLog)))
+ http.Handle(prefix+"static/", http.StripPrefix(prefix+"static/", http.FileServer(http.Dir("static"))))
+ http.Handle(prefix, http.StripPrefix(prefix, http.HandlerFunc(handlePanel)))
+ if deletionPath != "" {
+ http.Handle(deletionPath, http.HandlerFunc(handleDeletionLog))
+ }
+
+ for _, dir := range []string{"log/", "upload/ids/", "upload/files/"} {
+ if _, err := os.Stat(path.Join(gomfRoot, dir)); err != nil {
+ fmt.Fprintf(os.Stderr, "error: %s; is --gomf-root set correctly?\n", err)
+ os.Exit(1)
+ }
+ }
+
+ exit := true
+ if *listenHttp != "" {
+ exit = false
+ fmt.Printf("listening on http://%s%s\n", *listenHttp, prefix)
+ go func() {
+ panic(http.ListenAndServe(*listenHttp, http.HandlerFunc(handle)))
+ }()
+ }
+ if *listenHttps != "" {
+ exit = false
+ fmt.Printf("listening on https://%s%s\n", *listenHttps, prefix)
+ go func() {
+ panic(http.ListenAndServeTLS(*listenHttps, *cert, *key, http.HandlerFunc(handle)))
+ }()
+ }
+
+ if !exit {
+ select {}
+ }
+}