Introducing Warehouse - the store for files shared by the user.

New endpoints:
/warehouse/create               Create a file in the warehouse
/warehouse/create/path          Create a file in the warehouse via copy
/warehouse/read                 Read a file in the warehouse
/warehouse/delete               Delete a file in the warehouse
This commit is contained in:
Kleissner
2021-10-13 01:40:58 +02:00
parent 956e6dd76b
commit cec070b740
8 changed files with 590 additions and 14 deletions

View File

@@ -11,6 +11,7 @@ func Init() {
initFilters()
initPeerID()
initUserBlockchain()
initUserWarehouse()
initKademlia()
initMessageSequence()
initSeedList()

26
Warehouse.go Normal file
View File

@@ -0,0 +1,26 @@
/*
File Name: Warehouse.go
Copyright: 2021 Peernet s.r.o.
Author: Peter Kleissner
*/
package core
import (
"github.com/PeernetOfficial/core/warehouse"
)
// UserWarehouse is the user's warehouse for storing files that are shared
var UserWarehouse *warehouse.Warehouse
// folderUserWarehouse is the folder of the user's warehouse
const folderUserWarehouse = "warehouse"
func initUserWarehouse() {
var err error
UserWarehouse, err = warehouse.Init(folderUserWarehouse)
if err != nil {
Filters.LogError("initUserWarehouse", "error: %s\n", err.Error())
}
}

192
warehouse/Store.go Normal file
View File

@@ -0,0 +1,192 @@
/*
File Name: Store.go
Copyright: 2021 Peernet Foundation s.r.o.
Author: Peter Kleissner
*/
package warehouse
import (
"encoding/hex"
"io"
"os"
"strings"
"time"
"lukechampine.com/blake3"
)
const (
StatusOK = 0 // Success.
StatusErrorCreateTempFile = 1 // Error creating a temporary file.
StatusErrorWriteTempFile = 2 // Error writing temporary file.
StatusErrorCloseTempFile = 3 // Error closing temporary file.
StatusErrorRenameTempFile = 4 // Error renaming temporary file.
StatusErrorCreatePath = 5 // Error creating path for target file in warehouse.
StatusErrorOpenFile = 7 // Error opening file.
StatusInvalidHash = 8 // Invalid hash.
StatusFileNotFound = 9 // File not found.
StatusErrorDeleteFile = 10 // Error deleting file.
StatusErrorReadFile = 11 // Error reading file.
StatusErrorSeekFile = 12 // Error seeking to position in file.
)
// CreateFile creates a new file in the warehouse
func (wh *Warehouse) CreateFile(data io.Reader) (hash []byte, status int, err error) {
// create a temporary file to hold the body content
tmpFile, err := wh.TempFile()
if err != nil {
return nil, StatusErrorCreateTempFile, err
}
tmpFileName := tmpFile.Name()
// create the hash-writer
hashWriter := blake3.New(hashSize, nil)
// the multi-writer writes to the temp-file and the hash simultaneously
mw := io.MultiWriter(tmpFile, hashWriter)
// copy into the multiwriter
if _, err = io.Copy(mw, data); err != nil {
tmpFile.Close()
os.Remove(tmpFileName)
return nil, StatusErrorWriteTempFile, err
}
if err := tmpFile.Close(); err != nil {
os.Remove(tmpFileName)
return nil, StatusErrorCloseTempFile, err
}
hash = hashWriter.Sum(nil)
hashA := hex.EncodeToString(hash)
// Check if the file exists
if _, _, valid := wh.FileExists(hashA); valid {
// file exists already, temp file not needed
os.Remove(tmpFileName)
// return success
return hash, StatusOK, nil
}
// Destination
pathFull, err := wh.createFilePath(hashA)
if err != nil {
os.Remove(tmpFileName)
return nil, StatusErrorCreatePath, err
}
// first check if the file is already stored. if not rename the temp file to the final one
if _, err := os.Stat(pathFull); err == nil {
// file exists already, temp file not needed
os.Remove(tmpFileName)
} else {
// rename temp file to final one with proper path
if err := os.Rename(tmpFileName, pathFull); err != nil {
os.Remove(tmpFileName)
// A race condition may exist where the file exists here. If it does, continue successfully.
if _, err = os.Stat(pathFull); err != nil {
return nil, StatusErrorRenameTempFile, err
}
}
}
return hash, StatusOK, nil
}
// CreateFileFromPath creates a file from an existing file path.
// Warning: An attacker could supply any local file using this function, put them into storage and read them! No input path verification or limitation is done.
func (wh *Warehouse) CreateFileFromPath(file string) (hash []byte, status int, err error) {
fileHandle, err := os.Open(file)
if err != nil && os.IsNotExist(err) {
return nil, StatusFileNotFound, err
} else if err != nil {
// cannot open file
return nil, StatusErrorOpenFile, err
}
defer fileHandle.Close()
// create the file using the opened file
return wh.CreateFile(fileHandle)
}
// ReadFile reads a file from the warehouse and outputs it to the writer
// Offset is the position in the file to start reading. Limit (0 = not used) defines how many bytes to read starting at the offset.
func (wh *Warehouse) ReadFile(hash []byte, offset, limit int64, writer io.Writer) (status int, err error) {
hashA, err := validateHash(hash)
if err != nil {
return StatusInvalidHash, err
}
var reader io.ReadSeeker
path, _, valid := wh.FileExists(hashA)
if !valid {
// file does not exist
return StatusFileNotFound, os.ErrNotExist
}
// read from drive
retryCount := 0
retryOpenFile:
file, err := os.Open(path)
if err != nil {
// There may be a race condition when the file is being written: "The process cannot access the file because it is being used by another process."
// Wait up to 3 times for 400ms.
if strings.Contains(err.Error(), "cannot access the file because it is being used by another process") && retryCount < 3 {
retryCount++
time.Sleep(time.Millisecond * 400)
goto retryOpenFile
}
return StatusErrorOpenFile, err
}
defer file.Close()
reader = file
// seek to offset, if provided
if offset > 0 {
if _, err = reader.Seek(offset, io.SeekStart); err != nil {
return StatusErrorSeekFile, err
}
}
// read the file and copy it into the output
if limit > 0 {
_, err = io.Copy(writer, io.LimitReader(reader, limit))
} else {
_, err = io.Copy(writer, reader)
}
// do not consider EOF an error if all bytes were read
if err != nil {
return StatusErrorReadFile, err
}
return StatusOK, nil
}
// DeleteFile deletes a file from the warehouse
func (wh *Warehouse) DeleteFile(hash []byte) (status int, err error) {
hashA, err := validateHash(hash)
if err != nil {
return StatusInvalidHash, err
}
path, _, valid := wh.FileExists(hashA)
if !valid {
return StatusFileNotFound, os.ErrNotExist
}
if err := os.Remove(path); err != nil {
return StatusErrorDeleteFile, err
}
return StatusOK, nil
}

98
warehouse/Warehouse.go Normal file
View File

@@ -0,0 +1,98 @@
/*
File Name: Warehouse.go
Copyright: 2021 Peernet Foundation s.r.o.
Author: Peter Kleissner
*/
package warehouse
import (
"encoding/hex"
"io/ioutil"
"os"
"path/filepath"
)
// Blake3 hash size = 32 bytes.
const (
hashSize = 256 / 8
)
// Warehouse represents a folder on disk.
type Warehouse struct {
Directory string // The main directory for the files
Temp string // Temporary folder
}
// LogError is called for any error. The caller can use it to capture any errors.
//var LogError func(function, format string, v ...interface{}) = func(function, format string, v ...interface{}) {}
// Init initializes the warehouse
func Init(Directory string) (wh *Warehouse, err error) {
// The temp folder will always be a sub-folder named "_Temp"
wh = &Warehouse{Directory: Directory, Temp: filepath.Join(Directory, "_Temp")}
if err = createDirectory(wh.Directory); err != nil {
return nil, err
}
if err = createDirectory(wh.Temp); err != nil {
return nil, err
}
return
}
// TempFile creates a temporary file in the Warehouse. Do not forget to delete.
func (wh *Warehouse) TempFile() (file *os.File, err error) {
file, err = ioutil.TempFile(wh.Temp, "wh")
return
}
// createFilePath creates the file path for the specified hash and returns the full file path
func (wh *Warehouse) createFilePath(hash string) (pathFull string, err error) {
path, filename := buildPath(wh.Directory, hash)
return filepath.Join(path, filename), createDirectory(path)
}
// FileExists checks if the file exists
func (wh *Warehouse) FileExists(hash string) (path string, fileInfo os.FileInfo, valid bool) {
a, b := buildPath(wh.Directory, hash)
path = filepath.Join(a, b)
if fileInfo, err := os.Stat(path); err == nil {
// file exists
return path, fileInfo, true
}
return "", nil, false
}
// ---- hash functions ----
func validateHash(hash []byte) (hashA string, err error) {
if len(hash) != hashSize {
return "", os.ErrInvalid
}
return hex.EncodeToString(hash), nil
}
// ---- path ----
func createDirectory(path string) (err error) {
if _, err = os.Stat(path); err != nil && os.IsNotExist(err) {
err = os.MkdirAll(path, os.ModePerm)
}
return err
}
// buildPath returns the full directory and the filename for the hash
func buildPath(storagePath, hash string) (directory string, filename string) {
part1 := hash[:4]
part2 := hash[4:8]
filename = hash[8:]
newPath := filepath.Join(storagePath, part1, part2)
return newPath, filename
}

18
warehouse/readme.md Normal file
View File

@@ -0,0 +1,18 @@
# Warehouse
This package manages provides a warehouse for files that are shared by the user (i.e., published via the user's blockchain). Since the blockchain only stores the metadata, the actual file data needs to be stored in a separate local database.
Features:
* Automatic deduplication
* Addressing files based on the data hash
* Read/Write/Delete
* Provide the entire file or parts of it at anytime
* Store files as large as supported by the target disk
## Limitations
There is currently no hard or soft limit of used storage. If the underlying target disk does not have enough available storage, adding new files will fail.
## Implementation
This package uses blake3 for hashing.

View File

@@ -62,6 +62,10 @@ func Start(ListenAddresses []string, UseSSL bool, CertificateFile, CertificateKe
Router.HandleFunc("/download/start", apiDownloadStart).Methods("GET")
Router.HandleFunc("/download/status", apiDownloadStatus).Methods("GET")
Router.HandleFunc("/download/action", apiDownloadAction).Methods("GET")
Router.HandleFunc("/warehouse/create", apiWarehouseCreateFile).Methods("POST")
Router.HandleFunc("/warehouse/create/path", apiWarehouseCreateFilePath).Methods("GET")
Router.HandleFunc("/warehouse/read", apiWarehouseReadFile).Methods("GET")
Router.HandleFunc("/warehouse/delete", apiWarehouseDeleteFile).Methods("GET")
for _, listen := range ListenAddresses {
go startWebServer(listen, UseSSL, CertificateFile, CertificateKey, Router, "API", TimeoutRead, TimeoutWrite)

123
webapi/Warehouse.go Normal file
View File

@@ -0,0 +1,123 @@
/*
File Name: Warehouse.go
Copyright: 2021 Peernet Foundation s.r.o.
Author: Peter Kleissner
*/
package webapi
import (
"net/http"
"strconv"
"github.com/PeernetOfficial/core"
"github.com/PeernetOfficial/core/warehouse"
)
// WarehouseResult is the response to creating a new file in the warehouse
type WarehouseResult struct {
Status int `json:"status"` // See warehouse.StatusX.
Hash []byte `json:"hash"` // Hash of the file.
}
/*
apiWarehouseCreateFile creates a file in the warehouse.
Request: POST /warehouse/create with raw data to create as new file
Response: 200 with JSON structure WarehouseResult
*/
func apiWarehouseCreateFile(w http.ResponseWriter, r *http.Request) {
hash, status, err := core.UserWarehouse.CreateFile(r.Body)
if err != nil {
core.Filters.LogError("warehouese.CreateFile", "status %d error: %v", status, err)
}
EncodeJSON(w, r, WarehouseResult{Status: status, Hash: hash})
}
/*
apiWarehouseCreateFilePath creates a file in the warehouse by copying it from an existing file.
Warning: An attacker could supply any local file using this function, put them into storage and read them! No input path verification or limitation is done.
In the future the API should be secured using a random API key and setting the CORS header prohibiting regular browsers to access the API.
Request: GET /warehouse/create/path?path=[target path on disk]
Response: 200 with JSON structure WarehouseResult
*/
func apiWarehouseCreateFilePath(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
filePath := r.Form.Get("path")
if filePath == "" {
http.Error(w, "", http.StatusBadRequest)
return
}
hash, status, err := core.UserWarehouse.CreateFileFromPath(filePath)
if err != nil {
core.Filters.LogError("warehouese.CreateFile", "status %d error: %v", status, err)
}
EncodeJSON(w, r, WarehouseResult{Status: status, Hash: hash})
}
/*
apiWarehouseReadFile reads a file in the warehouse.
Request: GET /warehouse/read?hash=[hash]
Optional parameters &offset=[file offset]&limit=[read limit in bytes]
Response: 200 with the raw file data
404 if file was not found
500 in case of internal error opening the file
*/
func apiWarehouseReadFile(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
hash, valid1 := DecodeBlake3Hash(r.Form.Get("hash"))
if !valid1 {
http.Error(w, "", http.StatusBadRequest)
return
}
offset, _ := strconv.Atoi(r.Form.Get("offset"))
limit, _ := strconv.Atoi(r.Form.Get("limit"))
status, err := core.UserWarehouse.ReadFile(hash, int64(offset), int64(limit), w)
switch status {
case warehouse.StatusFileNotFound:
w.WriteHeader(http.StatusNotFound)
return
case warehouse.StatusInvalidHash, warehouse.StatusErrorOpenFile, warehouse.StatusErrorSeekFile:
w.WriteHeader(http.StatusInternalServerError)
return
// Cannot catch WarehouseStatusErrorReadFile since data may have been already returned.
// In the future a special header indicating the expected file length could be sent (would require a callback in ReadFile), although the caller should already know the file size based on metadata.
}
if err != nil {
core.Filters.LogError("warehouese.ReadFile", "status %d error: %v", status, err)
}
}
/*
apiWarehouseDeleteFile deletes a file in the warehouse.
Request: GET /warehouse/delete?hash=[hash]
Response: 200 with JSON structure WarehouseResult
*/
func apiWarehouseDeleteFile(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
hash, valid1 := DecodeBlake3Hash(r.Form.Get("hash"))
if !valid1 {
http.Error(w, "", http.StatusBadRequest)
return
}
status, err := core.UserWarehouse.DeleteFile(hash)
if err != nil {
core.Filters.LogError("warehouese.DeleteFile", "status %d error: %v", status, err)
}
EncodeJSON(w, r, WarehouseResult{Status: status, Hash: hash})
}

View File

@@ -55,6 +55,11 @@ These are the functions provided by the API:
/explore List recently shared files
/file/format Detect file type and format
/warehouse/create Create a file in the warehouse
/warehouse/create/path Create a file in the warehouse via copy
/warehouse/read Read a file in the warehouse
/warehouse/delete Delete a file in the warehouse
```
# API Documentation
@@ -598,9 +603,9 @@ Result: 200 with JSON structure SearchResult. Check the field status.
```go
type SearchResult struct {
Status int `json:"status"` // Status: 0 = Success with results, 1 = No more results available, 2 = Search ID not found, 3 = No results yet available keep trying
Files []apiFile `json:"files"` // List of files found
Statistic interface{} `json:"statistic"` // Statistics of all results (independent from applied filters), if requested. Only set if files are returned (= if statistics changed). See SearchStatisticData.
Status int `json:"status"` // Status: 0 = Success with results, 1 = No more results available, 2 = Search ID not found, 3 = No results yet available keep trying
Files []apiFile `json:"files"` // List of files found
Statistic interface{} `json:"statistic"` // Statistics of all results (independent from applied filters), if requested. Only set if files are returned (= if statistics changed). See SearchStatisticData.
}
```
@@ -652,26 +657,26 @@ Result: 200 with JSON structure SearchStatistic. Check the field status (0 =
```go
type SearchStatistic struct {
SearchStatisticData
Status int `json:"status"` // Status: 0 = Success
IsTerminated bool `json:"terminated"` // Whether the search is terminated, meaning that statistics won't change
SearchStatisticData
Status int `json:"status"` // Status: 0 = Success
IsTerminated bool `json:"terminated"` // Whether the search is terminated, meaning that statistics won't change
}
type SearchStatisticData struct {
Date []SearchStatisticRecordDay `json:"date"` // Files per date
FileType []SearchStatisticRecord `json:"filetype"` // Files per file type
FileFormat []SearchStatisticRecord `json:"fileformat"` // Files per file format
Total int `json:"total"` // Total count of files
Date []SearchStatisticRecordDay `json:"date"` // Files per date
FileType []SearchStatisticRecord `json:"filetype"` // Files per file type
FileFormat []SearchStatisticRecord `json:"fileformat"` // Files per file format
Total int `json:"total"` // Total count of files
}
type SearchStatisticRecordDay struct {
Date time.Time `json:"date"` // The day (which covers the full 24 hours). Always rounded down to midnight.
Count int `json:"count"` // Count of files.
Date time.Time `json:"date"` // The day (which covers the full 24 hours). Always rounded down to midnight.
Count int `json:"count"` // Count of files.
}
type SearchStatisticRecord struct {
Key int `json:"key"` // Key index. The exact meaning depends on where this structure is used.
Count int `json:"count"` // Count of files for the given key
Key int `json:"key"` // Key index. The exact meaning depends on where this structure is used.
Count int `json:"count"` // Count of files for the given key
}
```
@@ -856,3 +861,112 @@ Example response:
"fileformat": 10
}
```
## Warehouse
The Warehouse stores the actual files that are shared by the user. The blockchain only stores the metadata information. The Warehouse and the blockchain must be kept in sync.
* Files are identified (and adressed) by their hash.
* Before using `/blockchain/self/add/file`, you must store the file in the Warehouse using `/warehouse/create` or `/warehouse/create/path`. The blockchain function verifies if the file exists in the Warehouse and fails if it does not.
* When deleting a file from the blockchain via `/blockchain/self/delete/file`, it will automatically delete the file from the warehouse if there are no other files on the blockchain referencing it.
* Because files are addressed using their hash, they are automatically deduplicated. If the user shares the exact same file data under different file names, it is only stored once.
Note: The Warehouse does NOT store files downloaded from other users. It strictly only stores files that the user choses to publish.
Status codes:
| Status | Constant | Info |
|------|----------------|-------------------------------|
| 0 | StatusOK | Success |
| 1 | StatusErrorCreateTempFile | Error creating a temporary file. |
| 2 | StatusErrorWriteTempFile | Error writing temporary file. |
| 3 | StatusErrorCloseTempFile | Error closing temporary file. |
| 4 | StatusErrorRenameTempFile | Error renaming temporary file. |
| 5 | StatusErrorCreatePath | Error creating path for target file in warehouse. |
| 7 | StatusErrorOpenFile | Error opening file. |
| 8 | StatusInvalidHash | Invalid hash. |
| 9 | StatusFileNotFound | File not found. |
| 10 | StatusErrorDeleteFile | Error deleting file. |
| 11 | StatusErrorReadFile | Error reading file. |
| 12 | StatusErrorSeekFile | Error seeking to position in file. |
### Create File
This creates a file in the warehouse. The payload data is the file data to store. It returns the hash of the stored file. If the file already exists it does not return an error.
```
Request: POST /warehouse/create with raw data to create as new file
Response: 200 with JSON structure WarehouseResult
```
```go
type WarehouseResult struct {
Status int `json:"status"` // See warehouse.StatusX.
Hash []byte `json:"hash"` // Hash of the file.
}
```
Example POST request to `http://127.0.0.1:112/warehouse/create`:
```
Test file.
```
Example response:
```json
{
"status": 0,
"hash": "2/NE8j54ICYTKYg64m9kkpp8mXdUkAHSjcQMkgLXZR4="
}
```
### Create File by Copy
This creates a file in the warehouse by copying it from an existing local file.
Warning: An attacker could supply any local file using this function, put them into storage and read them! No input path verification or limitation is done.
In the future the API should be secured using a random API key and setting the CORS header prohibiting regular browsers to access the API.
```
Request: GET /warehouse/create/path?path=[target path on disk]
Response: 200 with JSON structure WarehouseResult
```
Example request to add the local file "C:\Test File 1.txt": `http://127.0.0.1:112/warehouse/create/path?path=C%3A%5CTest%20File%201.txt`
Example response in case the file does not exist (returning StatusFileNotFound):
```json
{
"status": 9,
"hash": null
}
```
### Read File
This reads a file in the warehouse. The offset and limit parameter are optional. The hash must be hex encoded.
```
Request: GET /warehouse/read?hash=[hash]
Optional parameters &offset=[file offset]&limit=[read limit in bytes]
Response: 200 with the raw file data
404 if file was not found
500 in case of internal error opening the file
```
Example request: `http://127.0.0.1:112/warehouse/read?hash=dbf344f23e7820261329883ae26f64929a7c9977549001d28dc40c9202d7651e`
### Delete File
This deletes a file in the warehouse. This is normally not needed, since `/blockchain/self/delete/file` will automatically delete files in the Warehouse if there are no active references.
Warning: Deleting files from the warehouse but not the blockchain creates orphans. Peers might blacklist other peers who advertise files via their blockchain, but fail to provide them for transfer.
```
Request: GET /warehouse/delete?hash=[hash]
Response: 200 with JSON structure WarehouseResult
```
Example request: `http://127.0.0.1:112/warehouse/delete?hash=dbf344f23e7820261329883ae26f64929a7c9977549001d28dc40c9202d7651e`