From 4e6f8509d1b95c620e27ac32343baea3195e628e Mon Sep 17 00:00:00 2001 From: Laurent Le Meur Date: Sat, 28 Dec 2024 21:40:02 +0100 Subject: [PATCH] When content is updated, update content info and touches all associated licenses --- lcpencrypt/get_content_info.go | 100 +++++++++++++++++++++++++++++++++ lcpserver/api/store.go | 55 ++++++++++++++++-- lcpserver/server/server.go | 7 ++- license/store.go | 12 ++++ 4 files changed, 167 insertions(+), 7 deletions(-) create mode 100644 lcpencrypt/get_content_info.go diff --git a/lcpencrypt/get_content_info.go b/lcpencrypt/get_content_info.go new file mode 100644 index 00000000..aee9e5e2 --- /dev/null +++ b/lcpencrypt/get_content_info.go @@ -0,0 +1,100 @@ +// Copyright 2024 Readium Foundation. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file exposed on Github (readium) in the project repository. + +package main + +import ( + b64 "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +type ContentInfo struct { + ID string `json:"id"` + EncryptionKey []byte `json:"key,omitempty"` + Location string `json:"location"` + Length int64 `json:"length"` + Sha256 string `json:"sha256"` + Type string `json:"type"` +} + +// getContentKey gets content information from the License Server +// for a given content id, +// and returns the associated content key. +func getContentKey(contentKey *string, contentID, lcpsv string, v2 bool, username, password string) error { + + // An empty notify URL is not an error, simply a silent encryption + if lcpsv == "" { + return nil + } + + if !strings.HasPrefix(lcpsv, "http://") && !strings.HasPrefix(lcpsv, "https://") { + lcpsv = "http://" + lcpsv + } + var getInfoURL string + var err error + if v2 { + getInfoURL, err = url.JoinPath(lcpsv, "publications", contentID, "info") + } else { + getInfoURL, err = url.JoinPath(lcpsv, "contents", contentID, "info") + } + if err != nil { + return err + } + + // look for the username and password in the url + err = getUsernamePassword(&getInfoURL, &username, &password) + if err != nil { + return err + } + + req, err := http.NewRequest("GET", getInfoURL, nil) + if err != nil { + return err + } + + req.SetBasicAuth(username, password) + client := &http.Client{ + Timeout: 15 * time.Second, + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + contentInfo := ContentInfo{} + dec := json.NewDecoder(resp.Body) + err = dec.Decode(&contentInfo) + if err != nil { + return errors.New("unable to decode content information") + } + + *contentKey = b64.StdEncoding.EncodeToString(contentInfo.EncryptionKey) + + fmt.Println("Existing encryption key retrieved") + return nil +} + +// Look for the username and password in the url +func getUsernamePassword(notifyURL, username, password *string) error { + u, err := url.Parse(*notifyURL) + if err != nil { + return err + } + un := u.User.Username() + pw, pwfound := u.User.Password() + if un != "" && pwfound { + *username = un + *password = pw + u.User = nil + *notifyURL = u.String() // notifyURL is updated + } + return nil +} diff --git a/lcpserver/api/store.go b/lcpserver/api/store.go index 7274c807..c95f3921 100644 --- a/lcpserver/api/store.go +++ b/lcpserver/api/store.go @@ -10,7 +10,7 @@ import ( "errors" "fmt" "io" - "io/ioutil" + "log" "net/http" "net/url" "os" @@ -56,7 +56,7 @@ const ( func writeRequestFileToTemp(r io.Reader) (int64, *os.File, error) { dir := os.TempDir() - file, err := ioutil.TempFile(dir, "readium-lcp") + file, err := os.CreateTemp(dir, "readium-lcp") if err != nil { return 0, file, err } @@ -155,9 +155,10 @@ func AddContent(w http.ResponseWriter, r *http.Request, s Server) { } // insert a row in the database if the content id does not already exist - // or update the database with a new content key and file location if the content id already exists + // or update the database with new information if the content id already exists var c index.Content c, err = s.Index().Get(contentID) + // err checked later ... c.EncryptionKey = encrypted.ContentKey // the Location field contains either the file name (useful during download) // or the storage URL of the encrypted, depending the storage mode. @@ -174,9 +175,16 @@ func AddContent(w http.ResponseWriter, r *http.Request, s Server) { if err == index.ErrNotFound { //insert into database c.ID = contentID err = s.Index().Add(c) + // the content id was found in the database } else { //update the encryption key for c.ID = encrypted.ContentID err = s.Index().Update(c) code = http.StatusOK + + if err == nil { + log.Println("Update all license timestamps associated with this publication") + err = s.Licenses().TouchByContentID(contentID) // update all licenses update timestamps + } + } if err != nil { //if db not updated problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) @@ -193,7 +201,9 @@ func ListContents(w http.ResponseWriter, r *http.Request, s Server) { fn := s.Index().List() contents := make([]index.Content, 0) + var razkey []byte // in a list, we don't return the encryption key. for it, err := fn(); err == nil; it, err = fn() { + it.EncryptionKey = razkey contents = append(contents, it) } @@ -210,11 +220,44 @@ func ListContents(w http.ResponseWriter, r *http.Request, s Server) { } -// GetContent fetches and returns an encrypted content file +// GetContentInfo returns information about the encrypted content, +// especially the encryption key. +// Used by the encryption utility when the file to encrypt is an update of an existing encrypted publication. +func GetContentInfo(w http.ResponseWriter, r *http.Request, s Server) { + // get the content id from the calling url + vars := mux.Vars(r) + contentID := vars["content_id"] + + // add a log + logging.Print("Get content info " + contentID) + + // get the info + content, err := s.Index().Get(contentID) + if err != nil { //item probably not found + if err == index.ErrNotFound { + problem.Error(w, r, problem.Problem{Detail: "Index:" + err.Error(), Instance: contentID}, http.StatusNotFound) + } else { + problem.Error(w, r, problem.Problem{Detail: "Index:" + err.Error(), Instance: contentID}, http.StatusInternalServerError) + } + return + } + + // return the info + w.Header().Set("Content-Type", api.ContentType_JSON) + enc := json.NewEncoder(w) + err = enc.Encode(content) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + return + } + +} + +// GetContentFile fetches and returns an encrypted content file // selected by it content id (uuid) // This should be called only if the License Server stores the file. // If it is not the case, the file should be fetched from a standard web server -func GetContent(w http.ResponseWriter, r *http.Request, s Server) { +func GetContentFile(w http.ResponseWriter, r *http.Request, s Server) { // get the content id from the calling url vars := mux.Vars(r) @@ -316,7 +359,7 @@ func getAndOpenFile(filePathOrURL string) (*os.File, error) { } func downloadAndOpenFile(url string) (*os.File, error) { - file, _ := ioutil.TempFile("", "") + file, _ := os.CreateTemp("", "") fileName := file.Name() err := downloadFile(url, fileName) diff --git a/lcpserver/server/server.go b/lcpserver/server/server.go index de4dadca..2a1db76c 100644 --- a/lcpserver/server/server.go +++ b/lcpserver/server/server.go @@ -114,10 +114,15 @@ func New(bindAddr string, readonly bool, idx *index.Index, st *storage.Store, ls s.handleFunc(sr.R, contentRoutesPathPrefix, apilcp.ListContents).Methods("GET") + // Public routes // get encrypted content by content id (a uuid) - s.handleFunc(contentRoutes, "/{content_id}", apilcp.GetContent).Methods("GET") + s.handleFunc(contentRoutes, "/{content_id}", apilcp.GetContentFile).Methods("GET") + + // Private routes // get all licenses associated with a given content s.handlePrivateFunc(contentRoutes, "/{content_id}/licenses", apilcp.ListLicensesForContent, basicAuth).Methods("GET") + // get content information by content id (a uuid) + s.handlePrivateFunc(contentRoutes, "/{content_id}/info", apilcp.GetContentInfo, basicAuth).Methods("GET") if !readonly { // create a publication diff --git a/license/store.go b/license/store.go index cd0bf7af..aa4b45a3 100644 --- a/license/store.go +++ b/license/store.go @@ -25,6 +25,7 @@ type Store interface { UpdateLsdStatus(id string, status int32) error Add(l License) error Get(id string) (License, error) + TouchByContentID(ContentID string) error } type sqlStore struct { @@ -160,6 +161,17 @@ func (s *sqlStore) Get(id string) (License, error) { return l, err } +// TouchByContentID updates the updated field of all licenses for a given contentID +func (s *sqlStore) TouchByContentID(contentID string) error { + + _, err := s.db.Exec(dbutils.GetParamQuery(config.Config.LcpServer.Database, `UPDATE license SET updated=? WHERE content_fk=?`), + time.Now().UTC().Truncate(time.Second), contentID) + if err != nil { + log.Println("Error touching licenses for contentID", contentID) + } + return err +} + // Open func Open(db *sql.DB) (store Store, err error) {