This commit is contained in:
x3 2024-02-25 17:25:07 +01:00
commit 4c6b88d530
Signed by: x3
GPG Key ID: 7E9961E8AD0E240E
17 changed files with 1014 additions and 0 deletions

35
auth/auth.go Normal file
View File

@ -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)
}
})
}

31
config/config.go Normal file
View File

@ -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
}

74
controller/delete.go Normal file
View File

@ -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"}`)
}

31
controller/download.go Normal file
View File

@ -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)
}

143
controller/upload.go Normal file
View File

@ -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)
}
}

73
controller/web.go Normal file
View File

@ -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)
}

219
db/db.go Normal file
View File

@ -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
}

8
go.mod Normal file
View File

@ -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
)

4
go.sum Normal file
View File

@ -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=

28
id/id.go Normal file
View File

@ -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)
}

58
main.go Normal file
View File

@ -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
}
}

6
model/model.go Normal file
View File

@ -0,0 +1,6 @@
package model
type File struct {
Id, Filename, ContentType, Sha1Sum, UploadKey string
Size, UploadTime int64
}

36
net/net.go Normal file
View File

@ -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
}

63
templates/files.html Normal file
View File

@ -0,0 +1,63 @@
{{define "files"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>ngfshare - Listing</title>
<style>
{{template "style"}}
</style>
</head>
<body>
<div id="header">
<div id="txtheader"><h1>ngfshare</h1></div>
<div id="upload"><form enctype="multipart/form-data" id="fupload" method="POST" action="/api/upload">
<input type="file" name="file">
<input type="submit" value="Upload">
</form></div>
<div id="logout"><form id="flogout" method="POST" action="/logout">
<input type="submit" value="Logout">
</form></div>
</div>
<content>
<h3>Files</h3>
<table>
<tbody>
<thead>
<th>Filename</th>
<th>Size</th>
<th>Uploaded</th>
<th>Type</th>
<th>Action</th>
</thead>
<tbody>
{{range .}}
<tr>
<td>
<a target="_blank" href="/-{{.Id}}/{{.Filename}}"><span title="{{.Sha1Sum}}">{{.Filename}}</span></a>
</td>
<td>
<span>{{formatFileSize .Size}}</span>
</td>
<td>
<span>{{formatDate .UploadTime}}</span>
</td>
<td>
<span>{{.ContentType}}</span>
</td>
<td>
<form class="deleteform" action="/api/delete/{{.Id}}" method="POST" target="_blank">
<input type="submit" value="Delete" />
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{if not .}}
<span>No uploads yet</span>
{{end}}
</div>
</body>
</html>
{{end}}

23
templates/login.html Normal file
View File

@ -0,0 +1,23 @@
{{define "login"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>ngfshare - Log in</title>
<style>
{{template "style"}}
</style>
</head>
<body>
<div id='divLogin'>
<h1>ngfshare</h1>
<form id="loginForm" action="/login" method="POST">
<!-- <label for="auth">Auth key</label>-->
<input placeholder="Auth key" required type="password" name="auth">
<br>
<input type="submit" value="Login">
</form>
</div>
</body>
</html>
{{end}}

133
templates/style.css Normal file
View File

@ -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}}

49
view/view.go Normal file
View File

@ -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)
}