diff --git a/Peernet.go b/Peernet.go index 6dd6667..6470eab 100644 --- a/Peernet.go +++ b/Peernet.go @@ -11,6 +11,7 @@ func Init() { initFilters() initPeerID() initUserBlockchain() + initUserWarehouse() initKademlia() initMessageSequence() initSeedList() diff --git a/Warehouse.go b/Warehouse.go new file mode 100644 index 0000000..54d8773 --- /dev/null +++ b/Warehouse.go @@ -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()) + } +} diff --git a/warehouse/Store.go b/warehouse/Store.go new file mode 100644 index 0000000..eec3b2e --- /dev/null +++ b/warehouse/Store.go @@ -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 +} diff --git a/warehouse/Warehouse.go b/warehouse/Warehouse.go new file mode 100644 index 0000000..44fd55f --- /dev/null +++ b/warehouse/Warehouse.go @@ -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 +} diff --git a/warehouse/readme.md b/warehouse/readme.md new file mode 100644 index 0000000..1368ee5 --- /dev/null +++ b/warehouse/readme.md @@ -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. diff --git a/webapi/API.go b/webapi/API.go index 2b0b2b3..cd4c7a0 100644 --- a/webapi/API.go +++ b/webapi/API.go @@ -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) diff --git a/webapi/Warehouse.go b/webapi/Warehouse.go new file mode 100644 index 0000000..41ab80a --- /dev/null +++ b/webapi/Warehouse.go @@ -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}) +} diff --git a/webapi/readme.md b/webapi/readme.md index 58aa5e0..eea8d9f 100644 --- a/webapi/readme.md +++ b/webapi/readme.md @@ -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`