Skip to content

Commit

Permalink
Merge pull request #337 from readium/subscription
Browse files Browse the repository at this point in the history
New private endpoint /extend, for easing subscription management, plus some clarifications.
  • Loading branch information
llemeurfr authored Aug 27, 2024
2 parents 799e77a + 564b928 commit ada7849
Show file tree
Hide file tree
Showing 6 changed files with 172 additions and 11 deletions.
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion api/common_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 1 addition & 3 deletions lcpserver/lcpserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
163 changes: 161 additions & 2 deletions lsdserver/api/license_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions lsdserver/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down

0 comments on commit ada7849

Please sign in to comment.