diff options
-rw-r--r-- | .gitignore | 9 | ||||
-rw-r--r-- | main.go | 373 |
2 files changed, 382 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5e5352a --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +*.swp +/gomf +/gomf-modpanel +/templates/ +/deleted.log.json +/log/ +/pages/ +/static/ +/upload/ @@ -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 {} + } +} |