diff --git a/README.md b/README.md index 40b843bb..8cc60da9 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ The mission is to bring software dev inspired features (refactoring, testing, li ## Latest version -v1.50.2 +v1.51.0 ## OS Support diff --git a/core/api/endpoint/authentication_endpoint.go b/core/api/endpoint/authentication_endpoint.go index 800023ad..81b9144e 100644 --- a/core/api/endpoint/authentication_endpoint.go +++ b/core/api/endpoint/authentication_endpoint.go @@ -24,10 +24,10 @@ import ( "github.com/documize/community/core/api/entity" "github.com/documize/community/core/api/request" "github.com/documize/community/core/api/util" + "github.com/documize/community/core/env" "github.com/documize/community/core/log" "github.com/documize/community/core/secrets" "github.com/documize/community/domain/section/provider" - "github.com/documize/community/server/web" ) // Authenticate user based up HTTP Authorization header. @@ -98,7 +98,7 @@ func Authenticate(w http.ResponseWriter, r *http.Request) { } // Attach user accounts and work out permissions - attachUserAccounts(p, org.RefID, &user) + AttachUserAccounts(p, org.RefID, &user) // active check @@ -201,7 +201,7 @@ func Authorize(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { // Fetch user permissions for this org if context.Authenticated { - user, err := getSecuredUser(p, org.RefID, context.UserID) + user, err := GetSecuredUser(p, org.RefID, context.UserID) if err != nil { writeServerError(w, method, err) @@ -242,8 +242,6 @@ func Authorize(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { // ValidateAuthToken finds and validates authentication token. func ValidateAuthToken(w http.ResponseWriter, r *http.Request) { - - log.Info("cb gh") // TODO should this go after token validation? if s := r.URL.Query().Get("section"); s != "" { if err := provider.Callback(s, w, r); err != nil { @@ -325,7 +323,7 @@ func ValidateAuthToken(w http.ResponseWriter, r *http.Request) { return } - user, err := getSecuredUser(p, org.RefID, context.UserID) + user, err := GetSecuredUser(p, org.RefID, context.UserID) if err != nil { w.WriteHeader(http.StatusUnauthorized) @@ -349,7 +347,7 @@ func preAuthorizeStaticAssets(r *http.Request) bool { strings.ToLower(r.URL.Path) == "/robots.txt" || strings.ToLower(r.URL.Path) == "/version" || strings.HasPrefix(strings.ToLower(r.URL.Path), "/api/public/") || - ((api.Runtime.Flags.SiteMode == web.SiteModeSetup) && (strings.ToLower(r.URL.Path) == "/api/setup")) { + ((api.Runtime.Flags.SiteMode == env.SiteModeSetup) && (strings.ToLower(r.URL.Path) == "/api/setup")) { return true } diff --git a/core/api/endpoint/keycloak.go b/core/api/endpoint/keycloak.go index 445e5e3b..c106a577 100644 --- a/core/api/endpoint/keycloak.go +++ b/core/api/endpoint/keycloak.go @@ -140,7 +140,7 @@ func AuthenticateKeycloak(w http.ResponseWriter, r *http.Request) { } // Attach user accounts and work out permissions. - attachUserAccounts(p, org.RefID, &user) + AttachUserAccounts(p, org.RefID, &user) // No accounts signals data integrity problem // so we reject login request. @@ -301,7 +301,7 @@ func addUser(p request.Persister, u *entity.User, addSpace bool) (err error) { return err } } else { - attachUserAccounts(p, p.Context.OrgID, &userDupe) + AttachUserAccounts(p, p.Context.OrgID, &userDupe) for _, a := range userDupe.Accounts { if a.OrgID == p.Context.OrgID { diff --git a/core/api/endpoint/user_endpoint.go b/core/api/endpoint/user_endpoint.go index 406e32a6..d2d04269 100644 --- a/core/api/endpoint/user_endpoint.go +++ b/core/api/endpoint/user_endpoint.go @@ -136,7 +136,7 @@ func AddUser(w http.ResponseWriter, r *http.Request) { log.Info("Adding user") } else { - attachUserAccounts(p, p.Context.OrgID, &userDupe) + AttachUserAccounts(p, p.Context.OrgID, &userDupe) for _, a := range userDupe.Accounts { if a.OrgID == p.Context.OrgID { @@ -206,7 +206,7 @@ func AddUser(w http.ResponseWriter, r *http.Request) { } // Send back new user record - userModel, err = getSecuredUser(p, p.Context.OrgID, userID) + userModel, err = GetSecuredUser(p, p.Context.OrgID, userID) json, err := json.Marshal(userModel) if err != nil { @@ -254,7 +254,7 @@ func GetOrganizationUsers(w http.ResponseWriter, r *http.Request) { } for i := range users { - attachUserAccounts(p, p.Context.OrgID, &users[i]) + AttachUserAccounts(p, p.Context.OrgID, &users[i]) } json, err := json.Marshal(users) @@ -333,7 +333,7 @@ func GetUser(w http.ResponseWriter, r *http.Request) { return } - user, err := getSecuredUser(p, p.Context.OrgID, userID) + user, err := GetSecuredUser(p, p.Context.OrgID, userID) if err == sql.ErrNoRows { writeNotFoundError(w, method, userID) @@ -719,13 +719,14 @@ func ResetUserPassword(w http.ResponseWriter, r *http.Request) { } // Get user object contain associated accounts but credentials are wiped. -func getSecuredUser(p request.Persister, orgID, user string) (u entity.User, err error) { +func GetSecuredUser(p request.Persister, orgID, user string) (u entity.User, err error) { u, err = p.GetUser(user) - attachUserAccounts(p, orgID, &u) + AttachUserAccounts(p, orgID, &u) + return } -func attachUserAccounts(p request.Persister, orgID string, user *entity.User) { +func AttachUserAccounts(p request.Persister, orgID string, user *entity.User) { user.ProtectSecrets() a, err := p.GetUserAccounts(user.RefID) diff --git a/core/api/request/organization.go b/core/api/request/organization.go index 4aa76c6d..b9079243 100644 --- a/core/api/request/organization.go +++ b/core/api/request/organization.go @@ -19,9 +19,9 @@ import ( "github.com/documize/community/core/api" "github.com/documize/community/core/api/entity" + "github.com/documize/community/core/env" "github.com/documize/community/core/log" "github.com/documize/community/core/streamutil" - "github.com/documize/community/server/web" "github.com/jmoiron/sqlx" ) @@ -83,7 +83,7 @@ func (p *Persister) GetOrganizationByDomain(subdomain string) (org entity.Organi err = nil subdomain = strings.ToLower(subdomain) - if api.Runtime.Flags.SiteMode == web.SiteModeNormal { // only return an organization when running normally + if api.Runtime.Flags.SiteMode == env.SiteModeNormal { // only return an organization when running normally var stmt *sqlx.Stmt diff --git a/core/database/check.go b/core/database/check.go index 61c00229..fad6570c 100644 --- a/core/database/check.go +++ b/core/database/check.go @@ -49,7 +49,7 @@ func Check(runtime *env.Runtime) bool { if err != nil { runtime.Log.Error("Can't get MySQL configuration", err) web.SiteInfo.Issue = "Can't get MySQL configuration: " + err.Error() - runtime.Flags.SiteMode = web.SiteModeBadDB + runtime.Flags.SiteMode = env.SiteModeBadDB return false } defer streamutil.Close(rows) @@ -65,7 +65,7 @@ func Check(runtime *env.Runtime) bool { if err != nil { runtime.Log.Error("no MySQL configuration returned", err) web.SiteInfo.Issue = "no MySQL configuration return issue: " + err.Error() - runtime.Flags.SiteMode = web.SiteModeBadDB + runtime.Flags.SiteMode = env.SiteModeBadDB return false } @@ -92,7 +92,7 @@ func Check(runtime *env.Runtime) bool { want := fmt.Sprintf("%d.%d.%d", verInts[0], verInts[1], verInts[2]) runtime.Log.Error("MySQL version element "+strconv.Itoa(k+1)+" of '"+version+"' not high enough, need at least version "+want, errors.New("bad MySQL version")) web.SiteInfo.Issue = "MySQL version element " + strconv.Itoa(k+1) + " of '" + version + "' not high enough, need at least version " + want - runtime.Flags.SiteMode = web.SiteModeBadDB + runtime.Flags.SiteMode = env.SiteModeBadDB return false } } @@ -101,13 +101,13 @@ func Check(runtime *env.Runtime) bool { if charset != "utf8" { runtime.Log.Error("MySQL character set not utf8:", errors.New(charset)) web.SiteInfo.Issue = "MySQL character set not utf8: " + charset - runtime.Flags.SiteMode = web.SiteModeBadDB + runtime.Flags.SiteMode = env.SiteModeBadDB return false } if !strings.HasPrefix(collation, "utf8") { runtime.Log.Error("MySQL collation sequence not utf8...:", errors.New(collation)) web.SiteInfo.Issue = "MySQL collation sequence not utf8...: " + collation - runtime.Flags.SiteMode = web.SiteModeBadDB + runtime.Flags.SiteMode = env.SiteModeBadDB return false } } @@ -119,12 +119,12 @@ func Check(runtime *env.Runtime) bool { `' and TABLE_TYPE='BASE TABLE'`); err != nil { runtime.Log.Error("Can't get MySQL number of tables", err) web.SiteInfo.Issue = "Can't get MySQL number of tables: " + err.Error() - runtime.Flags.SiteMode = web.SiteModeBadDB + runtime.Flags.SiteMode = env.SiteModeBadDB return false } if strings.TrimSpace(flds[0]) == "0" { runtime.Log.Info("Entering database set-up mode because the database is empty.....") - runtime.Flags.SiteMode = web.SiteModeSetup + runtime.Flags.SiteMode = env.SiteModeSetup return false } } @@ -140,13 +140,13 @@ func Check(runtime *env.Runtime) bool { if err := runtime.Db.Select(&dummy, "SELECT 1 FROM "+table+" LIMIT 1;"); err != nil { runtime.Log.Error("Entering bad database mode because: SELECT 1 FROM "+table+" LIMIT 1;", err) web.SiteInfo.Issue = "MySQL database is not empty, but does not contain table: " + table - runtime.Flags.SiteMode = web.SiteModeBadDB + runtime.Flags.SiteMode = env.SiteModeBadDB return false } } } - runtime.Flags.SiteMode = web.SiteModeNormal // actually no need to do this (as already ""), this for documentation + runtime.Flags.SiteMode = env.SiteModeNormal // actually no need to do this (as already ""), this for documentation web.SiteInfo.DBname = "" // do not give this info when not in set-up mode dbCheckOK = true return true diff --git a/core/database/create.go b/core/database/create.go index 5b05e90b..3ab22f6a 100644 --- a/core/database/create.go +++ b/core/database/create.go @@ -19,6 +19,7 @@ import ( "time" "github.com/documize/community/core/api" + "github.com/documize/community/core/env" "github.com/documize/community/core/log" "github.com/documize/community/core/secrets" "github.com/documize/community/core/stringutil" @@ -65,7 +66,7 @@ func Create(w http.ResponseWriter, r *http.Request) { target := "/setup" status := http.StatusBadRequest - if api.Runtime.Flags.SiteMode == web.SiteModeNormal { + if api.Runtime.Flags.SiteMode == env.SiteModeNormal { target = "/" status = http.StatusOK } @@ -133,7 +134,7 @@ func Create(w http.ResponseWriter, r *http.Request) { return } - api.Runtime.Flags.SiteMode = web.SiteModeNormal + api.Runtime.Flags.SiteMode = env.SiteModeNormal } // The result of completing the onboarding process. diff --git a/core/env/product.go b/core/env/product.go index 360b5685..688a182e 100644 --- a/core/env/product.go +++ b/core/env/product.go @@ -58,6 +58,11 @@ func (l *License) Status() string { return fmt.Sprintf("License is %s and %s", lp, lv) } +// IsValid returns if license is valid +func (l *License) IsValid() bool { + return l.Valid == true +} + // LicenseData holds encrypted data and is unpacked into License. type LicenseData struct { Key string `json:"key"` diff --git a/core/env/runtime.go b/core/env/runtime.go index 3cb04747..3dde6652 100644 --- a/core/env/runtime.go +++ b/core/env/runtime.go @@ -22,3 +22,14 @@ type Runtime struct { Log Logger Product ProdInfo } + +const ( + // SiteModeNormal serves app + SiteModeNormal = "" + // SiteModeOffline serves offline.html + SiteModeOffline = "1" + // SiteModeSetup tells Ember to serve setup route + SiteModeSetup = "2" + // SiteModeBadDB redirects to db-error.html page + SiteModeBadDB = "3" +) diff --git a/core/request/param.go b/core/request/param.go index 4c85b6e2..1cc2b547 100644 --- a/core/request/param.go +++ b/core/request/param.go @@ -18,6 +18,12 @@ import ( "github.com/gorilla/mux" ) +// Param returns the requested paramater from route request. +func Param(r *http.Request, p string) string { + params := mux.Vars(r) + return params[p] +} + // Params returns the paramaters from route request. func Params(r *http.Request) map[string]string { return mux.Vars(r) diff --git a/core/response/write.go b/core/response/write.go index f054410f..41a06f89 100644 --- a/core/response/write.go +++ b/core/response/write.go @@ -14,10 +14,7 @@ package response import ( "encoding/json" - "fmt" "net/http" - - "github.com/documize/community/core/log" ) // Helper for writing consistent headers back to HTTP client @@ -29,55 +26,43 @@ func writeStatus(w http.ResponseWriter, status int) { // WriteMissingDataError notifies HTTP client of missing data in request. func WriteMissingDataError(w http.ResponseWriter, method, parameter string) { writeStatus(w, http.StatusBadRequest) - _, err := w.Write([]byte("{Error: 'Missing data'}")) - log.IfErr(err) - log.Info(fmt.Sprintf("Missing data %s for method %s", parameter, method)) + w.Write([]byte("{Error: 'Missing data'}")) } // WriteNotFoundError notifies HTTP client of 'record not found' error. func WriteNotFoundError(w http.ResponseWriter, method string, id string) { writeStatus(w, http.StatusNotFound) - _, err := w.Write([]byte("{Error: 'Not found'}")) - log.IfErr(err) - log.Info(fmt.Sprintf("Not found ID %s for method %s", id, method)) + w.Write([]byte("{Error: 'Not found'}")) } // WriteServerError notifies HTTP client of general application error. func WriteServerError(w http.ResponseWriter, method string, err error) { writeStatus(w, http.StatusBadRequest) - _, err2 := w.Write([]byte("{Error: 'Internal server error'}")) - log.IfErr(err2) - log.Error(fmt.Sprintf("Internal server error for method %s", method), err) + w.Write([]byte("{Error: 'Internal server error'}")) } // WriteDuplicateError notifies HTTP client of duplicate data that has been rejected. func WriteDuplicateError(w http.ResponseWriter, method, entity string) { writeStatus(w, http.StatusConflict) - _, err := w.Write([]byte("{Error: 'Duplicate record'}")) - log.IfErr(err) - log.Info(fmt.Sprintf("Duplicate %s record detected for method %s", entity, method)) + w.Write([]byte("{Error: 'Duplicate record'}")) } // WriteUnauthorizedError notifies HTTP client of rejected unauthorized request. func WriteUnauthorizedError(w http.ResponseWriter) { writeStatus(w, http.StatusUnauthorized) - _, err := w.Write([]byte("{Error: 'Unauthorized'}")) - log.IfErr(err) + w.Write([]byte("{Error: 'Unauthorized'}")) } // WriteForbiddenError notifies HTTP client of request that is not allowed. func WriteForbiddenError(w http.ResponseWriter) { writeStatus(w, http.StatusForbidden) - _, err := w.Write([]byte("{Error: 'Forbidden'}")) - log.IfErr(err) + w.Write([]byte("{Error: 'Forbidden'}")) } // WriteBadRequestError notifies HTTP client of rejected request due to bad data within request. func WriteBadRequestError(w http.ResponseWriter, method, message string) { writeStatus(w, http.StatusBadRequest) - _, err := w.Write([]byte("{Error: 'Bad Request'}")) - log.IfErr(err) - log.Info(fmt.Sprintf("Bad Request %s for method %s", message, method)) + w.Write([]byte("{Error: 'Bad Request'}")) } // WriteBadLicense notifies HTTP client of invalid license (402) @@ -87,44 +72,34 @@ func WriteBadLicense(w http.ResponseWriter) { var e struct { Reason string } + e.Reason = "invalid or expired Documize license" j, _ := json.Marshal(e) - _, err := w.Write(j) - log.IfErr(err) + w.Write(j) } // WriteBytes dumps bytes to HTTP response func WriteBytes(w http.ResponseWriter, data []byte) { writeStatus(w, http.StatusOK) - _, err := w.Write(data) - log.IfErr(err) + w.Write(data) } // WriteString writes string to HTTP response func WriteString(w http.ResponseWriter, data string) { writeStatus(w, http.StatusOK) - _, err := w.Write([]byte(data)) - log.IfErr(err) + w.Write([]byte(data)) } // WriteEmpty writes empty JSON HTTP response func WriteEmpty(w http.ResponseWriter) { writeStatus(w, http.StatusOK) - _, err := w.Write([]byte("{}")) - log.IfErr(err) + w.Write([]byte("{}")) } // WriteJSON serializes data as JSON to HTTP response. func WriteJSON(w http.ResponseWriter, v interface{}) { writeStatus(w, http.StatusOK) - - j, err := json.Marshal(v) - - if err != nil { - log.IfErr(err) - } - - _, err = w.Write(j) - log.IfErr(err) + j, _ := json.Marshal(v) + w.Write(j) } diff --git a/core/timeutil/nulltime.go b/core/timeutil/nulltime.go new file mode 100644 index 00000000..32b9f2c1 --- /dev/null +++ b/core/timeutil/nulltime.go @@ -0,0 +1,42 @@ +// Source: https://github.com/lib/pq/blob/b269bd035a727d6c1081f76e7a239a1b00674c40/encode.go#L521 +// +// Copyright (c) 2011-2013, 'pq' Contributors Portions Copyright (C) 2011 Blake Mizerany +// 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. + +// Package timeutil provides date and time related types and helpers. +package timeutil + +import ( + "database/sql/driver" + "time" +) + +// NullTime represents a time.Time that may be null. NullTime implements the +// sql.Scanner interface so it can be used as a scan destination, similar to +// sql.NullString. +type NullTime struct { + Time time.Time + Valid bool // Valid is true if Time is not NULL +} + +// Scan implements the Scanner interface. +func (nt *NullTime) Scan(value interface{}) error { + nt.Time, nt.Valid = value.(time.Time) + return nil +} + +// Value implements the driver Valuer interface. +func (nt NullTime) Value() (driver.Value, error) { + if !nt.Valid { + return nil, nil + } + return nt.Time, nil +} diff --git a/domain/account/model.go b/domain/account/model.go new file mode 100644 index 00000000..a8634ff1 --- /dev/null +++ b/domain/account/model.go @@ -0,0 +1,36 @@ +// Copyright 2016 Documize Inc. . All rights reserved. +// +// This software (Documize Community Edition) is licensed under +// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html +// +// You can operate outside the AGPL restrictions by purchasing +// Documize Enterprise Edition and obtaining a commercial license +// by contacting . +// +// https://documize.com + +package account + +import ( + "github.com/documize/community/core/env" + "github.com/documize/community/domain" +) + +// Handler contains the runtime information such as logging and database. +type Handler struct { + Runtime env.Runtime +} + +// Account links a User to an Organization. +type Account struct { + domain.BaseEntity + Admin bool `json:"admin"` + Editor bool `json:"editor"` + UserID string `json:"userId"` + OrgID string `json:"orgId"` + Company string `json:"company"` + Title string `json:"title"` + Message string `json:"message"` + Domain string `json:"domain"` + Active bool `json:"active"` +} diff --git a/domain/account/store.go b/domain/account/store.go new file mode 100644 index 00000000..8b00a5bd --- /dev/null +++ b/domain/account/store.go @@ -0,0 +1,144 @@ +package account + +import ( + "database/sql" + "fmt" + "time" + + "github.com/documize/community/core/streamutil" + "github.com/documize/community/domain" + "github.com/documize/community/domain/store/mysql" + "github.com/pkg/errors" +) + +// Add inserts the given record into the datbase account table. +func Add(s domain.StoreContext, account Account) (err error) { + account.Created = time.Now().UTC() + account.Revised = time.Now().UTC() + + stmt, err := s.Context.Transaction.Preparex("INSERT INTO account (refid, orgid, userid, admin, editor, active, created, revised) VALUES (?, ?, ?, ?, ?, ?, ?, ?)") + defer streamutil.Close(stmt) + + if err != nil { + err = errors.Wrap(err, "unable to prepare insert for account") + return + } + + _, err = stmt.Exec(account.RefID, account.OrgID, account.UserID, account.Admin, account.Editor, account.Active, account.Created, account.Revised) + + if err != nil { + err = errors.Wrap(err, "unable to execute insert for account") + return + } + + return +} + +// GetUserAccount returns the database account record corresponding to the given userID, using the client's current organizaion. +func GetUserAccount(s domain.StoreContext, userID string) (account Account, err error) { + stmt, err := s.Runtime.Db.Preparex("SELECT a.*, b.company, b.title, b.message, b.domain FROM account a, organization b WHERE b.refid=a.orgid and a.orgid=? and a.userid=?") + defer streamutil.Close(stmt) + + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("prepare select for account by user %s", userID)) + return + } + + err = stmt.Get(&account, s.Context.OrgID, userID) + if err != sql.ErrNoRows && err != nil { + err = errors.Wrap(err, fmt.Sprintf("execute select for account by user %s", userID)) + return + } + + return +} + +// GetUserAccounts returns a slice of database account records, for all organizations that the userID is a member of, in organization title order. +func GetUserAccounts(s domain.StoreContext, userID string) (t []Account, err error) { + err = s.Runtime.Db.Select(&t, "SELECT a.*, b.company, b.title, b.message, b.domain FROM account a, organization b WHERE a.userid=? AND a.orgid=b.refid AND a.active=1 ORDER BY b.title", userID) + + if err != sql.ErrNoRows && err != nil { + err = errors.Wrap(err, fmt.Sprintf("Unable to execute select account for user %s", userID)) + } + + return +} + +// GetAccountsByOrg returns a slice of database account records, for all users in the client's organization. +func GetAccountsByOrg(s domain.StoreContext) (t []Account, err error) { + err = s.Runtime.Db.Select(&t, "SELECT a.*, b.company, b.title, b.message, b.domain FROM account a, organization b WHERE a.orgid=b.refid AND a.orgid=? AND a.active=1", s.Context.OrgID) + + if err != sql.ErrNoRows && err != nil { + err = errors.Wrap(err, fmt.Sprintf("execute select account for org %s", s.Context.OrgID)) + } + + return +} + +// CountOrgAccounts returns the numnber of active user accounts for specified organization. +func CountOrgAccounts(s domain.StoreContext) (c int) { + row := s.Runtime.Db.QueryRow("SELECT count(*) FROM account WHERE orgid=? AND active=1", s.Context.OrgID) + + err := row.Scan(&c) + + if err == sql.ErrNoRows { + return 0 + } + + if err != nil { + err = errors.Wrap(err, "count org accounts") + return 0 + } + + return +} + +// UpdateAccount updates the database record for the given account to the given values. +func UpdateAccount(s domain.StoreContext, account Account) (err error) { + account.Revised = time.Now().UTC() + + stmt, err := s.Context.Transaction.PrepareNamed("UPDATE account SET userid=:userid, admin=:admin, editor=:editor, active=:active, revised=:revised WHERE orgid=:orgid AND refid=:refid") + defer streamutil.Close(stmt) + + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("prepare update for account %s", account.RefID)) + return + } + + _, err = stmt.Exec(&account) + if err != sql.ErrNoRows && err != nil { + err = errors.Wrap(err, fmt.Sprintf("execute update for account %s", account.RefID)) + return + } + + return +} + +// HasOrgAccount returns if the given orgID has valid userID. +func HasOrgAccount(s domain.StoreContext, orgID, userID string) bool { + row := s.Runtime.Db.QueryRow("SELECT count(*) FROM account WHERE orgid=? and userid=?", orgID, userID) + + var count int + err := row.Scan(&count) + + if err == sql.ErrNoRows { + return false + } + + if err != nil && err != sql.ErrNoRows { + err = errors.Wrap(err, "HasOrgAccount") + return false + } + + if count == 0 { + return false + } + + return true +} + +// DeleteAccount deletes the database record in the account table for user ID. +func DeleteAccount(s domain.StoreContext, ID string) (rows int64, err error) { + b := mysql.BaseQuery{} + return b.DeleteConstrained(s.Context.Transaction, "account", s.Context.OrgID, ID) +} diff --git a/domain/auth/endpoint.go b/domain/auth/endpoint.go new file mode 100644 index 00000000..89ed7629 --- /dev/null +++ b/domain/auth/endpoint.go @@ -0,0 +1,207 @@ +// Copyright 2016 Documize Inc. . All rights reserved. +// +// This software (Documize Community Edition) is licensed under +// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html +// +// You can operate outside the AGPL restrictions by purchasing +// Documize Enterprise Edition and obtaining a commercial license +// by contacting . +// +// https://documize.com + +package auth + +import ( + "database/sql" + "errors" + "net/http" + "strings" + + "github.com/documize/community/core/response" + "github.com/documize/community/core/secrets" + "github.com/documize/community/domain" + "github.com/documize/community/domain/organization" + "github.com/documize/community/domain/section/provider" + "github.com/documize/community/domain/user" +) + +// Authenticate user based up HTTP Authorization header. +// An encrypted authentication token is issued with an expiry date. +func (h *Handler) Authenticate(w http.ResponseWriter, r *http.Request) { + method := "Authenticate" + + s := domain.StoreContext{Runtime: h.Runtime, Context: domain.GetRequestContext(r)} + + // check for http header + authHeader := r.Header.Get("Authorization") + if len(authHeader) == 0 { + response.WriteBadRequestError(w, method, "Missing Authorization header") + return + } + + // decode what we received + data := strings.Replace(authHeader, "Basic ", "", 1) + + decodedBytes, err := secrets.DecodeBase64([]byte(data)) + if err != nil { + response.WriteBadRequestError(w, method, "Unable to decode authentication token") + return + } + + decoded := string(decodedBytes) + + // check that we have domain:email:password (but allow for : in password field!) + credentials := strings.SplitN(decoded, ":", 3) + + if len(credentials) != 3 { + response.WriteBadRequestError(w, method, "Bad authentication token, expecting domain:email:password") + return + } + + dom := strings.TrimSpace(strings.ToLower(credentials[0])) + dom = organization.CheckDomain(s, dom) // TODO optimize by removing this once js allows empty domains + email := strings.TrimSpace(strings.ToLower(credentials[1])) + password := credentials[2] + h.Runtime.Log.Info("logon attempt " + email + " @ " + dom) + + u, err := user.GetByDomain(s, dom, email) + + if err == sql.ErrNoRows { + response.WriteUnauthorizedError(w) + return + } + + if err != nil { + response.WriteServerError(w, method, err) + return + } + + if len(u.Reset) > 0 || len(u.Password) == 0 { + response.WriteUnauthorizedError(w) + return + } + + // Password correct and active user + if email != strings.TrimSpace(strings.ToLower(u.Email)) || !secrets.MatchPassword(u.Password, password, u.Salt) { + response.WriteUnauthorizedError(w) + return + } + + org, err := organization.GetOrganizationByDomain(s, dom) + if err != nil { + response.WriteUnauthorizedError(w) + return + } + + // Attach user accounts and work out permissions + user.AttachUserAccounts(s, org.RefID, &u) + + // active check + + if len(u.Accounts) == 0 { + response.WriteUnauthorizedError(w) + return + } + + authModel := AuthenticationModel{} + authModel.Token = GenerateJWT(h.Runtime, u.RefID, org.RefID, dom) + authModel.User = u + + response.WriteJSON(w, authModel) +} + +// ValidateAuthToken finds and validates authentication token. +func (h *Handler) ValidateAuthToken(w http.ResponseWriter, r *http.Request) { + // TODO should this go after token validation? + if s := r.URL.Query().Get("section"); s != "" { + if err := provider.Callback(s, w, r); err != nil { + h.Runtime.Log.Error("section validation failure", err) + w.WriteHeader(http.StatusUnauthorized) + } + + return + } + + s := domain.StoreContext{Runtime: h.Runtime, Context: domain.GetRequestContext(r)} + + token := FindJWT(r) + rc, _, tokenErr := DecodeJWT(h.Runtime, token) + + var org = organization.Organization{} + var err = errors.New("") + + // We always grab the org record regardless of token status. + // Why? If bad token we might be OK to alow anonymous access + // depending upon the domain in question. + if len(rc.OrgID) == 0 { + org, err = organization.GetOrganizationByDomain(s, organization.GetRequestSubdomain(s, r)) + } else { + org, err = organization.GetOrganization(s, rc.OrgID) + } + + rc.Subdomain = org.Domain + + // Inability to find org record spells the end of this request. + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + return + } + + // If we have bad auth token and the domain does not allow anon access + if !org.AllowAnonymousAccess && tokenErr != nil { + return + } + + dom := organization.GetSubdomainFromHost(s, r) + dom2 := organization.GetRequestSubdomain(s, r) + if org.Domain != dom && org.Domain != dom2 { + w.WriteHeader(http.StatusUnauthorized) + return + } + + // If we have bad auth token and the domain allows anon access + // then we generate guest rc. + if org.AllowAnonymousAccess { + // So you have a bad token + if len(token) > 1 { + if tokenErr != nil { + w.WriteHeader(http.StatusUnauthorized) + return + } + } else { + // Just grant anon user guest access + rc.UserID = "0" + rc.OrgID = org.RefID + rc.Authenticated = false + rc.Guest = true + } + } + + rc.AllowAnonymousAccess = org.AllowAnonymousAccess + rc.OrgName = org.Title + rc.Administrator = false + rc.Editor = false + rc.Global = false + rc.AppURL = r.Host + rc.Subdomain = organization.GetSubdomainFromHost(s, r) + rc.SSL = r.TLS != nil + + // Fetch user permissions for this org + if !rc.Authenticated { + w.WriteHeader(http.StatusUnauthorized) + return + } + + u, err := user.GetSecuredUser(s, org.RefID, rc.UserID) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + return + } + + rc.Administrator = u.Admin + rc.Editor = u.Editor + rc.Global = u.Global + + response.WriteJSON(w, u) + return +} diff --git a/domain/auth/jwt.go b/domain/auth/jwt.go new file mode 100644 index 00000000..f0eb54ae --- /dev/null +++ b/domain/auth/jwt.go @@ -0,0 +1,133 @@ +// Copyright 2016 Documize Inc. . All rights reserved. +// +// This software (Documize Community Edition) is licensed under +// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html +// +// You can operate outside the AGPL restrictions by purchasing +// Documize Enterprise Edition and obtaining a commercial license +// by contacting . +// +// https://documize.com + +package auth + +import ( + "fmt" + "net/http" + "strings" + "time" + + jwt "github.com/dgrijalva/jwt-go" + "github.com/documize/community/core/env" + "github.com/documize/community/domain" +) + +// GenerateJWT generates JSON Web Token (http://jwt.io) +func GenerateJWT(rt env.Runtime, user, org, domain string) string { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "iss": "Documize", + "sub": "webapp", + "exp": time.Now().Add(time.Hour * 168).Unix(), + "user": user, + "org": org, + "domain": domain, + }) + + tokenString, _ := token.SignedString([]byte(rt.Flags.Salt)) + + return tokenString +} + +// FindJWT looks for 'Authorization' request header OR query string "?token=XXX". +func FindJWT(r *http.Request) (token string) { + header := r.Header.Get("Authorization") + + if header != "" { + header = strings.Replace(header, "Bearer ", "", 1) + } + + if len(header) > 1 { + token = header + } else { + query := r.URL.Query() + token = query.Get("token") + } + + if token == "null" { + token = "" + } + + return +} + +// DecodeJWT decodes raw token. +func DecodeJWT(rt env.Runtime, tokenString string) (c domain.RequestContext, claims jwt.Claims, err error) { + // sensible defaults + c.UserID = "" + c.OrgID = "" + c.Authenticated = false + c.Guest = false + + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + return []byte(rt.Flags.Salt), nil + }) + + if err != nil { + err = fmt.Errorf("bad authorization token") + return + } + + if !token.Valid { + if ve, ok := err.(*jwt.ValidationError); ok { + if ve.Errors&jwt.ValidationErrorMalformed != 0 { + err = fmt.Errorf("bad token") + return + } else if ve.Errors&(jwt.ValidationErrorExpired|jwt.ValidationErrorNotValidYet) != 0 { + err = fmt.Errorf("expired token") + return + } else { + err = fmt.Errorf("bad token") + return + } + } else { + err = fmt.Errorf("bad token") + return + } + } + + c = domain.RequestContext{} + + if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { + c.UserID = claims["user"].(string) + c.OrgID = claims["org"].(string) + } else { + fmt.Println(err) + } + + if len(c.UserID) == 0 || len(c.OrgID) == 0 { + err = fmt.Errorf("unable parse token data") + return + } + + c.Authenticated = true + c.Guest = false + + return c, token.Claims, nil +} + +// DecodeKeycloakJWT takes in Keycloak token string and decodes it. +func DecodeKeycloakJWT(t, pk string) (c jwt.MapClaims, err error) { + token, err := jwt.Parse(t, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) + } + + return jwt.ParseRSAPublicKeyFromPEM([]byte(pk)) + }) + + if c, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { + return c, nil + } + + return nil, err +} diff --git a/domain/auth/model.go b/domain/auth/model.go new file mode 100644 index 00000000..3f3a6710 --- /dev/null +++ b/domain/auth/model.go @@ -0,0 +1,28 @@ +// Copyright 2016 Documize Inc. . All rights reserved. +// +// This software (Documize Community Edition) is licensed under +// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html +// +// You can operate outside the AGPL restrictions by purchasing +// Documize Enterprise Edition and obtaining a commercial license +// by contacting . +// +// https://documize.com + +package auth + +import ( + "github.com/documize/community/core/env" + "github.com/documize/community/domain/user" +) + +// Handler contains the runtime information such as logging and database. +type Handler struct { + Runtime env.Runtime +} + +// AuthenticationModel details authentication token and user details. +type AuthenticationModel struct { + Token string `json:"token"` + User user.User `json:"user"` +} diff --git a/domain/context.go b/domain/context.go new file mode 100644 index 00000000..66427af1 --- /dev/null +++ b/domain/context.go @@ -0,0 +1,64 @@ +package domain + +import ( + "fmt" + "net/http" + "time" + + "github.com/documize/community/core/env" + "github.com/jmoiron/sqlx" +) + +// RequestContext provides per request scoped values required +// by HTTP handlers. +type RequestContext struct { + AllowAnonymousAccess bool + Authenticated bool + Administrator bool + Guest bool + Editor bool + Global bool + UserID string + OrgID string + OrgName string + SSL bool + AppURL string // e.g. https://{url}.documize.com + Subdomain string + ClientIP string + Expires time.Time + Fullname string + Transaction *sqlx.Tx +} + +//GetAppURL returns full HTTP url for the app +func (c *RequestContext) GetAppURL(endpoint string) string { + scheme := "http://" + + if c.SSL { + scheme = "https://" + } + + return fmt.Sprintf("%s%s/%s", scheme, c.AppURL, endpoint) +} + +type key string + +// DocumizeContextKey prevents key name collisions. +const DocumizeContextKey key = "documize context key" + +// GetRequestContext returns RequestContext from context.Context +func GetRequestContext(r *http.Request) RequestContext { + return r.Context().Value(DocumizeContextKey).(RequestContext) +} + +// StoreContext provides data persistence methods with runtime and request context. +type StoreContext struct { + Runtime env.Runtime + Context RequestContext +} + +// NewContexts returns request scoped user context and store context for persistence logic. +func NewContexts(rt env.Runtime, r *http.Request) (RequestContext, StoreContext) { + ctx := GetRequestContext(r) + return ctx, StoreContext{Runtime: rt, Context: ctx} +} diff --git a/domain/document/store.go b/domain/document/store.go new file mode 100644 index 00000000..2312c160 --- /dev/null +++ b/domain/document/store.go @@ -0,0 +1,39 @@ +// Copyright 2016 Documize Inc. . All rights reserved. +// +// This software (Documize Community Edition) is licensed under +// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html +// +// You can operate outside the AGPL restrictions by purchasing +// Documize Enterprise Edition and obtaining a commercial license +// by contacting . +// +// https://documize.com + +package document + +import ( + "fmt" + + "github.com/documize/community/core/streamutil" + "github.com/documize/community/domain" + "github.com/pkg/errors" +) + +// MoveDocumentSpace changes the label for client's organization's documents which have space "id", to "move". +func MoveDocumentSpace(s domain.StoreContext, id, move string) (err error) { + stmt, err := s.Context.Transaction.Preparex("UPDATE document SET labelid=? WHERE orgid=? AND labelid=?") + defer streamutil.Close(stmt) + + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("prepare document space move %s", id)) + return + } + + _, err = stmt.Exec(move, s.Context.OrgID, id) + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("execute document space move %s", id)) + return + } + + return +} diff --git a/domain/eventing/model.go b/domain/eventing/model.go new file mode 100644 index 00000000..5f2b79cc --- /dev/null +++ b/domain/eventing/model.go @@ -0,0 +1,70 @@ +// Copyright 2016 Documize Inc. . All rights reserved. +// +// This software (Documize Community Edition) is licensed under +// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html +// +// You can operate outside the AGPL restrictions by purchasing +// Documize Enterprise Edition and obtaining a commercial license +// by contacting . +// +// https://documize.com + +// Package eventing records and propagates events based on user actions. +package eventing + +import "time" + +// AppEvent represents an event initiated by a user. +type AppEvent struct { + ID uint64 `json:"-"` + OrgID string `json:"orgId"` + UserID string `json:"userId"` + Type string `json:"eventType"` + IP string `json:"ip"` + Created time.Time `json:"created"` +} + +// EventType defines valid event entry types +type EventType string + +const ( + EventTypeDocumentAdd EventType = "added-document" + EventTypeDocumentUpload EventType = "uploaded-document" + EventTypeDocumentView EventType = "viewed-document" + EventTypeDocumentUpdate EventType = "updated-document" + EventTypeDocumentDelete EventType = "removed-document" + EventTypeDocumentRevisions EventType = "viewed-document-revisions" + EventTypeSpaceAdd EventType = "added-space" + EventTypeSpaceUpdate EventType = "updated-space" + EventTypeSpaceDelete EventType = "removed-space" + EventTypeSpacePermission EventType = "changed-space-permissions" + EventTypeSpaceJoin EventType = "joined-space" + EventTypeSpaceInvite EventType = "invited-space" + EventTypeSectionAdd EventType = "added-document-section" + EventTypeSectionUpdate EventType = "updated-document-section" + EventTypeSectionDelete EventType = "removed-document-section" + EventTypeSectionRollback EventType = "rolled-back-document-section" + EventTypeSectionResequence EventType = "resequenced-document-section" + EventTypeSectionCopy EventType = "copied-document-section" + EventTypeAttachmentAdd EventType = "added-attachment" + EventTypeAttachmentDownload EventType = "downloaded-attachment" + EventTypeAttachmentDelete EventType = "removed-attachment" + EventTypePinAdd EventType = "added-pin" + EventTypePinDelete EventType = "removed-pin" + EventTypePinResequence EventType = "resequenced-pin" + EventTypeBlockAdd EventType = "added-reusable-block" + EventTypeBlockUpdate EventType = "updated-reusable-block" + EventTypeBlockDelete EventType = "removed-reusable-block" + EventTypeTemplateAdd EventType = "added-document-template" + EventTypeTemplateUse EventType = "used-document-template" + EventTypeUserAdd EventType = "added-user" + EventTypeUserUpdate EventType = "updated-user" + EventTypeUserDelete EventType = "removed-user" + EventTypeUserPasswordReset EventType = "reset-user-password" + EventTypeAccountAdd EventType = "added-account" + EventTypeSystemLicense EventType = "changed-system-license" + EventTypeSystemAuth EventType = "changed-system-auth" + EventTypeSystemSMTP EventType = "changed-system-smtp" + EventTypeSessionStart EventType = "started-session" + EventTypeSearch EventType = "searched" +) diff --git a/domain/eventing/store.go b/domain/eventing/store.go new file mode 100644 index 00000000..8ab82e99 --- /dev/null +++ b/domain/eventing/store.go @@ -0,0 +1,55 @@ +// Copyright 2016 Documize Inc. . All rights reserved. +// +// This software (Documize Community Edition) is licensed under +// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html +// +// You can operate outside the AGPL restrictions by purchasing +// Documize Enterprise Edition and obtaining a commercial license +// by contacting . +// +// https://documize.com + +// Package eventing records user events. +package eventing + +import ( + "time" + + "github.com/documize/community/domain" + "github.com/pkg/errors" +) + +// Record adds event entry for specified user. +func Record(s domain.StoreContext, t EventType) { + e := AppEvent{} + e.OrgID = s.Context.OrgID + e.UserID = s.Context.UserID + e.Created = time.Now().UTC() + e.IP = s.Context.ClientIP + e.Type = string(t) + + tx, err := s.Runtime.Db.Beginx() + if err != nil { + err = errors.Wrap(err, "start transaction") + return + } + + stmt, err := tx.Preparex("INSERT INTO userevent (orgid, userid, eventtype, ip, created) VALUES (?, ?, ?, ?, ?)") + if err != nil { + tx.Rollback() + err = errors.Wrap(err, "prepare insert RecordEvent") + return + } + + _, err = stmt.Exec(e.OrgID, e.UserID, e.Type, e.IP, e.Created) + if err != nil { + err = errors.Wrap(err, "execute insert RecordEvent") + tx.Rollback() + return + } + + stmt.Close() + tx.Commit() + + return +} diff --git a/domain/model.go b/domain/model.go new file mode 100644 index 00000000..62fcb542 --- /dev/null +++ b/domain/model.go @@ -0,0 +1,34 @@ +// Copyright 2016 Documize Inc. . All rights reserved. +// +// This software (Documize Community Edition) is licensed under +// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html +// +// You can operate outside the AGPL restrictions by purchasing +// Documize Enterprise Edition and obtaining a commercial license +// by contacting . +// +// https://documize.com + +// Package domain ... +package domain + +import ( + "time" +) + +// BaseEntity contains the database fields used in every table. +type BaseEntity struct { + ID uint64 `json:"-"` + RefID string `json:"id"` + Created time.Time `json:"created"` + Revised time.Time `json:"revised"` +} + +// BaseEntityObfuscated is a mirror of BaseEntity, +// but with the fields invisible to JSON. +type BaseEntityObfuscated struct { + ID uint64 `json:"-"` + RefID string `json:"-"` + Created time.Time `json:"-"` + Revised time.Time `json:"-"` +} diff --git a/domain/organization/model.go b/domain/organization/model.go new file mode 100644 index 00000000..f6a9661f --- /dev/null +++ b/domain/organization/model.go @@ -0,0 +1,39 @@ +// Copyright 2016 Documize Inc. . All rights reserved. +// +// This software (Documize Community Edition) is licensed under +// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html +// +// You can operate outside the AGPL restrictions by purchasing +// Documize Enterprise Edition and obtaining a commercial license +// by contacting . +// +// https://documize.com + +package organization + +import ( + "github.com/documize/community/core/env" + "github.com/documize/community/domain" +) + +// Handler contains the runtime information such as logging and database. +type Handler struct { + Runtime env.Runtime +} + +// Organization defines a company that uses this app. +type Organization struct { + domain.BaseEntity + Company string `json:"-"` + Title string `json:"title"` + Message string `json:"message"` + URL string `json:"url"` + Domain string `json:"domain"` + Email string `json:"email"` + AllowAnonymousAccess bool `json:"allowAnonymousAccess"` + AuthProvider string `json:"authProvider"` + AuthConfig string `json:"authConfig"` + ConversionEndpoint string `json:"conversionEndpoint"` + Serial string `json:"-"` + Active bool `json:"-"` +} diff --git a/domain/organization/organization.go b/domain/organization/organization.go new file mode 100644 index 00000000..48433510 --- /dev/null +++ b/domain/organization/organization.go @@ -0,0 +1,46 @@ +// Copyright 2016 Documize Inc. . All rights reserved. +// +// This software (Documize Community Edition) is licensed under +// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html +// +// You can operate outside the AGPL restrictions by purchasing +// Documize Enterprise Edition and obtaining a commercial license +// by contacting . +// +// https://documize.com + +package organization + +import ( + "net/http" + "strings" + + "github.com/documize/community/domain" +) + +// GetRequestSubdomain extracts subdomain from referring URL. +func GetRequestSubdomain(s domain.StoreContext, r *http.Request) string { + return urlSubdomain(s, r.Referer()) +} + +// GetSubdomainFromHost extracts the subdomain from the requesting URL. +func GetSubdomainFromHost(s domain.StoreContext, r *http.Request) string { + return urlSubdomain(s, r.Host) +} + +// Find the subdomain (which is actually the organisation). +func urlSubdomain(s domain.StoreContext, url string) string { + url = strings.ToLower(url) + url = strings.Replace(url, "https://", "", 1) + url = strings.Replace(url, "http://", "", 1) + + parts := strings.Split(url, ".") + + if len(parts) >= 2 { + url = parts[0] + } else { + url = "" + } + + return CheckDomain(s, url) +} diff --git a/domain/organization/store.go b/domain/organization/store.go new file mode 100644 index 00000000..17397cc0 --- /dev/null +++ b/domain/organization/store.go @@ -0,0 +1,183 @@ +// Copyright 2016 Documize Inc. . All rights reserved. +// +// This software (Documize Community Edition) is licensed under +// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html +// +// You can operate outside the AGPL restrictions by purchasing +// Documize Enterprise Edition and obtaining a commercial license +// by contacting . +// +// https://documize.com + +package organization + +import ( + "database/sql" + "fmt" + "strings" + "time" + + "github.com/documize/community/core/env" + "github.com/documize/community/core/streamutil" + "github.com/documize/community/domain" + "github.com/documize/community/domain/store/mysql" + "github.com/jmoiron/sqlx" + "github.com/pkg/errors" +) + +// AddOrganization inserts the passed organization record into the organization table. +func AddOrganization(s domain.StoreContext, org Organization) error { + org.Created = time.Now().UTC() + org.Revised = time.Now().UTC() + + stmt, err := s.Context.Transaction.Preparex( + "INSERT INTO organization (refid, company, title, message, url, domain, email, allowanonymousaccess, serial, created, revised) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") + defer streamutil.Close(stmt) + + if err != nil { + err = errors.Wrap(err, "unable to prepare insert for org") + return err + } + + _, err = stmt.Exec(org.RefID, org.Company, org.Title, org.Message, strings.ToLower(org.URL), strings.ToLower(org.Domain), + strings.ToLower(org.Email), org.AllowAnonymousAccess, org.Serial, org.Created, org.Revised) + + if err != nil { + err = errors.Wrap(err, "unable to execute insert for org") + return err + } + + return nil +} + +// GetOrganization returns the Organization reocrod from the organization database table with the given id. +func GetOrganization(s domain.StoreContext, id string) (org Organization, err error) { + stmt, err := s.Runtime.Db.Preparex("SELECT id, refid, company, title, message, url, domain, service as conversionendpoint, email, serial, active, allowanonymousaccess, authprovider, coalesce(authconfig,JSON_UNQUOTE('{}')) as authconfig, created, revised FROM organization WHERE refid=?") + defer streamutil.Close(stmt) + + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("unable to prepare select for org %s", id)) + return + } + + err = stmt.Get(&org, id) + + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("unable to get org %s", id)) + return + } + + return +} + +// GetOrganizationByDomain returns the organization matching a given URL subdomain. +func GetOrganizationByDomain(s domain.StoreContext, subdomain string) (org Organization, err error) { + err = nil + subdomain = strings.ToLower(subdomain) + + if s.Runtime.Flags.SiteMode == env.SiteModeNormal { // only return an organization when running normally + var stmt *sqlx.Stmt + + stmt, err = s.Runtime.Db.Preparex("SELECT id, refid, company, title, message, url, domain, service as conversionendpoint, email, serial, active, allowanonymousaccess, authprovider, coalesce(authconfig,JSON_UNQUOTE('{}')) as authconfig, created, revised FROM organization WHERE domain=? AND active=1") + defer streamutil.Close(stmt) + + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("unable to prepare select for subdomain %s", subdomain)) + return + } + + err = stmt.Get(&org, subdomain) + + if err != nil && err != sql.ErrNoRows { + err = errors.Wrap(err, fmt.Sprintf("unable to execute select for subdomain %s", subdomain)) + return + } + } + + return +} + +// UpdateOrganization updates the given organization record in the database to the values supplied. +func UpdateOrganization(s domain.StoreContext, org Organization) (err error) { + org.Revised = time.Now().UTC() + + stmt, err := s.Context.Transaction.PrepareNamed("UPDATE organization SET title=:title, message=:message, service=:conversionendpoint, email=:email, allowanonymousaccess=:allowanonymousaccess, revised=:revised WHERE refid=:refid") + defer streamutil.Close(stmt) + + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("unable to prepare update for org %s", org.RefID)) + return + } + + _, err = stmt.Exec(&org) + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("unable to execute update for org %s", org.RefID)) + return + } + + return +} + +// DeleteOrganization deletes the orgID organization from the organization table. +func DeleteOrganization(s domain.StoreContext, orgID string) (rows int64, err error) { + b := mysql.BaseQuery{} + return b.Delete(s.Context.Transaction, "organization", orgID) +} + +// RemoveOrganization sets the orgID organization to be inactive, thus executing a "soft delete" operation. +func RemoveOrganization(s domain.StoreContext, rc domain.RequestContext, orgID string) (err error) { + stmt, err := s.Context.Transaction.Preparex("UPDATE organization SET active=0 WHERE refid=?") + defer streamutil.Close(stmt) + + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("unable to prepare soft delete for org %s", orgID)) + return + } + + _, err = stmt.Exec(orgID) + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("unable to execute soft delete for org %s", orgID)) + return + } + + return +} + +// UpdateAuthConfig updates the given organization record in the database with the auth config details. +func UpdateAuthConfig(s domain.StoreContext, org Organization) (err error) { + org.Revised = time.Now().UTC() + + stmt, err := s.Context.Transaction.PrepareNamed("UPDATE organization SET allowanonymousaccess=:allowanonymousaccess, authprovider=:authprovider, authconfig=:authconfig, revised=:revised WHERE refid=:refid") + defer streamutil.Close(stmt) + + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("unable to prepare UpdateAuthConfig %s", org.RefID)) + return + } + + _, err = stmt.Exec(&org) + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("unable to execute UpdateAuthConfig %s", org.RefID)) + return + } + + return +} + +// CheckDomain makes sure there is an organisation with the correct domain +func CheckDomain(s domain.StoreContext, domain string) string { + row := s.Runtime.Db.QueryRow("SELECT COUNT(*) FROM organization WHERE domain=? AND active=1", domain) + + var count int + err := row.Scan(&count) + + if err != nil { + return "" + } + + if count == 1 { + return domain + } + + return "" +} diff --git a/domain/pin/model.go b/domain/pin/model.go new file mode 100644 index 00000000..4f18328d --- /dev/null +++ b/domain/pin/model.go @@ -0,0 +1,33 @@ +// Copyright 2016 Documize Inc. . All rights reserved. +// +// This software (Documize Community Edition) is licensed under +// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html +// +// You can operate outside the AGPL restrictions by purchasing +// Documize Enterprise Edition and obtaining a commercial license +// by contacting . +// +// https://documize.com + +package pin + +import ( + "github.com/documize/community/core/env" + "github.com/documize/community/domain" +) + +// Handler contains the runtime information such as logging and database. +type Handler struct { + Runtime env.Runtime +} + +// Pin defines a saved link to a document or space +type Pin struct { + domain.BaseEntity + OrgID string `json:"orgId"` + UserID string `json:"userId"` + FolderID string `json:"folderId"` + DocumentID string `json:"documentId"` + Pin string `json:"pin"` + Sequence int `json:"sequence"` +} diff --git a/domain/pin/store.go b/domain/pin/store.go new file mode 100644 index 00000000..e369d315 --- /dev/null +++ b/domain/pin/store.go @@ -0,0 +1,145 @@ +// Copyright 2016 Documize Inc. . All rights reserved. +// +// This software (Documize Community Edition) is licensed under +// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html +// +// You can operate outside the AGPL restrictions by purchasing +// Documize Enterprise Edition and obtaining a commercial license +// by contacting . +// +// https://documize.com + +package pin + +import ( + "fmt" + "time" + + "github.com/documize/community/core/api/entity" + "github.com/documize/community/core/streamutil" + "github.com/documize/community/domain" + "github.com/documize/community/domain/store/mysql" + "github.com/jmoiron/sqlx" + "github.com/pkg/errors" +) + +// Add saves pinned item. +func Add(s domain.StoreContext, pin Pin) (err error) { + row := s.Runtime.Db.QueryRow("SELECT max(sequence) FROM pin WHERE orgid=? AND userid=?", s.Context.OrgID, s.Context.UserID) + var maxSeq int + err = row.Scan(&maxSeq) + + if err != nil { + maxSeq = 99 + } + + pin.Created = time.Now().UTC() + pin.Revised = time.Now().UTC() + pin.Sequence = maxSeq + 1 + + stmt, err := s.Context.Transaction.Preparex("INSERT INTO pin (refid, orgid, userid, labelid, documentid, pin, sequence, created, revised) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") + defer streamutil.Close(stmt) + + if err != nil { + err = errors.Wrap(err, "prepare pin insert") + return + } + + _, err = stmt.Exec(pin.RefID, pin.OrgID, pin.UserID, pin.FolderID, pin.DocumentID, pin.Pin, pin.Sequence, pin.Created, pin.Revised) + if err != nil { + err = errors.Wrap(err, "execute pin insert") + return + } + + return +} + +// GetPin returns requested pinned item. +func GetPin(s domain.StoreContext, id string) (pin Pin, err error) { + stmt, err := s.Runtime.Db.Preparex("SELECT id, refid, orgid, userid, labelid as folderid, documentid, pin, sequence, created, revised FROM pin WHERE orgid=? AND refid=?") + defer streamutil.Close(stmt) + + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("prepare select for pin %s", id)) + return + } + + err = stmt.Get(&pin, s.Context.OrgID, id) + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("execute select for pin %s", id)) + return + } + + return +} + +// GetUserPins returns pinned items for specified user. +func GetUserPins(s domain.StoreContext, userID string) (pins []Pin, err error) { + err = s.Runtime.Db.Select(&pins, "SELECT id, refid, orgid, userid, labelid as folderid, documentid, pin, sequence, created, revised FROM pin WHERE orgid=? AND userid=? ORDER BY sequence", s.Context.OrgID, userID) + + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("execute select pins for org %s and user %s", s.Context.OrgID, userID)) + return + } + + return +} + +// UpdatePin updates existing pinned item. +func UpdatePin(s domain.StoreContext, pin entity.Pin) (err error) { + pin.Revised = time.Now().UTC() + + var stmt *sqlx.NamedStmt + stmt, err = s.Context.Transaction.PrepareNamed("UPDATE pin SET labelid=:folderid, documentid=:documentid, pin=:pin, sequence=:sequence, revised=:revised WHERE orgid=:orgid AND refid=:refid") + defer streamutil.Close(stmt) + + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("prepare pin update %s", pin.RefID)) + return + } + + _, err = stmt.Exec(&pin) + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("execute pin update %s", pin.RefID)) + return + } + + return +} + +// UpdatePinSequence updates existing pinned item sequence number +func UpdatePinSequence(s domain.StoreContext, pinID string, sequence int) (err error) { + stmt, err := s.Context.Transaction.Preparex("UPDATE pin SET sequence=?, revised=? WHERE orgid=? AND userid=? AND refid=?") + defer streamutil.Close(stmt) + + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("prepare pin sequence update %s", pinID)) + return + } + + _, err = stmt.Exec(sequence, time.Now().UTC(), s.Context.OrgID, s.Context.UserID, pinID) + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("execute pin sequence update %s", pinID)) + return + } + + return +} + +// DeletePin removes folder from the store. +func DeletePin(s domain.StoreContext, id string) (rows int64, err error) { + b := mysql.BaseQuery{} + return b.DeleteConstrained(s.Context.Transaction, "pin", s.Context.OrgID, id) +} + +// DeletePinnedSpace removes any pins for specified space. +func DeletePinnedSpace(s domain.StoreContext, spaceID string) (rows int64, err error) { + b := mysql.BaseQuery{} + return b.DeleteWhere(s.Context.Transaction, fmt.Sprintf("DELETE FROM pin WHERE orgid=\"%s\" AND labelid=\"%s\"", s.Context.OrgID, spaceID)) +} + +// DeletePinnedDocument removes any pins for specified document. +func DeletePinnedDocument(s domain.StoreContext, documentID string) (rows int64, err error) { + b := mysql.BaseQuery{} + return b.DeleteWhere(s.Context.Transaction, fmt.Sprintf("DELETE FROM pin WHERE orgid=\"%s\" AND documentid=\"%s\"", s.Context.OrgID, documentID)) +} diff --git a/domain/space/endpoint.go b/domain/space/endpoint.go index 9c75d367..26b4cda1 100644 --- a/domain/space/endpoint.go +++ b/domain/space/endpoint.go @@ -12,3 +12,757 @@ // Package space handles API calls and persistence for spaces. // Spaces in Documize contain documents. package space + +import ( + "database/sql" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "strings" + + "github.com/documize/api/wordsmith/log" + "github.com/documize/community/core/api/mail" + "github.com/documize/community/core/request" + "github.com/documize/community/core/response" + "github.com/documize/community/core/secrets" + "github.com/documize/community/core/streamutil" + "github.com/documize/community/core/stringutil" + "github.com/documize/community/core/uniqueid" + "github.com/documize/community/domain" + "github.com/documize/community/domain/account" + "github.com/documize/community/domain/document" + "github.com/documize/community/domain/eventing" + "github.com/documize/community/domain/organization" + "github.com/documize/community/domain/pin" + "github.com/documize/community/domain/user" +) + +// Add creates a new space. +func (h *Handler) Add(w http.ResponseWriter, r *http.Request) { + method := "AddSpace" + ctx, s := domain.NewContexts(h.Runtime, r) + + if !h.Runtime.Product.License.IsValid() { + response.WriteBadLicense(w) + return + } + + if !ctx.Editor { + response.WriteForbiddenError(w) + return + } + + defer streamutil.Close(r.Body) + body, err := ioutil.ReadAll(r.Body) + if err != nil { + response.WriteBadRequestError(w, method, err.Error()) + return + } + + var space = Space{} + err = json.Unmarshal(body, &space) + if err != nil { + response.WriteServerError(w, method, err) + return + } + + if len(space.Name) == 0 { + response.WriteMissingDataError(w, method, "name") + return + } + + ctx.Transaction, err = h.Runtime.Db.Beginx() + if err != nil { + response.WriteServerError(w, method, err) + return + } + + space.RefID = uniqueid.Generate() + space.OrgID = ctx.OrgID + + err = addSpace(s, space) + if err != nil { + ctx.Transaction.Rollback() + response.WriteServerError(w, method, err) + return + } + + eventing.Record(s, eventing.EventTypeSpaceAdd) + + ctx.Transaction.Commit() + + space, _ = Get(s, space.RefID) + + response.WriteJSON(w, space) +} + +// Get returns the requested space. +func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { + method := "Get" + _, s := domain.NewContexts(h.Runtime, r) + + id := request.Param(r, "folderID") + if len(id) == 0 { + response.WriteMissingDataError(w, method, "folderID") + return + } + + sp, err := Get(s, id) + if err == sql.ErrNoRows { + response.WriteNotFoundError(w, method, id) + return + } + if err != nil { + response.WriteServerError(w, method, err) + return + } + + response.WriteJSON(w, sp) +} + +// GetAll returns spaces the user can see. +func (h *Handler) GetAll(w http.ResponseWriter, r *http.Request) { + method := "GetAll" + _, s := domain.NewContexts(h.Runtime, r) + + sp, err := GetAll(s) + if err != nil && err != sql.ErrNoRows { + response.WriteServerError(w, method, err) + return + } + + if len(sp) == 0 { + sp = []Space{} + } + + response.WriteJSON(w, sp) +} + +// GetSpaceViewers returns the users that can see the shared spaces. +func (h *Handler) GetSpaceViewers(w http.ResponseWriter, r *http.Request) { + method := "GetSpaceViewers" + _, s := domain.NewContexts(h.Runtime, r) + + v, err := Viewers(s) + if err != nil && err != sql.ErrNoRows { + response.WriteServerError(w, method, err) + return + } + + if len(v) == 0 { + v = []Viewer{} + } + + response.WriteJSON(w, v) +} + +// Update processes request to save space object to the database +func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { + method := "space.Update" + ctx, s := domain.NewContexts(h.Runtime, r) + + if !ctx.Editor { + response.WriteForbiddenError(w) + return + } + + folderID := request.Param(r, "folderID") + if len(folderID) == 0 { + response.WriteMissingDataError(w, method, "folderID") + return + } + + defer streamutil.Close(r.Body) + body, err := ioutil.ReadAll(r.Body) + if err != nil { + response.WriteBadRequestError(w, method, err.Error()) + return + } + + var sp Space + err = json.Unmarshal(body, &sp) + if err != nil { + response.WriteBadRequestError(w, method, "marshal") + return + } + + if len(sp.Name) == 0 { + response.WriteMissingDataError(w, method, "name") + return + } + + sp.RefID = folderID + + ctx.Transaction, err = h.Runtime.Db.Beginx() + if err != nil { + response.WriteServerError(w, method, err) + return + } + + err = Update(s, sp) + if err != nil { + ctx.Transaction.Rollback() + response.WriteServerError(w, method, err) + return + } + + eventing.Record(s, eventing.EventTypeSpaceUpdate) + + ctx.Transaction.Commit() + + response.WriteJSON(w, sp) +} + +// Remove moves documents to another folder before deleting it +func (h *Handler) Remove(w http.ResponseWriter, r *http.Request) { + method := "space.Remove" + ctx, s := domain.NewContexts(h.Runtime, r) + + if !h.Runtime.Product.License.IsValid() { + response.WriteBadLicense(w) + return + } + + if !ctx.Editor { + response.WriteForbiddenError(w) + return + } + + id := request.Param(r, "folderID") + move := request.Param(r, "moveToId") + + if len(id) == 0 { + response.WriteMissingDataError(w, method, "folderID") + return + } + if len(move) == 0 { + response.WriteMissingDataError(w, method, "moveToId") + return + } + + var err error + ctx.Transaction, err = h.Runtime.Db.Beginx() + if err != nil { + response.WriteServerError(w, method, err) + return + } + + _, err = Delete(s, id) + if err != nil { + ctx.Transaction.Rollback() + response.WriteServerError(w, method, err) + return + } + + err = document.MoveDocumentSpace(s, id, move) + if err != nil { + ctx.Transaction.Rollback() + response.WriteServerError(w, method, err) + return + } + + err = MoveSpaceRoles(s, id, move) + if err != nil { + ctx.Transaction.Rollback() + response.WriteServerError(w, method, err) + return + } + + _, err = pin.DeletePinnedSpace(s, id) + if err != nil && err != sql.ErrNoRows { + ctx.Transaction.Rollback() + response.WriteServerError(w, method, err) + return + } + + eventing.Record(s, eventing.EventTypeSpaceDelete) + + ctx.Transaction.Commit() + + response.WriteEmpty(w) +} + +// Delete deletes empty space. +func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { + method := "space.Delete" + ctx, s := domain.NewContexts(h.Runtime, r) + + if !h.Runtime.Product.License.IsValid() { + response.WriteBadLicense(w) + return + } + + if !ctx.Editor { + response.WriteForbiddenError(w) + return + } + + id := request.Param(r, "folderID") + if len(id) == 0 { + response.WriteMissingDataError(w, method, "folderID") + return + } + + var err error + ctx.Transaction, err = h.Runtime.Db.Beginx() + if err != nil { + response.WriteServerError(w, method, err) + return + } + + _, err = Delete(s, id) + if err != nil { + ctx.Transaction.Rollback() + response.WriteServerError(w, method, err) + return + } + + _, err = DeleteSpaceRoles(s, id) + if err != nil { + ctx.Transaction.Rollback() + response.WriteServerError(w, method, err) + return + } + + _, err = pin.DeletePinnedSpace(s, id) + if err != nil && err != sql.ErrNoRows { + ctx.Transaction.Rollback() + response.WriteServerError(w, method, err) + return + } + + eventing.Record(s, eventing.EventTypeSpaceDelete) + + ctx.Transaction.Commit() + response.WriteEmpty(w) +} + +// SetPermissions persists specified spac3 permissions +func (h *Handler) SetPermissions(w http.ResponseWriter, r *http.Request) { + method := "space.SetPermissions" + ctx, s := domain.NewContexts(h.Runtime, r) + + if !ctx.Editor { + response.WriteForbiddenError(w) + return + } + + id := request.Param(r, "folderID") + if len(id) == 0 { + response.WriteMissingDataError(w, method, "folderID") + return + } + + sp, err := Get(s, id) + if err != nil { + response.WriteNotFoundError(w, method, "No such space") + return + } + + if sp.UserID != s.Context.UserID { + response.WriteForbiddenError(w) + return + } + + defer streamutil.Close(r.Body) + body, err := ioutil.ReadAll(r.Body) + if err != nil { + response.WriteBadRequestError(w, method, err.Error()) + return + } + + var model = RolesModel{} + err = json.Unmarshal(body, &model) + if err != nil { + response.WriteServerError(w, method, err) + return + } + + ctx.Transaction, err = h.Runtime.Db.Beginx() + if err != nil { + response.WriteServerError(w, method, err) + return + } + + // We compare new permisions to what we had before. + // Why? So we can send out folder invitation emails. + previousRoles, err := GetRoles(s, id) + if err != nil { + ctx.Transaction.Rollback() + response.WriteServerError(w, method, err) + return + } + + // Store all previous roles as map for easy querying + previousRoleUsers := make(map[string]bool) + + for _, v := range previousRoles { + previousRoleUsers[v.UserID] = true + } + + // Who is sharing this folder? + inviter, err := user.Get(s, s.Context.UserID) + if err != nil { + ctx.Transaction.Rollback() + response.WriteServerError(w, method, err) + return + } + + // Nuke all previous permissions for this folder + _, err = DeleteSpaceRoles(s, id) + if err != nil { + ctx.Transaction.Rollback() + response.WriteServerError(w, method, err) + return + } + + me := false + hasEveryoneRole := false + roleCount := 0 + + url := s.Context.GetAppURL(fmt.Sprintf("s/%s/%s", sp.RefID, stringutil.MakeSlug(sp.Name))) + + for _, role := range model.Roles { + role.OrgID = s.Context.OrgID + role.LabelID = id + + // Ensure the folder owner always has access! + if role.UserID == s.Context.UserID { + me = true + role.CanView = true + role.CanEdit = true + } + + if len(role.UserID) == 0 && (role.CanView || role.CanEdit) { + hasEveryoneRole = true + } + + // Only persist if there is a role! + if role.CanView || role.CanEdit { + roleID := uniqueid.Generate() + role.RefID = roleID + err = AddRole(s, role) + roleCount++ + log.IfErr(err) + + // We send out folder invitation emails to those users + // that have *just* been given permissions. + if _, isExisting := previousRoleUsers[role.UserID]; !isExisting { + + // we skip 'everyone' (user id != empty string) + if len(role.UserID) > 0 { + var existingUser user.User + existingUser, err = user.Get(s, role.UserID) + + if err == nil { + go mail.ShareFolderExistingUser(existingUser.Email, inviter.Fullname(), url, sp.Name, model.Message) + h.Runtime.Log.Info(fmt.Sprintf("%s is sharing space %s with existing user %s", inviter.Email, sp.Name, existingUser.Email)) + } else { + response.WriteServerError(w, method, err) + } + } + } + } + } + + // Do we need to ensure permissions for space owner when shared? + if !me { + role := Role{} + role.LabelID = id + role.OrgID = s.Context.OrgID + role.UserID = s.Context.UserID + role.CanEdit = true + role.CanView = true + roleID := uniqueid.Generate() + role.RefID = roleID + + err = AddRole(s, role) + if err != nil { + ctx.Transaction.Rollback() + response.WriteServerError(w, method, err) + return + } + } + + // Mark up folder type as either public, private or restricted access. + if hasEveryoneRole { + sp.Type = ScopePublic + } else { + if roleCount > 1 { + sp.Type = ScopeRestricted + } else { + sp.Type = ScopePrivate + } + } + + err = Update(s, sp) + if err != nil { + ctx.Transaction.Rollback() + response.WriteServerError(w, method, err) + return + } + + eventing.Record(s, eventing.EventTypeSpacePermission) + + ctx.Transaction.Commit() + + response.WriteEmpty(w) +} + +// GetPermissions returns user permissions for the requested folder. +func (h *Handler) GetPermissions(w http.ResponseWriter, r *http.Request) { + method := "space.GetPermissions" + _, s := domain.NewContexts(h.Runtime, r) + + folderID := request.Param(r, "folderID") + if len(folderID) == 0 { + response.WriteMissingDataError(w, method, "folderID") + return + } + + roles, err := GetRoles(s, folderID) + if err != nil && err != sql.ErrNoRows { + response.WriteServerError(w, method, err) + return + } + + if len(roles) == 0 { + roles = []Role{} + } + + response.WriteJSON(w, roles) +} + +// AcceptInvitation records the fact that a user has completed space onboard process. +func (h *Handler) AcceptInvitation(w http.ResponseWriter, r *http.Request) { + method := "space.AcceptInvitation" + ctx, s := domain.NewContexts(h.Runtime, r) + + folderID := request.Param(r, "folderID") + if len(folderID) == 0 { + response.WriteMissingDataError(w, method, "folderID") + return + } + + org, err := organization.GetOrganizationByDomain(s, ctx.Subdomain) + if err != nil { + response.WriteServerError(w, method, err) + return + } + + // AcceptShare does not authenticate the user hence the context needs to set up + ctx.OrgID = org.RefID + s.Context.OrgID = org.RefID + + defer streamutil.Close(r.Body) + body, err := ioutil.ReadAll(r.Body) + if err != nil { + response.WriteBadRequestError(w, method, err.Error()) + return + } + + var model = AcceptShareModel{} + err = json.Unmarshal(body, &model) + if err != nil { + response.WriteBadRequestError(w, method, err.Error()) + return + } + + if len(model.Serial) == 0 || len(model.Firstname) == 0 || len(model.Lastname) == 0 || len(model.Password) == 0 { + response.WriteMissingDataError(w, method, "Serial, Firstname, Lastname, Password") + return + } + + u, err := user.GetBySerial(s, model.Serial) + if err != nil && err == sql.ErrNoRows { + response.WriteDuplicateError(w, method, "user") + return + } + + // AcceptShare does not authenticate the user hence the context needs to set up + ctx.UserID = u.RefID + s.Context.UserID = u.RefID + + u.Firstname = model.Firstname + u.Lastname = model.Lastname + u.Initials = stringutil.MakeInitials(u.Firstname, u.Lastname) + + ctx.Transaction, err = h.Runtime.Db.Beginx() + if err != nil { + response.WriteServerError(w, method, err) + return + } + + err = user.UpdateUser(s, u) + if err != nil { + ctx.Transaction.Rollback() + response.WriteServerError(w, method, err) + return + } + + salt := secrets.GenerateSalt() + + err = user.UpdateUserPassword(s, u.RefID, salt, secrets.GeneratePassword(model.Password, salt)) + if err != nil { + ctx.Transaction.Rollback() + response.WriteServerError(w, method, err) + return + } + + eventing.Record(s, eventing.EventTypeSpaceJoin) + + ctx.Transaction.Commit() + + response.WriteJSON(w, u) +} + +// Invite sends users folder invitation emails. +func (h *Handler) Invite(w http.ResponseWriter, r *http.Request) { + method := "space.Invite" + ctx, s := domain.NewContexts(h.Runtime, r) + + id := request.Param(r, "folderID") + if len(id) == 0 { + response.WriteMissingDataError(w, method, "folderID") + return + } + + sp, err := Get(s, id) + if err != nil { + response.WriteServerError(w, method, err) + return + } + + if sp.UserID != s.Context.UserID { + response.WriteForbiddenError(w) + return + } + + defer streamutil.Close(r.Body) + body, err := ioutil.ReadAll(r.Body) + if err != nil { + response.WriteBadRequestError(w, method, "body") + return + } + + var model = InvitationModel{} + err = json.Unmarshal(body, &model) + if err != nil { + response.WriteBadRequestError(w, method, "json") + return + } + + ctx.Transaction, err = h.Runtime.Db.Beginx() + if err != nil { + response.WriteServerError(w, method, err) + return + } + + inviter, err := user.Get(s, ctx.UserID) + if err != nil { + response.WriteServerError(w, method, err) + return + } + + for _, email := range model.Recipients { + u, err := user.GetByEmail(s, email) + if err != nil && err != sql.ErrNoRows { + ctx.Transaction.Rollback() + response.WriteServerError(w, method, err) + return + } + + if len(u.RefID) > 0 { + // Ensure they have access to this organization + accounts, err2 := account.GetUserAccounts(s, u.RefID) + if err2 != nil { + ctx.Transaction.Rollback() + response.WriteServerError(w, method, err) + return + } + + // we create if they c + hasAccess := false + for _, a := range accounts { + if a.OrgID == s.Context.OrgID { + hasAccess = true + } + } + + if !hasAccess { + var a account.Account + a.UserID = u.RefID + a.OrgID = s.Context.OrgID + a.Admin = false + a.Editor = false + a.Active = true + accountID := uniqueid.Generate() + a.RefID = accountID + + err = account.Add(s, a) + if err != nil { + ctx.Transaction.Rollback() + response.WriteServerError(w, method, err) + return + } + } + + // Ensure they have space roles + DeleteUserSpaceRoles(s, sp.RefID, u.RefID) + + role := Role{} + role.LabelID = sp.RefID + role.OrgID = ctx.OrgID + role.UserID = u.RefID + role.CanEdit = false + role.CanView = true + roleID := uniqueid.Generate() + role.RefID = roleID + + err = AddRole(s, role) + if err != nil { + ctx.Transaction.Rollback() + response.WriteServerError(w, method, err) + return + } + + url := ctx.GetAppURL(fmt.Sprintf("s/%s/%s", sp.RefID, stringutil.MakeSlug(sp.Name))) + go mail.ShareFolderExistingUser(email, inviter.Fullname(), url, sp.Name, model.Message) + + h.Runtime.Log.Info(fmt.Sprintf("%s is sharing space %s with existing user %s", inviter.Email, sp.Name, email)) + } else { + // On-board new user + if strings.Contains(email, "@") { + url := ctx.GetAppURL(fmt.Sprintf("auth/share/%s/%s", sp.RefID, stringutil.MakeSlug(sp.Name))) + err = inviteNewUserToSharedSpace(s, email, inviter, url, sp, model.Message) + + if err != nil { + ctx.Transaction.Rollback() + response.WriteServerError(w, method, err) + return + } + + h.Runtime.Log.Info(fmt.Sprintf("%s is sharing space %s with new user %s", inviter.Email, sp.Name, email)) + } + } + } + + // We ensure that the folder is marked as restricted as a minimum! + if len(model.Recipients) > 0 && sp.Type == ScopePrivate { + sp.Type = ScopeRestricted + + err = Update(s, sp) + if err != nil { + ctx.Transaction.Rollback() + response.WriteServerError(w, method, err) + return + } + } + + eventing.Record(s, eventing.EventTypeSpaceInvite) + + ctx.Transaction.Commit() + + response.WriteEmpty(w) +} diff --git a/domain/space/model.go b/domain/space/model.go index 9c75d367..1017e6a9 100644 --- a/domain/space/model.go +++ b/domain/space/model.go @@ -12,3 +12,92 @@ // Package space handles API calls and persistence for spaces. // Spaces in Documize contain documents. package space + +import ( + "github.com/documize/community/core/env" + "github.com/documize/community/domain" +) + +// Handler contains the runtime information such as logging and database. +type Handler struct { + Runtime env.Runtime +} + +// Space defines a container for documents. +type Space struct { + domain.BaseEntity + Name string `json:"name"` + OrgID string `json:"orgId"` + UserID string `json:"userId"` + Type Scope `json:"folderType"` +} + +// Scope determines folder visibility. +type Scope int + +const ( + // ScopePublic can be seen by anyone + ScopePublic Scope = 1 + + // ScopePrivate can only be seen by the person who owns it + ScopePrivate Scope = 2 + + // ScopeRestricted can be seen by selected users + ScopeRestricted Scope = 3 +) + +// IsPublic means the folder can be seen by anyone. +func (l *Space) IsPublic() bool { + return l.Type == ScopePublic +} + +// IsPrivate means the folder can only be seen by the person who owns it. +func (l *Space) IsPrivate() bool { + return l.Type == ScopePrivate +} + +// IsRestricted means the folder can be seen by selected users. +func (l *Space) IsRestricted() bool { + return l.Type == ScopeRestricted +} + +// Role determines user permissions for a folder. +type Role struct { + domain.BaseEntityObfuscated + OrgID string `json:"-"` + LabelID string `json:"folderId"` + UserID string `json:"userId"` + CanView bool `json:"canView"` + CanEdit bool `json:"canEdit"` +} + +// Viewer details who can see a particular space +type Viewer struct { + Name string `json:"name"` + LabelID string `json:"folderId"` + Type int `json:"folderType"` + UserID string `json:"userId"` + Firstname string `json:"firstname"` + Lastname string `json:"lastname"` + Email string `json:"email"` +} + +// RolesModel details which users have what permissions on a given space. +type RolesModel struct { + Message string + Roles []Role +} + +// AcceptShareModel is used to setup a user who has accepted a shared space. +type AcceptShareModel struct { + Serial string `json:"serial"` + Firstname string `json:"firstname"` + Lastname string `json:"lastname"` + Password string `json:"password"` +} + +// InvitationModel details which users have been invited to a space. +type InvitationModel struct { + Message string + Recipients []string +} diff --git a/domain/space/permission.go b/domain/space/permission.go index 9c75d367..54a3a09d 100644 --- a/domain/space/permission.go +++ b/domain/space/permission.go @@ -12,3 +12,51 @@ // Package space handles API calls and persistence for spaces. // Spaces in Documize contain documents. package space + +import ( + "database/sql" + "fmt" + + "github.com/documize/community/domain" +) + +// CanViewSpace returns if the user has permission to view the given spaceID. +func CanViewSpace(s domain.StoreContext, spaceID string) (hasPermission bool) { + roles, err := GetRoles(s, spaceID) + if err == sql.ErrNoRows { + err = nil + } + if err != nil { + s.Runtime.Log.Error(fmt.Sprintf("check space permissions %s", spaceID), err) + return false + } + + for _, role := range roles { + if role.LabelID == spaceID && (role.CanView || role.CanEdit) { + return true + } + } + + return false +} + +// CanViewSpaceDocuments returns if the user has permission to view a document within the specified space. +func CanViewSpaceDocuments(s domain.StoreContext, spaceID string) (hasPermission bool) { + roles, err := GetRoles(s, spaceID) + if err == sql.ErrNoRows { + err = nil + } + + if err != nil { + s.Runtime.Log.Error(fmt.Sprintf("check space permissions %s", spaceID), err) + return false + } + + for _, role := range roles { + if role.LabelID == spaceID && (role.CanView || role.CanEdit) { + return true + } + } + + return false +} diff --git a/domain/space/space.go b/domain/space/space.go new file mode 100644 index 00000000..830435f3 --- /dev/null +++ b/domain/space/space.go @@ -0,0 +1,103 @@ +// Copyright 2016 Documize Inc. . All rights reserved. +// +// This software (Documize Community Edition) is licensed under +// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html +// +// You can operate outside the AGPL restrictions by purchasing +// Documize Enterprise Edition and obtaining a commercial license +// by contacting . +// +// https://documize.com + +package space + +import ( + "fmt" + + "github.com/documize/community/core/api/mail" + "github.com/documize/community/core/secrets" + "github.com/documize/community/core/uniqueid" + "github.com/documize/community/domain" + "github.com/documize/community/domain/account" + "github.com/documize/community/domain/user" +) + +// addSpace prepares and creates space record. +func addSpace(s domain.StoreContext, sp Space) (err error) { + sp.Type = ScopePrivate + sp.UserID = s.Context.UserID + + err = Add(s, sp) + if err != nil { + return + } + + role := Role{} + role.LabelID = sp.RefID + role.OrgID = sp.OrgID + role.UserID = s.Context.UserID + role.CanEdit = true + role.CanView = true + role.RefID = uniqueid.Generate() + + err = AddRole(s, role) + + return +} + +// Invite new user to a folder that someone has shared with them. +// We create the user account with default values and then take them +// through a welcome process designed to capture profile data. +// We add them to the organization and grant them view-only folder access. +func inviteNewUserToSharedSpace(s domain.StoreContext, email string, invitedBy user.User, + baseURL string, sp Space, invitationMessage string) (err error) { + + var u = user.User{} + u.Email = email + u.Firstname = email + u.Lastname = "" + u.Salt = secrets.GenerateSalt() + requestedPassword := secrets.GenerateRandomPassword() + u.Password = secrets.GeneratePassword(requestedPassword, u.Salt) + userID := uniqueid.Generate() + u.RefID = userID + + err = user.Add(s, u) + if err != nil { + return + } + + // Let's give this user access to the organization + var a account.Account + a.UserID = userID + a.OrgID = s.Context.OrgID + a.Admin = false + a.Editor = false + a.Active = true + accountID := uniqueid.Generate() + a.RefID = accountID + + err = account.Add(s, a) + if err != nil { + return + } + + role := Role{} + role.LabelID = sp.RefID + role.OrgID = s.Context.OrgID + role.UserID = userID + role.CanEdit = false + role.CanView = true + roleID := uniqueid.Generate() + role.RefID = roleID + + err = AddRole(s, role) + if err != nil { + return + } + + url := fmt.Sprintf("%s/%s", baseURL, u.Salt) + go mail.ShareFolderNewUser(u.Email, invitedBy.Fullname(), url, sp.Name, invitationMessage) + + return +} diff --git a/domain/space/store.go b/domain/space/store.go new file mode 100644 index 00000000..4bf52983 --- /dev/null +++ b/domain/space/store.go @@ -0,0 +1,286 @@ +// Copyright 2016 Documize Inc. . All rights reserved. +// +// This software (Documize Community Edition) is licensed under +// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html +// +// You can operate outside the AGPL restrictions by purchasing +// Documize Enterprise Edition and obtaining a commercial license +// by contacting . +// +// https://documize.com + +// Package space handles API calls and persistence for spaces. +// Spaces in Documize contain documents. +package space + +import ( + "database/sql" + "fmt" + "time" + + "github.com/documize/community/core/streamutil" + "github.com/documize/community/domain" + "github.com/documize/community/domain/store/mysql" + "github.com/pkg/errors" +) + +// Add adds new folder into the store. +func Add(s domain.StoreContext, sp Space) (err error) { + sp.UserID = s.Context.UserID + sp.Created = time.Now().UTC() + sp.Revised = time.Now().UTC() + + stmt, err := s.Context.Transaction.Preparex("INSERT INTO label (refid, label, orgid, userid, type, created, revised) VALUES (?, ?, ?, ?, ?, ?, ?)") + defer streamutil.Close(stmt) + + if err != nil { + err = errors.Wrap(err, "unable to prepare insert for label") + return + } + + _, err = stmt.Exec(sp.RefID, sp.Name, sp.OrgID, sp.UserID, sp.Type, sp.Created, sp.Revised) + if err != nil { + err = errors.Wrap(err, "unable to execute insert for label") + return + } + + return +} + +// Get returns a space from the store. +func Get(s domain.StoreContext, id string) (sp Space, err error) { + stmt, err := s.Runtime.Db.Preparex("SELECT id,refid,label as name,orgid,userid,type,created,revised FROM label WHERE orgid=? and refid=?") + defer streamutil.Close(stmt) + + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("unable to prepare select for label %s", id)) + return + } + + err = stmt.Get(&sp, s.Context.OrgID, id) + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("unable to execute select for label %s", id)) + return + } + + return +} + +// PublicSpaces returns folders that anyone can see. +func PublicSpaces(s domain.StoreContext, orgID string) (sp []Space, err error) { + sql := "SELECT id,refid,label as name,orgid,userid,type,created,revised FROM label a where orgid=? AND type=1" + + err = s.Runtime.Db.Select(&sp, sql, orgID) + + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("Unable to execute GetPublicFolders for org %s", orgID)) + return + } + + return +} + +// GetAll returns folders that the user can see. +// Also handles which folders can be seen by anonymous users. +func GetAll(s domain.StoreContext) (sp []Space, err error) { + sql := ` +(SELECT id,refid,label as name,orgid,userid,type,created,revised from label WHERE orgid=? AND type=2 AND userid=?) +UNION ALL +(SELECT id,refid,label as name,orgid,userid,type,created,revised FROM label a where orgid=? AND type=1 AND refid in + (SELECT labelid from labelrole WHERE orgid=? AND userid='' AND (canedit=1 OR canview=1))) +UNION ALL +(SELECT id,refid,label as name,orgid,userid,type,created,revised FROM label a where orgid=? AND type=3 AND refid in + (SELECT labelid from labelrole WHERE orgid=? AND userid=? AND (canedit=1 OR canview=1))) +ORDER BY name` + + err = s.Runtime.Db.Select(&sp, sql, + s.Context.OrgID, + s.Context.UserID, + s.Context.OrgID, + s.Context.OrgID, + s.Context.OrgID, + s.Context.OrgID, + s.Context.UserID) + + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("Unable to execute select labels for org %s", s.Context.OrgID)) + return + } + + return +} + +// Update saves space changes. +func Update(s domain.StoreContext, sp Space) (err error) { + sp.Revised = time.Now().UTC() + + stmt, err := s.Context.Transaction.PrepareNamed("UPDATE label SET label=:name, type=:type, userid=:userid, revised=:revised WHERE orgid=:orgid AND refid=:refid") + defer streamutil.Close(stmt) + + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("unable to prepare update for label %s", sp.RefID)) + return + } + + _, err = stmt.Exec(&sp) + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("unable to execute update for label %s", sp.RefID)) + return + } + + return +} + +// ChangeOwner transfer space ownership. +func ChangeOwner(s domain.StoreContext, currentOwner, newOwner string) (err error) { + stmt, err := s.Context.Transaction.Preparex("UPDATE label SET userid=? WHERE userid=? AND orgid=?") + defer streamutil.Close(stmt) + + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("unable to prepare change space owner for %s", currentOwner)) + return + } + + _, err = stmt.Exec(newOwner, currentOwner, s.Context.OrgID) + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("unable to execute change space owner for %s", currentOwner)) + return + } + + return +} + +// Viewers returns the list of people who can see shared folders. +func Viewers(s domain.StoreContext) (v []Viewer, err error) { + sql := ` +SELECT a.userid, + COALESCE(u.firstname, '') as firstname, + COALESCE(u.lastname, '') as lastname, + COALESCE(u.email, '') as email, + a.labelid, + b.label as name, + b.type +FROM labelrole a +LEFT JOIN label b ON b.refid=a.labelid +LEFT JOIN user u ON u.refid=a.userid +WHERE a.orgid=? AND b.type != 2 +GROUP BY a.labelid,a.userid +ORDER BY u.firstname,u.lastname` + + err = s.Runtime.Db.Select(&v, sql, s.Context.OrgID) + + return +} + +// Delete removes space from the store. +func Delete(s domain.StoreContext, id string) (rows int64, err error) { + b := mysql.BaseQuery{} + return b.DeleteConstrained(s.Context.Transaction, "label", s.Context.OrgID, id) +} + +// AddRole inserts the given record into the labelrole database table. +func AddRole(s domain.StoreContext, r Role) (err error) { + r.Created = time.Now().UTC() + r.Revised = time.Now().UTC() + + stmt, err := s.Context.Transaction.Preparex("INSERT INTO labelrole (refid, labelid, orgid, userid, canview, canedit, created, revised) VALUES (?, ?, ?, ?, ?, ?, ?, ?)") + defer streamutil.Close(stmt) + + if err != nil { + err = errors.Wrap(err, "unable to prepare insert for space role") + return + } + + _, err = stmt.Exec(r.RefID, r.LabelID, r.OrgID, r.UserID, r.CanView, r.CanEdit, r.Created, r.Revised) + if err != nil { + err = errors.Wrap(err, "unable to execute insert for space role") + return + } + + return +} + +// GetRoles returns a slice of labelrole records, for the given labelID in the client's organization, grouped by user. +func GetRoles(s domain.StoreContext, labelID string) (r []Role, err error) { + query := `SELECT id, refid, labelid, orgid, userid, canview, canedit, created, revised FROM labelrole WHERE orgid=? AND labelid=?` // was + "GROUP BY userid" + + err = s.Runtime.Db.Select(&r, query, s.Context.OrgID, labelID) + + if err == sql.ErrNoRows { + err = nil + } + + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("unable to execute select for space roles %s", labelID)) + return + } + + return +} + +// GetUserRoles returns a slice of role records, for both the client's user and organization, and +// those space roles that exist for all users in the client's organization. +func GetUserRoles(s domain.StoreContext) (r []Role, err error) { + err = s.Runtime.Db.Select(&r, ` + SELECT id, refid, labelid, orgid, userid, canview, canedit, created, revised FROM labelrole WHERE orgid=? and userid=? + UNION ALL + SELECT id, refid, labelid, orgid, userid, canview, canedit, created, revised FROM labelrole WHERE orgid=? AND userid=''`, + s.Context.OrgID, s.Context.UserID, s.Context.OrgID) + + if err == sql.ErrNoRows { + err = nil + } + + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("unable to execute select for user space roles %s", s.Context.UserID)) + return + } + + return +} + +// DeleteRole deletes the labelRoleID record from the labelrole table. +func DeleteRole(s domain.StoreContext, roleID string) (rows int64, err error) { + b := mysql.BaseQuery{} + + sql := fmt.Sprintf("DELETE FROM labelrole WHERE orgid='%s' AND refid='%s'", s.Context.OrgID, roleID) + + return b.DeleteWhere(s.Context.Transaction, sql) +} + +// DeleteSpaceRoles deletes records from the labelrole table which have the given space ID. +func DeleteSpaceRoles(s domain.StoreContext, spaceID string) (rows int64, err error) { + b := mysql.BaseQuery{} + + sql := fmt.Sprintf("DELETE FROM labelrole WHERE orgid='%s' AND labelid='%s'", s.Context.OrgID, spaceID) + + return b.DeleteWhere(s.Context.Transaction, sql) +} + +// DeleteUserSpaceRoles removes all roles for the specified user, for the specified space. +func DeleteUserSpaceRoles(s domain.StoreContext, spaceID, userID string) (rows int64, err error) { + b := mysql.BaseQuery{} + + sql := fmt.Sprintf("DELETE FROM labelrole WHERE orgid='%s' AND labelid='%s' AND userid='%s'", + s.Context.OrgID, spaceID, userID) + + return b.DeleteWhere(s.Context.Transaction, sql) +} + +// MoveSpaceRoles changes the space ID for space role records from previousLabel to newLabel. +func MoveSpaceRoles(s domain.StoreContext, previousLabel, newLabel string) (err error) { + stmt, err := s.Context.Transaction.Preparex("UPDATE labelrole SET labelid=? WHERE labelid=? AND orgid=?") + defer streamutil.Close(stmt) + + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("unable to prepare move space roles for label %s", previousLabel)) + return + } + + _, err = stmt.Exec(newLabel, previousLabel, s.Context.OrgID) + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("unable to execute move space roles for label %s", previousLabel)) + } + + return +} diff --git a/domain/space/store_mysql.go b/domain/space/store_mysql.go deleted file mode 100644 index 9c75d367..00000000 --- a/domain/space/store_mysql.go +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright 2016 Documize Inc. . All rights reserved. -// -// This software (Documize Community Edition) is licensed under -// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html -// -// You can operate outside the AGPL restrictions by purchasing -// Documize Enterprise Edition and obtaining a commercial license -// by contacting . -// -// https://documize.com - -// Package space handles API calls and persistence for spaces. -// Spaces in Documize contain documents. -package space diff --git a/domain/space/storer.go b/domain/space/storer.go deleted file mode 100644 index 9c75d367..00000000 --- a/domain/space/storer.go +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright 2016 Documize Inc. . All rights reserved. -// -// This software (Documize Community Edition) is licensed under -// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html -// -// You can operate outside the AGPL restrictions by purchasing -// Documize Enterprise Edition and obtaining a commercial license -// by contacting . -// -// https://documize.com - -// Package space handles API calls and persistence for spaces. -// Spaces in Documize contain documents. -package space diff --git a/domain/store/mysql/mysql.go b/domain/store/mysql/mysql.go new file mode 100644 index 00000000..07dc29e4 --- /dev/null +++ b/domain/store/mysql/mysql.go @@ -0,0 +1,100 @@ +// Copyright 2016 Documize Inc. . All rights reserved. +// +// This software (Documize Community Edition) is licensed under +// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html +// +// You can operate outside the AGPL restrictions by purchasing +// Documize Enterprise Edition and obtaining a commercial license +// by contacting . +// +// https://documize.com + +package mysql + +import ( + "fmt" + + "github.com/documize/community/core/streamutil" + "github.com/jmoiron/sqlx" + "github.com/pkg/errors" +) + +// BaseQuery provides common MySQL methods. +type BaseQuery struct { +} + +// Delete record. +func (m *BaseQuery) Delete(tx *sqlx.Tx, table string, id string) (rows int64, err error) { + stmt, err := tx.Preparex("DELETE FROM " + table + " WHERE refid=?") + defer streamutil.Close(stmt) + + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("unable to prepare delete of row in table %s", table)) + return + } + + result, err := stmt.Exec(id) + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("unable to delete row in table %s", table)) + return + } + + rows, err = result.RowsAffected() + + return +} + +// DeleteConstrained record constrained to Organization using refid. +func (m *BaseQuery) DeleteConstrained(tx *sqlx.Tx, table string, orgID, id string) (rows int64, err error) { + stmt, err := tx.Preparex("DELETE FROM " + table + " WHERE orgid=? AND refid=?") + defer streamutil.Close(stmt) + + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("unable to prepare constrained delete of row in table %s", table)) + return + } + + result, err := stmt.Exec(orgID, id) + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("unable to delete row in table %s", table)) + return + } + + rows, err = result.RowsAffected() + + return +} + +// DeleteConstrainedWithID record constrained to Organization using non refid. +func (m *BaseQuery) DeleteConstrainedWithID(tx *sqlx.Tx, table string, orgID, id string) (rows int64, err error) { + stmt, err := tx.Preparex("DELETE FROM " + table + " WHERE orgid=? AND id=?") + defer streamutil.Close(stmt) + + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("unable to prepare ConstrainedWithID delete of row in table %s", table)) + return + } + + result, err := stmt.Exec(orgID, id) + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("unable to delete row in table %s", table)) + return + } + + rows, err = result.RowsAffected() + + return +} + +// DeleteWhere free form query. +func (m *BaseQuery) DeleteWhere(tx *sqlx.Tx, statement string) (rows int64, err error) { + result, err := tx.Exec(statement) + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("unable to delete rows: %s", statement)) + return + } + + rows, err = result.RowsAffected() + + return +} diff --git a/domain/user/model.go b/domain/user/model.go new file mode 100644 index 00000000..d7f04509 --- /dev/null +++ b/domain/user/model.go @@ -0,0 +1,65 @@ +// Copyright 2016 Documize Inc. . All rights reserved. +// +// This software (Documize Community Edition) is licensed under +// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html +// +// You can operate outside the AGPL restrictions by purchasing +// Documize Enterprise Edition and obtaining a commercial license +// by contacting . +// +// https://documize.com + +package user + +import ( + "fmt" + + "github.com/documize/community/core/env" + "github.com/documize/community/domain" + "github.com/documize/community/domain/account" +) + +// Handler contains the runtime information such as logging and database. +type Handler struct { + Runtime env.Runtime +} + +// User defines a login. +type User struct { + domain.BaseEntity + Firstname string `json:"firstname"` + Lastname string `json:"lastname"` + Email string `json:"email"` + Initials string `json:"initials"` + Active bool `json:"active"` + Editor bool `json:"editor"` + Admin bool `json:"admin"` + Global bool `json:"global"` + Password string `json:"-"` + Salt string `json:"-"` + Reset string `json:"-"` + Accounts []account.Account `json:"accounts"` +} + +// ProtectSecrets blanks sensitive data. +func (user *User) ProtectSecrets() { + user.Password = "" + user.Salt = "" + user.Reset = "" +} + +// Fullname returns Firstname + Lastname. +func (user *User) Fullname() string { + return fmt.Sprintf("%s %s", user.Firstname, user.Lastname) +} + +// GetAccount returns matching org account using orgID +func (user *User) GetAccount(orgID string) (a account.Account, found bool) { + for _, a := range user.Accounts { + if a.OrgID == orgID { + return a, true + } + } + + return a, false +} diff --git a/domain/user/store.go b/domain/user/store.go new file mode 100644 index 00000000..d6a59b1b --- /dev/null +++ b/domain/user/store.go @@ -0,0 +1,294 @@ +// Copyright 2016 Documize Inc. . All rights reserved. +// +// This software (Documize Community Edition) is licensed under +// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html +// +// You can operate outside the AGPL restrictions by purchasing +// Documize Enterprise Edition and obtaining a commercial license +// by contacting . +// +// https://documize.com + +package user + +import ( + "database/sql" + "fmt" + "strings" + "time" + + "github.com/documize/community/core/streamutil" + "github.com/documize/community/domain" + "github.com/pkg/errors" +) + +// Add adds the given user record to the user table. +func Add(s domain.StoreContext, u User) (err error) { + u.Created = time.Now().UTC() + u.Revised = time.Now().UTC() + + stmt, err := s.Context.Transaction.Preparex("INSERT INTO user (refid, firstname, lastname, email, initials, password, salt, reset, created, revised) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") + defer streamutil.Close(stmt) + + if err != nil { + err = errors.Wrap(err, "prepare user insert") + return + } + + _, err = stmt.Exec(u.RefID, u.Firstname, u.Lastname, strings.ToLower(u.Email), u.Initials, u.Password, u.Salt, "", u.Created, u.Revised) + if err != nil { + err = errors.Wrap(err, "execute user insert") + return + } + + return +} + +// Get returns the user record for the given id. +func Get(s domain.StoreContext, id string) (u User, err error) { + stmt, err := s.Runtime.Db.Preparex("SELECT id, refid, firstname, lastname, email, initials, global, password, salt, reset, created, revised FROM user WHERE refid=?") + defer streamutil.Close(stmt) + + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("unable to prepare select for user %s", id)) + return + } + + err = stmt.Get(&u, id) + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("unable to execute select for user %s", id)) + return + } + + return +} + +// GetByDomain matches user by email and domain. +func GetByDomain(s domain.StoreContext, domain, email string) (u User, err error) { + email = strings.TrimSpace(strings.ToLower(email)) + + stmt, err := s.Runtime.Db.Preparex("SELECT u.id, u.refid, u.firstname, u.lastname, u.email, u.initials, u.global, u.password, u.salt, u.reset, u.created, u.revised FROM user u, account a, organization o WHERE TRIM(LOWER(u.email))=? AND u.refid=a.userid AND a.orgid=o.refid AND TRIM(LOWER(o.domain))=?") + defer streamutil.Close(stmt) + + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("Unable to prepare GetUserByDomain %s %s", domain, email)) + return + } + + err = stmt.Get(&u, email, domain) + if err != nil && err != sql.ErrNoRows { + err = errors.Wrap(err, fmt.Sprintf("Unable to execute GetUserByDomain %s %s", domain, email)) + return + } + + return +} + +// GetByEmail returns a single row match on email. +func GetByEmail(s domain.StoreContext, email string) (u User, err error) { + email = strings.TrimSpace(strings.ToLower(email)) + + stmt, err := s.Runtime.Db.Preparex("SELECT id, refid, firstname, lastname, email, initials, global, password, salt, reset, created, revised FROM user WHERE TRIM(LOWER(email))=?") + defer streamutil.Close(stmt) + + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("prepare select user by email %s", email)) + return + } + + err = stmt.Get(&u, email) + if err != nil && err != sql.ErrNoRows { + err = errors.Wrap(err, fmt.Sprintf("execute select user by email %s", email)) + return + } + + return +} + +// GetByToken returns a user record given a reset token value. +func GetByToken(s domain.StoreContext, token string) (u User, err error) { + stmt, err := s.Runtime.Db.Preparex("SELECT id, refid, firstname, lastname, email, initials, global, password, salt, reset, created, revised FROM user WHERE reset=?") + defer streamutil.Close(stmt) + + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("prepare user select by token %s", token)) + return + } + + err = stmt.Get(&u, token) + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("execute user select by token %s", token)) + return + } + + return +} + +// GetBySerial is used to retrieve a user via their temporary password salt value! +// This occurs when we you share a folder with a new user and they have to complete +// the onboarding process. +func GetBySerial(s domain.StoreContext, serial string) (u User, err error) { + stmt, err := s.Runtime.Db.Preparex("SELECT id, refid, firstname, lastname, email, initials, global, password, salt, reset, created, revised FROM user WHERE salt=?") + defer streamutil.Close(stmt) + + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("prepare user select by serial %s", serial)) + return + } + + err = stmt.Get(&u, serial) + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("execute user select by serial %s", serial)) + return + } + + return +} + +// GetActiveUsersForOrganization returns a slice containing of active user records for the organization +// identified in the Persister. +func GetActiveUsersForOrganization(s domain.StoreContext) (u []User, err error) { + err = s.Runtime.Db.Select(&u, + `SELECT u.id, u.refid, u.firstname, u.lastname, u.email, u.initials, u.password, u.salt, u.reset, u.created, u.revised + FROM user u + WHERE u.refid IN (SELECT userid FROM account WHERE orgid = ? AND active=1) ORDER BY u.firstname,u.lastname`, + s.Context.OrgID) + + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("get active users by org %s", s.Context.OrgID)) + return + } + + return +} + +// GetUsersForOrganization returns a slice containing all of the user records for the organizaiton +// identified in the Persister. +func GetUsersForOrganization(s domain.StoreContext) (u []User, err error) { + err = s.Runtime.Db.Select(&u, + "SELECT id, refid, firstname, lastname, email, initials, password, salt, reset, created, revised FROM user WHERE refid IN (SELECT userid FROM account where orgid = ?) ORDER BY firstname,lastname", s.Context.OrgID) + + if err != nil { + err = errors.Wrap(err, fmt.Sprintf(" get users for org %s", s.Context.OrgID)) + return + } + + return +} + +// GetSpaceUsers returns a slice containing all user records for given folder. +func GetSpaceUsers(s domain.StoreContext, folderID string) (u []User, err error) { + err = s.Runtime.Db.Select(&u, + `SELECT u.id, u.refid, u.firstname, u.lastname, u.email, u.initials, u.password, u.salt, u.reset, u.created, u.revised + FROM user u, account a + WHERE u.refid IN (SELECT userid from labelrole WHERE orgid=? AND labelid=?) + AND a.orgid=? AND u.refid = a.userid AND a.active=1 + ORDER BY u.firstname, u.lastname`, + s.Context.OrgID, folderID, s.Context.OrgID) + + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("get space users for org %s", s.Context.OrgID)) + return + } + + return +} + +// UpdateUser updates the user table using the given replacement user record. +func UpdateUser(s domain.StoreContext, u User) (err error) { + u.Revised = time.Now().UTC() + u.Email = strings.ToLower(u.Email) + + stmt, err := s.Context.Transaction.PrepareNamed( + "UPDATE user SET firstname=:firstname, lastname=:lastname, email=:email, revised=:revised, initials=:initials WHERE refid=:refid") + defer streamutil.Close(stmt) + + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("prepare user update %s", u.RefID)) + return + } + + _, err = stmt.Exec(&u) + if err != nil { + err = errors.Wrap(err, fmt.Sprintf("execute user update %s", u.RefID)) + return + } + + return +} + +// UpdateUserPassword updates a user record with new password and salt values. +func UpdateUserPassword(s domain.StoreContext, userID, salt, password string) (err error) { + stmt, err := s.Context.Transaction.Preparex("UPDATE user SET salt=?, password=?, reset='' WHERE refid=?") + defer streamutil.Close(stmt) + + if err != nil { + err = errors.Wrap(err, "prepare user update") + return + } + + _, err = stmt.Exec(salt, password, userID) + if err != nil { + err = errors.Wrap(err, "execute user update") + return + } + + return +} + +// DeactiveUser deletes the account record for the given userID and persister.Context.OrgID. +func DeactiveUser(s domain.StoreContext, userID string) (err error) { + stmt, err := s.Context.Transaction.Preparex("DELETE FROM account WHERE userid=? and orgid=?") + defer streamutil.Close(stmt) + + if err != nil { + err = errors.Wrap(err, "prepare user deactivation") + return + } + + _, err = stmt.Exec(userID, s.Context.OrgID) + + if err != nil { + err = errors.Wrap(err, "execute user deactivation") + return + } + + return +} + +// ForgotUserPassword sets the password to '' and the reset field to token, for a user identified by email. +func ForgotUserPassword(s domain.StoreContext, email, token string) (err error) { + stmt, err := s.Context.Transaction.Preparex("UPDATE user SET reset=?, password='' WHERE LOWER(email)=?") + defer streamutil.Close(stmt) + + if err != nil { + err = errors.Wrap(err, "prepare password reset") + return + } + + _, err = stmt.Exec(token, strings.ToLower(email)) + if err != nil { + err = errors.Wrap(err, "execute password reset") + return + } + + return +} + +// CountActiveUsers returns the number of active users in the system. +func CountActiveUsers(s domain.StoreContext) (c int) { + row := s.Runtime.Db.QueryRow("SELECT count(*) FROM user u WHERE u.refid IN (SELECT userid FROM account WHERE active=1)") + + err := row.Scan(&c) + + if err == sql.ErrNoRows { + return 0 + } + + if err != nil && err != sql.ErrNoRows { + s.Runtime.Log.Error("CountActiveUsers", err) + return 0 + } + + return +} diff --git a/domain/user/user.go b/domain/user/user.go new file mode 100644 index 00000000..7e1780fe --- /dev/null +++ b/domain/user/user.go @@ -0,0 +1,51 @@ +// Copyright 2016 Documize Inc. . All rights reserved. +// +// This software (Documize Community Edition) is licensed under +// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html +// +// You can operate outside the AGPL restrictions by purchasing +// Documize Enterprise Edition and obtaining a commercial license +// by contacting . +// +// https://documize.com + +package user + +import ( + "github.com/documize/community/domain" + "github.com/documize/community/domain/account" + "github.com/pkg/errors" +) + +// GetSecuredUser contain associated accounts but credentials are wiped. +func GetSecuredUser(s domain.StoreContext, orgID, q string) (u User, err error) { + u, err = Get(s, q) + AttachUserAccounts(s, orgID, &u) + + return +} + +// AttachUserAccounts attachs user accounts to user object. +func AttachUserAccounts(s domain.StoreContext, orgID string, u *User) { + u.ProtectSecrets() + + a, err := account.GetUserAccounts(s, u.RefID) + if err != nil { + err = errors.Wrap(err, "fetch user accounts") + return + } + + u.Accounts = a + u.Editor = false + u.Admin = false + u.Active = false + + for _, account := range u.Accounts { + if account.OrgID == orgID { + u.Admin = account.Admin + u.Editor = account.Editor + u.Active = account.Active + break + } + } +} diff --git a/edition/boot/runtime.go b/edition/boot/runtime.go index 21db8cd7..bfdffb37 100644 --- a/edition/boot/runtime.go +++ b/edition/boot/runtime.go @@ -19,7 +19,6 @@ import ( "github.com/documize/community/core/database" "github.com/documize/community/core/env" "github.com/documize/community/core/secrets" - "github.com/documize/community/server/web" "github.com/jmoiron/sqlx" ) @@ -66,7 +65,7 @@ func InitRuntime(r *env.Runtime) bool { } // go into setup mode if required - if r.Flags.SiteMode != web.SiteModeOffline { + if r.Flags.SiteMode != env.SiteModeOffline { if database.Check(r) { if err := database.Migrate(*r, true /* the config table exists */); err != nil { r.Log.Error("unable to run database migration", err) diff --git a/edition/community.go b/edition/community.go index 5fd70428..956fdb8f 100644 --- a/edition/community.go +++ b/edition/community.go @@ -38,8 +38,8 @@ func main() { // product details rt.Product = env.ProdInfo{} rt.Product.Major = "1" - rt.Product.Minor = "50" - rt.Product.Patch = "2" + rt.Product.Minor = "51" + rt.Product.Patch = "0" rt.Product.Version = fmt.Sprintf("%s.%s.%s", rt.Product.Major, rt.Product.Minor, rt.Product.Patch) rt.Product.Edition = "Community" rt.Product.Title = fmt.Sprintf("%s Edition", rt.Product.Edition) diff --git a/gui/package.json b/gui/package.json index 9e4cfe87..579b4908 100644 --- a/gui/package.json +++ b/gui/package.json @@ -1,6 +1,6 @@ { "name": "documize", - "version": "1.50.2", + "version": "1.51.0", "description": "The Document IDE", "private": true, "repository": "", diff --git a/meta.json b/meta.json index a59f2ec9..f7506f02 100644 --- a/meta.json +++ b/meta.json @@ -1,16 +1,16 @@ { "community": { - "version": "1.50.2", + "version": "1.51.0", "major": 1, "minor": 50, - "patch": 2 + "patch": 0 }, "enterprise": { - "version": "1.52.2", + "version": "1.53.0", "major": 1, - "minor": 52, - "patch": 2 + "minor": 53, + "patch": 0 } } \ No newline at end of file diff --git a/server/middleware.go b/server/middleware.go new file mode 100644 index 00000000..c69e994d --- /dev/null +++ b/server/middleware.go @@ -0,0 +1,210 @@ +// Copyright 2016 Documize Inc. . All rights reserved. +// +// This software (Documize Community Edition) is licensed under +// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html +// +// You can operate outside the AGPL restrictions by purchasing +// Documize Enterprise Edition and obtaining a commercial license +// by contacting . +// +// https://documize.com + +package server + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/documize/community/core/env" + "github.com/documize/community/core/response" + "github.com/documize/community/domain" + "github.com/documize/community/domain/auth" + "github.com/documize/community/domain/organization" + "github.com/documize/community/domain/user" +) + +type middleware struct { + Runtime env.Runtime +} + +func (m *middleware) cors(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "PUT, GET, POST, DELETE, OPTIONS, PATCH") + w.Header().Set("Access-Control-Allow-Headers", "host, content-type, accept, authorization, origin, referer, user-agent, cache-control, x-requested-with") + w.Header().Set("Access-Control-Expose-Headers", "x-documize-version, x-documize-status") + + if r.Method == "OPTIONS" { + w.Header().Add("X-Documize-Version", m.Runtime.Product.Version) + w.Header().Add("Cache-Control", "no-cache") + + w.Write([]byte("")) + + return + } + + next(w, r) +} + +func (m *middleware) metrics(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + w.Header().Add("X-Documize-Version", m.Runtime.Product.Version) + w.Header().Add("Cache-Control", "no-cache") + + // Prevent page from being displayed in an iframe + w.Header().Add("X-Frame-Options", "DENY") + + next(w, r) +} + +// Authorize secure API calls by inspecting authentication token. +// request.Context provides caller user information. +// Site meta sent back as HTTP custom headers. +func (m *middleware) Authorize(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + method := "Authorize" + + s := domain.StoreContext{Runtime: m.Runtime, Context: domain.RequestContext{}} + + // Let certain requests pass straight through + authenticated := preAuthorizeStaticAssets(m.Runtime, r) + + if !authenticated { + token := auth.FindJWT(r) + rc, _, tokenErr := auth.DecodeJWT(m.Runtime, token) + + var org = organization.Organization{} + var err = errors.New("") + + if len(rc.OrgID) == 0 { + org, err = organization.GetOrganizationByDomain(s, organization.GetRequestSubdomain(s, r)) + } else { + org, err = organization.GetOrganization(s, rc.OrgID) + } + + // Inability to find org record spells the end of this request. + if err != nil { + response.WriteForbiddenError(w) + return + } + + // If we have bad auth token and the domain does not allow anon access + if !org.AllowAnonymousAccess && tokenErr != nil { + response.WriteUnauthorizedError(w) + return + } + + rc.Subdomain = org.Domain + dom := organization.GetSubdomainFromHost(s, r) + dom2 := organization.GetRequestSubdomain(s, r) + + if org.Domain != dom && org.Domain != dom2 { + m.Runtime.Log.Info(fmt.Sprintf("domain mismatch %s vs. %s vs. %s", dom, dom2, org.Domain)) + + response.WriteUnauthorizedError(w) + return + } + + // If we have bad auth token and the domain allows anon access + // then we generate guest context. + if org.AllowAnonymousAccess { + // So you have a bad token + if len(token) > 1 { + if tokenErr != nil { + response.WriteUnauthorizedError(w) + return + } + } else { + // Just grant anon user guest access + rc.UserID = "0" + rc.OrgID = org.RefID + rc.Authenticated = false + rc.Guest = true + } + } + + rc.AllowAnonymousAccess = org.AllowAnonymousAccess + rc.OrgName = org.Title + rc.Administrator = false + rc.Editor = false + rc.Global = false + rc.AppURL = r.Host + rc.Subdomain = organization.GetSubdomainFromHost(s, r) + rc.SSL = r.TLS != nil + + // get user IP from request + i := strings.LastIndex(r.RemoteAddr, ":") + if i == -1 { + rc.ClientIP = r.RemoteAddr + } else { + rc.ClientIP = r.RemoteAddr[:i] + } + + fip := r.Header.Get("X-Forwarded-For") + if len(fip) > 0 { + rc.ClientIP = fip + } + + // Fetch user permissions for this org + if rc.Authenticated { + u, err := user.GetSecuredUser(s, org.RefID, rc.UserID) + + if err != nil { + response.WriteServerError(w, method, err) + return + } + + rc.Administrator = u.Admin + rc.Editor = u.Editor + rc.Global = u.Global + rc.Fullname = u.Fullname() + + // We send back with every HTTP request/response cycle the latest + // user state. This helps client-side applications to detect changes in + // user state/privileges. + var state struct { + Active bool `json:"active"` + Admin bool `json:"admin"` + Editor bool `json:"editor"` + } + + state.Active = u.Active + state.Admin = u.Admin + state.Editor = u.Editor + sb, err := json.Marshal(state) + + w.Header().Add("X-Documize-Status", string(sb)) + } + + // m.Runtime.Log.Info(fmt.Sprintf("%v", rc)) + ctx := context.WithValue(r.Context(), domain.DocumizeContextKey, rc) + r = r.WithContext(ctx) + + // Middleware moves on if we say 'yes' -- authenticated or allow anon access. + authenticated = rc.Authenticated || org.AllowAnonymousAccess + } + + if authenticated { + next(w, r) + } else { + w.WriteHeader(http.StatusUnauthorized) + } +} + +// Certain assets/URL do not require authentication. +// Just stops the log files being clogged up with failed auth errors. +func preAuthorizeStaticAssets(rt env.Runtime, r *http.Request) bool { + if strings.ToLower(r.URL.Path) == "/" || + strings.ToLower(r.URL.Path) == "/validate" || + strings.ToLower(r.URL.Path) == "/favicon.ico" || + strings.ToLower(r.URL.Path) == "/robots.txt" || + strings.ToLower(r.URL.Path) == "/version" || + strings.HasPrefix(strings.ToLower(r.URL.Path), "/api/public/") || + ((rt.Flags.SiteMode == env.SiteModeSetup) && (strings.ToLower(r.URL.Path) == "/api/setup")) { + + return true + } + + return false +} diff --git a/server/routing/table.go b/server/routing/table.go index 4e2378b8..585a957a 100644 --- a/server/routing/table.go +++ b/server/routing/table.go @@ -28,6 +28,8 @@ const ( RoutePrefixPrivate = "/api/" // RoutePrefixRoot used for unsecured endpoints at root (e.g. robots.txt) RoutePrefixRoot = "/" + // RoutePrefixTesting used for isolated testing of routes with custom middleware + RoutePrefixTesting = "/testing/" ) type routeDef struct { diff --git a/server/server.go b/server/server.go index 907fbe2b..08241e15 100644 --- a/server/server.go +++ b/server/server.go @@ -18,7 +18,6 @@ import ( "strings" "github.com/codegangsta/negroni" - "github.com/documize/community/core/api" "github.com/documize/community/core/api/endpoint" "github.com/documize/community/core/api/plugins" "github.com/documize/community/core/database" @@ -39,21 +38,24 @@ func Start(rt env.Runtime, ready chan struct{}) { os.Exit(1) } - rt.Log.Info(fmt.Sprintf("Starting %s version %s", api.Runtime.Product.Title, api.Runtime.Product.Version)) + rt.Log.Info(fmt.Sprintf("Starting %s version %s", rt.Product.Title, rt.Product.Version)) // decide which mode to serve up - switch api.Runtime.Flags.SiteMode { - case web.SiteModeOffline: + switch rt.Flags.SiteMode { + case env.SiteModeOffline: rt.Log.Info("Serving OFFLINE web server") - case web.SiteModeSetup: + case env.SiteModeSetup: routing.Add(rt, routing.RoutePrefixPrivate, "setup", []string{"POST", "OPTIONS"}, nil, database.Create) rt.Log.Info("Serving SETUP web server") - case web.SiteModeBadDB: + case env.SiteModeBadDB: rt.Log.Info("Serving BAD DATABASE web server") default: rt.Log.Info("Starting web server") } + // define middleware + cm := middleware{Runtime: rt} + // define API endpoints routing.RegisterEndpoints(rt) @@ -62,7 +64,7 @@ func Start(rt env.Runtime, ready chan struct{}) { // "/api/public/..." router.PathPrefix(routing.RoutePrefixPublic).Handler(negroni.New( - negroni.HandlerFunc(cors), + negroni.HandlerFunc(cm.cors), negroni.Wrap(routing.BuildRoutes(rt, routing.RoutePrefixPublic)), )) @@ -74,74 +76,46 @@ func Start(rt env.Runtime, ready chan struct{}) { // "/..." router.PathPrefix(routing.RoutePrefixRoot).Handler(negroni.New( - negroni.HandlerFunc(cors), + negroni.HandlerFunc(cm.cors), negroni.Wrap(routing.BuildRoutes(rt, routing.RoutePrefixRoot)), )) n := negroni.New() n.Use(negroni.NewStatic(web.StaticAssetsFileSystem())) - n.Use(negroni.HandlerFunc(cors)) - n.Use(negroni.HandlerFunc(metrics)) + n.Use(negroni.HandlerFunc(cm.cors)) + n.Use(negroni.HandlerFunc(cm.metrics)) n.UseHandler(router) // start server - if !api.Runtime.Flags.SSLEnabled() { - rt.Log.Info("Starting non-SSL server on " + api.Runtime.Flags.HTTPPort) - n.Run(testHost + ":" + api.Runtime.Flags.HTTPPort) + if !rt.Flags.SSLEnabled() { + rt.Log.Info("Starting non-SSL server on " + rt.Flags.HTTPPort) + n.Run(testHost + ":" + rt.Flags.HTTPPort) } else { - if api.Runtime.Flags.ForceHTTPPort2SSL != "" { - rt.Log.Info("Starting non-SSL server on " + api.Runtime.Flags.ForceHTTPPort2SSL + " and redirecting to SSL server on " + api.Runtime.Flags.HTTPPort) + if rt.Flags.ForceHTTPPort2SSL != "" { + rt.Log.Info("Starting non-SSL server on " + rt.Flags.ForceHTTPPort2SSL + " and redirecting to SSL server on " + rt.Flags.HTTPPort) go func() { - err := http.ListenAndServe(":"+api.Runtime.Flags.ForceHTTPPort2SSL, http.HandlerFunc( + err := http.ListenAndServe(":"+rt.Flags.ForceHTTPPort2SSL, http.HandlerFunc( func(w http.ResponseWriter, req *http.Request) { w.Header().Set("Connection", "close") - var host = strings.Replace(req.Host, api.Runtime.Flags.ForceHTTPPort2SSL, api.Runtime.Flags.HTTPPort, 1) + req.RequestURI + var host = strings.Replace(req.Host, rt.Flags.ForceHTTPPort2SSL, rt.Flags.HTTPPort, 1) + req.RequestURI http.Redirect(w, req, "https://"+host, http.StatusMovedPermanently) })) if err != nil { - rt.Log.Error("ListenAndServe on "+api.Runtime.Flags.ForceHTTPPort2SSL, err) + rt.Log.Error("ListenAndServe on "+rt.Flags.ForceHTTPPort2SSL, err) } }() } - rt.Log.Info("Starting SSL server on " + api.Runtime.Flags.HTTPPort + " with " + api.Runtime.Flags.SSLCertFile + " " + api.Runtime.Flags.SSLKeyFile) + rt.Log.Info("Starting SSL server on " + rt.Flags.HTTPPort + " with " + rt.Flags.SSLCertFile + " " + rt.Flags.SSLKeyFile) // TODO: https://blog.gopheracademy.com/advent-2016/exposing-go-on-the-internet/ - server := &http.Server{Addr: ":" + api.Runtime.Flags.HTTPPort, Handler: n /*, TLSConfig: myTLSConfig*/} + server := &http.Server{Addr: ":" + rt.Flags.HTTPPort, Handler: n /*, TLSConfig: myTLSConfig*/} server.SetKeepAlivesEnabled(true) - if err := server.ListenAndServeTLS(api.Runtime.Flags.SSLCertFile, api.Runtime.Flags.SSLKeyFile); err != nil { - rt.Log.Error("ListenAndServeTLS on "+api.Runtime.Flags.HTTPPort, err) + if err := server.ListenAndServeTLS(rt.Flags.SSLCertFile, rt.Flags.SSLKeyFile); err != nil { + rt.Log.Error("ListenAndServeTLS on "+rt.Flags.HTTPPort, err) } } } - -func cors(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "PUT, GET, POST, DELETE, OPTIONS, PATCH") - w.Header().Set("Access-Control-Allow-Headers", "host, content-type, accept, authorization, origin, referer, user-agent, cache-control, x-requested-with") - w.Header().Set("Access-Control-Expose-Headers", "x-documize-version, x-documize-status") - - if r.Method == "OPTIONS" { - w.Header().Add("X-Documize-Version", api.Runtime.Product.Version) - w.Header().Add("Cache-Control", "no-cache") - - w.Write([]byte("")) - - return - } - - next(w, r) -} - -func metrics(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { - w.Header().Add("X-Documize-Version", api.Runtime.Product.Version) - w.Header().Add("Cache-Control", "no-cache") - - // Prevent page from being displayed in an iframe - w.Header().Add("X-Frame-Options", "DENY") - - next(w, r) -} diff --git a/server/web/serve.go b/server/web/serve.go index c72ae4c3..65322ba2 100644 --- a/server/web/serve.go +++ b/server/web/serve.go @@ -17,20 +17,10 @@ import ( "net/http" "github.com/documize/community/core/api" + "github.com/documize/community/core/env" "github.com/documize/community/core/secrets" ) -const ( - // SiteModeNormal serves app - SiteModeNormal = "" - // SiteModeOffline serves offline.html - SiteModeOffline = "1" - // SiteModeSetup tells Ember to serve setup route - SiteModeSetup = "2" - // SiteModeBadDB redirects to db-error.html page - SiteModeBadDB = "3" -) - // SiteInfo describes set-up information about the site var SiteInfo struct { DBname, DBhash, Issue string @@ -44,11 +34,11 @@ func init() { func EmberHandler(w http.ResponseWriter, r *http.Request) { filename := "index.html" switch api.Runtime.Flags.SiteMode { - case SiteModeOffline: + case env.SiteModeOffline: filename = "offline.html" - case SiteModeSetup: + case env.SiteModeSetup: // NoOp - case SiteModeBadDB: + case env.SiteModeBadDB: filename = "db-error.html" default: SiteInfo.DBhash = ""