diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile index dd9d604..5f2d62e 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,9 @@ run: build ./mycorrhiza metarrhiza +run_with_fixed_auth: build + ./mycorrhiza -auth-method fixed metarrhiza + build: go generate go build . diff --git a/README.md b/README.md index 21b4503..82876c6 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# šŸ„ MycorrhizaWiki 0.10 +# šŸ„ MycorrhizaWiki 0.11 A wiki engine. ## Building @@ -15,13 +15,21 @@ make ``` mycorrhiza [OPTIONS...] WIKI_PATH +WIKI_PATH must be a path to git repository which you want to be a wiki. + Options: + -auth-method string + What auth method to use. Variants: "none", "fixed" (default "none") + -fixed-credentials-path string + Used when -auth-method=fixed. Path to file with user credentials. (default "mycocredentials.json") -home string The home page (default "home") -port string Port to serve the wiki at (default "1737") -title string How to call your wiki in the navititle (default "šŸ„") + -user-tree string + Hypha which is a superhypha of all user pages (default "u") ``` ## Features @@ -39,11 +47,11 @@ Options: * Hyphae can be deleted (while still preserving history) * Hyphae can be renamed (recursive renaming of subhyphae is also supported) * Light on resources: I run a home wiki on this engine 24/7 at an [Orange Ļ€ Lite](http://www.orangepi.org/orangepilite/). +* Authorization with pre-set credentials ## Contributing Help is always needed. We have a [tg chat](https://t.me/mycorrhizadev) where some development is coordinated. Feel free to open an issue or contact me. ## Future plans * Tagging system -* Authorization * Better history viewing diff --git a/flag.go b/flag.go index d5d6df4..8a9857a 100644 --- a/flag.go +++ b/flag.go @@ -5,6 +5,7 @@ import ( "log" "path/filepath" + "github.com/bouncepaw/mycorrhiza/user" "github.com/bouncepaw/mycorrhiza/util" ) @@ -12,6 +13,9 @@ func init() { flag.StringVar(&util.ServerPort, "port", "1737", "Port to serve the wiki at") flag.StringVar(&util.HomePage, "home", "home", "The home page") flag.StringVar(&util.SiteTitle, "title", "šŸ„", "How to call your wiki in the navititle") + flag.StringVar(&util.UserTree, "user-tree", "u", "Hypha which is a superhypha of all user pages") + flag.StringVar(&util.AuthMethod, "auth-method", "none", "What auth method to use. Variants: \"none\", \"fixed\"") + flag.StringVar(&util.FixedCredentialsPath, "fixed-credentials-path", "mycocredentials.json", "Used when -auth-method=fixed. Path to file with user credentials.") } // Do the things related to cli args and die maybe @@ -33,4 +37,17 @@ func parseCliArgs() { if !isCanonicalName(util.HomePage) { log.Fatal("Error: you must use a proper name for the homepage") } + + if !isCanonicalName(util.UserTree) { + log.Fatal("Error: you must use a proper name for user tree") + } + + switch util.AuthMethod { + case "none": + case "fixed": + user.AuthUsed = true + user.PopulateFixedUserStorage() + default: + log.Fatal("Error: unknown auth method:", util.AuthMethod) + } } diff --git a/go.mod b/go.mod index e2ffb7c..995478b 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,7 @@ module github.com/bouncepaw/mycorrhiza go 1.14 -require github.com/valyala/quicktemplate v1.6.3 +require ( + github.com/adrg/xdg v0.2.2 + github.com/valyala/quicktemplate v1.6.3 +) diff --git a/go.sum b/go.sum index a9a43a1..dbbc51b 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,12 @@ +github.com/adrg/xdg v0.2.2 h1:A7ZHKRz5KGOLJX/bg7IPzStryhvCzAE1wX+KWawPiAo= +github.com/adrg/xdg v0.2.2/go.mod h1:7I2hH/IT30IsupOpKZ5ue7/qNi3CoKzD6tL3HwpaRMQ= github.com/andybalholm/brotli v1.0.0/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/klauspost/compress v1.10.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.11.0/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.16.0/go.mod h1:YOKImeEosDdBPnxc0gy7INqi3m1zK6A+xl6TwOBhHCA= @@ -13,3 +19,5 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/history/history.go b/history/history.go index ec409c2..247b4c5 100644 --- a/history/history.go +++ b/history/history.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/bouncepaw/mycorrhiza/user" "github.com/bouncepaw/mycorrhiza/util" ) @@ -76,11 +77,19 @@ func (rev Revision) HyphaeLinks() (html string) { } func (rev Revision) RecentChangesEntry() (html string) { + if user.AuthUsed && rev.Username != "anon" { + return fmt.Sprintf(` +
  • +
  • %[2]s
  • +
  • %[5]s
  • +
  • %[6]s by
  • +`, rev.TimeString(), rev.Hash, util.UserTree, rev.Username, rev.HyphaeLinks(), rev.Message) + } return fmt.Sprintf(` -
  • -
  • %s
  • -
  • %s
  • -
  • %s
  • +
  • +
  • %[2]s
  • +
  • %[3]s
  • +
  • %[4]s
  • `, rev.TimeString(), rev.Hash, rev.HyphaeLinks(), rev.Message) } diff --git a/history/information.go b/history/information.go index 164e3a9..bc65cac 100644 --- a/history/information.go +++ b/history/information.go @@ -15,7 +15,7 @@ func RecentChanges(n int) string { var ( out, err = gitsh( "log", "--oneline", "--no-merges", - "--pretty=format:\"%h\t%ce\t%ct\t%s\"", + "--pretty=format:\"%h\t%ae\t%at\t%s\"", "--max-count="+strconv.Itoa(n), ) revs []Revision diff --git a/history/operations.go b/history/operations.go index 0507483..e16c367 100644 --- a/history/operations.go +++ b/history/operations.go @@ -8,6 +8,7 @@ import ( "path/filepath" "sync" + "github.com/bouncepaw/mycorrhiza/user" "github.com/bouncepaw/mycorrhiza/util" ) @@ -29,7 +30,7 @@ const ( type HistoryOp struct { // All errors are appended here. Errs []error - opType OpType + Type OpType userMsg string name string email string @@ -39,8 +40,10 @@ type HistoryOp struct { func Operation(opType OpType) *HistoryOp { gitMutex.Lock() hop := &HistoryOp{ - Errs: []error{}, - opType: opType, + Errs: []error{}, + name: "anon", + email: "anon@mycorrhiza", + Type: opType, } return hop } @@ -116,9 +119,11 @@ func (hop *HistoryOp) WithMsg(userMsg string) *HistoryOp { return hop } -// WithSignature sets a signature for the future commit. You need to pass a username only, the rest is upon us (including email and time). -func (hop *HistoryOp) WithSignature(username string) *HistoryOp { - hop.name = username - hop.email = username + "@mycorrhiza" // A fake email, why not +// WithUser sets a user for the commit. +func (hop *HistoryOp) WithUser(u *user.User) *HistoryOp { + if u.Group != user.UserAnon { + hop.name = u.Name + hop.email = u.Name + "@mycorrhiza" + } return hop } diff --git a/http_auth.go b/http_auth.go new file mode 100644 index 0000000..2e1039b --- /dev/null +++ b/http_auth.go @@ -0,0 +1,62 @@ +package main + +import ( + "log" + "net/http" + + "github.com/bouncepaw/mycorrhiza/templates" + "github.com/bouncepaw/mycorrhiza/user" +) + +func init() { + http.HandleFunc("/login", handlerLogin) + http.HandleFunc("/login-data", handlerLoginData) + http.HandleFunc("/logout", handlerLogout) + http.HandleFunc("/logout-confirm", handlerLogoutConfirm) +} + +func handlerLogout(w http.ResponseWriter, rq *http.Request) { + var ( + u = user.FromRequest(rq) + can = u != nil + ) + w.Header().Set("Content-Type", "text/html;charset=utf-8") + if can { + log.Println("User", u.Name, "tries to log out") + w.WriteHeader(http.StatusOK) + } else { + log.Println("Unknown user tries to log out") + w.WriteHeader(http.StatusForbidden) + } + w.Write([]byte(base("Logout?", templates.LogoutHTML(can)))) +} + +func handlerLogoutConfirm(w http.ResponseWriter, rq *http.Request) { + user.LogoutFromRequest(w, rq) + http.Redirect(w, rq, "/", http.StatusSeeOther) +} + +func handlerLoginData(w http.ResponseWriter, rq *http.Request) { + log.Println(rq.URL) + var ( + username = CanonicalName(rq.PostFormValue("username")) + password = rq.PostFormValue("password") + err = user.LoginDataHTTP(w, rq, username, password) + ) + if err != "" { + w.Write([]byte(base(err, templates.LoginErrorHTML(err)))) + } else { + http.Redirect(w, rq, "/", http.StatusSeeOther) + } +} + +func handlerLogin(w http.ResponseWriter, rq *http.Request) { + log.Println(rq.URL) + w.Header().Set("Content-Type", "text/html;charset=utf-8") + if user.AuthUsed { + w.WriteHeader(http.StatusOK) + } else { + w.WriteHeader(http.StatusForbidden) + } + w.Write([]byte(base("Login", templates.LoginHTML()))) +} diff --git a/http_mutators.go b/http_mutators.go index beb43e6..232fed3 100644 --- a/http_mutators.go +++ b/http_mutators.go @@ -6,16 +6,19 @@ import ( "net/http" "github.com/bouncepaw/mycorrhiza/templates" + "github.com/bouncepaw/mycorrhiza/user" "github.com/bouncepaw/mycorrhiza/util" ) func init() { - http.HandleFunc("/upload-binary/", handlerUploadBinary) - http.HandleFunc("/upload-text/", handlerUploadText) + // Those that do not actually mutate anything: http.HandleFunc("/edit/", handlerEdit) http.HandleFunc("/delete-ask/", handlerDeleteAsk) - http.HandleFunc("/delete-confirm/", handlerDeleteConfirm) http.HandleFunc("/rename-ask/", handlerRenameAsk) + // And those that do mutate something: + http.HandleFunc("/upload-binary/", handlerUploadBinary) + http.HandleFunc("/upload-text/", handlerUploadText) + http.HandleFunc("/delete-confirm/", handlerDeleteConfirm) http.HandleFunc("/rename-confirm/", handlerRenameConfirm) } @@ -25,22 +28,28 @@ func handlerRenameAsk(w http.ResponseWriter, rq *http.Request) { hyphaName = HyphaNameFromRq(rq, "rename-ask") _, isOld = HyphaStorage[hyphaName] ) - util.HTTP200Page(w, base("Rename "+hyphaName+"?", templates.RenameAskHTML(hyphaName, isOld))) + if ok := user.CanProceed(rq, "rename-confirm"); !ok { + HttpErr(w, http.StatusForbidden, hyphaName, "Not enough rights", "You must be a trusted editor to rename pages.") + log.Println("Rejected", rq.URL) + return + } + util.HTTP200Page(w, base("Rename "+hyphaName+"?", templates.RenameAskHTML(rq, hyphaName, isOld))) } func handlerRenameConfirm(w http.ResponseWriter, rq *http.Request) { log.Println(rq.URL) var ( hyphaName = HyphaNameFromRq(rq, "rename-confirm") - hyphaData, isOld = HyphaStorage[hyphaName] + _, isOld = HyphaStorage[hyphaName] newName = CanonicalName(rq.PostFormValue("new-name")) _, newNameIsUsed = HyphaStorage[newName] - recursive bool + recursive = rq.PostFormValue("recursive") == "true" + u = user.FromRequest(rq).OrAnon() ) - if rq.PostFormValue("recursive") == "true" { - recursive = true - } switch { + case !u.CanProceed("rename-confirm"): + HttpErr(w, http.StatusForbidden, hyphaName, "Not enough rights", "You must be a trusted editor to rename pages.") + log.Println("Rejected", rq.URL) case newNameIsUsed: HttpErr(w, http.StatusBadRequest, hyphaName, "Error: hypha exists", fmt.Sprintf("Hypha named %s already exists.", hyphaName, hyphaName)) @@ -54,12 +63,12 @@ func handlerRenameConfirm(w http.ResponseWriter, rq *http.Request) { HttpErr(w, http.StatusBadRequest, hyphaName, "Error: invalid name", "Invalid new name. Names cannot contain characters ^?!:#@><*|\"\\'&%") default: - if hop := hyphaData.RenameHypha(hyphaName, newName, recursive); len(hop.Errs) == 0 { - http.Redirect(w, rq, "/page/"+newName, http.StatusSeeOther) - } else { + if hop := RenameHypha(hyphaName, newName, recursive, u); len(hop.Errs) != 0 { HttpErr(w, http.StatusInternalServerError, hyphaName, "Error: could not rename hypha", fmt.Sprintf("Could not rename this hypha due to an internal error. Server errors: %v", hop.Errs)) + } else { + http.Redirect(w, rq, "/page/"+newName, http.StatusSeeOther) } } } @@ -71,7 +80,12 @@ func handlerDeleteAsk(w http.ResponseWriter, rq *http.Request) { hyphaName = HyphaNameFromRq(rq, "delete-ask") _, isOld = HyphaStorage[hyphaName] ) - util.HTTP200Page(w, base("Delete "+hyphaName+"?", templates.DeleteAskHTML(hyphaName, isOld))) + if ok := user.CanProceed(rq, "delete-ask"); !ok { + HttpErr(w, http.StatusForbidden, hyphaName, "Not enough rights", "You must be a moderator to delete pages.") + log.Println("Rejected", rq.URL) + return + } + util.HTTP200Page(w, base("Delete "+hyphaName+"?", templates.DeleteAskHTML(rq, hyphaName, isOld))) } // handlerDeleteConfirm deletes a hypha for sure @@ -80,22 +94,27 @@ func handlerDeleteConfirm(w http.ResponseWriter, rq *http.Request) { var ( hyphaName = HyphaNameFromRq(rq, "delete-confirm") hyphaData, isOld = HyphaStorage[hyphaName] + u = user.FromRequest(rq) ) - if isOld { - // If deleted successfully - if hop := hyphaData.DeleteHypha(hyphaName); len(hop.Errs) == 0 { - http.Redirect(w, rq, "/page/"+hyphaName, http.StatusSeeOther) - } else { - HttpErr(w, http.StatusInternalServerError, hyphaName, - "Error: could not delete hypha", - fmt.Sprintf("Could not delete this hypha due to an internal error. Server errors: %v", hop.Errs)) - } - } else { + if !u.CanProceed("delete-confirm") { + HttpErr(w, http.StatusForbidden, hyphaName, "Not enough rights", "You must be a moderator to delete pages.") + log.Println("Rejected", rq.URL) + return + } + if !isOld { // The precondition is to have the hypha in the first place. HttpErr(w, http.StatusPreconditionFailed, hyphaName, "Error: no such hypha", "Could not delete this hypha because it does not exist.") + return } + if hop := hyphaData.DeleteHypha(hyphaName, u); len(hop.Errs) != 0 { + HttpErr(w, http.StatusInternalServerError, hyphaName, + "Error: could not delete hypha", + fmt.Sprintf("Could not delete this hypha due to internal errors. Server errors: %v", hop.Errs)) + return + } + http.Redirect(w, rq, "/page/"+hyphaName, http.StatusSeeOther) } // handlerEdit shows the edit form. It doesn't edit anything actually. @@ -108,6 +127,11 @@ func handlerEdit(w http.ResponseWriter, rq *http.Request) { textAreaFill string err error ) + if ok := user.CanProceed(rq, "edit"); !ok { + HttpErr(w, http.StatusForbidden, hyphaName, "Not enough rights", "You must be an editor to edit pages.") + log.Println("Rejected", rq.URL) + return + } if isOld { textAreaFill, err = FetchTextPart(hyphaData) if err != nil { @@ -118,25 +142,27 @@ func handlerEdit(w http.ResponseWriter, rq *http.Request) { } else { warning = `

    You are creating a new hypha.

    ` } - util.HTTP200Page(w, base("Edit "+hyphaName, templates.EditHTML(hyphaName, textAreaFill, warning))) + util.HTTP200Page(w, base("Edit "+hyphaName, templates.EditHTML(rq, hyphaName, textAreaFill, warning))) } // handlerUploadText uploads a new text part for the hypha. func handlerUploadText(w http.ResponseWriter, rq *http.Request) { log.Println(rq.URL) var ( - hyphaName = HyphaNameFromRq(rq, "upload-text") - hyphaData, isOld = HyphaStorage[hyphaName] - textData = rq.PostFormValue("text") + hyphaName = HyphaNameFromRq(rq, "upload-text") + textData = rq.PostFormValue("text") + u = user.FromRequest(rq).OrAnon() ) - if !isOld { - hyphaData = &HyphaData{} + if ok := user.CanProceed(rq, "upload-text"); !ok { + HttpErr(w, http.StatusForbidden, hyphaName, "Not enough rights", "You must be an editor to edit pages.") + log.Println("Rejected", rq.URL) + return } if textData == "" { HttpErr(w, http.StatusBadRequest, hyphaName, "Error", "No text data passed") return } - if hop := hyphaData.UploadText(hyphaName, textData, isOld); len(hop.Errs) != 0 { + if hop := UploadText(hyphaName, textData, u); len(hop.Errs) != 0 { HttpErr(w, http.StatusInternalServerError, hyphaName, "Error", hop.Errs[0].Error()) } else { http.Redirect(w, rq, "/page/"+hyphaName, http.StatusSeeOther) @@ -146,31 +172,37 @@ func handlerUploadText(w http.ResponseWriter, rq *http.Request) { // handlerUploadBinary uploads a new binary part for the hypha. func handlerUploadBinary(w http.ResponseWriter, rq *http.Request) { log.Println(rq.URL) - hyphaName := HyphaNameFromRq(rq, "upload-binary") - rq.ParseMultipartForm(10 << 20) + var ( + hyphaName = HyphaNameFromRq(rq, "upload-binary") + u = user.FromRequest(rq) + ) + if !u.CanProceed("upload-binary") { + HttpErr(w, http.StatusForbidden, hyphaName, "Not enough rights", "You must be an editor to upload attachments.") + log.Println("Rejected", rq.URL) + return + } + rq.ParseMultipartForm(10 << 20) // Set upload limit file, handler, err := rq.FormFile("binary") if file != nil { defer file.Close() } + // If file is not passed: if err != nil { HttpErr(w, http.StatusBadRequest, hyphaName, "Error", "No binary data passed") return } + // If file is passed: var ( - hyphaData, isOld = HyphaStorage[hyphaName] - mime = handler.Header.Get("Content-Type") + mime = handler.Header.Get("Content-Type") + hop = UploadBinary(hyphaName, mime, file, u) ) - if !isOld { - hyphaData = &HyphaData{} - } - hop := hyphaData.UploadBinary(hyphaName, mime, file, isOld) if len(hop.Errs) != 0 { HttpErr(w, http.StatusInternalServerError, hyphaName, "Error", hop.Errs[0].Error()) - } else { - http.Redirect(w, rq, "/page/"+hyphaName, http.StatusSeeOther) + return } + http.Redirect(w, rq, "/page/"+hyphaName, http.StatusSeeOther) } diff --git a/http_readers.go b/http_readers.go index 7870d3e..b8887e9 100644 --- a/http_readers.go +++ b/http_readers.go @@ -40,6 +40,7 @@ func handlerRevision(w http.ResponseWriter, rq *http.Request) { contents = markup.ToHtml(hyphaName, textContents) } page := templates.RevisionHTML( + rq, hyphaName, naviTitle(hyphaName), contents, @@ -67,7 +68,7 @@ func handlerHistory(w http.ResponseWriter, rq *http.Request) { log.Println("Found", len(revs), "revisions for", hyphaName) util.HTTP200Page(w, - base(hyphaName, templates.HistoryHTML(hyphaName, tbody))) + base(hyphaName, templates.HistoryHTML(rq, hyphaName, tbody))) } // handlerText serves raw source text of the hypha. @@ -110,7 +111,7 @@ func handlerPage(w http.ResponseWriter, rq *http.Request) { contents = binaryHtmlBlock(hyphaName, data) + contents } } - util.HTTP200Page(w, base(hyphaName, templates.PageHTML(hyphaName, + util.HTTP200Page(w, base(hyphaName, templates.PageHTML(rq, hyphaName, naviTitle(hyphaName), contents, tree.TreeAsHtml(hyphaName, IterateHyphaNamesWith)))) diff --git a/hypha.go b/hypha.go index a49b785..e412c14 100644 --- a/hypha.go +++ b/hypha.go @@ -12,6 +12,7 @@ import ( "github.com/bouncepaw/mycorrhiza/history" "github.com/bouncepaw/mycorrhiza/markup" + "github.com/bouncepaw/mycorrhiza/user" "github.com/bouncepaw/mycorrhiza/util" ) @@ -31,6 +32,16 @@ func init() { } return } + markup.HyphaIterate = IterateHyphaNamesWith +} + +// GetHyphaData finds a hypha addressed by `hyphaName` and returns its `hyphaData`. `hyphaData` is set to a zero value if this hypha does not exist. `isOld` is false if this hypha does not exist. +func GetHyphaData(hyphaName string) (hyphaData *HyphaData, isOld bool) { + hyphaData, isOld = HyphaStorage[hyphaName] + if hyphaData == nil { + hyphaData = &HyphaData{} + } + return } // HyphaData represents a hypha's meta information: binary and text parts rooted paths and content types. @@ -40,10 +51,16 @@ type HyphaData struct { } // uploadHelp is a helper function for UploadText and UploadBinary -func (hd *HyphaData) uploadHelp(hop *history.HistoryOp, hyphaName, ext string, originalFullPath *string, isOld bool, data []byte) *history.HistoryOp { +func uploadHelp(hop *history.HistoryOp, hyphaName, ext string, data []byte, u *user.User) *history.HistoryOp { var ( - fullPath = filepath.Join(WikiDir, hyphaName+ext) + hyphaData, isOld = GetHyphaData(hyphaName) + fullPath = filepath.Join(WikiDir, hyphaName+ext) + originalFullPath = &hyphaData.textPath ) + if hop.Type == history.TypeEditBinary { + originalFullPath = &hyphaData.binaryPath + } + if err := os.MkdirAll(filepath.Dir(fullPath), 0777); err != nil { return hop.WithError(err) } @@ -51,30 +68,34 @@ func (hd *HyphaData) uploadHelp(hop *history.HistoryOp, hyphaName, ext string, o if err := ioutil.WriteFile(fullPath, data, 0644); err != nil { return hop.WithError(err) } + if isOld && *originalFullPath != fullPath && *originalFullPath != "" { if err := history.Rename(*originalFullPath, fullPath); err != nil { return hop.WithError(err) } log.Println("Move", *originalFullPath, "to", fullPath) } + // New hyphae must be added to the hypha storage if !isOld { - HyphaStorage[hyphaName] = hd + HyphaStorage[hyphaName] = hyphaData } *originalFullPath = fullPath - log.Printf("%v\n", *hd) return hop.WithFiles(fullPath). - WithSignature("anon"). + WithUser(u). Apply() } -// UploadText loads a new text part from `textData` for hypha `hyphaName` with `hd`. It must be marked if the hypha `isOld`. -func (hd *HyphaData) UploadText(hyphaName, textData string, isOld bool) *history.HistoryOp { - hop := history.Operation(history.TypeEditText).WithMsg(fmt.Sprintf("Edit ā€˜%sā€™", hyphaName)) - return hd.uploadHelp(hop, hyphaName, ".myco", &hd.textPath, isOld, []byte(textData)) +// UploadText loads a new text part from `textData` for hypha `hyphaName`. +func UploadText(hyphaName, textData string, u *user.User) *history.HistoryOp { + return uploadHelp( + history. + Operation(history.TypeEditText). + WithMsg(fmt.Sprintf("Edit ā€˜%sā€™", hyphaName)), + hyphaName, ".myco", []byte(textData), u) } // UploadBinary loads a new binary part from `file` for hypha `hyphaName` with `hd`. The contents have the specified `mime` type. It must be marked if the hypha `isOld`. -func (hd *HyphaData) UploadBinary(hyphaName, mime string, file multipart.File, isOld bool) *history.HistoryOp { +func UploadBinary(hyphaName, mime string, file multipart.File, u *user.User) *history.HistoryOp { var ( hop = history.Operation(history.TypeEditBinary).WithMsg(fmt.Sprintf("Upload binary part for ā€˜%sā€™ with type ā€˜%sā€™", hyphaName, mime)) data, err = ioutil.ReadAll(file) @@ -82,16 +103,15 @@ func (hd *HyphaData) UploadBinary(hyphaName, mime string, file multipart.File, i if err != nil { return hop.WithError(err).Apply() } - - return hd.uploadHelp(hop, hyphaName, MimeToExtension(mime), &hd.binaryPath, isOld, data) + return uploadHelp(hop, hyphaName, MimeToExtension(mime), data, u) } // DeleteHypha deletes hypha and makes a history record about that. -func (hd *HyphaData) DeleteHypha(hyphaName string) *history.HistoryOp { +func (hd *HyphaData) DeleteHypha(hyphaName string, u *user.User) *history.HistoryOp { hop := history.Operation(history.TypeDeleteHypha). WithFilesRemoved(hd.textPath, hd.binaryPath). WithMsg(fmt.Sprintf("Delete ā€˜%sā€™", hyphaName)). - WithSignature("anon"). + WithUser(u). Apply() if len(hop.Errs) == 0 { delete(HyphaStorage, hyphaName) @@ -138,7 +158,7 @@ func relocateHyphaData(hyphaNames []string, replaceName func(string) string) { } // RenameHypha renames hypha from old name `hyphaName` to `newName` and makes a history record about that. If `recursive` is `true`, its subhyphae will be renamed the same way. -func (hd *HyphaData) RenameHypha(hyphaName, newName string, recursive bool) *history.HistoryOp { +func RenameHypha(hyphaName, newName string, recursive bool, u *user.User) *history.HistoryOp { var ( replaceName = func(str string) string { return strings.Replace(str, hyphaName, newName, 1) @@ -157,7 +177,7 @@ func (hd *HyphaData) RenameHypha(hyphaName, newName string, recursive bool) *his } hop.WithFilesRenamed(renameMap). WithMsg(fmt.Sprintf(renameMsg, hyphaName, newName)). - WithSignature("anon"). + WithUser(u). Apply() if len(hop.Errs) == 0 { relocateHyphaData(hyphaNames, replaceName) @@ -171,7 +191,7 @@ func binaryHtmlBlock(hyphaName string, hd *HyphaData) string { case ".jpg", ".gif", ".png", ".webp", ".svg", ".ico": return fmt.Sprintf(`
    - +
    `, hyphaName) case ".ogg", ".webm", ".mp4": return fmt.Sprintf(` diff --git a/main.go b/main.go index 0f63208..51d7610 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,7 @@ import ( "github.com/bouncepaw/mycorrhiza/history" "github.com/bouncepaw/mycorrhiza/templates" + "github.com/bouncepaw/mycorrhiza/user" "github.com/bouncepaw/mycorrhiza/util" ) @@ -22,7 +23,7 @@ import ( var WikiDir string // HyphaPattern is a pattern which all hyphae must match. -var HyphaPattern = regexp.MustCompile(`[^?!:#@><*|"\'&%]+`) +var HyphaPattern = regexp.MustCompile(`[^?!:#@><*|"\'&%{}]+`) // HyphaStorage is a mapping between canonical hypha names and their meta information. var HyphaStorage = make(map[string]*HyphaData) @@ -63,6 +64,11 @@ var base = templates.BaseHTML // Reindex all hyphae by checking the wiki storage directory anew. func handlerReindex(w http.ResponseWriter, rq *http.Request) { log.Println(rq.URL) + if ok := user.CanProceed(rq, "reindex"); !ok { + HttpErr(w, http.StatusForbidden, util.HomePage, "Not enough rights", "You must be an admin to reindex hyphae.") + log.Println("Rejected", rq.URL) + return + } HyphaStorage = make(map[string]*HyphaData) log.Println("Wiki storage directory is", WikiDir) log.Println("Start indexing hyphae...") @@ -125,6 +131,7 @@ func main() { http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(WikiDir+"/static")))) // See http_readers.go for /page/, /text/, /binary/, /history/. // See http_mutators.go for /upload-binary/, /upload-text/, /edit/, /delete-ask/, /delete-confirm/, /rename-ask/, /rename-confirm/. + // See http_auth.go for /login, /login-data, /logout, /logout-confirm http.HandleFunc("/list", handlerList) http.HandleFunc("/reindex", handlerReindex) http.HandleFunc("/random", handlerRandom) diff --git a/markup/img.go b/markup/img.go index 244451b..6445ed1 100644 --- a/markup/img.go +++ b/markup/img.go @@ -13,53 +13,169 @@ func MatchesImg(line string) bool { } type imgEntry struct { - path string - sizeH string - sizeV string - desc string + trimmedPath string + path strings.Builder + sizeW strings.Builder + sizeH strings.Builder + desc strings.Builder } +func (entry *imgEntry) descriptionAsHtml(hyphaName string) (html string) { + if entry.desc.Len() == 0 { + return "" + } + lines := strings.Split(entry.desc.String(), "\n") + for _, line := range lines { + if line = strings.TrimSpace(line); line != "" { + if html != "" { + html += `
    ` + } + html += ParagraphToHtml(hyphaName, line) + } + } + return `
    ` + html + `
    ` +} + +func (entry *imgEntry) sizeWAsAttr() string { + if entry.sizeW.Len() == 0 { + return "" + } + return ` width="` + entry.sizeW.String() + `"` +} + +func (entry *imgEntry) sizeHAsAttr() string { + if entry.sizeH.Len() == 0 { + return "" + } + return ` height="` + entry.sizeH.String() + `"` +} + +type imgState int + +const ( + inRoot imgState = iota + inName + inDimensionsW + inDimensionsH + inDescription +) + type Img struct { entries []imgEntry - inDesc bool + currEntry imgEntry hyphaName string + state imgState +} + +func (img *Img) pushEntry() { + if strings.TrimSpace(img.currEntry.path.String()) != "" { + img.entries = append(img.entries, img.currEntry) + img.currEntry = imgEntry{} + img.currEntry.path.Reset() + } } func (img *Img) Process(line string) (shouldGoBackToNormal bool) { - if img.inDesc { - rightBraceIndex := strings.IndexRune(line, '}') - if cnt := len(img.entries); rightBraceIndex == -1 && cnt != 0 { - img.entries[cnt-1].desc += "\n" + line - } else if rightBraceIndex != -1 && cnt != 0 { - img.entries[cnt-1].desc += "\n" + line[:rightBraceIndex] - img.inDesc = false - } - if strings.Count(line, "}") > 1 { + stateToProcessor := map[imgState]func(rune) bool{ + inRoot: img.processInRoot, + inName: img.processInName, + inDimensionsW: img.processInDimensionsW, + inDimensionsH: img.processInDimensionsH, + inDescription: img.processInDescription, + } + for _, r := range line { + if shouldReturnTrue := stateToProcessor[img.state](r); shouldReturnTrue { return true } - } else if s := strings.TrimSpace(line); s != "" { - if s[0] == '}' { - return true - } - img.parseStartOfEntry(line) } return false } -func ImgFromFirstLine(line, hyphaName string) Img { - img := Img{ +func (img *Img) processInDescription(r rune) (shouldReturnTrue bool) { + switch r { + case '}': + img.state = inName + default: + img.currEntry.desc.WriteRune(r) + } + return false +} + +func (img *Img) processInRoot(r rune) (shouldReturnTrue bool) { + switch r { + case '}': + img.pushEntry() + return true + case '\n', '\r': + img.pushEntry() + case ' ', '\t': + default: + img.state = inName + img.currEntry = imgEntry{} + img.currEntry.path.Reset() + img.currEntry.path.WriteRune(r) + } + return false +} + +func (img *Img) processInName(r rune) (shouldReturnTrue bool) { + switch r { + case '}': + img.pushEntry() + return true + case '|': + img.state = inDimensionsW + case '{': + img.state = inDescription + case '\n', '\r': + img.pushEntry() + img.state = inRoot + default: + img.currEntry.path.WriteRune(r) + } + return false +} + +func (img *Img) processInDimensionsW(r rune) (shouldReturnTrue bool) { + switch r { + case '}': + img.pushEntry() + return true + case '*': + img.state = inDimensionsH + case ' ', '\t', '\n': + case '{': + img.state = inDescription + default: + img.currEntry.sizeW.WriteRune(r) + } + return false +} + +func (img *Img) processInDimensionsH(r rune) (shouldGoBackToNormal bool) { + switch r { + case '}': + img.pushEntry() + return true + case ' ', '\t', '\n': + case '{': + img.state = inDescription + default: + img.currEntry.sizeH.WriteRune(r) + } + return false +} + +func ImgFromFirstLine(line, hyphaName string) (img *Img, shouldGoBackToNormal bool) { + img = &Img{ hyphaName: hyphaName, entries: make([]imgEntry, 0), } - line = line[strings.IndexRune(line, '{'):] - if len(line) == 1 { // if { only - } else { - line = line[1:] // Drop the { - } - return img + line = line[strings.IndexRune(line, '{')+1:] + return img, img.Process(line) } -func (img *Img) canonicalPathFor(path string) string { +func (img *Img) binaryPathFor(path string) string { path = strings.TrimSpace(path) if strings.IndexRune(path, ':') != -1 || strings.IndexRune(path, '/') == 0 { return path @@ -68,73 +184,71 @@ func (img *Img) canonicalPathFor(path string) string { } } -func (img *Img) parseStartOfEntry(line string) (entry imgEntry, followedByDesc bool) { - pipeIndex := strings.IndexRune(line, '|') - if pipeIndex == -1 { // If no : in string - entry.path = img.canonicalPathFor(line) +func (img *Img) pagePathFor(path string) string { + path = strings.TrimSpace(path) + if strings.IndexRune(path, ':') != -1 || strings.IndexRune(path, '/') == 0 { + return path } else { - entry.path = img.canonicalPathFor(line[:pipeIndex]) - line = strings.TrimPrefix(line, line[:pipeIndex+1]) - - var ( - leftBraceIndex = strings.IndexRune(line, '{') - rightBraceIndex = strings.IndexRune(line, '}') - dimensions string - ) - - if leftBraceIndex == -1 { - dimensions = line - } else { - dimensions = line[:leftBraceIndex] - } - - sizeH, sizeV := parseDimensions(dimensions) - entry.sizeH = sizeH - entry.sizeV = sizeV - - if leftBraceIndex != -1 && rightBraceIndex == -1 { - img.inDesc = true - followedByDesc = true - entry.desc = strings.TrimPrefix(line, line[:leftBraceIndex+1]) - } else if leftBraceIndex != -1 && rightBraceIndex != -1 { - entry.desc = line[leftBraceIndex+1 : rightBraceIndex] - } + return "/page/" + xclCanonicalName(img.hyphaName, path) } - img.entries = append(img.entries, entry) - return } -func parseDimensions(dimensions string) (sizeH, sizeV string) { +func parseDimensions(dimensions string) (sizeW, sizeH string) { xIndex := strings.IndexRune(dimensions, '*') if xIndex == -1 { // If no x in dimensions - sizeH = strings.TrimSpace(dimensions) + sizeW = strings.TrimSpace(dimensions) } else { - sizeH = strings.TrimSpace(dimensions[:xIndex]) - sizeV = strings.TrimSpace(strings.TrimPrefix(dimensions, dimensions[:xIndex+1])) + sizeW = strings.TrimSpace(dimensions[:xIndex]) + sizeH = strings.TrimSpace(strings.TrimPrefix(dimensions, dimensions[:xIndex+1])) } return } -func (img Img) ToHtml() (html string) { - for _, entry := range img.entries { - html += fmt.Sprintf(`
    - -`, entry.path, entry.sizeH, entry.sizeV) - if entry.desc != "" { - html += `
    ` - for i, line := range strings.Split(entry.desc, "\n") { - if line != "" { - if i > 0 { - html += `
    ` - } - html += ParagraphToHtml(img.hyphaName, line) - } - } - html += `
    ` +func (img *Img) checkLinks() map[string]bool { + m := make(map[string]bool) + for i, entry := range img.entries { + // Also trim them for later use + entry.trimmedPath = strings.TrimSpace(entry.path.String()) + isAbsoluteUrl := strings.ContainsRune(entry.trimmedPath, ':') + if !isAbsoluteUrl { + entry.trimmedPath = canonicalName(entry.trimmedPath) } + img.entries[i] = entry + m[entry.trimmedPath] = isAbsoluteUrl + } + HyphaIterate(func(hyphaName string) { + for _, entry := range img.entries { + if hyphaName == entry.trimmedPath { + m[entry.trimmedPath] = true + } + } + }) + return m +} + +func (img *Img) ToHtml() (html string) { + linkAvailabilityMap := img.checkLinks() + isOneImageOnly := len(img.entries) == 1 && img.entries[0].desc.Len() == 0 + if isOneImageOnly { + html += `