Merge branch 'develop' into T319-admin-delete-acct

pull/203/head
Matt Baer 4 years ago
commit 3b58d77e67
  1. 7
      .github/dependabot.yml
  2. 1
      .gitignore
  3. 3
      .gitmodules
  4. 101
      CONTRIBUTING.md
  5. 26
      Dockerfile
  6. 4
      Makefile
  7. 77
      README.md
  8. 163
      account.go
  9. 131
      activitypub.go
  10. 145
      admin.go
  11. 42
      app.go
  12. 4
      author/author.go
  13. 60
      cmd/writefreely/config.go
  14. 49
      cmd/writefreely/db.go
  15. 38
      cmd/writefreely/keys.go
  16. 240
      cmd/writefreely/main.go
  17. 96
      cmd/writefreely/user.go
  18. 48
      cmd/writefreely/web.go
  19. 123
      collections.go
  20. 26
      config.ini.example
  21. 71
      config/config.go
  22. 2
      config/setup.go
  23. 10
      database-lib.go
  24. 10
      database-no-sqlite.go
  25. 12
      database-sqlite.go
  26. 217
      database.go
  27. 4
      database_test.go
  28. 26
      db/create.go
  29. 51
      docker-compose.yml
  30. 6
      errors.go
  31. 8
      feed.go
  32. 65
      go.mod
  33. 188
      go.sum
  34. 156
      gopher.go
  35. 43
      handle.go
  36. 37
      invites.go
  37. 4
      keys.go
  38. 1
      less/Makefile
  39. 63
      less/admin.less
  40. 2
      less/app.less
  41. 125
      less/core.less
  42. 2
      less/install-less.sh
  43. 91
      less/login.less
  44. 9
      less/new-core.less
  45. 6
      less/pad-theme.less
  46. 70
      less/pad.less
  47. 4
      less/post-temp.less
  48. 450
      less/prose-editor.less
  49. 4
      less/prose.less
  50. 13
      less/resources.less
  51. 7
      migrations/drivers.go
  52. 6
      migrations/migrations.go
  53. 33
      migrations/v10.go
  54. 20
      migrations/v4.go
  55. 51
      migrations/v5.go
  56. 4
      migrations/v6.go
  57. 46
      migrations/v7.go
  58. 45
      migrations/v8.go
  59. 37
      migrations/v9.go
  60. 6
      nodeinfo.go
  61. 200
      oauth.go
  62. 126
      oauth_generic.go
  63. 114
      oauth_gitea.go
  64. 115
      oauth_gitlab.go
  65. 17
      oauth_signup.go
  66. 8
      oauth_slack.go
  67. 36
      oauth_test.go
  68. 4
      oauth_writeas.go
  69. 22
      pad.go
  70. 4
      page/page.go
  71. 4
      pages.go
  72. 2
      pages/500.tmpl
  73. 7
      pages/503.tmpl
  74. 6
      pages/landing.tmpl
  75. 63
      pages/login.tmpl
  76. 26
      pages/signup-oauth.tmpl
  77. 4
      pages/signup.tmpl
  78. 7
      parse/posts.go
  79. 65
      postrender.go
  80. 139
      posts.go
  81. 45
      posts_test.go
  82. 8
      prose/.babelrc.js
  83. 4
      prose/.prettierrc
  84. 3
      prose/Makefile
  85. 7
      prose/README.md
  86. 57
      prose/markdownParser.js
  87. 123
      prose/markdownSerializer.js
  88. 32
      prose/menu.js
  89. 16278
      prose/package-lock.json
  90. 32
      prose/package.json
  91. 14
      prose/prose.html
  92. 118
      prose/prose.js
  93. 21
      prose/schema.js
  94. 25
      prose/webpack.config.js
  95. 43
      read.go
  96. 17
      routes.go
  97. 38
      routes_test.go
  98. 37
      scripts/invalidate-css.sh
  99. 315
      semver.go
  100. BIN
      static/img/mark/gitea.png
  101. Some files were not shown because too many files have changed in this diff Show More

@ -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"

1
.gitignore vendored

@ -1,3 +1,4 @@
node_modules
*~ *~
*.swp *.swp
*.swo *.swo

3
.gitmodules vendored

@ -1,3 +0,0 @@
[submodule "static/js/mathjax"]
path = static/js/mathjax
url = https://github.com/mathjax/MathJax.git

@ -1,26 +1,99 @@
# Contributing to WriteFreely # 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** ## Working on WriteFreely
* 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
## 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.

@ -1,28 +1,30 @@
# Build image # 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 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 . . COPY . .
ENV GO111MODULE=on ENV GO111MODULE=on
RUN make build \ RUN make build \
&& make ui && make ui
RUN mkdir /stage && \ RUN mkdir /stage && \
cp -R /go/bin \ cp -R /go/bin \
/go/src/github.com/writeas/writefreely/templates \ /go/src/github.com/writefreely/writefreely/templates \
/go/src/github.com/writeas/writefreely/static \ /go/src/github.com/writefreely/writefreely/static \
/go/src/github.com/writeas/writefreely/pages \ /go/src/github.com/writefreely/writefreely/pages \
/go/src/github.com/writeas/writefreely/keys \ /go/src/github.com/writefreely/writefreely/keys \
/go/src/github.com/writeas/writefreely/cmd \ /go/src/github.com/writefreely/writefreely/cmd \
/stage /stage
# Final image # Final image
FROM alpine:3.8 FROM alpine:3.12
RUN apk add --no-cache openssl ca-certificates RUN apk add --no-cache openssl ca-certificates
COPY --from=build --chown=daemon:daemon /stage /go COPY --from=build --chown=daemon:daemon /stage /go

@ -1,5 +1,5 @@
GITREV=`git describe | cut -c 2-` 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 GOCMD=go
GOINSTALL=$(GOCMD) install $(LDFLAGS) GOINSTALL=$(GOCMD) install $(LDFLAGS)
@ -86,6 +86,7 @@ release : clean ui assets
cp -r templates $(BUILDPATH) cp -r templates $(BUILDPATH)
cp -r pages $(BUILDPATH) cp -r pages $(BUILDPATH)
cp -r static $(BUILDPATH) cp -r static $(BUILDPATH)
scripts/invalidate-css.sh $(BUILDPATH)
mkdir $(BUILDPATH)/keys mkdir $(BUILDPATH)/keys
$(MAKE) build-linux $(MAKE) build-linux
mv build/$(BINARY_NAME)-linux-amd64 $(BUILDPATH)/$(BINARY_NAME) mv build/$(BINARY_NAME)-linux-amd64 $(BUILDPATH)/$(BINARY_NAME)
@ -130,6 +131,7 @@ release-docker :
ui : force_look ui : force_look
cd less/; $(MAKE) $(MFLAGS) cd less/; $(MAKE) $(MFLAGS)
cd prose/; $(MAKE) $(MFLAGS)
assets : generate assets : generate
go-bindata -pkg writefreely -ignore=\\.gitignore -tags="!wflib" schema.sql sqlite.sql go-bindata -pkg writefreely -ignore=\\.gitignore -tags="!wflib" schema.sql sqlite.sql

@ -4,91 +4,86 @@
</p> </p>
<hr /> <hr />
<p align="center"> <p align="center">
<a href="https://github.com/writeas/writefreely/releases/"> <a href="https://github.com/writefreely/writefreely/releases/">
<img src="https://img.shields.io/github/release/writeas/writefreely.svg" alt="Latest release" /> <img src="https://img.shields.io/github/release/writeas/writefreely.svg" alt="Latest release" />
</a> </a>
<a href="https://goreportcard.com/report/github.com/writeas/writefreely">
<img src="https://goreportcard.com/badge/github.com/writeas/writefreely" alt="Go Report Card" />
</a>
<a href="https://travis-ci.org/writeas/writefreely"> <a href="https://travis-ci.org/writeas/writefreely">
<img src="https://travis-ci.org/writeas/writefreely.svg" alt="Build status" /> <img src="https://travis-ci.org/writeas/writefreely.svg" alt="Build status" />
</a> </a>
<a href="https://github.com/writeas/writefreely/releases/latest"> <a href="https://github.com/writefreely/writefreely/releases/latest">
<img src="https://img.shields.io/github/downloads/writeas/writefreely/total.svg" /> <img src="https://img.shields.io/github/downloads/writeas/writefreely/total.svg" />
</a> </a>
<a href="https://goreportcard.com/report/github.com/writefreely/writefreely">
<img src="https://goreportcard.com/badge/github.com/writefreely/writefreely" alt="Go Report Card" />
</a>
<a href="https://hub.docker.com/r/writeas/writefreely/"> <a href="https://hub.docker.com/r/writeas/writefreely/">
<img src="https://img.shields.io/docker/pulls/writeas/writefreely.svg" /> <img src="https://img.shields.io/docker/pulls/writeas/writefreely.svg" />
</a> </a>
</p> </p>
&nbsp; &nbsp;
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 &mdash; 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) [Find an instance](https://writefreely.org/instances)
## Features ## Features
* Start a blog for yourself, or host a community of writers ### Made for writing
* 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
## 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
## Quick start Blog elements are localized in 20+ languages, and WriteFreely includes first-class support for non-Latin and right-to-left (RTL) script languages.
WriteFreely has minimal requirements to get up and running — you only need to be able to run an executable. ### Private by default
> **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. 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.
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. <h2><a href="https://write.as/writefreely"><img src="https://writefreely.org/img/writeas-readme.png" height="32px" alt="Write.as" /></a></h2>
## Packages 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 is available in these package repositories: 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!
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.
### Packages
You can also find WriteFreely in these package repositories, thanks to our wonderful community!
* [Arch User Repository](https://aur.archlinux.org/packages/writefreely/) * [Arch User Repository](https://aur.archlinux.org/packages/writefreely/)
## Documentation ## 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) &mdash; and help us improve by contributing to the [writefreely/documentation](https://github.com/writefreely/documentation) repo.
## Development ## Development
Ready to hack on your site? Get started with our [developer guide](https://writefreely.org/docs/latest/developer/setup). 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).
## Docker
Read about using Docker in the [documentation](https://writefreely.org/docs/latest/admin/docker).
## Contributing ## 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 ## 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).

@ -1,5 +1,5 @@
/* /*
* Copyright © 2018-2019 A Bunch Tell LLC. * Copyright © 2018-2021 A Bunch Tell LLC.
* *
* This file is part of WriteFreely. * This file is part of WriteFreely.
* *
@ -27,9 +27,9 @@ import (
"github.com/writeas/web-core/auth" "github.com/writeas/web-core/auth"
"github.com/writeas/web-core/data" "github.com/writeas/web-core/data"
"github.com/writeas/web-core/log" "github.com/writeas/web-core/log"
"github.com/writeas/writefreely/author" "github.com/writefreely/writefreely/author"
"github.com/writeas/writefreely/config" "github.com/writefreely/writefreely/config"
"github.com/writeas/writefreely/page" "github.com/writefreely/writefreely/page"
) )
type ( type (
@ -48,6 +48,7 @@ type (
Separator template.HTML Separator template.HTML
IsAdmin bool IsAdmin bool
CanInvite bool CanInvite bool
CollAlias string
} }
) )
@ -70,7 +71,7 @@ func canUserInvite(cfg *config.Config, isAdmin bool) bool {
} }
func (up *UserPage) SetMessaging(u *User) { func (up *UserPage) SetMessaging(u *User) {
//up.NeedsAuth = app.db.DoesUserNeedAuth(u.ID) // up.NeedsAuth = app.db.DoesUserNeedAuth(u.ID)
} }
const ( 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) { 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) reqJSON := IsJSON(r)
// Get params // Get params
@ -144,8 +150,6 @@ func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWr
} }
// Handle empty optional params // Handle empty optional params
// TODO: remove this var
createdWithPass := true
hashedPass, err := auth.HashPass([]byte(signup.Pass)) hashedPass, err := auth.HashPass([]byte(signup.Pass))
if err != nil { if err != nil {
return nil, impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."} 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{ u := &User{
Username: signup.Alias, Username: signup.Alias,
HashedPass: hashedPass, HashedPass: hashedPass,
HasPass: createdWithPass, HasPass: true,
Email: prepareUserEmail(signup.Email, app.keys.EmailKey), Email: prepareUserEmail(signup.Email, app.keys.EmailKey),
Created: time.Now().Truncate(time.Second).UTC(), Created: time.Now().Truncate(time.Second).UTC(),
} }
@ -167,11 +171,7 @@ func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWr
// Log invite if needed // Log invite if needed
if signup.InviteCode != "" { if signup.InviteCode != "" {
cu, err := app.db.GetUserForAuth(signup.Alias) err = app.db.CreateInvitedUser(signup.InviteCode, u.ID)
if err != nil {
return nil, err
}
err = app.db.CreateInvitedUser(signup.InviteCode, cu.ID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -185,9 +185,6 @@ func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWr
resUser := &AuthUser{ resUser := &AuthUser{
User: u, User: u,
} }
if !createdWithPass {
resUser.Password = signup.Pass
}
title := signup.Alias title := signup.Alias
if signup.Normalize { if signup.Normalize {
title = desiredUsername title = desiredUsername
@ -302,20 +299,18 @@ func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error {
p := &struct { p := &struct {
page.StaticPage page.StaticPage
*OAuthButtons
To string To string
Message template.HTML Message template.HTML
Flashes []template.HTML Flashes []template.HTML
LoginUsername string LoginUsername string
OauthSlack bool
OauthWriteAs bool
}{ }{
pageForReq(app, r), StaticPage: pageForReq(app, r),
r.FormValue("to"), OAuthButtons: NewOAuthButtons(app.Config()),
template.HTML(""), To: r.FormValue("to"),
[]template.HTML{}, Message: template.HTML(""),
getTempInfo(app, "login-user", r, w), Flashes: []template.HTML{},
app.Config().SlackOauth.ClientID != "", LoginUsername: getTempInfo(app, "login-user", r, w),
app.Config().WriteAsOauth.ClientID != "",
} }
if earlyError != "" { if earlyError != "" {
@ -390,6 +385,11 @@ func login(app *App, w http.ResponseWriter, r *http.Request) error {
var err error var err error
var signin userCredentials var signin userCredentials
if app.cfg.App.DisablePasswordAuth {
err := ErrDisabledPasswordAuth
return err
}
// Log in with one-time token if one is given // Log in with one-time token if one is given
if oneTimeToken != "" { if oneTimeToken != "" {
log.Info("Login: Logging user in via token.") 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."} 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)) { if !auth.Authenticated(u.HashedPass, []byte(signin.Pass)) {
return impart.HTTPError{http.StatusUnauthorized, "Incorrect password."} 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) 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 { if err != nil {
log.Error("view articles: %v", err) log.Error("view articles: %v", err)
} }
@ -754,12 +757,12 @@ func viewArticles(app *App, u *User, w http.ResponseWriter, r *http.Request) err
*UserPage *UserPage
AnonymousPosts *[]PublicPost AnonymousPosts *[]PublicPost
Collections *[]Collection Collections *[]Collection
Suspended bool Silenced bool
}{ }{
UserPage: NewUserPage(app, r, u, u.Username+"'s Posts", f), UserPage: NewUserPage(app, r, u, u.Username+"'s Posts", f),
AnonymousPosts: p, AnonymousPosts: p,
Collections: c, Collections: c,
Suspended: suspended, Silenced: silenced,
} }
d.UserPage.SetMessaging(u) d.UserPage.SetMessaging(u)
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") 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) uc, _ := app.db.GetUserCollectionCount(u.ID)
// TODO: handle any errors // TODO: handle any errors
suspended, err := app.db.IsUserSuspended(u.ID) silenced, err := app.db.IsUserSilenced(u.ID)
if err != nil { if err != nil {
log.Error("view collections %v", err) log.Error("view collections %v", err)
return fmt.Errorf("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 UsedCollections, TotalCollections int
NewBlogsDisabled bool NewBlogsDisabled bool
Suspended bool Silenced bool
}{ }{
UserPage: NewUserPage(app, r, u, u.Username+"'s Blogs", f), UserPage: NewUserPage(app, r, u, u.Username+"'s Blogs", f),
Collections: c, Collections: c,
UsedCollections: int(uc), UsedCollections: int(uc),
NewBlogsDisabled: !app.cfg.App.CanCreateBlogs(uc), NewBlogsDisabled: !app.cfg.App.CanCreateBlogs(uc),
Suspended: suspended, Silenced: silenced,
} }
d.UserPage.SetMessaging(u) d.UserPage.SetMessaging(u)
showUserPage(w, "collections", d) showUserPage(w, "collections", d)
@ -817,7 +820,10 @@ func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Reques
return ErrCollectionNotFound 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 { if err != nil {
log.Error("view edit collection %v", err) log.Error("view edit collection %v", err)
return fmt.Errorf("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 { obj := struct {
*UserPage *UserPage
*Collection *Collection
Suspended bool Silenced bool
}{ }{
UserPage: NewUserPage(app, r, u, "Edit "+c.DisplayTitle(), flashes), UserPage: NewUserPage(app, r, u, "Edit "+c.DisplayTitle(), flashes),
Collection: c, Collection: c,
Suspended: suspended, Silenced: silenced,
} }
obj.UserPage.CollAlias = c.Alias
showUserPage(w, "collection", obj) showUserPage(w, "collection", obj)
return nil return nil
@ -992,7 +999,7 @@ func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error
titleStats = c.DisplayTitle() + " " titleStats = c.DisplayTitle() + " "
} }
suspended, err := app.db.IsUserSuspended(u.ID) silenced, err := app.db.IsUserSilenced(u.ID)
if err != nil { if err != nil {
log.Error("view stats: %v", err) log.Error("view stats: %v", err)
return err return err
@ -1003,14 +1010,15 @@ func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error
Collection *Collection Collection *Collection
TopPosts *[]PublicPost TopPosts *[]PublicPost
APFollowers int APFollowers int
Suspended bool Silenced bool
}{ }{
UserPage: NewUserPage(app, r, u, titleStats+"Stats", flashes), UserPage: NewUserPage(app, r, u, titleStats+"Stats", flashes),
VisitsBlog: alias, VisitsBlog: alias,
Collection: c, Collection: c,
TopPosts: topPosts, TopPosts: topPosts,
Suspended: suspended, Silenced: silenced,
} }
obj.UserPage.CollAlias = c.Alias
if app.cfg.App.Federation { if app.cfg.App.Federation {
folls, err := app.db.GetAPFollowers(c) folls, err := app.db.GetAPFollowers(c)
if err != nil { 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) 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 { obj := struct {
*UserPage *UserPage
Email string Email string
HasPass bool HasPass bool
IsLogOut bool IsLogOut bool
Suspended 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), UserPage: NewUserPage(app, r, u, "Account Settings", flashes),
Email: fullUser.EmailClear(app.keys), Email: fullUser.EmailClear(app.keys),
HasPass: passIsSet, HasPass: passIsSet,
IsLogOut: r.FormValue("logout") == "1", IsLogOut: r.FormValue("logout") == "1",
Suspended: fullUser.IsSilenced(), 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) showUserPage(w, "settings", obj)
@ -1094,6 +1152,19 @@ func getTempInfo(app *App, key string, r *http.Request, w http.ResponseWriter) s
return 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 { func prepareUserEmail(input string, emailKey []byte) zero.String {
email := zero.NewString("", input != "") email := zero.NewString("", input != "")
if len(input) > 0 { if len(input) > 0 {

@ -1,5 +1,5 @@
/* /*
* Copyright © 2018-2020 A Bunch Tell LLC. * Copyright © 2018-2021 A Bunch Tell LLC.
* *
* This file is part of WriteFreely. * This file is part of WriteFreely.
* *
@ -21,6 +21,7 @@ import (
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
"net/url" "net/url"
"path/filepath"
"strconv" "strconv"
"time" "time"
@ -28,9 +29,9 @@ import (
"github.com/writeas/activity/streams" "github.com/writeas/activity/streams"
"github.com/writeas/httpsig" "github.com/writeas/httpsig"
"github.com/writeas/impart" "github.com/writeas/impart"
"github.com/writeas/nerds/store"
"github.com/writeas/web-core/activitypub" "github.com/writeas/web-core/activitypub"
"github.com/writeas/web-core/activitystreams" "github.com/writeas/web-core/activitystreams"
"github.com/writeas/web-core/id"
"github.com/writeas/web-core/log" "github.com/writeas/web-core/log"
) )
@ -41,6 +42,19 @@ const (
apCacheTime = time.Minute 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 { type RemoteUser struct {
ID int64 ID int64
ActorID string 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 { func handleFetchCollectionActivities(app *App, w http.ResponseWriter, r *http.Request) error {
w.Header().Set("Server", serverSoftware) w.Header().Set("Server", serverSoftware)
vars := mux.Vars(r) vars := mux.Vars(r)
alias := vars["alias"] alias := vars["alias"]
if alias == "" {
alias = filepath.Base(r.RequestURI)
}
// TODO: enforce visibility // TODO: enforce visibility
// Get base Collection data // Get base Collection data
var c *Collection var c *Collection
var err error 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) c, err = app.db.GetCollectionByID(1)
} else { } else {
c, err = app.db.GetCollection(alias) c, err = app.db.GetCollection(alias)
@ -83,16 +108,19 @@ func handleFetchCollectionActivities(app *App, w http.ResponseWriter, r *http.Re
if err != nil { if err != nil {
return err 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 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() p := c.PersonObject()
setCacheControl(w, apCacheTime) setCacheControl(w, apCacheTime)
@ -117,12 +145,12 @@ func handleFetchCollectionOutbox(app *App, w http.ResponseWriter, r *http.Reques
if err != nil { if err != nil {
return err return err
} }
suspended, err := app.db.IsUserSuspended(c.OwnerID) silenced, err := app.db.IsUserSilenced(c.OwnerID)
if err != nil { if err != nil {
log.Error("fetch collection outbox: %v", err) log.Error("fetch collection outbox: %v", err)
return ErrInternalGeneral return ErrInternalGeneral
} }
if suspended { if silenced {
return ErrCollectionNotFound return ErrCollectionNotFound
} }
c.hostName = app.cfg.App.Host c.hostName = app.cfg.App.Host
@ -154,6 +182,7 @@ func handleFetchCollectionOutbox(app *App, w http.ResponseWriter, r *http.Reques
pp.Collection = res pp.Collection = res
o := pp.ActivityObject(app) o := pp.ActivityObject(app)
a := activitystreams.NewCreateActivity(o) a := activitystreams.NewCreateActivity(o)
a.Context = nil
ocp.OrderedItems = append(ocp.OrderedItems, *a) ocp.OrderedItems = append(ocp.OrderedItems, *a)
} }
@ -179,12 +208,12 @@ func handleFetchCollectionFollowers(app *App, w http.ResponseWriter, r *http.Req
if err != nil { if err != nil {
return err return err
} }
suspended, err := app.db.IsUserSuspended(c.OwnerID) silenced, err := app.db.IsUserSilenced(c.OwnerID)
if err != nil { if err != nil {
log.Error("fetch collection followers: %v", err) log.Error("fetch collection followers: %v", err)
return ErrInternalGeneral return ErrInternalGeneral
} }
if suspended { if silenced {
return ErrCollectionNotFound return ErrCollectionNotFound
} }
c.hostName = app.cfg.App.Host c.hostName = app.cfg.App.Host
@ -234,12 +263,12 @@ func handleFetchCollectionFollowing(app *App, w http.ResponseWriter, r *http.Req
if err != nil { if err != nil {
return err return err
} }
suspended, err := app.db.IsUserSuspended(c.OwnerID) silenced, err := app.db.IsUserSilenced(c.OwnerID)
if err != nil { if err != nil {
log.Error("fetch collection following: %v", err) log.Error("fetch collection following: %v", err)
return ErrInternalGeneral return ErrInternalGeneral
} }
if suspended { if silenced {
return ErrCollectionNotFound return ErrCollectionNotFound
} }
c.hostName = app.cfg.App.Host c.hostName = app.cfg.App.Host
@ -277,12 +306,12 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
// TODO: return Reject? // TODO: return Reject?
return err return err
} }
suspended, err := app.db.IsUserSuspended(c.OwnerID) silenced, err := app.db.IsUserSilenced(c.OwnerID)
if err != nil { if err != nil {
log.Error("fetch collection inbox: %v", err) log.Error("fetch collection inbox: %v", err)
return ErrInternalGeneral return ErrInternalGeneral
} }
if suspended { if silenced {
return ErrCollectionNotFound return ErrCollectionNotFound
} }
c.hostName = app.cfg.App.Host c.hostName = app.cfg.App.Host
@ -324,7 +353,7 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
if followID == nil { if followID == nil {
log.Error("Didn't resolve follow ID") log.Error("Didn't resolve follow ID")
} else { } else {
aID := c.FederatedAccount() + "#accept-" + store.GenerateFriendlyRandomString(20) aID := c.FederatedAccount() + "#accept-" + id.GenerateFriendlyRandomString(20)
acceptID, err := url.Parse(aID) acceptID, err := url.Parse(aID)
if err != nil { if err != nil {
log.Error("Couldn't parse generated Accept URL '%s': %v", aID, err) 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() { go func() {
if to == nil {
if debugging {
log.Error("No `to` value!")
}
return
}
time.Sleep(2 * time.Second) time.Sleep(2 * time.Second)
am, err := a.Serialize() am, err := a.Serialize()
if err != nil { if err != nil {
@ -397,10 +433,6 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
} }
am["@context"] = []string{activitystreams.Namespace} 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) err = makeActivityPost(app.cfg.App.Host, p, fullActor.Inbox, am)
if err != nil { if err != nil {
log.Error("Unable to make activity POST: %v", err) 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, _ := http.NewRequest("POST", url, bytes.NewBuffer(b))
r.Header.Add("Content-Type", "application/activity+json") 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 := sha256.New()
h.Write(b) h.Write(b)
r.Header.Add("Digest", "SHA-256="+base64.StdEncoding.EncodeToString(h.Sum(nil))) 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 { if err != nil {
return err return err
} }
@ -534,7 +566,23 @@ func resolveIRI(hostName, url string) ([]byte, error) {
r, _ := http.NewRequest("GET", url, nil) r, _ := http.NewRequest("GET", url, nil)
r.Header.Add("Accept", "application/activity+json") 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 { if debugging {
dump, err := httputil.DumpRequestOut(r, true) 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 { if err != nil {
return nil, err return nil, err
} }
@ -600,7 +648,12 @@ func deleteFederatedPost(app *App, p *PublicPost, collID int64) error {
na.CC = append(na.CC, f) 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 { if err != nil {
log.Error("Couldn't delete post! %v", err) 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 { 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 debugging {
if isUpdate { if isUpdate {
log.Info("Federating updated post!") 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!") log.Info("Federating new post!")
} }
} }
actor := p.Collection.PersonObject(collID) actor := p.Collection.PersonObject(collID)
na := p.ActivityObject(app) 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 // I don't believe we'd ever have too many mentions in a single post that this
// could become a burden. // could become a burden.
remoteUser, err := getRemoteUser(app, tag.HRef) 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) err = makeActivityPost(app.cfg.App.Host, actor, remoteUser.Inbox, activity)
if err != nil { if err != nil {
log.Error("Couldn't post! %v", err) 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) { func getRemoteUser(app *App, actorID string) (*RemoteUser, error) {
u := RemoteUser{ActorID: actorID} 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 { switch {
case err == sql.ErrNoRows: case err == sql.ErrNoRows:
return nil, impart.HTTPError{http.StatusNotFound, "No remote user with that ID."} 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 return nil, err
} }
u.Handle = handle.String
return &u, nil return &u, nil
} }

@ -1,5 +1,5 @@
/* /*
* Copyright © 2018-2019 A Bunch Tell LLC. * Copyright © 2018-2021 A Bunch Tell LLC.
* *
* This file is part of WriteFreely. * This file is part of WriteFreely.
* *
@ -24,8 +24,8 @@ import (
"github.com/writeas/web-core/auth" "github.com/writeas/web-core/auth"
"github.com/writeas/web-core/log" "github.com/writeas/web-core/log"
"github.com/writeas/web-core/passgen" "github.com/writeas/web-core/passgen"
"github.com/writeas/writefreely/appstats" "github.com/writefreely/writefreely/appstats"
"github.com/writeas/writefreely/config" "github.com/writefreely/writefreely/config"
) )
var ( var (
@ -90,6 +90,18 @@ type instanceContent struct {
Updated time.Time 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 { func (c instanceContent) UpdatedFriendly() string {
/* /*
// TODO: accept a locale in this method and use that for the format // 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 { 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() updateAppStats()
p := struct { p := struct {
*UserPage *UserPage
*AdminPage
SysStatus systemStatus SysStatus systemStatus
Config config.AppCfg Config config.AppCfg
Message, ConfigMessage string Message, ConfigMessage string
}{ }{
UserPage: NewUserPage(app, r, u, "Admin", nil), UserPage: NewUserPage(app, r, u, "Admin", nil),
AdminPage: NewAdminPage(app),
SysStatus: sysStatus, SysStatus: sysStatus,
Config: app.cfg.App, Config: app.cfg.App,
@ -116,13 +159,34 @@ func handleViewAdminDash(app *App, u *User, w http.ResponseWriter, r *http.Reque
ConfigMessage: r.FormValue("cm"), 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 return nil
} }
func handleViewAdminUsers(app *App, u *User, w http.ResponseWriter, r *http.Request) error { func handleViewAdminUsers(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
p := struct { p := struct {
*UserPage *UserPage
*AdminPage
Config config.AppCfg Config config.AppCfg
Message string Message string
Flashes []string Flashes []string
@ -132,9 +196,10 @@ func handleViewAdminUsers(app *App, u *User, w http.ResponseWriter, r *http.Requ
TotalUsers int64 TotalUsers int64
TotalPages []int TotalPages []int
}{ }{
UserPage: NewUserPage(app, r, u, "Users", nil), UserPage: NewUserPage(app, r, u, "Users", nil),
Config: app.cfg.App, AdminPage: NewAdminPage(app),
Message: r.FormValue("m"), Config: app.cfg.App,
Message: r.FormValue("m"),
} }
p.Flashes, _ = getSessionFlashes(app, w, r, nil) 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 { p := struct {
*UserPage *UserPage
*AdminPage
Config config.AppCfg Config config.AppCfg
Message string Message string
@ -181,9 +247,10 @@ func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Reque
TotalPosts int64 TotalPosts int64
ClearEmail string ClearEmail string
}{ }{
Config: app.cfg.App, AdminPage: NewAdminPage(app),
Message: r.FormValue("m"), Config: app.cfg.App,
Colls: []inspectedCollection{}, Message: r.FormValue("m"),
Colls: []inspectedCollection{},
} }
var err error 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) err = app.db.SetUserStatus(user.ID, UserActive)
} else { } else {
err = app.db.SetUserStatus(user.ID, UserSilenced) err = app.db.SetUserStatus(user.ID, UserSilenced)
// reset the cache to removed silence user posts
updateTimelineCache(app.timeline, true)
} }
if err != nil { 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.StatusInternalServerError, fmt.Sprintf("Could not toggle user status: %v", err)}
} }
return impart.HTTPError{http.StatusFound, fmt.Sprintf("/admin/user/%s#status", username)} 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 { func handleViewAdminPages(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
p := struct { p := struct {
*UserPage *UserPage
*AdminPage
Config config.AppCfg Config config.AppCfg
Message string Message string
Pages []*instanceContent Pages []*instanceContent
}{ }{
UserPage: NewUserPage(app, r, u, "Pages", nil), UserPage: NewUserPage(app, r, u, "Pages", nil),
Config: app.cfg.App, AdminPage: NewAdminPage(app),
Message: r.FormValue("m"), Config: app.cfg.App,
Message: r.FormValue("m"),
} }
var err error var err error
@ -401,14 +473,16 @@ func handleViewAdminPage(app *App, u *User, w http.ResponseWriter, r *http.Reque
p := struct { p := struct {
*UserPage *UserPage
*AdminPage
Config config.AppCfg Config config.AppCfg
Message string Message string
Banner *instanceContent Banner *instanceContent
Content *instanceContent Content *instanceContent
}{ }{
Config: app.cfg.App, AdminPage: NewAdminPage(app),
Message: r.FormValue("m"), Config: app.cfg.App,
Message: r.FormValue("m"),
} }
var err error 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.Federation = r.FormValue("federation") == "on"
apper.App().cfg.App.PublicStats = r.FormValue("public_stats") == "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.Private = r.FormValue("private") == "on"
apper.App().cfg.App.LocalTimeline = r.FormValue("local_timeline") == "on" apper.App().cfg.App.LocalTimeline = r.FormValue("local_timeline") == "on"
if apper.App().cfg.App.LocalTimeline && apper.App().timeline == nil { 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 { if err != nil {
m = "?cm=" + err.Error() m = "?cm=" + err.Error()
} }
return impart.HTTPError{http.StatusFound, "/admin" + m + "#config"} return impart.HTTPError{http.StatusFound, "/admin/settings" + m + "#config"}
} }
func updateAppStats() { func updateAppStats() {
@ -561,3 +636,39 @@ func adminResetPassword(app *App, u *User, newPass string) error {
} }
return nil 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
}

@ -1,5 +1,5 @@
/* /*
* Copyright © 2018-2019 A Bunch Tell LLC. * Copyright © 2018-2021 A Bunch Tell LLC.
* *
* This file is part of WriteFreely. * This file is part of WriteFreely.
* *
@ -35,11 +35,11 @@ import (
"github.com/writeas/web-core/auth" "github.com/writeas/web-core/auth"
"github.com/writeas/web-core/converter" "github.com/writeas/web-core/converter"
"github.com/writeas/web-core/log" "github.com/writeas/web-core/log"
"github.com/writeas/writefreely/author" "github.com/writefreely/writefreely/author"
"github.com/writeas/writefreely/config" "github.com/writefreely/writefreely/config"
"github.com/writeas/writefreely/key" "github.com/writefreely/writefreely/key"
"github.com/writeas/writefreely/migrations" "github.com/writefreely/writefreely/migrations"
"github.com/writeas/writefreely/page" "github.com/writefreely/writefreely/page"
"golang.org/x/crypto/acme/autocert" "golang.org/x/crypto/acme/autocert"
) )
@ -56,7 +56,7 @@ var (
debugging bool debugging bool
// Software version can be set from git env using -ldflags // Software version can be set from git env using -ldflags
softwareVer = "0.11.2" softwareVer = "0.12.0"
// DEPRECATED VARS // DEPRECATED VARS
isSingleUser bool isSingleUser bool
@ -72,6 +72,7 @@ type App struct {
keys *key.Keychain keys *key.Keychain
sessionStore sessions.Store sessionStore sessions.Store
formDecoder *schema.Decoder formDecoder *schema.Decoder
updates *updatesCache
timeline *localTimeline timeline *localTimeline
} }
@ -220,6 +221,10 @@ func handleViewHome(app *App, w http.ResponseWriter, r *http.Request) error {
return handleViewPad(app, w, r) return handleViewPad(app, w, r)
} }
if app.cfg.App.Private {
return viewLogin(app, w, r)
}
if land := app.cfg.App.LandingPath(); land != "/" { if land := app.cfg.App.LandingPath(); land != "/" {
return impart.HTTPError{http.StatusFound, land} return impart.HTTPError{http.StatusFound, land}
} }
@ -233,6 +238,7 @@ func handleViewLanding(app *App, w http.ResponseWriter, r *http.Request) error {
p := struct { p := struct {
page.StaticPage page.StaticPage
*OAuthButtons
Flashes []template.HTML Flashes []template.HTML
Banner template.HTML Banner template.HTML
Content template.HTML Content template.HTML
@ -240,6 +246,7 @@ func handleViewLanding(app *App, w http.ResponseWriter, r *http.Request) error {
ForcedLanding bool ForcedLanding bool
}{ }{
StaticPage: pageForReq(app, r), StaticPage: pageForReq(app, r),
OAuthButtons: NewOAuthButtons(app.Config()),
ForcedLanding: forceLanding, ForcedLanding: forceLanding,
} }
@ -371,6 +378,8 @@ func Initialize(apper Apper, debug bool) (*App, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("init keys: %s", err) return nil, fmt.Errorf("init keys: %s", err)
} }
apper.App().InitUpdates()
apper.App().InitSession() apper.App().InitSession()
apper.App().InitDecoder() apper.App().InitDecoder()
@ -380,6 +389,8 @@ func Initialize(apper Apper, debug bool) (*App, error) {
return nil, fmt.Errorf("connect to DB: %s", err) return nil, fmt.Errorf("connect to DB: %s", err)
} }
initActivityPub(apper.App())
// Handle local timeline, if enabled // Handle local timeline, if enabled
if apper.App().cfg.App.LocalTimeline { if apper.App().cfg.App.LocalTimeline {
log.Info("Initializing local timeline...") log.Info("Initializing local timeline...")
@ -406,6 +417,11 @@ func Serve(app *App, r *mux.Router) {
os.Exit(0) os.Exit(0)
}() }()
// Start gopher server
if app.cfg.Server.GopherPort > 0 && !app.cfg.App.Private {
go initGopher(app)
}
// Start web application server // Start web application server
var bindAddress = app.cfg.Server.Bind var bindAddress = app.cfg.Server.Bind
if bindAddress == "" { if bindAddress == "" {
@ -741,7 +757,7 @@ func connectToDatabase(app *App) {
var db *sql.DB var db *sql.DB
var err error var err error
if app.cfg.Database.Type == driverMySQL { 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) db.SetMaxOpenConns(50)
} else if app.cfg.Database.Type == driverSQLite { } else if app.cfg.Database.Type == driverSQLite {
if !SQLiteEnabled { if !SQLiteEnabled {
@ -878,3 +894,13 @@ func adminInitDatabase(app *App) error {
log.Info("Done.") log.Info("Done.")
return nil 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 + ")"
}

@ -1,5 +1,5 @@
/* /*
* Copyright © 2018-2020 A Bunch Tell LLC. * Copyright © 2018-2021 A Bunch Tell LLC.
* *
* This file is part of WriteFreely. * This file is part of WriteFreely.
* *
@ -11,7 +11,7 @@
package author package author
import ( import (
"github.com/writeas/writefreely/config" "github.com/writefreely/writefreely/config"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"

@ -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
}

@ -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)
}

@ -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)
}

@ -1,5 +1,5 @@
/* /*
* Copyright © 2018-2019 A Bunch Tell LLC. * Copyright © 2018-2021 A Bunch Tell LLC.
* *
* This file is part of WriteFreely. * This file is part of WriteFreely.
* *
@ -11,122 +11,156 @@
package main package main
import ( import (
"flag"
"fmt" "fmt"
"os" "os"
"strings" "strings"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/urfave/cli/v2"
"github.com/writeas/web-core/log" "github.com/writeas/web-core/log"
"github.com/writeas/writefreely" "github.com/writefreely/writefreely"
) )
func main() { func main() {
// General options usable with other commands cli.VersionPrinter = func(c *cli.Context) {
debugPtr := flag.Bool("debug", false, "Enables debug logging.") fmt.Printf("%s\n", c.App.Version)
configFile := flag.String("c", "config.ini", "The configuration file to use") }
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 defaultFlags := []cli.Flag{
createConfig := flag.Bool("create-config", false, "Creates a basic configuration and exits") &cli.StringFlag{
doConfig := flag.Bool("config", false, "Run the configuration process") Name: "c",
configSections := flag.String("sections", "server db app", "Which sections of the configuration to go through (requires --config), "+ Value: "config.ini",
"valid values are any combination of 'server', 'db' and 'app' "+ Usage: "Load configuration from `FILE`",
"example: writefreely --config --sections \"db app\"") },
genKeys := flag.Bool("gen-keys", false, "Generate encryption and authentication keys") &cli.BoolFlag{
createSchema := flag.Bool("init-db", false, "Initialize app database") Name: "debug",
migrate := flag.Bool("migrate", false, "Migrate the database") Value: false,
Usage: "Enables debug logging",
},
}
// Admin actions app.Flags = append(app.Flags, defaultFlags...)
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 := writefreely.NewApp(*configFile) app.Commands = []*cli.Command{
&cmdUser,
&cmdDB,
&cmdConfig,
&cmdKeys,
&cmdServe,
}
if *outputVersion { err := app.Run(os.Args)
writefreely.OutputVersion() if err != nil {
os.Exit(0) log.Error(err.Error())
} else if *createConfig { os.Exit(1)
err := writefreely.CreateConfig(app) }
if err != nil { }
log.Error(err.Error())
os.Exit(1) func legacyActions(c *cli.Context) error {
} app := writefreely.NewApp(c.String("c"))
os.Exit(0)
} else if *doConfig { switch true {
writefreely.DoConfig(app, *configSections) case c.IsSet("create-config"):
os.Exit(0) return writefreely.CreateConfig(app)
} else if *genKeys { case c.IsSet("config"):
err := writefreely.GenerateKeyFiles(app) writefreely.DoConfig(app, c.String("sections"))
if err != nil { return nil
log.Error(err.Error()) case c.IsSet("gen-keys"):
os.Exit(1) return writefreely.GenerateKeyFiles(app)
} case c.IsSet("init-db"):
os.Exit(0) return writefreely.CreateSchema(app)
} else if *createSchema { case c.IsSet("migrate"):
err := writefreely.CreateSchema(app) return writefreely.Migrate(app)
if err != nil { case c.IsSet("create-admin"):
log.Error(err.Error()) username, password, err := parseCredentials(c.String("create-admin"))
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 { if err != nil {
log.Error(err.Error()) return err
os.Exit(1)
} }
os.Exit(0) return writefreely.CreateUser(app, username, password, true)
} else if *migrate { case c.IsSet("create-user"):
err := writefreely.Migrate(app) username, password, err := parseCredentials(c.String("create-user"))
if err != nil { if err != nil {
log.Error(err.Error()) return err
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 // Initialize the application
var err error var err error
log.Info("Starting %s...", writefreely.FormatVersion()) log.Info("Starting %s...", writefreely.FormatVersion())
app, err = writefreely.Initialize(app, *debugPtr) app, err = writefreely.Initialize(app, c.Bool("debug"))
if err != nil { if err != nil {
log.Error("%s", err) return err
os.Exit(1)
} }
// Set app routes // Set app routes
@ -136,20 +170,14 @@ func main() {
// Serve the application // Serve the application
writefreely.Serve(app, r) writefreely.Serve(app, r)
return nil
} }
func userPass(credStr string, isAdmin bool) (user string, pass string, err error) { func parseCredentials(credentialString string) (string, string, error) {
creds := strings.Split(credStr, ":") creds := strings.Split(credentialString, ":")
if len(creds) != 2 { if len(creds) != 2 {
c := "user" return "", "", fmt.Errorf("invalid format for passed credentials, must be username:password")
if isAdmin {
c = "admin"
}
err = fmt.Errorf("usage: writefreely --create-%s username:password", c)
return
} }
return creds[0], creds[1], nil
user = creds[0]
pass = creds[1]
return
} }

@ -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)
}

@ -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
}

@ -1,5 +1,5 @@
/* /*
* Copyright © 2018-2020 A Bunch Tell LLC. * Copyright © 2018-2021 A Bunch Tell LLC.
* *
* This file is part of WriteFreely. * This file is part of WriteFreely.
* *
@ -30,9 +30,9 @@ import (
"github.com/writeas/web-core/bots" "github.com/writeas/web-core/bots"
"github.com/writeas/web-core/log" "github.com/writeas/web-core/log"
waposts "github.com/writeas/web-core/posts" waposts "github.com/writeas/web-core/posts"
"github.com/writeas/writefreely/author" "github.com/writefreely/writefreely/author"
"github.com/writeas/writefreely/config" "github.com/writefreely/writefreely/config"
"github.com/writeas/writefreely/page" "github.com/writefreely/writefreely/page"
) )
type ( type (
@ -47,6 +47,7 @@ type (
Language string `schema:"lang" json:"lang,omitempty"` Language string `schema:"lang" json:"lang,omitempty"`
StyleSheet string `datastore:"style_sheet" schema:"style_sheet" json:"style_sheet"` StyleSheet string `datastore:"style_sheet" schema:"style_sheet" json:"style_sheet"`
Script string `datastore:"script" schema:"script" json:"script,omitempty"` Script string `datastore:"script" schema:"script" json:"script,omitempty"`
Signature string `datastore:"post_signature" schema:"signature" json:"-"`
Public bool `datastore:"public" json:"public"` Public bool `datastore:"public" json:"public"`
Visibility collVisibility `datastore:"private" json:"-"` Visibility collVisibility `datastore:"private" json:"-"`
Format string `datastore:"format" json:"format,omitempty"` Format string `datastore:"format" json:"format,omitempty"`
@ -55,6 +56,8 @@ type (
PublicOwner bool `datastore:"public_owner" json:"-"` PublicOwner bool `datastore:"public_owner" json:"-"`
URL string `json:"url,omitempty"` URL string `json:"url,omitempty"`
MonetizationPointer string `json:"monetization_pointer,omitempty"`
db *datastore db *datastore
hostName string hostName string
} }
@ -71,7 +74,7 @@ type (
IsTopLevel bool IsTopLevel bool
CurrentPage int CurrentPage int
TotalPages int TotalPages int
Suspended bool Silenced bool
} }
SubmittedCollection struct { SubmittedCollection struct {
// Data used for updating a given collection // Data used for updating a given collection
@ -86,13 +89,15 @@ type (
Handle string `schema:"handle" json:"handle"` Handle string `schema:"handle" json:"handle"`
// Actual collection values updated in the DB // Actual collection values updated in the DB
Alias *string `schema:"alias" json:"alias"` Alias *string `schema:"alias" json:"alias"`
Title *string `schema:"title" json:"title"` Title *string `schema:"title" json:"title"`
Description *string `schema:"description" json:"description"` Description *string `schema:"description" json:"description"`
StyleSheet *sql.NullString `schema:"style_sheet" json:"style_sheet"` StyleSheet *sql.NullString `schema:"style_sheet" json:"style_sheet"`
Script *sql.NullString `schema:"script" json:"script"` Script *sql.NullString `schema:"script" json:"script"`
Visibility *int `schema:"visibility" json:"public"` Signature *sql.NullString `schema:"signature" json:"signature"`
Format *sql.NullString `schema:"format" json:"format"` Monetization *string `schema:"monetization_pointer" json:"monetization_pointer"`
Visibility *int `schema:"visibility" json:"public"`
Format *sql.NullString `schema:"format" json:"format"`
} }
CollectionFormat struct { CollectionFormat struct {
Format string Format string
@ -105,6 +110,8 @@ type (
// User-related fields // User-related fields
isCollOwner bool isCollOwner bool
isAuthorized bool
} }
) )
@ -175,6 +182,11 @@ func (c *Collection) NewFormat() *CollectionFormat {
return cf return cf
} }
func (c *Collection) IsInstanceColl() bool {
ur, _ := url.Parse(c.hostName)
return c.Alias == ur.Host
}
func (c *Collection) IsUnlisted() bool { func (c *Collection) IsUnlisted() bool {
return c.Visibility == 0 return c.Visibility == 0
} }
@ -230,7 +242,7 @@ func (c *Collection) DisplayCanonicalURL() string {
func (c *Collection) RedirectingCanonicalURL(isRedir bool) string { func (c *Collection) RedirectingCanonicalURL(isRedir bool) string {
if c.hostName == "" { if c.hostName == "" {
// If this is true, the human programmers screwed up. So ask for a bug report and fail, fail, fail // 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 { if isSingleUser {
return c.hostName + "/" return c.hostName + "/"
@ -397,13 +409,13 @@ func newCollection(app *App, w http.ResponseWriter, r *http.Request) error {
} }
userID = u.ID userID = u.ID
} }
suspended, err := app.db.IsUserSuspended(userID) silenced, err := app.db.IsUserSilenced(userID)
if err != nil { if err != nil {
log.Error("new collection: %v", err) log.Error("new collection: %v", err)
return ErrInternalGeneral return ErrInternalGeneral
} }
if suspended { if silenced {
return ErrUserSuspended return ErrUserSilenced
} }
if !author.IsValidUsername(app.cfg, c.Alias) { 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 res.Owner = u
} }
} }
// TODO: check suspended // TODO: check status for silenced
app.db.GetPostsCount(res, isCollOwner) app.db.GetPostsCount(res, isCollOwner)
// Strip non-public information // Strip non-public information
res.Collection.ForPublic() res.Collection.ForPublic()
@ -548,8 +560,10 @@ type CollectionPage struct {
IsCustomDomain bool IsCustomDomain bool
IsWelcome bool IsWelcome bool
IsOwner bool IsOwner bool
IsCollLoggedIn bool
CanPin bool CanPin bool
Username string Username string
Monetization string
Collections *[]Collection Collections *[]Collection
PinnedPosts *[]PublicPost PinnedPosts *[]PublicPost
IsAdmin bool IsAdmin bool
@ -656,7 +670,7 @@ func processCollectionPermissions(app *App, cr *collectionReq, u *User, w http.R
} }
// TODO: move this to all permission checks? // TODO: move this to all permission checks?
suspended, err := app.db.IsUserSuspended(c.OwnerID) suspended, err := app.db.IsUserSilenced(c.OwnerID)
if err != nil { if err != nil {
log.Error("process protected collection permissions: %v", err) log.Error("process protected collection permissions: %v", err)
return nil, 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 // 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 { p := struct {
page.StaticPage page.StaticPage
*CollectionObj *CollectionObj
@ -721,14 +735,14 @@ func newDisplayCollection(c *Collection, cr *collectionReq, page int) *DisplayCo
return coll 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 { func getCollectionPage(vars map[string]string) int {
page := 1 if p, _ := strconv.Atoi(vars["page"]); p > 0 {
var p int return p
p, _ = strconv.Atoi(vars["page"])
if p > 0 {
page = p
} }
return page
return 1
} }
// handleViewCollection displays the requested Collection // 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 c.hostName = app.cfg.App.Host
suspended, err := app.db.IsUserSuspended(c.OwnerID) silenced, err := app.db.IsUserSilenced(c.OwnerID)
if err != nil { if err != nil {
log.Error("view collection: %v", err) log.Error("view collection: %v", err)
return ErrInternalGeneral return ErrInternalGeneral
@ -786,6 +800,7 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
// Serve collection // Serve collection
displayPage := CollectionPage{ displayPage := CollectionPage{
DisplayCollection: coll, DisplayCollection: coll,
IsCollLoggedIn: cr.isAuthorized,
StaticPage: pageForReq(app, r), StaticPage: pageForReq(app, r),
IsCustomDomain: cr.isCustomDomain, IsCustomDomain: cr.isCustomDomain,
IsWelcome: r.FormValue("greeting") != "", 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) log.Error("Error getting user for collection: %v", err)
} }
} }
if !isOwner && suspended { if !isOwner && silenced {
return ErrCollectionNotFound return ErrCollectionNotFound
} }
displayPage.Suspended = isOwner && suspended displayPage.Silenced = isOwner && silenced
displayPage.Owner = owner displayPage.Owner = owner
coll.Owner = displayPage.Owner coll.Owner = displayPage.Owner
// Add more data // Add more data
// TODO: fix this mess of collections inside collections // TODO: fix this mess of collections inside collections
displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner) displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner)
displayPage.Monetization = app.db.GetCollectionAttribute(coll.ID, "monetization_pointer")
collTmpl := "collection" collTmpl := "collection"
if app.cfg.App.Chorus { if app.cfg.App.Chorus {
@ -939,12 +955,13 @@ func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) e
return ErrCollectionNotFound return ErrCollectionNotFound
} }
} }
displayPage.Suspended = owner != nil && owner.IsSilenced() displayPage.Silenced = owner != nil && owner.IsSilenced()
displayPage.Owner = owner displayPage.Owner = owner
coll.Owner = displayPage.Owner coll.Owner = displayPage.Owner
// Add more data // Add more data
// TODO: fix this mess of collections inside collections // TODO: fix this mess of collections inside collections
displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner) 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) err = templates["collection-tags"].ExecuteTemplate(w, "collection-tags", displayPage)
if err != nil { 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 { if err != nil {
log.Error("existing collection: %v", err) log.Error("existing collection: %v", err)
return ErrInternalGeneral return ErrInternalGeneral
} }
if suspended { if silenced {
return ErrUserSuspended return ErrUserSilenced
} }
if r.Method == "DELETE" { if r.Method == "DELETE" {
@ -1150,3 +1167,43 @@ func isAuthorizedForCollection(app *App, alias string, r *http.Request) bool {
} }
return authd 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()}
}

@ -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

@ -1,5 +1,5 @@
/* /*
* Copyright © 2018-2019 A Bunch Tell LLC. * Copyright © 2018-2020 A Bunch Tell LLC.
* *
* This file is part of WriteFreely. * This file is part of WriteFreely.
* *
@ -12,8 +12,9 @@
package config package config
import ( import (
"gopkg.in/ini.v1"
"strings" "strings"
"gopkg.in/ini.v1"
) )
const ( const (
@ -44,6 +45,8 @@ type (
HashSeed string `ini:"hash_seed"` HashSeed string `ini:"hash_seed"`
GopherPort int `ini:"gopher_port"`
Dev bool `ini:"-"` Dev bool `ini:"-"`
} }
@ -56,6 +59,7 @@ type (
Database string `ini:"database"` Database string `ini:"database"`
Host string `ini:"host"` Host string `ini:"host"`
Port int `ini:"port"` Port int `ini:"port"`
TLS bool `ini:"tls"`
} }
WriteAsOauthCfg struct { WriteAsOauthCfg struct {
@ -68,6 +72,24 @@ type (
CallbackProxyAPI string `ini:"callback_proxy_api"` 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 { SlackOauthCfg struct {
ClientID string `ini:"client_id"` ClientID string `ini:"client_id"`
ClientSecret string `ini:"client_secret"` ClientSecret string `ini:"client_secret"`
@ -76,6 +98,24 @@ type (
CallbackProxyAPI string `ini:"callback_proxy_api"` 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 holds values that affect how the application functions
AppCfg struct { AppCfg struct {
SiteName string `ini:"site_name"` SiteName string `ini:"site_name"`
@ -93,6 +133,7 @@ type (
// Site functionality // Site functionality
Chorus bool `ini:"chorus"` 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"` DisableDrafts bool `ini:"disable_drafts"`
// Users // Users
@ -101,9 +142,12 @@ type (
MinUsernameLen int `ini:"min_username_len"` MinUsernameLen int `ini:"min_username_len"`
MaxBlogs int `ini:"max_blogs"` MaxBlogs int `ini:"max_blogs"`
// Options for public instances
// Federation // Federation
Federation bool `ini:"federation"` Federation bool `ini:"federation"`
PublicStats bool `ini:"public_stats"` PublicStats bool `ini:"public_stats"`
Monetization bool `ini:"monetization"`
NotesOnly bool `ini:"notes_only"`
// Access // Access
Private bool `ini:"private"` Private bool `ini:"private"`
@ -114,6 +158,12 @@ type (
// Defaults // Defaults
DefaultVisibility string `ini:"default_visibility"` 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 // Config holds the complete configuration for running a writefreely instance
@ -123,6 +173,9 @@ type (
App AppCfg `ini:"app"` App AppCfg `ini:"app"`
SlackOauth SlackOauthCfg `ini:"oauth.slack"` SlackOauth SlackOauthCfg `ini:"oauth.slack"`
WriteAsOauth WriteAsOauthCfg `ini:"oauth.writeas"` 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 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. // Load reads the given configuration file, then parses and returns it as a Config.
func Load(fname string) (*Config, error) { func Load(fname string) (*Config, error) {
if fname == "" { if fname == "" {

@ -356,7 +356,7 @@ func Configure(fname string, configSections string) (*SetupData, error) {
if data.Config.App.Federation { if data.Config.App.Federation {
selPrompt = promptui.Select{ selPrompt = promptui.Select{
Templates: selTmpls, Templates: selTmpls,
Label: "Federation usage stats", Label: "Usage stats (active users, posts)",
Items: []string{"Public", "Private"}, Items: []string{"Public", "Private"},
} }
_, fedStatsType, err := selPrompt.Run() _, fedStatsType, err := selPrompt.Run()

@ -1,7 +1,7 @@
// +build wflib // +build wflib
/* /*
* Copyright © 2019 A Bunch Tell LLC. * Copyright © 2019-2020 A Bunch Tell LLC.
* *
* This file is part of WriteFreely. * This file is part of WriteFreely.
* *
@ -18,3 +18,11 @@ package writefreely
func (db *datastore) isDuplicateKeyErr(err error) bool { func (db *datastore) isDuplicateKeyErr(err error) bool {
return false return false
} }
func (db *datastore) isIgnorableError(err error) bool {
return false
}
func (db *datastore) isHighLoadError(err error) bool {
return false
}

@ -40,3 +40,13 @@ func (db *datastore) isIgnorableError(err error) bool {
return false 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
}

@ -1,7 +1,7 @@
// +build sqlite,!wflib // +build sqlite,!wflib
/* /*
* Copyright © 2019 A Bunch Tell LLC. * Copyright © 2019-2020 A Bunch Tell LLC.
* *
* This file is part of WriteFreely. * This file is part of WriteFreely.
* *
@ -60,3 +60,13 @@ func (db *datastore) isIgnorableError(err error) bool {
return false 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
}

@ -1,5 +1,5 @@
/* /*
* Copyright © 2018-2020 A Bunch Tell LLC. * Copyright © 2018-2021 A Bunch Tell LLC.
* *
* This file is part of WriteFreely. * This file is part of WriteFreely.
* *
@ -14,7 +14,8 @@ import (
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
wf_db "github.com/writeas/writefreely/db" "github.com/writeas/web-core/silobridge"
wf_db "github.com/writefreely/writefreely/db"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@ -24,21 +25,22 @@ import (
uuid "github.com/nu7hatch/gouuid" uuid "github.com/nu7hatch/gouuid"
"github.com/writeas/activityserve" "github.com/writeas/activityserve"
"github.com/writeas/impart" "github.com/writeas/impart"
"github.com/writeas/nerds/store"
"github.com/writeas/web-core/activitypub" "github.com/writeas/web-core/activitypub"
"github.com/writeas/web-core/auth" "github.com/writeas/web-core/auth"
"github.com/writeas/web-core/data" "github.com/writeas/web-core/data"
"github.com/writeas/web-core/id" "github.com/writeas/web-core/id"
"github.com/writeas/web-core/log" "github.com/writeas/web-core/log"
"github.com/writeas/web-core/query" "github.com/writeas/web-core/query"
"github.com/writeas/writefreely/author" "github.com/writefreely/writefreely/author"
"github.com/writeas/writefreely/config" "github.com/writefreely/writefreely/config"
"github.com/writeas/writefreely/key" "github.com/writefreely/writefreely/key"
) )
const ( const (
mySQLErrDuplicateKey = 1062 mySQLErrDuplicateKey = 1062
mySQLErrCollationMix = 1267 mySQLErrCollationMix = 1267
mySQLErrTooManyConns = 1040
mySQLErrMaxUserConns = 1203
driverMySQL = "mysql" driverMySQL = "mysql"
driverSQLite = "sqlite3" driverSQLite = "sqlite3"
@ -130,8 +132,10 @@ type writestore interface {
GetIDForRemoteUser(context.Context, string, string, string) (int64, error) GetIDForRemoteUser(context.Context, string, string, string) (int64, error)
RecordRemoteUserID(context.Context, int64, string, string, string, string) error RecordRemoteUserID(context.Context, int64, string, string, string, string) error
ValidateOAuthState(context.Context, string) (string, string, error) ValidateOAuthState(context.Context, string) (string, string, int64, string, error)
GenerateOAuthState(context.Context, string, string) (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 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) 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 { func (db *datastore) CreateUser(cfg *config.Config, u *User, collectionTitle string) error {
if db.PostIDExists(u.Username) { if db.PostIDExists(u.Username) {
return impart.HTTPError{http.StatusConflict, "Invalid collection name."} return impart.HTTPError{http.StatusConflict, "Invalid collection name."}
@ -319,18 +324,18 @@ func (db *datastore) GetUserByID(id int64) (*User, error) {
return u, nil return u, nil
} }
// IsUserSuspended returns true if the user account associated with id is // IsUserSilenced returns true if the user account associated with id is
// currently suspended. // currently silenced.
func (db *datastore) IsUserSuspended(id int64) (bool, error) { func (db *datastore) IsUserSilenced(id int64) (bool, error) {
u := &User{ID: id} u := &User{ID: id}
err := db.QueryRow("SELECT status FROM users WHERE id = ?", id).Scan(&u.Status) err := db.QueryRow("SELECT status FROM users WHERE id = ?", id).Scan(&u.Status)
switch { switch {
case err == sql.ErrNoRows: 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: case err != nil:
log.Error("Couldn't SELECT user password: %v", err) log.Error("Couldn't SELECT user status: %v", err)
return false, fmt.Errorf("is user suspended: %v", err) return false, fmt.Errorf("is user silenced: %v", err)
} }
return u.IsSilenced(), nil 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) { func (db *datastore) CreatePost(userID, collID int64, post *SubmittedPost) (*Post, error) {
idLen := postIDLen idLen := postIDLen
friendlyID := store.GenerateFriendlyRandomString(idLen) friendlyID := id.GenerateFriendlyRandomString(idLen)
// Handle appearance / font face // Handle appearance / font face
appearance := post.Font appearance := post.Font
@ -632,13 +637,17 @@ func (db *datastore) CreatePost(userID, collID int64, post *SubmittedPost) (*Pos
ownerCollID.Int64 = collID ownerCollID.Int64 = collID
ownerCollID.Valid = true ownerCollID.Valid = true
var slugVal string var slugVal string
if post.Title != nil && *post.Title != "" { if post.Slug != nil && *post.Slug != "" {
slugVal = getSlug(*post.Title, post.Language.String) slugVal = *post.Slug
if slugVal == "" { } 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) slugVal = getSlug(*post.Content, post.Language.String)
} }
} else {
slugVal = getSlug(*post.Content, post.Language.String)
} }
if slugVal == "" { if slugVal == "" {
slugVal = friendlyID slugVal = friendlyID
@ -786,19 +795,22 @@ func (db *datastore) GetCollectionBy(condition string, value interface{}) (*Coll
c := &Collection{} c := &Collection{}
// FIXME: change Collection to reflect database values. Add helper functions to get actual values // FIXME: change Collection to reflect database values. Add helper functions to get actual values
var styleSheet, script, format zero.String var styleSheet, script, signature, 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) 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 { switch {
case err == sql.ErrNoRows: case err == sql.ErrNoRows:
return nil, impart.HTTPError{http.StatusNotFound, "Collection doesn't exist."} return nil, impart.HTTPError{http.StatusNotFound, "Collection doesn't exist."}
case db.isHighLoadError(err):
return nil, ErrUnavailable
case err != nil: case err != nil:
log.Error("Failed selecting from collections: %v", err) log.Error("Failed selecting from collections: %v", err)
return nil, err return nil, err
} }
c.StyleSheet = styleSheet.String c.StyleSheet = styleSheet.String
c.Script = script.String c.Script = script.String
c.Signature = signature.String
c.Format = format.String c.Format = format.String
c.Public = c.IsPublic() c.Public = c.IsPublic()
@ -842,7 +854,8 @@ func (db *datastore) UpdateCollection(c *SubmittedCollection, alias string) erro
SetStringPtr(c.Title, "title"). SetStringPtr(c.Title, "title").
SetStringPtr(c.Description, "description"). SetStringPtr(c.Description, "description").
SetNullString(c.StyleSheet, "style_sheet"). SetNullString(c.StyleSheet, "style_sheet").
SetNullString(c.Script, "script") SetNullString(c.Script, "script").
SetNullString(c.Signature, "post_signature")
if c.Format != nil { if c.Format != nil {
cf := &CollectionFormat{Format: c.Format.String} 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 // Update rest of the collection data
res, err = db.Exec("UPDATE collections SET "+q.Updates+" WHERE "+q.Conditions, q.Params...) res, err = db.Exec("UPDATE collections SET "+q.Updates+" WHERE "+q.Conditions, q.Params...)
if err != nil { if err != nil {
@ -1143,6 +1179,7 @@ func (db *datastore) GetPosts(cfg *config.Config, c *Collection, page int, inclu
break break
} }
p.extractData() p.extractData()
p.augmentContent(c)
p.formatContent(cfg, c, includeFuture) p.formatContent(cfg, c, includeFuture)
posts = append(posts, p.processPost()) posts = append(posts, p.processPost())
@ -1207,6 +1244,7 @@ func (db *datastore) GetPostsTagged(cfg *config.Config, c *Collection, tag strin
break break
} }
p.extractData() p.extractData()
p.augmentContent(c)
p.formatContent(cfg, c, includeFuture) p.formatContent(cfg, c, includeFuture)
posts = append(posts, p.processPost()) posts = append(posts, p.processPost())
@ -1583,6 +1621,7 @@ func (db *datastore) GetPinnedPosts(coll *CollectionObj, includeFuture bool) (*[
break break
} }
p.extractData() p.extractData()
p.augmentContent(&coll.Collection)
pp := p.processPost() pp := p.processPost()
pp.Collection = coll pp.Collection = coll
@ -1633,6 +1672,40 @@ func (db *datastore) GetPublishableCollections(u *User, hostName string) (*[]Col
return c, nil 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 { func (db *datastore) GetMeStats(u *User) userMeStats {
s := 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) { func (db *datastore) GetCollectionRedirect(alias string) (new string) {
row := db.QueryRow("SELECT new_alias FROM collectionredirects WHERE prev_alias = ?", alias) row := db.QueryRow("SELECT new_alias FROM collectionredirects WHERE prev_alias = ?", alias)
err := row.Scan(&new) 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) log.Error("Failed selecting from collectionredirects: %v", err)
} }
return return
@ -2115,6 +2188,28 @@ func (db *datastore) CollectionHasAttribute(id int64, attr string) bool {
return true 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 // DeleteAccount will delete the entire account for userID
func (db *datastore) DeleteAccount(userID int64) error { func (db *datastore) DeleteAccount(userID int64) error {
// Get all collections // Get all collections
@ -2510,20 +2605,26 @@ func (db *datastore) GetCollectionLastPostTime(id int64) (*time.Time, error) {
return &t, nil return &t, nil
} }
func (db *datastore) GenerateOAuthState(ctx context.Context, provider, clientID string) (string, error) { func (db *datastore) GenerateOAuthState(ctx context.Context, provider string, clientID string, attachUser int64, inviteCode string) (string, error) {
state := store.Generate62RandomString(24) state := id.Generate62RandomString(24)
_, err := db.ExecContext(ctx, "INSERT INTO oauth_client_states (state, provider, client_id, used, created_at) VALUES (?, ?, ?, FALSE, NOW())", state, provider, clientID) 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 { if err != nil {
return "", fmt.Errorf("unable to record oauth client state: %w", err) return "", fmt.Errorf("unable to record oauth client state: %w", err)
} }
return state, nil 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 provider string
var clientID 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 := 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 { if err != nil {
return err return err
} }
@ -2542,9 +2643,9 @@ func (db *datastore) ValidateOAuthState(ctx context.Context, state string) (stri
return nil return nil
}) })
if err != 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 { 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 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 // DatabaseInitialized returns whether or not the current datastore has been
// initialized with the correct schema. // initialized with the correct schema.
// Currently, it checks to see if the `users` table exists. // Currently, it checks to see if the `users` table exists.
@ -2595,6 +2725,11 @@ func (db *datastore) DatabaseInitialized() bool {
return true 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{}) { func stringLogln(log *string, s string, v ...interface{}) {
*log += fmt.Sprintf(s+"\n", v...) *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) { func (db *datastore) GetProfilePageFromHandle(app *App, handle string) (string, error) {
handle = strings.TrimLeft(handle, "@")
actorIRI := "" 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) remoteUser, err := getRemoteUserFromHandle(app, handle)
if err != nil { if err != nil {
// can't find using handle in the table but the table may already have this user without // 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 { if errRemoteUser == nil {
_, err := app.db.Exec("UPDATE remoteusers SET handle = ? WHERE actor_id = ?", handle, actorIRI) _, err := app.db.Exec("UPDATE remoteusers SET handle = ? WHERE actor_id = ?", handle, actorIRI)
if err != nil { 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 { } else {
// this probably means we don't have the user in the table so let's try to insert it // 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 // here we need to ask the server for the inboxes
remoteActor, err := activityserve.NewRemoteActor(actorIRI) remoteActor, err := activityserve.NewRemoteActor(actorIRI)
if err != nil { if err != nil {
log.Error("Couldn't fetch remote actor", err) log.Error("Couldn't fetch remote actor: %v", err)
} }
if debugging { if debugging {
log.Info("%s %s %s %s", actorIRI, remoteActor.GetInbox(), remoteActor.GetSharedInbox(), handle) 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) _, err = app.db.Exec("INSERT INTO remoteusers (actor_id, inbox, shared_inbox, handle) VALUES(?, ?, ?, ?)", actorIRI, remoteActor.GetInbox(), remoteActor.GetSharedInbox(), handle)
if err != nil { if err != nil {
log.Error("Can't insert remote user in database", err) log.Error("Couldn't insert remote user: %v", err)
return "", err return "", err
} }
} }

@ -18,13 +18,13 @@ func TestOAuthDatastore(t *testing.T) {
driverName: "", driverName: "",
} }
state, err := ds.GenerateOAuthState(ctx, "test", "development") state, err := ds.GenerateOAuthState(ctx, "test", "development", 0, "")
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, state, 24) assert.Len(t, state, 24)
countRows(t, ctx, db, 1, "SELECT COUNT(*) FROM `oauth_client_states` WHERE `state` = ? AND `used` = false", state) 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) assert.NoError(t, err)
countRows(t, ctx, db, 1, "SELECT COUNT(*) FROM `oauth_client_states` WHERE `state` = ? AND `used` = true", state) countRows(t, ctx, db, 1, "SELECT COUNT(*) FROM `oauth_client_states` WHERE `state` = ? AND `used` = true", state)

@ -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 package db
import ( import (
@ -139,6 +149,15 @@ func (c *Column) SetDefault(value string) *Column {
return c 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 { func (c *Column) SetType(t ColumnType) *Column {
c.Type = t c.Type = t
return c return c
@ -168,7 +187,11 @@ func (c *Column) String() (string, error) {
if c.Default.Set { if c.Default.Set {
str.WriteString(" DEFAULT ") str.WriteString(" DEFAULT ")
str.WriteString(c.Default.Value) val := c.Default.Value
if val == "" {
val = "''"
}
str.WriteString(val)
} }
if c.PrimaryKey { if c.PrimaryKey {
@ -241,4 +264,3 @@ func (b *CreateTableSqlBuilder) ToSQL() (string, error) {
return str.String(), nil return str.String(), nil
} }

@ -1,32 +1,47 @@
version: "3" version: "3"
volumes:
web-keys:
db-data:
networks:
external_writefreely:
internal_writefreely:
internal: true
services: services:
web: writefreely-web:
build: . container_name: "writefreely-web"
image: "writeas/writefreely:latest"
volumes: volumes:
- "web-data:/go/src/app" - "web-keys:/go/keys"
- "./config.ini.example:/go/src/app/config.ini" - "./config.ini:/go/config.ini"
networks:
- "internal_writefreely"
- "external_writefreely"
ports: ports:
- "8080:8080" - "8080:8080"
networks:
- writefreely
depends_on: depends_on:
- db - "writefreely-db"
restart: unless-stopped restart: unless-stopped
db:
writefreely-db:
container_name: "writefreely-db"
image: "mariadb:latest" image: "mariadb:latest"
volumes: volumes:
- "./schema.sql:/tmp/schema.sql" - "db-data:/var/lib/mysql/data"
- db-data:/var/lib/mysql/data
networks: networks:
- writefreely - "internal_writefreely"
environment: environment:
- MYSQL_DATABASE=writefreely - MYSQL_DATABASE=writefreely
- MYSQL_ROOT_PASSWORD=changeme - MYSQL_ROOT_PASSWORD=changeme
restart: unless-stopped
volumes: restart: unless-stopped
web-data:
db-data:
networks:
writefreely:

@ -37,6 +37,8 @@ var (
ErrInternalGeneral = impart.HTTPError{http.StatusInternalServerError, "The humans messed something up. They've been notified."} ErrInternalGeneral = impart.HTTPError{http.StatusInternalServerError, "The humans messed something up. They've been notified."}
ErrInternalCookieSession = impart.HTTPError{http.StatusInternalServerError, "Could not get cookie session."} 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."} ErrCollectionNotFound = impart.HTTPError{http.StatusNotFound, "Collection doesn't exist."}
ErrCollectionGone = impart.HTTPError{http.StatusGone, "This blog was unpublished."} ErrCollectionGone = impart.HTTPError{http.StatusGone, "This blog was unpublished."}
ErrCollectionPageNotFound = impart.HTTPError{http.StatusNotFound, "Collection page doesn't exist."} ErrCollectionPageNotFound = impart.HTTPError{http.StatusNotFound, "Collection page doesn't exist."}
@ -49,7 +51,9 @@ var (
ErrRemoteUserNotFound = impart.HTTPError{http.StatusNotFound, "Remote user not found."} ErrRemoteUserNotFound = impart.HTTPError{http.StatusNotFound, "Remote user not found."}
ErrUserNotFoundEmail = impart.HTTPError{http.StatusNotFound, "Please enter your username instead of your email address."} 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 // Post operation errors

@ -1,5 +1,5 @@
/* /*
* Copyright © 2018-2019 A Bunch Tell LLC. * Copyright © 2018-2020 A Bunch Tell LLC.
* *
* This file is part of WriteFreely. * This file is part of WriteFreely.
* *
@ -36,12 +36,12 @@ func ViewFeed(app *App, w http.ResponseWriter, req *http.Request) error {
return nil return nil
} }
suspended, err := app.db.IsUserSuspended(c.OwnerID) silenced, err := app.db.IsUserSilenced(c.OwnerID)
if err != nil { if err != nil {
log.Error("view feed: get user: %v", err) log.Error("view feed: get user: %v", err)
return ErrInternalGeneral return ErrInternalGeneral
} }
if suspended { if silenced {
return ErrCollectionNotFound return ErrCollectionNotFound
} }
c.hostName = app.cfg.App.Host c.hostName = app.cfg.App.Host
@ -104,7 +104,7 @@ func ViewFeed(app *App, w http.ResponseWriter, req *http.Request) error {
Title: title, Title: title,
Link: &Link{Href: permalink}, Link: &Link{Href: permalink},
Description: "<![CDATA[" + stripmd.Strip(p.Content) + "]]>", Description: "<![CDATA[" + stripmd.Strip(p.Content) + "]]>",
Content: applyMarkdown([]byte(p.Content), "", app.cfg), Content: string(p.HTMLContent),
Author: &Author{author, ""}, Author: &Author{author, ""},
Created: p.Created, Created: p.Created,
Updated: p.Updated, Updated: p.Updated,

@ -1,66 +1,49 @@
module github.com/writeas/writefreely module github.com/writefreely/writefreely
require ( 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/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/dustin/go-humanize v1.0.0
github.com/fatih/color v1.7.0 github.com/fatih/color v1.10.0
github.com/go-fed/httpsig v0.1.1-0.20190924171022-f4c36041199d // indirect github.com/go-sql-driver/mysql v1.6.0
github.com/go-sql-driver/mysql v1.4.1
github.com/go-test/deep v1.0.1 // indirect 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/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect
github.com/gorilla/feeds v1.1.0 github.com/gorilla/feeds v1.1.1
github.com/gorilla/mux v1.7.0 github.com/gorilla/mux v1.8.0
github.com/gorilla/schema v1.0.2 github.com/gorilla/schema v1.2.0
github.com/gorilla/sessions v1.2.0 github.com/gorilla/sessions v1.2.0
github.com/guregu/null v3.4.0+incompatible github.com/guregu/null v3.5.0+incompatible
github.com/hashicorp/go-multierror v1.0.0 github.com/hashicorp/go-multierror v1.1.1
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2 github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2
github.com/jtolds/gls v4.2.1+incompatible // indirect github.com/jtolds/gls v4.2.1+incompatible // indirect
github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec
github.com/lunixbochs/vtclean v1.0.0 // indirect github.com/lunixbochs/vtclean v1.0.0 // indirect
github.com/manifoldco/promptui v0.3.2 github.com/manifoldco/promptui v0.8.0
github.com/mattn/go-colorable v0.1.0 // indirect github.com/mattn/go-sqlite3 v1.14.6
github.com/mattn/go-sqlite3 v1.10.0 github.com/microcosm-cc/bluemonday v1.0.5
github.com/microcosm-cc/bluemonday v1.0.2 github.com/mitchellh/go-wordwrap v1.0.1
github.com/mitchellh/go-wordwrap v1.0.0
github.com/nicksnyder/go-i18n v1.10.0 // indirect
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d 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/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/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect
github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 // indirect github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 // indirect
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c // 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/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-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/httpsig v1.0.0
github.com/writeas/impart v1.1.1-0.20191230230525-d3c45ced010d github.com/writeas/impart v1.1.1
github.com/writeas/import v0.2.0 github.com/writeas/import v0.2.1
github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219 github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219
github.com/writeas/nerds v1.0.0 github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320
github.com/writeas/saturday v1.7.1
github.com/writeas/slug v1.2.0 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 github.com/writefreely/go-nodeinfo v1.2.0
golang.org/x/crypto v0.0.0-20190208162236-193df9c0f06f golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1 // indirect golang.org/x/net v0.0.0-20200707034311-ab3426394381 // indirect
golang.org/x/net v0.0.0-20190206173232-65e2d4e15006 // indirect gopkg.in/ini.v1 v1.62.0
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
) )
go 1.13 go 1.13

188
go.sum

@ -1,16 +1,14 @@
code.as/core/socks v1.0.0 h1:SPQXNp4SbEwjOAP9VzUahLHak8SDqy5n+9cm9tpjZOs= code.as/core/socks v1.0.0 h1:SPQXNp4SbEwjOAP9VzUahLHak8SDqy5n+9cm9tpjZOs=
code.as/core/socks v1.0.0/go.mod h1:BAXBy5O9s2gmw6UxLqNJcVbWY7C/UPs+801CcSsfWOY= 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/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/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/alecthomas/gometalinter v3.0.0+incompatible h1:e9Zfvfytsw/e6Kd/PYd75wggK+/kX5Xn8IYDUKyc5fU= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
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/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs= 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/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-20190703115713-e80254a89df1 h1:AFSJaASPGYNbkUa5c8ZybrcW9pP3Cy7+z5dnpcc/qG8=
github.com/captncraig/cors v0.0.0-20180620154129-376d45073b49/go.mod h1:EIlIeMufZ8nqdUhnesledB15xLRl4wIJUppwDLPrdrQ= 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 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 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= 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.3/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I= 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/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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-20200228104902-7aecb25e1fe5 h1:RAV05c0xOkJ3dZGS0JFybxFKZ2WMLabgx3uXnd7rpGs=
github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4= 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 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= 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.10.0 h1:s36xzo75JdqLaaWoiEHk767eHiwo0598uUxyfiPkDsg=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= 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 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= 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.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.20200204213531-0ef28562fabe h1:U71giCx5NjRn4Lb71UuprPHqhjxGv3Jqonb9fgcaJH8=
github.com/go-fed/httpsig v0.1.1-0.20190924171022-f4c36041199d/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE= github.com/go-fed/httpsig v0.1.1-0.20200204213531-0ef28562fabe/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE=
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA= github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= 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 h1:UQhStjbkDClarlmv0am7OXXO4/GaPdCGiUiMTvi28sg=
github.com/go-test/deep v1.0.1/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= 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/gofrs/uuid v3.3.0+incompatible h1:8K4tyRfvU1CYPgJsveYFQMhpFd/wXNM7iK6rR7UHz84=
github.com/golang/lint v0.0.0-20181217174547-8f45f776aaf1 h1:6DVPu65tee05kY0/rciBQ47ue+AnuY8KTayV6VHikIo= github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/golang/lint v0.0.0-20181217174547-8f45f776aaf1/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= github.com/gologme/log v1.2.0 h1:Ya5Ip/KD6FX7uH0S31QO87nCCSucKtF44TLbTtO7V4c=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/gologme/log v1.2.0/go.mod h1:gq31gQ8wEHkR+WekdWsqDuf8pXTUZA9BnnzTuPz1Y9U=
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/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg= 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/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gordonklaus/ineffassign v0.0.0-20180909121442-1003c8bd00dc h1:cJlkeAx1QYgO5N80aF5xRGstVsRQwgLR7uA2FnP1ZjY= github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gordonklaus/ineffassign v0.0.0-20180909121442-1003c8bd00dc/go.mod h1:cuNKsD1zp2v6XfE/orVX2QE1LC+i254ceGcVeDT3pTU= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/gorilla/feeds v1.1.0 h1:pcgLJhbdYgaUESnj3AmXPcB7cS3vy63+jC/TI14AGXk= github.com/gorilla/feeds v1.1.1 h1:HwKXxqzcRNg9to+BbvJog4+f3s/xzvtZXICcQGutYfY=
github.com/gorilla/feeds v1.1.0/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA= github.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA=
github.com/gorilla/mux v1.7.0 h1:tOSd0UKHQd6urX6ApfOn4XdBMY6Sh1MfxV3kmaazO+U= github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/mux v1.7.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/schema v1.0.2 h1:sAgNfOcNYvdDSrzGHVy9nzCQahG+qmsg+nE8dK85QRA= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/schema v1.0.2/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= 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 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= 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 h1:S7P+1Hm5V/AT9cjEcUD5uDaQSX0OE577aCXgoaKpYbQ=
github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= 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.5.0+incompatible h1:fSdvRTQtmBA4B4YDZXhLtxTIJZYuUxBFTTHS4B9djG4=
github.com/guregu/null v3.4.0+incompatible/go.mod h1:ePGpQaN9cw0tj45IR5E5ehMvsFlLlQZAkkOXZurJ3NM= 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 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 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.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 h1:wIdDEle9HEy7vBPjC6oKz6ejs3Ut+jmsYvuOoAW2pSM=
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2/go.mod h1:WtaVKD9TeruTED9ydiaOJU08qGoEPP/LyzTKiD3jEsw= 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= 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/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 h1:ZXWuspqypleMuJy4bzYEqlMhJnGAYpLrWe5p7W3CdvI=
github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec/go.mod h1:voECJzdraJmolzPBgL9Z7ANwXf4oMXaTCsIkdiPpR/g= 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 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 h1:xu2sLAri4lGiovBDQKxl5mrXyESr3gUr5m5SM5+LVb8=
github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= 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.8.0 h1:R95mMF+McvXZQ7j1g8ucVZE1gLP3Sv6j9vlF9kyRqQo=
github.com/manifoldco/promptui v0.3.2/go.mod h1:8JU+igZ+eeiiRku4T5BjtKh2ms8sziGpSYl1gN8Bazw= github.com/manifoldco/promptui v0.8.0/go.mod h1:n4zTdgP0vr0S3w7/O/g98U+e0gwLScEXGwov2nIKuGQ=
github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= 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.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8=
github.com/mattn/go-colorable v0.1.0/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= 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-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/microcosm-cc/bluemonday v1.0.2 h1:5lPfLTTAvAbtS0VqT+94yOtFnGfUWYyx0+iToC3Os3s= 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/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/microcosm-cc/bluemonday v1.0.5 h1:cF59UCKMmmUgqN1baLvqU/B1ZsMori+duLVTLpgiG3w=
github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= github.com/microcosm-cc/bluemonday v1.0.5/go.mod h1:8iwZnFn2CDDNZ0r6UXhF4xawGvzaqzCRa1n3/lO3W2w=
github.com/nicksnyder/go-i18n v1.10.0 h1:5AzlPKvXBH4qBzmZ09Ua9Gipyruv6uApMcrNZdo96+Q= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
github.com/nicksnyder/go-i18n v1.10.0/go.mod h1:HrK7VCrbOvQoUAQ7Vpy7i87N7JZZZ7R2xBGjv0j365Q= 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 h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ=
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= 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 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ=
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q= 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 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 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= 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 h1:Ho+uVpkel/udgjbwB5Lktg9BtvJSh2DT0Hi6LPSyI2w=
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= 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/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.6.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/tsenart/deadcode v0.0.0-20160724212837-210d2dc333e9 h1:vY5WqiEon0ZSTGM3ayVVi+twaHKHDFUVloaQ/wug9/c= github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
github.com/tsenart/deadcode v0.0.0-20160724212837-210d2dc333e9/go.mod h1:q+QjxYvZ+fpjMXqs+XEriussHjSYqeXVnAdSV1tkMYk= 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 h1:Y12B5lIrabfqKE7e7HFCWiXrlfXljr9tlkFm2mp7DgY=
github.com/writeas/activity v0.1.2/go.mod h1:mYYgiewmEM+8tlifirK/vl6tmB2EbjYaxwb+ndUw5T0= 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-20200409150223-d7ab3eaa4481 h1:BiSivIxLQFcKoUorpNN3rNwwFG5bITPnqUSyIccfdh0=
github.com/writeas/activityserve v0.0.0-20191008122325-5fc3b48e70c5/go.mod h1:Kz62mzYsCnrFTSTSFLXFj3fGYBQOntmBWTDDq57b46A= github.com/writeas/activityserve v0.0.0-20200409150223-d7ab3eaa4481/go.mod h1:4akDJSl+sSp+QhrQKMqzAqdV1gJ1pPx6XPI77zgMM8o=
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/go-strip-markdown v2.0.1+incompatible h1:IIqxTM5Jr7RzhigcL6FkrCNfXkvbR+Nbu1ls48pXYcw= 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-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 v1.1.0 h1:MzNyt0ry/GMsRmJGftn2o9mPwqK1Q5MLdh4VuJCfb1Q=
github.com/writeas/go-webfinger v0.0.0-20190106002315-85cf805c86d2/go.mod h1:w2VxyRO/J5vfNjJHYVubsjUGHd3RLDoVciz0DE3ApOc= 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 h1:WHGm6wriBkxYAOGbvriXH8DlMUGOi6jhSZLUZKQ+4mQ=
github.com/writeas/go-writeas v1.1.0/go.mod h1:oh9U1rWaiE0p3kzdKwwvOpNXgp0P0IELI7OLOwV4fkA= 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 h1:akvdMg89U5oBJiCkBwOXljVLTqP354uN6qnG2oOMrbk=
github.com/writeas/go-writeas/v2 v2.0.2/go.mod h1:9sjczQJKmru925fLzg0usrU1R1tE4vBmQtGnItUMR0M= 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 h1:peIAoIA3DmlP8IG8tMNZqI4YD1uEnWBmkcC9OFPjt3A=
github.com/writeas/httpsig v1.0.0/go.mod h1:7ClMGSrSVXJbmiLa17bZ1LrG1oibGZmUMlh3402flPY= 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.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 h1:RyA9+CqbdbDuz53k+nXCWUY+NlEkdyw6+nWanxSBl5o=
github.com/writeas/impart v1.1.1-0.20191230230525-d3c45ced010d/go.mod h1:g0MpxdnTOHHrl+Ca/2oMXUHJ0PcRAEWtkCzYCJUXC9Y= github.com/writeas/impart v1.1.1/go.mod h1:g0MpxdnTOHHrl+Ca/2oMXUHJ0PcRAEWtkCzYCJUXC9Y=
github.com/writeas/import v0.2.0 h1:Ov23JW9Rnjxk06rki1Spar45bNX647HhwhAZj3flJiY= github.com/writeas/import v0.2.1 h1:3k+bDNCyqaWdZinyUZtEO4je3mR6fr/nE4ozTh9/9Wg=
github.com/writeas/import v0.2.0/go.mod h1:gFe0Pl7ZWYiXbI0TJxeMMyylPGZmhVvCfQxhMEc8CxM= 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 h1:baEp0631C8sT2r/hqwypIw2snCFZa6h7U6TojoLHu/c=
github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219/go.mod h1:NyM35ayknT7lzO6O/1JpfgGyv+0W9Z9q7aE0J8bXxfQ= 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 h1:YXM1tDXeYOlTyJjoMlYLQH1xOloUimSR1WMF8kjFc5o=
github.com/writeas/openssl-go v1.0.0/go.mod h1:WsKeK5jYl0B5y8ggOmtVjbmb+3rEGqSD25TppjJnETA= 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.6.0/go.mod h1:ETE1EK6ogxptJpAgUbcJD0prAtX48bSloie80+tvnzQ=
github.com/writeas/saturday v1.7.1 h1:lYo1EH6CYyrFObQoA9RNWHVlpZA5iYL5Opxo7PYAnZE= github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320 h1:PozPZ29CQ/xt6ym/+FvIz+KvKEObSSc5ye+95zbTjVU=
github.com/writeas/saturday v1.7.1/go.mod h1:ETE1EK6ogxptJpAgUbcJD0prAtX48bSloie80+tvnzQ= 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 h1:EMQ+cwLiOcA6EtFwUgyw3Ge18x9uflUnOnR6bp/J+/g=
github.com/writeas/slug v1.2.0/go.mod h1:RE8shOqQP3YhsfsQe0L3RnuejfQ4Mk+JjY5YJQFubfQ= 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.3.1-0.20210330164422-95a3a717ed8f h1:ItBZYzdIbBmmqn8BZGWww00MBFgcUKy5ei0gJrzRDFk=
github.com/writeas/web-core v1.2.0/go.mod h1:vTYajviuNBAxjctPp2NUYdgjofywVkxUGpeaERF3SfI= 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 h1:La+YbTCvmpTwFhBSlebWDDL81N88Qf/SCAvRLR7F8ss=
github.com/writefreely/go-nodeinfo v1.2.0/go.mod h1:UTvE78KpcjYOlRHupZIiSEFcXHioTXuacCbHU+CAcPg= 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-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-20190131182504-b8fe1690c613/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190208162236-193df9c0f06f h1:ETU2VEl7TnT5bl7IvuKEzTDpplg5wzGYsOCAPhdoEIg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190208162236-193df9c0f06f/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
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/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 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-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190206173232-65e2d4e15006/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 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-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-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-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190209173611-3b5209105503/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/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 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 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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.55.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.41.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 h1:POO/ycCATvegFmVuPpQzZFJ+pGZeX22Ufu6fibxDVjU=
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= 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.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
src.techknowlogick.com/xgo v0.0.0-20200129005940-d0fae26e014b h1:rPAdjgXks4ToezTjygsnKZroxKVnA1L35DSpsJXPtfc= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
src.techknowlogick.com/xgo v0.0.0-20200129005940-d0fae26e014b/go.mod h1:31CE1YKtDOrKTk9PSnjTpe6YbO6W/0LTYZ1VskL09oU=

@ -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()
}

@ -1,5 +1,5 @@
/* /*
* Copyright © 2018-2019 A Bunch Tell LLC. * Copyright © 2018-2021 A Bunch Tell LLC.
* *
* This file is part of WriteFreely. * This file is part of WriteFreely.
* *
@ -21,10 +21,11 @@ import (
"time" "time"
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
"github.com/prologic/go-gopher"
"github.com/writeas/impart" "github.com/writeas/impart"
"github.com/writeas/web-core/log" "github.com/writeas/web-core/log"
"github.com/writeas/writefreely/config" "github.com/writefreely/writefreely/config"
"github.com/writeas/writefreely/page" "github.com/writefreely/writefreely/page"
) )
// UserLevel represents the required user level for accessing an endpoint // UserLevel represents the required user level for accessing an endpoint
@ -64,6 +65,7 @@ func UserLevelReader(cfg *config.Config) UserLevel {
type ( type (
handlerFunc func(app *App, w http.ResponseWriter, r *http.Request) error 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 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 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) dataHandlerFunc func(app *App, w http.ResponseWriter, r *http.Request) ([]byte, string, error)
@ -83,6 +85,7 @@ type ErrorPages struct {
NotFound *template.Template NotFound *template.Template
Gone *template.Template Gone *template.Template
InternalServerError *template.Template InternalServerError *template.Template
UnavailableError *template.Template
Blank *template.Template Blank *template.Template
} }
@ -94,6 +97,7 @@ func NewHandler(apper Apper) *Handler {
NotFound: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>404</title></head><body><p>Not found.</p></body></html>{{end}}")), NotFound: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>404</title></head><body><p>Not found.</p></body></html>{{end}}")),
Gone: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>410</title></head><body><p>Gone.</p></body></html>{{end}}")), Gone: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>410</title></head><body><p>Gone.</p></body></html>{{end}}")),
InternalServerError: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>500</title></head><body><p>Internal server error.</p></body></html>{{end}}")), InternalServerError: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>500</title></head><body><p>Internal server error.</p></body></html>{{end}}")),
UnavailableError: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>503</title></head><body><p>Service is temporarily unavailable.</p></body></html>{{end}}")),
Blank: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>{{.Title}}</title></head><body><p>{{.Content}}</p></body></html>{{end}}")), Blank: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>{{.Title}}</title></head><body><p>{{.Content}}</p></body></html>{{end}}")),
}, },
sessionStore: apper.App().SessionStore(), sessionStore: apper.App().SessionStore(),
@ -111,6 +115,7 @@ func NewWFHandler(apper Apper) *Handler {
NotFound: pages["404-general.tmpl"], NotFound: pages["404-general.tmpl"],
Gone: pages["410.tmpl"], Gone: pages["410.tmpl"],
InternalServerError: pages["500.tmpl"], InternalServerError: pages["500.tmpl"],
UnavailableError: pages["503.tmpl"],
Blank: pages["blank.tmpl"], Blank: pages["blank.tmpl"],
}) })
return h return h
@ -596,6 +601,9 @@ func (h *Handler) AllReader(f handlerFunc) http.HandlerFunc {
log.Info(h.app.ReqLog(r, status, time.Since(start))) 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 { if h.app.App().cfg.App.Private {
// This instance is private, so ensure it's being accessed by a valid user // This instance is private, so ensure it's being accessed by a valid user
// Check if authenticated with an access token // 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") log.Info("handleHTTPErorr internal error render")
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r)) h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
return 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 { } else if err.Status == http.StatusAccepted {
impart.WriteSuccess(w, "", err.Status) impart.WriteSuccess(w, "", err.Status)
return 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 { func sendRedirect(w http.ResponseWriter, code int, location string) int {
w.Header().Set("Location", location) w.Header().Set("Location", location)
w.WriteHeader(code) w.WriteHeader(code)
return 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)
})
}

@ -1,5 +1,5 @@
/* /*
* Copyright © 2019 A Bunch Tell LLC. * Copyright © 2019-2021 A Bunch Tell LLC.
* *
* This file is part of WriteFreely. * This file is part of WriteFreely.
* *
@ -19,9 +19,9 @@ import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/writeas/impart" "github.com/writeas/impart"
"github.com/writeas/nerds/store" "github.com/writeas/web-core/id"
"github.com/writeas/web-core/log" "github.com/writeas/web-core/log"
"github.com/writeas/writefreely/page" "github.com/writefreely/writefreely/page"
) )
type Invite struct { type Invite struct {
@ -42,6 +42,18 @@ func (i Invite) Expired() bool {
return i.Expires != nil && i.Expires.Before(time.Now()) 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 { func (i Invite) ExpiresFriendly() string {
return i.Expires.Format("January 2, 2006, 3:04 PM") 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 { p := struct {
*UserPage *UserPage
Invites *[]Invite Invites *[]Invite
Suspended bool Silenced bool
}{ }{
UserPage: NewUserPage(app, r, u, "Invite People", f), UserPage: NewUserPage(app, r, u, "Invite People", f),
} }
var err error var err error
p.Suspended, err = app.db.IsUserSuspended(u.ID) p.Silenced, err = app.db.IsUserSilenced(u.ID)
if err != nil { if err != nil {
log.Error("view invites: %v", err) 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") expVal := r.FormValue("expires")
if u.IsSilenced() { if u.IsSilenced() {
return ErrUserSuspended return ErrUserSilenced
} }
var err error var err error
@ -109,7 +121,7 @@ func handleCreateUserInvite(app *App, u *User, w http.ResponseWriter, r *http.Re
expDate = &ed expDate = &ed
} }
inviteID := store.GenerateRandomString("0123456789BCDFGHJKLMNPQRSTVWXYZbcdfghjklmnpqrstvwxyz", 6) inviteID := id.GenerateRandomString("0123456789BCDFGHJKLMNPQRSTVWXYZbcdfghjklmnpqrstvwxyz", 6)
err = app.db.CreateUserInvite(inviteID, u.ID, maxUses, expDate) err = app.db.CreateUserInvite(inviteID, u.ID, maxUses, expDate)
if err != nil { if err != nil {
return err return err
@ -158,18 +170,23 @@ func handleViewInvite(app *App, w http.ResponseWriter, r *http.Request) error {
p := struct { p := struct {
page.StaticPage page.StaticPage
*OAuthButtons
Error string Error string
Flashes []template.HTML Flashes []template.HTML
Invite string Invite string
}{ }{
StaticPage: pageForReq(app, r), StaticPage: pageForReq(app, r),
Invite: inviteCode, OAuthButtons: NewOAuthButtons(app.cfg),
Invite: inviteCode,
} }
if expired { if expired {
p.Error = "This invite link has 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 // Get error messages
session, err := app.sessionStore.Get(r, cookieName) session, err := app.sessionStore.Get(r, cookieName)
if err != nil { if err != nil {

@ -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. * This file is part of WriteFreely.
* *
@ -12,7 +12,7 @@ package writefreely
import ( import (
"github.com/writeas/web-core/log" "github.com/writeas/web-core/log"
"github.com/writeas/writefreely/key" "github.com/writefreely/writefreely/key"
"io/ioutil" "io/ioutil"
"os" "os"
"path/filepath" "path/filepath"

@ -5,6 +5,7 @@ all :
lessc app.less --clean-css="--s1 --advanced" $(CSSDIR)write.css lessc app.less --clean-css="--s1 --advanced" $(CSSDIR)write.css
lessc fonts.less --clean-css="--s1 --advanced" $(CSSDIR)fonts.css lessc fonts.less --clean-css="--s1 --advanced" $(CSSDIR)fonts.css
lessc icons.less --clean-css="--s1 --advanced" $(CSSDIR)icons.css lessc icons.less --clean-css="--s1 --advanced" $(CSSDIR)icons.css
lessc prose.less --clean-css="--s1 --advanced" $(CSSDIR)prose.css
install : install :
./install-less.sh ./install-less.sh

@ -13,19 +13,38 @@ nav#admin {
display: block; display: block;
margin: 0.5em 0; margin: 0.5em 0;
a { a {
color: @primary; margin-left: 0;
&:first-child { .rounded(.25em);
margin-left: 0; border: 0;
}
&.selected { &.selected {
background: #dedede;
font-weight: bold; font-weight: bold;
.blip {
color: black;
}
} }
} }
.blip {
font-weight: bold;
}
} }
.pager { .pager {
display: flex; display: flex;
justify-content: center; justify-content: center;
&:not(.pages) {
display: block;
margin: 0.5em 0;
a {
margin-left: 0;
.rounded(.25em);
&+a {
margin-left: 0.5em;
}
}
}
a { a {
color: #333; color: #333;
font-family: @sansFont; 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;
}
}

@ -5,6 +5,8 @@
@import "post-temp"; @import "post-temp";
@import "effects"; @import "effects";
@import "admin"; @import "admin";
@import "login";
@import "pages/error"; @import "pages/error";
@import "resources";
@import "lib/elements"; @import "lib/elements";
@import "lib/material"; @import "lib/material";

@ -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 { body {
font-family: @serifFont; font-family: @serifFont;
font-size-adjust: 0.5; font-size-adjust: 0.5;
@ -81,7 +69,7 @@ body {
font-size: 1.5em; font-size: 1.5em;
} }
h2 { 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; margin-bottom: 1em;
p { p {
text-align: left; 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 { &.norm, &.sans, &.wrap {
line-height: 1.4em; line-height: 1.5;
white-space: pre-wrap; /* CSS 3 */ white-space: pre-wrap; /* CSS 3 */
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */ white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
white-space: -pre-wrap; /* Opera 4-6 */ 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+ */ 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 { &.norm {
font-family: @serifFont; 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 { body#collection article, body#subpage article {
padding-top: 0; padding-top: 0;
padding-bottom: 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 { div.flat-select {
display: inline-block; display: inline-block;
position: relative; position: relative;
@ -794,9 +811,6 @@ input {
&.snug { &.snug {
max-width: 40em; max-width: 40em;
} }
&.regular {
font-size: 1em;
}
.app { .app {
+ .app { + .app {
margin-top: 1.5em; margin-top: 1.5em;
@ -813,7 +827,7 @@ input {
font-weight: normal; font-weight: normal;
} }
p { p {
line-height: 1.4; line-height: 1.5;
} }
li { li {
margin: 0.3em 0; margin: 0.3em 0;
@ -868,20 +882,6 @@ input {
text-align: center; 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 { div.blurbs {
>h2 { >h2 {
text-align: center; text-align: center;
@ -965,7 +965,12 @@ footer.contain-me {
} }
ul { ul {
&.collections { &.collections {
padding-left: 0;
margin-left: 0; margin-left: 0;
h3 {
margin-top: 0;
font-weight: normal;
}
li { li {
&.collection { &.collection {
a.title { a.title {
@ -1007,7 +1012,7 @@ footer.contain-me {
} }
li { li {
line-height: 1.4; line-height: 1.5;
.item-desc, .prog-lang { .item-desc, .prog-lang {
font-size: 0.6em; font-size: 0.6em;
@ -1095,7 +1100,8 @@ body#pad-sub #posts, .atoms {
} }
.electron { .electron {
font-weight: normal; font-weight: normal;
margin-left: 0.5em; font-size: 0.86em;
margin-left: 0.75rem;
} }
} }
h3, h4 { h3, h4 {
@ -1245,7 +1251,7 @@ header {
} }
} }
&.singleuser { &.singleuser {
margin: 0.5em 0.25em; margin: 0.5em 1em 0.5em 0.25em;
nav#user-nav { nav#user-nav {
nav > ul > li:first-child { nav > ul > li:first-child {
img { img {
@ -1253,6 +1259,9 @@ header {
} }
} }
} }
.right-side {
padding-top: 0.5em;
}
} }
.dash-nav { .dash-nav {
font-weight: bold; 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) { @media all and (max-width: 450px) {
body#post { body#post {
header { header {
@ -1411,7 +1430,7 @@ div.row {
} }
@media all and (max-width: 600px) { @media all and (max-width: 600px) {
div.row { div.row:not(.admin-actions) {
flex-direction: column; flex-direction: column;
} }
.half { .half {
@ -1496,6 +1515,11 @@ div.row {
margin-left: 0; margin-left: 0;
margin-top: 0; margin-top: 0;
} }
article {
.hidden {
.opacity(1);
}
}
} }
@media print { @media print {
@ -1537,3 +1561,26 @@ div.row {
pre.code-block { pre.code-block {
overflow-x: auto; 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;
}
}

@ -2,7 +2,7 @@
# Install Less via npm # Install Less via npm
if [ ! -e "$(which lessc)" ]; then 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 sudo npm install -g less-plugin-clean-css
else else
echo LESS $(npm view less version 2>&1 | grep -v WARN) is installed echo LESS $(npm view less version 2>&1 | grep -v WARN) is installed

@ -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;
}
}

@ -1,4 +1,4 @@
@actionNavColor: #999; @actionNavColor: #767676;
body { body {
margin: 0; margin: 0;
@ -58,7 +58,7 @@ header {
} }
p { p {
&.description { &.description {
color: #666; color: #444;
font-size: 1.1em; font-size: 1.1em;
margin-top: 0.5em; margin-top: 0.5em;
line-height: 1.5; line-height: 1.5;
@ -113,7 +113,7 @@ textarea {
ul { ul {
margin: 0; margin: 0;
padding: 0 0 0 1em; padding: 0 0 0 1em;
line-height: 1.4; line-height: 1.5;
&.collections, &.posts, &.integrations { &.collections, &.posts, &.integrations {
list-style: none; list-style: none;
@ -127,7 +127,6 @@ textarea {
&.collection { &.collection {
a.title { a.title {
font-size: 1.3em; font-size: 1.3em;
font-weight: bold;
} }
} }
} }
@ -206,7 +205,7 @@ code, textarea#embed {
font-weight: normal; font-weight: normal;
} }
p { p {
line-height: 1.4; line-height: 1.5;
} }
li { li {
margin: 0.3em 0; margin: 0.3em 0;

@ -188,18 +188,18 @@ body#pad, body#pad-sub {
body#pad { body#pad {
.pad-theme-transition; .pad-theme-transition;
textarea { textarea, #title {
.pad-theme-transition; .pad-theme-transition;
} }
&.dark { &.dark {
textarea { textarea, #title, #editor {
background-color: @darkBG; background-color: @darkBG;
color: @darkTextColor; color: @darkTextColor;
} }
} }
&.light { &.light {
textarea { textarea, #title, #editor {
background-color: @lightBG; background-color: @lightBG;
color: @lightTextColor; color: @lightTextColor;
} }

@ -60,7 +60,7 @@
&:hover { &:hover {
background: @lightNavHoverBG; background: @lightNavHoverBG;
} }
&:hover > ul { &:hover > ul, &.open > ul {
display: block; display: block;
} }
&.selected { &.selected {
@ -256,7 +256,7 @@ body#pad {
border: 0; border: 0;
outline: 0; outline: 0;
} }
textarea { textarea, #title {
position: fixed !important; position: fixed !important;
top: 3em; top: 3em;
right: 0; right: 0;
@ -361,12 +361,38 @@ body#pad {
z-index: 10; 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) { @media all and (max-height: 500px) {
body#pad { body#pad {
textarea { textarea {
top: 2.25em; top: 2.25em;
padding-top: 0.25em; padding-top: 0.25em;
} }
&.classic {
#editor {
top: 5.25em;
}
#title {
top: 3.5rem;
}
}
#tools { #tools {
padding-top: 0.5em; padding-top: 0.5em;
padding-bottom: 0.5em; padding-bottom: 0.5em;
@ -420,43 +446,63 @@ body#pad {
} }
@media all and (min-width: 50em) { @media all and (min-width: 50em) {
body#pad { body#pad, body#pad.classic {
textarea { textarea, #title {
padding-left: 10%; padding-left: 10%;
padding-right: 10%; padding-right: 10%;
} }
.alert {
left: 10%;
right: 10%;
}
} }
} }
@media all and (min-width: 60em) { @media all and (min-width: 60em) {
body#pad { body#pad, body#pad.classic {
textarea { textarea, #title {
padding-left: 15%; padding-left: 15%;
padding-right: 15%; padding-right: 15%;
} }
.alert {
left: 15%;
right: 15%;
}
} }
} }
@media all and (min-width: 70em) { @media all and (min-width: 70em) {
body#pad { body#pad, body#pad.classic {
textarea { textarea, #title {
padding-left: 20%; padding-left: 20%;
padding-right: 20%; padding-right: 20%;
} }
.alert {
left: 20%;
right: 20%;
}
} }
} }
@media all and (min-width: 85em) { @media all and (min-width: 85em) {
body#pad { body#pad, body#pad.classic {
textarea { textarea, #title {
padding-left: 25%; padding-left: 25%;
padding-right: 25%; padding-right: 25%;
} }
.alert {
left: 25%;
right: 25%;
}
} }
} }
@media all and (min-width: 105em) { @media all and (min-width: 105em) {
body#pad { body#pad, body#pad.classic {
textarea { textarea, #title {
padding-left: 30%; padding-left: 30%;
padding-right: 30%; padding-right: 30%;
} }
.alert {
left: 30%;
right: 30%;
}
} }
} }
@media (pointer: coarse) { @media (pointer: coarse) {

@ -49,7 +49,7 @@ body#post article, pre, .hljs {
border-left: 4px solid #ddd; border-left: 4px solid #ddd;
padding: 0 1em; padding: 0 1em;
margin: 0.5em; margin: 0.5em;
color: #777; color: #767676;
display: inline-block; display: inline-block;
p { p {
@ -58,7 +58,7 @@ body#post article, pre, .hljs {
} }
} }
.article-p() { .article-p() {
line-height: 1.4em; line-height: 1.5;
white-space: pre-wrap; /* CSS 3 */ white-space: pre-wrap; /* CSS 3 */
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */ white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
white-space: -pre-wrap; /* Opera 4-6 */ white-space: -pre-wrap; /* Opera 4-6 */

@ -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%;
}
}

@ -0,0 +1,4 @@
@import "prose-editor";
@import "pad-theme";
@import "resources";
@import "lib/elements";

@ -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;

@ -78,3 +78,10 @@ func (db *datastore) engine() string {
} }
return " ENGINE = InnoDB" return " ENGINE = InnoDB"
} }
func (db *datastore) after(colName string) string {
if db.driverName == driverSQLite {
return ""
}
return " AFTER " + colName
}

@ -61,7 +61,11 @@ var migrations = []Migration{
New("support users suspension", supportUserStatus), // V2 -> V3 (v0.11.0) New("support users suspension", supportUserStatus), // V2 -> V3 (v0.11.0)
New("support oauth", oauth), // V3 -> V4 New("support oauth", oauth), // V3 -> V4
New("support slack oauth", oauthSlack), // V4 -> v5 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 // CurrentVer returns the current migration version the application is on

@ -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
}

@ -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 package migrations
import ( import (
"context" "context"
"database/sql" "database/sql"
wf_db "github.com/writeas/writefreely/db" wf_db "github.com/writefreely/writefreely/db"
) )
func oauth(db *datastore) error { 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 { return wf_db.RunTransactionWithOptions(context.Background(), db.DB, &sql.TxOptions{}, func(ctx context.Context, tx *sql.Tx) error {
createTableUsersOauth, err := dialect. createTableUsersOauth, err := dialect.
Table("oauth_users"). Table("oauth_users").
SetIfNotExists(true). SetIfNotExists(false).
Column(dialect.Column("user_id", wf_db.ColumnTypeInteger, wf_db.UnsetSize)). Column(dialect.Column("user_id", wf_db.ColumnTypeInteger, wf_db.UnsetSize)).
Column(dialect.Column("remote_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() ToSQL()
if err != nil { if err != nil {
return err return err
} }
createTableOauthClientState, err := dialect. createTableOauthClientState, err := dialect.
Table("oauth_client_states"). 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("state", wf_db.ColumnTypeVarChar, wf_db.OptionalInt{Set: true, Value: 255})).
Column(dialect.Column("used", wf_db.ColumnTypeBool, wf_db.UnsetSize)). 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"). UniqueConstraint("state").
ToSQL() ToSQL()
if err != nil { if err != nil {

@ -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 package migrations
import ( import (
"context" "context"
"database/sql" "database/sql"
wf_db "github.com/writeas/writefreely/db" wf_db "github.com/writefreely/writefreely/db"
) )
func oauthSlack(db *datastore) error { func oauthSlack(db *datastore) error {
@ -20,39 +30,50 @@ func oauthSlack(db *datastore) error {
Column( Column(
"provider", "provider",
wf_db.ColumnTypeVarChar, 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. AddColumn(dialect.
Column( Column(
"client_id", "client_id",
wf_db.ColumnTypeVarChar, wf_db.ColumnTypeVarChar,
wf_db.OptionalInt{Set: true, Value: 128,})), wf_db.OptionalInt{Set: true, Value: 128}).SetDefault("")),
dialect. dialect.
AlterTable("oauth_users"). AlterTable("oauth_users").
ChangeColumn("remote_user_id",
dialect.
Column(
"remote_user_id",
wf_db.ColumnTypeVarChar,
wf_db.OptionalInt{Set: true, Value: 128,})).
AddColumn(dialect. AddColumn(dialect.
Column( Column(
"provider", "provider",
wf_db.ColumnTypeVarChar, wf_db.ColumnTypeVarChar,
wf_db.OptionalInt{Set: true, Value: 24,})). wf_db.OptionalInt{Set: true, Value: 24}).SetDefault("")),
dialect.
AlterTable("oauth_users").
AddColumn(dialect. AddColumn(dialect.
Column( Column(
"client_id", "client_id",
wf_db.ColumnTypeVarChar, wf_db.ColumnTypeVarChar,
wf_db.OptionalInt{Set: true, Value: 128,})). wf_db.OptionalInt{Set: true, Value: 128}).SetDefault("")),
dialect.
AlterTable("oauth_users").
AddColumn(dialect. AddColumn(dialect.
Column( Column(
"access_token", "access_token",
wf_db.ColumnTypeVarChar, wf_db.ColumnTypeVarChar,
wf_db.OptionalInt{Set: true, Value: 512,})), wf_db.OptionalInt{Set: true, Value: 512}).SetDefault("")),
dialect.DropIndex("remote_user_id", "oauth_users"), dialect.CreateUniqueIndex("oauth_users_uk", "oauth_users", "user_id", "provider", "client_id"),
dialect.DropIndex("user_id", "oauth_users"), }
dialect.CreateUniqueIndex("oauth_users", "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})))
} }
for _, builder := range builders { for _, builder := range builders {
query, err := builder.ToSQL() query, err := builder.ToSQL()
if err != nil { if err != nil {

@ -1,5 +1,5 @@
/* /*
* Copyright © 2019 A Bunch Tell LLC. * Copyright © 2019-2020 A Bunch Tell LLC.
* *
* This file is part of WriteFreely. * This file is part of WriteFreely.
* *
@ -13,7 +13,7 @@ package migrations
func supportActivityPubMentions(db *datastore) error { func supportActivityPubMentions(db *datastore) error {
t, err := db.Begin() 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 { if err != nil {
t.Rollback() t.Rollback()
return err return err

@ -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
})
}

@ -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
})
}

@ -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
}

@ -1,5 +1,5 @@
/* /*
* Copyright © 2018 A Bunch Tell LLC. * Copyright © 2018-2019, 2021 A Bunch Tell LLC.
* *
* This file is part of WriteFreely. * This file is part of WriteFreely.
* *
@ -12,8 +12,8 @@ package writefreely
import ( import (
"github.com/writeas/web-core/log" "github.com/writeas/web-core/log"
"github.com/writeas/writefreely/config"
"github.com/writefreely/go-nodeinfo" "github.com/writefreely/go-nodeinfo"
"github.com/writefreely/writefreely/config"
"strings" "strings"
) )
@ -45,7 +45,7 @@ func nodeInfoConfig(db *datastore, cfg *config.Config) *nodeinfo.Config {
Private: cfg.App.Private, Private: cfg.App.Private,
Software: nodeinfo.SoftwareMeta{ Software: nodeinfo.SoftwareMeta{
HomePage: softwareURL, HomePage: softwareURL,
GitHub: "https://github.com/writeas/writefreely", GitHub: "https://github.com/writefreely/writefreely",
Follow: "https://writing.exchange/@write_as", Follow: "https://writing.exchange/@write_as",
}, },
MaxBlogs: cfg.App.MaxBlogs, MaxBlogs: cfg.App.MaxBlogs,

@ -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 package writefreely
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "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"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"time" "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 // TokenResponse contains data returned when a token is created either
// through a code exchange or using a refresh token. // through a code exchange or using a refresh token.
type TokenResponse struct { type TokenResponse struct {
@ -59,8 +96,8 @@ type OAuthDatastoreProvider interface {
type OAuthDatastore interface { type OAuthDatastore interface {
GetIDForRemoteUser(context.Context, string, string, string) (int64, error) GetIDForRemoteUser(context.Context, string, string, string) (int64, error)
RecordRemoteUserID(context.Context, int64, string, string, string, string) error RecordRemoteUserID(context.Context, int64, string, string, string, string) error
ValidateOAuthState(context.Context, string) (string, string, error) ValidateOAuthState(context.Context, string) (string, string, int64, string, error)
GenerateOAuthState(context.Context, string, string) (string, error) GenerateOAuthState(context.Context, string, string, int64, string) (string, error)
CreateUser(*config.Config, *User, string) error CreateUser(*config.Config, *User, string) error
GetUserByID(int64) (*User, 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 { func (h oauthHandler) viewOauthInit(app *App, w http.ResponseWriter, r *http.Request) error {
ctx := r.Context() 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 { if err != nil {
log.Error("viewOauthInit error: %s", err)
return impart.HTTPError{http.StatusInternalServerError, "could not prepare oauth redirect url"} return impart.HTTPError{http.StatusInternalServerError, "could not prepare oauth redirect url"}
} }
if h.callbackProxy != nil { if h.callbackProxy != nil {
if err := h.callbackProxy.register(ctx, state); err != 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"} return impart.HTTPError{http.StatusInternalServerError, "could not register state server"}
} }
} }
location, err := h.oauthClient.buildLoginURL(state) location, err := h.oauthClient.buildLoginURL(state)
if err != nil { if err != nil {
log.Error("viewOauthInit error: %s", err)
return impart.HTTPError{http.StatusInternalServerError, "could not prepare oauth redirect url"} return impart.HTTPError{http.StatusInternalServerError, "could not prepare oauth redirect url"}
} }
return impart.HTTPError{http.StatusTemporaryRedirect, location} 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", callbackLocation: app.Config().App.Host + "/oauth/callback/write.as",
httpClient: config.DefaultHTTPClient(), httpClient: config.DefaultHTTPClient(),
} }
callbackLocation = app.Config().SlackOauth.CallbackProxy callbackLocation = app.Config().WriteAsOauth.CallbackProxy
} }
oauthClient := writeAsOauthClient{ 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) { func configureOauthRoutes(parentHandler *Handler, r *mux.Router, app *App, oauthClient oauthClient, callbackProxy *callbackProxyClient) {
handler := &oauthHandler{ handler := &oauthHandler{
Config: app.Config(), Config: app.Config(),
@ -185,7 +322,7 @@ func (h oauthHandler) viewOauthCallback(app *App, w http.ResponseWriter, r *http
code := r.FormValue("code") code := r.FormValue("code")
state := r.FormValue("state") 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 { if err != nil {
log.Error("Unable to ValidateOAuthState: %s", err) log.Error("Unable to ValidateOAuthState: %s", err)
return impart.HTTPError{http.StatusInternalServerError, err.Error()} 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) tokenResponse, err := h.oauthClient.exchangeOauthCode(ctx, code)
if err != nil { if err != nil {
log.Error("Unable to exchangeOauthCode: %s", err) 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()} 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. // it really really works.
tokenInfo, err := h.oauthClient.inspectOauthAccessToken(ctx, tokenResponse.AccessToken) tokenInfo, err := h.oauthClient.inspectOauthAccessToken(ctx, tokenResponse.AccessToken)
if err != nil { 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()} 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 { if localUserID != -1 {
// Existing user, so log in now
user, err := h.DB.GetUserByID(localUserID) user, err := h.DB.GetUserByID(localUserID)
if err != nil { if err != nil {
log.Error("Unable to GetUserByID %d: %s", localUserID, err) 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 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 displayName := tokenInfo.DisplayName
if len(displayName) == 0 { if len(displayName) == 0 {
@ -237,6 +412,7 @@ func (h oauthHandler) viewOauthCallback(app *App, w http.ResponseWriter, r *http
TokenRemoteUser: tokenInfo.UserID, TokenRemoteUser: tokenInfo.UserID,
Provider: provider, Provider: provider,
ClientID: clientID, ClientID: clientID,
InviteCode: inviteCode,
} }
tp.TokenHash = tp.HashTokenParams(h.Config.Server.HashSeed) 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 { if err != nil {
return err return err
} }
req.Header.Set("User-Agent", "writefreely") req.Header.Set("User-Agent", ServerUserAgent(""))
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

@ -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
}

@ -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
}

@ -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
}

@ -1,5 +1,5 @@
/* /*
* Copyright © 2020 A Bunch Tell LLC. * Copyright © 2020-2021 A Bunch Tell LLC.
* *
* This file is part of WriteFreely. * This file is part of WriteFreely.
* *
@ -17,7 +17,7 @@ import (
"github.com/writeas/impart" "github.com/writeas/impart"
"github.com/writeas/web-core/auth" "github.com/writeas/web-core/auth"
"github.com/writeas/web-core/log" "github.com/writeas/web-core/log"
"github.com/writeas/writefreely/page" "github.com/writefreely/writefreely/page"
"html/template" "html/template"
"net/http" "net/http"
"strings" "strings"
@ -38,6 +38,7 @@ type viewOauthSignupVars struct {
Provider string Provider string
ClientID string ClientID string
TokenHash string TokenHash string
InviteCode string
LoginUsername string LoginUsername string
Alias string // TODO: rename this to match the data it represents: the collection title Alias string // TODO: rename this to match the data it represents: the collection title
@ -57,6 +58,7 @@ const (
oauthParamAlias = "alias" oauthParamAlias = "alias"
oauthParamEmail = "email" oauthParamEmail = "email"
oauthParamPassword = "password" oauthParamPassword = "password"
oauthParamInviteCode = "invite_code"
) )
type oauthSignupPageParams struct { type oauthSignupPageParams struct {
@ -68,6 +70,7 @@ type oauthSignupPageParams struct {
ClientID string ClientID string
Provider string Provider string
TokenHash string TokenHash string
InviteCode string
} }
func (p oauthSignupPageParams) HashTokenParams(key string) 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), TokenRemoteUser: r.FormValue(oauthParamTokenRemoteUserID),
ClientID: r.FormValue(oauthParamClientID), ClientID: r.FormValue(oauthParamClientID),
Provider: r.FormValue(oauthParamProvider), Provider: r.FormValue(oauthParamProvider),
InviteCode: r.FormValue(oauthParamInviteCode),
} }
if tp.HashTokenParams(h.Config.Server.HashSeed) != r.FormValue(oauthParamHash) { if tp.HashTokenParams(h.Config.Server.HashSeed) != r.FormValue(oauthParamHash) {
return impart.HTTPError{Status: http.StatusBadRequest, Message: "Request has been tampered with."} 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) 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)) err = h.DB.RecordRemoteUserID(r.Context(), newUser.ID, r.FormValue(oauthParamTokenRemoteUserID), r.FormValue(oauthParamProvider), r.FormValue(oauthParamClientID), r.FormValue(oauthParamAccessToken))
if err != nil { if err != nil {
return h.showOauthSignupPage(app, w, r, tp, err) 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, Provider: tp.Provider,
ClientID: tp.ClientID, ClientID: tp.ClientID,
TokenHash: tp.TokenHash, TokenHash: tp.TokenHash,
InviteCode: tp.InviteCode,
LoginUsername: username, LoginUsername: username,
Alias: collTitle, Alias: collTitle,

@ -13,8 +13,6 @@ package writefreely
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"github.com/writeas/nerds/store"
"github.com/writeas/slug" "github.com/writeas/slug"
"net/http" "net/http"
"net/url" "net/url"
@ -113,7 +111,7 @@ func (c slackOauthClient) exchangeOauthCode(ctx context.Context, code string) (*
return nil, err return nil, err
} }
req.WithContext(ctx) req.WithContext(ctx)
req.Header.Set("User-Agent", "writefreely") req.Header.Set("User-Agent", ServerUserAgent(""))
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(c.ClientID, c.ClientSecret) req.SetBasicAuth(c.ClientID, c.ClientSecret)
@ -142,7 +140,7 @@ func (c slackOauthClient) inspectOauthAccessToken(ctx context.Context, accessTok
return nil, err return nil, err
} }
req.WithContext(ctx) req.WithContext(ctx)
req.Header.Set("User-Agent", "writefreely") req.Header.Set("User-Agent", ServerUserAgent(""))
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Authorization", "Bearer "+accessToken)
@ -167,7 +165,7 @@ func (c slackOauthClient) inspectOauthAccessToken(ctx context.Context, accessTok
func (resp slackUserIdentityResponse) InspectResponse() *InspectResponse { func (resp slackUserIdentityResponse) InspectResponse() *InspectResponse {
return &InspectResponse{ return &InspectResponse{
UserID: resp.User.ID, 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, DisplayName: resp.User.Name,
Email: resp.User.Email, Email: resp.User.Email,
} }

@ -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 package writefreely
import ( import (
@ -6,8 +16,8 @@ import (
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/writeas/impart" "github.com/writeas/impart"
"github.com/writeas/nerds/store" "github.com/writeas/web-core/id"
"github.com/writeas/writefreely/config" "github.com/writefreely/writefreely/config"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
@ -22,8 +32,8 @@ type MockOAuthDatastoreProvider struct {
} }
type MockOAuthDatastore struct { type MockOAuthDatastore struct {
DoGenerateOAuthState func(context.Context, string, string) (string, error) DoGenerateOAuthState func(context.Context, string, string, int64, string) (string, error)
DoValidateOAuthState func(context.Context, string) (string, string, error) DoValidateOAuthState func(context.Context, string) (string, string, int64, string, error)
DoGetIDForRemoteUser func(context.Context, string, string, string) (int64, error) DoGetIDForRemoteUser func(context.Context, string, string, string) (int64, error)
DoCreateUser func(*config.Config, *User, string) error DoCreateUser func(*config.Config, *User, string) error
DoRecordRemoteUserID func(context.Context, int64, string, string, string, string) error DoRecordRemoteUserID func(context.Context, int64, string, string, string, string) error
@ -86,11 +96,11 @@ func (m *MockOAuthDatastoreProvider) Config() *config.Config {
return cfg 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 { if m.DoValidateOAuthState != nil {
return m.DoValidateOAuthState(ctx, state) return m.DoValidateOAuthState(ctx, state)
} }
return "", "", nil return "", "", 0, "", nil
} }
func (m *MockOAuthDatastore) GetIDForRemoteUser(ctx context.Context, remoteUserID, provider, clientID string) (int64, error) { 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 { if m.DoGetUserByID != nil {
return m.DoGetUserByID(userID) return m.DoGetUserByID(userID)
} }
user := &User{ user := &User{}
}
return user, nil 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 { 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) { func TestViewOauthInit(t *testing.T) {
@ -173,7 +181,7 @@ func TestViewOauthInit(t *testing.T) {
app := &MockOAuthDatastoreProvider{ app := &MockOAuthDatastoreProvider{
DoDB: func() OAuthDatastore { DoDB: func() OAuthDatastore {
return &MockOAuthDatastore{ 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") 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) req, err := http.NewRequest("GET", "/oauth/callback", nil)
assert.NoError(t, err) assert.NoError(t, err)
rr := httptest.NewRecorder() 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.NoError(t, err)
assert.Equal(t, http.StatusTemporaryRedirect, rr.Code) assert.Equal(t, http.StatusTemporaryRedirect, rr.Code)
}) })

@ -62,7 +62,7 @@ func (c writeAsOauthClient) exchangeOauthCode(ctx context.Context, code string)
return nil, err return nil, err
} }
req.WithContext(ctx) req.WithContext(ctx)
req.Header.Set("User-Agent", "writefreely") req.Header.Set("User-Agent", ServerUserAgent(""))
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(c.ClientID, c.ClientSecret) req.SetBasicAuth(c.ClientID, c.ClientSecret)
@ -91,7 +91,7 @@ func (c writeAsOauthClient) inspectOauthAccessToken(ctx context.Context, accessT
return nil, err return nil, err
} }
req.WithContext(ctx) req.WithContext(ctx)
req.Header.Set("User-Agent", "writefreely") req.Header.Set("User-Agent", ServerUserAgent(""))
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Authorization", "Bearer "+accessToken)

@ -1,5 +1,5 @@
/* /*
* Copyright © 2018-2019 A Bunch Tell LLC. * Copyright © 2018-2021 A Bunch Tell LLC.
* *
* This file is part of WriteFreely. * This file is part of WriteFreely.
* *
@ -17,7 +17,7 @@ import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/writeas/impart" "github.com/writeas/impart"
"github.com/writeas/web-core/log" "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 { 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 { appData := &struct {
page.StaticPage page.StaticPage
Post *RawPost Post *RawPost
User *User User *User
Blogs *[]Collection Blogs *[]Collection
Suspended bool Silenced bool
Editing bool // True if we're modifying an existing post Editing bool // True if we're modifying an existing post
EditCollection *Collection // Collection of the post we're editing, if any 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 { if err != nil {
log.Error("Unable to get user's blogs for Pad: %v", err) 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 { 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 EditCollection *Collection // Collection of the post we're editing, if any
Flashes []string Flashes []string
NeedsToken bool NeedsToken bool
Suspended bool Silenced bool
}{ }{
StaticPage: pageForReq(app, r), StaticPage: pageForReq(app, r),
Post: &RawPost{Font: "norm"}, Post: &RawPost{Font: "norm"},
User: getUserSession(app, r), User: getUserSession(app, r),
} }
var err error var err error
appData.Suspended, err = app.db.IsUserSuspended(appData.User.ID) appData.Silenced, err = app.db.IsUserSilenced(appData.User.ID)
if err != nil { if err != nil {
log.Error("view meta: get user suspended status: %v", err) log.Error("view meta: get user status: %v", err)
return ErrInternalGeneral return ErrInternalGeneral
} }

@ -1,5 +1,5 @@
/* /*
* Copyright © 2018 A Bunch Tell LLC. * Copyright © 2018-2019, 2021 A Bunch Tell LLC.
* *
* This file is part of WriteFreely. * This file is part of WriteFreely.
* *
@ -12,7 +12,7 @@
package page package page
import ( import (
"github.com/writeas/writefreely/config" "github.com/writefreely/writefreely/config"
"strings" "strings"
) )

@ -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. * This file is part of WriteFreely.
* *
@ -12,7 +12,7 @@ package writefreely
import ( import (
"database/sql" "database/sql"
"github.com/writeas/writefreely/config" "github.com/writefreely/writefreely/config"
"time" "time"
) )

@ -2,7 +2,7 @@
{{define "content"}} {{define "content"}}
<div class="content-container tight"> <div class="content-container tight">
<h1>Server error &#x1F635;</h1> <h1>Server error &#x1F635;</h1>
<p>Please <a href="https://github.com/writeas/writefreely/issues/new">contact the human authors</a> of this software and remind them of their many shortcomings.</p> <p>Please <a href="https://github.com/writefreely/writefreely/issues/new">contact the human authors</a> of this software and remind them of their many shortcomings.</p>
<p>Be gentle, though. They are fragile mortal beings.</p> <p>Be gentle, though. They are fragile mortal beings.</p>
<p style="margin-top:2em">Also, unlike the AI that will soon replace them, you will need to include an error log from the server in your report. (Utterly <em>primitive</em>, we know.)</p> <p style="margin-top:2em">Also, unlike the AI that will soon replace them, you will need to include an error log from the server in your report. (Utterly <em>primitive</em>, we know.)</p>
<p>&ndash; {{.SiteName}} &#x1F916;</p> <p>&ndash; {{.SiteName}} &#x1F916;</p>

@ -0,0 +1,7 @@
{{define "head"}}<title>Temporarily Unavailable &mdash; {{.SiteMetaName}}</title>{{end}}
{{define "content"}}
<div class="error-page">
<p class="msg">The words aren't coming to me. &#x1F5C5;</p>
<p>We couldn't serve this page due to high server load. This should only be temporary.</p>
</div>
{{end}}

@ -60,6 +60,9 @@ form dd {
margin-top: 0; margin-top: 0;
max-width: 8em; max-width: 8em;
} }
.or {
margin-bottom: 2.5em !important;
}
</style> </style>
{{end}} {{end}}
{{define "content"}} {{define "content"}}
@ -73,6 +76,8 @@ form dd {
<div{{if not .OpenRegistration}} style="padding: 2em 0;"{{end}}> <div{{if not .OpenRegistration}} style="padding: 2em 0;"{{end}}>
{{ if .OpenRegistration }} {{ if .OpenRegistration }}
{{template "oauth-buttons" .}}
{{if not .DisablePasswordAuth}}
{{if .Flashes}}<ul class="errors"> {{if .Flashes}}<ul class="errors">
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}} {{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
</ul>{{end}} </ul>{{end}}
@ -101,6 +106,7 @@ form dd {
</dl> </dl>
</form> </form>
</div> </div>
{{end}}
{{ else }} {{ else }}
<p style="font-size: 1.3em; margin: 1rem 0;">Registration is currently closed.</p> <p style="font-size: 1.3em; margin: 1rem 0;">Registration is currently closed.</p>
<p>You can always sign up on <a href="https://writefreely.org/instances">another instance</a>.</p> <p>You can always sign up on <a href="https://writefreely.org/instances">another instance</a>.</p>

@ -3,35 +3,6 @@
<meta itemprop="description" content="Log in to {{.SiteName}}."> <meta itemprop="description" content="Log in to {{.SiteName}}.">
<style> <style>
input{margin-bottom:0.5em;} input{margin-bottom:0.5em;}
.or {
text-align: center;
margin-bottom: 3.5em;
}
.or p {
display: inline-block;
background-color: white;
padding: 0 1em;
}
.or hr {
margin-top: -1.6em;
margin-bottom: 0;
}
hr.short {
max-width: 30rem;
}
.row.signinbtns {
justify-content: space-evenly;
font-size: 1em;
margin-top: 3em;
margin-bottom: 2em;
}
.loginbtn {
height: 40px;
}
#writeas-login {
box-sizing: border-box;
font-size: 17px;
}
</style> </style>
{{end}} {{end}}
{{define "content"}} {{define "content"}}
@ -42,22 +13,9 @@ hr.short {
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}} {{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
</ul>{{end}} </ul>{{end}}
{{ if or .OauthSlack .OauthWriteAs }} {{template "oauth-buttons" .}}
<div class="row content-container signinbtns">
{{ if .OauthSlack }}
<a class="loginbtn" href="/oauth/slack"><img alt="Sign in with Slack" height="40" width="172" src="/img/sign_in_with_slack.png" srcset="/img/sign_in_with_slack.png 1x, /img/sign_in_with_slack@2x.png 2x" /></a>
{{ end }}
{{ if .OauthWriteAs }}
<a class="btn cta loginbtn" id="writeas-login" href="/oauth/write.as">Sign in with <strong>Write.as</strong></a>
{{ end }}
</div>
<div class="or">
<p>or</p>
<hr class="short" />
</div>
{{ end }}
{{if not .DisablePasswordAuth}}
<form action="/auth/login" method="post" style="text-align: center;margin-top:1em;" onsubmit="disableSubmit()"> <form action="/auth/login" method="post" style="text-align: center;margin-top:1em;" onsubmit="disableSubmit()">
<input type="text" name="alias" placeholder="Username" value="{{.LoginUsername}}" {{if not .LoginUsername}}autofocus{{end}} /><br /> <input type="text" name="alias" placeholder="Username" value="{{.LoginUsername}}" {{if not .LoginUsername}}autofocus{{end}} /><br />
<input type="password" name="pass" placeholder="Password" {{if .LoginUsername}}autofocus{{end}} /><br /> <input type="password" name="pass" placeholder="Password" {{if .LoginUsername}}autofocus{{end}} /><br />
@ -65,13 +23,14 @@ hr.short {
<input type="submit" id="btn-login" value="Login" /> <input type="submit" id="btn-login" value="Login" />
</form> </form>
{{if and (not .SingleUser) .OpenRegistration}}<p style="text-align:center;font-size:0.9em;margin:3em auto;max-width:26em;">{{if .Message}}{{.Message}}{{else}}<em>No account yet?</em> <a href="/">Sign up</a> to start a blog.{{end}}</p>{{end}} {{if and (not .SingleUser) .OpenRegistration}}<p style="text-align:center;font-size:0.9em;margin:3em auto;max-width:26em;">{{if .Message}}{{.Message}}{{else}}<em>No account yet?</em> <a href="{{.SignupPath}}">Sign up</a> to start a blog.{{end}}</p>{{end}}
<script type="text/javascript"> <script type="text/javascript">
function disableSubmit() { function disableSubmit() {
var $btn = document.getElementById("btn-login"); var $btn = document.getElementById("btn-login");
$btn.value = "Logging in..."; $btn.value = "Logging in...";
$btn.disabled = true; $btn.disabled = true;
} }
</script> </script>
{{end}}
{{end}} {{end}}

@ -1,6 +1,4 @@
{{define "head"}}<title>Log in &mdash; {{.SiteName}}</title> {{define "head"}}<title>Finish Creating Account &mdash; {{.SiteName}}</title>
<meta name="description" content="Log in to {{.SiteName}}.">
<meta itemprop="description" content="Log in to {{.SiteName}}.">
<style>input{margin-bottom:0.5em;}</style> <style>input{margin-bottom:0.5em;}</style>
<style type="text/css"> <style type="text/css">
h2 { h2 {
@ -58,7 +56,7 @@ form dd {
{{end}} {{end}}
{{define "content"}} {{define "content"}}
<div id="pricing" class="tight content-container"> <div id="pricing" class="tight content-container">
<h1>Log in to {{.SiteName}}</h1> <h1>Finish creating account</h1>
{{if .Flashes}}<ul class="errors"> {{if .Flashes}}<ul class="errors">
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}} {{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
@ -74,6 +72,7 @@ form dd {
<input type="hidden" name="provider" value="{{ .Provider }}" /> <input type="hidden" name="provider" value="{{ .Provider }}" />
<input type="hidden" name="client_id" value="{{ .ClientID }}" /> <input type="hidden" name="client_id" value="{{ .ClientID }}" />
<input type="hidden" name="signature" value="{{ .TokenHash }}" /> <input type="hidden" name="signature" value="{{ .TokenHash }}" />
{{if .InviteCode}}<input type="hidden" name="invite_code" value="{{ .InviteCode }}" />{{end}}
<dl class="billing"> <dl class="billing">
<label> <label>
@ -96,7 +95,7 @@ form dd {
</dd> </dd>
</label> </label>
<dt> <dt>
<input type="submit" id="btn-login" value="Login" /> <input type="submit" id="btn-login" value="Next" />
</dt> </dt>
</dl> </dl>
</form> </form>
@ -129,7 +128,7 @@ var $aliasSite = document.getElementById('alias-site');
var aliasOK = true; var aliasOK = true;
var typingTimer; var typingTimer;
var doneTypingInterval = 750; var doneTypingInterval = 750;
var doneTyping = function() { var doneTyping = function(genID) {
// Check on username // Check on username
var alias = $alias.el.value; var alias = $alias.el.value;
if (alias != "") { if (alias != "") {
@ -152,6 +151,11 @@ var doneTyping = function() {
$aliasSite.className = $aliasSite.className.replace(/(?:^|\s)error(?!\S)/g, ''); $aliasSite.className = $aliasSite.className.replace(/(?:^|\s)error(?!\S)/g, '');
$aliasSite.innerHTML = '{{ if .Federation }}@<strong>' + data.data + '</strong>@{{.FriendlyHost}}{{ else }}{{.FriendlyHost}}/<strong>' + data.data + '</strong>/{{ end }}'; $aliasSite.innerHTML = '{{ if .Federation }}@<strong>' + data.data + '</strong>@{{.FriendlyHost}}{{ else }}{{.FriendlyHost}}/<strong>' + data.data + '</strong>/{{ end }}';
} else { } else {
if (genID === true) {
$alias.el.value = alias + "-" + randStr(4);
doneTyping();
return;
}
aliasOK = false; aliasOK = false;
$alias.setClass('error'); $alias.setClass('error');
$aliasSite.className = 'error'; $aliasSite.className = 'error';
@ -169,6 +173,14 @@ $alias.on('keyup input', function() {
clearTimeout(typingTimer); clearTimeout(typingTimer);
typingTimer = setTimeout(doneTyping, doneTypingInterval); typingTimer = setTimeout(doneTyping, doneTypingInterval);
}); });
doneTyping(); function randStr(len) {
var res = '';
var chars = '23456789bcdfghjklmnpqrstvwxyz';
for (var i=0; i<len; i++) {
res += chars.charAt(Math.floor(Math.random() * chars.length));
}
return res;
}
doneTyping(true);
</script> </script>
{{end}} {{end}}

@ -70,6 +70,9 @@ form dd {
</ul>{{end}} </ul>{{end}}
<div id="billing"> <div id="billing">
{{template "oauth-buttons" .}}
{{if not .DisablePasswordAuth}}
<form action="/auth/signup" method="POST" id="signup-form" onsubmit="return signup()"> <form action="/auth/signup" method="POST" id="signup-form" onsubmit="return signup()">
<input type="hidden" name="invite_code" value="{{.Invite}}" /> <input type="hidden" name="invite_code" value="{{.Invite}}" />
<dl class="billing"> <dl class="billing">
@ -93,6 +96,7 @@ form dd {
</dt> </dt>
</dl> </dl>
</form> </form>
{{end}}
</div> </div>
{{ end }} {{ end }}
</div> </div>

@ -1,5 +1,5 @@
/* /*
* Copyright © 2018 A Bunch Tell LLC. * Copyright © 2018-2020 A Bunch Tell LLC.
* *
* This file is part of WriteFreely. * This file is part of WriteFreely.
* *
@ -57,6 +57,11 @@ func PostLede(t string, includePunc bool) string {
c := []rune(t) c := []rune(t)
t = string(c[:punc+iAdj]) t = string(c[:punc+iAdj])
} }
punc = stringmanip.IndexRune(t, '?')
if punc > -1 {
c := []rune(t)
t = string(c[:punc+iAdj])
}
return t return t
} }

@ -1,5 +1,5 @@
/* /*
* Copyright © 2018-2020 A Bunch Tell LLC. * Copyright © 2018-2021 A Bunch Tell LLC.
* *
* This file is part of WriteFreely. * This file is part of WriteFreely.
* *
@ -16,6 +16,7 @@ import (
"html" "html"
"html/template" "html/template"
"net/http" "net/http"
"net/url"
"regexp" "regexp"
"strings" "strings"
"unicode" "unicode"
@ -27,8 +28,8 @@ import (
blackfriday "github.com/writeas/saturday" blackfriday "github.com/writeas/saturday"
"github.com/writeas/web-core/log" "github.com/writeas/web-core/log"
"github.com/writeas/web-core/stringmanip" "github.com/writeas/web-core/stringmanip"
"github.com/writeas/writefreely/config" "github.com/writefreely/writefreely/config"
"github.com/writeas/writefreely/parse" "github.com/writefreely/writefreely/parse"
) )
var ( var (
@ -58,10 +59,48 @@ func (p *PublicPost) formatContent(cfg *config.Config, isOwner bool) {
p.Post.formatContent(cfg, &p.Collection.Collection, isOwner) p.Post.formatContent(cfg, &p.Collection.Collection, isOwner)
} }
func (p *Post) augmentContent(c *Collection) {
if p.PinnedPosition.Valid {
// Don't augment posts that are pinned
return
}
if strings.Index(p.Content, "<!--nosig-->") > -1 {
// Don't augment posts with the special "nosig" shortcode
return
}
// Add post signatures
if c.Signature != "" {
p.Content += "\n\n" + c.Signature
}
}
func (p *PublicPost) augmentContent() {
p.Post.augmentContent(&p.Collection.Collection)
}
func applyMarkdown(data []byte, baseURL string, cfg *config.Config) string { func applyMarkdown(data []byte, baseURL string, cfg *config.Config) string {
return applyMarkdownSpecial(data, false, baseURL, cfg) return applyMarkdownSpecial(data, false, baseURL, cfg)
} }
func disableYoutubeAutoplay(outHTML string) string {
for _, match := range youtubeReg.FindAllString(outHTML, -1) {
u, err := url.Parse(match)
if err != nil {
continue
}
u.RawQuery = html.UnescapeString(u.RawQuery)
q := u.Query()
// Set Youtube autoplay url parameter, if any, to 0
if len(q["autoplay"]) == 1 {
q.Set("autoplay", "0")
}
u.RawQuery = q.Encode()
cleanURL := u.String()
outHTML = strings.Replace(outHTML, match, cleanURL, 1)
}
return outHTML
}
func applyMarkdownSpecial(data []byte, skipNoFollow bool, baseURL string, cfg *config.Config) string { func applyMarkdownSpecial(data []byte, skipNoFollow bool, baseURL string, cfg *config.Config) string {
mdExtensions := 0 | mdExtensions := 0 |
blackfriday.EXTENSION_TABLES | blackfriday.EXTENSION_TABLES |
@ -97,10 +136,7 @@ func applyMarkdownSpecial(data []byte, skipNoFollow bool, baseURL string, cfg *c
// Strip newlines on certain block elements that render with them // Strip newlines on certain block elements that render with them
outHTML = blockReg.ReplaceAllString(outHTML, "<$1>") outHTML = blockReg.ReplaceAllString(outHTML, "<$1>")
outHTML = endBlockReg.ReplaceAllString(outHTML, "</$1></$2>") outHTML = endBlockReg.ReplaceAllString(outHTML, "</$1></$2>")
// Remove all query parameters on YouTube embed links outHTML = disableYoutubeAutoplay(outHTML)
// TODO: make this more specific. Taking the nuclear approach here to strip ?autoplay=1
outHTML = youtubeReg.ReplaceAllString(outHTML, "$1")
return outHTML return outHTML
} }
@ -129,9 +165,7 @@ func applyBasicMarkdown(data []byte) string {
func postTitle(content, friendlyId string) string { func postTitle(content, friendlyId string) string {
const maxTitleLen = 80 const maxTitleLen = 80
// Strip HTML tags with bluemonday's StrictPolicy, then unescape the HTML content = stripHTMLWithoutEscaping(content)
// entities added in by sanitizing the content.
content = html.UnescapeString(bluemonday.StrictPolicy().Sanitize(content))
content = strings.TrimLeftFunc(stripmd.Strip(content), unicode.IsSpace) content = strings.TrimLeftFunc(stripmd.Strip(content), unicode.IsSpace)
eol := strings.IndexRune(content, '\n') eol := strings.IndexRune(content, '\n')
@ -149,9 +183,7 @@ func postTitle(content, friendlyId string) string {
func friendlyPostTitle(content, friendlyId string) string { func friendlyPostTitle(content, friendlyId string) string {
const maxTitleLen = 80 const maxTitleLen = 80
// Strip HTML tags with bluemonday's StrictPolicy, then unescape the HTML content = stripHTMLWithoutEscaping(content)
// entities added in by sanitizing the content.
content = html.UnescapeString(bluemonday.StrictPolicy().Sanitize(content))
content = strings.TrimLeftFunc(stripmd.Strip(content), unicode.IsSpace) content = strings.TrimLeftFunc(stripmd.Strip(content), unicode.IsSpace)
eol := strings.IndexRune(content, '\n') eol := strings.IndexRune(content, '\n')
@ -168,6 +200,12 @@ func friendlyPostTitle(content, friendlyId string) string {
return title return title
} }
// Strip HTML tags with bluemonday's StrictPolicy, then unescape the HTML
// entities added in by sanitizing the content.
func stripHTMLWithoutEscaping(content string) string {
return html.UnescapeString(bluemonday.StrictPolicy().Sanitize(content))
}
func getSanitizationPolicy() *bluemonday.Policy { func getSanitizationPolicy() *bluemonday.Policy {
policy := bluemonday.UGCPolicy() policy := bluemonday.UGCPolicy()
policy.AllowAttrs("src", "style").OnElements("iframe", "video", "audio") policy.AllowAttrs("src", "style").OnElements("iframe", "video", "audio")
@ -179,6 +217,7 @@ func getSanitizationPolicy() *bluemonday.Policy {
policy.AllowAttrs("target").OnElements("a") policy.AllowAttrs("target").OnElements("a")
policy.AllowAttrs("title").OnElements("abbr") policy.AllowAttrs("title").OnElements("abbr")
policy.AllowAttrs("style", "class", "id").Globally() policy.AllowAttrs("style", "class", "id").Globally()
policy.AllowElements("header", "footer")
policy.AllowURLSchemes("http", "https", "mailto", "xmpp") policy.AllowURLSchemes("http", "https", "mailto", "xmpp")
return policy return policy
} }

@ -1,5 +1,5 @@
/* /*
* Copyright © 2018-2020 A Bunch Tell LLC. * Copyright © 2018-2021 A Bunch Tell LLC.
* *
* This file is part of WriteFreely. * This file is part of WriteFreely.
* *
@ -16,6 +16,7 @@ import (
"fmt" "fmt"
"html/template" "html/template"
"net/http" "net/http"
"net/url"
"regexp" "regexp"
"strings" "strings"
"time" "time"
@ -35,8 +36,8 @@ import (
"github.com/writeas/web-core/i18n" "github.com/writeas/web-core/i18n"
"github.com/writeas/web-core/log" "github.com/writeas/web-core/log"
"github.com/writeas/web-core/tags" "github.com/writeas/web-core/tags"
"github.com/writeas/writefreely/page" "github.com/writefreely/writefreely/page"
"github.com/writeas/writefreely/parse" "github.com/writefreely/writefreely/parse"
) )
const ( const (
@ -62,6 +63,7 @@ type (
Description string Description string
Author string Author string
Views int64 Views int64
Images []string
IsPlainText bool IsPlainText bool
IsCode bool IsCode bool
IsLinkable bool IsLinkable bool
@ -133,6 +135,7 @@ type (
Views int64 Views int64
Font string Font string
Created time.Time Created time.Time
Updated time.Time
IsRTL sql.NullBool IsRTL sql.NullBool
Language sql.NullString Language sql.NullString
OwnerID int64 OwnerID int64
@ -208,8 +211,7 @@ func (p Post) Summary() string {
if p.Content == "" { if p.Content == "" {
return "" return ""
} }
// Strip out HTML p.Content = stripHTMLWithoutEscaping(p.Content)
p.Content = bluemonday.StrictPolicy().Sanitize(p.Content)
// and Markdown // and Markdown
p.Content = stripmd.Strip(p.Content) p.Content = stripmd.Strip(p.Content)
@ -381,12 +383,13 @@ func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error {
} }
if !isRaw { if !isRaw {
post.HTMLContent = template.HTML(applyMarkdown([]byte(content), "", app.cfg)) post.HTMLContent = template.HTML(applyMarkdown([]byte(content), "", app.cfg))
post.Images = extractImages(post.Content)
} }
} }
var suspended bool var silenced bool
if found { if found {
suspended, err = app.db.IsUserSuspended(ownerID.Int64) silenced, err = app.db.IsUserSilenced(ownerID.Int64)
if err != nil { if err != nil {
log.Error("view post: %v", err) log.Error("view post: %v", err)
} }
@ -439,10 +442,10 @@ func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error {
page := struct { page := struct {
*AnonymousPost *AnonymousPost
page.StaticPage page.StaticPage
Username string Username string
IsOwner bool IsOwner bool
SiteURL string SiteURL string
Suspended bool Silenced bool
}{ }{
AnonymousPost: post, AnonymousPost: post,
StaticPage: pageForReq(app, r), StaticPage: pageForReq(app, r),
@ -453,10 +456,10 @@ func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error {
page.IsOwner = ownerID.Valid && ownerID.Int64 == u.ID page.IsOwner = ownerID.Valid && ownerID.Int64 == u.ID
} }
if !page.IsOwner && suspended { if !page.IsOwner && silenced {
return ErrPostNotFound return ErrPostNotFound
} }
page.Suspended = suspended page.Silenced = silenced
err = templates["post"].ExecuteTemplate(w, "post", page) err = templates["post"].ExecuteTemplate(w, "post", page)
if err != nil { if err != nil {
log.Error("Post template execute error: %v", err) log.Error("Post template execute error: %v", err)
@ -513,12 +516,12 @@ func newPost(app *App, w http.ResponseWriter, r *http.Request) error {
} else { } else {
userID = app.db.GetUserID(accessToken) userID = app.db.GetUserID(accessToken)
} }
suspended, err := app.db.IsUserSuspended(userID) silenced, err := app.db.IsUserSilenced(userID)
if err != nil { if err != nil {
log.Error("new post: %v", err) log.Error("new post: %v", err)
} }
if suspended { if silenced {
return ErrUserSuspended return ErrUserSilenced
} }
if userID == -1 { if userID == -1 {
@ -686,12 +689,12 @@ func existingPost(app *App, w http.ResponseWriter, r *http.Request) error {
} }
} }
suspended, err := app.db.IsUserSuspended(userID) silenced, err := app.db.IsUserSilenced(userID)
if err != nil { if err != nil {
log.Error("existing post: %v", err) log.Error("existing post: %v", err)
} }
if suspended { if silenced {
return ErrUserSuspended return ErrUserSilenced
} }
// Modify post struct // Modify post struct
@ -888,12 +891,12 @@ func addPost(app *App, w http.ResponseWriter, r *http.Request) error {
ownerID = u.ID ownerID = u.ID
} }
suspended, err := app.db.IsUserSuspended(ownerID) silenced, err := app.db.IsUserSilenced(ownerID)
if err != nil { if err != nil {
log.Error("add post: %v", err) log.Error("add post: %v", err)
} }
if suspended { if silenced {
return ErrUserSuspended return ErrUserSilenced
} }
// Parse claimed posts in format: // Parse claimed posts in format:
@ -990,12 +993,12 @@ func pinPost(app *App, w http.ResponseWriter, r *http.Request) error {
userID = u.ID userID = u.ID
} }
suspended, err := app.db.IsUserSuspended(userID) silenced, err := app.db.IsUserSilenced(userID)
if err != nil { if err != nil {
log.Error("pin post: %v", err) log.Error("pin post: %v", err)
} }
if suspended { if silenced {
return ErrUserSuspended return ErrUserSilenced
} }
// Parse request // Parse request
@ -1071,11 +1074,11 @@ func fetchPost(app *App, w http.ResponseWriter, r *http.Request) error {
} }
} }
suspended, err := app.db.IsUserSuspended(p.OwnerID.Int64) silenced, err := app.db.IsUserSilenced(p.OwnerID.Int64)
if err != nil { if err != nil {
log.Error("fetch post: %v", err) log.Error("fetch post: %v", err)
} }
if suspended { if silenced {
return ErrPostNotFound return ErrPostNotFound
} }
@ -1128,7 +1131,12 @@ func (p *PublicPost) CanonicalURL(hostName string) string {
func (p *PublicPost) ActivityObject(app *App) *activitystreams.Object { func (p *PublicPost) ActivityObject(app *App) *activitystreams.Object {
cfg := app.cfg cfg := app.cfg
o := activitystreams.NewArticleObject() var o *activitystreams.Object
if cfg.App.NotesOnly || strings.Index(p.Content, "\n\n") == -1 {
o = activitystreams.NewNoteObject()
} else {
o = activitystreams.NewArticleObject()
}
o.ID = p.Collection.FederatedAPIBase() + "api/posts/" + p.ID o.ID = p.Collection.FederatedAPIBase() + "api/posts/" + p.ID
o.Published = p.Created o.Published = p.Created
o.URL = p.CanonicalURL(cfg.App.Host) o.URL = p.CanonicalURL(cfg.App.Host)
@ -1137,6 +1145,7 @@ func (p *PublicPost) ActivityObject(app *App) *activitystreams.Object {
p.Collection.FederatedAccount() + "/followers", p.Collection.FederatedAccount() + "/followers",
} }
o.Name = p.DisplayTitle() o.Name = p.DisplayTitle()
p.augmentContent()
if p.HTMLContent == template.HTML("") { if p.HTMLContent == template.HTML("") {
p.formatContent(cfg, false) p.formatContent(cfg, false)
} }
@ -1167,19 +1176,23 @@ func (p *PublicPost) ActivityObject(app *App) *activitystreams.Object {
}) })
} }
} }
if len(p.Images) > 0 {
for _, i := range p.Images {
o.Attachment = append(o.Attachment, activitystreams.NewImageAttachment(i))
}
}
// Find mentioned users // Find mentioned users
mentionedUsers := make(map[string]string) mentionedUsers := make(map[string]string)
stripper := bluemonday.StrictPolicy() stripper := bluemonday.StrictPolicy()
content := stripper.Sanitize(p.Content) content := stripper.Sanitize(p.Content)
mentionRegex := regexp.MustCompile(`@[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]+\b`) mentions := mentionReg.FindAllString(content, -1)
mentions := mentionRegex.FindAllString(content, -1)
for _, handle := range mentions { for _, handle := range mentions {
actorIRI, err := app.db.GetProfilePageFromHandle(app, handle) actorIRI, err := app.db.GetProfilePageFromHandle(app, handle)
if err != nil { if err != nil {
log.Info("Can't find this user either in the database nor in the remote instance") log.Info("Couldn't find user '%s' locally or remotely", handle)
return nil continue
} }
mentionedUsers[handle] = actorIRI mentionedUsers[handle] = actorIRI
} }
@ -1238,9 +1251,9 @@ func getRawPost(app *App, friendlyID string) *RawPost {
var isRTL sql.NullBool var isRTL sql.NullBool
var lang sql.NullString var lang sql.NullString
var ownerID sql.NullInt64 var ownerID sql.NullInt64
var created time.Time var created, updated time.Time
err := app.db.QueryRow("SELECT title, content, text_appearance, language, rtl, created, owner_id FROM posts WHERE id = ?", friendlyID).Scan(&title, &content, &font, &lang, &isRTL, &created, &ownerID) err := app.db.QueryRow("SELECT title, content, text_appearance, language, rtl, created, updated, owner_id FROM posts WHERE id = ?", friendlyID).Scan(&title, &content, &font, &lang, &isRTL, &created, &updated, &ownerID)
switch { switch {
case err == sql.ErrNoRows: case err == sql.ErrNoRows:
return &RawPost{Content: "", Found: false, Gone: false} return &RawPost{Content: "", Found: false, Gone: false}
@ -1248,7 +1261,7 @@ func getRawPost(app *App, friendlyID string) *RawPost {
return &RawPost{Content: "", Found: true, Gone: false} return &RawPost{Content: "", Found: true, Gone: false}
} }
return &RawPost{Title: title, Content: content, Font: font, Created: created, IsRTL: isRTL, Language: lang, OwnerID: ownerID.Int64, Found: true, Gone: content == ""} return &RawPost{Title: title, Content: content, Font: font, Created: created, Updated: updated, IsRTL: isRTL, Language: lang, OwnerID: ownerID.Int64, Found: true, Gone: content == ""}
} }
@ -1257,15 +1270,15 @@ func getRawCollectionPost(app *App, slug, collAlias string) *RawPost {
var id, title, content, font string var id, title, content, font string
var isRTL sql.NullBool var isRTL sql.NullBool
var lang sql.NullString var lang sql.NullString
var created time.Time var created, updated time.Time
var ownerID null.Int var ownerID null.Int
var views int64 var views int64
var err error var err error
if app.cfg.App.SingleUser { if app.cfg.App.SingleUser {
err = app.db.QueryRow("SELECT id, title, content, text_appearance, language, rtl, view_count, created, owner_id FROM posts WHERE slug = ? AND collection_id = 1", slug).Scan(&id, &title, &content, &font, &lang, &isRTL, &views, &created, &ownerID) err = app.db.QueryRow("SELECT id, title, content, text_appearance, language, rtl, view_count, created, updated, owner_id FROM posts WHERE slug = ? AND collection_id = 1", slug).Scan(&id, &title, &content, &font, &lang, &isRTL, &views, &created, &updated, &ownerID)
} else { } else {
err = app.db.QueryRow("SELECT id, title, content, text_appearance, language, rtl, view_count, created, owner_id FROM posts WHERE slug = ? AND collection_id = (SELECT id FROM collections WHERE alias = ?)", slug, collAlias).Scan(&id, &title, &content, &font, &lang, &isRTL, &views, &created, &ownerID) err = app.db.QueryRow("SELECT id, title, content, text_appearance, language, rtl, view_count, created, updated, owner_id FROM posts WHERE slug = ? AND collection_id = (SELECT id FROM collections WHERE alias = ?)", slug, collAlias).Scan(&id, &title, &content, &font, &lang, &isRTL, &views, &created, &updated, &ownerID)
} }
switch { switch {
case err == sql.ErrNoRows: case err == sql.ErrNoRows:
@ -1281,6 +1294,7 @@ func getRawCollectionPost(app *App, slug, collAlias string) *RawPost {
Content: content, Content: content,
Font: font, Font: font,
Created: created, Created: created,
Updated: updated,
IsRTL: isRTL, IsRTL: isRTL,
Language: lang, Language: lang,
OwnerID: ownerID.Int64, OwnerID: ownerID.Int64,
@ -1355,7 +1369,7 @@ func viewCollectionPost(app *App, w http.ResponseWriter, r *http.Request) error
} }
c.hostName = app.cfg.App.Host c.hostName = app.cfg.App.Host
suspended, err := app.db.IsUserSuspended(c.OwnerID) silenced, err := app.db.IsUserSilenced(c.OwnerID)
if err != nil { if err != nil {
log.Error("view collection post: %v", err) log.Error("view collection post: %v", err)
} }
@ -1365,7 +1379,7 @@ func viewCollectionPost(app *App, w http.ResponseWriter, r *http.Request) error
return ErrPostNotFound return ErrPostNotFound
} }
if c.IsProtected() && (u == nil || u.ID != c.OwnerID) { if c.IsProtected() && (u == nil || u.ID != c.OwnerID) {
if suspended { if silenced {
return ErrPostNotFound return ErrPostNotFound
} else if !isAuthorizedForCollection(app, c.Alias, r) { } else if !isAuthorizedForCollection(app, c.Alias, r) {
return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "/?g=" + slug} return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "/?g=" + slug}
@ -1416,18 +1430,24 @@ Are you sure it was ever here?`,
return err return err
} }
} }
p.IsOwner = owner != nil && p.OwnerID.Valid && owner.ID == p.OwnerID.Int64
// Check if the authenticated user is the post owner
p.IsOwner = u != nil && u.ID == p.OwnerID.Int64
p.Collection = coll p.Collection = coll
p.IsTopLevel = app.cfg.App.SingleUser p.IsTopLevel = app.cfg.App.SingleUser
if !p.IsOwner && suspended { // Only allow a post owner or admin to view a post for silenced collections
if silenced && !p.IsOwner && (u == nil || !u.IsAdmin()) {
return ErrPostNotFound return ErrPostNotFound
} }
// Check if post has been unpublished // Check if post has been unpublished
if p.Content == "" && p.Title.String == "" { if p.Content == "" && p.Title.String == "" {
return impart.HTTPError{http.StatusGone, "Post was unpublished."} return impart.HTTPError{http.StatusGone, "Post was unpublished."}
} }
p.augmentContent()
// Serve collection post // Serve collection post
if isRaw { if isRaw {
contentType := "text/plain" contentType := "text/plain"
@ -1469,23 +1489,25 @@ Are you sure it was ever here?`,
IsOwner bool IsOwner bool
IsPinned bool IsPinned bool
IsCustomDomain bool IsCustomDomain bool
Monetization string
PinnedPosts *[]PublicPost PinnedPosts *[]PublicPost
IsFound bool IsFound bool
IsAdmin bool IsAdmin bool
CanInvite bool CanInvite bool
Suspended bool Silenced bool
}{ }{
PublicPost: p, PublicPost: p,
StaticPage: pageForReq(app, r), StaticPage: pageForReq(app, r),
IsOwner: cr.isCollOwner, IsOwner: cr.isCollOwner,
IsCustomDomain: cr.isCustomDomain, IsCustomDomain: cr.isCustomDomain,
IsFound: postFound, IsFound: postFound,
Suspended: suspended, Silenced: silenced,
} }
tp.IsAdmin = u != nil && u.IsAdmin() tp.IsAdmin = u != nil && u.IsAdmin()
tp.CanInvite = canUserInvite(app.cfg, tp.IsAdmin) tp.CanInvite = canUserInvite(app.cfg, tp.IsAdmin)
tp.PinnedPosts, _ = app.db.GetPinnedPosts(coll, p.IsOwner) tp.PinnedPosts, _ = app.db.GetPinnedPosts(coll, p.IsOwner)
tp.IsPinned = len(*tp.PinnedPosts) > 0 && PostsContains(tp.PinnedPosts, p) tp.IsPinned = len(*tp.PinnedPosts) > 0 && PostsContains(tp.PinnedPosts, p)
tp.Monetization = app.db.GetCollectionAttribute(coll.ID, "monetization_pointer")
if !postFound { if !postFound {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
@ -1541,22 +1563,39 @@ func (rp *RawPost) Created8601() string {
return rp.Created.Format("2006-01-02T15:04:05Z") return rp.Created.Format("2006-01-02T15:04:05Z")
} }
var imageURLRegex = regexp.MustCompile(`(?i)^https?:\/\/[^ ]*\.(gif|png|jpg|jpeg|image)$`) func (rp *RawPost) Updated8601() string {
if rp.Updated.IsZero() {
return ""
}
return rp.Updated.Format("2006-01-02T15:04:05Z")
}
var imageURLRegex = regexp.MustCompile(`(?i)[^ ]+\.(gif|png|jpg|jpeg|image)$`)
func (p *Post) extractImages() { func (p *Post) extractImages() {
matches := extract.ExtractUrls(p.Content) p.Images = extractImages(p.Content)
}
func extractImages(content string) []string {
matches := extract.ExtractUrls(content)
urls := map[string]bool{} urls := map[string]bool{}
for i := range matches { for i := range matches {
u := matches[i].Text uRaw := matches[i].Text
if !imageURLRegex.MatchString(u) { // Parse the extracted text so we can examine the path
u, err := url.Parse(uRaw)
if err != nil {
continue
}
// Ensure the path looks like it leads to an image file
if !imageURLRegex.MatchString(u.Path) {
continue continue
} }
urls[u] = true urls[uRaw] = true
} }
resURLs := make([]string, 0) resURLs := make([]string, 0)
for k := range urls { for k := range urls {
resURLs = append(resURLs, k) resURLs = append(resURLs, k)
} }
p.Images = resURLs return resURLs
} }

@ -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 writefreely_test
import (
"testing"
"github.com/guregu/null/zero"
"github.com/stretchr/testify/assert"
"github.com/writefreely/writefreely"
)
func TestPostSummary(t *testing.T) {
testCases := map[string]struct {
given writefreely.Post
expected string
}{
"no special chars": {givenPost("Content."), "Content."},
"HTML content": {givenPost("Content <p>with a</p> paragraph."), "Content with a paragraph."},
"content with escaped char": {givenPost("Content&#39;s all OK."), "Content's all OK."},
"multiline content": {givenPost(`Content
in
multiple
lines.`), "Content in multiple lines."},
}
for name, test := range testCases {
t.Run(name, func(t *testing.T) {
actual := test.given.Summary()
assert.Equal(t, test.expected, actual)
})
}
}
func givenPost(content string) writefreely.Post {
return writefreely.Post{Title: zero.StringFrom("Title"), Content: content}
}

@ -0,0 +1,8 @@
module.exports = {
"presets": [
["@babel/env", {
"modules": false
}]
],
"plugins": ["@babel/plugin-syntax-dynamic-import"]
}

@ -0,0 +1,4 @@
{
"tabWidth": 2,
"useTabs": false
}

@ -0,0 +1,3 @@
all :
npm install
npm run-script build

@ -0,0 +1,7 @@
# Building
* Run `npm install` to download dependencies.
* Run `npm run-script build` to build a production script in `../static/js/` or run
`npm run develop` to build and watch for changes. You can use `prose.html`
to test your development changes.
* Manually copy the file `prose.bundle.js` to `static/js/`. _To be automated_

@ -0,0 +1,57 @@
import { MarkdownParser } from "prosemirror-markdown";
import markdownit from "markdown-it";
import { writeFreelySchema } from "./schema";
export const writeAsMarkdownParser = new MarkdownParser(
writeFreelySchema,
markdownit("commonmark", { html: true }),
{
// blockquote: { block: "blockquote" },
paragraph: { block: "paragraph" },
list_item: { block: "list_item" },
bullet_list: { block: "bullet_list" },
ordered_list: {
block: "ordered_list",
getAttrs: (tok) => ({ order: +tok.attrGet("start") || 1 }),
},
heading: {
block: "heading",
getAttrs: (tok) => ({ level: +tok.tag.slice(1) }),
},
code_block: { block: "code_block", noCloseToken: true },
fence: {
block: "code_block",
getAttrs: (tok) => ({ params: tok.info || "" }),
noCloseToken: true,
},
// hr: { node: "horizontal_rule" },
image: {
node: "image",
getAttrs: (tok) => ({
src: tok.attrGet("src"),
title: tok.attrGet("title") || null,
alt: tok.children?.[0].content || null,
}),
},
hardbreak: { node: "hard_break" },
em: { mark: "em" },
strong: { mark: "strong" },
link: {
mark: "link",
getAttrs: (tok) => ({
href: tok.attrGet("href"),
title: tok.attrGet("title") || null,
}),
},
code_inline: { mark: "code", noCloseToken: true },
html_block: {
node: "readmore",
getAttrs(token) {
// TODO: Give different attributes depending on the token content
return {};
},
},
}
);

@ -0,0 +1,123 @@
import { MarkdownSerializer } from "prosemirror-markdown";
function backticksFor(node, side) {
const ticks = /`+/g;
let m;
let len = 0;
if (node.isText)
while ((m = ticks.exec(node.text))) len = Math.max(len, m[0].length);
let result = len > 0 && side > 0 ? " `" : "`";
for (let i = 0; i < len; i++) result += "`";
if (len > 0 && side < 0) result += " ";
return result;
}
function isPlainURL(link, parent, index, side) {
if (link.attrs.title || !/^\w+:/.test(link.attrs.href)) return false;
const content = parent.child(index + (side < 0 ? -1 : 0));
if (
!content.isText ||
content.text != link.attrs.href ||
content.marks[content.marks.length - 1] != link
)
return false;
if (index == (side < 0 ? 1 : parent.childCount - 1)) return true;
const next = parent.child(index + (side < 0 ? -2 : 1));
return !link.isInSet(next.marks);
}
export const writeAsMarkdownSerializer = new MarkdownSerializer(
{
readmore(state, node) {
state.write("<!--more-->\n");
state.closeBlock(node);
},
// blockquote(state, node) {
// state.wrapBlock("> ", undefined, node, () => state.renderContent(node));
// },
code_block(state, node) {
state.write(`\`\`\`${node.attrs.params || ""}\n`);
state.text(node.textContent, false);
state.ensureNewLine();
state.write("```");
state.closeBlock(node);
},
heading(state, node) {
state.write(`${state.repeat("#", node.attrs.level)} `);
state.renderInline(node);
state.closeBlock(node);
},
bullet_list(state, node) {
state.renderList(node, " ", () => `${node.attrs.bullet || "*"} `);
},
ordered_list(state, node) {
const start = node.attrs.order || 1;
const maxW = String(start + node.childCount - 1).length;
const space = state.repeat(" ", maxW + 2);
state.renderList(node, space, (i) => {
const nStr = String(start + i);
return `${state.repeat(" ", maxW - nStr.length) + nStr}. `;
});
},
list_item(state, node) {
state.renderContent(node);
},
paragraph(state, node) {
state.renderInline(node);
state.closeBlock(node);
},
image(state, node) {
state.write(
`![${state.esc(node.attrs.alt || "")}](${state.esc(node.attrs.src)}${
node.attrs.title ? ` ${state.quote(node.attrs.title)}` : ""
})`
);
},
hard_break(state, node, parent, index) {
for (let i = index + 1; i < parent.childCount; i += 1)
if (parent.child(i).type !== node.type) {
state.write("\\\n");
return;
}
},
text(state, node) {
state.text(node.text || "");
},
},
{
em: {
open: "*",
close: "*",
mixable: true,
expelEnclosingWhitespace: true,
},
strong: {
open: "**",
close: "**",
mixable: true,
expelEnclosingWhitespace: true,
},
link: {
open(_state, mark, parent, index) {
return isPlainURL(mark, parent, index, 1) ? "<" : "[";
},
close(state, mark, parent, index) {
return isPlainURL(mark, parent, index, -1)
? ">"
: `](${state.esc(mark.attrs.href)}${
mark.attrs.title ? ` ${state.quote(mark.attrs.title)}` : ""
})`;
},
},
code: {
open(_state, _mark, parent, index) {
return backticksFor(parent.child(index), -1);
},
close(_state, _mark, parent, index) {
return backticksFor(parent.child(index - 1), 1);
},
escape: false,
},
}
);

@ -0,0 +1,32 @@
import { MenuItem } from "prosemirror-menu";
import { buildMenuItems } from "prosemirror-example-setup";
import { writeFreelySchema } from "./schema";
function canInsert(state, nodeType, attrs) {
let $from = state.selection.$from;
for (let d = $from.depth; d >= 0; d--) {
let index = $from.index(d);
if ($from.node(d).canReplaceWith(index, index, nodeType, attrs))
return true;
}
return false;
}
const ReadMoreItem = new MenuItem({
label: "Read more",
select: (state) => canInsert(state, writeFreelySchema.nodes.readmore),
run(state, dispatch) {
dispatch(
state.tr.replaceSelectionWith(writeFreelySchema.nodes.readmore.create())
);
},
});
export const getMenu = () => {
const menuContent = [
...buildMenuItems(writeFreelySchema).fullMenu,
[ReadMoreItem],
];
return menuContent;
};

16278
prose/package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,32 @@
{
"name": "prose",
"version": "1.0.0",
"description": "",
"main": "prose.js",
"dependencies": {
"babel-core": "^6.26.3",
"babel-preset-es2015": "^6.24.1",
"markdown-it": "^12.0.4",
"prosemirror-example-setup": "^1.1.2",
"prosemirror-keymap": "^1.1.4",
"prosemirror-markdown": "github:VV-EE/prosemirror-markdown",
"prosemirror-model": "^1.9.1",
"prosemirror-state": "^1.3.2",
"prosemirror-view": "^1.14.2",
"webpack": "^4.42.0",
"webpack-cli": "^3.3.11"
},
"devDependencies": {
"@babel/core": "^7.8.7",
"@babel/preset-env": "^7.9.0",
"babel-loader": "^8.0.6",
"prettier": "^2.2.1"
},
"scripts": {
"develop": "webpack --mode development --watch",
"build": "webpack --mode production"
},
"keywords": [],
"author": "",
"license": "ISC"
}

@ -0,0 +1,14 @@
<link rel="stylesheet" href="../static/css/prose.css" />
<div id="editor" style="margin-bottom: 0"></div>
<!-- <div style="text-align: center"> -->
<!-- <label style="border-right: 1px solid silver"> -->
<!-- Markdown <input type=radio name=inputformat value=markdown>&nbsp;</label> -->
<!-- <label>&nbsp;<input type=radio name=inputformat value=prosemirror checked> WYSIWYM</label> -->
<!-- </div> -->
<div style="display: none">
<textarea id="content">
This is a comment written in [Markdown](http://commonmark.org). *You* may know the syntax for inserting a link, but does your whole audience?&#13;&#13;So you can give people the **choice** to use a more familiar, discoverable interface.</textarea
>
</div>
<script src="dist/prose.bundle.js"></script>

@ -0,0 +1,118 @@
// class MarkdownView {
// constructor(target, content) {
// this.textarea = target.appendChild(document.createElement("textarea"))
// this.textarea.value = content
// }
// get content() { return this.textarea.value }
// focus() { this.textarea.focus() }
// destroy() { this.textarea.remove() }
// }
import { EditorView } from "prosemirror-view";
import { EditorState, TextSelection } from "prosemirror-state";
import { exampleSetup } from "prosemirror-example-setup";
import { keymap } from "prosemirror-keymap";
import { writeAsMarkdownParser } from "./markdownParser";
import { writeAsMarkdownSerializer } from "./markdownSerializer";
import { writeFreelySchema } from "./schema";
import { getMenu } from "./menu";
let $title = document.querySelector("#title");
let $content = document.querySelector("#content");
// Bugs:
// 1. When there's just an empty line and a hard break is inserted with shift-enter then two enters are inserted
// which do not show up in the markdown ( maybe bc. they are training enters )
class ProseMirrorView {
constructor(target, content) {
let typingTimer;
let localDraft = localStorage.getItem(window.draftKey);
if (localDraft != null) {
content = localDraft;
}
if (content.indexOf("# ") === 0) {
let eol = content.indexOf("\n");
let title = content.substring("# ".length, eol);
content = content.substring(eol + "\n\n".length);
$title.value = title;
}
const doc = writeAsMarkdownParser.parse(
// Replace all "solo" \n's with \\\n for correct markdown parsing
// Can't use lookahead or lookbehind because it's not supported on Safari
content.replace(/([^]{0,1})(\n)([^]{0,1})/g, (match, p1, p2, p3) => {
return p1 !== "\n" && p3 !== "\n" ? p1 + "\\\n" + p3 : match;
})
);
this.view = new EditorView(target, {
state: EditorState.create({
doc,
plugins: [
keymap({
"Mod-Enter": () => {
document.getElementById("publish").click();
return true;
},
"Mod-k": () => {
const linkButton = document.querySelector(
".ProseMirror-icon[title='Add or remove link']"
);
linkButton.dispatchEvent(new Event("mousedown"));
return true;
},
}),
...exampleSetup({
schema: writeFreelySchema,
menuContent: getMenu(),
}),
],
}),
dispatchTransaction(transaction) {
let newState = this.state.apply(transaction);
const newContent = writeAsMarkdownSerializer
.serialize(newState.doc)
// Replace all \\\ns ( not followed by a \n ) with \n
.replace(/(\\\n)(\n{0,1})/g, (match, p1, p2) =>
p2 !== "\n" ? "\n" + p2 : match
);
$content.value = newContent;
let draft = "";
if ($title.value != null && $title.value !== "") {
draft = "# " + $title.value + "\n\n";
}
draft += newContent;
clearTimeout(typingTimer);
typingTimer = setTimeout(doneTyping, doneTypingInterval);
this.updateState(newState);
},
});
// Editor is focused to the last position. This is a workaround for a bug:
// 1. 1 type something in an existing entry
// 2. reload - works fine, the draft is reloaded
// 3. reload again - the draft is somehow removed from localStorage and the original content is loaded
// When the editor is focused the content is re-saved to localStorage
// This is also useful for editing, so it's not a bad thing even
const lastPosition = this.view.state.doc.content.size;
const selection = TextSelection.create(this.view.state.doc, lastPosition);
this.view.dispatch(this.view.state.tr.setSelection(selection));
this.view.focus();
}
get content() {
return defaultMarkdownSerializer.serialize(this.view.state.doc);
}
focus() {
this.view.focus();
}
destroy() {
this.view.destroy();
}
}
let place = document.querySelector("#editor");
let view = new ProseMirrorView(place, $content.value);

@ -0,0 +1,21 @@
import { schema } from "prosemirror-markdown";
import { Schema } from "prosemirror-model";
export const writeFreelySchema = new Schema({
nodes: schema.spec.nodes
.remove("blockquote")
.remove("horizontal_rule")
.addToEnd("readmore", {
inline: false,
content: "",
group: "block",
draggable: true,
toDOM: (node) => [
"div",
{ class: "editorreadmore" },
"Read more...",
],
parseDOM: [{ tag: "div.editorreadmore" }],
}),
marks: schema.spec.marks,
});

@ -0,0 +1,25 @@
const path = require('path')
module.exports = {
entry: {
entry: __dirname + '/prose.js'
},
output: {
filename: 'prose.bundle.js',
path: path.resolve('..', 'static', 'js'),
},
module: {
rules: [
{
test: /\.js$/,
exclude: /(nodue_modules|bower_components)/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env']
}
}
}
]
}
}

@ -1,5 +1,5 @@
/* /*
* Copyright © 2018-2019 A Bunch Tell LLC. * Copyright © 2018-2021 A Bunch Tell LLC.
* *
* This file is part of WriteFreely. * This file is part of WriteFreely.
* *
@ -25,7 +25,7 @@ import (
"github.com/writeas/impart" "github.com/writeas/impart"
"github.com/writeas/web-core/log" "github.com/writeas/web-core/log"
"github.com/writeas/web-core/memo" "github.com/writeas/web-core/memo"
"github.com/writeas/writefreely/page" "github.com/writefreely/writefreely/page"
) )
const ( const (
@ -33,6 +33,8 @@ const (
tlAPIPageLimit = 10 tlAPIPageLimit = 10
tlMaxAuthorPosts = 5 tlMaxAuthorPosts = 5
tlPostsPerPage = 16 tlPostsPerPage = 16
tlMaxPostCache = 250
tlCacheDur = 10 * time.Minute
) )
type localTimeline struct { type localTimeline struct {
@ -60,19 +62,25 @@ type readPublication struct {
func initLocalTimeline(app *App) { func initLocalTimeline(app *App) {
app.timeline = &localTimeline{ app.timeline = &localTimeline{
postsPerPage: tlPostsPerPage, postsPerPage: tlPostsPerPage,
m: memo.New(app.FetchPublicPosts, 10*time.Minute), m: memo.New(app.FetchPublicPosts, tlCacheDur),
} }
} }
// satisfies memo.Func // satisfies memo.Func
func (app *App) FetchPublicPosts() (interface{}, error) { func (app *App) FetchPublicPosts() (interface{}, error) {
// Conditions
limit := fmt.Sprintf("LIMIT %d", tlMaxPostCache)
// This is better than the hard limit when limiting posts from individual authors
// ageCond := `p.created >= ` + app.db.dateSub(3, "month") + ` AND `
// Finds all public posts and posts in a public collection published during the owner's active subscription period and within the last 3 months // Finds all public posts and posts in a public collection published during the owner's active subscription period and within the last 3 months
rows, err := app.db.Query(`SELECT p.id, alias, c.title, p.slug, p.title, p.content, p.text_appearance, p.language, p.rtl, p.created, p.updated rows, err := app.db.Query(`SELECT p.id, alias, c.title, p.slug, p.title, p.content, p.text_appearance, p.language, p.rtl, p.created, p.updated
FROM collections c FROM collections c
LEFT JOIN posts p ON p.collection_id = c.id LEFT JOIN posts p ON p.collection_id = c.id
LEFT JOIN users u ON u.id = p.owner_id LEFT JOIN users u ON u.id = p.owner_id
WHERE c.privacy = 1 AND (p.created >= ` + app.db.dateSub(3, "month") + ` AND p.created <= ` + app.db.now() + ` AND pinned_position IS NULL) AND u.status = 0 WHERE c.privacy = 1 AND (p.created <= ` + app.db.now() + ` AND pinned_position IS NULL) AND u.status = 0
ORDER BY p.created DESC`) ORDER BY p.created DESC
` + limit)
if err != nil { if err != nil {
log.Error("Failed selecting from posts: %v", err) log.Error("Failed selecting from posts: %v", err)
return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve collection posts." + err.Error()} return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve collection posts." + err.Error()}
@ -120,7 +128,7 @@ func (app *App) FetchPublicPosts() (interface{}, error) {
} }
func viewLocalTimelineAPI(app *App, w http.ResponseWriter, r *http.Request) error { func viewLocalTimelineAPI(app *App, w http.ResponseWriter, r *http.Request) error {
updateTimelineCache(app.timeline) updateTimelineCache(app.timeline, false)
skip, _ := strconv.Atoi(r.FormValue("skip")) skip, _ := strconv.Atoi(r.FormValue("skip"))
@ -148,13 +156,19 @@ func viewLocalTimeline(app *App, w http.ResponseWriter, r *http.Request) error {
return showLocalTimeline(app, w, r, page, vars["author"], vars["tag"]) return showLocalTimeline(app, w, r, page, vars["author"], vars["tag"])
} }
func updateTimelineCache(tl *localTimeline) { // updateTimelineCache will reset and update the cache if it is stale or
// Fetch posts if enough time has passed since last cache // the boolean passed in is true.
if tl.posts == nil || tl.m.Invalidate() { func updateTimelineCache(tl *localTimeline, reset bool) {
if reset {
tl.m.Reset()
}
// Fetch posts if the cache is empty, has been reset or enough time has
// passed since last cache.
if tl.posts == nil || reset || tl.m.Invalidate() {
log.Info("[READ] Updating post cache") log.Info("[READ] Updating post cache")
var err error
var postsInterfaces interface{} postsInterfaces, err := tl.m.Get()
postsInterfaces, err = tl.m.Get()
if err != nil { if err != nil {
log.Error("[READ] Unable to cache posts: %v", err) log.Error("[READ] Unable to cache posts: %v", err)
} else { } else {
@ -162,10 +176,11 @@ func updateTimelineCache(tl *localTimeline) {
tl.posts = &castPosts tl.posts = &castPosts
} }
} }
} }
func showLocalTimeline(app *App, w http.ResponseWriter, r *http.Request, page int, author, tag string) error { func showLocalTimeline(app *App, w http.ResponseWriter, r *http.Request, page int, author, tag string) error {
updateTimelineCache(app.timeline) updateTimelineCache(app.timeline, false)
pl := len(*(app.timeline.posts)) pl := len(*(app.timeline.posts))
ttlPages := int(math.Ceil(float64(pl) / float64(app.timeline.postsPerPage))) ttlPages := int(math.Ceil(float64(pl) / float64(app.timeline.postsPerPage)))
@ -278,7 +293,7 @@ func viewLocalTimelineFeed(app *App, w http.ResponseWriter, req *http.Request) e
return impart.HTTPError{http.StatusNotFound, "Page doesn't exist."} return impart.HTTPError{http.StatusNotFound, "Page doesn't exist."}
} }
updateTimelineCache(app.timeline) updateTimelineCache(app.timeline, false)
feed := &Feed{ feed := &Feed{
Title: app.cfg.App.SiteName + " Reader", Title: app.cfg.App.SiteName + " Reader",

@ -1,5 +1,5 @@
/* /*
* Copyright © 2018-2019 A Bunch Tell LLC. * Copyright © 2018-2021 A Bunch Tell LLC.
* *
* This file is part of WriteFreely. * This file is part of WriteFreely.
* *
@ -12,6 +12,7 @@ package writefreely
import ( import (
"net/http" "net/http"
"net/url"
"path/filepath" "path/filepath"
"strings" "strings"
@ -26,6 +27,7 @@ import (
func (app *App) InitStaticRoutes(r *mux.Router) { func (app *App) InitStaticRoutes(r *mux.Router) {
// Handle static files // Handle static files
fs := http.FileServer(http.Dir(filepath.Join(app.cfg.Server.StaticParentDir, staticDir))) fs := http.FileServer(http.Dir(filepath.Join(app.cfg.Server.StaticParentDir, staticDir)))
fs = cacheControl(fs)
app.shttp = http.NewServeMux() app.shttp = http.NewServeMux()
app.shttp.Handle("/", fs) app.shttp.Handle("/", fs)
r.PathPrefix("/").Handler(fs) r.PathPrefix("/").Handler(fs)
@ -75,6 +77,9 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
configureSlackOauth(handler, write, apper.App()) configureSlackOauth(handler, write, apper.App())
configureWriteAsOauth(handler, write, apper.App()) configureWriteAsOauth(handler, write, apper.App())
configureGitlabOauth(handler, write, apper.App())
configureGenericOauth(handler, write, apper.App())
configureGiteaOauth(handler, write, apper.App())
// Set up dyamic page handlers // Set up dyamic page handlers
// Handle auth // Handle auth
@ -114,15 +119,20 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
apiMe.HandleFunc("/self", handler.All(updateSettings)).Methods("POST") apiMe.HandleFunc("/self", handler.All(updateSettings)).Methods("POST")
apiMe.HandleFunc("/invites", handler.User(handleCreateUserInvite)).Methods("POST") apiMe.HandleFunc("/invites", handler.User(handleCreateUserInvite)).Methods("POST")
apiMe.HandleFunc("/import", handler.User(handleImport)).Methods("POST") apiMe.HandleFunc("/import", handler.User(handleImport)).Methods("POST")
apiMe.HandleFunc("/oauth/remove", handler.User(removeOauth)).Methods("POST")
// Sign up validation // Sign up validation
write.HandleFunc("/api/alias", handler.All(handleUsernameCheck)).Methods("POST") write.HandleFunc("/api/alias", handler.All(handleUsernameCheck)).Methods("POST")
write.HandleFunc("/api/markdown", handler.All(handleRenderMarkdown)).Methods("POST") write.HandleFunc("/api/markdown", handler.All(handleRenderMarkdown)).Methods("POST")
instanceURL, _ := url.Parse(apper.App().Config().App.Host)
host := instanceURL.Host
// Handle collections // Handle collections
write.HandleFunc("/api/collections", handler.All(newCollection)).Methods("POST") write.HandleFunc("/api/collections", handler.All(newCollection)).Methods("POST")
apiColls := write.PathPrefix("/api/collections/").Subrouter() apiColls := write.PathPrefix("/api/collections/").Subrouter()
apiColls.HandleFunc("/"+host, handler.AllReader(fetchCollection)).Methods("GET")
apiColls.HandleFunc("/{alias:[0-9a-zA-Z\\-]+}", handler.AllReader(fetchCollection)).Methods("GET") apiColls.HandleFunc("/{alias:[0-9a-zA-Z\\-]+}", handler.AllReader(fetchCollection)).Methods("GET")
apiColls.HandleFunc("/{alias:[0-9a-zA-Z\\-]+}", handler.All(existingCollection)).Methods("POST", "DELETE") apiColls.HandleFunc("/{alias:[0-9a-zA-Z\\-]+}", handler.All(existingCollection)).Methods("POST", "DELETE")
apiColls.HandleFunc("/{alias}/posts", handler.AllReader(fetchCollectionPosts)).Methods("GET") apiColls.HandleFunc("/{alias}/posts", handler.AllReader(fetchCollectionPosts)).Methods("GET")
@ -152,6 +162,8 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
write.HandleFunc("/auth/login", handler.Web(webLogin, UserLevelNoneRequired)).Methods("POST") write.HandleFunc("/auth/login", handler.Web(webLogin, UserLevelNoneRequired)).Methods("POST")
write.HandleFunc("/admin", handler.Admin(handleViewAdminDash)).Methods("GET") write.HandleFunc("/admin", handler.Admin(handleViewAdminDash)).Methods("GET")
write.HandleFunc("/admin/monitor", handler.Admin(handleViewAdminMonitor)).Methods("GET")
write.HandleFunc("/admin/settings", handler.Admin(handleViewAdminSettings)).Methods("GET")
write.HandleFunc("/admin/users", handler.Admin(handleViewAdminUsers)).Methods("GET") write.HandleFunc("/admin/users", handler.Admin(handleViewAdminUsers)).Methods("GET")
write.HandleFunc("/admin/user/{username}", handler.Admin(handleViewAdminUser)).Methods("GET") write.HandleFunc("/admin/user/{username}", handler.Admin(handleViewAdminUser)).Methods("GET")
write.HandleFunc("/admin/user/{username}/delete", handler.Admin(handleAdminDeleteUser)).Methods("POST") write.HandleFunc("/admin/user/{username}/delete", handler.Admin(handleAdminDeleteUser)).Methods("POST")
@ -161,6 +173,7 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
write.HandleFunc("/admin/page/{slug}", handler.Admin(handleViewAdminPage)).Methods("GET") write.HandleFunc("/admin/page/{slug}", handler.Admin(handleViewAdminPage)).Methods("GET")
write.HandleFunc("/admin/update/config", handler.AdminApper(handleAdminUpdateConfig)).Methods("POST") write.HandleFunc("/admin/update/config", handler.AdminApper(handleAdminUpdateConfig)).Methods("POST")
write.HandleFunc("/admin/update/{page}", handler.Admin(handleAdminUpdateSite)).Methods("POST") write.HandleFunc("/admin/update/{page}", handler.Admin(handleAdminUpdateSite)).Methods("POST")
write.HandleFunc("/admin/updates", handler.Admin(handleViewAdminUpdates)).Methods("GET")
// Handle special pages first // Handle special pages first
write.HandleFunc("/login", handler.Web(viewLogin, UserLevelNoneRequired)) write.HandleFunc("/login", handler.Web(viewLogin, UserLevelNoneRequired))
@ -197,10 +210,10 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
} }
func RouteCollections(handler *Handler, r *mux.Router) { func RouteCollections(handler *Handler, r *mux.Router) {
r.HandleFunc("/logout", handler.Web(handleLogOutCollection, UserLevelOptional))
r.HandleFunc("/page/{page:[0-9]+}", handler.Web(handleViewCollection, UserLevelReader)) r.HandleFunc("/page/{page:[0-9]+}", handler.Web(handleViewCollection, UserLevelReader))
r.HandleFunc("/tag:{tag}", handler.Web(handleViewCollectionTag, UserLevelReader)) r.HandleFunc("/tag:{tag}", handler.Web(handleViewCollectionTag, UserLevelReader))
r.HandleFunc("/tag:{tag}/feed/", handler.Web(ViewFeed, UserLevelReader)) r.HandleFunc("/tag:{tag}/feed/", handler.Web(ViewFeed, UserLevelReader))
r.HandleFunc("/tags/{tag}", handler.Web(handleViewCollectionTag, UserLevelReader))
r.HandleFunc("/sitemap.xml", handler.AllReader(handleViewSitemap)) r.HandleFunc("/sitemap.xml", handler.AllReader(handleViewSitemap))
r.HandleFunc("/feed/", handler.AllReader(ViewFeed)) r.HandleFunc("/feed/", handler.AllReader(ViewFeed))
r.HandleFunc("/{slug}", handler.CollectionPostOrStatic) r.HandleFunc("/{slug}", handler.CollectionPostOrStatic)

@ -0,0 +1,38 @@
package writefreely
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gorilla/mux"
)
func TestCacheControlForStaticFiles(t *testing.T) {
app := NewApp("testdata/config.ini")
if err := app.LoadConfig(); err != nil {
t.Fatalf("Could not create an app; %v", err)
}
router := mux.NewRouter()
app.InitStaticRoutes(router)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/style.css", nil)
router.ServeHTTP(rec, req)
if code := rec.Result().StatusCode; code != http.StatusOK {
t.Fatalf("Could not get /style.css, got HTTP status %d", code)
}
actual := rec.Result().Header.Get("Cache-Control")
expectedDirectives := []string{
"public",
"max-age",
"immutable",
}
for _, expected := range expectedDirectives {
if !strings.Contains(actual, expected) {
t.Errorf("Expected Cache-Control header to contain '%s', but was '%s'", expected, actual)
}
}
}

@ -0,0 +1,37 @@
#!/bin/bash
#
# 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.
#
###############################################################################
#
# WriteFreely CSS invalidation script
#
# usage: ./invalidate-css.sh <build-directory>
#
# This script provides an automated way to invalidate stylesheets cached in the
# browser. It uses the last git commit hashes of the most frequently modified
# LESS files in the project and appends them to the stylesheet `href` in all
# template files.
#
# This is designed to be used when building a WriteFreely release.
#
###############################################################################
# Get parent build directory from first argument
buildDir=$1
# Get short hash of each primary LESS file's last commit
cssHash=$(git log -n 1 --pretty=format:%h -- less/core.less)
cssNewHash=$(git log -n 1 --pretty=format:%h -- less/new-core.less)
cssPadHash=$(git log -n 1 --pretty=format:%h -- less/pad.less)
echo "Adding write.css version ($cssHash $cssNewHash $cssPadHash) to .tmpl files..."
cd "$buildDir/templates" || exit 1
find . -type f -name "*.tmpl" -print0 | xargs -0 sed -i "s/write.css/write.css?${cssHash}${cssNewHash}${cssPadHash}/g"
find . -type f -name "*.tmpl" -print0 | xargs -0 sed -i "s/{{.Theme}}.css/{{.Theme}}.css?${cssHash}${cssNewHash}${cssPadHash}/g"

@ -0,0 +1,315 @@
// Copyright 2018 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package semver implements comparison of semantic version strings.
// In this package, semantic version strings must begin with a leading "v",
// as in "v1.0.0".
//
// The general form of a semantic version string accepted by this package is
//
// vMAJOR[.MINOR[.PATCH[-PRERELEASE][+BUILD]]]
//
// where square brackets indicate optional parts of the syntax;
// MAJOR, MINOR, and PATCH are decimal integers without extra leading zeros;
// PRERELEASE and BUILD are each a series of non-empty dot-separated identifiers
// using only alphanumeric characters and hyphens; and
// all-numeric PRERELEASE identifiers must not have leading zeros.
//
// This package follows Semantic Versioning 2.0.0 (see semver.org)
// with two exceptions. First, it requires the "v" prefix. Second, it recognizes
// vMAJOR and vMAJOR.MINOR (with no prerelease or build suffixes)
// as shorthands for vMAJOR.0.0 and vMAJOR.MINOR.0.
// Package writefreely
// copied from
// https://github.com/golang/tools/blob/master/internal/semver/semver.go
// slight modifications made
package writefreely
// parsed returns the parsed form of a semantic version string.
type parsed struct {
major string
minor string
patch string
short string
prerelease string
build string
err string
}
// IsValid reports whether v is a valid semantic version string.
func IsValid(v string) bool {
_, ok := semParse(v)
return ok
}
// CompareSemver returns an integer comparing two versions according to
// according to semantic version precedence.
// The result will be 0 if v == w, -1 if v < w, or +1 if v > w.
//
// An invalid semantic version string is considered less than a valid one.
// All invalid semantic version strings compare equal to each other.
func CompareSemver(v, w string) int {
pv, ok1 := semParse(v)
pw, ok2 := semParse(w)
if !ok1 && !ok2 {
return 0
}
if !ok1 {
return -1
}
if !ok2 {
return +1
}
if c := compareInt(pv.major, pw.major); c != 0 {
return c
}
if c := compareInt(pv.minor, pw.minor); c != 0 {
return c
}
if c := compareInt(pv.patch, pw.patch); c != 0 {
return c
}
return comparePrerelease(pv.prerelease, pw.prerelease)
}
func semParse(v string) (p parsed, ok bool) {
if v == "" || v[0] != 'v' {
p.err = "missing v prefix"
return
}
p.major, v, ok = parseInt(v[1:])
if !ok {
p.err = "bad major version"
return
}
if v == "" {
p.minor = "0"
p.patch = "0"
p.short = ".0.0"
return
}
if v[0] != '.' {
p.err = "bad minor prefix"
ok = false
return
}
p.minor, v, ok = parseInt(v[1:])
if !ok {
p.err = "bad minor version"
return
}
if v == "" {
p.patch = "0"
p.short = ".0"
return
}
if v[0] != '.' {
p.err = "bad patch prefix"
ok = false
return
}
p.patch, v, ok = parseInt(v[1:])
if !ok {
p.err = "bad patch version"
return
}
if len(v) > 0 && v[0] == '-' {
p.prerelease, v, ok = parsePrerelease(v)
if !ok {
p.err = "bad prerelease"
return
}
}
if len(v) > 0 && v[0] == '+' {
p.build, v, ok = parseBuild(v)
if !ok {
p.err = "bad build"
return
}
}
if v != "" {
p.err = "junk on end"
ok = false
return
}
ok = true
return
}
func parseInt(v string) (t, rest string, ok bool) {
if v == "" {
return
}
if v[0] < '0' || '9' < v[0] {
return
}
i := 1
for i < len(v) && '0' <= v[i] && v[i] <= '9' {
i++
}
if v[0] == '0' && i != 1 {
return
}
return v[:i], v[i:], true
}
func parsePrerelease(v string) (t, rest string, ok bool) {
// "A pre-release version MAY be denoted by appending a hyphen and
// a series of dot separated identifiers immediately following the patch version.
// Identifiers MUST comprise only ASCII alphanumerics and hyphen [0-9A-Za-z-].
// Identifiers MUST NOT be empty. Numeric identifiers MUST NOT include leading zeroes."
if v == "" || v[0] != '-' {
return
}
i := 1
start := 1
for i < len(v) && v[i] != '+' {
if !isIdentChar(v[i]) && v[i] != '.' {
return
}
if v[i] == '.' {
if start == i || isBadNum(v[start:i]) {
return
}
start = i + 1
}
i++
}
if start == i || isBadNum(v[start:i]) {
return
}
return v[:i], v[i:], true
}
func parseBuild(v string) (t, rest string, ok bool) {
if v == "" || v[0] != '+' {
return
}
i := 1
start := 1
for i < len(v) {
if !isIdentChar(v[i]) {
return
}
if v[i] == '.' {
if start == i {
return
}
start = i + 1
}
i++
}
if start == i {
return
}
return v[:i], v[i:], true
}
func isIdentChar(c byte) bool {
return 'A' <= c && c <= 'Z' || 'a' <= c && c <= 'z' || '0' <= c && c <= '9' || c == '-'
}
func isBadNum(v string) bool {
i := 0
for i < len(v) && '0' <= v[i] && v[i] <= '9' {
i++
}
return i == len(v) && i > 1 && v[0] == '0'
}
func isNum(v string) bool {
i := 0
for i < len(v) && '0' <= v[i] && v[i] <= '9' {
i++
}
return i == len(v)
}
func compareInt(x, y string) int {
if x == y {
return 0
}
if len(x) < len(y) {
return -1
}
if len(x) > len(y) {
return +1
}
if x < y {
return -1
} else {
return +1
}
}
func comparePrerelease(x, y string) int {
// "When major, minor, and patch are equal, a pre-release version has
// lower precedence than a normal version.
// Example: 1.0.0-alpha < 1.0.0.
// Precedence for two pre-release versions with the same major, minor,
// and patch version MUST be determined by comparing each dot separated
// identifier from left to right until a difference is found as follows:
// identifiers consisting of only digits are compared numerically and
// identifiers with letters or hyphens are compared lexically in ASCII
// sort order. Numeric identifiers always have lower precedence than
// non-numeric identifiers. A larger set of pre-release fields has a
// higher precedence than a smaller set, if all of the preceding
// identifiers are equal.
// Example: 1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-alpha.beta <
// 1.0.0-beta < 1.0.0-beta.2 < 1.0.0-beta.11 < 1.0.0-rc.1 < 1.0.0."
if x == y {
return 0
}
if x == "" {
return +1
}
if y == "" {
return -1
}
for x != "" && y != "" {
x = x[1:] // skip - or .
y = y[1:] // skip - or .
var dx, dy string
dx, x = nextIdent(x)
dy, y = nextIdent(y)
if dx != dy {
ix := isNum(dx)
iy := isNum(dy)
if ix != iy {
if ix {
return -1
} else {
return +1
}
}
if ix {
if len(dx) < len(dy) {
return -1
}
if len(dx) > len(dy) {
return +1
}
}
if dx < dy {
return -1
} else {
return +1
}
}
}
if x == "" {
return -1
} else {
return +1
}
}
func nextIdent(x string) (dx, rest string) {
i := 0
for i < len(x) && x[i] != '.' {
i++
}
return x[:i], x[i:]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save