diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d1a7fc2 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: + - package-ecosystem: "gomod" # See documentation for possible values + directory: "/" # Location of package manifests + open-pull-requests-limit: 50 + schedule: + interval: "monthly" diff --git a/.gitignore b/.gitignore index 847e8f0..f33ebb0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +node_modules *~ *.swp *.swo diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index bd71237..0000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "static/js/mathjax"] - path = static/js/mathjax - url = https://github.com/mathjax/MathJax.git diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index efe343a..30ec4bb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,26 +1,99 @@ # Contributing to WriteFreely -Welcome! We're glad you're interested in contributing to the WriteFreely project. +Welcome! We're glad you're interested in contributing to WriteFreely. -To start, we'd suggest checking out [our Phabricator board](https://phabricator.write.as/tag/write_freely/) to see where the project is at and where it's going. You can also [join the WriteFreely forums](https://discuss.write.as/c/writefreely) to start talking about what you'd like to do or see. +For **questions**, **help**, **feature requests**, and **general discussion**, please use [our forum](https://discuss.write.as). -## Asking Questions +For **bug reports**, please [open a GitHub issue](https://github.com/writefreely/writefreely/issues/new). See our guide on [submitting bug reports](https://writefreely.org/contribute#bugs). -The best place to get answers to your questions is on [our forums](https://discuss.write.as/c/writefreely). You can quickly log in using your GitHub account and ask the community about anything. We're also there to answer your questions and discuss potential changes or features. +## Getting Started -## Submitting Bugs +There are many ways to contribute to WriteFreely, from code to documentation, to translations, to help in the community! -Please use the [GitHub issue tracker](https://github.com/writeas/writefreely/issues/new) to report any bugs you encounter. We're very responsive there and try to keep open issues to a minimum, so you can help by: +See our [Contributing Guide](https://writefreely.org/contribute) on WriteFreely.org for ways to contribute without writing code. Otherwise, please read on. -* **Only reporting bugs in the issue tracker** -* Providing as much information as possible to replicate the issue, including server logs around the incident -* Including the `[app]` section of your configuration, if related -* Breaking issues into smaller pieces if they're larger or have many parts +## Working on WriteFreely -## Contributing code +First, you'll want to clone the WriteFreely repo, install development dependencies, and build the application from source. Learn how to do this in our [Development Setup](https://writefreely.org/docs/latest/developer/setup) guide. -We gladly welcome development help, regardless of coding experience. We can also use help [translating the app](https://poeditor.com/join/project/TIZ6HFRFdE) and documenting it! +### Starting development -**Before writing or submitting any code**, please sign our [contributor's agreement](https://phabricator.write.as/L1) so we can accept your contributions. It is substantially similar to the _Apache Individual Contributor License Agreement_. If you'd like to know about the rationale behind this requirement, you can [read more about that here](https://phabricator.write.as/w/writefreely/cla/). +Next, [join our forum](https://discuss.write.as) so you can discuss development with the team. Then take a look at [our roadmap on Phabricator](https://phabricator.write.as/tag/write_freely/) to see where the project is today and where it's headed. -Once you've done that, please feel free to [submit a pull request](https://github.com/writeas/writefreely/pulls) for any small improvements. For larger projects, please [join our development discussions](https://discuss.write.as/c/writefreely) or [get in touch](https://write.as/contact) so we can talk about what you'd like to work on. +When you find something you want to work on, start a new topic on the forum or jump into an existing discussion, if there is one. The team will respond and continue the conversation there. + +Lastly, **before submitting any code**, please sign our [contributor's agreement](https://phabricator.write.as/L1) so we can accept your contributions. It is substantially similar to the _Apache Individual Contributor License Agreement_. If you'd like to know about the rationale behind this requirement, you can [read more about that here](https://phabricator.write.as/w/writefreely/cla/). + +### Branching + +All stable work lives on the `master` branch. We merge into it only when creating a release. Releases are tagged using semantic versioning. + +While developing, we primarily work from the `develop` branch, creating _feature branches_ off of it for new features and fixes. When starting a new feature or fix, you should also create a new branch off of `develop`. + +#### Branch naming + +For fixes and modifications to existing behavior, branch names should follow a similar pattern to commit messages (see below), such as `fix-post-rendering` or `update-documentation`. You can optionally append a task number, e.g. `fix-post-rendering-T000`. + +For new features, branches can be named after the new feature, e.g. `activitypub-mentions` or `import-zip`. + +#### Pull request scope + +The scope of work on each branch should be as small as possible -- one complete feature, one complete change, or one complete fix. This makes it easier for us to review and accept. + +### Writing code + +We value reliable, readable, and maintainable code over all else in our work. To help you write that kind of code, we offer a few guiding principles, as well as a few concrete guidelines. + +#### Guiding principles + +* Write code for other humans, not computers. +* The less complexity, the better. The more someone can understand code just by looking at it, the better. +* Functionality, readability, and maintainability over senseless elegance. +* Only abstract when necessary. +* Keep an eye to the future, but don't pre-optimize at the expense of today's simplicity. + +#### Code guidelines + +* Format all Go code with `go fmt` before committing (**important!**) +* Follow whitespace conventions established within the project (tabs vs. spaces) +* Add comments to exported Go functions and variables +* Follow Go naming conventions, like using [`mixedCaps`](https://golang.org/doc/effective_go.html#mixed-caps) +* Avoid new dependencies unless absolutely necessary + +### Commit messages + +We highly value commit messages that follow established form within the project. Generally speaking, we follow the practices [outlined](https://git-scm.com/book/en/v2/Distributed-Git-Contributing-to-a-Project#_commit_guidelines) in the Pro Git Book. A good commit message will look like the following: + +* **Line 1**: A short summary written in the present imperative tense. For example: + * ✔️ **Good**: "Fix post rendering bug" + * ❌ No: ~~"Fixes post rendering bug"~~ + * ❌ No: ~~"Fixing post rendering bug"~~ + * ❌ No: ~~"Fixed post rendering bug"~~ + * ❌ No: ~~"Post rendering bug is fixed now"~~ +* **Line 2**: _[left blank]_ +* **Line 3**: An added description of what changed, any rationale, etc. -- if necessary +* **Last line**: A mention of any applicable task or issue + * For Phabricator tasks: `Ref T000` or `Closes T000` + * For GitHub issues: `Ref #000` or `Fixes #000` + +#### Good examples + +When in doubt, look to our existing git history for examples of good commit messages. Here are a few: + +* [Rename Suspend status to Silence](https://github.com/writefreely/writefreely/commit/7e014ca65958750ab703e317b1ce8cfc4aad2d6e) +* [Show 404 when remote user not found](https://github.com/writefreely/writefreely/commit/867eb53b3596bd7b3f2be3c53a3faf857f4cd36d) +* [Fix post deletion on Pleroma](https://github.com/writefreely/writefreely/commit/fe82cbb96e3d5c57cfde0db76c28c4ea6dabfe50) + +### Submitting pull requests + +Like our GitHub issues, we aim to keep our number of open pull requests to a minimum. You can follow a few guidelines to ensure changes are merged quickly. + +First, make sure your changes follow the established practices and good form outlined in this guide. This is crucial to our project, and ignoring our practices can delay otherwise important fixes. + +Beyond that, we prioritize pull requests in this order: + +1. Fixes to open GitHub issues +2. Superficial changes and improvements that don't adversely impact users +3. New features and changes that have been discussed before with the team + +Any pull requests that haven't previously been discussed with the team may be extensively delayed or closed, especially if they require a wider consideration before integrating into the project. When in doubt, please reach out [on the forum](https://discuss.write.as) before submitting a pull request. \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 2ae05a6..1021ec4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,28 +1,30 @@ # Build image -FROM golang:1.12-alpine as build +FROM golang:1.14-alpine as build -RUN apk add --update nodejs nodejs-npm make g++ git sqlite-dev +RUN apk add --update nodejs nodejs-npm make g++ git RUN npm install -g less less-plugin-clean-css -RUN go get -u github.com/jteeuwen/go-bindata/... +RUN go get -u github.com/go-bindata/go-bindata/... + +RUN mkdir -p /go/src/github.com/writefreely/writefreely +WORKDIR /go/src/github.com/writefreely/writefreely -RUN mkdir -p /go/src/github.com/writeas/writefreely -WORKDIR /go/src/github.com/writeas/writefreely COPY . . ENV GO111MODULE=on + RUN make build \ - && make ui + && make ui RUN mkdir /stage && \ cp -R /go/bin \ - /go/src/github.com/writeas/writefreely/templates \ - /go/src/github.com/writeas/writefreely/static \ - /go/src/github.com/writeas/writefreely/pages \ - /go/src/github.com/writeas/writefreely/keys \ - /go/src/github.com/writeas/writefreely/cmd \ + /go/src/github.com/writefreely/writefreely/templates \ + /go/src/github.com/writefreely/writefreely/static \ + /go/src/github.com/writefreely/writefreely/pages \ + /go/src/github.com/writefreely/writefreely/keys \ + /go/src/github.com/writefreely/writefreely/cmd \ /stage # Final image -FROM alpine:3.8 +FROM alpine:3.12 RUN apk add --no-cache openssl ca-certificates COPY --from=build --chown=daemon:daemon /stage /go diff --git a/Makefile b/Makefile index 85f02d3..663faf6 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ GITREV=`git describe | cut -c 2-` -LDFLAGS=-ldflags="-X 'github.com/writeas/writefreely.softwareVer=$(GITREV)'" +LDFLAGS=-ldflags="-X 'github.com/writefreely/writefreely.softwareVer=$(GITREV)'" GOCMD=go GOINSTALL=$(GOCMD) install $(LDFLAGS) @@ -86,6 +86,7 @@ release : clean ui assets cp -r templates $(BUILDPATH) cp -r pages $(BUILDPATH) cp -r static $(BUILDPATH) + scripts/invalidate-css.sh $(BUILDPATH) mkdir $(BUILDPATH)/keys $(MAKE) build-linux mv build/$(BINARY_NAME)-linux-amd64 $(BUILDPATH)/$(BINARY_NAME) @@ -130,6 +131,7 @@ release-docker : ui : force_look cd less/; $(MAKE) $(MFLAGS) + cd prose/; $(MAKE) $(MFLAGS) assets : generate go-bindata -pkg writefreely -ignore=\\.gitignore -tags="!wflib" schema.sql sqlite.sql diff --git a/README.md b/README.md index 68da89b..02c8300 100644 --- a/README.md +++ b/README.md @@ -4,91 +4,86 @@


- + Latest release - - Go Report Card - Build status - + + + Go Report Card +

  -WriteFreely is a beautifully pared-down blogging platform that's simple on the surface, yet powerful underneath. +WriteFreely is free and open source software for building **a writing space** on the web — whether a publication, internal blog, or writing community in the fediverse. -It's designed to be flexible and share your writing widely, so it's built around plain text and can publish to the _fediverse_ via ActivityPub. It's easy to install and light enough to run on a Raspberry Pi. +![](https://writefreely.org/img/screens/pencil-reader.png) -[Try the editor](https://write.as/new) +[Try the writing experience](https://write.as/new) [Find an instance](https://writefreely.org/instances) ## Features -* Start a blog for yourself, or host a community of writers -* Form larger federated networks, and interact over modern protocols like ActivityPub -* Write on a fast, dead-simple, and distraction-free editor -* [Format text](https://howto.write.as/getting-started) with Markdown -* [Organize posts](https://howto.write.as/organization) with hashtags -* Create [static pages](https://howto.write.as/creating-a-static-page) -* Publish drafts and let others proofread them by sharing a private link -* Create multiple lightweight blogs under a single account -* Export all data in plain text files -* Read a stream of other posts in your writing community -* Build more advanced apps and extensions with the [well-documented API](https://developers.write.as/docs/api/) -* Designed around user privacy and consent +### Made for writing -## Hosting +Built on a plain, auto-saving editor, WriteFreely gives you a distraction-free writing environment. Once published, your words are front and center, and easy to read. -We offer two kinds of hosting services that make WriteFreely deployment painless: [Write.as Pro](https://write.as/pro) for individuals, and [Write.as for Teams](https://write.as/for/teams) for businesses. Besides saving you time and effort, both services directly fund WriteFreely development and ensure the long-term sustainability of our open source work. +### A connected community -### [![Write.as Pro](https://writefreely.org/img/writeas-pro-readme.png)](https://write.as/pro) +Start writing together, publicly or privately. Connect with other communities, whether running WriteFreely, [Plume](https://joinplu.me/), or other ActivityPub-powered software. And bring members on board from your existing platforms, thanks to our OAuth 2.0 support. -Start a personal blog on [Write.as](https://write.as), our flagship instance. Built to eliminate setup friction and preserve your privacy, Write.as helps you start a blog in seconds. It supports custom domains (with SSL) and multiple blogs / pen names per account. [Read more here](https://write.as/pro). +### Intuitive organization -### [![Write.as for Teams](https://writefreely.org/img/writeas-for-teams-readme.png)](https://write.as/for/teams) +Categorize articles [with hashtags](https://writefreely.org/docs/latest/writer/hashtags), and create static pages from normal posts by [_pinning_ them](https://writefreely.org/docs/latest/writer/static) to your blog. Create draft posts and publish to multiple blogs from one account. -[Write.as for Teams](https://write.as/for/teams) gives your organization, business, or [open source project](https://write.as/for/open-source) a clutter-free space to share updates or proposals and build your collective knowledge. We take care of hosting, upgrades, backups, and maintenance so your team can focus on writing. +### International + +Blog elements are localized in 20+ languages, and WriteFreely includes first-class support for non-Latin and right-to-left (RTL) script languages. + +### Private by default + +WriteFreely collects minimal data, and never publicizes more than a writer consents to. Writers can seamlessly create multiple blogs from a single account for different pen names or purposes without publicly revealing their association. + +

Write.as

+ +The quickest way to deploy WriteFreely is with [Write.as](https://write.as/writefreely), a hosted service from the team behind WriteFreely. You'll get fully-managed installation, backup, upgrades, and maintenance — and directly fund our free software work ❤️ + +[**Learn more on Write.as**](https://write.as/writefreely). ## Quick start -WriteFreely has minimal requirements to get up and running — you only need to be able to run an executable. +WriteFreely deploys as a static binary on any platform and architecture that Go supports. Just use our built-in SQLite support, or add a MySQL database, and you'll be up and running! -> **Note** this is currently alpha software. We're quickly moving out of this v0.x stage, but while we're in it, there are no guarantees that this is ready for production use. +For common platforms, start with our [pre-built binaries](https://github.com/writefreely/writefreely/releases/) and head over to our [installation guide](https://writefreely.org/start) to get started. -To get started, head over to our [Getting Started guide](https://writefreely.org/start). For production use, jump to the [Running in Production](https://writefreely.org/start#production) section. +### Packages -## Packages - -WriteFreely is available in these package repositories: +You can also find WriteFreely in these package repositories, thanks to our wonderful community! * [Arch User Repository](https://aur.archlinux.org/packages/writefreely/) ## Documentation -Read our full [documentation on WriteFreely.org](https://writefreely.org/docs). Help us improve by contributing to the [writefreely/documentation](https://github.com/writefreely/documentation) repo. +Read our full [documentation on WriteFreely.org](https://writefreely.org/docs) —️ and help us improve by contributing to the [writefreely/documentation](https://github.com/writefreely/documentation) repo. ## Development -Ready to hack on your site? Get started with our [developer guide](https://writefreely.org/docs/latest/developer/setup). - -## Docker - -Read about using Docker in the [documentation](https://writefreely.org/docs/latest/admin/docker). +Start hacking on WriteFreely with our [developer setup guide](https://writefreely.org/docs/latest/developer/setup). For Docker support, see our [Docker guide](https://writefreely.org/docs/latest/admin/docker). ## Contributing -We gladly welcome contributions to WriteFreely, whether in the form of [code](https://github.com/writeas/writefreely/blob/master/CONTRIBUTING.md#contributing-to-writefreely), [bug reports](https://github.com/writeas/writefreely/issues/new?template=bug_report.md), [feature requests](https://discuss.write.as/c/feedback/feature-requests), [translations](https://poeditor.com/join/project/TIZ6HFRFdE), or [documentation](https://github.com/writefreely/documentation) improvements. +We gladly welcome contributions to WriteFreely, whether in the form of [code](https://github.com/writefreely/writefreely/blob/master/CONTRIBUTING.md#contributing-to-writefreely), [bug reports](https://github.com/writefreely/writefreely/issues/new?template=bug_report.md), [feature requests](https://discuss.write.as/c/feedback/feature-requests), [translations](https://poeditor.com/join/project/TIZ6HFRFdE), or [documentation](https://github.com/writefreely/documentation) improvements. -Before contributing anything, please read our [Contributing Guide](https://github.com/writeas/writefreely/blob/master/CONTRIBUTING.md#contributing-to-writefreely). It describes the correct channels for submitting contributions and any potential requirements. +Before contributing anything, please read our [Contributing Guide](https://github.com/writefreely/writefreely/blob/master/CONTRIBUTING.md#contributing-to-writefreely). It describes the correct channels for submitting contributions and any potential requirements. ## License -Licensed under the AGPL. +Copyright © 2018-2021 [A Bunch Tell LLC](https://abunchtell.com) and contributing authors. Licensed under the [AGPL](https://github.com/writefreely/writefreely/blob/develop/LICENSE). diff --git a/account.go b/account.go index 2dcfd27..ba3c391 100644 --- a/account.go +++ b/account.go @@ -1,5 +1,5 @@ /* - * Copyright © 2018-2019 A Bunch Tell LLC. + * Copyright © 2018-2021 A Bunch Tell LLC. * * This file is part of WriteFreely. * @@ -27,9 +27,9 @@ import ( "github.com/writeas/web-core/auth" "github.com/writeas/web-core/data" "github.com/writeas/web-core/log" - "github.com/writeas/writefreely/author" - "github.com/writeas/writefreely/config" - "github.com/writeas/writefreely/page" + "github.com/writefreely/writefreely/author" + "github.com/writefreely/writefreely/config" + "github.com/writefreely/writefreely/page" ) type ( @@ -48,6 +48,7 @@ type ( Separator template.HTML IsAdmin bool CanInvite bool + CollAlias string } ) @@ -70,7 +71,7 @@ func canUserInvite(cfg *config.Config, isAdmin bool) bool { } func (up *UserPage) SetMessaging(u *User) { - //up.NeedsAuth = app.db.DoesUserNeedAuth(u.ID) + // up.NeedsAuth = app.db.DoesUserNeedAuth(u.ID) } const ( @@ -85,6 +86,11 @@ func apiSignup(app *App, w http.ResponseWriter, r *http.Request) error { } func signup(app *App, w http.ResponseWriter, r *http.Request) (*AuthUser, error) { + if app.cfg.App.DisablePasswordAuth { + err := ErrDisabledPasswordAuth + return nil, err + } + reqJSON := IsJSON(r) // Get params @@ -144,8 +150,6 @@ func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWr } // Handle empty optional params - // TODO: remove this var - createdWithPass := true hashedPass, err := auth.HashPass([]byte(signup.Pass)) if err != nil { return nil, impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."} @@ -155,7 +159,7 @@ func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWr u := &User{ Username: signup.Alias, HashedPass: hashedPass, - HasPass: createdWithPass, + HasPass: true, Email: prepareUserEmail(signup.Email, app.keys.EmailKey), Created: time.Now().Truncate(time.Second).UTC(), } @@ -167,11 +171,7 @@ func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWr // Log invite if needed if signup.InviteCode != "" { - cu, err := app.db.GetUserForAuth(signup.Alias) - if err != nil { - return nil, err - } - err = app.db.CreateInvitedUser(signup.InviteCode, cu.ID) + err = app.db.CreateInvitedUser(signup.InviteCode, u.ID) if err != nil { return nil, err } @@ -185,9 +185,6 @@ func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWr resUser := &AuthUser{ User: u, } - if !createdWithPass { - resUser.Password = signup.Pass - } title := signup.Alias if signup.Normalize { title = desiredUsername @@ -302,20 +299,18 @@ func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error { p := &struct { page.StaticPage + *OAuthButtons To string Message template.HTML Flashes []template.HTML LoginUsername string - OauthSlack bool - OauthWriteAs bool }{ - pageForReq(app, r), - r.FormValue("to"), - template.HTML(""), - []template.HTML{}, - getTempInfo(app, "login-user", r, w), - app.Config().SlackOauth.ClientID != "", - app.Config().WriteAsOauth.ClientID != "", + StaticPage: pageForReq(app, r), + OAuthButtons: NewOAuthButtons(app.Config()), + To: r.FormValue("to"), + Message: template.HTML(""), + Flashes: []template.HTML{}, + LoginUsername: getTempInfo(app, "login-user", r, w), } if earlyError != "" { @@ -390,6 +385,11 @@ func login(app *App, w http.ResponseWriter, r *http.Request) error { var err error var signin userCredentials + if app.cfg.App.DisablePasswordAuth { + err := ErrDisabledPasswordAuth + return err + } + // Log in with one-time token if one is given if oneTimeToken != "" { log.Info("Login: Logging user in via token.") @@ -488,6 +488,9 @@ func login(app *App, w http.ResponseWriter, r *http.Request) error { return impart.HTTPError{http.StatusPreconditionFailed, "This user never added a password or email address. Please contact us for help."} } } + if len(u.HashedPass) == 0 { + return impart.HTTPError{http.StatusUnauthorized, "This user never set a password. Perhaps try logging in via OAuth?"} + } if !auth.Authenticated(u.HashedPass, []byte(signin.Pass)) { return impart.HTTPError{http.StatusUnauthorized, "Incorrect password."} } @@ -746,7 +749,7 @@ func viewArticles(app *App, u *User, w http.ResponseWriter, r *http.Request) err log.Error("unable to fetch collections: %v", err) } - suspended, err := app.db.IsUserSuspended(u.ID) + silenced, err := app.db.IsUserSilenced(u.ID) if err != nil { log.Error("view articles: %v", err) } @@ -754,12 +757,12 @@ func viewArticles(app *App, u *User, w http.ResponseWriter, r *http.Request) err *UserPage AnonymousPosts *[]PublicPost Collections *[]Collection - Suspended bool + Silenced bool }{ UserPage: NewUserPage(app, r, u, u.Username+"'s Posts", f), AnonymousPosts: p, Collections: c, - Suspended: suspended, + Silenced: silenced, } d.UserPage.SetMessaging(u) w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") @@ -781,7 +784,7 @@ func viewCollections(app *App, u *User, w http.ResponseWriter, r *http.Request) uc, _ := app.db.GetUserCollectionCount(u.ID) // TODO: handle any errors - suspended, err := app.db.IsUserSuspended(u.ID) + silenced, err := app.db.IsUserSilenced(u.ID) if err != nil { log.Error("view collections %v", err) return fmt.Errorf("view collections: %v", err) @@ -793,13 +796,13 @@ func viewCollections(app *App, u *User, w http.ResponseWriter, r *http.Request) UsedCollections, TotalCollections int NewBlogsDisabled bool - Suspended bool + Silenced bool }{ UserPage: NewUserPage(app, r, u, u.Username+"'s Blogs", f), Collections: c, UsedCollections: int(uc), NewBlogsDisabled: !app.cfg.App.CanCreateBlogs(uc), - Suspended: suspended, + Silenced: silenced, } d.UserPage.SetMessaging(u) showUserPage(w, "collections", d) @@ -817,7 +820,10 @@ func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Reques return ErrCollectionNotFound } - suspended, err := app.db.IsUserSuspended(u.ID) + // Add collection properties + c.MonetizationPointer = app.db.GetCollectionAttribute(c.ID, "monetization_pointer") + + silenced, err := app.db.IsUserSilenced(u.ID) if err != nil { log.Error("view edit collection %v", err) return fmt.Errorf("view edit collection: %v", err) @@ -826,12 +832,13 @@ func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Reques obj := struct { *UserPage *Collection - Suspended bool + Silenced bool }{ UserPage: NewUserPage(app, r, u, "Edit "+c.DisplayTitle(), flashes), Collection: c, - Suspended: suspended, + Silenced: silenced, } + obj.UserPage.CollAlias = c.Alias showUserPage(w, "collection", obj) return nil @@ -992,7 +999,7 @@ func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error titleStats = c.DisplayTitle() + " " } - suspended, err := app.db.IsUserSuspended(u.ID) + silenced, err := app.db.IsUserSilenced(u.ID) if err != nil { log.Error("view stats: %v", err) return err @@ -1003,14 +1010,15 @@ func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error Collection *Collection TopPosts *[]PublicPost APFollowers int - Suspended bool + Silenced bool }{ UserPage: NewUserPage(app, r, u, titleStats+"Stats", flashes), VisitsBlog: alias, Collection: c, TopPosts: topPosts, - Suspended: suspended, + Silenced: silenced, } + obj.UserPage.CollAlias = c.Alias if app.cfg.App.Federation { folls, err := app.db.GetAPFollowers(c) if err != nil { @@ -1038,18 +1046,68 @@ func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) err flashes, _ := getSessionFlashes(app, w, r, nil) + enableOauthSlack := app.Config().SlackOauth.ClientID != "" + enableOauthWriteAs := app.Config().WriteAsOauth.ClientID != "" + enableOauthGitLab := app.Config().GitlabOauth.ClientID != "" + enableOauthGeneric := app.Config().GenericOauth.ClientID != "" + enableOauthGitea := app.Config().GiteaOauth.ClientID != "" + + oauthAccounts, err := app.db.GetOauthAccounts(r.Context(), u.ID) + if err != nil { + log.Error("Unable to get oauth accounts for settings: %s", err) + return impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve user data. The humans have been alerted."} + } + for idx, oauthAccount := range oauthAccounts { + switch oauthAccount.Provider { + case "slack": + enableOauthSlack = false + case "write.as": + enableOauthWriteAs = false + case "gitlab": + enableOauthGitLab = false + case "generic": + oauthAccounts[idx].DisplayName = app.Config().GenericOauth.DisplayName + oauthAccounts[idx].AllowDisconnect = app.Config().GenericOauth.AllowDisconnect + enableOauthGeneric = false + case "gitea": + enableOauthGitea = false + } + } + + displayOauthSection := enableOauthSlack || enableOauthWriteAs || enableOauthGitLab || enableOauthGeneric || enableOauthGitea || len(oauthAccounts) > 0 + obj := struct { *UserPage - Email string - HasPass bool - IsLogOut bool - Suspended bool + Email string + HasPass bool + IsLogOut bool + Silenced bool + OauthSection bool + OauthAccounts []oauthAccountInfo + OauthSlack bool + OauthWriteAs bool + OauthGitLab bool + GitLabDisplayName string + OauthGeneric bool + OauthGenericDisplayName string + OauthGitea bool + GiteaDisplayName string }{ - UserPage: NewUserPage(app, r, u, "Account Settings", flashes), - Email: fullUser.EmailClear(app.keys), - HasPass: passIsSet, - IsLogOut: r.FormValue("logout") == "1", - Suspended: fullUser.IsSilenced(), + UserPage: NewUserPage(app, r, u, "Account Settings", flashes), + Email: fullUser.EmailClear(app.keys), + HasPass: passIsSet, + IsLogOut: r.FormValue("logout") == "1", + Silenced: fullUser.IsSilenced(), + OauthSection: displayOauthSection, + OauthAccounts: oauthAccounts, + OauthSlack: enableOauthSlack, + OauthWriteAs: enableOauthWriteAs, + OauthGitLab: enableOauthGitLab, + GitLabDisplayName: config.OrDefaultString(app.Config().GitlabOauth.DisplayName, gitlabDisplayName), + OauthGeneric: enableOauthGeneric, + OauthGenericDisplayName: config.OrDefaultString(app.Config().GenericOauth.DisplayName, genericOauthDisplayName), + OauthGitea: enableOauthGitea, + GiteaDisplayName: config.OrDefaultString(app.Config().GiteaOauth.DisplayName, giteaDisplayName), } showUserPage(w, "settings", obj) @@ -1094,6 +1152,19 @@ func getTempInfo(app *App, key string, r *http.Request, w http.ResponseWriter) s return s } +func removeOauth(app *App, u *User, w http.ResponseWriter, r *http.Request) error { + provider := r.FormValue("provider") + clientID := r.FormValue("client_id") + remoteUserID := r.FormValue("remote_user_id") + + err := app.db.RemoveOauth(r.Context(), u.ID, provider, clientID, remoteUserID) + if err != nil { + return impart.HTTPError{Status: http.StatusInternalServerError, Message: err.Error()} + } + + return impart.HTTPError{Status: http.StatusFound, Message: "/me/settings"} +} + func prepareUserEmail(input string, emailKey []byte) zero.String { email := zero.NewString("", input != "") if len(input) > 0 { diff --git a/activitypub.go b/activitypub.go index a5b140d..720d5c9 100644 --- a/activitypub.go +++ b/activitypub.go @@ -1,5 +1,5 @@ /* - * Copyright © 2018-2020 A Bunch Tell LLC. + * Copyright © 2018-2021 A Bunch Tell LLC. * * This file is part of WriteFreely. * @@ -21,6 +21,7 @@ import ( "net/http" "net/http/httputil" "net/url" + "path/filepath" "strconv" "time" @@ -28,9 +29,9 @@ import ( "github.com/writeas/activity/streams" "github.com/writeas/httpsig" "github.com/writeas/impart" - "github.com/writeas/nerds/store" "github.com/writeas/web-core/activitypub" "github.com/writeas/web-core/activitystreams" + "github.com/writeas/web-core/id" "github.com/writeas/web-core/log" ) @@ -41,6 +42,19 @@ const ( apCacheTime = time.Minute ) +var instanceColl *Collection + +func initActivityPub(app *App) { + ur, _ := url.Parse(app.cfg.App.Host) + instanceColl = &Collection{ + ID: 0, + Alias: ur.Host, + Title: ur.Host, + db: app.db, + hostName: app.cfg.App.Host, + } +} + type RemoteUser struct { ID int64 ActorID string @@ -65,17 +79,28 @@ func (ru *RemoteUser) AsPerson() *activitystreams.Person { } } +func activityPubClient() *http.Client { + return &http.Client{ + Timeout: 15 * time.Second, + } +} + func handleFetchCollectionActivities(app *App, w http.ResponseWriter, r *http.Request) error { w.Header().Set("Server", serverSoftware) vars := mux.Vars(r) alias := vars["alias"] + if alias == "" { + alias = filepath.Base(r.RequestURI) + } // TODO: enforce visibility // Get base Collection data var c *Collection var err error - if app.cfg.App.SingleUser { + if alias == r.Host { + c = instanceColl + } else if app.cfg.App.SingleUser { c, err = app.db.GetCollectionByID(1) } else { c, err = app.db.GetCollection(alias) @@ -83,16 +108,19 @@ func handleFetchCollectionActivities(app *App, w http.ResponseWriter, r *http.Re if err != nil { return err } - suspended, err := app.db.IsUserSuspended(c.OwnerID) - if err != nil { - log.Error("fetch collection activities: %v", err) - return ErrInternalGeneral - } - if suspended { - return ErrCollectionNotFound - } c.hostName = app.cfg.App.Host + if !c.IsInstanceColl() { + silenced, err := app.db.IsUserSilenced(c.OwnerID) + if err != nil { + log.Error("fetch collection activities: %v", err) + return ErrInternalGeneral + } + if silenced { + return ErrCollectionNotFound + } + } + p := c.PersonObject() setCacheControl(w, apCacheTime) @@ -117,12 +145,12 @@ func handleFetchCollectionOutbox(app *App, w http.ResponseWriter, r *http.Reques if err != nil { return err } - suspended, err := app.db.IsUserSuspended(c.OwnerID) + silenced, err := app.db.IsUserSilenced(c.OwnerID) if err != nil { log.Error("fetch collection outbox: %v", err) return ErrInternalGeneral } - if suspended { + if silenced { return ErrCollectionNotFound } c.hostName = app.cfg.App.Host @@ -154,6 +182,7 @@ func handleFetchCollectionOutbox(app *App, w http.ResponseWriter, r *http.Reques pp.Collection = res o := pp.ActivityObject(app) a := activitystreams.NewCreateActivity(o) + a.Context = nil ocp.OrderedItems = append(ocp.OrderedItems, *a) } @@ -179,12 +208,12 @@ func handleFetchCollectionFollowers(app *App, w http.ResponseWriter, r *http.Req if err != nil { return err } - suspended, err := app.db.IsUserSuspended(c.OwnerID) + silenced, err := app.db.IsUserSilenced(c.OwnerID) if err != nil { log.Error("fetch collection followers: %v", err) return ErrInternalGeneral } - if suspended { + if silenced { return ErrCollectionNotFound } c.hostName = app.cfg.App.Host @@ -234,12 +263,12 @@ func handleFetchCollectionFollowing(app *App, w http.ResponseWriter, r *http.Req if err != nil { return err } - suspended, err := app.db.IsUserSuspended(c.OwnerID) + silenced, err := app.db.IsUserSilenced(c.OwnerID) if err != nil { log.Error("fetch collection following: %v", err) return ErrInternalGeneral } - if suspended { + if silenced { return ErrCollectionNotFound } c.hostName = app.cfg.App.Host @@ -277,12 +306,12 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request // TODO: return Reject? return err } - suspended, err := app.db.IsUserSuspended(c.OwnerID) + silenced, err := app.db.IsUserSilenced(c.OwnerID) if err != nil { log.Error("fetch collection inbox: %v", err) return ErrInternalGeneral } - if suspended { + if silenced { return ErrCollectionNotFound } c.hostName = app.cfg.App.Host @@ -324,7 +353,7 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request if followID == nil { log.Error("Didn't resolve follow ID") } else { - aID := c.FederatedAccount() + "#accept-" + store.GenerateFriendlyRandomString(20) + aID := c.FederatedAccount() + "#accept-" + id.GenerateFriendlyRandomString(20) acceptID, err := url.Parse(aID) if err != nil { log.Error("Couldn't parse generated Accept URL '%s': %v", aID, err) @@ -389,6 +418,13 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request } go func() { + if to == nil { + if debugging { + log.Error("No `to` value!") + } + return + } + time.Sleep(2 * time.Second) am, err := a.Serialize() if err != nil { @@ -397,10 +433,6 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request } am["@context"] = []string{activitystreams.Namespace} - if to == nil { - log.Error("No to! %v", err) - return - } err = makeActivityPost(app.cfg.App.Host, p, fullActor.Inbox, am) if err != nil { log.Error("Unable to make activity POST: %v", err) @@ -484,7 +516,7 @@ func makeActivityPost(hostName string, p *activitystreams.Person, url string, m r, _ := http.NewRequest("POST", url, bytes.NewBuffer(b)) r.Header.Add("Content-Type", "application/activity+json") - r.Header.Set("User-Agent", "Go ("+serverSoftware+"/"+softwareVer+"; +"+hostName+")") + r.Header.Set("User-Agent", ServerUserAgent(hostName)) h := sha256.New() h.Write(b) r.Header.Add("Digest", "SHA-256="+base64.StdEncoding.EncodeToString(h.Sum(nil))) @@ -509,7 +541,7 @@ func makeActivityPost(hostName string, p *activitystreams.Person, url string, m } } - resp, err := http.DefaultClient.Do(r) + resp, err := activityPubClient().Do(r) if err != nil { return err } @@ -534,7 +566,23 @@ func resolveIRI(hostName, url string) ([]byte, error) { r, _ := http.NewRequest("GET", url, nil) r.Header.Add("Accept", "application/activity+json") - r.Header.Set("User-Agent", "Go ("+serverSoftware+"/"+softwareVer+"; +"+hostName+")") + r.Header.Set("User-Agent", ServerUserAgent(hostName)) + + p := instanceColl.PersonObject() + h := sha256.New() + h.Write([]byte{}) + r.Header.Add("Digest", "SHA-256="+base64.StdEncoding.EncodeToString(h.Sum(nil))) + + // Sign using the 'Signature' header + privKey, err := activitypub.DecodePrivateKey(p.GetPrivKey()) + if err != nil { + return nil, err + } + signer := httpsig.NewSigner(p.PublicKey.ID, privKey, httpsig.RSASHA256, []string{"(request-target)", "date", "host", "digest"}) + err = signer.SignSigHeader(r) + if err != nil { + log.Error("Can't sign: %v", err) + } if debugging { dump, err := httputil.DumpRequestOut(r, true) @@ -545,7 +593,7 @@ func resolveIRI(hostName, url string) ([]byte, error) { } } - resp, err := http.DefaultClient.Do(r) + resp, err := activityPubClient().Do(r) if err != nil { return nil, err } @@ -600,7 +648,12 @@ func deleteFederatedPost(app *App, p *PublicPost, collID int64) error { na.CC = append(na.CC, f) } - err = makeActivityPost(app.cfg.App.Host, actor, si, activitystreams.NewDeleteActivity(na)) + da := activitystreams.NewDeleteActivity(na) + // Make the ID unique to ensure it works in Pleroma + // See: https://git.pleroma.social/pleroma/pleroma/issues/1481 + da.ID += "#Delete" + + err = makeActivityPost(app.cfg.App.Host, actor, si, da) if err != nil { log.Error("Couldn't delete post! %v", err) } @@ -609,6 +662,16 @@ func deleteFederatedPost(app *App, p *PublicPost, collID int64) error { } func federatePost(app *App, p *PublicPost, collID int64, isUpdate bool) error { + // If app is private, do not federate + if app.cfg.App.Private { + return nil + } + + // Do not federate posts from private or protected blogs + if p.Collection.Visibility == CollPrivate || p.Collection.Visibility == CollProtected { + return nil + } + if debugging { if isUpdate { log.Info("Federating updated post!") @@ -616,6 +679,7 @@ func federatePost(app *App, p *PublicPost, collID int64, isUpdate bool) error { log.Info("Federating new post!") } } + actor := p.Collection.PersonObject(collID) na := p.ActivityObject(app) @@ -684,6 +748,10 @@ func federatePost(app *App, p *PublicPost, collID int64, isUpdate bool) error { // I don't believe we'd ever have too many mentions in a single post that this // could become a burden. remoteUser, err := getRemoteUser(app, tag.HRef) + if err != nil { + log.Error("Unable to find remote user %s. Skipping: %v", tag.HRef, err) + continue + } err = makeActivityPost(app.cfg.App.Host, actor, remoteUser.Inbox, activity) if err != nil { log.Error("Couldn't post! %v", err) @@ -696,7 +764,8 @@ func federatePost(app *App, p *PublicPost, collID int64, isUpdate bool) error { func getRemoteUser(app *App, actorID string) (*RemoteUser, error) { u := RemoteUser{ActorID: actorID} - err := app.db.QueryRow("SELECT id, inbox, shared_inbox, handle FROM remoteusers WHERE actor_id = ?", actorID).Scan(&u.ID, &u.Inbox, &u.SharedInbox, &u.Handle) + var handle sql.NullString + err := app.db.QueryRow("SELECT id, inbox, shared_inbox, handle FROM remoteusers WHERE actor_id = ?", actorID).Scan(&u.ID, &u.Inbox, &u.SharedInbox, &handle) switch { case err == sql.ErrNoRows: return nil, impart.HTTPError{http.StatusNotFound, "No remote user with that ID."} @@ -705,6 +774,8 @@ func getRemoteUser(app *App, actorID string) (*RemoteUser, error) { return nil, err } + u.Handle = handle.String + return &u, nil } diff --git a/admin.go b/admin.go index a9b9d25..4b89a69 100644 --- a/admin.go +++ b/admin.go @@ -1,5 +1,5 @@ /* - * Copyright © 2018-2019 A Bunch Tell LLC. + * Copyright © 2018-2021 A Bunch Tell LLC. * * This file is part of WriteFreely. * @@ -24,8 +24,8 @@ import ( "github.com/writeas/web-core/auth" "github.com/writeas/web-core/log" "github.com/writeas/web-core/passgen" - "github.com/writeas/writefreely/appstats" - "github.com/writeas/writefreely/config" + "github.com/writefreely/writefreely/appstats" + "github.com/writefreely/writefreely/config" ) var ( @@ -90,6 +90,18 @@ type instanceContent struct { Updated time.Time } +type AdminPage struct { + UpdateAvailable bool +} + +func NewAdminPage(app *App) *AdminPage { + ap := &AdminPage{} + if app.updates != nil { + ap.UpdateAvailable = app.updates.AreAvailableNoCheck() + } + return ap +} + func (c instanceContent) UpdatedFriendly() string { /* // TODO: accept a locale in this method and use that for the format @@ -100,15 +112,46 @@ func (c instanceContent) UpdatedFriendly() string { } func handleViewAdminDash(app *App, u *User, w http.ResponseWriter, r *http.Request) error { + p := struct { + *UserPage + *AdminPage + Message string + + UsersCount, CollectionsCount, PostsCount int64 + }{ + UserPage: NewUserPage(app, r, u, "Admin", nil), + AdminPage: NewAdminPage(app), + Message: r.FormValue("m"), + } + + // Get user stats + p.UsersCount = app.db.GetAllUsersCount() + var err error + p.CollectionsCount, err = app.db.GetTotalCollections() + if err != nil { + return err + } + p.PostsCount, err = app.db.GetTotalPosts() + if err != nil { + return err + } + + showUserPage(w, "admin", p) + return nil +} + +func handleViewAdminMonitor(app *App, u *User, w http.ResponseWriter, r *http.Request) error { updateAppStats() p := struct { *UserPage + *AdminPage SysStatus systemStatus Config config.AppCfg Message, ConfigMessage string }{ UserPage: NewUserPage(app, r, u, "Admin", nil), + AdminPage: NewAdminPage(app), SysStatus: sysStatus, Config: app.cfg.App, @@ -116,13 +159,34 @@ func handleViewAdminDash(app *App, u *User, w http.ResponseWriter, r *http.Reque ConfigMessage: r.FormValue("cm"), } - showUserPage(w, "admin", p) + showUserPage(w, "monitor", p) + return nil +} + +func handleViewAdminSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) error { + p := struct { + *UserPage + *AdminPage + Config config.AppCfg + + Message, ConfigMessage string + }{ + UserPage: NewUserPage(app, r, u, "Admin", nil), + AdminPage: NewAdminPage(app), + Config: app.cfg.App, + + Message: r.FormValue("m"), + ConfigMessage: r.FormValue("cm"), + } + + showUserPage(w, "app-settings", p) return nil } func handleViewAdminUsers(app *App, u *User, w http.ResponseWriter, r *http.Request) error { p := struct { *UserPage + *AdminPage Config config.AppCfg Message string Flashes []string @@ -132,9 +196,10 @@ func handleViewAdminUsers(app *App, u *User, w http.ResponseWriter, r *http.Requ TotalUsers int64 TotalPages []int }{ - UserPage: NewUserPage(app, r, u, "Users", nil), - Config: app.cfg.App, - Message: r.FormValue("m"), + UserPage: NewUserPage(app, r, u, "Users", nil), + AdminPage: NewAdminPage(app), + Config: app.cfg.App, + Message: r.FormValue("m"), } p.Flashes, _ = getSessionFlashes(app, w, r, nil) @@ -171,6 +236,7 @@ func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Reque p := struct { *UserPage + *AdminPage Config config.AppCfg Message string @@ -181,9 +247,10 @@ func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Reque TotalPosts int64 ClearEmail string }{ - Config: app.cfg.App, - Message: r.FormValue("m"), - Colls: []inspectedCollection{}, + AdminPage: NewAdminPage(app), + Config: app.cfg.App, + Message: r.FormValue("m"), + Colls: []inspectedCollection{}, } var err error @@ -294,9 +361,12 @@ func handleAdminToggleUserStatus(app *App, u *User, w http.ResponseWriter, r *ht err = app.db.SetUserStatus(user.ID, UserActive) } else { err = app.db.SetUserStatus(user.ID, UserSilenced) + + // reset the cache to removed silence user posts + updateTimelineCache(app.timeline, true) } if err != nil { - log.Error("toggle user suspended: %v", err) + log.Error("toggle user silenced: %v", err) return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not toggle user status: %v", err)} } return impart.HTTPError{http.StatusFound, fmt.Sprintf("/admin/user/%s#status", username)} @@ -337,14 +407,16 @@ func handleAdminResetUserPass(app *App, u *User, w http.ResponseWriter, r *http. func handleViewAdminPages(app *App, u *User, w http.ResponseWriter, r *http.Request) error { p := struct { *UserPage + *AdminPage Config config.AppCfg Message string Pages []*instanceContent }{ - UserPage: NewUserPage(app, r, u, "Pages", nil), - Config: app.cfg.App, - Message: r.FormValue("m"), + UserPage: NewUserPage(app, r, u, "Pages", nil), + AdminPage: NewAdminPage(app), + Config: app.cfg.App, + Message: r.FormValue("m"), } var err error @@ -401,14 +473,16 @@ func handleViewAdminPage(app *App, u *User, w http.ResponseWriter, r *http.Reque p := struct { *UserPage + *AdminPage Config config.AppCfg Message string Banner *instanceContent Content *instanceContent }{ - Config: app.cfg.App, - Message: r.FormValue("m"), + AdminPage: NewAdminPage(app), + Config: app.cfg.App, + Message: r.FormValue("m"), } var err error @@ -491,6 +565,7 @@ func handleAdminUpdateConfig(apper Apper, u *User, w http.ResponseWriter, r *htt } apper.App().cfg.App.Federation = r.FormValue("federation") == "on" apper.App().cfg.App.PublicStats = r.FormValue("public_stats") == "on" + apper.App().cfg.App.Monetization = r.FormValue("monetization") == "on" apper.App().cfg.App.Private = r.FormValue("private") == "on" apper.App().cfg.App.LocalTimeline = r.FormValue("local_timeline") == "on" if apper.App().cfg.App.LocalTimeline && apper.App().timeline == nil { @@ -508,7 +583,7 @@ func handleAdminUpdateConfig(apper Apper, u *User, w http.ResponseWriter, r *htt if err != nil { m = "?cm=" + err.Error() } - return impart.HTTPError{http.StatusFound, "/admin" + m + "#config"} + return impart.HTTPError{http.StatusFound, "/admin/settings" + m + "#config"} } func updateAppStats() { @@ -561,3 +636,39 @@ func adminResetPassword(app *App, u *User, newPass string) error { } return nil } + +func handleViewAdminUpdates(app *App, u *User, w http.ResponseWriter, r *http.Request) error { + check := r.URL.Query().Get("check") + + if check == "now" && app.cfg.App.UpdateChecks { + app.updates.CheckNow() + } + + p := struct { + *UserPage + *AdminPage + CurReleaseNotesURL string + LastChecked string + LastChecked8601 string + LatestVersion string + LatestReleaseURL string + LatestReleaseNotesURL string + CheckFailed bool + }{ + UserPage: NewUserPage(app, r, u, "Updates", nil), + AdminPage: NewAdminPage(app), + } + p.CurReleaseNotesURL = wfReleaseNotesURL(p.Version) + if app.cfg.App.UpdateChecks { + p.LastChecked = app.updates.lastCheck.Format("January 2, 2006, 3:04 PM") + p.LastChecked8601 = app.updates.lastCheck.Format("2006-01-02T15:04:05Z") + p.LatestVersion = app.updates.LatestVersion() + p.LatestReleaseURL = app.updates.ReleaseURL() + p.LatestReleaseNotesURL = app.updates.ReleaseNotesURL() + p.UpdateAvailable = app.updates.AreAvailable() + p.CheckFailed = app.updates.checkError != nil + } + + showUserPage(w, "app-updates", p) + return nil +} diff --git a/app.go b/app.go index 170c321..ed4e096 100644 --- a/app.go +++ b/app.go @@ -1,5 +1,5 @@ /* - * Copyright © 2018-2019 A Bunch Tell LLC. + * Copyright © 2018-2021 A Bunch Tell LLC. * * This file is part of WriteFreely. * @@ -35,11 +35,11 @@ import ( "github.com/writeas/web-core/auth" "github.com/writeas/web-core/converter" "github.com/writeas/web-core/log" - "github.com/writeas/writefreely/author" - "github.com/writeas/writefreely/config" - "github.com/writeas/writefreely/key" - "github.com/writeas/writefreely/migrations" - "github.com/writeas/writefreely/page" + "github.com/writefreely/writefreely/author" + "github.com/writefreely/writefreely/config" + "github.com/writefreely/writefreely/key" + "github.com/writefreely/writefreely/migrations" + "github.com/writefreely/writefreely/page" "golang.org/x/crypto/acme/autocert" ) @@ -56,7 +56,7 @@ var ( debugging bool // Software version can be set from git env using -ldflags - softwareVer = "0.11.2" + softwareVer = "0.12.0" // DEPRECATED VARS isSingleUser bool @@ -72,6 +72,7 @@ type App struct { keys *key.Keychain sessionStore sessions.Store formDecoder *schema.Decoder + updates *updatesCache timeline *localTimeline } @@ -220,6 +221,10 @@ func handleViewHome(app *App, w http.ResponseWriter, r *http.Request) error { return handleViewPad(app, w, r) } + if app.cfg.App.Private { + return viewLogin(app, w, r) + } + if land := app.cfg.App.LandingPath(); land != "/" { return impart.HTTPError{http.StatusFound, land} } @@ -233,6 +238,7 @@ func handleViewLanding(app *App, w http.ResponseWriter, r *http.Request) error { p := struct { page.StaticPage + *OAuthButtons Flashes []template.HTML Banner template.HTML Content template.HTML @@ -240,6 +246,7 @@ func handleViewLanding(app *App, w http.ResponseWriter, r *http.Request) error { ForcedLanding bool }{ StaticPage: pageForReq(app, r), + OAuthButtons: NewOAuthButtons(app.Config()), ForcedLanding: forceLanding, } @@ -371,6 +378,8 @@ func Initialize(apper Apper, debug bool) (*App, error) { if err != nil { return nil, fmt.Errorf("init keys: %s", err) } + apper.App().InitUpdates() + apper.App().InitSession() apper.App().InitDecoder() @@ -380,6 +389,8 @@ func Initialize(apper Apper, debug bool) (*App, error) { return nil, fmt.Errorf("connect to DB: %s", err) } + initActivityPub(apper.App()) + // Handle local timeline, if enabled if apper.App().cfg.App.LocalTimeline { log.Info("Initializing local timeline...") @@ -406,6 +417,11 @@ func Serve(app *App, r *mux.Router) { os.Exit(0) }() + // Start gopher server + if app.cfg.Server.GopherPort > 0 && !app.cfg.App.Private { + go initGopher(app) + } + // Start web application server var bindAddress = app.cfg.Server.Bind if bindAddress == "" { @@ -741,7 +757,7 @@ func connectToDatabase(app *App) { var db *sql.DB var err error if app.cfg.Database.Type == driverMySQL { - db, err = sql.Open(app.cfg.Database.Type, fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=%s", app.cfg.Database.User, app.cfg.Database.Password, app.cfg.Database.Host, app.cfg.Database.Port, app.cfg.Database.Database, url.QueryEscape(time.Local.String()))) + db, err = sql.Open(app.cfg.Database.Type, fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=%s&tls=%t", app.cfg.Database.User, app.cfg.Database.Password, app.cfg.Database.Host, app.cfg.Database.Port, app.cfg.Database.Database, url.QueryEscape(time.Local.String()), app.cfg.Database.TLS)) db.SetMaxOpenConns(50) } else if app.cfg.Database.Type == driverSQLite { if !SQLiteEnabled { @@ -878,3 +894,13 @@ func adminInitDatabase(app *App) error { log.Info("Done.") return nil } + +// ServerUserAgent returns a User-Agent string to use in external requests. The +// hostName parameter may be left empty. +func ServerUserAgent(hostName string) string { + hostUAStr := "" + if hostName != "" { + hostUAStr = "; +" + hostName + } + return "Go (" + serverSoftware + "/" + softwareVer + hostUAStr + ")" +} diff --git a/author/author.go b/author/author.go index 0114905..7431ac5 100644 --- a/author/author.go +++ b/author/author.go @@ -1,5 +1,5 @@ /* - * Copyright © 2018-2020 A Bunch Tell LLC. + * Copyright © 2018-2021 A Bunch Tell LLC. * * This file is part of WriteFreely. * @@ -11,7 +11,7 @@ package author import ( - "github.com/writeas/writefreely/config" + "github.com/writefreely/writefreely/config" "os" "path/filepath" "regexp" diff --git a/cmd/writefreely/config.go b/cmd/writefreely/config.go new file mode 100644 index 0000000..32e3801 --- /dev/null +++ b/cmd/writefreely/config.go @@ -0,0 +1,60 @@ +/* + * Copyright © 2020-2021 A Bunch Tell LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package main + +import ( + "github.com/urfave/cli/v2" + "github.com/writefreely/writefreely" +) + +var ( + cmdConfig cli.Command = cli.Command{ + Name: "config", + Usage: "config management tools", + Subcommands: []*cli.Command{ + &cmdConfigGenerate, + &cmdConfigInteractive, + }, + } + + cmdConfigGenerate cli.Command = cli.Command{ + Name: "generate", + Aliases: []string{"gen"}, + Usage: "Generate a basic configuration", + Action: genConfigAction, + } + + cmdConfigInteractive cli.Command = cli.Command{ + Name: "start", + Usage: "Interactive configuration process", + Action: interactiveConfigAction, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "sections", + Value: "server db app", + Usage: "Which sections of the configuration to go through\n" + + "valid values of sections flag are any combination of 'server', 'db' and 'app' \n" + + "example: writefreely config start --sections \"db app\"", + }, + }, + } +) + +func genConfigAction(c *cli.Context) error { + app := writefreely.NewApp(c.String("c")) + return writefreely.CreateConfig(app) +} + +func interactiveConfigAction(c *cli.Context) error { + app := writefreely.NewApp(c.String("c")) + writefreely.DoConfig(app, c.String("sections")) + return nil +} diff --git a/cmd/writefreely/db.go b/cmd/writefreely/db.go new file mode 100644 index 0000000..ccae418 --- /dev/null +++ b/cmd/writefreely/db.go @@ -0,0 +1,49 @@ +/* + * Copyright © 2020-2021 A Bunch Tell LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package main + +import ( + "github.com/urfave/cli/v2" + "github.com/writefreely/writefreely" +) + +var ( + cmdDB cli.Command = cli.Command{ + Name: "db", + Usage: "db management tools", + Subcommands: []*cli.Command{ + &cmdDBInit, + &cmdDBMigrate, + }, + } + + cmdDBInit cli.Command = cli.Command{ + Name: "init", + Usage: "Initialize Database", + Action: initDBAction, + } + + cmdDBMigrate cli.Command = cli.Command{ + Name: "migrate", + Usage: "Migrate Database", + Action: migrateDBAction, + } +) + +func initDBAction(c *cli.Context) error { + app := writefreely.NewApp(c.String("c")) + return writefreely.CreateSchema(app) +} + +func migrateDBAction(c *cli.Context) error { + app := writefreely.NewApp(c.String("c")) + return writefreely.Migrate(app) +} diff --git a/cmd/writefreely/keys.go b/cmd/writefreely/keys.go new file mode 100644 index 0000000..680cd4d --- /dev/null +++ b/cmd/writefreely/keys.go @@ -0,0 +1,38 @@ +/* + * Copyright © 2020-2021 A Bunch Tell LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package main + +import ( + "github.com/urfave/cli/v2" + "github.com/writefreely/writefreely" +) + +var ( + cmdKeys cli.Command = cli.Command{ + Name: "keys", + Usage: "key management tools", + Subcommands: []*cli.Command{ + &cmdGenerateKeys, + }, + } + + cmdGenerateKeys cli.Command = cli.Command{ + Name: "generate", + Aliases: []string{"gen"}, + Usage: "Generate encryption and authentication keys", + Action: genKeysAction, + } +) + +func genKeysAction(c *cli.Context) error { + app := writefreely.NewApp(c.String("c")) + return writefreely.GenerateKeyFiles(app) +} diff --git a/cmd/writefreely/main.go b/cmd/writefreely/main.go index 7fc2342..992d611 100644 --- a/cmd/writefreely/main.go +++ b/cmd/writefreely/main.go @@ -1,5 +1,5 @@ /* - * Copyright © 2018-2019 A Bunch Tell LLC. + * Copyright © 2018-2021 A Bunch Tell LLC. * * This file is part of WriteFreely. * @@ -11,122 +11,156 @@ package main import ( - "flag" "fmt" "os" "strings" "github.com/gorilla/mux" + "github.com/urfave/cli/v2" "github.com/writeas/web-core/log" - "github.com/writeas/writefreely" + "github.com/writefreely/writefreely" ) func main() { - // General options usable with other commands - debugPtr := flag.Bool("debug", false, "Enables debug logging.") - configFile := flag.String("c", "config.ini", "The configuration file to use") + cli.VersionPrinter = func(c *cli.Context) { + fmt.Printf("%s\n", c.App.Version) + } + app := &cli.App{ + Name: "WriteFreely", + Usage: "A beautifully pared-down blogging platform", + Version: writefreely.FormatVersion(), + Action: legacyActions, // legacy due to use of flags for switching actions + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "create-config", + Value: false, + Usage: "Generate a basic configuration", + Hidden: true, + }, + &cli.BoolFlag{ + Name: "config", + Value: false, + Usage: "Interactive configuration process", + Hidden: true, + }, + &cli.StringFlag{ + Name: "sections", + Value: "server db app", + Usage: "Which sections of the configuration to go through (requires --config)\n" + + "valid values are any combination of 'server', 'db' and 'app' \n" + + "example: writefreely --config --sections \"db app\"", + Hidden: true, + }, + &cli.BoolFlag{ + Name: "gen-keys", + Value: false, + Usage: "Generate encryption and authentication keys", + Hidden: true, + }, + &cli.BoolFlag{ + Name: "init-db", + Value: false, + Usage: "Initialize app database", + Hidden: true, + }, + &cli.BoolFlag{ + Name: "migrate", + Value: false, + Usage: "Migrate the database", + Hidden: true, + }, + &cli.StringFlag{ + Name: "create-admin", + Usage: "Create an admin with the given username:password", + Hidden: true, + }, + &cli.StringFlag{ + Name: "create-user", + Usage: "Create a regular user with the given username:password", + Hidden: true, + }, + &cli.StringFlag{ + Name: "delete-user", + Usage: "Delete a user with the given username", + Hidden: true, + }, + &cli.StringFlag{ + Name: "reset-pass", + Usage: "Reset the given user's password", + Hidden: true, + }, + }, // legacy flags (set to hidden to eventually switch to bash-complete compatible format) + } - // Setup actions - createConfig := flag.Bool("create-config", false, "Creates a basic configuration and exits") - doConfig := flag.Bool("config", false, "Run the configuration process") - configSections := flag.String("sections", "server db app", "Which sections of the configuration to go through (requires --config), "+ - "valid values are any combination of 'server', 'db' and 'app' "+ - "example: writefreely --config --sections \"db app\"") - genKeys := flag.Bool("gen-keys", false, "Generate encryption and authentication keys") - createSchema := flag.Bool("init-db", false, "Initialize app database") - migrate := flag.Bool("migrate", false, "Migrate the database") + defaultFlags := []cli.Flag{ + &cli.StringFlag{ + Name: "c", + Value: "config.ini", + Usage: "Load configuration from `FILE`", + }, + &cli.BoolFlag{ + Name: "debug", + Value: false, + Usage: "Enables debug logging", + }, + } - // Admin actions - createAdmin := flag.String("create-admin", "", "Create an admin with the given username:password") - createUser := flag.String("create-user", "", "Create a regular user with the given username:password") - deleteUsername := flag.String("delete-user", "", "Delete a user with the given username") - resetPassUser := flag.String("reset-pass", "", "Reset the given user's password") - outputVersion := flag.Bool("v", false, "Output the current version") - flag.Parse() + app.Flags = append(app.Flags, defaultFlags...) - app := writefreely.NewApp(*configFile) + app.Commands = []*cli.Command{ + &cmdUser, + &cmdDB, + &cmdConfig, + &cmdKeys, + &cmdServe, + } - if *outputVersion { - writefreely.OutputVersion() - os.Exit(0) - } else if *createConfig { - err := writefreely.CreateConfig(app) + err := app.Run(os.Args) + if err != nil { + log.Error(err.Error()) + os.Exit(1) + } +} + +func legacyActions(c *cli.Context) error { + app := writefreely.NewApp(c.String("c")) + + switch true { + case c.IsSet("create-config"): + return writefreely.CreateConfig(app) + case c.IsSet("config"): + writefreely.DoConfig(app, c.String("sections")) + return nil + case c.IsSet("gen-keys"): + return writefreely.GenerateKeyFiles(app) + case c.IsSet("init-db"): + return writefreely.CreateSchema(app) + case c.IsSet("migrate"): + return writefreely.Migrate(app) + case c.IsSet("create-admin"): + username, password, err := parseCredentials(c.String("create-admin")) if err != nil { - log.Error(err.Error()) - os.Exit(1) + return err } - os.Exit(0) - } else if *doConfig { - writefreely.DoConfig(app, *configSections) - os.Exit(0) - } else if *genKeys { - err := writefreely.GenerateKeyFiles(app) + return writefreely.CreateUser(app, username, password, true) + case c.IsSet("create-user"): + username, password, err := parseCredentials(c.String("create-user")) if err != nil { - log.Error(err.Error()) - os.Exit(1) + return err } - os.Exit(0) - } else if *createSchema { - err := writefreely.CreateSchema(app) - if err != nil { - log.Error(err.Error()) - os.Exit(1) - } - os.Exit(0) - } else if *createAdmin != "" { - username, password, err := userPass(*createAdmin, true) - if err != nil { - log.Error(err.Error()) - os.Exit(1) - } - err = writefreely.CreateUser(app, username, password, true) - if err != nil { - log.Error(err.Error()) - os.Exit(1) - } - os.Exit(0) - } else if *createUser != "" { - username, password, err := userPass(*createUser, false) - if err != nil { - log.Error(err.Error()) - os.Exit(1) - } - err = writefreely.CreateUser(app, username, password, false) - if err != nil { - log.Error(err.Error()) - os.Exit(1) - } - os.Exit(0) - } else if *resetPassUser != "" { - err := writefreely.ResetPassword(app, *resetPassUser) - if err != nil { - log.Error(err.Error()) - os.Exit(1) - } - os.Exit(0) - } else if *deleteUsername != "" { - err := writefreely.DoDeleteAccount(app, *deleteUsername) - if err != nil { - log.Error(err.Error()) - os.Exit(1) - } - os.Exit(0) - } else if *migrate { - err := writefreely.Migrate(app) - if err != nil { - log.Error(err.Error()) - os.Exit(1) - } - os.Exit(0) + return writefreely.CreateUser(app, username, password, false) + case c.IsSet("delete-user"): + return writefreely.DoDeleteAccount(app, c.String("delete-user")) + case c.IsSet("reset-pass"): + return writefreely.ResetPassword(app, c.String("reset-pass")) } // Initialize the application var err error log.Info("Starting %s...", writefreely.FormatVersion()) - app, err = writefreely.Initialize(app, *debugPtr) + app, err = writefreely.Initialize(app, c.Bool("debug")) if err != nil { - log.Error("%s", err) - os.Exit(1) + return err } // Set app routes @@ -136,20 +170,14 @@ func main() { // Serve the application writefreely.Serve(app, r) + + return nil } -func userPass(credStr string, isAdmin bool) (user string, pass string, err error) { - creds := strings.Split(credStr, ":") +func parseCredentials(credentialString string) (string, string, error) { + creds := strings.Split(credentialString, ":") if len(creds) != 2 { - c := "user" - if isAdmin { - c = "admin" - } - err = fmt.Errorf("usage: writefreely --create-%s username:password", c) - return + return "", "", fmt.Errorf("invalid format for passed credentials, must be username:password") } - - user = creds[0] - pass = creds[1] - return + return creds[0], creds[1], nil } diff --git a/cmd/writefreely/user.go b/cmd/writefreely/user.go new file mode 100644 index 0000000..8429513 --- /dev/null +++ b/cmd/writefreely/user.go @@ -0,0 +1,96 @@ +/* + * Copyright © 2020-2021 A Bunch Tell LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package main + +import ( + "fmt" + + "github.com/urfave/cli/v2" + "github.com/writefreely/writefreely" +) + +var ( + cmdUser cli.Command = cli.Command{ + Name: "user", + Usage: "user management tools", + Subcommands: []*cli.Command{ + &cmdAddUser, + &cmdDelUser, + &cmdResetPass, + // TODO: possibly add a user list command + }, + } + + cmdAddUser cli.Command = cli.Command{ + Name: "create", + Usage: "Add new user", + Aliases: []string{"a", "add"}, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "admin", + Value: false, + Usage: "Create admin user", + }, + }, + Action: addUserAction, + } + + cmdDelUser cli.Command = cli.Command{ + Name: "delete", + Usage: "Delete user", + Aliases: []string{"del", "d"}, + Action: delUserAction, + } + + cmdResetPass cli.Command = cli.Command{ + Name: "reset-pass", + Usage: "Reset user's password", + Aliases: []string{"resetpass", "reset"}, + Action: resetPassAction, + } +) + +func addUserAction(c *cli.Context) error { + credentials := "" + if c.NArg() > 0 { + credentials = c.Args().Get(0) + } else { + return fmt.Errorf("No user passed. Example: writefreely user add [USER]:[PASSWORD]") + } + username, password, err := parseCredentials(credentials) + if err != nil { + return err + } + app := writefreely.NewApp(c.String("c")) + return writefreely.CreateUser(app, username, password, c.Bool("admin")) +} + +func delUserAction(c *cli.Context) error { + username := "" + if c.NArg() > 0 { + username = c.Args().Get(0) + } else { + return fmt.Errorf("No user passed. Example: writefreely user delete [USER]") + } + app := writefreely.NewApp(c.String("c")) + return writefreely.DoDeleteAccount(app, username) +} + +func resetPassAction(c *cli.Context) error { + username := "" + if c.NArg() > 0 { + username = c.Args().Get(0) + } else { + return fmt.Errorf("No user passed. Example: writefreely user reset-pass [USER]") + } + app := writefreely.NewApp(c.String("c")) + return writefreely.ResetPassword(app, username) +} diff --git a/cmd/writefreely/web.go b/cmd/writefreely/web.go new file mode 100644 index 0000000..02ae1c9 --- /dev/null +++ b/cmd/writefreely/web.go @@ -0,0 +1,48 @@ +/* + * Copyright © 2020-2021 A Bunch Tell LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package main + +import ( + "github.com/gorilla/mux" + "github.com/urfave/cli/v2" + "github.com/writeas/web-core/log" + "github.com/writefreely/writefreely" +) + +var ( + cmdServe cli.Command = cli.Command{ + Name: "serve", + Aliases: []string{"web"}, + Usage: "Run web application", + Action: serveAction, + } +) + +func serveAction(c *cli.Context) error { + // Initialize the application + app := writefreely.NewApp(c.String("c")) + var err error + log.Info("Starting %s...", writefreely.FormatVersion()) + app, err = writefreely.Initialize(app, c.Bool("debug")) + if err != nil { + return err + } + + // Set app routes + r := mux.NewRouter() + writefreely.InitRoutes(app, r) + app.InitStaticRoutes(r) + + // Serve the application + writefreely.Serve(app, r) + + return nil +} diff --git a/collections.go b/collections.go index 189b4e4..15938fc 100644 --- a/collections.go +++ b/collections.go @@ -1,5 +1,5 @@ /* - * Copyright © 2018-2020 A Bunch Tell LLC. + * Copyright © 2018-2021 A Bunch Tell LLC. * * This file is part of WriteFreely. * @@ -30,9 +30,9 @@ import ( "github.com/writeas/web-core/bots" "github.com/writeas/web-core/log" waposts "github.com/writeas/web-core/posts" - "github.com/writeas/writefreely/author" - "github.com/writeas/writefreely/config" - "github.com/writeas/writefreely/page" + "github.com/writefreely/writefreely/author" + "github.com/writefreely/writefreely/config" + "github.com/writefreely/writefreely/page" ) type ( @@ -47,6 +47,7 @@ type ( Language string `schema:"lang" json:"lang,omitempty"` StyleSheet string `datastore:"style_sheet" schema:"style_sheet" json:"style_sheet"` Script string `datastore:"script" schema:"script" json:"script,omitempty"` + Signature string `datastore:"post_signature" schema:"signature" json:"-"` Public bool `datastore:"public" json:"public"` Visibility collVisibility `datastore:"private" json:"-"` Format string `datastore:"format" json:"format,omitempty"` @@ -55,6 +56,8 @@ type ( PublicOwner bool `datastore:"public_owner" json:"-"` URL string `json:"url,omitempty"` + MonetizationPointer string `json:"monetization_pointer,omitempty"` + db *datastore hostName string } @@ -71,7 +74,7 @@ type ( IsTopLevel bool CurrentPage int TotalPages int - Suspended bool + Silenced bool } SubmittedCollection struct { // Data used for updating a given collection @@ -86,13 +89,15 @@ type ( Handle string `schema:"handle" json:"handle"` // Actual collection values updated in the DB - Alias *string `schema:"alias" json:"alias"` - Title *string `schema:"title" json:"title"` - Description *string `schema:"description" json:"description"` - StyleSheet *sql.NullString `schema:"style_sheet" json:"style_sheet"` - Script *sql.NullString `schema:"script" json:"script"` - Visibility *int `schema:"visibility" json:"public"` - Format *sql.NullString `schema:"format" json:"format"` + Alias *string `schema:"alias" json:"alias"` + Title *string `schema:"title" json:"title"` + Description *string `schema:"description" json:"description"` + StyleSheet *sql.NullString `schema:"style_sheet" json:"style_sheet"` + Script *sql.NullString `schema:"script" json:"script"` + Signature *sql.NullString `schema:"signature" json:"signature"` + Monetization *string `schema:"monetization_pointer" json:"monetization_pointer"` + Visibility *int `schema:"visibility" json:"public"` + Format *sql.NullString `schema:"format" json:"format"` } CollectionFormat struct { Format string @@ -105,6 +110,8 @@ type ( // User-related fields isCollOwner bool + + isAuthorized bool } ) @@ -175,6 +182,11 @@ func (c *Collection) NewFormat() *CollectionFormat { return cf } +func (c *Collection) IsInstanceColl() bool { + ur, _ := url.Parse(c.hostName) + return c.Alias == ur.Host +} + func (c *Collection) IsUnlisted() bool { return c.Visibility == 0 } @@ -230,7 +242,7 @@ func (c *Collection) DisplayCanonicalURL() string { func (c *Collection) RedirectingCanonicalURL(isRedir bool) string { if c.hostName == "" { // If this is true, the human programmers screwed up. So ask for a bug report and fail, fail, fail - log.Error("[PROGRAMMER ERROR] WARNING: Collection.hostName is empty! Federation and many other things will fail! If you're seeing this in the wild, please report this bug and let us know what you were doing just before this: https://github.com/writeas/writefreely/issues/new?template=bug_report.md") + log.Error("[PROGRAMMER ERROR] WARNING: Collection.hostName is empty! Federation and many other things will fail! If you're seeing this in the wild, please report this bug and let us know what you were doing just before this: https://github.com/writefreely/writefreely/issues/new?template=bug_report.md") } if isSingleUser { return c.hostName + "/" @@ -397,13 +409,13 @@ func newCollection(app *App, w http.ResponseWriter, r *http.Request) error { } userID = u.ID } - suspended, err := app.db.IsUserSuspended(userID) + silenced, err := app.db.IsUserSilenced(userID) if err != nil { log.Error("new collection: %v", err) return ErrInternalGeneral } - if suspended { - return ErrUserSuspended + if silenced { + return ErrUserSilenced } if !author.IsValidUsername(app.cfg, c.Alias) { @@ -487,7 +499,7 @@ func fetchCollection(app *App, w http.ResponseWriter, r *http.Request) error { res.Owner = u } } - // TODO: check suspended + // TODO: check status for silenced app.db.GetPostsCount(res, isCollOwner) // Strip non-public information res.Collection.ForPublic() @@ -548,8 +560,10 @@ type CollectionPage struct { IsCustomDomain bool IsWelcome bool IsOwner bool + IsCollLoggedIn bool CanPin bool Username string + Monetization string Collections *[]Collection PinnedPosts *[]PublicPost IsAdmin bool @@ -656,7 +670,7 @@ func processCollectionPermissions(app *App, cr *collectionReq, u *User, w http.R } // TODO: move this to all permission checks? - suspended, err := app.db.IsUserSuspended(c.OwnerID) + suspended, err := app.db.IsUserSilenced(c.OwnerID) if err != nil { log.Error("process protected collection permissions: %v", err) return nil, err @@ -666,9 +680,9 @@ func processCollectionPermissions(app *App, cr *collectionReq, u *User, w http.R } // See if we've authorized this collection - authd := isAuthorizedForCollection(app, c.Alias, r) + cr.isAuthorized = isAuthorizedForCollection(app, c.Alias, r) - if !authd { + if !cr.isAuthorized { p := struct { page.StaticPage *CollectionObj @@ -721,14 +735,14 @@ func newDisplayCollection(c *Collection, cr *collectionReq, page int) *DisplayCo return coll } +// getCollectionPage returns the collection page as an int. If the parsed page value is not +// greater than 0 then the default value of 1 is returned. func getCollectionPage(vars map[string]string) int { - page := 1 - var p int - p, _ = strconv.Atoi(vars["page"]) - if p > 0 { - page = p + if p, _ := strconv.Atoi(vars["page"]); p > 0 { + return p } - return page + + return 1 } // handleViewCollection displays the requested Collection @@ -754,7 +768,7 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro } c.hostName = app.cfg.App.Host - suspended, err := app.db.IsUserSuspended(c.OwnerID) + silenced, err := app.db.IsUserSilenced(c.OwnerID) if err != nil { log.Error("view collection: %v", err) return ErrInternalGeneral @@ -786,6 +800,7 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro // Serve collection displayPage := CollectionPage{ DisplayCollection: coll, + IsCollLoggedIn: cr.isAuthorized, StaticPage: pageForReq(app, r), IsCustomDomain: cr.isCustomDomain, IsWelcome: r.FormValue("greeting") != "", @@ -817,16 +832,17 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro log.Error("Error getting user for collection: %v", err) } } - if !isOwner && suspended { + if !isOwner && silenced { return ErrCollectionNotFound } - displayPage.Suspended = isOwner && suspended + displayPage.Silenced = isOwner && silenced displayPage.Owner = owner coll.Owner = displayPage.Owner // Add more data // TODO: fix this mess of collections inside collections displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner) + displayPage.Monetization = app.db.GetCollectionAttribute(coll.ID, "monetization_pointer") collTmpl := "collection" if app.cfg.App.Chorus { @@ -939,12 +955,13 @@ func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) e return ErrCollectionNotFound } } - displayPage.Suspended = owner != nil && owner.IsSilenced() + displayPage.Silenced = owner != nil && owner.IsSilenced() displayPage.Owner = owner coll.Owner = displayPage.Owner // Add more data // TODO: fix this mess of collections inside collections displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner) + displayPage.Monetization = app.db.GetCollectionAttribute(coll.ID, "monetization_pointer") err = templates["collection-tags"].ExecuteTemplate(w, "collection-tags", displayPage) if err != nil { @@ -993,14 +1010,14 @@ func existingCollection(app *App, w http.ResponseWriter, r *http.Request) error } } - suspended, err := app.db.IsUserSuspended(u.ID) + silenced, err := app.db.IsUserSilenced(u.ID) if err != nil { log.Error("existing collection: %v", err) return ErrInternalGeneral } - if suspended { - return ErrUserSuspended + if silenced { + return ErrUserSilenced } if r.Method == "DELETE" { @@ -1150,3 +1167,43 @@ func isAuthorizedForCollection(app *App, alias string, r *http.Request) bool { } return authd } + +func logOutCollection(app *App, alias string, w http.ResponseWriter, r *http.Request) error { + session, err := app.sessionStore.Get(r, blogPassCookieName) + if err != nil { + return err + } + + // Remove this from map of blogs logged into + delete(session.Values, alias) + + // If not auth'd with any blog, delete entire cookie + if len(session.Values) == 0 { + session.Options.MaxAge = -1 + } + return session.Save(r, w) +} + +func handleLogOutCollection(app *App, w http.ResponseWriter, r *http.Request) error { + alias := collectionAliasFromReq(r) + var c *Collection + var err error + if app.cfg.App.SingleUser { + c, err = app.db.GetCollectionByID(1) + } else { + c, err = app.db.GetCollection(alias) + } + if err != nil { + return err + } + if !c.IsProtected() { + // Invalid to log out of this collection + return ErrCollectionPageNotFound + } + + err = logOutCollection(app, c.Alias, w, r) + if err != nil { + addSessionFlash(app, w, r, "Logging out failed. Try clearing cookies for this site, instead.", nil) + } + return impart.HTTPError{http.StatusFound, c.CanonicalURL()} +} diff --git a/config.ini.example b/config.ini.example deleted file mode 100644 index dcbd6ee..0000000 --- a/config.ini.example +++ /dev/null @@ -1,26 +0,0 @@ -[server] -hidden_host = -port = 8080 - -[database] -type = mysql -username = root -password = changeme -database = writefreely -host = db -port = 3306 - -[app] -site_name = WriteFreely Example Blog! -host = http://localhost:8080 -theme = write -disable_js = false -webfonts = true -single_user = true -open_registration = false -min_username_len = 3 -max_blogs = 1 -federation = true -public_stats = true -private = false - diff --git a/config/config.go b/config/config.go index 2616e9e..8ee03ba 100644 --- a/config/config.go +++ b/config/config.go @@ -1,5 +1,5 @@ /* - * Copyright © 2018-2019 A Bunch Tell LLC. + * Copyright © 2018-2020 A Bunch Tell LLC. * * This file is part of WriteFreely. * @@ -12,8 +12,9 @@ package config import ( - "gopkg.in/ini.v1" "strings" + + "gopkg.in/ini.v1" ) const ( @@ -44,6 +45,8 @@ type ( HashSeed string `ini:"hash_seed"` + GopherPort int `ini:"gopher_port"` + Dev bool `ini:"-"` } @@ -56,6 +59,7 @@ type ( Database string `ini:"database"` Host string `ini:"host"` Port int `ini:"port"` + TLS bool `ini:"tls"` } WriteAsOauthCfg struct { @@ -68,6 +72,24 @@ type ( CallbackProxyAPI string `ini:"callback_proxy_api"` } + GitlabOauthCfg struct { + ClientID string `ini:"client_id"` + ClientSecret string `ini:"client_secret"` + Host string `ini:"host"` + DisplayName string `ini:"display_name"` + CallbackProxy string `ini:"callback_proxy"` + CallbackProxyAPI string `ini:"callback_proxy_api"` + } + + GiteaOauthCfg struct { + ClientID string `ini:"client_id"` + ClientSecret string `ini:"client_secret"` + Host string `ini:"host"` + DisplayName string `ini:"display_name"` + CallbackProxy string `ini:"callback_proxy"` + CallbackProxyAPI string `ini:"callback_proxy_api"` + } + SlackOauthCfg struct { ClientID string `ini:"client_id"` ClientSecret string `ini:"client_secret"` @@ -76,6 +98,24 @@ type ( CallbackProxyAPI string `ini:"callback_proxy_api"` } + GenericOauthCfg struct { + ClientID string `ini:"client_id"` + ClientSecret string `ini:"client_secret"` + Host string `ini:"host"` + DisplayName string `ini:"display_name"` + CallbackProxy string `ini:"callback_proxy"` + CallbackProxyAPI string `ini:"callback_proxy_api"` + TokenEndpoint string `ini:"token_endpoint"` + InspectEndpoint string `ini:"inspect_endpoint"` + AuthEndpoint string `ini:"auth_endpoint"` + Scope string `ini:"scope"` + AllowDisconnect bool `ini:"allow_disconnect"` + MapUserID string `ini:"map_user_id"` + MapUsername string `ini:"map_username"` + MapDisplayName string `ini:"map_display_name"` + MapEmail string `ini:"map_email"` + } + // AppCfg holds values that affect how the application functions AppCfg struct { SiteName string `ini:"site_name"` @@ -93,6 +133,7 @@ type ( // Site functionality Chorus bool `ini:"chorus"` + Forest bool `ini:"forest"` // The admin cares about the forest, not the trees. Hide unnecessary technical info. DisableDrafts bool `ini:"disable_drafts"` // Users @@ -101,9 +142,12 @@ type ( MinUsernameLen int `ini:"min_username_len"` MaxBlogs int `ini:"max_blogs"` + // Options for public instances // Federation - Federation bool `ini:"federation"` - PublicStats bool `ini:"public_stats"` + Federation bool `ini:"federation"` + PublicStats bool `ini:"public_stats"` + Monetization bool `ini:"monetization"` + NotesOnly bool `ini:"notes_only"` // Access Private bool `ini:"private"` @@ -114,6 +158,12 @@ type ( // Defaults DefaultVisibility string `ini:"default_visibility"` + + // Check for Updates + UpdateChecks bool `ini:"update_checks"` + + // Disable password authentication if use only Oauth + DisablePasswordAuth bool `ini:"disable_password_auth"` } // Config holds the complete configuration for running a writefreely instance @@ -123,6 +173,9 @@ type ( App AppCfg `ini:"app"` SlackOauth SlackOauthCfg `ini:"oauth.slack"` WriteAsOauth WriteAsOauthCfg `ini:"oauth.writeas"` + GitlabOauth GitlabOauthCfg `ini:"oauth.gitlab"` + GiteaOauth GiteaOauthCfg `ini:"oauth.gitea"` + GenericOauth GenericOauthCfg `ini:"oauth.generic"` } ) @@ -178,6 +231,16 @@ func (ac *AppCfg) LandingPath() string { return ac.Landing } +func (ac AppCfg) SignupPath() string { + if !ac.OpenRegistration { + return "" + } + if ac.Chorus || ac.Private || (ac.Landing != "" && ac.Landing != "/") { + return "/signup" + } + return "/" +} + // Load reads the given configuration file, then parses and returns it as a Config. func Load(fname string) (*Config, error) { if fname == "" { diff --git a/config/setup.go b/config/setup.go index fd5a632..08c479f 100644 --- a/config/setup.go +++ b/config/setup.go @@ -356,7 +356,7 @@ func Configure(fname string, configSections string) (*SetupData, error) { if data.Config.App.Federation { selPrompt = promptui.Select{ Templates: selTmpls, - Label: "Federation usage stats", + Label: "Usage stats (active users, posts)", Items: []string{"Public", "Private"}, } _, fedStatsType, err := selPrompt.Run() diff --git a/database-lib.go b/database-lib.go index 58beb05..8b28577 100644 --- a/database-lib.go +++ b/database-lib.go @@ -1,7 +1,7 @@ // +build wflib /* - * Copyright © 2019 A Bunch Tell LLC. + * Copyright © 2019-2020 A Bunch Tell LLC. * * This file is part of WriteFreely. * @@ -18,3 +18,11 @@ package writefreely func (db *datastore) isDuplicateKeyErr(err error) bool { return false } + +func (db *datastore) isIgnorableError(err error) bool { + return false +} + +func (db *datastore) isHighLoadError(err error) bool { + return false +} diff --git a/database-no-sqlite.go b/database-no-sqlite.go index 03d1a32..f2c7ffc 100644 --- a/database-no-sqlite.go +++ b/database-no-sqlite.go @@ -40,3 +40,13 @@ func (db *datastore) isIgnorableError(err error) bool { return false } + +func (db *datastore) isHighLoadError(err error) bool { + if db.driverName == driverMySQL { + if mysqlErr, ok := err.(*mysql.MySQLError); ok { + return mysqlErr.Number == mySQLErrMaxUserConns || mysqlErr.Number == mySQLErrTooManyConns + } + } + + return false +} diff --git a/database-sqlite.go b/database-sqlite.go index bd77e6a..10e701e 100644 --- a/database-sqlite.go +++ b/database-sqlite.go @@ -1,7 +1,7 @@ // +build sqlite,!wflib /* - * Copyright © 2019 A Bunch Tell LLC. + * Copyright © 2019-2020 A Bunch Tell LLC. * * This file is part of WriteFreely. * @@ -60,3 +60,13 @@ func (db *datastore) isIgnorableError(err error) bool { return false } + +func (db *datastore) isHighLoadError(err error) bool { + if db.driverName == driverMySQL { + if mysqlErr, ok := err.(*mysql.MySQLError); ok { + return mysqlErr.Number == mySQLErrMaxUserConns || mysqlErr.Number == mySQLErrTooManyConns + } + } + + return false +} diff --git a/database.go b/database.go index e114d3a..88b46e5 100644 --- a/database.go +++ b/database.go @@ -1,5 +1,5 @@ /* - * Copyright © 2018-2020 A Bunch Tell LLC. + * Copyright © 2018-2021 A Bunch Tell LLC. * * This file is part of WriteFreely. * @@ -14,7 +14,8 @@ import ( "context" "database/sql" "fmt" - wf_db "github.com/writeas/writefreely/db" + "github.com/writeas/web-core/silobridge" + wf_db "github.com/writefreely/writefreely/db" "net/http" "strings" "time" @@ -24,21 +25,22 @@ import ( uuid "github.com/nu7hatch/gouuid" "github.com/writeas/activityserve" "github.com/writeas/impart" - "github.com/writeas/nerds/store" "github.com/writeas/web-core/activitypub" "github.com/writeas/web-core/auth" "github.com/writeas/web-core/data" "github.com/writeas/web-core/id" "github.com/writeas/web-core/log" "github.com/writeas/web-core/query" - "github.com/writeas/writefreely/author" - "github.com/writeas/writefreely/config" - "github.com/writeas/writefreely/key" + "github.com/writefreely/writefreely/author" + "github.com/writefreely/writefreely/config" + "github.com/writefreely/writefreely/key" ) const ( mySQLErrDuplicateKey = 1062 mySQLErrCollationMix = 1267 + mySQLErrTooManyConns = 1040 + mySQLErrMaxUserConns = 1203 driverMySQL = "mysql" driverSQLite = "sqlite3" @@ -130,8 +132,10 @@ type writestore interface { GetIDForRemoteUser(context.Context, string, string, string) (int64, error) RecordRemoteUserID(context.Context, int64, string, string, string, string) error - ValidateOAuthState(context.Context, string) (string, string, error) - GenerateOAuthState(context.Context, string, string) (string, error) + ValidateOAuthState(context.Context, string) (string, string, int64, string, error) + GenerateOAuthState(context.Context, string, string, int64, string) (string, error) + GetOauthAccounts(ctx context.Context, userID int64) ([]oauthAccountInfo, error) + RemoveOauth(ctx context.Context, userID int64, provider string, clientID string, remoteUserID string) error DatabaseInitialized() bool } @@ -174,6 +178,7 @@ func (db *datastore) dateSub(l int, unit string) string { return fmt.Sprintf("DATE_SUB(NOW(), INTERVAL %d %s)", l, unit) } +// CreateUser creates a new user in the database from the given User, UPDATING it in the process with the user's ID. func (db *datastore) CreateUser(cfg *config.Config, u *User, collectionTitle string) error { if db.PostIDExists(u.Username) { return impart.HTTPError{http.StatusConflict, "Invalid collection name."} @@ -319,18 +324,18 @@ func (db *datastore) GetUserByID(id int64) (*User, error) { return u, nil } -// IsUserSuspended returns true if the user account associated with id is -// currently suspended. -func (db *datastore) IsUserSuspended(id int64) (bool, error) { +// IsUserSilenced returns true if the user account associated with id is +// currently silenced. +func (db *datastore) IsUserSilenced(id int64) (bool, error) { u := &User{ID: id} err := db.QueryRow("SELECT status FROM users WHERE id = ?", id).Scan(&u.Status) switch { case err == sql.ErrNoRows: - return false, fmt.Errorf("is user suspended: %v", ErrUserNotFound) + return false, fmt.Errorf("is user silenced: %v", ErrUserNotFound) case err != nil: - log.Error("Couldn't SELECT user password: %v", err) - return false, fmt.Errorf("is user suspended: %v", err) + log.Error("Couldn't SELECT user status: %v", err) + return false, fmt.Errorf("is user silenced: %v", err) } return u.IsSilenced(), nil @@ -607,7 +612,7 @@ func (db *datastore) CreateOwnedPost(post *SubmittedPost, accessToken, collAlias func (db *datastore) CreatePost(userID, collID int64, post *SubmittedPost) (*Post, error) { idLen := postIDLen - friendlyID := store.GenerateFriendlyRandomString(idLen) + friendlyID := id.GenerateFriendlyRandomString(idLen) // Handle appearance / font face appearance := post.Font @@ -632,13 +637,17 @@ func (db *datastore) CreatePost(userID, collID int64, post *SubmittedPost) (*Pos ownerCollID.Int64 = collID ownerCollID.Valid = true var slugVal string - if post.Title != nil && *post.Title != "" { - slugVal = getSlug(*post.Title, post.Language.String) - if slugVal == "" { + if post.Slug != nil && *post.Slug != "" { + slugVal = *post.Slug + } else { + if post.Title != nil && *post.Title != "" { + slugVal = getSlug(*post.Title, post.Language.String) + if slugVal == "" { + slugVal = getSlug(*post.Content, post.Language.String) + } + } else { slugVal = getSlug(*post.Content, post.Language.String) } - } else { - slugVal = getSlug(*post.Content, post.Language.String) } if slugVal == "" { slugVal = friendlyID @@ -786,19 +795,22 @@ func (db *datastore) GetCollectionBy(condition string, value interface{}) (*Coll c := &Collection{} // FIXME: change Collection to reflect database values. Add helper functions to get actual values - var styleSheet, script, format zero.String - row := db.QueryRow("SELECT id, alias, title, description, style_sheet, script, format, owner_id, privacy, view_count FROM collections WHERE "+condition, value) + var styleSheet, script, signature, format zero.String + row := db.QueryRow("SELECT id, alias, title, description, style_sheet, script, post_signature, format, owner_id, privacy, view_count FROM collections WHERE "+condition, value) - err := row.Scan(&c.ID, &c.Alias, &c.Title, &c.Description, &styleSheet, &script, &format, &c.OwnerID, &c.Visibility, &c.Views) + err := row.Scan(&c.ID, &c.Alias, &c.Title, &c.Description, &styleSheet, &script, &signature, &format, &c.OwnerID, &c.Visibility, &c.Views) switch { case err == sql.ErrNoRows: return nil, impart.HTTPError{http.StatusNotFound, "Collection doesn't exist."} + case db.isHighLoadError(err): + return nil, ErrUnavailable case err != nil: log.Error("Failed selecting from collections: %v", err) return nil, err } c.StyleSheet = styleSheet.String c.Script = script.String + c.Signature = signature.String c.Format = format.String c.Public = c.IsPublic() @@ -842,7 +854,8 @@ func (db *datastore) UpdateCollection(c *SubmittedCollection, alias string) erro SetStringPtr(c.Title, "title"). SetStringPtr(c.Description, "description"). SetNullString(c.StyleSheet, "style_sheet"). - SetNullString(c.Script, "script") + SetNullString(c.Script, "script"). + SetNullString(c.Signature, "post_signature") if c.Format != nil { cf := &CollectionFormat{Format: c.Format.String} @@ -895,6 +908,29 @@ func (db *datastore) UpdateCollection(c *SubmittedCollection, alias string) erro } } + // Update Monetization value + if c.Monetization != nil { + skipUpdate := false + if *c.Monetization != "" { + // Strip away any excess spaces + trimmed := strings.TrimSpace(*c.Monetization) + // Only update value when it starts with "$", per spec: https://paymentpointers.org + if strings.HasPrefix(trimmed, "$") { + c.Monetization = &trimmed + } else { + // Value appears invalid, so don't update + skipUpdate = true + } + } + if !skipUpdate { + _, err = db.Exec("INSERT INTO collectionattributes (collection_id, attribute, value) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE value = ?", collID, "monetization_pointer", *c.Monetization, *c.Monetization) + if err != nil { + log.Error("Unable to insert monetization_pointer value: %v", err) + return err + } + } + } + // Update rest of the collection data res, err = db.Exec("UPDATE collections SET "+q.Updates+" WHERE "+q.Conditions, q.Params...) if err != nil { @@ -1143,6 +1179,7 @@ func (db *datastore) GetPosts(cfg *config.Config, c *Collection, page int, inclu break } p.extractData() + p.augmentContent(c) p.formatContent(cfg, c, includeFuture) posts = append(posts, p.processPost()) @@ -1207,6 +1244,7 @@ func (db *datastore) GetPostsTagged(cfg *config.Config, c *Collection, tag strin break } p.extractData() + p.augmentContent(c) p.formatContent(cfg, c, includeFuture) posts = append(posts, p.processPost()) @@ -1583,6 +1621,7 @@ func (db *datastore) GetPinnedPosts(coll *CollectionObj, includeFuture bool) (*[ break } p.extractData() + p.augmentContent(&coll.Collection) pp := p.processPost() pp.Collection = coll @@ -1633,6 +1672,40 @@ func (db *datastore) GetPublishableCollections(u *User, hostName string) (*[]Col return c, nil } +func (db *datastore) GetPublicCollections(hostName string) (*[]Collection, error) { + rows, err := db.Query(`SELECT c.id, alias, title, description, privacy, view_count + FROM collections c + LEFT JOIN users u ON u.id = c.owner_id + WHERE c.privacy = 1 AND u.status = 0 + ORDER BY id ASC`) + if err != nil { + log.Error("Failed selecting public collections: %v", err) + return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve public collections."} + } + defer rows.Close() + + colls := []Collection{} + for rows.Next() { + c := Collection{} + err = rows.Scan(&c.ID, &c.Alias, &c.Title, &c.Description, &c.Visibility, &c.Views) + if err != nil { + log.Error("Failed scanning row: %v", err) + break + } + c.hostName = hostName + c.URL = c.CanonicalURL() + c.Public = c.IsPublic() + + colls = append(colls, c) + } + err = rows.Err() + if err != nil { + log.Error("Error after Next() on rows: %v", err) + } + + return &colls, nil +} + func (db *datastore) GetMeStats(u *User) userMeStats { s := userMeStats{} @@ -2016,7 +2089,7 @@ func (db *datastore) RemoveCollectionRedirect(t *sql.Tx, alias string) error { func (db *datastore) GetCollectionRedirect(alias string) (new string) { row := db.QueryRow("SELECT new_alias FROM collectionredirects WHERE prev_alias = ?", alias) err := row.Scan(&new) - if err != nil && err != sql.ErrNoRows { + if err != nil && err != sql.ErrNoRows && !db.isIgnorableError(err) { log.Error("Failed selecting from collectionredirects: %v", err) } return @@ -2115,6 +2188,28 @@ func (db *datastore) CollectionHasAttribute(id int64, attr string) bool { return true } +func (db *datastore) GetCollectionAttribute(id int64, attr string) string { + var v string + err := db.QueryRow("SELECT value FROM collectionattributes WHERE collection_id = ? AND attribute = ?", id, attr).Scan(&v) + switch { + case err == sql.ErrNoRows: + return "" + case err != nil: + log.Error("Couldn't SELECT value in getCollectionAttribute for attribute '%s': %v", attr, err) + return "" + } + return v +} + +func (db *datastore) SetCollectionAttribute(id int64, attr, v string) error { + _, err := db.Exec("INSERT INTO collectionattributes (collection_id, attribute, value) VALUES (?, ?, ?)", id, attr, v) + if err != nil { + log.Error("Unable to INSERT into collectionattributes: %v", err) + return err + } + return nil +} + // DeleteAccount will delete the entire account for userID func (db *datastore) DeleteAccount(userID int64) error { // Get all collections @@ -2510,20 +2605,26 @@ func (db *datastore) GetCollectionLastPostTime(id int64) (*time.Time, error) { return &t, nil } -func (db *datastore) GenerateOAuthState(ctx context.Context, provider, clientID string) (string, error) { - state := store.Generate62RandomString(24) - _, err := db.ExecContext(ctx, "INSERT INTO oauth_client_states (state, provider, client_id, used, created_at) VALUES (?, ?, ?, FALSE, NOW())", state, provider, clientID) +func (db *datastore) GenerateOAuthState(ctx context.Context, provider string, clientID string, attachUser int64, inviteCode string) (string, error) { + state := id.Generate62RandomString(24) + attachUserVal := sql.NullInt64{Valid: attachUser > 0, Int64: attachUser} + inviteCodeVal := sql.NullString{Valid: inviteCode != "", String: inviteCode} + _, err := db.ExecContext(ctx, "INSERT INTO oauth_client_states (state, provider, client_id, used, created_at, attach_user_id, invite_code) VALUES (?, ?, ?, FALSE, "+db.now()+", ?, ?)", state, provider, clientID, attachUserVal, inviteCodeVal) if err != nil { return "", fmt.Errorf("unable to record oauth client state: %w", err) } return state, nil } -func (db *datastore) ValidateOAuthState(ctx context.Context, state string) (string, string, error) { +func (db *datastore) ValidateOAuthState(ctx context.Context, state string) (string, string, int64, string, error) { var provider string var clientID string + var attachUserID sql.NullInt64 + var inviteCode sql.NullString err := wf_db.RunTransactionWithOptions(ctx, db.DB, &sql.TxOptions{}, func(ctx context.Context, tx *sql.Tx) error { - err := tx.QueryRow("SELECT provider, client_id FROM oauth_client_states WHERE state = ? AND used = FALSE", state).Scan(&provider, &clientID) + err := tx. + QueryRowContext(ctx, "SELECT provider, client_id, attach_user_id, invite_code FROM oauth_client_states WHERE state = ? AND used = FALSE", state). + Scan(&provider, &clientID, &attachUserID, &inviteCode) if err != nil { return err } @@ -2542,9 +2643,9 @@ func (db *datastore) ValidateOAuthState(ctx context.Context, state string) (stri return nil }) if err != nil { - return "", "", nil + return "", "", 0, "", nil } - return provider, clientID, nil + return provider, clientID, attachUserID.Int64, inviteCode.String, nil } func (db *datastore) RecordRemoteUserID(ctx context.Context, localUserID int64, remoteUserID, provider, clientID, accessToken string) error { @@ -2573,6 +2674,35 @@ func (db *datastore) GetIDForRemoteUser(ctx context.Context, remoteUserID, provi return userID, nil } +type oauthAccountInfo struct { + Provider string + ClientID string + RemoteUserID string + DisplayName string + AllowDisconnect bool +} + +func (db *datastore) GetOauthAccounts(ctx context.Context, userID int64) ([]oauthAccountInfo, error) { + rows, err := db.QueryContext(ctx, "SELECT provider, client_id, remote_user_id FROM oauth_users WHERE user_id = ? ", userID) + if err != nil { + log.Error("Failed selecting from oauth_users: %v", err) + return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve user oauth accounts."} + } + defer rows.Close() + + var records []oauthAccountInfo + for rows.Next() { + info := oauthAccountInfo{} + err = rows.Scan(&info.Provider, &info.ClientID, &info.RemoteUserID) + if err != nil { + log.Error("Failed scanning GetAllUsers() row: %v", err) + break + } + records = append(records, info) + } + return records, nil +} + // DatabaseInitialized returns whether or not the current datastore has been // initialized with the correct schema. // Currently, it checks to see if the `users` table exists. @@ -2595,6 +2725,11 @@ func (db *datastore) DatabaseInitialized() bool { return true } +func (db *datastore) RemoveOauth(ctx context.Context, userID int64, provider string, clientID string, remoteUserID string) error { + _, err := db.ExecContext(ctx, `DELETE FROM oauth_users WHERE user_id = ? AND provider = ? AND client_id = ? AND remote_user_id = ?`, userID, provider, clientID, remoteUserID) + return err +} + func stringLogln(log *string, s string, v ...interface{}) { *log += fmt.Sprintf(s+"\n", v...) } @@ -2605,7 +2740,19 @@ func handleFailedPostInsert(err error) error { } func (db *datastore) GetProfilePageFromHandle(app *App, handle string) (string, error) { + handle = strings.TrimLeft(handle, "@") actorIRI := "" + parts := strings.Split(handle, "@") + if len(parts) != 2 { + return "", fmt.Errorf("invalid handle format") + } + domain := parts[1] + + // Check non-AP instances + if siloProfileURL := silobridge.Profile(parts[0], domain); siloProfileURL != "" { + return siloProfileURL, nil + } + remoteUser, err := getRemoteUserFromHandle(app, handle) if err != nil { // can't find using handle in the table but the table may already have this user without @@ -2617,21 +2764,21 @@ func (db *datastore) GetProfilePageFromHandle(app *App, handle string) (string, if errRemoteUser == nil { _, err := app.db.Exec("UPDATE remoteusers SET handle = ? WHERE actor_id = ?", handle, actorIRI) if err != nil { - log.Error("Can't update handle (" + handle + ") in database for user " + actorIRI) + log.Error("Couldn't update handle '%s' for user %s", handle, actorIRI) } } else { // this probably means we don't have the user in the table so let's try to insert it // here we need to ask the server for the inboxes remoteActor, err := activityserve.NewRemoteActor(actorIRI) if err != nil { - log.Error("Couldn't fetch remote actor", err) + log.Error("Couldn't fetch remote actor: %v", err) } if debugging { log.Info("%s %s %s %s", actorIRI, remoteActor.GetInbox(), remoteActor.GetSharedInbox(), handle) } _, err = app.db.Exec("INSERT INTO remoteusers (actor_id, inbox, shared_inbox, handle) VALUES(?, ?, ?, ?)", actorIRI, remoteActor.GetInbox(), remoteActor.GetSharedInbox(), handle) if err != nil { - log.Error("Can't insert remote user in database", err) + log.Error("Couldn't insert remote user: %v", err) return "", err } } diff --git a/database_test.go b/database_test.go index c4c586a..c114077 100644 --- a/database_test.go +++ b/database_test.go @@ -18,13 +18,13 @@ func TestOAuthDatastore(t *testing.T) { driverName: "", } - state, err := ds.GenerateOAuthState(ctx, "test", "development") + state, err := ds.GenerateOAuthState(ctx, "test", "development", 0, "") assert.NoError(t, err) assert.Len(t, state, 24) countRows(t, ctx, db, 1, "SELECT COUNT(*) FROM `oauth_client_states` WHERE `state` = ? AND `used` = false", state) - _, _, err = ds.ValidateOAuthState(ctx, state) + _, _, _, _, err = ds.ValidateOAuthState(ctx, state) assert.NoError(t, err) countRows(t, ctx, db, 1, "SELECT COUNT(*) FROM `oauth_client_states` WHERE `state` = ? AND `used` = true", state) diff --git a/db/create.go b/db/create.go index c384778..648f93a 100644 --- a/db/create.go +++ b/db/create.go @@ -1,3 +1,13 @@ +/* + * Copyright © 2019-2020 A Bunch Tell LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + package db import ( @@ -139,6 +149,15 @@ func (c *Column) SetDefault(value string) *Column { return c } +func (c *Column) SetDefaultCurrentTimestamp() *Column { + def := "NOW()" + if c.Dialect == DialectSQLite { + def = "CURRENT_TIMESTAMP" + } + c.Default = OptionalString{Set: true, Value: def} + return c +} + func (c *Column) SetType(t ColumnType) *Column { c.Type = t return c @@ -168,7 +187,11 @@ func (c *Column) String() (string, error) { if c.Default.Set { str.WriteString(" DEFAULT ") - str.WriteString(c.Default.Value) + val := c.Default.Value + if val == "" { + val = "''" + } + str.WriteString(val) } if c.PrimaryKey { @@ -241,4 +264,3 @@ func (b *CreateTableSqlBuilder) ToSQL() (string, error) { return str.String(), nil } - diff --git a/docker-compose.yml b/docker-compose.yml index 29a841e..652ce57 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,32 +1,47 @@ version: "3" -services: - web: - build: . - volumes: - - "web-data:/go/src/app" - - "./config.ini.example:/go/src/app/config.ini" - ports: - - "8080:8080" - networks: - - writefreely - depends_on: - - db - restart: unless-stopped - db: - image: "mariadb:latest" - volumes: - - "./schema.sql:/tmp/schema.sql" - - db-data:/var/lib/mysql/data - networks: - - writefreely - environment: - - MYSQL_DATABASE=writefreely - - MYSQL_ROOT_PASSWORD=changeme - restart: unless-stopped volumes: - web-data: + web-keys: db-data: networks: - writefreely: + external_writefreely: + internal_writefreely: + internal: true + +services: + writefreely-web: + container_name: "writefreely-web" + image: "writeas/writefreely:latest" + + volumes: + - "web-keys:/go/keys" + - "./config.ini:/go/config.ini" + + networks: + - "internal_writefreely" + - "external_writefreely" + + ports: + - "8080:8080" + + depends_on: + - "writefreely-db" + + restart: unless-stopped + + writefreely-db: + container_name: "writefreely-db" + image: "mariadb:latest" + + volumes: + - "db-data:/var/lib/mysql/data" + + networks: + - "internal_writefreely" + + environment: + - MYSQL_DATABASE=writefreely + - MYSQL_ROOT_PASSWORD=changeme + + restart: unless-stopped diff --git a/errors.go b/errors.go index 1da713a..cf52df1 100644 --- a/errors.go +++ b/errors.go @@ -37,6 +37,8 @@ var ( ErrInternalGeneral = impart.HTTPError{http.StatusInternalServerError, "The humans messed something up. They've been notified."} ErrInternalCookieSession = impart.HTTPError{http.StatusInternalServerError, "Could not get cookie session."} + ErrUnavailable = impart.HTTPError{http.StatusServiceUnavailable, "Service temporarily unavailable due to high load."} + ErrCollectionNotFound = impart.HTTPError{http.StatusNotFound, "Collection doesn't exist."} ErrCollectionGone = impart.HTTPError{http.StatusGone, "This blog was unpublished."} ErrCollectionPageNotFound = impart.HTTPError{http.StatusNotFound, "Collection page doesn't exist."} @@ -49,7 +51,9 @@ var ( ErrRemoteUserNotFound = impart.HTTPError{http.StatusNotFound, "Remote user not found."} ErrUserNotFoundEmail = impart.HTTPError{http.StatusNotFound, "Please enter your username instead of your email address."} - ErrUserSuspended = impart.HTTPError{http.StatusForbidden, "Account is silenced."} + ErrUserSilenced = impart.HTTPError{http.StatusForbidden, "Account is silenced."} + + ErrDisabledPasswordAuth = impart.HTTPError{http.StatusForbidden, "Password authentication is disabled."} ) // Post operation errors diff --git a/feed.go b/feed.go index 44bb331..3062e26 100644 --- a/feed.go +++ b/feed.go @@ -1,5 +1,5 @@ /* - * Copyright © 2018-2019 A Bunch Tell LLC. + * Copyright © 2018-2020 A Bunch Tell LLC. * * This file is part of WriteFreely. * @@ -36,12 +36,12 @@ func ViewFeed(app *App, w http.ResponseWriter, req *http.Request) error { return nil } - suspended, err := app.db.IsUserSuspended(c.OwnerID) + silenced, err := app.db.IsUserSilenced(c.OwnerID) if err != nil { log.Error("view feed: get user: %v", err) return ErrInternalGeneral } - if suspended { + if silenced { return ErrCollectionNotFound } c.hostName = app.cfg.App.Host @@ -104,7 +104,7 @@ func ViewFeed(app *App, w http.ResponseWriter, req *http.Request) error { Title: title, Link: &Link{Href: permalink}, Description: "", - Content: applyMarkdown([]byte(p.Content), "", app.cfg), + Content: string(p.HTMLContent), Author: &Author{author, ""}, Created: p.Created, Updated: p.Updated, diff --git a/go.mod b/go.mod index 68cbe22..3006fa6 100644 --- a/go.mod +++ b/go.mod @@ -1,66 +1,49 @@ -module github.com/writeas/writefreely +module github.com/writefreely/writefreely require ( - github.com/BurntSushi/toml v0.3.1 // indirect - github.com/alecthomas/gometalinter v3.0.0+incompatible // indirect - github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf // indirect - github.com/captncraig/cors v0.0.0-20180620154129-376d45073b49 // indirect github.com/clbanning/mxj v1.8.4 // indirect - github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9 // indirect github.com/dustin/go-humanize v1.0.0 - github.com/fatih/color v1.7.0 - github.com/go-fed/httpsig v0.1.1-0.20190924171022-f4c36041199d // indirect - github.com/go-sql-driver/mysql v1.4.1 + github.com/fatih/color v1.10.0 + github.com/go-sql-driver/mysql v1.6.0 github.com/go-test/deep v1.0.1 // indirect - github.com/golang/lint v0.0.0-20181217174547-8f45f776aaf1 // indirect - github.com/gologme/log v0.0.0-20181207131047-4e5d8ccb38e8 // indirect github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect - github.com/gorilla/feeds v1.1.0 - github.com/gorilla/mux v1.7.0 - github.com/gorilla/schema v1.0.2 + github.com/gorilla/feeds v1.1.1 + github.com/gorilla/mux v1.8.0 + github.com/gorilla/schema v1.2.0 github.com/gorilla/sessions v1.2.0 - github.com/guregu/null v3.4.0+incompatible - github.com/hashicorp/go-multierror v1.0.0 + github.com/guregu/null v3.5.0+incompatible + github.com/hashicorp/go-multierror v1.1.1 github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2 github.com/jtolds/gls v4.2.1+incompatible // indirect github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec github.com/lunixbochs/vtclean v1.0.0 // indirect - github.com/manifoldco/promptui v0.3.2 - github.com/mattn/go-colorable v0.1.0 // indirect - github.com/mattn/go-sqlite3 v1.10.0 - github.com/microcosm-cc/bluemonday v1.0.2 - github.com/mitchellh/go-wordwrap v1.0.0 - github.com/nicksnyder/go-i18n v1.10.0 // indirect + github.com/manifoldco/promptui v0.8.0 + github.com/mattn/go-sqlite3 v1.14.6 + github.com/microcosm-cc/bluemonday v1.0.5 + github.com/mitchellh/go-wordwrap v1.0.1 github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d - github.com/pelletier/go-toml v1.2.0 // indirect github.com/pkg/errors v0.8.1 // indirect + github.com/prologic/go-gopher v0.0.0-20200721020712-3e11dcff0469 github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 // indirect github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c // indirect - github.com/stretchr/testify v1.3.0 + github.com/stretchr/testify v1.7.0 + github.com/urfave/cli/v2 v2.3.0 github.com/writeas/activity v0.1.2 - github.com/writeas/activityserve v0.0.0-20191115095800-dd6d19cc8b89 + github.com/writeas/activityserve v0.0.0-20200409150223-d7ab3eaa4481 github.com/writeas/go-strip-markdown v2.0.1+incompatible - github.com/writeas/go-webfinger v0.0.0-20190106002315-85cf805c86d2 + github.com/writeas/go-webfinger v1.1.0 github.com/writeas/httpsig v1.0.0 - github.com/writeas/impart v1.1.1-0.20191230230525-d3c45ced010d - github.com/writeas/import v0.2.0 + github.com/writeas/impart v1.1.1 + github.com/writeas/import v0.2.1 github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219 - github.com/writeas/nerds v1.0.0 - github.com/writeas/saturday v1.7.1 + github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320 github.com/writeas/slug v1.2.0 - github.com/writeas/web-core v1.2.0 + github.com/writeas/web-core v1.3.1-0.20210330164422-95a3a717ed8f github.com/writefreely/go-nodeinfo v1.2.0 - golang.org/x/crypto v0.0.0-20190208162236-193df9c0f06f - golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1 // indirect - golang.org/x/net v0.0.0-20190206173232-65e2d4e15006 // indirect - golang.org/x/sys v0.0.0-20190209173611-3b5209105503 // indirect - golang.org/x/tools v0.0.0-20190208222737-3744606dbb67 // indirect - google.golang.org/appengine v1.4.0 // indirect - gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c // indirect - gopkg.in/ini.v1 v1.41.0 - gopkg.in/yaml.v2 v2.2.2 // indirect - src.techknowlogick.com/xgo v0.0.0-20200129005940-d0fae26e014b // indirect + golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 + golang.org/x/net v0.0.0-20200707034311-ab3426394381 // indirect + gopkg.in/ini.v1 v1.62.0 ) go 1.13 diff --git a/go.sum b/go.sum index de849c4..b8adf04 100644 --- a/go.sum +++ b/go.sum @@ -1,16 +1,14 @@ code.as/core/socks v1.0.0 h1:SPQXNp4SbEwjOAP9VzUahLHak8SDqy5n+9cm9tpjZOs= code.as/core/socks v1.0.0/go.mod h1:BAXBy5O9s2gmw6UxLqNJcVbWY7C/UPs+801CcSsfWOY= -github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/alecthomas/gometalinter v2.0.11+incompatible/go.mod h1:qfIpQGGz3d+NmgyPBqv+LSh50emm1pt72EtcX2vKYQk= -github.com/alecthomas/gometalinter v3.0.0+incompatible h1:e9Zfvfytsw/e6Kd/PYd75wggK+/kX5Xn8IYDUKyc5fU= -github.com/alecthomas/gometalinter v3.0.0+incompatible/go.mod h1:qfIpQGGz3d+NmgyPBqv+LSh50emm1pt72EtcX2vKYQk= -github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf h1:qet1QNfXsQxTZqLG4oE62mJzwPIB8+Tee4RNCL9ulrY= -github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs= github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= -github.com/captncraig/cors v0.0.0-20180620154129-376d45073b49 h1:jWNY1NDg6a/c8RSXkai7IX6UOhir0LD39I4Dukg+4Ks= -github.com/captncraig/cors v0.0.0-20180620154129-376d45073b49/go.mod h1:EIlIeMufZ8nqdUhnesledB15xLRl4wIJUppwDLPrdrQ= +github.com/captncraig/cors v0.0.0-20190703115713-e80254a89df1 h1:AFSJaASPGYNbkUa5c8ZybrcW9pP3Cy7+z5dnpcc/qG8= +github.com/captncraig/cors v0.0.0-20190703115713-e80254a89df1/go.mod h1:EIlIeMufZ8nqdUhnesledB15xLRl4wIJUppwDLPrdrQ= +github.com/chris-ramon/douceur v0.2.0 h1:IDMEdxlEUUBYBKE4z/mJnFyVXox+MjuEVDJNN27glkU= +github.com/chris-ramon/douceur v0.2.0/go.mod h1:wDW5xjJdeoMm1mRt4sD4c/LbF/mWdEpRXQKjTR8nIBE= github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= @@ -20,55 +18,52 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn github.com/clbanning/mxj v1.8.3/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng= github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I= github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng= -github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9 h1:74lLNRzvsdIlkTgfDSMuaPjBr4cf6k7pwQQANm/yLKU= -github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4= +github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 h1:RAV05c0xOkJ3dZGS0JFybxFKZ2WMLabgx3uXnd7rpGs= +github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4= github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= -github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= -github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/color v1.10.0 h1:s36xzo75JdqLaaWoiEHk767eHiwo0598uUxyfiPkDsg= +github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= -github.com/go-fed/httpsig v0.1.0 h1:6F2OxRVnNTN4OPN+Mc2jxs2WEay9/qiHT/jphlvAwIY= github.com/go-fed/httpsig v0.1.0/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE= -github.com/go-fed/httpsig v0.1.1-0.20190924171022-f4c36041199d h1:+uoOvOnNDgsYbWtAij4xP6Rgir3eJGjocFPxBJETU/U= -github.com/go-fed/httpsig v0.1.1-0.20190924171022-f4c36041199d/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE= -github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA= -github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/go-fed/httpsig v0.1.1-0.20200204213531-0ef28562fabe h1:U71giCx5NjRn4Lb71UuprPHqhjxGv3Jqonb9fgcaJH8= +github.com/go-fed/httpsig v0.1.1-0.20200204213531-0ef28562fabe/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE= +github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= +github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-test/deep v1.0.1 h1:UQhStjbkDClarlmv0am7OXXO4/GaPdCGiUiMTvi28sg= github.com/go-test/deep v1.0.1/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= -github.com/golang/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= -github.com/golang/lint v0.0.0-20181217174547-8f45f776aaf1 h1:6DVPu65tee05kY0/rciBQ47ue+AnuY8KTayV6VHikIo= -github.com/golang/lint v0.0.0-20181217174547-8f45f776aaf1/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/gologme/log v0.0.0-20181207131047-4e5d8ccb38e8 h1:WD8iJ37bRNwvETMfVTusVSAi0WdXTpfNVGY2aHycNKY= -github.com/gologme/log v0.0.0-20181207131047-4e5d8ccb38e8/go.mod h1:gq31gQ8wEHkR+WekdWsqDuf8pXTUZA9BnnzTuPz1Y9U= -github.com/google/shlex v0.0.0-20181106134648-c34317bd91bf h1:7+FW5aGwISbqUtkfmIpZJGRgNFg2ioYPvFaUxdqpDsg= -github.com/google/shlex v0.0.0-20181106134648-c34317bd91bf/go.mod h1:RpwtwJQFrIEPstU94h88MWPXP2ektJZ8cZ0YntAmXiE= +github.com/gofrs/uuid v3.3.0+incompatible h1:8K4tyRfvU1CYPgJsveYFQMhpFd/wXNM7iK6rR7UHz84= +github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gologme/log v1.2.0 h1:Ya5Ip/KD6FX7uH0S31QO87nCCSucKtF44TLbTtO7V4c= +github.com/gologme/log v1.2.0/go.mod h1:gq31gQ8wEHkR+WekdWsqDuf8pXTUZA9BnnzTuPz1Y9U= github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg= github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= -github.com/gordonklaus/ineffassign v0.0.0-20180909121442-1003c8bd00dc h1:cJlkeAx1QYgO5N80aF5xRGstVsRQwgLR7uA2FnP1ZjY= -github.com/gordonklaus/ineffassign v0.0.0-20180909121442-1003c8bd00dc/go.mod h1:cuNKsD1zp2v6XfE/orVX2QE1LC+i254ceGcVeDT3pTU= -github.com/gorilla/feeds v1.1.0 h1:pcgLJhbdYgaUESnj3AmXPcB7cS3vy63+jC/TI14AGXk= -github.com/gorilla/feeds v1.1.0/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA= -github.com/gorilla/mux v1.7.0 h1:tOSd0UKHQd6urX6ApfOn4XdBMY6Sh1MfxV3kmaazO+U= -github.com/gorilla/mux v1.7.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= -github.com/gorilla/schema v1.0.2 h1:sAgNfOcNYvdDSrzGHVy9nzCQahG+qmsg+nE8dK85QRA= -github.com/gorilla/schema v1.0.2/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= +github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= +github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= +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/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc= +github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.2.0 h1:S7P+1Hm5V/AT9cjEcUD5uDaQSX0OE577aCXgoaKpYbQ= github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= -github.com/guregu/null v3.4.0+incompatible h1:a4mw37gBO7ypcBlTJeZGuMpSxxFTV9qFfFKgWxQSGaM= -github.com/guregu/null v3.4.0+incompatible/go.mod h1:ePGpQaN9cw0tj45IR5E5ehMvsFlLlQZAkkOXZurJ3NM= +github.com/guregu/null v3.5.0+incompatible h1:fSdvRTQtmBA4B4YDZXhLtxTIJZYuUxBFTTHS4B9djG4= +github.com/guregu/null v3.5.0+incompatible/go.mod h1:ePGpQaN9cw0tj45IR5E5ehMvsFlLlQZAkkOXZurJ3NM= github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2 h1:wIdDEle9HEy7vBPjC6oKz6ejs3Ut+jmsYvuOoAW2pSM= github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2/go.mod h1:WtaVKD9TeruTED9ydiaOJU08qGoEPP/LyzTKiD3jEsw= github.com/jtolds/gls v4.2.1+incompatible h1:fSuqC+Gmlu6l/ZYAoZzx2pyucC8Xza35fpRVWLVmUEE= @@ -82,36 +77,36 @@ 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/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec h1:ZXWuspqypleMuJy4bzYEqlMhJnGAYpLrWe5p7W3CdvI= github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec/go.mod h1:voECJzdraJmolzPBgL9Z7ANwXf4oMXaTCsIkdiPpR/g= -github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a h1:weJVJJRzAJBFRlAiJQROKQs8oC9vOxvm4rZmBBk0ONw= github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= github.com/lunixbochs/vtclean v1.0.0 h1:xu2sLAri4lGiovBDQKxl5mrXyESr3gUr5m5SM5+LVb8= github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= -github.com/manifoldco/promptui v0.3.2 h1:rir7oByTERac6jhpHUPErHuopoRDvO3jxS+FdadEns8= -github.com/manifoldco/promptui v0.3.2/go.mod h1:8JU+igZ+eeiiRku4T5BjtKh2ms8sziGpSYl1gN8Bazw= -github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4= +github.com/manifoldco/promptui v0.8.0 h1:R95mMF+McvXZQ7j1g8ucVZE1gLP3Sv6j9vlF9kyRqQo= +github.com/manifoldco/promptui v0.8.0/go.mod h1:n4zTdgP0vr0S3w7/O/g98U+e0gwLScEXGwov2nIKuGQ= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= -github.com/mattn/go-colorable v0.1.0 h1:v2XXALHHh6zHfYTJ+cSkwtyffnaOyR1MXaA91mTrb8o= -github.com/mattn/go-colorable v0.1.0/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= -github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs= +github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8= +github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mattn/go-sqlite3 v1.10.0 h1:jbhqpg7tQe4SupckyijYiy0mJJ/pRyHvXf7JdWK860o= -github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= -github.com/microcosm-cc/bluemonday v1.0.2 h1:5lPfLTTAvAbtS0VqT+94yOtFnGfUWYyx0+iToC3Os3s= +github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= +github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc= -github.com/mitchellh/go-wordwrap v1.0.0 h1:6GlHJ/LTGMrIJbwgdqdl2eEH8o+Exx/0m8ir9Gns0u4= -github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= -github.com/nicksnyder/go-i18n v1.10.0 h1:5AzlPKvXBH4qBzmZ09Ua9Gipyruv6uApMcrNZdo96+Q= -github.com/nicksnyder/go-i18n v1.10.0/go.mod h1:HrK7VCrbOvQoUAQ7Vpy7i87N7JZZZ7R2xBGjv0j365Q= +github.com/microcosm-cc/bluemonday v1.0.5 h1:cF59UCKMmmUgqN1baLvqU/B1ZsMori+duLVTLpgiG3w= +github.com/microcosm-cc/bluemonday v1.0.5/go.mod h1:8iwZnFn2CDDNZ0r6UXhF4xawGvzaqzCRa1n3/lO3W2w= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ= github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= -github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= -github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/prologic/go-gopher v0.0.0-20200721020712-3e11dcff0469 h1:rAbv2gekFbUcjhUkruwo0vMJ0JqhUgg9tz7t+bxHbN4= +github.com/prologic/go-gopher v0.0.0-20200721020712-3e11dcff0469/go.mod h1:c61IFFAJw8ADWu54tti30Tj5VrBstVoTprmET35UEkY= github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ= github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q= +github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 h1:Jpy1PXuP99tXNrhbq2BaPz9B+jNAvH1JPQQpG/9GCXY= @@ -119,83 +114,70 @@ github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304/go.mod h1 github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c h1:Ho+uVpkel/udgjbwB5Lktg9BtvJSh2DT0Hi6LPSyI2w= github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/tsenart/deadcode v0.0.0-20160724212837-210d2dc333e9 h1:vY5WqiEon0ZSTGM3ayVVi+twaHKHDFUVloaQ/wug9/c= -github.com/tsenart/deadcode v0.0.0-20160724212837-210d2dc333e9/go.mod h1:q+QjxYvZ+fpjMXqs+XEriussHjSYqeXVnAdSV1tkMYk= +github.com/stretchr/testify v1.6.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= +github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= github.com/writeas/activity v0.1.2 h1:Y12B5lIrabfqKE7e7HFCWiXrlfXljr9tlkFm2mp7DgY= github.com/writeas/activity v0.1.2/go.mod h1:mYYgiewmEM+8tlifirK/vl6tmB2EbjYaxwb+ndUw5T0= -github.com/writeas/activityserve v0.0.0-20191008122325-5fc3b48e70c5 h1:nG84xWpxBM8YU/FJchezJqg7yZH8ImSRow6NoYtbSII= -github.com/writeas/activityserve v0.0.0-20191008122325-5fc3b48e70c5/go.mod h1:Kz62mzYsCnrFTSTSFLXFj3fGYBQOntmBWTDDq57b46A= -github.com/writeas/activityserve v0.0.0-20191011072627-3a81f7784d5b h1:rd2wX/bTqD55hxtBjAhwLcUgaQE36c70KX3NzpDAwVI= -github.com/writeas/activityserve v0.0.0-20191011072627-3a81f7784d5b/go.mod h1:Kz62mzYsCnrFTSTSFLXFj3fGYBQOntmBWTDDq57b46A= -github.com/writeas/activityserve v0.0.0-20191115095800-dd6d19cc8b89 h1:NJhzq9aTccL3SSSZMrcnYhkD6sObdY9otNZ1X6/ZKNE= -github.com/writeas/activityserve v0.0.0-20191115095800-dd6d19cc8b89/go.mod h1:Kz62mzYsCnrFTSTSFLXFj3fGYBQOntmBWTDDq57b46A= +github.com/writeas/activityserve v0.0.0-20200409150223-d7ab3eaa4481 h1:BiSivIxLQFcKoUorpNN3rNwwFG5bITPnqUSyIccfdh0= +github.com/writeas/activityserve v0.0.0-20200409150223-d7ab3eaa4481/go.mod h1:4akDJSl+sSp+QhrQKMqzAqdV1gJ1pPx6XPI77zgMM8o= github.com/writeas/go-strip-markdown v2.0.1+incompatible h1:IIqxTM5Jr7RzhigcL6FkrCNfXkvbR+Nbu1ls48pXYcw= github.com/writeas/go-strip-markdown v2.0.1+incompatible/go.mod h1:Rsyu10ZhbEK9pXdk8V6MVnZmTzRG0alMNLMwa0J01fE= -github.com/writeas/go-webfinger v0.0.0-20190106002315-85cf805c86d2 h1:DUsp4OhdfI+e6iUqcPQlwx8QYXuUDsToTz/x82D3Zuo= -github.com/writeas/go-webfinger v0.0.0-20190106002315-85cf805c86d2/go.mod h1:w2VxyRO/J5vfNjJHYVubsjUGHd3RLDoVciz0DE3ApOc= +github.com/writeas/go-webfinger v1.1.0 h1:MzNyt0ry/GMsRmJGftn2o9mPwqK1Q5MLdh4VuJCfb1Q= +github.com/writeas/go-webfinger v1.1.0/go.mod h1:w2VxyRO/J5vfNjJHYVubsjUGHd3RLDoVciz0DE3ApOc= github.com/writeas/go-writeas v1.1.0 h1:WHGm6wriBkxYAOGbvriXH8DlMUGOi6jhSZLUZKQ+4mQ= github.com/writeas/go-writeas v1.1.0/go.mod h1:oh9U1rWaiE0p3kzdKwwvOpNXgp0P0IELI7OLOwV4fkA= github.com/writeas/go-writeas/v2 v2.0.2 h1:akvdMg89U5oBJiCkBwOXljVLTqP354uN6qnG2oOMrbk= github.com/writeas/go-writeas/v2 v2.0.2/go.mod h1:9sjczQJKmru925fLzg0usrU1R1tE4vBmQtGnItUMR0M= github.com/writeas/httpsig v1.0.0 h1:peIAoIA3DmlP8IG8tMNZqI4YD1uEnWBmkcC9OFPjt3A= github.com/writeas/httpsig v1.0.0/go.mod h1:7ClMGSrSVXJbmiLa17bZ1LrG1oibGZmUMlh3402flPY= -github.com/writeas/impart v1.1.0 h1:nPnoO211VscNkp/gnzir5UwCDEvdHThL5uELU60NFSE= github.com/writeas/impart v1.1.0/go.mod h1:g0MpxdnTOHHrl+Ca/2oMXUHJ0PcRAEWtkCzYCJUXC9Y= -github.com/writeas/impart v1.1.1-0.20191230230525-d3c45ced010d h1:PK7DOj3JE6MGf647esPrKzXEHFjGWX2hl22uX79ixaE= -github.com/writeas/impart v1.1.1-0.20191230230525-d3c45ced010d/go.mod h1:g0MpxdnTOHHrl+Ca/2oMXUHJ0PcRAEWtkCzYCJUXC9Y= -github.com/writeas/import v0.2.0 h1:Ov23JW9Rnjxk06rki1Spar45bNX647HhwhAZj3flJiY= -github.com/writeas/import v0.2.0/go.mod h1:gFe0Pl7ZWYiXbI0TJxeMMyylPGZmhVvCfQxhMEc8CxM= +github.com/writeas/impart v1.1.1 h1:RyA9+CqbdbDuz53k+nXCWUY+NlEkdyw6+nWanxSBl5o= +github.com/writeas/impart v1.1.1/go.mod h1:g0MpxdnTOHHrl+Ca/2oMXUHJ0PcRAEWtkCzYCJUXC9Y= +github.com/writeas/import v0.2.1 h1:3k+bDNCyqaWdZinyUZtEO4je3mR6fr/nE4ozTh9/9Wg= +github.com/writeas/import v0.2.1/go.mod h1:gFe0Pl7ZWYiXbI0TJxeMMyylPGZmhVvCfQxhMEc8CxM= github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219 h1:baEp0631C8sT2r/hqwypIw2snCFZa6h7U6TojoLHu/c= github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219/go.mod h1:NyM35ayknT7lzO6O/1JpfgGyv+0W9Z9q7aE0J8bXxfQ= -github.com/writeas/nerds v1.0.0 h1:ZzRcCN+Sr3MWID7o/x1cr1ZbLvdpej9Y1/Ho+JKlqxo= -github.com/writeas/nerds v1.0.0/go.mod h1:Gn2bHy1EwRcpXeB7ZhVmuUwiweK0e+JllNf66gvNLdU= github.com/writeas/openssl-go v1.0.0 h1:YXM1tDXeYOlTyJjoMlYLQH1xOloUimSR1WMF8kjFc5o= github.com/writeas/openssl-go v1.0.0/go.mod h1:WsKeK5jYl0B5y8ggOmtVjbmb+3rEGqSD25TppjJnETA= github.com/writeas/saturday v1.6.0/go.mod h1:ETE1EK6ogxptJpAgUbcJD0prAtX48bSloie80+tvnzQ= -github.com/writeas/saturday v1.7.1 h1:lYo1EH6CYyrFObQoA9RNWHVlpZA5iYL5Opxo7PYAnZE= -github.com/writeas/saturday v1.7.1/go.mod h1:ETE1EK6ogxptJpAgUbcJD0prAtX48bSloie80+tvnzQ= +github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320 h1:PozPZ29CQ/xt6ym/+FvIz+KvKEObSSc5ye+95zbTjVU= +github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320/go.mod h1:ETE1EK6ogxptJpAgUbcJD0prAtX48bSloie80+tvnzQ= github.com/writeas/slug v1.2.0 h1:EMQ+cwLiOcA6EtFwUgyw3Ge18x9uflUnOnR6bp/J+/g= github.com/writeas/slug v1.2.0/go.mod h1:RE8shOqQP3YhsfsQe0L3RnuejfQ4Mk+JjY5YJQFubfQ= -github.com/writeas/web-core v1.2.0 h1:CYqvBd+byi1cK4mCr1NZ6CjILuMOFmiFecv+OACcmG0= -github.com/writeas/web-core v1.2.0/go.mod h1:vTYajviuNBAxjctPp2NUYdgjofywVkxUGpeaERF3SfI= +github.com/writeas/web-core v1.3.1-0.20210330164422-95a3a717ed8f h1:ItBZYzdIbBmmqn8BZGWww00MBFgcUKy5ei0gJrzRDFk= +github.com/writeas/web-core v1.3.1-0.20210330164422-95a3a717ed8f/go.mod h1:DzNxa0YLV/wNeeWeHFPNa/nHmyJBFIIzXN/m9PpDm5c= github.com/writefreely/go-nodeinfo v1.2.0 h1:La+YbTCvmpTwFhBSlebWDDL81N88Qf/SCAvRLR7F8ss= github.com/writefreely/go-nodeinfo v1.2.0/go.mod h1:UTvE78KpcjYOlRHupZIiSEFcXHioTXuacCbHU+CAcPg= -golang.org/x/crypto v0.0.0-20180527072434-ab813273cd59 h1:hk3yo72LXLapY9EXVttc3Z1rLOxT9IuAPPX3GpY2+jo= golang.org/x/crypto v0.0.0-20180527072434-ab813273cd59/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190131182504-b8fe1690c613/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190208162236-193df9c0f06f h1:ETU2VEl7TnT5bl7IvuKEzTDpplg5wzGYsOCAPhdoEIg= -golang.org/x/crypto v0.0.0-20190208162236-193df9c0f06f/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1 h1:rJm0LuqUjoDhSk2zO9ISMSToQxGz7Os2jRiOL8AWu4c= -golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181220203305-927f97764cc3 h1:eH6Eip3UpmR+yM/qI9Ijluzb1bNv/cAU/n+6l8tRSis= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190206173232-65e2d4e15006 h1:bfLnR+k0tq5Lqt6dflRLcZiz6UaXCMt3vhYJ1l4FQ80= -golang.org/x/net v0.0.0-20190206173232-65e2d4e15006/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20200707034311-ab3426394381 h1:VXak5I6aEWmAXeQjA+QSZzlgNrpq9mjcfDemuexIKsU= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/sys v0.0.0-20180525142821-c11f84a56e43/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190209173611-3b5209105503 h1:5SvYFrOM3W8Mexn9/oA44Ji7vhXAZQ9hiP+1Q/DMrWg= -golang.org/x/sys v0.0.0-20190209173611-3b5209105503/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/tools v0.0.0-20181122213734-04b5d21e00f1/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190208222737-3744606dbb67 h1:bPP/rGuN1LUM0eaEwo6vnP6OfIWJzJBulzGUiKLjjSY= -golang.org/x/tools v0.0.0-20190208222737-3744606dbb67/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c h1:vTxShRUnK60yd8DZU+f95p1zSLj814+5CuEh7NjF2/Y= -gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c/go.mod h1:3HH7i1SgMqlzxCcBmUHW657sD4Kvv9sC3HpL3YukzwA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/ini.v1 v1.41.0 h1:Ka3ViY6gNYSKiVy71zXBEqKplnV35ImDLVG+8uoIklE= -gopkg.in/ini.v1 v1.41.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.55.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.62.0 h1:duBzk771uxoUuOlyRLkHsygud9+5lrlGjdFBb4mSKDU= +gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0 h1:POO/ycCATvegFmVuPpQzZFJ+pGZeX22Ufu6fibxDVjU= gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= -gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -src.techknowlogick.com/xgo v0.0.0-20200129005940-d0fae26e014b h1:rPAdjgXks4ToezTjygsnKZroxKVnA1L35DSpsJXPtfc= -src.techknowlogick.com/xgo v0.0.0-20200129005940-d0fae26e014b/go.mod h1:31CE1YKtDOrKTk9PSnjTpe6YbO6W/0LTYZ1VskL09oU= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +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/gopher.go b/gopher.go new file mode 100644 index 0000000..56d3fd6 --- /dev/null +++ b/gopher.go @@ -0,0 +1,156 @@ +/* + * Copyright © 2020 A Bunch Tell LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package writefreely + +import ( + "bytes" + "fmt" + "io" + "regexp" + "strings" + + "github.com/prologic/go-gopher" + "github.com/writeas/web-core/log" +) + +func initGopher(apper Apper) { + handler := NewWFHandler(apper) + + gopher.HandleFunc("/", handler.Gopher(handleGopher)) + log.Info("Serving on gopher://localhost:%d", apper.App().Config().Server.GopherPort) + gopher.ListenAndServe(fmt.Sprintf(":%d", apper.App().Config().Server.GopherPort), nil) +} + +// Utility function to strip the URL from the hostname provided by app.cfg.App.Host +func stripHostProtocol(app *App) string { + return string(regexp.MustCompile("^.*://").ReplaceAll([]byte(app.cfg.App.Host), []byte(""))) +} + +func handleGopher(app *App, w gopher.ResponseWriter, r *gopher.Request) error { + parts := strings.Split(r.Selector, "/") + if app.cfg.App.SingleUser { + if parts[1] != "" { + return handleGopherCollectionPost(app, w, r) + } + return handleGopherCollection(app, w, r) + } + + // Show all public collections (a gopher Reader view, essentially) + if len(parts) == 3 { + return handleGopherCollection(app, w, r) + } + + w.WriteInfo(fmt.Sprintf("Welcome to %s", app.cfg.App.SiteName)) + + colls, err := app.db.GetPublicCollections(app.cfg.App.Host) + if err != nil { + return err + } + + for _, c := range *colls { + w.WriteItem(&gopher.Item{ + Host: stripHostProtocol(app), + Port: app.cfg.Server.GopherPort, + Type: gopher.DIRECTORY, + Description: c.DisplayTitle(), + Selector: "/" + c.Alias + "/", + }) + } + return w.End() +} + +func handleGopherCollection(app *App, w gopher.ResponseWriter, r *gopher.Request) error { + var collAlias, slug string + var c *Collection + var err error + var baseSel = "/" + + parts := strings.Split(r.Selector, "/") + if app.cfg.App.SingleUser { + // sanity check + slug = parts[1] + if slug != "" { + return handleGopherCollectionPost(app, w, r) + } + + c, err = app.db.GetCollectionByID(1) + if err != nil { + return err + } + } else { + collAlias = parts[1] + slug = parts[2] + if slug != "" { + return handleGopherCollectionPost(app, w, r) + } + + c, err = app.db.GetCollection(collAlias) + if err != nil { + return err + } + baseSel = "/" + c.Alias + "/" + } + c.hostName = app.cfg.App.Host + + posts, err := app.db.GetPosts(app.cfg, c, 0, false, false, false) + if err != nil { + return err + } + + for _, p := range *posts { + w.WriteItem(&gopher.Item{ + Port: app.cfg.Server.GopherPort, + Host: stripHostProtocol(app), + Type: gopher.FILE, + Description: p.CreatedDate() + " - " + p.DisplayTitle(), + Selector: baseSel + p.Slug.String, + }) + } + return w.End() +} + +func handleGopherCollectionPost(app *App, w gopher.ResponseWriter, r *gopher.Request) error { + var collAlias, slug string + var c *Collection + var err error + + parts := strings.Split(r.Selector, "/") + if app.cfg.App.SingleUser { + slug = parts[1] + c, err = app.db.GetCollectionByID(1) + if err != nil { + return err + } + } else { + collAlias = parts[1] + slug = parts[2] + c, err = app.db.GetCollection(collAlias) + if err != nil { + return err + } + } + c.hostName = app.cfg.App.Host + + p, err := app.db.GetPost(slug, c.ID) + if err != nil { + return err + } + + b := bytes.Buffer{} + if p.Title.String != "" { + b.WriteString(p.Title.String + "\n") + } + b.WriteString(p.DisplayDate + "\n\n") + b.WriteString(p.Content) + io.Copy(w, &b) + + return w.End() +} diff --git a/handle.go b/handle.go index 0fcc483..01d5728 100644 --- a/handle.go +++ b/handle.go @@ -1,5 +1,5 @@ /* - * Copyright © 2018-2019 A Bunch Tell LLC. + * Copyright © 2018-2021 A Bunch Tell LLC. * * This file is part of WriteFreely. * @@ -21,10 +21,11 @@ import ( "time" "github.com/gorilla/sessions" + "github.com/prologic/go-gopher" "github.com/writeas/impart" "github.com/writeas/web-core/log" - "github.com/writeas/writefreely/config" - "github.com/writeas/writefreely/page" + "github.com/writefreely/writefreely/config" + "github.com/writefreely/writefreely/page" ) // UserLevel represents the required user level for accessing an endpoint @@ -64,6 +65,7 @@ func UserLevelReader(cfg *config.Config) UserLevel { type ( handlerFunc func(app *App, w http.ResponseWriter, r *http.Request) error + gopherFunc func(app *App, w gopher.ResponseWriter, r *gopher.Request) error userHandlerFunc func(app *App, u *User, w http.ResponseWriter, r *http.Request) error userApperHandlerFunc func(apper Apper, u *User, w http.ResponseWriter, r *http.Request) error dataHandlerFunc func(app *App, w http.ResponseWriter, r *http.Request) ([]byte, string, error) @@ -83,6 +85,7 @@ type ErrorPages struct { NotFound *template.Template Gone *template.Template InternalServerError *template.Template + UnavailableError *template.Template Blank *template.Template } @@ -94,6 +97,7 @@ func NewHandler(apper Apper) *Handler { NotFound: template.Must(template.New("").Parse("{{define \"base\"}}404

Not found.

{{end}}")), Gone: template.Must(template.New("").Parse("{{define \"base\"}}410

Gone.

{{end}}")), InternalServerError: template.Must(template.New("").Parse("{{define \"base\"}}500

Internal server error.

{{end}}")), + UnavailableError: template.Must(template.New("").Parse("{{define \"base\"}}503

Service is temporarily unavailable.

{{end}}")), Blank: template.Must(template.New("").Parse("{{define \"base\"}}{{.Title}}

{{.Content}}

{{end}}")), }, sessionStore: apper.App().SessionStore(), @@ -111,6 +115,7 @@ func NewWFHandler(apper Apper) *Handler { NotFound: pages["404-general.tmpl"], Gone: pages["410.tmpl"], InternalServerError: pages["500.tmpl"], + UnavailableError: pages["503.tmpl"], Blank: pages["blank.tmpl"], }) return h @@ -596,6 +601,9 @@ func (h *Handler) AllReader(f handlerFunc) http.HandlerFunc { log.Info(h.app.ReqLog(r, status, time.Since(start))) }() + // Allow any origin, as public endpoints are handled in here + w.Header().Set("Access-Control-Allow-Origin", "*") + if h.app.App().cfg.App.Private { // This instance is private, so ensure it's being accessed by a valid user // Check if authenticated with an access token @@ -763,6 +771,10 @@ func (h *Handler) handleHTTPError(w http.ResponseWriter, r *http.Request, err er log.Info("handleHTTPErorr internal error render") h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r)) return + } else if err.Status == http.StatusServiceUnavailable { + w.WriteHeader(err.Status) + h.errors.UnavailableError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r)) + return } else if err.Status == http.StatusAccepted { impart.WriteSuccess(w, "", err.Status) return @@ -891,8 +903,33 @@ func (h *Handler) LogHandlerFunc(f http.HandlerFunc) http.HandlerFunc { } } +func (h *Handler) Gopher(f gopherFunc) gopher.HandlerFunc { + return func(w gopher.ResponseWriter, r *gopher.Request) { + defer func() { + if e := recover(); e != nil { + log.Error("%s: %s", e, debug.Stack()) + w.WriteError("An internal error occurred") + } + log.Info("gopher: %s", r.Selector) + }() + + err := f(h.app.App(), w, r) + if err != nil { + log.Error("failed: %s", err) + w.WriteError("the page failed for some reason (see logs)") + } + } +} + func sendRedirect(w http.ResponseWriter, code int, location string) int { w.Header().Set("Location", location) w.WriteHeader(code) return code } + +func cacheControl(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "public, max-age=604800, immutable") + next.ServeHTTP(w, r) + }) +} diff --git a/invites.go b/invites.go index e39ec3b..4024634 100644 --- a/invites.go +++ b/invites.go @@ -1,5 +1,5 @@ /* - * Copyright © 2019 A Bunch Tell LLC. + * Copyright © 2019-2021 A Bunch Tell LLC. * * This file is part of WriteFreely. * @@ -19,9 +19,9 @@ import ( "github.com/gorilla/mux" "github.com/writeas/impart" - "github.com/writeas/nerds/store" + "github.com/writeas/web-core/id" "github.com/writeas/web-core/log" - "github.com/writeas/writefreely/page" + "github.com/writefreely/writefreely/page" ) type Invite struct { @@ -42,6 +42,18 @@ func (i Invite) Expired() bool { return i.Expires != nil && i.Expires.Before(time.Now()) } +func (i Invite) Active(db *datastore) bool { + if i.Expired() { + return false + } + if i.MaxUses.Valid && i.MaxUses.Int64 > 0 { + if c := db.GetUsersInvitedCount(i.ID); c >= i.MaxUses.Int64 { + return false + } + } + return true +} + func (i Invite) ExpiresFriendly() string { return i.Expires.Format("January 2, 2006, 3:04 PM") } @@ -56,15 +68,15 @@ func handleViewUserInvites(app *App, u *User, w http.ResponseWriter, r *http.Req p := struct { *UserPage - Invites *[]Invite - Suspended bool + Invites *[]Invite + Silenced bool }{ UserPage: NewUserPage(app, r, u, "Invite People", f), } var err error - p.Suspended, err = app.db.IsUserSuspended(u.ID) + p.Silenced, err = app.db.IsUserSilenced(u.ID) if err != nil { log.Error("view invites: %v", err) } @@ -86,7 +98,7 @@ func handleCreateUserInvite(app *App, u *User, w http.ResponseWriter, r *http.Re expVal := r.FormValue("expires") if u.IsSilenced() { - return ErrUserSuspended + return ErrUserSilenced } var err error @@ -109,7 +121,7 @@ func handleCreateUserInvite(app *App, u *User, w http.ResponseWriter, r *http.Re expDate = &ed } - inviteID := store.GenerateRandomString("0123456789BCDFGHJKLMNPQRSTVWXYZbcdfghjklmnpqrstvwxyz", 6) + inviteID := id.GenerateRandomString("0123456789BCDFGHJKLMNPQRSTVWXYZbcdfghjklmnpqrstvwxyz", 6) err = app.db.CreateUserInvite(inviteID, u.ID, maxUses, expDate) if err != nil { return err @@ -158,18 +170,23 @@ func handleViewInvite(app *App, w http.ResponseWriter, r *http.Request) error { p := struct { page.StaticPage + *OAuthButtons Error string Flashes []template.HTML Invite string }{ - StaticPage: pageForReq(app, r), - Invite: inviteCode, + StaticPage: pageForReq(app, r), + OAuthButtons: NewOAuthButtons(app.cfg), + Invite: inviteCode, } if expired { p.Error = "This invite link has expired." } + // Tell search engines not to index invite links + w.Header().Set("X-Robots-Tag", "noindex") + // Get error messages session, err := app.sessionStore.Get(r, cookieName) if err != nil { diff --git a/keys.go b/keys.go index 5cc63a3..e53d811 100644 --- a/keys.go +++ b/keys.go @@ -1,5 +1,5 @@ /* - * Copyright © 2018-2019 A Bunch Tell LLC. + * Copyright © 2018-2019, 2021 A Bunch Tell LLC. * * This file is part of WriteFreely. * @@ -12,7 +12,7 @@ package writefreely import ( "github.com/writeas/web-core/log" - "github.com/writeas/writefreely/key" + "github.com/writefreely/writefreely/key" "io/ioutil" "os" "path/filepath" diff --git a/less/Makefile b/less/Makefile index 117e9b2..1a44a4f 100644 --- a/less/Makefile +++ b/less/Makefile @@ -5,6 +5,7 @@ all : lessc app.less --clean-css="--s1 --advanced" $(CSSDIR)write.css lessc fonts.less --clean-css="--s1 --advanced" $(CSSDIR)fonts.css lessc icons.less --clean-css="--s1 --advanced" $(CSSDIR)icons.css + lessc prose.less --clean-css="--s1 --advanced" $(CSSDIR)prose.css install : ./install-less.sh diff --git a/less/admin.less b/less/admin.less index 9c4a7c2..86dc9ff 100644 --- a/less/admin.less +++ b/less/admin.less @@ -13,19 +13,38 @@ nav#admin { display: block; margin: 0.5em 0; a { - color: @primary; - &:first-child { - margin-left: 0; - } + margin-left: 0; + .rounded(.25em); + border: 0; &.selected { + background: #dedede; font-weight: bold; + .blip { + color: black; + } } } + .blip { + font-weight: bold; + } } .pager { display: flex; justify-content: center; + &:not(.pages) { + display: block; + margin: 0.5em 0; + a { + margin-left: 0; + .rounded(.25em); + + &+a { + margin-left: 0.5em; + } + } + } + a { color: #333; font-family: @sansFont; @@ -42,3 +61,39 @@ nav#admin { } } } + +.admin-actions { + .btn { + font-family: @sansFont; + font-size: 0.86em; + } +} + +.features { + margin: 1em 0; + + div { + &:first-child { + font-weight: bold; + } + &+div { + padding-left: 1em; + } + + p { + font-weight: normal; + margin: 0.5rem 0; + font-size: 0.86em; + color: #666; + } + } +} + +@media (max-width: 600px) { + div.row.features { + align-items: start; + } + .features div + div { + padding-left: 0; + } +} \ No newline at end of file diff --git a/less/app.less b/less/app.less index ec3472d..01db3fb 100644 --- a/less/app.less +++ b/less/app.less @@ -5,6 +5,8 @@ @import "post-temp"; @import "effects"; @import "admin"; +@import "login"; @import "pages/error"; +@import "resources"; @import "lib/elements"; @import "lib/material"; diff --git a/less/core.less b/less/core.less index fe8a28d..9d2e1ab 100644 --- a/less/core.less +++ b/less/core.less @@ -1,15 +1,3 @@ -@primary: rgb(114, 120, 191); -@secondary: rgb(114, 191, 133); -@subheaders: #444; -@headerTextColor: black; -@sansFont: 'Open Sans', 'Segoe UI', Tahoma, Arial, sans-serif; -@serifFont: Lora, 'Palatino Linotype', 'Book Antiqua', 'New York', 'DejaVu serif', serif; -@monoFont: Hack, consolas, Menlo-Regular, Menlo, Monaco, 'ubuntu mono', monospace, monospace; -@dangerCol: #e21d27; -@errUrgentCol: #ecc63c; -@proSelectedCol: #71D571; -@textLinkColor: rgb(0, 0, 238); - body { font-family: @serifFont; font-size-adjust: 0.5; @@ -81,7 +69,7 @@ body { font-size: 1.5em; } h2 { - font-size: 1.17em; + font-size: 1.4em; } } @@ -524,12 +512,12 @@ pre, body#post article, #post .alert, #subpage .alert, body#collection article, margin-bottom: 1em; p { text-align: left; - line-height: 1.4; + line-height: 1.5; } } -textarea, pre, body#post article, body#collection article p { +textarea, input#title, pre, body#post article, body#collection article p { &.norm, &.sans, &.wrap { - line-height: 1.4em; + line-height: 1.5; white-space: pre-wrap; /* CSS 3 */ white-space: -moz-pre-wrap; /* Mozilla, since 1999 */ white-space: -pre-wrap; /* Opera 4-6 */ @@ -537,7 +525,7 @@ textarea, pre, body#post article, body#collection article p { word-wrap: break-word; /* Internet Explorer 5.5+ */ } } -textarea, pre, body#post article, body#collection article, body#subpage article, span, .font { +textarea, input#title, pre, body#post article, body#collection article, body#subpage article, span, .font { &.norm { font-family: @serifFont; } @@ -639,6 +627,23 @@ table.classy { } } +article table { + border-spacing: 0; + border-collapse: collapse; + width: 100%; + th { + border-width: 1px 1px 2px 1px; + border-style: solid; + border-color: #ccc; + } + td { + border-width: 0 1px 1px 1px; + border-style: solid; + border-color: #ccc; + padding: .25rem .5rem; + } +} + body#collection article, body#subpage article { padding-top: 0; padding-bottom: 0; @@ -726,6 +731,18 @@ input, button, select.inputform, textarea.inputform, a.btn { } } +.btn.pager { + border: 1px solid @lightNavBorder; + font-size: .86em; + padding: .5em 1em; + white-space: nowrap; + font-family: @sansFont; + &:hover { + text-decoration: none; + background: @lightNavBorder; + } +} + div.flat-select { display: inline-block; position: relative; @@ -794,9 +811,6 @@ input { &.snug { max-width: 40em; } - &.regular { - font-size: 1em; - } .app { + .app { margin-top: 1.5em; @@ -813,7 +827,7 @@ input { font-weight: normal; } p { - line-height: 1.4; + line-height: 1.5; } li { margin: 0.3em 0; @@ -868,20 +882,6 @@ input { text-align: center; } } - div.features { - margin-top: 1.5em; - text-align: center; - font-size: 0.86em; - ul { - text-align: left; - max-width: 26em; - margin-left: auto !important; - margin-right: auto !important; - li.soon, span.soon { - color: lighten(#111, 40%); - } - } - } div.blurbs { >h2 { text-align: center; @@ -965,7 +965,12 @@ footer.contain-me { } ul { &.collections { + padding-left: 0; margin-left: 0; + h3 { + margin-top: 0; + font-weight: normal; + } li { &.collection { a.title { @@ -1007,7 +1012,7 @@ footer.contain-me { } li { - line-height: 1.4; + line-height: 1.5; .item-desc, .prog-lang { font-size: 0.6em; @@ -1095,7 +1100,8 @@ body#pad-sub #posts, .atoms { } .electron { font-weight: normal; - margin-left: 0.5em; + font-size: 0.86em; + margin-left: 0.75rem; } } h3, h4 { @@ -1245,7 +1251,7 @@ header { } } &.singleuser { - margin: 0.5em 0.25em; + margin: 0.5em 1em 0.5em 0.25em; nav#user-nav { nav > ul > li:first-child { img { @@ -1253,6 +1259,9 @@ header { } } } + .right-side { + padding-top: 0.5em; + } } .dash-nav { font-weight: bold; @@ -1345,6 +1354,16 @@ div.row { } } +.check, .blip { + font-size: 1.125em; + color: #71D571; +} + +.ex.failure { + font-weight: bold; + color: @dangerCol; +} + @media all and (max-width: 450px) { body#post { header { @@ -1411,7 +1430,7 @@ div.row { } @media all and (max-width: 600px) { - div.row { + div.row:not(.admin-actions) { flex-direction: column; } .half { @@ -1496,6 +1515,11 @@ div.row { margin-left: 0; margin-top: 0; } + article { + .hidden { + .opacity(1); + } + } } @media print { @@ -1537,3 +1561,26 @@ div.row { pre.code-block { overflow-x: auto; } + +#org-nav { + font-family: @sansFont; + font-size: 1.1em; + color: #888; + + em, strong { + color: #000; + } + &+h1 { + margin-top: 0.5em; + } + a:link, a:visited, a:hover { + color: @accent; + } + a:first-child { + margin-right: 0.25em; + } + a.coll-name { + font-weight: bold; + margin-left: 0.25em; + } +} \ No newline at end of file diff --git a/less/install-less.sh b/less/install-less.sh index fc86ad2..2c5ce08 100755 --- a/less/install-less.sh +++ b/less/install-less.sh @@ -2,7 +2,7 @@ # Install Less via npm if [ ! -e "$(which lessc)" ]; then - sudo npm install -g less + sudo npm install -g less@3.5.3 sudo npm install -g less-plugin-clean-css else echo LESS $(npm view less version 2>&1 | grep -v WARN) is installed diff --git a/less/login.less b/less/login.less new file mode 100644 index 0000000..fefeb12 --- /dev/null +++ b/less/login.less @@ -0,0 +1,91 @@ +/* + * Copyright © 2020 A Bunch Tell LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +.row.signinbtns { + justify-content: center; + font-size: 1em; + margin-top: 2em; + margin-bottom: 1em; + flex-wrap: wrap; + + .loginbtn { + height: 40px; + margin: 0.5em; + + &.btn { + box-sizing: border-box; + font-size: 17px; + white-space: nowrap; + + img { + height: 1.5em; + vertical-align: middle; + } + } + + &#writeas-login, &#slack-login { + img { + margin-top: -0.2em; + } + } + + &#gitlab-login { + background-color: #fc6d26; + border-color: #fc6d26; + &:hover { + background-color: darken(#fc6d26, 5%); + border-color: darken(#fc6d26, 5%); + } + } + + &#gitea-login { + background-color: #2ecc71; + border-color: #2ecc71; + &:hover { + background-color: #2cc26b; + border-color: #2cc26b; + } + } + + &#slack-login, &#gitlab-login, &#gitea-login, &#generic-oauth-login { + font-size: 0.86em; + font-family: @sansFont; + } + + &#slack-login, &#generic-oauth-login { + color: @lightTextColor; + background-color: @lightNavBG; + border-color: @lightNavBorder; + &:hover { + background-color: @lightNavHoverBG; + } + } + } +} + +.or { + text-align: center; + margin-bottom: 3.5em; + + p { + display: inline-block; + background-color: white; + padding: 0 1em; + } + + hr { + margin-top: -1.6em; + margin-bottom: 0; + } + + hr.short { + max-width: 30rem; + } +} \ No newline at end of file diff --git a/less/new-core.less b/less/new-core.less index 802f34d..c9e7a17 100644 --- a/less/new-core.less +++ b/less/new-core.less @@ -1,4 +1,4 @@ -@actionNavColor: #999; +@actionNavColor: #767676; body { margin: 0; @@ -58,7 +58,7 @@ header { } p { &.description { - color: #666; + color: #444; font-size: 1.1em; margin-top: 0.5em; line-height: 1.5; @@ -113,7 +113,7 @@ textarea { ul { margin: 0; padding: 0 0 0 1em; - line-height: 1.4; + line-height: 1.5; &.collections, &.posts, &.integrations { list-style: none; @@ -127,7 +127,6 @@ textarea { &.collection { a.title { font-size: 1.3em; - font-weight: bold; } } } @@ -206,7 +205,7 @@ code, textarea#embed { font-weight: normal; } p { - line-height: 1.4; + line-height: 1.5; } li { margin: 0.3em 0; diff --git a/less/pad-theme.less b/less/pad-theme.less index a8f668e..94276cd 100644 --- a/less/pad-theme.less +++ b/less/pad-theme.less @@ -188,18 +188,18 @@ body#pad, body#pad-sub { body#pad { .pad-theme-transition; - textarea { + textarea, #title { .pad-theme-transition; } &.dark { - textarea { + textarea, #title, #editor { background-color: @darkBG; color: @darkTextColor; } } &.light { - textarea { + textarea, #title, #editor { background-color: @lightBG; color: @lightTextColor; } diff --git a/less/pad.less b/less/pad.less index a132b30..6cdd383 100644 --- a/less/pad.less +++ b/less/pad.less @@ -60,7 +60,7 @@ &:hover { background: @lightNavHoverBG; } - &:hover > ul { + &:hover > ul, &.open > ul { display: block; } &.selected { @@ -256,7 +256,7 @@ body#pad { border: 0; outline: 0; } - textarea { + textarea, #title { position: fixed !important; top: 3em; right: 0; @@ -361,12 +361,38 @@ body#pad { z-index: 10; } +body#pad .alert { + position: fixed; + bottom: 0.25em; + left: 2em; + right: 2em; + font-size: 1.1em; + + &#edited-elsewhere { + &.hidden { + display: none; + } + + a { + font-weight: bold; + } + } +} + @media all and (max-height: 500px) { body#pad { textarea { top: 2.25em; padding-top: 0.25em; } + &.classic { + #editor { + top: 5.25em; + } + #title { + top: 3.5rem; + } + } #tools { padding-top: 0.5em; padding-bottom: 0.5em; @@ -420,43 +446,63 @@ body#pad { } @media all and (min-width: 50em) { - body#pad { - textarea { + body#pad, body#pad.classic { + textarea, #title { padding-left: 10%; padding-right: 10%; } + .alert { + left: 10%; + right: 10%; + } } } @media all and (min-width: 60em) { - body#pad { - textarea { + body#pad, body#pad.classic { + textarea, #title { padding-left: 15%; padding-right: 15%; } + .alert { + left: 15%; + right: 15%; + } } } @media all and (min-width: 70em) { - body#pad { - textarea { + body#pad, body#pad.classic { + textarea, #title { padding-left: 20%; padding-right: 20%; } + .alert { + left: 20%; + right: 20%; + } } } @media all and (min-width: 85em) { - body#pad { - textarea { + body#pad, body#pad.classic { + textarea, #title { padding-left: 25%; padding-right: 25%; } + .alert { + left: 25%; + right: 25%; + } } } @media all and (min-width: 105em) { - body#pad { - textarea { + body#pad, body#pad.classic { + textarea, #title { padding-left: 30%; padding-right: 30%; } + .alert { + left: 30%; + right: 30%; + } } } @media (pointer: coarse) { diff --git a/less/post-temp.less b/less/post-temp.less index 8173864..7ab5d92 100644 --- a/less/post-temp.less +++ b/less/post-temp.less @@ -49,7 +49,7 @@ body#post article, pre, .hljs { border-left: 4px solid #ddd; padding: 0 1em; margin: 0.5em; - color: #777; + color: #767676; display: inline-block; p { @@ -58,7 +58,7 @@ body#post article, pre, .hljs { } } .article-p() { - line-height: 1.4em; + line-height: 1.5; white-space: pre-wrap; /* CSS 3 */ white-space: -moz-pre-wrap; /* Mozilla, since 1999 */ white-space: -pre-wrap; /* Opera 4-6 */ diff --git a/less/prose-editor.less b/less/prose-editor.less new file mode 100644 index 0000000..389ee48 --- /dev/null +++ b/less/prose-editor.less @@ -0,0 +1,450 @@ +body#pad.classic { + header { + display: flex; + justify-content: space-between; + align-items: center; + } + #editor { + top: 4em; + } + #title { + top: 4.25rem; + bottom: unset; + height: auto; + font-weight: bold; + font-size: 2em; + padding-top: 0; + padding-bottom: 0; + border: 0; + } + #tools { + #belt { + float: none; + } + } + #target { + ul { + a { + padding: 0 0.5em !important; + } + } + } +} + +.ProseMirror { + position: relative; + height: calc(~"100% - 1.6em"); + overflow-y: auto; + box-sizing: border-box; + -moz-box-sizing: border-box; + font-size: 1.2em; + word-wrap: break-word; + white-space: pre-wrap; + -webkit-font-variant-ligatures: none; + font-variant-ligatures: none; + padding: 0.5em 0; + line-height: 1.5; + outline: none; +} + +.ProseMirror pre { + white-space: pre-wrap; +} + +.ProseMirror li { + position: relative; +} + +.ProseMirror-hideselection *::selection { + background: transparent; +} + +.ProseMirror-hideselection *::-moz-selection { + background: transparent; +} + +.ProseMirror-hideselection { + caret-color: transparent; +} + +.ProseMirror-selectednode { + outline: 2px solid #8cf; +} + +/* Make sure li selections wrap around markers */ + +li.ProseMirror-selectednode { + outline: none; +} + +li.ProseMirror-selectednode:after { + content: ""; + position: absolute; + left: -32px; + right: -2px; + top: -2px; + bottom: -2px; + border: 2px solid #8cf; + pointer-events: none; +} + +.ProseMirror-textblock-dropdown { + min-width: 3em; +} + +.ProseMirror-menu { + margin: 0 -4px; + line-height: 1; +} + +.ProseMirror-tooltip .ProseMirror-menu { + width: -webkit-fit-content; + width: fit-content; + white-space: pre; +} + +.ProseMirror-menuitem { + margin-right: 3px; + display: inline-block; + div { + cursor: pointer; + } +} + +.ProseMirror-menuseparator { + border-right: 1px solid #ddd; + margin-right: 3px; +} + +.ProseMirror-menu-dropdown, .ProseMirror-menu-dropdown-menu { + font-size: 90%; + white-space: nowrap; +} + +.ProseMirror-menu-dropdown { + vertical-align: 1px; + cursor: pointer; + position: relative; + padding-right: 15px; +} + +.ProseMirror-menu-dropdown-wrap { + padding: 1px 0 1px 4px; + display: inline-block; + position: relative; +} + +.ProseMirror-menu-dropdown:after { + content: ""; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 4px solid currentColor; + opacity: .6; + position: absolute; + right: 4px; + top: calc(50% - 2px); +} + +.ProseMirror-menu-dropdown-menu, .ProseMirror-menu-submenu { + position: absolute; + background: white; + color: #666; + border: 1px solid #aaa; + padding: 2px; +} + +.ProseMirror-menu-dropdown-menu { + z-index: 15; + min-width: 6em; +} + +.ProseMirror-menu-dropdown-item { + cursor: pointer; + padding: 2px 8px 2px 4px; +} + +.ProseMirror-menu-dropdown-item:hover { + background: #f2f2f2; +} + +.ProseMirror-menu-submenu-wrap { + position: relative; + margin-right: -4px; +} + +.ProseMirror-menu-submenu-label:after { + content: ""; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + border-left: 4px solid currentColor; + opacity: .6; + position: absolute; + right: 4px; + top: calc(50% - 4px); +} + +.ProseMirror-menu-submenu { + display: none; + min-width: 4em; + left: 100%; + top: -3px; +} + +.ProseMirror-menu-active { + background: #eee; + border-radius: 4px; +} + +.ProseMirror-menu-active { + background: #eee; + border-radius: 4px; +} + +.ProseMirror-menu-disabled { + opacity: .3; +} + +.ProseMirror-menu-submenu-wrap:hover .ProseMirror-menu-submenu, .ProseMirror-menu-submenu-wrap-active .ProseMirror-menu-submenu { + display: block; +} + +.ProseMirror-menubar { + position: relative; + min-height: 1em; + color: #666; + padding: 0.5em; + top: 0; + left: 0; + right: 0; + background: rgba(255, 255, 255, 0.8); + z-index: 10; + -moz-box-sizing: border-box; + box-sizing: border-box; + overflow: visible; +} + +.ProseMirror-icon { + display: inline-block; + line-height: .8; + vertical-align: -2px; /* Compensate for padding */ + padding: 2px 8px; + cursor: pointer; +} + +.ProseMirror-menu-disabled.ProseMirror-icon { + cursor: default; +} + +.ProseMirror-icon svg { + fill: currentColor; + height: 1em; +} + +.ProseMirror-icon span { + vertical-align: text-top; +} + +.ProseMirror-gapcursor { + display: none; + pointer-events: none; + position: absolute; +} + +.ProseMirror-gapcursor:after { + content: ""; + display: block; + position: absolute; + top: -2px; + width: 20px; + border-top: 1px solid black; + animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite; +} + +@keyframes ProseMirror-cursor-blink { + to { + visibility: hidden; + } +} + +.ProseMirror-focused .ProseMirror-gapcursor { + display: block; +} + +/* Add space around the hr to make clicking it easier */ + +.ProseMirror-example-setup-style hr { + padding: 2px 10px; + border: none; + margin: 1em 0; +} + +.ProseMirror-example-setup-style hr:after { + content: ""; + display: block; + height: 1px; + background-color: silver; + line-height: 2px; +} + +.ProseMirror ul, .ProseMirror ol { + padding-left: 30px; +} + +.ProseMirror blockquote { + padding-left: 1em; + border-left: 3px solid #eee; + margin-left: 0; + margin-right: 0; +} + +.ProseMirror-example-setup-style img { + cursor: default; +} + +.ProseMirror-prompt { + background: white; + padding: 1em; + border: 1px solid silver; + position: fixed; + border-radius: 0.25em; + z-index: 11; + box-shadow: -.5px 2px 5px rgba(0, 0, 0, .2); +} + +.ProseMirror-prompt h5 { + margin: 0 0 0.75em; + font-family: @sansFont; + font-size: 100%; + color: #444; +} + +.ProseMirror-prompt input[type="text"], +.ProseMirror-prompt textarea { + background: #eee; + border: none; + outline: none; +} + +.ProseMirror-prompt input[type="text"] { + margin: 0.25em 0; +} + +.ProseMirror-prompt-close { + position: absolute; + left: 2px; + top: 1px; + color: #666; + border: none; + background: transparent; + padding: 0; +} + +.ProseMirror-prompt-close:after { + content: "✕"; + font-size: 12px; +} + +.ProseMirror-invalid { + background: #ffc; + border: 1px solid #cc7; + border-radius: 4px; + padding: 5px 10px; + position: absolute; + min-width: 10em; +} + +.ProseMirror-prompt-buttons { + margin-top: 5px; + display: none; +} + +#editor, .editor { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + color: black; + background-clip: padding-box; + padding: 5px 0; + margin: 4em auto 23px auto; +} + +.ProseMirror p:first-child, +.ProseMirror h1:first-child, +.ProseMirror h2:first-child, +.ProseMirror h3:first-child, +.ProseMirror h4:first-child, +.ProseMirror h5:first-child, +.ProseMirror h6:first-child { + margin-top: 10px; +} + +.ProseMirror p { + margin-bottom: 1em; +} + +textarea { + width: 100%; + height: 123px; + border: 1px solid silver; + box-sizing: border-box; + -moz-box-sizing: border-box; + padding: 3px 10px; + border: none; + outline: none; + font-family: inherit; + font-size: inherit; +} + +.ProseMirror-menubar-wrapper { + height: 100%; + box-sizing: border-box; +} + +.ProseMirror-menubar-wrapper, #markdown textarea { + display: block; + margin-bottom: 4px; +} + +.editorreadmore { + color: @textLinkColor; + text-decoration: underline; + text-align: center; + width: 100%; +} + +@media all and (min-width: 50em) { + #editor { + margin-left: 10%; + margin-right: 10%; + } +} + +@media all and (min-width: 60em) { + #editor { + margin-left: 15%; + margin-right: 15%; + } +} + +@media all and (min-width: 70em) { + #editor { + margin-left: 20%; + margin-right: 20%; + } +} + +@media all and (min-width: 85em) { + #editor { + margin-left: 25%; + margin-right: 25%; + } +} + +@media all and (min-width: 105em) { + #editor { + margin-left: 30%; + margin-right: 30%; + } +} diff --git a/less/prose.less b/less/prose.less new file mode 100644 index 0000000..c70a709 --- /dev/null +++ b/less/prose.less @@ -0,0 +1,4 @@ +@import "prose-editor"; +@import "pad-theme"; +@import "resources"; +@import "lib/elements"; \ No newline at end of file diff --git a/less/resources.less b/less/resources.less new file mode 100644 index 0000000..c255166 --- /dev/null +++ b/less/resources.less @@ -0,0 +1,13 @@ +@primary: rgb(114, 120, 191); +@secondary: rgb(114, 191, 133); +@subheaders: #444; +@headerTextColor: black; +@sansFont: 'Open Sans', 'Segoe UI', Tahoma, Arial, sans-serif; +@serifFont: Lora, 'Palatino Linotype', 'Book Antiqua', 'New York', 'DejaVu serif', serif; +@monoFont: Hack, consolas, Menlo-Regular, Menlo, Monaco, 'ubuntu mono', monospace, monospace; +@dangerCol: #e21d27; +@errUrgentCol: #ecc63c; +@proSelectedCol: #71D571; +@textLinkColor: rgb(0, 0, 238); + +@accent: #767676; \ No newline at end of file diff --git a/migrations/drivers.go b/migrations/drivers.go index 59fe16f..1399411 100644 --- a/migrations/drivers.go +++ b/migrations/drivers.go @@ -78,3 +78,10 @@ func (db *datastore) engine() string { } return " ENGINE = InnoDB" } + +func (db *datastore) after(colName string) string { + if db.driverName == driverSQLite { + return "" + } + return " AFTER " + colName +} diff --git a/migrations/migrations.go b/migrations/migrations.go index 41f036f..88897fd 100644 --- a/migrations/migrations.go +++ b/migrations/migrations.go @@ -61,7 +61,11 @@ var migrations = []Migration{ New("support users suspension", supportUserStatus), // V2 -> V3 (v0.11.0) New("support oauth", oauth), // V3 -> V4 New("support slack oauth", oauthSlack), // V4 -> v5 - New("support ActivityPub mentions", supportActivityPubMentions), // V5 -> V6 (v0.12.0) + New("support ActivityPub mentions", supportActivityPubMentions), // V5 -> V6 + New("support oauth attach", oauthAttach), // V6 -> V7 + New("support oauth via invite", oauthInvites), // V7 -> V8 (v0.12.0) + New("optimize drafts retrieval", optimizeDrafts), // V8 -> V9 + New("support post signatures", supportPostSignatures), // V9 -> V10 } // CurrentVer returns the current migration version the application is on diff --git a/migrations/v10.go b/migrations/v10.go new file mode 100644 index 0000000..9c84a01 --- /dev/null +++ b/migrations/v10.go @@ -0,0 +1,33 @@ +/* + * Copyright © 2020 A Bunch Tell LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package migrations + +func supportPostSignatures(db *datastore) error { + t, err := db.Begin() + if err != nil { + t.Rollback() + return err + } + + _, err = t.Exec(`ALTER TABLE collections ADD COLUMN post_signature ` + db.typeText() + db.collateMultiByte() + ` NULL` + db.after("script")) + if err != nil { + t.Rollback() + return err + } + + err = t.Commit() + if err != nil { + t.Rollback() + return err + } + + return nil +} diff --git a/migrations/v4.go b/migrations/v4.go index c075dd8..c69dce1 100644 --- a/migrations/v4.go +++ b/migrations/v4.go @@ -1,10 +1,20 @@ +/* + * Copyright © 2019-2021 A Bunch Tell LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + package migrations import ( "context" "database/sql" - wf_db "github.com/writeas/writefreely/db" + wf_db "github.com/writefreely/writefreely/db" ) func oauth(db *datastore) error { @@ -15,21 +25,19 @@ func oauth(db *datastore) error { return wf_db.RunTransactionWithOptions(context.Background(), db.DB, &sql.TxOptions{}, func(ctx context.Context, tx *sql.Tx) error { createTableUsersOauth, err := dialect. Table("oauth_users"). - SetIfNotExists(true). + SetIfNotExists(false). Column(dialect.Column("user_id", wf_db.ColumnTypeInteger, wf_db.UnsetSize)). Column(dialect.Column("remote_user_id", wf_db.ColumnTypeInteger, wf_db.UnsetSize)). - UniqueConstraint("user_id"). - UniqueConstraint("remote_user_id"). ToSQL() if err != nil { return err } createTableOauthClientState, err := dialect. Table("oauth_client_states"). - SetIfNotExists(true). + SetIfNotExists(false). Column(dialect.Column("state", wf_db.ColumnTypeVarChar, wf_db.OptionalInt{Set: true, Value: 255})). Column(dialect.Column("used", wf_db.ColumnTypeBool, wf_db.UnsetSize)). - Column(dialect.Column("created_at", wf_db.ColumnTypeDateTime, wf_db.UnsetSize).SetDefault("NOW()")). + Column(dialect.Column("created_at", wf_db.ColumnTypeDateTime, wf_db.UnsetSize).SetDefaultCurrentTimestamp()). UniqueConstraint("state"). ToSQL() if err != nil { diff --git a/migrations/v5.go b/migrations/v5.go index 94e3944..1fe3e30 100644 --- a/migrations/v5.go +++ b/migrations/v5.go @@ -1,10 +1,20 @@ +/* + * Copyright © 2019-2021 A Bunch Tell LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + package migrations import ( "context" "database/sql" - wf_db "github.com/writeas/writefreely/db" + wf_db "github.com/writefreely/writefreely/db" ) func oauthSlack(db *datastore) error { @@ -20,39 +30,50 @@ func oauthSlack(db *datastore) error { Column( "provider", wf_db.ColumnTypeVarChar, - wf_db.OptionalInt{Set: true, Value: 24,})). + wf_db.OptionalInt{Set: true, Value: 24}).SetDefault("")), + dialect. + AlterTable("oauth_client_states"). AddColumn(dialect. Column( "client_id", wf_db.ColumnTypeVarChar, - wf_db.OptionalInt{Set: true, Value: 128,})), + wf_db.OptionalInt{Set: true, Value: 128}).SetDefault("")), dialect. + AlterTable("oauth_users"). + AddColumn(dialect. + Column( + "provider", + wf_db.ColumnTypeVarChar, + wf_db.OptionalInt{Set: true, Value: 24}).SetDefault("")), + dialect. + AlterTable("oauth_users"). + AddColumn(dialect. + Column( + "client_id", + wf_db.ColumnTypeVarChar, + wf_db.OptionalInt{Set: true, Value: 128}).SetDefault("")), + dialect. + AlterTable("oauth_users"). + AddColumn(dialect. + Column( + "access_token", + wf_db.ColumnTypeVarChar, + wf_db.OptionalInt{Set: true, Value: 512}).SetDefault("")), + dialect.CreateUniqueIndex("oauth_users_uk", "oauth_users", "user_id", "provider", "client_id"), + } + + if dialect != wf_db.DialectSQLite { + // This updates the length of the `remote_user_id` column. It isn't needed for SQLite databases. + builders = append(builders, dialect. AlterTable("oauth_users"). ChangeColumn("remote_user_id", dialect. Column( "remote_user_id", wf_db.ColumnTypeVarChar, - wf_db.OptionalInt{Set: true, Value: 128,})). - AddColumn(dialect. - Column( - "provider", - wf_db.ColumnTypeVarChar, - wf_db.OptionalInt{Set: true, Value: 24,})). - AddColumn(dialect. - Column( - "client_id", - wf_db.ColumnTypeVarChar, - wf_db.OptionalInt{Set: true, Value: 128,})). - AddColumn(dialect. - Column( - "access_token", - wf_db.ColumnTypeVarChar, - wf_db.OptionalInt{Set: true, Value: 512,})), - dialect.DropIndex("remote_user_id", "oauth_users"), - dialect.DropIndex("user_id", "oauth_users"), - dialect.CreateUniqueIndex("oauth_users", "oauth_users", "user_id", "provider", "client_id"), + wf_db.OptionalInt{Set: true, Value: 128}))) } + for _, builder := range builders { query, err := builder.ToSQL() if err != nil { diff --git a/migrations/v6.go b/migrations/v6.go index c6f5012..8e0be78 100644 --- a/migrations/v6.go +++ b/migrations/v6.go @@ -1,5 +1,5 @@ /* - * Copyright © 2019 A Bunch Tell LLC. + * Copyright © 2019-2020 A Bunch Tell LLC. * * This file is part of WriteFreely. * @@ -13,7 +13,7 @@ package migrations func supportActivityPubMentions(db *datastore) error { t, err := db.Begin() - _, err = t.Exec(`ALTER TABLE remoteusers ADD COLUMN handle ` + db.typeVarChar(255) + ` DEFAULT '' NOT NULL`) + _, err = t.Exec(`ALTER TABLE remoteusers ADD COLUMN handle ` + db.typeVarChar(255) + ` NULL`) if err != nil { t.Rollback() return err diff --git a/migrations/v7.go b/migrations/v7.go new file mode 100644 index 0000000..5737b21 --- /dev/null +++ b/migrations/v7.go @@ -0,0 +1,46 @@ +/* + * Copyright © 2020-2021 A Bunch Tell LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package migrations + +import ( + "context" + "database/sql" + + wf_db "github.com/writefreely/writefreely/db" +) + +func oauthAttach(db *datastore) error { + dialect := wf_db.DialectMySQL + if db.driverName == driverSQLite { + dialect = wf_db.DialectSQLite + } + return wf_db.RunTransactionWithOptions(context.Background(), db.DB, &sql.TxOptions{}, func(ctx context.Context, tx *sql.Tx) error { + builders := []wf_db.SQLBuilder{ + dialect. + AlterTable("oauth_client_states"). + AddColumn(dialect. + Column( + "attach_user_id", + wf_db.ColumnTypeInteger, + wf_db.OptionalInt{Set: true, Value: 24}).SetNullable(true)), + } + for _, builder := range builders { + query, err := builder.ToSQL() + if err != nil { + return err + } + if _, err := tx.ExecContext(ctx, query); err != nil { + return err + } + } + return nil + }) +} diff --git a/migrations/v8.go b/migrations/v8.go new file mode 100644 index 0000000..28af523 --- /dev/null +++ b/migrations/v8.go @@ -0,0 +1,45 @@ +/* + * Copyright © 2020-2021 A Bunch Tell LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package migrations + +import ( + "context" + "database/sql" + + wf_db "github.com/writefreely/writefreely/db" +) + +func oauthInvites(db *datastore) error { + dialect := wf_db.DialectMySQL + if db.driverName == driverSQLite { + dialect = wf_db.DialectSQLite + } + return wf_db.RunTransactionWithOptions(context.Background(), db.DB, &sql.TxOptions{}, func(ctx context.Context, tx *sql.Tx) error { + builders := []wf_db.SQLBuilder{ + dialect. + AlterTable("oauth_client_states"). + AddColumn(dialect.Column("invite_code", wf_db.ColumnTypeChar, wf_db.OptionalInt{ + Set: true, + Value: 6, + }).SetNullable(true)), + } + for _, builder := range builders { + query, err := builder.ToSQL() + if err != nil { + return err + } + if _, err := tx.ExecContext(ctx, query); err != nil { + return err + } + } + return nil + }) +} diff --git a/migrations/v9.go b/migrations/v9.go new file mode 100644 index 0000000..c6b832e --- /dev/null +++ b/migrations/v9.go @@ -0,0 +1,37 @@ +/* + * Copyright © 2020 A Bunch Tell LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package migrations + +func optimizeDrafts(db *datastore) error { + t, err := db.Begin() + if err != nil { + t.Rollback() + return err + } + + if db.driverName == driverSQLite { + _, err = t.Exec(`CREATE INDEX key_owner_post_id ON posts (owner_id, id)`) + } else { + _, err = t.Exec(`ALTER TABLE posts ADD INDEX(owner_id, id)`) + } + if err != nil { + t.Rollback() + return err + } + + err = t.Commit() + if err != nil { + t.Rollback() + return err + } + + return nil +} diff --git a/nodeinfo.go b/nodeinfo.go index 944a5df..f0c0b5e 100644 --- a/nodeinfo.go +++ b/nodeinfo.go @@ -1,5 +1,5 @@ /* - * Copyright © 2018 A Bunch Tell LLC. + * Copyright © 2018-2019, 2021 A Bunch Tell LLC. * * This file is part of WriteFreely. * @@ -12,8 +12,8 @@ package writefreely import ( "github.com/writeas/web-core/log" - "github.com/writeas/writefreely/config" "github.com/writefreely/go-nodeinfo" + "github.com/writefreely/writefreely/config" "strings" ) @@ -45,7 +45,7 @@ func nodeInfoConfig(db *datastore, cfg *config.Config) *nodeinfo.Config { Private: cfg.App.Private, Software: nodeinfo.SoftwareMeta{ HomePage: softwareURL, - GitHub: "https://github.com/writeas/writefreely", + GitHub: "https://github.com/writefreely/writefreely", Follow: "https://writing.exchange/@write_as", }, MaxBlogs: cfg.App.MaxBlogs, diff --git a/oauth.go b/oauth.go index caf8189..e28e21a 100644 --- a/oauth.go +++ b/oauth.go @@ -1,22 +1,59 @@ +/* + * Copyright © 2019-2021 A Bunch Tell LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + package writefreely import ( "context" "encoding/json" "fmt" - "github.com/gorilla/mux" - "github.com/gorilla/sessions" - "github.com/writeas/impart" - "github.com/writeas/web-core/log" - "github.com/writeas/writefreely/config" "io" "io/ioutil" "net/http" "net/url" "strings" "time" + + "github.com/gorilla/mux" + "github.com/gorilla/sessions" + "github.com/writeas/impart" + "github.com/writeas/web-core/log" + "github.com/writefreely/writefreely/config" ) +// OAuthButtons holds display information for different OAuth providers we support. +type OAuthButtons struct { + SlackEnabled bool + WriteAsEnabled bool + GitLabEnabled bool + GitLabDisplayName string + GiteaEnabled bool + GiteaDisplayName string + GenericEnabled bool + GenericDisplayName string +} + +// NewOAuthButtons creates a new OAuthButtons struct based on our app configuration. +func NewOAuthButtons(cfg *config.Config) *OAuthButtons { + return &OAuthButtons{ + SlackEnabled: cfg.SlackOauth.ClientID != "", + WriteAsEnabled: cfg.WriteAsOauth.ClientID != "", + GitLabEnabled: cfg.GitlabOauth.ClientID != "", + GitLabDisplayName: config.OrDefaultString(cfg.GitlabOauth.DisplayName, gitlabDisplayName), + GiteaEnabled: cfg.GiteaOauth.ClientID != "", + GiteaDisplayName: config.OrDefaultString(cfg.GiteaOauth.DisplayName, giteaDisplayName), + GenericEnabled: cfg.GenericOauth.ClientID != "", + GenericDisplayName: config.OrDefaultString(cfg.GenericOauth.DisplayName, genericOauthDisplayName), + } +} + // TokenResponse contains data returned when a token is created either // through a code exchange or using a refresh token. type TokenResponse struct { @@ -59,8 +96,8 @@ type OAuthDatastoreProvider interface { type OAuthDatastore interface { GetIDForRemoteUser(context.Context, string, string, string) (int64, error) RecordRemoteUserID(context.Context, int64, string, string, string, string) error - ValidateOAuthState(context.Context, string) (string, string, error) - GenerateOAuthState(context.Context, string, string) (string, error) + ValidateOAuthState(context.Context, string) (string, string, int64, string, error) + GenerateOAuthState(context.Context, string, string, int64, string) (string, error) CreateUser(*config.Config, *User, string) error GetUserByID(int64) (*User, error) @@ -96,19 +133,32 @@ type oauthHandler struct { func (h oauthHandler) viewOauthInit(app *App, w http.ResponseWriter, r *http.Request) error { ctx := r.Context() - state, err := h.DB.GenerateOAuthState(ctx, h.oauthClient.GetProvider(), h.oauthClient.GetClientID()) + + var attachUser int64 + if attach := r.URL.Query().Get("attach"); attach == "t" { + user, _ := getUserAndSession(app, r) + if user == nil { + return impart.HTTPError{http.StatusInternalServerError, "cannot attach auth to user: user not found in session"} + } + attachUser = user.ID + } + + state, err := h.DB.GenerateOAuthState(ctx, h.oauthClient.GetProvider(), h.oauthClient.GetClientID(), attachUser, r.FormValue("invite_code")) if err != nil { + log.Error("viewOauthInit error: %s", err) return impart.HTTPError{http.StatusInternalServerError, "could not prepare oauth redirect url"} } if h.callbackProxy != nil { if err := h.callbackProxy.register(ctx, state); err != nil { + log.Error("viewOauthInit error: %s", err) return impart.HTTPError{http.StatusInternalServerError, "could not register state server"} } } location, err := h.oauthClient.buildLoginURL(state) if err != nil { + log.Error("viewOauthInit error: %s", err) return impart.HTTPError{http.StatusInternalServerError, "could not prepare oauth redirect url"} } return impart.HTTPError{http.StatusTemporaryRedirect, location} @@ -149,7 +199,7 @@ func configureWriteAsOauth(parentHandler *Handler, r *mux.Router, app *App) { callbackLocation: app.Config().App.Host + "/oauth/callback/write.as", httpClient: config.DefaultHTTPClient(), } - callbackLocation = app.Config().SlackOauth.CallbackProxy + callbackLocation = app.Config().WriteAsOauth.CallbackProxy } oauthClient := writeAsOauthClient{ @@ -165,6 +215,93 @@ func configureWriteAsOauth(parentHandler *Handler, r *mux.Router, app *App) { } } +func configureGitlabOauth(parentHandler *Handler, r *mux.Router, app *App) { + if app.Config().GitlabOauth.ClientID != "" { + callbackLocation := app.Config().App.Host + "/oauth/callback/gitlab" + + var callbackProxy *callbackProxyClient = nil + if app.Config().GitlabOauth.CallbackProxy != "" { + callbackProxy = &callbackProxyClient{ + server: app.Config().GitlabOauth.CallbackProxyAPI, + callbackLocation: app.Config().App.Host + "/oauth/callback/gitlab", + httpClient: config.DefaultHTTPClient(), + } + callbackLocation = app.Config().GitlabOauth.CallbackProxy + } + + address := config.OrDefaultString(app.Config().GitlabOauth.Host, gitlabHost) + oauthClient := gitlabOauthClient{ + ClientID: app.Config().GitlabOauth.ClientID, + ClientSecret: app.Config().GitlabOauth.ClientSecret, + ExchangeLocation: address + "/oauth/token", + InspectLocation: address + "/api/v4/user", + AuthLocation: address + "/oauth/authorize", + HttpClient: config.DefaultHTTPClient(), + CallbackLocation: callbackLocation, + } + configureOauthRoutes(parentHandler, r, app, oauthClient, callbackProxy) + } +} + +func configureGenericOauth(parentHandler *Handler, r *mux.Router, app *App) { + if app.Config().GenericOauth.ClientID != "" { + callbackLocation := app.Config().App.Host + "/oauth/callback/generic" + + var callbackProxy *callbackProxyClient = nil + if app.Config().GenericOauth.CallbackProxy != "" { + callbackProxy = &callbackProxyClient{ + server: app.Config().GenericOauth.CallbackProxyAPI, + callbackLocation: app.Config().App.Host + "/oauth/callback/generic", + httpClient: config.DefaultHTTPClient(), + } + callbackLocation = app.Config().GenericOauth.CallbackProxy + } + + oauthClient := genericOauthClient{ + ClientID: app.Config().GenericOauth.ClientID, + ClientSecret: app.Config().GenericOauth.ClientSecret, + ExchangeLocation: app.Config().GenericOauth.Host + app.Config().GenericOauth.TokenEndpoint, + InspectLocation: app.Config().GenericOauth.Host + app.Config().GenericOauth.InspectEndpoint, + AuthLocation: app.Config().GenericOauth.Host + app.Config().GenericOauth.AuthEndpoint, + HttpClient: config.DefaultHTTPClient(), + CallbackLocation: callbackLocation, + Scope: config.OrDefaultString(app.Config().GenericOauth.Scope, "read_user"), + MapUserID: config.OrDefaultString(app.Config().GenericOauth.MapUserID, "user_id"), + MapUsername: config.OrDefaultString(app.Config().GenericOauth.MapUsername, "username"), + MapDisplayName: config.OrDefaultString(app.Config().GenericOauth.MapDisplayName, "-"), + MapEmail: config.OrDefaultString(app.Config().GenericOauth.MapEmail, "email"), + } + configureOauthRoutes(parentHandler, r, app, oauthClient, callbackProxy) + } +} + +func configureGiteaOauth(parentHandler *Handler, r *mux.Router, app *App) { + if app.Config().GiteaOauth.ClientID != "" { + callbackLocation := app.Config().App.Host + "/oauth/callback/gitea" + + var callbackProxy *callbackProxyClient = nil + if app.Config().GiteaOauth.CallbackProxy != "" { + callbackProxy = &callbackProxyClient{ + server: app.Config().GiteaOauth.CallbackProxyAPI, + callbackLocation: app.Config().App.Host + "/oauth/callback/gitea", + httpClient: config.DefaultHTTPClient(), + } + callbackLocation = app.Config().GiteaOauth.CallbackProxy + } + + oauthClient := giteaOauthClient{ + ClientID: app.Config().GiteaOauth.ClientID, + ClientSecret: app.Config().GiteaOauth.ClientSecret, + ExchangeLocation: app.Config().GiteaOauth.Host + "/login/oauth/access_token", + InspectLocation: app.Config().GiteaOauth.Host + "/api/v1/user", + AuthLocation: app.Config().GiteaOauth.Host + "/login/oauth/authorize", + HttpClient: config.DefaultHTTPClient(), + CallbackLocation: callbackLocation, + } + configureOauthRoutes(parentHandler, r, app, oauthClient, callbackProxy) + } +} + func configureOauthRoutes(parentHandler *Handler, r *mux.Router, app *App, oauthClient oauthClient, callbackProxy *callbackProxyClient) { handler := &oauthHandler{ Config: app.Config(), @@ -185,7 +322,7 @@ func (h oauthHandler) viewOauthCallback(app *App, w http.ResponseWriter, r *http code := r.FormValue("code") state := r.FormValue("state") - provider, clientID, err := h.DB.ValidateOAuthState(ctx, state) + provider, clientID, attachUserID, inviteCode, err := h.DB.ValidateOAuthState(ctx, state) if err != nil { log.Error("Unable to ValidateOAuthState: %s", err) return impart.HTTPError{http.StatusInternalServerError, err.Error()} @@ -194,10 +331,16 @@ func (h oauthHandler) viewOauthCallback(app *App, w http.ResponseWriter, r *http tokenResponse, err := h.oauthClient.exchangeOauthCode(ctx, code) if err != nil { log.Error("Unable to exchangeOauthCode: %s", err) + // TODO: show user friendly message if needed + // TODO: show NO message for cases like user pressing "Cancel" on authorize step + addSessionFlash(app, w, r, err.Error(), nil) + if attachUserID > 0 { + return impart.HTTPError{http.StatusFound, "/me/settings"} + } return impart.HTTPError{http.StatusInternalServerError, err.Error()} } - // Now that we have the access token, let's use it real quick to make sur + // Now that we have the access token, let's use it real quick to make sure // it really really works. tokenInfo, err := h.oauthClient.inspectOauthAccessToken(ctx, tokenResponse.AccessToken) if err != nil { @@ -211,7 +354,15 @@ func (h oauthHandler) viewOauthCallback(app *App, w http.ResponseWriter, r *http return impart.HTTPError{http.StatusInternalServerError, err.Error()} } + if localUserID != -1 && attachUserID > 0 { + if err = addSessionFlash(app, w, r, "This Slack account is already attached to another user.", nil); err != nil { + return impart.HTTPError{Status: http.StatusInternalServerError, Message: err.Error()} + } + return impart.HTTPError{http.StatusFound, "/me/settings"} + } + if localUserID != -1 { + // Existing user, so log in now user, err := h.DB.GetUserByID(localUserID) if err != nil { log.Error("Unable to GetUserByID %d: %s", localUserID, err) @@ -223,6 +374,30 @@ func (h oauthHandler) viewOauthCallback(app *App, w http.ResponseWriter, r *http } return nil } + if attachUserID > 0 { + log.Info("attaching to user %d", attachUserID) + err = h.DB.RecordRemoteUserID(r.Context(), attachUserID, tokenInfo.UserID, provider, clientID, tokenResponse.AccessToken) + if err != nil { + return impart.HTTPError{http.StatusInternalServerError, err.Error()} + } + return impart.HTTPError{http.StatusFound, "/me/settings"} + } + + // New user registration below. + // First, verify that user is allowed to register + if inviteCode != "" { + // Verify invite code is valid + i, err := app.db.GetUserInvite(inviteCode) + if err != nil { + return impart.HTTPError{http.StatusInternalServerError, err.Error()} + } + if !i.Active(app.db) { + return impart.HTTPError{http.StatusNotFound, "Invite link has expired."} + } + } else if !app.cfg.App.OpenRegistration { + addSessionFlash(app, w, r, ErrUserNotFound.Error(), nil) + return impart.HTTPError{http.StatusFound, "/login"} + } displayName := tokenInfo.DisplayName if len(displayName) == 0 { @@ -237,6 +412,7 @@ func (h oauthHandler) viewOauthCallback(app *App, w http.ResponseWriter, r *http TokenRemoteUser: tokenInfo.UserID, Provider: provider, ClientID: clientID, + InviteCode: inviteCode, } tp.TokenHash = tp.HashTokenParams(h.Config.Server.HashSeed) @@ -251,7 +427,7 @@ func (r *callbackProxyClient) register(ctx context.Context, state string) error if err != nil { return err } - req.Header.Set("User-Agent", "writefreely") + req.Header.Set("User-Agent", ServerUserAgent("")) req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/x-www-form-urlencoded") diff --git a/oauth_generic.go b/oauth_generic.go new file mode 100644 index 0000000..ba8b97e --- /dev/null +++ b/oauth_generic.go @@ -0,0 +1,126 @@ +package writefreely + +import ( + "context" + "errors" + "net/http" + "net/url" + "strings" +) + +type genericOauthClient struct { + ClientID string + ClientSecret string + AuthLocation string + ExchangeLocation string + InspectLocation string + CallbackLocation string + Scope string + MapUserID string + MapUsername string + MapDisplayName string + MapEmail string + HttpClient HttpClient +} + +var _ oauthClient = genericOauthClient{} + +const ( + genericOauthDisplayName = "OAuth" +) + +func (c genericOauthClient) GetProvider() string { + return "generic" +} + +func (c genericOauthClient) GetClientID() string { + return c.ClientID +} + +func (c genericOauthClient) GetCallbackLocation() string { + return c.CallbackLocation +} + +func (c genericOauthClient) buildLoginURL(state string) (string, error) { + u, err := url.Parse(c.AuthLocation) + if err != nil { + return "", err + } + q := u.Query() + q.Set("client_id", c.ClientID) + q.Set("redirect_uri", c.CallbackLocation) + q.Set("response_type", "code") + q.Set("state", state) + q.Set("scope", c.Scope) + u.RawQuery = q.Encode() + return u.String(), nil +} + +func (c genericOauthClient) exchangeOauthCode(ctx context.Context, code string) (*TokenResponse, error) { + form := url.Values{} + form.Add("grant_type", "authorization_code") + form.Add("redirect_uri", c.CallbackLocation) + form.Add("scope", c.Scope) + form.Add("code", code) + req, err := http.NewRequest("POST", c.ExchangeLocation, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.WithContext(ctx) + req.Header.Set("User-Agent", ServerUserAgent("")) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(c.ClientID, c.ClientSecret) + + resp, err := c.HttpClient.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, errors.New("unable to exchange code for access token") + } + + var tokenResponse TokenResponse + if err := limitedJsonUnmarshal(resp.Body, tokenRequestMaxLen, &tokenResponse); err != nil { + return nil, err + } + if tokenResponse.Error != "" { + return nil, errors.New(tokenResponse.Error) + } + return &tokenResponse, nil +} + +func (c genericOauthClient) inspectOauthAccessToken(ctx context.Context, accessToken string) (*InspectResponse, error) { + req, err := http.NewRequest("GET", c.InspectLocation, nil) + if err != nil { + return nil, err + } + req.WithContext(ctx) + req.Header.Set("User-Agent", ServerUserAgent("")) + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+accessToken) + + resp, err := c.HttpClient.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, errors.New("unable to inspect access token") + } + + // since we don't know what the JSON from the server will look like, we create a + // generic interface and then map manually to values set in the config + var genericInterface map[string]interface{} + if err := limitedJsonUnmarshal(resp.Body, infoRequestMaxLen, &genericInterface); err != nil { + return nil, err + } + + // map each relevant field in inspectResponse to the mapped field from the config + var inspectResponse InspectResponse + inspectResponse.UserID, _ = genericInterface[c.MapUserID].(string) + inspectResponse.Username, _ = genericInterface[c.MapUsername].(string) + inspectResponse.DisplayName, _ = genericInterface[c.MapDisplayName].(string) + inspectResponse.Email, _ = genericInterface[c.MapEmail].(string) + + return &inspectResponse, nil +} diff --git a/oauth_gitea.go b/oauth_gitea.go new file mode 100644 index 0000000..a9b7741 --- /dev/null +++ b/oauth_gitea.go @@ -0,0 +1,114 @@ +package writefreely + +import ( + "context" + "errors" + "net/http" + "net/url" + "strings" +) + +type giteaOauthClient struct { + ClientID string + ClientSecret string + AuthLocation string + ExchangeLocation string + InspectLocation string + CallbackLocation string + HttpClient HttpClient +} + +var _ oauthClient = giteaOauthClient{} + +const ( + giteaDisplayName = "Gitea" +) + +func (c giteaOauthClient) GetProvider() string { + return "gitea" +} + +func (c giteaOauthClient) GetClientID() string { + return c.ClientID +} + +func (c giteaOauthClient) GetCallbackLocation() string { + return c.CallbackLocation +} + +func (c giteaOauthClient) buildLoginURL(state string) (string, error) { + u, err := url.Parse(c.AuthLocation) + if err != nil { + return "", err + } + q := u.Query() + q.Set("client_id", c.ClientID) + q.Set("redirect_uri", c.CallbackLocation) + q.Set("response_type", "code") + q.Set("state", state) + // q.Set("scope", "read_user") + u.RawQuery = q.Encode() + return u.String(), nil +} + +func (c giteaOauthClient) exchangeOauthCode(ctx context.Context, code string) (*TokenResponse, error) { + form := url.Values{} + form.Add("grant_type", "authorization_code") + form.Add("redirect_uri", c.CallbackLocation) + // form.Add("scope", "read_user") + form.Add("code", code) + req, err := http.NewRequest("POST", c.ExchangeLocation, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.WithContext(ctx) + req.Header.Set("User-Agent", ServerUserAgent("")) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(c.ClientID, c.ClientSecret) + + resp, err := c.HttpClient.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, errors.New("unable to exchange code for access token") + } + + var tokenResponse TokenResponse + if err := limitedJsonUnmarshal(resp.Body, tokenRequestMaxLen, &tokenResponse); err != nil { + return nil, err + } + if tokenResponse.Error != "" { + return nil, errors.New(tokenResponse.Error) + } + return &tokenResponse, nil +} + +func (c giteaOauthClient) inspectOauthAccessToken(ctx context.Context, accessToken string) (*InspectResponse, error) { + req, err := http.NewRequest("GET", c.InspectLocation, nil) + if err != nil { + return nil, err + } + req.WithContext(ctx) + req.Header.Set("User-Agent", ServerUserAgent("")) + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+accessToken) + + resp, err := c.HttpClient.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, errors.New("unable to inspect access token") + } + + var inspectResponse InspectResponse + if err := limitedJsonUnmarshal(resp.Body, infoRequestMaxLen, &inspectResponse); err != nil { + return nil, err + } + if inspectResponse.Error != "" { + return nil, errors.New(inspectResponse.Error) + } + return &inspectResponse, nil +} diff --git a/oauth_gitlab.go b/oauth_gitlab.go new file mode 100644 index 0000000..ad919e4 --- /dev/null +++ b/oauth_gitlab.go @@ -0,0 +1,115 @@ +package writefreely + +import ( + "context" + "errors" + "net/http" + "net/url" + "strings" +) + +type gitlabOauthClient struct { + ClientID string + ClientSecret string + AuthLocation string + ExchangeLocation string + InspectLocation string + CallbackLocation string + HttpClient HttpClient +} + +var _ oauthClient = gitlabOauthClient{} + +const ( + gitlabHost = "https://gitlab.com" + gitlabDisplayName = "GitLab" +) + +func (c gitlabOauthClient) GetProvider() string { + return "gitlab" +} + +func (c gitlabOauthClient) GetClientID() string { + return c.ClientID +} + +func (c gitlabOauthClient) GetCallbackLocation() string { + return c.CallbackLocation +} + +func (c gitlabOauthClient) buildLoginURL(state string) (string, error) { + u, err := url.Parse(c.AuthLocation) + if err != nil { + return "", err + } + q := u.Query() + q.Set("client_id", c.ClientID) + q.Set("redirect_uri", c.CallbackLocation) + q.Set("response_type", "code") + q.Set("state", state) + q.Set("scope", "read_user") + u.RawQuery = q.Encode() + return u.String(), nil +} + +func (c gitlabOauthClient) exchangeOauthCode(ctx context.Context, code string) (*TokenResponse, error) { + form := url.Values{} + form.Add("grant_type", "authorization_code") + form.Add("redirect_uri", c.CallbackLocation) + form.Add("scope", "read_user") + form.Add("code", code) + req, err := http.NewRequest("POST", c.ExchangeLocation, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.WithContext(ctx) + req.Header.Set("User-Agent", ServerUserAgent("")) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(c.ClientID, c.ClientSecret) + + resp, err := c.HttpClient.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, errors.New("unable to exchange code for access token") + } + + var tokenResponse TokenResponse + if err := limitedJsonUnmarshal(resp.Body, tokenRequestMaxLen, &tokenResponse); err != nil { + return nil, err + } + if tokenResponse.Error != "" { + return nil, errors.New(tokenResponse.Error) + } + return &tokenResponse, nil +} + +func (c gitlabOauthClient) inspectOauthAccessToken(ctx context.Context, accessToken string) (*InspectResponse, error) { + req, err := http.NewRequest("GET", c.InspectLocation, nil) + if err != nil { + return nil, err + } + req.WithContext(ctx) + req.Header.Set("User-Agent", ServerUserAgent("")) + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+accessToken) + + resp, err := c.HttpClient.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, errors.New("unable to inspect access token") + } + + var inspectResponse InspectResponse + if err := limitedJsonUnmarshal(resp.Body, infoRequestMaxLen, &inspectResponse); err != nil { + return nil, err + } + if inspectResponse.Error != "" { + return nil, errors.New(inspectResponse.Error) + } + return &inspectResponse, nil +} diff --git a/oauth_signup.go b/oauth_signup.go index 220afbd..b1256be 100644 --- a/oauth_signup.go +++ b/oauth_signup.go @@ -1,5 +1,5 @@ /* - * Copyright © 2020 A Bunch Tell LLC. + * Copyright © 2020-2021 A Bunch Tell LLC. * * This file is part of WriteFreely. * @@ -17,7 +17,7 @@ import ( "github.com/writeas/impart" "github.com/writeas/web-core/auth" "github.com/writeas/web-core/log" - "github.com/writeas/writefreely/page" + "github.com/writefreely/writefreely/page" "html/template" "net/http" "strings" @@ -38,6 +38,7 @@ type viewOauthSignupVars struct { Provider string ClientID string TokenHash string + InviteCode string LoginUsername string Alias string // TODO: rename this to match the data it represents: the collection title @@ -57,6 +58,7 @@ const ( oauthParamAlias = "alias" oauthParamEmail = "email" oauthParamPassword = "password" + oauthParamInviteCode = "invite_code" ) type oauthSignupPageParams struct { @@ -68,6 +70,7 @@ type oauthSignupPageParams struct { ClientID string Provider string TokenHash string + InviteCode string } func (p oauthSignupPageParams) HashTokenParams(key string) string { @@ -92,6 +95,7 @@ func (h oauthHandler) viewOauthSignup(app *App, w http.ResponseWriter, r *http.R TokenRemoteUser: r.FormValue(oauthParamTokenRemoteUserID), ClientID: r.FormValue(oauthParamClientID), Provider: r.FormValue(oauthParamProvider), + InviteCode: r.FormValue(oauthParamInviteCode), } if tp.HashTokenParams(h.Config.Server.HashSeed) != r.FormValue(oauthParamHash) { return impart.HTTPError{Status: http.StatusBadRequest, Message: "Request has been tampered with."} @@ -128,6 +132,14 @@ func (h oauthHandler) viewOauthSignup(app *App, w http.ResponseWriter, r *http.R return h.showOauthSignupPage(app, w, r, tp, err) } + // Log invite if needed + if tp.InviteCode != "" { + err = app.db.CreateInvitedUser(tp.InviteCode, newUser.ID) + if err != nil { + return err + } + } + err = h.DB.RecordRemoteUserID(r.Context(), newUser.ID, r.FormValue(oauthParamTokenRemoteUserID), r.FormValue(oauthParamProvider), r.FormValue(oauthParamClientID), r.FormValue(oauthParamAccessToken)) if err != nil { return h.showOauthSignupPage(app, w, r, tp, err) @@ -195,6 +207,7 @@ func (h oauthHandler) showOauthSignupPage(app *App, w http.ResponseWriter, r *ht Provider: tp.Provider, ClientID: tp.ClientID, TokenHash: tp.TokenHash, + InviteCode: tp.InviteCode, LoginUsername: username, Alias: collTitle, diff --git a/oauth_slack.go b/oauth_slack.go index 35db156..bad3775 100644 --- a/oauth_slack.go +++ b/oauth_slack.go @@ -13,8 +13,6 @@ package writefreely import ( "context" "errors" - "fmt" - "github.com/writeas/nerds/store" "github.com/writeas/slug" "net/http" "net/url" @@ -113,7 +111,7 @@ func (c slackOauthClient) exchangeOauthCode(ctx context.Context, code string) (* return nil, err } req.WithContext(ctx) - req.Header.Set("User-Agent", "writefreely") + req.Header.Set("User-Agent", ServerUserAgent("")) req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.SetBasicAuth(c.ClientID, c.ClientSecret) @@ -142,7 +140,7 @@ func (c slackOauthClient) inspectOauthAccessToken(ctx context.Context, accessTok return nil, err } req.WithContext(ctx) - req.Header.Set("User-Agent", "writefreely") + req.Header.Set("User-Agent", ServerUserAgent("")) req.Header.Set("Accept", "application/json") req.Header.Set("Authorization", "Bearer "+accessToken) @@ -167,7 +165,7 @@ func (c slackOauthClient) inspectOauthAccessToken(ctx context.Context, accessTok func (resp slackUserIdentityResponse) InspectResponse() *InspectResponse { return &InspectResponse{ UserID: resp.User.ID, - Username: fmt.Sprintf("%s-%s", slug.Make(resp.User.Name), store.GenerateRandomString("0123456789bcdfghjklmnpqrstvwxyz", 5)), + Username: slug.Make(resp.User.Name), DisplayName: resp.User.Name, Email: resp.User.Email, } diff --git a/oauth_test.go b/oauth_test.go index 2e293e7..5694416 100644 --- a/oauth_test.go +++ b/oauth_test.go @@ -1,3 +1,13 @@ +/* + * Copyright © 2019-2021 A Bunch Tell LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + package writefreely import ( @@ -6,8 +16,8 @@ import ( "github.com/gorilla/sessions" "github.com/stretchr/testify/assert" "github.com/writeas/impart" - "github.com/writeas/nerds/store" - "github.com/writeas/writefreely/config" + "github.com/writeas/web-core/id" + "github.com/writefreely/writefreely/config" "net/http" "net/http/httptest" "net/url" @@ -22,8 +32,8 @@ type MockOAuthDatastoreProvider struct { } type MockOAuthDatastore struct { - DoGenerateOAuthState func(context.Context, string, string) (string, error) - DoValidateOAuthState func(context.Context, string) (string, string, error) + DoGenerateOAuthState func(context.Context, string, string, int64, string) (string, error) + DoValidateOAuthState func(context.Context, string) (string, string, int64, string, error) DoGetIDForRemoteUser func(context.Context, string, string, string) (int64, error) DoCreateUser func(*config.Config, *User, string) error DoRecordRemoteUserID func(context.Context, int64, string, string, string, string) error @@ -86,11 +96,11 @@ func (m *MockOAuthDatastoreProvider) Config() *config.Config { return cfg } -func (m *MockOAuthDatastore) ValidateOAuthState(ctx context.Context, state string) (string, string, error) { +func (m *MockOAuthDatastore) ValidateOAuthState(ctx context.Context, state string) (string, string, int64, string, error) { if m.DoValidateOAuthState != nil { return m.DoValidateOAuthState(ctx, state) } - return "", "", nil + return "", "", 0, "", nil } func (m *MockOAuthDatastore) GetIDForRemoteUser(ctx context.Context, remoteUserID, provider, clientID string) (int64, error) { @@ -119,17 +129,15 @@ func (m *MockOAuthDatastore) GetUserByID(userID int64) (*User, error) { if m.DoGetUserByID != nil { return m.DoGetUserByID(userID) } - user := &User{ - - } + user := &User{} return user, nil } -func (m *MockOAuthDatastore) GenerateOAuthState(ctx context.Context, provider string, clientID string) (string, error) { +func (m *MockOAuthDatastore) GenerateOAuthState(ctx context.Context, provider string, clientID string, attachUserID int64, inviteCode string) (string, error) { if m.DoGenerateOAuthState != nil { - return m.DoGenerateOAuthState(ctx, provider, clientID) + return m.DoGenerateOAuthState(ctx, provider, clientID, attachUserID, inviteCode) } - return store.Generate62RandomString(14), nil + return id.Generate62RandomString(14), nil } func TestViewOauthInit(t *testing.T) { @@ -173,7 +181,7 @@ func TestViewOauthInit(t *testing.T) { app := &MockOAuthDatastoreProvider{ DoDB: func() OAuthDatastore { return &MockOAuthDatastore{ - DoGenerateOAuthState: func(ctx context.Context, provider, clientID string) (string, error) { + DoGenerateOAuthState: func(ctx context.Context, provider, clientID string, attachUserID int64, inviteCode string) (string, error) { return "", fmt.Errorf("pretend unable to write state error") }, } @@ -246,7 +254,7 @@ func TestViewOauthCallback(t *testing.T) { req, err := http.NewRequest("GET", "/oauth/callback", nil) assert.NoError(t, err) rr := httptest.NewRecorder() - err = h.viewOauthCallback(nil, rr, req) + err = h.viewOauthCallback(&App{cfg: app.Config(), sessionStore: app.SessionStore()}, rr, req) assert.NoError(t, err) assert.Equal(t, http.StatusTemporaryRedirect, rr.Code) }) diff --git a/oauth_writeas.go b/oauth_writeas.go index 6251a16..e58f6e9 100644 --- a/oauth_writeas.go +++ b/oauth_writeas.go @@ -62,7 +62,7 @@ func (c writeAsOauthClient) exchangeOauthCode(ctx context.Context, code string) return nil, err } req.WithContext(ctx) - req.Header.Set("User-Agent", "writefreely") + req.Header.Set("User-Agent", ServerUserAgent("")) req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.SetBasicAuth(c.ClientID, c.ClientSecret) @@ -91,7 +91,7 @@ func (c writeAsOauthClient) inspectOauthAccessToken(ctx context.Context, accessT return nil, err } req.WithContext(ctx) - req.Header.Set("User-Agent", "writefreely") + req.Header.Set("User-Agent", ServerUserAgent("")) req.Header.Set("Accept", "application/json") req.Header.Set("Authorization", "Bearer "+accessToken) diff --git a/pad.go b/pad.go index 3b0f1c2..b64c282 100644 --- a/pad.go +++ b/pad.go @@ -1,5 +1,5 @@ /* - * Copyright © 2018-2019 A Bunch Tell LLC. + * Copyright © 2018-2021 A Bunch Tell LLC. * * This file is part of WriteFreely. * @@ -17,7 +17,7 @@ import ( "github.com/gorilla/mux" "github.com/writeas/impart" "github.com/writeas/web-core/log" - "github.com/writeas/writefreely/page" + "github.com/writefreely/writefreely/page" ) func handleViewPad(app *App, w http.ResponseWriter, r *http.Request) error { @@ -35,10 +35,10 @@ func handleViewPad(app *App, w http.ResponseWriter, r *http.Request) error { } appData := &struct { page.StaticPage - Post *RawPost - User *User - Blogs *[]Collection - Suspended bool + Post *RawPost + User *User + Blogs *[]Collection + Silenced bool Editing bool // True if we're modifying an existing post EditCollection *Collection // Collection of the post we're editing, if any @@ -53,9 +53,9 @@ func handleViewPad(app *App, w http.ResponseWriter, r *http.Request) error { if err != nil { log.Error("Unable to get user's blogs for Pad: %v", err) } - appData.Suspended, err = app.db.IsUserSuspended(appData.User.ID) + appData.Silenced, err = app.db.IsUserSilenced(appData.User.ID) if err != nil { - log.Error("Unable to get users suspension status for Pad: %v", err) + log.Error("Unable to get user status for Pad: %v", err) } } @@ -127,16 +127,16 @@ func handleViewMeta(app *App, w http.ResponseWriter, r *http.Request) error { EditCollection *Collection // Collection of the post we're editing, if any Flashes []string NeedsToken bool - Suspended bool + Silenced bool }{ StaticPage: pageForReq(app, r), Post: &RawPost{Font: "norm"}, User: getUserSession(app, r), } var err error - appData.Suspended, err = app.db.IsUserSuspended(appData.User.ID) + appData.Silenced, err = app.db.IsUserSilenced(appData.User.ID) if err != nil { - log.Error("view meta: get user suspended status: %v", err) + log.Error("view meta: get user status: %v", err) return ErrInternalGeneral } diff --git a/page/page.go b/page/page.go index 15f09a9..2cfb6cc 100644 --- a/page/page.go +++ b/page/page.go @@ -1,5 +1,5 @@ /* - * Copyright © 2018 A Bunch Tell LLC. + * Copyright © 2018-2019, 2021 A Bunch Tell LLC. * * This file is part of WriteFreely. * @@ -12,7 +12,7 @@ package page import ( - "github.com/writeas/writefreely/config" + "github.com/writefreely/writefreely/config" "strings" ) diff --git a/pages.go b/pages.go index d8f034b..f871882 100644 --- a/pages.go +++ b/pages.go @@ -1,5 +1,5 @@ /* - * Copyright © 2018-2019 A Bunch Tell LLC. + * Copyright © 2018-2019, 2021 A Bunch Tell LLC. * * This file is part of WriteFreely. * @@ -12,7 +12,7 @@ package writefreely import ( "database/sql" - "github.com/writeas/writefreely/config" + "github.com/writefreely/writefreely/config" "time" ) diff --git a/pages/500.tmpl b/pages/500.tmpl index c436280..e148fb5 100644 --- a/pages/500.tmpl +++ b/pages/500.tmpl @@ -2,7 +2,7 @@ {{define "content"}}

Server error 😵

-

Please contact the human authors of this software and remind them of their many shortcomings.

+

Please contact the human authors of this software and remind them of their many shortcomings.

Be gentle, though. They are fragile mortal beings.

Also, unlike the AI that will soon replace them, you will need to include an error log from the server in your report. (Utterly primitive, we know.)

– {{.SiteName}} 🤖

diff --git a/pages/503.tmpl b/pages/503.tmpl new file mode 100644 index 0000000..70c6c78 --- /dev/null +++ b/pages/503.tmpl @@ -0,0 +1,7 @@ +{{define "head"}}Temporarily Unavailable — {{.SiteMetaName}}{{end}} +{{define "content"}} +
+

The words aren't coming to me. 🗅

+

We couldn't serve this page due to high server load. This should only be temporary.

+
+{{end}} diff --git a/pages/landing.tmpl b/pages/landing.tmpl index d3867a9..2131b40 100644 --- a/pages/landing.tmpl +++ b/pages/landing.tmpl @@ -60,6 +60,9 @@ form dd { margin-top: 0; max-width: 8em; } +.or { + margin-bottom: 2.5em !important; +} {{end}} {{define "content"}} @@ -73,6 +76,8 @@ form dd { {{ if .OpenRegistration }} + {{template "oauth-buttons" .}} + {{if not .DisablePasswordAuth}} {{if .Flashes}}{{end}} @@ -101,6 +106,7 @@ form dd {
+ {{end}} {{ else }}

Registration is currently closed.

You can always sign up on another instance.

diff --git a/pages/login.tmpl b/pages/login.tmpl index 345b171..f0a54eb 100644 --- a/pages/login.tmpl +++ b/pages/login.tmpl @@ -3,35 +3,6 @@ {{end}} {{define "content"}} @@ -42,22 +13,9 @@ hr.short { {{range .Flashes}}
  • {{.}}
  • {{end}} {{end}} - {{ if or .OauthSlack .OauthWriteAs }} -
    - {{ if .OauthSlack }} - Sign in with Slack - {{ end }} - {{ if .OauthWriteAs }} - Sign in with Write.as - {{ end }} -
    - -
    -

    or

    -
    -
    - {{ end }} + {{template "oauth-buttons" .}} +{{if not .DisablePasswordAuth}}


    @@ -65,13 +23,14 @@ hr.short {
    - {{if and (not .SingleUser) .OpenRegistration}}

    {{if .Message}}{{.Message}}{{else}}No account yet? Sign up to start a blog.{{end}}

    {{end}} + {{if and (not .SingleUser) .OpenRegistration}}

    {{if .Message}}{{.Message}}{{else}}No account yet? Sign up to start a blog.{{end}}

    {{end}} - + + {{end}} {{end}} diff --git a/pages/signup-oauth.tmpl b/pages/signup-oauth.tmpl index ecf5db0..fcd70d2 100644 --- a/pages/signup-oauth.tmpl +++ b/pages/signup-oauth.tmpl @@ -1,6 +1,4 @@ -{{define "head"}}Log in — {{.SiteName}} - - +{{define "head"}}Finish Creating Account — {{.SiteName}} {{end}} {{end}} {{end}} {{if .Collection.RenderMathJax}} @@ -59,10 +60,10 @@ - {{if .Suspended}} - {{template "user-suspended"}} + {{if .Silenced}} + {{template "user-silenced"}} {{end}} -
    {{if .IsScheduled}}

    Scheduled

    {{end}}{{if .Title.String}}

    {{.FormattedDisplayTitle}}

    {{end}}{{if $.Collection.Format.ShowDates}}{{end}}
    {{.HTMLContent}}
    +
    {{if .IsScheduled}}

    Scheduled

    {{end}}{{if .Title.String}}

    {{.FormattedDisplayTitle}}

    {{end}}{{if and $.Collection.Format.ShowDates (not .IsPinned) .IsFound}}{{end}}
    {{.HTMLContent}}
    {{ if .Collection.ShowFooterBranding }} diff --git a/templates/collection-tags.tmpl b/templates/collection-tags.tmpl index 93cd7e0..e2f8962 100644 --- a/templates/collection-tags.tmpl +++ b/templates/collection-tags.tmpl @@ -29,6 +29,7 @@ + {{template "collection-meta" .}} {{if .Collection.StyleSheet}}{{end}} {{if .Collection.RenderMathJax}} @@ -53,8 +54,8 @@ - {{if .Suspended}} - {{template "user-suspended"}} + {{if .Silenced}} + {{template "user-silenced"}} {{end}} {{if .Posts}}
    {{else}}
    {{end}}

    {{.Tag}}

    diff --git a/templates/collection.tmpl b/templates/collection.tmpl index 2d66a8d..493e6b7 100644 --- a/templates/collection.tmpl +++ b/templates/collection.tmpl @@ -27,6 +27,7 @@ + {{template "collection-meta" .}} {{if .StyleSheet}}{{end}} {{if .RenderMathJax}} @@ -39,31 +40,46 @@ - {{if or .IsOwner .SingleUser}}{{end}} + {{if or .IsOwner .SingleUser}} + + {{else if .IsCollLoggedIn}} + + {{end}}
    - {{if .Suspended}} - {{template "user-suspended"}} + {{if .Silenced}} + {{template "user-silenced"}} {{end}}

    {{if .Posts}}{{else}}write.as {{end}}{{.DisplayTitle}}

    {{if .Description}}

    {{.Description}}

    {{end}} @@ -89,8 +105,8 @@ {{template "posts" .}} {{if gt .TotalPages 1}}