commit 4c6b88d530210882a11194235194138a1eb2d9c8 Author: x3 Date: Sun Feb 25 17:25:07 2024 +0100 First!! diff --git a/auth/auth.go b/auth/auth.go new file mode 100644 index 0000000..578440c --- /dev/null +++ b/auth/auth.go @@ -0,0 +1,35 @@ +package auth + +import ( + "log" + "net/http" + + "git.fuwafuwa.moe/x3/ngfshare/db" +) + +func GetAuthCookie(r *http.Request) string { + cookies := r.Cookies() + for i, _ := range(cookies) { + if cookies[i].Name == "auth" { + return cookies[i].Value + } + } + return "" +} + +func AuthMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if auth == "" { + auth = GetAuthCookie(r) + } + + if auth != "" && db.Db.IsAuthKeyExists(auth) { + log.Printf("Accepted auth header: '%s'\n", auth) + next.ServeHTTP(w, r) + } else { + log.Printf("Rejected auth header: '%s'\n", auth) + http.Error(w, "Forbidden", http.StatusForbidden) + } + }) +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..04a52b4 --- /dev/null +++ b/config/config.go @@ -0,0 +1,31 @@ +package config + +import ( + _ "fmt" + "encoding/json" + "io/ioutil" +) + +type Config struct { + Port uint16 + Address string + DBpath string + StoreDir string + UrlPrefix string + IdLen int + AuthKeyLen int +} + +var Conf = Config{} + +func LoadConfig(path string) (Config, error) { + conf := Config{} + + cont, err := ioutil.ReadFile(path) + if err != nil { + return conf, err + } + err = json.Unmarshal(cont, &conf) + Conf = conf + return conf, err +} diff --git a/controller/delete.go b/controller/delete.go new file mode 100644 index 0000000..46c02b9 --- /dev/null +++ b/controller/delete.go @@ -0,0 +1,74 @@ +package controller + +import ( + "net/http" + "fmt" + "log" + "os" + + "git.fuwafuwa.moe/x3/ngfshare/db" + "git.fuwafuwa.moe/x3/ngfshare/config" + sauth "git.fuwafuwa.moe/x3/ngfshare/auth" + "github.com/gorilla/mux" +) + +func deleteFile(sha1sum string) error { + d1 := sha1sum[0:2] + d2 := sha1sum[2:4] + path := fmt.Sprintf("%s/%s/%s/%s", config.Conf.StoreDir, d1, d2, sha1sum) + + // This leaves empty directories, but whatever + err := os.Remove(path) + return err +} + +func Delete(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + auth := r.Header.Get("Authorization") + if auth == "" { + auth = sauth.GetAuthCookie(r) + } + w.Header().Add("Content-Type", "application/json") + + file, err := db.Db.GetFileById(id) + if err != nil { + log.Println("Delete: File by id", id, "not found in databse", err) + http.Error(w, `{"status":"Not found"}`, http.StatusNotFound) + return + } + + if file.UploadKey != auth { + log.Println("Trying to delete a file uploaded by a different key", id) + http.Error(w, "", http.StatusForbidden) + return + } + + ex, err := db.Db.DeleteFile(id) + if err != nil { + log.Println("Failed to delete file with id", id, err) + http.Error(w, "", http.StatusInternalServerError) + return + } + + if !ex { + log.Println("Tried to delete file that doens't exists", id) + http.NotFound(w, r) + return + } + + err = deleteFile(file.Sha1Sum) + if err != nil { + log.Println("Cannot delete file with sum", file.Sha1Sum, err) + } + + log.Println("Deleted file", id) + + /* + if auth.GetAuthCookie() != "" { + // Called from the webpage, return redirect + } + */ + + fmt.Fprintln(w, `{"status":"OK"}`) +} diff --git a/controller/download.go b/controller/download.go new file mode 100644 index 0000000..528c242 --- /dev/null +++ b/controller/download.go @@ -0,0 +1,31 @@ +package controller + +import ( + "net/http" + "fmt" + "log" + + "git.fuwafuwa.moe/x3/ngfshare/db" + "github.com/gorilla/mux" +) + +func Download(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + file, err := db.Db.GetFileById(id) + if err != nil { + log.Println("File by id", id, "not found in databse", err) + http.NotFound(w, r) + return + } + d1 := file.Sha1Sum[0:2] + d2 := file.Sha1Sum[2:4] + + w.Header().Add("Content-Disposition", fmt.Sprintf("filename=\"%s\"", file.Filename)) + w.Header().Add("Content-Type", file.ContentType) + w.Header().Add("X-Accel-Expires", "1800") + w.Header().Add("X-Accel-Redirect", fmt.Sprintf("/store/%s/%s/%s", d1, d2, file.Sha1Sum)) + + log.Printf("Served file with id: '%s' sha1sum: '%s'", file.Id, file.Sha1Sum) +} diff --git a/controller/upload.go b/controller/upload.go new file mode 100644 index 0000000..ca372b4 --- /dev/null +++ b/controller/upload.go @@ -0,0 +1,143 @@ +package controller + +import ( + "fmt" + "encoding/json" + "io" + "log" + "net/http" + "crypto/sha1" + "os" + "mime/multipart" + + "git.fuwafuwa.moe/x3/ngfshare/config" + sauth "git.fuwafuwa.moe/x3/ngfshare/auth" + "git.fuwafuwa.moe/x3/ngfshare/db" +) + +type responseStruct struct { + Id string `json:"id"` + Filename string `json:"filename"` + Url string `json:"url"` + UrlShort string `json:"url_short"` + DeleteUrl string `json:"delete_url"` +} +func responseWithFile(id, filename string, w http.ResponseWriter) { + urlShort := fmt.Sprintf("%s/-%s", config.Conf.UrlPrefix, id) + rsp := responseStruct{ + Id: id, + Filename: filename, + Url: fmt.Sprintf("%s/%s", urlShort, filename), + UrlShort: urlShort, + DeleteUrl: fmt.Sprintf("%s/api/delete/%s", config.Conf.UrlPrefix, id), + } + jsb, err := json.Marshal(rsp) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintln(w, `{"error":"Failed response json marshal"}`) + return + } + w.Write(jsb) +} + +func copyFileToStorage(srcFile multipart.File, sha1sum string) error { + d1 := sha1sum[0:2] + d2 := sha1sum[2:4] + saveDir := fmt.Sprintf("%s/%s/%s", config.Conf.StoreDir, d1, d2) + savePath := fmt.Sprintf("%s/%s", saveDir, sha1sum) + + err := os.MkdirAll(saveDir, 0750) + if err != nil { + log.Println("Cannot create directory for savedir:", saveDir, err) + return err + } + + dstFile, err := os.Create(savePath) + if err != nil { + log.Println("Cannot create file for savepath:", savePath, err) + return err + } + defer dstFile.Close() + + _, err = io.Copy(dstFile, srcFile) + if err != nil { + log.Println("Cannot copy file", err) + return err + } + + log.Println("File copied") + return nil +} + +func Upload(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + cookieAuth := false + if auth == "" { + auth = sauth.GetAuthCookie(r) + cookieAuth = true + } + r.ParseMultipartForm(40*1024*1024) + + w.Header().Add("Content-Type", "application/json") + + file, fheader, err := r.FormFile("file") + if err != nil { + log.Println("No file POST field in upload, or some other error", err) + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + defer file.Close() + + sha := sha1.New() + hlen, err := io.Copy(sha, file) + if err != nil || hlen != fheader.Size { + log.Println("Hash copy failed or not all file got hashed", err) + http.Error(w, "Bad Request", http.StatusBadRequest) + } + sum := fmt.Sprintf("%x", sha.Sum(nil)) + + dbFile, exists := db.Db.GetFileBySha1(sum) + if exists { + log.Println("Dupe upload detected on id", dbFile.Id) + // Don't check if it was uploaded by the same api key or not because i don't care. + if !cookieAuth { + responseWithFile(dbFile.Id, dbFile.Filename, w) + } else { + // If upload from web, redirect to the final url + http.Redirect(w, r, fmt.Sprintf("/-%s/%s", dbFile.Id, dbFile.Filename), http.StatusFound) + } + return + } + file.Seek(0, 0) + b := make([]byte, 512) + file.Read(b) + fType := http.DetectContentType(b) + if fType == "application/octet-stream" { + fType = fheader.Header.Get("Content-Type") + } + + resId, tx, err := db.Db.InsertFile(fheader.Filename, fheader.Size, fType, sum, auth) + if err != nil { + log.Println("Failed to insert into database", err) + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintln(w, `{"error":"Failed to insert into databse"}`) + return + } + + file.Seek(0, 0) + err = copyFileToStorage(file, sum) + if err != nil { + tx.Rollback() + fmt.Fprintln(w, `{"error":"Failed to copy file"}`) + return + } + tx.Commit() + + log.Printf("Added file with id: '%s' name: '%s' sha1sum: '%s'", resId, fheader.Filename, sum) + if !cookieAuth { + responseWithFile(resId, fheader.Filename, w) + } else { + // If upload from web, redirect to the final url + http.Redirect(w, r, fmt.Sprintf("/-%s/%s", resId, fheader.Filename), http.StatusFound) + } +} diff --git a/controller/web.go b/controller/web.go new file mode 100644 index 0000000..c33c718 --- /dev/null +++ b/controller/web.go @@ -0,0 +1,73 @@ +package controller + +import ( + "net/http" + "log" + + "git.fuwafuwa.moe/x3/ngfshare/view" + "git.fuwafuwa.moe/x3/ngfshare/db" + "git.fuwafuwa.moe/x3/ngfshare/auth" +) + +func webGetList(w http.ResponseWriter, r *http.Request, auth string) { + log.Println("In webget file list") + + files, err := db.Db.GetFilesByAuthKey(auth) + if err != nil { + log.Println("Failed to get files by auth key", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + err = view.Execute("files", w, files) + if err != nil { + log.Println("Error in webGetList:", err) + } +} + +func webGetLogin(w http.ResponseWriter, r *http.Request) { + log.Println("In webget login") + err := view.Execute("login", w, "") + if err != nil { + log.Println("Error in webGetLogin:", err) + } +} + +func WebGet(w http.ResponseWriter, r *http.Request) { + auth := auth.GetAuthCookie(r) + authOk := auth != "" && db.Db.IsAuthKeyExists(auth) + + if authOk { + webGetList(w, r, auth) + } else { + webGetLogin(w, r) + } +} + +func WebLogin(w http.ResponseWriter, r *http.Request) { + auth := r.FormValue("auth") + ok := db.Db.IsAuthKeyExists(auth) + if !ok { + http.Error(w, "Authentication failure", http.StatusForbidden) + return + } + + log.Println("Successfull web auth for key", auth) + cookie := http.Cookie{ + Name: "auth", + Value: auth, + } + http.SetCookie(w, &cookie) + http.Redirect(w, r, "/", http.StatusFound) +} + +func WebLogout(w http.ResponseWriter, r *http.Request) { + log.Println("In logout") + cookie := http.Cookie{ + Name: "auth", + Value: "", + MaxAge: -1, + } + http.SetCookie(w, &cookie) + http.Redirect(w, r, "/", http.StatusFound) +} diff --git a/db/db.go b/db/db.go new file mode 100644 index 0000000..7a7fb3e --- /dev/null +++ b/db/db.go @@ -0,0 +1,219 @@ +package db + +import ( + "log" + "database/sql" + + "git.fuwafuwa.moe/x3/ngfshare/id" + "git.fuwafuwa.moe/x3/ngfshare/model" + + _ "github.com/mattn/go-sqlite3" +) + +type DB struct { + ctx *sql.DB +} + +var Db *DB + +func createTables(ctx *sql.DB) error { + _, err := ctx.Exec(` + CREATE TABLE IF NOT EXISTS keys ( + key TEXT PRIMARY KEY UNIQUE + ); + + CREATE TABLE IF NOT EXISTS files ( + id TEXT PRIMARY KEY UNIQUE, + filename TEXT, + size INTEGER, + content_type TEXT, + upload_time INTEGER, + sha1sum TEXT UNIQUE, + uploadKey TEXT, + FOREIGN KEY (uploadKey) + REFERENCES keys(key) + ); + `) + + return err +} + +func Open(path string) (*DB, error) { + log.Printf("Opening DB at '%s'\n", path) + + ctx, err := sql.Open("sqlite3", path) + if err != nil { + log.Println("Failed to open DB", err) + return nil, err + } + + err = createTables(ctx) + if err != nil { + log.Println("Failed to create tables", err) + return nil, err + } + + Db = &DB{ + ctx: ctx, + } + return Db, nil +} + +func (db *DB) Close() error { + err := db.ctx.Close() + Db = nil + return err +} + +func (db *DB) IsAuthKeyExists(key string) bool { + row := db.ctx.QueryRow(` + SELECT EXISTS(SELECT 1 FROM keys WHERE key = ?); + `, key) + var ex int + row.Scan(&ex) + return ex == 1 +} + +func (db *DB) CreateNewAuthKey() (string, error) { + var err error + key := id.GenAuthKey() + + for i := 0; i < 10; i++ { + _, err = db.ctx.Exec(` + INSERT INTO keys ( + key + ) + VALUES (?) + `, key) + + if err == nil { + break + } + /* + errorCode := err.(sqll.Error).Code + log.Println(errorCode) + log.Println(err) + if errorCode != sqll.ErrConstraint { + log.Println("HERERERE") + break + } + */ + + // Try again + key = id.GenAuthKey() + } + return key, err +} + +func (db *DB) GetFileBySha1(sum string) (model.File, bool) { + row := db.ctx.QueryRow(` + SELECT + id, filename, size, content_type, upload_time, uploadKey + FROM + files + WHERE + sha1sum = ? + ;`, sum) + + f := model.File{Sha1Sum: sum} + err := row.Scan(&f.Id, &f.Filename, &f.Size, &f.ContentType, &f.UploadTime, &f.UploadKey) + //log.Println("db: ", err) + return f, err == nil +} + +func (db *DB) GetFileById(id string) (model.File, error) { + row := db.ctx.QueryRow(` + SELECT + filename, size, content_type, upload_time, sha1sum, uploadKey + FROM + files + WHERE + id = ? + ;`, id) + + f := model.File{Id: id} + err := row.Scan(&f.Filename, &f.Size, &f.ContentType, &f.UploadTime, &f.Sha1Sum, &f.UploadKey) + //log.Println("db: ", err) + return f, err +} + +func (db *DB) GetFilesByAuthKey(key string) ([]model.File, error) { + lst := make([]model.File, 0, 16) + rows, err := db.ctx.Query(` + SELECT + id, filename, size, content_type, upload_time, sha1sum + FROM + files + WHERE + uploadKey = ? + ORDER BY + upload_time DESC + ;`, key) + if err != nil { + return lst, err + } + defer rows.Close() + + for rows.Next() { + f := model.File{ + UploadKey: key, + } + err = rows.Scan(&f.Id, &f.Filename, &f.Size, &f.ContentType, &f.UploadTime, &f.Sha1Sum) + if err != nil { + return lst, err + } + lst = append(lst, f) + } + + return lst, nil +} + +func (db *DB) InsertFile(filename string, size int64, content_type, sha1sum, uploadKey string) (string, *sql.Tx, error) { + var err error + tx, err := db.ctx.Begin() + if err != nil { + log.Println("Cannot start Tx", err) + return "", nil, err + } + fId := id.GenFileId() + + for i := 0; i < 10; i++ { + + _, err = tx.Exec(` + INSERT INTO files ( + id, + size, + filename, + content_type, + upload_time, + sha1sum, + uploadKey + ) + VALUES(?,?,?,?,strftime('%s', 'now'),?,?) + `, fId, size, filename, content_type, sha1sum, uploadKey) + + if err == nil { + break + } + fId = id.GenFileId() + } + + if err != nil { + tx.Rollback() + } + + return fId, tx, err +} + +func (db *DB) DeleteFile(id string) (bool, error) { + res, err := db.ctx.Exec(` + DELETE FROM files + WHERE id = ?; + `, id) + + if err != nil { + return false, err + } + n, err := res.RowsAffected() + return n != 0, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a68e262 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module git.fuwafuwa.moe/x3/ngfshare + +go 1.21.6 + +require ( + github.com/gorilla/mux v1.8.1 + github.com/mattn/go-sqlite3 v1.14.22 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e3bcc1d --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= diff --git a/id/id.go b/id/id.go new file mode 100644 index 0000000..f50d551 --- /dev/null +++ b/id/id.go @@ -0,0 +1,28 @@ +package id + +import ( + "math/rand" + "strings" + + "git.fuwafuwa.moe/x3/ngfshare/config" +) + +var idChars = []rune("abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789") + +func genRandStr(length int) string { + var b strings.Builder + b.Grow(length) + for i := 0; i < length; i++ { + rndIdx := rand.Intn(len(idChars)) + b.WriteRune(idChars[rndIdx]) + } + return b.String() +} + +func GenFileId() string { + return genRandStr(config.Conf.IdLen) +} + +func GenAuthKey() string { + return genRandStr(config.Conf.AuthKeyLen) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..95e591a --- /dev/null +++ b/main.go @@ -0,0 +1,58 @@ +package main + +import ( + "fmt" + "flag" + "os" + + "git.fuwafuwa.moe/x3/ngfshare/config" + "git.fuwafuwa.moe/x3/ngfshare/net" + "git.fuwafuwa.moe/x3/ngfshare/db" + "git.fuwafuwa.moe/x3/ngfshare/view" +); + +func main() { + + confFilePath := flag.String("config", "", "The path for the config.json file") + createAuthKey := flag.Bool("genauth", false, "Generate a new auth key and exit") + + flag.Parse() + + if *confFilePath == "" { + fmt.Println("Failure: --config argument is required") + os.Exit(1) + } + + conf, err := config.LoadConfig(*confFilePath); + if err != nil { + fmt.Printf("Failed to load: %+v", err) + os.Exit(1) + } + + err = view.LoadTemplates() + if err != nil { + return + } + + dbctx, err := db.Open(conf.DBpath) + if err != nil { + return + } + defer dbctx.Close() + + if *createAuthKey { + key, err := dbctx.CreateNewAuthKey() + if err != nil { + fmt.Println("Failed to create auth key:", err) + os.Exit(1) + } + fmt.Println(key) + return + } + + err = net.Start(conf) + if err != nil { + fmt.Printf("Failed to start listen: ", err) + return + } +} diff --git a/model/model.go b/model/model.go new file mode 100644 index 0000000..d6a635e --- /dev/null +++ b/model/model.go @@ -0,0 +1,6 @@ +package model + +type File struct { + Id, Filename, ContentType, Sha1Sum, UploadKey string + Size, UploadTime int64 +} diff --git a/net/net.go b/net/net.go new file mode 100644 index 0000000..4482b2f --- /dev/null +++ b/net/net.go @@ -0,0 +1,36 @@ +package net + +import ( + "fmt" + "net/http" + + "git.fuwafuwa.moe/x3/ngfshare/config" + "git.fuwafuwa.moe/x3/ngfshare/controller" + "git.fuwafuwa.moe/x3/ngfshare/auth" + "github.com/gorilla/mux" +) + +func Start(conf config.Config) error { + r := mux.NewRouter() + + authedR := r.PathPrefix("/api").Methods("POST").Subrouter() + authedR.Use(auth.AuthMiddleware) + authedR.HandleFunc("/upload", controller.Upload) + authedR.HandleFunc("/delete/{id}", controller.Delete) + + r.HandleFunc("/-{id}", controller.Download).Methods("GET") + r.HandleFunc("/-{id}/{filename}", controller.Download).Methods("GET") + r.HandleFunc("/-{id}/", controller.Download).Methods("GET") + + r.HandleFunc("/", controller.WebGet).Methods("GET") + r.HandleFunc("/login", controller.WebLogin).Methods("POST") + r.HandleFunc("/logout", controller.WebLogout).Methods("POST") + + http.Handle("/", r) + + lstStr := fmt.Sprintf("%s:%d", conf.Address, conf.Port) + fmt.Println("Listening on", lstStr) + http.ListenAndServe(lstStr, nil) + + return nil +} diff --git a/templates/files.html b/templates/files.html new file mode 100644 index 0000000..3bc8038 --- /dev/null +++ b/templates/files.html @@ -0,0 +1,63 @@ +{{define "files"}} + + + + + ngfshare - Listing + + + + + +

Files

+ + + + + + + + + + + {{range .}} + + + + + + + + {{end}} + +
FilenameSizeUploadedTypeAction
+ {{.Filename}} + + {{formatFileSize .Size}} + + {{formatDate .UploadTime}} + + {{.ContentType}} + +
+ +
+
+ {{if not .}} + No uploads yet + {{end}} + + + +{{end}} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..0a0ea63 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,23 @@ +{{define "login"}} + + + + + ngfshare - Log in + + + +
+

ngfshare

+
+ + +
+ +
+
+ + +{{end}} diff --git a/templates/style.css b/templates/style.css new file mode 100644 index 0000000..eaf9e9b --- /dev/null +++ b/templates/style.css @@ -0,0 +1,133 @@ +{{define "style"}} + + +body { + background-color: #414141; + color: #D4D3D3; + font-size: 1.4em; + font-family: sans-serif; +} + +#divLogin > h1 { + width: initial; + text-align: center; +} + +#divLogin { + margin: auto; + width: 400px; + margin-top: 80px; + +} + + +th { + text-align: left; +} + +a { + color: #F1EBEB; +} + +a:link { + text-decoration: none; +} + +th { + border-bottom: 1px solid #D4D3D3; +} + +td { + border-bottom: 1px dashed #D4D3D3; +} + +#loginForm { + display: block; + width: 100%; + display: block; + margin: auto; +} +#loginForm > input { + padding: 2px 5px; + margin-bottom: 10px; + width: 100%; +} + +content { + margin: auto 0px; + +} + + +#header { + width: 100%; + display: flex; + justify-content: space-between; +} + +#flogout { + +} + +#flogout { + float: right; + +} + +#txtheader { + width: fit-content; + display: inline; +} + +#flogout { + float: right; + margin: 20px; +} + +#flogout input { + width: 100px; + background-color: #F1EBEB; + border: none; + color: #414141; + padding: 10px 20px; + text-align: center; + text-decoration: none; + display: inline-block; + font-size: 16px; +} + +content > table { + width: 80%; +} + +.deleteform input { + background-color: rgba(0,0,0,0); + border: none; + color: #F1EBEB; + text-align: center; + text-decoration: none; + display: inline-block; + cursor: pointer; +} + +#header form input { + width: 100px; + background-color: #F1EBEB; + border: none; + color: #414141; + padding: 10px 20px; + text-align: center; + text-decoration: none; + display: inline-block; + font-size: 16px; +} + + +#fupload { + width: 500px; + align-self: center; + float: center; + margin: 20px; +} + +{{end}} diff --git a/view/view.go b/view/view.go new file mode 100644 index 0000000..2eddfae --- /dev/null +++ b/view/view.go @@ -0,0 +1,49 @@ +package view + +import ( + "log" + "fmt" + "io" + "html/template" + "time" +) + +var tmpl *template.Template + +func addFuncs(t *template.Template) *template.Template { + fmap := template.FuncMap{ + "formatDate": func(unix int64) string { + return time.Unix(unix, 0).Format("2006-01-02 15:04:05") + }, + "formatFileSize": func(b int64) string { + const unit = 1024 + if b < unit { + return fmt.Sprintf("%d B", b) + } + div, exp := int64(unit), 0 + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp]) + }, + } + return t.Funcs(fmap) +} + +func LoadTemplates() error { + t, err := addFuncs(template.New("")).ParseGlob("./templates/*") + if err != nil { + log.Println("LoadTemplates:", err) + return err + } + tmpl = t + + log.Println("Templates loaded") + + return nil +} + +func Execute(tstr string, wr io.Writer, data any) error { + return tmpl.ExecuteTemplate(wr, tstr, data) +}