diff --git a/Main.go b/Main.go index d961cdb..83da949 100644 --- a/Main.go +++ b/Main.go @@ -31,7 +31,7 @@ func main() { configuration.Load() checkArguments() go storage.CleanUp(true) - webserver.Start(staticFolderEmbedded, templateFolderEmbedded) + webserver.Start(&staticFolderEmbedded, &templateFolderEmbedded) } // Checks for command line arguments that have to be parsed before loading the configuration diff --git a/src/configuration/Configuration.go b/src/configuration/Configuration.go index ee5af78..121ddbb 100644 --- a/src/configuration/Configuration.go +++ b/src/configuration/Configuration.go @@ -34,7 +34,7 @@ var Environment environment.Environment var ServerSettings Configuration // Version of the configuration structure. Used for upgrading -const currentConfigVersion = 3 +const currentConfigVersion = 4 // Struct that contains the global configuration type Configuration struct { @@ -48,6 +48,7 @@ type Configuration struct { RedirectUrl string `json:"RedirectUrl"` Sessions map[string]sessionstructure.Session `json:"Sessions"` Files map[string]filestructure.File `json:"Files"` + Hotlinks map[string]filestructure.Hotlink `json:"Hotlinks"` ConfigVersion int `json:"ConfigVersion"` SaltAdmin string `json:"SaltAdmin"` SaltFiles string `json:"SaltFiles"` @@ -86,6 +87,10 @@ func updateConfig() { ServerSettings.LengthId = 15 ServerSettings.DataDir = Environment.DataDir } + // < v1.1.3 + if ServerSettings.ConfigVersion < 4 { + ServerSettings.Hotlinks = make(map[string]filestructure.Hotlink) + } if ServerSettings.ConfigVersion < currentConfigVersion { fmt.Println("Successfully upgraded database") @@ -126,6 +131,7 @@ func generateDefaultConfig() { RedirectUrl: redirect, Files: make(map[string]filestructure.File), Sessions: make(map[string]sessionstructure.Session), + Hotlinks: make(map[string]filestructure.Hotlink), ConfigVersion: currentConfigVersion, SaltAdmin: saltAdmin, SaltFiles: saltFiles, diff --git a/src/helper/OS.go b/src/helper/OS.go index 2ae0f77..ae7274a 100644 --- a/src/helper/OS.go +++ b/src/helper/OS.go @@ -57,3 +57,12 @@ func Check(e error) { panic(e) } } + +func IsInArray(haystack []string, needle string) bool { + for _, item := range haystack { + if needle == item { + return true + } + } + return false +} diff --git a/src/helper/StringGeneration.go b/src/helper/StringGeneration.go index 0415c8c..4449cb7 100644 --- a/src/helper/StringGeneration.go +++ b/src/helper/StringGeneration.go @@ -10,6 +10,7 @@ import ( "fmt" "log" "math/rand" + "regexp" ) // A rune array to be used for pseudo-random string generation @@ -44,7 +45,7 @@ func GenerateRandomString(length int) string { if err != nil { return generateUnsafeId(length) } - return base64.URLEncoding.EncodeToString(b) + return cleanRandomString(base64.URLEncoding.EncodeToString(b)) } // Converts bytes to a human readable format @@ -61,3 +62,13 @@ func ByteCountSI(b int64) string { return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "kMGTPE"[exp]) } + +// Removes special characters from string +func cleanRandomString(input string) string { + reg, err := regexp.Compile("[^a-zA-Z0-9]+") + if err != nil { + log.Fatal(err) + } + return reg.ReplaceAllString(input, "") + +} diff --git a/src/storage/FileServing.go b/src/storage/FileServing.go index b0a86f6..ee75700 100644 --- a/src/storage/FileServing.go +++ b/src/storage/FileServing.go @@ -11,9 +11,13 @@ import ( "crypto/sha1" "encoding/hex" "fmt" + "io" "io/ioutil" "mime/multipart" + "net/http" "os" + "path/filepath" + "strings" "time" ) @@ -41,6 +45,7 @@ func NewFile(fileContent *multipart.File, fileHeader *multipart.FileHeader, expi DownloadsRemaining: downloads, PasswordHash: configuration.HashPassword(password, true), } + addHotlink(&file) configuration.ServerSettings.Files[id] = file filename := configuration.ServerSettings.DataDir + "/" + file.SHA256 if !helper.FileExists(configuration.ServerSettings.DataDir + "/" + file.SHA256) { @@ -55,6 +60,68 @@ func NewFile(fileContent *multipart.File, fileHeader *multipart.FileHeader, expi return file, nil } +var imageFileExtensions = []string{".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"} + +// If file is an image, create link for hotlinking +func addHotlink(file *filestructure.File) { + extension := strings.ToLower(filepath.Ext(file.Name)) + if !helper.IsInArray(imageFileExtensions, extension) { + return + } + link := helper.GenerateRandomString(40) + extension + file.HotlinkId = link + configuration.ServerSettings.Hotlinks[link] = filestructure.Hotlink{ + Id: link, + FileId: file.Id, + } +} + +// Gets the file by id. Returns (empty File, false) if invalid / expired file +// or (file, true) if valid file +func GetFile(id string) (filestructure.File, bool) { + var emptyResult = filestructure.File{} + if id == "" { + return emptyResult, false + } + file := configuration.ServerSettings.Files[id] + if file.ExpireAt < time.Now().Unix() || file.DownloadsRemaining < 1 { + return emptyResult, false + } + if !helper.FileExists(configuration.ServerSettings.DataDir + "/" + file.SHA256) { + return emptyResult, false + } + return file, true +} + +// Gets the file by hotlink id. Returns (empty File, false) if invalid / expired file +// or (file, true) if valid file +func GetFileByHotlink(id string) (filestructure.File, bool) { + var emptyResult = filestructure.File{} + if id == "" { + return emptyResult, false + } + hotlink := configuration.ServerSettings.Hotlinks[id] + return GetFile(hotlink.FileId) +} + +// Subtracts a download allowance and serves the file to the browser +func ServeFile(file filestructure.File, w http.ResponseWriter, r *http.Request, forceDownload bool) { + file.DownloadsRemaining = file.DownloadsRemaining - 1 + configuration.ServerSettings.Files[file.Id] = file + // Investigate: Possible race condition with clean-up routine? + configuration.Save() + + if forceDownload { + w.Header().Set("Content-Disposition", "attachment; filename=\""+file.Name+"\"") + } + w.Header().Set("Content-Type", r.Header.Get("Content-Type")) + storageData, err := os.OpenFile(configuration.ServerSettings.DataDir+"/"+file.SHA256, os.O_RDONLY, 0644) + defer storageData.Close() + helper.Check(err) + _, err = io.Copy(w, storageData) + helper.Check(err) +} + // Removes expired files from the config and from the filesystem if they are not referenced by other files anymore // Will be called periodically or after a file has been manually deleted in the admin view. // If parameter periodic is true, this function is recursive and calls itself every hour. @@ -75,6 +142,9 @@ func CleanUp(periodic bool) { fmt.Println(err) } } + if element.HotlinkId != "" { + delete(configuration.ServerSettings.Hotlinks, element.HotlinkId) + } delete(configuration.ServerSettings.Files, key) wasItemDeleted = true } diff --git a/src/storage/filestructure/FileList.go b/src/storage/filestructure/FileList.go index 8b24f91..625d9a3 100644 --- a/src/storage/filestructure/FileList.go +++ b/src/storage/filestructure/FileList.go @@ -15,14 +15,21 @@ type File struct { ExpireAtString string `json:"ExpireAtString"` DownloadsRemaining int `json:"DownloadsRemaining"` PasswordHash string `json:"PasswordHash"` + HotlinkId string `json:"HotlinkId"` +} + +type Hotlink struct { + Id string `json:"Id"` + FileId string `json:"FileId"` } // Converts the file info to a json String used for returning a result for an upload func (f *File) ToJsonResult(serverUrl string) string { result := Result{ - Result: "OK", - Url: serverUrl + "d?id=", - FileInfo: f, + Result: "OK", + Url: serverUrl + "d?id=", + HotlinkUrl: serverUrl + "hotlink/", + FileInfo: f, } bytes, err := json.Marshal(result) if err != nil { @@ -34,7 +41,8 @@ func (f *File) ToJsonResult(serverUrl string) string { // The struct used for the result after an upload type Result struct { - Result string `json:"Result"` - FileInfo *File `json:"FileInfo"` - Url string `json:"Url"` + Result string `json:"Result"` + FileInfo *File `json:"FileInfo"` + Url string `json:"Url"` + HotlinkUrl string `json:"HotlinkUrl"` } diff --git a/src/webserver/Webserver.go b/src/webserver/Webserver.go index a8e4ba2..3b7ed81 100644 --- a/src/webserver/Webserver.go +++ b/src/webserver/Webserver.go @@ -12,7 +12,6 @@ import ( "embed" "fmt" "html/template" - "io" "io/fs" "log" "net/http" @@ -26,18 +25,28 @@ import ( // Variable containing all parsed templates var templateFolder *template.Template +var imageExpiredPicture []byte + +const expiredFile = "static/expired.png" + // Starts the webserver on the port set in the config -func Start(staticFolderEmbedded, templateFolderEmbedded embed.FS) { - initTemplates(templateFolderEmbedded) - webserverDir, _ := fs.Sub(staticFolderEmbedded, "static") +func Start(staticFolderEmbedded, templateFolderEmbedded *embed.FS) { + initTemplates(*templateFolderEmbedded) + webserverDir, _ := fs.Sub(*staticFolderEmbedded, "static") + var err error if helper.FolderExists("static") { fmt.Println("Found folder 'static', using local folder instead of internal static folder") http.Handle("/", http.FileServer(http.Dir("static"))) + imageExpiredPicture, err = os.ReadFile(expiredFile) + helper.Check(err) } else { http.Handle("/", http.FileServer(http.FS(webserverDir))) + imageExpiredPicture, err = fs.ReadFile(staticFolderEmbedded, expiredFile) + helper.Check(err) } http.HandleFunc("/index", showIndex) http.HandleFunc("/d", showDownload) + http.HandleFunc("/hotlink/", showHotlink) http.HandleFunc("/error", showError) http.HandleFunc("/login", showLogin) http.HandleFunc("/logout", doLogout) @@ -132,19 +141,12 @@ type LoginView struct { // If it exists, a download form is shown or a password needs to be entered. func showDownload(w http.ResponseWriter, r *http.Request) { keyId := queryUrl(w, r, "error") - if keyId == "" { - return - } - file := configuration.ServerSettings.Files[keyId] - if file.ExpireAt < time.Now().Unix() || file.DownloadsRemaining < 1 { + file, ok := storage.GetFile(keyId) + if !ok { redirect(w, "error") return } - if !helper.FileExists(configuration.ServerSettings.DataDir + "/" + file.SHA256) { - redirect(w, "error") - return - } view := DownloadView{ Name: file.Name, Size: file.Size, @@ -176,6 +178,20 @@ func showDownload(w http.ResponseWriter, r *http.Request) { helper.Check(err) } +// Handling of /hotlink/ +// Hotlinks an image or returns a static error image if image has expired +func showHotlink(w http.ResponseWriter, r *http.Request) { + hotlinkId := strings.Replace(r.URL.Path, "/hotlink/", "", 1) + file, ok := storage.GetFileByHotlink(hotlinkId) + if !ok { + w.Header().Set("Content-Type", r.Header.Get("Content-Type")) + _, err := w.Write(imageExpiredPicture) + helper.Check(err) + return + } + storage.ServeFile(file, w, r, false) +} + // Handling of /delete // User needs to be admin. Deleted the requested file func deleteFile(w http.ResponseWriter, r *http.Request) { @@ -227,6 +243,7 @@ type DownloadView struct { type UploadView struct { Items []filestructure.File Url string + HotlinkUrl string TimeNow int64 DefaultDownloads int DefaultExpiry int @@ -244,6 +261,7 @@ func (u *UploadView) convertGlobalConfig() *UploadView { return result[i].ExpireAt > result[j].ExpireAt }) u.Url = configuration.ServerSettings.ServerUrl + "d?id=" + u.HotlinkUrl = configuration.ServerSettings.ServerUrl + "hotlink/" u.DefaultPassword = configuration.ServerSettings.DefaultPassword u.Items = result u.DefaultExpiry = configuration.ServerSettings.DefaultExpiry @@ -295,11 +313,8 @@ func responseError(w http.ResponseWriter, err error) { // Outputs the file to the user and reduces the download remaining count for the file func downloadFile(w http.ResponseWriter, r *http.Request) { keyId := queryUrl(w, r, "error") - if keyId == "" { - return - } - savedFile := configuration.ServerSettings.Files[keyId] - if savedFile.DownloadsRemaining == 0 || savedFile.ExpireAt < time.Now().Unix() || !helper.FileExists(configuration.ServerSettings.DataDir+"/"+savedFile.SHA256) { + savedFile, ok := storage.GetFile(keyId) + if !ok { redirect(w, "error") return } @@ -309,17 +324,7 @@ func downloadFile(w http.ResponseWriter, r *http.Request) { return } } - savedFile.DownloadsRemaining = savedFile.DownloadsRemaining - 1 - configuration.ServerSettings.Files[keyId] = savedFile - configuration.Save() - - w.Header().Set("Content-Disposition", "attachment; filename=\""+savedFile.Name+"\"") - w.Header().Set("Content-Type", r.Header.Get("Content-Type")) - file, err := os.OpenFile(configuration.ServerSettings.DataDir+"/"+savedFile.SHA256, os.O_RDONLY, 0644) - defer file.Close() - helper.Check(err) - _, err = io.Copy(w, file) - helper.Check(err) + storage.ServeFile(savedFile, w, r, true) } // Checks if the user is logged in as an admin diff --git a/static/expired.png b/static/expired.png new file mode 100644 index 0000000..f2c515c Binary files /dev/null and b/static/expired.png differ diff --git a/static/js/admin.js b/static/js/admin.js index ca40c4c..6116930 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -57,7 +57,16 @@ function addRow(jsonText) { cell3.innerText = item.DownloadsRemaining; cell4.innerText = item.ExpireAtString; cell5.innerHTML = ''+jsonObject.Url+item.Id+''+lockIcon; - cell6.innerHTML = " "; + + let buttons = " "; + if (item.HotlinkId != "") { + buttons = buttons + ' '; + } else { + buttons = buttons + ' '; + } + buttons = buttons + ""; + + cell6.innerHTML = buttons; cell1.style.backgroundColor="green" cell2.style.backgroundColor="green" cell3.style.backgroundColor="green" diff --git a/templates/html_admin.tmpl b/templates/html_admin.tmpl index dbeaecb..c6d59b2 100644 --- a/templates/html_admin.tmpl +++ b/templates/html_admin.tmpl @@ -52,7 +52,12 @@ {{ .DownloadsRemaining }} {{ .ExpireAtString }} {{ $.Url }}{{ .Id }}{{ if ne .PasswordHash "" }} 🔒{{ end }} - + +{{ if ne .HotlinkId "" }} + +{{ else }} + +{{ end }} {{ end }} {{ end }} diff --git a/templates/string_constants.tmpl b/templates/string_constants.tmpl index 96d3306..51c135c 100644 --- a/templates/string_constants.tmpl +++ b/templates/string_constants.tmpl @@ -1,2 +1,2 @@ {{define "app_name"}}Gokapi{{end}} -{{define "version"}}1.1.2{{end}} +{{define "version"}}1.1.3-dev{{end}}