diff --git a/.gitignore b/.gitignore index 4afcff76..3ce6bd97 100644 --- a/.gitignore +++ b/.gitignore @@ -4,13 +4,22 @@ 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 1247d29c..e058dc58 100644 --- a/README.md +++ b/README.md @@ -151,9 +151,9 @@ NOTE: here is a license section snippet: ```json license: links: - hint: "http://www.edrlab.org/static/hint.html" - publication: "http://www.edrlab.org/files/{publication_id}" - status: "http://www.edrlab.org/licenses/{license_id}/status" + 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 @@ -174,8 +174,16 @@ NOTE: list files for localization (ex: 'en-US.json, de-DE.json') must match the - 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. + + Contributing ============ Please make a Pull Request with tests at github.com/readium/readium-lcp-server diff --git a/api/common_server.go b/api/common_server.go index 446168cf..3d5bc320 100644 --- a/api/common_server.go +++ b/api/common_server.go @@ -35,6 +35,7 @@ import ( "github.com/urfave/negroni" "github.com/jeffbmartinez/delay" "github.com/technoweenie/grohl" + "github.com/rs/cors" "github.com/readium/readium-lcp-server/problem" ) @@ -95,12 +96,13 @@ func CreateServerRouter(tplPath string) ServerRouter { // 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{"POST", "GET", "OPTIONS", "PUT", "DELETE"}, - // Debug: true, - // }) - // n.Use(c) + 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) @@ -139,8 +141,10 @@ func ExtraLogger(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) func CORSHeaders(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) { grohl.Log(grohl.Data{"CORS": "yes"}) - rw.Header().Add("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE") + 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) diff --git a/config/config.go b/config/config.go index a9e3f344..7ebf49b7 100644 --- a/config/config.go +++ b/config/config.go @@ -21,7 +21,7 @@ // 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. +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. package config @@ -35,18 +35,20 @@ import ( ) type Configuration struct { - Certificate Certificate `yaml:"certificate"` - Storage Storage `yaml:"storage"` - License License `yaml:"license"` - LcpServer ServerInfo `yaml:"lcp"` - LsdServer ServerInfo `yaml:"lsd"` - LsdNotifyAuth Auth `yaml:"lsd_notify_auth"` - LcpUpdateAuth Auth `yaml:"lcp_update_auth"` - Static Static `yaml:"static"` - LicenseStatus LicenseStatus `yaml:"license_status"` - Localization Localization `yaml:"localization"` - Logging Logging `yaml:"logging"` - AES256_CBC_OR_GCM string `yaml:"aes256_cbc_or_gcm,omitempty"` + 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 { @@ -56,6 +58,19 @@ type ServerInfo struct { 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 { @@ -72,10 +87,6 @@ type FileSystem struct { Directory string `yaml:"directory"` } -type Static struct { - Directory string `yaml:"directory"` -} - type Storage struct { FileSystem FileSystem `yaml:"filesystem"` AccessId string `yaml:"access_id"` @@ -123,14 +134,15 @@ func ReadConfig(configFileName string) { } err = yaml.Unmarshal(yamlFile, &Config) + if err != nil { panic("Can't unmarshal config. " + configFileName + " -> " + err.Error()) } } func SetPublicUrls() error { - var lcpPublicBaseUrl, lsdPublicBaseUrl, lcpHost, lsdHost string - var lcpPort, lsdPort int + var lcpPublicBaseUrl, lsdPublicBaseUrl, frontendPublicBaseUrl, lcpHost, lsdHost, frontendHost string + var lcpPort, lsdPort, frontendPort int var err error if lcpHost = Config.LcpServer.Host; lcpHost == "" { @@ -147,12 +159,22 @@ func SetPublicUrls() error { } } + 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) @@ -162,6 +184,10 @@ func SetPublicUrls() error { 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 index 69bf0a8b..40dbdd92 100644 --- a/crypto/aes_cbc.go +++ b/crypto/aes_cbc.go @@ -21,7 +21,7 @@ // 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. +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. package crypto @@ -40,6 +40,7 @@ const ( ) 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" } @@ -49,7 +50,8 @@ func (e cbcEncrypter) GenerateKey() (ContentKey, error) { } func (e cbcEncrypter) Encrypt(key ContentKey, r io.Reader, w io.Writer) error { - r = PaddedReader(r, aes.BlockSize) + + r = PaddedReader(r, aes.BlockSize, false) block, err := aes.NewCipher(key) if err != nil { @@ -99,7 +101,7 @@ func (c cbcEncrypter) Decrypt(key ContentKey, r io.Reader, w io.Writer) error { mode := cipher.NewCBCDecrypter(block, iv) mode.CryptBlocks(buf[aes.BlockSize:], buf[aes.BlockSize:]) - padding := buf[len(buf)-1] + 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 @@ -107,4 +109,4 @@ func (c cbcEncrypter) Decrypt(key ContentKey, r io.Reader, w io.Writer) error { func NewAESCBCEncrypter() Encrypter { return cbcEncrypter(struct{}{}) -} \ No newline at end of file +} diff --git a/crypto/encrypt.go b/crypto/encrypt.go index 2b72c0b5..6a390e1f 100644 --- a/crypto/encrypt.go +++ b/crypto/encrypt.go @@ -28,9 +28,9 @@ package crypto import ( "crypto/aes" "io" - - "github.com/readium/readium-lcp-server/config" ) +//"github.com/readium/readium-lcp-server/config" +// FOR: config.Config.AES256_CBC_OR_GCM type Encrypter interface { Encrypt(key ContentKey, r io.Reader, w io.Writer) error @@ -43,11 +43,15 @@ type Decrypter interface { } func NewAESEncrypter_PUBLICATION_RESOURCES() Encrypter { - if config.Config.AES256_CBC_OR_GCM == "GCM" { - return NewAESGCMEncrypter() - } else { // default to CBC - return NewAESCBCEncrypter() - } + + return NewAESCBCEncrypter() + + // 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() + // } } func NewAESEncrypter_CONTENT_KEY() Encrypter { diff --git a/crypto/pad.go b/crypto/pad.go index cb5391b3..fae9b5fa 100644 --- a/crypto/pad.go +++ b/crypto/pad.go @@ -27,6 +27,8 @@ package crypto import ( "io" + "math/rand" + "time" ) type paddedReader struct { @@ -35,6 +37,7 @@ type paddedReader struct { count byte left byte done bool + insertPadLengthAll bool } func (r *paddedReader) Read(buf []byte) (int, error) { @@ -73,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-- } @@ -86,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 57cd2428..9aaeb41c 100644 --- a/crypto/pad_test.go +++ b/crypto/pad_test.go @@ -33,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 { @@ -43,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]) } @@ -50,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[:]) @@ -61,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 { @@ -84,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[:]) @@ -122,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/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/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/lcpserver/api/license.go b/lcpserver/api/license.go index 13463487..0a0a80fb 100644 --- a/lcpserver/api/license.go +++ b/lcpserver/api/license.go @@ -35,12 +35,14 @@ import ( "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" @@ -54,17 +56,11 @@ import ( "github.com/readium/readium-lcp-server/storage" ) -//{ -//"content_key": "12345", -//"date": "2013-11-04T01:08:15+01:00", -//"hint": "Enter your email address", -//"hint_url": "http://www.imaginaryebookretailer.com/lcp" -//} func GetLicense(w http.ResponseWriter, r *http.Request, s Server) { vars := mux.Vars(r) - licenceId := vars["key"] - // search existing license using key + licenceId := vars["license_id"] + var ExistingLicense license.License ExistingLicense, e := s.Licenses().Get(licenceId) if e != nil { @@ -77,9 +73,15 @@ func GetLicense(w http.ResponseWriter, r *http.Request, s Server) { } 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 - // just send partial license + 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) @@ -101,55 +103,27 @@ func GetLicense(w http.ResponseWriter, r *http.Request, s Server) { 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 - content, err := s.Index().Get(ExistingLicense.ContentId) - if err != nil { - 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.StatusBadRequest) - } - return - } if ExistingLicense.Links == nil { ExistingLicense.Links = license.DefaultLinksCopy() } - err = prepareLinks(ExistingLicense, s) - if err != nil { - problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusInternalServerError) - return - } - - encrypter_content_key := crypto.NewAESEncrypter_CONTENT_KEY() - - ExistingLicense.Encryption.ContentKey.Algorithm = encrypter_content_key.Signature() - ExistingLicense.Encryption.ContentKey.Value = encryptKey(encrypter_content_key, content.EncryptionKey, ExistingLicense.Encryption.UserKey.Value) //use old UserKey.Value - ExistingLicense.Encryption.UserKey.Algorithm = "http://www.w3.org/2001/04/xmlenc#sha256" - - encrypter_fields := crypto.NewAESEncrypter_FIELDS() - - err = encryptFields(encrypter_fields, &ExistingLicense, ExistingLicense.Encryption.UserKey.Value) - if err != nil { - problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) - return - } - encrypter_user_key_check := crypto.NewAESEncrypter_USER_KEY_CHECK() + ExistingLicense.Encryption.UserKey.Value = lic.Encryption.UserKey.Value - err = buildKeyCheck(encrypter_user_key_check, &ExistingLicense, ExistingLicense.Encryption.UserKey.Value) - if err != nil { - problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) - return - } - err = signLicense(&ExistingLicense, s.Certificate()) + err = completeLicense(&ExistingLicense, ExistingLicense.ContentId, s) if err != nil { problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) return @@ -157,119 +131,141 @@ func GetLicense(w http.ResponseWriter, r *http.Request, s Server) { 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 } } -func UpdateLicense(w http.ResponseWriter, r *http.Request, s Server) { - - vars := mux.Vars(r) - licenceId := vars["key"] - // search existing license using key +// 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, 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 + ExistingLicense, err := s.Licenses().Get(licenseID) + if err != nil { + return ExistingLicense, err } + // update rights of license in database / verify validity of lic / existingLicense - if lic.Provider != "" { - ExistingLicense.Provider = lic.Provider - } - if lic.Rights.Copy != nil { - ExistingLicense.Rights.Copy = lic.Rights.Copy + if partialLicense.Provider != "" { + ExistingLicense.Provider = partialLicense.Provider } - 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 + 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 lic.Encryption.UserKey.Hint != "" { - ExistingLicense.Encryption.UserKey.Hint = lic.Encryption.UserKey.Hint + + if partialLicense.Encryption.UserKey.Hint != "" { + ExistingLicense.Encryption.UserKey.Hint = partialLicense.Encryption.UserKey.Hint } - if lic.ContentId != "" { //change content - ExistingLicense.ContentId = lic.ContentId + 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 - problem.Error(w, r, problem.Problem{Detail: err.Error()}, http.StatusBadRequest) - return + return ExistingLicense, err } - // go on and GET license io to return the updated license - GetLicense(w, r, s) + return ExistingLicense, err } -// TODO: the UpdateRightsLicense function appears to be unused? -func UpdateRightsLicense(w http.ResponseWriter, r *http.Request, s Server) { +// 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) - 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 - } + 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 != licenceId { + if lic.Id != licenseID { 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) + _, 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 to GET license io to return the existing license + // 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 @@ -281,14 +277,15 @@ func GenerateLicense(w http.ResponseWriter, r *http.Request, s Server) { return } - key := vars["key"] - err = completeLicense(&lic, key, s) + 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: key}, http.StatusNotFound) + problem.Error(w, r, problem.Problem{Detail: err.Error(), Instance: contentID}, http.StatusNotFound) } else { - problem.Error(w, r, problem.Problem{Detail: err.Error(), Instance: key}, http.StatusInternalServerError) + problem.Error(w, r, problem.Problem{Detail: err.Error(), Instance: contentID}, http.StatusInternalServerError) } return @@ -296,7 +293,7 @@ func GenerateLicense(w http.ResponseWriter, r *http.Request, s Server) { err = s.Licenses().Add(lic) if err != nil { - problem.Error(w, r, problem.Problem{Detail: err.Error(), Instance: key}, http.StatusInternalServerError) + problem.Error(w, r, problem.Problem{Detail: err.Error(), Instance: contentID}, http.StatusInternalServerError) return } @@ -311,95 +308,114 @@ func GenerateLicense(w http.ResponseWriter, r *http.Request, s Server) { } func GenerateProtectedPublication(w http.ResponseWriter, r *http.Request, s Server) { - vars := mux.Vars(r) - key := vars["key"] - - var lic license.License - - err := DecodeJsonLicense(r, &lic) - + 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 } - if key == "" { - problem.Error(w, r, problem.Problem{Detail: "No content id", Instance: key}, http.StatusBadRequest) + 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 } - item, err := s.Store().Get(key) + epubFile, err := s.Store().Get(contentID) if err != nil { if err == storage.NotFound { - problem.Error(w, r, problem.Problem{Detail: err.Error(), Instance: key}, http.StatusNotFound) - return - - } else { - problem.Error(w, r, problem.Problem{Detail: err.Error(), Instance: key}, http.StatusInternalServerError) + 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(key) + content, err := s.Index().Get(contentID) if err != nil { if err == index.NotFound { - problem.Error(w, r, problem.Problem{Detail: err.Error(), Instance: key}, http.StatusNotFound) + problem.Error(w, r, problem.Problem{Detail: err.Error(), Instance: contentID}, http.StatusNotFound) } else { - problem.Error(w, r, problem.Problem{Detail: err.Error(), Instance: key}, http.StatusInternalServerError) + problem.Error(w, r, problem.Problem{Detail: err.Error(), Instance: contentID}, http.StatusInternalServerError) } return } var b bytes.Buffer - contents, err := item.Contents() + contents, err := epubFile.Contents() if err != nil { - problem.Error(w, r, problem.Problem{Detail: err.Error(), Instance: key}, http.StatusInternalServerError) + 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: key}, http.StatusInternalServerError) + 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: key}, http.StatusInternalServerError) + problem.Error(w, r, problem.Problem{Detail: err.Error(), Instance: contentID}, http.StatusInternalServerError) return } + //add license to publication var buf bytes.Buffer - - //lic.Links["publication"] = license.Link{Href: item.PublicUrl(), Type: epub.ContentType_EPUB} - //lic.ContentId = key - - err = completeLicense(&lic, key, s) - if err != nil { - problem.Error(w, r, problem.Problem{Detail: err.Error(), Instance: key}, http.StatusInternalServerError) - return - } - enc := json.NewEncoder(&buf) - enc.Encode(lic) - - err = s.Licenses().Add(lic) - - if err != nil { - problem.Error(w, r, problem.Problem{Detail: err.Error(), Instance: key}, http.StatusInternalServerError) - return - } - + 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 { @@ -417,14 +433,19 @@ func DecodeJsonLicense(r *http.Request, lic *license.License) error { return err } -func completeLicense(l *license.License, key string, s Server) error { - c, err := s.Index().Get(key) +func completeLicense(l *license.License, contentID string, s Server) error { + c, err := s.Index().Get(contentID) if err != nil { return err } - license.Prepare(l) - l.ContentId = key + 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 @@ -453,9 +474,10 @@ func completeLicense(l *license.License, key string, s Server) error { l.Links = *links var encryptionKey []byte + if len(l.Encryption.UserKey.Value) > 0 { encryptionKey = l.Encryption.UserKey.Value - l.Encryption.UserKey.Value = nil + //l.Encryption.UserKey.Value = nil } else { passphrase := l.Encryption.UserKey.ClearValue l.Encryption.UserKey.ClearValue = "" @@ -477,7 +499,7 @@ func completeLicense(l *license.License, key string, s Server) error { } encrypter_user_key_check := crypto.NewAESEncrypter_USER_KEY_CHECK() - + err = buildKeyCheck(encrypter_user_key_check, l, encryptionKey[:]) if err != nil { return err @@ -567,8 +589,8 @@ func ListLicenses(w http.ResponseWriter, r *http.Request, s Server) { } else { per_page = 30 } - if page > 0 { - page -= 1 //pagenum starting at 0 in code, but user interface starting at 1 + 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) @@ -633,7 +655,7 @@ func ListLicensesForContent(w http.ResponseWriter, r *http.Request, s Server) { per_page = 30 } if page > 0 { - page -= 1 //pagenum starting at 0 in code, but user interface starting at 1 + 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) 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 index 4637da7f..2ad69776 100644 --- a/lcpserver/lcpserver.go +++ b/lcpserver/lcpserver.go @@ -21,7 +21,7 @@ // 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. +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. package main @@ -73,6 +73,35 @@ func main() { 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" @@ -126,12 +155,6 @@ func main() { packager := pack.NewPackager(store, idx, 4) - static = config.Config.Static.Directory - if static == "" { - _, file, _, _ := runtime.Caller(0) - here := filepath.Dir(file) - static = filepath.Join(here, "../static") - } authFile := config.Config.LcpServer.AuthFile if authFile == "" { panic("Must have passwords file") diff --git a/static/manage/favicon.ico b/lcpserver/manage/favicon.ico similarity index 100% rename from static/manage/favicon.ico rename to lcpserver/manage/favicon.ico diff --git a/static/manage/index.html b/lcpserver/manage/index.html similarity index 68% rename from static/manage/index.html rename to lcpserver/manage/index.html index 57976e30..8b9d7f54 100644 --- a/static/manage/index.html +++ b/lcpserver/manage/index.html @@ -3,114 +3,38 @@ + - - + + -

LCPServer Admin

+

LCPServer test Admin

 

Packages