diff --git a/.gitignore b/.gitignore index db5b71e..c450f48 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ -hypha mycorrhiza diff --git a/README.md b/README.md index 82876c6..db4db81 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,10 @@ -# 🍄 MycorrhizaWiki 0.11 +# 🍄 MycorrhizaWiki 0.12 A wiki engine. +[Main wiki](https://mycorrhiza.lesarbr.es) + ## Building +Also see [detailed instructions](https://mycorrhiza.lesarbr.es/page/deploy) on wiki. ```sh git clone --recurse-submodules https://github.com/bouncepaw/mycorrhiza cd mycorrhiza @@ -22,36 +25,40 @@ Options: 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") + -header-links-hypha string + Optional hypha that overrides the header links -home string The home page (default "home") + -icon string + What to show in the navititle in the beginning, before the colon (default "🍄") + -name string + What is the name of your wiki (default "wiki") -port string Port to serve the wiki at (default "1737") - -title string - How to call your wiki in the navititle (default "🍄") - -user-tree string + -url string + URL at which your wiki can be found. Used to generate feeds (default "http://0.0.0.0:$port") + -user-hypha string Hypha which is a superhypha of all user pages (default "u") ``` ## Features -* Edit pages through html forms -* Responsive design +* Edit pages through html forms, graphical preview +* Responsive design, dark theme (synced with system theme) * Works in text browsers * Wiki pages (called hyphae) are written in mycomarkup -* Everything is stored as simple files, no database required. You can run a wiki on almost any directory and get something to work with. -* Page trees +* Everything is stored as simple files, no database required. You can run a wiki on almost any directory and get something to work with +* Page trees; links to previous and next pages * Changes are saved to git * List of hyphae page * History page * Random page -* Recent changes page +* Recent changes page; RSS, Atom and JSON feeds available * 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/). +* Light on resources * 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. +Help is always needed. We have a [tg chat](https://t.me/mycorrhizadev) where some development is coordinated. You can also sponsor on [boosty](https://boosty.to/bouncepaw). Feel free to open an issue or contact directly. -## Future plans -* Tagging system -* Better history viewing +You can view list of all planned features on [our kanban board](https://github.com/bouncepaw/mycorrhiza/projects/1). diff --git a/flag.go b/flag.go index 8a9857a..47a2134 100644 --- a/flag.go +++ b/flag.go @@ -10,12 +10,15 @@ import ( ) 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.URL, "url", "http://0.0.0.0:$port", "URL at which your wiki can be found. Used to generate feeds and social media previews") + flag.StringVar(&util.ServerPort, "port", "1737", "Port to serve the wiki at using HTTP") + flag.StringVar(&util.HomePage, "home", "home", "The home page name") + flag.StringVar(&util.SiteNavIcon, "icon", "🍄", "What to show in the navititle in the beginning, before the colon") + flag.StringVar(&util.SiteName, "name", "wiki", "What is the name of your wiki") + flag.StringVar(&util.UserHypha, "user-hypha", "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.") + flag.StringVar(&util.HeaderLinksHypha, "header-links-hypha", "", "Optional hypha that overrides the header links") } // Do the things related to cli args and die maybe @@ -34,19 +37,19 @@ func parseCliArgs() { log.Fatal(err) } - if !isCanonicalName(util.HomePage) { - log.Fatal("Error: you must use a proper name for the homepage") + if util.URL == "http://0.0.0.0:$port" { + util.URL = "http://0.0.0.0:" + util.ServerPort } - if !isCanonicalName(util.UserTree) { - log.Fatal("Error: you must use a proper name for user tree") - } + util.HomePage = CanonicalName(util.HomePage) + util.UserHypha = CanonicalName(util.UserHypha) + util.HeaderLinksHypha = CanonicalName(util.HeaderLinksHypha) switch util.AuthMethod { case "none": case "fixed": user.AuthUsed = true - user.PopulateFixedUserStorage() + user.ReadUsersFromFilesystem() default: log.Fatal("Error: unknown auth method:", util.AuthMethod) } diff --git a/go.mod b/go.mod index 995478b..137cbae 100644 --- a/go.mod +++ b/go.mod @@ -4,5 +4,7 @@ go 1.14 require ( github.com/adrg/xdg v0.2.2 + github.com/gorilla/feeds v1.1.1 + github.com/kr/pretty v0.2.1 // indirect github.com/valyala/quicktemplate v1.6.3 ) diff --git a/go.sum b/go.sum index dbbc51b..f950cb8 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,21 @@ 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 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gorilla/feeds v1.1.1 h1:HwKXxqzcRNg9to+BbvJog4+f3s/xzvtZXICcQGutYfY= +github.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA= 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/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= 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= @@ -19,5 +29,7 @@ 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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 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 247b4c5..f3d117d 100644 --- a/history/history.go +++ b/history/history.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "os/exec" + "regexp" "strconv" "strings" "time" @@ -13,6 +14,8 @@ import ( "github.com/bouncepaw/mycorrhiza/util" ) +var renameMsgPattern = regexp.MustCompile(`^Rename ‘(.*)’ to ‘.*’`) + // Start initializes git credentials. func Start(wikiDir string) { _, err := gitsh("config", "user.name", "wikimind") @@ -27,10 +30,46 @@ func Start(wikiDir string) { // Revision represents a revision, duh. Hash is usually short. Username is extracted from email. type Revision struct { - Hash string - Username string - Time time.Time - Message string + Hash string + Username string + Time time.Time + Message string + hyphaeAffectedBuf []string +} + +// determine what hyphae were affected by this revision +func (rev *Revision) hyphaeAffected() (hyphae []string) { + if nil != rev.hyphaeAffectedBuf { + return rev.hyphaeAffectedBuf + } + hyphae = make([]string, 0) + var ( + // List of files affected by this revision, one per line. + out, err = gitsh("diff-tree", "--no-commit-id", "--name-only", "-r", rev.Hash) + // set is used to determine if a certain hypha has been already noted (hyphae are stored in 2 files at most currently). + set = make(map[string]bool) + isNewName = func(hyphaName string) bool { + if _, present := set[hyphaName]; present { + return false + } + set[hyphaName] = true + return true + } + ) + if err != nil { + return hyphae + } + for _, filename := range strings.Split(out.String(), "\n") { + if strings.IndexRune(filename, '.') >= 0 { + dotPos := strings.LastIndexByte(filename, '.') + hyphaName := string([]byte(filename)[0:dotPos]) // is it safe? + if isNewName(hyphaName) { + hyphae = append(hyphae, hyphaName) + } + } + } + rev.hyphaeAffectedBuf = hyphae + return hyphae } // TimeString returns a human readable time representation. @@ -40,42 +79,38 @@ func (rev Revision) TimeString() string { // HyphaeLinks returns a comma-separated list of hyphae that were affected by this revision as HTML string. func (rev Revision) HyphaeLinks() (html string) { - // diff-tree --no-commit-id --name-only -r - var ( - // List of files affected by this revision, one per line. - out, err = gitsh("diff-tree", "--no-commit-id", "--name-only", "-r", rev.Hash) - // set is used to determine if a certain hypha has been already noted (hyphae are stored in 2 files at most). - set = make(map[string]bool) - isNewName = func(hyphaName string) bool { - if _, present := set[hyphaName]; present { - return false - } else { - set[hyphaName] = true - return true - } - } - ) - if err != nil { - return "" - } - for _, filename := range strings.Split(out.String(), "\n") { - // If filename has an ampersand: - if strings.IndexRune(filename, '.') >= 0 { - // Remove ampersanded suffix from filename: - ampersandPos := strings.LastIndexByte(filename, '.') - hyphaName := string([]byte(filename)[0:ampersandPos]) // is it safe? - if isNewName(hyphaName) { - // Entries are separated by commas - if len(set) > 1 { - html += `` - } - html += fmt.Sprintf(`%[1]s`, hyphaName) - } + hyphae := rev.hyphaeAffected() + for i, hyphaName := range hyphae { + if i > 0 { + html += `` } + html += fmt.Sprintf(`%[1]s`, hyphaName) } return html } +func (rev *Revision) descriptionForFeed() (html string) { + return fmt.Sprintf( + `

%s

+

Hyphae affected: %s

`, rev.Message, rev.HyphaeLinks()) +} + +// Try and guess what link is the most important by looking at the message. +func (rev *Revision) bestLink() string { + var ( + revs = rev.hyphaeAffected() + renameRes = renameMsgPattern.FindStringSubmatch(rev.Message) + ) + switch { + case renameRes != nil: + return "/page/" + renameRes[1] + case len(revs) == 0: + return "" + default: + return "/page/" + revs[0] + } +} + func (rev Revision) RecentChangesEntry() (html string) { if user.AuthUsed && rev.Username != "anon" { return fmt.Sprintf(` @@ -83,7 +118,7 @@ func (rev Revision) RecentChangesEntry() (html string) {
  • %[2]s
  • %[5]s
  • %[6]s by
  • -`, rev.TimeString(), rev.Hash, util.UserTree, rev.Username, rev.HyphaeLinks(), rev.Message) +`, rev.TimeString(), rev.Hash, util.UserHypha, rev.Username, rev.HyphaeLinks(), rev.Message) } return fmt.Sprintf(`
  • diff --git a/history/information.go b/history/information.go index bc65cac..f2c0aad 100644 --- a/history/information.go +++ b/history/information.go @@ -7,10 +7,60 @@ import ( "regexp" "strconv" "strings" + "time" "github.com/bouncepaw/mycorrhiza/templates" + "github.com/bouncepaw/mycorrhiza/util" + "github.com/gorilla/feeds" ) +func recentChangesFeed() *feeds.Feed { + feed := &feeds.Feed{ + Title: "Recent changes", + Link: &feeds.Link{Href: util.URL}, + Description: "List of 30 recent changes on the wiki", + Author: &feeds.Author{Name: "Wikimind", Email: "wikimind@mycorrhiza"}, + Updated: time.Now(), + } + var ( + out, err = gitsh( + "log", "--oneline", "--no-merges", + "--pretty=format:\"%h\t%ae\t%at\t%s\"", + "--max-count=30", + ) + revs []Revision + ) + if err == nil { + for _, line := range strings.Split(out.String(), "\n") { + revs = append(revs, parseRevisionLine(line)) + } + } + for _, rev := range revs { + feed.Add(&feeds.Item{ + Title: rev.Message, + Author: &feeds.Author{Name: rev.Username}, + Id: rev.Hash, + Description: rev.descriptionForFeed(), + Created: rev.Time, + Updated: rev.Time, + Link: &feeds.Link{Href: util.URL + rev.bestLink()}, + }) + } + return feed +} + +func RecentChangesRSS() (string, error) { + return recentChangesFeed().ToRss() +} + +func RecentChangesAtom() (string, error) { + return recentChangesFeed().ToAtom() +} + +func RecentChangesJSON() (string, error) { + return recentChangesFeed().ToJSON() +} + func RecentChanges(n int) string { var ( out, err = gitsh( @@ -32,13 +82,19 @@ func RecentChanges(n int) string { return templates.RecentChangesHTML(entries, n) } +// FileChanged tells you if the file has been changed. +func FileChanged(path string) bool { + _, err := gitsh("diff", "--exit-code", path) + return err != nil +} + // Revisions returns slice of revisions for the given hypha name. func Revisions(hyphaName string) ([]Revision, error) { var ( out, err = gitsh( "log", "--oneline", "--no-merges", - // Hash, Commiter email, Commiter time, Commit msg separated by tab - "--pretty=format:\"%h\t%ce\t%ct\t%s\"", + // Hash, author email, author time, commit msg separated by tab + "--pretty=format:\"%h\t%ae\t%at\t%s\"", "--", hyphaName+".*", ) revs []Revision @@ -53,6 +109,59 @@ func Revisions(hyphaName string) ([]Revision, error) { return revs, err } +// HistoryWithRevisions returns an html representation of `revs` that is meant to be inserted in a history page. +func HistoryWithRevisions(hyphaName string, revs []Revision) (html string) { + var ( + currentYear int + currentMonth time.Month + ) + for i, rev := range revs { + if rev.Time.Month() != currentMonth || rev.Time.Year() != currentYear { + currentYear = rev.Time.Year() + currentMonth = rev.Time.Month() + if i != 0 { + html += ` + +` + } + html += fmt.Sprintf(` +
    + +

    %[3]s

    +
    + ") + case "p": + addParagraphIfNeeded() } return } @@ -74,13 +90,17 @@ func geminiLineToAST(line string, state *GemLexerState, ast *[]Line) { switch state.where { case "img": goto imgState + case "table": + goto tableState case "pre": goto preformattedState case "list": goto listState case "number": goto numberState - default: + case "launchpad": + goto launchpadState + default: // "p" or "" goto normalState } @@ -91,6 +111,13 @@ imgState: } return +tableState: + if shouldGoBackToNormal := state.table.Process(line); shouldGoBackToNormal { + state.where = "" + addLine(*state.table) + } + return + preformattedState: switch { case startsWith("```"): @@ -135,48 +162,84 @@ numberState: } return +launchpadState: + switch { + case startsWith("=>"): + href, text, class := Rocketlink(line, state.name) + state.buf += fmt.Sprintf(`
  • %s
  • `, class, href, text) + case startsWith("```"): + state.where = "pre" + addLine(state.buf + "") + state.id++ + state.buf = fmt.Sprintf("
    ", state.id, strings.TrimPrefix(line, "```"))
    +	default:
    +		state.where = ""
    +		addLine(state.buf + "")
    +		goto normalState
    +	}
    +	return
    +
     normalState:
     	state.id++
     	switch {
     
     	case startsWith("```"):
    +		addParagraphIfNeeded()
     		state.where = "pre"
     		state.buf = fmt.Sprintf("
    ", state.id, strings.TrimPrefix(line, "```"))
     	case startsWith("* "):
    +		addParagraphIfNeeded()
     		state.where = "list"
     		state.buf = fmt.Sprintf("
      \n", state.id) goto listState case startsWith("*. "): + addParagraphIfNeeded() state.where = "number" state.buf = fmt.Sprintf("
        \n", state.id) goto numberState case startsWith("###### "): + addParagraphIfNeeded() addHeading(6) case startsWith("##### "): + addParagraphIfNeeded() addHeading(5) case startsWith("#### "): + addParagraphIfNeeded() addHeading(4) case startsWith("### "): + addParagraphIfNeeded() addHeading(3) case startsWith("## "): + addParagraphIfNeeded() addHeading(2) case startsWith("# "): + addParagraphIfNeeded() addHeading(1) case startsWith(">"): - addLine(fmt.Sprintf( - "
        %s
        ", state.id, remover(">")(line))) + addParagraphIfNeeded() + addLine( + fmt.Sprintf( + "
        %s
        ", + state.id, + ParagraphToHtml(state.name, remover(">")(line)), + ), + ) case startsWith("=>"): - href, text, class := Rocketlink(line, state.name) - addLine(fmt.Sprintf( - `

        %s

        `, state.id, class, href, text)) + addParagraphIfNeeded() + state.where = "launchpad" + state.buf = fmt.Sprintf("
          \n", state.id) + goto launchpadState case startsWith("<="): + addParagraphIfNeeded() addLine(parseTransclusion(line, state.name)) case line == "----": + addParagraphIfNeeded() *ast = append(*ast, Line{id: -1, contents: "
          "}) case MatchesImg(line): + addParagraphIfNeeded() img, shouldGoBackToNormal := ImgFromFirstLine(line, state.name) if shouldGoBackToNormal { addLine(*img) @@ -184,7 +247,15 @@ normalState: state.where = "img" state.img = img } + case MatchesTable(line): + addParagraphIfNeeded() + state.where = "table" + state.table = TableFromFirstLine(line, state.name) + + case state.where == "p": + state.buf += "\n" + line default: - addLine(fmt.Sprintf("

          %s

          ", state.id, ParagraphToHtml(state.name, line))) + state.where = "p" + state.buf = line } } diff --git a/markup/link.go b/markup/link.go index eb52c63..af3e51c 100644 --- a/markup/link.go +++ b/markup/link.go @@ -1,6 +1,7 @@ package markup import ( + "fmt" "path" "strings" ) @@ -15,11 +16,19 @@ func LinkParts(addr, display, hyphaName string) (href, text, class string) { } else { text = strings.TrimSpace(display) } - class = "wikilink_internal" + class = "wikilink wikilink_internal" switch { case strings.ContainsRune(addr, ':'): - return addr, text, "wikilink_external" + pos := strings.IndexRune(addr, ':') + destination := addr[:pos] + if display == "" { + text = addr[pos+1:] + if strings.HasPrefix(text, "//") && len(text) > 2 { + text = text[2:] + } + } + return addr, text, fmt.Sprintf("wikilink wikilink_external wikilink_%s", destination) case strings.HasPrefix(addr, "/"): return addr, text, class case strings.HasPrefix(addr, "./"): diff --git a/markup/mycomarkup.go b/markup/mycomarkup.go index fa47e2b..656ca28 100644 --- a/markup/mycomarkup.go +++ b/markup/mycomarkup.go @@ -2,8 +2,12 @@ package markup import ( + "fmt" "html" + "regexp" "strings" + + "github.com/bouncepaw/mycorrhiza/util" ) // A Mycomarkup-formatted document @@ -11,26 +15,79 @@ type MycoDoc struct { // data hyphaName string contents string - - // state - recursionDepth int - + // indicators + parsedAlready bool // results + ast []Line + html string + firstImageURL string + description string } // Constructor func Doc(hyphaName, contents string) *MycoDoc { - return &MycoDoc{ + md := &MycoDoc{ hyphaName: hyphaName, contents: contents, } + return md +} + +func (md *MycoDoc) Lex(recursionLevel int) *MycoDoc { + if !md.parsedAlready { + md.ast = md.lex() + } + md.parsedAlready = true + return md } // AsHtml returns an html representation of the document -func (md *MycoDoc) AsHtml() string { - return "" +func (md *MycoDoc) AsHTML() string { + md.html = Parse(md.Lex(0).ast, 0, 0, 0) + return md.html } +// Used to clear opengraph description from html tags. This method is usually bad because of dangers of malformed HTML, but I'm going to use it only for Mycorrhiza-generated HTML, so it's okay. The question mark is required; without it the whole string is eaten away. +var htmlTagRe = regexp.MustCompile(`<.*?>`) + +// OpenGraphHTML returns an html representation of og: meta tags. +func (md *MycoDoc) OpenGraphHTML() string { + md.ogFillVars() + return strings.Join([]string{ + ogTag("title", md.hyphaName), + ogTag("type", "article"), + ogTag("image", md.firstImageURL), + ogTag("url", util.URL+"/page/"+md.hyphaName), + ogTag("determiner", ""), + ogTag("description", htmlTagRe.ReplaceAllString(md.description, "")), + }, "\n") +} + +func (md *MycoDoc) ogFillVars() *MycoDoc { + foundDesc := false + md.firstImageURL = HyphaImageForOG(md.hyphaName) + for _, line := range md.ast { + switch v := line.contents.(type) { + case string: + if !foundDesc { + md.description = v + foundDesc = true + } + case Img: + if len(v.entries) > 0 { + md.firstImageURL = v.entries[0].path.String() + } + } + } + return md +} + +func ogTag(property, content string) string { + return fmt.Sprintf(``, property, content) +} + +/* The rest of this file is currently unused. TODO: use it I guess */ + type BlockType int const ( diff --git a/markup/paragraph.go b/markup/paragraph.go index 8f90085..f506bb6 100644 --- a/markup/paragraph.go +++ b/markup/paragraph.go @@ -5,6 +5,7 @@ import ( "fmt" "html" "strings" + "unicode" ) type spanTokenType int @@ -34,8 +35,10 @@ func tagFromState(stt spanTokenType, tagState map[spanTokenType]bool, tagName, o } } -func getLinkNode(input *bytes.Buffer, hyphaName string) string { - input.Next(2) +func getLinkNode(input *bytes.Buffer, hyphaName string, isBracketedLink bool) string { + if isBracketedLink { + input.Next(2) // drop those [[ + } var ( escaping = false addrBuf = bytes.Buffer{} @@ -47,11 +50,13 @@ func getLinkNode(input *bytes.Buffer, hyphaName string) string { if escaping { currBuf.WriteByte(b) escaping = false - } else if b == '|' && currBuf == &addrBuf { + } else if isBracketedLink && b == '|' && currBuf == &addrBuf { currBuf = &displayBuf - } else if b == ']' && bytes.HasPrefix(input.Bytes(), []byte{']'}) { + } else if isBracketedLink && b == ']' && bytes.HasPrefix(input.Bytes(), []byte{']'}) { input.Next(1) break + } else if !isBracketedLink && unicode.IsSpace(rune(b)) { + break } else { currBuf.WriteByte(b) } @@ -65,6 +70,12 @@ func getTextNode(input *bytes.Buffer) string { var ( textNodeBuffer = bytes.Buffer{} escaping = false + startsWith = func(t string) bool { + return bytes.HasPrefix(input.Bytes(), []byte(t)) + } + couldBeLinkStart = func() bool { + return startsWith("https://") || startsWith("http://") || startsWith("gemini://") || startsWith("gopher://") || startsWith("ftp://") + } ) // Always read the first byte in advance to avoid endless loops that kill computers (sad experience) if input.Len() != 0 { @@ -82,6 +93,9 @@ func getTextNode(input *bytes.Buffer) string { } else if strings.IndexByte("/*`^,![~", b) >= 0 { input.UnreadByte() break + } else if couldBeLinkStart() { + textNodeBuffer.WriteByte(b) + break } else { textNodeBuffer.WriteByte(b) } @@ -106,6 +120,9 @@ func ParagraphToHtml(hyphaName, input string) string { startsWith = func(t string) bool { return bytes.HasPrefix(p.Bytes(), []byte(t)) } + noTagsActive = func() bool { + return !(tagState[spanItalic] || tagState[spanBold] || tagState[spanMono] || tagState[spanSuper] || tagState[spanSub] || tagState[spanMark] || tagState[spanLink]) + } ) for p.Len() != 0 { @@ -132,7 +149,9 @@ func ParagraphToHtml(hyphaName, input string) string { ret.WriteString(tagFromState(spanMark, tagState, "s", "~~")) p.Next(2) case startsWith("[["): - ret.WriteString(getLinkNode(p, hyphaName)) + ret.WriteString(getLinkNode(p, hyphaName, true)) + case (startsWith("https://") || startsWith("http://") || startsWith("gemini://") || startsWith("gopher://") || startsWith("ftp://")) && noTagsActive(): + ret.WriteString(getLinkNode(p, hyphaName, false)) default: ret.WriteString(html.EscapeString(getTextNode(p))) } diff --git a/markup/parser.go b/markup/parser.go index 23887d1..665f8ba 100644 --- a/markup/parser.go +++ b/markup/parser.go @@ -1,35 +1,26 @@ package markup -import () - const maxRecursionLevel = 3 -type GemParserState struct { - recursionLevel int -} - -func Parse(ast []Line, from, to int, state GemParserState) (html string) { - if state.recursionLevel > maxRecursionLevel { +func Parse(ast []Line, from, to int, recursionLevel int) (html string) { + if recursionLevel > maxRecursionLevel { return "Transclusion depth limit" } for _, line := range ast { if line.id >= from && (line.id <= to || to == 0) || line.id == -1 { switch v := line.contents.(type) { case Transclusion: - html += Transclude(v, state) + html += Transclude(v, recursionLevel) case Img: html += v.ToHtml() + case Table: + html += v.asHtml() case string: html += v default: - html += "Unknown" + html += "Unknown element." } } } return html } - -func ToHtml(name, text string) string { - state := GemParserState{} - return Parse(lex(name, text), 0, 0, state) -} diff --git a/markup/table.go b/markup/table.go new file mode 100644 index 0000000..a4dca62 --- /dev/null +++ b/markup/table.go @@ -0,0 +1,231 @@ +package markup + +import ( + "fmt" + "regexp" + "strings" + "unicode" + // "github.com/bouncepaw/mycorrhiza/util" +) + +var tableRe = regexp.MustCompile(`^table\s+{`) + +func MatchesTable(line string) bool { + return tableRe.MatchString(line) +} + +func TableFromFirstLine(line, hyphaName string) *Table { + return &Table{ + hyphaName: hyphaName, + caption: line[strings.IndexRune(line, '{')+1:], + rows: make([]*tableRow, 0), + } +} + +func (t *Table) Process(line string) (shouldGoBackToNormal bool) { + if strings.TrimSpace(line) == "}" && !t.inMultiline { + return true + } + if !t.inMultiline { + t.pushRow() + } + var ( + inLink bool + skipNext bool + escaping bool + lookingForNonSpace = !t.inMultiline + countingColspan bool + ) + for i, r := range line { + switch { + case skipNext: + skipNext = false + continue + + case lookingForNonSpace && unicode.IsSpace(r): + case lookingForNonSpace && (r == '!' || r == '|'): + t.currCellMarker = r + t.currColspan = 1 + lookingForNonSpace = false + countingColspan = true + case lookingForNonSpace: + t.currCellMarker = '^' // ^ represents implicit |, not part of syntax + t.currColspan = 1 + lookingForNonSpace = false + t.currCellBuilder.WriteRune(r) + + case escaping: + t.currCellBuilder.WriteRune(r) + case inLink && r == ']' && len(line)-1 > i && line[i+1] == ']': + t.currCellBuilder.WriteString("]]") + inLink = false + skipNext = true + case inLink: + t.currCellBuilder.WriteRune(r) + + case t.inMultiline && r == '}': + t.inMultiline = false + case t.inMultiline && i == len(line)-1: + t.currCellBuilder.WriteRune('\n') + case t.inMultiline: + t.currCellBuilder.WriteRune(r) + + // Not in multiline: + case (r == '|' || r == '!') && !countingColspan: + t.pushCell() + t.currCellMarker = r + t.currColspan = 1 + countingColspan = true + case r == t.currCellMarker && (r == '|' || r == '!') && countingColspan: + t.currColspan++ + case r == '{': + t.inMultiline = true + countingColspan = false + case r == '[' && len(line)-1 > i && line[i+1] == '[': + t.currCellBuilder.WriteString("[[") + inLink = true + skipNext = true + case i == len(line)-1: + t.pushCell() + default: + t.currCellBuilder.WriteRune(r) + countingColspan = false + } + } + return false +} + +type Table struct { + // data + hyphaName string + caption string + rows []*tableRow + // state + inMultiline bool + // tmp + currCellMarker rune + currColspan uint + currCellBuilder strings.Builder +} + +func (t *Table) pushRow() { + t.rows = append(t.rows, &tableRow{ + cells: make([]*tableCell, 0), + }) +} + +func (t *Table) pushCell() { + tc := &tableCell{ + content: t.currCellBuilder.String(), + colspan: t.currColspan, + } + switch t.currCellMarker { + case '|', '^': + tc.kind = tableCellDatum + case '!': + tc.kind = tableCellHeader + } + // We expect the table to have at least one row ready, so no nil-checking + tr := t.rows[len(t.rows)-1] + tr.cells = append(tr.cells, tc) + t.currCellBuilder = strings.Builder{} +} + +func (t *Table) asHtml() (html string) { + if t.caption != "" { + html += fmt.Sprintf("%s", t.caption) + } + if len(t.rows) > 0 && t.rows[0].looksLikeThead() { + html += fmt.Sprintf("%s", t.rows[0].asHtml(t.hyphaName)) + t.rows = t.rows[1:] + } + html += "\n\n" + for _, tr := range t.rows { + html += tr.asHtml(t.hyphaName) + } + return fmt.Sprintf(`%s
          `, html) +} + +type tableRow struct { + cells []*tableCell +} + +func (tr *tableRow) asHtml(hyphaName string) (html string) { + for _, tc := range tr.cells { + html += tc.asHtml(hyphaName) + } + return fmt.Sprintf("%s\n", html) +} + +// Most likely, rows with more than two header cells are theads. I allow one extra datum cell for tables like this: +// | ! a ! b +// ! c | d | e +// ! f | g | h +func (tr *tableRow) looksLikeThead() bool { + var ( + headerAmount = 0 + datumAmount = 0 + ) + for _, tc := range tr.cells { + switch tc.kind { + case tableCellHeader: + headerAmount++ + case tableCellDatum: + datumAmount++ + } + } + return headerAmount >= 2 && datumAmount <= 1 +} + +type tableCell struct { + kind tableCellKind + colspan uint + content string +} + +func (tc *tableCell) asHtml(hyphaName string) string { + return fmt.Sprintf( + "<%[1]s %[2]s>%[3]s\n", + tc.kind.tagName(), + tc.colspanAttribute(), + tc.contentAsHtml(hyphaName), + ) +} + +func (tc *tableCell) colspanAttribute() string { + if tc.colspan <= 1 { + return "" + } + return fmt.Sprintf(`colspan="%d"`, tc.colspan) +} + +func (tc *tableCell) contentAsHtml(hyphaName string) (html string) { + for _, line := range strings.Split(tc.content, "\n") { + if line = strings.TrimSpace(line); line != "" { + if html != "" { + html += `
          ` + } + html += ParagraphToHtml(hyphaName, line) + } + } + return html +} + +type tableCellKind int + +const ( + tableCellUnknown tableCellKind = iota + tableCellHeader + tableCellDatum +) + +func (tck tableCellKind) tagName() string { + switch tck { + case tableCellHeader: + return "th" + case tableCellDatum: + return "td" + default: + return "p" + } +} diff --git a/markup/xclusion.go b/markup/xclusion.go index b4a4370..a5df6c8 100644 --- a/markup/xclusion.go +++ b/markup/xclusion.go @@ -17,14 +17,14 @@ type Transclusion struct { } // Transclude transcludes `xcl` and returns html representation. -func Transclude(xcl Transclusion, state GemParserState) (html string) { - state.recursionLevel++ +func Transclude(xcl Transclusion, recursionLevel int) (html string) { + recursionLevel++ tmptOk := `
          %s
          %s
          ` tmptFailed := `
          -

          Failed to transclude %s

          +

          Hypha %s does not exist

          ` if xcl.from == xclError || xcl.to == xclError || xcl.from > xcl.to { return fmt.Sprintf(tmptFailed, xcl.name, xcl.name) @@ -34,7 +34,8 @@ func Transclude(xcl Transclusion, state GemParserState) (html string) { if err != nil { return fmt.Sprintf(tmptFailed, xcl.name, xcl.name) } - xclText := Parse(lex(xcl.name, rawText), xcl.from, xcl.to, state) + md := Doc(xcl.name, rawText) + xclText := Parse(md.lex(), xcl.from, xcl.to, recursionLevel) return fmt.Sprintf(tmptOk, xcl.name, xcl.name, binaryHtml+xclText) } diff --git a/metarrhiza b/metarrhiza index 7828352..be5b922 160000 --- a/metarrhiza +++ b/metarrhiza @@ -1 +1 @@ -Subproject commit 7828352598c19afe5f2e13df0219656ac7b44c9c +Subproject commit be5b922e9b564551601d21ed45bf7d9ced65c6bb diff --git a/name.go b/name.go index 1ceb0ba..2a859d8 100644 --- a/name.go +++ b/name.go @@ -23,16 +23,24 @@ func CanonicalName(name string) string { func naviTitle(canonicalName string) string { var ( html = fmt.Sprintf(`

          - %s`, util.HomePage, util.SiteTitle) + %s`, util.HomePage, util.SiteNavIcon) prevAcc = `/page/` parts = strings.Split(canonicalName, "/") + rel = "up" ) - for _, part := range parts { - html += fmt.Sprintf(` - - %s`, + for i, part := range parts { + if i > 0 { + html += `` + } + if i == len(parts)-1 { + rel = "bookmark" + } + html += fmt.Sprintf( + `%s`, prevAcc+part, - strings.Title(part)) + rel, + util.BeautifulName(part), + ) prevAcc += part + "/" } return html + "

          " diff --git a/templates/asset.qtpl b/templates/asset.qtpl new file mode 100644 index 0000000..2e6b4f2 --- /dev/null +++ b/templates/asset.qtpl @@ -0,0 +1,21 @@ +{% func DefaultCSS() %} +{% cat "default.css" %} +{% endfunc %} + +Next three are from https://remixicon.com/ +{% func IconHTTP() %} +{% cat "icon/http-protocol-icon.svg" %} +{% endfunc %} + +{% func IconGemini() %} +{% cat "icon/gemini-protocol-icon.svg" %} +{% endfunc %} + +{% func IconMailto() %} +{% cat "icon/mailto-protocol-icon.svg" %} +{% endfunc %} + +This is a modified version of https://www.svgrepo.com/svg/232085/rat +{% func IconGopher() %} +{% cat "icon/gopher-protocol-icon.svg" %} +{% endfunc %} diff --git a/templates/asset.qtpl.go b/templates/asset.qtpl.go new file mode 100644 index 0000000..95a984b --- /dev/null +++ b/templates/asset.qtpl.go @@ -0,0 +1,414 @@ +// Code generated by qtc from "asset.qtpl". DO NOT EDIT. +// See https://github.com/valyala/quicktemplate for details. + +//line templates/asset.qtpl:1 +package templates + +//line templates/asset.qtpl:1 +import ( + qtio422016 "io" + + qt422016 "github.com/valyala/quicktemplate" +) + +//line templates/asset.qtpl:1 +var ( + _ = qtio422016.Copy + _ = qt422016.AcquireByteBuffer +) + +//line templates/asset.qtpl:1 +func StreamDefaultCSS(qw422016 *qt422016.Writer) { +//line templates/asset.qtpl:1 + qw422016.N().S(` +`) +//line templates/asset.qtpl:2 + qw422016.N().S(`/* Layout stuff */ +@media screen and (min-width: 800px) { + main { padding:1rem 2rem; margin: 0 auto; width: 800px; } + .hypha-tabs { padding: 1rem 2rem; margin: 0 auto; width: 800px; } + header { margin: 0 auto; width: 800px; } + .header-links__entry { margin-right: 1.5rem; } + .header-links__entry_user { margin: 0 2rem 0 auto; } + .header-links__entry:nth-of-type(1), + .hypha-tabs__tab:nth-of-type(1) { margin-left: 2rem; } + .hypha-tabs__tab { margin-right: 1.5rem; box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.2); border-bottom: 2px #ddd solid; padding: 0 .5rem; } +} +@media screen and (max-width: 800px) { + main { padding: 1rem; margin: 0; width: 100%; } + .hypha-tabs{ padding: 1rem; margin: 0; width: 100%; } + .hypha-tabs__tab { box-shadow: none; margin-right: .5rem; padding: .25rem .5rem; } + header { width: 100%; } + .header-links__entry { margin-right: .5rem; } +} +*, *::before, *::after {box-sizing: border-box;} +html { height:100%; padding:0; } +body {height:100%; margin:0; font-size:16px; font-family: 'PT Sans', 'Liberation Sans', sans-serif;} +main {border-radius: 0 0 .25rem .25rem; } +main > form {margin-bottom:1rem;} +textarea {font-size:16px; font-family: 'PT Sans', 'Liberation Sans', sans-serif;} +.edit_no-preview {height:100%;} +.edit_with-preview .edit-form textarea { min-height: 500px; } +.edit__preview { border: 2px dashed #ddd; } +.edit-form {height:90%;} +.edit-form textarea {width:100%;height:90%;} +.edit-form__save { font-weight: bold; } +.icon {margin-right: .25rem; vertical-align: bottom; } + +main h1:not(.navi-title) {font-size:1.7rem;} +blockquote { margin-left: 0; padding-left: 1rem; } +.wikilink_external::before { display: inline-block; width: 18px; height: 16px; vertical-align: sub; } +/* .wikilink_external { padding-left: 16px; } */ +.wikilink_gopher::before { content: url("/static/icon/gopher"); } +.wikilink_http::before { content: url("/static/icon/http"); } +.wikilink_https::before { content: url("/static/icon/http"); } +/* .wikilink_https { background: transparent url("/static/icon/http") center left no-repeat; } */ +.wikilink_gemini::before { content: url("/static/icon/gemini"); } +.wikilink_mailto::before { content: url("/static/icon/mailto"); } + +article { overflow-wrap: break-word; word-wrap: break-word; word-break: break-word; line-height: 150%; } +article h1, article h2, article h3, article h4, article h5, article h6 { margin: 1.5rem 0 0 0; } +article p { margin: .5rem 0; } +article ul, ol { padding-left: 1.5rem; margin: .5rem 0; } +article code { padding: .1rem .3rem; border-radius: .25rem; font-size: 90%; } +article pre.codeblock { padding:.5rem; white-space: pre-wrap; border-radius: .25rem;} +.codeblock code {padding:0; font-size:15px;} +.transclusion { border-radius: .25rem; } +.transclusion__content > *:not(.binary-container) {margin: 0.5rem; } +.transclusion__link {display: block; text-align: right; font-style: italic; margin-top: .5rem; margin-right: .25rem; text-decoration: none;} +.transclusion__link::before {content: "⇐ ";} + +/* Derived from https://commons.wikimedia.org/wiki/File:U%2B21D2.svg */ +.launchpad__entry { list-style-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' version='1.0' width='25' height='12'%3E%3Cg transform='scale(0.7,0.8) translate(-613.21429,-421)'%3E%3Cpath fill='%23999' d='M 638.06773,429.49751 L 631.01022,436.87675 L 630.1898,436.02774 L 632.416,433.30375 L 613.46876,433.30375 L 613.46876,431.66382 L 633.82089,431.66382 L 635.57789,429.5261 L 633.79229,427.35979 L 613.46876,427.35979 L 613.46876,425.71985 L 632.416,425.71985 L 630.1898,422.99587 L 631.01022,422.08788 L 638.06773,429.49751 z '/%3E%3C/g%3E%3C/svg%3E"); } + +.binary-container_with-img img, +.binary-container_with-video video, +.binary-container_with-audio audio {width: 100%} + +.navi-title { padding-bottom: .5rem; margin: .25rem 0; } +.navi-title a {text-decoration:none; } +.navi-title__separator { margin: 0 .25rem; } +.navi-title__colon { margin-right: .5rem; } +.upload-amnt { clear: both; padding: .5rem; border-radius: .25rem; } +.upload-amnt__unattach { display: block; } +aside { clear: both; } + +.img-gallery { text-align: center; margin-top: .25rem; margin-bottom: .25rem; } +.img-gallery_many-images { border-radius: .25rem; padding: .5rem; } +.img-gallery img { max-width: 100%; max-height: 50vh; } +figure { margin: 0; } +figcaption { padding-bottom: .5rem; } + +#new-name {width:100%;} + +header { margin-bottom: .5rem; } +.header-links__entry_user { font-style:italic; } +.header-links__link { text-decoration: none; display: block; width: 100%; height: 100%; padding: .25rem; } +.hypha-tabs { padding: 0; } +.header-links__list, .hypha-tabs__flex { margin: 0; padding: 0; display: flex; flex-wrap: wrap; } +.header-links__entry, .hypha-tabs__tab { list-style-type: none; } +.hypha-tabs__tab a { text-decoration: none; } +.hypha-tabs__tab_active { font-weight: bold; } + +.rc-entry { display: grid; list-style-type: none; padding: .25rem; grid-template-columns: 1fr 1fr; } +.rc-entry__time { font-style: italic; } +.rc-entry__hash { font-style: italic; text-align: right; } +.rc-entry__links { grid-column: 1 / span 2; } +.rc-entry__author { font-style: italic; } + +.prevnext__el { display: block-inline; min-width: 40%; padding: .5rem; margin-bottom: .25rem; text-decoration: none; border-radius: .25rem; } +.prevnext__prev { float: left; } +.prevnext__next { float: right; text-align: right; } + +.page-separator { clear: both; } +.history__entries { background-color: #eee; margin: 0; padding: 0; border-radius: .25rem; } +.history__month-anchor { text-decoration: none; color: inherit; } +.history__entry { list-style-type: none; padding: .25rem; } +.history-entry { padding: .25rem; } +.history-entry__time { font-weight: bold; } +.history-entry__author { font-style: italic; } + +table { border: #ddd 1px solid; border-radius: .25rem; min-width: 4rem; } +td { padding: .25rem; } +caption { caption-side: top; font-size: small; } + +/* Color stuff */ +/* Lighter stuff #eee */ +article code, +article .codeblock, +.transclusion, +.img-gallery_many-images, +.rc-entry, +.prevnext__el, +table { background-color: #eee; } + +@media screen and (max-width: 800px) { + .hypha-tabs { background-color: white; } + .hypha-tabs__tab { box-shadow: none; } +} + +/* Other stuff */ +html { background-color: #ddd; +background-image: url("data:image/svg+xml,%3Csvg width='42' height='44' viewBox='0 0 42 44' xmlns='http://www.w3.org/2000/svg'%3E%3Cg id='Page-1' fill='none' fill-rule='evenodd'%3E%3Cg id='brick-wall' fill='%23bbbbbb' fill-opacity='0.4'%3E%3Cpath d='M0 0h42v44H0V0zm1 1h40v20H1V1zM0 23h20v20H0V23zm22 0h20v20H22V23z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E"); +} /* heropatterns.com */ +header { background-color: #bbb; } +.header-links__link { color: black; } +.header-links__link:hover { background-color: #eee; } + +main, .hypha-tabs__tab { background-color: white; } +.hypha-tabs__tab { clip-path: inset(-20px -20px 0 -20px); } +.hypha-tabs__tab a { color: black; } +.hypha-tabs__tab_active { border-bottom: 2px white solid; } + +blockquote { border-left: 4px black solid; } +.wikilink_new {color:#a55858;} +.transclusion code, .transclusion .codeblock {background-color:#ddd;} +.transclusion__link { color: black; } +.wikilink_new:visited {color:#a55858;} +.navi-title { border-bottom: #eee 1px solid; } +.upload-amnt { border: #eee 1px solid; } +td { border: #ddd 1px solid; } + +/* Dark theme! */ +@media (prefers-color-scheme: dark) { +html { background: #222; color: #ddd; } +main, article, .hypha-tabs__tab, header { background-color: #343434; color: #ddd; } + +a, .wikilink_external { color: #f1fa8c; } +a:visited, .wikilink_external:visited { color: #ffb86c; } +.wikilink_new, .wikilink_new:visited { color: #dd4444; } + +.header-links__link, .header-links__link:visited, +.prevnext__el, .prevnext__el:visited { color: #ddd; } +.header-links__link:hover { background-color: #444; } + +.hypha-tabs__tab a, .hypha-tabs__tab { color: #ddd; background-color: #232323; border: 0; } +.hypha-tabs__tab_active { background-color: #343434; } + +blockquote { border-left: 4px #ddd solid; } + +.transclusion .transclusion__link { color: #ddd; } +article code, +article .codeblock, +.transclusion, +.img-gallery_many-images, +.rc-entry, +.history__entry, +.prevnext__el, +.upload-amnt, +textarea, +table { border: 0; background-color: #444444; color: #ddd; } +.transclusion code, +.transclusion .codeblock { background-color: #454545; } +mark { background: rgba(130, 80, 30, 5); color: inherit; } +@media screen and (max-width: 800px) { + .hypha-tabs { background-color: #232323; } +} +} + +`) +//line templates/asset.qtpl:2 + qw422016.N().S(` +`) +//line templates/asset.qtpl:3 +} + +//line templates/asset.qtpl:3 +func WriteDefaultCSS(qq422016 qtio422016.Writer) { +//line templates/asset.qtpl:3 + qw422016 := qt422016.AcquireWriter(qq422016) +//line templates/asset.qtpl:3 + StreamDefaultCSS(qw422016) +//line templates/asset.qtpl:3 + qt422016.ReleaseWriter(qw422016) +//line templates/asset.qtpl:3 +} + +//line templates/asset.qtpl:3 +func DefaultCSS() string { +//line templates/asset.qtpl:3 + qb422016 := qt422016.AcquireByteBuffer() +//line templates/asset.qtpl:3 + WriteDefaultCSS(qb422016) +//line templates/asset.qtpl:3 + qs422016 := string(qb422016.B) +//line templates/asset.qtpl:3 + qt422016.ReleaseByteBuffer(qb422016) +//line templates/asset.qtpl:3 + return qs422016 +//line templates/asset.qtpl:3 +} + +// Next three are from https://remixicon.com/ + +//line templates/asset.qtpl:6 +func StreamIconHTTP(qw422016 *qt422016.Writer) { +//line templates/asset.qtpl:6 + qw422016.N().S(` +`) +//line templates/asset.qtpl:7 + qw422016.N().S(` +`) +//line templates/asset.qtpl:7 + qw422016.N().S(` +`) +//line templates/asset.qtpl:8 +} + +//line templates/asset.qtpl:8 +func WriteIconHTTP(qq422016 qtio422016.Writer) { +//line templates/asset.qtpl:8 + qw422016 := qt422016.AcquireWriter(qq422016) +//line templates/asset.qtpl:8 + StreamIconHTTP(qw422016) +//line templates/asset.qtpl:8 + qt422016.ReleaseWriter(qw422016) +//line templates/asset.qtpl:8 +} + +//line templates/asset.qtpl:8 +func IconHTTP() string { +//line templates/asset.qtpl:8 + qb422016 := qt422016.AcquireByteBuffer() +//line templates/asset.qtpl:8 + WriteIconHTTP(qb422016) +//line templates/asset.qtpl:8 + qs422016 := string(qb422016.B) +//line templates/asset.qtpl:8 + qt422016.ReleaseByteBuffer(qb422016) +//line templates/asset.qtpl:8 + return qs422016 +//line templates/asset.qtpl:8 +} + +//line templates/asset.qtpl:10 +func StreamIconGemini(qw422016 *qt422016.Writer) { +//line templates/asset.qtpl:10 + qw422016.N().S(` +`) +//line templates/asset.qtpl:11 + qw422016.N().S(` +`) +//line templates/asset.qtpl:11 + qw422016.N().S(` +`) +//line templates/asset.qtpl:12 +} + +//line templates/asset.qtpl:12 +func WriteIconGemini(qq422016 qtio422016.Writer) { +//line templates/asset.qtpl:12 + qw422016 := qt422016.AcquireWriter(qq422016) +//line templates/asset.qtpl:12 + StreamIconGemini(qw422016) +//line templates/asset.qtpl:12 + qt422016.ReleaseWriter(qw422016) +//line templates/asset.qtpl:12 +} + +//line templates/asset.qtpl:12 +func IconGemini() string { +//line templates/asset.qtpl:12 + qb422016 := qt422016.AcquireByteBuffer() +//line templates/asset.qtpl:12 + WriteIconGemini(qb422016) +//line templates/asset.qtpl:12 + qs422016 := string(qb422016.B) +//line templates/asset.qtpl:12 + qt422016.ReleaseByteBuffer(qb422016) +//line templates/asset.qtpl:12 + return qs422016 +//line templates/asset.qtpl:12 +} + +//line templates/asset.qtpl:14 +func StreamIconMailto(qw422016 *qt422016.Writer) { +//line templates/asset.qtpl:14 + qw422016.N().S(` +`) +//line templates/asset.qtpl:15 + qw422016.N().S(` +`) +//line templates/asset.qtpl:15 + qw422016.N().S(` +`) +//line templates/asset.qtpl:16 +} + +//line templates/asset.qtpl:16 +func WriteIconMailto(qq422016 qtio422016.Writer) { +//line templates/asset.qtpl:16 + qw422016 := qt422016.AcquireWriter(qq422016) +//line templates/asset.qtpl:16 + StreamIconMailto(qw422016) +//line templates/asset.qtpl:16 + qt422016.ReleaseWriter(qw422016) +//line templates/asset.qtpl:16 +} + +//line templates/asset.qtpl:16 +func IconMailto() string { +//line templates/asset.qtpl:16 + qb422016 := qt422016.AcquireByteBuffer() +//line templates/asset.qtpl:16 + WriteIconMailto(qb422016) +//line templates/asset.qtpl:16 + qs422016 := string(qb422016.B) +//line templates/asset.qtpl:16 + qt422016.ReleaseByteBuffer(qb422016) +//line templates/asset.qtpl:16 + return qs422016 +//line templates/asset.qtpl:16 +} + +// This is a modified version of https://www.svgrepo.com/svg/232085/rat + +//line templates/asset.qtpl:19 +func StreamIconGopher(qw422016 *qt422016.Writer) { +//line templates/asset.qtpl:19 + qw422016.N().S(` +`) +//line templates/asset.qtpl:20 + qw422016.N().S(` + + + +`) +//line templates/asset.qtpl:20 + qw422016.N().S(` +`) +//line templates/asset.qtpl:21 +} + +//line templates/asset.qtpl:21 +func WriteIconGopher(qq422016 qtio422016.Writer) { +//line templates/asset.qtpl:21 + qw422016 := qt422016.AcquireWriter(qq422016) +//line templates/asset.qtpl:21 + StreamIconGopher(qw422016) +//line templates/asset.qtpl:21 + qt422016.ReleaseWriter(qw422016) +//line templates/asset.qtpl:21 +} + +//line templates/asset.qtpl:21 +func IconGopher() string { +//line templates/asset.qtpl:21 + qb422016 := qt422016.AcquireByteBuffer() +//line templates/asset.qtpl:21 + WriteIconGopher(qb422016) +//line templates/asset.qtpl:21 + qs422016 := string(qb422016.B) +//line templates/asset.qtpl:21 + qt422016.ReleaseByteBuffer(qb422016) +//line templates/asset.qtpl:21 + return qs422016 +//line templates/asset.qtpl:21 +} diff --git a/templates/auth.qtpl b/templates/auth.qtpl index bbb7d25..32ab3d8 100644 --- a/templates/auth.qtpl +++ b/templates/auth.qtpl @@ -6,7 +6,7 @@ {% if user.AuthUsed %}

          Login

          -

          Use the data you were given by the administrator.

          +

          Use the data you were given by an administrator.

          Username @@ -15,7 +15,7 @@ Password
          -

          By submitting this form you give this wiki a permission to store cookies in your browser. It lets the engine associate your edits with you.

          +

          By submitting this form you give this wiki a permission to store cookies in your browser. It lets the engine associate your edits with you. You will stay logged in until you log out.

          Cancel
          diff --git a/templates/auth.qtpl.go b/templates/auth.qtpl.go index 27d13fe..ed88d8d 100644 --- a/templates/auth.qtpl.go +++ b/templates/auth.qtpl.go @@ -33,7 +33,7 @@ func StreamLoginHTML(qw422016 *qt422016.Writer) { qw422016.N().S(`

          Login

          -

          Use the data you were given by the administrator.

          +

          Use the data you were given by an administrator.

          Username @@ -42,7 +42,7 @@ func StreamLoginHTML(qw422016 *qt422016.Writer) { Password
          -

          By submitting this form you give this wiki a permission to store cookies in your browser. It lets the engine associate your edits with you.

          +

          By submitting this form you give this wiki a permission to store cookies in your browser. It lets the engine associate your edits with you. You will stay logged in until you log out.

          Cancel
          diff --git a/templates/common.qtpl b/templates/common.qtpl index 321da84..b5e06a1 100644 --- a/templates/common.qtpl +++ b/templates/common.qtpl @@ -20,32 +20,40 @@ var navEntries = []navEntry{ %} {% func navHTML(rq *http.Request, hyphaName, navType string, revisionHash ...string) %} -{% code - u := user.FromRequest(rq).OrAnon() +{% code + u := user.FromRequest(rq) %} - +

          Subscribe via RSS, Atom or JSON feed.

          + {% comment %} Here I am, willing to add some accesibility using ARIA. Turns out, role="feed" is not supported in any screen reader as of September diff --git a/templates/recent_changes.qtpl.go b/templates/recent_changes.qtpl.go index 8268ef1..329ccc5 100644 --- a/templates/recent_changes.qtpl.go +++ b/templates/recent_changes.qtpl.go @@ -77,81 +77,83 @@ func StreamRecentChangesHTML(qw422016 *qt422016.Writer, changes []string, n int) recent changes +

          Subscribe via RSS, Atom or JSON feed.

          + `) -//line templates/recent_changes.qtpl:26 +//line templates/recent_changes.qtpl:28 qw422016.N().S(`
          `) -//line templates/recent_changes.qtpl:29 +//line templates/recent_changes.qtpl:31 if len(changes) == 0 { -//line templates/recent_changes.qtpl:29 +//line templates/recent_changes.qtpl:31 qw422016.N().S(`

          Could not find any recent changes.

          `) -//line templates/recent_changes.qtpl:31 +//line templates/recent_changes.qtpl:33 } else { -//line templates/recent_changes.qtpl:31 +//line templates/recent_changes.qtpl:33 qw422016.N().S(` `) -//line templates/recent_changes.qtpl:32 +//line templates/recent_changes.qtpl:34 for i, entry := range changes { -//line templates/recent_changes.qtpl:32 +//line templates/recent_changes.qtpl:34 qw422016.N().S(`
            `) -//line templates/recent_changes.qtpl:35 +//line templates/recent_changes.qtpl:37 qw422016.N().S(entry) -//line templates/recent_changes.qtpl:35 +//line templates/recent_changes.qtpl:37 qw422016.N().S(`
          `) -//line templates/recent_changes.qtpl:37 +//line templates/recent_changes.qtpl:39 } -//line templates/recent_changes.qtpl:37 +//line templates/recent_changes.qtpl:39 qw422016.N().S(` `) -//line templates/recent_changes.qtpl:38 +//line templates/recent_changes.qtpl:40 } -//line templates/recent_changes.qtpl:38 +//line templates/recent_changes.qtpl:40 qw422016.N().S(`
          `) -//line templates/recent_changes.qtpl:41 +//line templates/recent_changes.qtpl:43 } -//line templates/recent_changes.qtpl:41 +//line templates/recent_changes.qtpl:43 func WriteRecentChangesHTML(qq422016 qtio422016.Writer, changes []string, n int) { -//line templates/recent_changes.qtpl:41 +//line templates/recent_changes.qtpl:43 qw422016 := qt422016.AcquireWriter(qq422016) -//line templates/recent_changes.qtpl:41 +//line templates/recent_changes.qtpl:43 StreamRecentChangesHTML(qw422016, changes, n) -//line templates/recent_changes.qtpl:41 +//line templates/recent_changes.qtpl:43 qt422016.ReleaseWriter(qw422016) -//line templates/recent_changes.qtpl:41 +//line templates/recent_changes.qtpl:43 } -//line templates/recent_changes.qtpl:41 +//line templates/recent_changes.qtpl:43 func RecentChangesHTML(changes []string, n int) string { -//line templates/recent_changes.qtpl:41 +//line templates/recent_changes.qtpl:43 qb422016 := qt422016.AcquireByteBuffer() -//line templates/recent_changes.qtpl:41 +//line templates/recent_changes.qtpl:43 WriteRecentChangesHTML(qb422016, changes, n) -//line templates/recent_changes.qtpl:41 +//line templates/recent_changes.qtpl:43 qs422016 := string(qb422016.B) -//line templates/recent_changes.qtpl:41 +//line templates/recent_changes.qtpl:43 qt422016.ReleaseByteBuffer(qb422016) -//line templates/recent_changes.qtpl:41 +//line templates/recent_changes.qtpl:43 return qs422016 -//line templates/recent_changes.qtpl:41 +//line templates/recent_changes.qtpl:43 } diff --git a/templates/rename.qtpl b/templates/rename.qtpl index b68d912..aee28ce 100644 --- a/templates/rename.qtpl +++ b/templates/rename.qtpl @@ -1,8 +1,8 @@ {% import "net/http" %} This dialog is to be shown to a user when they try to rename a hypha. {% func RenameAskHTML(rq *http.Request, hyphaName string, isOld bool) %} -
          {%= navHTML(rq, hyphaName, "rename-ask") %} +
          {%- if isOld -%}

          Rename {%s hyphaName %}

          diff --git a/templates/rename.qtpl.go b/templates/rename.qtpl.go index 34f4ed9..2347bcd 100644 --- a/templates/rename.qtpl.go +++ b/templates/rename.qtpl.go @@ -26,12 +26,12 @@ var ( func StreamRenameAskHTML(qw422016 *qt422016.Writer, rq *http.Request, hyphaName string, isOld bool) { //line templates/rename.qtpl:3 qw422016.N().S(` -
          `) -//line templates/rename.qtpl:5 +//line templates/rename.qtpl:4 streamnavHTML(qw422016, rq, hyphaName, "rename-ask") -//line templates/rename.qtpl:5 +//line templates/rename.qtpl:4 qw422016.N().S(` +
          `) //line templates/rename.qtpl:6 if isOld { diff --git a/templates/unattach.qtpl b/templates/unattach.qtpl new file mode 100644 index 0000000..e4f0ae2 --- /dev/null +++ b/templates/unattach.qtpl @@ -0,0 +1,24 @@ +{% import "net/http" %} +{% func UnattachAskHTML(rq *http.Request, hyphaName string, isOld bool) %} +
          +{%= navHTML(rq, hyphaName, "unattach-ask") %} +{%- if isOld -%} +
          +

          Unattach {%s hyphaName %}?

          +

          Do you really want to unattach hypha {%s hyphaName %}?

          +

          Confirm

          +

          Cancel

          +
          +{%- else -%} + {%= cannotUnattachDueToNonExistence(hyphaName) %} +{%- endif -%} +
          +{% endfunc %} + +{% func cannotUnattachDueToNonExistence(hyphaName string) %} +
          +

          Cannot unattach {%s hyphaName %}

          +

          This hypha does not exist.

          +

          Go back

          +
          +{% endfunc %} diff --git a/templates/unattach.qtpl.go b/templates/unattach.qtpl.go new file mode 100644 index 0000000..c00bc80 --- /dev/null +++ b/templates/unattach.qtpl.go @@ -0,0 +1,148 @@ +// Code generated by qtc from "unattach.qtpl". DO NOT EDIT. +// See https://github.com/valyala/quicktemplate for details. + +//line templates/unattach.qtpl:1 +package templates + +//line templates/unattach.qtpl:1 +import "net/http" + +//line templates/unattach.qtpl:2 +import ( + qtio422016 "io" + + qt422016 "github.com/valyala/quicktemplate" +) + +//line templates/unattach.qtpl:2 +var ( + _ = qtio422016.Copy + _ = qt422016.AcquireByteBuffer +) + +//line templates/unattach.qtpl:2 +func StreamUnattachAskHTML(qw422016 *qt422016.Writer, rq *http.Request, hyphaName string, isOld bool) { +//line templates/unattach.qtpl:2 + qw422016.N().S(` +
          +`) +//line templates/unattach.qtpl:4 + streamnavHTML(qw422016, rq, hyphaName, "unattach-ask") +//line templates/unattach.qtpl:4 + qw422016.N().S(` +`) +//line templates/unattach.qtpl:5 + if isOld { +//line templates/unattach.qtpl:5 + qw422016.N().S(`
          +

          Unattach `) +//line templates/unattach.qtpl:7 + qw422016.E().S(hyphaName) +//line templates/unattach.qtpl:7 + qw422016.N().S(`?

          +

          Do you really want to unattach hypha `) +//line templates/unattach.qtpl:8 + qw422016.E().S(hyphaName) +//line templates/unattach.qtpl:8 + qw422016.N().S(`?

          +

          Confirm

          +

          Cancel

          +
          +`) +//line templates/unattach.qtpl:12 + } else { +//line templates/unattach.qtpl:12 + qw422016.N().S(` `) +//line templates/unattach.qtpl:13 + streamcannotUnattachDueToNonExistence(qw422016, hyphaName) +//line templates/unattach.qtpl:13 + qw422016.N().S(` +`) +//line templates/unattach.qtpl:14 + } +//line templates/unattach.qtpl:14 + qw422016.N().S(`
          +`) +//line templates/unattach.qtpl:16 +} + +//line templates/unattach.qtpl:16 +func WriteUnattachAskHTML(qq422016 qtio422016.Writer, rq *http.Request, hyphaName string, isOld bool) { +//line templates/unattach.qtpl:16 + qw422016 := qt422016.AcquireWriter(qq422016) +//line templates/unattach.qtpl:16 + StreamUnattachAskHTML(qw422016, rq, hyphaName, isOld) +//line templates/unattach.qtpl:16 + qt422016.ReleaseWriter(qw422016) +//line templates/unattach.qtpl:16 +} + +//line templates/unattach.qtpl:16 +func UnattachAskHTML(rq *http.Request, hyphaName string, isOld bool) string { +//line templates/unattach.qtpl:16 + qb422016 := qt422016.AcquireByteBuffer() +//line templates/unattach.qtpl:16 + WriteUnattachAskHTML(qb422016, rq, hyphaName, isOld) +//line templates/unattach.qtpl:16 + qs422016 := string(qb422016.B) +//line templates/unattach.qtpl:16 + qt422016.ReleaseByteBuffer(qb422016) +//line templates/unattach.qtpl:16 + return qs422016 +//line templates/unattach.qtpl:16 +} + +//line templates/unattach.qtpl:18 +func streamcannotUnattachDueToNonExistence(qw422016 *qt422016.Writer, hyphaName string) { +//line templates/unattach.qtpl:18 + qw422016.N().S(` +
          +

          Cannot unattach `) +//line templates/unattach.qtpl:20 + qw422016.E().S(hyphaName) +//line templates/unattach.qtpl:20 + qw422016.N().S(`

          +

          This hypha does not exist.

          +

          Go back

          +
          +`) +//line templates/unattach.qtpl:24 +} + +//line templates/unattach.qtpl:24 +func writecannotUnattachDueToNonExistence(qq422016 qtio422016.Writer, hyphaName string) { +//line templates/unattach.qtpl:24 + qw422016 := qt422016.AcquireWriter(qq422016) +//line templates/unattach.qtpl:24 + streamcannotUnattachDueToNonExistence(qw422016, hyphaName) +//line templates/unattach.qtpl:24 + qt422016.ReleaseWriter(qw422016) +//line templates/unattach.qtpl:24 +} + +//line templates/unattach.qtpl:24 +func cannotUnattachDueToNonExistence(hyphaName string) string { +//line templates/unattach.qtpl:24 + qb422016 := qt422016.AcquireByteBuffer() +//line templates/unattach.qtpl:24 + writecannotUnattachDueToNonExistence(qb422016, hyphaName) +//line templates/unattach.qtpl:24 + qs422016 := string(qb422016.B) +//line templates/unattach.qtpl:24 + qt422016.ReleaseByteBuffer(qb422016) +//line templates/unattach.qtpl:24 + return qs422016 +//line templates/unattach.qtpl:24 +} diff --git a/tree/tree.go b/tree/tree.go index 1655664..476b540 100644 --- a/tree/tree.go +++ b/tree/tree.go @@ -5,22 +5,27 @@ import ( "path" "sort" "strings" + + "github.com/bouncepaw/mycorrhiza/util" ) // If Name == "", the tree is empty. type tree struct { name string + exists bool + prevSibling string + nextSibling string siblings []string descendants []*tree root bool hyphaIterator func(func(string)) } -// TreeAsHtml generates a tree for `hyphaName`. `hyphaStorage` has this type because package `tree` has no access to `HyphaData` data type. One day it shall have it, I guess. -func TreeAsHtml(hyphaName string, hyphaIterator func(func(string))) string { +// Tree generates a tree for `hyphaName` as html and returns next and previous hyphae if any. +func Tree(hyphaName string, hyphaIterator func(func(string))) (html, prev, next string) { t := &tree{name: hyphaName, root: true, hyphaIterator: hyphaIterator} t.fill() - return t.asHtml() + return t.asHtml(), util.BeautifulName(t.prevSibling), util.BeautifulName(t.nextSibling) } // subtree adds a descendant tree to `t` and returns that tree. @@ -34,11 +39,22 @@ func (t *tree) fork(descendantName string) *tree { return subt } +// Compare current prev next hyphae and decide if any of them should be set to `name2`. +func (t *tree) prevNextDetermine(name2 string) { + if name2 < t.name && (name2 > t.prevSibling || t.prevSibling == "") { + t.prevSibling = name2 + } else if name2 > t.name && (name2 < t.nextSibling || t.nextSibling == "") { + t.nextSibling = name2 + } +} + // Compares names and does something with them, may generate a subtree. func (t *tree) compareNamesAndAppend(name2 string) { switch { case t.name == name2: + t.exists = true case t.root && path.Dir(t.name) == path.Dir(name2): + t.prevNextDetermine(name2) t.siblings = append(t.siblings, name2) case t.name == path.Dir(name2): t.fork(name2).fill() diff --git a/user/fs.go b/user/files.go similarity index 55% rename from user/fs.go rename to user/files.go index f47c423..3235497 100644 --- a/user/fs.go +++ b/user/files.go @@ -10,53 +10,55 @@ import ( "github.com/bouncepaw/mycorrhiza/util" ) -func PopulateFixedUserStorage() { +// ReadUsersFromFilesystem reads all user information from filesystem and stores it internally. Call it during initialization. +func ReadUsersFromFilesystem() { + rememberUsers(usersFromFixedCredentials()) + readTokensToUsers() +} + +func usersFromFixedCredentials() []*User { + var users []*User contents, err := ioutil.ReadFile(util.FixedCredentialsPath) if err != nil { log.Fatal(err) } - err = json.Unmarshal(contents, &UserStorage.Users) + err = json.Unmarshal(contents, &users) if err != nil { log.Fatal(err) } - for _, user := range UserStorage.Users { - user.Group = groupFromString(user.GroupString) - } - log.Println("Found", len(UserStorage.Users), "fixed users") + log.Println("Found", len(users), "fixed users") + return users +} - contents, err = ioutil.ReadFile(tokenStoragePath()) +func rememberUsers(uu []*User) { + // uu is used to not shadow the `users` in `users.go`. + for _, user := range uu { + users.Store(user.Name, user) + } +} + +func readTokensToUsers() { + contents, err := ioutil.ReadFile(tokenStoragePath()) if os.IsNotExist(err) { return } if err != nil { log.Fatal(err) } + var tmp map[string]string err = json.Unmarshal(contents, &tmp) if err != nil { log.Fatal(err) } + for token, username := range tmp { - user := UserStorage.userByName(username) - UserStorage.Tokens[token] = user + commenceSession(username, token) } log.Println("Found", len(tmp), "active sessions") } -func dumpTokens() { - tmp := make(map[string]string) - for token, user := range UserStorage.Tokens { - tmp[token] = user.Name - } - blob, err := json.Marshal(tmp) - if err != nil { - log.Println(err) - } else { - ioutil.WriteFile(tokenStoragePath(), blob, 0644) - } -} - -// Return path to tokens.json. +// Return path to tokens.json. Creates folders if needed. func tokenStoragePath() string { dir, err := xdg.DataFile("mycorrhiza/tokens.json") if err != nil { @@ -67,3 +69,21 @@ func tokenStoragePath() string { } return dir } + +func dumpTokens() { + tmp := make(map[string]string) + + tokens.Range(func(k, v interface{}) bool { + token := k.(string) + username := v.(string) + tmp[token] = username + return true + }) + + blob, err := json.Marshal(tmp) + if err != nil { + log.Println(err) + } else { + ioutil.WriteFile(tokenStoragePath(), blob, 0644) + } +} diff --git a/user/group.go b/user/group.go deleted file mode 100644 index 6a9a296..0000000 --- a/user/group.go +++ /dev/null @@ -1,70 +0,0 @@ -package user - -import ( - "log" - "net/http" -) - -func groupFromString(s string) UserGroup { - switch s { - case "admin": - return UserAdmin - case "moderator": - return UserModerator - case "trusted": - return UserTrusted - case "editor": - return UserEditor - default: - log.Fatal("Unknown user group", s) - return UserAnon - } -} - -// UserGroup represents a group that a user is part of. -type UserGroup int - -const ( - // UserAnon is the default user group which all unauthorized visitors have. - UserAnon UserGroup = iota - // UserEditor is a user who can edit and upload stuff. - UserEditor - // UserTrusted is a trusted editor who can also rename stuff. - UserTrusted - // UserModerator is a moderator who can also delete stuff. - UserModerator - // UserAdmin can do everything. - UserAdmin -) - -var minimalRights = map[string]UserGroup{ - "edit": UserEditor, - "upload-binary": UserEditor, - "upload-text": UserEditor, - "rename-ask": UserTrusted, - "rename-confirm": UserTrusted, - "delete-ask": UserModerator, - "delete-confirm": UserModerator, - "reindex": UserAdmin, -} - -func (ug UserGroup) CanAccessRoute(route string) bool { - if !AuthUsed { - return true - } - if minimalRight, ok := minimalRights[route]; ok { - if ug >= minimalRight { - return true - } - return false - } - return true -} - -func CanProceed(rq *http.Request, route string) bool { - return FromRequest(rq).OrAnon().CanProceed(route) -} - -func (u *User) CanProceed(route string) bool { - return u.Group.CanAccessRoute(route) -} diff --git a/user/net.go b/user/net.go new file mode 100644 index 0000000..caec383 --- /dev/null +++ b/user/net.go @@ -0,0 +1,75 @@ +package user + +import ( + "log" + "net/http" + "time" + + "github.com/bouncepaw/mycorrhiza/util" +) + +// CanProceed returns `true` if the user in `rq` has enough rights to access `route`. +func CanProceed(rq *http.Request, route string) bool { + return FromRequest(rq).CanProceed(route) +} + +// FromRequest returns user from `rq`. If there is no user, an anon user is returned instead. +func FromRequest(rq *http.Request) *User { + cookie, err := rq.Cookie("mycorrhiza_token") + if err != nil { + return EmptyUser() + } + return userByToken(cookie.Value) +} + +// LogoutFromRequest logs the user in `rq` out and rewrites the cookie in `w`. +func LogoutFromRequest(w http.ResponseWriter, rq *http.Request) { + cookieFromUser, err := rq.Cookie("mycorrhiza_token") + if err == nil { + http.SetCookie(w, cookie("token", "", time.Unix(0, 0))) + terminateSession(cookieFromUser.Value) + } +} + +// LoginDataHTTP logs such user in and returns string representation of an error if there is any. +func LoginDataHTTP(w http.ResponseWriter, rq *http.Request, username, password string) string { + w.Header().Set("Content-Type", "text/html;charset=utf-8") + if !HasUsername(username) { + w.WriteHeader(http.StatusBadRequest) + log.Println("Unknown username", username, "was entered") + return "unknown username" + } + if !CredentialsOK(username, password) { + w.WriteHeader(http.StatusBadRequest) + log.Println("A wrong password was entered for username", username) + return "wrong password" + } + token, err := AddSession(username) + if err != nil { + log.Println(err) + w.WriteHeader(http.StatusBadRequest) + return err.Error() + } + http.SetCookie(w, cookie("token", token, time.Now().Add(365*24*time.Hour))) + return "" +} + +// AddSession saves a session for `username` and returns a token to use. +func AddSession(username string) (string, error) { + token, err := util.RandomString(16) + if err == nil { + commenceSession(username, token) + log.Println("New token for", username, "is", token) + } + return token, err +} + +// A handy cookie constructor +func cookie(name_suffix, val string, t time.Time) *http.Cookie { + return &http.Cookie{ + Name: "mycorrhiza_" + name_suffix, + Value: val, + Expires: t, + Path: "/", + } +} diff --git a/user/user.go b/user/user.go index 7169760..efc0028 100644 --- a/user/user.go +++ b/user/user.go @@ -1,138 +1,69 @@ package user import ( - "log" - "net/http" - "time" - - "github.com/bouncepaw/mycorrhiza/util" + "sync" ) -func (u *User) OrAnon() *User { - if u == nil { - return &User{} - } - return u -} - -func LogoutFromRequest(w http.ResponseWriter, rq *http.Request) { - cookieFromUser, err := rq.Cookie("mycorrhiza_token") - if err == nil { - http.SetCookie(w, cookie("token", "", time.Unix(0, 0))) - terminateSession(cookieFromUser.Value) - } -} - -func (us *FixedUserStorage) userByToken(token string) *User { - if user, ok := us.Tokens[token]; ok { - return user - } - return nil -} - -func (us *FixedUserStorage) userByName(username string) *User { - for _, user := range us.Users { - if user.Name == username { - return user - } - } - return nil -} - -func FromRequest(rq *http.Request) *User { - cookie, err := rq.Cookie("mycorrhiza_token") - if err != nil { - return nil - } - return UserStorage.userByToken(cookie.Value).OrAnon() -} - -func LoginDataHTTP(w http.ResponseWriter, rq *http.Request, username, password string) string { - w.Header().Set("Content-Type", "text/html;charset=utf-8") - if !HasUsername(username) { - w.WriteHeader(http.StatusBadRequest) - log.Println("Unknown username", username, "was entered") - return "unknown username" - } - if !CredentialsOK(username, password) { - w.WriteHeader(http.StatusBadRequest) - log.Println("A wrong password was entered for username", username) - return "wrong password" - } - token, err := AddSession(username) - if err != nil { - log.Println(err) - w.WriteHeader(http.StatusBadRequest) - return err.Error() - } - http.SetCookie(w, cookie("token", token, time.Now().Add(14*24*time.Hour))) - return "" -} - -// AddSession saves a session for `username` and returns a token to use. -func AddSession(username string) (string, error) { - token, err := util.RandomString(16) - if err == nil { - for _, user := range UserStorage.Users { - if user.Name == username { - UserStorage.Tokens[token] = user - go dumpTokens() - } - } - log.Println("New token for", username, "is", token) - } - return token, err -} - -func terminateSession(token string) { - delete(UserStorage.Tokens, token) - go dumpTokens() -} - -func HasUsername(username string) bool { - for _, user := range UserStorage.Users { - if user.Name == username { - return true - } - } - return false -} - -func CredentialsOK(username, password string) bool { - for _, user := range UserStorage.Users { - if user.Name == username && user.Password == password { - return true - } - } - return false -} - -type FixedUserStorage struct { - Users []*User - Tokens map[string]*User -} - -var UserStorage = FixedUserStorage{Tokens: make(map[string]*User)} - -// AuthUsed shows if a method of authentication is used. You should set it by yourself. -var AuthUsed bool - // User is a user. type User struct { // Name is a username. It must follow hypha naming rules. - Name string `json:"name"` - // Group the user is part of. - Group UserGroup `json:"-"` - GroupString string `json:"group"` - Password string `json:"password"` + Name string `json:"name"` + Group string `json:"group"` + Password string `json:"password"` + sync.RWMutex } -// A handy cookie constructor -func cookie(name_suffix, val string, t time.Time) *http.Cookie { - return &http.Cookie{ - Name: "mycorrhiza_" + name_suffix, - Value: val, - Expires: t, - Path: "/", +// Route — Right (more is more right) +var minimalRights = map[string]int{ + "edit": 1, + "upload-binary": 1, + "upload-text": 1, + "rename-ask": 2, + "rename-confirm": 2, + "unattach-ask": 2, + "unattach-confirm": 2, + "update-header-links": 3, + "delete-ask": 3, + "delete-confirm": 3, + "reindex": 4, +} + +// Group — Right +var groupRight = map[string]int{ + "anon": 0, + "editor": 1, + "trusted": 2, + "moderator": 3, + "admin": 4, +} + +func EmptyUser() *User { + return &User{ + Name: "anon", + Group: "anon", + Password: "", } } + +func (user *User) CanProceed(route string) bool { + if !AuthUsed { + return true + } + + user.RLock() + defer user.RUnlock() + + right, _ := groupRight[user.Group] + minimalRight, _ := minimalRights[route] + if right >= minimalRight { + return true + } + return false +} + +func (user *User) isCorrectPassword(password string) bool { + user.RLock() + defer user.RUnlock() + + return password == user.Password +} diff --git a/user/users.go b/user/users.go new file mode 100644 index 0000000..aa5bb4b --- /dev/null +++ b/user/users.go @@ -0,0 +1,66 @@ +package user + +import ( + "sync" +) + +var AuthUsed bool +var users sync.Map +var tokens sync.Map + +func ListUsersWithGroup(group string) []string { + usersWithTheGroup := []string{} + users.Range(func(_, v interface{}) bool { + userobj := v.(*User) + + if userobj.Group == group { + usersWithTheGroup = append(usersWithTheGroup, userobj.Name) + } + return true + }) + return usersWithTheGroup +} + +func Count() int { + i := 0 + users.Range(func(k, v interface{}) bool { + i++ + return true + }) + return i +} + +func HasUsername(username string) bool { + _, has := users.Load(username) + return has +} + +func CredentialsOK(username, password string) bool { + return userByName(username).isCorrectPassword(password) +} + +func userByToken(token string) *User { + if usernameUntyped, ok := tokens.Load(token); ok { + username := usernameUntyped.(string) + return userByName(username) + } + return EmptyUser() +} + +func userByName(username string) *User { + if userUntyped, ok := users.Load(username); ok { + user := userUntyped.(*User) + return user + } + return EmptyUser() +} + +func commenceSession(username, token string) { + tokens.Store(token, username) + go dumpTokens() +} + +func terminateSession(token string) { + tokens.Delete(token) + go dumpTokens() +} diff --git a/util/header_links.go b/util/header_links.go new file mode 100644 index 0000000..c574768 --- /dev/null +++ b/util/header_links.go @@ -0,0 +1,35 @@ +package util + +import ( + "strings" +) + +func SetDefaultHeaderLinks() { + HeaderLinks = []HeaderLink{ + {"/", SiteName}, + {"/recent-changes", "Recent changes"}, + {"/list", "All hyphae"}, + {"/random", "Random"}, + } +} + +// rocketlinkλ is markup.Rocketlink. You have to pass it like that to avoid cyclical dependency. +func ParseHeaderLinks(text string, rocketlinkλ func(string, string) (string, string, string)) { + HeaderLinks = []HeaderLink{} + for _, line := range strings.Split(text, "\n") { + if strings.HasPrefix(line, "=>") { + href, text, _ := rocketlinkλ(line, HeaderLinksHypha) + HeaderLinks = append(HeaderLinks, HeaderLink{ + Href: href, + Display: text, + }) + } + } +} + +type HeaderLink struct { + Href string + Display string +} + +var HeaderLinks []HeaderLink diff --git a/util/util.go b/util/util.go index 745bf96..c977992 100644 --- a/util/util.go +++ b/util/util.go @@ -8,11 +8,14 @@ import ( ) var ( + URL string ServerPort string HomePage string - SiteTitle string + SiteNavIcon string + SiteName string WikiDir string - UserTree string + UserHypha string + HeaderLinksHypha string AuthMethod string FixedCredentialsPath string ) @@ -54,3 +57,11 @@ func RandomString(n int) (string, error) { } return hex.EncodeToString(bytes), nil } + +// Strip hypha name from all ancestor names, replace _ with spaces, title case +func BeautifulName(uglyName string) string { + if uglyName == "" { + return uglyName + } + return strings.Title(strings.ReplaceAll(uglyName, "_", " ")) +}