Refactoring, adding of documentation

This commit is contained in:
Marc Ole Bulling
2021-03-28 19:53:31 +02:00
parent f430e30c02
commit b342a7886d
10 changed files with 227 additions and 75 deletions

View File

@@ -1,4 +1,4 @@
name: Docker Publish Test Compile
name: Test Compilation
on: [push, pull_request]

View File

@@ -1,6 +1,14 @@
package main
// compiler sets this to true if compiled with the Docker image
/**
Variables that are set during build
*/
// Has to be true if compiled for the Docker image
var IS_DOCKER = "false"
// Time of the build
var BUILD_TIME = "Dev Build"
// Name of builder
var BUILDER = "Manual Build"

View File

@@ -9,12 +9,26 @@ import (
"strings"
)
/**
Loading and saving of the persistent configuration
*/
// Name of the config dir that will be created
const configDir = "config"
// Name of the config file where the configuration is written to
const configFile = "config.json"
// Full path of configDir and configFile
const configPath = configDir + "/" + configFile
// Name of the data dir that will be created
const dataDir = "data"
// Global object containing the configuration
var globalConfig Configuration
// Struct that contains the global configuration
type Configuration struct {
Port string `json:"Port"`
AdminName string `json:"AdminName"`
@@ -28,20 +42,7 @@ type Configuration struct {
Files map[string]FileList `json:"Files"`
}
func (f *FileList) toJsonResult() string {
result := Result{
Result: "OK",
Url: globalConfig.ServerUrl + "d?id=",
FileInfo: f,
}
bytes, err := json.Marshal(result)
if err != nil {
fmt.Println(err)
return "{\"Result\":\"error\",\"ErrorMessage\":\"" + err.Error() + "\"}"
}
return string(bytes)
}
// Loads the configuration or creates the folder structure and a default configuration
func loadConfig() {
createConfigDir()
if !fileExists(configPath) {
@@ -60,6 +61,7 @@ func loadConfig() {
}
}
// Creates a default configuration and asks for items like username/password etc.
func generateDefaultConfig() {
fmt.Println("First start, creating new admin account")
username := askForUsername()
@@ -86,6 +88,7 @@ func generateDefaultConfig() {
saveConfig()
}
// Saves the configuration as a json file
func saveConfig() {
file, err := os.OpenFile(configPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
@@ -101,6 +104,7 @@ func saveConfig() {
}
}
// Asks for username and returns input as string if valid
func askForUsername() string {
fmt.Print("Username: ")
username := readLine()
@@ -111,15 +115,7 @@ func askForUsername() string {
return askForUsername()
}
func askForLocalOnly() bool {
if IS_DOCKER != "false" {
return false
}
fmt.Print("Bind port to localhost only? [Y/n]: ")
input := strings.ToLower(readLine())
return input != "n"
}
// Asks for password and returns input as string if valid
func askForPassword() string {
fmt.Print("Password: ")
password1, err := terminal.ReadPassword(0)
@@ -139,6 +135,17 @@ func askForPassword() string {
return string(password1)
}
// Asks if the server shall be bound to 127.0.0.1 and returns result as bool
func askForLocalOnly() bool {
if IS_DOCKER != "false" {
return false
}
fmt.Print("Bind port to localhost only? [Y/n]: ")
input := strings.ToLower(readLine())
return input != "n"
}
// Asks for server URL and returns input as string if valid
func askForUrl() string {
fmt.Print("Server URL [eg. https://gokapi.url/]: ")
url := readLine()
@@ -151,6 +158,7 @@ func askForUrl() string {
return url
}
// Asks for redirect URL and returns input as string if valid
func askForRedirect() string {
fmt.Print("URL that the index gets redirected to [eg. https://yourcompany.com/]: ")
url := readLine()
@@ -166,16 +174,11 @@ func askForRedirect() string {
return url
}
// Returns true if URL starts with http:// or https://
func isValidUrl(url string) bool {
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
fmt.Println("URL needs to start with http:// or https://")
return false
}
return true
}
type Result struct {
Result string `json:"Result"`
FileInfo *FileList `json:"FileInfo"`
Url string `json:"Url"`
}
}

24
Dockerfile.testing Normal file
View File

@@ -0,0 +1,24 @@
FROM golang:1.16 AS build_base
## Usage:
## docker build . -t gokapi
## docker run -it -v gokapi-data:/app/data -v gokapi-config:/app/config -p 127.0.0.1:53842:53842 gokapi
RUN mkdir /compile
COPY go.mod /compile
RUN cd /compile && go mod download
COPY . /compile
RUN cd /compile && CGO_ENABLED=0 go build -ldflags="-s -w -X 'main.IS_DOCKER=true' -X 'main.BUILDER=Docker Testing' -X 'main.BUILD_TIME=$(date)'" -o /compile/gokapi
FROM alpine:3.13
RUN apk add ca-certificates && mkdir /app && touch /app/.isdocker
COPY --from=build_base /compile/gokapi /app/gokapi
WORKDIR /app
CMD ["/app/gokapi"]

View File

@@ -3,6 +3,7 @@ package main
import (
"crypto/sha1"
"encoding/hex"
"encoding/json"
"fmt"
"io/ioutil"
"mime/multipart"
@@ -10,6 +11,14 @@ import (
"time"
)
/**
Serving and processing uploaded files
*/
// The length for IDs used in URLs. Can be increased to improve security and decreased to increase readability
const lengthId = 15
// Struct used for saving information about an uploaded file
type FileList struct {
Id string `json:"Id"`
Name string `json:"Name"`
@@ -21,8 +30,31 @@ type FileList struct {
PasswordHash string `json:"PasswordHash"`
}
const lengthId = 15
// Converts the file info to a json String used for returning a result for an upload
func (f *FileList) toJsonResult() string {
result := Result{
Result: "OK",
Url: globalConfig.ServerUrl + "d?id=",
FileInfo: f,
}
bytes, err := json.Marshal(result)
if err != nil {
fmt.Println(err)
return "{\"Result\":\"error\",\"ErrorMessage\":\"" + err.Error() + "\"}"
}
return string(bytes)
}
// The struct used for the result after an upload
type Result struct {
Result string `json:"Result"`
FileInfo *FileList `json:"FileInfo"`
Url string `json:"Url"`
}
// Creates a new file in the system. Called after an upload has been completed. If a file with the same sha256 hash
// already exists, it is deduplicated. This function gathers information about the file, creates and ID and saves
// it into the global configuration.
func createNewFile(fileContent *multipart.File, fileHeader *multipart.FileHeader, expireAt int64, downloads int, password string) (FileList, error) {
id, err := generateRandomString(lengthId)
if err != nil {
@@ -46,8 +78,8 @@ func createNewFile(fileContent *multipart.File, fileHeader *multipart.FileHeader
PasswordHash: hashPassword(password, SALT_PW_FILES),
}
globalConfig.Files[id] = file
filename := "data/" + file.SHA256
if !fileExists("data/" + file.SHA256) {
filename := dataDir + "/" + file.SHA256
if !fileExists(dataDir + "/" + file.SHA256) {
destinationFile, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
return FileList{}, err
@@ -59,7 +91,10 @@ func createNewFile(fileContent *multipart.File, fileHeader *multipart.FileHeader
return file, nil
}
func cleanUpOldFiles(sleep bool) {
// 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.
func cleanUpOldFiles(periodic bool) {
timeNow := time.Now().Unix()
wasItemDeleted := false
for key, element := range globalConfig.Files {
@@ -71,7 +106,7 @@ func cleanUpOldFiles(sleep bool) {
}
}
if deleteFile {
err := os.Remove("data/" + element.SHA256)
err := os.Remove(dataDir + "/" + element.SHA256)
if err != nil {
fmt.Println(err)
}
@@ -84,8 +119,8 @@ func cleanUpOldFiles(sleep bool) {
saveConfig()
cleanUpOldFiles(false)
}
if sleep {
if periodic {
time.Sleep(time.Hour)
go cleanUpOldFiles(true)
go cleanUpOldFiles(periodic)
}
}

View File

@@ -13,15 +13,11 @@ import (
"strings"
)
func check(e error) {
if e != nil {
panic(e)
}
}
const SALT_PW_ADMIN = "eefwkjqweduiotbrkl##$2342brerlk2321"
const SALT_PW_FILES = "P1UI5sRNDwuBgOvOYhNsmucZ2pqo4KEvOoqqbpdu"
/**
Various functions, mostly for OS access
*/
// Hashes a password with SHA256 and a salt
func hashPassword(password, salt string) string {
if password == "" {
return ""
@@ -32,6 +28,7 @@ func hashPassword(password, salt string) string {
return hex.EncodeToString(hash.Sum(nil))
}
// Returns true if a folder exists
func folderExists(folder string) bool {
_, err := os.Stat(folder)
if err == nil {
@@ -40,6 +37,7 @@ func folderExists(folder string) bool {
return !os.IsNotExist(err)
}
// Returns true if a file exists
func fileExists(filename string) bool {
info, err := os.Stat(filename)
if os.IsNotExist(err) {
@@ -48,6 +46,7 @@ func fileExists(filename string) bool {
return !info.IsDir()
}
// Converts bytes to a human readable format
func byteCountSI(b int64) string {
const unit = 1024
if b < unit {
@@ -62,6 +61,7 @@ func byteCountSI(b int64) string {
float64(b)/float64(div), "kMGTPE"[exp])
}
// A rune array to be used for pseudo-random string generation
var characters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
//Used if unable to generate secure random string. A warning will be output
@@ -75,7 +75,7 @@ func unsafeId(length int) string {
return string(b)
}
// generateRandomBytes returns securely generated random bytes.
// Returns securely generated random bytes.
// It will return an error if the system's secure random
// number generator fails to function correctly
func generateRandomBytes(n int) ([]byte, error) {
@@ -87,28 +87,38 @@ func generateRandomBytes(n int) ([]byte, error) {
return b, nil
}
// generateRandomString returns a URL-safe, base64 encoded
// securely generated random string.
// Returns a URL-safe, base64 encoded securely generated random string.
func generateRandomString(length int) (string, error) {
b, err := generateRandomBytes(length)
return base64.URLEncoding.EncodeToString(b), err
}
// Creates the data folder if it does not exist
func createDataDir() {
if !folderExists("data") {
err := os.Mkdir("data", 0770)
if !folderExists(dataDir) {
err := os.Mkdir(dataDir, 0770)
check(err)
}
}
// Creates the config folder if it does not exist
func createConfigDir() {
if !folderExists(configDir) {
err := os.Mkdir(configDir, 0770)
check(err)
}
}
// Reads a line from the terminal and returns it as a string
func readLine() string {
reader := bufio.NewReader(os.Stdin)
text, _ := reader.ReadString('\n')
return strings.Replace(text, "\n", "", -1)
}
}
// Panics if error is not nil
func check(e error) {
if e != nil {
panic(e)
}
}

15
Main.go
View File

@@ -7,9 +7,20 @@ import (
"time"
)
//needs to be changed in ./templates/string_constants.tmpl as well
/**
Main routine
*/
// needs to be changed in ./templates/string_constants.tmpl as well
const VERSION = "1.1.0"
// Salt for the admin password hash
const SALT_PW_ADMIN = "eefwkjqweduiotbrkl##$2342brerlk2321"
// Salt for the file password hashes
const SALT_PW_FILES = "P1UI5sRNDwuBgOvOYhNsmucZ2pqo4KEvOoqqbpdu"
// Main routine that is called on startup
func main() {
checkPrimaryArguments()
rand.Seed(time.Now().UnixNano())
@@ -22,6 +33,7 @@ func main() {
startWebserver()
}
// Checks for command line arguments that have to be parsed before loading the configuration
func checkPrimaryArguments() {
if len(os.Args) > 1 {
if os.Args[1] == "--version" || os.Args[1] == "-v" {
@@ -34,6 +46,7 @@ func checkPrimaryArguments() {
}
}
// Checks for command line arguments that have to be parsed after loading the configuration
func checkArguments() {
if len(os.Args) > 1 {
if os.Args[1] == "--reset-pw" {

View File

@@ -5,28 +5,22 @@ import (
"time"
)
//If no login occurred during this time, the session will be deleted. Default 30 days
const COOKIE_LIFE = 30 * 24 * time.Hour
/**
Manages the sessions for the admin user or to access password protected files
*/
//If no login occurred during this time, the admin session will be deleted. Default 30 days
const COOKIE_LIFE_ADMIN = 30 * 24 * time.Hour
// Structure for cookies
type Session struct {
RenewAt int64
ValidUntil int64
}
func useSession(w http.ResponseWriter, sessionString string) bool {
session := globalConfig.Sessions[sessionString]
if session.ValidUntil < time.Now().Unix() {
delete(globalConfig.Sessions, sessionString)
return false
}
if session.RenewAt < time.Now().Unix() {
createSession(w)
delete(globalConfig.Sessions, sessionString)
saveConfig()
}
return true
}
// Checks if the user is submitting a valid session token
// If valid session is found, useSession will be called
// Returns true if authenticated, otherwise false
func isValidSession(w http.ResponseWriter, r *http.Request) bool {
cookie, err := r.Cookie("session_token")
if err == nil {
@@ -41,6 +35,25 @@ func isValidSession(w http.ResponseWriter, r *http.Request) bool {
return false
}
// Checks if a session is still valid. Changes the session string if it has
// been used for more than an hour to limit session hijacking
// Returns true if session is still valid
// Returns false if session is invalid (and deletes it)
func useSession(w http.ResponseWriter, sessionString string) bool {
session := globalConfig.Sessions[sessionString]
if session.ValidUntil < time.Now().Unix() {
delete(globalConfig.Sessions, sessionString)
return false
}
if session.RenewAt < time.Now().Unix() {
createSession(w)
delete(globalConfig.Sessions, sessionString)
saveConfig()
}
return true
}
//Creates a new session - called after login with correct username / password
func createSession(w http.ResponseWriter) {
sessionString, err := generateRandomString(60)
if err != nil {
@@ -48,12 +61,13 @@ func createSession(w http.ResponseWriter) {
}
globalConfig.Sessions[sessionString] = Session{
RenewAt: time.Now().Add(time.Hour).Unix(),
ValidUntil: time.Now().Add(COOKIE_LIFE).Unix(),
ValidUntil: time.Now().Add(COOKIE_LIFE_ADMIN).Unix(),
}
writeSessionCookie(w, sessionString, time.Now().Add(COOKIE_LIFE))
writeSessionCookie(w, sessionString, time.Now().Add(COOKIE_LIFE_ADMIN))
saveConfig()
}
// Logs out user and deletes session
func logoutSession(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session_token")
if err == nil {
@@ -63,6 +77,7 @@ func logoutSession(w http.ResponseWriter, r *http.Request) {
writeSessionCookie(w, "", time.Now())
}
// Writes session cookie to browser
func writeSessionCookie(w http.ResponseWriter, sessionString string, expiry time.Time) {
http.SetCookie(w, &http.Cookie{
Name: "session_token",
@@ -70,4 +85,3 @@ func writeSessionCookie(w http.ResponseWriter, sessionString string, expiry time
Expires: expiry,
})
}

View File

@@ -15,14 +15,24 @@ import (
"time"
)
/**
Handling of webserver and requests / uploads
*/
// Embedded version of the "static" folder
// This contains JS files, CSS, images etc
//go:embed static
var staticFolderEmbedded embed.FS
// Embedded version of the "templates" folder
// This contains templates that Gokapi uses for creating the HTML output
//go:embed templates
var templateFolderEmbedded embed.FS
// Variable containing all parsed templates
var templateFolder *template.Template
// Starts the webserver on the port set in the config
func startWebserver() {
webserverDir, _ := fs.Sub(staticFolderEmbedded, "static")
if folderExists("static") {
@@ -45,6 +55,9 @@ func startWebserver() {
log.Fatal(http.ListenAndServe(globalConfig.Port, nil))
}
// Initialises the templateFolder variable by scanning through all the templates.
// If a folder "templates" exists in the main directory, it is used.
// Otherwise templateFolderEmbedded will be used.
func initTemplates() {
var err error
if folderExists("templates") {
@@ -56,30 +69,38 @@ func initTemplates() {
}
}
// Sends a redirect HTTP output to the client. Variable url is used to redirect to ./url
func redirect(w http.ResponseWriter, url string) {
_, _ = fmt.Fprint(w, "<head><meta http-equiv=\"Refresh\" content=\"0; URL=./"+url+"\"></head>")
}
// Handling of /logout
func doLogout(w http.ResponseWriter, r *http.Request) {
logoutSession(w, r)
redirect(w, "login")
}
// Handling of /index and redirecting to globalConfig.RedirectUrl
func showIndex(w http.ResponseWriter, r *http.Request) {
err := templateFolder.ExecuteTemplate(w, "index", globalConfig.RedirectUrl)
check(err)
}
// Handling of /error
func showError(w http.ResponseWriter, r *http.Request) {
err := templateFolder.ExecuteTemplate(w, "error", nil)
check(err)
}
// Handling of /forgotpw
func forgotPassword(w http.ResponseWriter, r *http.Request) {
err := templateFolder.ExecuteTemplate(w, "forgotpw", nil)
check(err)
}
// Handling of /login
// Shows a login form. If username / pw combo is incorrect, client needs to wait for three seconds.
// If correct, a new session is created and the user is redirected to the admin menu
func showLogin(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
check(err)
@@ -103,11 +124,15 @@ func showLogin(w http.ResponseWriter, r *http.Request) {
check(err)
}
// Variables for the login template
type LoginView struct {
IsFailedLogin bool
User string
}
// Handling of /d
// Checks if a file exists for the submitted ID
// 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 == "" {
@@ -154,6 +179,8 @@ func showDownload(w http.ResponseWriter, r *http.Request) {
check(err)
}
// Handling of /delete
// User needs to be admin. Deleted the requested file
func deleteFile(w http.ResponseWriter, r *http.Request) {
if !isAuthenticated(w, r, false) {
return
@@ -169,9 +196,11 @@ func deleteFile(w http.ResponseWriter, r *http.Request) {
redirect(w, "admin")
}
// Checks if a file is associated with the GET parameter from the current URL
// Stops for 500ms to limit brute forcing if invalid key and redirects to redirectUrl
func queryUrl(w http.ResponseWriter, r *http.Request, redirectUrl string) string {
keys, ok := r.URL.Query()["id"]
if !ok || len(keys[0]) < 15 {
if !ok || len(keys[0]) < lengthId {
time.Sleep(500 * time.Millisecond)
redirect(w, redirectUrl)
return ""
@@ -179,6 +208,8 @@ func queryUrl(w http.ResponseWriter, r *http.Request, redirectUrl string) string
return keys[0]
}
// Handling of /admin
// If user is authenticated, this menu lists all uploads and enables uploading new files
func showAdminMenu(w http.ResponseWriter, r *http.Request) {
if !isAuthenticated(w, r, false) {
return
@@ -187,6 +218,7 @@ func showAdminMenu(w http.ResponseWriter, r *http.Request) {
check(err)
}
// Parameters for the download template
type DownloadView struct {
Name string
Size string
@@ -194,6 +226,7 @@ type DownloadView struct {
IsFailedLogin bool
}
// Parameters for the admin menu template
type UploadView struct {
Items []FileList
Url string
@@ -203,6 +236,8 @@ type UploadView struct {
DefaultPassword string
}
// Converts the globalConfig variable to an UploadView struct to pass the infos to
// the admin template
func (u *UploadView) convertGlobalConfig() *UploadView {
var result []FileList
for _, element := range globalConfig.Files {
@@ -220,6 +255,9 @@ func (u *UploadView) convertGlobalConfig() *UploadView {
return u
}
// Handling of /upload
// If the user is authenticated, this parses the uploaded file from the Multipart Form and
// adds it to the system.
func uploadFile(w http.ResponseWriter, r *http.Request) {
if !isAuthenticated(w, r, true) {
return
@@ -248,6 +286,8 @@ func uploadFile(w http.ResponseWriter, r *http.Request) {
_, err = fmt.Fprint(w, result.toJsonResult())
check(err)
}
// Outputs an error in json format
func responseError(w http.ResponseWriter, err error) {
if err != nil {
fmt.Fprint(w, "{\"Result\":\"error\",\"ErrorMessage\":\""+err.Error()+"\"}")
@@ -255,6 +295,7 @@ 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 == "" {
@@ -284,6 +325,7 @@ func downloadFile(w http.ResponseWriter, r *http.Request) {
check(err)
}
// Checks if the user is logged in as an admin
func isAuthenticated(w http.ResponseWriter, r *http.Request, isUpload bool) bool {
if isValidSession(w, r) {
return true
@@ -297,6 +339,7 @@ func isAuthenticated(w http.ResponseWriter, r *http.Request, isUpload bool) bool
return false
}
// Write a cookie if the user has entered a correct password for a password-protected file
func writeFilePwCookie(w http.ResponseWriter, file FileList) {
http.SetCookie(w, &http.Cookie{
Name: "p" + file.Id,
@@ -305,6 +348,8 @@ func writeFilePwCookie(w http.ResponseWriter, file FileList) {
})
}
// Checks if a cookie contains the correct password hash for a password-protected file
// If incorrect, a 3 second delay is introduced unless the cookie was empty.
func isValidPwCookie(r *http.Request, file FileList) bool {
cookie, err := r.Cookie("p" + file.Id)
if err == nil {

2
go.mod
View File

@@ -1,4 +1,4 @@
module SingleDownload
module Gokapi
go 1.16