diff --git a/README.md b/README.md index 94408e3d..4d93404e 100644 --- a/README.md +++ b/README.md @@ -338,11 +338,11 @@ lsd_notify_auth: #### license_status section `license_status`: parameters related to the interactions implemented by the Status server, if any: -- `renting_days`: maximum number of days allowed for a loan, used for laon extensions. The maximum end date is calculated from the date the loan starts plus this value. If set to 0 or absent, no loan renewal is possible. -- `renew`: boolean; if `true`, the renewal of a loan is possible. -- `renew_days`: default number of additional days allowed during a renewal. -- `return`: boolean; if `true`, an early return is possible. -- `register`: boolean; if `true`, registering a device is possible. +- `register`: boolean; if `true`, registering a device is possible; `true` by default. +- `renew`: boolean; if `true`, loan extensions are possible; `false` by default. +- `return`: boolean; if `true`, early returns are possible; `false` by default. +- `renting_days`: maximum number of days allowed for a loan. The maximum end date of a license is based on the date the loan starts, plus this value. No loan extension is possible after this upper limit. Use a large value (20000?) if you operate a subscription model. +- `renew_days`: default number of additional days for a loan extension. It will be overwritten by an explicit attribute of the renew command. - `renew_page_url`: URL template; if set, the renew feature is implemented as an HTML page. This url template supports a `{license_id}`, `{/license_id}` or `{?license_id}` parameter. The final url will be inserted in the 'renew' link of every status document. - `renew_custom_url`: URL template; if set, the renew feature is managed by the license provider. This url template supports a `{license_id}`, `{/license_id}` or `{?license_id}` parameter. The final url will be inserted in the 'renew' link of every status document. diff --git a/api/common_server.go b/api/common_server.go index c4b75567..fb18e8bf 100644 --- a/api/common_server.go +++ b/api/common_server.go @@ -20,7 +20,7 @@ import ( const ( // DO NOT FORGET to update the version - Software_Version = "1.9.2" + Software_Version = "1.9.3" ContentType_LCP_JSON = "application/vnd.readium.lcp.license.v1.0+json" ContentType_LSD_JSON = "application/vnd.readium.license.status.v1.0+json" diff --git a/config/config.go b/config/config.go index 3387f59d..87f8fc37 100644 --- a/config/config.go +++ b/config/config.go @@ -127,6 +127,9 @@ func ReadConfig(configFileName string) { panic("Can't read config file: " + configFileName) } + // Set default values + Config.LicenseStatus.Register = true + err = yaml.Unmarshal(yamlFile, &Config) if err != nil { diff --git a/lcpserver/lcpserver.go b/lcpserver/lcpserver.go index 44b39f14..4f69ccfe 100644 --- a/lcpserver/lcpserver.go +++ b/lcpserver/lcpserver.go @@ -69,12 +69,10 @@ func main() { log.Println("Error loading X509 cert: " + err.Error()) os.Exit(1) } - /* this check is temporarily deactivated. It will be reactivated after a new LCP production lib has been distributed. if config.Config.Profile != "basic" && !license.LCP_PRODUCTION_LIB { - log.Println("Can't run in production mode, not built with the proper lib") + log.Println("Can't run in production mode, server built with a test LCP lib") os.Exit(1) } - */ if config.Config.Profile == "basic" { log.Println("Server running in test mode") } else { diff --git a/lsdserver/api/license_status.go b/lsdserver/api/license_status.go index e67eb9fe..0ad87ca8 100644 --- a/lsdserver/api/license_status.go +++ b/lsdserver/api/license_status.go @@ -348,9 +348,12 @@ func LendingReturn(w http.ResponseWriter, r *http.Request, s Server) { // LendingRenewal checks that the calling device is registered with the license, // then modifies the end date associated with the license // and returns an updated license status to the caller. -// the 'end' parameter is optional; if absent, the end date is computed from -// the current end date plus a configuration parameter. // Note: as per the spec, a non-registered device can renew a loan. +// +// parameters: +// +// key: license id +// end: the new end date for the license (optional) func LendingRenewal(w http.ResponseWriter, r *http.Request, s Server) { w.Header().Set("Content-Type", api.ContentType_LSD_JSON) vars := mux.Vars(r) @@ -500,6 +503,162 @@ func LendingRenewal(w http.ResponseWriter, r *http.Request, s Server) { } } +// ExtendSubscription extends the lifetime of a subscription license. +// It can re-activate an expired license (but not a returned or cancelled/revoked one); +// this allows the extension to be made after a trial period as ended. +// +// parameters: +// +// key: license id +// end: the new end date for the license (optional) +func ExtendSubscription(w http.ResponseWriter, r *http.Request, s Server) { + w.Header().Set("Content-Type", api.ContentType_LSD_JSON) + vars := mux.Vars(r) + + var msg string + + // get the license status by license id + licenseID := vars["key"] + + // add a log + logging.Print("Extend the Subscription for License " + licenseID) + + // get the license status + licenseStatus, err := s.LicenseStatuses().GetByLicenseID(licenseID) + if err != nil { + if licenseStatus == nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusNotFound) + return + } + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + return + } + + // the max end date must be set + if licenseStatus.PotentialRights == nil || licenseStatus.PotentialRights.End == nil { + msg := "The maximum end date must be set" + problem.Error(w, r, problem.Problem{Type: problem.RETURN_BAD_REQUEST, Detail: msg}, http.StatusBadRequest) + return + } + + // extension is impossible if the status is revoked, cancelled or returned + if licenseStatus.Status == status.STATUS_REVOKED || licenseStatus.Status == status.STATUS_CANCELLED || licenseStatus.Status == status.STATUS_RETURNED { + msg := "The license cannot be extended as it is " + licenseStatus.Status + problem.Error(w, r, problem.Problem{Type: problem.RETURN_BAD_REQUEST, Detail: msg}, http.StatusBadRequest) + return + } + + // check if the license contains a date end property + var currentEnd time.Time + if licenseStatus.CurrentEndLicense == nil || (*licenseStatus.CurrentEndLicense).IsZero() { + msg = "This license has no current end date; it cannot be extended" + problem.Error(w, r, problem.Problem{Type: problem.RENEW_BAD_REQUEST, Detail: msg}, http.StatusForbidden) + return + } + currentEnd = *licenseStatus.CurrentEndLicense + log.Print("Current end date " + currentEnd.UTC().Format(time.RFC3339)) + if licenseStatus.Status == status.STATUS_EXPIRED { + log.Println("This license had expired and will be re-activated") + } + + var suggestedEnd time.Time + // check if the 'end' request parameter is empty + timeEndString := r.FormValue("end") + if timeEndString == "" { + // get the config parameter renew_days + renewDays := config.Config.LicenseStatus.RenewDays + if renewDays == 0 { + msg = "No explicit end value and no configured value" + problem.Error(w, r, problem.Problem{Detail: msg}, http.StatusInternalServerError) + return + } + // compute a suggested duration from the config value + suggestedDuration := 24 * time.Hour * time.Duration(renewDays) // nanoseconds + + // compute the suggested end date from the current end date + suggestedEnd = currentEnd.Add(time.Duration(suggestedDuration)) + log.Print("Default extension request until ", suggestedEnd.UTC().Format(time.RFC3339)) + + // if the 'end' request parameter is set + } else { + var err error + suggestedEnd, err = time.Parse(time.RFC3339, timeEndString) + if err != nil { + problem.Error(w, r, problem.Problem{Type: problem.RENEW_BAD_REQUEST, Detail: err.Error()}, http.StatusBadRequest) + return + } + log.Print("Explicit extension request until ", suggestedEnd.UTC().Format(time.RFC3339)) + } + + // check the suggested end date vs the max end date (which is already set in our implementation) + //log.Print("Potential rights end = ", licenseStatus.PotentialRights.End.UTC().Format(time.RFC3339)) + if suggestedEnd.After(*licenseStatus.PotentialRights.End) { + msg := "Attempt to extend with a date greater than max end = " + licenseStatus.PotentialRights.End.UTC().Format(time.RFC3339) + problem.Error(w, r, problem.Problem{Type: problem.RENEW_REJECT, Detail: msg}, http.StatusForbidden) + return + } + // check the suggested end date vs the current end date + if suggestedEnd.Before(currentEnd) { + msg := "Attempt to extend with a date before the current end date" + problem.Error(w, r, problem.Problem{Type: problem.RENEW_REJECT, Detail: msg}, http.StatusForbidden) + return + } + + // add a log + logging.Print("License extended until " + suggestedEnd.UTC().Format(time.RFC3339)) + + // create a renew event with a static device name + event := makeEvent(status.EVENT_RENEWED, "subscription", "suscription", licenseStatus.ID) + err = s.Transactions().Add(*event, status.EVENT_RENEWED_INT) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + return + } + + // update a license via a call to the lcp Server + var httpStatusCode int + httpStatusCode, err = updateLicense(suggestedEnd, licenseID) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + return + } + if httpStatusCode != http.StatusOK && httpStatusCode != http.StatusPartialContent { // 200, 206 + err = errors.New("LCP license PATCH returned HTTP error code " + strconv.Itoa(httpStatusCode)) + + problem.Error(w, r, problem.Problem{Type: problem.REGISTRATION_BAD_REQUEST, Detail: err.Error()}, httpStatusCode) + return + } + // update the license status fields; the status is active + licenseStatus.Status = status.STATUS_ACTIVE + licenseStatus.CurrentEndLicense = &suggestedEnd + licenseStatus.Updated.Status = &event.Timestamp + licenseStatus.Updated.License = &event.Timestamp + log.Print("Update timestamp ", event.Timestamp.UTC().Format(time.RFC3339)) + + // update the license status in db + err = s.LicenseStatuses().Update(*licenseStatus) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + return + } + + // fill the localized 'message', the 'links' and 'event' objects in the license status + err = fillLicenseStatus(licenseStatus, s) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + return + } + // return the updated license status to the caller + // the device count must not be sent in json to the caller + licenseStatus.DeviceCount = nil + enc := json.NewEncoder(w) + err = enc.Encode(licenseStatus) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + return + } +} + // FilterLicenseStatuses returns a sequence of license statuses, in their id order // function for detecting licenses which used a lot of devices func FilterLicenseStatuses(w http.ResponseWriter, r *http.Request, s Server) { diff --git a/lsdserver/server/server.go b/lsdserver/server/server.go index 7a6388db..73919b6e 100644 --- a/lsdserver/server/server.go +++ b/lsdserver/server/server.go @@ -74,6 +74,7 @@ func New(bindAddr string, readonly bool, goofyMode bool, lst *licensestatuses.Li s.handleFunc(licenseRoutes, "/{key}/return", apilsd.LendingReturn).Methods("PUT") s.handleFunc(licenseRoutes, "/{key}/renew", apilsd.LendingRenewal).Methods("PUT") s.handlePrivateFunc(licenseRoutes, "/{key}/status", apilsd.LendingCancellation, basicAuth).Methods("PATCH") + s.handlePrivateFunc(licenseRoutes, "/{key}/extend", apilsd.ExtendSubscription, basicAuth).Methods("PUT") s.handlePrivateFunc(sr.R, "/licenses", apilsd.CreateLicenseStatusDocument, basicAuth).Methods("PUT") s.handlePrivateFunc(licenseRoutes, "/", apilsd.CreateLicenseStatusDocument, basicAuth).Methods("PUT")