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.Template ) 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 r.URL.Path == deletionPath || strings.HasPrefix(r.URL.Path, prefix+"static/") { http.DefaultServeMux.ServeHTTP(w, r) } if !strings.HasPrefix(r.URL.Path, prefix) { 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() templates = template.Must(template.ParseGlob("templates/*.html")) 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) } } 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)) } 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 {} } }