mirror of
https://github.com/Forceu/Gokapi.git
synced 2026-05-04 13:30:31 -05:00
Add end-to-end encryption (#71)
* Added WASM module for e2e * Added cmd util to read database * Changed to go 1.19 * Fixed crash with random string generator * Fixed typos and tests * Host service worker on github
This commit is contained in:
@@ -11,7 +11,7 @@ jobs:
|
||||
uses: actions/checkout@v2
|
||||
- uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: '^1.18'
|
||||
go-version: '^1.19'
|
||||
- run: go generate ./...
|
||||
- run: go test ./... -parallel 8 --tags=test,awsmock
|
||||
- run: go clean -testcache
|
||||
|
||||
@@ -7,4 +7,5 @@ gokapi
|
||||
docs/_build
|
||||
wasmServer
|
||||
internal/webserver/web/main.wasm
|
||||
internal/webserver/web/e2e.wasm
|
||||
internal/webserver/web/static/js/wasm_exec.js
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.18 AS build_base
|
||||
FROM golang:1.19 AS build_base
|
||||
|
||||
## Usage:
|
||||
## docker build . -t gokapi
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.18
|
||||
FROM golang:1.19
|
||||
|
||||
## To compile:
|
||||
## cd Gokapi/build/
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
module github.com/forceu/gokapi
|
||||
|
||||
go 1.18
|
||||
go 1.19
|
||||
|
||||
require (
|
||||
git.mills.io/prologic/bitcask v1.0.2
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/forceu/gokapi/internal/configuration/database"
|
||||
"github.com/forceu/gokapi/internal/helper"
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if !correctArgs() {
|
||||
showUsageAndExit()
|
||||
return
|
||||
}
|
||||
path := os.Args[1]
|
||||
database.Init(path)
|
||||
metadata := database.GetAllMetadata()
|
||||
for _, file := range metadata {
|
||||
result, err := json.MarshalIndent(file, "", " ")
|
||||
if err != nil {
|
||||
log.Fatal("Error encoding file: ", err)
|
||||
}
|
||||
fmt.Println(string(result))
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
func correctArgs() bool {
|
||||
if len(os.Args) < 2 {
|
||||
return false
|
||||
}
|
||||
path := os.Args[1]
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
if !helper.FolderExists(path) {
|
||||
fmt.Println("Error: Folder does not exist: " + path)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func showUsageAndExit() {
|
||||
fmt.Println("Usage: ./databasereader /path/to/database")
|
||||
osExit(1)
|
||||
return
|
||||
}
|
||||
|
||||
var osExit = os.Exit
|
||||
@@ -0,0 +1,43 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/forceu/gokapi/internal/test"
|
||||
"github.com/forceu/gokapi/internal/test/testconfiguration"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
testconfiguration.Create(false)
|
||||
exitVal := m.Run()
|
||||
testconfiguration.Delete()
|
||||
os.Exit(exitVal)
|
||||
}
|
||||
|
||||
func TestRun(t *testing.T) {
|
||||
originalArgs := os.Args
|
||||
hasExited := false
|
||||
osExit = func(code int) {
|
||||
hasExited = true
|
||||
}
|
||||
os.Args = []string{os.Args[0]}
|
||||
main()
|
||||
test.IsEqualBool(t, hasExited, true)
|
||||
hasExited = false
|
||||
|
||||
os.Args = append(os.Args, "")
|
||||
main()
|
||||
test.IsEqualBool(t, hasExited, true)
|
||||
hasExited = false
|
||||
|
||||
os.Args[1] = "invalidFolder"
|
||||
main()
|
||||
test.IsEqualBool(t, hasExited, true)
|
||||
hasExited = false
|
||||
|
||||
os.Args[1] = "./test/filestorage.db"
|
||||
main()
|
||||
test.IsEqualBool(t, hasExited, false)
|
||||
|
||||
os.Args = originalArgs
|
||||
}
|
||||
+2
-2
@@ -33,7 +33,8 @@ const Version = "1.6.0"
|
||||
|
||||
//go:generate sh "../../build/setVersionTemplate.sh" "1.6.0"
|
||||
//go:generate sh -c "cp \"$(go env GOROOT)/misc/wasm/wasm_exec.js\" ../../internal/webserver/web/static/js/ && echo Copied wasm_exec.js"
|
||||
//go:generate sh -c "GOOS=js GOARCH=wasm go build -o ../../internal/webserver/web/main.wasm github.com/forceu/gokapi/cmd/wasmdownloader && echo Compiled WASM module"
|
||||
//go:generate sh -c "GOOS=js GOARCH=wasm go build -o ../../internal/webserver/web/main.wasm github.com/forceu/gokapi/cmd/wasmdownloader && echo Compiled Downloader WASM module"
|
||||
//go:generate sh -c "GOOS=js GOARCH=wasm go build -o ../../internal/webserver/web/e2e.wasm github.com/forceu/gokapi/cmd/wasme2e && echo Compiled E2E WASM module"
|
||||
|
||||
// Main routine that is called on startup
|
||||
func main() {
|
||||
@@ -49,7 +50,6 @@ func main() {
|
||||
authentication.Init(configuration.Get().Authentication)
|
||||
createSsl(passedFlags)
|
||||
initCloudConfig(passedFlags)
|
||||
|
||||
go storage.CleanUp(true)
|
||||
logging.AddString("Gokapi started")
|
||||
go webserver.Start()
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
func main() {
|
||||
js.Global().Set("GokapiEncrypt", js.FuncOf(Encrypt))
|
||||
js.Global().Set("GokapiDecrypt", js.FuncOf(Decrypt))
|
||||
println("WASM module loaded")
|
||||
println("WASM Downloader module loaded")
|
||||
// Prevent the function from returning, which is required in a wasm module
|
||||
select {}
|
||||
}
|
||||
@@ -176,10 +176,3 @@ func jsError(message string) js.Value {
|
||||
errVal := errConstructor.New(message)
|
||||
return errVal
|
||||
}
|
||||
|
||||
// Returns a byte slice from a js.Value
|
||||
func bytesFromJs(arg js.Value) []byte {
|
||||
out := make([]byte, arg.Length())
|
||||
js.CopyBytesToGo(out, arg)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
//go:build js && wasm
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/forceu/gokapi/internal/encryption"
|
||||
"github.com/forceu/gokapi/internal/encryption/end2end"
|
||||
"github.com/forceu/gokapi/internal/models"
|
||||
"github.com/secure-io/sio-go"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"syscall/js"
|
||||
)
|
||||
|
||||
var fileInfo models.E2EInfoPlainText
|
||||
var key []byte
|
||||
var fileMutex sync.Mutex
|
||||
|
||||
var uploads map[string]uploadData
|
||||
|
||||
type uploadData struct {
|
||||
totalFilesizeEncrypted int64
|
||||
totalFilesizePlain int64
|
||||
bytesSent int64
|
||||
id string
|
||||
writerInput *bytes.Buffer
|
||||
encrypter *sio.EncWriter
|
||||
cipher []byte
|
||||
filename string
|
||||
}
|
||||
|
||||
// Main routine that is called on startup
|
||||
func main() {
|
||||
uploads = make(map[string]uploadData)
|
||||
js.Global().Set("GokapiE2EInfoParse", js.FuncOf(InfoParse))
|
||||
js.Global().Set("GokapiE2EInfoEncrypt", js.FuncOf(InfoEncrypt))
|
||||
js.Global().Set("GokapiE2EAddFile", js.FuncOf(AddFile))
|
||||
js.Global().Set("GokapiE2EGetNewCipher", js.FuncOf(GetNewCipher))
|
||||
js.Global().Set("GokapiE2ESetCipher", js.FuncOf(SetCipher))
|
||||
js.Global().Set("GokapiE2EEncryptNew", js.FuncOf(EncryptNew))
|
||||
js.Global().Set("GokapiE2EUploadChunk", js.FuncOf(UploadChunk))
|
||||
js.Global().Set("GokapiE2EDecryptMenu", js.FuncOf(DecryptMenu))
|
||||
println("WASM end-to-end encryption module loaded")
|
||||
// Prevent the function from returning, which is required in a wasm module
|
||||
select {}
|
||||
}
|
||||
|
||||
func EncryptNew(this js.Value, args []js.Value) interface{} {
|
||||
id := args[0].String()
|
||||
fileSize := int64(args[1].Float())
|
||||
filename := args[2].String()
|
||||
fileSizeEncrypted := encryption.CalculateEncryptedFilesize(fileSize)
|
||||
cipher, err := encryption.GetRandomCipher()
|
||||
if err != nil {
|
||||
return jsError(err.Error())
|
||||
}
|
||||
input := bytes.NewBuffer(nil)
|
||||
stream, err := encryption.GetEncryptWriter(cipher, input)
|
||||
if err != nil {
|
||||
return jsError(err.Error())
|
||||
}
|
||||
result := uploadData{
|
||||
totalFilesizeEncrypted: fileSizeEncrypted,
|
||||
totalFilesizePlain: fileSize,
|
||||
bytesSent: 0,
|
||||
id: id,
|
||||
encrypter: stream,
|
||||
writerInput: input,
|
||||
cipher: cipher,
|
||||
filename: filename,
|
||||
}
|
||||
uploads[id] = result
|
||||
return fileSizeEncrypted
|
||||
}
|
||||
|
||||
func UploadChunk(this js.Value, args []js.Value) interface{} {
|
||||
id := args[0].String()
|
||||
if uploads[id].id != id {
|
||||
return jsError("upload id not found")
|
||||
}
|
||||
size := int64(args[1].Float())
|
||||
isLastChunk := args[2].Bool()
|
||||
chunkContent := make([]byte, size)
|
||||
js.CopyBytesToGo(chunkContent, args[3])
|
||||
|
||||
// Handler for the Promise
|
||||
// We need to return a Promise because HTTP requests are blocking in Go
|
||||
handler := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||
resolve := args[0]
|
||||
reject := args[1]
|
||||
|
||||
// Run this code asynchronously
|
||||
go func() {
|
||||
uploadInfo := uploads[id]
|
||||
|
||||
_, err := io.Copy(uploadInfo.encrypter, bytes.NewReader(chunkContent))
|
||||
if err != nil {
|
||||
reject.Invoke(jsError(err.Error()))
|
||||
return
|
||||
}
|
||||
if isLastChunk {
|
||||
err = uploads[id].encrypter.Close()
|
||||
if err != nil {
|
||||
reject.Invoke(jsError(err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
encryptedContent := uploads[id].writerInput.Bytes()
|
||||
|
||||
uploadInfo.bytesSent = uploadInfo.bytesSent + int64(len(encryptedContent))
|
||||
uploadInfo.writerInput.Reset()
|
||||
uploads[id] = uploadInfo
|
||||
chunkContent = nil
|
||||
|
||||
jsResult := js.Global().Get("Uint8Array").New(len(encryptedContent))
|
||||
js.CopyBytesToJS(jsResult, encryptedContent)
|
||||
resolve.Invoke(jsResult)
|
||||
}()
|
||||
return nil
|
||||
})
|
||||
// Create and return the Promise object
|
||||
// The Promise will resolve with a Response object
|
||||
promiseConstructor := js.Global().Get("Promise")
|
||||
return promiseConstructor.New(handler)
|
||||
}
|
||||
|
||||
func postChunk(data *[]byte, uuid string, fileSize, offset int64, jsFile js.Value) error {
|
||||
body := new(bytes.Buffer)
|
||||
writer := multipart.NewWriter(body)
|
||||
part, err := writer.CreateFormFile("file", "encrypted.file")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = part.Write(*data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = writer.WriteField("dztotalfilesize", strconv.FormatInt(fileSize, 10))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = writer.WriteField("dzchunkbyteoffset", strconv.FormatInt(offset, 10))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = writer.WriteField("dzuuid", uuid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = writer.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r, err := http.NewRequest("POST", "./uploadChunk", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(r)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bodyContent, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response := string(bodyContent)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return errors.New("failed to upload chunk: status code " + strconv.Itoa(resp.StatusCode) + ", response: " + response)
|
||||
}
|
||||
if response != "{\"result\":\"OK\"}" {
|
||||
return errors.New("failed to upload chunk: unexpected response: " + response)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func InfoParse(this js.Value, args []js.Value) interface{} {
|
||||
var err error
|
||||
var e2EncModel models.E2EInfoEncrypted
|
||||
|
||||
e2InfoJson := args[0].String()
|
||||
err = json.Unmarshal([]byte(e2InfoJson), &e2EncModel)
|
||||
if err != nil {
|
||||
return jsError(err.Error())
|
||||
}
|
||||
fileInfo, err = end2end.DecryptData(e2EncModel, key)
|
||||
if err != nil {
|
||||
return jsError(err.Error())
|
||||
}
|
||||
fileInfo.Files = removeExpiredFiles(e2EncModel)
|
||||
return nil
|
||||
}
|
||||
|
||||
func DecryptMenu(this js.Value, args []js.Value) interface{} {
|
||||
for _, file := range fileInfo.Files {
|
||||
cipher := base64.StdEncoding.EncodeToString(file.Cipher)
|
||||
hashContent, err := json.Marshal(models.E2EHashContent{
|
||||
Filename: file.Filename,
|
||||
Cipher: cipher,
|
||||
})
|
||||
if err != nil {
|
||||
return jsError(err.Error())
|
||||
}
|
||||
hashBase64 := base64.StdEncoding.EncodeToString(hashContent)
|
||||
js.Global().Call("decryptFileEntry", file.Id, file.Filename, hashBase64)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeExpiredFiles(encInfo models.E2EInfoEncrypted) []models.E2EFile {
|
||||
cleanedFiles := make([]models.E2EFile, 0)
|
||||
for _, id := range encInfo.AvailableFiles {
|
||||
for _, file := range fileInfo.Files {
|
||||
if file.Id == id {
|
||||
cleanedFiles = append(cleanedFiles, file)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return cleanedFiles
|
||||
}
|
||||
|
||||
func AddFile(this js.Value, args []js.Value) interface{} {
|
||||
fileMutex.Lock()
|
||||
files := fileInfo.Files
|
||||
uuid := args[0].String()
|
||||
if uploads[uuid].id != uuid {
|
||||
return jsError("upload id not found")
|
||||
}
|
||||
id := args[1].String()
|
||||
fileName := args[2].String()
|
||||
|
||||
files = append(files, models.E2EFile{
|
||||
Uuid: uuid,
|
||||
Id: id,
|
||||
Filename: fileName,
|
||||
Cipher: uploads[uuid].cipher,
|
||||
})
|
||||
fileInfo.Files = files
|
||||
fileMutex.Unlock()
|
||||
delete(uploads, uuid)
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetNewCipher(this js.Value, args []js.Value) interface{} {
|
||||
cipher, err := encryption.GetRandomCipher()
|
||||
if err != nil {
|
||||
return jsError(err.Error())
|
||||
}
|
||||
setAsMaster := args[0].Bool()
|
||||
if setAsMaster {
|
||||
key = cipher
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(cipher)
|
||||
}
|
||||
|
||||
func SetCipher(this js.Value, args []js.Value) interface{} {
|
||||
cipher := args[0].String()
|
||||
return setCipher(cipher)
|
||||
}
|
||||
|
||||
func setCipher(keyBase64 string) interface{} {
|
||||
rawKey, err := base64.StdEncoding.DecodeString(keyBase64)
|
||||
if err != nil {
|
||||
return jsError(err.Error())
|
||||
}
|
||||
if len(rawKey) != 32 {
|
||||
return jsError("invalid cipher length")
|
||||
}
|
||||
key = rawKey
|
||||
return nil
|
||||
}
|
||||
|
||||
func InfoEncrypt(this js.Value, args []js.Value) interface{} {
|
||||
output, err := end2end.EncryptData(fileInfo.Files, key)
|
||||
if err != nil {
|
||||
return jsError(err.Error())
|
||||
}
|
||||
outputJson, err := json.Marshal(output)
|
||||
if err != nil {
|
||||
return jsError(err.Error())
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(outputJson)
|
||||
}
|
||||
|
||||
// Wraps a message into a JavaScript object of type error
|
||||
func jsError(message string) js.Value {
|
||||
errConstructor := js.Global().Get("Error")
|
||||
errVal := errConstructor.New(message)
|
||||
return errVal
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/forceu/gokapi
|
||||
|
||||
go 1.18
|
||||
go 1.19
|
||||
|
||||
require (
|
||||
git.mills.io/prologic/bitcask v1.0.2
|
||||
|
||||
@@ -18,6 +18,7 @@ const prefixFile = "file:id:"
|
||||
const prefixHotlink = "hotlink:id:"
|
||||
const prefixSessions = "session:id:"
|
||||
const idLastUploadConfig = "default:lastupload"
|
||||
const idEnd2EndInfo = "e2e:info"
|
||||
|
||||
const maxKeySize = 96
|
||||
|
||||
@@ -289,6 +290,40 @@ func SaveUploadDefaults(values models.LastUploadValues) {
|
||||
helper.Check(err)
|
||||
}
|
||||
|
||||
// ## End2End Encryption ##
|
||||
|
||||
// SaveEnd2EndInfo stores the encrypted e2e info
|
||||
func SaveEnd2EndInfo(info models.E2EInfoEncrypted) {
|
||||
var buf bytes.Buffer
|
||||
enc := gob.NewEncoder(&buf)
|
||||
err := enc.Encode(info)
|
||||
helper.Check(err)
|
||||
err = bitcaskDb.Put([]byte(idEnd2EndInfo), buf.Bytes())
|
||||
helper.Check(err)
|
||||
err = bitcaskDb.Sync()
|
||||
helper.Check(err)
|
||||
}
|
||||
|
||||
// GetEnd2EndInfo retrieves the encrypted e2e info
|
||||
func GetEnd2EndInfo() models.E2EInfoEncrypted {
|
||||
result := models.E2EInfoEncrypted{}
|
||||
value, ok := getValue(idEnd2EndInfo)
|
||||
if !ok {
|
||||
return result
|
||||
}
|
||||
buf := bytes.NewBuffer(value)
|
||||
dec := gob.NewDecoder(buf)
|
||||
err := dec.Decode(&result)
|
||||
helper.Check(err)
|
||||
result.AvailableFiles = GetAllMetaDataIds()
|
||||
return result
|
||||
}
|
||||
|
||||
// DeleteEnd2EndInfo resets the encrypted e2e info
|
||||
func DeleteEnd2EndInfo() {
|
||||
deleteKey(idEnd2EndInfo)
|
||||
}
|
||||
|
||||
// RunGarbageCollection runs the databases GC
|
||||
func RunGarbageCollection() {
|
||||
err := bitcaskDb.RunGC()
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/forceu/gokapi/internal/configuration"
|
||||
"github.com/forceu/gokapi/internal/configuration/cloudconfig"
|
||||
"github.com/forceu/gokapi/internal/configuration/configupgrade"
|
||||
"github.com/forceu/gokapi/internal/configuration/database"
|
||||
"github.com/forceu/gokapi/internal/encryption"
|
||||
"github.com/forceu/gokapi/internal/environment"
|
||||
"github.com/forceu/gokapi/internal/helper"
|
||||
@@ -29,11 +30,13 @@ import (
|
||||
|
||||
// webserverDir is the embedded version of the "static" folder
|
||||
// This contains JS files, CSS, images etc for the setup
|
||||
//
|
||||
//go:embed static
|
||||
var webserverDirEmb embed.FS
|
||||
|
||||
// templateFolderEmbedded is the embedded version of the "templates" folder
|
||||
// This contains templates that Gokapi uses for creating the HTML output
|
||||
//
|
||||
//go:embed templates
|
||||
var templateFolderEmbedded embed.FS
|
||||
|
||||
@@ -42,7 +45,8 @@ var isInitialSetup = true
|
||||
var username string
|
||||
var password string
|
||||
|
||||
var serverStarted = false
|
||||
// statusChannel is only used for testing to indicate to the unit test that the server has been started or shut down.
|
||||
var statusChannel chan bool = nil
|
||||
|
||||
const debugDisableAuth = false
|
||||
|
||||
@@ -111,16 +115,21 @@ func startSetupWebserver() {
|
||||
Handler: mux,
|
||||
}
|
||||
fmt.Println("Please open http://" + resolveHostIp() + ":" + port + "/setup to setup Gokapi.")
|
||||
go func() {
|
||||
time.Sleep(time.Second)
|
||||
serverStarted = true
|
||||
}()
|
||||
if statusChannel != nil {
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second)
|
||||
statusChannel <- true
|
||||
}()
|
||||
}
|
||||
|
||||
// always returns error. ErrServerClosed on graceful close
|
||||
err := srv.ListenAndServe()
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("Setup Webserver: %v", err)
|
||||
}
|
||||
serverStarted = false
|
||||
if statusChannel != nil {
|
||||
statusChannel <- false
|
||||
}
|
||||
}
|
||||
|
||||
func resolveHostIp() string {
|
||||
@@ -408,6 +417,12 @@ func parseEncryptionAndDelete(result *models.Configuration, formObjects *[]jsonF
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if encLevel == encryption.EndToEndEncryption {
|
||||
deleteE2eInfo, _ := getFormValueString(formObjects, "cleare2e")
|
||||
if deleteE2eInfo == "true" {
|
||||
database.DeleteEnd2EndInfo()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !generateNewEncConfig {
|
||||
@@ -456,10 +471,6 @@ func parseEncryptionLevel(formObjects *[]jsonFormObject) (int, error) {
|
||||
if encLevel < encryption.NoEncryption || encLevel > encryption.EndToEndEncryption {
|
||||
return 0, errors.New("invalid encryption level selected")
|
||||
}
|
||||
|
||||
if encLevel == encryption.EndToEndEncryption {
|
||||
return 0, errors.New("end to end encryption not implemented yet") // TODO
|
||||
}
|
||||
return encLevel, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -227,14 +227,13 @@ func TestInitialSetup(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunConfigModification(t *testing.T) {
|
||||
statusChannel = make(chan bool, 1)
|
||||
testconfiguration.Create(false)
|
||||
username = ""
|
||||
password = ""
|
||||
finish := make(chan bool)
|
||||
go func() {
|
||||
for !serverStarted {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
checkServerStatus(t, true)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
test.HttpPageResult(t, test.HttpTestConfig{
|
||||
Url: "http://localhost:53842/setup/start",
|
||||
@@ -252,15 +251,36 @@ func TestRunConfigModification(t *testing.T) {
|
||||
test.IsEqualInt(t, len(password), 10)
|
||||
isInitialSetup = true
|
||||
<-finish
|
||||
statusChannel = nil
|
||||
}
|
||||
|
||||
func checkServerStatus(t *testing.T, expected bool) {
|
||||
t.Helper()
|
||||
if statusChannel == nil {
|
||||
t.Fatal("statusChannel is nil")
|
||||
}
|
||||
var result bool
|
||||
select {
|
||||
case result = <-statusChannel:
|
||||
|
||||
case <-time.After(20 * time.Second):
|
||||
t.Fatal("statusChannel timeout after 20 seconds")
|
||||
}
|
||||
if result != expected {
|
||||
if expected == true {
|
||||
t.Fatal("Server was started when it was supposed to be shut down")
|
||||
} else {
|
||||
t.Fatal("Server was shut down when it was supposed to be started")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration(t *testing.T) {
|
||||
statusChannel = make(chan bool, 1)
|
||||
testconfiguration.Delete()
|
||||
test.FileDoesNotExist(t, "test/config.json")
|
||||
go RunIfFirstStart()
|
||||
for !serverStarted {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
checkServerStatus(t, true)
|
||||
|
||||
test.HttpPageResult(t, test.HttpTestConfig{
|
||||
Url: "http://localhost:53842/admin",
|
||||
@@ -298,14 +318,7 @@ func TestIntegration(t *testing.T) {
|
||||
Body: strings.NewReader(setupValues.toJson()),
|
||||
})
|
||||
|
||||
counter := 0
|
||||
for serverStarted {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
counter++
|
||||
if counter > 100 {
|
||||
t.Fatal("Unbroken loop")
|
||||
}
|
||||
}
|
||||
checkServerStatus(t, false)
|
||||
test.FileExists(t, "test/config.json")
|
||||
settings := configuration.Get()
|
||||
test.IsEqualInt(t, settings.Authentication.Method, 0)
|
||||
@@ -333,9 +346,7 @@ func TestIntegration(t *testing.T) {
|
||||
test.FileExists(t, "test/cloudconfig.yml")
|
||||
|
||||
go RunConfigModification()
|
||||
for !serverStarted {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
checkServerStatus(t, true)
|
||||
|
||||
username = "test"
|
||||
password = "testpw"
|
||||
@@ -380,14 +391,8 @@ func TestIntegration(t *testing.T) {
|
||||
Body: strings.NewReader(setupInput.toJson()),
|
||||
})
|
||||
|
||||
counter = 0
|
||||
for serverStarted {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
counter++
|
||||
if counter > 100 {
|
||||
t.Fatal("Unbroken loop")
|
||||
}
|
||||
}
|
||||
checkServerStatus(t, false)
|
||||
|
||||
test.FileExists(t, "test/config.json")
|
||||
settings = configuration.Get()
|
||||
test.IsEqualInt(t, settings.Authentication.Method, 2)
|
||||
@@ -416,9 +421,8 @@ func TestIntegration(t *testing.T) {
|
||||
test.FileDoesNotExist(t, "test/cloudconfig.yml")
|
||||
|
||||
go RunConfigModification()
|
||||
for !serverStarted {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
checkServerStatus(t, true)
|
||||
|
||||
username = "test"
|
||||
password = "testpw"
|
||||
|
||||
@@ -434,14 +438,7 @@ func TestIntegration(t *testing.T) {
|
||||
Body: strings.NewReader(setupInput.toJson()),
|
||||
})
|
||||
|
||||
counter = 0
|
||||
for serverStarted {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
counter++
|
||||
if counter > 100 {
|
||||
t.Fatal("Unbroken loop")
|
||||
}
|
||||
}
|
||||
checkServerStatus(t, false)
|
||||
|
||||
test.IsEqualBool(t, settings.PicturesAlwaysLocal, true)
|
||||
test.IsEqualString(t, settings.Authentication.OauthProvider, "provider")
|
||||
@@ -453,6 +450,7 @@ func TestIntegration(t *testing.T) {
|
||||
test.IsEqualString(t, settings.Authentication.OauthUsers[0], "oatest1")
|
||||
test.IsEqualString(t, settings.Authentication.OauthUsers[1], "oatest2")
|
||||
}
|
||||
statusChannel = nil
|
||||
}
|
||||
|
||||
type setupValues struct {
|
||||
@@ -554,10 +552,6 @@ func createInvalidSetupValues() []setupValues {
|
||||
invalidSetup.EncryptionLevel.Value = "9"
|
||||
result = append(result, invalidSetup)
|
||||
|
||||
invalidSetup = input
|
||||
invalidSetup.EncryptionLevel.Value = "5" // e2e not implemented yet
|
||||
result = append(result, invalidSetup)
|
||||
|
||||
invalidSetup = input
|
||||
invalidSetup.EncryptionLevel.Value = "4"
|
||||
invalidSetup.EncryptionPassword.Value = "2shrt"
|
||||
|
||||
@@ -179,4 +179,14 @@ li.wizard-nav-item {
|
||||
|
||||
.wizard-dialog .popover.error-popover .arrow {
|
||||
border-right-color:#953B39;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
input[type=checkbox] {
|
||||
/* Double-sized Checkboxes */
|
||||
-ms-transform: scale(1.5); /* IE */
|
||||
-moz-transform: scale(1.5); /* FF */
|
||||
-webkit-transform: scale(1.5); /* Safari and Chrome */
|
||||
-o-transform: scale(1.5); /* Opera */
|
||||
transform: scale(1.5);
|
||||
}
|
||||
|
||||
@@ -400,9 +400,10 @@ function TestAWS(button) {
|
||||
<option value="2" >Level 1 - Local encryption / Key on startup</option>
|
||||
<option value="3" >Level 2 - Full encryption / Key locally</option>
|
||||
<option value="4" >Level 2 - Full encryption / Key on startup</option>
|
||||
<!-- <option value="5" disabled>Level 3 - End-To-End encryption (not implemented yet)</option> -->
|
||||
<option value="5" >Level 3 - End-To-End encryption</option>
|
||||
</select>
|
||||
|
||||
|
||||
<div id="encinfo0">
|
||||
<p><br><br><b>Level 0 - No encryption</b>
|
||||
<ul>
|
||||
@@ -476,17 +477,26 @@ function TestAWS(button) {
|
||||
|
||||
|
||||
<div id="encinfo5">
|
||||
<p><br><br><b>Level 3 - NOT IMPLEMENTED YET</b>
|
||||
<p><br><br><b>Level 3 - End-To-End encryption</b>
|
||||
<ul>
|
||||
<li>All files are encrypted end-to-end</li>
|
||||
<li>Encryption and decryption is done client-side and requires 2MB file to load on first visit</li>
|
||||
<li>Does not support hotlinks to files</li>
|
||||
<li>Does not support download progress bar</li>
|
||||
<li>Gokapi starts without user input</li>
|
||||
<li>Files uploaded through the API have to be unencrypted</li>
|
||||
<li>Password cannot be read with access to Gokapi configuration</li>
|
||||
<li><b>Warning:</b> During upload temporary files containing the plaintext content may be created.</li>
|
||||
<li><b>Warning:</b> Encryption keys are stored in plain-text on this machine</li>
|
||||
<li><b>Warning:</b> Encryption has not been audited.</li>
|
||||
</ul></p>
|
||||
|
||||
{{ if not .IsInitialSetup }}
|
||||
<div id="e2eclear"><br>
|
||||
<input name="cleare2e" type="checkbox" value="true"/>
|
||||
<span> Reset end-to-end password (you will lose access to already encrypted files)</span>
|
||||
</div>
|
||||
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -55,7 +55,7 @@ func Init(config models.Configuration) {
|
||||
case FullEncryptionInput:
|
||||
initWithPassword(config.Encryption.Salt, config.Encryption.Checksum, config.Encryption.ChecksumSalt)
|
||||
case EndToEndEncryption:
|
||||
// TODO
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,14 +128,14 @@ func storeMasterKey(cipherKey []byte) {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
encryptedKey, err = encryptDecryptText(cipherKey, ramCipher, make([]byte, nonceSize), true)
|
||||
encryptedKey, err = EncryptDecryptBytes(cipherKey, ramCipher, make([]byte, nonceSize), true)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func getMasterCipher() []byte {
|
||||
key, err := encryptDecryptText(encryptedKey, ramCipher, make([]byte, nonceSize), false)
|
||||
key, err := EncryptDecryptBytes(encryptedKey, ramCipher, make([]byte, nonceSize), false)
|
||||
if err != nil {
|
||||
key = []byte{}
|
||||
log.Fatal(err)
|
||||
@@ -207,6 +207,14 @@ func GetEncryptReader(cipherKey []byte, input io.Reader) (io.Reader, error) {
|
||||
return stream.EncryptReader(input, nonce, nil), nil
|
||||
}
|
||||
|
||||
// GetEncryptWriter returns a writer that can encrypt plain files
|
||||
func GetEncryptWriter(cipherKey []byte, input io.Writer) (*sio.EncWriter, error) {
|
||||
stream := getStream(cipherKey)
|
||||
nonce := make([]byte, stream.NonceSize()) // Nonce is not used
|
||||
return stream.EncryptWriter(input, nonce, nil), nil
|
||||
|
||||
}
|
||||
|
||||
func generateNewFileKey(encInfo *models.EncryptionInfo) ([]byte, error) {
|
||||
encryptionKey, err := getRandomData(blockSize)
|
||||
if err != nil {
|
||||
@@ -226,6 +234,11 @@ func generateNewFileKey(encInfo *models.EncryptionInfo) ([]byte, error) {
|
||||
return encryptionKey, nil
|
||||
}
|
||||
|
||||
// CalculateEncryptedFilesize returns the filesize of the encrypted file including the encryption overhead
|
||||
func CalculateEncryptedFilesize(size int64) int64 {
|
||||
return size + getStream(make([]byte, blockSize)).Overhead(size)
|
||||
}
|
||||
|
||||
// GetCipherFromFile loads the cipher from a file model
|
||||
func GetCipherFromFile(encInfo models.EncryptionInfo) ([]byte, error) {
|
||||
cipherFile, err := fileCipherDecrypt(encInfo.DecryptionKey, encInfo.Nonce)
|
||||
@@ -252,13 +265,14 @@ func getStream(cipherKey []byte) *sio.Stream {
|
||||
}
|
||||
|
||||
func fileCipherEncrypt(input, nonce []byte) ([]byte, error) {
|
||||
return encryptDecryptText(input, getMasterCipher(), nonce, true)
|
||||
return EncryptDecryptBytes(input, getMasterCipher(), nonce, true)
|
||||
}
|
||||
func fileCipherDecrypt(input, nonce []byte) ([]byte, error) {
|
||||
return encryptDecryptText(input, getMasterCipher(), nonce, false)
|
||||
return EncryptDecryptBytes(input, getMasterCipher(), nonce, false)
|
||||
}
|
||||
|
||||
func encryptDecryptText(input, cipherBlock, nonce []byte, doEncrypt bool) ([]byte, error) {
|
||||
// EncryptDecryptBytes encrypts or decrypts a byte array
|
||||
func EncryptDecryptBytes(input, cipherBlock, nonce []byte, doEncrypt bool) ([]byte, error) {
|
||||
block, err := aes.NewCipher(cipherBlock)
|
||||
if err != nil {
|
||||
return []byte{}, err
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package end2end
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/gob"
|
||||
"github.com/forceu/gokapi/internal/encryption"
|
||||
"github.com/forceu/gokapi/internal/helper"
|
||||
"github.com/forceu/gokapi/internal/models"
|
||||
)
|
||||
|
||||
const e2eVersion = 1
|
||||
|
||||
// EncryptData encrypts the locally stored e2e data to save on the server
|
||||
func EncryptData(files []models.E2EFile, key []byte) (models.E2EInfoEncrypted, error) {
|
||||
nonce, err := encryption.GetRandomNonce()
|
||||
if err != nil {
|
||||
return models.E2EInfoEncrypted{}, err
|
||||
}
|
||||
result := models.E2EInfoEncrypted{
|
||||
Nonce: nonce,
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
enc := gob.NewEncoder(&buf)
|
||||
err = enc.Encode(files)
|
||||
helper.Check(err)
|
||||
|
||||
encryptedResult, err := encryption.EncryptDecryptBytes(buf.Bytes(), key, nonce, true)
|
||||
if err != nil {
|
||||
return models.E2EInfoEncrypted{}, err
|
||||
}
|
||||
result.Content = encryptedResult
|
||||
result.Version = e2eVersion
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DecryptData decrypts the e2e data stored on the server
|
||||
func DecryptData(encryptedContent models.E2EInfoEncrypted, key []byte) (models.E2EInfoPlainText, error) {
|
||||
result, err := encryption.EncryptDecryptBytes(encryptedContent.Content, key, encryptedContent.Nonce, false)
|
||||
if err != nil {
|
||||
return models.E2EInfoPlainText{}, err
|
||||
}
|
||||
|
||||
var fileData []models.E2EFile
|
||||
buf := bytes.NewBuffer(result)
|
||||
dec := gob.NewDecoder(buf)
|
||||
err = dec.Decode(&fileData)
|
||||
helper.Check(err)
|
||||
return models.E2EInfoPlainText{
|
||||
Files: fileData,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package end2end
|
||||
|
||||
import (
|
||||
"github.com/forceu/gokapi/internal/encryption"
|
||||
"github.com/forceu/gokapi/internal/models"
|
||||
"github.com/forceu/gokapi/internal/test"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEncrypting(t *testing.T) {
|
||||
cipherEncryption, err := encryption.GetRandomCipher()
|
||||
test.IsNil(t, err)
|
||||
cipherF1, err := encryption.GetRandomCipher()
|
||||
test.IsNil(t, err)
|
||||
cipherF2, err := encryption.GetRandomCipher()
|
||||
test.IsNil(t, err)
|
||||
|
||||
files := make([]models.E2EFile, 2)
|
||||
files = append(files, models.E2EFile{
|
||||
Uuid: "1234",
|
||||
Id: "id123",
|
||||
Filename: "testfile",
|
||||
Cipher: cipherF1,
|
||||
})
|
||||
files = append(files, models.E2EFile{
|
||||
Uuid: "5678",
|
||||
Id: "id5567",
|
||||
Filename: "testfile2",
|
||||
Cipher: cipherF2,
|
||||
})
|
||||
|
||||
encryptedFiles, err := EncryptData(files, cipherEncryption)
|
||||
test.IsNil(t, err)
|
||||
test.IsEqualBool(t, len(encryptedFiles.Content) > 0, true)
|
||||
test.IsEqualInt(t, encryptedFiles.Version, 1)
|
||||
test.IsEqualBool(t, len(encryptedFiles.Nonce) > 0, true)
|
||||
|
||||
decryptedFiles, err := DecryptData(encryptedFiles, cipherEncryption)
|
||||
test.IsNil(t, err)
|
||||
test.IsEqualBool(t, reflect.DeepEqual(files, decryptedFiles.Files), true)
|
||||
|
||||
}
|
||||
@@ -41,15 +41,18 @@ func generateRandomBytes(n int) ([]byte, error) {
|
||||
|
||||
// GenerateRandomString returns a URL-safe, base64 encoded securely generated random string.
|
||||
func GenerateRandomString(length int) string {
|
||||
b, err := generateRandomBytes(length)
|
||||
b, err := generateRandomBytes(length + 10)
|
||||
if err != nil {
|
||||
return generateUnsafeId(length)
|
||||
}
|
||||
result := cleanRandomString(base64.URLEncoding.EncodeToString(b))
|
||||
if len(result) < length {
|
||||
return GenerateRandomString(length)
|
||||
}
|
||||
return result[:length]
|
||||
}
|
||||
|
||||
// ByteCountSI converts bytes to a human readable format
|
||||
// ByteCountSI converts bytes to a human-readable format
|
||||
func ByteCountSI(b int64) string {
|
||||
const unit = 1024
|
||||
if b < unit {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package models
|
||||
|
||||
// E2EInfoPlainText is stored locally and will be encrypted before storing on server
|
||||
type E2EInfoPlainText struct {
|
||||
Files []E2EFile `json:"files"`
|
||||
}
|
||||
|
||||
// E2EInfoEncrypted is the struct that is stored on the server and decrypted locally
|
||||
type E2EInfoEncrypted struct {
|
||||
Version int `json:"version"`
|
||||
Nonce []byte `json:"nonce"`
|
||||
Content []byte `json:"content"`
|
||||
AvailableFiles []string `json:"availablefiles"`
|
||||
}
|
||||
|
||||
// HasBeenSetUp returns true if E2E setup has been run
|
||||
func (e *E2EInfoEncrypted) HasBeenSetUp() bool {
|
||||
return e.Version != 0 && len(e.Content) != 0
|
||||
}
|
||||
|
||||
// E2EFile contains information about a stored e2e file
|
||||
type E2EFile struct {
|
||||
Uuid string `json:"uuid"`
|
||||
Id string `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
Cipher []byte `json:"cipher"`
|
||||
}
|
||||
|
||||
// E2EHashContent contains the info that is added after the hash for an e2e link
|
||||
type E2EHashContent struct {
|
||||
Filename string `json:"f"`
|
||||
Cipher string `json:"c"`
|
||||
}
|
||||
+25
-22
@@ -8,22 +8,21 @@ import (
|
||||
|
||||
// File is a struct used for saving information about an uploaded file
|
||||
type File struct {
|
||||
Id string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
Size string `json:"Size"`
|
||||
SHA1 string `json:"SHA1"`
|
||||
ExpireAt int64 `json:"ExpireAt"`
|
||||
ExpireAtString string `json:"ExpireAtString"`
|
||||
DownloadsRemaining int `json:"DownloadsRemaining"`
|
||||
DownloadCount int `json:"DownloadCount"`
|
||||
PasswordHash string `json:"PasswordHash"`
|
||||
HotlinkId string `json:"HotlinkId"`
|
||||
ContentType string `json:"ContentType"`
|
||||
AwsBucket string `json:"AwsBucket"`
|
||||
Encryption EncryptionInfo `json:"Encryption"`
|
||||
UnlimitedDownloads bool `json:"UnlimitedDownloads"`
|
||||
UnlimitedTime bool `json:"UnlimitedTime"`
|
||||
RequiresClientSideDecryption bool `json:"RequiresClientSideDecryption"`
|
||||
Id string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
Size string `json:"Size"`
|
||||
SHA1 string `json:"SHA1"`
|
||||
ExpireAt int64 `json:"ExpireAt"`
|
||||
ExpireAtString string `json:"ExpireAtString"`
|
||||
DownloadsRemaining int `json:"DownloadsRemaining"`
|
||||
DownloadCount int `json:"DownloadCount"`
|
||||
PasswordHash string `json:"PasswordHash"`
|
||||
HotlinkId string `json:"HotlinkId"`
|
||||
ContentType string `json:"ContentType"`
|
||||
AwsBucket string `json:"AwsBucket"`
|
||||
Encryption EncryptionInfo `json:"Encryption"`
|
||||
UnlimitedDownloads bool `json:"UnlimitedDownloads"`
|
||||
UnlimitedTime bool `json:"UnlimitedTime"`
|
||||
}
|
||||
|
||||
// FileApiOutput will be displayed for public outputs from the ID, hiding sensitive information
|
||||
@@ -47,9 +46,10 @@ type FileApiOutput struct {
|
||||
|
||||
// EncryptionInfo holds information about the encryption used on the file
|
||||
type EncryptionInfo struct {
|
||||
IsEncrypted bool `json:"IsEncrypted"`
|
||||
DecryptionKey []byte `json:"DecryptionKey"`
|
||||
Nonce []byte `json:"Nonce"`
|
||||
IsEncrypted bool `json:"IsEncrypted"`
|
||||
IsEndToEndEncrypted bool `json:"IsEndToEndEncrypted"`
|
||||
DecryptionKey []byte `json:"DecryptionKey"`
|
||||
Nonce []byte `json:"Nonce"`
|
||||
}
|
||||
|
||||
// IsLocalStorage returns true if the file is not stored on a remote storage
|
||||
@@ -58,7 +58,7 @@ func (f *File) IsLocalStorage() bool {
|
||||
}
|
||||
|
||||
// ToFileApiOutput returns a json object without sensitive information
|
||||
func (f *File) ToFileApiOutput() (FileApiOutput, error) {
|
||||
func (f *File) ToFileApiOutput(isClientSideDecryption bool) (FileApiOutput, error) {
|
||||
var result FileApiOutput
|
||||
err := copier.Copy(&result, &f)
|
||||
if err != nil {
|
||||
@@ -67,12 +67,15 @@ func (f *File) ToFileApiOutput() (FileApiOutput, error) {
|
||||
result.IsPasswordProtected = f.PasswordHash != ""
|
||||
result.IsEncrypted = f.Encryption.IsEncrypted
|
||||
result.IsSavedOnLocalStorage = f.AwsBucket == ""
|
||||
if f.Encryption.IsEndToEndEncrypted || isClientSideDecryption {
|
||||
result.RequiresClientSideDecryption = true
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ToJsonResult converts the file info to a json String used for returning a result for an upload
|
||||
func (f *File) ToJsonResult(serverUrl string) string {
|
||||
info, err := f.ToFileApiOutput()
|
||||
func (f *File) ToJsonResult(serverUrl string, isClientSideDecryption bool) string {
|
||||
info, err := f.ToFileApiOutput(isClientSideDecryption)
|
||||
if err != nil {
|
||||
return errorAsJson(err)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestToJsonResult(t *testing.T) {
|
||||
UnlimitedDownloads: true,
|
||||
UnlimitedTime: true,
|
||||
}
|
||||
test.IsEqualString(t, file.ToJsonResult("serverurl/"), `{"Result":"OK","FileInfo":{"Id":"testId","Name":"testName","Size":"10 B","HotlinkId":"hotlinkid","ContentType":"text/html","ExpireAt":50,"ExpireAtString":"future","DownloadsRemaining":1,"DownloadCount":3,"UnlimitedDownloads":true,"UnlimitedTime":true,"RequiresClientSideDecryption":false,"IsEncrypted":true,"IsPasswordProtected":true,"IsSavedOnLocalStorage":false},"Url":"serverurl/d?id=","HotlinkUrl":"serverurl/hotlink/","GenericHotlinkUrl":"serverurl/downloadFile?id="}`)
|
||||
test.IsEqualString(t, file.ToJsonResult("serverurl/", true), `{"Result":"OK","FileInfo":{"Id":"testId","Name":"testName","Size":"10 B","HotlinkId":"hotlinkid","ContentType":"text/html","ExpireAt":50,"ExpireAtString":"future","DownloadsRemaining":1,"DownloadCount":3,"UnlimitedDownloads":true,"UnlimitedTime":true,"RequiresClientSideDecryption":true,"IsEncrypted":true,"IsPasswordProtected":true,"IsSavedOnLocalStorage":false},"Url":"serverurl/d?id=","HotlinkUrl":"serverurl/hotlink/","GenericHotlinkUrl":"serverurl/downloadFile?id="}`)
|
||||
}
|
||||
|
||||
func TestIsLocalStorage(t *testing.T) {
|
||||
|
||||
@@ -2,12 +2,14 @@ package models
|
||||
|
||||
// UploadRequest is used to set an upload request
|
||||
type UploadRequest struct {
|
||||
AllowedDownloads int
|
||||
Expiry int
|
||||
ExpiryTimestamp int64
|
||||
Password string
|
||||
ExternalUrl string
|
||||
MaxMemory int
|
||||
UnlimitedDownload bool
|
||||
UnlimitedTime bool
|
||||
AllowedDownloads int
|
||||
Expiry int
|
||||
ExpiryTimestamp int64
|
||||
Password string
|
||||
ExternalUrl string
|
||||
MaxMemory int
|
||||
UnlimitedDownload bool
|
||||
UnlimitedTime bool
|
||||
IsEndToEndEncrypted bool
|
||||
RealSize int64
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
"github.com/forceu/gokapi/internal/webserver/downloadstatus"
|
||||
"github.com/jinzhu/copier"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
@@ -43,7 +42,7 @@ func NewFile(fileContent io.Reader, fileHeader *multipart.FileHeader, uploadRequ
|
||||
return models.File{}, ErrorFileTooLarge
|
||||
}
|
||||
var hasBeenRenamed bool
|
||||
reader, hash, tempFile, encInfo := generateHash(fileContent, fileHeader)
|
||||
reader, hash, tempFile, encInfo := generateHashAndEncrypt(fileContent, fileHeader)
|
||||
defer deleteTempFile(tempFile, &hasBeenRenamed)
|
||||
header, err := chunking.ParseMultipartHeader(fileHeader)
|
||||
if err != nil {
|
||||
@@ -129,7 +128,6 @@ func NewFileFromChunk(chunkId string, fileHeader chunking.FileHeader, uploadRequ
|
||||
return models.File{}, errors.New("empty chunk id provided")
|
||||
}
|
||||
if !helper.FileExists(configuration.Get().DataDir + "/chunk-" + chunkId) {
|
||||
time.Sleep(1 * time.Second)
|
||||
return models.File{}, errors.New("chunk file does not exist")
|
||||
}
|
||||
file, err := chunking.GetFileByChunkId(chunkId)
|
||||
@@ -142,10 +140,15 @@ func NewFileFromChunk(chunkId string, fileHeader chunking.FileHeader, uploadRequ
|
||||
return models.File{}, err
|
||||
}
|
||||
|
||||
hash, err := hashFile(file, isEncryptionRequested())
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return models.File{}, err
|
||||
var hash string
|
||||
if uploadRequest.IsEndToEndEncrypted {
|
||||
hash = "e2e-" + helper.GenerateRandomString(20)
|
||||
} else {
|
||||
hash, err = hashFile(file, isEncryptionRequested())
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return models.File{}, err
|
||||
}
|
||||
}
|
||||
|
||||
metaData := createNewMetaData(hash, fileHeader, uploadRequest)
|
||||
@@ -272,6 +275,10 @@ func createNewMetaData(hash string, fileHeader chunking.FileHeader, uploadReques
|
||||
UnlimitedDownloads: uploadRequest.UnlimitedDownload,
|
||||
PasswordHash: configuration.HashPassword(uploadRequest.Password, true),
|
||||
}
|
||||
if uploadRequest.IsEndToEndEncrypted {
|
||||
file.Encryption = models.EncryptionInfo{IsEndToEndEncrypted: true, IsEncrypted: true}
|
||||
file.Size = helper.ByteCountSI(uploadRequest.RealSize)
|
||||
}
|
||||
if aws.IsAvailable() {
|
||||
if !configuration.Get().PicturesAlwaysLocal || !isPictureFile(file.Name) {
|
||||
aws.AddBucketName(&file)
|
||||
@@ -380,11 +387,11 @@ func hashFile(input io.Reader, useSalt bool) (string, error) {
|
||||
|
||||
// Generates the SHA1 hash of an uploaded file and returns a reader for the file, the hash and if a temporary file was created the
|
||||
// reference to that file.
|
||||
func generateHash(fileContent io.Reader, fileHeader *multipart.FileHeader) (io.Reader, []byte, *os.File, models.EncryptionInfo) {
|
||||
func generateHashAndEncrypt(fileContent io.Reader, fileHeader *multipart.FileHeader) (io.Reader, []byte, *os.File, models.EncryptionInfo) {
|
||||
hash := sha1.New()
|
||||
encInfo := models.EncryptionInfo{}
|
||||
if fileHeader.Size <= int64(configuration.Get().MaxMemory)*1024*1024 {
|
||||
content, err := ioutil.ReadAll(fileContent)
|
||||
content, err := io.ReadAll(fileContent)
|
||||
helper.Check(err)
|
||||
hash.Write(content)
|
||||
if isEncryptionRequested() {
|
||||
@@ -431,6 +438,8 @@ func isEncryptionRequested() bool {
|
||||
fallthrough
|
||||
case encryption.FullEncryptionInput:
|
||||
return true
|
||||
case encryption.EndToEndEncryption:
|
||||
return false
|
||||
default:
|
||||
log.Fatalln("Unknown encryption level requested")
|
||||
return false
|
||||
@@ -501,7 +510,7 @@ func RequiresClientDecryption(file models.File) bool {
|
||||
if !file.Encryption.IsEncrypted {
|
||||
return false
|
||||
}
|
||||
return !file.IsLocalStorage()
|
||||
return !file.IsLocalStorage() || file.Encryption.IsEndToEndEncrypted
|
||||
}
|
||||
|
||||
// ServeFile subtracts a download allowance and serves the file to the browser
|
||||
|
||||
@@ -548,7 +548,7 @@ func TestServeFile(t *testing.T) {
|
||||
test.IsEqualString(t, w.Result().Header.Get("Content-Disposition"), "attachment; filename=\"test.dat\"")
|
||||
test.IsEqualString(t, w.Result().Header.Get("Content-Length"), "35")
|
||||
test.IsEqualString(t, w.Result().Header.Get("Content-Type"), "text/plain")
|
||||
content, err := ioutil.ReadAll(w.Result().Body)
|
||||
content, err := io.ReadAll(w.Result().Body)
|
||||
test.IsNil(t, err)
|
||||
test.IsEqualString(t, string(content), "This is a file for testing purposes")
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ package test
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -262,7 +261,7 @@ func checkResponse(t MockT, response *http.Response, config HttpTestConfig) {
|
||||
t.Errorf("Status Code - Got: %d Want: %d", config.ResultCode, response.StatusCode)
|
||||
}
|
||||
|
||||
content, err := ioutil.ReadAll(response.Body)
|
||||
content, err := io.ReadAll(response.Body)
|
||||
IsNil(t, err)
|
||||
if config.IsHtml && !bytes.Contains(content, []byte("</html>")) {
|
||||
t.Errorf(config.Url + ": Incorrect response, no HTML tag")
|
||||
|
||||
+130
-17
@@ -8,6 +8,8 @@ import (
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/NYTimes/gziphandler"
|
||||
"github.com/forceu/gokapi/internal/configuration"
|
||||
@@ -34,21 +36,29 @@ import (
|
||||
)
|
||||
|
||||
// TODO add 404 handler
|
||||
|
||||
// staticFolderEmbedded is the embedded version of the "static" folder
|
||||
// This contains JS files, CSS, images etc
|
||||
//
|
||||
//go:embed web/static
|
||||
var staticFolderEmbedded embed.FS
|
||||
|
||||
// templateFolderEmbedded is the embedded version of the "templates" folder
|
||||
// This contains templates that Gokapi uses for creating the HTML output
|
||||
//
|
||||
//go:embed web/templates
|
||||
var templateFolderEmbedded embed.FS
|
||||
|
||||
// wasmFile is the compiled binary of the wasm downloader
|
||||
// wasmDownloadFile is the compiled binary of the wasm downloader
|
||||
// Will be generated with go generate ./...
|
||||
//
|
||||
//go:embed web/main.wasm
|
||||
var wasmFile embed.FS
|
||||
var wasmDownloadFile embed.FS
|
||||
|
||||
// wasmE2EFile is the compiled binary of the wasm e2e encrypter
|
||||
// Will be generated with go generate ./...
|
||||
//
|
||||
//go:embed web/e2e.wasm
|
||||
var wasmE2EFile embed.FS
|
||||
|
||||
const timeOutWebserverRead = 15 * time.Minute
|
||||
const timeOutWebserverWrite = 12 * time.Hour
|
||||
@@ -88,6 +98,8 @@ func Start() {
|
||||
mux.HandleFunc("/d", showDownload)
|
||||
mux.HandleFunc("/delete", requireLogin(deleteFile, false))
|
||||
mux.HandleFunc("/downloadFile", downloadFile)
|
||||
mux.HandleFunc("/e2eInfo", requireLogin(e2eInfo, true))
|
||||
mux.HandleFunc("/e2eSetup", requireLogin(showE2ESetup, false))
|
||||
mux.HandleFunc("/error", showError)
|
||||
mux.HandleFunc("/forgotpw", forgotPassword)
|
||||
mux.HandleFunc("/hotlink/", showHotlink)
|
||||
@@ -97,7 +109,8 @@ func Start() {
|
||||
mux.HandleFunc("/uploadChunk", requireLogin(uploadChunk, true))
|
||||
mux.HandleFunc("/uploadComplete", requireLogin(uploadComplete, true))
|
||||
mux.HandleFunc("/error-auth", showErrorAuth)
|
||||
mux.Handle("/main.wasm", gziphandler.GzipHandler(http.HandlerFunc(serveWasm)))
|
||||
mux.Handle("/main.wasm", gziphandler.GzipHandler(http.HandlerFunc(serveDownloadWasm)))
|
||||
mux.Handle("/e2e.wasm", gziphandler.GzipHandler(http.HandlerFunc(serveE2EWasm)))
|
||||
if configuration.Get().Authentication.Method == authentication.OAuth2 {
|
||||
oauth.Init(configuration.Get().ServerUrl, configuration.Get().Authentication)
|
||||
mux.HandleFunc("/oauth-login", oauth.HandlerLogin)
|
||||
@@ -164,10 +177,19 @@ func redirect(w http.ResponseWriter, url string) {
|
||||
}
|
||||
|
||||
// Handling of /main.wasm
|
||||
func serveWasm(w http.ResponseWriter, r *http.Request) {
|
||||
func serveDownloadWasm(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Cache-Control", "public, max-age=100800") // 2 days
|
||||
w.Header().Set("content-type", "application/wasm")
|
||||
file, err := wasmFile.ReadFile("web/main.wasm")
|
||||
file, err := wasmDownloadFile.ReadFile("web/main.wasm")
|
||||
helper.Check(err)
|
||||
w.Write(file)
|
||||
}
|
||||
|
||||
// Handling of /e2e.wasm
|
||||
func serveE2EWasm(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Cache-Control", "public, max-age=100800") // 2 days
|
||||
w.Header().Set("content-type", "application/wasm")
|
||||
file, err := wasmE2EFile.ReadFile("web/e2e.wasm")
|
||||
helper.Check(err)
|
||||
w.Write(file)
|
||||
}
|
||||
@@ -185,7 +207,18 @@ func showIndex(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Handling of /error
|
||||
func showError(w http.ResponseWriter, r *http.Request) {
|
||||
err := templateFolder.ExecuteTemplate(w, "error", genericView{})
|
||||
const invalidFile = 0
|
||||
const noCipherSupplied = 1
|
||||
const wrongCipher = 2
|
||||
|
||||
errorReason := invalidFile
|
||||
if r.URL.Query().Has("e2e") {
|
||||
errorReason = noCipherSupplied
|
||||
}
|
||||
if r.URL.Query().Has("key") {
|
||||
errorReason = wrongCipher
|
||||
}
|
||||
err := templateFolder.ExecuteTemplate(w, "error", genericView{ErrorId: errorReason})
|
||||
helper.Check(err)
|
||||
}
|
||||
|
||||
@@ -284,22 +317,25 @@ func showDownload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
view := DownloadView{
|
||||
Name: file.Name,
|
||||
Size: file.Size,
|
||||
Id: file.Id,
|
||||
IsFailedLogin: false,
|
||||
UsesHttps: configuration.UsesHttps(),
|
||||
Name: file.Name,
|
||||
Size: file.Size,
|
||||
Id: file.Id,
|
||||
EndToEndEncryption: file.Encryption.IsEndToEndEncrypted,
|
||||
IsFailedLogin: false,
|
||||
UsesHttps: configuration.UsesHttps(),
|
||||
}
|
||||
|
||||
if storage.RequiresClientDecryption(file) {
|
||||
view.ClientSideDecryption = true
|
||||
cipher, err := encryption.GetCipherFromFile(file.Encryption)
|
||||
helper.Check(err)
|
||||
view.Cipher = base64.StdEncoding.EncodeToString(cipher)
|
||||
if !file.Encryption.IsEndToEndEncrypted {
|
||||
cipher, err := encryption.GetCipherFromFile(file.Encryption)
|
||||
helper.Check(err)
|
||||
view.Cipher = base64.StdEncoding.EncodeToString(cipher)
|
||||
}
|
||||
}
|
||||
|
||||
if file.PasswordHash != "" {
|
||||
r.ParseForm()
|
||||
_ = r.ParseForm()
|
||||
enteredPassword := r.Form.Get("password")
|
||||
if configuration.HashPassword(enteredPassword, true) != file.PasswordHash && !isValidPwCookie(r, file) {
|
||||
if enteredPassword != "" {
|
||||
@@ -338,6 +374,57 @@ func showHotlink(w http.ResponseWriter, r *http.Request) {
|
||||
storage.ServeFile(file, w, r, false)
|
||||
}
|
||||
|
||||
// Handling of /e2eInfo
|
||||
// User needs to be admin. Receives or stores end2end encryption info
|
||||
func e2eInfo(w http.ResponseWriter, r *http.Request) {
|
||||
action, ok := r.URL.Query()["action"]
|
||||
if !ok || len(action) < 1 {
|
||||
responseError(w, errors.New("invalid action specified"))
|
||||
return
|
||||
}
|
||||
switch action[0] {
|
||||
case "get":
|
||||
getE2eInfo(w)
|
||||
case "store":
|
||||
storeE2eInfo(w, r)
|
||||
default:
|
||||
responseError(w, errors.New("invalid action specified"))
|
||||
}
|
||||
}
|
||||
|
||||
func storeE2eInfo(w http.ResponseWriter, r *http.Request) {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
responseError(w, err)
|
||||
return
|
||||
}
|
||||
uploadedInfoBase64 := r.Form.Get("info")
|
||||
if uploadedInfoBase64 == "" {
|
||||
responseError(w, errors.New("empty info sent"))
|
||||
return
|
||||
}
|
||||
uploadedInfo, err := base64.StdEncoding.DecodeString(uploadedInfoBase64)
|
||||
if err != nil {
|
||||
responseError(w, err)
|
||||
return
|
||||
}
|
||||
var info models.E2EInfoEncrypted
|
||||
err = json.Unmarshal(uploadedInfo, &info)
|
||||
if err != nil {
|
||||
responseError(w, err)
|
||||
return
|
||||
}
|
||||
database.SaveEnd2EndInfo(info)
|
||||
_, _ = w.Write([]byte("\"result\":\"OK\""))
|
||||
}
|
||||
|
||||
func getE2eInfo(w http.ResponseWriter) {
|
||||
info := database.GetEnd2EndInfo()
|
||||
bytes, err := json.Marshal(info)
|
||||
helper.Check(err)
|
||||
_, _ = w.Write(bytes)
|
||||
}
|
||||
|
||||
// Handling of /delete
|
||||
// User needs to be admin. Deletes the requested file
|
||||
func deleteFile(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -366,10 +453,27 @@ func queryUrl(w http.ResponseWriter, r *http.Request, redirectUrl string) string
|
||||
// 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 configuration.Get().Encryption.Level == encryption.EndToEndEncryption {
|
||||
e2einfo := database.GetEnd2EndInfo()
|
||||
if !e2einfo.HasBeenSetUp() {
|
||||
redirect(w, "e2eSetup")
|
||||
return
|
||||
}
|
||||
}
|
||||
err := templateFolder.ExecuteTemplate(w, "admin", (&UploadView{}).convertGlobalConfig(true))
|
||||
helper.Check(err)
|
||||
}
|
||||
|
||||
func showE2ESetup(w http.ResponseWriter, r *http.Request) {
|
||||
if configuration.Get().Encryption.Level != encryption.EndToEndEncryption {
|
||||
redirect(w, "admin")
|
||||
return
|
||||
}
|
||||
e2einfo := database.GetEnd2EndInfo()
|
||||
err := templateFolder.ExecuteTemplate(w, "e2esetup", e2ESetupView{HasBeenSetup: e2einfo.HasBeenSetUp()})
|
||||
helper.Check(err)
|
||||
}
|
||||
|
||||
// DownloadView contains parameters for the download template
|
||||
type DownloadView struct {
|
||||
Name string
|
||||
@@ -378,10 +482,16 @@ type DownloadView struct {
|
||||
IsFailedLogin bool
|
||||
IsAdminView bool
|
||||
ClientSideDecryption bool
|
||||
EndToEndEncryption bool
|
||||
UsesHttps bool
|
||||
Cipher string
|
||||
}
|
||||
|
||||
type e2ESetupView struct {
|
||||
IsAdminView bool
|
||||
HasBeenSetup bool
|
||||
}
|
||||
|
||||
// UploadView contains parameters for the admin menu template
|
||||
type UploadView struct {
|
||||
Items []models.FileApiOutput
|
||||
@@ -400,6 +510,7 @@ type UploadView struct {
|
||||
DefaultPassword string
|
||||
DefaultUnlimitedDownload bool
|
||||
DefaultUnlimitedTime bool
|
||||
EndToEndEncryption bool
|
||||
}
|
||||
|
||||
// Converts the globalConfig variable to an UploadView struct to pass the infos to
|
||||
@@ -409,7 +520,7 @@ func (u *UploadView) convertGlobalConfig(isMainView bool) *UploadView {
|
||||
var resultApi []models.ApiKey
|
||||
if isMainView {
|
||||
for _, element := range database.GetAllMetadata() {
|
||||
fileInfo, err := element.ToFileApiOutput()
|
||||
fileInfo, err := element.ToFileApiOutput(storage.RequiresClientDecryption(element))
|
||||
helper.Check(err)
|
||||
result = append(result, fileInfo)
|
||||
}
|
||||
@@ -451,6 +562,7 @@ func (u *UploadView) convertGlobalConfig(isMainView bool) *UploadView {
|
||||
u.DefaultPassword = defaultValues.Password
|
||||
u.DefaultUnlimitedDownload = defaultValues.UnlimitedDownload
|
||||
u.DefaultUnlimitedTime = defaultValues.UnlimitedTime
|
||||
u.EndToEndEncryption = configuration.Get().Encryption.Level == encryption.EndToEndEncryption
|
||||
return u
|
||||
}
|
||||
|
||||
@@ -546,4 +658,5 @@ func addNoCacheHeader(w http.ResponseWriter) {
|
||||
type genericView struct {
|
||||
IsAdminView bool
|
||||
RedirectUrl string
|
||||
ErrorId int
|
||||
}
|
||||
|
||||
@@ -220,14 +220,26 @@ func TestInvalidLink(t *testing.T) {
|
||||
IsHtml: true,
|
||||
})
|
||||
}
|
||||
|
||||
func TestError(t *testing.T) {
|
||||
t.Parallel()
|
||||
test.HttpPageResult(t, test.HttpTestConfig{
|
||||
Url: "http://localhost:53843/error",
|
||||
RequiredContent: []string{"this file cannot be found"},
|
||||
RequiredContent: []string{"Sorry, this file cannot be found"},
|
||||
IsHtml: true,
|
||||
})
|
||||
test.HttpPageResult(t, test.HttpTestConfig{
|
||||
Url: "http://localhost:53843/error?e2e",
|
||||
RequiredContent: []string{"This file is encrypted and no key has been passed"},
|
||||
IsHtml: true,
|
||||
})
|
||||
test.HttpPageResult(t, test.HttpTestConfig{
|
||||
Url: "http://localhost:53843/error?key",
|
||||
RequiredContent: []string{"This file is encrypted and an incorrect key has been passed"},
|
||||
IsHtml: true,
|
||||
})
|
||||
}
|
||||
|
||||
func TestForgotPw(t *testing.T) {
|
||||
t.Parallel()
|
||||
test.HttpPageResult(t, test.HttpTestConfig{
|
||||
@@ -236,6 +248,7 @@ func TestForgotPw(t *testing.T) {
|
||||
IsHtml: true,
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoginCorrect(t *testing.T) {
|
||||
t.Parallel()
|
||||
test.HttpPageResult(t, test.HttpTestConfig{
|
||||
@@ -698,10 +711,17 @@ func TestShowErrorAuth(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestServeWasm(t *testing.T) {
|
||||
func TestServeWasmDownloader(t *testing.T) {
|
||||
t.Parallel()
|
||||
test.HttpPageResult(t, test.HttpTestConfig{
|
||||
Url: "http://localhost:53843/main.wasm",
|
||||
IsHtml: false,
|
||||
})
|
||||
}
|
||||
func TestServeWasmE2E(t *testing.T) {
|
||||
t.Parallel()
|
||||
test.HttpPageResult(t, test.HttpTestConfig{
|
||||
Url: "http://localhost:53843/e2e.wasm",
|
||||
IsHtml: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ func list(w http.ResponseWriter) {
|
||||
timeNow := time.Now().Unix()
|
||||
for _, element := range database.GetAllMetadata() {
|
||||
if !storage.IsExpiredFile(element, timeNow) {
|
||||
file, err := element.ToFileApiOutput()
|
||||
file, err := element.ToFileApiOutput(storage.RequiresClientDecryption(element))
|
||||
helper.Check(err)
|
||||
validFiles = append(validFiles, file)
|
||||
}
|
||||
@@ -154,7 +154,7 @@ func duplicateFile(w http.ResponseWriter, request apiRequest) {
|
||||
sendError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
publicOutput, err := newFile.ToFileApiOutput()
|
||||
publicOutput, err := newFile.ToFileApiOutput(storage.RequiresClientDecryption(newFile))
|
||||
helper.Check(err)
|
||||
result, err := json.Marshal(publicOutput)
|
||||
helper.Check(err)
|
||||
|
||||
@@ -19,7 +19,10 @@ func Process(w http.ResponseWriter, r *http.Request, isWeb bool, maxMemory int)
|
||||
return err
|
||||
}
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
config := parseConfig(r.Form, isWeb)
|
||||
config, err := parseConfig(r.Form, isWeb)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -30,7 +33,7 @@ func Process(w http.ResponseWriter, r *http.Request, isWeb bool, maxMemory int)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = io.WriteString(w, result.ToJsonResult(config.ExternalUrl))
|
||||
_, _ = io.WriteString(w, result.ToJsonResult(config.ExternalUrl, storage.RequiresClientDecryption(result)))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -66,7 +69,10 @@ func CompleteChunk(w http.ResponseWriter, r *http.Request, isApiCall bool) error
|
||||
return err
|
||||
}
|
||||
chunkId := r.Form.Get("chunkid")
|
||||
config := parseConfig(r.Form, !isApiCall)
|
||||
config, err := parseConfig(r.Form, !isApiCall)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header, err := chunking.ParseFileHeader(r)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -76,11 +82,11 @@ func CompleteChunk(w http.ResponseWriter, r *http.Request, isApiCall bool) error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = io.WriteString(w, result.ToJsonResult(config.ExternalUrl))
|
||||
_, _ = io.WriteString(w, result.ToJsonResult(config.ExternalUrl, storage.RequiresClientDecryption(result)))
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseConfig(values formOrHeader, setNewDefaults bool) models.UploadRequest {
|
||||
func parseConfig(values formOrHeader, setNewDefaults bool) (models.UploadRequest, error) {
|
||||
allowedDownloads := values.Get("allowedDownloads")
|
||||
expiryDays := values.Get("expiryDays")
|
||||
password := values.Get("password")
|
||||
@@ -115,17 +121,29 @@ func parseConfig(values formOrHeader, setNewDefaults bool) models.UploadRequest
|
||||
}
|
||||
database.SaveUploadDefaults(values)
|
||||
}
|
||||
var isEnd2End bool
|
||||
var realSize int64
|
||||
if values.Get("isE2E") == "true" {
|
||||
isEnd2End = true
|
||||
realSizeStr := values.Get("realSize")
|
||||
realSize, err = strconv.ParseInt(realSizeStr, 10, 64)
|
||||
if err != nil {
|
||||
return models.UploadRequest{}, err
|
||||
}
|
||||
}
|
||||
settings := configuration.Get()
|
||||
return models.UploadRequest{
|
||||
AllowedDownloads: allowedDownloadsInt,
|
||||
Expiry: expiryDaysInt,
|
||||
ExpiryTimestamp: time.Now().Add(time.Duration(expiryDaysInt) * time.Hour * 24).Unix(),
|
||||
Password: password,
|
||||
ExternalUrl: settings.ServerUrl,
|
||||
MaxMemory: settings.MaxMemory,
|
||||
UnlimitedTime: unlimitedTime,
|
||||
UnlimitedDownload: unlimitedDownload,
|
||||
}
|
||||
AllowedDownloads: allowedDownloadsInt,
|
||||
Expiry: expiryDaysInt,
|
||||
ExpiryTimestamp: time.Now().Add(time.Duration(expiryDaysInt) * time.Hour * 24).Unix(),
|
||||
Password: password,
|
||||
ExternalUrl: settings.ServerUrl,
|
||||
MaxMemory: settings.MaxMemory,
|
||||
UnlimitedTime: unlimitedTime,
|
||||
UnlimitedDownload: unlimitedDownload,
|
||||
IsEndToEndEncrypted: isEnd2End,
|
||||
RealSize: realSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type formOrHeader interface {
|
||||
|
||||
@@ -32,30 +32,49 @@ func TestParseConfig(t *testing.T) {
|
||||
allowedDownloads: "9",
|
||||
expiryDays: "5",
|
||||
password: "123",
|
||||
isE2E: "",
|
||||
realSize: "",
|
||||
}
|
||||
config := parseConfig(data, false)
|
||||
config, err := parseConfig(data, false)
|
||||
test.IsNil(t, err)
|
||||
test.IsEqualBool(t, config.IsEndToEndEncrypted, false)
|
||||
test.IsEqualInt64(t, config.RealSize, 0)
|
||||
|
||||
defaults := database.GetUploadDefaults()
|
||||
test.IsEqualInt(t, config.AllowedDownloads, 9)
|
||||
test.IsEqualString(t, config.Password, "123")
|
||||
test.IsEqualInt(t, config.Expiry, 5)
|
||||
|
||||
test.IsEqualInt(t, defaults.Downloads, 3)
|
||||
config = parseConfig(data, true)
|
||||
|
||||
config, err = parseConfig(data, true)
|
||||
test.IsNil(t, err)
|
||||
defaults = database.GetUploadDefaults()
|
||||
test.IsEqualInt(t, defaults.Downloads, 9)
|
||||
database.SaveUploadDefaults(models.LastUploadValues{Downloads: 3, TimeExpiry: 20})
|
||||
|
||||
data.allowedDownloads = ""
|
||||
data.expiryDays = "invalid"
|
||||
config = parseConfig(data, false)
|
||||
|
||||
config, err = parseConfig(data, false)
|
||||
test.IsNil(t, err)
|
||||
test.IsEqualInt(t, config.AllowedDownloads, 3)
|
||||
test.IsEqualInt(t, config.Expiry, 20)
|
||||
test.IsEqualBool(t, config.UnlimitedTime, false)
|
||||
test.IsEqualBool(t, config.UnlimitedDownload, false)
|
||||
|
||||
data.allowedDownloads = "0"
|
||||
data.expiryDays = "0"
|
||||
config = parseConfig(data, false)
|
||||
config, err = parseConfig(data, false)
|
||||
test.IsNil(t, err)
|
||||
test.IsEqualBool(t, config.UnlimitedTime, true)
|
||||
test.IsEqualBool(t, config.UnlimitedDownload, true)
|
||||
|
||||
data.isE2E = "true"
|
||||
data.realSize = "200"
|
||||
config, err = parseConfig(data, false)
|
||||
test.IsNil(t, err)
|
||||
test.IsEqualBool(t, config.IsEndToEndEncrypted, true)
|
||||
test.IsEqualInt64(t, config.RealSize, 200)
|
||||
}
|
||||
|
||||
func TestProcess(t *testing.T) {
|
||||
@@ -163,7 +182,7 @@ func getFileUploadRecorder(addChunkInfo bool) *http.Request {
|
||||
}
|
||||
|
||||
type testData struct {
|
||||
allowedDownloads, expiryDays, password string
|
||||
allowedDownloads, expiryDays, password, isE2E, realSize string
|
||||
}
|
||||
|
||||
func (t testData) Get(key string) string {
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
"files"
|
||||
],
|
||||
"summary": "Uploads a new chunk",
|
||||
"description": "Uploads a file in chunks, in case a reverse proxy does not support upload of larger files. Parallel uploading is supported. Must call /chunk/complete after all chunks have been uploaded.",
|
||||
"description": "Uploads a file in chunks, in case a reverse proxy does not support upload of larger files. Parallel uploading is supported. Must call /chunk/complete after all chunks have been uploaded. WARNING: Does not support end-to-end encryption! If server is setup to utilise end-to-end encryption, file will be stored in plain-text!",
|
||||
"operationId": "chunkadd",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -138,7 +138,7 @@
|
||||
"files"
|
||||
],
|
||||
"summary": "Adds a new file without chunking",
|
||||
"description": "Uploads the submitted file to Gokapi. Please note: This method does not use chunking, therefore if you are behind a reverse proxy or have a provider that limits upload filesizes, this might not work for bigger files (e.g. Cloudflare).",
|
||||
"description": "Uploads the submitted file to Gokapi. Please note: This method does not use chunking, therefore if you are behind a reverse proxy or have a provider that limits upload filesizes, this might not work for bigger files (e.g. Cloudflare). WARNING: Does not support end-to-end encryption! If server is setup to utilise end-to-end encryption, file will be stored in plain-text!",
|
||||
"operationId": "add",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
var clipboard = new ClipboardJS('.btn');
|
||||
|
||||
|
||||
var dropzoneObject;
|
||||
var isE2EEnabled = false;
|
||||
|
||||
Dropzone.options.uploaddropzone = {
|
||||
paramName: "file",
|
||||
@@ -12,17 +12,21 @@ Dropzone.options.uploaddropzone = {
|
||||
},
|
||||
init: function() {
|
||||
dropzoneObject = this;
|
||||
this.on("sending", function(file, xhr, formData) {
|
||||
formData.append("allowedDownloads", document.getElementById("allowedDownloads").value);
|
||||
formData.append("expiryDays", document.getElementById("expiryDays").value);
|
||||
formData.append("password", document.getElementById("password").value);
|
||||
formData.append("isUnlimitedDownload", !document.getElementById("enableDownloadLimit").checked);
|
||||
formData.append("isUnlimitedTime", !document.getElementById("enableTimeLimit").checked);
|
||||
});
|
||||
this.on("sending", function(file, xhr, formData) {});
|
||||
// This will be executed after the page has loaded. If e2e ist enabled, the end2end_admin.js has set isE2EEnabled to true
|
||||
if (isE2EEnabled) {
|
||||
dropzoneObject.disable();
|
||||
dropzoneObject.options.dictDefaultMessage = "Loading end-to-end encryption...";
|
||||
document.getElementsByClassName("dz-button")[0].innerText = "Loading end-to-end encryption...";
|
||||
setE2eUpload();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
document.onpaste = function(event) {
|
||||
if (dropzoneObject.disabled) {
|
||||
return;
|
||||
}
|
||||
var items = (event.clipboardData || event.originalEvent.clipboardData).items;
|
||||
for (index in items) {
|
||||
var item = items[index];
|
||||
@@ -74,24 +78,49 @@ function sendChunkComplete(file, done) {
|
||||
formData.append("isUnlimitedDownload", !document.getElementById("enableDownloadLimit").checked);
|
||||
formData.append("isUnlimitedTime", !document.getElementById("enableTimeLimit").checked);
|
||||
formData.append("chunkid", file.upload.uuid);
|
||||
formData.append("filesize", file.size);
|
||||
formData.append("filename", file.name);
|
||||
formData.append("filecontenttype", file.type);
|
||||
|
||||
if (file.isEndToEndEncrypted === true) {
|
||||
formData.append("filesize", file.sizeEncrypted);
|
||||
formData.append("filename", "Encrypted File");
|
||||
formData.append("filecontenttype", "");
|
||||
formData.append("isE2E", "true");
|
||||
formData.append("realSize", file.size);
|
||||
} else {
|
||||
formData.append("filesize", file.size);
|
||||
formData.append("filename", file.name);
|
||||
formData.append("filecontenttype", file.type);
|
||||
}
|
||||
|
||||
xhr.onreadystatechange = function() {
|
||||
if (this.readyState == 4) {
|
||||
if (this.status == 200) {
|
||||
Dropzone.instances[0].removeFile(file);
|
||||
addRow(xhr.response);
|
||||
let fileId = addRow(xhr.response);
|
||||
if (file.isEndToEndEncrypted === true) {
|
||||
try {
|
||||
let result = GokapiE2EAddFile(file.upload.uuid, fileId, file.name);
|
||||
if (result instanceof Error) {
|
||||
throw result;
|
||||
}
|
||||
let info = GokapiE2EInfoEncrypt();
|
||||
if (info instanceof Error) {
|
||||
throw info;
|
||||
}
|
||||
storeE2EInfo(info);
|
||||
} catch (err) {
|
||||
file.accepted = false;
|
||||
dropzoneObject._errorProcessing([file], err);
|
||||
return;
|
||||
}
|
||||
GokapiE2EDecryptMenu();
|
||||
}
|
||||
dropzoneObject.removeFile(file);
|
||||
done();
|
||||
} else {
|
||||
file.accepted = false;
|
||||
Dropzone.instances[0]._errorProcessing([file], getErrorMessage(xhr.responseText));
|
||||
dropzoneObject._errorProcessing([file], getErrorMessage(xhr.responseText));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
xhr.send(urlencodeFormData(formData));
|
||||
}
|
||||
|
||||
@@ -154,6 +183,7 @@ function addRow(jsonText) {
|
||||
lockIcon = " 🔒";
|
||||
}
|
||||
cellFilename.innerText = item.Name;
|
||||
cellFilename.id = "cell-name-" + item.Id;
|
||||
cellFileSize.innerText = item.Size;
|
||||
if (item.UnlimitedDownloads) {
|
||||
cellRemainingDownloads.innerText = "Unlimited";
|
||||
@@ -166,9 +196,9 @@ function addRow(jsonText) {
|
||||
cellStoredUntil.innerText = item.ExpireAtString;
|
||||
}
|
||||
cellDownloadCount.innerHTML = '0';
|
||||
cellUrl.innerHTML = '<a target="_blank" style="color: inherit" href="' + jsonObject.Url + item.Id + '">' + jsonObject.Url + item.Id + '</a>' + lockIcon;
|
||||
cellUrl.innerHTML = '<a target="_blank" style="color: inherit" id="url-href-' + item.Id + '" href="' + jsonObject.Url + item.Id + '">' + item.Id + '</a>' + lockIcon;
|
||||
|
||||
let buttons = "<button type=\"button\" data-clipboard-text=\"" + jsonObject.Url + item.Id + "\" class=\"copyurl btn btn-outline-light btn-sm\">Copy URL</button> ";
|
||||
let buttons = '<button type="button" id="url-button-' + item.Id + '" 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 {
|
||||
@@ -189,4 +219,5 @@ function addRow(jsonText) {
|
||||
cellDownloadCount.style.backgroundColor = "green"
|
||||
cellUrl.style.backgroundColor = "green"
|
||||
cellButtons.style.backgroundColor = "green"
|
||||
return item.Id;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
Blob.prototype.arrayBuffer ??= function() {
|
||||
return new Response(this).arrayBuffer()
|
||||
}
|
||||
isE2EEnabled = true;
|
||||
|
||||
|
||||
function displayError(err) {
|
||||
document.getElementById("errordiv").style.display = "block";
|
||||
document.getElementById("errormessage").innerHTML = "<b>Error: </b> " + err.toString().replace(/^Error:/gi, "");
|
||||
console.error('Caught exception', err)
|
||||
}
|
||||
|
||||
|
||||
function checkIfE2EKeyIsSet() {
|
||||
if (!isE2EKeySet()) {
|
||||
window.location = './e2eSetup';
|
||||
} else {
|
||||
loadWasm(function() {
|
||||
let key = localStorage.getItem("e2ekey");
|
||||
let err = GokapiE2ESetCipher(key);
|
||||
if (err !== null) {
|
||||
displayError(err);
|
||||
return;
|
||||
}
|
||||
getE2EInfo();
|
||||
GokapiE2EDecryptMenu();
|
||||
dropzoneObject.enable();
|
||||
document.getElementsByClassName("dz-button")[0].innerText = "Drop files, paste or click here to upload (end-to-end encrypted)";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getE2EInfo() {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open("GET", "./e2eInfo?action=get", false);
|
||||
xhr.onreadystatechange = function() {
|
||||
if (this.readyState == 4) {
|
||||
if (this.status == 200) {
|
||||
let err = GokapiE2EInfoParse(xhr.response);
|
||||
if (err !== null) {
|
||||
displayError(err);
|
||||
if (err.message === "cipher: message authentication failed") {
|
||||
invalidCipherRedirectConfim();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
displayError("Trying to get E2E info: " + xhr.statusText);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
function invalidCipherRedirectConfim() {
|
||||
if (confirm('It appears that an invalid end-to-end encryption key has been entered. Would you like to enter the correct one?')) {
|
||||
window.location = './e2eSetup';
|
||||
}
|
||||
}
|
||||
|
||||
function storeE2EInfo(data) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "./e2eInfo?action=store", false);
|
||||
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
|
||||
|
||||
xhr.onreadystatechange = function() {
|
||||
if (this.readyState == 4) {
|
||||
if (this.status != 200) {
|
||||
displayError("Trying to store E2E info: " + xhr.statusText);
|
||||
}
|
||||
}
|
||||
};
|
||||
let formData = new FormData();
|
||||
formData.append("info", data);
|
||||
xhr.send(urlencodeFormData(formData));
|
||||
}
|
||||
|
||||
function isE2EKeySet() {
|
||||
let key = localStorage.getItem("e2ekey");
|
||||
return key !== null && key !== "";
|
||||
}
|
||||
|
||||
|
||||
function loadWasm(func) {
|
||||
const go = new Go(); // Defined in wasm_exec.js
|
||||
const WASM_URL = 'e2e.wasm?v=1';
|
||||
|
||||
var wasm;
|
||||
|
||||
try {
|
||||
if ('instantiateStreaming' in WebAssembly) {
|
||||
WebAssembly.instantiateStreaming(fetch(WASM_URL), go.importObject).then(function(obj) {
|
||||
wasm = obj.instance;
|
||||
go.run(wasm);
|
||||
func();
|
||||
})
|
||||
} else {
|
||||
fetch(WASM_URL).then(resp =>
|
||||
resp.arrayBuffer()
|
||||
).then(bytes =>
|
||||
WebAssembly.instantiate(bytes, go.importObject).then(function(obj) {
|
||||
wasm = obj.instance;
|
||||
go.run(wasm);
|
||||
func();
|
||||
})
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
displayError(err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function urlencodeFormData(fd) {
|
||||
let s = '';
|
||||
|
||||
function encode(s) {
|
||||
return encodeURIComponent(s).replace(/%20/g, '+');
|
||||
}
|
||||
for (var pair of fd.entries()) {
|
||||
if (typeof pair[1] == 'string') {
|
||||
s += (s ? '&' : '') + encode(pair[0]) + '=' + encode(pair[1]);
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
|
||||
function setE2eUpload() {
|
||||
dropzoneObject.uploadFiles = function(files) {
|
||||
this._transformFiles(files, (transformedFiles) => {
|
||||
|
||||
let transformedFile = transformedFiles[0];
|
||||
files[0].upload.chunked = true;
|
||||
files[0].isEndToEndEncrypted = true;
|
||||
|
||||
let filename = files[0].upload.filename;
|
||||
let plainTextSize = transformedFile.size;
|
||||
let bytesSent = 0;
|
||||
|
||||
let encryptedSize = GokapiE2EEncryptNew(files[0].upload.uuid, plainTextSize, filename);
|
||||
if (encryptedSize instanceof Error) {
|
||||
displayError(encryptedSize);
|
||||
return;
|
||||
}
|
||||
|
||||
files[0].upload.totalChunkCount = Math.ceil(
|
||||
encryptedSize / this.options.chunkSize
|
||||
);
|
||||
|
||||
files[0].sizeEncrypted = encryptedSize;
|
||||
let file = files[0];
|
||||
|
||||
let bytesReadPlaintext = 0;
|
||||
let bytesSendEncrypted = 0;
|
||||
|
||||
let finishedReading = false;
|
||||
let chunkIndex = 0;
|
||||
|
||||
uploadChunk(file, 0, encryptedSize, plainTextSize, dropzoneObject.options.chunkSize, 0);
|
||||
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function decryptFileEntry(id, filename, cipher) {
|
||||
let cellName = document.getElementById("cell-name-" + id);
|
||||
if (cellName != null) {
|
||||
cellName.innerText = filename;
|
||||
}
|
||||
|
||||
let urlLink = document.getElementById("url-href-" + id);
|
||||
if (urlLink != null) {
|
||||
let url = urlLink.href;
|
||||
if (!url.includes(cipher)) {
|
||||
urlLink.href = url + "#" + cipher;
|
||||
}
|
||||
}
|
||||
|
||||
let urlButton = document.getElementById("url-button-" + id)
|
||||
if (urlButton != null) {
|
||||
let url = urlButton.getAttribute("data-clipboard-text");
|
||||
if (!url.includes(cipher)) {
|
||||
urlButton.setAttribute("data-clipboard-text", url + "#" + cipher);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
async function uploadChunk(file, chunkIndex, encryptedTotalSize, plainTextSize, chunkSize, bytesWritten) {
|
||||
let isLastChunk = false;
|
||||
let bytesReadPlaintext = chunkIndex * chunkSize;
|
||||
let readEnd = bytesReadPlaintext + chunkSize;
|
||||
|
||||
if (chunkIndex === file.upload.totalChunkCount - 1) {
|
||||
isLastChunk = true;
|
||||
readEnd = plainTextSize;
|
||||
}
|
||||
|
||||
|
||||
let dataBlock = file.webkitSlice ?
|
||||
file.webkitSlice(bytesReadPlaintext, readEnd) :
|
||||
file.slice(bytesReadPlaintext, readEnd);
|
||||
|
||||
let data = await dataBlock.arrayBuffer();
|
||||
|
||||
let dataEnc = await GokapiE2EUploadChunk(file.upload.uuid, data.byteLength, isLastChunk, new Uint8Array(data));
|
||||
if (dataEnc instanceof Error) {
|
||||
displayError(data);
|
||||
return;
|
||||
}
|
||||
let err = await postChunk(file.upload.uuid, bytesWritten, encryptedTotalSize, dataEnc, file);
|
||||
if (err !== null) {
|
||||
file.accepted = false;
|
||||
dropzoneObject._errorProcessing([file], err);
|
||||
return;
|
||||
}
|
||||
bytesWritten = bytesWritten + dataEnc.byteLength;
|
||||
data = null;
|
||||
dataEnc = null;
|
||||
dataBlock = null;
|
||||
|
||||
if (!isLastChunk) {
|
||||
await uploadChunk(file, chunkIndex + 1, encryptedTotalSize, plainTextSize, chunkSize, bytesWritten)
|
||||
} else {
|
||||
file.status = Dropzone.SUCCESS;
|
||||
dropzoneObject.emit("success", file, 'success', null);
|
||||
dropzoneObject.emit("complete", file);
|
||||
dropzoneObject.processQueue();
|
||||
|
||||
dropzoneObject.options.chunksUploaded(file, () => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function postChunk(uuid, bytesWritten, encSize, data, file) {
|
||||
return new Promise(resolve => {
|
||||
let formData = new FormData();
|
||||
formData.append("dztotalfilesize", encSize)
|
||||
formData.append("dzchunkbyteoffset", bytesWritten)
|
||||
formData.append("dzuuid", uuid)
|
||||
formData.append("file", new Blob([data]), "encrypted.file");
|
||||
|
||||
let xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "./uploadChunk");
|
||||
|
||||
let progressObj = xhr.upload != null ? xhr.upload : xhr;
|
||||
progressObj.onprogress = (event) => {
|
||||
try {
|
||||
dropzoneObject.emit("uploadprogress", file, (100 * (event.loaded + bytesWritten)) / encSize, event.loaded + bytesWritten);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
xhr.onreadystatechange = function() {
|
||||
if (this.readyState == 4) {
|
||||
if (this.status == 200) {
|
||||
resolve(null);
|
||||
} else {
|
||||
console.log(xhr.responseText);
|
||||
resolve(xhr.responseText);
|
||||
}
|
||||
}
|
||||
};
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
function parseHashValue(id) {
|
||||
let key = localStorage.getItem("key-" + id);
|
||||
let filename = localStorage.getItem("fn-" + id);
|
||||
|
||||
if (key === null || filename === null) {
|
||||
hash = window.location.hash.substr(1);
|
||||
if (hash.length < 50) {
|
||||
redirectToE2EError();
|
||||
return;
|
||||
}
|
||||
let info;
|
||||
try {
|
||||
let infoJson = atob(hash);
|
||||
info = JSON.parse(infoJson)
|
||||
} catch (err) {
|
||||
redirectToE2EError();
|
||||
return;
|
||||
}
|
||||
if (!isCorrectJson(info)) {
|
||||
redirectToE2EError();
|
||||
return;
|
||||
}
|
||||
localStorage.setItem("key-" + id, info.c);
|
||||
localStorage.setItem("fn-" + id, info.f);
|
||||
}
|
||||
}
|
||||
|
||||
function isCorrectJson(input) {
|
||||
return (input.f !== undefined &&
|
||||
input.c !== undefined &&
|
||||
typeof input.f === 'string' &&
|
||||
typeof input.c === 'string' &&
|
||||
input.f != "" &&
|
||||
input.c != "");
|
||||
}
|
||||
|
||||
function redirectToE2EError() {
|
||||
window.location = "./error?e2e";
|
||||
}
|
||||
@@ -10,8 +10,7 @@
|
||||
<p class="card-text"><form action="./uploadChunk"
|
||||
class="dropzone"
|
||||
id="uploaddropzone"></form>
|
||||
</p><br>
|
||||
|
||||
<p></p><br>
|
||||
<div class="container-md">
|
||||
<div class="row">
|
||||
<div class="form-group col">
|
||||
@@ -37,6 +36,9 @@
|
||||
<label class="control-label small" for="password">Password</label>
|
||||
<input class="form-control admin-input" value="{{ .DefaultPassword }}" name="password" id="password" placeholder="None" {{ if eq .DefaultPassword "" }} disabled {{ end }}/>
|
||||
</div>
|
||||
<div id="errordiv" class="alert alert-danger" style="display:none">
|
||||
<span id="errormessage" ></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<br><br>
|
||||
@@ -49,7 +51,7 @@
|
||||
<th scope="col">Downloads remaining</th>
|
||||
<th scope="col">Stored until</th>
|
||||
<th scope="col">Downloads</th>
|
||||
<th scope="col">URL</th>
|
||||
<th scope="col">ID</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -58,7 +60,7 @@
|
||||
{{ if or (gt .ExpireAt $.TimeNow) (.UnlimitedTime) }}
|
||||
{{ if or (gt .DownloadsRemaining 0) (.UnlimitedDownloads) }}
|
||||
<tr>
|
||||
<td scope="col">{{ .Name }}</td>
|
||||
<td scope="col" id="cell-name-{{ .Id }}">{{ .Name }}</td>
|
||||
<td scope="col">{{ .Size }}</td>
|
||||
{{ if .UnlimitedDownloads }}
|
||||
<td scope="col">Unlimited</td>
|
||||
@@ -71,8 +73,8 @@
|
||||
<td scope="col">{{ .ExpireAtString }}</td>
|
||||
{{ end }}
|
||||
<td scope="col">{{ .DownloadCount }}</td>
|
||||
<td scope="col"><a target="_blank" href="{{ $.Url }}{{ .Id }}">{{ $.Url }}{{ .Id }}</a>{{ if .IsPasswordProtected }} 🔒{{ end }}</td>
|
||||
<td scope="col"><button type="button" data-clipboard-text="{{ $.Url }}{{ .Id }}" class="copyurl btn btn-outline-light btn-sm">Copy URL</button>
|
||||
<td scope="col"><a id="url-href-{{ .Id }}" target="_blank" href="{{ $.Url }}{{ .Id }}">{{ .Id }}</a>{{ if .IsPasswordProtected }} 🔒{{ end }}</td>
|
||||
<td scope="col"><button id="url-button-{{ .Id }}" 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 }}
|
||||
@@ -107,6 +109,15 @@
|
||||
Dropzone.options.uploaddropzone["retryChunks"] = true;
|
||||
</script>
|
||||
|
||||
{{ if .EndToEndEncryption }}
|
||||
<script src="./js/wasm_exec.js"></script>
|
||||
<script src="./js/end2end_admin.js?v=1"></script>
|
||||
<script>
|
||||
checkIfE2EKeyIsSet();
|
||||
</script>
|
||||
{{ end }}
|
||||
|
||||
|
||||
|
||||
{{ template "footer" }}
|
||||
{{ end }}
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
<div class="col">
|
||||
<div class="card" style="width: 18rem;">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title">{{ .Name }}</h4>
|
||||
{{ if .EndToEndEncryption }}
|
||||
<h4 id="filename" class="card-title">Decrypting...</h4>
|
||||
{{ else }}
|
||||
<h4 id="filename" class="card-title">{{ .Name }}</h4>
|
||||
{{ end }}
|
||||
<p class="card-text">Filesize: {{ .Size }}</p><br>
|
||||
<div id="buttondiv">
|
||||
{{ if .ClientSideDecryption }}
|
||||
@@ -35,73 +39,96 @@
|
||||
|
||||
|
||||
<script>
|
||||
const go = new Go(); // Defined in wasm_exec.js
|
||||
const WASM_URL = 'main.wasm?v=1';
|
||||
{{ if .EndToEndEncryption }}
|
||||
parseHashValue({{.Id}});
|
||||
{{ end }}
|
||||
|
||||
const go = new Go(); // Defined in wasm_exec.js
|
||||
const WASM_URL = 'main.wasm?v=1';
|
||||
|
||||
var wasm;
|
||||
var wasm;
|
||||
|
||||
try {
|
||||
if ('instantiateStreaming' in WebAssembly) {
|
||||
WebAssembly.instantiateStreaming(fetch(WASM_URL), go.importObject).then(function (obj) {
|
||||
wasm = obj.instance;
|
||||
go.run(wasm);
|
||||
document.getElementById("wasmspinner").style.display = "none";
|
||||
document.getElementById("downloadbutton").disabled = false;
|
||||
document.getElementById("downloadbutton").innerHTML = "Download";
|
||||
})
|
||||
} else {
|
||||
fetch(WASM_URL).then(resp =>
|
||||
resp.arrayBuffer()
|
||||
).then(bytes =>
|
||||
WebAssembly.instantiate(bytes, go.importObject).then(function (obj) {
|
||||
wasm = obj.instance;
|
||||
go.run(wasm);
|
||||
})
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
displayError(err);
|
||||
}
|
||||
try {
|
||||
if ('instantiateStreaming' in WebAssembly) {
|
||||
WebAssembly.instantiateStreaming(fetch(WASM_URL), go.importObject).then(function(obj) {
|
||||
wasm = obj.instance;
|
||||
go.run(wasm);
|
||||
document.getElementById("wasmspinner").style.display = "none";
|
||||
document.getElementById("downloadbutton").disabled = false;
|
||||
document.getElementById("downloadbutton").innerHTML = "Download";
|
||||
})
|
||||
} else {
|
||||
fetch(WASM_URL).then(resp =>
|
||||
resp.arrayBuffer()
|
||||
).then(bytes =>
|
||||
WebAssembly.instantiate(bytes, go.importObject).then(function(obj) {
|
||||
wasm = obj.instance;
|
||||
go.run(wasm);
|
||||
document.getElementById("wasmspinner").style.display = "none";
|
||||
document.getElementById("downloadbutton").disabled = false;
|
||||
document.getElementById("downloadbutton").innerHTML = "Download";
|
||||
})
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
displayError(err);
|
||||
}
|
||||
|
||||
|
||||
async function DownloadEncrypted() {
|
||||
try {
|
||||
{{ if .EndToEndEncryption }}
|
||||
let key = localStorage.getItem("key-{{ .Id }}");
|
||||
localStorage.removeItem("key-{{ .Id }}");
|
||||
let filename = localStorage.getItem("fn-{{ .Id }}");
|
||||
localStorage.removeItem("fn-{{ .Id }}");
|
||||
{{ else }}
|
||||
let key = "{{ .Cipher }}";
|
||||
{{ end }}
|
||||
const response = await GokapiDecrypt(key, "./downloadFile?id={{ .Id }}");
|
||||
if (response instanceof Error) {
|
||||
throw response;
|
||||
}
|
||||
|
||||
|
||||
async function DownloadEncrypted() {
|
||||
try {
|
||||
const response = await GokapiDecrypt({{ .Cipher }}, "./downloadFile?id={{ .Id }}");
|
||||
const readableStream = response.body;
|
||||
const reader = response.body.getReader();
|
||||
const readableStream = response.body;
|
||||
const reader = response.body.getReader();
|
||||
{{ if .UsesHttps }}
|
||||
streamSaver.mitm = './serviceworker/index.html';
|
||||
streamSaver.mitm = './serviceworker/index.html';
|
||||
{{ else }}
|
||||
console.log("Gokapi is not being accessed through https, therefore an external serviceworker will be used");
|
||||
streamSaver.mitm = 'https://bulling-it.de/gokapi/serviceworker.html';
|
||||
console.log("Gokapi is not being accessed through https, therefore an external serviceworker will be used");
|
||||
streamSaver.mitm = 'https://forceu.github.io/Gokapi/internal/webserver/web/static/serviceworker/index.html';
|
||||
{{ end }}
|
||||
const fileStream = streamSaver.createWriteStream({{.Name }});
|
||||
|
||||
window.writer = fileStream.getWriter();
|
||||
{{ if .EndToEndEncryption }}
|
||||
const fileStream = streamSaver.createWriteStream(filename);
|
||||
{{ else }}
|
||||
const fileStream = streamSaver.createWriteStream({{.Name }});
|
||||
{{ end }}
|
||||
|
||||
window.writer = fileStream.getWriter();
|
||||
|
||||
const pump = () => reader.read()
|
||||
.then(res => res.done
|
||||
? writer.close()
|
||||
: writer.write(res.value).then(pump))
|
||||
const pump = () => reader.read()
|
||||
.then(res => res.done ?
|
||||
writer.close() :
|
||||
writer.write(res.value).then(pump))
|
||||
|
||||
pump();
|
||||
} catch (err) {
|
||||
displayError(err);
|
||||
}
|
||||
}
|
||||
pump();
|
||||
} catch (err) {
|
||||
displayError(err);
|
||||
}
|
||||
}
|
||||
|
||||
function displayError(err) {
|
||||
document.getElementById("buttondiv").style.display = "none";
|
||||
document.getElementById("errordiv").style.display = "block";
|
||||
document.getElementById("errormessage").innerHTML = "<b>Error</b> "+err.toString();
|
||||
console.error('Caught exception', err)
|
||||
}
|
||||
|
||||
|
||||
function Download(button) {
|
||||
button.disabled = true;
|
||||
DownloadEncrypted();
|
||||
}
|
||||
function displayError(err) {
|
||||
document.getElementById("buttondiv").style.display = "none";
|
||||
document.getElementById("errordiv").style.display = "block";
|
||||
document.getElementById("errormessage").innerHTML = "<b>Error: </b> " + err.toString().replace(/^Error:/gi, "");
|
||||
console.error('Caught exception', err)
|
||||
}
|
||||
|
||||
|
||||
function Download(button) {
|
||||
button.disabled = true;
|
||||
DownloadEncrypted();
|
||||
}
|
||||
</script>
|
||||
{{ else }}
|
||||
|
||||
@@ -112,6 +139,11 @@
|
||||
}
|
||||
</script>
|
||||
{{ end }}
|
||||
{{ if .EndToEndEncryption }}
|
||||
<script>
|
||||
document.getElementById("filename").innerText = localStorage.getItem("fn-{{ .Id }}");
|
||||
</script>
|
||||
{{ end }}
|
||||
|
||||
{{template "footer"}}
|
||||
{{end}}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
{{define "download_password"}}
|
||||
{{template "header" .}}
|
||||
|
||||
{{ if .EndToEndEncryption }}
|
||||
<script>
|
||||
parseHashValue({{.Id}});
|
||||
</script>
|
||||
{{ end }}
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="card" style="width: 18rem;">
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
{{define "e2esetup"}}
|
||||
{{template "header" .}}
|
||||
|
||||
|
||||
<script src="./js/end2end_admin.js?v=1"></script>
|
||||
<script src="./js/streamsaver.js"></script>
|
||||
<script src="./js/wasm_exec.js"></script>
|
||||
<script>
|
||||
document.title = "Gokapi - E2E Setup";
|
||||
</script>
|
||||
|
||||
{{ if not .HasBeenSetup }}
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="card" style="width: 50%;">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">End-to-End Encryption Setup</h2>
|
||||
<br><br>
|
||||
<p class="card-text">Your password for decryption is:<br>
|
||||
<b><kbd id="decryptionpw">Generating.......</kbd></b> <button type="button" id="downpw" class="btn btn-outline-light btn-sm" disabled>⭳</button></p>
|
||||
<div id="errordiv" style="display:none">
|
||||
<span id="errormessage" style="color:red"></span>
|
||||
<br>
|
||||
<br>
|
||||
</div>
|
||||
<b>Save this password to a secure location, without it you will not be able to decrypt/share your files if your browser data gets deleted or you login from a different machine! This password will only be shown once.</b>
|
||||
<br><br>If you need to reset the password, run the Gokapi setup again.
|
||||
<br><br><button type="button" id="genbutton" class="btn btn-light" disabled onclick="window.location='./admin';">Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
function downloadKey(key) {
|
||||
const uInt8 = new TextEncoder().encode(key)
|
||||
|
||||
const fileStream = streamSaver.createWriteStream('GokapiE2E.txt', {
|
||||
size: uInt8.byteLength
|
||||
})
|
||||
|
||||
const writer = fileStream.getWriter()
|
||||
writer.write(uInt8)
|
||||
writer.close()
|
||||
|
||||
}
|
||||
|
||||
function newKey() {
|
||||
localStorage.clear();
|
||||
let key;
|
||||
try {
|
||||
key = GokapiE2EGetNewCipher(true);
|
||||
if (key instanceof Error) {
|
||||
throw (key);
|
||||
}
|
||||
document.getElementById("decryptionpw").innerText = key;
|
||||
let data = GokapiE2EInfoEncrypt();
|
||||
if (data instanceof Error) {
|
||||
throw (data);
|
||||
}
|
||||
storeE2EInfo(data);
|
||||
} catch (err) {
|
||||
displayError(err);
|
||||
return;
|
||||
|
||||
}
|
||||
localStorage.setItem("e2ekey", key);
|
||||
document.getElementById("downpw").onclick = function() {
|
||||
downloadKey(key);
|
||||
}
|
||||
document.getElementById("downpw").disabled = false;
|
||||
document.getElementById("genbutton").disabled = false;
|
||||
}
|
||||
|
||||
loadWasm(newKey);
|
||||
|
||||
</script>
|
||||
|
||||
{{ else }}
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="card" style="width: 50%;">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">End-to-End Encryption Setup</h2>
|
||||
<br><br>
|
||||
<p class="card-text">End-to-end encryption has been set up, however no key was found on the local machine. Please enter the password in the text field below. If you do not know the decryption password, please re-run the Gokapi setup to reset the password.</p>
|
||||
<div class="mb-3"><br>
|
||||
<input type="password" class="form-control" id="password" name="password">
|
||||
<div id="errordiv" style="display:none">
|
||||
<br>
|
||||
<span id="errormessage" style="color:red"></span>
|
||||
<br>
|
||||
</div>
|
||||
<br><br><button type="button" id="enterpwbutton" class="btn btn-light" onclick="saveKey()" >Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
function saveKey() {
|
||||
localStorage.clear();
|
||||
let key = document.getElementById("password").value;
|
||||
localStorage.setItem("e2ekey", key);
|
||||
|
||||
let output = GokapiE2ESetCipher(key);
|
||||
if (output instanceof Error) {
|
||||
localStorage.removeItem("e2ekey");
|
||||
document.getElementById("password").value = "";
|
||||
displayError(output);
|
||||
return;
|
||||
}
|
||||
window.location = './admin';
|
||||
|
||||
}
|
||||
|
||||
loadWasm(function() {
|
||||
document.getElementById("enterpwbutton").disabled = false;
|
||||
});
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
{{ end }}
|
||||
{{template "footer"}}
|
||||
{{end}}
|
||||
@@ -6,7 +6,19 @@
|
||||
<div class="card" style="width: 18rem;">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Error</h2>
|
||||
<p class="card-text">Sorry, this file cannot be found. Either the link has expired or it has been downloaded already.</p>
|
||||
<p class="card-text">
|
||||
<br>
|
||||
{{ if eq .ErrorId 0 }}
|
||||
Sorry, this file cannot be found.<br><br>Either the link has expired or it has been downloaded too many times.
|
||||
{{ end }}
|
||||
{{ if eq .ErrorId 1 }}
|
||||
This file is encrypted and no key has been passed.<br><br>Please contact the uploader to give you the correct link, including the value after the hash.
|
||||
{{ end }}
|
||||
{{ if eq .ErrorId 2 }}
|
||||
This file is encrypted and an incorrect key has been passed.<br><br>If this file is end-to-end encrypted, please contact the uploader to give you the correct link, including the value after the hash.
|
||||
{{ end }}
|
||||
<br>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
</main>
|
||||
|
||||
<footer class="mt-auto text-white-50">
|
||||
<p> Powered by <a href="https://github.com/Forceu/Gokapi">Gokapi v{{template "version"}}</a></p>
|
||||
<p> Powered by <a href="https://github.com/Forceu/Gokapi" target="_blank">Gokapi v{{template "version"}}</a></p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<link href="./assets/dist/css/dropzone.min.css" rel="stylesheet">
|
||||
<script src="./assets/dist/js/jquery.min.js"></script>
|
||||
<script src="./assets/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="./assets/dist/js/dropzone.min.js?v=2"></script>
|
||||
<script src="./assets/dist/js/dropzone.min.js?v=3"></script>
|
||||
<script src="./assets/dist/js/clipboard.min.js"></script>
|
||||
<style>
|
||||
.masthead-brand {
|
||||
@@ -34,6 +34,7 @@
|
||||
box-shadow: inset 0 0 5rem rgba(0, 0, 0, .5);
|
||||
}
|
||||
</style>
|
||||
<script src="./js/end2end_download.js"></script>
|
||||
{{end}}
|
||||
</head>
|
||||
<body class="d-flex h-100 text-center text-white bg-dark">
|
||||
|
||||
+2
-2
@@ -62,7 +62,7 @@
|
||||
"files"
|
||||
],
|
||||
"summary": "Uploads a new chunk",
|
||||
"description": "Uploads a file in chunks, in case a reverse proxy does not support upload of larger files. Parallel uploading is supported. Must call /chunk/complete after all chunks have been uploaded.",
|
||||
"description": "Uploads a file in chunks, in case a reverse proxy does not support upload of larger files. Parallel uploading is supported. Must call /chunk/complete after all chunks have been uploaded. WARNING: Does not support end-to-end encryption! If server is setup to utilise end-to-end encryption, file will be stored in plain-text!",
|
||||
"operationId": "chunkadd",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -138,7 +138,7 @@
|
||||
"files"
|
||||
],
|
||||
"summary": "Adds a new file without chunking",
|
||||
"description": "Uploads the submitted file to Gokapi. Please note: This method does not use chunking, therefore if you are behind a reverse proxy or have a provider that limits upload filesizes, this might not work for bigger files (e.g. Cloudflare).",
|
||||
"description": "Uploads the submitted file to Gokapi. Please note: This method does not use chunking, therefore if you are behind a reverse proxy or have a provider that limits upload filesizes, this might not work for bigger files (e.g. Cloudflare). WARNING: Does not support end-to-end encryption! If server is setup to utilise end-to-end encryption, file will be stored in plain-text!",
|
||||
"operationId": "add",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
|
||||
Reference in New Issue
Block a user