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:
Marc Ole Bulling
2022-08-11 13:37:55 +02:00
committed by GitHub
parent 482d651056
commit 9c9ea6dbb7
44 changed files with 1559 additions and 252 deletions
+1 -1
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
View File
@@ -1,4 +1,4 @@
FROM golang:1.18
FROM golang:1.19
## To compile:
## cd Gokapi/build/
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/forceu/gokapi
go 1.18
go 1.19
require (
git.mills.io/prologic/bitcask v1.0.2
+51
View File
@@ -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
+43
View File
@@ -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
View File
@@ -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()
+1 -8
View File
@@ -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
}
+303
View File
@@ -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 -1
View File
@@ -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()
+21 -10
View File
@@ -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
}
+34 -40
View File
@@ -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>&nbsp;Reset end-to-end password (you will lose access to already encrypted files)</span>
</div>
{{ end }}
</div>
</div>
+20 -6
View File
@@ -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
+51
View File
@@ -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)
}
+5 -2
View File
@@ -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 {
+33
View File
@@ -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
View File
@@ -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)
}
+1 -1
View File
@@ -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) {
+10 -8
View File
@@ -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
}
+19 -10
View File
@@ -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
+1 -1
View File
@@ -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")
+1 -2
View File
@@ -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
View File
@@ -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
}
+22 -2
View File
@@ -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,
})
}
+2 -2
View File
@@ -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)
+32 -14
View File
@@ -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": {
+49 -18
View File
@@ -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 = " &#128274;";
}
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 }} &#128274;{{ end }}</td>
<td scope="col"><button type="button" data-clipboard-text="{{ $.Url }}{{ .Id }}" class="copyurl btn btn-outline-light btn-sm">Copy URL</button>
<td scope="col"><a id="url-href-{{ .Id }}" target="_blank" href="{{ $.Url }}{{ .Id }}">{{ .Id }}</a>{{ if .IsPasswordProtected }} &#128274;{{ 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>&#x2B73;</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>&nbsp;
</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
View File
@@ -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": {