Added hotlinking for image files

This commit is contained in:
Marc Ole Bulling
2021-04-06 15:16:48 +02:00
parent 779e68af3d
commit 132187dd08
11 changed files with 164 additions and 41 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

BIN
static/expired.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

@@ -57,7 +57,16 @@ function addRow(jsonText) {
cell3.innerText = item.DownloadsRemaining;
cell4.innerText = item.ExpireAtString;
cell5.innerHTML = '<a target="_blank" style="color: inherit" href="'+jsonObject.Url+item.Id+'">'+jsonObject.Url+item.Id+'</a>'+lockIcon;
cell6.innerHTML = "<button type=\"button\" data-clipboard-text=\""+jsonObject.Url+item.Id+"\" class=\"copyurl btn btn-outline-light btn-sm\">Copy URL</button> <button type=\"button\" class=\"btn btn-outline-light btn-sm\" onclick=\"window.location='./delete?id="+item.Id+"'\">Delete</button>";
let buttons = "<button type=\"button\" data-clipboard-text=\""+jsonObject.Url+item.Id+"\" class=\"copyurl btn btn-outline-light btn-sm\">Copy URL</button> ";
if (item.HotlinkId != "") {
buttons = buttons + '<button type="button" data-clipboard-text="'+jsonObject.HotlinkUrl+item.HotlinkId+'" class="copyurl btn btn-outline-light btn-sm">Copy Hotlink</button> ';
} else {
buttons = buttons + '<button type="button"class="copyurl btn btn-outline-light btn-sm disabled">Copy Hotlink</button> ';
}
buttons = buttons + "<button type=\"button\" class=\"btn btn-outline-light btn-sm\" onclick=\"window.location='./delete?id="+item.Id+"'\">Delete</button>";
cell6.innerHTML = buttons;
cell1.style.backgroundColor="green"
cell2.style.backgroundColor="green"
cell3.style.backgroundColor="green"

View File

@@ -52,7 +52,12 @@
<td scope="col">{{ .DownloadsRemaining }}</td>
<td scope="col">{{ .ExpireAtString }}</td>
<td scope="col"><a target="_blank" href="{{ $.Url }}{{ .Id }}">{{ $.Url }}{{ .Id }}</a>{{ if ne .PasswordHash "" }} &#128274;{{ end }}</td>
<td scope="col"><button type="button" data-clipboard-text="{{ $.Url }}{{ .Id }}" class="copyurl btn btn-outline-light btn-sm">Copy URL</button> <button type="button" class="btn btn-outline-light btn-sm" onclick="window.location='./delete?id={{ .Id }}'">Delete</button></td>
<td scope="col"><button type="button" data-clipboard-text="{{ $.Url }}{{ .Id }}" class="copyurl btn btn-outline-light btn-sm">Copy URL</button>
{{ if ne .HotlinkId "" }}
<button type="button" data-clipboard-text="{{ $.HotlinkUrl }}{{ .HotlinkId }}" class="copyurl btn btn-outline-light btn-sm">Copy Hotlink</button>
{{ else }}
<button type="button"class="copyurl btn btn-outline-light btn-sm disabled">Copy Hotlink</button>
{{ end }} <button type="button" class="btn btn-outline-light btn-sm" onclick="window.location='./delete?id={{ .Id }}'">Delete</button></td>
</tr>
{{ end }}
{{ end }}

View File

@@ -1,2 +1,2 @@
{{define "app_name"}}Gokapi{{end}}
{{define "version"}}1.1.2{{end}}
{{define "version"}}1.1.3-dev{{end}}