diff --git a/.gitignore b/.gitignore index 84c14060..3ce6bd97 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,23 @@ readium-lcp-server lcpencrypt/lcpencrypt files/ *.sqlite* +config.yaml +lcpserver/manage/config.js +.vscode/launch.json +debug +*.exe +*.yaml +**/manage/config.js +frontend/manage/node_modules/* +frontend/manage/dist/* +frontend/manage/js/* +frontend/manage/js/components/* +*.map +*.htpasswd +lcpserver/test.sqllite +.DS_Store +.vscode +lcpencrypt/.DS_Store +lcpserver/.DS_Store +npm-debug.log +manage/config.js \ No newline at end of file diff --git a/README.md b/README.md index 8576c8a2..e058dc58 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,5 @@ Readium LCP Server -======== - -This server allows you to both encrypt EPUBs as well as deliver licenses in accordance with the Readium LCP specification. +================== Requirements ============ @@ -9,45 +7,182 @@ Requirements No binaries are currently pre-built, so you need to get a working Golang installation. Please refer to the official documentation for installation procedures at https://golang.org/. -In order to keep the content keys for each encrypted EPUB, the server requires a SQL Database. The server currently includes drivers -for SQLite (the default option, and should be fine for small to medium installations) as well as MySQL and Postgres. +In order to keep the content keys for each encrypted EPUB, the server requires an SQL Database. The server currently includes drivers +for SQLite (the default option, which should be fine for small to medium installations) as well as MySQL and Postgres. -If you wish to use the external licenses, where a client gets a simple json file that contains instructions on how to fetch the encrypted EPUBS, +If you wish to use the external licenses, where a client gets a simple json file that contains instructions on how to fetch the encrypted EPUB file, a publicly accessible folder must be made available for the server to store the file. -You must obtain a X.509 certificate through the Readium Foundation in order for your licenses to be accepted by the Reading Systems. +You must obtain a X.509 certificate through EDRLab in order for your licenses to be accepted by Readium LCP compliant Reading Systems. -Install -====== +Executables +=========== +The server software is composed of three independant parts: -Assuming a working Go installation, +## [lcpencrypt] -go get github.com/readium/readium-lcp-server +A command line utility for EPUB content encryption. This utility can be included in any processing pipeline. + +* takes one unprotected EPUB 3 file as input and generates an encrypted file as output. +* notifies the License server of the generation of an encrypted file. + +## [lcpserver] + +A License server, which implements Readium Licensed Content Protection 1.0. -Usage -===== +Private functionalities (authentication needed): +* Store the data resulting from an external encryption +* Generate a license +* Generate a protected publication +* Update the rights associated with a license +* Get a set of licenses +* Get a license -*Please note that the LCP Server currently does not include any authentication. Make sure it is only available to your internal services or add an authenticating -proxy in front of it* -The server is controlled by a set of environment variables. Here are their descriptions and possible values: +## [lsdserver] -- CERT - Points to the certificate file (a .crt) -- PRIVATE_KEY - Points to the private key (a .pem) -- PORT - Where lcpserve will listen, by default 8989 -- HOST - The public hostname, defaults to `hostname` -- READONLY - Readonly mode for demo purposes, no new file can be packaged -- DB - the connection string to the database, by default sqlite3://file:lcpserve.sqlite?cache=shared&mode=rwc +A License Status server, which implements Readium License Status Document 1.0. -You can also use a YAML config file named config.yaml, which follows the structure presented in config.yaml.sample +Public functionalities (accessible from the web): +* Return a license status document +* Process a device registration +* Process a lending return +* Process a lending renewal +Private functionalities (authentication needed): +* Create a license status document +* Filter licenses +* List all registered devices for a given licence +* Revoke/cancel a license -Once those are set, you can run the server by calling the following: -$GOPATH/bin/readium-lcp-server +Install +======= + +Assuming a working Go installation, the following will install the three executables that constitute a complete Readium LCP Server. + +If you want to use the master branch: +```sh +// from the go workspace +cd $GOPATH +// get the different packages and their dependencies, then installs the packages +go get github.com/readium/readium-lcp-server +``` + +If you want to use a feature/F branch: +```sh +// from the go workspace +cd $GOPATH +// create the project repository +mkdir -p src/github.com/readium/readium-lcp-server +// clone the repo, selecting the development branch +git clone -b feature/F https://github.com/readium/readium-lcp-server.git src/github.com/readium/readium-lcp-server +// move to the project repository +cd src/github.com/readium/readium-lcp-server +// get the different packages and their dependencies, then installs the packages (dot / triple dot pattern) +go get ./... +``` + +You may prefer to install only some of the three executables. +In such a case, the "go get" command should be called once for each package, e.g. for the lcpserver from the master branch: +```sh +// from the go workspace +cd $GOPATH +// get the different packages and their dependencies, then installs the packages +go get github.com/readium/readium-lcp-server/lcpserver +``` + +Server Configuration +==================== + +The server is controlled by a yaml configuration file (e.g. "config.yaml"). +The License Server and License Status Server will search their configuration file in the bin directory by default; +but the path to this file can be changed using the environment variable READIUM_LICENSE_CONFIG. +The License Server and License Status Server may share the same configuration file (if they are both executed on the same server) +or they can have their own configucation file. In the first case, the htpasswd file and database may also be shared. +In the latter case, the License Server will have a "lcp" section and a "lsd_notify_auth" section; +the License Status Server will have a "lsd" section and a "lcp_update_auth" section. + +"certificate": parameters related to the signature of the licenses +- "cert": the provider certificate file (.pem or .crt). It will be inserted in the licenses and used by clients for checking the signature. +- "private_key": the private key (.pem). It will be used for signing licenses. + +"lcp" (License Server) & "lsd" (License Status Server) sections have an identical structure: +- "host": the public server hostname, `hostname` by default +- "port": the listening port, `8989` by default +- "public_base_url": the public base URL, combination of the host and port values on http by default +- "database": the URI formatted connection string to the database, `sqlite3://file:lcpserve.sqlite?cache=shared&mode=rwc` by default +- "auth_file": mandatory; the authentication file (an .htpasswd). Passwords must be encrypted using MD5. + The source example for creating password is http://www.htaccesstools.com/htpasswd-generator/. + The format of the file is: +```sh + User1:$apr1$OMWGq53X$Qf17b.ezwEM947Vrr/oTh0 + User2:$apr1$lldfYQA5$8fVeTVyKsiPeqcBWrjBKMT +``` + +"lsd_notify_auth": authentication parameters used by the License Server for notifying the License Status Server +of a license generation. The notification endpoint is configured in the "lsd" section. +- "username": mandatory, authentication username +- "password": mandatory, authentication password + +"lcp_update_auth": authentication parameters used by the License Status Server for updating a license via the License Server. +The notification endpoint is configured in the "lcp" section. +- "username": mandatory, authentication username +- "password": mandatory, authentication password + +"storage": parameters related to the storage of the protected publications. +- "filesystem": parameters related to a file system storage + - "directory": absolute path to the directory in which the protected publications are stored. + +"license": parameters related to static information to be included in all licenses generated by the License Server +- "links": links that will be included in all licenses. "hint" and "publication" links are required in a Readium LCP license. + If no such link exists in the partial license passed from the frontend when a new license his requested, + these link values will be inserted in the partial license. + If no value is present in the configuration file and no value is inserted in the partial license, + the License server will reply with a 500 Server Error at license creation. + - "hint": required; location where a Reading System can redirect a User looking for additional information about the User Passphrase. + - "publication": optional, templated URL; + location where the Publication associated with the License Document can be downloaded. + The publication identifier is inserted via the variable {publication_id}. + - "status": optional, templated URL; location of the Status Document associated with a License Document. + The license identifier is inserted via the variable {license_id}. + +NOTE: here is a license section snippet: +```json +license: + links: + hint: "http://www.edrlab.org/readiumlcp/static/hint.html" + publication: "http://www.edrlab.org/readiumlcp/files/{publication_id}" + status: "http://www.edrlab.org/readiumlcp/licenses/{license_id}/status" +``` + +"license_status": parameters related to the interactions implemented by the License Status server, if any +- renting_days: number of days be the license ends. +- renew: boolean; if `true`, rental renewal is possible. +- renew_days: number of days added to the license if renewal is active. +- return: boolean; if `true`, early return is possible. +- register: boolean; if `true`, registering a device is possible. + +"localization": parameters related to the localization of the messages sent by the server +- languages: array of supported localization languages +- folder: point to localization file (a .json) +- default_language: default language for localization + +NOTE: list files for localization (ex: 'en-US.json, de-DE.json') must match the array of supported localization languages + +"logging": parameters for logging results of API methods +- log_directory: point to log file (a .log). +- compliance_tests_mode_on: boolean; if `true`, logging is turned on. + + +The following CBC / GCM configurable property is DISABLED, see https://github.com/readium/readium-lcp-server/issues/109 +"aes256_cbc_or_gcm": either "GCM" or "CBC" (which is the default value). This is used only for encrypting publication resources, not the content key, not the user key check, not the LCP license fields. + + +Documentation +============ +Detailed documentation can be found in the [Wiki pages](../../wiki) of the project. -The server includes a basic web interface that can be reached at http://HOST:PORT/manage/. You can drag and drop EPUB files to encrypt them, -as well as emit licenses for the currently encrypted EPUBs. Contributing ============ diff --git a/api/common_server.go b/api/common_server.go new file mode 100644 index 00000000..3d5bc320 --- /dev/null +++ b/api/common_server.go @@ -0,0 +1,166 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package api + +import ( + "log" + "fmt" + "net/http" + + "github.com/abbot/go-http-auth" + "github.com/gorilla/mux" + "github.com/urfave/negroni" + "github.com/jeffbmartinez/delay" + "github.com/technoweenie/grohl" + "github.com/rs/cors" + + "github.com/readium/readium-lcp-server/problem" +) + +const ( + ContentType_LCP_JSON = "application/vnd.readium.lcp.license.1.0+json" + ContentType_LSD_JSON = "application/vnd.readium.license.status.v1.0+json" + + ContentType_JSON = "application/json" + + ContentType_FORM_URL_ENCODED = "application/x-www-form-urlencoded" +) + +type ServerRouter struct { + R *mux.Router + N *negroni.Negroni +} + +func CreateServerRouter(tplPath string) ServerRouter { + + r := mux.NewRouter() + + r.NotFoundHandler = http.HandlerFunc(problem.NotFoundHandler) //handle all other requests 404 + + // this demonstrates a panic report + r.HandleFunc("/panic", func(w http.ResponseWriter, req *http.Request) { + panic("just testing. no worries.") + }) + + //n := negroni.Classic() == negroni.New(negroni.NewRecovery(), negroni.NewLogger(), negroni.NewStatic(...)) + n := negroni.New() + + // HTTP client can emit requests with custom header: + //X-Add-Delay: 300ms + //X-Add-Delay: 2.5s + n.Use(delay.Middleware{}) + + // possibly useful middlewares: + // https://github.com/jeffbmartinez/delay + + //https://github.com/urfave/negroni#recovery + recovery := negroni.NewRecovery() + recovery.PrintStack = true + recovery.ErrorHandlerFunc = problem.PanicReport + n.Use(recovery) + + //https://github.com/urfave/negroni#logger + n.Use(negroni.NewLogger()) + + n.Use(negroni.HandlerFunc(ExtraLogger)) + + if tplPath != "" { + //https://github.com/urfave/negroni#static + n.Use(negroni.NewStatic(http.Dir(tplPath))) + } + + n.Use(negroni.HandlerFunc(CORSHeaders)) + // Does not insert CORS headers as intended, depends on Origin check in the HTTP request...we want the same headers, always. + // IMPORT "github.com/rs/cors" + // //https://github.com/rs/cors#parameters + c := cors.New(cors.Options{ + AllowedOrigins: []string{"*"}, + AllowedMethods: []string{"PATCH", "HEAD", "POST", "GET", "OPTIONS", "PUT", "DELETE"}, + AllowedHeaders: []string{"Range", "Content-Type", "Origin", "X-Requested-With", "Accept", "Accept-Language", "Content-Language", "Authorization"}, + Debug: true, + }) + n.Use(c) + + n.UseHandler(r) + + sr := ServerRouter{ + R: r, + N: n, + } + + return sr +} + +func ExtraLogger(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + + log.Print(" << -------------------") + + fmt.Printf("%s => %s (%s)\n", r.RemoteAddr, r.URL.String(), r.RequestURI) + + grohl.Log(grohl.Data{"method": r.Method, "path": r.URL.Path, "query": r.URL.RawQuery}) + + log.Printf("REQUEST headers: %#v", r.Header) + + // before + next(rw, r) + // after + + contentType := rw.Header().Get("Content-Type"); + if contentType == problem.ContentType_PROBLEM_JSON { + log.Print("^^^^ " + problem.ContentType_PROBLEM_JSON + " ^^^^") + } + + log.Printf("RESPONSE headers: %#v", rw.Header()) + + log.Print(" >> -------------------") +} + +func CORSHeaders(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + + grohl.Log(grohl.Data{"CORS": "yes"}) + rw.Header().Add("Access-Control-Allow-Methods", "PATCH, HEAD, POST, GET, OPTIONS, PUT, DELETE") + rw.Header().Add("Access-Control-Allow-Credentials", "true") + rw.Header().Add("Access-Control-Allow-Origin", "*") + rw.Header().Add("Access-Control-Allow-Headers", "Range, Content-Type, Origin, X-Requested-With, Accept, Accept-Language, Content-Language, Authorization") + + // before + next(rw, r) + // after + + // noop +} + +func CheckAuth(authenticator *auth.BasicAuth, w http.ResponseWriter, r *http.Request) bool { + var username string + if username = authenticator.CheckAuth(r); username == "" { + grohl.Log(grohl.Data{"error": "Unauthorized", "method": r.Method, "path": r.URL.Path}) + w.Header().Set("WWW-Authenticate", `Basic realm="`+authenticator.Realm+`"`) + problem.Error(w, r, problem.Problem{Detail: "User or password do not match!"}, http.StatusUnauthorized) + return false + } + grohl.Log(grohl.Data{"user": username}) + return true +} diff --git a/api/struct.go b/api/struct.go new file mode 100644 index 00000000..b348fd6a --- /dev/null +++ b/api/struct.go @@ -0,0 +1,38 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package api + +/* +type Problem struct { + Type string `json:"type"` + //optionnal + Title string `json:"title,omitempty"` + Status int `json:"status,omitempty"` //if present = http response code + Detail string `json:"detail,omitempty"` + Instance string `json:"instance,omitempty"` + //Additional members +} +*/ diff --git a/authentication/md5password/makeMd5Password.go b/authentication/md5password/makeMd5Password.go new file mode 100644 index 00000000..18ec12f2 --- /dev/null +++ b/authentication/md5password/makeMd5Password.go @@ -0,0 +1,59 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package main + +import ( + "log" + "math/rand" + "os" + "strconv" + "time" + + "github.com/abbot/go-http-auth" +) + +func main() { + + var salt []byte + var magic []byte + if len(os.Args) < 2 { + panic("need a password") + } + if len(os.Args) > 2 { + salt = []byte(os.Args[2]) + } else { + r := rand.New(rand.NewSource(int64(time.Now().Unix()))) + salt = []byte(strconv.Itoa(r.Int())) + } + if len(os.Args) > 3 { + magic = []byte("$" + string(os.Args[3]) + "$") + } else { + magic = []byte("$" + "$") + } + + log.Println(string(auth.MD5Crypt([]byte(os.Args[1]), salt, magic))) + +} diff --git a/authentication/passwords.htpasswd b/authentication/passwords.htpasswd new file mode 100644 index 00000000..c4dbd4e7 --- /dev/null +++ b/authentication/passwords.htpasswd @@ -0,0 +1,3 @@ +Hanna:$apr1$OMWGq53X$Qf17b.ezwEM947Vrr/oTh0 +User:$apr1$lldfYQA5$8fVeTVyKsiPeqcBWrjBKM. +Stefaan:$$8812069051407036158$00w3pIjfRqawvNxw9gzZs1 \ No newline at end of file diff --git a/config.yaml.sample b/config.yaml.sample deleted file mode 100644 index f3de7c03..00000000 --- a/config.yaml.sample +++ /dev/null @@ -1,7 +0,0 @@ -database: sqlite3://file:test.sqlite?cache=shared&mode=rwc -certificate: - cert: /home/banux/certs/lcp.crt - private_key: /home/banux/certs/lcp.pem -storage: - filesystem: - directory: /home/www/files diff --git a/config/config.go b/config/config.go new file mode 100644 index 00000000..7ebf49b7 --- /dev/null +++ b/config/config.go @@ -0,0 +1,193 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package config + +import ( + "io/ioutil" + "os" + "path/filepath" + "strconv" + + "gopkg.in/yaml.v2" +) + +type Configuration struct { + Certificate Certificate `yaml:"certificate"` + Storage Storage `yaml:"storage"` + License License `yaml:"license"` + LcpServer ServerInfo `yaml:"lcp"` + LsdServer LsdServerInfo `yaml:"lsd"` + FrontendServer FrontendServerInfo `yaml:"frontend"` + LsdNotifyAuth Auth `yaml:"lsd_notify_auth"` + LcpUpdateAuth Auth `yaml:"lcp_update_auth"` + LicenseStatus LicenseStatus `yaml:"license_status"` + Localization Localization `yaml:"localization"` + Logging Logging `yaml:"logging"` + + // DISABLED, see https://github.com/readium/readium-lcp-server/issues/109 + //AES256_CBC_OR_GCM string `yaml:"aes256_cbc_or_gcm,omitempty"` +} + +type ServerInfo struct { + Host string `yaml:"host,omitempty"` + Port int `yaml:"port,omitempty"` + AuthFile string `yaml:"auth_file"` + ReadOnly bool `yaml:"readonly,omitempty"` + PublicBaseUrl string `yaml:"public_base_url,omitempty"` + Database string `yaml:"database,omitempty"` + Directory string `yaml:"directory,omitempty"` +} + +type LsdServerInfo struct { + ServerInfo `yaml:",inline"` + LicenseLinkUrl string `yaml:"license_link_url,omitempty"` +} + +type FrontendServerInfo struct { + ServerInfo `yaml:",inline"` + ProviderID string `yaml:"provider_id"` + MasterRepository string `yaml:"master_repository"` + EncryptedRepository string `yaml:"encrypted_repository"` +} + +type Auth struct { + Username string `yaml:"username"` + Password string `yaml:"password"` +} + +type Certificate struct { + Cert string `yaml:"cert"` + PrivateKey string `yaml:"private_key"` +} + +type FileSystem struct { + Directory string `yaml:"directory"` +} + +type Storage struct { + FileSystem FileSystem `yaml:"filesystem"` + AccessId string `yaml:"access_id"` + DisableSSL bool `yaml:"disable_ssl"` + PathStyle bool `yaml:"path_style"` + Mode string + Secret string + Endpoint string + Bucket string + Region string + Token string +} + +type License struct { + Links map[string]string `yaml:"links"` +} + +type LicenseStatus struct { + Renew bool `yaml:"renew"` + Register bool `yaml:"register"` + Return bool `yaml:"return"` + RentingDays int `yaml:"renting_days" "default 0"` + RenewDays int `yaml:"renew_days" "default 0"` +} + +type Localization struct { + Languages []string `yaml:"languages"` + Folder string `yaml:"folder"` + DefaultLanguage string `yaml:"default_language"` +} + +type Logging struct { + LogDirectory string `yaml:"log_directory"` + ComplianceTestsModeOn bool `yaml:"compliance_tests_mode_on"` +} + +var Config Configuration + +func ReadConfig(configFileName string) { + filename, _ := filepath.Abs(configFileName) + yamlFile, err := ioutil.ReadFile(filename) + + if err != nil { + panic("Can't read config file: " + configFileName) + } + + err = yaml.Unmarshal(yamlFile, &Config) + + if err != nil { + panic("Can't unmarshal config. " + configFileName + " -> " + err.Error()) + } +} + +func SetPublicUrls() error { + var lcpPublicBaseUrl, lsdPublicBaseUrl, frontendPublicBaseUrl, lcpHost, lsdHost, frontendHost string + var lcpPort, lsdPort, frontendPort int + var err error + + if lcpHost = Config.LcpServer.Host; lcpHost == "" { + lcpHost, err = os.Hostname() + if err != nil { + return err + } + } + + if lsdHost = Config.LsdServer.Host; lsdHost == "" { + lsdHost, err = os.Hostname() + if err != nil { + return err + } + } + + if frontendHost = Config.FrontendServer.Host; frontendHost == "" { + frontendHost, err = os.Hostname() + if err != nil { + return err + } + } + + if lcpPort = Config.LcpServer.Port; lcpPort == 0 { + lcpPort = 8989 + } + if lsdPort = Config.LsdServer.Port; lsdPort == 0 { + lsdPort = 8990 + } + if frontendPort = Config.FrontendServer.Port; frontendPort == 0 { + frontendPort = 80 + } + + if lcpPublicBaseUrl = Config.LcpServer.PublicBaseUrl; lcpPublicBaseUrl == "" { + lcpPublicBaseUrl = "http://" + lcpHost + ":" + strconv.Itoa(lcpPort) + Config.LcpServer.PublicBaseUrl = lcpPublicBaseUrl + } + if lsdPublicBaseUrl = Config.LsdServer.PublicBaseUrl; lsdPublicBaseUrl == "" { + lsdPublicBaseUrl = "http://" + lsdHost + ":" + strconv.Itoa(lsdPort) + Config.LsdServer.PublicBaseUrl = lsdPublicBaseUrl + } + if frontendPublicBaseUrl = Config.FrontendServer.PublicBaseUrl; frontendPublicBaseUrl == "" { + frontendPublicBaseUrl = "http://" + frontendHost + ":" + strconv.Itoa(frontendPort) + Config.FrontendServer.PublicBaseUrl = frontendPublicBaseUrl + } + + return err +} diff --git a/crypto/aes_cbc.go b/crypto/aes_cbc.go new file mode 100644 index 00000000..40dbdd92 --- /dev/null +++ b/crypto/aes_cbc.go @@ -0,0 +1,112 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package crypto + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "io" +) + +type cbcEncrypter struct{} + +const ( + aes256keyLength = 32 // 256 bits +) + +func (e cbcEncrypter) Signature() string { + // W3C padding scheme, not PKCS#7 (see last parameter "insertPadLengthAll" [false] of PaddedReader constructor) + return "http://www.w3.org/2001/04/xmlenc#aes256-cbc" +} + +func (e cbcEncrypter) GenerateKey() (ContentKey, error) { + slice, err := GenerateKey(aes256keyLength) + return ContentKey(slice), err +} + +func (e cbcEncrypter) Encrypt(key ContentKey, r io.Reader, w io.Writer) error { + + r = PaddedReader(r, aes.BlockSize, false) + + block, err := aes.NewCipher(key) + if err != nil { + return err + } + + // generate the IV + iv := make([]byte, aes.BlockSize) + if _, err := io.ReadFull(rand.Reader, iv); err != nil { + return err + } + + // write the IV first + if _, err = w.Write(iv); err != nil { + return err + } + + mode := cipher.NewCBCEncrypter(block, iv) + buffer := make([]byte, aes.BlockSize) + for _, err = io.ReadFull(r, buffer); err == nil; _, err = io.ReadFull(r, buffer) { + mode.CryptBlocks(buffer, buffer) + _, wErr := w.Write(buffer) + if wErr != nil { + return wErr + } + } + + if err == nil || err == io.EOF { + return nil + } + + return err +} + +func (c cbcEncrypter) Decrypt(key ContentKey, r io.Reader, w io.Writer) error { + block, err := aes.NewCipher(key) + if err != nil { + return err + } + + var buffer bytes.Buffer + io.Copy(&buffer, r) + + buf := buffer.Bytes() + iv := buf[:aes.BlockSize] + + mode := cipher.NewCBCDecrypter(block, iv) + mode.CryptBlocks(buf[aes.BlockSize:], buf[aes.BlockSize:]) + + padding := buf[len(buf)-1] // padding length valid for both PKCS#7 and W3C schemes + w.Write(buf[aes.BlockSize : len(buf)-int(padding)]) + + return nil +} + +func NewAESCBCEncrypter() Encrypter { + return cbcEncrypter(struct{}{}) +} diff --git a/crypto/encrypt_test.go b/crypto/aes_cbc_test.go similarity index 50% rename from crypto/encrypt_test.go rename to crypto/aes_cbc_test.go index ecf5bb01..08d40bac 100644 --- a/crypto/encrypt_test.go +++ b/crypto/aes_cbc_test.go @@ -1,3 +1,28 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + package crypto import ( @@ -12,7 +37,9 @@ func TestSimpleEncrypt(t *testing.T) { var output bytes.Buffer var key [32]byte //not a safe key to have - err := Encrypt(key[:], input, &output) + cbc := NewAESCBCEncrypter() + + err := cbc.Encrypt(key[:], input, &output) if err != nil { t.Log(err) @@ -31,7 +58,9 @@ func TestConsecutiveEncrypts(t *testing.T) { var output bytes.Buffer var key [32]byte //not a safe key to have - err := Encrypt(key[:], input, &output) + cbc := NewAESCBCEncrypter() + + err := cbc.Encrypt(key[:], input, &output) if err != nil { t.Log(err) @@ -41,7 +70,7 @@ func TestConsecutiveEncrypts(t *testing.T) { input = bytes.NewBufferString("1234") var output2 bytes.Buffer - err = Encrypt(key[:], input, &output2) + err = cbc.Encrypt(key[:], input, &output2) if err != nil { t.Log(err) @@ -56,7 +85,10 @@ func TestConsecutiveEncrypts(t *testing.T) { func TestFailingReaderForEncryption(t *testing.T) { var output bytes.Buffer var key [32]byte //not a safe key to have - err := Encrypt(key[:], failingReader{}, &output) + + cbc := NewAESCBCEncrypter() + + err := cbc.Encrypt(key[:], failingReader{}, &output) if err == nil { t.Error("expected an error from the reader") @@ -68,12 +100,14 @@ func TestDecrypt(t *testing.T) { key := sha256.Sum256([]byte("password")) var cipher bytes.Buffer - err := Encrypt(key[:], clear, &cipher) + cbc := &cbcEncrypter{} + err := cbc.Encrypt(key[:], clear, &cipher) if err != nil { t.Fatal(err) } + var res bytes.Buffer - err = Decrypt(key[:], &cipher, &res) + err = cbc.Decrypt(key[:], &cipher, &res) if err != nil { t.Fatal(err) } @@ -103,4 +137,4 @@ func TestKeyWrap(t *testing.T) { if !bytes.Equal(out, expected) { t.Errorf("Expected %x, got %x", expected, out) } -} +} \ No newline at end of file diff --git a/crypto/aes_gcm.go b/crypto/aes_gcm.go new file mode 100644 index 00000000..a9866fef --- /dev/null +++ b/crypto/aes_gcm.go @@ -0,0 +1,76 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "encoding/binary" + "io" + "io/ioutil" +) + +type gcmEncrypter struct { + counter uint64 +} + +func (e gcmEncrypter) Signature() string { + return "http://www.w3.org/2009/xmlenc11#aes256-gcm" +} + +func (e gcmEncrypter) GenerateKey() (ContentKey, error) { + slice, err := GenerateKey(aes256keyLength) + return ContentKey(slice), err +} + +func (e *gcmEncrypter) Encrypt(key ContentKey, r io.Reader, w io.Writer) error { + block, err := aes.NewCipher(key) + if err != nil { + return err + } + + counter := e.counter + e.counter++ + + gcm, err := cipher.NewGCM(block) + if err != nil { + return err + } + + nonce := make([]byte, gcm.NonceSize()) + binary.BigEndian.PutUint64(nonce, counter) + + data, err := ioutil.ReadAll(r) + out := gcm.Seal(nonce, nonce, data, nil) + + _, err = w.Write(out) + + return err +} + +func NewAESGCMEncrypter() Encrypter { + return &gcmEncrypter{} +} \ No newline at end of file diff --git a/crypto/aes_gcm_test.go b/crypto/aes_gcm_test.go new file mode 100644 index 00000000..707e1d03 --- /dev/null +++ b/crypto/aes_gcm_test.go @@ -0,0 +1,69 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package crypto + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "encoding/hex" + "testing" +) + +func TestEncryptGCM(t *testing.T) { + key, _ := hex.DecodeString("11754cd72aec309bf52f7687212e8957") + + encrypter := NewAESGCMEncrypter() + + data := []byte("The quick brown fox jumps over the lazy dog") + + r := bytes.NewReader(data) + w := new(bytes.Buffer) + + if err := encrypter.Encrypt(ContentKey(key), r, w); err != nil { + t.Fatal("Encryption failed", err) + } + + block, _ := aes.NewCipher(key) + gcm, _ := cipher.NewGCM(block) + + out := w.Bytes() + t.Logf("nonce size: %#v", gcm.NonceSize()) + t.Logf("nonce: %#v", out[0:gcm.NonceSize()]) + t.Logf("ciphertext: %#v", out[gcm.NonceSize():]) + clear := make([]byte, 0) + clear, err := gcm.Open(clear, out[0:gcm.NonceSize()], out[gcm.NonceSize():], nil) + + if err != nil { + t.Fatal("Decryption failed", err) + } + + if diff := bytes.Compare(data, clear); diff != 0 { + t.Logf("Original: %#v", data) + t.Logf("After cycle: %#v", clear) + t.Errorf("Expected encryption-decryption to return original") + } +} \ No newline at end of file diff --git a/crypto/encrypt.go b/crypto/encrypt.go index b6e668a0..6a390e1f 100644 --- a/crypto/encrypt.go +++ b/crypto/encrypt.go @@ -1,69 +1,72 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + package crypto import ( - "bytes" "crypto/aes" - "crypto/cipher" - "crypto/rand" "io" ) +//"github.com/readium/readium-lcp-server/config" +// FOR: config.Config.AES256_CBC_OR_GCM -func Encrypt(key []byte, r io.Reader, w io.Writer) error { - r = PaddedReader(r, aes.BlockSize) - - block, err := aes.NewCipher(key) - if err != nil { - return err - } - - // generate the IV - iv := make([]byte, aes.BlockSize) - if _, err := io.ReadFull(rand.Reader, iv); err != nil { - return err - } - - // write the IV first - if _, err = w.Write(iv); err != nil { - return err - } - - mode := cipher.NewCBCEncrypter(block, iv) - buffer := make([]byte, aes.BlockSize) - for _, err = io.ReadFull(r, buffer); err == nil; _, err = io.ReadFull(r, buffer) { - mode.CryptBlocks(buffer, buffer) - _, wErr := w.Write(buffer) - if wErr != nil { - return wErr - } - } - - if err == nil || err == io.EOF { - return nil - } - - return err +type Encrypter interface { + Encrypt(key ContentKey, r io.Reader, w io.Writer) error + GenerateKey() (ContentKey, error) + Signature() string } -func Decrypt(key []byte, r io.Reader, w io.Writer) error { - block, err := aes.NewCipher(key) - if err != nil { - return err - } - - var buffer bytes.Buffer - io.Copy(&buffer, r) +type Decrypter interface { + Decrypt(key ContentKey, r io.Reader, w io.Writer) error +} - buf := buffer.Bytes() - iv := buf[:aes.BlockSize] +func NewAESEncrypter_PUBLICATION_RESOURCES() Encrypter { + + return NewAESCBCEncrypter() - mode := cipher.NewCBCDecrypter(block, iv) - mode.CryptBlocks(buf[aes.BlockSize:], buf[aes.BlockSize:]) + // DISABLED, see https://github.com/readium/readium-lcp-server/issues/109 + // if config.Config.AES256_CBC_OR_GCM == "GCM" { + // return NewAESGCMEncrypter() + // } else { // default to CBC + // return NewAESCBCEncrypter() + // } +} - padding := buf[len(buf)-1] - w.Write(buf[aes.BlockSize : len(buf)-int(padding)]) +func NewAESEncrypter_CONTENT_KEY() Encrypter { + // default to CBC + return NewAESCBCEncrypter() +} - return nil +func NewAESEncrypter_USER_KEY_CHECK() Encrypter { + // default to CBC + return NewAESEncrypter_CONTENT_KEY() +} +func NewAESEncrypter_FIELDS() Encrypter { + // default to CBC + return NewAESEncrypter_CONTENT_KEY() } var ( diff --git a/crypto/key.go b/crypto/key.go index 25bbf544..f0e7182a 100644 --- a/crypto/key.go +++ b/crypto/key.go @@ -1,15 +1,38 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + package crypto import ( "crypto/rand" ) -const ( - keyLength = 32 // 256 bits -) +type ContentKey []byte -func GenerateKey() ([]byte, error) { - k := make([]byte, keyLength) +func GenerateKey(size int) ([]byte, error) { + k := make([]byte, size) _, err := rand.Read(k) if err != nil { diff --git a/crypto/key_test.go b/crypto/key_test.go index 677d83bb..ffc3c728 100644 --- a/crypto/key_test.go +++ b/crypto/key_test.go @@ -1,11 +1,34 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + package crypto -import ( - "testing" -) +import "testing" func TestGenerateKey(t *testing.T) { - buf, err := GenerateKey() + buf, err := GenerateKey(aes256keyLength) if err != nil { t.Error(err) diff --git a/crypto/pad.go b/crypto/pad.go index a6f9f9d0..fae9b5fa 100644 --- a/crypto/pad.go +++ b/crypto/pad.go @@ -1,7 +1,34 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + package crypto import ( "io" + "math/rand" + "time" ) type paddedReader struct { @@ -10,6 +37,7 @@ type paddedReader struct { count byte left byte done bool + insertPadLengthAll bool } func (r *paddedReader) Read(buf []byte) (int, error) { @@ -48,8 +76,21 @@ func (r *paddedReader) Read(buf []byte) (int, error) { func (r *paddedReader) pad(buf []byte) (i int, err error) { capacity := cap(buf) + + src := rand.New(rand.NewSource(time.Now().UnixNano())) + for i = 0; capacity > 0 && r.left > 0; i++ { - buf[i] = r.count + + if (r.insertPadLengthAll) { + buf[i] = r.count + } else { + if r.left == 1 { //capacity == 1 && + buf[i] = r.count + } else { + buf[i] = byte(src.Intn(254) + 1) + } + } + capacity-- r.left-- } @@ -61,6 +102,9 @@ func (r *paddedReader) pad(buf []byte) (i int, err error) { return } -func PaddedReader(r io.Reader, blockSize byte) io.Reader { - return &paddedReader{Reader: r, size: blockSize, count: 0, left: 0, done: false} + +// insertPadLengthAll = true means PKCS#7 (padding length inserted in each padding slot), +// otherwise false means padding length inserted only in the last slot (the rest is random bytes) +func PaddedReader(r io.Reader, blockSize byte, insertPadLengthAll bool) io.Reader { + return &paddedReader{Reader: r, size: blockSize, count: 0, left: 0, done: false, insertPadLengthAll: insertPadLengthAll} } diff --git a/crypto/pad_test.go b/crypto/pad_test.go index 1e19a5cc..9aaeb41c 100644 --- a/crypto/pad_test.go +++ b/crypto/pad_test.go @@ -1,3 +1,28 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + package crypto import ( @@ -8,7 +33,7 @@ import ( func TestOneBlock(t *testing.T) { buf := bytes.NewBufferString("4321") - reader := PaddedReader(buf, 6) + reader := PaddedReader(buf, 6, true) var out [12]byte n, err := reader.Read(out[:]) if err != nil && err != io.EOF { @@ -18,6 +43,8 @@ func TestOneBlock(t *testing.T) { t.Errorf("should have read 6 bytes, read %d", n) } + // PaddedReader constructor parameter "insertPadLengthAll" is true, + // means all last bytes equate the padding length if out[4] != 2 || out[5] != 2 { t.Errorf("last values were expected to be 2, got [%x %x]", out[4], out[5]) } @@ -25,7 +52,7 @@ func TestOneBlock(t *testing.T) { func TestFullPadding(t *testing.T) { buf := bytes.NewBufferString("1234") - reader := PaddedReader(buf, 4) + reader := PaddedReader(buf, 4, true) var out [8]byte n, err := io.ReadFull(reader, out[:]) @@ -36,14 +63,16 @@ func TestFullPadding(t *testing.T) { t.Error("should have read 8 bytes, read %d", n) } + // PaddedReader constructor parameter "insertPadLengthAll" is true, + // means all last bytes equate the padding length if out[4] != 4 || out[5] != 4 || out[6] != 4 || out[7] != 4 { - t.Errorf("last values were expected to be 8, got [%x %x %x %x]", out[4], out[5], out[6], out[7]) + t.Errorf("last values were expected to be 4, got [%x %x %x %x]", out[4], out[5], out[6], out[7]) } } func TestManyBlocks(t *testing.T) { buf := bytes.NewBufferString("1234") - reader := PaddedReader(buf, 3) + reader := PaddedReader(buf, 3, true) var out [3]byte n, err := io.ReadFull(reader, out[:]) if err != nil { @@ -59,14 +88,80 @@ func TestManyBlocks(t *testing.T) { t.Errorf("should have read 3 bytes, read %d", n) } + // PaddedReader constructor parameter "insertPadLengthAll" is true, + // means all last bytes equate the padding length if out[1] != 2 || out[2] != 2 { t.Errorf("last values were expected to be 2, got [%x %x]", out[1], out[2]) } } +func TestOneBlock_Random(t *testing.T) { + buf := bytes.NewBufferString("4321") + reader := PaddedReader(buf, 6, false) + var out [12]byte + n, err := reader.Read(out[:]) + if err != nil && err != io.EOF { + t.Error(err) + } + if n != 6 { + t.Errorf("should have read 6 bytes, read %d", n) + } + + // the PaddedReader constructor parameter "insertPadLengthAll" is false, + // so only the last byte out[2] equates the padding length (the others are random) + if out[4] == 2 || out[5] != 2 { + t.Errorf("last values were expected to be [random, 2], got [%x %x]", out[4], out[5]) + } +} + +func TestFullPadding_Random(t *testing.T) { + buf := bytes.NewBufferString("1234") + reader := PaddedReader(buf, 4, false) + + var out [8]byte + n, err := io.ReadFull(reader, out[:]) + if err != nil { + t.Error(err) + } + if n != 8 { + t.Error("should have read 8 bytes, read %d", n) + } + + // the PaddedReader constructor parameter "insertPadLengthAll" is false, + // so only the last byte out[7] equates the padding length (the others are random) + if out[4] == 4 || out[5] == 4 || out[6] == 4 || out[7] != 4 { + t.Errorf("last values were expected to be [random, random, random, 4], got [%x %x %x %x]", out[4], out[5], out[6], out[7]) + } +} + +func TestManyBlocks_Random(t *testing.T) { + buf := bytes.NewBufferString("1234") + reader := PaddedReader(buf, 3, false) + var out [3]byte + n, err := io.ReadFull(reader, out[:]) + if err != nil { + t.Error(err) + } + + n, err = io.ReadFull(reader, out[:]) + if err != nil { + t.Error(err) + } + + if n != 3 { + t.Errorf("should have read 3 bytes, read %d", n) + } + + // the PaddedReader constructor parameter "insertPadLengthAll" is false, + // so only the last byte out[2] equates the padding length (the others are random) + if out[1] == 2 || out[2] != 2 { + t.Errorf("last values were expected to be [random 2], got [%x %x]", out[1], out[2]) + } +} + func TestPaddingInMultipleCalls(t *testing.T) { buf := bytes.NewBufferString("1") - reader := PaddedReader(buf, 6) + reader := PaddedReader(buf, 6, false) var out [3]byte n, err := io.ReadFull(reader, out[:]) @@ -97,7 +192,7 @@ func (r failingReader) Read(buf []byte) (int, error) { } func TestFailingReader(t *testing.T) { - reader := PaddedReader(failingReader{}, 8) + reader := PaddedReader(failingReader{}, 8, false) var out [8]byte _, err := io.ReadFull(reader, out[:]) diff --git a/dbmodel/Database Model Diagram.png b/dbmodel/Database Model Diagram.png new file mode 100644 index 00000000..b7121d03 Binary files /dev/null and b/dbmodel/Database Model Diagram.png differ diff --git a/dbmodel/database script.sql b/dbmodel/database script.sql new file mode 100644 index 00000000..49807736 --- /dev/null +++ b/dbmodel/database script.sql @@ -0,0 +1,47 @@ + +CREATE TABLE IF NOT EXISTS content ( + id varchar(255) PRIMARY KEY NOT NULL, + encryption_key varchar(64) NOT NULL, + location text NOT NULL +); + +CREATE TABLE IF NOT EXISTS license ( + id varchar(255) PRIMARY KEY NOT NULL, + user_id varchar(255) NOT NULL, + provider varchar(255) NOT NULL, + issued datetime NOT NULL, + updated datetime DEFAULT NULL, + rights_print int(11) DEFAULT NULL, + rights_copy int(11) DEFAULT NULL, + rights_start datetime DEFAULT NULL, + rights_end datetime DEFAULT NULL, + user_key_hint text NOT NULL, + user_key_hash varchar(64) NOT NULL, + user_key_algorithm varchar(255) NOT NULL, + content_fk varchar(255) NOT NULL, + FOREIGN KEY(content_fk) REFERENCES content(id) +); + +CREATE TABLE IF NOT EXISTS license_status ( + id INTEGER PRIMARY KEY, + status int(11) NOT NULL, + license_updated datetime NOT NULL, + status_updated datetime NOT NULL, + device_count int(11) DEFAULT NULL, + potential_rights_end datetime DEFAULT NULL, + license_ref varchar(255) NOT NULL +); + +CREATE INDEX IF NOT EXISTS license_ref_index on license_status (license_ref); + +CREATE TABLE IF NOT EXISTS event ( + id INTEGER PRIMARY KEY, + device_name varchar(255) DEFAULT NULL, + timestamp datetime NOT NULL, + type int NOT NULL, + device_id varchar(255) DEFAULT NULL, + license_status_fk int NOT NULL, + FOREIGN KEY(license_status_fk) REFERENCES license_status(id) +); + +CREATE INDEX IF NOT EXISTS license_status_fk_index on event (license_status_fk); diff --git a/epub/epub.go b/epub/epub.go index 44dc5b76..37a85ab1 100644 --- a/epub/epub.go +++ b/epub/epub.go @@ -1,3 +1,28 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + package epub import ( @@ -5,11 +30,23 @@ import ( "path/filepath" "sort" "strings" + "io" "github.com/readium/readium-lcp-server/epub/opf" "github.com/readium/readium-lcp-server/xmlenc" +) - "io" +const ( + ContainerFile = "META-INF/container.xml" + EncryptionFile = "META-INF/encryption.xml" + LicenseFile = "META-INF/license.lcpl" + + ContentType_XHTML = "application/xhtml+xml" + ContentType_HTML = "text/html" + + ContentType_NCX = "application/x-dtbncx+xml" + + ContentType_EPUB = "application/epub+zip" ) type Epub struct { diff --git a/epub/opf/opf.go b/epub/opf/opf.go index 3195d580..21b79bd6 100644 --- a/epub/opf/opf.go +++ b/epub/opf/opf.go @@ -1,3 +1,28 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + package opf import ( diff --git a/epub/reader.go b/epub/reader.go index 6cccbc9d..1fac8428 100644 --- a/epub/reader.go +++ b/epub/reader.go @@ -1,21 +1,43 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + package epub import ( "archive/zip" "encoding/xml" "path/filepath" - - "github.com/readium/readium-lcp-server/epub/opf" - "github.com/readium/readium-lcp-server/xmlenc" - "io" "sort" "strings" + + "github.com/readium/readium-lcp-server/epub/opf" + "github.com/readium/readium-lcp-server/xmlenc" ) const ( - ContainerFile = "META-INF/container.xml" - EncryptionFile = "META-INF/encryption.xml" RootFileElement = "rootfile" ) @@ -164,7 +186,7 @@ func addCleartextResources(ep *Epub, p opf.Package) { for _, item := range p.Manifest.Items { if strings.Contains(item.Properties, "cover-image") || strings.Contains(item.Properties, "nav") || - item.MediaType == "application/x-dtbncx+xml" { + item.MediaType == ContentType_NCX { ep.addCleartextResource(filepath.Join(p.BasePath, item.Href)) } } diff --git a/epub/reader_test.go b/epub/reader_test.go index 959adad3..15786002 100644 --- a/epub/reader_test.go +++ b/epub/reader_test.go @@ -1,3 +1,28 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + package epub import ( @@ -28,7 +53,7 @@ func TestEpubLoading(t *testing.T) { t.Error("Expected 1 opf, got %d", len(ep.Package)) } - expectedCleartext := []string{"META-INF/container.xml", "OPS/package.opf", "OPS/images/9780316000000.jpg", "OPS/toc.xhtml"} + expectedCleartext := []string{ContainerFile, "OPS/package.opf", "OPS/images/9780316000000.jpg", "OPS/toc.xhtml"} sort.Strings(expectedCleartext) if fmt.Sprintf("%v", ep.cleartextResources) != fmt.Sprintf("%v", expectedCleartext) { t.Errorf("Cleartext resources, expected %v, got %v", expectedCleartext, ep.cleartextResources) @@ -38,7 +63,7 @@ func TestEpubLoading(t *testing.T) { t.Error("Expected a cover to be found") } - if expected := "application/xhtml+xml"; ep.Resource[2].ContentType != expected { + if expected := ContentType_XHTML; ep.Resource[2].ContentType != expected { t.Errorf("Content Type matching, expected %v, got %v", expected, ep.Resource[2].ContentType) } } diff --git a/epub/utils.go b/epub/utils.go index c40902ea..704d2377 100644 --- a/epub/utils.go +++ b/epub/utils.go @@ -1,3 +1,28 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + package epub import ( diff --git a/epub/writer.go b/epub/writer.go index b8a027f9..38186da2 100644 --- a/epub/writer.go +++ b/epub/writer.go @@ -1,3 +1,28 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + package epub import ( @@ -7,10 +32,6 @@ import ( "github.com/readium/readium-lcp-server/xmlenc" ) -const ( - mimetype = "application/epub+zip" -) - type Writer struct { w *zip.Writer } @@ -36,7 +57,7 @@ func (w *Writer) Copy(r *Resource) error { } func (w *Writer) WriteEncryption(enc *xmlenc.Manifest) error { - fw, err := w.AddResource("META-INF/encryption.xml", zip.Deflate) + fw, err := w.AddResource(EncryptionFile, zip.Deflate) if err != nil { return err } @@ -97,7 +118,7 @@ func writeMimetype(w *zip.Writer) error { return err } - wf.Write([]byte(mimetype)) + wf.Write([]byte(ContentType_EPUB)) return nil } diff --git a/epub/writer_test.go b/epub/writer_test.go index afc0d78c..2634cc95 100644 --- a/epub/writer_test.go +++ b/epub/writer_test.go @@ -1,3 +1,28 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + package epub import ( @@ -39,7 +64,7 @@ const basicPage = ` func createBasicEpub() Epub { var ep Epub - ep.Add("META-INF/container.xml", strings.NewReader(containerSpec), uint64(len(containerSpec))) + ep.Add(ContainerFile, strings.NewReader(containerSpec), uint64(len(containerSpec))) ep.Add("EPUB/package.opf", strings.NewReader(basicOpf), uint64(len(basicOpf))) @@ -79,8 +104,8 @@ func TestWriteBasicEpub(t *testing.T) { } } - testContentsOfFileInZip(t, zr, zip.Store, "mimetype", "application/epub+zip") - testContentsOfFileInZip(t, zr, zip.Deflate, "META-INF/container.xml", containerSpec) + testContentsOfFileInZip(t, zr, zip.Store, "mimetype", ContentType_EPUB) + testContentsOfFileInZip(t, zr, zip.Deflate, ContainerFile, containerSpec) testContentsOfFileInZip(t, zr, zip.Deflate, "EPUB/package.opf", basicOpf) testContentsOfFileInZip(t, zr, zip.Deflate, "EPUB/page.xhtml", basicPage) } diff --git a/frontend/api/common.go b/frontend/api/common.go new file mode 100644 index 00000000..c18e108d --- /dev/null +++ b/frontend/api/common.go @@ -0,0 +1,106 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package staticapi + +import ( + "net/http" + "strconv" + + "github.com/readium/readium-lcp-server/api" + "github.com/readium/readium-lcp-server/frontend/webpublication" + "github.com/readium/readium-lcp-server/frontend/webpurchase" + "github.com/readium/readium-lcp-server/frontend/webrepository" + "github.com/readium/readium-lcp-server/frontend/webuser" +) + +//IServer defines methods for db interaction +type IServer interface { + RepositoryAPI() webrepository.WebRepository + PublicationAPI() webpublication.WebPublication + UserAPI() webuser.WebUser + PurchaseAPI() webpurchase.WebPurchase +} + +// Pagination used to paginate listing +type Pagination struct { + Page int + PerPage int +} + +// ExtractPaginationFromRequest extract from http.Request pagination information +func ExtractPaginationFromRequest(r *http.Request) (Pagination, error) { + var err error + var page int64 // default: page 1 + var perPage int64 // default: 30 items per page + pagination := Pagination{} + + if r.FormValue("page") != "" { + page, err = strconv.ParseInt((r).FormValue("page"), 10, 32) + if err != nil { + return pagination, err + } + } else { + page = 1 + } + + if r.FormValue("per_page") != "" { + perPage, err = strconv.ParseInt((r).FormValue("per_page"), 10, 32) + if err != nil { + return pagination, err + } + } else { + perPage = 30 + } + + if page > 0 { + page-- //pagenum starting at 0 in code, but user interface starting at 1 + } + + if page < 0 { + return pagination, err + } + + pagination.Page = int(page) + pagination.PerPage = int(perPage) + return pagination, err +} + +// PrepareListHeaderResponse Set http headers +func PrepareListHeaderResponse( + resourceCount int, + resourceLink string, + pagination Pagination, + w http.ResponseWriter) { + if resourceCount > 0 { + nextPage := strconv.Itoa(int(pagination.Page) + 1) + w.Header().Set("Link", "<"+resourceLink+"?page="+nextPage+">; rel=\"next\"; title=\"next\"") + } + if pagination.Page > 1 { + previousPage := strconv.Itoa(int(pagination.Page) - 1) + w.Header().Set("Link", "<"+resourceLink+"/?page="+previousPage+">; rel=\"previous\"; title=\"previous\"") + } + w.Header().Set("Content-Type", api.ContentType_JSON) +} diff --git a/frontend/api/publication.go b/frontend/api/publication.go new file mode 100644 index 00000000..b774e972 --- /dev/null +++ b/frontend/api/publication.go @@ -0,0 +1,213 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package staticapi + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/gorilla/mux" + "github.com/readium/readium-lcp-server/api" + "github.com/readium/readium-lcp-server/frontend/webpublication" + "github.com/readium/readium-lcp-server/problem" +) + +//GetPublications returns a list of publications +func GetPublications(w http.ResponseWriter, r *http.Request, s IServer) { + var page int64 + var perPage int64 + var err error + + if r.FormValue("page") != "" { + page, err = strconv.ParseInt((r).FormValue("page"), 10, 32) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + return + } + } else { + page = 1 + } + + if r.FormValue("per_page") != "" { + perPage, err = strconv.ParseInt((r).FormValue("per_page"), 10, 32) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + return + } + } else { + perPage = 30 + } + + if page > 0 { + page-- //pagenum starting at 0 in code, but user interface starting at 1 + } + + if page < 0 { + problem.Error(w, r, problem.Problem{Detail: "page must be positive integer"}, http.StatusBadRequest) + return + } + + pubs := make([]webpublication.Publication, 0) + //log.Println("ListAll(" + strconv.Itoa(int(per_page)) + "," + strconv.Itoa(int(page)) + ")") + fn := s.PublicationAPI().List(int(perPage), int(page)) + for it, err := fn(); err == nil; it, err = fn() { + pubs = append(pubs, it) + } + if len(pubs) > 0 { + nextPage := strconv.Itoa(int(page) + 1) + w.Header().Set("Link", "; rel=\"next\"; title=\"next\"") + } + if page > 1 { + previousPage := strconv.Itoa(int(page) - 1) + w.Header().Set("Link", "; rel=\"previous\"; title=\"previous\"") + } + w.Header().Set("Content-Type", api.ContentType_JSON) + + enc := json.NewEncoder(w) + err = enc.Encode(pubs) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + return + } +} + +// GetPublicationByUUID searches a publication by its uuid +func GetPublication(w http.ResponseWriter, r *http.Request, s IServer) { + vars := mux.Vars(r) + var id int + var err error + if id, err = strconv.Atoi(vars["id"]); err != nil { + // id is not a number + problem.Error(w, r, problem.Problem{Detail: "Plublication ID must be an integer"}, http.StatusBadRequest) + } + + if pub, err := s.PublicationAPI().Get(int64(id)); err == nil { + enc := json.NewEncoder(w) + if err = enc.Encode(pub); err == nil { + // send json of correctly encoded user info + w.Header().Set("Content-Type", api.ContentType_JSON) + w.WriteHeader(http.StatusOK) + return + } + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + } else { + switch err { + case webpublication.ErrNotFound: + { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusNotFound) + } + default: + { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + } + } + } +} + +//DecodeJSONUser transforms a json string to a User struct +func DecodeJSONPublication(r *http.Request) (webpublication.Publication, error) { + var dec *json.Decoder + if ctype := r.Header["Content-Type"]; len(ctype) > 0 && ctype[0] == api.ContentType_JSON { + dec = json.NewDecoder(r.Body) + } + pub := webpublication.Publication{} + err := dec.Decode(&pub) + return pub, err +} + +// CreatePublication creates a publication in the database +func CreatePublication(w http.ResponseWriter, r *http.Request, s IServer) { + var pub webpublication.Publication + var err error + if pub, err = DecodeJSONPublication(r); err != nil { + problem.Error(w, r, problem.Problem{Detail: "incorrect JSON Publication " + err.Error()}, http.StatusBadRequest) + return + } + // publication ok + if err := s.PublicationAPI().Add(pub); err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + return + } + + // publication added to db + w.WriteHeader(http.StatusCreated) +} + +// UpdatePublication updates an identified publication (id) in the database +func UpdatePublication(w http.ResponseWriter, r *http.Request, s IServer) { + vars := mux.Vars(r) + var id int + var err error + var pub webpublication.Publication + if id, err = strconv.Atoi(vars["id"]); err != nil { + // id is not a number + problem.Error(w, r, problem.Problem{Detail: "Plublication ID must be an integer"}, http.StatusBadRequest) + return + } + // ID is a number, check publication (json) + if pub, err = DecodeJSONPublication(r); err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + return + } + // publication ok, id is a number, search publication to update + if foundPub, err := s.PublicationAPI().Get(int64(id)); err != nil { + switch err { + case webpublication.ErrNotFound: + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusNotFound) + default: + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + } + } else { + // publication is found! + if err := s.PublicationAPI().Update(webpublication.Publication{ + ID: foundPub.ID, + Title: pub.Title, + Status: foundPub.Status}); err != nil { + //update failed! + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + } + //database update ok + w.WriteHeader(http.StatusOK) + //return + } +} + +// DeletePublication removes a publication in the database +func DeletePublication(w http.ResponseWriter, r *http.Request, s IServer) { + vars := mux.Vars(r) + id, err := strconv.ParseInt(vars["id"], 10, 64) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + return + } + if err := s.PublicationAPI().Delete(id); err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + return + } + // publication deleted from db + w.WriteHeader(http.StatusOK) +} diff --git a/frontend/api/purchase.go b/frontend/api/purchase.go new file mode 100644 index 00000000..851f7118 --- /dev/null +++ b/frontend/api/purchase.go @@ -0,0 +1,314 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package staticapi + +import ( + "bytes" + "encoding/json" + "net/http" + "strconv" + + "github.com/gorilla/mux" + "github.com/readium/readium-lcp-server/api" + "github.com/readium/readium-lcp-server/frontend/webpurchase" + "github.com/readium/readium-lcp-server/license" + "github.com/readium/readium-lcp-server/problem" + + "github.com/Machiel/slugify" +) + +//DecodeJSONPurchase transforms a json string to a User struct +func DecodeJSONPurchase(r *http.Request) (webpurchase.Purchase, error) { + var dec *json.Decoder + if ctype := r.Header["Content-Type"]; len(ctype) > 0 && ctype[0] == api.ContentType_JSON { + dec = json.NewDecoder(r.Body) + } + purchase := webpurchase.Purchase{} + err := dec.Decode(&purchase) + return purchase, err +} + +// GetPurchases searches all purchases for a client +func GetPurchases(w http.ResponseWriter, r *http.Request, s IServer) { + var err error + + pagination, err := ExtractPaginationFromRequest(r) + if err != nil { + // user id is not a number + problem.Error(w, r, problem.Problem{Detail: "Pagination error"}, http.StatusBadRequest) + return + } + + purchases := make([]webpurchase.Purchase, 0) + fn := s.PurchaseAPI().List(pagination.PerPage, pagination.Page) + + for it, err := fn(); err == nil; it, err = fn() { + purchases = append(purchases, it) + } + + enc := json.NewEncoder(w) + err = enc.Encode(purchases) + PrepareListHeaderResponse(len(purchases), "/api/v1/purchases", pagination, w) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + return + } +} + +//GetUserPurchases searches all purchases for a client +func GetUserPurchases(w http.ResponseWriter, r *http.Request, s IServer) { + var err error + var userId int64 + vars := mux.Vars(r) + + if userId, err = strconv.ParseInt(vars["user_id"], 10, 64); err != nil { + // user id is not a number + problem.Error(w, r, problem.Problem{Detail: "User ID must be an integer"}, http.StatusBadRequest) + return + } + + pagination, err := ExtractPaginationFromRequest(r) + if err != nil { + // user id is not a number + problem.Error(w, r, problem.Problem{Detail: "Pagination error"}, http.StatusBadRequest) + return + } + + purchases := make([]webpurchase.Purchase, 0) + fn := s.PurchaseAPI().ListByUser(userId, pagination.PerPage, pagination.Page) + for it, err := fn(); err == nil; it, err = fn() { + purchases = append(purchases, it) + } + + enc := json.NewEncoder(w) + err = enc.Encode(purchases) + PrepareListHeaderResponse(len(purchases), "/api/v1/users/"+vars["user_id"]+"/purchases", pagination, w) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + return + } +} + +//CreatePurchase creates a purchase in the database +func CreatePurchase(w http.ResponseWriter, r *http.Request, s IServer) { + var purchase webpurchase.Purchase + var err error + if purchase, err = DecodeJSONPurchase(r); err != nil { + problem.Error(w, r, problem.Problem{Detail: "incorrect JSON Purchase " + err.Error()}, http.StatusBadRequest) + return + } + + // purchase ok + if err = s.PurchaseAPI().Add(purchase); err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + return + } + + // publication added to db + w.WriteHeader(http.StatusCreated) +} + +//GetPurchaseLicenseFromLicenseUUID() finds the purchase ID from a given license UUID (passed in URL), +//and performs the same as GetPurchaseLicense(), returning "license.lcpl" filename +//(as this API is meant to be accessed from the LSD JSON license link) +func GetPurchaseLicenseFromLicenseUUID(w http.ResponseWriter, r *http.Request, s IServer) { + + vars := mux.Vars(r) + var purchase webpurchase.Purchase + var err error + + if purchase, err = s.PurchaseAPI().GetByLicenseID(vars["licenseID"]); err != nil { + switch err { + case webpurchase.ErrNotFound: + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusNotFound) + default: + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + } + return + } + + fullLicense, err := s.PurchaseAPI().GenerateLicense(purchase) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + return + } + + //attachmentName := slugify.Slugify(purchase.Publication.Title) + w.Header().Set("Content-Type", api.ContentType_LCP_JSON) + w.Header().Set("Content-Disposition", "attachment; filename=\"license.lcpl\"") + + enc := json.NewEncoder(w) + err = enc.Encode(fullLicense) + + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + return + } +} + +//GetPurchaseLicense contacts LCP server and asks a license for the purchase using the partial license and resourceID +func GetPurchaseLicense(w http.ResponseWriter, r *http.Request, s IServer) { + vars := mux.Vars(r) + var id int + var err error + + if id, err = strconv.Atoi(vars["id"]); err != nil { + // id is not a number + problem.Error(w, r, problem.Problem{Detail: "Purchase ID must be an integer"}, http.StatusBadRequest) + return + } + + purchase, err := s.PurchaseAPI().Get(int64(id)) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusNotFound) + return + } + + fullLicense, err := s.PurchaseAPI().GenerateLicense(purchase) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + return + } + + attachmentName := slugify.Slugify(purchase.Publication.Title) + w.Header().Set("Content-Type", api.ContentType_LCP_JSON) + w.Header().Set("Content-Disposition", "attachment; filename=\""+attachmentName+".lcpl\"") + + enc := json.NewEncoder(w) + err = enc.Encode(fullLicense) + + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + return + } +} + +//GetPurchase gets a purchase by its ID in the database +func GetPurchase(w http.ResponseWriter, r *http.Request, s IServer) { + vars := mux.Vars(r) + var id int + var err error + if id, err = strconv.Atoi(vars["id"]); err != nil { + // id is not a number + problem.Error(w, r, problem.Problem{Detail: "Purchase ID must be an integer"}, http.StatusBadRequest) + return + } + + purchase, err := s.PurchaseAPI().Get(int64(id)) + if err != nil { + switch err { + case webpurchase.ErrNotFound: + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusNotFound) + default: + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + } + return + } + + // purchase found + // purchase.PartialLicense = "*" //hide partialLicense? + enc := json.NewEncoder(w) + if err = enc.Encode(purchase); err == nil { + // send json of correctly encoded user info + w.Header().Set("Content-Type", api.ContentType_JSON) + w.WriteHeader(http.StatusOK) + return + } + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) +} + +//GetPurchaseByLicenseID gets a purchase by a LicenseID in the database +func GetPurchaseByLicenseID(w http.ResponseWriter, r *http.Request, s IServer) { + var purchase webpurchase.Purchase + vars := mux.Vars(r) + var err error + + if purchase, err = s.PurchaseAPI().GetByLicenseID(vars["licenseID"]); err != nil { + switch err { + case webpurchase.ErrNotFound: + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusNotFound) + default: + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + } + return + } + // purchase found + enc := json.NewEncoder(w) + if err = enc.Encode(purchase); err == nil { + // send json of correctly encoded user info + w.Header().Set("Content-Type", api.ContentType_JSON) + w.WriteHeader(http.StatusOK) + return + } + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) +} + +// getLicenseInfo decoldes a license in data (bytes, response.body) +func getLicenseInfo(data []byte, lic *license.License) error { + var dec *json.Decoder + dec = json.NewDecoder(bytes.NewReader(data)) + if err := dec.Decode(&lic); err != nil { + return err + } + return nil +} + +//UpdatePurchase updates a purchase in the database +func UpdatePurchase(w http.ResponseWriter, r *http.Request, s IServer) { + var newPurchase webpurchase.Purchase + vars := mux.Vars(r) + var id int + var err error + if id, err = strconv.Atoi(vars["id"]); err != nil { + // id is not a number + problem.Error(w, r, problem.Problem{Detail: "Purchase ID must be an integer"}, http.StatusBadRequest) + return + } + //ID is a number, check user (json) + if newPurchase, err = DecodeJSONPurchase(r); err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + return + } + + // purchase found + if err := s.PurchaseAPI().Update(webpurchase.Purchase{ + ID: int64(id), + LicenseUUID: newPurchase.LicenseUUID, + StartDate: newPurchase.StartDate, + EndDate: newPurchase.EndDate, + Status: newPurchase.Status}); err != nil { + + switch err { + case webpurchase.ErrNotFound: + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusNotFound) + default: + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + } + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/frontend/api/repository.go b/frontend/api/repository.go new file mode 100644 index 00000000..2eaa2dc5 --- /dev/null +++ b/frontend/api/repository.go @@ -0,0 +1,57 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package staticapi + +import ( + "encoding/json" + "net/http" + + "github.com/readium/readium-lcp-server/api" + "github.com/readium/readium-lcp-server/frontend/webrepository" + "github.com/readium/readium-lcp-server/problem" +) + +// GetRepositoryMasterFiles returns a list of repository masterfiles +func GetRepositoryMasterFiles(w http.ResponseWriter, r *http.Request, s IServer) { + var err error + + files := make([]webrepository.RepositoryFile, 0) + + fn := s.RepositoryAPI().GetMasterFiles() + + for it, err := fn(); err == nil; it, err = fn() { + files = append(files, it) + } + + w.Header().Set("Content-Type", api.ContentType_JSON) + + enc := json.NewEncoder(w) + err = enc.Encode(files) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + return + } +} diff --git a/frontend/api/user.go b/frontend/api/user.go new file mode 100644 index 00000000..15c0edee --- /dev/null +++ b/frontend/api/user.go @@ -0,0 +1,205 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package staticapi + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/gorilla/mux" + "github.com/readium/readium-lcp-server/api" + "github.com/readium/readium-lcp-server/frontend/webuser" + "github.com/readium/readium-lcp-server/problem" +) + +//GetUsers returns a list of users +func GetUsers(w http.ResponseWriter, r *http.Request, s IServer) { + var page int64 + var perPage int64 + var err error + if r.FormValue("page") != "" { + page, err = strconv.ParseInt((r).FormValue("page"), 10, 32) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + return + } + } else { + page = 1 + } + if r.FormValue("per_page") != "" { + perPage, err = strconv.ParseInt((r).FormValue("per_page"), 10, 32) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + return + } + } else { + perPage = 30 + } + if page > 0 { + page-- //pagenum starting at 0 in code, but user interface starting at 1 + } + if page < 0 { + problem.Error(w, r, problem.Problem{Detail: "page must be positive integer"}, http.StatusBadRequest) + return + } + users := make([]webuser.User, 0) + //log.Println("ListAll(" + strconv.Itoa(int(per_page)) + "," + strconv.Itoa(int(page)) + ")") + fn := s.UserAPI().ListUsers(int(perPage), int(page)) + for it, err := fn(); err == nil; it, err = fn() { + users = append(users, it) + } + if len(users) > 0 { + nextPage := strconv.Itoa(int(page) + 1) + w.Header().Set("Link", "; rel=\"next\"; title=\"next\"") + } + if page > 1 { + previousPage := strconv.Itoa(int(page) - 1) + w.Header().Set("Link", "; rel=\"previous\"; title=\"previous\"") + } + w.Header().Set("Content-Type", api.ContentType_JSON) + + enc := json.NewEncoder(w) + err = enc.Encode(users) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + return + } +} + +//GetUserByEmail searches a client by his email +func GetUser(w http.ResponseWriter, r *http.Request, s IServer) { + vars := mux.Vars(r) + id, err := strconv.Atoi(vars["id"]) + if err != nil { + // id is not a number + problem.Error(w, r, problem.Problem{Detail: "User ID must be an integer"}, http.StatusBadRequest) + } + if user, err := s.UserAPI().Get(int64(id)); err == nil { + enc := json.NewEncoder(w) + if err = enc.Encode(user); err == nil { + // send json of correctly encoded user info + w.Header().Set("Content-Type", api.ContentType_JSON) + w.WriteHeader(http.StatusOK) + return + } + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + } else { + switch err { + case webuser.ErrNotFound: + { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusNotFound) + } + default: + { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + } + } + } + return +} + +//DecodeJSONUser transforms a json string to a User struct +func DecodeJSONUser(r *http.Request) (webuser.User, error) { + var dec *json.Decoder + if ctype := r.Header["Content-Type"]; len(ctype) > 0 && ctype[0] == api.ContentType_JSON { + dec = json.NewDecoder(r.Body) + } + user := webuser.User{} + err := dec.Decode(&user) + return user, err +} + +//CreateUser creates a user in the database +func CreateUser(w http.ResponseWriter, r *http.Request, s IServer) { + var user webuser.User + var err error + if user, err = DecodeJSONUser(r); err != nil { + problem.Error(w, r, problem.Problem{Detail: "incorrect JSON User " + err.Error()}, http.StatusBadRequest) + return + } + //user ok + if err := s.UserAPI().Add(user); err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + return + } + // user added to db + w.WriteHeader(http.StatusCreated) +} + +//UpdateUser updates an identified user (id) in the database +func UpdateUser(w http.ResponseWriter, r *http.Request, s IServer) { + vars := mux.Vars(r) + var id int + var err error + var user webuser.User + if id, err = strconv.Atoi(vars["id"]); err != nil { + // id is not a number + problem.Error(w, r, problem.Problem{Detail: "User ID must be an integer"}, http.StatusBadRequest) + return + } + //ID is a number, check user (json) + if user, err = DecodeJSONUser(r); err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + return + } + // user ok, id is a number, search user to update + if _, err := s.UserAPI().Get(int64(id)); err != nil { + switch err { + case webuser.ErrNotFound: + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusNotFound) + default: + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + } + } else { + // client is found! + if err := s.UserAPI().Update(webuser.User{ID: int64(id), Name: user.Name, Email: user.Email, Password: user.Password}); err != nil { + //update failed! + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + return + } + //database update ok + w.WriteHeader(http.StatusOK) + //return + } + +} + +//DeleteUser creates a user in the database +func DeleteUser(w http.ResponseWriter, r *http.Request, s IServer) { + vars := mux.Vars(r) + uid, err := strconv.ParseInt(vars["id"], 10, 64) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + return + } + if err := s.UserAPI().DeleteUser(uid); err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + return + } + // user added to db + w.WriteHeader(http.StatusOK) +} diff --git a/frontend/frontend.go b/frontend/frontend.go new file mode 100644 index 00000000..d44869b1 --- /dev/null +++ b/frontend/frontend.go @@ -0,0 +1,169 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package main + +import ( + "database/sql" + "fmt" + "log" + "os" + "os/signal" + "path/filepath" + "runtime" + "strconv" + "strings" + "syscall" + + _ "github.com/go-sql-driver/mysql" + _ "github.com/lib/pq" + _ "github.com/mattn/go-sqlite3" + + "github.com/readium/readium-lcp-server/config" + "github.com/readium/readium-lcp-server/frontend/server" + "github.com/readium/readium-lcp-server/frontend/webpublication" + "github.com/readium/readium-lcp-server/frontend/webpurchase" + "github.com/readium/readium-lcp-server/frontend/webrepository" + "github.com/readium/readium-lcp-server/frontend/webuser" +) + +func dbFromURI(uri string) (string, string) { + parts := strings.Split(uri, "://") + return parts[0], parts[1] +} + +func main() { + var dbURI, static, configFile string + var err error + + if configFile = os.Getenv("READIUM_WEBTEST_CONFIG"); configFile == "" { + configFile = "config.yaml" + } + config.ReadConfig(configFile) + log.Println("Read config from " + configFile) + + err = config.SetPublicUrls() + if err != nil { + panic(err) + } + + log.Println("LCP server = " + config.Config.LcpServer.PublicBaseUrl) + log.Println("using login " + config.Config.LcpUpdateAuth.Username) + + if dbURI = config.Config.FrontendServer.Database; dbURI == "" { + dbURI = "sqlite3://file:frontend.sqlite?cache=shared&mode=rwc" + } + driver, cnxn := dbFromURI(dbURI) + db, err := sql.Open(driver, cnxn) + if err != nil { + panic(err) + } + _, err = db.Exec("PRAGMA journal_mode = WAL") + if err != nil { + panic(err) + } + + repoManager, err := webrepository.Init(config.Config.FrontendServer) + if err != nil { + panic(err) + } + + publicationDB, err := webpublication.Init(config.Config, db) + if err != nil { + panic(err) + } + + userDB, err := webuser.Open(db) + if err != nil { + panic(err) + } + + purchaseDB, err := webpurchase.Init(config.Config, db) + if err != nil { + panic(err) + } + + static = config.Config.FrontendServer.Directory + if static == "" { + _, file, _, _ := runtime.Caller(0) + here := filepath.Dir(file) + static = filepath.Join(here, "../frontend/manage") + } + + filepathConfigJs := filepath.Join(static, "config.js") + fileConfigJs, err := os.Create(filepathConfigJs) + if err != nil { + panic(err) + } + + defer func() { + if err := fileConfigJs.Close(); err != nil { + panic(err) + } + }() + + configJs := ` + // This file is automatically generated, and git-ignored. + // To ignore your local changes, use: + // git update-index --assume-unchanged frontend/manage/config.js + window.Config = {` + configJs += "\n\tfrontend: {url: '" + config.Config.FrontendServer.PublicBaseUrl + "' },\n" + configJs += "\tlcp: {url: '" + config.Config.LcpServer.PublicBaseUrl + "'},\n" + configJs += "\tlsd: {url: '" + config.Config.LsdServer.PublicBaseUrl + "'}\n}" + + log.Println("manage/index.html config.js:") + log.Println(configJs) + + fileConfigJs.WriteString(configJs) + HandleSignals() + s := frontend.New(config.Config.FrontendServer.Host+":"+strconv.Itoa(config.Config.FrontendServer.Port), static, repoManager, publicationDB, userDB, purchaseDB) + log.Println("Frontend webserver for LCP running on " + config.Config.FrontendServer.Host + ":" + strconv.Itoa(config.Config.FrontendServer.Port)) + log.Println("using database " + dbURI) + + if err := s.ListenAndServe(); err != nil { + log.Println("Error " + err.Error()) + } +} + +// HandleSignals handles system signals and adds a log before quitting +func HandleSignals() { + sigChan := make(chan os.Signal) + go func() { + stacktrace := make([]byte, 1<<20) + for sig := range sigChan { + switch sig { + case syscall.SIGQUIT: + length := runtime.Stack(stacktrace, true) + fmt.Println(string(stacktrace[:length])) + case syscall.SIGINT: + fallthrough + case syscall.SIGTERM: + fmt.Println("Shutting down...") + os.Exit(0) + } + } + }() + signal.Notify(sigChan, syscall.SIGQUIT, syscall.SIGINT, syscall.SIGTERM) +} diff --git a/frontend/manage/.editorconfig b/frontend/manage/.editorconfig new file mode 100644 index 00000000..928602c5 --- /dev/null +++ b/frontend/manage/.editorconfig @@ -0,0 +1,28 @@ +# http://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +# 4 spaces indentation +[*.{ts,scss,css}] +indent_style = space +indent_size = 4 + +# 2 spaces indentation +[*.{html}] +indent_style = space +indent_size = 2 + +[*.md] +max_line_length = 0 +trim_trailing_whitespace = false + +# Indentation override +#[lib/**.js] +#[{package.json,.travis.yml}] +#[**/**.js] diff --git a/frontend/manage/.travis.yml b/frontend/manage/.travis.yml new file mode 100644 index 00000000..20ff41e7 --- /dev/null +++ b/frontend/manage/.travis.yml @@ -0,0 +1,20 @@ +dist: trusty +sudo: required +language: node_js +node_js: + - "5" +os: + - linux +env: + global: + - DBUS_SESSION_BUS_ADDRESS=/dev/null + - DISPLAY=:99.0 + - CHROME_BIN=chromium-browser +before_script: + - sh -e /etc/init.d/xvfb start +install: + - npm install +script: + - npm run lint + - npm run test-once + - npm run e2e diff --git a/frontend/manage/LICENSE-angular-quickstart b/frontend/manage/LICENSE-angular-quickstart new file mode 100644 index 00000000..51b127e8 --- /dev/null +++ b/frontend/manage/LICENSE-angular-quickstart @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2014-2016 Google, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/frontend/manage/README.md b/frontend/manage/README.md new file mode 100644 index 00000000..4b8baf4d --- /dev/null +++ b/frontend/manage/README.md @@ -0,0 +1,101 @@ +# see Angular QuickStart Source (github) + +## Prerequisites + +Node.js and npm are essential to Angular development. + + +Get it now if it's not already installed on your machine. + +**Verify that you are running at least node `v4.x.x` and npm `3.x.x`** +by running `node -v` and `npm -v` in a terminal/console window. +Older versions produce errors. + +We recommend [nvm](https://github.com/creationix/nvm) for managing multiple versions of node and npm. + +## Install npm packages + +> See npm and nvm version notes above + +Install the npm packages described in the `package.json` and verify that it works: + +```bash +npm install +npm start +``` + +Only for development: (This angular project should be served by staticserver (go project)) + +The `npm start` command first compiles the application, +then simultaneously re-compiles and runs the `lite-server`. +Both the compiler and the server watch for file changes. + +Shut it down manually with `Ctrl-C`. +Index.html contains angular test lcp project +The old index.html is now renamed to manage.html + +### npm scripts + +We've captured many of the most useful commands in npm scripts defined in the `package.json`: + +* `npm start` - runs the compiler and a server at the same time, both in "watch mode". +* `npm run tsc` - runs the TypeScript compiler once. +* `npm run tsc:w` - runs the TypeScript compiler in watch mode; the process keeps running, awaiting changes to TypeScript files and re-compiling when it sees them. +* `npm run lite` - runs the [lite-server](https://www.npmjs.com/package/lite-server), a light-weight, static file server, written and maintained by +[John Papa](https://github.com/johnpapa) and +[Christopher Martin](https://github.com/cgmartin) +with excellent support for Angular apps that use routing. + +Here are the test related scripts: +* `npm test` - compiles, runs and watches the karma unit tests +* `npm run e2e` - run protractor e2e tests, written in JavaScript (*e2e-spec.js) + +## Testing + +karma/jasmine unit test and protractor end-to-end testing support. + +These tools are configured for specific conventions described below. + +*It is unwise and rarely possible to run the application, the unit tests, and the e2e tests at the same time. +We recommend that you shut down one before starting another.* + +### Unit Tests +TypeScript unit-tests are usually in the `app` folder. Their filenames must end in `.spec`. + +Look for the example `app/app.component.spec.ts`. +Add more `.spec.ts` files as you wish; we configured karma to find them. + +Run it with `npm test` + +That command first compiles the application, then simultaneously re-compiles and runs the karma test-runner. +Both the compiler and the karma watch for (different) file changes. + +Shut it down manually with `Ctrl-C`. + +Test-runner output appears in the terminal window. +We can update our app and our tests in real-time, keeping a weather eye on the console for broken tests. +Karma is occasionally confused and it is often necessary to shut down its browser or even shut the command down (`Ctrl-C`) and +restart it. No worries; it's pretty quick. + +### End-to-end (E2E) Tests + +E2E tests are in the `e2e` directory, side by side with the `app` folder. +Their filenames must end in `.e2e-spec.ts`. + +Look for the example `e2e/app.e2e-spec.ts`. +Add more `.e2e-spec.js` files as you wish (although one usually suffices for small projects); +we configured protractor to find them. + +Thereafter, run them with `npm run e2e`. + +That command first compiles, then simultaneously starts the Http-Server at `localhost:8080` +and launches protractor. + +The pass/fail test results appear at the bottom of the terminal window. +A custom reporter (see `protractor.config.js`) generates a `./_test-output/protractor-results.txt` file +which is easier to read; this file is excluded from source control. + +Shut it down manually with `Ctrl-C`. + +[travis-badge]: https://travis-ci.org/angular/quickstart.svg?branch=master +[travis-badge-url]: https://travis-ci.org/angular/quickstart diff --git a/frontend/manage/app/app-routing.module.ts b/frontend/manage/app/app-routing.module.ts new file mode 100644 index 00000000..d2ab1e3f --- /dev/null +++ b/frontend/manage/app/app-routing.module.ts @@ -0,0 +1,19 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { PageNotFoundComponent } from './not-found.component'; + +const appRoutes: Routes = [ + { path: '', redirectTo: '/dashboard', pathMatch: 'full' }, + { path: '**', component: PageNotFoundComponent } +]; + +@NgModule({ + imports: [ + RouterModule.forRoot(appRoutes) + ], + exports: [ + RouterModule + ] +}) + +export class AppRoutingModule {} diff --git a/frontend/manage/app/app.component.html b/frontend/manage/app/app.component.html new file mode 100644 index 00000000..5b7fbdf9 --- /dev/null +++ b/frontend/manage/app/app.component.html @@ -0,0 +1,13 @@ +
+ + +
+
+
+
+ +
+
+
+
+
diff --git a/frontend/manage/app/app.component.ts b/frontend/manage/app/app.component.ts new file mode 100644 index 00000000..4bf6eac4 --- /dev/null +++ b/frontend/manage/app/app.component.ts @@ -0,0 +1,28 @@ +import { Component, OnDestroy } from '@angular/core'; +import { Subscription } from 'rxjs/Subscription'; + +import { SidebarService } from './shared/sidebar/sidebar.service'; + +@Component({ + moduleId: module.id, + selector: 'lcp-app', + templateUrl: 'app.component.html' +}) + +export class AppComponent implements OnDestroy { + sidebarOpen: boolean = false; + private sidebarSubscription: Subscription; + + constructor(private sidebarService: SidebarService) { + this.sidebarSubscription = sidebarService.open$.subscribe( + sidebarOpen => { + this.sidebarOpen = sidebarOpen; + } + ); + } + + ngOnDestroy() { + // prevent memory leak when component destroyed + this.sidebarSubscription.unsubscribe(); + } +} diff --git a/frontend/manage/app/app.module.ts b/frontend/manage/app/app.module.ts new file mode 100644 index 00000000..cbc52495 --- /dev/null +++ b/frontend/manage/app/app.module.ts @@ -0,0 +1,39 @@ +import { NgModule } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import { HttpModule } from '@angular/http'; + +import { PageNotFoundComponent } from './not-found.component'; +import { AppComponent } from './app.component'; +import { AppRoutingModule } from './app-routing.module'; + +import { LsdModule } from './lsd/lsd.module'; +import { SidebarModule } from './shared/sidebar/sidebar.module'; +import { HeaderModule } from './shared/header/header.module'; +import { DashboardModule } from './dashboard/dashboard.module'; +import { UserModule } from './user/user.module'; +import { PublicationModule } from './publication/publication.module'; +import { PurchaseModule } from './purchase/purchase.module'; + +@NgModule({ + imports: [ + BrowserModule, + HttpModule, + LsdModule, + HeaderModule, + SidebarModule, + DashboardModule, + UserModule, + PublicationModule, + PurchaseModule, + AppRoutingModule, + ], + declarations: [ + AppComponent, + PageNotFoundComponent + ], + bootstrap: [ + AppComponent + ] +}) + +export class AppModule { } diff --git a/frontend/manage/app/components/app.component.html b/frontend/manage/app/components/app.component.html new file mode 100644 index 00000000..4590d1ca --- /dev/null +++ b/frontend/manage/app/components/app.component.html @@ -0,0 +1,2 @@ +

Testing web site for License Server Protection and License Status Document

+ \ No newline at end of file diff --git a/frontend/manage/app/components/app.component.ts b/frontend/manage/app/components/app.component.ts new file mode 100644 index 00000000..f16da426 --- /dev/null +++ b/frontend/manage/app/components/app.component.ts @@ -0,0 +1,8 @@ +import { Component } from '@angular/core'; + +@Component({ + moduleId: module.id, + selector: 'lcp-test-app', + templateUrl: '/app/components/app.component.html' +}) +export class AppComponent { name = 'Lcp/lsd server test'; } diff --git a/frontend/manage/app/components/lsd-structs.ts b/frontend/manage/app/components/lsd-structs.ts new file mode 100644 index 00000000..43459e9d --- /dev/null +++ b/frontend/manage/app/components/lsd-structs.ts @@ -0,0 +1,37 @@ + +export class Updated { + license: Date; + status: Date; +} + +export class Link { + rel: string; + href: string; + type: string; + title: string; + profile: string; + templated: boolean; +} + +export class PotentialRights { + end: Date; +} + + +export class Event { + name: string; // device name + timestamp: Date; + type: string; + id: string; // device ID +} + +export class LicenseStatus { + id: string; + status: string; + updated: Updated; + message: string; + links: Link[]; + device_count: number; + potential_rights: PotentialRights; + events: Event[]; +} diff --git a/frontend/manage/app/components/lsd.service.ts b/frontend/manage/app/components/lsd.service.ts new file mode 100644 index 00000000..32679001 --- /dev/null +++ b/frontend/manage/app/components/lsd.service.ts @@ -0,0 +1,110 @@ +import { Injectable } from '@angular/core'; +import { Http } from '@angular/http'; +import 'rxjs/add/operator/toPromise'; +// import { User } from './user'; +// import { Purchase } from './purchase'; +import * as lsd from './lsd-structs'; + +declare var Config: any; // this comes from the autogenerated config.js file +@Injectable() +export class LsdService { + private lsdServer = Config.lsd.url; // from Config + // private headers = new Headers ({'Content-Type': 'application/json'}); + + static getParamsFor(id: string, name: string) { + let params = ''; + if ( id !== undefined ) { + params += '?id=' + id; + if ( name !== undefined ) { + params += '&name=' + name; + } + } else if ( name !== undefined ) { + params += '?name=' + name; + } + return params; + } + + static getParams(enddate: Date, id: string, name: string) { + let p = LsdService.getParamsFor(id, name); + if (p === '') { + return '?end=' + enddate.toISOString(); + } else { + p += '&end=' + enddate.toISOString(); + return p; + } + } + + constructor (private http: Http) { } + + getStatus(licenseID: string, id: string | undefined, name: string | undefined): Promise { + return this.http.get(this.lsdServer + '/licenses/' + licenseID + '/status' + LsdService.getParamsFor(id, name) ) + .toPromise() + .then(function (response) { + if ((response.status === 200) || (response.status === 201)) { + return response.json(); + } else { + throw 'Error in getStatus(License Status Document); ' + response.status + response.text; + } + }) + .catch(this.handleError); + } + + registerDevice(licenseID: string, id: string, name: string | undefined): Promise { + return this.http.post(this.lsdServer + '/licenses/' + licenseID + '/register' + LsdService.getParamsFor(id, name), undefined ) + .toPromise() + .then(function (response) { + if ((response.status === 200) || (response.status === 201)) { + return response.json(); + } else if ((response.status === 400)) { // bad request + let obj = response.json(); + throw 'Error registering device (License Status Document): ' + obj.detail + '\n' + response.status + response.text; + } else if ((response.status === 404)) { // license not found + let obj = response.json(); + throw 'License not found: ' + obj.detail + '\n' + response.status + response.text; + } else { + throw 'Error registering device (License Status Document); ' + response.status + response.text; + } + }) + .catch(this.handleError); + } + + returnLoan(licenseID: string, id: string | undefined, name: string | undefined): Promise { + return this.http.put(this.lsdServer + '/licenses/' + licenseID + '/return' + LsdService.getParamsFor(id, name), undefined ) + .toPromise() + .then(function (response) { + if ((response.status === 200) || (response.status === 201)) { + return response.json(); + } else { + throw 'Error in returnLoan(License Status Document); ' + response.status + response.text; + } + }) + .catch(this.handleError); + } + + renewLoan(licenseID: string, endLicense: Date, id: string | undefined, name: string | undefined): Promise { + return this.http.put(this.lsdServer + '/licenses/' + licenseID + '/renew' + LsdService.getParams(endLicense, id, name), undefined ) + .toPromise() + .then(function (response) { + if ((response.status === 200) || (response.status === 201)) { + return response.json(); + } else if ((response.status === 400)) { + let obj = response.json(); + throw 'Error in renewLoan(License Status Document): ' + obj.detail + '\n' + response.status + response.text; + } else { + throw 'Error in renewLoan(License Status Document); ' + response.status + response.text; + } + }) + .catch(this.handleRenewError); + } + + + private handleError(error: any): Promise { + console.error('An error occurred (lsd-service)', error); + return Promise.reject(error.message || error); + } + + private handleRenewError(error: any): Promise { + console.error('Error renew (lsd-service)', error); + return Promise.reject(error); + } +} diff --git a/frontend/manage/app/components/partialLicense.ts b/frontend/manage/app/components/partialLicense.ts new file mode 100644 index 00000000..8bd0c4ed --- /dev/null +++ b/frontend/manage/app/components/partialLicense.ts @@ -0,0 +1,64 @@ +import { User } from './user'; + +export const PROFILE = 'http://readium.org/lcp/profile-1.0'; +export const USERKEY_ALGO = 'http://www.w3.org/2001/04/xmlenc#sha256'; +export const PROVIDER = 'http://edrlab.org'; + +export class Key { + algorithm: string; +} + +export class ContentKey extends Key { + encrypted_value: any[] | undefined; +} + +export class UserKey extends Key { + text_hint: string; + key_check: any[] | undefined; + value: any[] | undefined; + clear_value: string | undefined; +} + +export class Encryption { + profile: string; + content_key: ContentKey | undefined; + user_key: UserKey | undefined; +} + +export class Link { + rel: string; + href: string; + type: string | undefined; + title: string | undefined; + profile: string | undefined; + templated: boolean | undefined; + size: number | undefined; + checksum: string | undefined; +}; + +export class UserRights { + print: number | undefined; + copy: number | undefined; + start: Date |undefined; + end: Date |undefined; +} + +export class UserInfo { + id: string; + email: string; + name: string; + encrypted: string[] | undefined; +} + + +export class PartialLicense { + provider: string; // 'http://edrlab.org' + user: UserInfo; // get it from user.user_id, user_email, ... + encryption: Encryption; + rights: UserRights | undefined; +} + +export class PartialLicenseJSON extends PartialLicense { + // function to encode / decode JSON string + +} diff --git a/frontend/manage/app/components/purchase-list-component.ts b/frontend/manage/app/components/purchase-list-component.ts new file mode 100644 index 00000000..26b743f4 --- /dev/null +++ b/frontend/manage/app/components/purchase-list-component.ts @@ -0,0 +1,122 @@ +import { Component, Input, OnInit } from '@angular/core'; +import {ActivatedRoute, Params} from '@angular/router'; +import {Location} from '@angular/common'; + +import { User } from './user'; +import { Purchase } from './purchase'; +import { PurchaseService } from './purchase.service'; +import {LsdService} from './lsd.service'; + +@Component({ + moduleId: module.id, + selector: 'purchases', + templateUrl: '/app/components/purchases.html', + styleUrls: ['../../app/components/purchases.css'], + providers: [PurchaseService, LsdService] +}) + +export class PurchasesComponent implements OnInit { + @Input() user: User; + @Input() hours: string; + @Input() deviceID: string; + @Input() deviceName: string; + + purchases: Purchase[]; + selectedPurchase: Purchase; + + constructor( + private purchaseService: PurchaseService, + private lsdService: LsdService, + private route: ActivatedRoute, + private location: Location + ) {} + + ngOnInit(): void { + this.purchaseService.getPurchases(this.user) + .then(purchases => this.purchases = purchases); + } + goBack(): void { + this.location.back(); + } + + onSelect(p: Purchase): void { + this.selectedPurchase = p; + } + RegisterDevice(p: Purchase, deviceID: string, deviceName: string|undefined) { + this.deviceID = deviceID; + this.deviceName = deviceName; + console.log('register license for device ' + deviceID ); + if ( p.licenseID !== '') { + this.lsdService.registerDevice(p.licenseID, deviceID, deviceName) + .then( status => alert('DEVICE registered!\n' + JSON.stringify(status) ) ) + .catch( reason => alert( 'PROBLEM: \n' + reason._body)); + } else { + alert('No licenseID for this purchase, please press download to create a license.'); + } + } + + RenewLoan(p: Purchase, hours: number, deviceID: string|undefined, deviceName: string|undefined) { + console.log('should renew license for another ' + hours + ' hours. ()' + p.label + ')'); + if ( p.licenseID !== '') { + let t = Date.now(); + t += hours * 3600 * 1000; + this.lsdService.renewLoan(p.licenseID, new Date(t), deviceID, deviceName) + .then( status => alert(JSON.stringify(status) ) ) + .catch( reason => alert( 'RENEW PROBLEM: \n' + reason._body)); + } else { + alert('No licenseID for this purchase, please press download to create a license.'); + } + } + + // contact lsd server and return the license + ReturnLoan(p: Purchase, deviceID: string|undefined, deviceName: string|undefined) { + if ( p.licenseID !== '') { + this.lsdService.returnLoan(p.licenseID, deviceID, deviceName) + .then( status => alert(JSON.stringify(status) ) ) + .catch( reason => console.log('error returning license for ' + p.label + ':' + reason) ) + } else { + alert('No licenseID yet for this purchase! (clic download first)'); + } + } + + // contact lsd server and CheckStatus of the license + CheckStatus(p: Purchase) { + if ( p.licenseID !== '') { + this.lsdService.getStatus(p.licenseID,undefined,undefined) + .then( status => alert(JSON.stringify(status) ) ) + .catch( reason => console.log('error checking LSD status for ' + p.label + ':' + reason) ) + } else { + alert('No licenseID for this purchase, please press download to create a license.'); + } + + } + DownloadLicense(p: Purchase): void { + // get License ! + if ( p.licenseID === undefined) { + console.log('Get license and download ' + p.label ); + // the license does not yet exist (some error occured ?) + // we need to recontact the static server and ask to create a new license + window.location.href = '/users/' + p.user.userID + '/purchases/' + p.purchaseID + '/license'; + } else { + console.log('Re-download ' + p.label + '(' + p.licenseID + ')'); + // redirect to /licenses/ p.licenseID + window.location.href = '/licenses/' + p.licenseID; + } + + } + + // get epub with license + DownloadPublication(p: Purchase): void { + if ( p.licenseID === undefined) { + console.log('Get purchase and download ' + p.label ); + // existing lcp function + // we need to recontact the static server and ask to create a new license + window.location.href = '/users/' + p.user.userID + '/purchases/' + p.purchaseID + '/publication'; + } else { + console.log('Re-download publication : ' + p.label + '(' + p.licenseID + ')'); + // redirect to /licenses/ p.licenseID + window.location.href = '/licenses/' + p.licenseID + '/publication'; + } + } + +} diff --git a/frontend/manage/app/components/purchase.service.ts b/frontend/manage/app/components/purchase.service.ts new file mode 100644 index 00000000..90362e2c --- /dev/null +++ b/frontend/manage/app/components/purchase.service.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@angular/core'; +import { Headers, Http } from '@angular/http'; +import 'rxjs/add/operator/toPromise'; +import { User } from './user'; +import { Purchase } from './purchase'; + +declare var Config: any; // this comes from the autogenerated config.js file +@Injectable() +export class PurchaseService { + private usersUrl = Config.frontend.url + '/users' ; + // /users/{user_id}/purchases + private headers = new Headers ({'Content-Type': 'application/json'}); + + constructor (private http: Http) { } + getPurchases(user: User): Promise { + return this.http.get(this.usersUrl + '/' + user.userID + '/purchases') + .toPromise() + .then(function (response) { + let purchases: Purchase[] = []; + for (let ResponseItem of response.json()) { + let p = new Purchase; + p.label = ResponseItem.label; + p.licenseID = ResponseItem.licenseID; + p.purchaseID = ResponseItem.purchaseID; + p.resource = ResponseItem.resource; + p.transactionDate = ResponseItem.transactionDate; + p.user = ResponseItem.user; + p.partialLicense = ResponseItem.partialLicense; + purchases[purchases.length] = p; + } + return purchases; + }) + .catch(this.handleError); + } + + create(purchase: Purchase): Promise { + return this.http + .put(this.usersUrl + '/' + purchase.user.userID + '/purchases', JSON.stringify(purchase), {headers: this.headers}) + .toPromise() + .then(function (response) { + if ((response.status === 200) || (response.status === 201)) { + return purchase; // ok + } else { + throw 'Error in create(purchase); ' + response.status + response.text; + } + }) + .catch(this.handleError); + } + + private handleError(error: any): Promise { + console.error('An error occurred (purchase-service)', error); + return Promise.reject(error.message || error); + } + +} diff --git a/frontend/manage/app/components/purchase.ts b/frontend/manage/app/components/purchase.ts new file mode 100644 index 00000000..f581f7ff --- /dev/null +++ b/frontend/manage/app/components/purchase.ts @@ -0,0 +1,11 @@ +import { User } from './user'; + +export class Purchase { + user: User; + purchaseID: Number; + resource: String; + label: string; + licenseID: string; + transactionDate: Date; + partialLicense: string; +} diff --git a/frontend/manage/app/components/purchases.css b/frontend/manage/app/components/purchases.css new file mode 100644 index 00000000..2d7e400c --- /dev/null +++ b/frontend/manage/app/components/purchases.css @@ -0,0 +1,23 @@ +.purchase { + border-color: darkgrey; + background: black; + color : white; + padding: 8px 8px 8px 8px; + margin: 16px 16px; +} +.rights,.register { + font-style: italic; + color : gray; + width: 50%; + margin-left: 32px; +} + +.licenseID { + font-size: 75%; + width: 50%; + margin-left: 32px; +} + +.inputhours { + width: 50px; +} \ No newline at end of file diff --git a/frontend/manage/app/components/purchases.html b/frontend/manage/app/components/purchases.html new file mode 100644 index 00000000..aa035cee --- /dev/null +++ b/frontend/manage/app/components/purchases.html @@ -0,0 +1,28 @@ +

Purchases

+ +
    +
  • + {{purchase.transactionDate | date:'medium' }} + + + + {{purchase.label}}
    + {{purchase.licenseID}} +
    + ID + Name + +
    +
    +
    + {{ purchase.partialLicense | ShowRights }} + +
    +
    + + + +
    +
    +
  • +
\ No newline at end of file diff --git a/frontend/manage/app/components/resource-list-component.ts b/frontend/manage/app/components/resource-list-component.ts new file mode 100644 index 00000000..c3d22893 --- /dev/null +++ b/frontend/manage/app/components/resource-list-component.ts @@ -0,0 +1,101 @@ +import { Component, Input, OnInit } from '@angular/core'; + +import { Router } from '@angular/router'; +import { User } from './user'; +import { Purchase } from './purchase'; +import { Resource } from './resource'; +import * as lic from './partialLicense'; +import { ResourceService } from './resource.service'; +import { PurchaseService } from './purchase.service'; + +@Component({ + moduleId: module.id, + selector: 'resources', + templateUrl: '/app/components/resource-list.html', + styleUrls: ['../../app/components/resource.css', '../../styles.css'], // from /js/app/components... + providers: [ResourceService, PurchaseService] +}) + + +export class ResourcesComponent implements OnInit { + resources: Resource[]; + selectedResource: Resource; + @Input() id: string; + @Input() user: User; + @Input() hours: string; + + constructor(private resourceService: ResourceService, private purchaseService: PurchaseService, private router: Router) { } + + getResources(): void { + this.resourceService.getResources().then(Resources => this.resources = Resources); + } + + ngOnInit(): void { + this.getResources(); + } + + onSelect(resource: Resource): void { + this.selectedResource = resource; + } + + onBuy(resource: Resource): void { + // buy action for selectedResource and user + // create partial license + let partialLicense = this.createPartialLicense(this.user, undefined); + let p = new Purchase; + p.label = resource.location; + p.partialLicense = JSON.stringify(partialLicense); + p.resource = resource.id; + p.user = this.user; + let rp: Purchase; + // create a purchase in database (and get license on lcpserver ) + this.purchaseService.create(p) + .then(p => this.router.navigate(['/userdetail', this.user.userID])); + } + + onLoan(resource: Resource, hours: string): void { + // TODO add parameters for loan action (period etc.) + let rights = new lic.UserRights; + rights.copy = 10; + rights.print = 10; + rights.start = new Date(); + let h: number = parseFloat(hours); + + if (isNaN(h)) { + rights.end = new Date( rights.start.valueOf() + 30 * 24 * 3600 * 1000); // + 30 days + } else { + rights.end = new Date( rights.start.valueOf() + h * 3600 * 1000); // + h hours + } + // loan action action for selectedResource and user + let partialLicense = this.createPartialLicense(this.user, rights); + let p = new Purchase; + p.label = resource.location; + p.partialLicense = JSON.stringify(partialLicense); + p.resource = resource.id; + p.user = this.user; + // create a purchase(loan) in database (and get license on lcpserver ) + this.purchaseService.create(p) + .then( p => this.router.navigate(['/userdetail', this.user.userID])); + } + + private hexToBytes(hex: string) { + let bytes: number[] = []; + for (let i = 0; i < (hex.length/2); i++) { + bytes.push(parseInt(hex.substr(i * 2, 2), 16)); + } + return bytes; + } + + private createPartialLicense(user: User, rights: lic.UserRights): lic.PartialLicense { + let partialLicense = new lic.PartialLicense; + partialLicense.provider = lic.PROVIDER; + partialLicense.user = {id: '_' + String(user.userID), email: user.email, name: user.alias, encrypted: undefined }; + partialLicense.rights = rights; + partialLicense.encryption = new lic.Encryption; + partialLicense.encryption.user_key = new lic.UserKey; + partialLicense.encryption.user_key.value = this.hexToBytes( user.password); + partialLicense.encryption.user_key.algorithm = lic.USERKEY_ALGO; + partialLicense.encryption.user_key.text_hint = 'Enter passphrase'; + return partialLicense; + } +} diff --git a/frontend/manage/app/components/resource-list.html b/frontend/manage/app/components/resource-list.html new file mode 100644 index 00000000..aa0b844a --- /dev/null +++ b/frontend/manage/app/components/resource-list.html @@ -0,0 +1,16 @@ +

Listing of LCP Resources (please choose one)

+
    +
  • + {{resource.id}} {{resource.location}} + {{resource.length}} {{resource.sha256}} +
  • +
+
+

{{selectedResource.location }} ( {{selectedResource.id }} )

+ + + + + + +
diff --git a/frontend/manage/app/components/resource.css b/frontend/manage/app/components/resource.css new file mode 100644 index 00000000..dba5f761 --- /dev/null +++ b/frontend/manage/app/components/resource.css @@ -0,0 +1,54 @@ +.lcpResource { + +} +.resources { + padding: 16px; + margin-left: 24px; +} + +.selectedResource { + position: absolute; + top: 100px; + right: 0; + z-index: 0; + width: 40%; + float: right; + + background-color: blue; + border-radius: 10px; + border: 3px solid midnightblue; + margin:8px 48px ; + padding:8px; +} + +.anID { + font-size: 9px; + color: silver; + font-weight: bold; +} +.aLength { + font-size: 9px; + color: red; + font-weight: bold; +} +.aSha256 { + font-size: 9px; + color: green; + font-weight: bold; +} + + +.aLabel { + +} +.lcpBuy { + +} +.lcpLoan { + +} + + +.inputhours { + width: 50px; +} \ No newline at end of file diff --git a/frontend/manage/app/components/resource.service.ts b/frontend/manage/app/components/resource.service.ts new file mode 100644 index 00000000..3362b1b6 --- /dev/null +++ b/frontend/manage/app/components/resource.service.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@angular/core'; +import { Http } from '@angular/http'; +import 'rxjs/add/operator/toPromise'; +import { Resource } from './resource'; + +declare var Config: any; // this comes from the autogenerated config.js file +@Injectable() +export class ResourceService { + private resourceUrl = Config.lcp.url + '/contents'; + + constructor (private http: Http) { } + getResources(): Promise { + return this.http.get(this.resourceUrl) + .toPromise() + .then(function (response) { + let resources: Resource[] = []; + for (let jsonResult of response.json()) { + resources[resources.length] = { + id: jsonResult.id, location: jsonResult.location, length: jsonResult.length, sha256: jsonResult.sha356 + }; + } + return resources; + }) + .catch(this.handleError); + } + + private handleError(error: any): Promise { + console.error('An error occurred', error); + return Promise.reject(error.message || error); + } + getUser(id: string): Promise { + return this.getResources() + .then(resources => resources.find(resource => resource.id === id)); + } +} diff --git a/frontend/manage/app/components/resource.ts b/frontend/manage/app/components/resource.ts new file mode 100644 index 00000000..4219455a --- /dev/null +++ b/frontend/manage/app/components/resource.ts @@ -0,0 +1,6 @@ +export class Resource { + id: string; + location: string; + length: number; + sha256: string | undefined | null ; +} diff --git a/frontend/manage/app/components/rightsFromPartialLicense.pipe.ts b/frontend/manage/app/components/rightsFromPartialLicense.pipe.ts new file mode 100644 index 00000000..201a53a6 --- /dev/null +++ b/frontend/manage/app/components/rightsFromPartialLicense.pipe.ts @@ -0,0 +1,60 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import * as lic from './partialLicense'; + +/* + * Return only the rights of a partial license (or undefined) + * Takes partialLicense as a string argument + * Usage: + * partialLicense | filterRights + */ +@Pipe({name: 'FilterRights'}) +export class FilterRights implements PipeTransform { + + transform(partialLicense: string): lic.UserRights | undefined { + let r: lic.UserRights = new lic.UserRights; + let obj: any; + obj = JSON.parse(partialLicense, function (key, value): any { + if (typeof value === 'string') { + let a = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}(?:\.\d*)?)Z$/.exec(value); + if (a) { + return new Date(Date.UTC(+a[1], +a[2] - 1, +a[3], +a[4], +a[5], +a[6])); + } + } + return value; + }); + if ( obj.rights ) { + console.log(obj.rights); + r = obj.rights; + return r; + } + return undefined; + } +} + +@Pipe({name: 'ShowRights'}) +export class ShowRights implements PipeTransform { + + transform(partialLicense: string): string { + let r: lic.UserRights = new lic.UserRights; + let obj: any; + obj = JSON.parse(partialLicense, function (key, value): any { + if (typeof value === 'string') { + let a = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}(?:\.\d*)?)Z$/.exec(value); + if (a) { + return new Date(Date.UTC(+a[1], +a[2] - 1, +a[3], +a[4], +a[5], +a[6])); + } + } + return value; + }); + if ( obj.rights ) { + console.log(obj.rights); + r = obj.rights; + let s: string = ''; + if ( r.copy >0 ) { + s = 'copy=' + r.copy + ', print=' + r.print + ' '; + } + return s + 'available from ' + r.start.toLocaleString() + ' to ' + r.end.toLocaleString(); + } + return ''; + } +} \ No newline at end of file diff --git a/frontend/manage/app/components/user-component.ts b/frontend/manage/app/components/user-component.ts new file mode 100644 index 00000000..a5d3f6ad --- /dev/null +++ b/frontend/manage/app/components/user-component.ts @@ -0,0 +1,41 @@ +import { Component, Input, OnInit } from '@angular/core'; +import {ActivatedRoute, Params} from '@angular/router'; +import {Location} from '@angular/common'; + +import { User } from './user'; +import { UserService } from './user.service'; + +@Component({ + moduleId: module.id, + selector: 'user', + templateUrl: '/app/components/user.html', + styleUrls: ['../../app/components/user.css'], + providers: [UserService] +}) + +export class UserComponent implements OnInit { + @Input() user: User; + @Input() newPassword: string; + + constructor( + private userService: UserService, + private route: ActivatedRoute, + private location: Location + ) {} + + ngOnInit(): void { + this.route.params.forEach((params: Params) => { + let id = +params['id']; + this.userService.getUser(id) + .then(user => this.user = user); + }); + } + goBack(): void { + this.location.back(); + } + + save(): void { + this.userService.update(this.user, this.newPassword) + .then(() => this.goBack()); + } +} diff --git a/frontend/manage/app/components/user-list-component.ts b/frontend/manage/app/components/user-list-component.ts new file mode 100644 index 00000000..259c1162 --- /dev/null +++ b/frontend/manage/app/components/user-list-component.ts @@ -0,0 +1,63 @@ +import { Component, Input, OnInit } from '@angular/core'; + +import { Router } from '@angular/router'; +import { User } from './user'; +import { UserService } from './user.service'; + + +@Component({ + moduleId: module.id, + selector: 'users', + templateUrl: '/app/components/user-list.html', + styleUrls: ['../../app/components/user.css'], + providers: [UserService] +}) + + +export class UsersComponent implements OnInit { + users: User[]; + selectedUser: User; + @Input() alias: string; + @Input() email: string; + @Input() password: string; + + constructor(private UserService: UserService, private router: Router) { } + + + getUsers(): void { + this.UserService.getUsers().then(Users => this.users = Users); + } + + add(alias: string, email: string, password: string): void { + email = email.trim(); + if (!email) { return; }; + this.UserService.create(alias, email, password) + .then(User => { + this.getUsers(); // refresh user list + }); + } + + delete(user: User): void { + console.log('delete user ' + user.alias + ' ' + user.email + ' ' + user.userID); + this.UserService + .delete(user.userID) + .then(() => { + this.users = this.users.filter(h => h !== user ); + if (this.selectedUser === user ) { + this.selectedUser = null; + } + }); + } + + ngOnInit(): void { + this.getUsers(); + } + + onSelect(User: User): void { + this.selectedUser = User; + } + + gotoDetail(): void { + this.router.navigate(['/userdetail', this.selectedUser.userID]); + } +} diff --git a/frontend/manage/app/components/user-list.html b/frontend/manage/app/components/user-list.html new file mode 100644 index 00000000..eaef233c --- /dev/null +++ b/frontend/manage/app/components/user-list.html @@ -0,0 +1,24 @@ +

1. Subscribe and/or select user in the list below

+
+

Add User

+ + + + +
+

User list

+ + + + + + + +
{{user.userID}}{{user.alias}} hashed passphrase = {{user.password}}
+
+

2. Select item for license

+

Current user = {{selectedUser.alias | uppercase }}

+ +
diff --git a/frontend/manage/app/components/user.css b/frontend/manage/app/components/user.css new file mode 100644 index 00000000..014153ae --- /dev/null +++ b/frontend/manage/app/components/user.css @@ -0,0 +1,31 @@ +h2.user { + font-size: 200%; +} +.users { + width: 50%; + margin-left:16px; +} +.anID { + width: 50px; + margin-left: 8px; + padding: 8px; + border-radius: 10px; + border: 3px solid black; + text-align: center; +} +.aLabel { + width:200; + color: black; + background-color: gray; + border-radius: 10px; + border: 3px solid black; + text-align: left; + padding: 8px; +} +.selected { + background-color: blue; +} + +.delete { + background-color: red; +} diff --git a/frontend/manage/app/components/user.html b/frontend/manage/app/components/user.html new file mode 100644 index 00000000..3b4d5d27 --- /dev/null +++ b/frontend/manage/app/components/user.html @@ -0,0 +1,18 @@ +
+

{{user.alias}}'s details!

+ + +
+ + + + + +
+ + + + + + +
diff --git a/frontend/manage/app/components/user.service.ts b/frontend/manage/app/components/user.service.ts new file mode 100644 index 00000000..a3a8a6cc --- /dev/null +++ b/frontend/manage/app/components/user.service.ts @@ -0,0 +1,75 @@ +import { Injectable } from '@angular/core'; +import { Headers, Http } from '@angular/http'; +import 'rxjs/add/operator/toPromise'; +import { User } from './user'; +import * as jsSHA from 'jssha'; + +declare var Config: any; // this comes from the autogenerated config.js file +@Injectable() +export class UserService { + private usersUrl = Config.frontend.url + '/users' ; + private headers = new Headers ({'Content-Type': 'application/json'}); + + constructor (private http: Http) { } + getUsers(): Promise { + return this.http.get(this.usersUrl) + .toPromise() + .then(function (response) { + let users: User[] = []; + for (let jsonUser of response.json()) { + users[users.length] = {userID: jsonUser.userID, alias: jsonUser.alias, email: jsonUser.email, password: jsonUser.password}; + } + return users; + }) + .catch(this.handleError); + } + + create(newAlias: string, newEmail: string, newPassword: string): Promise { + const jsSHAObject:jsSHA.jsSHA = new jsSHA("SHA-256","TEXT"); + jsSHAObject.update(newPassword); + let hashedPassword = jsSHAObject.getHash ("HEX"); + let user: User = {userID: null, alias: newAlias, email: newEmail, password: hashedPassword}; + return this.http + .put(this.usersUrl, JSON.stringify(user), {headers: this.headers}) + .toPromise() + .then(function (response) { + if (response.status === 201) { + return user; + } else { + throw 'Error creating user ' + response.text; + } + }) + .catch(this.handleError); + } + + delete(id: number): Promise { + const url = `${this.usersUrl}/${id}`; + return this.http.delete(url, {headers: this.headers}) + .toPromise() + .then(() => null) + .catch(this.handleError); + } + + private handleError(error: any): Promise { + console.error('An error occurred', error); + return Promise.reject(error.message || error); + } + getUser(id: number): Promise { + return this.getUsers() + .then(users => users.find(user => user.userID === id)); + } + update(user: User, newPassword: string |undefined): Promise { + if ((user.password != newPassword) && newPassword!=undefined) { + const jsSHAObject:jsSHA.jsSHA = new jsSHA("SHA-256","TEXT"); + jsSHAObject.update(newPassword); + user.password = jsSHAObject.getHash ("HEX"); + } + const url = `${this.usersUrl}/${user.userID}`; + return this.http + .post(url, JSON.stringify(user), {headers: this.headers}) + .toPromise() + .then(() => user) + .catch(this.handleError); + } + +} diff --git a/frontend/manage/app/components/user.ts b/frontend/manage/app/components/user.ts new file mode 100644 index 00000000..d3a96073 --- /dev/null +++ b/frontend/manage/app/components/user.ts @@ -0,0 +1,6 @@ +export class User { + userID: number; + alias: string; + email: string; + password: string | undefined | null ; +} diff --git a/frontend/manage/app/crud/crud-item.ts b/frontend/manage/app/crud/crud-item.ts new file mode 100644 index 00000000..5cdde8cc --- /dev/null +++ b/frontend/manage/app/crud/crud-item.ts @@ -0,0 +1,3 @@ +export interface CrudItem { + id: any; +} diff --git a/frontend/manage/app/crud/crud.service.ts b/frontend/manage/app/crud/crud.service.ts new file mode 100644 index 00000000..e79fc034 --- /dev/null +++ b/frontend/manage/app/crud/crud.service.ts @@ -0,0 +1,104 @@ +import { Injectable } from '@angular/core'; +import { Http, Headers } from '@angular/http'; + +import 'rxjs/add/operator/toPromise'; + +import { CrudItem } from './crud-item' + +export abstract class CrudService { + http: Http; + defaultHttpHeaders = new Headers( + {'Content-Type': 'application/json'}); + baseUrl: string; + + // Decode Json from API and build crud object + abstract decode(jsonObj: any): T; + + // Encode crud object to API json + abstract encode(obj: T): any; + + list(): Promise { + var self = this + return this.http.get( + this.baseUrl, + { headers: this.defaultHttpHeaders }) + .toPromise() + .then(function (response) { + let items: T[] = []; + + for (let jsonObj of response.json()) { + items.push(self.decode(jsonObj)); + } + + return items; + }) + .catch(this.handleError); + } + + get(id: string): Promise { + var self = this + return this.http + .get( + this.baseUrl + "/" + id, + { headers: this.defaultHttpHeaders }) + .toPromise() + .then(function (response) { + let jsonObj = response.json(); + return self.decode(jsonObj); + }) + .catch(this.handleError); + } + + delete(id: string): Promise { + var self = this + return this.http.delete(this.baseUrl + "/" + id) + .toPromise() + .then(function (response) { + if (response.ok) { + return true; + } else { + throw 'Error creating user ' + response.text; + } + }) + .catch(this.handleError); + } + + add(obj: T): Promise { + return this.http + .post( + this.baseUrl, + this.encode(obj), + { headers: this.defaultHttpHeaders }) + .toPromise() + .then(function (response) { + if (response.ok) { + return obj; + } else { + throw 'Error creating user ' + response.text; + } + }) + .catch(this.handleError); + } + + update(obj: T): Promise { + return this.http + .put( + this.baseUrl + "/" + obj.id, + this.encode(obj), + { headers: this.defaultHttpHeaders }) + .toPromise() + .then(function (response) { + if (response.ok) { + return obj; + } else { + throw 'Error creating user ' + response.text; + } + }) + .catch(this.handleError); + } + + protected handleError(error: any): Promise { + console.error('An error occurred', error); + return Promise.reject(error.message || error); + } +} diff --git a/frontend/manage/app/dashboard/dashboard-routing.module.ts b/frontend/manage/app/dashboard/dashboard-routing.module.ts new file mode 100644 index 00000000..ef1eafdb --- /dev/null +++ b/frontend/manage/app/dashboard/dashboard-routing.module.ts @@ -0,0 +1,19 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { DashboardComponent } from './dashboard.component'; + +const dashboardRoutes: Routes = [ + { path: 'dashboard', component: DashboardComponent } +]; + +@NgModule({ + imports: [ + RouterModule.forChild(dashboardRoutes) + ], + exports: [ + RouterModule + ] +}) + +export class DashboardRoutingModule { } diff --git a/frontend/manage/app/dashboard/dashboard.component.html b/frontend/manage/app/dashboard/dashboard.component.html new file mode 100644 index 00000000..dcabf8fd --- /dev/null +++ b/frontend/manage/app/dashboard/dashboard.component.html @@ -0,0 +1 @@ +Dashboard diff --git a/frontend/manage/app/dashboard/dashboard.component.ts b/frontend/manage/app/dashboard/dashboard.component.ts new file mode 100644 index 00000000..fb9192e2 --- /dev/null +++ b/frontend/manage/app/dashboard/dashboard.component.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core'; + +@Component({ + moduleId: module.id, + selector: 'lcp-frontend-dashboard', + templateUrl: 'dashboard.component.html' +}) + +export class DashboardComponent { } diff --git a/frontend/manage/app/dashboard/dashboard.module.ts b/frontend/manage/app/dashboard/dashboard.module.ts new file mode 100644 index 00000000..0236ddf5 --- /dev/null +++ b/frontend/manage/app/dashboard/dashboard.module.ts @@ -0,0 +1,19 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule } from '@angular/router'; + +import { DashboardRoutingModule } from './dashboard-routing.module'; +import { DashboardComponent } from './dashboard.component'; + +@NgModule({ + imports: [ + CommonModule, + RouterModule, + DashboardRoutingModule + ], + declarations: [ + DashboardComponent + ] +}) + +export class DashboardModule { } diff --git a/frontend/manage/app/lsd/license-status.ts b/frontend/manage/app/lsd/license-status.ts new file mode 100644 index 00000000..81cc0e9f --- /dev/null +++ b/frontend/manage/app/lsd/license-status.ts @@ -0,0 +1,35 @@ +export class Updated { + license: Date; + status: Date; +} + +export class Link { + rel: string; + href: string; + type: string; + title: string; + profile: string; + templated: boolean; +} + +export class PotentialRights { + end: Date; +} + +export class Event { + name: string; // device name + timestamp: Date; + type: string; + id: string; // device ID +} + +export class LicenseStatus { + id: string; + status: string; + updated: Updated; + message: string; + links: Link[]; + device_count: number; + potential_rights: PotentialRights; + events: Event[]; +} diff --git a/frontend/manage/app/lsd/lsd.module.ts b/frontend/manage/app/lsd/lsd.module.ts new file mode 100644 index 00000000..8b50b0e0 --- /dev/null +++ b/frontend/manage/app/lsd/lsd.module.ts @@ -0,0 +1,22 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule } from '@angular/router'; +import { + FormsModule, + ReactiveFormsModule } from '@angular/forms'; + +import { Ng2DatetimePickerModule } from 'ng2-datetime-picker'; + +import { LsdService } from './lsd.service'; + +@NgModule({ + imports: [ + CommonModule + ], + declarations: [], + providers: [ + LsdService + ] +}) + +export class LsdModule { } diff --git a/frontend/manage/app/lsd/lsd.service.ts b/frontend/manage/app/lsd/lsd.service.ts new file mode 100644 index 00000000..1264ebfc --- /dev/null +++ b/frontend/manage/app/lsd/lsd.service.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@angular/core'; +import { Http, Headers } from '@angular/http'; + +import 'rxjs/add/operator/toPromise'; + +import { LicenseStatus } from './license-status' + +declare var Config: any; // this comes from the autogenerated config.js file + +@Injectable() +export class LsdService { + defaultHttpHeaders = new Headers( + {'Content-Type': 'application/json'}); + baseUrl: string = Config.lsd.url; + + constructor (private http: Http) { + } + + get(id: string): Promise { + let url = this.baseUrl + "/licenses/" + id + "/status"; + return this.http + .get( + url, + { headers: this.defaultHttpHeaders }) + .toPromise() + .then(function (response) { + if (response.ok) { + let jsonObj = response.json(); + let licenseStatus = jsonObj as LicenseStatus; + return licenseStatus; + } else { + throw 'Error retrieving license ' + response.text(); + } + }) + .catch(this.handleError); + } + + protected handleError(error: any): Promise { + console.error('An error occurred', error); + return Promise.reject(error.message || error); + } +} diff --git a/frontend/manage/app/main.ts b/frontend/manage/app/main.ts new file mode 100644 index 00000000..6af7a5b2 --- /dev/null +++ b/frontend/manage/app/main.ts @@ -0,0 +1,5 @@ +import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + +import { AppModule } from './app.module'; + +platformBrowserDynamic().bootstrapModule(AppModule); diff --git a/frontend/manage/app/not-found.component.ts b/frontend/manage/app/not-found.component.ts new file mode 100644 index 00000000..92541f82 --- /dev/null +++ b/frontend/manage/app/not-found.component.ts @@ -0,0 +1,7 @@ +import { Component } from '@angular/core'; + +@Component({ + template: '

Page not found

' +}) + +export class PageNotFoundComponent {} diff --git a/frontend/manage/app/publication/master-file.ts b/frontend/manage/app/publication/master-file.ts new file mode 100644 index 00000000..71c9a610 --- /dev/null +++ b/frontend/manage/app/publication/master-file.ts @@ -0,0 +1,3 @@ +export class MasterFile { + name: string; +} diff --git a/frontend/manage/app/publication/publication-add.component.html b/frontend/manage/app/publication/publication-add.component.html new file mode 100644 index 00000000..10105591 --- /dev/null +++ b/frontend/manage/app/publication/publication-add.component.html @@ -0,0 +1,8 @@ +

Add publication

+ + + + diff --git a/frontend/manage/app/publication/publication-add.component.ts b/frontend/manage/app/publication/publication-add.component.ts new file mode 100644 index 00000000..004ed58f --- /dev/null +++ b/frontend/manage/app/publication/publication-add.component.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core'; + +@Component({ + moduleId: module.id, + selector: 'lcp-publication-add', + templateUrl: 'publication-add.component.html' +}) + +export class PublicationAddComponent {} diff --git a/frontend/manage/app/publication/publication-edit.component.html b/frontend/manage/app/publication/publication-edit.component.html new file mode 100644 index 00000000..d60fb316 --- /dev/null +++ b/frontend/manage/app/publication/publication-edit.component.html @@ -0,0 +1,10 @@ +

Edit publication

+ +
+ + + +
diff --git a/frontend/manage/app/publication/publication-edit.component.ts b/frontend/manage/app/publication/publication-edit.component.ts new file mode 100644 index 00000000..3e9005a9 --- /dev/null +++ b/frontend/manage/app/publication/publication-edit.component.ts @@ -0,0 +1,29 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Params } from '@angular/router'; +import 'rxjs/add/operator/switchMap'; + +import { Publication } from './publication'; +import { PublicationService } from './publication.service'; + +@Component({ + moduleId: module.id, + selector: 'lcp-publication-edit', + templateUrl: 'publication-edit.component.html' +}) + +export class PublicationEditComponent implements OnInit { + publication: Publication; + + constructor( + private route: ActivatedRoute, + private publicationService: PublicationService) { + } + + ngOnInit(): void { + this.route.params + .switchMap((params: Params) => this.publicationService.get(""+params['id'])) + .subscribe(publication => { + this.publication = publication + }); + } +} diff --git a/frontend/manage/app/publication/publication-form.component.html b/frontend/manage/app/publication/publication-form.component.html new file mode 100644 index 00000000..315e7228 --- /dev/null +++ b/frontend/manage/app/publication/publication-form.component.html @@ -0,0 +1,21 @@ +
+
+ + +
+
+ + +
+ + +
diff --git a/frontend/manage/app/publication/publication-form.component.ts b/frontend/manage/app/publication/publication-form.component.ts new file mode 100644 index 00000000..10477d29 --- /dev/null +++ b/frontend/manage/app/publication/publication-form.component.ts @@ -0,0 +1,96 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; +import { + FormGroup, + FormControl, + Validators, + FormBuilder } from '@angular/forms'; + +import { Publication } from './publication'; +import { PublicationService } from './publication.service'; +import { MasterFile } from './master-file'; + +@Component({ + moduleId: module.id, + selector: 'lcp-publication-form', + templateUrl: 'publication-form.component.html' +}) + +export class PublicationFormComponent implements OnInit { + @Input() + publication: Publication; + masterFiles: MasterFile[]; + + hideFilename: boolean = false + submitButtonLabel: string = "Add"; + form: FormGroup; + + private submitted = false; + + constructor( + private fb: FormBuilder, + private router: Router, + private publicationService: PublicationService) { + } + + refreshMasterFiles(): void { + this.publicationService.getMasterFiles().then( + masterFiles => { + this.masterFiles = masterFiles; + } + ); + } + + ngOnInit(): void { + this.refreshMasterFiles(); + + if (this.publication == null) { + this.submitButtonLabel = "Add"; + this.form = this.fb.group({ + "title": ["", Validators.required], + "filename": ["", Validators.required] + }); + } else { + this.hideFilename = true + this.submitButtonLabel = "Save"; + this.form = this.fb.group({ + "title": [this.publication.title, Validators.required] + }); + } + } + + gotoList() { + this.router.navigate(['/publications']); + } + + onCancel() { + this.gotoList(); + } + + onSubmit() { + if (this.publication) { + // Update publication + this.publication.title = this.form.value['title']; + this.publicationService.update( + this.publication + ).then( + publication => { + this.gotoList(); + } + ); + } else { + // Create publication + let publication = new Publication(); + publication.title = this.form.value['title']; + publication.masterFilename = this.form.value['filename']; + this.publicationService.add(publication) + .then( + newPublication => { + this.gotoList(); + } + ); + } + + this.submitted = true; + } +} diff --git a/frontend/manage/app/publication/publication-list.component.html b/frontend/manage/app/publication/publication-list.component.html new file mode 100644 index 00000000..975dcd5c --- /dev/null +++ b/frontend/manage/app/publication/publication-list.component.html @@ -0,0 +1,40 @@ +

Publications

+ +
+ +
+ + + + + + + + + + + + + + + + + + + +
#UUIDTitleActions
{{publication.id}}{{publication.uuid}}{{publication.title}} + + + Edit + +
diff --git a/frontend/manage/app/publication/publication-list.component.ts b/frontend/manage/app/publication/publication-list.component.ts new file mode 100644 index 00000000..b72cc8a2 --- /dev/null +++ b/frontend/manage/app/publication/publication-list.component.ts @@ -0,0 +1,37 @@ +import { Component, OnInit } from '@angular/core'; +import { Publication } from './publication'; +import { PublicationService } from './publication.service'; + +@Component({ + moduleId: module.id, + selector: 'lcp-publication-list', + templateUrl: 'publication-list.component.html' +}) + +export class PublicationListComponent implements OnInit { + publications: Publication[]; + + constructor(private publicationService: PublicationService) { + this.publications = []; + } + + refreshPublications(): void { + this.publicationService.list().then( + publications => { + this.publications = publications; + } + ); + } + + ngOnInit(): void { + this.refreshPublications(); + } + + onRemove(objId: any): void { + this.publicationService.delete(objId).then( + publication => { + this.refreshPublications(); + } + ); + } + } diff --git a/frontend/manage/app/publication/publication-routing.module.ts b/frontend/manage/app/publication/publication-routing.module.ts new file mode 100644 index 00000000..7dd8c01a --- /dev/null +++ b/frontend/manage/app/publication/publication-routing.module.ts @@ -0,0 +1,23 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { PublicationEditComponent } from './publication-edit.component'; +import { PublicationAddComponent } from './publication-add.component'; +import { PublicationListComponent } from './publication-list.component'; + +const publicationRoutes: Routes = [ + { path: 'publications/:id/edit', component: PublicationEditComponent }, + { path: 'publications/add', component: PublicationAddComponent }, + { path: 'publications', component: PublicationListComponent } +]; + +@NgModule({ + imports: [ + RouterModule.forChild(publicationRoutes) + ], + exports: [ + RouterModule + ] +}) + +export class PublicationRoutingModule { } diff --git a/frontend/manage/app/publication/publication.module.ts b/frontend/manage/app/publication/publication.module.ts new file mode 100644 index 00000000..b36d0107 --- /dev/null +++ b/frontend/manage/app/publication/publication.module.ts @@ -0,0 +1,34 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule } from '@angular/router'; +import { + FormsModule, + ReactiveFormsModule } from '@angular/forms'; + +import { PublicationService } from './publication.service'; +import { PublicationRoutingModule } from './publication-routing.module'; +import { PublicationAddComponent } from './publication-add.component'; +import { PublicationEditComponent } from './publication-edit.component'; +import { PublicationListComponent } from './publication-list.component'; +import { PublicationFormComponent } from './publication-form.component'; + +@NgModule({ + imports: [ + CommonModule, + RouterModule, + FormsModule, + ReactiveFormsModule, + PublicationRoutingModule + ], + declarations: [ + PublicationAddComponent, + PublicationEditComponent, + PublicationListComponent, + PublicationFormComponent + ], + providers: [ + PublicationService + ] +}) + +export class PublicationModule { } diff --git a/frontend/manage/app/publication/publication.service.ts b/frontend/manage/app/publication/publication.service.ts new file mode 100644 index 00000000..23aa7318 --- /dev/null +++ b/frontend/manage/app/publication/publication.service.ts @@ -0,0 +1,66 @@ +import { Injectable } from '@angular/core'; +import { Http } from '@angular/http'; + +import 'rxjs/add/operator/toPromise'; + +import { Publication } from './publication'; +import { MasterFile } from './master-file'; + +import { CrudService } from '../crud/crud.service'; + +declare var Config: any; // this comes from the autogenerated config.js file + +@Injectable() +export class PublicationService extends CrudService { + private masterFileListUrl: string; + + constructor (http: Http) { + super(); + this.http = http; + this.baseUrl = Config.frontend.url + '/api/v1/publications'; + this.masterFileListUrl = Config.frontend.url + + '/api/v1/repositories/master-files'; + } + + decode(jsonObj: any): Publication { + return { + id: jsonObj.id, + uuid: jsonObj.uuid, + title: jsonObj.title, + status: jsonObj.status, + masterFilename: null + } + } + + encode(obj: Publication): any { + return { + id: obj.id, + title: obj.title, + masterFilename: obj.masterFilename + } + } + + getMasterFiles(): Promise { + return this.http + .get( + this.masterFileListUrl, + { headers: this.defaultHttpHeaders }) + .toPromise() + .then(function (response) { + if (response.ok) { + let items: MasterFile[] = []; + + for (let jsonObj of response.json()) { + items.push({ + name: jsonObj.name + }); + } + + return items; + } else { + throw 'Error creating user ' + response.text; + } + }) + .catch(this.handleError); + } +} diff --git a/frontend/manage/app/publication/publication.ts b/frontend/manage/app/publication/publication.ts new file mode 100644 index 00000000..4e29d0bd --- /dev/null +++ b/frontend/manage/app/publication/publication.ts @@ -0,0 +1,9 @@ +import { CrudItem } from '../crud/crud-item'; + +export class Publication implements CrudItem { + id: number; + uuid: string; + title: string; + masterFilename?: string; + status?: string; +} diff --git a/frontend/manage/app/purchase/purchase-add.component.html b/frontend/manage/app/purchase/purchase-add.component.html new file mode 100644 index 00000000..40c9388e --- /dev/null +++ b/frontend/manage/app/purchase/purchase-add.component.html @@ -0,0 +1,8 @@ +

Add purchase

+ + + + diff --git a/frontend/manage/app/purchase/purchase-add.component.ts b/frontend/manage/app/purchase/purchase-add.component.ts new file mode 100644 index 00000000..5cd717f4 --- /dev/null +++ b/frontend/manage/app/purchase/purchase-add.component.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core'; + +@Component({ + moduleId: module.id, + selector: 'lcp-purchase-add', + templateUrl: 'purchase-add.component.html' +}) + +export class PurchaseAddComponent {} diff --git a/frontend/manage/app/purchase/purchase-edit.component.html b/frontend/manage/app/purchase/purchase-edit.component.html new file mode 100644 index 00000000..a4e9d89c --- /dev/null +++ b/frontend/manage/app/purchase/purchase-edit.component.html @@ -0,0 +1,10 @@ +

Renew purchase

+ +
+ + + +
diff --git a/frontend/manage/app/purchase/purchase-edit.component.ts b/frontend/manage/app/purchase/purchase-edit.component.ts new file mode 100644 index 00000000..53dc3f3f --- /dev/null +++ b/frontend/manage/app/purchase/purchase-edit.component.ts @@ -0,0 +1,29 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Params } from '@angular/router'; +import 'rxjs/add/operator/switchMap'; + +import { Purchase } from './purchase'; +import { PurchaseService } from './purchase.service'; + +@Component({ + moduleId: module.id, + selector: 'lcp-purchase-edit', + templateUrl: 'purchase-edit.component.html' +}) + +export class PurchaseEditComponent implements OnInit { + purchase: Purchase; + + constructor( + private route: ActivatedRoute, + private purchaseService: PurchaseService) { + } + + ngOnInit(): void { + this.route.params + .switchMap((params: Params) => this.purchaseService.get(params['id'])) + .subscribe(purchase => { + this.purchase = purchase + }); + } +} diff --git a/frontend/manage/app/purchase/purchase-form.component.html b/frontend/manage/app/purchase/purchase-form.component.html new file mode 100644 index 00000000..7bba131f --- /dev/null +++ b/frontend/manage/app/purchase/purchase-form.component.html @@ -0,0 +1,73 @@ +
+
+ + +
+ +
+ + +
+ +
+ +
+ +
+
+ +
+
+ +
+ +
+ +
+
+ +
+
+ +
+ + +
+ + + +
diff --git a/frontend/manage/app/purchase/purchase-form.component.ts b/frontend/manage/app/purchase/purchase-form.component.ts new file mode 100644 index 00000000..ac4006a6 --- /dev/null +++ b/frontend/manage/app/purchase/purchase-form.component.ts @@ -0,0 +1,172 @@ +import { + Component, + Input, + OnInit } from '@angular/core'; +import { Router } from '@angular/router'; +import { + FormGroup, + FormControl, + Validators, + FormBuilder } from '@angular/forms'; + +import * as moment from 'moment'; + +import { Purchase } from './purchase'; +import { PurchaseService } from './purchase.service'; +import { UserService } from '../user/user.service'; +import { User } from '../user/user'; +import { PublicationService } from '../publication/publication.service'; +import { Publication } from '../publication/publication'; + +@Component({ + moduleId: module.id, + selector: 'lcp-purchase-form', + templateUrl: 'purchase-form.component.html' +}) + +export class PurchaseFormComponent implements OnInit{ + @Input() + purchase: Purchase; + availablePublications: Publication[]; + availableUsers: User[]; + + edit: boolean = false; + submitButtonLabel: string = "Add"; + form: FormGroup; + + private submitted = false; + + constructor( + private fb: FormBuilder, + private router: Router, + private purchaseService: PurchaseService, + private userService: UserService, + private publicationService: PublicationService + ) {} + + refreshAvailablePublications(): void { + this.publicationService.list().then( + publications => { + this.availablePublications = publications; + } + ); + } + + refreshAvailableUsers(): void { + this.userService.list().then( + users => { + this.availableUsers = users; + } + ); + } + + ngOnInit(): void { + this.refreshAvailablePublications(); + this.refreshAvailableUsers(); + + if (this.purchase == null) { + this.purchase = new Purchase(); + this.submitButtonLabel = "Add"; + this.form = this.fb.group({ + "publication": ["", Validators.required], + "user": ["", Validators.required], + "end_date": ["", Validators.required], + "type": ["LOAN", Validators.required] + }); + + this.form.get('type').valueChanges.subscribe( + value => { + if(value == "LOAN") { + console.log("LOAN - REQUIRED"); + this.form.get('end_date').setValidators(Validators.required); + } else { + console.log("BUY - NOT REQUIRED"); + this.form.get('end_date').clearValidators(); + } + this.form.updateValueAndValidity(); + this.form.get('end_date').updateValueAndValidity(); + } + ); + + } else { + let dateTime = moment(this.purchase.endDate).format('YYYY-MM-DD HH:mm') + this.edit = true; + this.submitButtonLabel = "Save"; + this.form = this.fb.group({ + "renew_type": ["NO_END_DATE", Validators.required], + "end_date": dateTime //[dateTime, Validators.required] + }); + + this.form.get('renew_type').valueChanges.subscribe( + value => { + if(value == "NO_END_DATE") { + console.log("NO_END_DATE - REQUIRED"); + this.form.get('end_date').clearValidators(); + } else { + console.log("END_DATE - NOT REQUIRED"); + this.form.get('end_date').setValidators(Validators.required); + } + this.form.updateValueAndValidity(); + this.form.get('end_date').updateValueAndValidity(); + } + ); + } + } + + gotoList() { + this.router.navigate(['/purchases']); + } + + onCancel() { + this.gotoList(); + } + + onSubmit() { + this.bindForm(); + + if (this.edit) { + this.purchaseService.update( + this.purchase + ).then( + purchase => { + this.gotoList(); + } + ); + } else { + this.purchaseService.add(this.purchase).then( + purchase => { + this.gotoList(); + } + ); + } + + this.submitted = true; + } + + // Bind form to purchase + bindForm(): void { + if (!this.edit) { + let publicationId = this.form.value['publication']; + let userId = this.form.value['user']; + let publication = new Publication(); + let user = new User(); + publication.id = publicationId; + user.id = userId; + this.purchase.publication = publication; + this.purchase.user = user; + this.purchase.type = this.form.value['type']; + } else { + this.purchase.status = 'to-be-renewed'; + } + + if (this.form.value['end_date'].trim().length > 0) { + this.purchase.endDate = moment(this.form.value['end_date']).format(); + } + + if (this.edit && this.form.value['renew_type'] == 'NO_END_DATE') { + // Set end date to null + // End date will be processed by LSD + this.purchase.endDate = null; + } + } +} diff --git a/frontend/manage/app/purchase/purchase-list.component.html b/frontend/manage/app/purchase/purchase-list.component.html new file mode 100644 index 00000000..5fdd2026 --- /dev/null +++ b/frontend/manage/app/purchase/purchase-list.component.html @@ -0,0 +1,63 @@ +

Purchases

+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#DatePublicationUserTypeStart dateEnd dateDeliveredActions
{{purchase.id}}{{formatDate(purchase.transactionDate)}}{{purchase.publication.title}}{{purchase.user.name}}{{purchase.type}}{{formatDate(purchase.startDate)}}{{formatDate(purchase.endDate)}}{{purchase.licenseUuid != null}} +
+ + + Status + + + Renew + + +
+
diff --git a/frontend/manage/app/purchase/purchase-list.component.ts b/frontend/manage/app/purchase/purchase-list.component.ts new file mode 100644 index 00000000..318d3d93 --- /dev/null +++ b/frontend/manage/app/purchase/purchase-list.component.ts @@ -0,0 +1,132 @@ +import { Component, OnInit } from '@angular/core'; +import { Observable, Subscription } from 'rxjs/Rx'; + +import { Slug } from 'ng2-slugify'; +import * as moment from 'moment'; +import * as saveAs from 'file-saver'; + +import { Purchase } from './purchase'; +import { PurchaseService } from './purchase.service'; + +declare var Config: any; // this comes from the autogenerated config.js file + +@Component({ + moduleId: module.id, + selector: 'lcp-purchase-list', + templateUrl: 'purchase-list.component.html' +}) + +export class PurchaseListComponent implements OnInit { + purchases: Purchase[]; + private slug = new Slug('default'); + + constructor(private purchaseService: PurchaseService) { + this.purchases = []; + } + + refreshPurchases(): void { + this.purchaseService.list().then( + purchases => { + this.purchases = purchases; + } + ); + } + + buildLsdDownloadUrl(purchase: Purchase): string { + return Config.lsd.url + '/licenses/' + purchase.licenseUuid + '/status'; + } + + buildLcplDownloadUrl(purchase: Purchase): string { + return Config.frontend.url + '/api/v1/purchases/' + purchase.id + '/license'; + } + + buildLicenseDeliveredClass(licenseUuid: string) { + if (licenseUuid == null) { + return "danger"; + } + + return "success"; + } + + buildStatusClass(status: string) { + if (status == "error") { + return "danger"; + } else if (status == "returned") { + return "warning" + } + return "success"; + } + + formatDate(date: string): string { + return moment(date).format('YYYY-MM-DD HH:mm'); + } + + ngOnInit(): void { + this.refreshPurchases(); + } + + onRemove(objId: any): void { + this.purchaseService.delete(objId).then( + purchase => { + this.refreshPurchases(); + } + ); + } + + onDownload_LSD(purchase: Purchase): void { + + // The URL does not resolve to a content-disposition+filename like "ebook_title.lsd" + // If this were the case, most web browsers would normally just download the linked file. + // Instead, with some browsers the file is displayed (the current page context is overwritten) + let url = this.buildLsdDownloadUrl(purchase); + + //document.location.href = url; + window.open(url, "_blank"); + } + + onDownload_LCPL(purchase: Purchase): void { + // Wait 5 seconds before refreshing purchases + let downloadTimer = Observable.timer(5000); + let downloadSubscriber = downloadTimer.subscribe( + (t: any) => { + this.refreshPurchases(); + downloadSubscriber.unsubscribe(); + } + ); + + // The URL resolves to a content-disposition+filename like "ebook_title.lcpl" + // Most web browsers should normally just download the linked file, not display it. + let url = this.buildLcplDownloadUrl(purchase); + + //document.location.href = url; + window.open(url, "_blank"); + + /*this.purchaseService.getLicense(String(purchase.id)).then( + license => { + let data = new Blob( + [license], + { type: 'application/vnd.readium.lcp.license.1.0+json;charset=utf-8' + + //'application/json;charset=utf-8' + // + // Safari OSX does not work with the above content-types (known bug with saveAs() lib). + // Works with below types, but unfortunately filename is "Unknown", or direct webpage render (not download as file) + // + //'application/octet-stream' + //'text/plain;charset=utf-8' + }); + this.refreshPurchases(); + saveAs(data, this.slug.slugify(purchase.publication.title)+'.lcpl'); + } + );*/ + } + + onReturn(purchase: Purchase): void { + purchase.status = 'to-be-returned'; + this.purchaseService.update(purchase).then( + purchase => { + this.refreshPurchases(); + } + ); + } + } diff --git a/frontend/manage/app/purchase/purchase-routing.module.ts b/frontend/manage/app/purchase/purchase-routing.module.ts new file mode 100644 index 00000000..dbd8dc5c --- /dev/null +++ b/frontend/manage/app/purchase/purchase-routing.module.ts @@ -0,0 +1,25 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { PurchaseAddComponent } from './purchase-add.component'; +import { PurchaseEditComponent } from './purchase-edit.component'; +import { PurchaseStatusComponent } from './purchase-status.component'; +import { PurchaseListComponent } from './purchase-list.component'; + +const purchaseRoutes: Routes = [ + { path: 'purchases/:id/renew', component: PurchaseEditComponent }, + { path: 'purchases/:id/status', component: PurchaseStatusComponent }, + { path: 'purchases/add', component: PurchaseAddComponent }, + { path: 'purchases', component: PurchaseListComponent } +]; + +@NgModule({ + imports: [ + RouterModule.forChild(purchaseRoutes) + ], + exports: [ + RouterModule + ] +}) + +export class PurchaseRoutingModule { } diff --git a/frontend/manage/app/purchase/purchase-status.component.html b/frontend/manage/app/purchase/purchase-status.component.html new file mode 100644 index 00000000..9990f52a --- /dev/null +++ b/frontend/manage/app/purchase/purchase-status.component.html @@ -0,0 +1,23 @@ +

Purchase status

+ +
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+
diff --git a/frontend/manage/app/purchase/purchase-status.component.ts b/frontend/manage/app/purchase/purchase-status.component.ts new file mode 100644 index 00000000..e301499d --- /dev/null +++ b/frontend/manage/app/purchase/purchase-status.component.ts @@ -0,0 +1,37 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Params } from '@angular/router'; +import 'rxjs/add/operator/switchMap'; + +import { Purchase } from './purchase'; +import { PurchaseService } from './purchase.service'; +import { LsdService } from '../lsd/lsd.service'; +import { LicenseStatus } from '../lsd/license-status'; + +@Component({ + moduleId: module.id, + selector: 'lcp-purchase-status', + templateUrl: 'purchase-status.component.html' +}) + +export class PurchaseStatusComponent implements OnInit { + purchase: Purchase; + licenseStatus: LicenseStatus; + + constructor( + private route: ActivatedRoute, + private purchaseService: PurchaseService, + private lsdService: LsdService) { + } + + ngOnInit(): void { + this.route.params + .switchMap((params: Params) => this.purchaseService.get(params['id'])) + .subscribe(purchase => { + this.purchase = purchase; + this.lsdService.get(purchase.licenseUuid).then( + licenseStatus => { + this.licenseStatus = licenseStatus; + }); + }); + } +} diff --git a/frontend/manage/app/purchase/purchase.module.ts b/frontend/manage/app/purchase/purchase.module.ts new file mode 100644 index 00000000..6288ce15 --- /dev/null +++ b/frontend/manage/app/purchase/purchase.module.ts @@ -0,0 +1,39 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule } from '@angular/router'; +import { + FormsModule, + ReactiveFormsModule } from '@angular/forms'; + +import { Ng2DatetimePickerModule } from 'ng2-datetime-picker'; + +import { PurchaseService } from './purchase.service'; +import { PurchaseRoutingModule } from './purchase-routing.module'; +import { PurchaseListComponent } from './purchase-list.component'; +import { PurchaseFormComponent } from './purchase-form.component'; +import { PurchaseEditComponent } from './purchase-edit.component'; +import { PurchaseStatusComponent } from './purchase-status.component'; +import { PurchaseAddComponent } from './purchase-add.component'; + +@NgModule({ + imports: [ + CommonModule, + RouterModule, + FormsModule, + ReactiveFormsModule, + PurchaseRoutingModule, + Ng2DatetimePickerModule + ], + declarations: [ + PurchaseListComponent, + PurchaseFormComponent, + PurchaseEditComponent, + PurchaseStatusComponent, + PurchaseAddComponent + ], + providers: [ + PurchaseService + ] +}) + +export class PurchaseModule { } diff --git a/frontend/manage/app/purchase/purchase.service.ts b/frontend/manage/app/purchase/purchase.service.ts new file mode 100644 index 00000000..967940d9 --- /dev/null +++ b/frontend/manage/app/purchase/purchase.service.ts @@ -0,0 +1,81 @@ +import { Injectable } from '@angular/core'; +import { Http } from '@angular/http'; + +import 'rxjs/add/operator/toPromise'; + +import { Purchase } from './purchase'; +import { User } from '../user/user'; +import { Publication } from '../publication/publication'; + +import { CrudService } from '../crud/crud.service'; + +declare var Config: any; // this comes from the autogenerated config.js file + +@Injectable() +export class PurchaseService extends CrudService { + constructor ( + http: Http) { + super(); + this.http = http; + this.baseUrl = Config.frontend.url + '/api/v1/purchases'; + } + + decode(jsonObj: any): Purchase { + return { + id: jsonObj.id, + uuid: jsonObj.uuid, + publication: { + id: jsonObj.publication.id, + uuid: jsonObj.publication.uuid, + title: jsonObj.publication.title + }, + user: { + id: jsonObj.user.id, + uuid: jsonObj.uuid, + name: jsonObj.user.name, + email: jsonObj.user.email + }, + type: jsonObj.type, + licenseUuid: jsonObj.licenseUuid, + transactionDate: jsonObj.transactionDate, + startDate: jsonObj.startDate, + endDate: jsonObj.endDate, + status: jsonObj.status + }; + } + + encode(obj: Purchase): any { + return { + id: Number(obj.id), + uuid: obj.uuid, + publication: { + id: Number(obj.publication.id) + }, + user: { + id: Number(obj.user.id) + }, + type: obj.type, + licenseUuid: obj.licenseUuid, + startDate: obj.startDate, + endDate: obj.endDate, + status: obj.status + } + } + + getLicense(id: string): Promise { + let licenseUrl = this.baseUrl + "/" + id + "/license"; + return this.http + .get( + licenseUrl, + { headers: this.defaultHttpHeaders }) + .toPromise() + .then(function (response) { + if (response.ok) { + return response.text() + } else { + throw 'Error retrieving license ' + response.text(); + } + }) + .catch(this.handleError); + } +} diff --git a/frontend/manage/app/purchase/purchase.ts b/frontend/manage/app/purchase/purchase.ts new file mode 100644 index 00000000..1990211b --- /dev/null +++ b/frontend/manage/app/purchase/purchase.ts @@ -0,0 +1,17 @@ +import { CrudItem } from '../crud/crud-item'; +import { Publication } from '../publication/publication'; +import { User } from '../user/user'; + +export class Purchase implements CrudItem { + id: number; + uuid: string; + publication: Publication; + user: User; + type: string; + endDate: string; + transactionDate?: string; + startDate?: string; + licenseUuid?: string | null; + status?: string; + maxEndDate?: string; +} diff --git a/frontend/manage/app/shared/header/header.component.html b/frontend/manage/app/shared/header/header.component.html new file mode 100644 index 00000000..a99bbfa6 --- /dev/null +++ b/frontend/manage/app/shared/header/header.component.html @@ -0,0 +1,3 @@ + diff --git a/frontend/manage/app/shared/header/header.component.ts b/frontend/manage/app/shared/header/header.component.ts new file mode 100644 index 00000000..772c2ddd --- /dev/null +++ b/frontend/manage/app/shared/header/header.component.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core'; + +@Component({ + moduleId: module.id, + selector: 'lcp-header', + templateUrl: 'header.component.html' +}) + +export class HeaderComponent { } diff --git a/frontend/manage/app/shared/header/header.module.ts b/frontend/manage/app/shared/header/header.module.ts new file mode 100644 index 00000000..bcfcb1fe --- /dev/null +++ b/frontend/manage/app/shared/header/header.module.ts @@ -0,0 +1,18 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { HeaderComponent } from './header.component'; + +@NgModule({ + imports: [ + CommonModule + ], + declarations: [ + HeaderComponent + ], + exports: [ + HeaderComponent + ] +}) + +export class HeaderModule { } diff --git a/frontend/manage/app/shared/sidebar/sidebar.component.html b/frontend/manage/app/shared/sidebar/sidebar.component.html new file mode 100644 index 00000000..444a8c88 --- /dev/null +++ b/frontend/manage/app/shared/sidebar/sidebar.component.html @@ -0,0 +1,25 @@ + + diff --git a/frontend/manage/app/shared/sidebar/sidebar.component.ts b/frontend/manage/app/shared/sidebar/sidebar.component.ts new file mode 100644 index 00000000..42ad583d --- /dev/null +++ b/frontend/manage/app/shared/sidebar/sidebar.component.ts @@ -0,0 +1,17 @@ +import { Component } from '@angular/core'; +import { SidebarService } from './sidebar.service'; + +@Component({ + moduleId: module.id, + selector: 'lcp-sidebar', + templateUrl: 'sidebar.component.html' +}) + +export class SidebarComponent { + constructor(private sidebarService: SidebarService) { + } + + toggle() { + this.sidebarService.toggle(); + } +} diff --git a/frontend/manage/app/shared/sidebar/sidebar.module.ts b/frontend/manage/app/shared/sidebar/sidebar.module.ts new file mode 100644 index 00000000..5d537eee --- /dev/null +++ b/frontend/manage/app/shared/sidebar/sidebar.module.ts @@ -0,0 +1,24 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule } from '@angular/router'; + +import { SidebarService } from './sidebar.service'; +import { SidebarComponent } from './sidebar.component'; + +@NgModule({ + imports: [ + CommonModule, + RouterModule + ], + declarations: [ + SidebarComponent + ], + exports: [ + SidebarComponent + ], + providers: [ + SidebarService + ] +}) + +export class SidebarModule { } diff --git a/frontend/manage/app/shared/sidebar/sidebar.service.ts b/frontend/manage/app/shared/sidebar/sidebar.service.ts new file mode 100644 index 00000000..aaa42e71 --- /dev/null +++ b/frontend/manage/app/shared/sidebar/sidebar.service.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@angular/core'; +import { Subject } from 'rxjs/Subject'; + +@Injectable() +export class SidebarService { + private openSource = new Subject(); + private open: boolean = false; + + // Observable string streams + open$ = this.openSource.asObservable(); + + toggle() { + this.open = !(this.open); + this.openSource.next(this.open); + } +} diff --git a/frontend/manage/app/user/user-add.component.html b/frontend/manage/app/user/user-add.component.html new file mode 100644 index 00000000..9989d5b2 --- /dev/null +++ b/frontend/manage/app/user/user-add.component.html @@ -0,0 +1,8 @@ +

Add user

+ + + + diff --git a/frontend/manage/app/user/user-add.component.ts b/frontend/manage/app/user/user-add.component.ts new file mode 100644 index 00000000..404ea342 --- /dev/null +++ b/frontend/manage/app/user/user-add.component.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; +import { User } from './user'; +import { UserService } from './user.service'; + +@Component({ + moduleId: module.id, + selector: 'lcp-user-add', + templateUrl: 'user-add.component.html' +}) + +export class UserAddComponent {} diff --git a/frontend/manage/app/user/user-edit.component.html b/frontend/manage/app/user/user-edit.component.html new file mode 100644 index 00000000..d8c7dcb0 --- /dev/null +++ b/frontend/manage/app/user/user-edit.component.html @@ -0,0 +1,10 @@ +

Edit user

+ +
+ + + +
diff --git a/frontend/manage/app/user/user-edit.component.ts b/frontend/manage/app/user/user-edit.component.ts new file mode 100644 index 00000000..56d9a692 --- /dev/null +++ b/frontend/manage/app/user/user-edit.component.ts @@ -0,0 +1,29 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Params } from '@angular/router'; +import 'rxjs/add/operator/switchMap'; + +import { User } from './user'; +import { UserService } from './user.service'; + +@Component({ + moduleId: module.id, + selector: 'lcp-user-edit', + templateUrl: 'user-edit.component.html' +}) + +export class UserEditComponent implements OnInit { + user: User; + + constructor( + private route: ActivatedRoute, + private userService: UserService) { + } + + ngOnInit(): void { + this.route.params + .switchMap((params: Params) => this.userService.get(params['id'])) + .subscribe(user => { + this.user = user + }); + } +} diff --git a/frontend/manage/app/user/user-form.component.html b/frontend/manage/app/user/user-form.component.html new file mode 100644 index 00000000..7d26d6bd --- /dev/null +++ b/frontend/manage/app/user/user-form.component.html @@ -0,0 +1,22 @@ +
+
+ + +
+
+ + +
+
+ + +
+ + +
diff --git a/frontend/manage/app/user/user-form.component.ts b/frontend/manage/app/user/user-form.component.ts new file mode 100644 index 00000000..ccb57394 --- /dev/null +++ b/frontend/manage/app/user/user-form.component.ts @@ -0,0 +1,96 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; +import { + FormGroup, + FormControl, + Validators, + FormBuilder } from '@angular/forms'; + +import { User } from './user'; +import { UserService } from './user.service'; + +@Component({ + moduleId: module.id, + selector: 'lcp-user-form', + templateUrl: 'user-form.component.html' +}) + +export class UserFormComponent implements OnInit { + @Input() + user: User; + + edit: boolean = false; + submitButtonLabel: string = "Add"; + form: FormGroup; + + private submitted = false; + + constructor( + private fb: FormBuilder, + private router: Router, + private userService: UserService) { + } + + ngOnInit(): void { + if (this.user == null) { + this.user = new User(); + this.submitButtonLabel = "Add"; + this.form = this.fb.group({ + "name": ["", Validators.required], + "email": ["", Validators.required], + "password": ["", Validators.required] + }); + } else { + this.edit = true; + this.submitButtonLabel = "Save"; + this.form = this.fb.group({ + "name": [this.user.name, Validators.required], + "email": [this.user.email, Validators.required], + "password": "" + }); + } + } + + gotoList() { + this.router.navigate(['/users']); + } + + onCancel() { + this.gotoList(); + } + + onSubmit() { + this.bindForm(); + + if (this.edit) { + this.userService.update( + this.user + ).then( + user => { + this.gotoList(); + } + ); + } else { + this.userService.add(this.user).then( + user => { + this.gotoList(); + } + ); + } + + this.submitted = true; + } + + // Bind form to user + bindForm(): void { + this.user.name = this.form.value['name']; + this.user.email = this.form.value['email']; + + let newPassword: string = this.form.value['password']; + newPassword = newPassword.trim(); + + if (newPassword.length > 0) { + this.user.clearPassword = newPassword; + } + } +} diff --git a/frontend/manage/app/user/user-list.component.html b/frontend/manage/app/user/user-list.component.html new file mode 100644 index 00000000..79f7ada9 --- /dev/null +++ b/frontend/manage/app/user/user-list.component.html @@ -0,0 +1,40 @@ +

Users

+ +
+ +
+ + + + + + + + + + + + + + + + + + + +
#NameEmailActions
{{user.id}}{{user.name}}{{user.email}} + + + Edit + +
diff --git a/frontend/manage/app/user/user-list.component.ts b/frontend/manage/app/user/user-list.component.ts new file mode 100644 index 00000000..00a83615 --- /dev/null +++ b/frontend/manage/app/user/user-list.component.ts @@ -0,0 +1,37 @@ +import { Component, OnInit } from '@angular/core'; +import { User } from './user'; +import { UserService } from './user.service'; + +@Component({ + moduleId: module.id, + selector: 'lcp-user-list', + templateUrl: 'user-list.component.html' +}) + +export class UserListComponent implements OnInit { + users: User[]; + + constructor(private userService: UserService) { + this.users = []; + } + + refreshUsers(): void { + this.userService.list().then( + users => { + this.users = users; + } + ); + } + + ngOnInit(): void { + this.refreshUsers(); + } + + onRemove(objId: any): void { + this.userService.delete(objId).then( + user => { + this.refreshUsers(); + } + ); + } + } diff --git a/frontend/manage/app/user/user-routing.module.ts b/frontend/manage/app/user/user-routing.module.ts new file mode 100644 index 00000000..82f83f02 --- /dev/null +++ b/frontend/manage/app/user/user-routing.module.ts @@ -0,0 +1,23 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { UserListComponent } from './user-list.component'; +import { UserAddComponent } from './user-add.component'; +import { UserEditComponent } from './user-edit.component'; + +const userRoutes: Routes = [ + { path: 'users/:id/edit', component: UserEditComponent }, + { path: 'users/add', component: UserAddComponent }, + { path: 'users', component: UserListComponent } +]; + +@NgModule({ + imports: [ + RouterModule.forChild(userRoutes) + ], + exports: [ + RouterModule + ] +}) + +export class UserRoutingModule { } diff --git a/frontend/manage/app/user/user.module.ts b/frontend/manage/app/user/user.module.ts new file mode 100644 index 00000000..6d32a38a --- /dev/null +++ b/frontend/manage/app/user/user.module.ts @@ -0,0 +1,34 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule } from '@angular/router'; +import { + FormsModule, + ReactiveFormsModule } from '@angular/forms'; + +import { UserService } from './user.service'; +import { UserRoutingModule } from './user-routing.module'; +import { UserListComponent } from './user-list.component'; +import { UserFormComponent } from './user-form.component'; +import { UserAddComponent } from './user-add.component'; +import { UserEditComponent } from './user-edit.component'; + +@NgModule({ + imports: [ + CommonModule, + RouterModule, + FormsModule, + ReactiveFormsModule, + UserRoutingModule + ], + declarations: [ + UserListComponent, + UserFormComponent, + UserAddComponent, + UserEditComponent + ], + providers: [ + UserService + ] +}) + +export class UserModule { } diff --git a/frontend/manage/app/user/user.service.ts b/frontend/manage/app/user/user.service.ts new file mode 100644 index 00000000..6e79ab5a --- /dev/null +++ b/frontend/manage/app/user/user.service.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@angular/core'; +import { Http } from '@angular/http'; + +import 'rxjs/add/operator/toPromise'; +import * as jsSHA from 'jssha'; + +import { User } from './user'; + +import { CrudService } from '../crud/crud.service'; + +declare var Config: any; // this comes from the autogenerated config.js file + +@Injectable() +export class UserService extends CrudService { + constructor (http: Http) { + super(); + this.http = http; + this.baseUrl = Config.frontend.url + '/api/v1/users'; + } + + decode(jsonObj: any): User { + return { + id: jsonObj.id, + uuid: jsonObj.uuid, + name: jsonObj.name, + email: jsonObj.email, + password: jsonObj.password, + clearPassword: null + } + } + + encode(obj: User): any { + let jsonObj = { + id: obj.id, + uuid: obj.uuid, + name: obj.name, + email: obj.email, + password: obj.password + }; + + if (obj.clearPassword == null) { + // No password change + return jsonObj; + } + + // Hash password + let jsSHAObject:jsSHA.jsSHA = new jsSHA("SHA-256", "TEXT"); + jsSHAObject.update(obj.clearPassword); + jsonObj['password'] = jsSHAObject.getHash("HEX"); + return jsonObj; + } +} diff --git a/frontend/manage/app/user/user.ts b/frontend/manage/app/user/user.ts new file mode 100644 index 00000000..1b9adac0 --- /dev/null +++ b/frontend/manage/app/user/user.ts @@ -0,0 +1,10 @@ +import { CrudItem } from '../crud/crud-item'; + +export class User implements CrudItem { + id: number; + uuid: string; + name: string; + email: string; + password?: string | undefined | null; // Hashed password + clearPassword?: string | undefined | null; +} diff --git a/frontend/manage/assets/sass/app.scss b/frontend/manage/assets/sass/app.scss new file mode 100644 index 00000000..816bd64b --- /dev/null +++ b/frontend/manage/assets/sass/app.scss @@ -0,0 +1,140 @@ +// Variables +$header-bg-color: #222; +$header-color: #eee; +$sidebar-bg-color: #222; +$sidebar-color: #999; +$sidebar-item-over-bg-color: #000; +$sidebar-item-over-color: #fff; + +a:hover { + text-decoration: none; +} + +a:focus, +a:active, +a:hover { + color: inherit; +} + +#sidebar-toggler { + display: none; + position: fixed; + left: 0; + top: 10px; + color: $header-color; + z-index: 3; + border: none; + background: transparent; +} + +lcp-header { + position: fixed; + z-index: 2; + top: 0; + right: 0; + left: 0; + border: none; + border-radius: 0; + padding : 6px; + background: $header-bg-color; + color: $header-color; + text-align: left + + a { + color: $header-color; + } + + a:visited { + color: inherit; + } +} + +#sidebar { + position: fixed; + z-index: 3; + top: 50px; + left: 225px; + width: 225px; + bottom: 0; + margin-left: -225px; + border: none; + border-radius: 0; + overflow-y: auto; + overflow-x: hidden; + background: $sidebar-bg-color; + color: $sidebar-color; + padding-bottom: 40px; + -webkit-transition: all 0.2s ease-in-out; + -moz-transition: all 0.2s ease-in-out; + -ms-transition: all 0.2s ease-in-out; + -o-transition: all 0.2s ease-in-out; + transition: all 0.2s ease-in-out; + + ul { + display: block; + padding: 20px 0 0 0; + } + + li { + display: block; + padding: 0; + } + + li:hover, + li.active { + background: $sidebar-item-over-bg-color; + color: $sidebar-item-over-color; + } + + .fa { + width: 20px; + text-align: center; + } + + a { + display: block; + padding: 5px 10px; + } + + a:visited { + color: inherit; + } +} + +#main-container { + position: relative; + margin: 50px 0 0 225px; + padding: 10px; +} + +@media screen and (max-width: 768px) { + #sidebar-toggler { + display: block; + } + + lcp-header { + text-align: center; + } + + lcp-header .navbar-brand { + float: none; + display: inline-block; + cursor: pointer; + } + + #sidebar { + left: 0; + } + + #main-container { + margin-left : 0; + } + + #app.sidebar-open #sidebar { + left: 225px; + } + + #app.sidebar-open #main-container { + margin-left: 225px; + } +} diff --git a/frontend/manage/assets/sass/main.scss b/frontend/manage/assets/sass/main.scss new file mode 100644 index 00000000..b94d8e19 --- /dev/null +++ b/frontend/manage/assets/sass/main.scss @@ -0,0 +1,8 @@ +@import "../../node_modules/tether/dist/css/tether.min.css"; +@import "../../node_modules/bootstrap/scss/bootstrap.scss"; + +$fa-font-path: "../../node_modules/font-awesome/fonts"; +@import "../../node_modules/font-awesome/scss/font-awesome.scss"; + + +@import "app.scss"; diff --git a/frontend/manage/bs-config.js b/frontend/manage/bs-config.js new file mode 100644 index 00000000..35f9e7b2 --- /dev/null +++ b/frontend/manage/bs-config.js @@ -0,0 +1,30 @@ +var vm = require("vm"); +var fs = require("fs"); + +var configJS = fs.readFileSync("./config.js"); + +global.window = {}; +vm.runInThisContext(configJS); +// eval() has direct access to the local context, no need for global.window = +//eval(configJS); + +var url = global.window.Config.frontend.url; +console.log(url); +var ip = url.replace("http://", "").replace("https://", ""); +var i = ip.indexOf(":"); +if (i > 0) { + ip = ip.substr(0, i); +} +console.log(ip); + +module.exports = { + "baseDir": "./", + "port": 3000, + "files": ["./**/*.{html,htm,css,js}"], + //"logLevel": "debug", + "logPrefix": "Readium BS", + "logConnections": true, + "logFileChanges": true, + "open": "external", + "host": ip +}; \ No newline at end of file diff --git a/frontend/manage/e2e/app.e2e-spec.js b/frontend/manage/e2e/app.e2e-spec.js new file mode 100644 index 00000000..338590c7 --- /dev/null +++ b/frontend/manage/e2e/app.e2e-spec.js @@ -0,0 +1,12 @@ +"use strict"; +var protractor_1 = require('protractor'); +describe('QuickStart E2E Tests', function () { + var expectedMsg = 'Hello Angular'; + beforeEach(function () { + protractor_1.browser.get(''); + }); + it('should display: ' + expectedMsg, function () { + expect(protractor_1.element(protractor_1.by.css('h1')).getText()).toEqual(expectedMsg); + }); +}); +//# sourceMappingURL=app.e2e-spec.js.map \ No newline at end of file diff --git a/frontend/manage/e2e/app.e2e-spec.ts b/frontend/manage/e2e/app.e2e-spec.ts new file mode 100644 index 00000000..67f5d8d6 --- /dev/null +++ b/frontend/manage/e2e/app.e2e-spec.ts @@ -0,0 +1,15 @@ +import { browser, element, by } from 'protractor'; + +describe('QuickStart E2E Tests', function () { + + let expectedMsg = 'Hello Angular'; + + beforeEach(function () { + browser.get(''); + }); + + it('should display: ' + expectedMsg, function () { + expect(element(by.css('h1')).getText()).toEqual(expectedMsg); + }); + +}); diff --git a/frontend/manage/index.html b/frontend/manage/index.html new file mode 100644 index 00000000..f86d7d88 --- /dev/null +++ b/frontend/manage/index.html @@ -0,0 +1,31 @@ + + + + + testing LCP / LSD server + + + + + + + + + + + + + + + + + + + + + + Loading lcp app... + + diff --git a/frontend/manage/karma-test-shim.js b/frontend/manage/karma-test-shim.js new file mode 100644 index 00000000..de5b015f --- /dev/null +++ b/frontend/manage/karma-test-shim.js @@ -0,0 +1,95 @@ +// /*global jasmine, __karma__, window*/ +Error.stackTraceLimit = 0; // "No stacktrace"" is usually best for app testing. + +// Uncomment to get full stacktrace output. Sometimes helpful, usually not. +// Error.stackTraceLimit = Infinity; // + +jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000; + +// builtPaths: root paths for output ("built") files +// get from karma.config.js, then prefix with '/base/' (default is 'app/') +var builtPaths = (__karma__.config.builtPaths || ['app/']) + .map(function(p) { return '/base/'+p;}); + +__karma__.loaded = function () { }; + +function isJsFile(path) { + return path.slice(-3) == '.js'; +} + +function isSpecFile(path) { + return /\.spec\.(.*\.)?js$/.test(path); +} + +// Is a "built" file if is JavaScript file in one of the "built" folders +function isBuiltFile(path) { + return isJsFile(path) && + builtPaths.reduce(function(keep, bp) { + return keep || (path.substr(0, bp.length) === bp); + }, false); +} + +var allSpecFiles = Object.keys(window.__karma__.files) + .filter(isSpecFile) + .filter(isBuiltFile); + +System.config({ + baseURL: 'base', + // Extend usual application package list with test folder + packages: { 'testing': { main: 'index.js', defaultExtension: 'js' } }, + + // Assume npm: is set in `paths` in systemjs.config + // Map the angular testing umd bundles + map: { + '@angular/core/testing': 'npm:@angular/core/bundles/core-testing.umd.js', + '@angular/common/testing': 'npm:@angular/common/bundles/common-testing.umd.js', + '@angular/compiler/testing': 'npm:@angular/compiler/bundles/compiler-testing.umd.js', + '@angular/platform-browser/testing': 'npm:@angular/platform-browser/bundles/platform-browser-testing.umd.js', + '@angular/platform-browser-dynamic/testing': 'npm:@angular/platform-browser-dynamic/bundles/platform-browser-dynamic-testing.umd.js', + '@angular/http/testing': 'npm:@angular/http/bundles/http-testing.umd.js', + '@angular/router/testing': 'npm:@angular/router/bundles/router-testing.umd.js', + '@angular/forms/testing': 'npm:@angular/forms/bundles/forms-testing.umd.js', + }, +}); + +System.import('systemjs.config.js') + .then(importSystemJsExtras) + .then(initTestBed) + .then(initTesting); + +/** Optional SystemJS configuration extras. Keep going w/o it */ +function importSystemJsExtras(){ + return System.import('systemjs.config.extras.js') + .catch(function(reason) { + console.log( + 'Warning: System.import could not load the optional "systemjs.config.extras.js". Did you omit it by accident? Continuing without it.' + ); + console.log(reason); + }); +} + +function initTestBed(){ + return Promise.all([ + System.import('@angular/core/testing'), + System.import('@angular/platform-browser-dynamic/testing') + ]) + + .then(function (providers) { + var coreTesting = providers[0]; + var browserTesting = providers[1]; + + coreTesting.TestBed.initTestEnvironment( + browserTesting.BrowserDynamicTestingModule, + browserTesting.platformBrowserDynamicTesting()); + }) +} + +// Import all spec files and start karma +function initTesting () { + return Promise.all( + allSpecFiles.map(function (moduleName) { + return System.import(moduleName); + }) + ) + .then(__karma__.start, __karma__.error); +} diff --git a/frontend/manage/karma.conf.js b/frontend/manage/karma.conf.js new file mode 100644 index 00000000..cd161c83 --- /dev/null +++ b/frontend/manage/karma.conf.js @@ -0,0 +1,99 @@ +module.exports = function(config) { + + var appBase = 'app/'; // transpiled app JS and map files + var appSrcBase = 'app/'; // app source TS files + var appAssets = 'base/app/'; // component assets fetched by Angular's compiler + + // Testing helpers (optional) are conventionally in a folder called `testing` + var testingBase = 'testing/'; // transpiled test JS and map files + var testingSrcBase = 'testing/'; // test source TS files + + config.set({ + basePath: '', + frameworks: ['jasmine'], + + plugins: [ + require('karma-jasmine'), + require('karma-chrome-launcher'), + require('karma-jasmine-html-reporter') + ], + + client: { + builtPaths: [appSrcBase, testingBase], // add more spec base paths as needed + clearContext: false // leave Jasmine Spec Runner output visible in browser + }, + + customLaunchers: { + // From the CLI. Not used here but interesting + // chrome setup for travis CI using chromium + Chrome_travis_ci: { + base: 'Chrome', + flags: ['--no-sandbox'] + } + }, + + files: [ + // System.js for module loading + 'node_modules/systemjs/dist/system.src.js', + + // Polyfills + 'node_modules/core-js/client/shim.js', + 'node_modules/reflect-metadata/Reflect.js', + + // zone.js + 'node_modules/zone.js/dist/zone.js', + 'node_modules/zone.js/dist/long-stack-trace-zone.js', + 'node_modules/zone.js/dist/proxy.js', + 'node_modules/zone.js/dist/sync-test.js', + 'node_modules/zone.js/dist/jasmine-patch.js', + 'node_modules/zone.js/dist/async-test.js', + 'node_modules/zone.js/dist/fake-async-test.js', + + // RxJs + { pattern: 'node_modules/rxjs/**/*.js', included: false, watched: false }, + { pattern: 'node_modules/rxjs/**/*.js.map', included: false, watched: false }, + + // Paths loaded via module imports: + // Angular itself + { pattern: 'node_modules/@angular/**/*.js', included: false, watched: false }, + { pattern: 'node_modules/@angular/**/*.js.map', included: false, watched: false }, + + { pattern: 'systemjs.config.js', included: false, watched: false }, + { pattern: 'systemjs.config.extras.js', included: false, watched: false }, + 'karma-test-shim.js', // optionally extend SystemJS mapping e.g., with barrels + + // transpiled application & spec code paths loaded via module imports + { pattern: appBase + '**/*.js', included: false, watched: true }, + { pattern: testingBase + '**/*.js', included: false, watched: true }, + + + // Asset (HTML & CSS) paths loaded via Angular's component compiler + // (these paths need to be rewritten, see proxies section) + { pattern: appBase + '**/*.html', included: false, watched: true }, + { pattern: appBase + '**/*.css', included: false, watched: true }, + + // Paths for debugging with source maps in dev tools + { pattern: appSrcBase + '**/*.ts', included: false, watched: false }, + { pattern: appBase + '**/*.js.map', included: false, watched: false }, + { pattern: testingSrcBase + '**/*.ts', included: false, watched: false }, + { pattern: testingBase + '**/*.js.map', included: false, watched: false} + ], + + // Proxied base paths for loading assets + proxies: { + // required for component assets fetched by Angular's compiler + "/app/": appAssets + }, + + exclude: [], + preprocessors: {}, + reporters: ['progress', 'kjhtml'], + + port: 9876, + colors: true, + logLevel: config.LOG_INFO, + autoWatch: true, + browsers: ['Chrome'], + singleRun: false + }) +} diff --git a/frontend/manage/package.json b/frontend/manage/package.json new file mode 100644 index 00000000..8760c124 --- /dev/null +++ b/frontend/manage/package.json @@ -0,0 +1,82 @@ +{ + "name": "frontend-angular-app", + "version": "1.0.0", + "description": "Frontend for LCP server", + "scripts": { + "clean": "node ./node_modules/rimraf/bin.js \"js/**/*.*\" && node ./node_modules/rimraf/bin.js \"dist/**/*.*\"", + "copy-templates": "cpx \"app/**/*.html\" dist/app", + "copy-templates:w": "cpx \"app/**/*.html\" dist/app --watch", + "build-css": "node-sass -r assets/sass -o dist/css", + "build-css:w": "node-sass -w -r assets/sass -o dist/css", + "prestart": "npm run clean && npm run build-css && npm run copy-templates", + "start": "tsc && concurrently \"cpx 'app/**/*.html' dist/app --watch\" \"node-sass -w -r assets/sass -o dist/css\" \"tsc -w\" \"lite-server --config=bs-config.js\" ", + "pree2e": "npm run clean && webdriver-manager update", + "e2e": "tsc && concurrently \"http-server -s\" \"protractor protractor.config.js\" --kill-others --success first", + "pretest": "npm run clean", + "test": "tsc && concurrently \"tsc -w\" \"karma start karma.conf.js\"", + "pretest-once": "npm run clean", + "test-once": "tsc && karma start karma.conf.js --single-run", + "pretsc": "npm run clean", + "tsc": "tsc", + "pretsc:w": "npm run clean", + "tsc:w": "tsc -w", + "lint": "tslint ./app/**/*.ts -t verbose", + "lite": "lite-server --config=bs-config.js" + }, + "keywords": [], + "author": "", + "license": "MIT", + "dependencies": { + "@angular/common": "~2.2.0", + "@angular/compiler": "~2.2.0", + "@angular/core": "~2.2.0", + "@angular/forms": "~2.2.0", + "@angular/http": "~2.2.0", + "@angular/platform-browser": "~2.2.0", + "@angular/platform-browser-dynamic": "~2.2.0", + "@angular/router": "~3.2.0", + "@types/cryptojs": "^3.1.29", + "@types/jquery": "^2.0.39", + "@types/jssha": "0.0.29", + "angular-in-memory-web-api": "~0.1.15", + "bootstrap": "git://github.com/twbs/bootstrap.git#v4-dev", + "core-js": "^2.4.1", + "cpx": "^1.5.0", + "cryptojs": "^2.5.3", + "fast-sha256": "^1.0.0", + "file-saver": "^1.3.3", + "font-awesome": "^4.7.0", + "jssha": "^2.2.0", + "moment": "^2.17.1", + "ng2-datetime-picker": "^0.12.8", + "ng2-slugify": "^0.1.0", + "node-sass": "^4.1.1", + "reflect-metadata": "^0.1.8", + "rxjs": "5.0.0-rc.5", + "systemjs": "0.19.40", + "tweetnacl-util": "^0.13.5", + "zone.js": "^0.7.2" + }, + "devDependencies": { + "rimraf": "^2.5.4", + "concurrently": "^3.1.0", + "lite-server": "^2.2.2", + "typescript": "^2.0.10", + "canonical-path": "0.0.2", + "http-server": "^0.9.0", + "tslint": "^3.15.1", + "lodash": "^4.16.4", + "jasmine-core": "~2.4.1", + "karma": "^1.3.0", + "karma-chrome-launcher": "^2.0.0", + "karma-cli": "^1.0.1", + "karma-jasmine": "^1.0.2", + "karma-jasmine-html-reporter": "^0.2.2", + "protractor": "4.0.9", + "webdriver-manager": "10.2.5", + "@types/node": "^6.0.46", + "@types/jasmine": "^2.5.36", + "@types/selenium-webdriver": "^2.53.33" + }, + "repository": {} +} diff --git a/frontend/manage/protractor.config.js b/frontend/manage/protractor.config.js new file mode 100644 index 00000000..8314510c --- /dev/null +++ b/frontend/manage/protractor.config.js @@ -0,0 +1,183 @@ +// FIRST TIME ONLY- run: +// ./node_modules/.bin/webdriver-manager update +// +// Try: `npm run webdriver:update` +// +// AND THEN EVERYTIME ... +// 1. Compile with `tsc` +// 2. Make sure the test server (e.g., http-server: localhost:8080) is running. +// 3. ./node_modules/.bin/protractor protractor.config.js +// +// To do all steps, try: `npm run e2e` + +var fs = require('fs'); +var path = require('canonical-path'); +var _ = require('lodash'); + + +exports.config = { + directConnect: true, + + // Capabilities to be passed to the webdriver instance. + capabilities: { + 'browserName': 'chrome' + }, + + // Framework to use. Jasmine is recommended. + framework: 'jasmine', + + // Spec patterns are relative to this config file + specs: ['**/*e2e-spec.js' ], + + + // For angular tests + useAllAngular2AppRoots: true, + + // Base URL for application server + baseUrl: 'http://localhost:8080', + + // doesn't seem to work. + // resultJsonOutputFile: "foo.json", + + onPrepare: function() { + //// SpecReporter + //var SpecReporter = require('jasmine-spec-reporter'); + //jasmine.getEnv().addReporter(new SpecReporter({displayStacktrace: 'none'})); + //// jasmine.getEnv().addReporter(new SpecReporter({displayStacktrace: 'all'})); + + // debugging + // console.log('browser.params:' + JSON.stringify(browser.params)); + jasmine.getEnv().addReporter(new Reporter( browser.params )) ; + + // Allow changing bootstrap mode to NG1 for upgrade tests + global.setProtractorToNg1Mode = function() { + browser.useAllAngular2AppRoots = false; + browser.rootEl = 'body'; + }; + }, + + jasmineNodeOpts: { + // defaultTimeoutInterval: 60000, + defaultTimeoutInterval: 10000, + showTiming: true, + print: function() {} + } +}; + +// Custom reporter +function Reporter(options) { + var _defaultOutputFile = path.resolve(process.cwd(), './_test-output', 'protractor-results.txt'); + options.outputFile = options.outputFile || _defaultOutputFile; + + initOutputFile(options.outputFile); + options.appDir = options.appDir || './'; + var _root = { appDir: options.appDir, suites: [] }; + log('AppDir: ' + options.appDir, +1); + var _currentSuite; + + this.suiteStarted = function(suite) { + _currentSuite = { description: suite.description, status: null, specs: [] }; + _root.suites.push(_currentSuite); + log('Suite: ' + suite.description, +1); + }; + + this.suiteDone = function(suite) { + var statuses = _currentSuite.specs.map(function(spec) { + return spec.status; + }); + statuses = _.uniq(statuses); + var status = statuses.indexOf('failed') >= 0 ? 'failed' : statuses.join(', '); + _currentSuite.status = status; + log('Suite ' + _currentSuite.status + ': ' + suite.description, -1); + }; + + this.specStarted = function(spec) { + + }; + + this.specDone = function(spec) { + var currentSpec = { + description: spec.description, + status: spec.status + }; + if (spec.failedExpectations.length > 0) { + currentSpec.failedExpectations = spec.failedExpectations; + } + + _currentSuite.specs.push(currentSpec); + log(spec.status + ' - ' + spec.description); + }; + + this.jasmineDone = function() { + outputFile = options.outputFile; + //// Alternate approach - just stringify the _root - not as pretty + //// but might be more useful for automation. + // var output = JSON.stringify(_root, null, 2); + var output = formatOutput(_root); + fs.appendFileSync(outputFile, output); + }; + + function ensureDirectoryExistence(filePath) { + var dirname = path.dirname(filePath); + if (directoryExists(dirname)) { + return true; + } + ensureDirectoryExistence(dirname); + fs.mkdirSync(dirname); + } + + function directoryExists(path) { + try { + return fs.statSync(path).isDirectory(); + } + catch (err) { + return false; + } + } + + function initOutputFile(outputFile) { + ensureDirectoryExistence(outputFile); + var header = "Protractor results for: " + (new Date()).toLocaleString() + "\n\n"; + fs.writeFileSync(outputFile, header); + } + + // for output file output + function formatOutput(output) { + var indent = ' '; + var pad = ' '; + var results = []; + results.push('AppDir:' + output.appDir); + output.suites.forEach(function(suite) { + results.push(pad + 'Suite: ' + suite.description + ' -- ' + suite.status); + pad+=indent; + suite.specs.forEach(function(spec) { + results.push(pad + spec.status + ' - ' + spec.description); + if (spec.failedExpectations) { + pad+=indent; + spec.failedExpectations.forEach(function (fe) { + results.push(pad + 'message: ' + fe.message); + }); + pad=pad.substr(2); + } + }); + pad = pad.substr(2); + results.push(''); + }); + results.push(''); + return results.join('\n'); + } + + // for console output + var _pad; + function log(str, indent) { + _pad = _pad || ''; + if (indent == -1) { + _pad = _pad.substr(2); + } + console.log(_pad + str); + if (indent == 1) { + _pad = _pad + ' '; + } + } + +} diff --git a/frontend/manage/styles.css b/frontend/manage/styles.css new file mode 100644 index 00000000..913ead1b --- /dev/null +++ b/frontend/manage/styles.css @@ -0,0 +1,80 @@ +body { + font-family: 'Lato', sans-serif; + background-color: white; + color: #222; + padding: 0; + margin: 0; + font-size: 14px; + } + +.dropping { + border-color: white; +} +#dropzone { + width: 300px; +} +h1 { + font-size: 16px; + background-color: #1abc9c; + color: white; + padding: 0; + padding-left: 5px; + margin: 0; +} + +h2 { + font-size: 14px; + padding: 0 0 0 5px; + margin: 0; +} + +ul { + margin: 0; + padding: 0; +} + +li { + list-style-type: none; + padding-left: 10px; + cursor: pointer; +} +li.epub { + border-top: 2px solid gray; + border-bottom: 2px solid gray +} +#key { + width: 350px; + font-size: 9px; +} + +.key { + font-size: 9px; + color: silver; + font-weight: bold; +} +.size { + font-size: 9px; + color: red; + font-weight: bold; +} +.sha { + font-size: 9px; + color: green; + font-weight: bold; +} +.download { + color: blue; + font-weight: bold; +} +.licenses { + color: blue; + font-size: 9px; +} + +#license-form { + padding-left: 10px; +} + +.step { + background-color: red; +} \ No newline at end of file diff --git a/frontend/manage/systemjs.config.extras.js b/frontend/manage/systemjs.config.extras.js new file mode 100644 index 00000000..027dfe58 --- /dev/null +++ b/frontend/manage/systemjs.config.extras.js @@ -0,0 +1,11 @@ +/** + * Add barrels and stuff + * Adjust as necessary for your application needs. + */ +// (function (global) { +// System.config({ +// packages: { +// // add packages here +// } +// }); +// })(this); diff --git a/frontend/manage/systemjs.config.js b/frontend/manage/systemjs.config.js new file mode 100644 index 00000000..79e654f6 --- /dev/null +++ b/frontend/manage/systemjs.config.js @@ -0,0 +1,68 @@ +/** + * System configuration for Angular samples + * Adjust as necessary for your application needs. + */ +(function (global) { + System.config({ + paths: { + // paths serve as alias + 'npm:': 'node_modules/' + }, + // map tells the System loader where to look for things + map: { + // our app is within the app folder + app: 'dist/app', + + // angular bundles + '@angular/core': 'npm:@angular/core/bundles/core.umd.js', + '@angular/common': 'npm:@angular/common/bundles/common.umd.js', + '@angular/compiler': 'npm:@angular/compiler/bundles/compiler.umd.js', + '@angular/platform-browser': 'npm:@angular/platform-browser/bundles/platform-browser.umd.js', + '@angular/platform-browser-dynamic': 'npm:@angular/platform-browser-dynamic/bundles/platform-browser-dynamic.umd.js', + '@angular/http': 'npm:@angular/http/bundles/http.umd.js', + '@angular/router': 'npm:@angular/router/bundles/router.umd.js', + '@angular/forms': 'npm:@angular/forms/bundles/forms.umd.js', + + // other libraries + 'rxjs': 'npm:rxjs', + 'angular-in-memory-web-api': 'npm:angular-in-memory-web-api/bundles/in-memory-web-api.umd.js', + 'ng2-slugify': 'npm:ng2-slugify', + 'jssha': 'npm:jssha/src', + 'ng2-datetime-picker': 'npm:ng2-datetime-picker/dist', + 'moment': 'node_modules/moment', + 'config': '/config.js', + 'file-saver': 'npm:file-saver' + }, + // packages tells the System loader how to load when no filename and/or no extension + packages: { + app: { + main: './main.js', + defaultExtension: 'js' + }, + rxjs: { + defaultExtension: 'js' + }, + 'jssha': { + main: 'sha.js', + defaultExtension: 'js' + + }, + 'ng2-datetime-picker': { + main: 'ng2-datetime-picker.umd.js', + defaultExtension: 'js' + }, + 'moment': { + main: 'moment.js', + defaultExtension: 'js' + }, + 'file-saver': { + main: './FileSaver.js', + defaultExtension: 'js' + }, + 'ng2-slugify': { + main: './ng2-slugify.js', + defaultExtension: 'js' + } + } + }); +})(this); diff --git a/frontend/manage/tsconfig.json b/frontend/manage/tsconfig.json new file mode 100644 index 00000000..565da481 --- /dev/null +++ b/frontend/manage/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "es5", + "module": "commonjs", + "moduleResolution": "node", + "sourceMap": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "lib": [ "es2015", "dom" ], + "noImplicitAny": true, + "suppressImplicitAnyIndexErrors": true, + "outDir": "dist/app", + "typeRoots": [ + "node_modules/@types", + "./typings" + ] + }, + "include": [ + "app/**/*" + ], + "exclude": [ + "node_modules", + "**/*.spec.ts" + ] +} diff --git a/frontend/manage/tslint.json b/frontend/manage/tslint.json new file mode 100644 index 00000000..276453f4 --- /dev/null +++ b/frontend/manage/tslint.json @@ -0,0 +1,93 @@ +{ + "rules": { + "class-name": true, + "comment-format": [ + true, + "check-space" + ], + "curly": true, + "eofline": true, + "forin": true, + "indent": [ + true, + "spaces" + ], + "label-position": true, + "label-undefined": true, + "max-line-length": [ + true, + 140 + ], + "member-access": false, + "member-ordering": [ + true, + "static-before-instance", + "variables-before-functions" + ], + "no-arg": true, + "no-bitwise": true, + "no-console": [ + true, + "debug", + "info", + "time", + "timeEnd", + "trace" + ], + "no-construct": true, + "no-debugger": true, + "no-duplicate-key": true, + "no-duplicate-variable": true, + "no-empty": false, + "no-eval": true, + "no-inferrable-types": true, + "no-shadowed-variable": true, + "no-string-literal": false, + "no-switch-case-fall-through": true, + "no-trailing-whitespace": true, + "no-unused-expression": true, + "no-unused-variable": true, + "no-unreachable": true, + "no-use-before-declare": true, + "no-var-keyword": true, + "object-literal-sort-keys": false, + "one-line": [ + true, + "check-open-brace", + "check-catch", + "check-else", + "check-whitespace" + ], + "quotemark": [ + true, + "single" + ], + "radix": true, + "semicolon": [ + "always" + ], + "triple-equals": [ + true, + "allow-null-check" + ], + "typedef-whitespace": [ + true, + { + "call-signature": "nospace", + "index-signature": "nospace", + "parameter": "nospace", + "property-declaration": "nospace", + "variable-declaration": "nospace" + } + ], + "variable-name": false, + "whitespace": [ + true, + "check-branch", + "check-decl", + "check-operator", + "check-separator", + "check-type" + ] + } +} diff --git a/frontend/manage/typings/file-saver/index.d.ts b/frontend/manage/typings/file-saver/index.d.ts new file mode 100644 index 00000000..2cf208b1 --- /dev/null +++ b/frontend/manage/typings/file-saver/index.d.ts @@ -0,0 +1,27 @@ +interface FileSaver { + ( + /** + * @summary Data. + * @type {Blob} + */ + data: Blob, + + /** + * @summary File name. + * @type {DOMString} + */ + filename: string, + + /** + * @summary Disable Unicode text encoding hints or not. + * @type {boolean} + */ + disableAutoBOM?: boolean + ): void +} + +declare var saveAs: FileSaver; + +declare module "file-saver" { + export = saveAs +} diff --git a/frontend/server/server.go b/frontend/server/server.go new file mode 100644 index 00000000..19e119c1 --- /dev/null +++ b/frontend/server/server.go @@ -0,0 +1,179 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package frontend + +import ( + "crypto/tls" + "net/http" + "time" + + "github.com/gorilla/mux" + "github.com/readium/readium-lcp-server/api" + "github.com/readium/readium-lcp-server/frontend/api" + "github.com/readium/readium-lcp-server/frontend/webpublication" + "github.com/readium/readium-lcp-server/frontend/webpurchase" + "github.com/readium/readium-lcp-server/frontend/webrepository" + "github.com/readium/readium-lcp-server/frontend/webuser" +) + +//Server struct contains server info and db interfaces +type Server struct { + http.Server + readonly bool + cert *tls.Certificate + repositories webrepository.WebRepository + publications webpublication.WebPublication + users webuser.WebUser + purchases webpurchase.WebPurchase +} + +// HandlerFunc type define a function handled by the server +type HandlerFunc func(w http.ResponseWriter, r *http.Request, s staticapi.IServer) + +//type HandlerPrivateFunc func(w http.ResponseWriter, r *auth.AuthenticatedRequest, s staticapi.IServer) + +// New creates a new webserver (basic user interface) +func New( + bindAddr string, + tplPath string, + repositoryAPI webrepository.WebRepository, + publicationAPI webpublication.WebPublication, + userAPI webuser.WebUser, + purchaseAPI webpurchase.WebPurchase) *Server { + + sr := api.CreateServerRouter(tplPath) + s := &Server{ + Server: http.Server{ + Handler: sr.N, + Addr: bindAddr, + WriteTimeout: 15 * time.Second, + ReadTimeout: 15 * time.Second, + MaxHeaderBytes: 1 << 20, + }, + repositories: repositoryAPI, + publications: publicationAPI, + users: userAPI, + purchases: purchaseAPI} + + // Route.PathPrefix: http://www.gorillatoolkit.org/pkg/mux#Route.PathPrefix + // Route.Subrouter: http://www.gorillatoolkit.org/pkg/mux#Route.Subrouter + // Router.StrictSlash: http://www.gorillatoolkit.org/pkg/mux#Router.StrictSlash + + apiURLPrefix := "/api/v1" + + // + // repositories + // + repositoriesRoutesPathPrefix := apiURLPrefix + "/repositories" + repositoriesRoutes := sr.R.PathPrefix(repositoriesRoutesPathPrefix).Subrouter().StrictSlash(false) + // + s.handleFunc(repositoriesRoutes, "/master-files", staticapi.GetRepositoryMasterFiles).Methods("GET") + + // + // publications + // + publicationsRoutesPathPrefix := apiURLPrefix + "/publications" + publicationsRoutes := sr.R.PathPrefix(publicationsRoutesPathPrefix).Subrouter().StrictSlash(false) + // + s.handleFunc(sr.R, publicationsRoutesPathPrefix, staticapi.GetPublications).Methods("GET") + // + s.handleFunc(sr.R, publicationsRoutesPathPrefix, staticapi.CreatePublication).Methods("POST") + // + s.handleFunc(publicationsRoutes, "/{id}", staticapi.GetPublication).Methods("GET") + s.handleFunc(publicationsRoutes, "/{id}", staticapi.UpdatePublication).Methods("PUT") + s.handleFunc(publicationsRoutes, "/{id}", staticapi.DeletePublication).Methods("DELETE") + + // + // user functions + // + usersRoutesPathPrefix := apiURLPrefix + "/users" + usersRoutes := sr.R.PathPrefix(usersRoutesPathPrefix).Subrouter().StrictSlash(false) + // + s.handleFunc(sr.R, usersRoutesPathPrefix, staticapi.GetUsers).Methods("GET") + // + s.handleFunc(sr.R, usersRoutesPathPrefix, staticapi.CreateUser).Methods("POST") + // + s.handleFunc(usersRoutes, "/{id}", staticapi.GetUser).Methods("GET") + s.handleFunc(usersRoutes, "/{id}", staticapi.UpdateUser).Methods("PUT") + s.handleFunc(usersRoutes, "/{id}", staticapi.DeleteUser).Methods("DELETE") + // + s.handleFunc(usersRoutes, "/{user_id}/purchases", staticapi.GetUserPurchases).Methods("GET") + + // + // purchases + // + purchasesRoutesPathPrefix := apiURLPrefix + "/purchases" + purchasesRoutes := sr.R.PathPrefix(purchasesRoutesPathPrefix).Subrouter().StrictSlash(false) + // + s.handleFunc(sr.R, purchasesRoutesPathPrefix, staticapi.GetPurchases).Methods("GET") + // + s.handleFunc(sr.R, purchasesRoutesPathPrefix, staticapi.CreatePurchase).Methods("POST") + // + s.handleFunc(purchasesRoutes, "/{id}", staticapi.GetPurchase).Methods("GET") + s.handleFunc(purchasesRoutes, "/{id}", staticapi.UpdatePurchase).Methods("PUT") + // + s.handleFunc(purchasesRoutes, "/{id}/license", staticapi.GetPurchaseLicense).Methods("GET") + // + s.handleFunc(purchasesRoutes, "/license/{licenseID}", staticapi.GetPurchaseLicenseFromLicenseUUID).Methods("GET") + + return s +} + +// RepositoryAPI ( staticapi.IServer ) returns interface for repositories +func (server *Server) RepositoryAPI() webrepository.WebRepository { + return server.repositories +} + +// PublicationAPI ( staticapi.IServer )returns DB interface for users +func (server *Server) PublicationAPI() webpublication.WebPublication { + return server.publications +} + +//UserAPI ( staticapi.IServer )returns DB interface for users +func (server *Server) UserAPI() webuser.WebUser { + return server.users +} + +//PurchaseAPI ( staticapi.IServer )returns DB interface for pruchases +func (server *Server) PurchaseAPI() webpurchase.WebPurchase { + return server.purchases +} + +func (server *Server) handleFunc(router *mux.Router, route string, fn HandlerFunc) *mux.Route { + return router.HandleFunc(route, func(w http.ResponseWriter, r *http.Request) { + fn(w, r, server) + }) +} + +/*no private functions used +func (server *Server) handlePrivateFunc(router *mux.Router, route string, fn HandlerFunc, authenticator *auth.BasicAuth) *mux.Route { + return router.HandleFunc(route, func(w http.ResponseWriter, r *http.Request) { + if api.CheckAuth(authenticator, w, r) { + fn(w, r, server) + } + }) +} +*/ diff --git a/frontend/server/server_test.go b/frontend/server/server_test.go new file mode 100644 index 00000000..3a0cf647 --- /dev/null +++ b/frontend/server/server_test.go @@ -0,0 +1,33 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package frontend + +import ( + "testing" +) + +func TestSetup(t *testing.T) { +} diff --git a/frontend/webpublication/webpublication.go b/frontend/webpublication/webpublication.go new file mode 100644 index 00000000..f9d673e7 --- /dev/null +++ b/frontend/webpublication/webpublication.go @@ -0,0 +1,296 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package webpublication + +import ( + "bytes" + "database/sql" + "encoding/json" + "errors" + "log" + "net/http" + "os" + "path" + "time" + + "fmt" + + "github.com/readium/readium-lcp-server/api" + "github.com/readium/readium-lcp-server/config" + "github.com/readium/readium-lcp-server/lcpencrypt/encrypt" + "github.com/readium/readium-lcp-server/lcpserver/api" + "github.com/satori/go.uuid" + + "github.com/Machiel/slugify" +) + +// Publication status +const ( + StatusDraft string = "draft" + StatusEncrypting string = "encrypting" + StatusError string = "error" + StatusOk string = "ok" +) + +// ErrNotFound error trown when publication is not found +var ErrNotFound = errors.New("Publication not found") + +// WebPublication interface for publication db interaction +type WebPublication interface { + Get(id int64) (Publication, error) + GetByUUID(uuid string) (Publication, error) + Add(publication Publication) error + Update(publication Publication) error + Delete(id int64) error + List(page int, pageNum int) func() (Publication, error) +} + +// Publication struct defines a publication +type Publication struct { + ID int64 `json:"id"` + UUID string `json:"uuid"` + Status string `json:"status"` + Title string `json:"title,omitempty"` + MasterFilename string `json:"masterFilename,omitempty"` +} + +// PublicationManager helper +type PublicationManager struct { + config config.Configuration + db *sql.DB +} + +// Get a publication for a given ID +func (pubManager PublicationManager) Get(id int64) (Publication, error) { + dbGetByID, err := pubManager.db.Prepare("SELECT id, uuid, title, status FROM publication WHERE id = ? LIMIT 1") + if err != nil { + return Publication{}, err + } + defer dbGetByID.Close() + + records, err := dbGetByID.Query(id) + if records.Next() { + var pub Publication + err = records.Scan( + &pub.ID, + &pub.UUID, + &pub.Title, + &pub.Status) + records.Close() + return pub, err + } + + return Publication{}, ErrNotFound +} + +// GetByUUID returns a publication for a given uuid +func (pubManager PublicationManager) GetByUUID(uuid string) (Publication, error) { + dbGetByUUID, err := pubManager.db.Prepare("SELECT id, uuid, title, status FROM publication WHERE uuid = ? LIMIT 1") + if err != nil { + return Publication{}, err + } + defer dbGetByUUID.Close() + + records, err := dbGetByUUID.Query(uuid) + if records.Next() { + var pub Publication + err = records.Scan( + &pub.ID, + &pub.UUID, + &pub.Title, + &pub.Status) + records.Close() + return pub, err + } + + return Publication{}, ErrNotFound +} + +// Add new publication +func (pubManager PublicationManager) Add(pub Publication) error { + // Get repository file + inputPath := path.Join( + pubManager.config.FrontendServer.MasterRepository, pub.MasterFilename) + + if _, err := os.Stat(inputPath); err != nil { + // Master file does not exist + return err + } + + // Create output file path + contentUUID := uuid.NewV4().String() + outputFilename := contentUUID + ".tmp" + outputPath := path.Join( + pubManager.config.FrontendServer.EncryptedRepository, outputFilename) + + // Encrypt file + encryptedEpub, err := encrypt.EncryptEpub(inputPath, outputPath) + + if err != nil { + // Unable to encrypt master file + return err + } + + // Prepare request + // POST LCP content + contentDisposition := slugify.Slugify(pub.Title) + lcpPublication := apilcp.LcpPublication{} + lcpPublication.ContentId = contentUUID + lcpPublication.ContentKey = encryptedEpub.EncryptionKey + lcpPublication.Output = path.Join( + pubManager.config.Storage.FileSystem.Directory, outputFilename) + lcpPublication.ContentDisposition = &contentDisposition + lcpPublication.Checksum = &encryptedEpub.Checksum + lcpPublication.Size = &encryptedEpub.Size + + jsonBody, err := json.Marshal(lcpPublication) + if err != nil { + return err + } + + // Post content to LCP + lcpServerConfig := pubManager.config.LcpServer + lcpURL := lcpServerConfig.PublicBaseUrl + "/contents/" + contentUUID + log.Println("PUT " + lcpURL) + req, err := http.NewRequest("PUT", lcpURL, bytes.NewReader(jsonBody)) + + lcpUpdateAuth := pubManager.config.LcpUpdateAuth + if pubManager.config.LcpUpdateAuth.Username != "" { + req.SetBasicAuth(lcpUpdateAuth.Username, lcpUpdateAuth.Password) + } + + req.Header.Add("Content-Type", api.ContentType_LCP_JSON) + + var lcpClient = &http.Client{ + Timeout: time.Second * 5, + } + resp, err := lcpClient.Do(req) + if err != nil { + return err + } + + if resp.StatusCode != 201 { + // Bad status code + return err + } + + // Remove temporary file + err = os.Remove(outputPath) + if err != nil { + return err + } + + // Store new publication + pub.UUID = contentUUID + pub.Status = StatusOk + dbAdd, err := pubManager.db.Prepare("INSERT INTO publication (uuid, title, status) VALUES ( ?, ?, ?)") + if err != nil { + return err + } + defer dbAdd.Close() + + _, err = dbAdd.Exec( + pub.UUID, + pub.Title, + pub.Status) + return err +} + +// Update publication +func (pubManager PublicationManager) Update(pub Publication) error { + dbUpdate, err := pubManager.db.Prepare("UPDATE publication SET title=?, status=? WHERE id = ?") + if err != nil { + return err + } + defer dbUpdate.Close() + _, err = dbUpdate.Exec( + pub.Title, + pub.Status, + pub.ID) + return err +} + +// Delete publication +func (pubManager PublicationManager) Delete(id int64) error { + fmt.Print("Delete:") + fmt.Println(id) + dbDelete, err := pubManager.db.Prepare("DELETE FROM publication WHERE id = ?") + if err != nil { + return err + } + defer dbDelete.Close() + _, err = dbDelete.Exec(id) + return err +} + +// List publications +func (pubManager PublicationManager) List(page int, pageNum int) func() (Publication, error) { + dbList, err := pubManager.db.Prepare("SELECT id, uuid, title, status FROM publication ORDER BY title desc LIMIT ? OFFSET ?") + if err != nil { + return func() (Publication, error) { return Publication{}, err } + } + defer dbList.Close() + records, err := dbList.Query(page, pageNum*page) + if err != nil { + return func() (Publication, error) { return Publication{}, err } + } + return func() (Publication, error) { + var pub Publication + if records.Next() { + err := records.Scan( + &pub.ID, + &pub.UUID, + &pub.Title, + &pub.Status) + if err != nil { + return pub, err + } + + } else { + records.Close() + err = ErrNotFound + } + return pub, err + } +} + +// Init publication manager +func Init(config config.Configuration, db *sql.DB) (i WebPublication, err error) { + _, err = db.Exec(`CREATE TABLE IF NOT EXISTS publication ( + id integer NOT NULL, + uuid varchar(255) NOT NULL, + title varchar(255) NOT NULL, + status varchar(255) NOT NULL, + + constraint pk_publication primary key(id) + )`) + if err != nil { + return + } + + i = PublicationManager{config, db} + return +} diff --git a/frontend/webpurchase/webpurchase.go b/frontend/webpurchase/webpurchase.go new file mode 100644 index 00000000..5f468923 --- /dev/null +++ b/frontend/webpurchase/webpurchase.go @@ -0,0 +1,602 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package webpurchase + +import ( + "bytes" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "log" + "net/http" + "time" + + "github.com/readium/readium-lcp-server/api" + "github.com/readium/readium-lcp-server/config" + "github.com/readium/readium-lcp-server/frontend/webpublication" + "github.com/readium/readium-lcp-server/frontend/webuser" + "github.com/readium/readium-lcp-server/license" + "github.com/readium/readium-lcp-server/license_statuses" + "github.com/satori/go.uuid" +) + +//ErrNotFound Error is thrown when a purchase is not found +var ErrNotFound = errors.New("Purchase not found") + +//ErrNoChange is thrown when an update action does not change any rows (not found) +var ErrNoChange = errors.New("No lines were updated") + +const purchaseManagerQuery = `SELECT +p.id, p.uuid, +p.type, p.transaction_date, +p.license_uuid, +p.start_date, p.end_date, p.status, +u.id, u.uuid, u.name, u.email, u.password, +pu.id, pu.uuid, pu.title, pu.status +FROM purchase p +left join user u on (p.user_id=u.id) +left join publication pu on (p.publication_id=pu.id)` + +//WebPurchase defines possible interactions with DB +type WebPurchase interface { + Get(id int64) (Purchase, error) + GenerateLicense(purchase Purchase) (license.License, error) + GetPartialLicense(purchase Purchase) (license.License, error) + GetLicenseStatusDocument(purchase Purchase) (licensestatuses.LicenseStatus, error) + GetByLicenseID(licenseID string) (Purchase, error) + List(page int, pageNum int) func() (Purchase, error) + ListByUser(userID int64, page int, pageNum int) func() (Purchase, error) + Add(p Purchase) error + Update(p Purchase) error +} + +// Purchase status +const ( + StatusToBeRenewed string = "to-be-renewed" + StatusToBeReturned string = "to-be-returned" + StatusError string = "error" + StatusOk string = "ok" +) + +// Enumeration of PurchaseType +const ( + BUY string = "BUY" + LOAN string = "LOAN" +) + +//Purchase struct defines a user in json and database +//PurchaseType: BUY or LOAN +type Purchase struct { + ID int64 `json:"id, omitempty"` + UUID string `json:"uuid"` + Publication webpublication.Publication `json:"publication"` + User webuser.User `json:"user"` + LicenseUUID *string `json:"licenseUuid,omitempty"` + Type string `json:"type"` + TransactionDate time.Time `json:"transactionDate, omitempty"` + StartDate *time.Time `json:"startDate, omitempty"` + EndDate *time.Time `json:"endDate, omitempty"` + Status string `json:"status"` + MaxEndDate *time.Time `json:"maxEndDate, omitempty"` +} + +type purchaseManager struct { + config config.Configuration + db *sql.DB +} + +func convertRecordsToPurchases(records *sql.Rows) func() (Purchase, error) { + return func() (Purchase, error) { + var err error + var purchase Purchase + if records.Next() { + purchase, err = convertRecordToPurchase(records) + if err != nil { + return purchase, err + } + } else { + records.Close() + err = ErrNotFound + } + + return purchase, err + } +} + +func convertRecordToPurchase(records *sql.Rows) (Purchase, error) { + purchase := Purchase{} + user := webuser.User{} + pub := webpublication.Publication{} + + err := records.Scan( + &purchase.ID, + &purchase.UUID, + &purchase.Type, + &purchase.TransactionDate, + &purchase.LicenseUUID, + &purchase.StartDate, + &purchase.EndDate, + &purchase.Status, + &user.ID, + &user.UUID, + &user.Name, + &user.Email, + &user.Password, + &pub.ID, + &pub.UUID, + &pub.Title, + &pub.Status) + + if err != nil { + return Purchase{}, err + } + + // Load relations + purchase.User = user + purchase.Publication = pub + return purchase, err +} + +func (pManager purchaseManager) Get(id int64) (Purchase, error) { + dbGetQuery := purchaseManagerQuery + ` WHERE p.id = ? LIMIT 1` + dbGet, err := pManager.db.Prepare(dbGetQuery) + if err != nil { + return Purchase{}, err + } + defer dbGet.Close() + + records, err := dbGet.Query(id) + defer records.Close() + + if records.Next() { + purchase, err := convertRecordToPurchase(records) + + if err != nil { + return Purchase{}, err + } + + if purchase.LicenseUUID != nil { + // Query LSD to retrieve max end date (PotentialRights.End) + statusDocument, err := pManager.GetLicenseStatusDocument(purchase) + + if err != nil { + return Purchase{}, err + } + + if statusDocument.PotentialRights != nil && statusDocument.PotentialRights.End != nil && !(*statusDocument.PotentialRights.End).IsZero() { + purchase.MaxEndDate = statusDocument.PotentialRights.End + } + } + + return purchase, nil + } + + return Purchase{}, ErrNotFound +} + +// GenerateLicense +func (pManager purchaseManager) GenerateLicense(purchase Purchase) (license.License, error) { + // Create LCP license + partialLicense := license.License{} + + // Provider + partialLicense.Provider = "provider" + + // User + encryptedAttrs := []string{"email", "name"} + partialLicense.User.Email = purchase.User.Email + partialLicense.User.Name = purchase.User.Name + partialLicense.User.Id = purchase.User.UUID + partialLicense.User.Encrypted = encryptedAttrs + + // Encryption + userKeyValue, err := hex.DecodeString(purchase.User.Password) + + if err != nil { + return license.License{}, err + } + + userKey := license.UserKey{} + userKey.Algorithm = "http://www.w3.org/2001/04/xmlenc#sha256" + userKey.Hint = "Enter your passphrase" + userKey.Value = userKeyValue + partialLicense.Encryption.UserKey = userKey + + // Rights + // FIXME: Do not use harcoded values + var copy int32 + var print int32 + copy = 2048 + print = 100 + userRights := license.UserRights{} + userRights.Copy = © + userRights.Print = &print + + // Do not include start and end date for a BUY purchase + if purchase.Type == LOAN { + userRights.Start = purchase.StartDate + userRights.End = purchase.EndDate + } + + partialLicense.Rights = &userRights + + // Encode in json + jsonBody, err := json.Marshal(partialLicense) + + if err != nil { + return license.License{}, err + } + + // Post partial license to LCP + lcpServerConfig := pManager.config.LcpServer + var lcpURL string + + if purchase.LicenseUUID == nil { + lcpURL = lcpServerConfig.PublicBaseUrl + "/contents/" + + purchase.Publication.UUID + "/licenses" + } else { + lcpURL = lcpServerConfig.PublicBaseUrl + "/licenses/" + + *purchase.LicenseUUID + } + + log.Println("POST " + lcpURL) + + req, err := http.NewRequest("POST", lcpURL, bytes.NewReader(jsonBody)) + + lcpUpdateAuth := pManager.config.LcpUpdateAuth + if pManager.config.LcpUpdateAuth.Username != "" { + req.SetBasicAuth(lcpUpdateAuth.Username, lcpUpdateAuth.Password) + } + + req.Header.Add("Content-Type", api.ContentType_LCP_JSON) + + var lcpClient = &http.Client{ + Timeout: time.Second * 5, + } + + resp, err := lcpClient.Do(req) + if err != nil { + return license.License{}, err + } + + defer resp.Body.Close() + + if (purchase.LicenseUUID == nil && resp.StatusCode != 201) || + (purchase.LicenseUUID != nil && resp.StatusCode != 200) { + // Bad status code + return license.License{}, errors.New("Bad status code") + } + + // Decode full license + fullLicense := license.License{} + var dec *json.Decoder + dec = json.NewDecoder(resp.Body) + err = dec.Decode(&fullLicense) + + if err != nil { + return license.License{}, errors.New("Unable to decode license") + } + + // Store license uuid + purchase.LicenseUUID = &fullLicense.Id + pManager.Update(purchase) + + if err != nil { + return license.License{}, errors.New("Unable to update license uuid") + } + + return fullLicense, nil +} + +// GetPartialLicense +func (pManager purchaseManager) GetPartialLicense(purchase Purchase) (license.License, error) { + if purchase.LicenseUUID == nil { + return license.License{}, errors.New("No license has been yet delivered") + } + + // Post partial license to LCP + lcpServerConfig := pManager.config.LcpServer + lcpURL := lcpServerConfig.PublicBaseUrl + "/licenses/" + *purchase.LicenseUUID + log.Println("GET " + lcpURL) + + req, err := http.NewRequest("GET", lcpURL, nil) + + lcpUpdateAuth := pManager.config.LcpUpdateAuth + if pManager.config.LcpUpdateAuth.Username != "" { + req.SetBasicAuth(lcpUpdateAuth.Username, lcpUpdateAuth.Password) + } + + req.Header.Add("Content-Type", api.ContentType_LCP_JSON) + + var lcpClient = &http.Client{ + Timeout: time.Second * 5, + } + + resp, err := lcpClient.Do(req) + if err != nil { + return license.License{}, err + } + + defer resp.Body.Close() + + if resp.StatusCode != 206 { + // Bad status code + return license.License{}, errors.New("Bad status code") + } + + // Decode full license + partialLicense := license.License{} + var dec *json.Decoder + dec = json.NewDecoder(resp.Body) + err = dec.Decode(&partialLicense) + + if err != nil { + return license.License{}, errors.New("Unable to decode license") + } + + return partialLicense, nil +} + +func (pManager purchaseManager) GetLicenseStatusDocument(purchase Purchase) (licensestatuses.LicenseStatus, error) { + if purchase.LicenseUUID == nil { + return licensestatuses.LicenseStatus{}, errors.New("No license has been yet delivered") + } + + lsdServerConfig := pManager.config.LsdServer + lsdURL := lsdServerConfig.PublicBaseUrl + "/licenses/" + *purchase.LicenseUUID + "/status" + log.Println("GET " + lsdURL) + req, err := http.NewRequest("GET", lsdURL, nil) + + lsdAuth := pManager.config.LsdNotifyAuth + if lsdAuth.Username != "" { + req.SetBasicAuth(lsdAuth.Username, lsdAuth.Password) + } + + req.Header.Add("Content-Type", api.ContentType_JSON) + + var lsdClient = &http.Client{ + Timeout: time.Second * 5, + } + + resp, err := lsdClient.Do(req) + if err != nil { + return licensestatuses.LicenseStatus{}, err + } + + if resp.StatusCode != 200 { + // Bad status code + return licensestatuses.LicenseStatus{}, errors.New("Unable to find license") + } + + // Decode status document + statusDocument := licensestatuses.LicenseStatus{} + var dec *json.Decoder + dec = json.NewDecoder(resp.Body) + err = dec.Decode(&statusDocument) + + if err != nil { + return licensestatuses.LicenseStatus{}, err + } + + defer resp.Body.Close() + + return statusDocument, nil +} + +func (pManager purchaseManager) GetByLicenseID(licenseID string) (Purchase, error) { + dbGetByLicenseIDQuery := purchaseManagerQuery + ` WHERE p.license_uuid = ? LIMIT 1` + dbGetByLicenseID, err := pManager.db.Prepare(dbGetByLicenseIDQuery) + if err != nil { + return Purchase{}, err + } + defer dbGetByLicenseID.Close() + + records, err := dbGetByLicenseID.Query(licenseID) + defer records.Close() + if records.Next() { + return convertRecordToPurchase(records) + } + + return Purchase{}, ErrNotFound +} + +func (pManager purchaseManager) List(page int, pageNum int) func() (Purchase, error) { + dbListByUserQuery := purchaseManagerQuery + ` ORDER BY p.transaction_date desc LIMIT ? OFFSET ?` + dbListByUser, err := pManager.db.Prepare(dbListByUserQuery) + + if err != nil { + return func() (Purchase, error) { return Purchase{}, err } + } + defer dbListByUser.Close() + + records, err := dbListByUser.Query(page, pageNum*page) + return convertRecordsToPurchases(records) +} + +func (pManager purchaseManager) ListByUser(userID int64, page int, pageNum int) func() (Purchase, error) { + dbListByUserQuery := purchaseManagerQuery + ` WHERE u.id = ? +ORDER BY p.transaction_date desc LIMIT ? OFFSET ?` + dbListByUser, err := pManager.db.Prepare(dbListByUserQuery) + if err != nil { + return func() (Purchase, error) { return Purchase{}, err } + } + defer dbListByUser.Close() + + records, err := dbListByUser.Query(userID, page, pageNum*page) + return convertRecordsToPurchases(records) +} + +func (pManager purchaseManager) Add(p Purchase) error { + add, err := pManager.db.Prepare(`INSERT INTO purchase + (uuid, publication_id, user_id, + type, transaction_date, + start_date, end_date, status) + VALUES (?, ?, ?, ?, ?, ?, ?, 'ok')`) + if err != nil { + return err + } + defer add.Close() + + // Fill default values + if p.TransactionDate.IsZero() { + p.TransactionDate = time.Now() + } + + if p.Type == LOAN && p.StartDate == nil { + p.StartDate = &p.TransactionDate + } + + // Create uuid + p.UUID = uuid.NewV4().String() + + _, err = add.Exec( + p.UUID, + p.Publication.ID, p.User.ID, + string(p.Type), p.TransactionDate, + p.StartDate, p.EndDate) + + return err +} + +func (pManager purchaseManager) Update(p Purchase) error { + // Get original purchase + origPurchase, err := pManager.Get(p.ID) + + if err != nil { + return ErrNotFound + } + + if origPurchase.Status != StatusOk { + return errors.New("Cannot update an invalid purchase") + } + + if p.Status == StatusToBeRenewed || + p.Status == StatusToBeReturned { + + if p.LicenseUUID == nil { + return errors.New("Cannot return or renew a purchase when no license has been delivered") + } + + lsdServerConfig := pManager.config.LsdServer + lsdURL := lsdServerConfig.PublicBaseUrl + "/licenses/" + *p.LicenseUUID + + if p.Status == StatusToBeRenewed { + lsdURL += "/renew" + + if p.EndDate != nil { + lsdURL += "?end=" + p.EndDate.Format(time.RFC3339) + } + + // Next status if LSD raises no error + p.Status = StatusOk + } else if p.Status == StatusToBeReturned { + lsdURL += "/return" + + // Next status if LSD raises no error + p.Status = StatusOk + } + + log.Println("PUT " + lsdURL) + req, err := http.NewRequest("PUT", lsdURL, nil) + + lsdAuth := pManager.config.LsdNotifyAuth + if lsdAuth.Username != "" { + req.SetBasicAuth(lsdAuth.Username, lsdAuth.Password) + } + + req.Header.Add("Content-Type", api.ContentType_JSON) + + var lsdClient = &http.Client{ + Timeout: time.Second * 5, + } + + resp, err := lsdClient.Do(req) + if err != nil { + return err + } + + // FIXME: Check status code + + defer resp.Body.Close() + + // Get new end date from LCP server + license, err := pManager.GetPartialLicense(origPurchase) + + if err != nil { + return err + } + + p.EndDate = license.Rights.End + } else { + p.Status = StatusOk + } + + update, err := pManager.db.Prepare(`UPDATE purchase + SET license_uuid=?, start_date=?, end_date=?, status=? WHERE id=?`) + if err != nil { + return err + } + defer update.Close() + result, err := update.Exec(p.LicenseUUID, p.StartDate, p.EndDate, p.Status, p.ID) + if changed, err := result.RowsAffected(); err == nil { + if changed != 1 { + return ErrNoChange + } + } + return err +} + +// Init purchaseManager +func Init(config config.Configuration, db *sql.DB) (i WebPurchase, err error) { + _, err = db.Exec(`CREATE TABLE IF NOT EXISTS purchase ( + id integer NOT NULL, + uuid varchar(255) NOT NULL, + publication_id integer NOT NULL, + user_id integer NOT NULL, + license_uuid varchar(255) NULL, + type varchar(32) NOT NULL, + transaction_date datetime, + start_date datetime, + end_date datetime, + status varchar(255) NOT NULL, + constraint pk_purchase primary key(id), + constraint fk_purchase_publication foreign key (publication_id) references publication(id) + constraint fk_purchase_user foreign key (user_id) references user(id) + )`) + if err != nil { + log.Println("Error creating purchase table") + return + } + _, err = db.Exec(`CREATE INDEX IF NOT EXISTS idx_purchase ON purchase (license_uuid)`) + if err != nil { + log.Println("Error creating idx_purchase table") + return + } + + i = purchaseManager{config, db} + return +} diff --git a/frontend/webrepository/webrepository.go b/frontend/webrepository/webrepository.go new file mode 100644 index 00000000..9afb89bf --- /dev/null +++ b/frontend/webrepository/webrepository.go @@ -0,0 +1,107 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package webrepository + +import ( + "errors" + "io/ioutil" + "os" + "path" + + "path/filepath" + + "github.com/readium/readium-lcp-server/config" +) + +// ErrNotFound error trown when repository is not found +var ErrNotFound = errors.New("Repository not found") + +// WebRepository interface for repository db interaction +type WebRepository interface { + GetMasterFile(name string) (RepositoryFile, error) + GetMasterFiles() func() (RepositoryFile, error) +} + +// RepositoryFile struct defines a file stored in a repository +type RepositoryFile struct { + Name string `json:"name"` + Path string +} + +// Contains all repository definitions +type RepositoryManager struct { + MasterRepositoryPath string + EncryptedRepositoryPath string +} + +// Returns a specific repository file +func (repManager RepositoryManager) GetMasterFile(name string) (RepositoryFile, error) { + var filePath = path.Join(repManager.MasterRepositoryPath, name) + + if _, err := os.Stat(filePath); err == nil { + // File exists + var repFile RepositoryFile + repFile.Name = name + repFile.Path = filePath + return repFile, err + } + + return RepositoryFile{}, ErrNotFound +} + +// Returns all repository files +func (repManager RepositoryManager) GetMasterFiles() func() (RepositoryFile, error) { + files, err := ioutil.ReadDir(repManager.MasterRepositoryPath) + var fileIndex int + + if err != nil { + return func() (RepositoryFile, error) { return RepositoryFile{}, err } + } + + return func() (RepositoryFile, error) { + var repFile RepositoryFile + + // Filter on epub + for fileIndex < len(files) { + file := files[fileIndex] + fileExt := filepath.Ext(file.Name()) + fileIndex++ + + if fileExt == ".epub" { + repFile.Name = file.Name() + return repFile, err + } + } + + return repFile, ErrNotFound + } +} + +// Open returns a WebPublication interface (db interaction) +func Init(config config.FrontendServerInfo) (i WebRepository, err error) { + i = RepositoryManager{config.MasterRepository, config.EncryptedRepository} + return +} diff --git a/frontend/webuser/webuser.go b/frontend/webuser/webuser.go new file mode 100644 index 00000000..5df60440 --- /dev/null +++ b/frontend/webuser/webuser.go @@ -0,0 +1,179 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package webuser + +import ( + "database/sql" + "errors" + + "github.com/satori/go.uuid" +) + +//ErrNotFound error trown when user is not found +var ErrNotFound = errors.New("User not found") + +// WebUser interface for user db interaction +type WebUser interface { + Get(id int64) (User, error) + GetByEmail(email string) (User, error) + Add(c User) error + Update(c User) error + DeleteUser(UserID int64) error + ListUsers(page int, pageNum int) func() (User, error) +} + +//User struct defines a user +type User struct { + ID int64 `json:"id"` + UUID string `json:"uuid"` + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty"` + Password string `json:"password,omitempty"` +} + +type dbUser struct { + db *sql.DB + getUser *sql.Stmt + getByEmail *sql.Stmt +} + +func (user dbUser) Get(id int64) (User, error) { + records, err := user.getUser.Query(id) + defer records.Close() + if records.Next() { + var c User + err = records.Scan(&c.ID, &c.UUID, &c.Name, &c.Email, &c.Password) + return c, err + } + + return User{}, ErrNotFound +} + +func (user dbUser) GetByEmail(email string) (User, error) { + records, err := user.getByEmail.Query(email) + defer records.Close() + if records.Next() { + var c User + err = records.Scan(&c.ID, &c.UUID, &c.Name, &c.Email, &c.Password) + return c, err + } + + return User{}, ErrNotFound +} + +func (user dbUser) Add(newUser User) error { + add, err := user.db.Prepare("INSERT INTO user (uuid, name, email, password) VALUES (?, ?, ?, ?)") + if err != nil { + return err + } + defer add.Close() + + // Create uuid + newUser.UUID = uuid.NewV4().String() + + _, err = add.Exec(newUser.UUID, newUser.Name, newUser.Email, newUser.Password) + return err +} + +func (user dbUser) Update(changedUser User) error { + add, err := user.db.Prepare("UPDATE user SET name=? , email=?, password=? WHERE id=?") + if err != nil { + return err + } + defer add.Close() + _, err = add.Exec(changedUser.Name, changedUser.Email, changedUser.Password, changedUser.ID) + return err +} + +func (user dbUser) DeleteUser(userID int64) error { + // delete purchases from user + delPurchases, err := user.db.Prepare(`DELETE FROM purchase WHERE user_id=?`) + if err != nil { + return err + } + defer delPurchases.Close() + if _, err := delPurchases.Exec(userID); err != nil { + return err + } + // and delete user + query, err := user.db.Prepare("DELETE FROM user WHERE id=?") + if err != nil { + return err + } + defer query.Close() + _, err = query.Exec(userID) + return err +} + +func (user dbUser) ListUsers(page int, pageNum int) func() (User, error) { + listUsers, err := user.db.Query(`SELECT id, uuid, name, email, password + FROM user + ORDER BY email desc LIMIT ? OFFSET ? `, page, pageNum*page) + if err != nil { + return func() (User, error) { return User{}, err } + } + return func() (User, error) { + var u User + if listUsers.Next() { + err := listUsers.Scan(&u.ID, &u.UUID, &u.Name, &u.Email, &u.Password) + + if err != nil { + return u, err + } + + } else { + listUsers.Close() + err = ErrNotFound + } + return u, err + } +} + +//Open returns a WebUser interface (db interaction) +func Open(db *sql.DB) (i WebUser, err error) { + _, err = db.Exec(`CREATE TABLE IF NOT EXISTS user ( + id integer NOT NULL, + uuid varchar(255) NOT NULL, + name varchar(64) NOT NULL, + email varchar(64) NOT NULL, + password varchar(64) NOT NULL, + + constraint pk_user primary key(id) + )`) + if err != nil { + return + } + get, err := db.Prepare("SELECT id, uuid, name, email, password FROM user WHERE id = ? LIMIT 1") + if err != nil { + return + } + getByEmail, err := db.Prepare("SELECT id, uuid, name, email, password FROM user WHERE email = ? LIMIT 1") + if err != nil { + return + } + i = dbUser{db, get, getByEmail} + return +} diff --git a/index/index.go b/index/index.go index 54a13197..9e88d02a 100644 --- a/index/index.go +++ b/index/index.go @@ -1,3 +1,28 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + package index import ( @@ -5,18 +30,21 @@ import ( "errors" ) -var NotFound = errors.New("Package not found") +var NotFound = errors.New("Content not found") type Index interface { - Get(storageKey string) (Package, error) - Add(p Package) error - List() func() (Package, error) + Get(id string) (Content, error) + Add(c Content) error + Update(c Content) error + List() func() (Content, error) } -type Package struct { - StorageKey string `json:"key"` - EncryptionKey []byte `json:"content_key"` - Filename string `json:"filename"` +type Content struct { + Id string `json:"id"` + EncryptionKey []byte `json:"-"` + Location string `json:"location"` + Length int64 `json:"length"` //not exported in license spec? + Sha256 string `json:"sha256"` //not exported in license spec? } type dbIndex struct { @@ -26,56 +54,72 @@ type dbIndex struct { list *sql.Stmt } -func (i dbIndex) Get(storageKey string) (Package, error) { - records, err := i.get.Query(storageKey) +func (i dbIndex) Get(id string) (Content, error) { + records, err := i.get.Query(id) defer records.Close() if records.Next() { - var p Package - err = records.Scan(&p.StorageKey, &p.EncryptionKey, &p.Filename) - return p, err + var c Content + err = records.Scan(&c.Id, &c.EncryptionKey, &c.Location, &c.Length, &c.Sha256) + return c, err } - return Package{}, NotFound + return Content{}, NotFound +} + +func (i dbIndex) Add(c Content) error { + add, err := i.db.Prepare("INSERT INTO content (id,encryption_key,location,length,sha256) VALUES (?, ?, ?, ?, ?)") + if err != nil { + return err + } + defer add.Close() + _, err = add.Exec(c.Id, c.EncryptionKey, c.Location, c.Length, c.Sha256) + return err } -func (i dbIndex) Add(p Package) error { - add, err := i.db.Prepare("INSERT INTO packages VALUES (?, ?, ?)") +func (i dbIndex) Update(c Content) error { + add, err := i.db.Prepare("UPDATE content SET encryption_key=? , location=?, length=?,sha256=? WHERE id=?") if err != nil { return err } defer add.Close() - _, err = add.Exec(p.StorageKey, p.EncryptionKey, p.Filename) + _, err = add.Exec(c.EncryptionKey, c.Location, c.Length, c.Sha256, c.Id) return err } -func (i dbIndex) List() func() (Package, error) { +func (i dbIndex) List() func() (Content, error) { rows, err := i.list.Query() if err != nil { - return func() (Package, error) { return Package{}, err } + return func() (Content, error) { return Content{}, err } } - return func() (Package, error) { - var p Package + return func() (Content, error) { + var c Content var err error if rows.Next() { - err = rows.Scan(&p.StorageKey, &p.EncryptionKey, &p.Filename) + err = rows.Scan(&c.Id, &c.EncryptionKey, &c.Location, &c.Length, &c.Sha256) } else { rows.Close() err = NotFound } - return p, err + return c, err } } func Open(db *sql.DB) (i Index, err error) { - _, err = db.Exec("CREATE TABLE IF NOT EXISTS packages (storage_key varchar(255) PRIMARY KEY, encryption_key blob, filename varchar(255))") + _, err = db.Exec(`CREATE TABLE IF NOT EXISTS content ( + id varchar(255) PRIMARY KEY, + encryption_key varchar(64) NOT NULL, + location text NOT NULL, + length bigint, + sha256 varchar(64), + FOREIGN KEY(id) REFERENCES license(content_fk))`) if err != nil { return } - get, err := db.Prepare("SELECT * FROM packages WHERE storage_key = ? LIMIT 1") + get, err := db.Prepare("SELECT id,encryption_key,location,length,sha256 FROM content WHERE id = ? LIMIT 1") if err != nil { return } - list, err := db.Prepare("SELECT * FROM packages") + list, err := db.Prepare("SELECT id,encryption_key,location,length,sha256 FROM content") if err != nil { return } diff --git a/index/index_test.go b/index/index_test.go index 0a630f53..6e432a15 100644 --- a/index/index_test.go +++ b/index/index_test.go @@ -1,9 +1,35 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + package index import ( "database/sql" - _ "github.com/mattn/go-sqlite3" "testing" + + _ "github.com/mattn/go-sqlite3" ) func TestIndexCreation(t *testing.T) { @@ -15,8 +41,8 @@ func TestIndexCreation(t *testing.T) { t.FailNow() } - p := Package{"test", []byte("1234"), "test.epub"} - err = idx.Add(p) + c := Content{"test", []byte("1234"), "test.epub"} + err = idx.Add(c) if err != nil { t.Error(err) } diff --git a/lcpencrypt/encrypt/encrypt.go b/lcpencrypt/encrypt/encrypt.go new file mode 100644 index 00000000..803f613e --- /dev/null +++ b/lcpencrypt/encrypt/encrypt.go @@ -0,0 +1,102 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package encrypt + +import ( + "archive/zip" + "bytes" + "crypto/sha256" + "encoding/hex" + "errors" + "io/ioutil" + "os" + + "github.com/readium/readium-lcp-server/crypto" + "github.com/readium/readium-lcp-server/epub" + "github.com/readium/readium-lcp-server/pack" +) + +// EncryptedEpub Encrypted epub +type EncryptedEpub struct { + Path string + EncryptionKey []byte + Size int64 + Checksum string +} + +// EncryptEpub Encrypt input file to output file +func EncryptEpub(inputPath string, outputPath string) (EncryptedEpub, error) { + if _, err := os.Stat(inputPath); err != nil { + return EncryptedEpub{}, errors.New("Input file does not exists") + } + + // Read file + buf, err := ioutil.ReadFile(inputPath) + if err != nil { + return EncryptedEpub{}, errors.New("Unable to read input file") + } + + // Read the epub content from the zipped buffer + zipReader, err := zip.NewReader(bytes.NewReader(buf), int64(len(buf))) + if err != nil { + return EncryptedEpub{}, errors.New("Invalid zip (epub) file") + } + + epubContent, err := epub.Read(zipReader) + if err != nil { + return EncryptedEpub{}, errors.New("Invalid epub content") + } + + // Create output file + output, err := os.Create(outputPath) + if err != nil { + return EncryptedEpub{}, errors.New("Unable to create output file") + } + + // Pack / encrypt the epub content, fill the output file + encrypter := crypto.NewAESEncrypter_PUBLICATION_RESOURCES() + _, encryptionKey, err := pack.Do(encrypter, epubContent, output) + if err != nil { + return EncryptedEpub{}, errors.New("Unable to encrypt file") + } + + stats, err := output.Stat() + if err != nil || (stats.Size() <= 0) { + return EncryptedEpub{}, errors.New("Unable to output file") + } + + hasher := sha256.New() + s, err := ioutil.ReadFile(outputPath) + _, err = hasher.Write(s) + if err != nil { + return EncryptedEpub{}, errors.New("Unable to build checksum") + } + + checksum := hex.EncodeToString(hasher.Sum(nil)) + + output.Close() + return EncryptedEpub{outputPath, encryptionKey, stats.Size(), checksum}, nil +} diff --git a/lcpencrypt/lcpencrypt.go b/lcpencrypt/lcpencrypt.go index 80af321a..820de688 100644 --- a/lcpencrypt/lcpencrypt.go +++ b/lcpencrypt/lcpencrypt.go @@ -1,9 +1,35 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + package main import ( "archive/zip" "bytes" "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "flag" @@ -13,28 +39,23 @@ import ( "net/http" "net/url" "os" + "path/filepath" "strings" + "github.com/readium/readium-lcp-server/crypto" "github.com/readium/readium-lcp-server/epub" + "github.com/readium/readium-lcp-server/lcpserver/api" "github.com/readium/readium-lcp-server/pack" ) -// struct for communication with lcp-server -type LcpPublication struct { - ContentId string `json:"content-id"` - ContentKey []byte `json:"content-encryption-key"` - Output string `json:"protected-content-location"` - ErrorMessage string `json:"error"` -} - // notification of newly added content (Publication) -func notifyLcpServer(lcpService, contentid string, lcpPublication LcpPublication) error { +func notifyLcpServer(lcpService, contentid string, lcpPublication apilcp.LcpPublication, username string, password string) error { //exchange encryption key with lcp service/content/, //Payload: {content-encryption-key, protected-content-location} //fmt.Printf("lcpsv = %s\n", *lcpsv) var urlBuffer bytes.Buffer urlBuffer.WriteString(lcpService) - urlBuffer.WriteString("/content/") + urlBuffer.WriteString("/contents/") urlBuffer.WriteString(contentid) jsonBody, err := json.Marshal(lcpPublication) @@ -45,12 +66,13 @@ func notifyLcpServer(lcpService, contentid string, lcpPublication LcpPublication if err != nil { return err } + req.SetBasicAuth(username, password) client := &http.Client{} resp, err := client.Do(req) if err != nil { return err } - if (resp.StatusCode / 100) != 2 { + if (resp.StatusCode != 302) && (resp.StatusCode/100) != 2 { //302=found or 20x reply = OK return errors.New(fmt.Sprintf("lcp server error %d ", resp.StatusCode)) } @@ -59,7 +81,7 @@ func notifyLcpServer(lcpService, contentid string, lcpPublication LcpPublication // reads and returns the content of // a file on the local filesystem -// or via a GET if the scheme is http:// or https:// +// or via a GET if the scheme is http:// or https:// func getInputFile(inputFilename string) ([]byte, error) { url, err := url.Parse(inputFilename) if err != nil { @@ -81,36 +103,53 @@ func getInputFile(inputFilename string) ([]byte, error) { } func showHelpAndExit() { - log.Println("lcpencrypt packs en epub for usage in an lcp environment") - log.Println("-input : source file locator. (file system or http GET)") - log.Println("[-contentid] : optional content identifier, if not present a new one is generated") - log.Println("[-output] : optional target file for protected content (file system or http PUT)") - log.Println("[-lcpsv] : http endpoint of the LCP service for exchange of information") - log.Println("[-help] ") + log.Println("lcpencrypt protects an epub file for usage in an lcp environment") + log.Println("-input source epub file locator (file system or http GET)") + log.Println("[-contentid] optional content identifier, if omitted a new one will be generated") + log.Println("[-output] optional target location for protected content (file system or http PUT)") + log.Println("[-lcpsv] optional http endpoint for the License server") + log.Println("[-login] login ( needed for License server) ") + log.Println("[-password] password ( needed for License server)") + log.Println("[-help] : help information") os.Exit(0) return } -func exitWithError(lcpPublication LcpPublication, err error, errorlevel int) { +func exitWithError(lcpPublication apilcp.LcpPublication, err error, errorlevel int) { os.Stderr.WriteString(lcpPublication.ErrorMessage) - os.Stderr.WriteString(err.Error()) os.Stderr.WriteString("\n") + if err != nil { + os.Stderr.WriteString(err.Error()) + } jsonBody, err := json.MarshalIndent(lcpPublication, " ", " ") if err != nil { - os.Stderr.WriteString("Error writing json to stdout") + os.Stderr.WriteString("\nError creating json lcpPublication") os.Exit(errorlevel) } os.Stdout.Write(jsonBody) os.Exit(errorlevel) } +func getChecksum(filename string) string { + hasher := sha256.New() + s, err := ioutil.ReadFile(filename) + hasher.Write(s) + if err != nil { + return "" + } + return hex.EncodeToString(hasher.Sum(nil)) +} + func main() { var err error - var addedPublication LcpPublication - var inputFilename = flag.String("input", "", "source file locator. (file system or http GET)") - var contentid = flag.String("contentid", "", "optional content identifier, if not present a new one is generated") - var outputFilename = flag.String("output", "", "optional target file for protected content (file system or http PUT) ") - var lcpsv = flag.String("lcpsv", "", "http endpoint of the LCP service for exchange of information ") + var addedPublication apilcp.LcpPublication + var inputFilename = flag.String("input", "", "source epub file locator (file system or http GET)") + var contentid = flag.String("contentid", "", "optional content identifier; if omitted a new one is generated") + var outputFilename = flag.String("output", "", "optional target location for the encrypted content (file system or http PUT)") + var lcpsv = flag.String("lcpsv", "", "optional http endpoint of the License server (adds content)") + var username = flag.String("login", "", "login (License server)") + var password = flag.String("password", "", "password (License server)") + var help = flag.Bool("help", false, "shows information") if !flag.Parsed() { @@ -119,68 +158,85 @@ func main() { if *help { showHelpAndExit() } + + if *lcpsv != "" && (*username == "" || *password == "") { + addedPublication.ErrorMessage = "incorrect parameters, lcpsv needs login and password, for more information type 'lcpencrypt -help' " + exitWithError(addedPublication, nil, 80) + } + + // read the epub input file content in memory buf, err := getInputFile(*inputFilename) if err != nil { - addedPublication.ErrorMessage = "Error opening input, for more information type \"lcpencrypt -help\"" - exitWithError(addedPublication, err, 12) - return + addedPublication.ErrorMessage = "Error opening input file, for more information type 'lcpencrypt -help' " + exitWithError(addedPublication, err, 70) } if *contentid == "" { // contentID not set -> generate a new one sha := sha256.Sum256(buf) *contentid = fmt.Sprintf("%x", sha) } + var basefilename string addedPublication.ContentId = *contentid - if *outputFilename == "" { //output not set -> "content-id.epub" in working directory + if *outputFilename == "" { //output not set -> "content-id.epub" in the working directory workingDir, _ := os.Getwd() - *outputFilename = strings.Join([]string{workingDir, *contentid, ".epub"}, "") + *outputFilename = strings.Join([]string{workingDir, string(os.PathSeparator), *contentid, ".epub"}, "") + basefilename = filepath.Base(*inputFilename) + } else { + basefilename = filepath.Base(*outputFilename) } + addedPublication.ContentDisposition = &basefilename addedPublication.Output = *outputFilename - // decode and pack epub file + + // read the epub content from the zipped buffer zr, err := zip.NewReader(bytes.NewReader(buf), int64(len(buf))) if err != nil { - addedPublication.ErrorMessage = "Error opening zip/epub" - exitWithError(addedPublication, err, 10) - return + addedPublication.ErrorMessage = "Error opening the epub file" + exitWithError(addedPublication, err, 60) } ep, err := epub.Read(zr) if err != nil { - addedPublication.ErrorMessage = "Error reading epub" - exitWithError(addedPublication, err, 8) - os.Exit(3) - return + addedPublication.ErrorMessage = "Error reading the epub content" + exitWithError(addedPublication, err, 50) } + // create an output file output, err := os.Create(*outputFilename) if err != nil { addedPublication.ErrorMessage = "Error writing output file" - exitWithError(addedPublication, err, 4) - return + exitWithError(addedPublication, err, 40) } - _, encryptionKey, err := pack.Do(ep, output) + // pack / encrypt the epub content, fill the output file + encrypter := crypto.NewAESEncrypter_PUBLICATION_RESOURCES() + _, encryptionKey, err := pack.Do(encrypter, ep, output) + + stats, err := output.Stat() + if err == nil && (stats.Size() > 0) { + filesize := stats.Size() + cs := getChecksum(*outputFilename) + addedPublication.Size = &filesize + addedPublication.Checksum = &cs + } output.Close() if err != nil { - addedPublication.ErrorMessage = "Error packing" - exitWithError(addedPublication, err, 6) - return + addedPublication.ErrorMessage = "Error packaging the publication" + exitWithError(addedPublication, err, 30) } addedPublication.ContentKey = encryptionKey - addedPublication.Output = *outputFilename + // notify the LCP Server if *lcpsv != "" { - err = notifyLcpServer(*lcpsv, *contentid, addedPublication) + err = notifyLcpServer(*lcpsv, *contentid, addedPublication, *username, *password) if err != nil { - addedPublication.ErrorMessage = "Error updating LCP-server" - exitWithError(addedPublication, err, 1) - os.Exit(6) + addedPublication.ErrorMessage = "Error notifying the License server" + exitWithError(addedPublication, err, 20) } } + // write json message to stdout jsonBody, err := json.Marshal(addedPublication) if err != nil { - addedPublication.ErrorMessage = "Error writing json to stdout" - exitWithError(addedPublication, err, 1) - return + addedPublication.ErrorMessage = "Error creating json addedPublication" + exitWithError(addedPublication, err, 10) } os.Stdout.Write(jsonBody) os.Exit(0) diff --git a/lcpserver/api/license.go b/lcpserver/api/license.go new file mode 100644 index 00000000..0a0a80fb --- /dev/null +++ b/lcpserver/api/license.go @@ -0,0 +1,704 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package apilcp + +import ( + "archive/zip" + "bytes" + "crypto/sha256" + "crypto/tls" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "log" + "net/http" + "reflect" + "strconv" + "strings" + + "github.com/davecgh/go-spew/spew" + "github.com/gorilla/mux" + + "github.com/readium/readium-lcp-server/api" + "github.com/readium/readium-lcp-server/config" + "github.com/readium/readium-lcp-server/crypto" + "github.com/readium/readium-lcp-server/epub" + "github.com/readium/readium-lcp-server/index" + "github.com/readium/readium-lcp-server/license" + "github.com/readium/readium-lcp-server/problem" + "github.com/readium/readium-lcp-server/sign" + "github.com/readium/readium-lcp-server/storage" +) + +func GetLicense(w http.ResponseWriter, r *http.Request, s Server) { + vars := mux.Vars(r) + + licenceId := vars["license_id"] + + var ExistingLicense license.License + ExistingLicense, e := s.Licenses().Get(licenceId) + if e != nil { + if e == license.NotFound { + problem.Error(w, r, problem.Problem{Detail: e.Error()}, http.StatusNotFound) + } else { + problem.Error(w, r, problem.Problem{Detail: e.Error()}, http.StatusBadRequest) + } + return + } + var lic license.License + err := DecodeJsonLicense(r, &lic) + + log.Println("PARTIAL LICENSE RECEIVED IN REQUEST BODY:") + spew.Dump(lic) + + if err != nil { // no or incorrect (json) license found in body + + log.Println("PARTIAL CONTENT:(error: " + err.Error() + ")") + body, _ := ioutil.ReadAll(r.Body) + log.Println("BODY=" + string(body)) + err = prepareLinks(ExistingLicense, s) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + return + } + + w.Header().Add("Content-Type", api.ContentType_LCP_JSON) + + // must come *after* w.Header().Add()/Set(), but before w.Write() + w.WriteHeader(http.StatusPartialContent) + + //delete some sensitive data from license + ExistingLicense.Encryption.UserKey.Check = nil + ExistingLicense.Encryption.UserKey.Value = nil + ExistingLicense.Encryption.UserKey.Hint = "" + ExistingLicense.Encryption.UserKey.ClearValue = "" + ExistingLicense.Encryption.UserKey.Key.Algorithm = "" + ExistingLicense.Encryption.Profile = "" + + enc := json.NewEncoder(w) + enc.Encode(ExistingLicense) + + log.Println("PARTIAL LICENSE FOR RESPONSE:") + spew.Dump(ExistingLicense) + + return + + } else { // add information to license , sign and return (real) License + + if lic.User.Email == "" { + problem.Error(w, r, problem.Problem{Detail: "User information must be passed in INPUT"}, http.StatusBadRequest) + return + } + ExistingLicense.User = lic.User + + if ExistingLicense.Links == nil { + ExistingLicense.Links = license.DefaultLinksCopy() + } + + ExistingLicense.Encryption.UserKey.Value = lic.Encryption.UserKey.Value + + err = completeLicense(&ExistingLicense, ExistingLicense.ContentId, s) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + return + } + + w.Header().Add("Content-Type", api.ContentType_LCP_JSON) + w.Header().Add("Content-Disposition", `attachment; filename="license.lcpl"`) + + // must come *after* w.Header().Add()/Set(), but before w.Write() + w.WriteHeader(http.StatusOK) + + enc := json.NewEncoder(w) + enc.Encode(ExistingLicense) + + log.Println("COMPLETE LICENSE FOR RESPONSE:") + spew.Dump(ExistingLicense) + + return + } +} + +// this function only updates the license in the database given a partial license input +func updateLicenseInDatabase(licenseID string, partialLicense license.License, s Server) (license.License, error) { + + var ExistingLicense license.License + ExistingLicense, err := s.Licenses().Get(licenseID) + if err != nil { + return ExistingLicense, err + } + + // update rights of license in database / verify validity of lic / existingLicense + if partialLicense.Provider != "" { + ExistingLicense.Provider = partialLicense.Provider + } + if partialLicense.Rights != nil { + if partialLicense.Rights.Copy != nil { + ExistingLicense.Rights.Copy = partialLicense.Rights.Copy + } + if partialLicense.Rights.Print != nil { + ExistingLicense.Rights.Print = partialLicense.Rights.Print + } + if partialLicense.Rights.Start != nil { + ExistingLicense.Rights.Start = partialLicense.Rights.Start + } + if partialLicense.Rights.End != nil { + ExistingLicense.Rights.End = partialLicense.Rights.End + } + } else { + ExistingLicense.Rights.Copy = nil + ExistingLicense.Rights.Print = nil + ExistingLicense.Rights.Start = nil + ExistingLicense.Rights.End = nil + } + + if partialLicense.Encryption.UserKey.Hint != "" { + ExistingLicense.Encryption.UserKey.Hint = partialLicense.Encryption.UserKey.Hint + } + if partialLicense.ContentId != "" { //change content + ExistingLicense.ContentId = partialLicense.ContentId + } + err = s.Licenses().Update(ExistingLicense) + if err != nil { // no or incorrect (json) license found in body + return ExistingLicense, err + } + return ExistingLicense, err +} + +// UpdateLicense updates an existing license and returns the new license (lcpl) +func UpdateLicense(w http.ResponseWriter, r *http.Request, s Server) { + + vars := mux.Vars(r) + licenseID := vars["license_id"] + var lic license.License + err := DecodeJsonLicense(r, &lic) + if err != nil { // no or incorrect (json) license found in body + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + return + } + if lic.Id != licenseID { + problem.Error(w, r, problem.Problem{Detail: "Different license IDs"}, http.StatusNotFound) + return + } + _, err = updateLicenseInDatabase(licenseID, lic, s) + if err != nil { + if err == license.NotFound { + problem.Error(w, r, problem.Problem{Detail: license.NotFound.Error()}, http.StatusNotFound) + } else { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + } + return + } + // go on and GET license io to return the updated license + GetLicense(w, r, s) +} + +// TODO: the UpdateRightsLicense function appears to be unused? +// func UpdateRightsLicense(w http.ResponseWriter, r *http.Request, s Server) { +// vars := mux.Vars(r) +// licenceId := vars["key"] +// // search existing license using key +// var ExistingLicense license.License +// ExistingLicense, e := s.Licenses().Get(licenceId) +// if e != nil { +// if e == license.NotFound { +// problem.Error(w, r, problem.Problem{Detail: license.NotFound.Error()}, http.StatusNotFound) +// } else { +// problem.Error(w, r, problem.Problem{Detail: e.Error()}, http.StatusBadRequest) +// } +// return +// } +// var lic license.License +// err := DecodeJsonLicense(r, &lic) +// if err != nil { // no or incorrect (json) license found in body +// problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) +// return +// } +// if lic.Id != licenceId { +// problem.Error(w, r, problem.Problem{Detail: "Different license IDs"}, http.StatusNotFound) +// return +// } +// // update rights of license in database +// if lic.Rights.Copy != nil { +// ExistingLicense.Rights.Copy = lic.Rights.Copy +// } +// if lic.Rights.Print != nil { +// ExistingLicense.Rights.Print = lic.Rights.Print +// } +// if lic.Rights.Start != nil { +// ExistingLicense.Rights.Start = lic.Rights.Start +// } +// if lic.Rights.End != nil { +// ExistingLicense.Rights.End = lic.Rights.End +// } +// err = s.Licenses().UpdateRights(ExistingLicense) +// if err != nil { // no or incorrect (json) license found in body +// problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) +// return +// } +// // go on to GET license io to return the existing license +// GetLicense(w, r, s) +// } + +func GenerateLicense(w http.ResponseWriter, r *http.Request, s Server) { + vars := mux.Vars(r) + var lic license.License + + err := DecodeJsonLicense(r, &lic) + + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + return + } + + contentID := vars["content_id"] + lic.ContentId = "" + err = completeLicense(&lic, contentID, s) + + if err != nil { + if err == storage.NotFound || err == index.NotFound { + problem.Error(w, r, problem.Problem{Detail: err.Error(), Instance: contentID}, http.StatusNotFound) + } else { + problem.Error(w, r, problem.Problem{Detail: err.Error(), Instance: contentID}, http.StatusInternalServerError) + } + return + + } + + err = s.Licenses().Add(lic) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error(), Instance: contentID}, http.StatusInternalServerError) + return + } + + w.Header().Add("Content-Type", api.ContentType_LCP_JSON) + w.Header().Add("Content-Disposition", `attachment; filename="license.lcpl"`) + + // must come *after* w.Header().Add()/Set(), but before w.Write() + w.WriteHeader(http.StatusCreated) + + enc := json.NewEncoder(w) + enc.Encode(lic) +} + +func GenerateProtectedPublication(w http.ResponseWriter, r *http.Request, s Server) { + var partialLicense license.License + var newLicense license.License + err := DecodeJsonLicense(r, &partialLicense) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + return + } + + vars := mux.Vars(r) + contentID := vars["content_id"] + licenseID := vars["license_id"] //may be empty (new request or update of existing license/publication + if licenseID != "" { // POST /{license_id}/publication + //license update license, regenerate publication, maybe only get from db ? + newLicense, err = updateLicenseInDatabase(licenseID, partialLicense, s) + if err != nil { + if err == license.NotFound { + problem.Error(w, r, problem.Problem{Detail: license.NotFound.Error()}, http.StatusNotFound) + } else { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + } + return + } + newLicense.User = partialLicense.User //pass user information in updated license + newLicense.Encryption.UserKey.Value = partialLicense.Encryption.UserKey.Value + newLicense.Encryption.UserKey.ClearValue = partialLicense.Encryption.UserKey.ClearValue + + // contentID is not set, get it from the license + contentID = newLicense.ContentId + err = completeLicense(&newLicense, contentID, s) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error(), Instance: contentID}, http.StatusInternalServerError) + return + } + } else { // POST //{key}/publication[s] + //new license , generate publication + newLicense = partialLicense + newLicense.ContentId = "" + err = completeLicense(&newLicense, contentID, s) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error(), Instance: contentID}, http.StatusInternalServerError) + return + } + err = s.Licenses().Add(newLicense) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error(), Instance: contentID}, http.StatusInternalServerError) + return + } + licenseID = newLicense.Id + } + // get publication and add license to the publication + if contentID == "" { + problem.Error(w, r, problem.Problem{Detail: "No content id", Instance: contentID}, http.StatusBadRequest) + return + } + + epubFile, err := s.Store().Get(contentID) + if err != nil { + if err == storage.NotFound { + problem.Error(w, r, problem.Problem{Detail: err.Error(), Instance: contentID}, http.StatusNotFound) + return + } + problem.Error(w, r, problem.Problem{Detail: err.Error(), Instance: contentID}, http.StatusInternalServerError) + return + } + + content, err := s.Index().Get(contentID) + if err != nil { + if err == index.NotFound { + problem.Error(w, r, problem.Problem{Detail: err.Error(), Instance: contentID}, http.StatusNotFound) + } else { + problem.Error(w, r, problem.Problem{Detail: err.Error(), Instance: contentID}, http.StatusInternalServerError) + } + return + } + var b bytes.Buffer + contents, err := epubFile.Contents() + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error(), Instance: contentID}, http.StatusInternalServerError) + return + } + + io.Copy(&b, contents) + zr, err := zip.NewReader(bytes.NewReader(b.Bytes()), int64(b.Len())) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error(), Instance: contentID}, http.StatusInternalServerError) + return + } + ep, err := epub.Read(zr) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error(), Instance: contentID}, http.StatusInternalServerError) + return + } + //add license to publication + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.Encode(newLicense) + var buf2 bytes.Buffer + buf2.Write(bytes.TrimRight(buf.Bytes(), "\n")) + ep.Add(epub.LicenseFile, &buf2, uint64(buf2.Len())) + + //set HTTP headers + w.Header().Add("Content-Type", epub.ContentType_EPUB) + w.Header().Add("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, content.Location)) + w.Header().Add("X-Lcp-License", newLicense.Id) + // must come *after* w.Header().Add()/Set(), but before w.Write() + w.WriteHeader(http.StatusCreated) + // write HTTP body + ep.Write(w) +} + +func DecodeJsonLicense(r *http.Request, lic *license.License) error { + var dec *json.Decoder + + if ctype := r.Header["Content-Type"]; len(ctype) > 0 && ctype[0] == api.ContentType_FORM_URL_ENCODED { + buf := bytes.NewBufferString(r.PostFormValue("data")) + dec = json.NewDecoder(buf) + } else { + dec = json.NewDecoder(r.Body) + } + + err := dec.Decode(&lic) + + return err +} + +func completeLicense(l *license.License, contentID string, s Server) error { + c, err := s.Index().Get(contentID) + if err != nil { + return err + } + + isNewLicense := l.ContentId == "" + if isNewLicense { + license.Prepare(l) + l.ContentId = contentID + } else { + l.Signature = nil // empty signature fields, needs to be recalculated + } + links := new([]license.Link) + + //verify that mandatory (hint & publication) links are present in the License + if value, present := license.DefaultLinks["hint"]; present { + hint := license.Link{Href: value, Rel: "hint"} + *links = append(*links, hint) + } else { + return errors.New("No hint link present in config") + } + + if value, present := license.DefaultLinks["publication"]; present { + // replace {publication_id} in template link + publicationLink := strings.Replace(value, "{publication_id}", c.Id, 1) + publication := license.Link{Href: publicationLink, Rel: "publication", Type: epub.ContentType_EPUB, Size: c.Length, Title: c.Location, Checksum: c.Sha256} + *links = append(*links, publication) + } else { + return errors.New("No publication link present in config") + } + + if value, present := config.Config.License.Links["status"]; present { // add status server to License + statusLink := strings.Replace(value, "{license_id}", l.Id, 1) + + status := license.Link{Href: statusLink, Rel: "status", Type: api.ContentType_LSD_JSON} //status.Type = ?? + *links = append(*links, status) + } + + l.Links = *links + var encryptionKey []byte + + if len(l.Encryption.UserKey.Value) > 0 { + encryptionKey = l.Encryption.UserKey.Value + //l.Encryption.UserKey.Value = nil + } else { + passphrase := l.Encryption.UserKey.ClearValue + l.Encryption.UserKey.ClearValue = "" + hash := sha256.Sum256([]byte(passphrase)) + encryptionKey = hash[:] + } + + encrypter_content_key := crypto.NewAESEncrypter_CONTENT_KEY() + + l.Encryption.ContentKey.Algorithm = encrypter_content_key.Signature() + l.Encryption.ContentKey.Value = encryptKey(encrypter_content_key, c.EncryptionKey, encryptionKey[:]) + l.Encryption.UserKey.Algorithm = "http://www.w3.org/2001/04/xmlenc#sha256" + + encrypter_fields := crypto.NewAESEncrypter_FIELDS() + + err = encryptFields(encrypter_fields, l, encryptionKey[:]) + if err != nil { + return err + } + + encrypter_user_key_check := crypto.NewAESEncrypter_USER_KEY_CHECK() + + err = buildKeyCheck(encrypter_user_key_check, l, encryptionKey[:]) + if err != nil { + return err + } + + if l.Signature != nil { + log.Println("Signature is NOT nil (it should)") + l.Signature = nil + } + err = signLicense(l, s.Certificate()) + if err != nil { + return err + } + return nil +} + +func buildKeyCheck(encrypter crypto.Encrypter, l *license.License, key []byte) error { + var out bytes.Buffer + err := encrypter.Encrypt(key, bytes.NewBufferString(l.Id), &out) + if err != nil { + return err + } + l.Encryption.UserKey.Check = out.Bytes() + return nil +} + +func encryptFields(encrypter crypto.Encrypter, l *license.License, key []byte) error { + for _, toEncrypt := range l.User.Encrypted { + var out bytes.Buffer + field := getField(&l.User, toEncrypt) + err := encrypter.Encrypt(key[:], bytes.NewBufferString(field.String()), &out) + if err != nil { + return err + } + field.Set(reflect.ValueOf(base64.StdEncoding.EncodeToString(out.Bytes()))) + } + return nil +} + +func getField(u *license.UserInfo, field string) reflect.Value { + v := reflect.ValueOf(u).Elem() + return v.FieldByName(strings.Title(field)) +} + +func signLicense(l *license.License, cert *tls.Certificate) error { + sig, err := sign.NewSigner(cert) + if err != nil { + return err + } + res, err := sig.Sign(l) + if err != nil { + return err + } + l.Signature = &res + + return nil +} + +func encryptKey(encrypter crypto.Encrypter, key []byte, kek []byte) []byte { + var out bytes.Buffer + in := bytes.NewReader(key) + encrypter.Encrypt(kek[:], in, &out) + return out.Bytes() +} + +//ListLicenses returns a JSON struct with information about emitted licenses +// optional GET parameters are "page" (page number) and "per_page" (items par page) +func ListLicenses(w http.ResponseWriter, r *http.Request, s Server) { + var page int64 + var per_page int64 + var err error + if r.FormValue("page") != "" { + page, err = strconv.ParseInt((r).FormValue("page"), 10, 32) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + return + } + } else { + page = 1 + } + if r.FormValue("per_page") != "" { + per_page, err = strconv.ParseInt((r).FormValue("per_page"), 10, 32) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + return + } + } else { + per_page = 30 + } + if page > 0 { //pagenum starting at 0 in code, but user interface starting at 1 + page-- + } + if page < 0 { + problem.Error(w, r, problem.Problem{Detail: "page must be positive integer"}, http.StatusBadRequest) + return + } + licenses := make([]license.LicenseReport, 0) + //log.Println("ListAll(" + strconv.Itoa(int(per_page)) + "," + strconv.Itoa(int(page)) + ")") + fn := s.Licenses().ListAll(int(per_page), int(page)) + for it, err := fn(); err == nil; it, err = fn() { + licenses = append(licenses, it) + } + if len(licenses) > 0 { + nextPage := strconv.Itoa(int(page) + 1) + w.Header().Set("Link", "; rel=\"next\"; title=\"next\"") + } + if page > 1 { + previousPage := strconv.Itoa(int(page) - 1) + w.Header().Set("Link", "; rel=\"previous\"; title=\"previous\"") + } + w.Header().Set("Content-Type", api.ContentType_JSON) + + enc := json.NewEncoder(w) + err = enc.Encode(licenses) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + return + } +} + +//ListLicenses returns a JSON struct with information about emitted licenses +// content-id is in url +// optional GET parameters are "page" (page number) and "per_page" (items par page) +func ListLicensesForContent(w http.ResponseWriter, r *http.Request, s Server) { + vars := mux.Vars(r) + var page int64 + var per_page int64 + var err error + contentId := vars["key"] + //check if license exists + _, err = s.Index().Get(contentId) + if err == index.NotFound { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusNotFound) + return + } //other errors pass, but will probably reoccur + if r.FormValue("page") != "" { + page, err = strconv.ParseInt(r.FormValue("page"), 10, 32) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + return + } + } else { + page = 1 + } + + if r.FormValue("per_page") != "" { + per_page, err = strconv.ParseInt((r).FormValue("per_page"), 10, 32) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + return + } + } else { + per_page = 30 + } + if page > 0 { + page-- //pagenum starting at 0 in code, but user interface starting at 1 + } + if page < 0 { + problem.Error(w, r, problem.Problem{Detail: "page must be positive integer"}, http.StatusBadRequest) + return + } + licenses := make([]license.LicenseReport, 0) + //log.Println("List(" + contentId + "," + strconv.Itoa(int(per_page)) + "," + strconv.Itoa(int(page)) + ")") + fn := s.Licenses().List(contentId, int(per_page), int(page)) + for it, err := fn(); err == nil; it, err = fn() { + licenses = append(licenses, it) + } + if len(licenses) > 0 { + nextPage := strconv.Itoa(int(page) + 1) + w.Header().Set("Link", "; rel=\"next\"; title=\"next\"") + } + if page > 1 { + previousPage := strconv.Itoa(int(page) - 1) + w.Header().Set("Link", "; rel=\"previous\"; title=\"previous\"") + } + w.Header().Set("Content-Type", api.ContentType_JSON) + enc := json.NewEncoder(w) + err = enc.Encode(licenses) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + return + } + +} + +func prepareLinks(license license.License, s Server) error { + for i := 0; i < len(license.Links); i++ { + if license.Links[i].Rel == "publication" { + item, err := s.Index().Get(license.ContentId) + if err != nil { + return err + } + license.Links[i].Href = strings.Replace(license.Links[i].Href, "{publication_id}", license.ContentId, 1) + license.Links[i].Href = strings.Replace(license.Links[i].Href, "{publication_loc}", item.Location, 1) + } + + if license.Links[i].Rel == "status" { + license.Links[i].Href = strings.Replace(license.Links[i].Href, "{license_id}", license.Id, 1) + } + } + return nil +} diff --git a/lcpserver/api/store.go b/lcpserver/api/store.go new file mode 100644 index 00000000..9b00f5cb --- /dev/null +++ b/lcpserver/api/store.go @@ -0,0 +1,248 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package apilcp + +import ( + "crypto/tls" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "os" + + "github.com/gorilla/mux" + + "github.com/readium/readium-lcp-server/api" + "github.com/readium/readium-lcp-server/epub" + "github.com/readium/readium-lcp-server/index" + "github.com/readium/readium-lcp-server/license" + "github.com/readium/readium-lcp-server/pack" + "github.com/readium/readium-lcp-server/problem" + "github.com/readium/readium-lcp-server/storage" +) + +type Server interface { + Store() storage.Store + Index() index.Index + Licenses() license.Store + Certificate() *tls.Certificate + Source() *pack.ManualSource +} + +// struct for communication with lcp-server +type LcpPublication struct { + ContentId string `json:"content-id"` + ContentKey []byte `json:"content-encryption-key"` + Output string `json:"protected-content-location"` + Size *int64 `json:"protected-content-length,omitempty"` + Checksum *string `json:"protected-content-sha256,omitempty"` + ContentDisposition *string `json:"protected-content-disposition,omitempty"` + ErrorMessage string `json:"error"` +} + +func writeRequestFileToTemp(r io.Reader) (int64, *os.File, error) { + dir := os.TempDir() + file, err := ioutil.TempFile(dir, "readium-lcp") + if err != nil { + return 0, file, err + } + + n, err := io.Copy(file, r) + + // Rewind to the beginning of the file + file.Seek(0, 0) + + return n, file, err +} + +func cleanupTemp(f *os.File) { + if f == nil { + return + } + f.Close() + os.Remove(f.Name()) +} + +func StoreContent(w http.ResponseWriter, r *http.Request, s Server) { + vars := mux.Vars(r) + + size, f, err := writeRequestFileToTemp(r.Body) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + return + } + + defer cleanupTemp(f) + + t := pack.NewTask(vars["name"], f, size) + result := s.Source().Post(t) + + if result.Error != nil { + problem.Error(w, r, problem.Problem{Detail: result.Error.Error()}, http.StatusBadRequest) + return + } + + // must come *after* w.Header().Add()/Set(), but before w.Write() + w.WriteHeader(http.StatusCreated) + + json.NewEncoder(w).Encode(result.Id) +} + +// AddContent() +// lcp spec : store data resulting from an external encryption +// PUT method with PAYLOAD : LcpPublication in json format +// content_id is also present in also url. +// if contentId is different , url key overrides the contentId in the json payload +// this method adds ths in the store (of encrypted files) +// and the needed key in the database in order to create the licenses +func AddContent(w http.ResponseWriter, r *http.Request, s Server) { + vars := mux.Vars(r) + decoder := json.NewDecoder(r.Body) + + var publication LcpPublication + err := decoder.Decode(&publication) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + } + contentId := vars["key"] + if contentId == "" { + problem.Error(w, r, problem.Problem{Detail: "Content ID must be set in url"}, http.StatusBadRequest) + return + } + //read encrypted file from reference + file, err := os.Open(publication.Output) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + return + } + defer file.Close() + //and add file to storage + //var storageItem storage.Item + _, err = s.Store().Add(contentId, file) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + return + } + var c index.Content + // insert row in database if key does not exist + c, err = s.Index().Get(contentId) + c.EncryptionKey = publication.ContentKey + if publication.ContentDisposition != nil { + c.Location = *publication.ContentDisposition + } else { + c.Location = "" + } + + if publication.Size != nil { + c.Length = *publication.Size + } else { + c.Length = -1 + } + + if publication.Checksum != nil { + c.Sha256 = *publication.Checksum + } else { + c.Sha256 = "" + } + //todo? check hash & length + code := http.StatusCreated + if err == index.NotFound { //insert into database + c.Id = contentId + err = s.Index().Add(c) + } else { //update encryption key for c.Id = publication.ContentId + err = s.Index().Update(c) + code = http.StatusOK + } + if err != nil { //db not updated + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + return + } + + // must come *after* w.Header().Add()/Set(), but before w.Write() + w.WriteHeader(code) + + return + //json.NewEncoder(w).Encode(publication.ContentId) + +} + +func ListContents(w http.ResponseWriter, r *http.Request, s Server) { + fn := s.Index().List() + contents := make([]index.Content, 0) + + for it, err := fn(); err == nil; it, err = fn() { + contents = append(contents, it) + } + + w.Header().Set("Content-Type", api.ContentType_JSON) + enc := json.NewEncoder(w) + err := enc.Encode(contents) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + return + } + +} + +func GetContent(w http.ResponseWriter, r *http.Request, s Server) { + vars := mux.Vars(r) + contentId := vars["key"] + content, err := s.Index().Get(contentId) + if err != nil { //item probably not found + if err == index.NotFound { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusNotFound) + } else { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + } + return + } + item, err := s.Store().Get(contentId) + if err != nil { //item probably not found + if err == storage.NotFound { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusNotFound) + } else { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + } + return + } + contentReadCloser, err := item.Contents() + defer contentReadCloser.Close() + if err != nil { //file probably not found + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + return + } + + //Send the headers + w.Header().Set("Content-Disposition", "attachment; filename="+content.Location) + w.Header().Set("Content-Type", epub.ContentType_EPUB) + w.Header().Set("Content-Length", fmt.Sprintf("%d", content.Length)) + + io.Copy(w, contentReadCloser) + + return + +} diff --git a/lcpserver/launch.json b/lcpserver/launch.json new file mode 100644 index 00000000..5425f0cd --- /dev/null +++ b/lcpserver/launch.json @@ -0,0 +1,31 @@ +{ + "version": "0.2.0", + "configurations": [ + + { + "name": "Launch", + "type": "go", + "request": "launch", + "mode": "debug", + "remotePath": "", + "port": 2345, + "host": "127.0.0.1", + "program": "${workspaceRoot}", + "env": {}, + "args": [], + "showLog": true + }, + { + "name": "Remote", + "type": "go", + "request": "launch", + "mode": "remote", + "remotePath": "${workspaceRoot}", + "port": 2345, + "host": "127.0.0.1", + "program": "${workspaceRoot}", + "env": {}, + "args": [] + } + ] +} \ No newline at end of file diff --git a/lcpserver/lcpserver.go b/lcpserver/lcpserver.go new file mode 100644 index 00000000..2ad69776 --- /dev/null +++ b/lcpserver/lcpserver.go @@ -0,0 +1,225 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package main + +import ( + "crypto/tls" + "database/sql" + "fmt" + "log" + "os" + "os/signal" + "path/filepath" + "runtime" + "strconv" + "strings" + "syscall" + + "github.com/abbot/go-http-auth" + _ "github.com/go-sql-driver/mysql" + _ "github.com/lib/pq" + _ "github.com/mattn/go-sqlite3" + + "github.com/readium/readium-lcp-server/config" + "github.com/readium/readium-lcp-server/index" + "github.com/readium/readium-lcp-server/lcpserver/server" + "github.com/readium/readium-lcp-server/license" + "github.com/readium/readium-lcp-server/pack" + "github.com/readium/readium-lcp-server/storage" +) + +func dbFromURI(uri string) (string, string) { + parts := strings.Split(uri, "://") + return parts[0], parts[1] +} + +func main() { + var config_file, dbURI, storagePath, certFile, privKeyFile, static string + var readonly bool = false + var err error + + if config_file = os.Getenv("READIUM_LICENSE_CONFIG"); config_file == "" { + config_file = "config.yaml" + } + config.ReadConfig(config_file) + log.Println("Reading config " + config_file) + + readonly = config.Config.LcpServer.ReadOnly + + err = config.SetPublicUrls() + if err != nil { + panic(err) + } + static = config.Config.LcpServer.Directory + if static == "" { + _, file, _, _ := runtime.Caller(0) + here := filepath.Dir(file) + static = filepath.Join(here, "../lcpserver/manage") + } + filepathConfigJs := filepath.Join(static, "/config.js") + fileConfigJs, err := os.Create(filepathConfigJs) + if err != nil { + panic(err) + } + + defer func() { + if err := fileConfigJs.Close(); err != nil { + panic(err) + } + }() + static = config.Config.LcpServer.Directory + if static == "" { + _, file, _, _ := runtime.Caller(0) + here := filepath.Dir(file) + static = filepath.Join(here, "../lcpserver/manage") + } + configJs := "// This file is automatically generated, and git-ignored.\n// To ignore your local changes, use:\n// git update-index --assume-unchanged lcpserver/manage/config.js\n\nvar Config = {\n lcp: {url: '" + config.Config.LcpServer.PublicBaseUrl + "', user:'" + config.Config.LcpUpdateAuth.Username + "', password: '" + config.Config.LcpUpdateAuth.Password + "'},\n lsd: {url: '" + config.Config.LsdServer.PublicBaseUrl + "', user:'" + config.Config.LcpUpdateAuth.Username + "', password: '" + config.Config.LcpUpdateAuth.Password + "'}\n}\n" + + log.Println("manage/index.html config.js:") + log.Println(configJs) + + fileConfigJs.WriteString(configJs) + + if dbURI = config.Config.LcpServer.Database; dbURI == "" { + dbURI = "sqlite3://file:test.sqlite?cache=shared&mode=rwc" + } + if storagePath = config.Config.Storage.FileSystem.Directory; storagePath == "" { + storagePath = "files" + } + if certFile = config.Config.Certificate.Cert; certFile == "" { + panic("Must specify a certificate") + } + if privKeyFile = config.Config.Certificate.PrivateKey; privKeyFile == "" { + panic("Must specify a private key") + } + cert, err := tls.LoadX509KeyPair(certFile, privKeyFile) + if err != nil { + panic(err) + } + + driver, cnxn := dbFromURI(dbURI) + db, err := sql.Open(driver, cnxn) + if err != nil { + panic(err) + } + if driver == "sqlite3" { + _, err = db.Exec("PRAGMA journal_mode = WAL") + if err != nil { + panic(err) + } + } + idx, err := index.Open(db) + if err != nil { + panic(err) + } + + lst, err := license.NewSqlStore(db) + + if err != nil { + panic(err) + } + + license.CreateLinks() + var store storage.Store + + if mode := config.Config.Storage.Mode; mode == "s3" { + s3Conf := s3ConfigFromYAML() + store, _ = storage.S3(s3Conf) + } else { + os.MkdirAll(storagePath, os.ModePerm) //ignore the error, the folder can already exist + store = storage.NewFileSystem(storagePath, config.Config.LcpServer.PublicBaseUrl+"/files") + } + + packager := pack.NewPackager(store, idx, 4) + + authFile := config.Config.LcpServer.AuthFile + if authFile == "" { + panic("Must have passwords file") + } + _, err = os.Stat(authFile) + if err != nil { + panic(err) + } + htpasswd := auth.HtpasswdFileProvider(authFile) + authenticator := auth.NewBasicAuthenticator("Readium License Content Protection Server", htpasswd) + + HandleSignals() + parsedPort := strconv.Itoa(config.Config.LcpServer.Port) + s := lcpserver.New(":"+parsedPort, static, readonly, &idx, &store, &lst, &cert, packager, authenticator) + if readonly { + log.Println("License server running in readonly mode on port " + parsedPort) + } else { + log.Println("License server running on port " + parsedPort) + } + log.Println("using database " + dbURI) + log.Println("Public base URL=" + config.Config.LcpServer.PublicBaseUrl) + log.Println("License links:") + for nameOfLink, link := range config.Config.License.Links { + log.Println(" " + nameOfLink + " => " + link) + } + + if err := s.ListenAndServe(); err != nil { + log.Println("Error " + err.Error()) + } + +} + +func HandleSignals() { + sigChan := make(chan os.Signal) + go func() { + stacktrace := make([]byte, 1<<20) + for sig := range sigChan { + switch sig { + case syscall.SIGQUIT: + length := runtime.Stack(stacktrace, true) + fmt.Println(string(stacktrace[:length])) + case syscall.SIGINT: + fallthrough + case syscall.SIGTERM: + fmt.Println("Shutting down...") + os.Exit(0) + } + } + }() + signal.Notify(sigChan, syscall.SIGQUIT, syscall.SIGINT, syscall.SIGTERM) +} + +func s3ConfigFromYAML() storage.S3Config { + s3config := storage.S3Config{} + + s3config.Id = config.Config.Storage.AccessId + s3config.Secret = config.Config.Storage.Secret + s3config.Token = config.Config.Storage.Token + + s3config.Endpoint = config.Config.Storage.Endpoint + s3config.Bucket = config.Config.Storage.Bucket + s3config.Region = config.Config.Storage.Region + + s3config.DisableSSL = config.Config.Storage.DisableSSL + s3config.ForcePathStyle = config.Config.Storage.PathStyle + + return s3config +} diff --git a/lcpserver/manage/favicon.ico b/lcpserver/manage/favicon.ico new file mode 100644 index 00000000..c834afe0 Binary files /dev/null and b/lcpserver/manage/favicon.ico differ diff --git a/lcpserver/manage/index.html b/lcpserver/manage/index.html new file mode 100644 index 00000000..8b9d7f54 --- /dev/null +++ b/lcpserver/manage/index.html @@ -0,0 +1,245 @@ + + + + + + + + + + + + + + +

LCPServer test Admin

+
 
+

Packages

+
    + + +

    Emit a License

    +
    + +
    +
    +
    +
    +License fragment to send in HTTP request:
    +
    + +
    +
    +
    + + diff --git a/lcpserver/manage/jquery-3.1.1.slim.min.js b/lcpserver/manage/jquery-3.1.1.slim.min.js new file mode 100644 index 00000000..a240ca9b --- /dev/null +++ b/lcpserver/manage/jquery-3.1.1.slim.min.js @@ -0,0 +1,4 @@ +/*! jQuery v3.1.1 -ajax,-ajax/jsonp,-ajax/load,-ajax/parseXML,-ajax/script,-ajax/var/location,-ajax/var/nonce,-ajax/var/rquery,-ajax/xhr,-manipulation/_evalUrl,-event/ajax,-effects,-effects/animatedSelector,-effects/Tween,-deprecated | (c) jQuery Foundation | jquery.org/license */ +!function(a,b){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=a.document?b(a,!0):function(a){if(!a.document)throw new Error("jQuery requires a window with a document");return b(a)}:b(a)}("undefined"!=typeof window?window:this,function(a,b){"use strict";var c=[],d=a.document,e=Object.getPrototypeOf,f=c.slice,g=c.concat,h=c.push,i=c.indexOf,j={},k=j.toString,l=j.hasOwnProperty,m=l.toString,n=m.call(Object),o={};function p(a,b){b=b||d;var c=b.createElement("script");c.text=a,b.head.appendChild(c).parentNode.removeChild(c)}var q="3.1.1 -ajax,-ajax/jsonp,-ajax/load,-ajax/parseXML,-ajax/script,-ajax/var/location,-ajax/var/nonce,-ajax/var/rquery,-ajax/xhr,-manipulation/_evalUrl,-event/ajax,-effects,-effects/animatedSelector,-effects/Tween,-deprecated",r=function(a,b){return new r.fn.init(a,b)},s=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,t=/^-ms-/,u=/-([a-z])/g,v=function(a,b){return b.toUpperCase()};r.fn=r.prototype={jquery:q,constructor:r,length:0,toArray:function(){return f.call(this)},get:function(a){return null==a?f.call(this):a<0?this[a+this.length]:this[a]},pushStack:function(a){var b=r.merge(this.constructor(),a);return b.prevObject=this,b},each:function(a){return r.each(this,a)},map:function(a){return this.pushStack(r.map(this,function(b,c){return a.call(b,c,b)}))},slice:function(){return this.pushStack(f.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(a){var b=this.length,c=+a+(a<0?b:0);return this.pushStack(c>=0&&c0&&b-1 in a)}var x=function(a){var b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u="sizzle"+1*new Date,v=a.document,w=0,x=0,y=ha(),z=ha(),A=ha(),B=function(a,b){return a===b&&(l=!0),0},C={}.hasOwnProperty,D=[],E=D.pop,F=D.push,G=D.push,H=D.slice,I=function(a,b){for(var c=0,d=a.length;c+~]|"+K+")"+K+"*"),S=new RegExp("="+K+"*([^\\]'\"]*?)"+K+"*\\]","g"),T=new RegExp(N),U=new RegExp("^"+L+"$"),V={ID:new RegExp("^#("+L+")"),CLASS:new RegExp("^\\.("+L+")"),TAG:new RegExp("^("+L+"|[*])"),ATTR:new RegExp("^"+M),PSEUDO:new RegExp("^"+N),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+K+"*(even|odd|(([+-]|)(\\d*)n|)"+K+"*(?:([+-]|)"+K+"*(\\d+)|))"+K+"*\\)|)","i"),bool:new RegExp("^(?:"+J+")$","i"),needsContext:new RegExp("^"+K+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+K+"*((?:-\\d)?\\d*)"+K+"*\\)|)(?=[^-]|$)","i")},W=/^(?:input|select|textarea|button)$/i,X=/^h\d$/i,Y=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,$=/[+~]/,_=new RegExp("\\\\([\\da-f]{1,6}"+K+"?|("+K+")|.)","ig"),aa=function(a,b,c){var d="0x"+b-65536;return d!==d||c?b:d<0?String.fromCharCode(d+65536):String.fromCharCode(d>>10|55296,1023&d|56320)},ba=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ca=function(a,b){return b?"\0"===a?"\ufffd":a.slice(0,-1)+"\\"+a.charCodeAt(a.length-1).toString(16)+" ":"\\"+a},da=function(){m()},ea=ta(function(a){return a.disabled===!0&&("form"in a||"label"in a)},{dir:"parentNode",next:"legend"});try{G.apply(D=H.call(v.childNodes),v.childNodes),D[v.childNodes.length].nodeType}catch(fa){G={apply:D.length?function(a,b){F.apply(a,H.call(b))}:function(a,b){var c=a.length,d=0;while(a[c++]=b[d++]);a.length=c-1}}}function ga(a,b,d,e){var f,h,j,k,l,o,r,s=b&&b.ownerDocument,w=b?b.nodeType:9;if(d=d||[],"string"!=typeof a||!a||1!==w&&9!==w&&11!==w)return d;if(!e&&((b?b.ownerDocument||b:v)!==n&&m(b),b=b||n,p)){if(11!==w&&(l=Z.exec(a)))if(f=l[1]){if(9===w){if(!(j=b.getElementById(f)))return d;if(j.id===f)return d.push(j),d}else if(s&&(j=s.getElementById(f))&&t(b,j)&&j.id===f)return d.push(j),d}else{if(l[2])return G.apply(d,b.getElementsByTagName(a)),d;if((f=l[3])&&c.getElementsByClassName&&b.getElementsByClassName)return G.apply(d,b.getElementsByClassName(f)),d}if(c.qsa&&!A[a+" "]&&(!q||!q.test(a))){if(1!==w)s=b,r=a;else if("object"!==b.nodeName.toLowerCase()){(k=b.getAttribute("id"))?k=k.replace(ba,ca):b.setAttribute("id",k=u),o=g(a),h=o.length;while(h--)o[h]="#"+k+" "+sa(o[h]);r=o.join(","),s=$.test(a)&&qa(b.parentNode)||b}if(r)try{return G.apply(d,s.querySelectorAll(r)),d}catch(x){}finally{k===u&&b.removeAttribute("id")}}}return i(a.replace(P,"$1"),b,d,e)}function ha(){var a=[];function b(c,e){return a.push(c+" ")>d.cacheLength&&delete b[a.shift()],b[c+" "]=e}return b}function ia(a){return a[u]=!0,a}function ja(a){var b=n.createElement("fieldset");try{return!!a(b)}catch(c){return!1}finally{b.parentNode&&b.parentNode.removeChild(b),b=null}}function ka(a,b){var c=a.split("|"),e=c.length;while(e--)d.attrHandle[c[e]]=b}function la(a,b){var c=b&&a,d=c&&1===a.nodeType&&1===b.nodeType&&a.sourceIndex-b.sourceIndex;if(d)return d;if(c)while(c=c.nextSibling)if(c===b)return-1;return a?1:-1}function ma(a){return function(b){var c=b.nodeName.toLowerCase();return"input"===c&&b.type===a}}function na(a){return function(b){var c=b.nodeName.toLowerCase();return("input"===c||"button"===c)&&b.type===a}}function oa(a){return function(b){return"form"in b?b.parentNode&&b.disabled===!1?"label"in b?"label"in b.parentNode?b.parentNode.disabled===a:b.disabled===a:b.isDisabled===a||b.isDisabled!==!a&&ea(b)===a:b.disabled===a:"label"in b&&b.disabled===a}}function pa(a){return ia(function(b){return b=+b,ia(function(c,d){var e,f=a([],c.length,b),g=f.length;while(g--)c[e=f[g]]&&(c[e]=!(d[e]=c[e]))})})}function qa(a){return a&&"undefined"!=typeof a.getElementsByTagName&&a}c=ga.support={},f=ga.isXML=function(a){var b=a&&(a.ownerDocument||a).documentElement;return!!b&&"HTML"!==b.nodeName},m=ga.setDocument=function(a){var b,e,g=a?a.ownerDocument||a:v;return g!==n&&9===g.nodeType&&g.documentElement?(n=g,o=n.documentElement,p=!f(n),v!==n&&(e=n.defaultView)&&e.top!==e&&(e.addEventListener?e.addEventListener("unload",da,!1):e.attachEvent&&e.attachEvent("onunload",da)),c.attributes=ja(function(a){return a.className="i",!a.getAttribute("className")}),c.getElementsByTagName=ja(function(a){return a.appendChild(n.createComment("")),!a.getElementsByTagName("*").length}),c.getElementsByClassName=Y.test(n.getElementsByClassName),c.getById=ja(function(a){return o.appendChild(a).id=u,!n.getElementsByName||!n.getElementsByName(u).length}),c.getById?(d.filter.ID=function(a){var b=a.replace(_,aa);return function(a){return a.getAttribute("id")===b}},d.find.ID=function(a,b){if("undefined"!=typeof b.getElementById&&p){var c=b.getElementById(a);return c?[c]:[]}}):(d.filter.ID=function(a){var b=a.replace(_,aa);return function(a){var c="undefined"!=typeof a.getAttributeNode&&a.getAttributeNode("id");return c&&c.value===b}},d.find.ID=function(a,b){if("undefined"!=typeof b.getElementById&&p){var c,d,e,f=b.getElementById(a);if(f){if(c=f.getAttributeNode("id"),c&&c.value===a)return[f];e=b.getElementsByName(a),d=0;while(f=e[d++])if(c=f.getAttributeNode("id"),c&&c.value===a)return[f]}return[]}}),d.find.TAG=c.getElementsByTagName?function(a,b){return"undefined"!=typeof b.getElementsByTagName?b.getElementsByTagName(a):c.qsa?b.querySelectorAll(a):void 0}:function(a,b){var c,d=[],e=0,f=b.getElementsByTagName(a);if("*"===a){while(c=f[e++])1===c.nodeType&&d.push(c);return d}return f},d.find.CLASS=c.getElementsByClassName&&function(a,b){if("undefined"!=typeof b.getElementsByClassName&&p)return b.getElementsByClassName(a)},r=[],q=[],(c.qsa=Y.test(n.querySelectorAll))&&(ja(function(a){o.appendChild(a).innerHTML="",a.querySelectorAll("[msallowcapture^='']").length&&q.push("[*^$]="+K+"*(?:''|\"\")"),a.querySelectorAll("[selected]").length||q.push("\\["+K+"*(?:value|"+J+")"),a.querySelectorAll("[id~="+u+"-]").length||q.push("~="),a.querySelectorAll(":checked").length||q.push(":checked"),a.querySelectorAll("a#"+u+"+*").length||q.push(".#.+[+~]")}),ja(function(a){a.innerHTML="";var b=n.createElement("input");b.setAttribute("type","hidden"),a.appendChild(b).setAttribute("name","D"),a.querySelectorAll("[name=d]").length&&q.push("name"+K+"*[*^$|!~]?="),2!==a.querySelectorAll(":enabled").length&&q.push(":enabled",":disabled"),o.appendChild(a).disabled=!0,2!==a.querySelectorAll(":disabled").length&&q.push(":enabled",":disabled"),a.querySelectorAll("*,:x"),q.push(",.*:")})),(c.matchesSelector=Y.test(s=o.matches||o.webkitMatchesSelector||o.mozMatchesSelector||o.oMatchesSelector||o.msMatchesSelector))&&ja(function(a){c.disconnectedMatch=s.call(a,"*"),s.call(a,"[s!='']:x"),r.push("!=",N)}),q=q.length&&new RegExp(q.join("|")),r=r.length&&new RegExp(r.join("|")),b=Y.test(o.compareDocumentPosition),t=b||Y.test(o.contains)?function(a,b){var c=9===a.nodeType?a.documentElement:a,d=b&&b.parentNode;return a===d||!(!d||1!==d.nodeType||!(c.contains?c.contains(d):a.compareDocumentPosition&&16&a.compareDocumentPosition(d)))}:function(a,b){if(b)while(b=b.parentNode)if(b===a)return!0;return!1},B=b?function(a,b){if(a===b)return l=!0,0;var d=!a.compareDocumentPosition-!b.compareDocumentPosition;return d?d:(d=(a.ownerDocument||a)===(b.ownerDocument||b)?a.compareDocumentPosition(b):1,1&d||!c.sortDetached&&b.compareDocumentPosition(a)===d?a===n||a.ownerDocument===v&&t(v,a)?-1:b===n||b.ownerDocument===v&&t(v,b)?1:k?I(k,a)-I(k,b):0:4&d?-1:1)}:function(a,b){if(a===b)return l=!0,0;var c,d=0,e=a.parentNode,f=b.parentNode,g=[a],h=[b];if(!e||!f)return a===n?-1:b===n?1:e?-1:f?1:k?I(k,a)-I(k,b):0;if(e===f)return la(a,b);c=a;while(c=c.parentNode)g.unshift(c);c=b;while(c=c.parentNode)h.unshift(c);while(g[d]===h[d])d++;return d?la(g[d],h[d]):g[d]===v?-1:h[d]===v?1:0},n):n},ga.matches=function(a,b){return ga(a,null,null,b)},ga.matchesSelector=function(a,b){if((a.ownerDocument||a)!==n&&m(a),b=b.replace(S,"='$1']"),c.matchesSelector&&p&&!A[b+" "]&&(!r||!r.test(b))&&(!q||!q.test(b)))try{var d=s.call(a,b);if(d||c.disconnectedMatch||a.document&&11!==a.document.nodeType)return d}catch(e){}return ga(b,n,null,[a]).length>0},ga.contains=function(a,b){return(a.ownerDocument||a)!==n&&m(a),t(a,b)},ga.attr=function(a,b){(a.ownerDocument||a)!==n&&m(a);var e=d.attrHandle[b.toLowerCase()],f=e&&C.call(d.attrHandle,b.toLowerCase())?e(a,b,!p):void 0;return void 0!==f?f:c.attributes||!p?a.getAttribute(b):(f=a.getAttributeNode(b))&&f.specified?f.value:null},ga.escape=function(a){return(a+"").replace(ba,ca)},ga.error=function(a){throw new Error("Syntax error, unrecognized expression: "+a)},ga.uniqueSort=function(a){var b,d=[],e=0,f=0;if(l=!c.detectDuplicates,k=!c.sortStable&&a.slice(0),a.sort(B),l){while(b=a[f++])b===a[f]&&(e=d.push(f));while(e--)a.splice(d[e],1)}return k=null,a},e=ga.getText=function(a){var b,c="",d=0,f=a.nodeType;if(f){if(1===f||9===f||11===f){if("string"==typeof a.textContent)return a.textContent;for(a=a.firstChild;a;a=a.nextSibling)c+=e(a)}else if(3===f||4===f)return a.nodeValue}else while(b=a[d++])c+=e(b);return c},d=ga.selectors={cacheLength:50,createPseudo:ia,match:V,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(a){return a[1]=a[1].replace(_,aa),a[3]=(a[3]||a[4]||a[5]||"").replace(_,aa),"~="===a[2]&&(a[3]=" "+a[3]+" "),a.slice(0,4)},CHILD:function(a){return a[1]=a[1].toLowerCase(),"nth"===a[1].slice(0,3)?(a[3]||ga.error(a[0]),a[4]=+(a[4]?a[5]+(a[6]||1):2*("even"===a[3]||"odd"===a[3])),a[5]=+(a[7]+a[8]||"odd"===a[3])):a[3]&&ga.error(a[0]),a},PSEUDO:function(a){var b,c=!a[6]&&a[2];return V.CHILD.test(a[0])?null:(a[3]?a[2]=a[4]||a[5]||"":c&&T.test(c)&&(b=g(c,!0))&&(b=c.indexOf(")",c.length-b)-c.length)&&(a[0]=a[0].slice(0,b),a[2]=c.slice(0,b)),a.slice(0,3))}},filter:{TAG:function(a){var b=a.replace(_,aa).toLowerCase();return"*"===a?function(){return!0}:function(a){return a.nodeName&&a.nodeName.toLowerCase()===b}},CLASS:function(a){var b=y[a+" "];return b||(b=new RegExp("(^|"+K+")"+a+"("+K+"|$)"))&&y(a,function(a){return b.test("string"==typeof a.className&&a.className||"undefined"!=typeof a.getAttribute&&a.getAttribute("class")||"")})},ATTR:function(a,b,c){return function(d){var e=ga.attr(d,a);return null==e?"!="===b:!b||(e+="","="===b?e===c:"!="===b?e!==c:"^="===b?c&&0===e.indexOf(c):"*="===b?c&&e.indexOf(c)>-1:"$="===b?c&&e.slice(-c.length)===c:"~="===b?(" "+e.replace(O," ")+" ").indexOf(c)>-1:"|="===b&&(e===c||e.slice(0,c.length+1)===c+"-"))}},CHILD:function(a,b,c,d,e){var f="nth"!==a.slice(0,3),g="last"!==a.slice(-4),h="of-type"===b;return 1===d&&0===e?function(a){return!!a.parentNode}:function(b,c,i){var j,k,l,m,n,o,p=f!==g?"nextSibling":"previousSibling",q=b.parentNode,r=h&&b.nodeName.toLowerCase(),s=!i&&!h,t=!1;if(q){if(f){while(p){m=b;while(m=m[p])if(h?m.nodeName.toLowerCase()===r:1===m.nodeType)return!1;o=p="only"===a&&!o&&"nextSibling"}return!0}if(o=[g?q.firstChild:q.lastChild],g&&s){m=q,l=m[u]||(m[u]={}),k=l[m.uniqueID]||(l[m.uniqueID]={}),j=k[a]||[],n=j[0]===w&&j[1],t=n&&j[2],m=n&&q.childNodes[n];while(m=++n&&m&&m[p]||(t=n=0)||o.pop())if(1===m.nodeType&&++t&&m===b){k[a]=[w,n,t];break}}else if(s&&(m=b,l=m[u]||(m[u]={}),k=l[m.uniqueID]||(l[m.uniqueID]={}),j=k[a]||[],n=j[0]===w&&j[1],t=n),t===!1)while(m=++n&&m&&m[p]||(t=n=0)||o.pop())if((h?m.nodeName.toLowerCase()===r:1===m.nodeType)&&++t&&(s&&(l=m[u]||(m[u]={}),k=l[m.uniqueID]||(l[m.uniqueID]={}),k[a]=[w,t]),m===b))break;return t-=e,t===d||t%d===0&&t/d>=0}}},PSEUDO:function(a,b){var c,e=d.pseudos[a]||d.setFilters[a.toLowerCase()]||ga.error("unsupported pseudo: "+a);return e[u]?e(b):e.length>1?(c=[a,a,"",b],d.setFilters.hasOwnProperty(a.toLowerCase())?ia(function(a,c){var d,f=e(a,b),g=f.length;while(g--)d=I(a,f[g]),a[d]=!(c[d]=f[g])}):function(a){return e(a,0,c)}):e}},pseudos:{not:ia(function(a){var b=[],c=[],d=h(a.replace(P,"$1"));return d[u]?ia(function(a,b,c,e){var f,g=d(a,null,e,[]),h=a.length;while(h--)(f=g[h])&&(a[h]=!(b[h]=f))}):function(a,e,f){return b[0]=a,d(b,null,f,c),b[0]=null,!c.pop()}}),has:ia(function(a){return function(b){return ga(a,b).length>0}}),contains:ia(function(a){return a=a.replace(_,aa),function(b){return(b.textContent||b.innerText||e(b)).indexOf(a)>-1}}),lang:ia(function(a){return U.test(a||"")||ga.error("unsupported lang: "+a),a=a.replace(_,aa).toLowerCase(),function(b){var c;do if(c=p?b.lang:b.getAttribute("xml:lang")||b.getAttribute("lang"))return c=c.toLowerCase(),c===a||0===c.indexOf(a+"-");while((b=b.parentNode)&&1===b.nodeType);return!1}}),target:function(b){var c=a.location&&a.location.hash;return c&&c.slice(1)===b.id},root:function(a){return a===o},focus:function(a){return a===n.activeElement&&(!n.hasFocus||n.hasFocus())&&!!(a.type||a.href||~a.tabIndex)},enabled:oa(!1),disabled:oa(!0),checked:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&!!a.checked||"option"===b&&!!a.selected},selected:function(a){return a.parentNode&&a.parentNode.selectedIndex,a.selected===!0},empty:function(a){for(a=a.firstChild;a;a=a.nextSibling)if(a.nodeType<6)return!1;return!0},parent:function(a){return!d.pseudos.empty(a)},header:function(a){return X.test(a.nodeName)},input:function(a){return W.test(a.nodeName)},button:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&"button"===a.type||"button"===b},text:function(a){var b;return"input"===a.nodeName.toLowerCase()&&"text"===a.type&&(null==(b=a.getAttribute("type"))||"text"===b.toLowerCase())},first:pa(function(){return[0]}),last:pa(function(a,b){return[b-1]}),eq:pa(function(a,b,c){return[c<0?c+b:c]}),even:pa(function(a,b){for(var c=0;c=0;)a.push(d);return a}),gt:pa(function(a,b,c){for(var d=c<0?c+b:c;++d1?function(b,c,d){var e=a.length;while(e--)if(!a[e](b,c,d))return!1;return!0}:a[0]}function va(a,b,c){for(var d=0,e=b.length;d-1&&(f[j]=!(g[j]=l))}}else r=wa(r===g?r.splice(o,r.length):r),e?e(null,g,r,i):G.apply(g,r)})}function ya(a){for(var b,c,e,f=a.length,g=d.relative[a[0].type],h=g||d.relative[" "],i=g?1:0,k=ta(function(a){return a===b},h,!0),l=ta(function(a){return I(b,a)>-1},h,!0),m=[function(a,c,d){var e=!g&&(d||c!==j)||((b=c).nodeType?k(a,c,d):l(a,c,d));return b=null,e}];i1&&ua(m),i>1&&sa(a.slice(0,i-1).concat({value:" "===a[i-2].type?"*":""})).replace(P,"$1"),c,i0,e=a.length>0,f=function(f,g,h,i,k){var l,o,q,r=0,s="0",t=f&&[],u=[],v=j,x=f||e&&d.find.TAG("*",k),y=w+=null==v?1:Math.random()||.1,z=x.length;for(k&&(j=g===n||g||k);s!==z&&null!=(l=x[s]);s++){if(e&&l){o=0,g||l.ownerDocument===n||(m(l),h=!p);while(q=a[o++])if(q(l,g||n,h)){i.push(l);break}k&&(w=y)}c&&((l=!q&&l)&&r--,f&&t.push(l))}if(r+=s,c&&s!==r){o=0;while(q=b[o++])q(t,u,g,h);if(f){if(r>0)while(s--)t[s]||u[s]||(u[s]=E.call(i));u=wa(u)}G.apply(i,u),k&&!f&&u.length>0&&r+b.length>1&&ga.uniqueSort(i)}return k&&(w=y,j=v),t};return c?ia(f):f}return h=ga.compile=function(a,b){var c,d=[],e=[],f=A[a+" "];if(!f){b||(b=g(a)),c=b.length;while(c--)f=ya(b[c]),f[u]?d.push(f):e.push(f);f=A(a,za(e,d)),f.selector=a}return f},i=ga.select=function(a,b,c,e){var f,i,j,k,l,m="function"==typeof a&&a,n=!e&&g(a=m.selector||a);if(c=c||[],1===n.length){if(i=n[0]=n[0].slice(0),i.length>2&&"ID"===(j=i[0]).type&&9===b.nodeType&&p&&d.relative[i[1].type]){if(b=(d.find.ID(j.matches[0].replace(_,aa),b)||[])[0],!b)return c;m&&(b=b.parentNode),a=a.slice(i.shift().value.length)}f=V.needsContext.test(a)?0:i.length;while(f--){if(j=i[f],d.relative[k=j.type])break;if((l=d.find[k])&&(e=l(j.matches[0].replace(_,aa),$.test(i[0].type)&&qa(b.parentNode)||b))){if(i.splice(f,1),a=e.length&&sa(i),!a)return G.apply(c,e),c;break}}}return(m||h(a,n))(e,b,!p,c,!b||$.test(a)&&qa(b.parentNode)||b),c},c.sortStable=u.split("").sort(B).join("")===u,c.detectDuplicates=!!l,m(),c.sortDetached=ja(function(a){return 1&a.compareDocumentPosition(n.createElement("fieldset"))}),ja(function(a){return a.innerHTML="","#"===a.firstChild.getAttribute("href")})||ka("type|href|height|width",function(a,b,c){if(!c)return a.getAttribute(b,"type"===b.toLowerCase()?1:2)}),c.attributes&&ja(function(a){return a.innerHTML="",a.firstChild.setAttribute("value",""),""===a.firstChild.getAttribute("value")})||ka("value",function(a,b,c){if(!c&&"input"===a.nodeName.toLowerCase())return a.defaultValue}),ja(function(a){return null==a.getAttribute("disabled")})||ka(J,function(a,b,c){var d;if(!c)return a[b]===!0?b.toLowerCase():(d=a.getAttributeNode(b))&&d.specified?d.value:null}),ga}(a);r.find=x,r.expr=x.selectors,r.expr[":"]=r.expr.pseudos,r.uniqueSort=r.unique=x.uniqueSort,r.text=x.getText,r.isXMLDoc=x.isXML,r.contains=x.contains,r.escapeSelector=x.escape;var y=function(a,b,c){var d=[],e=void 0!==c;while((a=a[b])&&9!==a.nodeType)if(1===a.nodeType){if(e&&r(a).is(c))break;d.push(a)}return d},z=function(a,b){for(var c=[];a;a=a.nextSibling)1===a.nodeType&&a!==b&&c.push(a);return c},A=r.expr.match.needsContext,B=/^<([a-z][^\/\0>:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i,C=/^.[^:#\[\.,]*$/;function D(a,b,c){return r.isFunction(b)?r.grep(a,function(a,d){return!!b.call(a,d,a)!==c}):b.nodeType?r.grep(a,function(a){return a===b!==c}):"string"!=typeof b?r.grep(a,function(a){return i.call(b,a)>-1!==c}):C.test(b)?r.filter(b,a,c):(b=r.filter(b,a),r.grep(a,function(a){return i.call(b,a)>-1!==c&&1===a.nodeType}))}r.filter=function(a,b,c){var d=b[0];return c&&(a=":not("+a+")"),1===b.length&&1===d.nodeType?r.find.matchesSelector(d,a)?[d]:[]:r.find.matches(a,r.grep(b,function(a){return 1===a.nodeType}))},r.fn.extend({find:function(a){var b,c,d=this.length,e=this;if("string"!=typeof a)return this.pushStack(r(a).filter(function(){for(b=0;b1?r.uniqueSort(c):c},filter:function(a){return this.pushStack(D(this,a||[],!1))},not:function(a){return this.pushStack(D(this,a||[],!0))},is:function(a){return!!D(this,"string"==typeof a&&A.test(a)?r(a):a||[],!1).length}});var E,F=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]+))$/,G=r.fn.init=function(a,b,c){var e,f;if(!a)return this;if(c=c||E,"string"==typeof a){if(e="<"===a[0]&&">"===a[a.length-1]&&a.length>=3?[null,a,null]:F.exec(a),!e||!e[1]&&b)return!b||b.jquery?(b||c).find(a):this.constructor(b).find(a);if(e[1]){if(b=b instanceof r?b[0]:b,r.merge(this,r.parseHTML(e[1],b&&b.nodeType?b.ownerDocument||b:d,!0)),B.test(e[1])&&r.isPlainObject(b))for(e in b)r.isFunction(this[e])?this[e](b[e]):this.attr(e,b[e]);return this}return f=d.getElementById(e[2]),f&&(this[0]=f,this.length=1),this}return a.nodeType?(this[0]=a,this.length=1,this):r.isFunction(a)?void 0!==c.ready?c.ready(a):a(r):r.makeArray(a,this)};G.prototype=r.fn,E=r(d);var H=/^(?:parents|prev(?:Until|All))/,I={children:!0,contents:!0,next:!0,prev:!0};r.fn.extend({has:function(a){var b=r(a,this),c=b.length;return this.filter(function(){for(var a=0;a-1:1===c.nodeType&&r.find.matchesSelector(c,a))){f.push(c);break}return this.pushStack(f.length>1?r.uniqueSort(f):f)},index:function(a){return a?"string"==typeof a?i.call(r(a),this[0]):i.call(this,a.jquery?a[0]:a):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(a,b){return this.pushStack(r.uniqueSort(r.merge(this.get(),r(a,b))))},addBack:function(a){return this.add(null==a?this.prevObject:this.prevObject.filter(a))}});function J(a,b){while((a=a[b])&&1!==a.nodeType);return a}r.each({parent:function(a){var b=a.parentNode;return b&&11!==b.nodeType?b:null},parents:function(a){return y(a,"parentNode")},parentsUntil:function(a,b,c){return y(a,"parentNode",c)},next:function(a){return J(a,"nextSibling")},prev:function(a){return J(a,"previousSibling")},nextAll:function(a){return y(a,"nextSibling")},prevAll:function(a){return y(a,"previousSibling")},nextUntil:function(a,b,c){return y(a,"nextSibling",c)},prevUntil:function(a,b,c){return y(a,"previousSibling",c)},siblings:function(a){return z((a.parentNode||{}).firstChild,a)},children:function(a){return z(a.firstChild)},contents:function(a){return a.contentDocument||r.merge([],a.childNodes)}},function(a,b){r.fn[a]=function(c,d){var e=r.map(this,b,c);return"Until"!==a.slice(-5)&&(d=c),d&&"string"==typeof d&&(e=r.filter(d,e)),this.length>1&&(I[a]||r.uniqueSort(e),H.test(a)&&e.reverse()),this.pushStack(e)}});var K=/[^\x20\t\r\n\f]+/g;function L(a){var b={};return r.each(a.match(K)||[],function(a,c){b[c]=!0}),b}r.Callbacks=function(a){a="string"==typeof a?L(a):r.extend({},a);var b,c,d,e,f=[],g=[],h=-1,i=function(){for(e=a.once,d=b=!0;g.length;h=-1){c=g.shift();while(++h-1)f.splice(c,1),c<=h&&h--}),this},has:function(a){return a?r.inArray(a,f)>-1:f.length>0},empty:function(){return f&&(f=[]),this},disable:function(){return e=g=[],f=c="",this},disabled:function(){return!f},lock:function(){return e=g=[],c||b||(f=c=""),this},locked:function(){return!!e},fireWith:function(a,c){return e||(c=c||[],c=[a,c.slice?c.slice():c],g.push(c),b||i()),this},fire:function(){return j.fireWith(this,arguments),this},fired:function(){return!!d}};return j};function M(a){return a}function N(a){throw a}function O(a,b,c){var d;try{a&&r.isFunction(d=a.promise)?d.call(a).done(b).fail(c):a&&r.isFunction(d=a.then)?d.call(a,b,c):b.call(void 0,a)}catch(a){c.call(void 0,a)}}r.extend({Deferred:function(b){var c=[["notify","progress",r.Callbacks("memory"),r.Callbacks("memory"),2],["resolve","done",r.Callbacks("once memory"),r.Callbacks("once memory"),0,"resolved"],["reject","fail",r.Callbacks("once memory"),r.Callbacks("once memory"),1,"rejected"]],d="pending",e={state:function(){return d},always:function(){return f.done(arguments).fail(arguments),this},"catch":function(a){return e.then(null,a)},pipe:function(){var a=arguments;return r.Deferred(function(b){r.each(c,function(c,d){var e=r.isFunction(a[d[4]])&&a[d[4]];f[d[1]](function(){var a=e&&e.apply(this,arguments);a&&r.isFunction(a.promise)?a.promise().progress(b.notify).done(b.resolve).fail(b.reject):b[d[0]+"With"](this,e?[a]:arguments)})}),a=null}).promise()},then:function(b,d,e){var f=0;function g(b,c,d,e){return function(){var h=this,i=arguments,j=function(){var a,j;if(!(b=f&&(d!==N&&(h=void 0,i=[a]),c.rejectWith(h,i))}};b?k():(r.Deferred.getStackHook&&(k.stackTrace=r.Deferred.getStackHook()),a.setTimeout(k))}}return r.Deferred(function(a){c[0][3].add(g(0,a,r.isFunction(e)?e:M,a.notifyWith)),c[1][3].add(g(0,a,r.isFunction(b)?b:M)),c[2][3].add(g(0,a,r.isFunction(d)?d:N))}).promise()},promise:function(a){return null!=a?r.extend(a,e):e}},f={};return r.each(c,function(a,b){var g=b[2],h=b[5];e[b[1]]=g.add,h&&g.add(function(){d=h},c[3-a][2].disable,c[0][2].lock),g.add(b[3].fire),f[b[0]]=function(){return f[b[0]+"With"](this===f?void 0:this,arguments),this},f[b[0]+"With"]=g.fireWith}),e.promise(f),b&&b.call(f,f),f},when:function(a){var b=arguments.length,c=b,d=Array(c),e=f.call(arguments),g=r.Deferred(),h=function(a){return function(c){d[a]=this,e[a]=arguments.length>1?f.call(arguments):c,--b||g.resolveWith(d,e)}};if(b<=1&&(O(a,g.done(h(c)).resolve,g.reject),"pending"===g.state()||r.isFunction(e[c]&&e[c].then)))return g.then();while(c--)O(e[c],h(c),g.reject);return g.promise()}});var P=/^(Eval|Internal|Range|Reference|Syntax|Type|URI)Error$/;r.Deferred.exceptionHook=function(b,c){a.console&&a.console.warn&&b&&P.test(b.name)&&a.console.warn("jQuery.Deferred exception: "+b.message,b.stack,c)},r.readyException=function(b){a.setTimeout(function(){throw b})};var Q=r.Deferred();r.fn.ready=function(a){return Q.then(a)["catch"](function(a){r.readyException(a)}),this},r.extend({isReady:!1,readyWait:1, +holdReady:function(a){a?r.readyWait++:r.ready(!0)},ready:function(a){(a===!0?--r.readyWait:r.isReady)||(r.isReady=!0,a!==!0&&--r.readyWait>0||Q.resolveWith(d,[r]))}}),r.ready.then=Q.then;function R(){d.removeEventListener("DOMContentLoaded",R),a.removeEventListener("load",R),r.ready()}"complete"===d.readyState||"loading"!==d.readyState&&!d.documentElement.doScroll?a.setTimeout(r.ready):(d.addEventListener("DOMContentLoaded",R),a.addEventListener("load",R));var S=function(a,b,c,d,e,f,g){var h=0,i=a.length,j=null==c;if("object"===r.type(c)){e=!0;for(h in c)S(a,b,h,c[h],!0,f,g)}else if(void 0!==d&&(e=!0,r.isFunction(d)||(g=!0),j&&(g?(b.call(a,d),b=null):(j=b,b=function(a,b,c){return j.call(r(a),c)})),b))for(;h1,null,!0)},removeData:function(a){return this.each(function(){W.remove(this,a)})}}),r.extend({queue:function(a,b,c){var d;if(a)return b=(b||"fx")+"queue",d=V.get(a,b),c&&(!d||r.isArray(c)?d=V.access(a,b,r.makeArray(c)):d.push(c)),d||[]},dequeue:function(a,b){b=b||"fx";var c=r.queue(a,b),d=c.length,e=c.shift(),f=r._queueHooks(a,b),g=function(){r.dequeue(a,b)};"inprogress"===e&&(e=c.shift(),d--),e&&("fx"===b&&c.unshift("inprogress"),delete f.stop,e.call(a,g,f)),!d&&f&&f.empty.fire()},_queueHooks:function(a,b){var c=b+"queueHooks";return V.get(a,c)||V.access(a,c,{empty:r.Callbacks("once memory").add(function(){V.remove(a,[b+"queue",c])})})}}),r.fn.extend({queue:function(a,b){var c=2;return"string"!=typeof a&&(b=a,a="fx",c--),arguments.length\x20\t\r\n\f]+)/i,ka=/^$|\/(?:java|ecma)script/i,la={option:[1,""],thead:[1,"","
    "],col:[2,"","
    "],tr:[2,"","
    "],td:[3,"","
    "],_default:[0,"",""]};la.optgroup=la.option,la.tbody=la.tfoot=la.colgroup=la.caption=la.thead,la.th=la.td;function ma(a,b){var c;return c="undefined"!=typeof a.getElementsByTagName?a.getElementsByTagName(b||"*"):"undefined"!=typeof a.querySelectorAll?a.querySelectorAll(b||"*"):[],void 0===b||b&&r.nodeName(a,b)?r.merge([a],c):c}function na(a,b){for(var c=0,d=a.length;c-1)e&&e.push(f);else if(j=r.contains(f.ownerDocument,f),g=ma(l.appendChild(f),"script"),j&&na(g),c){k=0;while(f=g[k++])ka.test(f.type||"")&&c.push(f)}return l}!function(){var a=d.createDocumentFragment(),b=a.appendChild(d.createElement("div")),c=d.createElement("input");c.setAttribute("type","radio"),c.setAttribute("checked","checked"),c.setAttribute("name","t"),b.appendChild(c),o.checkClone=b.cloneNode(!0).cloneNode(!0).lastChild.checked,b.innerHTML="",o.noCloneChecked=!!b.cloneNode(!0).lastChild.defaultValue}();var qa=d.documentElement,ra=/^key/,sa=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,ta=/^([^.]*)(?:\.(.+)|)/;function ua(){return!0}function va(){return!1}function wa(){try{return d.activeElement}catch(a){}}function xa(a,b,c,d,e,f){var g,h;if("object"==typeof b){"string"!=typeof c&&(d=d||c,c=void 0);for(h in b)xa(a,h,c,d,b[h],f);return a}if(null==d&&null==e?(e=c,d=c=void 0):null==e&&("string"==typeof c?(e=d,d=void 0):(e=d,d=c,c=void 0)),e===!1)e=va;else if(!e)return a;return 1===f&&(g=e,e=function(a){return r().off(a),g.apply(this,arguments)},e.guid=g.guid||(g.guid=r.guid++)),a.each(function(){r.event.add(this,b,e,d,c)})}r.event={global:{},add:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,n,o,p,q=V.get(a);if(q){c.handler&&(f=c,c=f.handler,e=f.selector),e&&r.find.matchesSelector(qa,e),c.guid||(c.guid=r.guid++),(i=q.events)||(i=q.events={}),(g=q.handle)||(g=q.handle=function(b){return"undefined"!=typeof r&&r.event.triggered!==b.type?r.event.dispatch.apply(a,arguments):void 0}),b=(b||"").match(K)||[""],j=b.length;while(j--)h=ta.exec(b[j])||[],n=p=h[1],o=(h[2]||"").split(".").sort(),n&&(l=r.event.special[n]||{},n=(e?l.delegateType:l.bindType)||n,l=r.event.special[n]||{},k=r.extend({type:n,origType:p,data:d,handler:c,guid:c.guid,selector:e,needsContext:e&&r.expr.match.needsContext.test(e),namespace:o.join(".")},f),(m=i[n])||(m=i[n]=[],m.delegateCount=0,l.setup&&l.setup.call(a,d,o,g)!==!1||a.addEventListener&&a.addEventListener(n,g)),l.add&&(l.add.call(a,k),k.handler.guid||(k.handler.guid=c.guid)),e?m.splice(m.delegateCount++,0,k):m.push(k),r.event.global[n]=!0)}},remove:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,n,o,p,q=V.hasData(a)&&V.get(a);if(q&&(i=q.events)){b=(b||"").match(K)||[""],j=b.length;while(j--)if(h=ta.exec(b[j])||[],n=p=h[1],o=(h[2]||"").split(".").sort(),n){l=r.event.special[n]||{},n=(d?l.delegateType:l.bindType)||n,m=i[n]||[],h=h[2]&&new RegExp("(^|\\.)"+o.join("\\.(?:.*\\.|)")+"(\\.|$)"),g=f=m.length;while(f--)k=m[f],!e&&p!==k.origType||c&&c.guid!==k.guid||h&&!h.test(k.namespace)||d&&d!==k.selector&&("**"!==d||!k.selector)||(m.splice(f,1),k.selector&&m.delegateCount--,l.remove&&l.remove.call(a,k));g&&!m.length&&(l.teardown&&l.teardown.call(a,o,q.handle)!==!1||r.removeEvent(a,n,q.handle),delete i[n])}else for(n in i)r.event.remove(a,n+b[j],c,d,!0);r.isEmptyObject(i)&&V.remove(a,"handle events")}},dispatch:function(a){var b=r.event.fix(a),c,d,e,f,g,h,i=new Array(arguments.length),j=(V.get(this,"events")||{})[b.type]||[],k=r.event.special[b.type]||{};for(i[0]=b,c=1;c=1))for(;j!==this;j=j.parentNode||this)if(1===j.nodeType&&("click"!==a.type||j.disabled!==!0)){for(f=[],g={},c=0;c-1:r.find(e,this,null,[j]).length),g[e]&&f.push(d);f.length&&h.push({elem:j,handlers:f})}return j=this,i\x20\t\r\n\f]*)[^>]*)\/>/gi,za=/\s*$/g;function Da(a,b){return r.nodeName(a,"table")&&r.nodeName(11!==b.nodeType?b:b.firstChild,"tr")?a.getElementsByTagName("tbody")[0]||a:a}function Ea(a){return a.type=(null!==a.getAttribute("type"))+"/"+a.type,a}function Fa(a){var b=Ba.exec(a.type);return b?a.type=b[1]:a.removeAttribute("type"),a}function Ga(a,b){var c,d,e,f,g,h,i,j;if(1===b.nodeType){if(V.hasData(a)&&(f=V.access(a),g=V.set(b,f),j=f.events)){delete g.handle,g.events={};for(e in j)for(c=0,d=j[e].length;c1&&"string"==typeof q&&!o.checkClone&&Aa.test(q))return a.each(function(e){var f=a.eq(e);s&&(b[0]=q.call(this,e,f.html())),Ia(f,b,c,d)});if(m&&(e=pa(b,a[0].ownerDocument,!1,a,d),f=e.firstChild,1===e.childNodes.length&&(e=f),f||d)){for(h=r.map(ma(e,"script"),Ea),i=h.length;l")},clone:function(a,b,c){var d,e,f,g,h=a.cloneNode(!0),i=r.contains(a.ownerDocument,a);if(!(o.noCloneChecked||1!==a.nodeType&&11!==a.nodeType||r.isXMLDoc(a)))for(g=ma(h),f=ma(a),d=0,e=f.length;d0&&na(g,!i&&ma(a,"script")),h},cleanData:function(a){for(var b,c,d,e=r.event.special,f=0;void 0!==(c=a[f]);f++)if(T(c)){if(b=c[V.expando]){if(b.events)for(d in b.events)e[d]?r.event.remove(c,d):r.removeEvent(c,d,b.handle);c[V.expando]=void 0}c[W.expando]&&(c[W.expando]=void 0)}}}),r.fn.extend({detach:function(a){return Ja(this,a,!0)},remove:function(a){return Ja(this,a)},text:function(a){return S(this,function(a){return void 0===a?r.text(this):this.empty().each(function(){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||(this.textContent=a)})},null,a,arguments.length)},append:function(){return Ia(this,arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=Da(this,a);b.appendChild(a)}})},prepend:function(){return Ia(this,arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=Da(this,a);b.insertBefore(a,b.firstChild)}})},before:function(){return Ia(this,arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this)})},after:function(){return Ia(this,arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this.nextSibling)})},empty:function(){for(var a,b=0;null!=(a=this[b]);b++)1===a.nodeType&&(r.cleanData(ma(a,!1)),a.textContent="");return this},clone:function(a,b){return a=null!=a&&a,b=null==b?a:b,this.map(function(){return r.clone(this,a,b)})},html:function(a){return S(this,function(a){var b=this[0]||{},c=0,d=this.length;if(void 0===a&&1===b.nodeType)return b.innerHTML;if("string"==typeof a&&!za.test(a)&&!la[(ja.exec(a)||["",""])[1].toLowerCase()]){a=r.htmlPrefilter(a);try{for(;c1)}}),r.fn.delay=function(b,c){return b=r.fx?r.fx.speeds[b]||b:b,c=c||"fx",this.queue(c,function(c,d){var e=a.setTimeout(c,b);d.stop=function(){a.clearTimeout(e)}})},function(){var a=d.createElement("input"),b=d.createElement("select"),c=b.appendChild(d.createElement("option"));a.type="checkbox",o.checkOn=""!==a.value,o.optSelected=c.selected,a=d.createElement("input"),a.value="t",a.type="radio",o.radioValue="t"===a.value}();var Ya,Za=r.expr.attrHandle;r.fn.extend({attr:function(a,b){return S(this,r.attr,a,b,arguments.length>1)},removeAttr:function(a){return this.each(function(){r.removeAttr(this,a)})}}),r.extend({attr:function(a,b,c){var d,e,f=a.nodeType;if(3!==f&&8!==f&&2!==f)return"undefined"==typeof a.getAttribute?r.prop(a,b,c):(1===f&&r.isXMLDoc(a)||(e=r.attrHooks[b.toLowerCase()]||(r.expr.match.bool.test(b)?Ya:void 0)),void 0!==c?null===c?void r.removeAttr(a,b):e&&"set"in e&&void 0!==(d=e.set(a,c,b))?d:(a.setAttribute(b,c+""),c):e&&"get"in e&&null!==(d=e.get(a,b))?d:(d=r.find.attr(a,b),null==d?void 0:d))},attrHooks:{type:{set:function(a,b){if(!o.radioValue&&"radio"===b&&r.nodeName(a,"input")){var c=a.value;return a.setAttribute("type",b),c&&(a.value=c),b}}}},removeAttr:function(a,b){var c,d=0,e=b&&b.match(K);if(e&&1===a.nodeType)while(c=e[d++])a.removeAttribute(c)}}),Ya={set:function(a,b,c){return b===!1?r.removeAttr(a,c):a.setAttribute(c,c),c}},r.each(r.expr.match.bool.source.match(/\w+/g),function(a,b){var c=Za[b]||r.find.attr;Za[b]=function(a,b,d){var e,f,g=b.toLowerCase();return d||(f=Za[g],Za[g]=e,e=null!=c(a,b,d)?g:null,Za[g]=f),e}});var $a=/^(?:input|select|textarea|button)$/i,_a=/^(?:a|area)$/i;r.fn.extend({prop:function(a,b){return S(this,r.prop,a,b,arguments.length>1)},removeProp:function(a){return this.each(function(){delete this[r.propFix[a]||a]})}}),r.extend({prop:function(a,b,c){var d,e,f=a.nodeType;if(3!==f&&8!==f&&2!==f)return 1===f&&r.isXMLDoc(a)||(b=r.propFix[b]||b,e=r.propHooks[b]),void 0!==c?e&&"set"in e&&void 0!==(d=e.set(a,c,b))?d:a[b]=c:e&&"get"in e&&null!==(d=e.get(a,b))?d:a[b]},propHooks:{tabIndex:{get:function(a){var b=r.find.attr(a,"tabindex");return b?parseInt(b,10):$a.test(a.nodeName)||_a.test(a.nodeName)&&a.href?0:-1}}},propFix:{"for":"htmlFor","class":"className"}}),o.optSelected||(r.propHooks.selected={get:function(a){var b=a.parentNode;return b&&b.parentNode&&b.parentNode.selectedIndex,null},set:function(a){var b=a.parentNode;b&&(b.selectedIndex,b.parentNode&&b.parentNode.selectedIndex)}}),r.each(["tabIndex","readOnly","maxLength","cellSpacing","cellPadding","rowSpan","colSpan","useMap","frameBorder","contentEditable"],function(){r.propFix[this.toLowerCase()]=this});function ab(a){var b=a.match(K)||[];return b.join(" ")}function bb(a){return a.getAttribute&&a.getAttribute("class")||""}r.fn.extend({addClass:function(a){var b,c,d,e,f,g,h,i=0;if(r.isFunction(a))return this.each(function(b){r(this).addClass(a.call(this,b,bb(this)))});if("string"==typeof a&&a){b=a.match(K)||[];while(c=this[i++])if(e=bb(c),d=1===c.nodeType&&" "+ab(e)+" "){g=0;while(f=b[g++])d.indexOf(" "+f+" ")<0&&(d+=f+" ");h=ab(d),e!==h&&c.setAttribute("class",h)}}return this},removeClass:function(a){var b,c,d,e,f,g,h,i=0;if(r.isFunction(a))return this.each(function(b){r(this).removeClass(a.call(this,b,bb(this)))});if(!arguments.length)return this.attr("class","");if("string"==typeof a&&a){b=a.match(K)||[];while(c=this[i++])if(e=bb(c),d=1===c.nodeType&&" "+ab(e)+" "){g=0;while(f=b[g++])while(d.indexOf(" "+f+" ")>-1)d=d.replace(" "+f+" "," ");h=ab(d),e!==h&&c.setAttribute("class",h)}}return this},toggleClass:function(a,b){var c=typeof a;return"boolean"==typeof b&&"string"===c?b?this.addClass(a):this.removeClass(a):r.isFunction(a)?this.each(function(c){r(this).toggleClass(a.call(this,c,bb(this),b),b)}):this.each(function(){var b,d,e,f;if("string"===c){d=0,e=r(this),f=a.match(K)||[];while(b=f[d++])e.hasClass(b)?e.removeClass(b):e.addClass(b)}else void 0!==a&&"boolean"!==c||(b=bb(this),b&&V.set(this,"__className__",b),this.setAttribute&&this.setAttribute("class",b||a===!1?"":V.get(this,"__className__")||""))})},hasClass:function(a){var b,c,d=0;b=" "+a+" ";while(c=this[d++])if(1===c.nodeType&&(" "+ab(bb(c))+" ").indexOf(b)>-1)return!0;return!1}});var cb=/\r/g;r.fn.extend({val:function(a){var b,c,d,e=this[0];{if(arguments.length)return d=r.isFunction(a),this.each(function(c){var e;1===this.nodeType&&(e=d?a.call(this,c,r(this).val()):a,null==e?e="":"number"==typeof e?e+="":r.isArray(e)&&(e=r.map(e,function(a){return null==a?"":a+""})),b=r.valHooks[this.type]||r.valHooks[this.nodeName.toLowerCase()],b&&"set"in b&&void 0!==b.set(this,e,"value")||(this.value=e))});if(e)return b=r.valHooks[e.type]||r.valHooks[e.nodeName.toLowerCase()],b&&"get"in b&&void 0!==(c=b.get(e,"value"))?c:(c=e.value,"string"==typeof c?c.replace(cb,""):null==c?"":c)}}}),r.extend({valHooks:{option:{get:function(a){var b=r.find.attr(a,"value");return null!=b?b:ab(r.text(a))}},select:{get:function(a){var b,c,d,e=a.options,f=a.selectedIndex,g="select-one"===a.type,h=g?null:[],i=g?f+1:e.length;for(d=f<0?i:g?f:0;d-1)&&(c=!0);return c||(a.selectedIndex=-1),f}}}}),r.each(["radio","checkbox"],function(){r.valHooks[this]={set:function(a,b){if(r.isArray(b))return a.checked=r.inArray(r(a).val(),b)>-1}},o.checkOn||(r.valHooks[this].get=function(a){return null===a.getAttribute("value")?"on":a.value})});var db=/^(?:focusinfocus|focusoutblur)$/;r.extend(r.event,{trigger:function(b,c,e,f){var g,h,i,j,k,m,n,o=[e||d],p=l.call(b,"type")?b.type:b,q=l.call(b,"namespace")?b.namespace.split("."):[];if(h=i=e=e||d,3!==e.nodeType&&8!==e.nodeType&&!db.test(p+r.event.triggered)&&(p.indexOf(".")>-1&&(q=p.split("."),p=q.shift(),q.sort()),k=p.indexOf(":")<0&&"on"+p,b=b[r.expando]?b:new r.Event(p,"object"==typeof b&&b),b.isTrigger=f?2:3,b.namespace=q.join("."),b.rnamespace=b.namespace?new RegExp("(^|\\.)"+q.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,b.result=void 0,b.target||(b.target=e),c=null==c?[b]:r.makeArray(c,[b]),n=r.event.special[p]||{},f||!n.trigger||n.trigger.apply(e,c)!==!1)){if(!f&&!n.noBubble&&!r.isWindow(e)){for(j=n.delegateType||p,db.test(j+p)||(h=h.parentNode);h;h=h.parentNode)o.push(h),i=h;i===(e.ownerDocument||d)&&o.push(i.defaultView||i.parentWindow||a)}g=0;while((h=o[g++])&&!b.isPropagationStopped())b.type=g>1?j:n.bindType||p,m=(V.get(h,"events")||{})[b.type]&&V.get(h,"handle"),m&&m.apply(h,c),m=k&&h[k],m&&m.apply&&T(h)&&(b.result=m.apply(h,c),b.result===!1&&b.preventDefault());return b.type=p,f||b.isDefaultPrevented()||n._default&&n._default.apply(o.pop(),c)!==!1||!T(e)||k&&r.isFunction(e[p])&&!r.isWindow(e)&&(i=e[k],i&&(e[k]=null),r.event.triggered=p,e[p](),r.event.triggered=void 0,i&&(e[k]=i)),b.result}},simulate:function(a,b,c){var d=r.extend(new r.Event,c,{type:a,isSimulated:!0});r.event.trigger(d,null,b)}}),r.fn.extend({trigger:function(a,b){return this.each(function(){r.event.trigger(a,b,this)})},triggerHandler:function(a,b){var c=this[0];if(c)return r.event.trigger(a,b,c,!0)}}),r.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(a,b){r.fn[b]=function(a,c){return arguments.length>0?this.on(b,null,a,c):this.trigger(b)}}),r.fn.extend({hover:function(a,b){return this.mouseenter(a).mouseleave(b||a)}}),o.focusin="onfocusin"in a,o.focusin||r.each({focus:"focusin",blur:"focusout"},function(a,b){var c=function(a){r.event.simulate(b,a.target,r.event.fix(a))};r.event.special[b]={setup:function(){var d=this.ownerDocument||this,e=V.access(d,b);e||d.addEventListener(a,c,!0),V.access(d,b,(e||0)+1)},teardown:function(){var d=this.ownerDocument||this,e=V.access(d,b)-1;e?V.access(d,b,e):(d.removeEventListener(a,c,!0),V.remove(d,b))}}});var eb=/\[\]$/,fb=/\r?\n/g,gb=/^(?:submit|button|image|reset|file)$/i,hb=/^(?:input|select|textarea|keygen)/i;function ib(a,b,c,d){var e;if(r.isArray(b))r.each(b,function(b,e){c||eb.test(a)?d(a,e):ib(a+"["+("object"==typeof e&&null!=e?b:"")+"]",e,c,d); +});else if(c||"object"!==r.type(b))d(a,b);else for(e in b)ib(a+"["+e+"]",b[e],c,d)}r.param=function(a,b){var c,d=[],e=function(a,b){var c=r.isFunction(b)?b():b;d[d.length]=encodeURIComponent(a)+"="+encodeURIComponent(null==c?"":c)};if(r.isArray(a)||a.jquery&&!r.isPlainObject(a))r.each(a,function(){e(this.name,this.value)});else for(c in a)ib(c,a[c],b,e);return d.join("&")},r.fn.extend({serialize:function(){return r.param(this.serializeArray())},serializeArray:function(){return this.map(function(){var a=r.prop(this,"elements");return a?r.makeArray(a):this}).filter(function(){var a=this.type;return this.name&&!r(this).is(":disabled")&&hb.test(this.nodeName)&&!gb.test(a)&&(this.checked||!ia.test(a))}).map(function(a,b){var c=r(this).val();return null==c?null:r.isArray(c)?r.map(c,function(a){return{name:b.name,value:a.replace(fb,"\r\n")}}):{name:b.name,value:c.replace(fb,"\r\n")}}).get()}}),r.fn.extend({wrapAll:function(a){var b;return this[0]&&(r.isFunction(a)&&(a=a.call(this[0])),b=r(a,this[0].ownerDocument).eq(0).clone(!0),this[0].parentNode&&b.insertBefore(this[0]),b.map(function(){var a=this;while(a.firstElementChild)a=a.firstElementChild;return a}).append(this)),this},wrapInner:function(a){return r.isFunction(a)?this.each(function(b){r(this).wrapInner(a.call(this,b))}):this.each(function(){var b=r(this),c=b.contents();c.length?c.wrapAll(a):b.append(a)})},wrap:function(a){var b=r.isFunction(a);return this.each(function(c){r(this).wrapAll(b?a.call(this,c):a)})},unwrap:function(a){return this.parent(a).not("body").each(function(){r(this).replaceWith(this.childNodes)}),this}}),r.expr.pseudos.hidden=function(a){return!r.expr.pseudos.visible(a)},r.expr.pseudos.visible=function(a){return!!(a.offsetWidth||a.offsetHeight||a.getClientRects().length)},o.createHTMLDocument=function(){var a=d.implementation.createHTMLDocument("").body;return a.innerHTML="
    ",2===a.childNodes.length}(),r.parseHTML=function(a,b,c){if("string"!=typeof a)return[];"boolean"==typeof b&&(c=b,b=!1);var e,f,g;return b||(o.createHTMLDocument?(b=d.implementation.createHTMLDocument(""),e=b.createElement("base"),e.href=d.location.href,b.head.appendChild(e)):b=d),f=B.exec(a),g=!c&&[],f?[b.createElement(f[1])]:(f=pa([a],b,g),g&&g.length&&r(g).remove(),r.merge([],f.childNodes))};function jb(a){return r.isWindow(a)?a:9===a.nodeType&&a.defaultView}r.offset={setOffset:function(a,b,c){var d,e,f,g,h,i,j,k=r.css(a,"position"),l=r(a),m={};"static"===k&&(a.style.position="relative"),h=l.offset(),f=r.css(a,"top"),i=r.css(a,"left"),j=("absolute"===k||"fixed"===k)&&(f+i).indexOf("auto")>-1,j?(d=l.position(),g=d.top,e=d.left):(g=parseFloat(f)||0,e=parseFloat(i)||0),r.isFunction(b)&&(b=b.call(a,c,r.extend({},h))),null!=b.top&&(m.top=b.top-h.top+g),null!=b.left&&(m.left=b.left-h.left+e),"using"in b?b.using.call(a,m):l.css(m)}},r.fn.extend({offset:function(a){if(arguments.length)return void 0===a?this:this.each(function(b){r.offset.setOffset(this,a,b)});var b,c,d,e,f=this[0];if(f)return f.getClientRects().length?(d=f.getBoundingClientRect(),d.width||d.height?(e=f.ownerDocument,c=jb(e),b=e.documentElement,{top:d.top+c.pageYOffset-b.clientTop,left:d.left+c.pageXOffset-b.clientLeft}):d):{top:0,left:0}},position:function(){if(this[0]){var a,b,c=this[0],d={top:0,left:0};return"fixed"===r.css(c,"position")?b=c.getBoundingClientRect():(a=this.offsetParent(),b=this.offset(),r.nodeName(a[0],"html")||(d=a.offset()),d={top:d.top+r.css(a[0],"borderTopWidth",!0),left:d.left+r.css(a[0],"borderLeftWidth",!0)}),{top:b.top-d.top-r.css(c,"marginTop",!0),left:b.left-d.left-r.css(c,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var a=this.offsetParent;while(a&&"static"===r.css(a,"position"))a=a.offsetParent;return a||qa})}}),r.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(a,b){var c="pageYOffset"===b;r.fn[a]=function(d){return S(this,function(a,d,e){var f=jb(a);return void 0===e?f?f[b]:a[d]:void(f?f.scrollTo(c?f.pageXOffset:e,c?e:f.pageYOffset):a[d]=e)},a,d,arguments.length)}}),r.each(["top","left"],function(a,b){r.cssHooks[b]=Oa(o.pixelPosition,function(a,c){if(c)return c=Na(a,b),La.test(c)?r(a).position()[b]+"px":c})}),r.each({Height:"height",Width:"width"},function(a,b){r.each({padding:"inner"+a,content:b,"":"outer"+a},function(c,d){r.fn[d]=function(e,f){var g=arguments.length&&(c||"boolean"!=typeof e),h=c||(e===!0||f===!0?"margin":"border");return S(this,function(b,c,e){var f;return r.isWindow(b)?0===d.indexOf("outer")?b["inner"+a]:b.document.documentElement["client"+a]:9===b.nodeType?(f=b.documentElement,Math.max(b.body["scroll"+a],f["scroll"+a],b.body["offset"+a],f["offset"+a],f["client"+a])):void 0===e?r.css(b,c,h):r.style(b,c,e,h)},b,g?e:void 0,g)}})}),"function"==typeof define&&define.amd&&define("jquery",[],function(){return r});var kb=a.jQuery,lb=a.$;return r.noConflict=function(b){return a.$===r&&(a.$=lb),b&&a.jQuery===r&&(a.jQuery=kb),r},b||(a.jQuery=a.$=r),r}); diff --git a/lcpserver/manage/style.css b/lcpserver/manage/style.css new file mode 100644 index 00000000..913ead1b --- /dev/null +++ b/lcpserver/manage/style.css @@ -0,0 +1,80 @@ +body { + font-family: 'Lato', sans-serif; + background-color: white; + color: #222; + padding: 0; + margin: 0; + font-size: 14px; + } + +.dropping { + border-color: white; +} +#dropzone { + width: 300px; +} +h1 { + font-size: 16px; + background-color: #1abc9c; + color: white; + padding: 0; + padding-left: 5px; + margin: 0; +} + +h2 { + font-size: 14px; + padding: 0 0 0 5px; + margin: 0; +} + +ul { + margin: 0; + padding: 0; +} + +li { + list-style-type: none; + padding-left: 10px; + cursor: pointer; +} +li.epub { + border-top: 2px solid gray; + border-bottom: 2px solid gray +} +#key { + width: 350px; + font-size: 9px; +} + +.key { + font-size: 9px; + color: silver; + font-weight: bold; +} +.size { + font-size: 9px; + color: red; + font-weight: bold; +} +.sha { + font-size: 9px; + color: green; + font-weight: bold; +} +.download { + color: blue; + font-weight: bold; +} +.licenses { + color: blue; + font-size: 9px; +} + +#license-form { + padding-left: 10px; +} + +.step { + background-color: red; +} \ No newline at end of file diff --git a/lcpserver/server/server.go b/lcpserver/server/server.go new file mode 100644 index 00000000..34783cb3 --- /dev/null +++ b/lcpserver/server/server.go @@ -0,0 +1,145 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package lcpserver + +import ( + "crypto/tls" + "net/http" + "time" + + "github.com/abbot/go-http-auth" + "github.com/gorilla/mux" + + "github.com/readium/readium-lcp-server/api" + "github.com/readium/readium-lcp-server/index" + "github.com/readium/readium-lcp-server/lcpserver/api" + "github.com/readium/readium-lcp-server/license" + "github.com/readium/readium-lcp-server/pack" + "github.com/readium/readium-lcp-server/storage" +) + +type Server struct { + http.Server + readonly bool + idx *index.Index + st *storage.Store + lst *license.Store + cert *tls.Certificate + source pack.ManualSource +} + +func (s *Server) Store() storage.Store { + return *s.st +} + +func (s *Server) Index() index.Index { + return *s.idx +} + +func (s *Server) Licenses() license.Store { + return *s.lst +} + +func (s *Server) Certificate() *tls.Certificate { + return s.cert +} + +func (s *Server) Source() *pack.ManualSource { + return &s.source +} + +func New(bindAddr string, static string, readonly bool, idx *index.Index, st *storage.Store, lst *license.Store, cert *tls.Certificate, packager *pack.Packager, basicAuth *auth.BasicAuth) *Server { + + sr := api.CreateServerRouter(static) + + s := &Server{ + Server: http.Server{ + Handler: sr.N, + Addr: bindAddr, + WriteTimeout: 15 * time.Second, + ReadTimeout: 15 * time.Second, + MaxHeaderBytes: 1 << 20, + }, + readonly: readonly, + idx: idx, + st: st, + lst: lst, + cert: cert, + source: pack.ManualSource{}, + } + + // Route.PathPrefix: http://www.gorillatoolkit.org/pkg/mux#Route.PathPrefix + // Route.Subrouter: http://www.gorillatoolkit.org/pkg/mux#Route.Subrouter + // Router.StrictSlash: http://www.gorillatoolkit.org/pkg/mux#Router.StrictSlash + + contentRoutesPathPrefix := "/contents" + contentRoutes := sr.R.PathPrefix(contentRoutesPathPrefix).Subrouter().StrictSlash(false) + + s.handleFunc(sr.R, contentRoutesPathPrefix, apilcp.ListContents).Methods("GET") + + s.handleFunc(contentRoutes, "/{key}", apilcp.GetContent).Methods("GET") + s.handlePrivateFunc(contentRoutes, "/{key}/licenses", apilcp.ListLicensesForContent, basicAuth).Methods("GET") + if !readonly { + s.handleFunc(contentRoutes, "/{name}", apilcp.StoreContent).Methods("POST") + s.handlePrivateFunc(contentRoutes, "/{key}", apilcp.AddContent, basicAuth).Methods("PUT") + s.handlePrivateFunc(contentRoutes, "/{content_id}/licenses", apilcp.GenerateLicense, basicAuth).Methods("POST") + s.handlePrivateFunc(contentRoutes, "/{content_id}/publications", apilcp.GenerateProtectedPublication, basicAuth).Methods("POST") + s.handlePrivateFunc(contentRoutes, "/{content_id}/publication", apilcp.GenerateProtectedPublication, basicAuth).Methods("POST") + } + + licenseRoutesPathPrefix := "/licenses" + licenseRoutes := sr.R.PathPrefix(licenseRoutesPathPrefix).Subrouter().StrictSlash(false) + + s.handlePrivateFunc(sr.R, licenseRoutesPathPrefix, apilcp.ListLicenses, basicAuth).Methods("GET") + + s.handlePrivateFunc(licenseRoutes, "/{license_id}", apilcp.GetLicense, basicAuth).Methods("GET") + s.handlePrivateFunc(licenseRoutes, "/{license_id}", apilcp.GetLicense, basicAuth).Methods("POST") + s.handlePrivateFunc(licenseRoutes, "/{license_id}/publication", apilcp.GenerateProtectedPublication, basicAuth).Methods("POST") + if !readonly { + s.handlePrivateFunc(licenseRoutes, "/{license_id}", apilcp.UpdateLicense, basicAuth).Methods("PATCH") + } + + s.source.Feed(packager.Incoming) + return s +} + +type HandlerFunc func(w http.ResponseWriter, r *http.Request, s apilcp.Server) + +func (s *Server) handleFunc(router *mux.Router, route string, fn HandlerFunc) *mux.Route { + return router.HandleFunc(route, func(w http.ResponseWriter, r *http.Request) { + fn(w, r, s) + }) +} + +type HandlerPrivateFunc func(w http.ResponseWriter, r *auth.AuthenticatedRequest, s apilcp.Server) + +func (s *Server) handlePrivateFunc(router *mux.Router, route string, fn HandlerFunc, authenticator *auth.BasicAuth) *mux.Route { + return router.HandleFunc(route, func(w http.ResponseWriter, r *http.Request) { + if api.CheckAuth(authenticator, w, r) { + fn(w, r, s) + } + }) +} diff --git a/lcpserver/server/server_test.go b/lcpserver/server/server_test.go new file mode 100644 index 00000000..37f034c3 --- /dev/null +++ b/lcpserver/server/server_test.go @@ -0,0 +1,33 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package lcpserver + +import ( + "testing" +) + +func TestSetup(t *testing.T) { +} diff --git a/license/license.go b/license/license.go index d6668836..a5517176 100644 --- a/license/license.go +++ b/license/license.go @@ -1,13 +1,38 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + package license import ( "crypto/rand" "fmt" - - "github.com/readium/readium-lcp-server/sign" - "io" "time" + + "github.com/readium/readium-lcp-server/config" + "github.com/readium/readium-lcp-server/sign" ) type Key struct { @@ -34,10 +59,15 @@ type Encryption struct { } type Link struct { - Href string `json:"href"` - Type string `json:"type,omitempty"` - Size int64 `json:"length,omitempty"` - Digest []byte `json:"hash,omitempty"` + Rel string `json:"rel"` + Href string `json:"href"` + Type string `json:"type,omitempty"` + Title string `json:"title,omitempty"` + Profile string `json:"profile,omitempty"` + Templated bool `json:"templated,omitempty" "default false"` + Size int64 `json:"length,omitempty"` + //Digest []byte `json:"hash,omitempty"` + Checksum string `json:"hash,omitempty"` } type UserInfo struct { @@ -48,35 +78,60 @@ type UserInfo struct { } type UserRights struct { - Print *int32 `json:"print,omitempty"` - Copy *int32 `json:"copy,omitempty"` - TTS bool `json:"tts"` - Editable bool `json:"edit"` - Start *time.Time `json:"start,omitempty"` - End *time.Time `json:"end,omitempty"` -} - -var DefaultRights = UserRights{ - TTS: true, - Editable: false, + Print *int32 `json:"print,omitempty"` + Copy *int32 `json:"copy,omitempty"` + Start *time.Time `json:"start,omitempty"` + End *time.Time `json:"end,omitempty"` } const DEFAULT_PROFILE = "http://readium.org/lcp/profile-1.0" +var DefaultLinks map[string]string + type License struct { Provider string `json:"provider"` Id string `json:"id"` Issued time.Time `json:"issued"` - Updated time.Time `json:"updated"` + Updated *time.Time `json:"updated,omitempty"` Encryption Encryption `json:"encryption"` - Links map[string]Link `json:"links"` + Links []Link `json:"links"` User UserInfo `json:"user"` Rights *UserRights `json:"rights,omitempty"` Signature *sign.Signature `json:"signature,omitempty"` + ContentId string `json:"-"` +} + +type LicenseReport struct { + Provider string `json:"provider"` + Id string `json:"id"` + Issued time.Time `json:"issued"` + Updated *time.Time `json:"updated,omitempty"` + User UserInfo `json:"user,omitempty"` + Rights *UserRights `json:"rights,omitempty"` + ContentId string `json:"-"` +} + +func CreateLinks() { + var configLinks map[string]string = config.Config.License.Links + + DefaultLinks = make(map[string]string) + + for key := range configLinks { + DefaultLinks[key] = configLinks[key] + } +} + +func DefaultLinksCopy() []Link { + links := new([]Link) + for key := range DefaultLinks { + link := Link{Href: DefaultLinks[key], Rel: key} + *links = append(*links, link) + } + return *links } func New() License { - l := License{Links: map[string]Link{}} + l := License{} Prepare(&l) return l } @@ -84,20 +139,31 @@ func New() License { func Prepare(l *License) { uuid, _ := newUUID() l.Id = uuid + l.Issued = time.Now() - l.Updated = l.Issued if l.Links == nil { - l.Links = map[string]Link{} + l.Links = DefaultLinksCopy() } if l.Rights == nil { - l.Rights = &DefaultRights + l.Rights = new(UserRights) } l.Encryption.Profile = DEFAULT_PROFILE } +func createForeigns(l *License) { + l.Encryption = Encryption{} + l.Encryption.UserKey = UserKey{} + l.User = UserInfo{} + l.Rights = new(UserRights) + l.Signature = new(sign.Signature) + + l.Links = DefaultLinksCopy() + l.Encryption.Profile = DEFAULT_PROFILE +} + // source: http://play.golang.org/p/4FkNSiUDMg // newUUID generates a random UUID according to RFC 4122 func newUUID() (string, error) { diff --git a/license/license_test.go b/license/license_test.go index 57335b36..155743c6 100644 --- a/license/license_test.go +++ b/license/license_test.go @@ -1,3 +1,28 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + package license import "testing" diff --git a/license/store.go b/license/store.go index 0ce905cf..76fdc1e8 100644 --- a/license/store.go +++ b/license/store.go @@ -1,18 +1,53 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + package license import ( - "bytes" "database/sql" "encoding/json" "errors" - - "github.com/readium/readium-lcp-server/sign" + "io" + "log" + "net/http" + "strconv" + "time" + + "github.com/readium/readium-lcp-server/api" + "github.com/readium/readium-lcp-server/config" ) -var NotFound = errors.New("Package not found") +var NotFound = errors.New("License not found") type Store interface { - List() func() (License, error) + //List() func() (License, error) + List(ContentId string, page int, pageNum int) func() (LicenseReport, error) + ListAll(page int, pageNum int) func() (LicenseReport, error) + UpdateRights(l License) error + Update(l License) error + UpdateLsdStatus(id string, status int32) error Add(l License) error Get(id string) (License, error) } @@ -21,40 +56,166 @@ type sqlStore struct { db *sql.DB } -func (s *sqlStore) List() func() (License, error) { - return func() (License, error) { - return License{}, NotFound +// notifyLsdServer informs LSD server of a new License +// and saves the result of the http request in the DB (using the *Store) +func notifyLsdServer(l License, s Store) { + if config.Config.LsdServer.PublicBaseUrl != "" { + var lsdClient = &http.Client{ + Timeout: time.Second * 10, + } + pr, pw := io.Pipe() + defer pr.Close() + go func() { + _ = json.NewEncoder(pw).Encode(l) + pw.Close() // signal end writing + }() + req, err := http.NewRequest("PUT", config.Config.LsdServer.PublicBaseUrl+"/licenses", pr) + + // Set credentials on lsd request + notifyAuth := config.Config.LsdNotifyAuth + if notifyAuth.Username != "" { + req.SetBasicAuth(notifyAuth.Username, notifyAuth.Password) + } + + req.Header.Add("Content-Type", api.ContentType_LCP_JSON) + + response, err := lsdClient.Do(req) + if err != nil { + log.Println("Error Notify LsdServer of new License (" + l.Id + "):" + err.Error()) + _ = s.UpdateLsdStatus(l.Id, -1) + } else { + defer req.Body.Close() + _ = s.UpdateLsdStatus(l.Id, int32(response.StatusCode)) + if response.StatusCode != 201 { //bad request or server error + log.Println("Notify LsdServer of new License (" + l.Id + ") = " + strconv.Itoa(response.StatusCode)) + } + } } } -func (s *sqlStore) Add(l License) error { - json, err := sign.Canon(l) +//ListAll, lists all licenses in ante-chronological order +// pageNum starting at 0 +func (s *sqlStore) ListAll(page int, pageNum int) func() (LicenseReport, error) { + listLicenses, err := s.db.Query(`SELECT id, user_id, provider, issued, updated, + rights_print, rights_copy, rights_start, rights_end, content_fk + FROM license + ORDER BY issued desc LIMIT ? OFFSET ? `, page, pageNum*page) + if err != nil { + return func() (LicenseReport, error) { return LicenseReport{}, err } + } + return func() (LicenseReport, error) { + var l LicenseReport + l.User = UserInfo{} + l.Rights = new(UserRights) + if listLicenses.Next() { + err := listLicenses.Scan(&l.Id, &l.User.Id, &l.Provider, &l.Issued, &l.Updated, + &l.Rights.Print, &l.Rights.Copy, &l.Rights.Start, &l.Rights.End, &l.ContentId) + + if err != nil { + return l, err + } + + } else { + listLicenses.Close() + err = NotFound + } + return l, err + } +} + +//List() list licenses for a given ContentId +//pageNum starting at 0 +func (s *sqlStore) List(ContentId string, page int, pageNum int) func() (LicenseReport, error) { + listLicenses, err := s.db.Query(`SELECT id, user_id, provider, issued, updated, + rights_print, rights_copy, rights_start, rights_end, content_fk + FROM license + WHERE content_fk=? LIMIT ? OFFSET ? `, ContentId, page, pageNum*page) if err != nil { - return err + return func() (LicenseReport, error) { return LicenseReport{}, err } + } + return func() (LicenseReport, error) { + var l LicenseReport + l.User = UserInfo{} + l.Rights = new(UserRights) + if listLicenses.Next() { + + err := listLicenses.Scan(&l.Id, &l.User.Id, &l.Provider, &l.Issued, &l.Updated, + &l.Rights.Print, &l.Rights.Copy, &l.Rights.Start, &l.Rights.End, &l.ContentId) + if err != nil { + return l, err + } + } else { + listLicenses.Close() + err = NotFound + } + return l, err } +} +func (s *sqlStore) UpdateRights(l License) error { + result, err := s.db.Exec("UPDATE license SET rights_print=?, rights_copy=?, rights_start=?, rights_end=?,updated=? WHERE id=?", + l.Rights.Print, l.Rights.Copy, l.Rights.Start, l.Rights.End, time.Now(), l.Id) + + if err == nil { + if r, _ := result.RowsAffected(); r == 0 { + return NotFound + } + } + return err +} +func (s *sqlStore) Add(l License) error { + _, err := s.db.Exec(`INSERT INTO license (id, user_id, provider, issued, updated, + rights_print, rights_copy, rights_start, rights_end, + user_key_hint, user_key_hash, user_key_algorithm, content_fk) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + l.Id, l.User.Id, l.Provider, l.Issued, nil, l.Rights.Print, l.Rights.Copy, l.Rights.Start, + l.Rights.End, l.Encryption.UserKey.Hint, l.Encryption.UserKey.Check, + l.Encryption.UserKey.Key.Algorithm, l.ContentId) + go notifyLsdServer(l, s) + return err +} + +func (s *sqlStore) Update(l License) error { + _, err := s.db.Exec(`UPDATE license SET user_id=?,provider=?,issued=?,updated=?, + rights_print=?, rights_copy=?, rights_start=?, rights_end=?, + user_key_hint=?, content_fk =? + WHERE id=?`, // user_key_hash=?, user_key_algorithm=?, + l.User.Id, l.Provider, l.Issued, time.Now(), + l.Rights.Print, l.Rights.Copy, l.Rights.Start, l.Rights.End, + l.Encryption.UserKey.Hint, l.ContentId, + l.Id) + + return err +} - _, err = s.db.Exec("INSERT INTO licenses VALUES (?, ?)", l.Id, json) +func (s *sqlStore) UpdateLsdStatus(id string, status int32) error { + _, err := s.db.Exec(`UPDATE license SET lsd_status =? + WHERE id=?`, // user_key_hash=?, user_key_algorithm=?, + status, + id) return err } func (s *sqlStore) Get(id string) (License, error) { + var l License - var buf []uint8 + createForeigns(&l) - row := s.db.QueryRow("SELECT data FROM licenses where id = ?", id) + row := s.db.QueryRow(`SELECT id, user_id, provider, issued, updated, rights_print, rights_copy, + rights_start, rights_end, user_key_hint, user_key_hash, user_key_algorithm, content_fk FROM license + where id = ?`, id) - err := row.Scan(&buf) - if err != nil { - return l, err - } - - b := bytes.NewBuffer(buf) + err := row.Scan(&l.Id, &l.User.Id, &l.Provider, &l.Issued, &l.Updated, + &l.Rights.Print, &l.Rights.Copy, &l.Rights.Start, &l.Rights.End, + &l.Encryption.UserKey.Hint, &l.Encryption.UserKey.Check, &l.Encryption.UserKey.Key.Algorithm, + &l.ContentId) - dec := json.NewDecoder(b) - err = dec.Decode(&l) if err != nil { - return l, err + if err == sql.ErrNoRows { + return l, NotFound + } else { + return l, err + } } return l, nil @@ -69,6 +230,18 @@ func NewSqlStore(db *sql.DB) (Store, error) { return &sqlStore{db}, nil } -const tableDef = `CREATE TABLE IF NOT EXISTS licenses ( +const tableDef = `CREATE TABLE IF NOT EXISTS license ( id varchar(255) PRIMARY KEY, - data blob)` + user_id varchar(255) NOT NULL, + provider varchar(255) NOT NULL, + issued datetime NOT NULL, + updated datetime DEFAULT NULL, + rights_print int(11) DEFAULT NULL, + rights_copy int(11) DEFAULT NULL, + rights_start datetime DEFAULT NULL, + rights_end datetime DEFAULT NULL, + user_key_hint text NOT NULL, + user_key_hash varchar(64) NOT NULL, + user_key_algorithm varchar(255) NOT NULL, + content_fk varchar(255) NOT NULL, + lsd_status integer default 0)` diff --git a/license/store_test.go b/license/store_test.go index 20df3769..02d03dc5 100644 --- a/license/store_test.go +++ b/license/store_test.go @@ -1,13 +1,38 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + package license import ( "bytes" "database/sql" + "testing" _ "github.com/mattn/go-sqlite3" - "github.com/readium/readium-lcp-server/sign" - "testing" + "github.com/readium/readium-lcp-server/sign" ) func TestStoreInit(t *testing.T) { @@ -41,7 +66,7 @@ func TestStoreAdd(t *testing.T) { Prepare(&l) err = st.Add(l) if err != nil { - t.Error(err) + t.Fatal(err) } l2, err := st.Get(l.Id) diff --git a/license_statuses/license_status.go b/license_statuses/license_status.go new file mode 100644 index 00000000..f783e26f --- /dev/null +++ b/license_statuses/license_status.go @@ -0,0 +1,63 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package licensestatuses + +import ( + "time" + + "github.com/readium/readium-lcp-server/transactions" +) + +type Updated struct { + License *time.Time `json:"license,omitempty"` + Status *time.Time `json:"status,omitempty"` +} + +type Link struct { + Rel string `json:"rel"` + Href string `json:"href"` + Type string `json:"type,omitempty"` + Title string `json:"title,omitempty"` + Profile string `json:"profile,omitempty"` + Templated bool `json:"templated,omitempty" "default false"` +} + +type PotentialRights struct { + End *time.Time `json:"end,omitempty"` +} + +type LicenseStatus struct { + Id int `json:"-"` + LicenseRef string `json:"id"` + Status string `json:"status"` + Updated *Updated `json:"updated,omitempty"` + Message string `json:"message"` + Links []Link `json:"links,omitempty"` + DeviceCount *int `json:"device_count,omitempty"` + PotentialRights *PotentialRights `json:"potential_rights,omitempty"` + Events []transactions.Event `json:"events,omitempty"` + CurrentEndLicense *time.Time `json:"-"` +} diff --git a/license_statuses/license_statuses.go b/license_statuses/license_statuses.go new file mode 100644 index 00000000..cc70eef3 --- /dev/null +++ b/license_statuses/license_statuses.go @@ -0,0 +1,218 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package licensestatuses + +import ( + "database/sql" + "errors" + "time" + + "github.com/readium/readium-lcp-server/status" +) + +var NotFound = errors.New("License Status not found") + +type LicenseStatuses interface { + //Get(id int) (LicenseStatus, error) + Add(ls LicenseStatus) error + List(deviceLimit int64, limit int64, offset int64) func() (LicenseStatus, error) + GetByLicenseId(id string) (*LicenseStatus, error) + Update(ls LicenseStatus) error +} + +type dbLicenseStatuses struct { + db *sql.DB + get *sql.Stmt + add *sql.Stmt + list *sql.Stmt + getbylicenseid *sql.Stmt + update *sql.Stmt +} + +// //Get gets license status by id +// func (i dbLicenseStatuses) Get(id int) (LicenseStatus, error) { +// var statusDB int64 + +// records, err := i.get.Query(id) +// defer records.Close() + +// if records.Next() { +// ls := LicenseStatus{} +// err = records.Scan(&ls.Id, &statusDB, ls.Updated.License, ls.Updated.Status, ls.DeviceCount, ls.PotentialRights.End, &ls.LicenseRef) + +// if err == nil { +// status.GetStatus(statusDB, &ls.Status) +// } + +// return ls, err +// } + +// return LicenseStatus{}, NotFound +// } + +//Add adds license status to database +func (i dbLicenseStatuses) Add(ls LicenseStatus) error { + add, err := i.db.Prepare("INSERT INTO license_status (status, license_updated, status_updated, device_count, potential_rights_end, license_ref, rights_end) VALUES (?, ?, ?, ?, ?, ?, ?)") + if err != nil { + return err + } + defer add.Close() + + statusDB, err := status.SetStatus(ls.Status) + + if err == nil { + var end time.Time + if ls.PotentialRights != nil && ls.PotentialRights.End != nil && !(*ls.PotentialRights.End).IsZero() { + end = *ls.PotentialRights.End + } + _, err = add.Exec(statusDB, ls.Updated.License, ls.Updated.Status, ls.DeviceCount, &end, ls.LicenseRef, ls.CurrentEndLicense) + } + + return err +} + +//List gets license statuses which have devices count more than devices limit +//input parameters: limit - how much license statuses need to get, offset - from what position need to start +func (i dbLicenseStatuses) List(deviceLimit int64, limit int64, offset int64) func() (LicenseStatus, error) { + rows, err := i.list.Query(deviceLimit, limit, offset) + if err != nil { + return func() (LicenseStatus, error) { return LicenseStatus{}, err } + } + return func() (LicenseStatus, error) { + var statusDB int64 + ls := LicenseStatus{} + ls.Updated = new(Updated) + + var err error + if rows.Next() { + err = rows.Scan(&statusDB, &ls.Updated.License, &ls.Updated.Status, &ls.DeviceCount, &ls.LicenseRef) + + if err == nil { + status.GetStatus(statusDB, &ls.Status) + } + } else { + rows.Close() + err = NotFound + } + return ls, err + } +} + +//GetByLicenseId gets license status by license id +func (i dbLicenseStatuses) GetByLicenseId(licenseFk string) (*LicenseStatus, error) { + var statusDB int64 + ls := LicenseStatus{} + + var potentialRightsEnd *time.Time + var licenseUpdate *time.Time + var statusUpdate *time.Time + + row := i.getbylicenseid.QueryRow(licenseFk) + err := row.Scan(&ls.Id, &statusDB, &licenseUpdate, &statusUpdate, &ls.DeviceCount, &potentialRightsEnd, &ls.LicenseRef, &ls.CurrentEndLicense) + + if err == nil { + status.GetStatus(statusDB, &ls.Status) + + ls.Updated = new(Updated) + + if (potentialRightsEnd != nil) && (!(*potentialRightsEnd).IsZero()) { + ls.PotentialRights = new(PotentialRights) + ls.PotentialRights.End = potentialRightsEnd + } + + if licenseUpdate != nil || statusUpdate != nil { + ls.Updated.Status = statusUpdate + ls.Updated.License = licenseUpdate + } + } else { + if err == sql.ErrNoRows { + return nil, err + } + } + + return &ls, err +} + +//Update updates license status +func (i dbLicenseStatuses) Update(ls LicenseStatus) error { + + statusInt, err := status.SetStatus(ls.Status) + if err != nil { + return err + } + + var potentialRightsEnd *time.Time + + if ls.PotentialRights != nil && ls.PotentialRights.End != nil && !(*ls.PotentialRights.End).IsZero() { + potentialRightsEnd = ls.PotentialRights.End + } + + var result sql.Result + result, err = i.db.Exec("UPDATE license_status SET status=?, license_updated=?, status_updated=?, device_count=?,potential_rights_end=?, rights_end=? WHERE id=?", + statusInt, ls.Updated.License, ls.Updated.Status, ls.DeviceCount, potentialRightsEnd, ls.CurrentEndLicense, ls.Id) + + if err == nil { + if r, _ := result.RowsAffected(); r == 0 { + return NotFound + } + } + return err +} + +//Open defines scripts for queries & create table 'licensestatus' if not exist +func Open(db *sql.DB) (l LicenseStatuses, err error) { + _, err = db.Exec(tableDef) + if err != nil { + return + } + get, err := db.Prepare("SELECT * FROM license_status WHERE id = ? LIMIT 1") + if err != nil { + return + } + + list, err := db.Prepare(`SELECT status, license_updated, status_updated, device_count, license_ref FROM license_status WHERE device_count >= ? + ORDER BY id DESC LIMIT ? OFFSET ?`) + + getbylicenseid, err := db.Prepare("SELECT * FROM license_status where license_ref = ?") + + if err != nil { + return + } + l = dbLicenseStatuses{db, get, nil, list, getbylicenseid, nil} + return +} + +const tableDef = `CREATE TABLE IF NOT EXISTS license_status ( + id INTEGER PRIMARY KEY, + status int(11) NOT NULL, + license_updated datetime NOT NULL, + status_updated datetime NOT NULL, + device_count int(11) DEFAULT NULL, + potential_rights_end datetime DEFAULT NULL, + license_ref varchar(255) NOT NULL, + rights_end datetime DEFAULT NULL +); +CREATE INDEX IF NOT EXISTS license_ref_index on license_status (license_ref);` diff --git a/license_statuses/license_statuses_test.go b/license_statuses/license_statuses_test.go new file mode 100644 index 00000000..8666c265 --- /dev/null +++ b/license_statuses/license_statuses_test.go @@ -0,0 +1,57 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package licensestatuses + +import ( + "database/sql" + "testing" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +//TestHistoryCreation opens database and tries to add(get) license status to(from) table 'licensestatus' +func TestHistoryCreation(t *testing.T) { + db, err := sql.Open("sqlite3", ":memory:") + lst, err := Open(db) + if err != nil { + t.Error("Can't open licensestatuses") + t.Error(err) + t.FailNow() + } + + timestamp := time.Now() + + ls := LicenseStatus{PotentialRights: &PotentialRights{End: ×tamp}, LicenseRef: "licenseref", Status: "active", Updated: &Updated{License: ×tamp, Status: ×tamp}, DeviceCount: 2} + err = lst.Add(ls) + if err != nil { + t.Error(err) + } + _, err = lst.Get(1) + if err != nil { + t.Error(err) + } +} diff --git a/localization/localization.go b/localization/localization.go new file mode 100644 index 00000000..edb86b8c --- /dev/null +++ b/localization/localization.go @@ -0,0 +1,58 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package localization + +import ( + "path" + + "github.com/nicksnyder/go-i18n/i18n" + + "github.com/readium/readium-lcp-server/config" +) + +//InitTranslations loads files with translation according to array in config file. +//Needs to run in the server main.go. +//err!=nil means that one of them can't be opened +func InitTranslations() error { + acceptableLanguages := config.Config.Localization.Languages + localizationPath := config.Config.Localization.Folder + + var err error + for _, value := range acceptableLanguages { + err = i18n.LoadTranslationFile(path.Join(localizationPath, value+".json")) + } + + return err +} + +//LocalizeMessage translates messages. +//acceptLanguage - Accept-Languages from request header (r.Header.Get("Accept-Language")) +func LocalizeMessage(acceptLanguage string, message *string, key string) { + defaultLanguage := config.Config.Localization.DefaultLanguage + + T, _ := i18n.Tfunc(acceptLanguage, defaultLanguage) + *message = T(key) +} diff --git a/logging/logging.go b/logging/logging.go new file mode 100644 index 00000000..c4e9dc81 --- /dev/null +++ b/logging/logging.go @@ -0,0 +1,116 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package logging + +import ( + "bufio" + "io/ioutil" + "log" + "os" + "strconv" + "strings" + "time" +) + +var ( + LogFile *log.Logger + complianceMode bool +) + +const ( + BASIC_FUNCTION = "2.3.2.1" + SUCCESS_REGISTRATION = "2.3.2.2.1" + REJECT_REGISTRATION = "2.3.2.2.2" + SUCCESS_RETURN = "2.3.2.3.1" + REJECT_RETURN = "2.3.2.3.2" + SUCCESS_RENEW = "2.3.2.4.1" + REJECT_RENEW = "2.3.2.4.2" +) + +//Init inits log file and opens it +func Init(logPath string, cm bool) error { + complianceMode = cm + if complianceMode == true { + file, err := os.OpenFile(logPath, os.O_RDWR|os.O_APPEND, 0666) + if err != nil { + return err + } + + LogFile = log.New(file, "", log.LUTC) + + return nil + } + + return nil +} + +//WriteToFile writes result of function execution in log file +func WriteToFile(testId string, status string, result string) { + if complianceMode == true { + currentTime := time.Now().UTC().Format(time.RFC3339) + + LogFile.Println(currentTime + "\t" + testId + " \t" + status + "\t" + result) + } +} + +//ReadLogs reads logs from file +func ReadLogs(logPath string) ([]string, error) { + var lines []string + file, err := os.OpenFile(logPath, os.O_RDONLY, 0666) + if err == nil { + reader := bufio.NewReader(file) + contents, err := ioutil.ReadAll(reader) + + if err == nil { + lines = strings.Split(string(contents), "\n") + } + } + lines = lines[:len(lines)-1] + return lines, err +} + +//CountTotal summarize the data in log file +func CountTotal(lines []string) (string, error) { + var total, positive, negative int + var result string + + for _, value := range lines { + splitted := strings.Split(value, "\t") + + if splitted[3] == "error" { + negative++ + } + if splitted[3] == "success" { + positive++ + } + + + total++ + } + + result = "Total count: " + strconv.Itoa(total) + "; ended successfully: " + strconv.Itoa(positive) + "; ended with error: " + strconv.Itoa(negative) + return result, nil +} diff --git a/logs/log.log b/logs/log.log new file mode 100644 index 00000000..e69de29b diff --git a/lsdserver/api/compliance_tests.go b/lsdserver/api/compliance_tests.go new file mode 100644 index 00000000..c82aa675 --- /dev/null +++ b/lsdserver/api/compliance_tests.go @@ -0,0 +1,59 @@ +package apilsd + +import ( + "net/http" + + "github.com/readium/readium-lcp-server/logging" + "github.com/readium/readium-lcp-server/problem" +) + +var complianceTestNumber string = "" + +var results = map[string]string{ + "s": "success", + "e": "error", +} + +const ( + LICENSE_STATUS = "status" + REGISTER_DEVICE = "register" + RENEW_LICENSE = "renew" + RETURN_LICENSE = "return" + CANCEL_REVOKE_LICENSE = "cancel_revoke" +) + +func AddLogToFile(w http.ResponseWriter, r *http.Request, s Server) { + testStage := r.FormValue("test_stage") + testNumber := r.FormValue("test_number") + testResult := r.FormValue("test_result") + + if testStage != "start" && testStage != "end" { + problem.Error(w, r, problem.Problem{Type: "about:blank", Detail: "You must type the regular stage of the compliance test"}, http.StatusBadRequest) + return + } + + if testStage == "start" { + if len(testNumber) == 0 { + problem.Error(w, r, problem.Problem{Type: "about:blank", Detail: "You must type the number of compliance test"}, http.StatusBadRequest) + return + } else { + complianceTestNumber = testNumber + testResult = "-" + writeLogToFile(testStage, testResult) + return + } + } else { + if testResult != "e" && testResult != "s" { + problem.Error(w, r, problem.Problem{Type: "about:blank", Detail: "You must type the result of compliance test"}, http.StatusBadRequest) + return + } else { + testResult = results[testResult] + writeLogToFile(testStage, testResult) + complianceTestNumber = "" + } + } +} + +func writeLogToFile(testStage string, result string) { + logging.WriteToFile(complianceTestNumber, testStage, result) +} diff --git a/lsdserver/api/license_status.go b/lsdserver/api/license_status.go new file mode 100644 index 00000000..42bdf019 --- /dev/null +++ b/lsdserver/api/license_status.go @@ -0,0 +1,901 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package apilsd + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "log" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gorilla/mux" + + "github.com/readium/readium-lcp-server/api" + "github.com/readium/readium-lcp-server/config" + "github.com/readium/readium-lcp-server/lcpserver/api" + "github.com/readium/readium-lcp-server/license" + "github.com/readium/readium-lcp-server/license_statuses" + "github.com/readium/readium-lcp-server/localization" + "github.com/readium/readium-lcp-server/logging" + "github.com/readium/readium-lcp-server/problem" + "github.com/readium/readium-lcp-server/status" + "github.com/readium/readium-lcp-server/transactions" +) + +type Server interface { + Transactions() transactions.Transactions + LicenseStatuses() licensestatuses.LicenseStatuses +} + +//CreateLicenseStatusDocument create license status and add it to database +func CreateLicenseStatusDocument(w http.ResponseWriter, r *http.Request, s Server) { + var lic license.License + err := apilcp.DecodeJsonLicense(r, &lic) + + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + return + } + + var ls licensestatuses.LicenseStatus + makeLicenseStatus(lic, &ls) + + err = s.LicenseStatuses().Add(ls) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + return + } + + // must come *after* w.Header().Add()/Set(), but before w.Write() + w.WriteHeader(http.StatusCreated) +} + +//GetLicenseStatusDocument get license status from database by licese id +//checks potential_rights_end and fill it +func GetLicenseStatusDocument(w http.ResponseWriter, r *http.Request, s Server) { + vars := mux.Vars(r) + + licenseFk := vars["key"] + + licenseStatus, err := s.LicenseStatuses().GetByLicenseId(licenseFk) + if err != nil { + if licenseStatus == nil { + problem.NotFoundHandler(w, r) + logging.WriteToFile(complianceTestNumber, LICENSE_STATUS, strconv.Itoa(http.StatusNotFound)) + return + } + + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + logging.WriteToFile(complianceTestNumber, LICENSE_STATUS, strconv.Itoa(http.StatusInternalServerError)) + return + } + + currentDateTime := time.Now() + + if licenseStatus.PotentialRights != nil && licenseStatus.PotentialRights.End != nil && !(*licenseStatus.PotentialRights.End).IsZero() { + diff := currentDateTime.Sub(*(licenseStatus.PotentialRights.End)) + + if (diff > 0) && ((licenseStatus.Status == status.STATUS_ACTIVE) || (licenseStatus.Status == status.STATUS_READY)) { + licenseStatus.Status = status.STATUS_EXPIRED + err = s.LicenseStatuses().Update(*licenseStatus) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + logging.WriteToFile(complianceTestNumber, LICENSE_STATUS, strconv.Itoa(http.StatusInternalServerError)) + return + } + } + } + + err = fillLicenseStatus(licenseStatus, r, s) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + logging.WriteToFile(complianceTestNumber, LICENSE_STATUS, strconv.Itoa(http.StatusInternalServerError)) + return + } + + w.Header().Set("Content-Type", api.ContentType_LSD_JSON) + + 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) + logging.WriteToFile(complianceTestNumber, LICENSE_STATUS, strconv.Itoa(http.StatusInternalServerError)) + return + } + + logging.WriteToFile(complianceTestNumber, LICENSE_STATUS, strconv.Itoa(http.StatusOK)) +} + +//RegisterDevice register device using device id & device name request parameters +//& returns updated and filled license status +func RegisterDevice(w http.ResponseWriter, r *http.Request, s Server) { + w.Header().Set("Content-Type", api.ContentType_LSD_JSON) + vars := mux.Vars(r) + + licenseFk := vars["key"] + licenseStatus, err := s.LicenseStatuses().GetByLicenseId(licenseFk) + + if err != nil { + if licenseStatus == nil { + problem.NotFoundHandler(w, r) + logging.WriteToFile(complianceTestNumber, REGISTER_DEVICE, strconv.Itoa(http.StatusNotFound)) + return + } + + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + logging.WriteToFile(complianceTestNumber, REGISTER_DEVICE, strconv.Itoa(http.StatusInternalServerError)) + return + } + + deviceId := r.FormValue("id") + deviceName := r.FormValue("name") + + dILen := len(deviceId) + dNLen := len(deviceName) + + //check mandatory request parameters + if (dILen == 0) || (dILen > 255) || (dNLen == 0) || (dNLen > 255) { + problem.Error(w, r, problem.Problem{Detail: "device id and device name are mandatory and maximum length is 255 symbols "}, http.StatusBadRequest) + logging.WriteToFile(complianceTestNumber, REGISTER_DEVICE, strconv.Itoa(http.StatusBadRequest)) + return + } + + //check status of license status + if (licenseStatus.Status != status.STATUS_ACTIVE) && (licenseStatus.Status != status.STATUS_READY) { + problem.Error(w, r, problem.Problem{Detail: "License is not active"}, http.StatusBadRequest) + logging.WriteToFile(complianceTestNumber, REGISTER_DEVICE, strconv.Itoa(http.StatusBadRequest)) + return + } + + //check the existence of device in license status + deviceStatus, err := s.Transactions().CheckDeviceStatus(licenseStatus.Id, deviceId) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + logging.WriteToFile(complianceTestNumber, REGISTER_DEVICE, strconv.Itoa(http.StatusInternalServerError)) + return + } + if deviceStatus != "" { // deviceStatus == status.TYPE_REGISTER || deviceStatus == status.TYPE_RETURN || deviceStatus == status.TYPE_RENEW + problem.Error(w, r, problem.Problem{Detail: "Device has been already registered"}, http.StatusBadRequest) + logging.WriteToFile(complianceTestNumber, REGISTER_DEVICE, strconv.Itoa(http.StatusBadRequest)) + return + } + + //make event for register transaction + event := makeEvent(status.TYPE_REGISTER, deviceName, deviceId, licenseStatus.Id) + + err = s.Transactions().Add(*event, 1) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + logging.WriteToFile(complianceTestNumber, REGISTER_DEVICE, strconv.Itoa(http.StatusInternalServerError)) + return + } + + licenseStatus.Updated.Status = &event.Timestamp + + //check & set the status of the license status + if licenseStatus.Status == status.STATUS_READY { + licenseStatus.Status = status.STATUS_ACTIVE + } + + *licenseStatus.DeviceCount += 1 + + err = s.LicenseStatuses().Update(*licenseStatus) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + logging.WriteToFile(complianceTestNumber, REGISTER_DEVICE, strconv.Itoa(http.StatusInternalServerError)) + return + } + + //fill license status + err = fillLicenseStatus(licenseStatus, r, s) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + logging.WriteToFile(complianceTestNumber, REGISTER_DEVICE, strconv.Itoa(http.StatusInternalServerError)) + return + } + + 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) + logging.WriteToFile(complianceTestNumber, REGISTER_DEVICE, strconv.Itoa(http.StatusInternalServerError)) + return + } + logging.WriteToFile(complianceTestNumber, REGISTER_DEVICE, strconv.Itoa(http.StatusOK)) +} + +//LendingReturn checks that the calling device is activated, then modifies +//the end date associated with the given license & returns updated and filled license status +func LendingReturn(w http.ResponseWriter, r *http.Request, s Server) { + w.Header().Set("Content-Type", api.ContentType_LSD_JSON) + vars := mux.Vars(r) + + licenseFk := vars["key"] + licenseStatus, err := s.LicenseStatuses().GetByLicenseId(licenseFk) + + if err != nil { + if licenseStatus == nil { + problem.NotFoundHandler(w, r) + logging.WriteToFile(complianceTestNumber, RETURN_LICENSE, strconv.Itoa(http.StatusNotFound)) + return + } + + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + logging.WriteToFile(complianceTestNumber, RETURN_LICENSE, strconv.Itoa(http.StatusInternalServerError)) + return + } + + deviceId := r.FormValue("id") + deviceName := r.FormValue("name") + + //checks request parameters + if (len(deviceName) > 255) || (len(deviceId) > 255) { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + logging.WriteToFile(complianceTestNumber, RETURN_LICENSE, strconv.Itoa(http.StatusBadRequest)) + return + } + + //check & set the status of license status according to its current value + switch licenseStatus.Status { + case status.STATUS_RETURNED: + problem.Error(w, r, problem.Problem{Detail: "License has been already returned"}, http.StatusForbidden) + logging.WriteToFile(complianceTestNumber, RETURN_LICENSE, strconv.Itoa(http.StatusForbidden)) + return + case status.STATUS_EXPIRED: + problem.Error(w, r, problem.Problem{Detail: "License is expired"}, http.StatusForbidden) + logging.WriteToFile(complianceTestNumber, RETURN_LICENSE, strconv.Itoa(http.StatusForbidden)) + return + case status.STATUS_ACTIVE: + licenseStatus.Status = status.STATUS_RETURNED + break + case status.STATUS_READY: + licenseStatus.Status = status.STATUS_CANCELLED + break + case status.STATUS_CANCELLED: + problem.Error(w, r, problem.Problem{Detail: "License is cancelled"}, http.StatusForbidden) + logging.WriteToFile(complianceTestNumber, RETURN_LICENSE, strconv.Itoa(http.StatusForbidden)) + return + case status.STATUS_REVOKED: + problem.Error(w, r, problem.Problem{Detail: "License is revoked"}, http.StatusForbidden) + logging.WriteToFile(complianceTestNumber, RETURN_LICENSE, strconv.Itoa(http.StatusForbidden)) + return + } + + //check if device is activated + if deviceId != "" { + deviceStatus, err := s.Transactions().CheckDeviceStatus(licenseStatus.Id, deviceId) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + logging.WriteToFile(complianceTestNumber, RETURN_LICENSE, strconv.Itoa(http.StatusInternalServerError)) + return + } + if deviceStatus == status.TYPE_RETURN || deviceStatus == "" { // deviceStatus != status.TYPE_REGISTER && deviceStatus != status.TYPE_RENEW + problem.Error(w, r, problem.Problem{Detail: "Device is not activated"}, http.StatusBadRequest) + logging.WriteToFile(complianceTestNumber, RETURN_LICENSE, strconv.Itoa(http.StatusBadRequest)) + return + } + } + + //create event for lending return + event := makeEvent(status.TYPE_RETURN, deviceName, deviceId, licenseStatus.Id) + + err = s.Transactions().Add(*event, 2) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + logging.WriteToFile(complianceTestNumber, RETURN_LICENSE, strconv.Itoa(http.StatusInternalServerError)) + return + } + + //update license using LCP Server + httpStatusCode, errorr := updateLicense(event.Timestamp, licenseFk) + if errorr != nil { + problem.Error(w, r, problem.Problem{Detail: errorr.Error()}, http.StatusInternalServerError) + logging.WriteToFile(complianceTestNumber, RETURN_LICENSE, strconv.Itoa(http.StatusInternalServerError)) + return + } + if httpStatusCode != http.StatusOK && httpStatusCode != http.StatusPartialContent { // 200, 206 + errorr = errors.New("LCP license PATCH returned HTTP error code " + strconv.Itoa(httpStatusCode)) + + problem.Error(w, r, problem.Problem{Detail: errorr.Error()}, httpStatusCode) + logging.WriteToFile(complianceTestNumber, RETURN_LICENSE, strconv.Itoa(httpStatusCode)) + return + } + licenseStatus.CurrentEndLicense = &event.Timestamp + + //update licenseStatus + licenseStatus.Updated.Status = &event.Timestamp + licenseStatus.Updated.License = &event.Timestamp + + err = s.LicenseStatuses().Update(*licenseStatus) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + logging.WriteToFile(complianceTestNumber, RETURN_LICENSE, strconv.Itoa(http.StatusInternalServerError)) + return + } + + //fill license status + err = fillLicenseStatus(licenseStatus, r, s) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + logging.WriteToFile(complianceTestNumber, RETURN_LICENSE, strconv.Itoa(http.StatusInternalServerError)) + return + } + + 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) + logging.WriteToFile(complianceTestNumber, RETURN_LICENSE, strconv.Itoa(http.StatusInternalServerError)) + return + } + + logging.WriteToFile(complianceTestNumber, RETURN_LICENSE, strconv.Itoa(http.StatusOK)) +} + +//LendingRenewal checks that the calling device is activated, then modifies +//the end date associated with the license & returns updated and filled license status +func LendingRenewal(w http.ResponseWriter, r *http.Request, s Server) { + w.Header().Set("Content-Type", api.ContentType_LSD_JSON) + vars := mux.Vars(r) + + licenseFk := vars["key"] + licenseStatus, err := s.LicenseStatuses().GetByLicenseId(licenseFk) + + if err != nil { + if licenseStatus == nil { + problem.NotFoundHandler(w, r) + logging.WriteToFile(complianceTestNumber, RENEW_LICENSE, strconv.Itoa(http.StatusNotFound)) + return + } + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + logging.WriteToFile(complianceTestNumber, RENEW_LICENSE, strconv.Itoa(http.StatusInternalServerError)) + return + } + + deviceId := r.FormValue("id") + deviceName := r.FormValue("name") + + //check the request parameters + if (len(deviceName) > 255) || (len(deviceId) > 255) { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + logging.WriteToFile(complianceTestNumber, RENEW_LICENSE, strconv.Itoa(http.StatusBadRequest)) + return + } + + if (licenseStatus.Status != status.STATUS_ACTIVE) && (licenseStatus.Status != status.STATUS_READY) { + problem.Error(w, r, problem.Problem{Detail: "License is not active"}, http.StatusBadRequest) + logging.WriteToFile(complianceTestNumber, RENEW_LICENSE, strconv.Itoa(http.StatusBadRequest)) + return + } + + //check if device is active + if deviceId != "" { + deviceStatus, err := s.Transactions().CheckDeviceStatus(licenseStatus.Id, deviceId) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + logging.WriteToFile(complianceTestNumber, RENEW_LICENSE, strconv.Itoa(http.StatusInternalServerError)) + return + } + if deviceStatus != status.TYPE_REGISTER && deviceStatus != status.TYPE_RENEW { // deviceStatus == "" || deviceStatus == status.TYPE_RETURN + problem.Error(w, r, problem.Problem{Detail: "The device is not active for this license"}, http.StatusBadRequest) + logging.WriteToFile(complianceTestNumber, RENEW_LICENSE, strconv.Itoa(http.StatusBadRequest)) + return + } + } + + if licenseStatus.PotentialRights == nil || licenseStatus.PotentialRights.End == nil || (*licenseStatus.PotentialRights.End).IsZero() { + problem.Error(w, r, problem.Problem{Detail: "Potential rights end not set"}, http.StatusInternalServerError) + logging.WriteToFile(complianceTestNumber, RENEW_LICENSE, strconv.Itoa(http.StatusInternalServerError)) + return + } + + var suggestedEnd time.Time + //suggestedEnd = time.Now() // isZero() is default value + + //set new date for potential_rights_end + //if request parameter 'end' is empty, it used renew_days parameter from config + timeEndString := r.FormValue("end") + if timeEndString == "" { + renewDays := config.Config.LicenseStatus.RenewDays + if renewDays == 0 { + problem.Error(w, r, problem.Problem{Detail: "renew_days not found"}, http.StatusInternalServerError) + logging.WriteToFile(complianceTestNumber, RENEW_LICENSE, strconv.Itoa(http.StatusInternalServerError)) + return + } + + var suggestedDuration time.Duration + //suggestedDuration = time.Duration(0) + + suggestedDuration = 24 * time.Hour * time.Duration(renewDays) // nanoseconds + + if licenseStatus.CurrentEndLicense != nil && !(*licenseStatus.CurrentEndLicense).IsZero() { + suggestedEnd = (*licenseStatus.CurrentEndLicense).Add(time.Duration(suggestedDuration)) + } else { + //suggestedEnd = time.Now().Add(time.Duration(suggestedDuration)) + + problem.Error(w, r, problem.Problem{Detail: "CurrentEndLicense for LSD License Status is not set"}, http.StatusInternalServerError) + logging.WriteToFile(complianceTestNumber, RENEW_LICENSE, strconv.Itoa(http.StatusInternalServerError)) + return + } + } else { + expirationEnd, err := time.Parse(time.RFC3339, timeEndString) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + logging.WriteToFile(complianceTestNumber, RENEW_LICENSE, strconv.Itoa(http.StatusInternalServerError)) + return + } + + suggestedEnd = expirationEnd + } + + if suggestedEnd.After(*licenseStatus.PotentialRights.End) { + problem.Error(w, r, problem.Problem{Detail: "attempt to renew with date greater than potential rights end"}, http.StatusForbidden) + logging.WriteToFile(complianceTestNumber, RENEW_LICENSE, strconv.Itoa(http.StatusForbidden)) + return + } + + if suggestedEnd.Before(time.Now()) { + problem.Error(w, r, problem.Problem{Detail: "attempt to renew with date before now"}, http.StatusForbidden) + logging.WriteToFile(complianceTestNumber, RENEW_LICENSE, strconv.Itoa(http.StatusForbidden)) + return + } + + event := makeEvent(status.TYPE_RENEW, deviceName, deviceId, licenseStatus.Id) + + err = s.Transactions().Add(*event, 3) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + logging.WriteToFile(complianceTestNumber, RENEW_LICENSE, strconv.Itoa(http.StatusInternalServerError)) + return + } + + //update license using LCP Server + httpStatusCode, errorr := updateLicense(suggestedEnd, licenseFk) + if errorr != nil { + problem.Error(w, r, problem.Problem{Detail: errorr.Error()}, http.StatusInternalServerError) + logging.WriteToFile(complianceTestNumber, RENEW_LICENSE, strconv.Itoa(http.StatusInternalServerError)) + return + } + if httpStatusCode != http.StatusOK && httpStatusCode != http.StatusPartialContent { // 200, 206 + errorr = errors.New("LCP license PATCH returned HTTP error code " + strconv.Itoa(httpStatusCode)) + + problem.Error(w, r, problem.Problem{Detail: errorr.Error()}, httpStatusCode) + logging.WriteToFile(complianceTestNumber, RENEW_LICENSE, strconv.Itoa(httpStatusCode)) + return + } + licenseStatus.CurrentEndLicense = &suggestedEnd + + //update license status fields + licenseStatus.Updated.Status = &event.Timestamp + licenseStatus.Updated.License = &event.Timestamp + licenseStatus.Status = status.STATUS_ACTIVE + + err = s.LicenseStatuses().Update(*licenseStatus) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + logging.WriteToFile(complianceTestNumber, RENEW_LICENSE, strconv.Itoa(http.StatusInternalServerError)) + return + } + + err = fillLicenseStatus(licenseStatus, r, s) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + logging.WriteToFile(complianceTestNumber, RENEW_LICENSE, strconv.Itoa(http.StatusInternalServerError)) + return + } + + 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) + logging.WriteToFile(complianceTestNumber, RENEW_LICENSE, strconv.Itoa(http.StatusInternalServerError)) + return + } + + logging.WriteToFile(complianceTestNumber, RENEW_LICENSE, strconv.Itoa(http.StatusOK)) +} + +//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) { + w.Header().Set("Content-Type", api.ContentType_JSON) + + // Get request parameters. If not defined, set default values + rDevices := r.FormValue("devices") + if rDevices == "" { + rDevices = "1" + } + + rPage := r.FormValue("page") + if rPage == "" { + rPage = "1" + } + + rPerPage := r.FormValue("per_page") + if rPerPage == "" { + rPerPage = "10" + } + + devicesLimit, err := strconv.ParseInt(rDevices, 10, 32) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + return + } + + page, err := strconv.ParseInt(rPage, 10, 32) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + return + } + + perPage, err := strconv.ParseInt(rPerPage, 10, 32) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) + return + } + + if (page < 1) || (perPage < 1) || (devicesLimit < 1) { + problem.Error(w, r, problem.Problem{Detail: "Devices, page, per_page must be positive number"}, http.StatusBadRequest) + return + } + + page -= 1 + + licenseStatuses := make([]licensestatuses.LicenseStatus, 0) + + fn := s.LicenseStatuses().List(devicesLimit, perPage, page*perPage) + for it, err := fn(); err == nil; it, err = fn() { + licenseStatuses = append(licenseStatuses, it) + } + + devices := strconv.Itoa(int(devicesLimit)) + lsperpage := strconv.Itoa(int(perPage) + 1) + var resultLink string + + if len(licenseStatuses) > 0 { + nextPage := strconv.Itoa(int(page) + 1) + resultLink += "; rel=\"next\"; title=\"next\"" + } + + if page > 0 { + previousPage := strconv.Itoa(int(page) - 1) + if len(resultLink) > 0 { + resultLink += ", " + } + resultLink += "; rel=\"previous\"; title=\"previous\"" + } + + if len(resultLink) > 0 { + w.Header().Set("Link", resultLink) + } + + enc := json.NewEncoder(w) + err = enc.Encode(licenseStatuses) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + return + } +} + +//ListRegisteredDevices returns data about the use of a given license +func ListRegisteredDevices(w http.ResponseWriter, r *http.Request, s Server) { + w.Header().Set("Content-Type", api.ContentType_JSON) + + vars := mux.Vars(r) + licenseFk := vars["key"] + + licenseStatus, err := s.LicenseStatuses().GetByLicenseId(licenseFk) + if err != nil { + if licenseStatus == nil { + problem.NotFoundHandler(w, r) + //logging.WriteToFile(complianceTestNumber, REGISTER_DEVICE, strconv.Itoa(http.StatusNotFound)) + return + } + + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + return + } + + registeredDevicesList := transactions.RegisteredDevicesList{Devices: make([]transactions.Device, 0), Id: licenseStatus.LicenseRef} + + fn := s.Transactions().ListRegisteredDevices(licenseStatus.Id) + for it, err := fn(); err == nil; it, err = fn() { + registeredDevicesList.Devices = append(registeredDevicesList.Devices, it) + } + + enc := json.NewEncoder(w) + err = enc.Encode(registeredDevicesList) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + return + } +} + +//CancelLicenseStatus cancel or revoke (according to the status) a license +func CancelLicenseStatus(w http.ResponseWriter, r *http.Request, s Server) { + vars := mux.Vars(r) + licenseFk := vars["key"] + + licenseStatus, err := s.LicenseStatuses().GetByLicenseId(licenseFk) + + if err != nil { + if licenseStatus == nil { + problem.NotFoundHandler(w, r) + logging.WriteToFile(complianceTestNumber, CANCEL_REVOKE_LICENSE, strconv.Itoa(http.StatusNotFound)) + return + } + + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + logging.WriteToFile(complianceTestNumber, CANCEL_REVOKE_LICENSE, strconv.Itoa(http.StatusInternalServerError)) + return + } + + if licenseStatus.Status != status.STATUS_READY { + problem.Error(w, r, problem.Problem{Detail: "The new status is not compatible with current status"}, http.StatusBadRequest) + logging.WriteToFile(complianceTestNumber, CANCEL_REVOKE_LICENSE, strconv.Itoa(http.StatusBadRequest)) + return + } + + var parsedLs licensestatuses.LicenseStatus + err = decodeJsonLicenseStatus(r, &parsedLs) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + logging.WriteToFile(complianceTestNumber, CANCEL_REVOKE_LICENSE, strconv.Itoa(http.StatusInternalServerError)) + return + } + + currentTime := time.Now() + + //update license using LCP Server + httpStatusCode, errorr := updateLicense(currentTime, licenseFk) + if errorr != nil { + problem.Error(w, r, problem.Problem{Detail: errorr.Error()}, http.StatusInternalServerError) + logging.WriteToFile(complianceTestNumber, CANCEL_REVOKE_LICENSE, strconv.Itoa(http.StatusInternalServerError)) + return + } + if httpStatusCode != http.StatusOK && httpStatusCode != http.StatusPartialContent { // 200, 206 + errorr = errors.New("LCP license PATCH returned HTTP error code " + strconv.Itoa(httpStatusCode)) + + problem.Error(w, r, problem.Problem{Detail: errorr.Error()}, httpStatusCode) + logging.WriteToFile(complianceTestNumber, CANCEL_REVOKE_LICENSE, strconv.Itoa(httpStatusCode)) + return + } + licenseStatus.CurrentEndLicense = ¤tTime + + licenseStatus.Status = parsedLs.Status + licenseStatus.Updated.Status = ¤tTime + licenseStatus.Updated.License = ¤tTime + + err = s.LicenseStatuses().Update(*licenseStatus) + if err != nil { + problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) + logging.WriteToFile(complianceTestNumber, CANCEL_REVOKE_LICENSE, strconv.Itoa(http.StatusInternalServerError)) + return + } + + logging.WriteToFile(complianceTestNumber, CANCEL_REVOKE_LICENSE, strconv.Itoa(http.StatusOK)) +} + +//makeLicenseStatus sets fields of license status according to the config file +//and creates needed inner objects of license status +func makeLicenseStatus(license license.License, ls *licensestatuses.LicenseStatus) { + ls.LicenseRef = license.Id + + registerAvailable := config.Config.LicenseStatus.Register + + if license.Rights == nil || license.Rights.End == nil { + // The publication was purchased (not a loan), so we do not set LSD.PotentialRights.End + ls.CurrentEndLicense = nil + } else { + // license.Rights.End exists => this is a loan + endFromLicense := license.Rights.End.Add(0) + ls.CurrentEndLicense = &endFromLicense + ls.PotentialRights = new(licensestatuses.PotentialRights) + + rentingDays := config.Config.LicenseStatus.RentingDays + if rentingDays > 0 { + endFromConfig := license.Issued.Add(time.Hour * 24 * time.Duration(rentingDays)) + + if endFromLicense.After(endFromConfig) { + ls.PotentialRights.End = &endFromLicense + } else { + ls.PotentialRights.End = &endFromConfig + } + } else { + ls.PotentialRights.End = &endFromLicense + } + } + + if registerAvailable { + ls.Status = status.STATUS_READY + } else { + ls.Status = status.STATUS_ACTIVE + } + + ls.Updated = new(licensestatuses.Updated) + ls.Updated.License = &license.Issued + + currentTime := time.Now() + ls.Updated.Status = ¤tTime + + count := 0 + ls.DeviceCount = &count +} + +//getEvents gets the events from database for the license status +func getEvents(ls *licensestatuses.LicenseStatus, s Server) error { + events := make([]transactions.Event, 0) + + fn := s.Transactions().GetByLicenseStatusId(ls.Id) + var err error + var event transactions.Event + for event, err = fn(); err == nil; event, err = fn() { + events = append(events, event) + } + + if err == transactions.NotFound { + ls.Events = events + err = nil + } + + return err +} + +//makeLinks creates and adds links to the license status +func makeLinks(ls *licensestatuses.LicenseStatus) { + lsdBaseUrl := config.Config.LsdServer.PublicBaseUrl + licenseLinkUrl := config.Config.LsdServer.LicenseLinkUrl + lcpBaseUrl := config.Config.LcpServer.PublicBaseUrl + //frontendBaseUrl := config.Config.FrontendServer.PublicBaseUrl + registerAvailable := config.Config.LicenseStatus.Register + + licenseHasRightsEnd := ls.CurrentEndLicense != nil && !(*ls.CurrentEndLicense).IsZero() + returnAvailable := config.Config.LicenseStatus.Return && licenseHasRightsEnd + renewAvailable := config.Config.LicenseStatus.Renew && licenseHasRightsEnd + + links := new([]licensestatuses.Link) + + if licenseLinkUrl != "" { + licenseLinkUrl_ := strings.Replace(licenseLinkUrl, "{license_id}", ls.LicenseRef, -1) + link := licensestatuses.Link{Href: licenseLinkUrl_, Rel: "license", Type: api.ContentType_LCP_JSON, Templated: false} + *links = append(*links, link) + } else { + link := licensestatuses.Link{Href: lcpBaseUrl + "/licenses/" + ls.LicenseRef, Rel: "license", Type: api.ContentType_LCP_JSON, Templated: false} + *links = append(*links, link) + } + + if registerAvailable { + link := licensestatuses.Link{Href: lsdBaseUrl + "/licenses/" + ls.LicenseRef + "/register{?id,name}", Rel: "register", Type: api.ContentType_LSD_JSON, Templated: true} + *links = append(*links, link) + } + + if returnAvailable { + link := licensestatuses.Link{Href: lsdBaseUrl + "/licenses/" + ls.LicenseRef + "/return{?id,name}", Rel: "return", Type: api.ContentType_LSD_JSON, Templated: true} + *links = append(*links, link) + } + + if renewAvailable { + link := licensestatuses.Link{Href: lsdBaseUrl + "/licenses/" + ls.LicenseRef + "/renew{?end,id,name}", Rel: "renew", Type: api.ContentType_LSD_JSON, Templated: true} + *links = append(*links, link) + } + + ls.Links = *links +} + +//makeEvent creates an event and fill it +func makeEvent(status string, deviceName string, deviceId string, licenseStatusFk int) *transactions.Event { + event := transactions.Event{} + event.DeviceId = deviceId + event.DeviceName = deviceName + event.Timestamp = time.Now() + event.Type = status + event.LicenseStatusFk = licenseStatusFk + + return &event +} + +//decodeJsonLicenseStatus decodes license status json to the object +func decodeJsonLicenseStatus(r *http.Request, ls *licensestatuses.LicenseStatus) error { + var dec *json.Decoder + + if ctype := r.Header["Content-Type"]; len(ctype) > 0 && ctype[0] == api.ContentType_FORM_URL_ENCODED { + buf := bytes.NewBufferString(r.PostFormValue("data")) + dec = json.NewDecoder(buf) + } else { + dec = json.NewDecoder(r.Body) + } + + err := dec.Decode(&ls) + + return err +} + +//updateLicense updates license using LCP Server +func updateLicense(timeEnd time.Time, licenseRef string) (int, error) { + + lcpBaseUrl := config.Config.LcpServer.PublicBaseUrl + if len(lcpBaseUrl) <= 0 { + return 0, errors.New("Undefined Config.LcpServer.PublicBaseUrl") + } + + l := license.License{Id: licenseRef, Rights: new(license.UserRights)} + l.Rights.End = &timeEnd + + var lcpClient = &http.Client{ + Timeout: time.Second * 10, + } + pr, pw := io.Pipe() + go func() { + _ = json.NewEncoder(pw).Encode(l) + pw.Close() + }() + req, err := http.NewRequest("PATCH", lcpBaseUrl+"/licenses/"+l.Id, pr) + if err != nil { + return 0, err + } + + updateAuth := config.Config.LcpUpdateAuth + + if updateAuth.Username != "" { + req.SetBasicAuth(updateAuth.Username, updateAuth.Password) + } + + req.Header.Add("Content-Type", api.ContentType_LCP_JSON) + response, err := lcpClient.Do(req) + if err == nil { + if response.StatusCode != http.StatusOK { + log.Println("Notify Lcp Server of License (" + l.Id + ") = " + strconv.Itoa(response.StatusCode)) + } + return response.StatusCode, nil + } + + log.Println("Error Notify Lcp Server of License (" + l.Id + "):" + err.Error()) + return 0, err +} + +//fillLicenseStatus fills object 'links' and field 'message' in license status +func fillLicenseStatus(ls *licensestatuses.LicenseStatus, r *http.Request, s Server) error { + makeLinks(ls) + + acceptLanguages := r.Header.Get("Accept-Language") + localization.LocalizeMessage(acceptLanguages, &ls.Message, ls.Status) + + err := getEvents(ls, s) + + return err +} diff --git a/lsdserver/lsdserver.go b/lsdserver/lsdserver.go new file mode 100644 index 00000000..27a26526 --- /dev/null +++ b/lsdserver/lsdserver.go @@ -0,0 +1,163 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package main + +import ( + "database/sql" + "fmt" + "log" + "os" + "os/signal" + "runtime" + "strconv" + "strings" + "syscall" + + "github.com/abbot/go-http-auth" + _ "github.com/go-sql-driver/mysql" + _ "github.com/lib/pq" + _ "github.com/mattn/go-sqlite3" + + "github.com/readium/readium-lcp-server/config" + "github.com/readium/readium-lcp-server/license_statuses" + "github.com/readium/readium-lcp-server/localization" + "github.com/readium/readium-lcp-server/logging" + "github.com/readium/readium-lcp-server/lsdserver/server" + "github.com/readium/readium-lcp-server/transactions" +) + +func dbFromURI(uri string) (string, string) { + parts := strings.Split(uri, "://") + return parts[0], parts[1] +} + +func main() { + var config_file, dbURI string + var readonly bool = false + var err error + + if config_file = os.Getenv("READIUM_LICENSE_CONFIG"); config_file == "" { + config_file = "config.yaml" + } + + config.ReadConfig(config_file) + + err = localization.InitTranslations() + if err != nil { + panic(err) + } + + readonly = config.Config.LsdServer.ReadOnly + + err = config.SetPublicUrls() + if err != nil { + panic(err) + } + + if dbURI = config.Config.LsdServer.Database; dbURI == "" { + dbURI = "sqlite3://file:test.sqlite?cache=shared&mode=rwc" + } + + driver, cnxn := dbFromURI(dbURI) + db, err := sql.Open(driver, cnxn) + if err != nil { + panic(err) + } + if driver == "sqlite3" { + _, err = db.Exec("PRAGMA journal_mode = WAL") + if err != nil { + panic(err) + } + } + + hist, err := licensestatuses.Open(db) + if err != nil { + panic(err) + } + + trns, err := transactions.Open(db) + if err != nil { + panic(err) + } + + authFile := config.Config.LsdServer.AuthFile + if authFile == "" { + panic("Must have passwords file") + } + + _, err = os.Stat(authFile) + if err != nil { + panic(err) + } + + htpasswd := auth.HtpasswdFileProvider(authFile) + authenticator := auth.NewBasicAuthenticator("Basic Realm", htpasswd) + + complianceMode := config.Config.Logging.ComplianceTestsModeOn + logDirectory := config.Config.Logging.LogDirectory + err = logging.Init(logDirectory, complianceMode) + if err != nil { + panic(err) + } + + HandleSignals() + + parsedPort := strconv.Itoa(config.Config.LsdServer.Port) + s := lsdserver.New(":"+parsedPort, readonly, complianceMode, &hist, &trns, authenticator, ) + if readonly { + log.Println("License status server running in readonly mode on port " + parsedPort) + } else { + log.Println("License status server running on port " + parsedPort) + } + log.Println("Using database " + dbURI) + log.Println("Public base URL=" + config.Config.LsdServer.PublicBaseUrl) + + if err := s.ListenAndServe(); err != nil { + log.Println("Error " + err.Error()) + } + +} + +func HandleSignals() { + sigChan := make(chan os.Signal) + go func() { + stacktrace := make([]byte, 1<<20) + for sig := range sigChan { + switch sig { + case syscall.SIGQUIT: + length := runtime.Stack(stacktrace, true) + fmt.Println(string(stacktrace[:length])) + case syscall.SIGINT: + fallthrough + case syscall.SIGTERM: + fmt.Println("Shutting down...") + os.Exit(0) + } + } + }() + + signal.Notify(sigChan, syscall.SIGQUIT, syscall.SIGINT, syscall.SIGTERM) +} diff --git a/lsdserver/server/server.go b/lsdserver/server/server.go new file mode 100644 index 00000000..f26f77cb --- /dev/null +++ b/lsdserver/server/server.go @@ -0,0 +1,118 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package lsdserver + +import ( + "net/http" + "time" + + "github.com/abbot/go-http-auth" + "github.com/gorilla/mux" + + "github.com/readium/readium-lcp-server/api" + "github.com/readium/readium-lcp-server/license_statuses" + "github.com/readium/readium-lcp-server/lsdserver/api" + "github.com/readium/readium-lcp-server/transactions" +) + +type Server struct { + http.Server + readonly bool + lst licensestatuses.LicenseStatuses + trns transactions.Transactions +} + +func (s *Server) LicenseStatuses() licensestatuses.LicenseStatuses { + return s.lst +} + +func (s *Server) Transactions() transactions.Transactions { + return s.trns +} + +func New(bindAddr string, readonly bool, complianceMode bool, lst *licensestatuses.LicenseStatuses, trns *transactions.Transactions, basicAuth *auth.BasicAuth) *Server { + + sr := api.CreateServerRouter("") + + s := &Server{ + Server: http.Server{ + Handler: sr.N, + Addr: bindAddr, + WriteTimeout: 15 * time.Second, + ReadTimeout: 15 * time.Second, + MaxHeaderBytes: 1 << 20, + }, + readonly: readonly, + lst: *lst, + trns: *trns, + } + + // Route.PathPrefix: http://www.gorillatoolkit.org/pkg/mux#Route.PathPrefix + // Route.Subrouter: http://www.gorillatoolkit.org/pkg/mux#Route.Subrouter + // Router.StrictSlash: http://www.gorillatoolkit.org/pkg/mux#Router.StrictSlash + + licenseRoutesPathPrefix := "/licenses" + licenseRoutes := sr.R.PathPrefix(licenseRoutesPathPrefix).Subrouter().StrictSlash(false) + + s.handlePrivateFunc(sr.R, licenseRoutesPathPrefix, apilsd.FilterLicenseStatuses, basicAuth).Methods("GET") + + s.handleFunc(licenseRoutes, "/{key}/status", apilsd.GetLicenseStatusDocument).Methods("GET") + + if complianceMode { + s.handleFunc(sr.R, "/compliancetest", apilsd.AddLogToFile).Methods("GET") + } + + s.handlePrivateFunc(licenseRoutes, "/{key}/registered", apilsd.ListRegisteredDevices, basicAuth).Methods("GET") + if !readonly { + s.handleFunc(licenseRoutes, "/{key}/register", apilsd.RegisterDevice).Methods("POST") + s.handleFunc(licenseRoutes, "/{key}/return", apilsd.LendingReturn).Methods("PUT") + s.handleFunc(licenseRoutes, "/{key}/renew", apilsd.LendingRenewal).Methods("PUT") + s.handlePrivateFunc(licenseRoutes, "/{key}/status", apilsd.CancelLicenseStatus, basicAuth).Methods("PATCH") + + s.handlePrivateFunc(sr.R, "/licenses", apilsd.CreateLicenseStatusDocument, basicAuth).Methods("PUT") + s.handlePrivateFunc(licenseRoutes, "/", apilsd.CreateLicenseStatusDocument, basicAuth).Methods("PUT") + } + + return s +} + +type HandlerFunc func(w http.ResponseWriter, r *http.Request, s apilsd.Server) + +func (s *Server) handleFunc(router *mux.Router, route string, fn HandlerFunc) *mux.Route { + return router.HandleFunc(route, func(w http.ResponseWriter, r *http.Request) { + fn(w, r, s) + }) +} + +type HandlerPrivateFunc func(w http.ResponseWriter, r *http.Request, s apilsd.Server) + +func (s *Server) handlePrivateFunc(router *mux.Router, route string, fn HandlerPrivateFunc, authenticator *auth.BasicAuth) *mux.Route { + return router.HandleFunc(route, func(w http.ResponseWriter, r *http.Request) { + if api.CheckAuth(authenticator, w, r) { + fn(w, r, s) + } + }) +} diff --git a/lsdserver/server/server_test.go b/lsdserver/server/server_test.go new file mode 100644 index 00000000..2d2db5ac --- /dev/null +++ b/lsdserver/server/server_test.go @@ -0,0 +1,33 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package lsdserver + +import ( + "testing" +) + +func TestSetup(t *testing.T) { +} diff --git a/main.go b/main.go deleted file mode 100644 index f279b3f5..00000000 --- a/main.go +++ /dev/null @@ -1,179 +0,0 @@ -package main - -import ( - "crypto/tls" - "database/sql" - "fmt" - "os" - "os/signal" - "path/filepath" - "runtime" - "strings" - "syscall" - - _ "github.com/go-sql-driver/mysql" - "github.com/kylelemons/go-gypsy/yaml" - _ "github.com/lib/pq" - _ "github.com/mattn/go-sqlite3" - "github.com/readium/readium-lcp-server/index" - "github.com/readium/readium-lcp-server/license" - "github.com/readium/readium-lcp-server/pack" - "github.com/readium/readium-lcp-server/server" - "github.com/readium/readium-lcp-server/storage" -) - -func dbFromURI(uri string) (string, string) { - parts := strings.Split(uri, "://") - return parts[0], parts[1] -} - -func main() { - var config_file, host, port, publicBaseUrl, dbURI, storagePath, certFile, privKeyFile, static string - var readonly bool = false - var err error - - if host = os.Getenv("HOST"); host == "" { - host, err = os.Hostname() - if err != nil { - panic(err) - } - } - - if config_file = os.Getenv("READIUM_LCP_CONFIG"); config_file == "" { - config_file = "config.yaml" - } - - config, err := yaml.ReadFile(config_file) - if err != nil { - panic("can't read config file : " + config_file) - } - - readonly = os.Getenv("READONLY") != "" - - if port = os.Getenv("PORT"); port == "" { - port = "8989" - } - - publicBaseUrl, _ = config.Get("public_base_url") - if publicBaseUrl == "" { - publicBaseUrl = "http://" + host + ":" + port - } - - dbURI, _ = config.Get("database") - if dbURI == "" { - if dbURI = os.Getenv("DB"); dbURI == "" { - dbURI = "sqlite3://file:test.sqlite?cache=shared&mode=rwc" - } - } - - storagePath, _ = config.Get("storage.filesystem.storage") - if storagePath == "" { - if storagePath = os.Getenv("STORAGE"); storagePath == "" { - storagePath = "files" - } - } - - certFile, _ = config.Get("certificate.cert") - privKeyFile, _ = config.Get("certificate.private_key") - - if certFile == "" { - if certFile = os.Getenv("CERT"); certFile == "" { - panic("Must specify a certificate") - } - } - - if privKeyFile == "" { - if privKeyFile = os.Getenv("PRIVATE_KEY"); privKeyFile == "" { - panic("Must specify a private key") - } - } - - cert, err := tls.LoadX509KeyPair(certFile, privKeyFile) - if err != nil { - panic(err) - } - - driver, cnxn := dbFromURI(dbURI) - db, err := sql.Open(driver, cnxn) - if err != nil { - panic(err) - } - if driver == "sqlite3" { - _, err = db.Exec("PRAGMA journal_mode = WAL") - if err != nil { - panic(err) - } - } - idx, err := index.Open(db) - if err != nil { - panic(err) - } - - lst, err := license.NewSqlStore(db) - if err != nil { - panic(err) - } - - var store storage.Store - - if mode, _ := config.Get("storage.mode"); mode == "s3" { - s3Conf := s3ConfigFromYAML(config) - store, _ = storage.S3(s3Conf) - } else { - os.Mkdir(storagePath, os.ModePerm) //ignore the error, the folder can already exist - store = storage.NewFileSystem(storagePath, publicBaseUrl+"/files") - } - - packager := pack.NewPackager(store, idx, 4) - - static, _ = config.Get("static.directory") - if static == "" { - _, file, _, _ := runtime.Caller(0) - here := filepath.Dir(file) - static = filepath.Join(here, "/static") - } - - HandleSignals() - - s := server.New(":"+port, static, readonly, &idx, &store, &lst, &cert, packager) - s.ListenAndServe() - -} - -func HandleSignals() { - sigChan := make(chan os.Signal) - go func() { - stacktrace := make([]byte, 1<<20) - for sig := range sigChan { - switch sig { - case syscall.SIGQUIT: - length := runtime.Stack(stacktrace, true) - fmt.Println(string(stacktrace[:length])) - case syscall.SIGINT: - fallthrough - case syscall.SIGTERM: - fmt.Println("Shutting down...") - os.Exit(0) - } - } - }() - signal.Notify(sigChan, syscall.SIGQUIT, syscall.SIGINT, syscall.SIGTERM) -} - -func s3ConfigFromYAML(in *yaml.File) storage.S3Config { - config := storage.S3Config{} - - config.Id, _ = in.Get("storage.access_id") - config.Secret, _ = in.Get("storage.token") - config.Token, _ = in.Get("storage.secret") - - config.Endpoint, _ = in.Get("storage.endpoint") - config.Bucket, _ = in.Get("storage.bucket") - config.Region, _ = in.Get("storage.region") - - ssl, _ := in.GetBool("storage.disable_ssl") - config.DisableSSL = ssl - config.ForcePathStyle, _ = in.GetBool("storage.path_style") - - return config -} diff --git a/messages/en-US.json b/messages/en-US.json new file mode 100644 index 00000000..dc3bae17 --- /dev/null +++ b/messages/en-US.json @@ -0,0 +1,39 @@ +[ + { + "id": "ready", + "translation":"Your license is ready for usage" + }, + { + "id": "active", + "translation":"Your license is actively used" + }, + { + "id": "cancelled", + "translation":"It was canсelled" + }, + { + "id": "expired", + "translation":"It was expired" + }, + { + "id": "revoked", + "translation": "It was revoked" + }, + { + "id": "returned", + "translation": "It was returned" + }, + { + "id": "Not Found", + "translation": "Page not found" + }, + { + "id": "Internal Server Error", + "translation": "Internal server error" + } + , + { + "id": "EOF", + "translation": "Unexpected end of file" + } +] \ No newline at end of file diff --git a/messages/ru-RU.json b/messages/ru-RU.json new file mode 100644 index 00000000..f3f4eea6 --- /dev/null +++ b/messages/ru-RU.json @@ -0,0 +1,34 @@ +[ + { + "id": "ready", + "translation":"Готово к использованию" + }, + { + "id": "active", + "translation":"Активно" + }, + { + "id": "cancelled", + "translation":"Отменено" + }, + { + "id": "expired", + "translation":"Истекло" + }, + { + "id": "revoked", + "translation": "Отозвано" + }, + { + "id": "returned", + "translation": "Возвращено" + }, + { + "id": "Not Found", + "translation": "Страница не найдена" + }, + { + "id": "Internal Server Error", + "translation": "Внутренняя ошибка сервера" + } +] \ No newline at end of file diff --git a/pack/pack.go b/pack/pack.go index 58c86a2d..bf8e5bac 100644 --- a/pack/pack.go +++ b/pack/pack.go @@ -1,3 +1,28 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + package pack import ( @@ -12,8 +37,8 @@ import ( "github.com/readium/readium-lcp-server/xmlenc" ) -func Do(ep epub.Epub, w io.Writer) (enc *xmlenc.Manifest, key []byte, err error) { - key, err = crypto.GenerateKey() +func Do(encrypter crypto.Encrypter, ep epub.Epub, w io.Writer) (enc *xmlenc.Manifest, key crypto.ContentKey, err error) { + key, err = encrypter.GenerateKey() if err != nil { return } @@ -27,7 +52,7 @@ func Do(ep epub.Epub, w io.Writer) (enc *xmlenc.Manifest, key []byte, err error) for _, res := range ep.Resource { if _, alreadyEncrypted := ep.Encryption.DataForFile(res.Path); !alreadyEncrypted && canEncrypt(res, ep) { toCompress := mustCompressBeforeEncryption(*res, ep) - err = encryptFile(key, ep.Encryption, res, toCompress, ew) + err = encryptFile(encrypter, key, ep.Encryption, res, toCompress, ew) if err != nil { return } @@ -65,9 +90,9 @@ func canEncrypt(file *epub.Resource, ep epub.Epub) bool { return ep.CanEncrypt(file.Path) } -func encryptFile(key []byte, m *xmlenc.Manifest, file *epub.Resource, compress bool, w *epub.Writer) error { +func encryptFile(encrypter crypto.Encrypter, key []byte, m *xmlenc.Manifest, file *epub.Resource, compress bool, w *epub.Writer) error { data := xmlenc.Data{} - data.Method.Algorithm = "http://www.w3.org/2001/04/xmlenc#aes256-cbc" + data.Method.Algorithm = xmlenc.URI(encrypter.Signature()) data.KeyInfo = &xmlenc.KeyInfo{} data.KeyInfo.RetrievalMethod.URI = "license.lcpl#/encryption/content_key" data.KeyInfo.RetrievalMethod.Type = "http://readium.org/2014/01/lcp#EncryptedContentKey" @@ -108,21 +133,7 @@ func encryptFile(key []byte, m *xmlenc.Manifest, file *epub.Resource, compress b if err != nil { return err } - return crypto.Encrypt(key, input, fw) -} - -func Undo(key []byte, ep epub.Epub) (epub.Epub, error) { - for _, data := range ep.Encryption.Data { - if res, ok := findFile(string(data.CipherData.CipherReference.URI), ep); ok { - var buf bytes.Buffer - crypto.Decrypt(key, res.Contents, &buf) - res.Contents = &buf - } - } - - ep.Encryption = nil - - return ep, nil + return encrypter.Encrypt(key, input, fw) } func findFile(name string, ep epub.Epub) (*epub.Resource, bool) { diff --git a/pack/pack_test.go b/pack/pack_test.go index e2b625be..ec7fc4d5 100644 --- a/pack/pack_test.go +++ b/pack/pack_test.go @@ -1,3 +1,28 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + package pack import ( @@ -35,7 +60,8 @@ func TestPacking(t *testing.T) { inputRes.Contents = bytes.NewReader(inputBytes) buf := new(bytes.Buffer) - encryption, key, err := Do(input, buf) + encrypter := crypto.NewAESEncrypter_PUBLICATION_RESOURCES() + encryption, key, err := Do(encrypter, input, buf) if err != nil { t.Fatal(err) } @@ -92,13 +118,16 @@ func TestPacking(t *testing.T) { } var buf bytes.Buffer - crypto.Decrypt(key, res.Contents, &buf) - if outputBytes, err := ioutil.ReadAll(flate.NewReader(&buf)); err != nil { - t.Fatalf("Could not decompress data from %s", htmlFilePath) + if decrypter, ok := encrypter.(crypto.Decrypter); !ok { + t.Errorf("Could not decrypt file") } else { - - if !bytes.Equal(inputBytes, outputBytes) { - t.Errorf("Expected the files to be equal before and after") + decrypter.Decrypt(key, res.Contents, &buf) + if outputBytes, err := ioutil.ReadAll(flate.NewReader(&buf)); err != nil { + t.Fatalf("Could not decompress data from %s", htmlFilePath) + } else { + if !bytes.Equal(inputBytes, outputBytes) { + t.Errorf("Expected the files to be equal before and after") + } } } } diff --git a/pack/pipeline.go b/pack/pipeline.go index dd8d77fb..4b3fd6f4 100644 --- a/pack/pipeline.go +++ b/pack/pipeline.go @@ -1,16 +1,45 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + package pack import ( "archive/zip" + "crypto/sha256" + "encoding/hex" "io" "io/ioutil" "os" "time" + "github.com/satori/go.uuid" + + "github.com/readium/readium-lcp-server/crypto" "github.com/readium/readium-lcp-server/epub" "github.com/readium/readium-lcp-server/index" "github.com/readium/readium-lcp-server/storage" - "github.com/satori/go.uuid" ) type Source interface { @@ -24,6 +53,12 @@ type Task struct { done chan Result } +type EncryptedFileInfo struct { + File *os.File + Size int64 + Sha256 string +} + func NewTask(name string, body io.ReaderAt, size int64) *Task { return &Task{Name: name, Body: body, Size: size, done: make(chan Result, 1)} } @@ -57,10 +92,10 @@ func (s *ManualSource) Post(t *Task) Result { } type Packager struct { - Incoming chan *Task - done chan struct{} - store storage.Store - idx index.Index + Incoming chan *Task + done chan struct{} + store storage.Store + idx index.Index } func (p Packager) work() { @@ -70,8 +105,8 @@ func (p Packager) work() { zr := p.readZip(&r, t.Body, t.Size) epub := p.readEpub(&r, zr) encrypted, key := p.encrypt(&r, epub) - p.addToStore(&r, encrypted) - p.addToIndex(&r, key, t.Name) + p.addToStore(&r, encrypted.File) + p.addToIndex(&r, key, t.Name, encrypted.Size, encrypted.Sha256) t.Done(r) } @@ -106,24 +141,34 @@ func (p Packager) readEpub(r *Result, zr *zip.Reader) epub.Epub { return ep } -func (p Packager) encrypt(r *Result, ep epub.Epub) (*os.File, []byte) { +func (p Packager) encrypt(r *Result, ep epub.Epub) (*EncryptedFileInfo, []byte) { if r.Error != nil { return nil, nil } - - file, err := ioutil.TempFile(os.TempDir(), "out-readium-lcp") - + tmpFile, err := ioutil.TempFile(os.TempDir(), "out-readium-lcp") if err != nil { r.Error = err return nil, nil } - - _, key, err := Do(ep, file) + encrypter := crypto.NewAESEncrypter_PUBLICATION_RESOURCES() + _, key, err := Do(encrypter, ep, tmpFile) r.Error = err + var encryptedFileInfo EncryptedFileInfo + encryptedFileInfo.File = tmpFile + //get file length & hash (sha256) + hasher := sha256.New() + encryptedFileInfo.File.Seek(0, 0) + written, err := io.Copy(hasher, encryptedFileInfo.File) + //hasher.Write(s) + if err != nil { + r.Error = err + return nil, nil + } + encryptedFileInfo.Size = written + encryptedFileInfo.Sha256 = hex.EncodeToString(hasher.Sum(nil)) - file.Seek(0, 0) - - return file, key + encryptedFileInfo.File.Seek(0, 0) + return &encryptedFileInfo, key } func (p Packager) addToStore(r *Result, f *os.File) { @@ -137,20 +182,20 @@ func (p Packager) addToStore(r *Result, f *os.File) { os.Remove(f.Name()) } -func (p Packager) addToIndex(r *Result, key []byte, name string) { +func (p Packager) addToIndex(r *Result, key []byte, name string, contentSize int64, contentHash string) { if r.Error != nil { return } - r.Error = p.idx.Add(index.Package{r.Id, key, name}) + r.Error = p.idx.Add(index.Content{r.Id, key, name, contentSize, contentHash}) } func NewPackager(store storage.Store, idx index.Index, concurrency int) *Packager { packager := Packager{ - Incoming: make(chan *Task), - done: make(chan struct{}), - store: store, - idx: idx, + Incoming: make(chan *Task), + done: make(chan struct{}), + store: store, + idx: idx, } for i := 0; i < concurrency; i++ { diff --git a/problem/problem.go b/problem/problem.go new file mode 100644 index 00000000..72f9b939 --- /dev/null +++ b/problem/problem.go @@ -0,0 +1,134 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package problem + +// rfc 7807 : https://tools.ietf.org/html/rfc7807 +// problem.Type should be an URI +// for example http://readium.org/readium/[lcpserver|lsdserver]/ +// for standard http error messages use "about:blank" status in json equals http status +import ( + "strings" + "log" + "encoding/json" + "fmt" + "net/http" + "runtime/debug" + + "github.com/technoweenie/grohl" + + "github.com/readium/readium-lcp-server/localization" +) + +const ( + ContentType_PROBLEM_JSON = "application/problem+json" +) + +type Problem struct { + Type string `json:"type,omitempty"` + //optionnal + Title string `json:"title,omitempty"` + Status int `json:"status,omitempty"` //if present = http response code + Detail string `json:"detail,omitempty"` + Instance string `json:"instance,omitempty"` + //Additional members +} + +const ERROR_BASE_URL = "http://readium.org/license-status-document/error/" +const SERVER_INTERNAL_ERROR = ERROR_BASE_URL + "server" +const REGISTRATION_BAD_REQUEST = ERROR_BASE_URL + "registration" +const RETURN_BAD_REQUEST = ERROR_BASE_URL + "return" +const RENEW_BAD_REQUEST = ERROR_BASE_URL + "renew" +const RENEW_REJECT = ERROR_BASE_URL + "renew/date" +const CANCEL_BAD_REQUEST = ERROR_BASE_URL + "cancel" +const FILTER_BAD_REQUEST = ERROR_BASE_URL + "filter" + +func Error(w http.ResponseWriter, r *http.Request, problem Problem, status int) { + acceptLanguages := r.Header.Get("Accept-Language") + + w.Header().Set("Content-Type", ContentType_PROBLEM_JSON) + w.Header().Set("X-Content-Type-Options", "nosniff") + + // must come *after* w.Header().Add()/Set(), but before w.Write() + w.WriteHeader(status) + + problem.Status = status + + if problem.Type == "about:blank" || problem.Type == "" { // lookup Title statusText should match http status + localization.LocalizeMessage(acceptLanguages, &problem.Title, http.StatusText(status)) + } else { + localization.LocalizeMessage(acceptLanguages, &problem.Title, problem.Title) + localization.LocalizeMessage(acceptLanguages, &problem.Detail, problem.Detail) + } + jsonError, e := json.Marshal(problem) + if e != nil { + http.Error(w, "{}", problem.Status) + } + fmt.Fprintln(w, string(jsonError)) + + PrintStack() + + log.Print(string(jsonError)) +} + +func PrintStack() { + log.Print("####################") + + //debug.PrintStack() + + b := debug.Stack() + s := string(b[:]) + l := strings.Index(s, "ServeHTTP") + if l > 0 { + ss := s[0:l] + log.Print(ss + " [...]") + } else { + log.Print(s) + } + + log.Print("####################") +} + +func NotFoundHandler(w http.ResponseWriter, r *http.Request) { + grohl.Log(grohl.Data{"method": r.Method, "path": r.URL.Path, "status": "404"}) + Error(w, r, Problem{}, http.StatusNotFound) +} + +func PanicReport(err interface{}) { + switch t := err.(type) { + case error: + errorr, found := err.(error) + if found { // should always be true + grohl.Log(grohl.Data{"panic recovery (error)": errorr.Error()}) + } + case string: + errorr, found := err.(string) + if found { // should always be true + grohl.Log(grohl.Data{"panic recovery (string)": errorr}) + } + default: + grohl.Log(grohl.Data{"panic recovery (other type)": t}) + } +} diff --git a/server/api/license.go b/server/api/license.go deleted file mode 100644 index 83e186b7..00000000 --- a/server/api/license.go +++ /dev/null @@ -1,212 +0,0 @@ -package api - -import ( - "archive/zip" - "bytes" - "crypto/sha256" - "crypto/tls" - "encoding/base64" - "encoding/json" - "fmt" - "reflect" - "strings" - - "github.com/gorilla/mux" - "github.com/readium/readium-lcp-server/crypto" - "github.com/readium/readium-lcp-server/epub" - "github.com/readium/readium-lcp-server/license" - "github.com/readium/readium-lcp-server/sign" - - "io" - "net/http" -) - -//{ -//"content_key": "12345", -//"date": "2013-11-04T01:08:15+01:00", -//"hint": "Enter your email address", -//"hint_url": "http://www.imaginaryebookretailer.com/lcp" -//} - -func GrantLicense(w http.ResponseWriter, r *http.Request, s Server) { - vars := mux.Vars(r) - var lic license.License - var dec *json.Decoder - if ctype := r.Header["Content-Type"]; len(ctype) > 0 && ctype[0] == "application/x-www-form-urlencoded" { - buf := bytes.NewBufferString(r.PostFormValue("data")) - dec = json.NewDecoder(buf) - } else { - dec = json.NewDecoder(r.Body) - } - err := dec.Decode(&lic) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if _, hintFound := lic.Links["hint"]; !hintFound { - http.Error(w, "hint url not set", http.StatusBadRequest) - return - } - - mode := r.PostFormValue("type") - key := vars["key"] - if mode == "embedded" { - item, err := s.Store().Get(key) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - indexItem, err := s.Index().Get(key) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - var b bytes.Buffer - contents, err := item.Contents() - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - io.Copy(&b, contents) - zr, err := zip.NewReader(bytes.NewReader(b.Bytes()), int64(b.Len())) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - ep, err := epub.Read(zr) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - var buf bytes.Buffer - err = grantLicense(&lic, key, false, s, &buf) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - err = s.Licenses().Add(lic) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - ep.Add("META-INF/license.lcpl", &buf, uint64(buf.Len())) - w.Header().Add("Content-Type", "application/epub+zip") - w.Header().Add("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, indexItem.Filename)) - ep.Write(w) - - } else { - w.Header().Add("Content-Type", "application/vnd.readium.lcp.license.1-0+json") - w.Header().Add("Content-Disposition", `attachment; filename="license.lcpl"`) - err = grantLicense(&lic, key, false, s, w) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - } -} - -func grantLicense(l *license.License, key string, embedded bool, s Server, w io.Writer) error { - p, err := s.Index().Get(key) - if err != nil { - return err - } - - item, err := s.Store().Get(key) - if err != nil { - return err - } - - license.Prepare(l) - - var encryptionKey []byte - if len(l.Encryption.UserKey.Value) > 0 { - encryptionKey = l.Encryption.UserKey.Value - l.Encryption.UserKey.Value = nil - } else { - passphrase := l.Encryption.UserKey.ClearValue - l.Encryption.UserKey.ClearValue = "" - hash := sha256.Sum256([]byte(passphrase)) - encryptionKey = hash[:] - } - - l.Encryption.ContentKey.Algorithm = "http://www.w3.org/2001/04/xmlenc#aes256-cbc" - l.Encryption.ContentKey.Value = encryptKey(p.EncryptionKey, encryptionKey[:]) - - l.Encryption.UserKey.Algorithm = "http://www.w3.org/2001/04/xmlenc#sha256" - l.Encryption.UserKey.Hint = "Enter your passphrase" - - if !embedded { - l.Links["publication"] = license.Link{Href: item.PublicUrl(), Type: "application/epub+zip"} - } - - err = encryptFields(l, encryptionKey[:]) - if err != nil { - return err - } - err = buildKeyCheck(l, encryptionKey[:]) - if err != nil { - return err - } - err = signLicense(l, s.Certificate()) - if err != nil { - return err - } - - enc := json.NewEncoder(w) - enc.Encode(l) - - return nil -} - -func buildKeyCheck(l *license.License, key []byte) error { - var out bytes.Buffer - err := crypto.Encrypt(key, bytes.NewBufferString(l.Id), &out) - if err != nil { - return err - } - l.Encryption.UserKey.Check = out.Bytes() - return nil -} - -func encryptFields(l *license.License, key []byte) error { - for _, toEncrypt := range l.User.Encrypted { - var out bytes.Buffer - field := getField(&l.User, toEncrypt) - err := crypto.Encrypt(key[:], bytes.NewBufferString(field.String()), &out) - if err != nil { - return err - } - field.Set(reflect.ValueOf(base64.StdEncoding.EncodeToString(out.Bytes()))) - } - return nil -} - -func getField(u *license.UserInfo, field string) reflect.Value { - v := reflect.ValueOf(u).Elem() - return v.FieldByName(strings.Title(field)) -} - -func signLicense(l *license.License, cert *tls.Certificate) error { - sig, err := sign.NewSigner(cert) - if err != nil { - return err - } - res, err := sig.Sign(l) - if err != nil { - return err - } - l.Signature = &res - - return nil -} - -func encryptKey(key []byte, kek []byte) []byte { - var out bytes.Buffer - in := bytes.NewReader(key) - crypto.Encrypt(kek[:], in, &out) - return out.Bytes() -} diff --git a/server/api/store.go b/server/api/store.go deleted file mode 100644 index f29c06cf..00000000 --- a/server/api/store.go +++ /dev/null @@ -1,87 +0,0 @@ -package api - -import ( - "crypto/tls" - "encoding/json" - "io" - "io/ioutil" - "os" - - "github.com/gorilla/mux" - "github.com/readium/readium-lcp-server/index" - "github.com/readium/readium-lcp-server/license" - "github.com/readium/readium-lcp-server/pack" - "github.com/readium/readium-lcp-server/storage" - - "net/http" -) - -type Server interface { - Store() storage.Store - Index() index.Index - Licenses() license.Store - Certificate() *tls.Certificate - Source() *pack.ManualSource -} - -func writeRequestFileToTemp(r io.Reader) (int64, *os.File, error) { - dir := os.TempDir() - file, err := ioutil.TempFile(dir, "readium-lcp") - if err != nil { - return 0, file, err - } - - n, err := io.Copy(file, r) - - // Rewind to the beginning of the file - file.Seek(0, 0) - - return n, file, err -} - -func cleanupTemp(f *os.File) { - if f == nil { - return - } - f.Close() - os.Remove(f.Name()) -} - -func StorePackage(w http.ResponseWriter, r *http.Request, s Server) { - vars := mux.Vars(r) - - size, f, err := writeRequestFileToTemp(r.Body) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - defer cleanupTemp(f) - - t := pack.NewTask(vars["name"], f, size) - result := s.Source().Post(t) - - if result.Error != nil { - http.Error(w, result.Error.Error(), http.StatusBadRequest) - return - } - - w.WriteHeader(200) - json.NewEncoder(w).Encode(result.Id) -} - -func ListPackages(w http.ResponseWriter, r *http.Request, s Server) { - fn := s.Index().List() - packages := make([]index.Package, 0) - - for it, err := fn(); err == nil; it, err = fn() { - packages = append(packages, it) - } - - enc := json.NewEncoder(w) - err := enc.Encode(packages) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - } - -} diff --git a/server/server.go b/server/server.go deleted file mode 100644 index cee9ce11..00000000 --- a/server/server.go +++ /dev/null @@ -1,99 +0,0 @@ -package server - -import ( - "crypto/tls" - "path/filepath" - - "github.com/gorilla/mux" - "github.com/readium/readium-lcp-server/index" - "github.com/readium/readium-lcp-server/license" - "github.com/readium/readium-lcp-server/pack" - "github.com/readium/readium-lcp-server/server/api" - "github.com/readium/readium-lcp-server/storage" - "github.com/technoweenie/grohl" - - "html/template" - "net/http" -) - -type Server struct { - http.Server - readonly bool - idx *index.Index - st *storage.Store - lst *license.Store - router *mux.Router - cert *tls.Certificate - source pack.ManualSource -} - -func (s *Server) Store() storage.Store { - return *s.st -} - -func (s *Server) Index() index.Index { - return *s.idx -} - -func (s *Server) Licenses() license.Store { - return *s.lst -} - -func (s *Server) Certificate() *tls.Certificate { - return s.cert -} - -func (s *Server) Source() *pack.ManualSource { - return &s.source -} - -func New(bindAddr string, tplPath string, readonly bool, idx *index.Index, st *storage.Store, lst *license.Store, cert *tls.Certificate, packager *pack.Packager) *Server { - r := mux.NewRouter() - s := &Server{ - Server: http.Server{ - Handler: r, - Addr: bindAddr, - }, - readonly: readonly, - idx: idx, - st: st, - lst: lst, - cert: cert, - router: r, - source: pack.ManualSource{}, - } - - s.source.Feed(packager.Incoming) - - manageIndex, err := template.ParseFiles(filepath.Join(tplPath, "/manage/index.html")) - if err != nil { - panic(err) - } - r.HandleFunc("/manage/", func(w http.ResponseWriter, r *http.Request) { - manageIndex.Execute(w, map[string]interface{}{}) - }) - r.Handle("/manage/{file}", http.FileServer(http.Dir("static"))) - - r.Handle("/files/{file}", http.StripPrefix("/files/", http.FileServer(http.Dir("files")))) - if !readonly { - s.handleFunc("/api/store/{name}", api.StorePackage).Methods("POST") - } - s.handleFunc("/api/packages", api.ListPackages).Methods("GET") - s.handleFunc("/api/packages/{key}/licenses", api.GrantLicense).Methods("POST") - r.Handle("/", http.NotFoundHandler()) - - return s -} - -type HandlerFunc func(w http.ResponseWriter, r *http.Request, s api.Server) - -func (s *Server) handleFunc(route string, fn HandlerFunc) *mux.Route { - return s.router.HandleFunc(route, func(w http.ResponseWriter, r *http.Request) { - grohl.Log(grohl.Data{"path": r.URL.Path}) - - // Add CORS - w.Header().Add("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE") - w.Header().Add("Access-Control-Allow-Origin", "*") - fn(w, r, s) - }) -} diff --git a/server/server_test.go b/server/server_test.go deleted file mode 100644 index 3e9856b4..00000000 --- a/server/server_test.go +++ /dev/null @@ -1,8 +0,0 @@ -package server - -import ( - "testing" -) - -func TestSetup(t *testing.T) { -} diff --git a/sign/canon.go b/sign/canon.go index ef6553ab..1191bbd2 100644 --- a/sign/canon.go +++ b/sign/canon.go @@ -1,6 +1,33 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + package sign import "encoding/json" +import "strings" +import "io" func Canon(in interface{}) ([]byte, error) { // the easiest way to canonicalize is to marshal it and reify it as a map @@ -10,12 +37,17 @@ func Canon(in interface{}) ([]byte, error) { return b, err } - temp := new(map[string]interface{}) + var jsonObj interface{} // map[string]interface{} ==> auto sorting - err = json.Unmarshal(b, temp) - if err != nil { - return b, err + dec := json.NewDecoder(strings.NewReader(string(b))) + dec.UseNumber() + for { + if er := dec.Decode(&jsonObj); er == io.EOF { + break + } else if er != nil { + return nil, er + } } - return json.Marshal(temp) + return json.Marshal(jsonObj) } diff --git a/sign/canon_test.go b/sign/canon_test.go index 319be2c6..01295fb6 100644 --- a/sign/canon_test.go +++ b/sign/canon_test.go @@ -1,3 +1,28 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + package sign import ( diff --git a/sign/sign.go b/sign/sign.go index ccc381f9..db05aabd 100644 --- a/sign/sign.go +++ b/sign/sign.go @@ -1,3 +1,28 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + package sign import ( diff --git a/sign/sign_test.go b/sign/sign_test.go index 6bf56000..8492bbb5 100644 --- a/sign/sign_test.go +++ b/sign/sign_test.go @@ -1,3 +1,28 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + package sign import ( diff --git a/static/manage/index.html b/static/manage/index.html deleted file mode 100644 index 8f2b53c4..00000000 --- a/static/manage/index.html +++ /dev/null @@ -1,198 +0,0 @@ - - - - - - - - - -

    LCPServer Admin

    -
     
    -

    Packages

    -
      - - -

      Emit a License

      -
      - -
      -
      -
      -
      -
      -
      -
      - - diff --git a/status/status.go b/status/status.go new file mode 100644 index 00000000..fd3e12cc --- /dev/null +++ b/status/status.go @@ -0,0 +1,98 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package status + +import ( + "strconv" + "strings" +) + +const ( + STATUS_READY = "ready" + STATUS_ACTIVE = "active" + STATUS_REVOKED = "revoked" + STATUS_RETURNED = "returned" + STATUS_CANCELLED = "cancelled" + STATUS_EXPIRED = "expired" + + TYPE_REGISTER = "register" + TYPE_RETURN = "return" + TYPE_RENEW = "renew" +) + +var statuses = map[int]string{ + 0: STATUS_READY, + 1: STATUS_ACTIVE, + 2: STATUS_REVOKED, + 3: STATUS_RETURNED, + 4: STATUS_CANCELLED, + 5: STATUS_EXPIRED, +} + +var Types = map[int]string{ + 1: TYPE_REGISTER, + 2: TYPE_RETURN, + 3: TYPE_RENEW, +} + +//GetStatus translate status number to status string +func GetStatus(statusDB int64, status *string) { + resultStr := reverse(strconv.FormatInt(statusDB, 2)) + + if count := strings.Count(resultStr, "1"); count == 1 { + index := strings.Index(resultStr, "1") + + if len(statuses) >= index+1 { + *status = statuses[index] + } + } +} + +//SetStatus translate status string to status number +func SetStatus(status string) (int64, error) { + reg := make([]string, len(statuses)) + + for key := range statuses { + if statuses[key] == status { + reg[key] = "1" + } else { + reg[key] = "0" + } + } + + resultStr := reverse(strings.Join(reg[:], "")) + + statusDB, err := strconv.ParseInt(resultStr, 2, 64) + return statusDB, err +} + +func reverse(s string) string { + r := []rune(s) + for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 { + r[i], r[j] = r[j], r[i] + } + return string(r) +} diff --git a/storage/fs.go b/storage/fs.go index 17de5170..e29e3c87 100644 --- a/storage/fs.go +++ b/storage/fs.go @@ -1,3 +1,28 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + package storage import ( diff --git a/storage/fs_test.go b/storage/fs_test.go index 0b80a713..feb1c690 100644 --- a/storage/fs_test.go +++ b/storage/fs_test.go @@ -1,3 +1,28 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + package storage import ( diff --git a/storage/interface.go b/storage/interface.go index 242c5163..d080fd99 100644 --- a/storage/interface.go +++ b/storage/interface.go @@ -1,3 +1,28 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + package storage import ( diff --git a/storage/s3.go b/storage/s3.go index 566ba331..56f552af 100644 --- a/storage/s3.go +++ b/storage/s3.go @@ -1,3 +1,28 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + package storage import ( diff --git a/tools/compliance_tests_tool/compliance_tests_tool.go b/tools/compliance_tests_tool/compliance_tests_tool.go new file mode 100644 index 00000000..7881d7b8 --- /dev/null +++ b/tools/compliance_tests_tool/compliance_tests_tool.go @@ -0,0 +1,90 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +//This tool is for testers to control and log the tests +package main + +import ( + "flag" + "fmt" + "log" + "net/http" + "os" + "strconv" + "strings" + "time" +) + +func main() { + lsdPublicBaseUrl := flag.String("lsdPublicBaseUrl", "", "public base url of lsd server") + flag.Parse() + + var testNumber string + var result string + + for { + fmt.Println("Enter the number of test, 'q' for quit the tool") + fmt.Scanln(&testNumber) + + if strings.EqualFold(testNumber, "q") { + os.Exit(0) + } + + notifyLsdServer(testNumber, "", "start", *lsdPublicBaseUrl) + + for { + fmt.Println("Enter the result of test ('e' if test has errors, 's' if test has success)") + fmt.Scanln(&result) + + if strings.EqualFold(result, "e") || strings.EqualFold(result, "s") { + notifyLsdServer(testNumber, result, "end", *lsdPublicBaseUrl) + break + } + } + } +} + +func notifyLsdServer(testNumber string, result string, testStage string, publicBaseUrl string) { + var lsdClient = &http.Client{ + Timeout: time.Second * 10, + } + + req, err := http.NewRequest("GET", publicBaseUrl+"/compliancetest", nil) + q := req.URL.Query() + q.Add("test_stage", testStage) + q.Add("test_number", testNumber) + q.Add("test_result", result) + req.URL.RawQuery = q.Encode() + + response, err := lsdClient.Do(req) + + if err != nil { + log.Println("Error Notify LsdServer of compliancetest: " + err.Error()) + } else { + if response.StatusCode != 200 { + log.Println("Notify LsdServer of compliancetest = " + strconv.Itoa(response.StatusCode)) + } + } +} diff --git a/tools/logs_parser/logs_parser.go b/tools/logs_parser/logs_parser.go new file mode 100644 index 00000000..ddab31e3 --- /dev/null +++ b/tools/logs_parser/logs_parser.go @@ -0,0 +1,58 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package main + +import ( + "flag" + "fmt" + + "github.com/readium/readium-lcp-server/logging" +) + +func main() { + logFilePath := flag.String("logfilepath", "", "path to .log file") + parsedFilePath := flag.String("parsedfilepath", "", "path to .log file") + + fmt.Println(parsedFilePath) + + flag.Parse() + + err := logging.Init(*logFilePath, true) + if err != nil { + panic(err) + } + + fmt.Println("Parsing log file...") + logs, err := logging.ReadLogs(*logFilePath) + summary, err := logging.CountTotal(logs) + + if err != nil { + panic(err) + } + + fmt.Println(logs) + fmt.Println(summary) +} diff --git a/transactions/transactions.go b/transactions/transactions.go new file mode 100644 index 00000000..347e7c60 --- /dev/null +++ b/transactions/transactions.go @@ -0,0 +1,202 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package transactions + +import ( + "database/sql" + "errors" + "time" + + "github.com/readium/readium-lcp-server/status" +) + +var NotFound = errors.New("Event not found") + +type Transactions interface { + Get(id int) (Event, error) + Add(e Event, typeEvent int) error + GetByLicenseStatusId(licenseStatusFk int) func() (Event, error) + CheckDeviceStatus(licenseStatusFk int, deviceId string) (string, error) + ListRegisteredDevices(licenseStatusFk int) func() (Device, error) +} + +type RegisteredDevicesList struct { + Id string `json:"id"` + Devices []Device `json:"devices"` +} + +type Device struct { + DeviceId string `json:"id"` + DeviceName string `json:"name"` + Timestamp time.Time `json:"timestamp"` +} + +type Event struct { + Id int `json:"-"` + DeviceName string `json:"name"` + Timestamp time.Time `json:"timestamp"` + Type string `json:"type"` + DeviceId string `json:"id"` + LicenseStatusFk int `json:"-"` +} + +type dbTransactions struct { + db *sql.DB + get *sql.Stmt + add *sql.Stmt + getbylicensestatusid *sql.Stmt + checkdevicestatus *sql.Stmt + listregistereddevices *sql.Stmt +} + +//Get returns event if it exists in table 'event' +func (i dbTransactions) Get(id int) (Event, error) { + records, err := i.get.Query(id) + var typeInt int + + defer records.Close() + if records.Next() { + var e Event + err = records.Scan(&e.Id, &e.DeviceName, &e.Timestamp, &typeInt, &e.DeviceId, &e.LicenseStatusFk) + if err == nil { + e.Type = status.Types[typeInt] + } + return e, err + } + + return Event{}, NotFound +} + +//Add adds event in database, parameter typeEvent is for field 'type' in table 'event' +//1 when register device, 2 when return and 3 when renew +func (i dbTransactions) Add(e Event, typeEvent int) error { + add, err := i.db.Prepare("INSERT INTO event (device_name, timestamp, type, device_id, license_status_fk) VALUES (?, ?, ?, ?, ?)") + + if err != nil { + return err + } + + defer add.Close() + _, err = add.Exec(e.DeviceName, e.Timestamp, typeEvent, e.DeviceId, e.LicenseStatusFk) + return err +} + +//GetByLicenseStatusId returns all events by licensestatus id +func (i dbTransactions) GetByLicenseStatusId(licenseStatusFk int) func() (Event, error) { + rows, err := i.getbylicensestatusid.Query(licenseStatusFk) + if err != nil { + return func() (Event, error) { return Event{}, err } + } + return func() (Event, error) { + var e Event + var err error + if rows.Next() { + err = rows.Scan(&e.Id, &e.DeviceName, &e.Timestamp, &e.Type, &e.DeviceId, &e.LicenseStatusFk) + } else { + rows.Close() + err = NotFound + } + return e, err + } +} + +//ListRegisteredDevices returns all devices which has status 'regitered' by licensestatus id +func (i dbTransactions) ListRegisteredDevices(licenseStatusFk int) func() (Device, error) { + rows, err := i.listregistereddevices.Query(licenseStatusFk) + if err != nil { + return func() (Device, error) { return Device{}, err } + } + return func() (Device, error) { + var d Device + var err error + if rows.Next() { + err = rows.Scan(&d.DeviceId, &d.DeviceName, &d.Timestamp) + } else { + rows.Close() + err = NotFound + } + return d, err + } +} + +//CheckDeviceStatus gets current status of device +//if there is no device in table 'event' by deviceId, typeString will be the empty string +func (i dbTransactions) CheckDeviceStatus(licenseStatusFk int, deviceId string) (string, error) { + var typeString string + var typeInt int + + row := i.checkdevicestatus.QueryRow(licenseStatusFk, deviceId) + err := row.Scan(&typeInt) + + if err == nil { + typeString = status.Types[typeInt] + } else { + if err == sql.ErrNoRows { + return typeString, nil + } + } + + return typeString, err +} + +//Open defines scripts for queries & create table 'event' if not exist +func Open(db *sql.DB) (t Transactions, err error) { + _, err = db.Exec(tableDef) + if err != nil { + return + } + get, err := db.Prepare("SELECT * FROM event WHERE id = ? LIMIT 1") + if err != nil { + return + } + + getbylicensestatusid, err := db.Prepare("SELECT * FROM event WHERE license_status_fk = ?") + + checkdevicestatus, err := db.Prepare(`SELECT type FROM event WHERE license_status_fk = ? + AND device_id = ? ORDER BY timestamp DESC LIMIT 1`) + + listregistereddevices, err := db.Prepare(`SELECT device_id, + device_name, timestamp FROM event WHERE license_status_fk = ? AND type = 1`) + + if err != nil { + return + } + + t = dbTransactions{db, get, nil, getbylicensestatusid, checkdevicestatus, listregistereddevices} + return +} + +const tableDef = `CREATE TABLE IF NOT EXISTS event ( + id INTEGER PRIMARY KEY, + device_name varchar(255) DEFAULT NULL, + timestamp datetime NOT NULL, + type int NOT NULL, + device_id varchar(255) DEFAULT NULL, + license_status_fk int NOT NULL, + FOREIGN KEY(license_status_fk) REFERENCES license_status(id) +); +CREATE INDEX IF NOT EXISTS license_status_fk_index on event (license_status_fk); +` diff --git a/transactions/transactions_test.go b/transactions/transactions_test.go new file mode 100644 index 00000000..196ecdef --- /dev/null +++ b/transactions/transactions_test.go @@ -0,0 +1,59 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package transactions + +import ( + "database/sql" + "testing" + "time" + + _ "github.com/mattn/go-sqlite3" + + "github.com/readium/readium-lcp-server/status" +) + +//TestTransactionCreation opens database and tries to add an event to table 'event' +func TestTransactionCreation(t *testing.T) { + db, err := sql.Open("sqlite3", ":memory:") + trns, err := Open(db) + if err != nil { + t.Error("Can't open transactions") + t.Error(err) + t.FailNow() + } + + timestamp := time.Now() + + e := Event{DeviceName: "testdevice", Timestamp: timestamp, Type: status.Types[1], DeviceId: "deviceid", LicenseStatusFk: 1} + err = trns.Add(e, 1) + if err != nil { + t.Error(err) + } + _, err = trns.Get(1) + if err != nil { + t.Error(err) + } +} diff --git a/xmlenc/encryption.go b/xmlenc/encryption.go index d5811c41..398daa58 100644 --- a/xmlenc/encryption.go +++ b/xmlenc/encryption.go @@ -1,3 +1,28 @@ +// Copyright (c) 2016 Readium Foundation +// +// Redistribution and use in source and binary forms, with or without modification, +// are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation and/or +// other materials provided with the distribution. +// 3. Neither the name of the organization nor the names of its contributors may be +// used to endorse or promote products derived from this software without specific +// prior written permission +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + package xmlenc import (