github.com/readium/readium-lcp-server@v0.0.0-20240509124024-799e77a0bbd6/lcpserver/api/store.go (about)

     1  // Copyright 2020 Readium Foundation. All rights reserved.
     2  // Use of this source code is governed by a BSD-style license
     3  // that can be found in the LICENSE file exposed on Github (readium) in the project repository.
     4  
     5  package apilcp
     6  
     7  import (
     8  	"crypto/tls"
     9  	"encoding/json"
    10  	"errors"
    11  	"fmt"
    12  	"io"
    13  	"io/ioutil"
    14  	"net/http"
    15  	"net/url"
    16  	"os"
    17  	"strconv"
    18  
    19  	"github.com/gorilla/mux"
    20  
    21  	"github.com/readium/readium-lcp-server/api"
    22  	"github.com/readium/readium-lcp-server/index"
    23  	"github.com/readium/readium-lcp-server/license"
    24  	"github.com/readium/readium-lcp-server/logging"
    25  	"github.com/readium/readium-lcp-server/pack"
    26  	"github.com/readium/readium-lcp-server/problem"
    27  	"github.com/readium/readium-lcp-server/storage"
    28  )
    29  
    30  // Server groups functions used by the lcp server
    31  type Server interface {
    32  	Store() storage.Store
    33  	Index() index.Index
    34  	Licenses() license.Store
    35  	Certificate() *tls.Certificate
    36  	Source() *pack.ManualSource
    37  }
    38  
    39  // Encrypted is used for communication with the License Server
    40  type Encrypted struct {
    41  	ContentID   string `json:"content-id"`
    42  	ContentKey  []byte `json:"content-encryption-key"`
    43  	StorageMode int    `json:"storage-mode"`
    44  	Output      string `json:"protected-content-location"`
    45  	FileName    string `json:"protected-content-disposition"`
    46  	Size        int64  `json:"protected-content-length"`
    47  	Checksum    string `json:"protected-content-sha256"`
    48  	ContentType string `json:"protected-content-type,omitempty"`
    49  }
    50  
    51  const (
    52  	Storage_none = 0
    53  	Storage_s3   = 1
    54  	Storage_fs   = 2
    55  )
    56  
    57  func writeRequestFileToTemp(r io.Reader) (int64, *os.File, error) {
    58  	dir := os.TempDir()
    59  	file, err := ioutil.TempFile(dir, "readium-lcp")
    60  	if err != nil {
    61  		return 0, file, err
    62  	}
    63  
    64  	n, err := io.Copy(file, r)
    65  
    66  	// Rewind to the beginning of the file
    67  	file.Seek(0, 0)
    68  
    69  	return n, file, err
    70  }
    71  
    72  func cleanupTempFile(f *os.File) {
    73  	if f == nil {
    74  		return
    75  	}
    76  	f.Close()
    77  	os.Remove(f.Name())
    78  }
    79  
    80  // StoreContent stores content passed through the request body into the storage.
    81  // The content name is given in the url (name)
    82  // A temporary file is created, then deleted after the content has been stored.
    83  // This function is using an async task.
    84  func StoreContent(w http.ResponseWriter, r *http.Request, s Server) {
    85  
    86  	vars := mux.Vars(r)
    87  
    88  	size, f, err := writeRequestFileToTemp(r.Body)
    89  	if err != nil {
    90  		problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest)
    91  		return
    92  	}
    93  
    94  	defer cleanupTempFile(f)
    95  
    96  	t := pack.NewTask(vars["name"], f, size)
    97  	result := s.Source().Post(t)
    98  
    99  	if result.Error != nil {
   100  		problem.Error(w, r, problem.Problem{Detail: result.Error.Error()}, http.StatusBadRequest)
   101  		return
   102  	}
   103  
   104  	// must come *after* w.Header().Add()/Set(), but before w.Write()
   105  	w.WriteHeader(http.StatusCreated)
   106  
   107  	json.NewEncoder(w).Encode(result.ID)
   108  }
   109  
   110  // AddContent adds content to the storage
   111  // lcp spec : store data resulting from an external encryption
   112  // PUT method with PAYLOAD : encrypted publication in json format
   113  // This method adds an encrypted file to a store
   114  // and adds the corresponding decryption key to the database.
   115  // The content_id is taken from  the url.
   116  // The input file is then deleted.
   117  func AddContent(w http.ResponseWriter, r *http.Request, s Server) {
   118  
   119  	// parse the json payload
   120  	vars := mux.Vars(r)
   121  	decoder := json.NewDecoder(r.Body)
   122  	var encrypted Encrypted
   123  	err := decoder.Decode(&encrypted)
   124  	if err != nil {
   125  		http.Error(w, err.Error(), http.StatusBadRequest)
   126  	}
   127  	// get the content ID in the url
   128  	contentID := vars["content_id"]
   129  	if contentID == "" {
   130  		problem.Error(w, r, problem.Problem{Detail: "The content id must be set in the url"}, http.StatusBadRequest)
   131  		return
   132  	}
   133  
   134  	// add a log
   135  	logging.Print("Add publication " + contentID)
   136  
   137  	// if the encrypted publication has not been already stored by lcpencrypt
   138  	if encrypted.StorageMode == Storage_none {
   139  
   140  		// open the encrypted file, use its full path
   141  		file, err := getAndOpenFile(encrypted.Output)
   142  		if err != nil {
   143  			problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest)
   144  			return
   145  		}
   146  		// the input file will be deleted when the function returns
   147  		defer cleanupTempFile(file)
   148  
   149  		// add the file to the storage, named by contentID, without file extension
   150  		_, err = s.Store().Add(contentID, file)
   151  		if err != nil {
   152  			problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest)
   153  			return
   154  		}
   155  	}
   156  
   157  	// insert a row in the database if the content id does not already exist
   158  	// or update the database with a new content key and file location if the content id already exists
   159  	var c index.Content
   160  	c, err = s.Index().Get(contentID)
   161  	c.EncryptionKey = encrypted.ContentKey
   162  	// the Location field contains either the file name (useful during download)
   163  	// or the storage URL of the encrypted, depending the storage mode.
   164  	if encrypted.StorageMode != Storage_none {
   165  		c.Location = encrypted.Output
   166  	} else {
   167  		c.Location = encrypted.FileName
   168  	}
   169  	c.Length = encrypted.Size
   170  	c.Sha256 = encrypted.Checksum
   171  	c.Type = encrypted.ContentType
   172  
   173  	code := http.StatusCreated
   174  	if err == index.ErrNotFound { //insert into database
   175  		c.ID = contentID
   176  		err = s.Index().Add(c)
   177  	} else { //update the encryption key for c.ID = encrypted.ContentID
   178  		err = s.Index().Update(c)
   179  		code = http.StatusOK
   180  	}
   181  	if err != nil { //if db not updated
   182  		problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError)
   183  		return
   184  	}
   185  
   186  	// set the response http code
   187  	w.WriteHeader(code)
   188  }
   189  
   190  // ListContents lists the content in the storage index
   191  func ListContents(w http.ResponseWriter, r *http.Request, s Server) {
   192  
   193  	fn := s.Index().List()
   194  	contents := make([]index.Content, 0)
   195  
   196  	for it, err := fn(); err == nil; it, err = fn() {
   197  		contents = append(contents, it)
   198  	}
   199  
   200  	// add a log
   201  	logging.Print("List publications, total " + strconv.Itoa(len(contents)))
   202  
   203  	w.Header().Set("Content-Type", api.ContentType_JSON)
   204  	enc := json.NewEncoder(w)
   205  	err := enc.Encode(contents)
   206  	if err != nil {
   207  		problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest)
   208  		return
   209  	}
   210  
   211  }
   212  
   213  // GetContent fetches and returns an encrypted content file
   214  // selected by it content id (uuid)
   215  // This should be called only if the License Server stores the file.
   216  // If it is not the case, the file should be fetched from a standard web server
   217  func GetContent(w http.ResponseWriter, r *http.Request, s Server) {
   218  
   219  	// get the content id from the calling url
   220  	vars := mux.Vars(r)
   221  	contentID := vars["content_id"]
   222  
   223  	// add a log
   224  	logging.Print("Fetch content " + contentID)
   225  
   226  	content, err := s.Index().Get(contentID)
   227  	if err != nil { //item probably not found
   228  		if err == index.ErrNotFound {
   229  			problem.Error(w, r, problem.Problem{Detail: "Index:" + err.Error(), Instance: contentID}, http.StatusNotFound)
   230  		} else {
   231  			problem.Error(w, r, problem.Problem{Detail: "Index:" + err.Error(), Instance: contentID}, http.StatusInternalServerError)
   232  		}
   233  		return
   234  	}
   235  
   236  	// check the existence of the file
   237  	item, err := s.Store().Get(contentID)
   238  	if err != nil { //item probably not found
   239  		if err == storage.ErrNotFound {
   240  			problem.Error(w, r, problem.Problem{Detail: "Storage:" + err.Error(), Instance: contentID}, http.StatusNotFound)
   241  		} else {
   242  			problem.Error(w, r, problem.Problem{Detail: "Storage:" + err.Error(), Instance: contentID}, http.StatusInternalServerError)
   243  		}
   244  		return
   245  	}
   246  	// opens the file
   247  	contentReadCloser, err := item.Contents()
   248  	if err != nil { //file probably not found
   249  		problem.Error(w, r, problem.Problem{Detail: err.Error(), Instance: contentID}, http.StatusBadRequest)
   250  		return
   251  	}
   252  
   253  	defer contentReadCloser.Close()
   254  
   255  	// set headers
   256  	// If this function is called for a file stored by the encrypting tool, we have to provide a sensible
   257  	// Content-Disposition header, to be used as file name after download.
   258  	hasPubLink, err := isURL(content.Location)
   259  	if err != nil {
   260  		problem.Error(w, r, problem.Problem{Detail: "Content Location:" + err.Error(), Instance: contentID}, http.StatusInternalServerError)
   261  		return
   262  	}
   263  	var filename string
   264  	if hasPubLink {
   265  		// we have not stored the original file name, therefore we use the content id.
   266  		filename = content.ID
   267  	} else {
   268  		// in the initial version of the server, the filename was in this field.
   269  		filename = content.Location
   270  	}
   271  	w.Header().Set("Content-Disposition", "attachment; filename="+filename)
   272  	w.Header().Set("Content-Type", content.Type)
   273  	w.Header().Set("Content-Length", fmt.Sprintf("%d", content.Length))
   274  
   275  	// returns the content of the file to the caller
   276  	io.Copy(w, contentReadCloser)
   277  }
   278  
   279  // DeleteContent deletes a record
   280  func DeleteContent(w http.ResponseWriter, r *http.Request, s Server) {
   281  
   282  	// get the content id from the calling url
   283  	vars := mux.Vars(r)
   284  	contentID := vars["content_id"]
   285  
   286  	// add a log
   287  	logging.Print("Delete publication " + contentID)
   288  
   289  	err := s.Index().Delete(contentID)
   290  	if err != nil { //item probably not found
   291  		if err == index.ErrNotFound {
   292  			problem.Error(w, r, problem.Problem{Detail: "Index:" + err.Error(), Instance: contentID}, http.StatusNotFound)
   293  		} else {
   294  			problem.Error(w, r, problem.Problem{Detail: "Index:" + err.Error(), Instance: contentID}, http.StatusInternalServerError)
   295  		}
   296  		return
   297  	}
   298  	// set the response http code
   299  	w.WriteHeader(http.StatusOK)
   300  
   301  }
   302  
   303  // getAndOpenFile opens a file from a path, or downloads then opens it if its location is a URL
   304  func getAndOpenFile(filePathOrURL string) (*os.File, error) {
   305  
   306  	isURL, err := isURL(filePathOrURL)
   307  	if err != nil {
   308  		return nil, err
   309  	}
   310  
   311  	if isURL {
   312  		return downloadAndOpenFile(filePathOrURL)
   313  	}
   314  
   315  	return os.Open(filePathOrURL)
   316  }
   317  
   318  func downloadAndOpenFile(url string) (*os.File, error) {
   319  	file, _ := ioutil.TempFile("", "")
   320  	fileName := file.Name()
   321  
   322  	err := downloadFile(url, fileName)
   323  
   324  	if err != nil {
   325  		return nil, err
   326  	}
   327  
   328  	return os.Open(fileName)
   329  }
   330  
   331  func isURL(filePathOrURL string) (bool, error) {
   332  	url, err := url.Parse(filePathOrURL)
   333  	if err != nil {
   334  		return false, errors.New("error parsing input string")
   335  	}
   336  	return url.Scheme == "http" || url.Scheme == "https", nil
   337  }
   338  
   339  func downloadFile(url string, targetFilePath string) error {
   340  	out, err := os.Create(targetFilePath)
   341  	if err != nil {
   342  		return err
   343  	}
   344  	defer out.Close()
   345  
   346  	resp, err := http.Get(url)
   347  	if err != nil {
   348  		return err
   349  	}
   350  
   351  	if resp.StatusCode >= 300 {
   352  		return fmt.Errorf("HTTP response: %d %s when downloading %s", resp.StatusCode, resp.Status, url)
   353  	}
   354  
   355  	defer resp.Body.Close()
   356  
   357  	_, err = io.Copy(out, resp.Body)
   358  	if err != nil {
   359  		return err
   360  	}
   361  
   362  	return nil
   363  }